diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e3ece91..ea618ff 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,24 +69,10 @@ jobs: pip install -r requirements.txt pip install -r requirements-dev.txt - - name: Check out activerecord and testsuite (release branches) - uses: actions/checkout@v6 - with: - repository: rhosocial/python-activerecord - ref: release/v1.0.0.dev30 - path: _activerecord - - - name: Check out testsuite (release branch) - uses: actions/checkout@v6 - with: - repository: rhosocial/python-activerecord-testsuite - ref: release/v1.0.0.dev17 - path: _testsuite - - - name: Install activerecord and testsuite (editable) + - name: Install activerecord and testsuite run: | - pip install -e ./_activerecord - pip install -e ./_testsuite + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates" - name: Verify ClickHouse connectivity run: | diff --git a/.gitignore b/.gitignore index 7c2198f..e99fa57 100644 --- a/.gitignore +++ b/.gitignore @@ -213,4 +213,9 @@ tests/config/clickhouse_scenarios.yml # Claude Code temporary files (local only) .claude/tmp/ -.claude/plan*/ \ No newline at end of file +.claude/plan*/ +# Worktrees for feature/* and fix/* branches. Never tracked: a `git add -A` +# from the repo root would commit each branch's whole toolchain -- the venv, the +# build directory and the test artifacts -- and the directory disappears when the +# branch merges, so the history would keep a second copy of the source tree. +.worktree/ diff --git a/AGENTS.md b/AGENTS.md index fba6954..4eb678c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,6 +66,14 @@ and raise `UnsupportedFeatureError` instead of being emulated: - Mutations run with `mutations_sync=1`; `affected_rows` for UPDATE/DELETE is a pre-count of rows matching the WHERE predicate. - Optional model fields must map to `Nullable(T)` columns. +- The CLI's `introspect` offers exactly seven types — `tables`, `views`, + `columns`, `indexes`, `partitions`, `table-engine`, `database` — and each is + gated on a dialect `supports_*` switch (exit 2 when the connected server + answers False). `foreign-keys` / `triggers` are absent from the vocabulary + because the engine has neither, which is a different statement from a + capability switch. The SHOW passthrough (`introspector.show`, 19 methods) is + library-only and deliberately not wired into `introspect`: SHOW and + `system.*` are not equivalent, and every introspect type reads `system.*`. ## Python Version Support diff --git a/README.md b/README.md index 8c8c77f..2321901 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ from pydantic import Field from rhosocial.activerecord.base import FieldProxy from rhosocial.activerecord.backend.impl.clickhouse.backend import ClickHouseBackend from rhosocial.activerecord.backend.impl.clickhouse.config import ClickHouseConnectionConfig -from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression import CreateTableStatement from rhosocial.activerecord.backend.expression.statements import ColumnDefinition from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType from rhosocial.activerecord.model import ActiveRecord @@ -80,7 +80,7 @@ User.configure( ) dialect = User.__backend__.dialect -User.__backend__.execute(*CreateTableExpression( +User.__backend__.execute(*CreateTableStatement( dialect, "users", columns=[ diff --git a/changelog.d/6.added.md b/changelog.d/6.added.md new file mode 100644 index 0000000..16b0c0f --- /dev/null +++ b/changelog.d/6.added.md @@ -0,0 +1 @@ +`partitions` and `table-engine` on the introspect CLI, `views NAME`, and storage clauses (`ENGINE`, `ORDER BY`, `PARTITION BY`, `TTL`) as expression nodes. diff --git a/changelog.d/6.changed.md b/changelog.d/6.changed.md new file mode 100644 index 0000000..9d54c9a --- /dev/null +++ b/changelog.d/6.changed.md @@ -0,0 +1 @@ +The introspect type list excludes `foreign-keys` and `triggers` because the engine has neither, and derives the rest from `_TYPE_CAPABILITIES`. `--async` is refused with a sentence rather than reached. diff --git a/changelog.d/6.fixed.md b/changelog.d/6.fixed.md new file mode 100644 index 0000000..d270e0e --- /dev/null +++ b/changelog.d/6.fixed.md @@ -0,0 +1 @@ +The async introspect handler called five methods no introspector class has; the relation kind is reported as the server answers it. diff --git a/changelog.d/6.internal.md b/changelog.d/6.internal.md new file mode 100644 index 0000000..3db7999 --- /dev/null +++ b/changelog.d/6.internal.md @@ -0,0 +1 @@ +The CLI tests exercise every declared type in both paths, and CI installs core from `release/v1.0.0.dev30` now that core #127 has merged. diff --git a/docs/en_US/cli/README.md b/docs/en_US/cli/README.md index 5349aa0..03dc234 100644 --- a/docs/en_US/cli/README.md +++ b/docs/en_US/cli/README.md @@ -59,11 +59,18 @@ Introspect the schema via `system.*` tables. ```bash $ python -m rhosocial.activerecord.backend.impl.clickhouse introspect tables \ --host localhost --database test_db --user root --password password + +$ python -m rhosocial.activerecord.backend.impl.clickhouse introspect views daily_totals \ + --host localhost --database test_db --user root --password password ``` -Positional argument (introspection type): `tables`/`views`/`table`/`columns`/`indexes`/`foreign-keys`/`triggers`/`database`. `foreign-keys`/`triggers` return empty for ClickHouse (not supported). +Positional argument (introspection type): `tables` / `views` / `columns` / `indexes` / `partitions` / `table-engine` / `database`. + +`NAME` is a table for `columns` / `indexes` / `partitions` / `table-engine`, and the view itself for `views`. `tables` / `database` answer for the whole database and **refuse** a name (exit 2) rather than print a listing that looks narrowed. Exit 1 means the run failed — a connection or query error, or a view name that does not exist. + +`foreign-keys` / `triggers` are not offered at all: ClickHouse has neither, so the type is absent from this backend's vocabulary instead of being answered with an empty list. Every offered type is gated on a dialect capability switch; a server that answers `False` for it is refused with exit 2 and `Error: introspection is not supported by this server`. The backend's `show.*` SHOW passthrough is not exposed here either: `SHOW` and `system.*` are not equivalent, and every introspection type reads `system.*`. -Specific arguments: `--schema SCHEMA`, `--include-system` (include system tables). +Specific arguments: `--include-system` (include system tables). ## status — server status diff --git a/docs/en_US/ddl/table_engine.md b/docs/en_US/ddl/table_engine.md index 5a8ff3d..f089e5f 100644 --- a/docs/en_US/ddl/table_engine.md +++ b/docs/en_US/ddl/table_engine.md @@ -2,7 +2,7 @@ ClickHouse DDL consists of clauses such as `ENGINE`, `ORDER BY`, `PARTITION BY`, `TTL`, `PRIMARY KEY`, `SAMPLE BY`, and `SETTINGS`. This backend supports them natively at the dialect layer and can generate them through the core library's DDL expressions — **full SQL semantic coverage**, no hand-written SQL required. -> 📐 This backend inherits and extends the core library's `CreateTableExpression`: declaring ClickHouse clauses through the `storage_options` dictionary, with `to_sql()` generating complete native ClickHouse DDL. +> 📐 This backend extends the core library's `CreateTableStatement` with its own options node: the clauses are declared as **expression nodes** on `ClickHouseCreateTableOptions(table_storage_options=...)`, and `to_sql()` generates complete native ClickHouse DDL. ## MergeTree Family @@ -29,16 +29,15 @@ The backend maps the model's `__primary_key__` to the `ORDER BY` sorting key. ## DDL Expression vs Raw SQL Comparison -Both approaches generate **exactly the same** ClickHouse DDL, demonstrating that the expressions cover all SQL semantics: +Both approaches produce the **same** ClickHouse DDL, demonstrating that the expressions cover the SQL semantics: ### Raw SQL ```sql CREATE TABLE IF NOT EXISTS events ( - ts DateTime64(6, 'UTC'), + ts DateTime64(6), id Int64, - url String, - INDEX idx_url url TYPE bloom_filter GRANULARITY 4 + url String ) ENGINE = MergeTree PARTITION BY toYYYYMM(ts) ORDER BY (ts, id) @@ -49,52 +48,107 @@ SETTINGS enable_block_number_column = 1, enable_block_offset_column = 1; ### DDL Expression (equivalent) ```python +from rhosocial.activerecord.backend.expression.core import Column, FunctionCall +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.operators import RawSQL from rhosocial.activerecord.backend.expression.statements import ( - CreateTableExpression, ColumnDefinition, IndexDefinition, + ColumnDefinition, + CreateTableStatement, ) -from rhosocial.activerecord.backend.expression.parts import IndexType from rhosocial.activerecord.backend.impl.clickhouse.expression import ( - ClickHouseInt64Type, ClickHouseStringType, ClickHouseDateTime64Type, + ClickHouseCreateTableOptions, + ClickHouseDateTime64Type, + ClickHouseEngineSpec, + ClickHouseInt64Type, + ClickHouseSetting, + ClickHouseStorageOptionKind, + ClickHouseStringType, + ClickHouseTableStorageOption, ) -expr = CreateTableExpression( - dialect=dialect, - table="events", - columns=[ - ColumnDefinition(name="ts", data_type=ClickHouseDateTime64Type(6)), - ColumnDefinition(name="id", data_type=ClickHouseInt64Type()), - ColumnDefinition(name="url", data_type=ClickHouseStringType()), +kind = ClickHouseStorageOptionKind + +storage = [ + ClickHouseTableStorageOption( + dialect, kind.ENGINE, value=ClickHouseEngineSpec(dialect, "MergeTree") + ), + ClickHouseTableStorageOption( + dialect, + kind.PARTITION_BY, + value=[FunctionCall(dialect, "toYYYYMM", Column(dialect, "ts"))], + ), + ClickHouseTableStorageOption( + dialect, kind.ORDER_BY, value=[Column(dialect, "ts"), Column(dialect, "id")] + ), + ClickHouseTableStorageOption( + dialect, kind.TTL, value=RawSQL(dialect, "ts + INTERVAL 90 DAY DELETE") + ), + ClickHouseTableStorageOption( + dialect, + kind.SETTINGS, + value=[ + ClickHouseSetting(dialect, "enable_block_number_column", 1), + ClickHouseSetting(dialect, "enable_block_offset_column", 1), + ], + ), +] + +expr = CreateTableStatement( + dialect, + Table(dialect, "events"), + [ + ColumnDefinition(dialect, "ts", ClickHouseDateTime64Type(dialect, precision=6)), + ColumnDefinition(dialect, "id", ClickHouseInt64Type(dialect)), + ColumnDefinition(dialect, "url", ClickHouseStringType(dialect)), ], - indexes=[ - IndexDefinition(name="idx_url", columns=["url"], index_type="bloom_filter", - dialect_options={"granularity": 4}), - ], - storage_options={ - "ENGINE": "MergeTree", - "PARTITION BY": "toYYYYMM(ts)", - "ORDER BY": ["ts", "id"], - "TTL": "ts + INTERVAL 90 DAY DELETE", - "SETTINGS": "enable_block_number_column = 1, enable_block_offset_column = 1", - }, if_not_exists=True, + table_options=ClickHouseCreateTableOptions(dialect, table_storage_options=storage), ) -sql, _ = expr.to_sql() # 与上方原始 SQL 等价 -dialect.execute(sql) +sql, params = expr.to_sql() +dialect.execute(sql, params) +``` + +Generated SQL (one line; ClickHouse accepts either form): + +```sql +CREATE TABLE IF NOT EXISTS `events` (`ts` DateTime64(6), `id` Int64, `url` String) +ENGINE = MergeTree PARTITION BY TOYYYYMM(`ts`) ORDER BY (`ts`, `id`) +TTL ts + INTERVAL 90 DAY DELETE +SETTINGS enable_block_number_column = 1, enable_block_offset_column = 1 ``` -### Supported `storage_options` keys +## Storage Clause Nodes + +One clause is one `ClickHouseTableStorageOption`. Which payload shape it takes is decided by its `option` member, and the renderer owns the keyword — the caller never writes SQL text for a clause: + +| `option` | `value` | renders as | +|-----|--------|--------| +| `ENGINE` | `ClickHouseEngineSpec` | `ENGINE = MergeTree`, `ENGINE = ReplacingMergeTree(version)` | +| `PRIMARY KEY` | list of expression nodes | `PRIMARY KEY (a, b)` | +| `PARTITION BY` | list of expression nodes | `PARTITION BY toYYYYMM(ts)` | +| `ORDER BY` | list of expression nodes | `ORDER BY (ts, id)` | +| `SAMPLE BY` | one expression node | `SAMPLE BY cityHash64(user_id)` | +| `TTL` | one expression node | `TTL ts + INTERVAL 90 DAY DELETE` | +| `SETTINGS` | list of `ClickHouseSetting` | `SETTINGS index_granularity = 8192` | -`format_table_engine_clauses` accepts (case/underscore insensitive, values inserted as-is): +Three rules the renderer enforces: -| key | renders as | -|-----|--------| -| `ENGINE` | `ENGINE = ` | -| `ORDER BY` | `ORDER BY (col1, col2)` | -| `PARTITION BY` | `PARTITION BY ` | -| `PRIMARY KEY` | `PRIMARY KEY (...)` | -| `SAMPLE BY` | `SAMPLE BY ` | -| `TTL` | `TTL ` | -| `SETTINGS` | `SETTINGS k = v, ...` | +- **Canonical order.** Clauses are emitted in ClickHouse's grammar order (`ENGINE` → `PRIMARY KEY` → `PARTITION BY` → `ORDER BY` → `SAMPLE BY` → `TTL` → `SETTINGS`) regardless of the order you declared them in. +- **One clause, once.** Declaring the same `option` twice raises `ValueError` at construction instead of rendering a statement ClickHouse would reject. +- **A single node needs no parentheses.** `ORDER BY id` and `ORDER BY (ts, id)` are both valid; the renderer parenthesises only when there is more than one expression. + +`ON CLUSTER` is not a storage clause: it sits between the table name and the column list, so it is declared on the options node next to the clauses: + +```python +table_options=ClickHouseCreateTableOptions( + dialect, + on_cluster="analytics_cluster", + table_storage_options=storage, +) +# CREATE TABLE `events` ON CLUSTER `analytics_cluster` (...) ENGINE = MergeTree ... +``` + +> ⚠️ The core `CreateTableStatement(storage_options=...)` slot is **refused by name** in this backend: ClickHouse has no generic `WITH (...)` storage-options clause, and accepting the dict would render a statement that silently differs from what the caller wrote. The error names `ClickHouseCreateTableOptions` as the replacement. > 💡 *AI prompt: "`ReplacingMergeTree` dedup is eventually consistent; how does the caller ensure it reads the latest version when querying? What is the cost of the `FINAL` keyword?"* @@ -119,17 +173,22 @@ Expired data is automatically deleted; commonly used for log/event tables. This backend supports `ALTER TABLE` ADD/DROP COLUMN (including `IF EXISTS`/`IF NOT EXISTS`), MODIFY COLUMN, etc.: ```python +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( - AlterTableExpression, AlterColumn, ColumnAlterOperation, + AddColumnAction, AlterTableStatement, ColumnDefinition, +) +from rhosocial.activerecord.backend.impl.clickhouse.expression import ClickHouseStringType + +expr = AlterTableStatement( + dialect, + Table(dialect, "events"), + [AddColumnAction(dialect, ColumnDefinition(dialect, "country", ClickHouseStringType(dialect)))], ) -expr = AlterTableExpression(dialect, "users", [ - AlterColumn("col", ColumnAlterOperation.SET_DEFAULT, new_value="ABC"), -]) sql, _ = expr.to_sql() -# ALTER TABLE users ALTER COLUMN col SET DEFAULT 'ABC' +# ALTER TABLE `events` ADD COLUMN `country` String ``` -See the DDL comparison in [First CRUD application](../getting_started/first_crud.md). +See the DDL comparison in [First CRUD application](../getting_started/first_crud.md), and [Skip indexes](skip_indexes.md) for inline `INDEX` clauses. ## Next steps diff --git a/docs/en_US/getting_started/first_crud.md b/docs/en_US/getting_started/first_crud.md index cab4041..82a92ca 100644 --- a/docs/en_US/getting_started/first_crud.md +++ b/docs/en_US/getting_started/first_crud.md @@ -44,14 +44,14 @@ User.configure(config, ClickHouseBackend) ## 2. Create the table (DDL expression vs raw SQL) ClickHouse does not auto-create tables. This backend **inherits and extends** the -core library's DDL expressions: `CreateTableExpression` can generate complete +core library's DDL expressions: `CreateTableStatement` can generate complete native ClickHouse DDL. The two approaches are equivalent: ### Approach A: DDL expression (recommended, type-safe, serializable) ```python from rhosocial.activerecord.backend.expression.statements import ( - CreateTableExpression, ColumnDefinition, DropTableExpression, + CreateTableStatement, ColumnDefinition, DropTableStatement, ) from rhosocial.activerecord.backend.impl.clickhouse.expression import ( ClickHouseInt64Type, ClickHouseStringType, ClickHouseInt32Type, @@ -59,7 +59,7 @@ from rhosocial.activerecord.backend.impl.clickhouse.expression import ( ) # Declare ENGINE / ORDER BY / PARTITION BY / TTL / SETTINGS via storage_options -expr = CreateTableExpression( +expr = CreateTableStatement( dialect=User.__dialect__, table="users", columns=[ diff --git a/docs/en_US/getting_started/quick_start.md b/docs/en_US/getting_started/quick_start.md index eaf1397..8a66991 100644 --- a/docs/en_US/getting_started/quick_start.md +++ b/docs/en_US/getting_started/quick_start.md @@ -69,7 +69,7 @@ SETTINGS enable_block_number_column = 1, enable_block_offset_column = 1; > mutations by default. See [Mutations (UPDATE/DELETE)](../capabilities/mutations.md). > > You can also generate the equivalent DDL via a DDL expression -> (`CreateTableExpression` + `storage_options`); see +> (`CreateTableStatement` + `storage_options`); see > [First CRUD application](first_crud.md#2-create-the-table-ddl-expression-vs-raw-sql). ## Key points diff --git a/docs/zh_CN/cli/README.md b/docs/zh_CN/cli/README.md index c825b0c..7f795f1 100644 --- a/docs/zh_CN/cli/README.md +++ b/docs/zh_CN/cli/README.md @@ -59,11 +59,18 @@ $ python -m rhosocial.activerecord.backend.impl.clickhouse info \ ```bash $ python -m rhosocial.activerecord.backend.impl.clickhouse introspect tables \ --host localhost --database test_db --user root --password password + +$ python -m rhosocial.activerecord.backend.impl.clickhouse introspect views daily_totals \ + --host localhost --database test_db --user root --password password ``` -位置参数(自省类型):`tables`/`views`/`table`/`columns`/`indexes`/`foreign-keys`/`triggers`/`database`。`foreign-keys`/`triggers` 对 ClickHouse 返回空(不支持)。 +位置参数(自省类型):`tables` / `views` / `columns` / `indexes` / `partitions` / `table-engine` / `database`。 + +`NAME` 对 `columns` / `indexes` / `partitions` / `table-engine` 是表名,对 `views` 是视图本身。`tables` / `database` 回答的是整个数据库,因此**拒绝**名字(退出 2),不会打印一份看起来被收窄过的列表。退出 1 表示运行失败——连接或查询出错,或指定的视图不存在。 + +`foreign-keys` / `triggers` 根本不提供:ClickHouse 既没有外键也没有触发器,所以这两个类型不在本后端的词汇表里,而不是返回空列表。每个提供的类型都挂在 dialect 的能力开关上;服务端对该开关回答 `False` 时以退出 2 拒绝,并输出 `Error: introspection is not supported by this server`。后端的 `show.*`(SHOW 直通)同样不从这里暴露:`SHOW` 与 `system.*` 并不等价,而所有自省类型都读 `system.*`。 -专属参数:`--schema SCHEMA`、`--include-system`(含系统表)。 +专属参数:`--include-system`(含系统表)。 ## status — 服务器状态 diff --git a/docs/zh_CN/ddl/table_engine.md b/docs/zh_CN/ddl/table_engine.md index 9ce2113..6121e6e 100644 --- a/docs/zh_CN/ddl/table_engine.md +++ b/docs/zh_CN/ddl/table_engine.md @@ -2,7 +2,7 @@ ClickHouse 的 DDL 由 `ENGINE`、`ORDER BY`、`PARTITION BY`、`TTL`、`PRIMARY KEY`、`SAMPLE BY`、`SETTINGS` 等子句组成。本后端在方言层原生支持,且能通过核心库的 DDL 表达式生成——**SQL 语义全覆盖**,无需手写 SQL。 -> 📐 本后端继承并扩展了核心库的 `CreateTableExpression`:通过 `storage_options` 字典声明 ClickHouse 子句,`to_sql()` 生成完整 ClickHouse 原生 DDL。 +> 📐 本后端扩展了核心库的 `CreateTableStatement`,并提供了自己的选项节点:子句以**表达式节点**的形式声明在 `ClickHouseCreateTableOptions(table_storage_options=...)` 上,`to_sql()` 生成完整 ClickHouse 原生 DDL。 ## MergeTree 家族 @@ -29,16 +29,15 @@ ClickHouse 的 DDL 由 `ENGINE`、`ORDER BY`、`PARTITION BY`、`TTL`、`PRIMARY ## DDL 表达式 vs 原始 SQL 对照 -两种方式生成**完全相同**的 ClickHouse DDL,证明表达式覆盖全部 SQL 语义: +两种方式生成**相同**的 ClickHouse DDL,证明表达式覆盖了这些 SQL 语义: ### 原始 SQL ```sql CREATE TABLE IF NOT EXISTS events ( - ts DateTime64(6, 'UTC'), + ts DateTime64(6), id Int64, - url String, - INDEX idx_url url TYPE bloom_filter GRANULARITY 4 + url String ) ENGINE = MergeTree PARTITION BY toYYYYMM(ts) ORDER BY (ts, id) @@ -49,52 +48,107 @@ SETTINGS enable_block_number_column = 1, enable_block_offset_column = 1; ### DDL 表达式(等价) ```python +from rhosocial.activerecord.backend.expression.core import Column, FunctionCall +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.operators import RawSQL from rhosocial.activerecord.backend.expression.statements import ( - CreateTableExpression, ColumnDefinition, IndexDefinition, + ColumnDefinition, + CreateTableStatement, ) -from rhosocial.activerecord.backend.expression.parts import IndexType from rhosocial.activerecord.backend.impl.clickhouse.expression import ( - ClickHouseInt64Type, ClickHouseStringType, ClickHouseDateTime64Type, + ClickHouseCreateTableOptions, + ClickHouseDateTime64Type, + ClickHouseEngineSpec, + ClickHouseInt64Type, + ClickHouseSetting, + ClickHouseStorageOptionKind, + ClickHouseStringType, + ClickHouseTableStorageOption, ) -expr = CreateTableExpression( - dialect=dialect, - table="events", - columns=[ - ColumnDefinition(name="ts", data_type=ClickHouseDateTime64Type(6)), - ColumnDefinition(name="id", data_type=ClickHouseInt64Type()), - ColumnDefinition(name="url", data_type=ClickHouseStringType()), +kind = ClickHouseStorageOptionKind + +storage = [ + ClickHouseTableStorageOption( + dialect, kind.ENGINE, value=ClickHouseEngineSpec(dialect, "MergeTree") + ), + ClickHouseTableStorageOption( + dialect, + kind.PARTITION_BY, + value=[FunctionCall(dialect, "toYYYYMM", Column(dialect, "ts"))], + ), + ClickHouseTableStorageOption( + dialect, kind.ORDER_BY, value=[Column(dialect, "ts"), Column(dialect, "id")] + ), + ClickHouseTableStorageOption( + dialect, kind.TTL, value=RawSQL(dialect, "ts + INTERVAL 90 DAY DELETE") + ), + ClickHouseTableStorageOption( + dialect, + kind.SETTINGS, + value=[ + ClickHouseSetting(dialect, "enable_block_number_column", 1), + ClickHouseSetting(dialect, "enable_block_offset_column", 1), + ], + ), +] + +expr = CreateTableStatement( + dialect, + Table(dialect, "events"), + [ + ColumnDefinition(dialect, "ts", ClickHouseDateTime64Type(dialect, precision=6)), + ColumnDefinition(dialect, "id", ClickHouseInt64Type(dialect)), + ColumnDefinition(dialect, "url", ClickHouseStringType(dialect)), ], - indexes=[ - IndexDefinition(name="idx_url", columns=["url"], index_type="bloom_filter", - dialect_options={"granularity": 4}), - ], - storage_options={ - "ENGINE": "MergeTree", - "PARTITION BY": "toYYYYMM(ts)", - "ORDER BY": ["ts", "id"], - "TTL": "ts + INTERVAL 90 DAY DELETE", - "SETTINGS": "enable_block_number_column = 1, enable_block_offset_column = 1", - }, if_not_exists=True, + table_options=ClickHouseCreateTableOptions(dialect, table_storage_options=storage), ) -sql, _ = expr.to_sql() # 与上方原始 SQL 等价 -dialect.execute(sql) +sql, params = expr.to_sql() +dialect.execute(sql, params) +``` + +生成的 SQL(单行;ClickHouse 两种形式都接受): + +```sql +CREATE TABLE IF NOT EXISTS `events` (`ts` DateTime64(6), `id` Int64, `url` String) +ENGINE = MergeTree PARTITION BY TOYYYYMM(`ts`) ORDER BY (`ts`, `id`) +TTL ts + INTERVAL 90 DAY DELETE +SETTINGS enable_block_number_column = 1, enable_block_offset_column = 1 ``` -### `storage_options` 支持的 key +## 存储子句节点 + +一个子句对应一个 `ClickHouseTableStorageOption`。它取哪种载荷由 `option` 成员决定,而关键字由渲染器负责——调用方从不自己拼子句的 SQL 文本: + +| `option` | `value` | 渲染为 | +|-----|--------|--------| +| `ENGINE` | `ClickHouseEngineSpec` | `ENGINE = MergeTree`、`ENGINE = ReplacingMergeTree(version)` | +| `PRIMARY KEY` | 表达式节点列表 | `PRIMARY KEY (a, b)` | +| `PARTITION BY` | 表达式节点列表 | `PARTITION BY toYYYYMM(ts)` | +| `ORDER BY` | 表达式节点列表 | `ORDER BY (ts, id)` | +| `SAMPLE BY` | 单个表达式节点 | `SAMPLE BY cityHash64(user_id)` | +| `TTL` | 单个表达式节点 | `TTL ts + INTERVAL 90 DAY DELETE` | +| `SETTINGS` | `ClickHouseSetting` 列表 | `SETTINGS index_granularity = 8192` | -`format_table_engine_clauses` 接受(大小写/下划线不敏感,值原样插入): +渲染器强制三条规则: -| key | 渲染为 | -|-----|--------| -| `ENGINE` | `ENGINE = ` | -| `ORDER BY` | `ORDER BY (col1, col2)` | -| `PARTITION BY` | `PARTITION BY ` | -| `PRIMARY KEY` | `PRIMARY KEY (...)` | -| `SAMPLE BY` | `SAMPLE BY ` | -| `TTL` | `TTL ` | -| `SETTINGS` | `SETTINGS k = v, ...` | +- **规范顺序**:无论声明顺序如何,子句一律按 ClickHouse 语法顺序输出(`ENGINE` → `PRIMARY KEY` → `PARTITION BY` → `ORDER BY` → `SAMPLE BY` → `TTL` → `SETTINGS`)。 +- **一个子句只出现一次**:同一个 `option` 声明两次会在构造时抛 `ValueError`,而不是渲染出一条 ClickHouse 会拒绝的语句。 +- **单个表达式不加括号**:`ORDER BY id` 与 `ORDER BY (ts, id)` 都合法;只有多于一个表达式时才加括号。 + +`ON CLUSTER` 不是存储子句:它位于表名与列清单之间,因此与子句一样声明在选项节点上: + +```python +table_options=ClickHouseCreateTableOptions( + dialect, + on_cluster="analytics_cluster", + table_storage_options=storage, +) +# CREATE TABLE `events` ON CLUSTER `analytics_cluster` (...) ENGINE = MergeTree ... +``` + +> ⚠️ 核心 `CreateTableStatement(storage_options=...)` 槽位在本后端被**按名拒绝**:ClickHouse 没有通用的 `WITH (...)` 存储选项子句,接受字典只会渲染出一条与调用方所写内容悄悄不同的语句。错误信息会直接指出替代方案 `ClickHouseCreateTableOptions`。 > 💡 *AI 提示词:"`ReplacingMergeTree` 的去重是最终一致的,调用方查询时如何确保拿到最新版本?`FINAL` 关键字有什么代价?"* @@ -119,17 +173,22 @@ TTL ts + INTERVAL 30 DAY DELETE 本后端支持 `ALTER TABLE` 的 ADD/DROP COLUMN(含 `IF EXISTS`/`IF NOT EXISTS`)、MODIFY COLUMN 等: ```python +from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( - AlterTableExpression, AlterColumn, ColumnAlterOperation, + AddColumnAction, AlterTableStatement, ColumnDefinition, +) +from rhosocial.activerecord.backend.impl.clickhouse.expression import ClickHouseStringType + +expr = AlterTableStatement( + dialect, + Table(dialect, "events"), + [AddColumnAction(dialect, ColumnDefinition(dialect, "country", ClickHouseStringType(dialect)))], ) -expr = AlterTableExpression(dialect, "users", [ - AlterColumn("col", ColumnAlterOperation.SET_DEFAULT, new_value="ABC"), -]) sql, _ = expr.to_sql() -# ALTER TABLE users ALTER COLUMN col SET DEFAULT 'ABC' +# ALTER TABLE `events` ADD COLUMN `country` String ``` -详见 [第一个 CRUD 应用](../getting_started/first_crud.md) 的 DDL 对照。 +详见 [第一个 CRUD 应用](../getting_started/first_crud.md) 的 DDL 对照,以及[跳数索引](skip_indexes.md)中的内联 `INDEX` 子句。 ## 下一步 diff --git a/docs/zh_CN/getting_started/first_crud.md b/docs/zh_CN/getting_started/first_crud.md index a52f396..35d587f 100644 --- a/docs/zh_CN/getting_started/first_crud.md +++ b/docs/zh_CN/getting_started/first_crud.md @@ -37,13 +37,13 @@ User.configure(config, ClickHouseBackend) ## 2. 建表(DDL 表达式 vs 原始 SQL 对照) -ClickHouse 不自动建表。本后端**继承并扩展**了核心库的 DDL 表达式,能用 `CreateTableExpression` 生成完整 ClickHouse 原生 DDL。两种方式等价: +ClickHouse 不自动建表。本后端**继承并扩展**了核心库的 DDL 表达式,能用 `CreateTableStatement` 生成完整 ClickHouse 原生 DDL。两种方式等价: ### 方式 A:DDL 表达式(推荐,类型安全、可序列化) ```python from rhosocial.activerecord.backend.expression.statements import ( - CreateTableExpression, ColumnDefinition, DropTableExpression, + CreateTableStatement, ColumnDefinition, DropTableStatement, ) from rhosocial.activerecord.backend.impl.clickhouse.expression import ( ClickHouseInt64Type, ClickHouseStringType, ClickHouseInt32Type, @@ -51,7 +51,7 @@ from rhosocial.activerecord.backend.impl.clickhouse.expression import ( ) # 通过 storage_options 声明 ENGINE / ORDER BY / PARTITION BY / TTL / SETTINGS -expr = CreateTableExpression( +expr = CreateTableStatement( dialect=User.__dialect__, table="users", columns=[ diff --git a/docs/zh_CN/getting_started/quick_start.md b/docs/zh_CN/getting_started/quick_start.md index 9b1d64f..84ae778 100644 --- a/docs/zh_CN/getting_started/quick_start.md +++ b/docs/zh_CN/getting_started/quick_start.md @@ -63,7 +63,7 @@ SETTINGS enable_block_number_column = 1, enable_block_offset_column = 1; > ⚠️ `SETTINGS enable_block_number_column = 1, enable_block_offset_column = 1` 是 UPDATE/DELETE 的前置条件——ClickHouse 26.x 默认拒绝轻量级变更。详见 [变更(UPDATE/DELETE)](../capabilities/mutations.md)。 > -> 也可用 DDL 表达式(`CreateTableExpression` + `storage_options`)生成等价 DDL,见 [第一个 CRUD 应用](first_crud.md#2-建表ddl-表达式-vs-原始-sql-对照)。 +> 也可用 DDL 表达式(`CreateTableStatement` + `storage_options`)生成等价 DDL,见 [第一个 CRUD 应用](first_crud.md#2-建表ddl-表达式-vs-原始-sql-对照)。 ## 关键点 diff --git a/pyproject.toml b/pyproject.toml index 1c0fc58..8d9b708 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,7 +79,7 @@ namespaces = true [tool.pytest.ini_options] minversion = "7.0" -addopts = "-ra -q -p rhosocial.activerecord.testsuite.conftest" +addopts = "-ra -q -p rhosocial.activerecord.testsuite.conftest -p plugins.expression_xdist" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" testpaths = [ diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/backend/backend.py b/src/rhosocial/activerecord/backend/impl/clickhouse/backend/backend.py index c265716..cdd8011 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/backend/backend.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/backend/backend.py @@ -46,7 +46,6 @@ from rhosocial.activerecord.backend.result import QueryResult from rhosocial.activerecord.backend.introspection.backend_mixin import IntrospectorBackendMixin from rhosocial.activerecord.backend.explain import SyncExplainBackendMixin -from rhosocial.activerecord.backend.expression.objects import Table from ..config import ClickHouseConnectionConfig from ..dialect import ClickHouseDialect from ..transaction import ClickHouseTransactionManager @@ -292,20 +291,21 @@ def _count_predicate_rows(self, table: str, schema_name: Optional[str], where) - ClickHouse cannot report how many rows an UPDATE/DELETE mutation affected, so we translate the same predicate into a ``SELECT count()`` and use its result as the affected-row count. + + The predicate goes in as the node it already is. The concatenated form + rendered it, then stripped the leading ``WHERE`` word back off the text, + which put the query's structure in a string twice over. """ + from rhosocial.activerecord.backend.impl.clickhouse.expression.status import ( + ClickHouseRowCountQuery, + ) + if where is None: return 0 - table_sql, _ = Table( - self.dialect, table, catalog_name=schema_name + sql, params = ClickHouseRowCountQuery( + self.dialect, table, where, schema_name=schema_name ).to_sql() - if hasattr(where, "to_sql"): - where_sql, where_params = where.to_sql() - if where_sql.lstrip().upper().startswith("WHERE"): - where_sql = where_sql.lstrip()[len("WHERE"):] - else: - where_sql, where_params = str(where), () - count_sql = f"SELECT count() FROM {table_sql} WHERE {where_sql}" - result = self.execute(count_sql, where_params) + result = self.execute(sql, params) if result.data and result.data[0]: return int(next(iter(result.data[0].values()))) return 0 @@ -458,8 +458,13 @@ def _get_cursor(self) -> Any: else: # Verify the connection is still alive via a lightweight query. try: + from rhosocial.activerecord.backend.impl.clickhouse.expression.status import ( + ClickHouseConnectionProbeQuery, + ) + + probe_sql, probe_params = ClickHouseConnectionProbeQuery(self.dialect).to_sql() cursor = self._connection.cursor() - cursor.execute("SELECT 1") + cursor.execute(probe_sql, probe_params) cursor.fetchone() cursor.close() except (BrokenPipeError, OSError, ClickHouseError): @@ -524,7 +529,12 @@ def get_server_version(self) -> Tuple[int, int, int]: cursor = None try: cursor = self._get_cursor() - cursor.execute("SELECT version()") + from rhosocial.activerecord.backend.impl.clickhouse.expression.status import ( + ClickHouseVersionQuery, + ) + + version_sql, version_params = ClickHouseVersionQuery(self.dialect).to_sql() + cursor.execute(version_sql, version_params) version_row = cursor.fetchone() version_str = version_row[0] if version_row else "26.0.0" @@ -567,10 +577,15 @@ def ping(self, reconnect: bool = True) -> bool: else: return False - # Verify connection with SELECT 1 + # Verify connection with the liveness probe try: + from rhosocial.activerecord.backend.impl.clickhouse.expression.status import ( + ClickHouseConnectionProbeQuery, + ) + + probe_sql, probe_params = ClickHouseConnectionProbeQuery(self.dialect).to_sql() cursor = self._connection.cursor() - cursor.execute("SELECT 1") + cursor.execute(probe_sql, probe_params) cursor.fetchone() cursor.close() return True diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/cli/README.md b/src/rhosocial/activerecord/backend/impl/clickhouse/cli/README.md new file mode 100644 index 0000000..9007859 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/cli/README.md @@ -0,0 +1,104 @@ +# `introspect` — ClickHouse metadata, from the command line + +The `introspect` subcommand asks a live ClickHouse server what it holds, and +prints the answer. Every question runs through the backend's introspector, so +what the CLI reports is what the library sees — the same objects, the same +types, the same parsing — rather than a second implementation that can drift +from it. + +```bash +python -m rhosocial.activerecord.backend.impl.clickhouse introspect [NAME] [OPTIONS] +``` + +## What it can ask about + +| Type | Reads | `NAME` | Notes | +| :--- | :--- | :--- | :--- | +| `tables` | `system.tables` | refused | Tables, with the relation kind in `table_type` (`BASE_TABLE` / `VIEW` / `MATERIALIZED_VIEW` / `SYSTEM_TABLE`) and the storage engine in `extra`. `--include-system` adds the engine's own tables. | +| `views` | `system.tables` | view, optional | Views, with their definitions; a name describes one view. | +| `columns` | `system.columns` | table, required | One table's columns: name, type, nullability, ordinal position, default. | +| `indexes` | `system.data_skipping_indices` | table, required | One table's data-skipping indices — ClickHouse's nearest analogue to an index. | +| `partitions` | `system.parts` | table, required | One table's partitions and subpartitions, in the engine's own order, aggregated from the parts currently active. | +| `table-engine` | `system.tables` | table, required | The engine of one table, with its sorting, primary and sampling keys, plus row and byte totals. | +| `database` | `system.build_options`, `system.databases` | refused | Server version and the databases visible to this role. | + +`NAME` is a table name for `columns`, `indexes`, `partitions` and +`table-engine`, and the view itself for `views`. `tables` and `database` answer +for the whole database, so they **refuse** a name rather than drop it: a run +that accepted one and printed the full listing would read as though it had been +narrowed. The two exit codes say which kind of refusal happened: + +* **1** — the request was well formed but did not succeed: the connection + failed, a query failed, or a named view does not exist. +* **2** — the request cannot be answered here: `NAME` is used where the type + takes none, or the connected server's capabilities do not include the type. + + +## Options + +| Option | Default | Description | +| :--- | :--- | :--- | +| `-o, --output` | `table` | `table` (rich), `json`, `csv`, `tsv`. | +| `--database` | required (env: `CLICKHOUSE_DATABASE`) | The database to connect to **and** the database to read. ClickHouse's namespace level is a *database* and the flag says so. | +| `--include-system` | off | Include system tables. | +| `--host`, `--port`, `--database`, `--user`, `--password` | env or defaults | Connection. `CLICKHOUSE_*` env vars are honoured, and arguments override them. | +| `--ssl` | off | Use SSL for the connection. | +| `--async` | off | **Not available**: ClickHouse is a synchronous-only backend, so this path cannot serve a request. | +| `--named-connection` | _None_ | A connection saved by the `named-connection` subcommand. | +| `--conn-param` | _None_ | Extra `key=value` connection parameters; repeatable. | +| `--rich-ascii` | off | ASCII characters for table borders. | + +## Notes + +* **Read-only.** `introspect` never writes. It does not create, drop, or + alter anything, so it is safe to run against a server whose schema + matters. +* **There is no namespace flag, and that is deliberate.** ClickHouse's one + namespace level is a *database* and `--database` names it, so the flag says + the engine's word rather than a generic one. No `--schema`, and no alias for + it, so a caller cannot name a level that does not exist here. +* **`partitions` reads `system.parts`, not `system.partitions`.** The parts + view is what is live; a partition with no active parts has nothing to + report, which is why the answer is aggregated rather than listed. +* **`table_type` is the relation kind; the engine is in `extra`.** ClickHouse + answers the storage engine, not a standard table type, so the kind is + derived from the declared view-engine set (`View` and friends are views, + `MaterializedView` its own kind, `system`-database rows system tables) and + the engine string accompanies it instead of standing in for it. +* **No `triggers`, no `foreign-keys`.** ClickHouse has neither, so the CLI + does not offer those types at all — they are missing from `TYPE` instead of + accepted and then refused. An empty list from a type that was offered would + read as "none declared", which is a different and wrong answer; the same + command against a MySQL database returns a real list. This is a statement + about the *engine*, and it is not the same as a capability switch: a + `supports_*` switch that answers False says "this server cannot answer", + while a type that is absent from the vocabulary says "this backend never + asks". Every type that *is* offered carries a switch, and a server that + answers False for it is refused with exit 2 and + `Error: introspection is not supported by this server`. +* **`show.*` is not reachable from `introspect`, and that is a boundary, not + a gap.** The backend's SHOW passthrough (`introspector.show`, 19 methods + shared with MySQL and MariaDB) stays where it is: ClickHouse's `SHOW` + statements and `system.tables` / `system.parts` / `system.columns` are not + equivalent, and every type here reads `system.*`. Offering both would give + one question two readings, one of which depends on session context. +* **One question per invocation.** A CLI process is one connection that + opens, answers, and closes — there is no cache carried between runs. + +## Examples + +```bash +# The two ClickHouse-specific families +python -m rhosocial.activerecord.backend.impl.clickhouse introspect partitions events +python -m rhosocial.activerecord.backend.impl.clickhouse introspect table-engine events -o json + +# One view, by name +python -m rhosocial.activerecord.backend.impl.clickhouse introspect views daily_totals +python -m rhosocial.activerecord.backend.impl.clickhouse introspect views nope # exit 1: no such view + +# A name a whole-database type cannot use +python -m rhosocial.activerecord.backend.impl.clickhouse introspect tables events # exit 2 + +# Another database, including its system tables +python -m rhosocial.activerecord.backend.impl.clickhouse introspect tables --database reports --include-system +``` diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/cli/introspect.py b/src/rhosocial/activerecord/backend/impl/clickhouse/cli/introspect.py index 3247cdf..0333cf8 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/cli/introspect.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/cli/introspect.py @@ -1,5 +1,14 @@ # src/rhosocial/activerecord/backend/impl/clickhouse/cli/introspect.py -"""introspect subcommand - Database introspection.""" +"""introspect subcommand - Database introspection. + +There is no namespace flag here, and that is deliberate. ClickHouse has +exactly one namespace level and calls it a *database* -- there is no +schema under it. So ``--database``, which the connection already takes, is +the whole answer: the run connects to a database and asks about that +database. An earlier version accepted ``--schema`` and had to explain in +its help text that the value named a database anyway; the flag is gone +rather than aliased. +""" import argparse import asyncio @@ -8,16 +17,103 @@ from enum import Enum from typing import Any -from rhosocial.activerecord.backend.impl.clickhouse.backend import ClickHouseBackend -from rhosocial.activerecord.backend.impl.clickhouse.backend.async_backend import AsyncClickHouseBackend from rhosocial.activerecord.backend.errors import ConnectionError, QueryError +from rhosocial.activerecord.backend.impl.clickhouse.backend import ClickHouseBackend +from rhosocial.activerecord.backend.impl.clickhouse.backend.async_backend import ( + AsyncClickHouseBackend, +) + from .connection import add_connection_args, resolve_connection_config_from_args from .output import create_provider OUTPUT_CHOICES = ["table", "json", "csv", "tsv"] -INTROSPECT_TYPES = ["tables", "views", "table", "columns", "indexes", "foreign-keys", "triggers", "database"] +#: The single place a type name maps to the dialect capability that decides +#: whether this backend can answer it. ``INTROSPECT_TYPES`` is derived from it, +#: so a family cannot be offered without a switch saying it may be -- and the +#: order below is the order the parser and ``--help`` print. +#: +#: ``foreign-keys`` and ``triggers`` are **not** here, and their absence says +#: something different from a switch that answers False. This engine neither +#: declares nor enforces foreign keys and has no triggers at all, so the +#: question is not part of this backend's vocabulary: argparse refuses the word +#: and the answer is "this backend does not ask" rather than "this server +#: cannot answer". A capability switch is for the second case -- a family the +#: dialect knows and a particular server version may lack. Mixing the two is +#: what made an earlier attempt to arbitrate types in core unworkable. +_TYPE_CAPABILITIES = { + "tables": "supports_table_introspection", + "views": "supports_view_introspection", + "columns": "supports_column_introspection", + "indexes": "supports_index_introspection", + "partitions": "supports_partition_introspection", + "table-engine": "supports_table_engine_introspection", + "database": "supports_database_info", +} + +INTROSPECT_TYPES = list(_TYPE_CAPABILITIES) + +#: What the optional NAME positional means for each type. +#: +#: ``required`` -- the type asks about one table and cannot be answered without +#: it. ``optional`` -- a catalogue family that can also answer for one member, +#: so a given name narrows it (and a name that matches nothing is an error, not +#: an empty list). ``forbidden`` -- the family has no single-member question +#: here. ``tables`` must stay one: answering for one table would fill in its +#: columns and indexes, which is the aggregate this command deliberately +#: dropped. +_NAME_RULE = { + "tables": "forbidden", + "views": "optional", + "columns": "required", + "indexes": "required", + "partitions": "required", + "table-engine": "required", + "database": "forbidden", +} + + +def _require_capability(args, backend) -> None: + """Refuse a type the connected server cannot answer. + + The dialect is adapted before this runs, so the switch answers for the + server in front of us rather than for the newest release this backend + knows. A stub standing in for the backend (the offline CLI tests) may carry + no dialect at all; a real one always does. + """ + switch = _TYPE_CAPABILITIES.get(args.type) + if switch is None: + return + dialect = getattr(backend, "dialect", None) + if dialect is None: + return + supported = getattr(dialect, switch, None) + if supported is None or supported(): + return + print( + f"Error: {args.type} introspection is not supported by this server", + file=sys.stderr, + ) + sys.exit(2) + + +def _validate_name(args) -> None: + """Refuse a NAME the type cannot use, before anything connects. + + A silently ignored argument is the worst of the three outcomes: the run + succeeds, prints a full listing, and reads as though it had been narrowed. + Only ``forbidden`` is decided here; a ``required`` name that is missing is + still reported by the branch that needs it, with the exit code it always + had. + """ + if _NAME_RULE.get(args.type) == "forbidden" and args.name: + print( + f"Error: '{args.type}' introspection answers for the whole database " + f"and takes no name", + file=sys.stderr, + ) + sys.exit(2) def create_parser(subparsers): @@ -32,12 +128,18 @@ def create_parser(subparsers): # List all views %(prog)s views --database mydb - # Get detailed table info (columns, indexes, foreign keys) - %(prog)s table users --database mydb + # Describe one view + %(prog)s views v1 --database mydb # Get column details for a table %(prog)s columns users --database mydb + # List a table's partitions (from system.parts, grouped by partition_id) + %(prog)s partitions events --database mydb + + # Show a table's engine and key expressions (from system.tables) + %(prog)s table-engine events --database mydb + # Output as JSON %(prog)s tables --database mydb -o json @@ -68,21 +170,24 @@ def create_parser(subparsers): ) # introspect-specific arguments + # The list above is this backend's own answer, written here because only + # ClickHouse can say which families it has. `foreign-keys` and `triggers` + # are absent on purpose: the engine neither declares nor enforces foreign + # keys and has no triggers, so offering those questions would advertise an + # error rather than an answer. parser.add_argument( "type", choices=INTROSPECT_TYPES, - help="Introspection type: tables, views, table, columns, indexes, foreign-keys, triggers, database", + help="Introspection type: " + ", ".join(INTROSPECT_TYPES), ) parser.add_argument( "name", nargs="?", default=None, - help="Table/view name (required for some types)", - ) - parser.add_argument( - "--schema", - default=None, - help="Schema name (for databases that support schemas)", + help=( + "Object name: a table for columns/indexes/partitions/table-engine, " + "a view for views. Refused for tables/database." + ), ) parser.add_argument( "--include-system", @@ -97,6 +202,8 @@ def handle(args): """Handle the introspect subcommand.""" provider = create_provider(args.output, ascii_borders=args.rich_ascii) + _validate_name(args) + named_conn = getattr(args, "named_connection", None) if not named_conn and not args.database: print("Error: --database is required for introspection", file=sys.stderr) @@ -105,7 +212,19 @@ def handle(args): config = resolve_connection_config_from_args(args) if args.is_async: - backend = AsyncClickHouseBackend(connection_config=config) + # ``AsyncClickHouseBackend`` is a fail-fast placeholder: its constructor + # raises. Catching that here keeps the refusal readable -- the same + # sentence ``query`` uses -- instead of a traceback out of the + # constructor, which is what an unguarded call produced. + try: + backend = AsyncClickHouseBackend(connection_config=config) + except NotImplementedError: + print( + "Error: --async is not supported by the ClickHouse backend. " + "clickhouse-connect is a synchronous-only library.", + file=sys.stderr, + ) + sys.exit(1) asyncio.run(_handle_introspect_async(args, backend, provider)) else: backend = ClickHouseBackend(connection_config=config) @@ -145,41 +264,41 @@ def _handle_introspect_sync(args, backend: ClickHouseBackend, provider): """Handle introspect subcommand synchronously.""" try: backend.connect() + # Ask the server which version it is before any query is formatted: + # every capability gate reads the adapted dialect, and the parsers + # report the version through the same adaptation. + backend.introspect_and_adapt() + _require_capability(args, backend) introspector = backend.introspector if args.type == "tables": - tables = introspector.list_tables(schema=args.schema, include_system=args.include_system) + tables = introspector.list_tables(schema=None, include_system=args.include_system) data = _serialize_for_output(tables) provider.display_results(data, title="Tables") elif args.type == "views": - views = introspector.list_views(schema=args.schema) - data = _serialize_for_output(views) - provider.display_results(data, title="Views") - - elif args.type == "table": - if not args.name: - print("Error: Table name is required for 'table' introspection", file=sys.stderr) - sys.exit(1) - info = introspector.get_table_info(args.name, schema=args.schema) - if info: - provider.display_results(_serialize_for_output(info.columns), title=f"Columns of {args.name}") - if info.indexes: - provider.display_results(_serialize_for_output(info.indexes), title=f"Indexes of {args.name}") - if info.foreign_keys: - provider.display_results( - _serialize_for_output(info.foreign_keys), title=f"Foreign Keys of {args.name}" - ) + if args.name: + # The core method calls its namespace parameter ``schema``; + # ClickHouse's one namespace level is its database, and + # ``None`` means the database this run connected to. The name + # above is not translated: it is the view name ``system.tables`` + # lists. + view = introspector.get_view_info(args.name, schema=None) + if view is None: + print(f"Error: View '{args.name}' not found", file=sys.stderr) + sys.exit(1) + provider.display_results(_serialize_for_output([view]), title=f"View {args.name}") else: - print(f"Error: Table '{args.name}' not found", file=sys.stderr) - sys.exit(1) + views = introspector.list_views(schema=None) + data = _serialize_for_output(views) + provider.display_results(data, title="Views") elif args.type == "columns": if not args.name: print("Error: Table name is required for 'columns' introspection", file=sys.stderr) sys.exit(1) - columns = introspector.list_columns(args.name, schema=args.schema) + columns = introspector.list_columns(args.name, schema=None) data = _serialize_for_output(columns) provider.display_results(data, title=f"Columns of {args.name}") @@ -187,22 +306,36 @@ def _handle_introspect_sync(args, backend: ClickHouseBackend, provider): if not args.name: print("Error: Table name is required for 'indexes' introspection", file=sys.stderr) sys.exit(1) - indexes = introspector.list_indexes(args.name, schema=args.schema) + indexes = introspector.list_indexes(args.name, schema=None) data = _serialize_for_output(indexes) provider.display_results(data, title=f"Indexes of {args.name}") - elif args.type == "foreign-keys": + elif args.type == "partitions": if not args.name: - print("Error: Table name is required for 'foreign-keys' introspection", file=sys.stderr) + print( + "Error: Table name is required for 'partitions' introspection", file=sys.stderr + ) sys.exit(1) - fks = introspector.list_foreign_keys(args.name, schema=args.schema) - data = _serialize_for_output(fks) - provider.display_results(data, title=f"Foreign Keys of {args.name}") + # This backend has no schema layer, so the method's parameter is + # named ``database`` and ``None`` means the connected database. + partitions = introspector.list_partitions(args.name, database=None) + data = _serialize_for_output(partitions) + provider.display_results(data, title=f"Partitions of {args.name}") - elif args.type == "triggers": - triggers = introspector.list_triggers(table=args.name, schema=args.schema) - data = _serialize_for_output(triggers) - provider.display_results(data, title="Triggers") + elif args.type == "table-engine": + if not args.name: + print( + "Error: Table name is required for 'table-engine' introspection", + file=sys.stderr, + ) + sys.exit(1) + engine = introspector.get_table_engine(args.name, database=None) + if engine is None: + print(f"Error: Table '{args.name}' not found", file=sys.stderr) + sys.exit(1) + provider.display_results( + [_serialize_for_output(engine)], title=f"Engine of {args.name}" + ) elif args.type == "database": info = introspector.get_database_info() @@ -224,44 +357,45 @@ def _handle_introspect_sync(args, backend: ClickHouseBackend, provider): async def _handle_introspect_async(args, backend: AsyncClickHouseBackend, provider): - """Handle introspect subcommand asynchronously.""" + """Handle introspect subcommand asynchronously. + + Kept correct even though the CLI refuses ``--async``: the backend it would + run against is a fail-fast placeholder, so the day a real async backend + exists the question is "connect it", not "write it". Before that, these + call sites named five methods no introspector class has. + """ try: await backend.connect() + await backend.introspect_and_adapt() + _require_capability(args, backend) introspector = backend.introspector if args.type == "tables": - tables = await introspector.list_tables_async(schema=args.schema, include_system=args.include_system) + tables = await introspector.list_tables(schema=None, include_system=args.include_system) data = _serialize_for_output(tables) provider.display_results(data, title="Tables") elif args.type == "views": - views = await introspector.list_views_async(schema=args.schema) - data = _serialize_for_output(views) - provider.display_results(data, title="Views") - - elif args.type == "table": - if not args.name: - print("Error: Table name is required for 'table' introspection", file=sys.stderr) - sys.exit(1) - info = await introspector.get_table_info_async(args.name, schema=args.schema) - if info: - provider.display_results(_serialize_for_output(info.columns), title=f"Columns of {args.name}") - if info.indexes: - provider.display_results(_serialize_for_output(info.indexes), title=f"Indexes of {args.name}") - if info.foreign_keys: - provider.display_results( - _serialize_for_output(info.foreign_keys), title=f"Foreign Keys of {args.name}" - ) + if args.name: + # Same rule as the synchronous handler: ``schema`` is + # ClickHouse's database in the core method's vocabulary, and + # ``None`` is the connected one. + view = await introspector.get_view_info(args.name, schema=None) + if view is None: + print(f"Error: View '{args.name}' not found", file=sys.stderr) + sys.exit(1) + provider.display_results(_serialize_for_output([view]), title=f"View {args.name}") else: - print(f"Error: Table '{args.name}' not found", file=sys.stderr) - sys.exit(1) + views = await introspector.list_views(schema=None) + data = _serialize_for_output(views) + provider.display_results(data, title="Views") elif args.type == "columns": if not args.name: print("Error: Table name is required for 'columns' introspection", file=sys.stderr) sys.exit(1) - columns = await introspector.list_columns_async(args.name, schema=args.schema) + columns = await introspector.list_columns(args.name, schema=None) data = _serialize_for_output(columns) provider.display_results(data, title=f"Columns of {args.name}") @@ -269,25 +403,39 @@ async def _handle_introspect_async(args, backend: AsyncClickHouseBackend, provid if not args.name: print("Error: Table name is required for 'indexes' introspection", file=sys.stderr) sys.exit(1) - indexes = await introspector.list_indexes_async(args.name, schema=args.schema) + indexes = await introspector.list_indexes(args.name, schema=None) data = _serialize_for_output(indexes) provider.display_results(data, title=f"Indexes of {args.name}") - elif args.type == "foreign-keys": + elif args.type == "partitions": if not args.name: - print("Error: Table name is required for 'foreign-keys' introspection", file=sys.stderr) + print( + "Error: Table name is required for 'partitions' introspection", file=sys.stderr + ) sys.exit(1) - fks = await introspector.list_foreign_keys_async(args.name, schema=args.schema) - data = _serialize_for_output(fks) - provider.display_results(data, title=f"Foreign Keys of {args.name}") + # This backend has no schema layer, so the method's parameter is + # named ``database`` and ``None`` means the connected database. + partitions = await introspector.list_partitions(args.name, database=None) + data = _serialize_for_output(partitions) + provider.display_results(data, title=f"Partitions of {args.name}") - elif args.type == "triggers": - triggers = await introspector.list_triggers_async(table=args.name, schema=args.schema) - data = _serialize_for_output(triggers) - provider.display_results(data, title="Triggers") + elif args.type == "table-engine": + if not args.name: + print( + "Error: Table name is required for 'table-engine' introspection", + file=sys.stderr, + ) + sys.exit(1) + engine = await introspector.get_table_engine(args.name, database=None) + if engine is None: + print(f"Error: Table '{args.name}' not found", file=sys.stderr) + sys.exit(1) + provider.display_results( + [_serialize_for_output(engine)], title=f"Engine of {args.name}" + ) elif args.type == "database": - info = await introspector.get_database_info_async() + info = await introspector.get_database_info() data = _serialize_for_output(info) provider.display_results([data], title="Database Info") diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/dialect.py b/src/rhosocial/activerecord/backend/impl/clickhouse/dialect.py index eb48d83..6bf77d9 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/dialect.py @@ -153,6 +153,7 @@ ClickHouseJSONFunctionMixin, ClickHouseSpatialMixin, ClickHouseIntrospectionMixin, + ClickHouseStatusQueryMixin, ClickHouseLockingMixin, ClickHouseModifyColumnMixin, ClickHouseJsonDualityViewMixin, @@ -182,6 +183,7 @@ ClickHouseJoinMixin, ClickHouseSetOperationMixin, ClickHouseDQLMixin, + ClickHouseWildcardMixin, ClickHouseMaterializedViewMixin, # MV DDL overrides ClickHouseViewMixin and ViewMixin ClickHouseViewMixin, ClickHouseNamespaceMixin, @@ -196,16 +198,39 @@ # reason documented there: the direction is the function name, and the # no-characters ANSI form the default renders is a syntax error here. ClickHouseTrimMixin, + ClickHouseLiteralValueMixin, ) 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 + from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + ModifyColumnAction as ModifyColumn, + ) class ClickHouseDialect( + # The bases below come in bundles: a backend mixin, then the core bundle + # it stands in for. The backend's answer has to come first -- a mixin + # placed after the class it overrides never wins -- so a new mixin goes + # immediately before the core mixin it overrides, and the pairing is read + # off the list. Two positions sit outside that pairing and are called out + # where they occur: a mixin that overrides a method ``SQLDialectBase`` + # defines has to precede the base, and a mixin that subclasses a core + # mixin has to precede it too (C3: a subclass precedes its base). + # Ahead of SQLDialectBase, unlike every other mixin in this list. Those + # add methods the base does not have, so their position is a readability + # choice; this one overrides ``format_literal`` -- ClickHouse's two + # tier-2 spellings, unhex for bytes and the bare quoted text for UUID -- + # and a mixin placed after the class it overrides never wins. + ClickHouseLiteralValueMixin, SQLDialectBase, + # ClickHouse's own answer to the row-source capabilities core's + # RelationSourceMixin supplies. It precedes that mixin because a mixin + # placed after the class it overrides never wins -- and this one was + # after it, so the override was dead code and core's default answered + # for ClickHouse. + ClickHouseTableStatementMixin, RelationSourceMixin, # The object tree: each *NameMixin renders one object kind and inherits # NamespaceMixin for the namespace levels, so they precede it. The object @@ -313,6 +338,7 @@ class ClickHouseDialect( ConstraintMixin, ClickHouseSpatialMixin, ClickHouseIntrospectionMixin, + ClickHouseStatusQueryMixin, ClickHouseShowDialectMixin, ClickHouseModifyColumnMixin, ClickHouseJsonDualityViewMixin, @@ -320,7 +346,6 @@ class ClickHouseDialect( UserDefinedTypeMixin, DomainMixin, ClickHouseOptimizerHintMixin, - ClickHouseTableStatementMixin, ClickHouseMaintenanceMixin, ClickHouseRoutineMixin, ClickHouseLoadXMLLMixin, @@ -330,6 +355,17 @@ class ClickHouseDialect( IntrospectionMixin, # Global new mixins PredicateMixin, + # Before ExpressionMixin: the wildcard split gave the row-count position its + # own formatter, and ClickHouse spells ``COUNT(DISTINCT *)`` where core + # spells ``*``. C3 gives the earlier name in this list priority, so this must + # stay ahead of ``ExpressionMixin`` or the override would never be reached. + ClickHouseWildcardMixin, + # Before ExpressionMixin for the same reason: this carries ClickHouse's + # ``lengthUTF8`` answer for ``CHAR_LENGTH`` (the engine's LENGTH counts + # bytes), and ExpressionMixin inherits the core's FunctionCallMixin + # whose ``LENGTH`` default it replaces. Listed after it, the override + # would be dead code -- which is what it was until the MRO was checked. + ClickHouseFunctionMixin, ExpressionMixin, ClickHouseDateTimeMixin, DateTimeMixin, @@ -341,7 +377,6 @@ class ClickHouseDialect( TransactionControlMixin, ClickHouseSetOperationMixin, SetOperationMixin, - ClickHouseFunctionMixin, # Column types. The ClickHouse half first: it overrides the eighteen-entry # table, and C3 gives the earlier name priority. Nothing else in this list # can answer it, so the pair may move as a unit without disturbing the order @@ -480,8 +515,7 @@ def create_schema_differ(self): return ClickHouseSchemaDiffer() - @staticmethod - def _escape_sql_string(value: str) -> str: + def escape_sql_string(self, value: str) -> str: """Escape string for ClickHouse. ClickHouse by default treats backslash as an escape character. @@ -526,7 +560,7 @@ def format_identifier(self, identifier: str, need_quote: bool = True) -> str: escaped = identifier.replace("`", "``") return f"`{escaped}`" - # region CreateTableExpression.diff() hooks (CreateTableExpressionDiffMixin) + # region CreateTableStatement.diff() hooks (CreateTableExpressionDiffMixin) # # ClickHouse-specific policy for the expression-level CREATE TABLE diff: # @@ -553,9 +587,11 @@ def supports_alter_column_type(self) -> bool: def alter_column_type_action(self, old_col, new_col) -> "ModifyColumn": """Render a column type change as MODIFY COLUMN .""" - from rhosocial.activerecord.backend.expression.statements.ddl_alter import ModifyColumn + from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + ModifyColumnAction, + ) - return ModifyColumn(self, column=new_col) + return ModifyColumnAction(self, column=new_col) def supports_alter_column_properties(self) -> bool: """No ``ALTER COLUMN SET/DROP DEFAULT`` / ``SET/DROP NOT NULL`` in ClickHouse.""" diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/conftest.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/conftest.py index cc8df35..501ac66 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/conftest.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/conftest.py @@ -15,6 +15,13 @@ """ EXAMPLES_META = { + "introspection_partitions.py": { + "title": "Partitions and Table Engine (backend-specific introspection)", + "dialect_protocols": [], + "priority": 10, + "min_version": "25.8", + "max_version": "26.7", + }, "transaction/basic.py": { "title": "Transaction Awareness (ClickHouse does not support transactions)", "dialect_protocols": [], @@ -59,7 +66,7 @@ "max_version": "26.7", }, "ddl/drop_table.py": { - "title": "DROP TABLE using DropTableExpression", + "title": "DROP TABLE using DropTableStatement", "dialect_protocols": [], "priority": 10, "min_version": "25.8", @@ -101,21 +108,21 @@ "max_version": "26.7", }, "delete/basic.py": { - "title": "DELETE using DeleteExpression", + "title": "DELETE using DeleteStatement", "dialect_protocols": [], "priority": 10, "min_version": "25.8", "max_version": "26.7", }, "update/basic.py": { - "title": "UPDATE using UpdateExpression", + "title": "UPDATE using UpdateStatement", "dialect_protocols": [], "priority": 10, "min_version": "25.8", "max_version": "26.7", }, "query/union.py": { - "title": "UNION using SetOperationExpression", + "title": "UNION using SetOperation", "dialect_protocols": [], "priority": 10, "min_version": "25.8", diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/connection/quickstart.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/connection/quickstart.py index abfaf2a..a41df30 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/connection/quickstart.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/connection/quickstart.py @@ -17,10 +17,10 @@ import os from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, - InsertExpression, - QueryExpression, + CreateTableStatement, + DropTableStatement, + InsertStatement, + QueryStatement, ValuesSource, WhereClause, ) @@ -28,7 +28,7 @@ from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ColumnNotNullConstraint, ColumnDefinition, ) from rhosocial.activerecord.backend.impl.clickhouse.backend import ClickHouseBackend @@ -62,7 +62,7 @@ def execute_expression(expression, options=None): def create_demo_tables(): - users_table = CreateTableExpression( + users_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "quickstart_users"), columns=[ @@ -70,14 +70,14 @@ def create_demo_tables(): "id", "UInt32", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition( "name", "String", - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], ), ColumnDefinition("status", "String"), ], @@ -85,7 +85,7 @@ def create_demo_tables(): ) execute_expression(users_table, ddl_options) - logs_table = CreateTableExpression( + logs_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "quickstart_logs"), columns=[ @@ -93,14 +93,14 @@ def create_demo_tables(): "id", "UInt32", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition( "message", "String", - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], ), ], if_not_exists=True, @@ -109,7 +109,7 @@ def create_demo_tables(): def seed_demo_data(): - insert_users = InsertExpression( + insert_users = InsertStatement( dialect=dialect, into=Table(dialect, "quickstart_users"), columns=["name", "status"], @@ -137,7 +137,7 @@ def seed_demo_data(): # ============================================================ # SECTION: Execute Queries # ============================================================ -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -172,7 +172,7 @@ def seed_demo_data(): # ============================================================ # SECTION: Execute Parameterized Queries (Recommended) # ============================================================ -filtered_query = QueryExpression( +filtered_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -203,7 +203,7 @@ def seed_demo_data(): # SECTION: Handle Transactions # ============================================================ with backend.transaction(): - insert_log = InsertExpression( + insert_log = InsertStatement( dialect=dialect, into=Table(dialect, "quickstart_logs"), columns=["message"], @@ -211,7 +211,7 @@ def seed_demo_data(): ) execute_expression(insert_log) -logs_query = QueryExpression( +logs_query = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "message")], from_=NamedRelationRef(dialect, Table(dialect, "quickstart_logs")), @@ -223,7 +223,7 @@ def seed_demo_data(): # SECTION: Error Handling # ============================================================ try: - invalid_query = QueryExpression( + invalid_query = QueryStatement( dialect=dialect, select=[Column(dialect, "id")], from_=NamedRelationRef(dialect, Table(dialect, "nonexistent_table")), @@ -235,10 +235,10 @@ def seed_demo_data(): # ============================================================ # SECTION: Disconnect # ============================================================ -drop_logs = DropTableExpression(dialect=dialect, table=Table(dialect, "quickstart_logs"), if_exists=True) +drop_logs = DropTableStatement(dialect=dialect, table=Table(dialect, "quickstart_logs"), if_exists=True) execute_expression(drop_logs, ddl_options) -drop_users = DropTableExpression(dialect=dialect, table=Table(dialect, "quickstart_users"), if_exists=True) +drop_users = DropTableStatement(dialect=dialect, table=Table(dialect, "quickstart_users"), if_exists=True) execute_expression(drop_users, ddl_options) backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/ddl/alter_table.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/ddl/alter_table.py index c1e05a0..5f458ad 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/ddl/alter_table.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/ddl/alter_table.py @@ -18,16 +18,16 @@ from rhosocial.activerecord.backend.impl.clickhouse.backend import ClickHouseBackend from rhosocial.activerecord.backend.impl.clickhouse.config import ClickHouseConnectionConfig from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ColumnNotNullConstraint, ColumnDefaultConstraint, ) config = ClickHouseConnectionConfig( @@ -41,15 +41,15 @@ backend.connect() dialect = backend.dialect -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -57,8 +57,8 @@ "id", "UInt32", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition("name", "String"), @@ -68,7 +68,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["name"], @@ -83,10 +83,12 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import AlterTableExpression # noqa: E402 -from rhosocial.activerecord.backend.expression.statements.ddl_alter import AddColumn # noqa: E402 +from rhosocial.activerecord.backend.expression import AlterTableStatement # noqa: E402 +from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( # noqa: E402 + AddColumnAction, +) -add_col_action = AddColumn( +add_col_action = AddColumnAction( dialect=dialect, column=ColumnDefinition( name="email", @@ -94,7 +96,7 @@ ), ) -add_col_expr = AlterTableExpression( +add_col_expr = AlterTableStatement( dialect=dialect, table=Table(dialect, "users"), actions=[add_col_action], @@ -107,21 +109,19 @@ backend.execute(sql, params) print("Column email added successfully") -add_age_action = AddColumn( +add_age_action = AddColumnAction( dialect=dialect, column=ColumnDefinition( "age", "UInt32", constraints=[ - ColumnConstraint( - ColumnConstraintType.DEFAULT, - default_value=Literal(dialect, 0), - ), + ColumnConstraint(dialect, + branch=ColumnDefaultConstraint(dialect, Literal(dialect, 0))), ], ), ) -add_age_expr = AlterTableExpression( +add_age_expr = AlterTableStatement( dialect=dialect, table=Table(dialect, "users"), actions=[add_age_action], diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/ddl/create_index.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/ddl/create_index.py index 582ce8f..fe3f1cc 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/ddl/create_index.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/ddl/create_index.py @@ -10,11 +10,11 @@ import os from rhosocial.activerecord.backend.impl.clickhouse.backend import ClickHouseBackend from rhosocial.activerecord.backend.impl.clickhouse.config import ClickHouseConnectionConfig -from rhosocial.activerecord.backend.expression import CreateTableExpression, DropTableExpression +from rhosocial.activerecord.backend.expression import CreateTableStatement, DropTableStatement from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ColumnNotNullConstraint, ) config = ClickHouseConnectionConfig( @@ -29,11 +29,11 @@ dialect = backend.dialect # Drop table first for clean setup -drop = DropTableExpression(dialect=dialect, table=Table(dialect, "products"), if_exists=True) +drop = DropTableStatement(dialect=dialect, table=Table(dialect, "products"), if_exists=True) sql, params = drop.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "products"), columns=[ @@ -41,8 +41,8 @@ "id", "UInt32", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition("name", "String"), @@ -57,17 +57,17 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import CreateIndexExpression, DropIndexExpression # noqa: E402 +from rhosocial.activerecord.backend.expression import CreateIndexStatement, DropIndexStatement # noqa: E402 # Drop index first if exists (ClickHouse does not support IF NOT EXISTS in CREATE INDEX) try: - drop_idx = DropIndexExpression(dialect=dialect, index=Index(dialect, "idx_category_price")) + drop_idx = DropIndexStatement(dialect=dialect, index=Index(dialect, "idx_category_price")) sql, params = drop_idx.to_sql() backend.execute(sql, params) except Exception: pass -create_idx = CreateIndexExpression( +create_idx = CreateIndexStatement( dialect=dialect, index=Index(dialect, "idx_category_price"), table=Table(dialect, "products"), diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/ddl/create_table.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/ddl/create_table.py index a8437d4..11928a9 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/ddl/create_table.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/ddl/create_table.py @@ -18,14 +18,14 @@ from rhosocial.activerecord.backend.impl.clickhouse.backend import ClickHouseBackend from rhosocial.activerecord.backend.impl.clickhouse.config import ClickHouseConnectionConfig from rhosocial.activerecord.backend.expression import ( - DropTableExpression, - CreateTableExpression, + DropTableStatement, + CreateTableStatement, ) -from rhosocial.activerecord.backend.expression.functions.datetime import current_timestamp +from rhosocial.activerecord.backend.expression import functions as F from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ColumnNotNullConstraint, ColumnDefaultConstraint, ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( IndexDefinition, @@ -43,7 +43,7 @@ dialect = backend.dialect # Drop if exists for clean setup -drop = DropTableExpression(dialect=dialect, table=Table(dialect, "products"), if_exists=True) +drop = DropTableStatement(dialect=dialect, table=Table(dialect, "products"), if_exists=True) sql, params = drop.to_sql() backend.execute(sql, params) @@ -56,22 +56,22 @@ name="id", data_type="UInt32", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition( name="name", data_type="String", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition( name="price", data_type="Decimal(10, 2)", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition( @@ -82,14 +82,14 @@ name="is_active", data_type="UInt8", constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1)), ], ), ColumnDefinition( name="created_at", data_type="DateTime", constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=current_timestamp(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, F.current_timestamp(dialect))), ], ), ] @@ -102,7 +102,7 @@ ] # Create table with ClickHouse-specific ENGINE option -create_expr = CreateTableExpression( +create_expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "products"), columns=columns, @@ -131,7 +131,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "products"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "products"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) backend.disconnect() @@ -143,5 +143,5 @@ # 1. Use ColumnDefinition with ClickHouse column types (UInt32, String, etc.) # 2. ClickHouse table engines are supplied through the table-engine layer # 3. IndexDefinition creates inline indexes within CREATE TABLE -# 4. Use current_timestamp(dialect) for SQL niladic functions (no parentheses) +# 4. Use F.current_timestamp(dialect) for SQL niladic functions (no parentheses) # 5. Use introspector.get_columns() to verify table structure diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/ddl/drop_table.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/ddl/drop_table.py index 187e2b4..ec3594c 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/ddl/drop_table.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/ddl/drop_table.py @@ -1,5 +1,5 @@ """ -DROP TABLE using DropTableExpression - ClickHouse. +DROP TABLE using DropTableStatement - ClickHouse. This example demonstrates: 1. DROP TABLE @@ -27,24 +27,24 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - DropTableExpression, + CreateTableStatement, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ) -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -52,7 +52,7 @@ "id", "UInt32", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), ], ), ], @@ -65,7 +65,7 @@ # ============================================================ # SECTION: DROP TABLE # ============================================================ -drop_expr = DropTableExpression( +drop_expr = DropTableStatement( dialect=dialect, table=Table(dialect, "users"), ) @@ -77,7 +77,7 @@ # ============================================================ # SECTION: DROP TABLE IF EXISTS # ============================================================ -drop_expr_if_exists = DropTableExpression( +drop_expr_if_exists = DropTableStatement( dialect=dialect, table=Table(dialect, "users"), if_exists=True, @@ -95,6 +95,6 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use DropTableExpression to drop tables +# 1. Use DropTableStatement to drop tables # 2. if_exists=True prevents error if table doesn't exist # 3. CASCADE and RESTRICT options available diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/ddl/unique_index.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/ddl/unique_index.py index 8264edf..f30aa23 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/ddl/unique_index.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/ddl/unique_index.py @@ -26,23 +26,23 @@ backend.connect() dialect = backend.dialect -from rhosocial.activerecord.backend.expression import CreateTableExpression, DropTableExpression # noqa: E402 +from rhosocial.activerecord.backend.expression import CreateTableStatement, DropTableStatement # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ) # Drop dependent tables first for clean setup -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -50,7 +50,7 @@ "id", "UInt32", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), ], ), ColumnDefinition("email", "String"), @@ -65,17 +65,17 @@ # ============================================================ # SECTION: CREATE UNIQUE INDEX # ============================================================ -from rhosocial.activerecord.backend.expression import CreateIndexExpression, DropIndexExpression # noqa: E402 +from rhosocial.activerecord.backend.expression import CreateIndexStatement, DropIndexStatement # noqa: E402 # Drop index first if exists (ClickHouse does not support IF NOT EXISTS in CREATE INDEX) try: - drop_idx = DropIndexExpression(dialect=dialect, index=Index(dialect, "idx_users_email_unique")) + drop_idx = DropIndexStatement(dialect=dialect, index=Index(dialect, "idx_users_email_unique")) sql, params = drop_idx.to_sql() backend.execute(sql, params) except Exception: pass -unique_idx = CreateIndexExpression( +unique_idx = CreateIndexStatement( dialect=dialect, index=Index(dialect, "idx_users_email_unique"), table=Table(dialect, "users"), @@ -92,13 +92,13 @@ # ============================================================ # Drop composite index first if exists try: - drop_idx2 = DropIndexExpression(dialect=dialect, index=Index(dialect, "idx_users_name_email")) + drop_idx2 = DropIndexStatement(dialect=dialect, index=Index(dialect, "idx_users_name_email")) sql, params = drop_idx2.to_sql() backend.execute(sql, params) except Exception: pass -composite_idx = CreateIndexExpression( +composite_idx = CreateIndexStatement( dialect=dialect, index=Index(dialect, "idx_users_name_email"), table=Table(dialect, "users"), @@ -111,11 +111,11 @@ # ============================================================ # SECTION: Teardown # ============================================================ -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) backend.disconnect() @@ -124,6 +124,6 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use CreateIndexExpression with unique=True +# 1. Use CreateIndexStatement with unique=True # 2. Use columns=[col1, col2] for composite index -# 3. Use DropIndexExpression to drop +# 3. Use DropIndexStatement to drop diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/ddl/view.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/ddl/view.py index 0abfa80..7c0ccad 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/ddl/view.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/ddl/view.py @@ -31,10 +31,10 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 @@ -42,15 +42,15 @@ ) # Drop dependent tables first for clean setup (orders may reference users via FK) -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_users = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_users = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_users.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -63,7 +63,7 @@ print(f"Create table SQL: {sql}") backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["id", "name"], @@ -83,19 +83,19 @@ # SECTION: CREATE VIEW # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, - CreateViewExpression, - DropViewExpression, + QueryStatement, + CreateViewStatement, + DropViewStatement, ) -from rhosocial.activerecord.backend.expression.core import WildcardExpression # noqa: E402 +from rhosocial.activerecord.backend.expression.core import AllColumnsWildcard # noqa: E402 -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[Column(dialect, "name")], from_=NamedRelationRef(dialect, Table(dialect, "users")), ) -view_expr = CreateViewExpression( +view_expr = CreateViewStatement( dialect=dialect, view_name="user_names", query=query, @@ -107,9 +107,9 @@ options = ExecutionOptions(stmt_type=StatementType.DDL) backend.execute(sql, params, options=options) -verify_query = QueryExpression( +verify_query = QueryStatement( dialect=dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=NamedRelationRef(dialect, Table(dialect, "user_names")), ) options = ExecutionOptions(stmt_type=StatementType.DQL) @@ -120,7 +120,7 @@ # ============================================================ # SECTION: CREATE OR REPLACE VIEW # ============================================================ -view_expr_replace = CreateViewExpression( +view_expr_replace = CreateViewStatement( dialect=dialect, view_name="user_names", query=query, @@ -133,7 +133,7 @@ # ============================================================ # SECTION: DROP VIEW # ============================================================ -drop_view = DropViewExpression( +drop_view = DropViewStatement( dialect=dialect, view_name="user_names", ) @@ -144,11 +144,11 @@ # ============================================================ # SECTION: Teardown # ============================================================ -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_expr = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params) backend.disconnect() @@ -157,6 +157,6 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use CreateViewExpression to create views +# 1. Use CreateViewStatement to create views # 2. Use replace=True to replace existing view -# 3. Use DropViewExpression to drop views +# 3. Use DropViewStatement to drop views diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/delete/basic.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/delete/basic.py index fd9b378..0e141aa 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/delete/basic.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/delete/basic.py @@ -1,8 +1,8 @@ """ -DELETE using DeleteExpression - ClickHouse. +DELETE using DeleteStatement - ClickHouse. This example demonstrates: -1. Delete single row using DeleteExpression +1. Delete single row using DeleteStatement 2. Delete with WHERE condition 3. Delete all rows @@ -30,28 +30,28 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ColumnNotNullConstraint, ) # Drop dependent tables first for clean setup -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -59,15 +59,15 @@ "id", "UInt32", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition( "name", "String", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ], @@ -77,7 +77,7 @@ print(f"Create table SQL: {sql}") backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["name"], @@ -97,10 +97,10 @@ # ============================================================ # SECTION: Delete Single Row # ============================================================ -from rhosocial.activerecord.backend.expression import DeleteExpression # noqa: E402 +from rhosocial.activerecord.backend.expression import DeleteStatement # noqa: E402 from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 -delete_expr = DeleteExpression( +delete_expr = DeleteStatement( dialect=dialect, table=Table(dialect, "users"), where=ComparisonPredicate(dialect, "=", Column(dialect, "name"), Literal(dialect, "Alice")), @@ -116,7 +116,7 @@ # ============================================================ # SECTION: Delete with Condition # ============================================================ -delete_expr = DeleteExpression( +delete_expr = DeleteStatement( dialect=dialect, table=Table(dialect, "users"), where=ComparisonPredicate(dialect, "=", Column(dialect, "name"), Literal(dialect, "Bob")), @@ -129,7 +129,7 @@ # ============================================================ # SECTION: Delete All Rows # ============================================================ -delete_expr = DeleteExpression(dialect=dialect, table=Table(dialect, "users")) +delete_expr = DeleteStatement(dialect=dialect, table=Table(dialect, "users")) sql, params = delete_expr.to_sql() print(f"Delete all SQL: {sql}") result = backend.execute(sql, params, options=options) @@ -138,11 +138,11 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_expr = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params) backend.disconnect() @@ -151,7 +151,7 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use DeleteExpression to build DELETE query +# 1. Use DeleteStatement to build DELETE query # 2. Use ComparisonPredicate for WHERE conditions # 3. Omit where parameter to delete all rows # 4. affected_rows shows number of deleted rows diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/insert/batch.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/insert/batch.py index d5fd1c7..2e730e0 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/insert/batch.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/insert/batch.py @@ -12,14 +12,14 @@ from rhosocial.activerecord.backend.impl.clickhouse.backend import ClickHouseBackend from rhosocial.activerecord.backend.impl.clickhouse.config import ClickHouseConnectionConfig from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, + CreateTableStatement, + DropTableStatement, ) -from rhosocial.activerecord.backend.expression.functions.datetime import current_timestamp +from rhosocial.activerecord.backend.expression import functions as F from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ColumnNotNullConstraint, ColumnDefaultConstraint, ) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -36,11 +36,11 @@ dialect = backend.dialect # Drop table first for clean setup -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "logs"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "logs"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "logs"), columns=[ @@ -48,8 +48,8 @@ "id", "UInt32", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition("level", "String"), @@ -58,10 +58,8 @@ "created_at", "DateTime", constraints=[ - ColumnConstraint( - ColumnConstraintType.DEFAULT, - default_value=current_timestamp(dialect), - ), + ColumnConstraint(dialect, + branch=ColumnDefaultConstraint(dialect, F.current_timestamp(dialect))), ], ), ], @@ -74,14 +72,14 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - InsertExpression, + InsertStatement, ValuesSource, - QueryExpression, + QueryStatement, ) -from rhosocial.activerecord.backend.expression.core import Literal, WildcardExpression, Column # noqa: E402 +from rhosocial.activerecord.backend.expression.core import Literal, AllColumnsWildcard, Column # noqa: E402 from rhosocial.activerecord.backend.expression.statements.dql import OrderByClause # noqa: E402 -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "logs"), source=ValuesSource( @@ -106,9 +104,9 @@ result = backend.execute(sql, params) print(f"Affected rows: {result.affected_rows}") -verify_query = QueryExpression( +verify_query = QueryStatement( dialect=dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=NamedRelationRef(dialect, Table(dialect, "logs")), order_by=OrderByClause(dialect, [Column(dialect, "id")]), ) diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/insert/single.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/insert/single.py index 4172bdd..0d72a2b 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/insert/single.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/insert/single.py @@ -4,7 +4,7 @@ This example demonstrates: 1. INSERT a single row with explicit column values 2. INSERT with UInt32 primary key (auto-generated) -3. Verify inserted data using QueryExpression +3. Verify inserted data using QueryStatement """ @@ -17,18 +17,18 @@ from rhosocial.activerecord.backend.impl.clickhouse.backend import ClickHouseBackend from rhosocial.activerecord.backend.impl.clickhouse.config import ClickHouseConnectionConfig from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, - QueryExpression, - InsertExpression, + CreateTableStatement, + DropTableStatement, + QueryStatement, + InsertStatement, ValuesSource, ) -from rhosocial.activerecord.backend.expression.core import Literal, WildcardExpression, Column +from rhosocial.activerecord.backend.expression.core import Literal, AllColumnsWildcard, Column from rhosocial.activerecord.backend.expression.statements.dql import OrderByClause from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ColumnNotNullConstraint, ) from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.options import ExecutionOptions @@ -48,15 +48,15 @@ dql_options = ExecutionOptions(stmt_type=StatementType.DQL) # Drop dependent tables first for clean setup -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -64,15 +64,15 @@ "id", "UInt32", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition( "name", "String", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition("email", "String"), @@ -87,7 +87,7 @@ # ============================================================ # 1. Insert a single row with explicit column values -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "users"), source=ValuesSource( @@ -109,9 +109,9 @@ print(f"Affected rows: {result.affected_rows}") # Verify the inserted row -verify_query = QueryExpression( +verify_query = QueryStatement( dialect=dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=NamedRelationRef(dialect, Table(dialect, "users")), where=ComparisonPredicate( dialect, @@ -125,7 +125,7 @@ print(f"Inserted row: {result.data}") # 2. Insert another row (id will be assigned automatically) -insert_expr2 = InsertExpression( +insert_expr2 = InsertStatement( dialect=dialect, into=Table(dialect, "users"), source=ValuesSource( @@ -141,7 +141,7 @@ print(f"Second insert affected rows: {result.affected_rows}") # Verify all rows -all_query = QueryExpression( +all_query = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "email")], from_=NamedRelationRef(dialect, Table(dialect, "users")), @@ -154,11 +154,11 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) backend.disconnect() @@ -167,7 +167,7 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use InsertExpression with ValuesSource containing a single row list +# 1. Use InsertStatement with ValuesSource containing a single row list # 2. The primary key id (UInt32) is auto-assigned and omitted from the insert columns list -# 3. Verify inserts using QueryExpression with WHERE clause +# 3. Verify inserts using QueryStatement with WHERE clause # 4. ClickHouse returns affected_rows = 1 for successful single insert diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/introspection_partitions.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/introspection_partitions.py new file mode 100644 index 0000000..9b7d55e --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/introspection_partitions.py @@ -0,0 +1,215 @@ +"""ClickHouse-specific introspection: partitions and table engine. + +This example uses the two reads this backend adds to the standard introspection +API: + + backend.introspector.list_partitions(table, database=None) + backend.introspector.get_table_engine(table, database=None) + +``list_partitions`` aggregates the table's active parts from ``system.parts`` +per ``partition_id``: rows, bytes on disk, part count, the modification-time +range and the engine. ``get_table_engine`` reads the storage definition from +``system.tables``: engine, ``ORDER BY`` / ``PRIMARY KEY`` / ``PARTITION BY`` / +``SAMPLE BY`` expressions, totals and comment. + +Naming: ClickHouse's top-level namespace is a *database*, and it has no schema +layer. That is why both methods take ``database=`` rather than the core +introspection API's ``schema=``; the value is the same namespace either way, and +nothing is translated. Passing ``None`` uses the connected database. + +The second half of the example counts the statements the introspector sends and +times both calls, cold (cache empty) and warm (cache filled), to show that the +cache actually removes the round trips rather than merely being present. + +Run it with a ClickHouse server reachable through the same environment +variables the test suite uses: + + CLICKHOUSE_HOST=127.0.0.1 CLICKHOUSE_PORT=8123 CLICKHOUSE_DATABASE=test_db + CLICKHOUSE_USER=root CLICKHOUSE_PASSWORD=password + python introspection_partitions.py + +With no server, the script prints these instructions and exits non-zero. It does +not invent partitions or an engine for a server it could not reach. +""" + +import os +import sys +import time + +from rhosocial.activerecord.backend.impl.clickhouse.backend import ClickHouseBackend +from rhosocial.activerecord.backend.impl.clickhouse.config import ClickHouseConnectionConfig + +DEMO_TABLE = "introspection_partitions_demo" + + +def build_config() -> ClickHouseConnectionConfig: + """Connection settings from the environment variables the tests use.""" + return ClickHouseConnectionConfig( + host=os.getenv("CLICKHOUSE_HOST", "localhost"), + port=int(os.getenv("CLICKHOUSE_PORT", "8123")), + database=os.getenv("CLICKHOUSE_DATABASE", "test_db"), + username=os.getenv("CLICKHOUSE_USER", "root"), + password=os.getenv("CLICKHOUSE_PASSWORD", "password"), + ) + + +def connect(config: ClickHouseConnectionConfig) -> ClickHouseBackend: + """Connect, or explain how to reach a server and exit non-zero.""" + backend = ClickHouseBackend(connection_config=config) + try: + backend.connect() + except Exception as error: # noqa: BLE001 - any connection failure is the same answer here + print(f"Could not connect to ClickHouse at {config.host}:{config.port}: {error}", file=sys.stderr) + print("", file=sys.stderr) + print("Start a server and set the environment variables the tests use:", file=sys.stderr) + print(" CLICKHOUSE_HOST=127.0.0.1", file=sys.stderr) + print(" CLICKHOUSE_PORT=8123", file=sys.stderr) + print(" CLICKHOUSE_DATABASE=test_db", file=sys.stderr) + print(" CLICKHOUSE_USER=root", file=sys.stderr) + print(" CLICKHOUSE_PASSWORD=password", file=sys.stderr) + sys.exit(1) + return backend + + +def ensure_demo_table(backend: ClickHouseBackend) -> None: + """Create the demo table if needed and give it two partitions' worth of rows. + + The rows are inserted only when the table is empty, so running the example + twice does not keep growing it. Two different months are used because the + table is partitioned by month: one partition per month makes the grouped + read visible. + """ + backend.execute( + f""" + CREATE TABLE IF NOT EXISTS {DEMO_TABLE} ( + id UInt32, + event_date Date, + label String + ) ENGINE = MergeTree() + PARTITION BY toYYYYMM(event_date) + ORDER BY id + """ + ) + row = backend.execute(f"SELECT count() AS row_count FROM {DEMO_TABLE}").data[0] + if row["row_count"]: + print(f"Table {DEMO_TABLE} already holds {row['row_count']} rows; leaving them as they are.") + return + backend.execute( + f""" + INSERT INTO {DEMO_TABLE} (id, event_date, label) VALUES + (1, toDate('2026-01-05'), 'alpha'), + (2, toDate('2026-01-19'), 'beta'), + (3, toDate('2026-02-02'), 'gamma') + """ + ) + print(f"Created {DEMO_TABLE} and inserted 3 rows across two monthly partitions.") + + +class QuestionCounter: + """Executor stand-in that counts the statements it is asked to run. + + The introspector talks to the server through its executor, so counting here + counts exactly the questions introspection sends -- no logging, no + server-side metric that other queries could disturb. + """ + + def __init__(self, inner): + self._inner = inner + self.questions = 0 + + def execute(self, sql, params=()): + self.questions += 1 + return self._inner.execute(sql, params) + + +def timed(read): + """Run *read* and return ``(value, seconds)``.""" + started = time.perf_counter() + value = read() + return value, time.perf_counter() - started + + +def print_partitions(partitions) -> None: + print("") + print(f" {'partition_id':<14} {'partition':<12} {'parts':>6} {'rows':>6} {'bytes':>10} engine") + for item in partitions: + print( + f" {item.partition_id:<14} {str(item.partition):<12} " + f"{item.active_part_count:>6} {item.row_count:>6} {item.bytes_on_disk:>10} {item.engine}" + ) + print( + f" {'':<14} modified {item.min_modification_time} .. {item.max_modification_time}" + ) + + +def print_engine(info) -> None: + print("") + print(f" engine : {info.engine}") + print(f" sorting_key : {info.sorting_key!r}") + print(f" primary_key : {info.primary_key!r}") + print(f" partition_key : {info.partition_key!r}") + print(f" sampling_key : {info.sampling_key!r}") + print(f" total_rows : {info.total_rows}") + print(f" total_bytes : {info.total_bytes}") + print(f" comment : {info.comment!r}") + + +def main() -> None: + config = build_config() + backend = connect(config) + try: + print(f"Connected to {config.host}:{config.port}, database {config.database}") + ensure_demo_table(backend) + + introspector = backend.introspector + counter = QuestionCounter(introspector._executor) + introspector._executor = counter + + # --- cold: the cache is empty, so both reads must ask the server --- + introspector.clear_cache() + cold_start = counter.questions + partitions, partitions_cold_seconds = timed(lambda: introspector.list_partitions(DEMO_TABLE)) + engine, engine_cold_seconds = timed(lambda: introspector.get_table_engine(DEMO_TABLE)) + cold_questions = counter.questions - cold_start + + print("") + print("Partitions (cold read):") + print_partitions(partitions) + print("") + print("Table engine (cold read):") + print_engine(engine) + + # --- warm: same two calls, answered from the cache --- + warm_start = counter.questions + warm_partitions, partitions_warm_seconds = timed(lambda: introspector.list_partitions(DEMO_TABLE)) + warm_engine, engine_warm_seconds = timed(lambda: introspector.get_table_engine(DEMO_TABLE)) + warm_questions = counter.questions - warm_start + + # The cached answers are the same objects, not re-parsed copies. + assert warm_partitions is partitions + assert warm_engine is engine + + print("") + print("Cache:") + print( + f" cold : {cold_questions} question(s) to the server, " + f"{partitions_cold_seconds + engine_cold_seconds:.4f}s total" + ) + print(f" list_partitions {partitions_cold_seconds:.4f}s, " + f"get_table_engine {engine_cold_seconds:.4f}s") + print( + f" warm : {warm_questions} question(s) to the server, " + f"{partitions_warm_seconds + engine_warm_seconds:.4f}s total" + ) + print(f" list_partitions {partitions_warm_seconds:.4f}s, " + f"get_table_engine {engine_warm_seconds:.4f}s") + print("") + print("The warm reads send nothing because each answer was cached under") + print("IntrospectionScope.TABLE with extra='partition' / extra='engine'.") + finally: + backend.execute(f"DROP TABLE IF EXISTS {DEMO_TABLE}") + backend.disconnect() + + +if __name__ == "__main__": + main() diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/named_expressions/order_expressions.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/named_expressions/order_expressions.py index 2d39aa6..fc3d45f 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/named_expressions/order_expressions.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/named_expressions/order_expressions.py @@ -24,7 +24,7 @@ from rhosocial.activerecord.backend.expression import ( # noqa: E402 Column, Literal, - QueryExpression, + QueryStatement, ) from rhosocial.activerecord.backend.expression.predicates import ( # noqa: E402 ComparisonPredicate, @@ -33,7 +33,7 @@ def get_order(dialect, order_id: int): """Get order details by ID.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "status"), Column(dialect, "user_id")], from_=NamedRelationRef(dialect, Table(dialect, "orders")), @@ -43,7 +43,7 @@ def get_order(dialect, order_id: int): def check_inventory(dialect, order_id: int): """Check available inventory for an order.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "available")], from_=NamedRelationRef(dialect, Table(dialect, "inventory")), @@ -55,7 +55,7 @@ def check_inventory(dialect, order_id: int): def reserve_inventory(dialect, order_id: int): """Reserve inventory for an order.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "available")], from_=NamedRelationRef(dialect, Table(dialect, "inventory")), @@ -67,7 +67,7 @@ def reserve_inventory(dialect, order_id: int): def send_notification(dialect, user_id: int, type: str): """Send notification to a user.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id")], from_=NamedRelationRef(dialect, Table(dialect, "notifications")), @@ -79,7 +79,7 @@ def send_notification(dialect, user_id: int, type: str): def process_payment(dialect, order_id: int, amount: float): """Process payment for an order.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "status"), Column(dialect, "transaction_id")], from_=NamedRelationRef(dialect, Table(dialect, "payments")), @@ -91,7 +91,7 @@ def process_payment(dialect, order_id: int, amount: float): def release_inventory(dialect, order_id: int): """Release reserved inventory.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id")], from_=NamedRelationRef(dialect, Table(dialect, "inventory")), @@ -103,7 +103,7 @@ def release_inventory(dialect, order_id: int): def create_order_record(dialect, order_id: int, user_id: int, amount: float): """Create an order record.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "created_at")], from_=NamedRelationRef(dialect, Table(dialect, "order_records")), @@ -115,7 +115,7 @@ def create_order_record(dialect, order_id: int, user_id: int, amount: float): def confirm_inventory(dialect, order_id: int): """Confirm inventory (final confirmation).""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id")], from_=NamedRelationRef(dialect, Table(dialect, "inventory")), diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/named_migrations/expressions.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/named_migrations/expressions.py index 22f1213..c6e6921 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/named_migrations/expressions.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/named_migrations/expressions.py @@ -9,11 +9,11 @@ from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableExpression, + CreateTableStatement, ColumnDefinition, ColumnConstraint, - ColumnConstraintType, - DropTableExpression, + ColumnPrimaryKeyConstraint, + DropTableStatement, ) from rhosocial.activerecord.backend.impl.clickhouse.expression.types import ( ClickHouseUInt32Type, @@ -23,7 +23,7 @@ def create_users_table(dialect): """CREATE TABLE users (id UInt32 PRIMARY KEY, name String, email String).""" - return CreateTableExpression( + return CreateTableStatement( dialect, table=Table(dialect, "users"), columns=[ @@ -32,9 +32,8 @@ def create_users_table(dialect): "id", ClickHouseUInt32Type(dialect), constraints=[ - ColumnConstraint( - ColumnConstraintType.PRIMARY_KEY, - ), + ColumnConstraint(dialect, + branch=ColumnPrimaryKeyConstraint(dialect)), ], ), ColumnDefinition(dialect, "name", ClickHouseStringType(dialect)), @@ -45,12 +44,12 @@ def create_users_table(dialect): def drop_users_table(dialect): """DROP TABLE IF EXISTS users.""" - return DropTableExpression(dialect, table=Table(dialect, "users"), if_exists=True) + return DropTableStatement(dialect, table=Table(dialect, "users"), if_exists=True) def create_posts_table(dialect): """CREATE TABLE posts (id UInt32 PRIMARY KEY, title String, user_id UInt32).""" - return CreateTableExpression( + return CreateTableStatement( dialect, table=Table(dialect, "posts"), columns=[ @@ -59,9 +58,8 @@ def create_posts_table(dialect): "id", ClickHouseUInt32Type(dialect), constraints=[ - ColumnConstraint( - ColumnConstraintType.PRIMARY_KEY, - ), + ColumnConstraint(dialect, + branch=ColumnPrimaryKeyConstraint(dialect)), ], ), ColumnDefinition(dialect, "title", ClickHouseStringType(dialect)), @@ -72,7 +70,7 @@ def create_posts_table(dialect): def drop_posts_table(dialect): """DROP TABLE IF EXISTS posts.""" - return DropTableExpression(dialect, table=Table(dialect, "posts"), if_exists=True) + return DropTableStatement(dialect, table=Table(dialect, "posts"), if_exists=True) def create_custom_table(dialect, table: str = "custom_table"): @@ -83,7 +81,7 @@ def create_custom_table(dialect, table: str = "custom_table"): the migration's to choose, so the object is composed here rather than passed in. """ - return CreateTableExpression( + return CreateTableStatement( dialect, table=Table(dialect, table), columns=[ @@ -92,9 +90,8 @@ def create_custom_table(dialect, table: str = "custom_table"): "id", ClickHouseUInt32Type(dialect), constraints=[ - ColumnConstraint( - ColumnConstraintType.PRIMARY_KEY, - ), + ColumnConstraint(dialect, + branch=ColumnPrimaryKeyConstraint(dialect)), ], ), ColumnDefinition(dialect, "value", ClickHouseStringType(dialect)), @@ -104,4 +101,4 @@ def create_custom_table(dialect, table: str = "custom_table"): def drop_custom_table(dialect, table: str = "custom_table"): """DROP TABLE IF EXISTS .""" - return DropTableExpression(dialect, table=Table(dialect, table), if_exists=True) \ No newline at end of file + return DropTableStatement(dialect, table=Table(dialect, table), if_exists=True) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/aggregate.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/aggregate.py index 66d180c..8b330dd 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/aggregate.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/aggregate.py @@ -26,23 +26,23 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ColumnNotNullConstraint, ) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "sales"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "sales"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "sales"), columns=[ @@ -50,8 +50,8 @@ "id", "UInt32", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition("product", "String"), @@ -64,7 +64,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "sales"), columns=["product", "quantity", "price", "region"], @@ -86,19 +86,19 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, Column, GroupByHavingClause, ) -from rhosocial.activerecord.backend.expression.core import FunctionCall # noqa: E402 +from rhosocial.activerecord.backend.expression import functions as F # noqa: E402 from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "product"), - FunctionCall(dialect, "SUM", Column(dialect, "quantity")).as_("total_qty"), - FunctionCall(dialect, "AVG", Column(dialect, "price")).as_("avg_price"), + F.sum_(dialect, Column(dialect, "quantity"), alias="total_qty"), + F.avg(dialect, Column(dialect, "price"), alias="avg_price"), ], from_=NamedRelationRef(dialect, Table(dialect, "sales")), group_by_having=GroupByHavingClause( @@ -107,7 +107,7 @@ having=ComparisonPredicate( dialect, ">", - FunctionCall(dialect, "SUM", Column(dialect, "quantity")), + F.sum_(dialect, Column(dialect, "quantity")), Literal(dialect, 10), ), ), diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/basic.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/basic.py index 0bd995f..6775fad 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/basic.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/basic.py @@ -26,27 +26,27 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ColumnNotNullConstraint, ) -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -54,14 +54,14 @@ "id", "UInt32", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition( "name", "String", - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], ), ColumnDefinition("age", "UInt32"), ColumnDefinition("status", "String"), @@ -72,7 +72,7 @@ print(f"Create table SQL: {sql}, params: {params}") backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["name", "age", "status"], @@ -92,7 +92,7 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, Column, WhereClause, OrderByClause, @@ -100,7 +100,7 @@ ) from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/constants.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/constants.py index 7c8e899..0d154e2 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/constants.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/constants.py @@ -34,22 +34,17 @@ # SECTION: SELECT CURRENT_TIMESTAMP # ============================================================ # Use factory functions for SQL statement-level constants. -# current_timestamp(dialect) generates CURRENT_TIMESTAMP (no parentheses) +# F.current_timestamp(dialect) generates CURRENT_TIMESTAMP (no parentheses) # which is the SQL:2003 standard niladic form, valid in both DDL DEFAULT # and SELECT contexts. ClickHouse also accepts CURRENT_TIMESTAMP() with parentheses. -from rhosocial.activerecord.backend.expression import QueryExpression # noqa: E402 +from rhosocial.activerecord.backend.expression import QueryStatement # noqa: E402 from rhosocial.activerecord.backend.expression.core import FunctionCall # noqa: E402 -from rhosocial.activerecord.backend.expression.functions.datetime import ( # noqa: E402 - current_timestamp, - now, - current_date, - current_time, -) +from rhosocial.activerecord.backend.expression import functions as F # noqa: E402 -query_ts = QueryExpression( +query_ts = QueryStatement( dialect=dialect, - select=[current_timestamp(dialect)], + select=[F.current_timestamp(dialect)], ) sql, params = query_ts.to_sql() print(f"CURRENT_TIMESTAMP SQL: {sql}") @@ -59,9 +54,9 @@ # ============================================================ # SECTION: SELECT NOW() # ============================================================ -query_now = QueryExpression( +query_now = QueryStatement( dialect=dialect, - select=[now(dialect)], + select=[F.now(dialect)], ) sql, params = query_now.to_sql() print(f"NOW() SQL: {sql}") @@ -71,11 +66,11 @@ # ============================================================ # SECTION: SELECT CURRENT_DATE, CURRENT_TIME # ============================================================ -query_date = QueryExpression( +query_date = QueryStatement( dialect=dialect, select=[ - current_date(dialect), - current_time(dialect), + F.current_date(dialect), + F.current_time(dialect), ], ) sql, params = query_date.to_sql() @@ -88,11 +83,11 @@ # ============================================================ from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 -query_multi = QueryExpression( +query_multi = QueryStatement( dialect=dialect, select=[ - now(dialect).as_("current_time"), - current_date(dialect).as_("current_date"), + F.now(dialect).as_("current_time"), + F.current_date(dialect).as_("current_date"), FunctionCall(dialect, "DATABASE").as_("db_name"), FunctionCall(dialect, "VERSION").as_("db_version"), ], @@ -107,10 +102,10 @@ # ============================================================ # FunctionCall supports arguments, e.g., formatDateTime, round -query_format = QueryExpression( +query_format = QueryStatement( dialect=dialect, select=[ - FunctionCall(dialect, "formatDateTime", now(dialect), Literal(dialect, "%Y-%m-%d")).as_("formatted_date"), + FunctionCall(dialect, "formatDateTime", F.now(dialect), Literal(dialect, "%Y-%m-%d")).as_("formatted_date"), ], ) sql, params = query_format.to_sql() @@ -121,7 +116,7 @@ # ============================================================ # SECTION: SELECT with generateUUIDv4() (ClickHouse) # ============================================================ -query_uuid = QueryExpression( +query_uuid = QueryStatement( dialect=dialect, select=[FunctionCall(dialect, "generateUUIDv4").as_("uuid_value")], ) @@ -140,7 +135,7 @@ # ============================================================ # Key points: # 1. Use factory functions (current_timestamp, now, current_date, etc.) for SQL niladic functions -# 2. QueryExpression without from_ clause generates "SELECT ..." without FROM +# 2. QueryStatement without from_ clause generates "SELECT ..." without FROM # 3. Use FunctionCall(dialect, 'FUNC', arg1, arg2) for regular functions with arguments # 4. Use .as_('alias') for column aliases in SELECT # 5. SQL:2003 niladic functions (CURRENT_TIMESTAMP, CURRENT_DATE, etc.) omit parentheses diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/cte.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/cte.py index ebc03d8..a7dd6d2 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/cte.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/cte.py @@ -47,20 +47,20 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, - QueryExpression, - CTEExpression, - WithQueryExpression, + DropTableStatement, + QueryStatement, + CTEClause, + WithStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.options import ExecutionOptions # noqa: E402 from rhosocial.activerecord.backend.schema import StatementType # noqa: E402 @@ -68,11 +68,11 @@ dql_options = ExecutionOptions(stmt_type=StatementType.DQL) # Drop table first for clean setup -drop = DropTableExpression(dialect=dialect, table=Table(dialect, "employees"), if_exists=True) +drop = DropTableStatement(dialect=dialect, table=Table(dialect, "employees"), if_exists=True) sql, params = drop.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "employees"), columns=[ @@ -80,7 +80,7 @@ "id", "UInt32", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), ], ), ColumnDefinition("name", "String"), @@ -94,7 +94,7 @@ delete_sql = "DELETE FROM employees" backend.execute(delete_sql) -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "employees"), columns=["id", "name", "manager_id"], @@ -117,10 +117,10 @@ # ============================================================ # CTE simplifies complex queries by defining temporary named result sets -high_earners_cte = CTEExpression( +high_earners_cte = CTEClause( dialect=dialect, name="high_earners", - query=QueryExpression( + query=QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=NamedRelationRef(dialect, Table(dialect, "employees")), @@ -128,10 +128,10 @@ ), ) -cte_query = WithQueryExpression( +cte_query = WithStatement( dialect=dialect, ctes=[high_earners_cte], - main_query=QueryExpression( + query=QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=NamedRelationRef(dialect, Table(dialect, "high_earners")), @@ -148,7 +148,7 @@ # Recursive CTE for hierarchical data (organizational chart) # Base case: top-level employees -base_query = QueryExpression( +base_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -160,17 +160,17 @@ where=ComparisonPredicate(dialect, "IS", Column(dialect, "manager_id"), Literal(dialect, None)), ) -org_cte = CTEExpression( +org_cte = CTEClause( dialect=dialect, name="org_chart", query=base_query, columns=["id", "name", "manager_id", "level"], ) -recursive_query = WithQueryExpression( +recursive_query = WithStatement( dialect=dialect, ctes=[org_cte], - main_query=QueryExpression( + query=QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "manager_id")], from_=NamedRelationRef(dialect, Table(dialect, "org_chart")), @@ -189,10 +189,10 @@ # ============================================================ # Multiple CTEs can be defined in a single WITH clause -active_cte = CTEExpression( +active_cte = CTEClause( dialect=dialect, name="active_employees", - query=QueryExpression( + query=QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=NamedRelationRef(dialect, Table(dialect, "employees")), @@ -200,10 +200,10 @@ ), ) -top_cte = CTEExpression( +top_cte = CTEClause( dialect=dialect, name="top_employees", - query=QueryExpression( + query=QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=NamedRelationRef(dialect, Table(dialect, "active_employees")), @@ -211,10 +211,10 @@ ), ) -multi_cte_query = WithQueryExpression( +multi_cte_query = WithStatement( dialect=dialect, ctes=[active_cte, top_cte], - main_query=QueryExpression( + query=QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=NamedRelationRef(dialect, Table(dialect, "top_employees")), @@ -228,7 +228,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "employees"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "employees"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) backend.disconnect() @@ -240,7 +240,7 @@ # 1. Plain `WITH name AS (subquery)` has no ClickHouse version floor; the # recursive form needs the query analyzer (default since 24.3, mandatory # since 26.9) — see https://clickhouse.com/docs/reference/statements/select/with -# 2. Use CTEExpression to define CTEs -# 3. Use WithQueryExpression to combine CTEs with a main query +# 2. Use CTEClause to define CTEs +# 3. Use WithStatement to combine CTEs with a main query # 4. Set recursive=True for recursive CTEs (hierarchical queries) -# 5. Multiple CTEs can be defined in a single WithQueryExpression +# 5. Multiple CTEs can be defined in a single WithStatement diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/distinct.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/distinct.py index 8a53d74..2426397 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/distinct.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/distinct.py @@ -30,10 +30,10 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 @@ -41,15 +41,15 @@ ) # Drop dependent tables first for clean setup -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -62,7 +62,7 @@ print(f"Create table SQL: {sql}") backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["id", "name"], @@ -83,11 +83,11 @@ # SECTION: SELECT DISTINCT # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, SelectModifier, ) -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[Column(dialect, "name")], from_=NamedRelationRef(dialect, Table(dialect, "users")), @@ -104,7 +104,7 @@ # ============================================================ # SECTION: SELECT DISTINCT with multiple columns # ============================================================ -query_distinct = QueryExpression( +query_distinct = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=NamedRelationRef(dialect, Table(dialect, "users")), @@ -118,11 +118,11 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_expr = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params) backend.disconnect() @@ -131,6 +131,6 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use SelectModifier.DISTINCT in QueryExpression +# 1. Use SelectModifier.DISTINCT in QueryStatement # 2. SELECT DISTINCT removes duplicate rows # 3. Works with multiple columns (distinct combo) diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/explain.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/explain.py index 90811bc..f4107bf 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/explain.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/explain.py @@ -16,19 +16,19 @@ from rhosocial.activerecord.backend.impl.clickhouse.backend import ClickHouseBackend from rhosocial.activerecord.backend.impl.clickhouse.config import ClickHouseConnectionConfig from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - QueryExpression, - ExplainExpression, - CreateIndexExpression, - DropTableExpression, + QueryStatement, + ExplainStatement, + CreateIndexStatement, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ColumnNotNullConstraint, ) from rhosocial.activerecord.backend.expression.statements.explain import ExplainOptions from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate @@ -44,11 +44,11 @@ backend.connect() dialect = backend.dialect -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -56,15 +56,15 @@ "id", "UInt32", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition( "name", "String", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition("email", "String"), @@ -75,7 +75,7 @@ backend.execute(sql, params) # Create a skip index on email column -create_index = CreateIndexExpression( +create_index = CreateIndexStatement( dialect=dialect, index=Index(dialect, "idx_users_email"), table=Table(dialect, "users"), @@ -90,7 +90,7 @@ ("Charlie", "charlie@example.com"), ] for name, email in users: - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["name", "email"], @@ -104,7 +104,7 @@ # ============================================================ # 1. EXPLAIN for a simple query -query1 = QueryExpression( +query1 = QueryStatement( dialect=dialect, select=[Column(dialect, "*")], from_=NamedRelationRef(dialect, Table(dialect, "users")), @@ -115,7 +115,7 @@ Literal(dialect, "Alice"), ), ) -explain_scan = ExplainExpression( +explain_scan = ExplainStatement( dialect=dialect, statement=query1, options=ExplainOptions(), @@ -128,7 +128,7 @@ print(f" {row}") # 2. EXPLAIN for another query -query2 = QueryExpression( +query2 = QueryStatement( dialect=dialect, select=[Column(dialect, "*")], from_=NamedRelationRef(dialect, Table(dialect, "users")), @@ -139,7 +139,7 @@ Literal(dialect, "alice@example.com"), ), ) -explain_search = ExplainExpression( +explain_search = ExplainStatement( dialect=dialect, statement=query2, options=ExplainOptions(), @@ -152,7 +152,7 @@ print(f" {row}") # 3. EXPLAIN with FORMAT=JSON for detailed analysis -explain_json = ExplainExpression( +explain_json = ExplainStatement( dialect=dialect, statement=query2, options=ExplainOptions(format="JSON"), @@ -167,7 +167,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/join.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/join.py index a56f663..1015111 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/join.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/join.py @@ -24,27 +24,27 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ColumnNotNullConstraint, ) -drop_customers = DropTableExpression(dialect=dialect, table=Table(dialect, "customers"), if_exists=True) +drop_customers = DropTableStatement(dialect=dialect, table=Table(dialect, "customers"), if_exists=True) sql, params = drop_customers.to_sql() backend.execute(sql, params) -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -create_customers = CreateTableExpression( +create_customers = CreateTableStatement( dialect=dialect, table=Table(dialect, "customers"), columns=[ @@ -52,14 +52,14 @@ "id", "UInt32", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition( "name", "String", - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], ), ColumnDefinition("email", "String"), ], @@ -68,7 +68,7 @@ sql, params = create_customers.to_sql() backend.execute(sql, params) -create_orders = CreateTableExpression( +create_orders = CreateTableStatement( dialect=dialect, table=Table(dialect, "orders"), columns=[ @@ -76,8 +76,8 @@ "id", "UInt32", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition("customer_id", "UInt32"), @@ -89,7 +89,7 @@ sql, params = create_orders.to_sql() backend.execute(sql, params) -insert_customers = InsertExpression( +insert_customers = InsertStatement( dialect=dialect, into=Table(dialect, "customers"), columns=["name", "email"], @@ -104,7 +104,7 @@ sql, params = insert_customers.to_sql() backend.execute(sql, params) -insert_orders = InsertExpression( +insert_orders = InsertStatement( dialect=dialect, into=Table(dialect, "orders"), columns=["customer_id", "total", "status"], @@ -124,7 +124,7 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, Column, WhereClause, ) @@ -135,7 +135,7 @@ customers = NamedRelationRef(dialect, Table(dialect, "customers"), alias="c") orders = NamedRelationRef(dialect, Table(dialect, "orders"), alias="o") -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "name", table="c"), diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/pagination.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/pagination.py index a98a53f..7c8c6b0 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/pagination.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/pagination.py @@ -31,10 +31,10 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 @@ -42,11 +42,11 @@ ) # Drop table first for clean setup -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -59,7 +59,7 @@ print(f"Create table SQL: {sql}") backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["id", "name"], @@ -82,11 +82,11 @@ # SECTION: LIMIT (get first N rows) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, LimitOffsetClause, ) -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=NamedRelationRef(dialect, Table(dialect, "users")), @@ -103,7 +103,7 @@ # ============================================================ # SECTION: OFFSET (skip first N rows) # ============================================================ -query_offset = QueryExpression( +query_offset = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=NamedRelationRef(dialect, Table(dialect, "users")), @@ -117,7 +117,7 @@ # ============================================================ # SECTION: Teardown # ============================================================ -drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_expr = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params) backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/predicate.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/predicate.py index 075b69d..6da4213 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/predicate.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/predicate.py @@ -26,23 +26,23 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ColumnNotNullConstraint, ) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "employees"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "employees"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "employees"), columns=[ @@ -50,14 +50,14 @@ "id", "UInt32", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition( "name", "String", - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], ), ColumnDefinition("department", "String"), ColumnDefinition("salary", "Decimal(10, 2)"), @@ -68,7 +68,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "employees"), columns=["name", "department", "salary", "manager_id"], @@ -96,10 +96,11 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, WhereClause, ) from rhosocial.activerecord.backend.expression.predicates import ( # noqa: E402 + LikeOperator, ComparisonPredicate, LikePredicate, InPredicate, @@ -110,7 +111,7 @@ # 1. LIKE predicate - pattern matching # Find employees whose name starts with 'A' -like_query = QueryExpression( +like_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -122,7 +123,7 @@ dialect, condition=LikePredicate( dialect, - "LIKE", + LikeOperator.LIKE, Column(dialect, "name"), Literal(dialect, "A%"), ), @@ -135,7 +136,7 @@ # 2. IN predicate - list membership # Find employees in Engineering or Sales departments -in_query = QueryExpression( +in_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -159,7 +160,7 @@ # 3. BETWEEN predicate - range check # Find employees with salary between 65000 and 75000 -between_query = QueryExpression( +between_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -184,7 +185,7 @@ # 4. IS NULL / IS NOT NULL predicates # Find employees without a manager (manager_id IS NULL) -is_null_query = QueryExpression( +is_null_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -206,7 +207,7 @@ print(f"IS NULL Params: {params}") # Find employees with a manager (manager_id IS NOT NULL) -is_not_null_query = QueryExpression( +is_not_null_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -230,7 +231,7 @@ # 5. Combining predicates with AND/OR logic # Engineering employees earning between 75000 and 100000 -combined_query = QueryExpression( +combined_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/subquery.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/subquery.py index 416d1a1..d4c7fa8 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/subquery.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/subquery.py @@ -26,27 +26,27 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ColumnNotNullConstraint, ) -drop_departments = DropTableExpression(dialect=dialect, table=Table(dialect, "departments"), if_exists=True) +drop_departments = DropTableStatement(dialect=dialect, table=Table(dialect, "departments"), if_exists=True) sql, params = drop_departments.to_sql() backend.execute(sql, params) -drop_employees = DropTableExpression(dialect=dialect, table=Table(dialect, "employees"), if_exists=True) +drop_employees = DropTableStatement(dialect=dialect, table=Table(dialect, "employees"), if_exists=True) sql, params = drop_employees.to_sql() backend.execute(sql, params) -create_departments = CreateTableExpression( +create_departments = CreateTableStatement( dialect=dialect, table=Table(dialect, "departments"), columns=[ @@ -54,8 +54,8 @@ "id", "UInt32", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition("name", "String"), @@ -66,7 +66,7 @@ sql, params = create_departments.to_sql() backend.execute(sql, params) -create_employees = CreateTableExpression( +create_employees = CreateTableStatement( dialect=dialect, table=Table(dialect, "employees"), columns=[ @@ -74,8 +74,8 @@ "id", "UInt32", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition("name", "String"), @@ -87,7 +87,7 @@ sql, params = create_employees.to_sql() backend.execute(sql, params) -insert_departments = InsertExpression( +insert_departments = InsertStatement( dialect=dialect, into=Table(dialect, "departments"), columns=["name", "budget"], @@ -102,7 +102,7 @@ sql, params = insert_departments.to_sql() backend.execute(sql, params) -insert_employees = InsertExpression( +insert_employees = InsertStatement( dialect=dialect, into=Table(dialect, "employees"), columns=["name", "salary", "department_id"], @@ -123,23 +123,23 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, Column, WhereClause, Subquery, ) -from rhosocial.activerecord.backend.expression.core import FunctionCall # noqa: E402 +from rhosocial.activerecord.backend.expression import functions as F # noqa: E402 from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 -subquery_query = QueryExpression( +subquery_query = QueryStatement( dialect=dialect, - select=[FunctionCall(dialect, "AVG", Column(dialect, "salary"))], + select=[F.avg(dialect, Column(dialect, "salary"))], from_=NamedRelationRef(dialect, Table(dialect, "employees")), ) sql, params = subquery_query.to_sql() subquery = Subquery(dialect, sql, params) -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "name"), diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/union.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/union.py index ebd529e..0a1bbfe 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/union.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/union.py @@ -1,10 +1,9 @@ """ -UNION using SetOperationExpression - ClickHouse. +UNION using SetOperation - ClickHouse. This example demonstrates: 1. UNION (distinct) 2. UNION ALL -3. UNION with ORDER BY """ @@ -31,24 +30,24 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ) # Drop table first for clean setup -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -56,7 +55,7 @@ "id", "UInt32", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), ], ), ColumnDefinition("name", "String"), @@ -68,7 +67,7 @@ backend.execute(sql, params) # Insert data into table_a -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["id", "name"], @@ -88,27 +87,27 @@ # SECTION: UNION (distinct) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, - SetOperationExpression, + QueryStatement, + SetOperation, ) # First query -query1 = QueryExpression( +query1 = QueryStatement( dialect=dialect, select=[Column(dialect, "name")], from_=NamedRelationRef(dialect, Table(dialect, "users")), ) -query2 = QueryExpression( +query2 = QueryStatement( dialect=dialect, select=[Literal(dialect, "Charlie")], ) # Union -union_expr = SetOperationExpression( +union_expr = SetOperation( dialect=dialect, left=query1, - operation="UNION", + op="union", right=query2, ) sql, params = union_expr.to_sql() @@ -122,11 +121,10 @@ # ============================================================ # SECTION: UNION ALL # ============================================================ -union_all = SetOperationExpression( +union_all = SetOperation( dialect=dialect, left=query1, - operation="UNION", - all_=True, + op="union_all", right=query2, ) sql, params = union_all.to_sql() @@ -137,7 +135,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_expr = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params) backend.disconnect() @@ -146,6 +144,9 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use SetOperationExpression with operation='UNION', all_=True for UNION ALL +# 1. Use SetOperation with op='union' for UNION DISTINCT, op='union_all' for UNION ALL # 2. UNION removes duplicates, UNION ALL keeps all rows -# 3. Can add ORDER BY using query1.with_order_by() +# 3. ClickHouse's grammar has no bare UNION: the dialect always spells the +# ALL/DISTINCT modifier, so op='union' renders 'UNION DISTINCT' +# 4. Ordering and row limiting of a compound query are statement-level +# clauses, not part of the SetOperation node diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/window.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/window.py index 81f4774..216abc0 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/window.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/query/window.py @@ -26,23 +26,23 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ColumnNotNullConstraint, ) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "sales_data"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "sales_data"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "sales_data"), columns=[ @@ -50,8 +50,8 @@ "id", "UInt32", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition("salesperson", "String"), @@ -63,7 +63,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "sales_data"), columns=["salesperson", "region", "amount"], @@ -85,14 +85,14 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, Column, WindowSpecification, OrderByClause, ) from rhosocial.activerecord.backend.expression.core import FunctionCall # noqa: E402 -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "salesperson"), diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/schema_diff/clickhouse_schema_diff.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/schema_diff/clickhouse_schema_diff.py index 3df9f8e..67aeac6 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/schema_diff/clickhouse_schema_diff.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/schema_diff/clickhouse_schema_diff.py @@ -36,12 +36,12 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - DropTableExpression, - CreateTableExpression, + DropTableStatement, + CreateTableStatement, ColumnDefinition, ColumnConstraint, - ColumnConstraintType, - InsertExpression, + ColumnPrimaryKeyConstraint, ColumnNotNullConstraint, ColumnDefaultConstraint, + InsertStatement, ValuesSource, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 @@ -53,18 +53,18 @@ ClickHouseStringType, ) -expr = DropTableExpression(dialect, "events", if_exists=True) +expr = DropTableStatement(dialect, "events", if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) -expr = CreateTableExpression( +expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "events"), columns=[ ColumnDefinition(dialect, "id", ClickHouseUInt32Type(dialect), constraints=[ - ColumnConstraint(constraint_type=ColumnConstraintType.NOT_NULL), - ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), ]), ColumnDefinition(dialect, "ts", ClickHouseDateTimeType(dialect)), ColumnDefinition(dialect, "message", ClickHouseStringType(dialect)), @@ -75,7 +75,7 @@ sql, params = expr.to_sql() backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "events"), columns=["id", "message", "value"], @@ -100,37 +100,37 @@ ClickHouseSchemaDiffer, ) from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( # noqa: E402 - AlterTableExpression, - AddColumn, - ModifyColumn, + AlterTableStatement, + AddColumnAction, + ModifyColumnAction, ) builder = SyncSchemaSnapshotBuilder(backend.introspector, dialect) snapshot_before = builder.build() # Add a new column with a default value -add_status = AddColumn( +add_status = AddColumnAction( dialect, ColumnDefinition( dialect, "status", ClickHouseStringType(dialect), constraints=[ - ColumnConstraint(constraint_type=ColumnConstraintType.DEFAULT, default_value=Literal(dialect, "new")), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, Literal(dialect, "new"))), ], ), ) -alter_expr = AlterTableExpression(dialect, "events", [add_status]) +alter_expr = AlterTableStatement(dialect, "events", [add_status]) sql, params = alter_expr.to_sql() print(f"ALTER (add column): {sql}") backend.execute(sql, params) # Modify an existing column's data type: Decimal(10, 2) -> Float64 -modify_value = ModifyColumn( +modify_value = ModifyColumnAction( dialect, ColumnDefinition(dialect, "value", ClickHouseFloat64Type(dialect)), ) -alter_expr = AlterTableExpression(dialect, "events", [modify_value]) +alter_expr = AlterTableStatement(dialect, "events", [modify_value]) sql, params = alter_expr.to_sql() print(f"ALTER (modify type): {sql}") backend.execute(sql, params) @@ -158,7 +158,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -expr = DropTableExpression(dialect, "events", if_exists=True) +expr = DropTableStatement(dialect, "events", if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) backend.disconnect() \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/schema_diff/column_order.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/schema_diff/column_order.py index c77dd55..333d6fa 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/schema_diff/column_order.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/schema_diff/column_order.py @@ -33,22 +33,22 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - DropTableExpression, CreateTableExpression, ColumnDefinition, - ColumnConstraint, ColumnConstraintType, + DropTableStatement, CreateTableStatement, ColumnDefinition, + ColumnConstraint, ColumnPrimaryKeyConstraint, ColumnNotNullConstraint, ) from rhosocial.activerecord.backend.impl.clickhouse.expression.types import ( # noqa: E402 ClickHouseUInt32Type, ClickHouseStringType, ) -expr = DropTableExpression(dialect, "users", if_exists=True) +expr = DropTableStatement(dialect, "users", if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) -expr = CreateTableExpression( +expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ ColumnDefinition(dialect, "id", ClickHouseUInt32Type(dialect), constraints=[ - ColumnConstraint(constraint_type=ColumnConstraintType.NOT_NULL), - ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), ]), ColumnDefinition(dialect, "name", ClickHouseStringType(dialect)), ColumnDefinition(dialect, "email", ClickHouseStringType(dialect)), @@ -67,19 +67,19 @@ ClickHouseSchemaDiffer, ) from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( # noqa: E402 - AlterTableExpression, + AlterTableStatement, ) from rhosocial.activerecord.backend.impl.clickhouse.expression import ( # noqa: E402 - ClickHouseAddColumn, + ClickHouseAddColumnAction, ) builder = SyncSchemaSnapshotBuilder(backend.introspector, dialect) snapshot_before = builder.build() # Add `age` column between `name` and `email` — shifts email to position 4 -add_col = ClickHouseAddColumn(dialect, ColumnDefinition(dialect, "age", ClickHouseUInt32Type(dialect)), +add_col = ClickHouseAddColumnAction(dialect, ColumnDefinition(dialect, "age", ClickHouseUInt32Type(dialect)), after="name") -alter_expr = AlterTableExpression(dialect, "users", [add_col]) +alter_expr = AlterTableStatement(dialect, "users", [add_col]) sql, params = alter_expr.to_sql() backend.execute(sql, params) @@ -104,7 +104,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -expr = DropTableExpression(dialect, "users", if_exists=True) +expr = DropTableStatement(dialect, "users", if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) backend.disconnect() \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/schema_diff/table_changes.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/schema_diff/table_changes.py index ad2bd15..e4fb774 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/schema_diff/table_changes.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/schema_diff/table_changes.py @@ -26,12 +26,12 @@ # Clean up any leftover tables from rhosocial.activerecord.backend.expression import ( # noqa: E402 - DropTableExpression, + DropTableStatement, ) -expr = DropTableExpression(dialect, "users", if_exists=True) +expr = DropTableStatement(dialect, "users", if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) -expr = DropTableExpression(dialect, "orders", if_exists=True) +expr = DropTableStatement(dialect, "orders", if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) @@ -45,8 +45,8 @@ ClickHouseSchemaDiffer, ) from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, ColumnDefinition, - ColumnConstraint, ColumnConstraintType, + CreateTableStatement, ColumnDefinition, + ColumnConstraint, ColumnPrimaryKeyConstraint, ColumnNotNullConstraint, ) from rhosocial.activerecord.backend.impl.clickhouse.expression.types import ( # noqa: E402 ClickHouseUInt32Type, ClickHouseStringType, ClickHouseDecimalType, @@ -56,24 +56,24 @@ snapshot_before = builder.build() # Create one table, drop another (if it existed) -expr = CreateTableExpression( +expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ ColumnDefinition(dialect, "id", ClickHouseUInt32Type(dialect), constraints=[ - ColumnConstraint(constraint_type=ColumnConstraintType.NOT_NULL), - ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), ]), ColumnDefinition(dialect, "name", ClickHouseStringType(dialect)), ] ) sql, params = expr.to_sql() backend.execute(sql, params) -expr = CreateTableExpression( +expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "orders"), columns=[ ColumnDefinition(dialect, "id", ClickHouseUInt32Type(dialect), constraints=[ - ColumnConstraint(constraint_type=ColumnConstraintType.NOT_NULL), - ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), ]), ColumnDefinition(dialect, "user_id", ClickHouseUInt32Type(dialect)), ColumnDefinition(dialect, "amount", ClickHouseDecimalType(dialect, precision=10, scale=2)), @@ -98,10 +98,10 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -expr = DropTableExpression(dialect, "users", if_exists=True) +expr = DropTableStatement(dialect, "users", if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) -expr = DropTableExpression(dialect, "orders", if_exists=True) +expr = DropTableStatement(dialect, "orders", if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) backend.disconnect() \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/transaction/basic.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/transaction/basic.py index b4c8dbe..6cff2a5 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/transaction/basic.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/transaction/basic.py @@ -31,27 +31,27 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - DropTableExpression, - InsertExpression, + CreateTableStatement, + DropTableStatement, + InsertStatement, ValuesSource, - QueryExpression, + QueryStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ColumnNotNullConstraint, ColumnDefaultConstraint, ) from rhosocial.activerecord.backend.options import ExecutionOptions # noqa: E402 from rhosocial.activerecord.backend.schema import StatementType # noqa: E402 # Drop table first for clean setup -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "accounts"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "accounts"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "accounts"), columns=[ @@ -59,22 +59,22 @@ "id", "UInt32", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition( "name", "String", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition( "balance", "Decimal(10, 2)", constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=Literal(dialect, 0)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, Literal(dialect, 0))), ], ), ], @@ -83,7 +83,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "accounts"), columns=["name", "balance"], @@ -110,7 +110,7 @@ print("Use plain INSERT/SELECT statements instead.") # Insert another row -insert_expr2 = InsertExpression( +insert_expr2 = InsertStatement( dialect=dialect, into=Table(dialect, "accounts"), columns=["name", "balance"], @@ -125,7 +125,7 @@ backend.execute(sql, params) # Verify -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[Column(dialect, "name"), Column(dialect, "balance")], from_=NamedRelationRef(dialect, Table(dialect, "accounts")), @@ -149,7 +149,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "accounts"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "accounts"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/types/json_basic.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/types/json_basic.py index 13ac954..75cf3dc 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/types/json_basic.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/types/json_basic.py @@ -31,30 +31,30 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, - QueryExpression, + DropTableStatement, + QueryStatement, Column, ) from rhosocial.activerecord.backend.expression.core import Literal, FunctionCall # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ColumnNotNullConstraint, ) from rhosocial.activerecord.backend.options import ExecutionOptions # noqa: E402 from rhosocial.activerecord.backend.schema import StatementType # noqa: E402 dql_options = ExecutionOptions(stmt_type=StatementType.DQL) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "documents"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "documents"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) # Create table with JSON column -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "documents"), columns=[ @@ -62,8 +62,8 @@ "id", "UInt32", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition("data", "JSON"), @@ -73,7 +73,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "documents"), columns=["data"], @@ -93,7 +93,7 @@ # ============================================================ # Use JSONExtract functions (ClickHouse) instead of JSON_EXTRACT (MySQL) -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/update/basic.py b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/update/basic.py index ca46311..6568af7 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/examples/update/basic.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/examples/update/basic.py @@ -1,5 +1,5 @@ """ -UPDATE using UpdateExpression - ClickHouse. +UPDATE using UpdateStatement - ClickHouse. This example demonstrates: 1. Update single row @@ -30,27 +30,27 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, - UpdateExpression, + DropTableStatement, + UpdateStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 -from rhosocial.activerecord.backend.expression.operators import BinaryArithmeticExpression # noqa: E402 +from rhosocial.activerecord.backend.expression.operators import ArithmeticOperation # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ColumnNotNullConstraint, ) # Drop table first for clean setup -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -58,15 +58,15 @@ "id", "UInt32", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition( "name", "String", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition("age", "UInt32"), @@ -77,7 +77,7 @@ print(f"Create table SQL: {sql}") backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["name", "age"], @@ -96,7 +96,7 @@ # ============================================================ # SECTION: Update Single Row # ============================================================ -update_expr = UpdateExpression( +update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, "users"), assignments={"age": Literal(dialect, 26)}, @@ -113,11 +113,11 @@ # ============================================================ # SECTION: Update with Expression # ============================================================ -update_expr = UpdateExpression( +update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, "users"), assignments={ - "age": BinaryArithmeticExpression(dialect, "+", Column(dialect, "age"), Literal(dialect, 1)), + "age": ArithmeticOperation(dialect, "add", Column(dialect, "age"), Literal(dialect, 1)), }, where=ComparisonPredicate(dialect, "=", Column(dialect, "name"), Literal(dialect, "Alice")), ) @@ -129,7 +129,7 @@ # ============================================================ # SECTION: Update All Rows # ============================================================ -update_expr = UpdateExpression( +update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, "users"), assignments={"age": Literal(dialect, 99)}, @@ -142,7 +142,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_expr = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params) backend.disconnect() @@ -151,7 +151,7 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use UpdateExpression with assignments dict for SET clause -# 2. Use BinaryArithmeticExpression for expressions like age + 1 +# 1. Use UpdateStatement with assignments dict for SET clause +# 2. Use ArithmeticOperation for expressions like age + 1 (op="add", not "+") # 3. Omit where parameter to update all rows # 4. affected_rows shows number of updated rows diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/__init__.py index 3fc1986..be308c4 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/__init__.py @@ -25,19 +25,19 @@ the corresponding dialect mixins fail fast with ``UnsupportedFeatureError``. """ -from .alter_column import ClickHouseAddColumn +from .alter_column import ClickHouseAddColumnAction from .column import ClickHouseColumnDefinition, ClickHouseColumnOptions +from .create_table_options import ClickHouseCreateTableOptions from .database import ( ClickHouseCreateDatabaseExpression, ClickHouseDropDatabaseExpression, ) from .dml import ClickHouseInsertExpression from .materialized_view import ( - ClickHouseCreateMaterializedViewExpression, - ClickHouseDropMaterializedViewExpression, - ClickHouseIntervalUnit, - ClickHouseModifyMaterializedViewRefreshExpression, - ClickHouseRefreshMaterializedViewExpression, + ClickHouseCreateMaterializedViewStatement, + ClickHouseDropMaterializedViewStatement, + ClickHouseModifyMaterializedViewRefreshStatement, + ClickHouseRefreshMaterializedViewStatement, ClickHouseRefreshSchedule, ) from .index import ClickHouseIndexDefinition @@ -54,13 +54,14 @@ ClickHouseJSONSearchExpression, ) from .partition import ( - ClickHouseDropPartitionExpression, - ClickHouseDetachPartitionExpression, - ClickHouseAttachPartitionExpression, + ClickHouseDropPartitionStatement, + ClickHouseDetachPartitionStatement, + ClickHouseAttachPartitionStatement, ) -from .rename_table import ClickHouseRenameTableExpression +from .rename_table import ClickHouseRenameTableStatement +from .wildcard import ClickHouseRowCountDistinctWildcard from .spatial import ( - ClickHouseCreateSpatialIndexExpression, + ClickHouseCreateSpatialIndexStatement, ClickHouseSTAsGeoJSONExpression, ClickHouseSTAsTextExpression, ClickHouseSTContainsExpression, @@ -70,6 +71,12 @@ ClickHouseSTWithinExpression, ClickHouseSpatialLiteralExpression, ) +from .storage_option import ( + ClickHouseEngineSpec, + ClickHouseSetting, + ClickHouseStorageOptionKind, + ClickHouseTableStorageOption, +) # DataType subclasses for DDL from .types import ( @@ -112,9 +119,15 @@ __all__ = [ # Index expressions "ClickHouseIndexDefinition", - "ClickHouseAddColumn", + "ClickHouseAddColumnAction", "ClickHouseColumnDefinition", "ClickHouseColumnOptions", + # CREATE TABLE storage clauses + "ClickHouseCreateTableOptions", + "ClickHouseStorageOptionKind", + "ClickHouseTableStorageOption", + "ClickHouseEngineSpec", + "ClickHouseSetting", # Database / DML expressions "ClickHouseCreateDatabaseExpression", "ClickHouseDropDatabaseExpression", @@ -131,7 +144,7 @@ "ClickHouseJSONValidExpression", "ClickHouseJSONSearchExpression", # Spatial expressions - "ClickHouseCreateSpatialIndexExpression", + "ClickHouseCreateSpatialIndexStatement", "ClickHouseSTAsGeoJSONExpression", "ClickHouseSTAsTextExpression", "ClickHouseSTContainsExpression", @@ -141,11 +154,13 @@ "ClickHouseSTWithinExpression", "ClickHouseSpatialLiteralExpression", # Partition - "ClickHouseDropPartitionExpression", - "ClickHouseDetachPartitionExpression", - "ClickHouseAttachPartitionExpression", + "ClickHouseDropPartitionStatement", + "ClickHouseDetachPartitionStatement", + "ClickHouseAttachPartitionStatement", # Rename table - "ClickHouseRenameTableExpression", + "ClickHouseRenameTableStatement", + # Row-count wildcard: the COUNT(DISTINCT *) spelling only ClickHouse has + "ClickHouseRowCountDistinctWildcard", # DataType subclasses for DDL "ClickHouseAggregateFunctionType", "ClickHouseArrayType", @@ -182,10 +197,59 @@ "ClickHouseUInt8Type", "ClickHouseUUIDType", # Materialized views - "ClickHouseCreateMaterializedViewExpression", - "ClickHouseDropMaterializedViewExpression", - "ClickHouseRefreshMaterializedViewExpression", - "ClickHouseModifyMaterializedViewRefreshExpression", + "ClickHouseCreateMaterializedViewStatement", + "ClickHouseDropMaterializedViewStatement", + "ClickHouseRefreshMaterializedViewStatement", + "ClickHouseModifyMaterializedViewRefreshStatement", "ClickHouseRefreshSchedule", - "ClickHouseIntervalUnit", ] + +# ``ClickHouse`` is one word. The general derivation -- a run of capitals counts +# as a single word -- splits at the camel-case seam instead, so this backend +# shipped ``click_house_flush_expression`` and ``click_house_load_xmle_xpression``: +# two spellings of one identifier, neither of them the word the class is named +# after. The kind is a wire contract (§3), so the identifier is declared whole +# and the kind is derived from that declaration: ``KIND_RESERVED`` is data, not +# behaviour, so no class overrides ``kind()`` and class name, kind and +# formatting entry keep one source. +# +# One word the declaration cannot reach: ``ClickHouseIPv4Type`` and +# ``ClickHouseUInt8Type`` still derive ``clickhouse_i_pv4_type`` and +# ``clickhouse_u_int8_type``, because ``IPv``/``UInt`` are one capital followed +# by lower case and the general rule reads that as two words. A reserved token +# cannot reach it either -- the token is lowercased and joined with a single +# underscore, so a token spelled ``ClickHouseIPv`` yields ``clickhouseipv``. The +# core has the same shape on ``JsonBType`` (``json_b_type``), so the split is +# the rule meeting a mixed-case vendor type spelling rather than a defect +# invented here; it is reported to the rule owner rather than worked around with +# a local spelling table. +# +# The loop walks this package's own modules rather than the ``__all__`` list +# above, for the reason the sqlite package's note gives: a hand-maintained list +# can only ever be out of date, and a node defined but not re-exported here -- +# the SHOW family, the private ``_ClickHousePartitionIdExpression`` -- would +# silently keep the split spelling. The leading-underscore node declares the +# token with its underscore so ``name.startswith(token)`` still matches: a +# private name is still a name the artifact carries. +import importlib as _importlib +import pkgutil as _pkgutil + +from rhosocial.activerecord.backend.expression.core import BaseExpression as _BaseExpression + +_KIND_TOKENS = ("ClickHouse",) + +for _module_info in _pkgutil.walk_packages(__path__, prefix=__name__ + ".", onerror=lambda _error: None): + _module = _importlib.import_module(_module_info.name) + for _value in vars(_module).values(): + if ( + isinstance(_value, type) + and issubclass(_value, _BaseExpression) + and _value.__module__ == _module_info.name + ): + _stem = _value.__name__.lstrip("_") + for _token in _KIND_TOKENS: + if _stem.startswith(_token): + _prefix = _value.__name__[: len(_value.__name__) - len(_stem)] + _value.KIND_RESERVED = (_prefix + _token,) + break +del _module_info, _module, _value, _importlib, _pkgutil, _BaseExpression, _KIND_TOKENS, _stem, _token, _prefix diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/admin.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/admin.py index 77b6490..d413e8b 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/admin.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/admin.py @@ -30,8 +30,18 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class FlushOption(Enum): - """FLUSH statement options.""" +class FlushOption(str, Enum): + """FLUSH statement options. + + A ``str`` enum: a member *is* the word it carries, so an artifact can name + the option without a vocabulary table, and the constructor normalises the + serialized word back to the member. ``__str__`` is pinned to ``str.__str__`` + because ``Enum.__str__`` would otherwise win and spell members as + ``FlushOption.TABLES`` -- on the XML channel that spelling is what travels, + and no reader accepts it. + """ + + __str__ = str.__str__ TABLES = "TABLES" LOGS = "LOGS" @@ -53,8 +63,10 @@ class FlushOption(Enum): SLAVE = "SLAVE" -class ResetOption(Enum): - """RESET statement options.""" +class ResetOption(str, Enum): + """RESET statement options. A ``str`` enum -- see :class:`FlushOption`.""" + + __str__ = str.__str__ MASTER = "MASTER" REPLICA = "REPLICA" @@ -62,15 +74,19 @@ class ResetOption(Enum): PERSIST = "PERSIST" -class KillTarget(Enum): - """KILL target selector.""" +class KillTarget(str, Enum): + """KILL target selector. A ``str`` enum -- see :class:`FlushOption`.""" + + __str__ = str.__str__ CONNECTION = "CONNECTION" QUERY = "QUERY" -class HandlerReadMode(Enum): - """HANDLER READ modes.""" +class HandlerReadMode(str, Enum): + """HANDLER READ modes. A ``str`` enum -- see :class:`FlushOption`.""" + + __str__ = str.__str__ FIRST = "FIRST" NEXT = "NEXT" @@ -78,7 +94,7 @@ class HandlerReadMode(Enum): LAST = "LAST" -class ClickHouseFlushExpression(BaseExpression): +class ClickHouseFlushStatement(BaseExpression): """Represent ``FLUSH`` with one or more options.""" def __init__( @@ -89,7 +105,11 @@ def __init__( no_write_to_binlog: bool = False, ): super().__init__(dialect) - self.options: List[FlushOption] = list(options) + # Normalise, don't just store: a member passes through, and the word an + # artifact carries comes back as its member. A plain store would make + # the restored instance hold a bare ``str`` where the original held the + # enum, and the round trip would fail on the type. + self.options: List[FlushOption] = [FlushOption(o) for o in options] self.no_write_to_binlog: bool = no_write_to_binlog def validate(self, strict: bool = True) -> None: @@ -103,7 +123,7 @@ def format_method(self) -> str: return "format_flush_statement" -class ClickHouseResetExpression(BaseExpression): +class ClickHouseResetStatement(BaseExpression): """Represent ``RESET`` with one option.""" def __init__( @@ -112,14 +132,14 @@ def __init__( option: ResetOption, ): super().__init__(dialect) - self.option: ResetOption = option + self.option: ResetOption = ResetOption(option) @property def format_method(self) -> str: return "format_reset_statement" -class ClickHouseCacheIndexExpression(BaseExpression): +class ClickHouseCacheIndexStatement(BaseExpression): """Represent ``CACHE INDEX ... IN key_cache``.""" def __init__( @@ -138,7 +158,7 @@ def format_method(self) -> str: return "format_cache_index_statement" -class ClickHouseLoadIndexIntoCacheExpression(BaseExpression): +class ClickHouseLoadIndexIntoCacheStatement(BaseExpression): """Represent ``LOAD INDEX INTO CACHE``.""" def __init__( @@ -154,7 +174,7 @@ def format_method(self) -> str: return "format_load_index_into_cache_statement" -class ClickHouseInstallComponentExpression(BaseExpression): +class ClickHouseInstallComponentStatement(BaseExpression): """Represent ``INSTALL COMPONENT 'name' [, 'name' ...]``.""" def __init__( @@ -170,7 +190,7 @@ def format_method(self) -> str: return "format_install_component_statement" -class ClickHouseUninstallComponentExpression(BaseExpression): +class ClickHouseUninstallComponentStatement(BaseExpression): """Represent ``UNINSTALL COMPONENT 'name' [, 'name' ...]``.""" def __init__( @@ -186,7 +206,7 @@ def format_method(self) -> str: return "format_uninstall_component_statement" -class ClickHouseInstallPluginExpression(BaseExpression): +class ClickHouseInstallPluginStatement(BaseExpression): """Represent ``INSTALL PLUGIN name SONAME 'library.so'``.""" def __init__( @@ -204,7 +224,7 @@ def format_method(self) -> str: return "format_install_plugin_statement" -class ClickHouseUninstallPluginExpression(BaseExpression): +class ClickHouseUninstallPluginStatement(BaseExpression): """Represent ``UNINSTALL PLUGIN name``.""" def __init__( @@ -220,7 +240,7 @@ def format_method(self) -> str: return "format_uninstall_plugin_statement" -class ClickHouseCloneExpression(BaseExpression): +class ClickHouseCloneStatement(BaseExpression): """Represent ``CLONE INSTANCE FROM ...``. Attributes: @@ -254,7 +274,7 @@ def format_method(self) -> str: return "format_clone_statement" -class ClickHouseRestartExpression(BaseExpression): +class ClickHouseRestartStatement(BaseExpression): """Represent the ``RESTART`` server statement.""" def __init__( @@ -268,7 +288,7 @@ def format_method(self) -> str: return "format_restart_statement" -class ClickHouseBinlogExpression(BaseExpression): +class ClickHouseBinlogStatement(BaseExpression): """Represent ``BINLOG 'base64_encoded_event'``.""" def __init__( @@ -284,7 +304,7 @@ def format_method(self) -> str: return "format_binlog_statement" -class ClickHouseHandlerOpenExpression(BaseExpression): +class ClickHouseHandlerOpenStatement(BaseExpression): """Represent ``HANDLER table OPEN [AS alias]``.""" def __init__( @@ -302,7 +322,7 @@ def format_method(self) -> str: return "format_handler_open_statement" -class ClickHouseHandlerReadExpression(BaseExpression): +class ClickHouseHandlerReadStatement(BaseExpression): """Represent ``HANDLER table READ {FIRST | NEXT}`` and variants.""" def __init__( @@ -318,7 +338,7 @@ def __init__( ): super().__init__(dialect) self.table = table - self.mode: HandlerReadMode = mode + self.mode: HandlerReadMode = HandlerReadMode(mode) self.index = index self.key_value = key_value self.where = where @@ -329,7 +349,7 @@ def format_method(self) -> str: return "format_handler_read_statement" -class ClickHouseHandlerCloseExpression(BaseExpression): +class ClickHouseHandlerCloseStatement(BaseExpression): """Represent ``HANDLER table CLOSE``.""" def __init__( @@ -345,7 +365,7 @@ def format_method(self) -> str: return "format_handler_close_statement" -class ClickHouseDoExpression(BaseExpression): +class ClickHouseDoStatement(BaseExpression): """Represent ``DO expr [, expr ...]``.""" def __init__( @@ -361,7 +381,7 @@ def format_method(self) -> str: return "format_do_statement" -class ClickHouseKillExpression(BaseExpression): +class ClickHouseKillStatement(BaseExpression): """Represent ``KILL [CONNECTION | QUERY] processlist_id``.""" def __init__( @@ -372,14 +392,14 @@ def __init__( ): super().__init__(dialect) self.processlist_id: int = processlist_id - self.target: KillTarget = target + self.target: KillTarget = KillTarget(target) @property def format_method(self) -> str: return "format_kill_statement" -class ClickHouseShutdownExpression(BaseExpression): +class ClickHouseShutdownStatement(BaseExpression): """Represent the ``SHUTDOWN`` statement.""" def __init__( @@ -393,7 +413,7 @@ def format_method(self) -> str: return "format_shutdown_statement" -class ClickHouseHelpExpression(BaseExpression): +class ClickHouseHelpStatement(BaseExpression): """Represent ``HELP 'topic'``.""" def __init__( @@ -420,7 +440,7 @@ def __repr__(self) -> str: # pragma: no cover return f"AccountSpec(user={self.user!r}, host={self.host!r})" -class ClickHouseCreateUserExpression(BaseExpression): +class ClickHouseCreateUserStatement(BaseExpression): """Represent ``CREATE USER [IF NOT EXISTS] 'user'@'host' [IDENTIFIED BY ...]``.""" def __init__( @@ -441,7 +461,7 @@ def format_method(self) -> str: return "format_create_user_statement" -class ClickHouseDropUserExpression(BaseExpression): +class ClickHouseDropUserStatement(BaseExpression): """Represent ``DROP USER [IF EXISTS] 'user'@'host' [, ...]``.""" def __init__( @@ -476,7 +496,7 @@ def __repr__(self) -> str: # pragma: no cover return f"GrantPrivilege(privilege={self.privilege!r}, columns={self.columns!r})" -class ClickHouseGrantExpression(BaseExpression): +class ClickHouseGrantStatement(BaseExpression): """Represent ``GRANT priv_list ON object TO account_list``.""" def __init__( @@ -499,7 +519,7 @@ def format_method(self) -> str: return "format_grant_statement" -class ClickHouseRevokeExpression(BaseExpression): +class ClickHouseRevokeStatement(BaseExpression): """Represent ``REVOKE priv_list ON object FROM account_list``.""" def __init__( diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/alter_column.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/alter_column.py index f550915..5e0a6e1 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/alter_column.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/alter_column.py @@ -6,16 +6,17 @@ * ``AFTER `` — place the new column immediately after ``column``. -It lives on ``ClickHouseAddColumn`` (deriving the generic ``AddColumn``) and -is rendered by the ClickHouse ``format_add_column_action`` override. -ClickHouse does **not** share it with any other backend. +It lives on ``ClickHouseAddColumnAction`` (deriving the generic +``AddColumnAction``) and is rendered by the ClickHouse +``format_clickhouse_add_column_action`` entry. ClickHouse does **not** share +it with any other backend. """ from typing import Optional, TYPE_CHECKING -from rhosocial.activerecord.backend.expression.statements import ( - AddColumn, - ColumnDefinition, +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition +from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AddColumnAction, ) if TYPE_CHECKING: # pragma: no cover @@ -23,11 +24,11 @@ __all__ = [ - "ClickHouseAddColumn", + "ClickHouseAddColumnAction", ] -class ClickHouseAddColumn(AddColumn): +class ClickHouseAddColumnAction(AddColumnAction): """A ClickHouse ``ADD COLUMN`` action extending the generic one. Adds the ClickHouse-only ``after`` positional clause. @@ -43,3 +44,7 @@ def __init__( ): super().__init__(dialect, column, if_not_exists=if_not_exists) self.after = after + + @property + def format_method(self) -> str: + return "format_clickhouse_add_column_action" diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/create_table_options.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/create_table_options.py new file mode 100644 index 0000000..371989e --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/create_table_options.py @@ -0,0 +1,86 @@ +# src/rhosocial/activerecord/backend/impl/clickhouse/expression/create_table_options.py +"""ClickHouse's ``CREATE TABLE`` options node. + +The generic :class:`CreateTableOptions` carries only ``OR REPLACE`` and an +inline table comment. Its docstring assigns the backend-specific creation +options to each backend -- naming ``ENGINE`` among them -- so ClickHouse +declares its own subclass rather than widening the generic one. + +ClickHouse's creation options do not all sit where the generic renderer puts +them, so this node holds the data and the statement renderer places it: + +* ``OR REPLACE`` -- between ``CREATE`` and ``TABLE``. Rendered by + :meth:`format_clickhouse_create_table_options`, which is where the generic + renderer's ``table_options.to_sql()`` call lands. +* ``ON CLUSTER `` -- after the table name, before the column list. +* the storage clause run -- after the column list, before the comment. + +The last two are read off this node by +``ClickHouseTableMixin.format_create_table_statement`` and rendered by their +own entries, the way the core renderer already treats ``comment``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional, Sequence + +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + CreateTableOptions, +) + +from .storage_option import ClickHouseTableStorageOption + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + TableCommentClause, + ) + + +__all__ = ["ClickHouseCreateTableOptions"] + + +class ClickHouseCreateTableOptions(CreateTableOptions): + """``CREATE TABLE`` options for ClickHouse. + + Extends the generic node with the two things ClickHouse adds: the + ``ON CLUSTER`` position and the storage clause run. A storage clause may + appear at most once -- a repeated option raises :exc:`ValueError` here, at + construction, rather than being silently collapsed or rendered twice. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + *, + or_replace: bool = False, + comment: Optional["TableCommentClause"] = None, + on_cluster: Optional[str] = None, + table_storage_options: Sequence[ClickHouseTableStorageOption] = (), + ): + super().__init__(dialect, or_replace=or_replace, comment=comment) + if on_cluster is not None and (not isinstance(on_cluster, str) or not on_cluster): + raise TypeError( + f"on_cluster must be a non-empty string, " f"got {type(on_cluster).__name__}" + ) + declared = {} + for clause in table_storage_options: + if not isinstance(clause, ClickHouseTableStorageOption): + raise TypeError( + f"table_storage_options must be " + f"ClickHouseTableStorageOption nodes, " + f"got {type(clause).__name__}" + ) + if clause.option in declared: + raise ValueError( + f"table_storage_options repeats {clause.option.keyword}; " + f"a storage clause may be declared once" + ) + declared[clause.option] = clause + self.on_cluster = on_cluster + self.table_storage_options = tuple(table_storage_options) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_create_table_options" diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/database.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/database.py index a2df7a7..c3c093a 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/database.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/database.py @@ -5,15 +5,15 @@ from rhosocial.activerecord.backend.expression.objects import Database from rhosocial.activerecord.backend.expression.statements.ddl_database import ( - CreateDatabaseExpression, - DropDatabaseExpression, + CreateDatabaseStatement, + DropDatabaseStatement, ) if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase -class ClickHouseCreateDatabaseExpression(CreateDatabaseExpression): +class ClickHouseCreateDatabaseExpression(CreateDatabaseStatement): """A ClickHouse CREATE DATABASE statement extending the generic one. Adds the ClickHouse-only ``ENGINE = `` and ``ON CLUSTER `` @@ -54,7 +54,7 @@ def __init__( self.on_cluster = on_cluster -class ClickHouseDropDatabaseExpression(DropDatabaseExpression): +class ClickHouseDropDatabaseExpression(DropDatabaseStatement): """A ClickHouse DROP DATABASE statement extending the generic one. Adds the ClickHouse-only ``SYNC`` modifier, which has no generic diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/dml.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/dml.py index 58415d3..a358957 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/dml.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/dml.py @@ -3,13 +3,13 @@ from typing import TYPE_CHECKING -from rhosocial.activerecord.backend.expression.statements import InsertExpression +from rhosocial.activerecord.backend.expression.statements import InsertStatement if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase -class ClickHouseInsertExpression(InsertExpression): +class ClickHouseInsertExpression(InsertStatement): """A ClickHouse INSERT statement extending the generic one. ClickHouse does not support REPLACE INTO / INSERT IGNORE, but the generic diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/introspection.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/introspection.py new file mode 100644 index 0000000..708cfd6 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/introspection.py @@ -0,0 +1,61 @@ +# src/rhosocial/activerecord/backend/impl/clickhouse/expression/introspection.py +"""ClickHouse backend-specific introspection expressions. + +The core introspection protocol has statements for tables, columns, indexes, +views and triggers. ClickHouse has two more facts worth asking its own catalog +about, and no core statement shape for either: + +* a table's parts, grouped into partitions (``system.parts``); +* the storage definition a table was created with (``system.tables``). + +Each node carries only its parameters, as every other expression in this +backend does (expression-dialect §16.2): the dialect writes the statement, the +introspector constructs and executes it. The formatters live in +``..mixins.introspection``, beside the core introspection formatters they share +their building blocks with. + +The parameter is named ``database`` rather than ``schema`` on purpose: +ClickHouse has no schema layer, and its top-level namespace is a database. See +``..introspection.types`` for the full statement of that naming rule. +""" + +from typing import TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "ClickHouseTableEngineQuery", + "ClickHouseTablePartitionsQuery", +] + + +class ClickHouseTablePartitionsQuery(BaseExpression): + """Active parts of one table, aggregated per ``partition_id``.""" + + def __init__(self, dialect: "SQLDialectBase", database: str, table: str): + super().__init__(dialect) + self.database = database + self.table = table + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_table_partitions_query" + + +class ClickHouseTableEngineQuery(BaseExpression): + """The storage definition of one table, from ``system.tables``.""" + + def __init__(self, dialect: "SQLDialectBase", database: str, table: str): + super().__init__(dialect) + self.database = database + self.table = table + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_table_engine_query" diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/json.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/json.py index 100cca8..1493cca 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/json.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/json.py @@ -17,7 +17,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional -from rhosocial.activerecord.backend.expression.bases import SQLValueExpression +from rhosocial.activerecord.backend.expression.bases import SQLValue from rhosocial.activerecord.backend.expression.mixins import ( AliasableMixin, ComparisonMixin, @@ -27,10 +27,11 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class ClickHouseJSONExtractExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class ClickHouseJSONExtractExpression(AliasableMixin, ComparisonMixin, SQLValue): """ClickHouse JSON_EXTRACT expression. - Extracts a value from a JSON document using a path. + Extracts a value from a JSON document using a path, or from several + paths when ``paths`` names more of them. Example: >>> expr = ClickHouseJSONExtractExpression(dialect, 'data', '$.name') @@ -42,24 +43,32 @@ def __init__( json_column: str, path: str, *, + paths: Optional[List[str]] = None, alias: Optional[str] = None, ): super().__init__(dialect) self.json_column = json_column self.path = path + self.paths = paths or [] self.alias = alias @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_extract" + return "format_json_extract_expression" -class ClickHouseJSONObjectExpression(AliasableMixin, SQLValueExpression): +class ClickHouseJSONObjectExpression(AliasableMixin, SQLValue): """ClickHouse JSON_OBJECT expression. Creates a JSON object from key-value pairs. + The members arrive as ``data`` -- a dict, or an iterable of + ``(key, value)`` pairs -- and never as ``**kwargs``: the artifact carries + its parameters by name (§4.11.4), and a ``**kwargs`` parameter cannot be + matched by name, so the class refuses that shape rather than building a + node the wire format cannot carry. + Example: >>> expr = ClickHouseJSONObjectExpression(dialect, {'name': 'Alice', 'age': 30}) OR @@ -72,17 +81,11 @@ def __init__( data: Any = None, *, alias: Optional[str] = None, - **kwargs: Any, ): super().__init__(dialect) self.data = data # keep raw for get_params() introspection - self.kwargs: Dict[str, Any] = dict(kwargs) - if data is not None and kwargs: - pairs = self._convert_to_pairs(data) + self._convert_to_pairs(kwargs) - elif data is not None: + if data is not None: pairs = self._convert_to_pairs(data) - elif kwargs: - pairs = self._convert_to_pairs(kwargs) else: pairs = [] self.pairs = pairs @@ -97,10 +100,10 @@ def _convert_to_pairs(self, data: Any) -> List[tuple]: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_object" + return "format_json_object_expression" -class ClickHouseJSONArrayExpression(AliasableMixin, SQLValueExpression): +class ClickHouseJSONArrayExpression(AliasableMixin, SQLValue): """ClickHouse JSON_ARRAY expression. Creates a JSON array from values. @@ -143,10 +146,10 @@ def get_params(self) -> Dict[str, Any]: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_array" + return "format_json_array_expression" -class ClickHouseJSONContainsExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class ClickHouseJSONContainsExpression(AliasableMixin, ComparisonMixin, SQLValue): """ClickHouse JSON_CONTAINS expression. Checks if a JSON document contains a specific value. @@ -173,10 +176,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_contains" + return "format_json_contains_expression" -class ClickHouseJSONUnquoteExpression(AliasableMixin, SQLValueExpression): +class ClickHouseJSONUnquoteExpression(AliasableMixin, SQLValue): """ClickHouse JSON_UNQUOTE expression. Unquotes a JSON value. @@ -199,10 +202,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_unquote" + return "format_json_unquote_expression" -class ClickHouseJSONSetExpression(AliasableMixin, SQLValueExpression): +class ClickHouseJSONSetExpression(AliasableMixin, SQLValue): """ClickHouse JSON_SET expression. Sets a value in a JSON document. @@ -231,10 +234,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_set" + return "format_json_set_expression" -class ClickHouseJSONRemoveExpression(AliasableMixin, SQLValueExpression): +class ClickHouseJSONRemoveExpression(AliasableMixin, SQLValue): """ClickHouse JSON_REMOVE expression. Removes a value from a JSON document. @@ -261,10 +264,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_remove" + return "format_json_remove_expression" -class ClickHouseJSONTypeExpression(AliasableMixin, SQLValueExpression): +class ClickHouseJSONTypeExpression(AliasableMixin, SQLValue): """ClickHouse JSON_TYPE expression. Returns the type of a JSON value. @@ -287,10 +290,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_type" + return "format_json_type_expression" -class ClickHouseJSONValidExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class ClickHouseJSONValidExpression(AliasableMixin, ComparisonMixin, SQLValue): """ClickHouse JSON_VALID expression. Checks if a value is valid JSON. @@ -313,10 +316,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_valid" + return "format_json_valid_expression" -class ClickHouseJSONSearchExpression(AliasableMixin, SQLValueExpression): +class ClickHouseJSONSearchExpression(AliasableMixin, SQLValue): """ClickHouse JSON_SEARCH expression. Searches for a string in a JSON document. @@ -345,7 +348,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_search" + return "format_json_search_expression" __all__ = [ diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/json_duality_view.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/json_duality_view.py index d8a7b7d..3bae08d 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/json_duality_view.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/json_duality_view.py @@ -46,8 +46,17 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class DualityViewDMLTag(Enum): - """DML permission tags for JSON_DUALITY_OBJECT.""" +class DualityViewDMLTag(str, Enum): + """DML permission tags for ``JSON_DUALITY_OBJECT``. + + A ``str`` enum whose ``__str__`` is ``str.__str__``: a member *is* the word + it carries, so an artifact names the tag as a plain JSON string. Without the + pin ``Enum.__str__`` wins and spells the member + ``DualityViewDMLTag.INSERT``, which no reader of the artifact can resolve + back to the member (§11.3). + """ + + __str__ = str.__str__ INSERT = "INSERT" UPDATE = "UPDATE" @@ -101,7 +110,7 @@ class DualityObjectSpec: join_condition: Optional[str] = None -class CreateJsonDualityViewExpression(BaseExpression): +class ClickHouseCreateJsonDualityViewStatement(BaseExpression): """``CREATE JSON RELATIONAL DUALITY VIEW`` — a statement ClickHouse does not have. Kept as a fail-fast switch, not as a renderer: ``to_sql()`` raises @@ -130,10 +139,10 @@ def __init__( @property def format_method(self) -> str: - return "format_create_json_duality_view_statement" + return "format_clickhouse_create_json_duality_view_statement" -class DropJsonDualityViewExpression(BaseExpression): +class ClickHouseDropJsonDualityViewStatement(BaseExpression): """DROP VIEW expression for JSON Duality Views. Uses standard DROP VIEW syntax since ClickHouse has no special DROP for duality views. @@ -151,4 +160,4 @@ def __init__( @property def format_method(self) -> str: - return "format_drop_json_duality_view_statement" + return "format_clickhouse_drop_json_duality_view_statement" diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/json_table.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/json_table.py index 405d435..4ed501d 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/json_table.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/json_table.py @@ -30,12 +30,13 @@ """ from dataclasses import dataclass -from typing import Any, List, Optional, TYPE_CHECKING +from typing import Any, List, Optional, TYPE_CHECKING, Union from rhosocial.activerecord.backend.expression.bases import BaseExpression if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase + from rhosocial.activerecord.backend.expression.types import DataType @dataclass @@ -44,7 +45,8 @@ class JSONTableColumn: Attributes: name: Column name - type: SQL data type (e.g., 'VARCHAR(255)', 'INT') + type: The column's type -- a type node (``VarCharType(dialect, 255)``) + or a type name, which this backend's own rule decides path: JSON path expression for the column value ordinality: If True, use FOR ORDINALITY (row number) exists: If True, use EXISTS (boolean for path existence) @@ -53,7 +55,7 @@ class JSONTableColumn: """ name: str - type: Optional[str] = None + type: Optional[Union[str, "DataType"]] = None path: Optional[str] = None ordinality: bool = False exists: bool = False diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/load_data.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/load_data.py index 7bbc3ca..32e80bd 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/load_data.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/load_data.py @@ -2,11 +2,12 @@ """ ClickHouse-specific LOAD DATA expression. -This module provides ClickHouseLoadDataExpression and LoadDataOptions for -ClickHouse's LOAD DATA INFILE functionality. +This module provides ClickHouseLoadDataStatement for ClickHouse's LOAD DATA +INFILE functionality. The statement itself is refused by the dialect's +formatter (ClickHouse has no LOAD DATA), so the expression exists to carry the +would-be statement's parameters as data -- and to say no when rendered. """ -from dataclasses import dataclass from typing import Any, Dict, List, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -15,11 +16,20 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -@dataclass -class LoadDataOptions: - """Options for LOAD DATA INFILE statement. +class ClickHouseLoadDataStatement(BaseExpression): + """ClickHouse LOAD DATA INFILE expression. + + Collects parameters for LOAD DATA [LOCAL] INFILE statement. + The actual SQL formatting is done by ClickHouseDialect.format_load_data_statement(), + which refuses: ClickHouse has no LOAD DATA. The parameters travel anyway, + as plain JSON scalars -- an artifact carries what the constructor declares, + and an options *object* has no artifact form (the codec writes nodes, + scalars, and containers of those two), which is why the former + ``LoadDataOptions`` dataclass was flattened into these parameters. Attributes: + file_path: Path to the data file + table: Target table name local: Use LOCAL keyword (client-side file) replace: Use REPLACE mode (replace existing rows on duplicate key) ignore: Use IGNORE mode (ignore duplicate key errors) @@ -34,34 +44,24 @@ class LoadDataOptions: set_assignments: SET clause for column transformations """ - local: bool = True - replace: bool = False - ignore: bool = False - character_set: Optional[str] = None - fields_terminated_by: Optional[str] = None - fields_enclosed_by: Optional[str] = None - fields_escaped_by: Optional[str] = None - lines_starting_by: Optional[str] = None - lines_terminated_by: Optional[str] = None - ignore_lines: Optional[int] = None - column_list: Optional[List[str]] = None - set_assignments: Optional[Dict[str, Any]] = None - - -class ClickHouseLoadDataExpression(BaseExpression): - """ClickHouse LOAD DATA INFILE expression. - - Collects parameters for LOAD DATA [LOCAL] INFILE statement. - The actual SQL formatting is done by ClickHouseDialect.format_load_data_statement(). - - Attributes: - file_path: Path to the data file - table: Target table name - options: Load options - """ - def __init__( - self, dialect: "SQLDialectBase", file_path: str, table: str, options: Optional[LoadDataOptions] = None + self, + dialect: "SQLDialectBase", + file_path: str, + table: str, + *, + local: bool = True, + replace: bool = False, + ignore: bool = False, + character_set: Optional[str] = None, + fields_terminated_by: Optional[str] = None, + fields_enclosed_by: Optional[str] = None, + fields_escaped_by: Optional[str] = None, + lines_starting_by: Optional[str] = None, + lines_terminated_by: Optional[str] = None, + ignore_lines: Optional[int] = None, + column_list: Optional[List[str]] = None, + set_assignments: Optional[Dict[str, Any]] = None, ): """Initialize LOAD DATA expression. @@ -69,12 +69,34 @@ def __init__( dialect: SQL dialect file_path: Path to the data file table: Target table name - options: Load options (default: LoadDataOptions()) + local: Use LOCAL keyword (client-side file) + replace: Use REPLACE mode (replace existing rows on duplicate key) + ignore: Use IGNORE mode (ignore duplicate key errors) + character_set: Character set of the file + fields_terminated_by: Field terminator string (default: tab) + fields_enclosed_by: Field enclosure character + fields_escaped_by: Escape character + lines_starting_by: Line prefix to skip + lines_terminated_by: Line terminator string + ignore_lines: Number of lines to skip at start + column_list: List of column names for mapping + set_assignments: SET clause for column transformations """ super().__init__(dialect) self.file_path = file_path self.table = table - self.options = options or LoadDataOptions() + self.local = local + self.replace = replace + self.ignore = ignore + self.character_set = character_set + self.fields_terminated_by = fields_terminated_by + self.fields_enclosed_by = fields_enclosed_by + self.fields_escaped_by = fields_escaped_by + self.lines_starting_by = lines_starting_by + self.lines_terminated_by = lines_terminated_by + self.ignore_lines = ignore_lines + self.column_list = list(column_list) if column_list is not None else None + self.set_assignments = set_assignments def validate(self, strict: bool = True) -> None: """Validate expression parameters. @@ -95,10 +117,7 @@ def validate(self, strict: bool = True) -> None: if not isinstance(self.table, str): raise TypeError(f"table must be str, got {type(self.table)}") - if not isinstance(self.options, LoadDataOptions): - raise TypeError(f"options must be LoadDataOptions, got {type(self.options)}") - - if self.options.replace and self.options.ignore: + if self.replace and self.ignore: raise ValueError("Cannot use both REPLACE and IGNORE in LOAD DATA") @property diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/load_xml.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/load_xml.py index 8fc0107..5f69a63 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/load_xml.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/load_xml.py @@ -20,23 +20,34 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class LoadXMLPriority(Enum): - """LOW_PRIORITY / CONCURRENT selector.""" +class LoadXMLPriority(str, Enum): + """LOW_PRIORITY / CONCURRENT selector. + + A ``str`` enum whose ``__str__`` is ``str.__str__``: a member *is* the word + it carries, so an artifact names the option as a plain JSON string and the + constructor normalises the word back to the member. ``Enum.__str__`` would + otherwise win and spell members as ``LoadXMLPriority.LOW_PRIORITY`` -- the + spelling the XML channel writes, and no reader accepts it. + """ + + __str__ = str.__str__ NONE = "" LOW_PRIORITY = "LOW_PRIORITY" CONCURRENT = "CONCURRENT" -class LoadXMLConflictMode(Enum): - """REPLACE / IGNORE selector.""" +class LoadXMLConflictMode(str, Enum): + """REPLACE / IGNORE selector. A ``str`` enum -- see :class:`LoadXMLPriority`.""" + + __str__ = str.__str__ NONE = "" REPLACE = "REPLACE" IGNORE = "IGNORE" -class ClickHouseLoadXMLEXpression(BaseExpression): +class ClickHouseLoadXMLStatement(BaseExpression): """Represent the ClickHouse ``LOAD XML`` statement. Attributes: @@ -68,8 +79,10 @@ def __init__( self.file_path: str = file_path self.table: str = table self.local: bool = local - self.priority: LoadXMLPriority = priority - self.conflict_mode: LoadXMLConflictMode = conflict_mode + # Normalise: the artifact carries the word, and the restored instance + # must hold the member the original held, not the bare string. + self.priority: LoadXMLPriority = LoadXMLPriority(priority) + self.conflict_mode: LoadXMLConflictMode = LoadXMLConflictMode(conflict_mode) self.character_set: Optional[str] = character_set self.rows_identified_by: Optional[str] = rows_identified_by self.ignore_count: Optional[int] = ignore_count @@ -98,4 +111,4 @@ def validate(self, strict: bool = True) -> None: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_load_xml_statement" \ No newline at end of file + return "format_clickhouse_load_xml_statement" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/maintenance.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/maintenance.py index d06df82..c5ac3ac 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/maintenance.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/maintenance.py @@ -24,16 +24,27 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class NoWriteToBinlogOption(Enum): - """NO_WRITE_TO_BINLOG / LOCAL synonym selector.""" +class NoWriteToBinlogOption(str, Enum): + """NO_WRITE_TO_BINLOG / LOCAL synonym selector. + + A ``str`` enum whose ``__str__`` is ``str.__str__``: a member *is* the word + it carries, so an artifact names the option as a plain JSON string and the + constructors below normalise the word back to the member. ``Enum.__str__`` + would otherwise win and spell members as ``NoWriteToBinlogOption.LOCAL`` -- + the spelling the XML channel writes, and no reader accepts it. + """ + + __str__ = str.__str__ NONE = "" NO_WRITE_TO_BINLOG = "NO_WRITE_TO_BINLOG" LOCAL = "LOCAL" -class CheckTableOption(Enum): - """CHECK TABLE optional modes.""" +class CheckTableOption(str, Enum): + """CHECK TABLE optional modes. A ``str`` enum -- see :class:`NoWriteToBinlogOption`.""" + + __str__ = str.__str__ FOR_UPGRADE = "FOR UPGRADE" QUICK = "QUICK" @@ -43,22 +54,26 @@ class CheckTableOption(Enum): CHANGED = "CHANGED" -class ChecksumTableOption(Enum): - """CHECKSUM TABLE optional modes.""" +class ChecksumTableOption(str, Enum): + """CHECKSUM TABLE optional modes. A ``str`` enum -- see :class:`NoWriteToBinlogOption`.""" + + __str__ = str.__str__ QUICK = "QUICK" EXTENDED = "EXTENDED" -class RepairTableOption(Enum): - """REPAIR TABLE optional modes.""" +class RepairTableOption(str, Enum): + """REPAIR TABLE optional modes. A ``str`` enum -- see :class:`NoWriteToBinlogOption`.""" + + __str__ = str.__str__ QUICK = "QUICK" EXTENDED = "EXTENDED" USE_FRM = "USE_FRM" -class ClickHouseTableMaintenanceExpression(BaseExpression): +class ClickHouseTableMaintenanceStatement(BaseExpression): """Base class for whole-table maintenance statements. Attributes: @@ -84,7 +99,11 @@ def __init__( ): super().__init__(dialect) self.tables: list = list(tables) - self.no_write_to_binlog: NoWriteToBinlogOption = no_write_to_binlog + # Normalise: the artifact carries the word, and the restored instance + # must hold the member the original held, not the bare string. + self.no_write_to_binlog: NoWriteToBinlogOption = NoWriteToBinlogOption( + no_write_to_binlog + ) self.validate() def validate(self, strict: bool = True) -> None: @@ -113,13 +132,13 @@ def format_method(self) -> str: return "format_table_maintenance_statement" -class ClickHouseAnalyzeTableExpression(ClickHouseTableMaintenanceExpression): +class ClickHouseAnalyzeTableExpression(ClickHouseTableMaintenanceStatement): """Represent ``ANALYZE TABLE``.""" operation: str = "ANALYZE" -class ClickHouseCheckTableExpression(ClickHouseTableMaintenanceExpression): +class ClickHouseCheckTableExpression(ClickHouseTableMaintenanceStatement): """Represent ``CHECK TABLE`` with optional modes.""" operation: str = "CHECK" @@ -136,10 +155,12 @@ def __init__( tables, no_write_to_binlog=NoWriteToBinlogOption.NONE, ) - self.options: list[CheckTableOption] = list(options or []) + self.options: list[CheckTableOption] = [ + CheckTableOption(o) for o in (options or []) + ] -class ClickHouseChecksumTableExpression(ClickHouseTableMaintenanceExpression): +class ClickHouseChecksumTableExpression(ClickHouseTableMaintenanceStatement): """Represent ``CHECKSUM TABLE`` with optional mode.""" operation: str = "CHECKSUM" @@ -156,16 +177,18 @@ def __init__( tables, no_write_to_binlog=NoWriteToBinlogOption.NONE, ) - self.option: Optional[ChecksumTableOption] = option + self.option: Optional[ChecksumTableOption] = ( + ChecksumTableOption(option) if option is not None else None + ) -class ClickHouseOptimizeTableExpression(ClickHouseTableMaintenanceExpression): +class ClickHouseOptimizeTableExpression(ClickHouseTableMaintenanceStatement): """Represent ``OPTIMIZE TABLE``.""" operation: str = "OPTIMIZE" -class ClickHouseRepairTableExpression(ClickHouseTableMaintenanceExpression): +class ClickHouseRepairTableExpression(ClickHouseTableMaintenanceStatement): """Represent ``REPAIR TABLE`` with optional modes.""" operation: str = "REPAIR" @@ -183,4 +206,6 @@ def __init__( tables, no_write_to_binlog=no_write_to_binlog, ) - self.options: list[RepairTableOption] = list(options or []) + self.options: list[RepairTableOption] = [ + RepairTableOption(o) for o in (options or []) + ] diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/match_against.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/match_against.py index dfa70a6..a85c20f 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/match_against.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/match_against.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, List, Optional -from rhosocial.activerecord.backend.expression.bases import SQLValueExpression +from rhosocial.activerecord.backend.expression.bases import SQLValue from rhosocial.activerecord.backend.expression.mixins import ( AliasableMixin, ComparisonMixin, @@ -28,13 +28,13 @@ class MatchAgainstMode: class ClickHouseMatchAgainstExpression( AliasableMixin, ComparisonMixin, - SQLValueExpression, + SQLValue, ): """ClickHouse MATCH...AGAINST expression. Generates MATCH(col1, col2, ...) AGAINST(search_string [IN mode]) syntax. - This dialect never renders it: ``ClickHouseFullTextSearchMixin.format_match_against`` + This dialect never renders it: ``ClickHouseFullTextSearchMixin.format_match_against_expression`` raises ``UnsupportedFeatureError``. ClickHouse has no ``MATCH ... AGAINST`` and no ``FULLTEXT`` index type — declaring one answers ``Unknown Index type 'fulltext'`` and lists what the server does have (``hypothesis, text, @@ -84,7 +84,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_match_against" + return "format_match_against_expression" __all__ = [ diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/materialized_view.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/materialized_view.py index 1f8302b..7158f8e 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/materialized_view.py @@ -48,9 +48,7 @@ https://clickhouse.com/docs/sql-reference/statements/create/view """ from collections.abc import Sequence as AbcSequence -from dataclasses import dataclass, field -from enum import Enum -from typing import Any, Dict, Optional, Sequence, TYPE_CHECKING +from typing import Any, Dict, Optional, Sequence, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression from rhosocial.activerecord.backend.expression.objects import MaterializedView, Table @@ -60,32 +58,27 @@ __all__ = [ - "ClickHouseCreateMaterializedViewExpression", - "ClickHouseDropMaterializedViewExpression", - "ClickHouseIntervalUnit", - "ClickHouseModifyMaterializedViewRefreshExpression", - "ClickHouseRefreshMaterializedViewExpression", + "ClickHouseCreateMaterializedViewStatement", + "ClickHouseDropMaterializedViewStatement", + "ClickHouseModifyMaterializedViewRefreshStatement", + "ClickHouseRefreshMaterializedViewStatement", "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" - - -@dataclass -class ClickHouseRefreshSchedule: +class ClickHouseRefreshSchedule(BaseExpression): """The ``REFRESH`` clause of a refreshable materialized view. + A clause, and therefore an expression node: it renders (through + ``format_clickhouse_refresh_schedule``) and it travels -- an artifact + carries it as a nested ``{kind, params}`` node, which is why it is a node + rather than a dataclass. A dataclass parameter has no artifact form: the + codec writes nodes, scalars and containers of those two, and anything else + is refused, so a schedule buried in an expression's parameters made the + whole expression unserializable. + Args: + dialect: the ClickHouse dialect instance the clause renders through. every: ``EVERY interval`` — a sequence of `` `` parts. after: ``AFTER interval`` — fires relative to the previous completion. offset: ``OFFSET interval``, only valid together with ``every``. @@ -98,17 +91,40 @@ class ClickHouseRefreshSchedule: the previous run. At least one of ``every`` / ``after`` / ``depends_on`` is required: the - server rejects a bare ``REFRESH``. + server rejects a bare ``REFRESH``. Validation runs when the schedule is + carried by a statement (see the owning expressions' ``validate``) and via + :meth:`validate` itself. """ - every: Optional[str] = None - after: Optional[str] = None - offset: Optional[str] = None - randomize_for: Optional[str] = None - depends_on: Sequence[MaterializedView] = field(default_factory=tuple) - settings: Optional[Dict[str, Any]] = None - append: bool = False - incremental: bool = False + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_refresh_schedule" + + def __init__( + self, + dialect: "ClickHouseDialect", + every: Optional[str] = None, + after: Optional[str] = None, + offset: Optional[str] = None, + randomize_for: Optional[str] = None, + depends_on: Sequence[MaterializedView] = (), + settings: Optional[Dict[str, Any]] = None, + append: bool = False, + incremental: bool = False, + ): + super().__init__(dialect) + self.every = every + self.after = after + self.offset = offset + self.randomize_for = randomize_for + # A tuple on the way in, a tuple on the way out: the artifact carries a + # sequence as an array, and the constructor normalises it back, so the + # restored clause holds what the original held. + self.depends_on: Tuple[MaterializedView, ...] = tuple(depends_on) + self.settings = settings + self.append = append + self.incremental = incremental def validate(self) -> None: """Validate the schedule the way the server does. @@ -138,7 +154,7 @@ def validate(self) -> None: ) -class ClickHouseCreateMaterializedViewExpression(BaseExpression): +class ClickHouseCreateMaterializedViewStatement(BaseExpression): """``CREATE MATERIALIZED VIEW`` for both ClickHouse MV flavours. Args: @@ -260,7 +276,7 @@ def format_method(self) -> str: return "format_create_materialized_view_statement" -class ClickHouseDropMaterializedViewExpression(BaseExpression): +class ClickHouseDropMaterializedViewStatement(BaseExpression): """``DROP VIEW [IF EXISTS]`` — ClickHouse removes MVs with ``DROP VIEW``. The ``VIEW`` keyword is ClickHouse's, not a mis-classification: the server @@ -299,7 +315,7 @@ def format_method(self) -> str: return "format_drop_materialized_view_statement" -class ClickHouseRefreshMaterializedViewExpression(BaseExpression): +class ClickHouseRefreshMaterializedViewStatement(BaseExpression): """``SYSTEM REFRESH VIEW`` — trigger a refreshable MV refresh now. ClickHouse has no ``REFRESH MATERIALIZED VIEW`` statement; an immediate @@ -344,7 +360,7 @@ def format_method(self) -> str: return "format_refresh_materialized_view_statement" -class ClickHouseModifyMaterializedViewRefreshExpression(BaseExpression): +class ClickHouseModifyMaterializedViewRefreshStatement(BaseExpression): """``ALTER TABLE ... MODIFY REFRESH`` — change an existing schedule. The statement replaces **all** refresh parameters: whatever is omitted is diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/optimizer_hint.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/optimizer_hint.py index 965a487..8116edc 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/optimizer_hint.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/optimizer_hint.py @@ -24,7 +24,7 @@ So the expression class below is a fail-fast switch, not a renderer: ``supports_optimizer_hint()`` and ``supports_hypergraph_optimizer()`` are both -``False`` and ``format_optimizer_hint`` raises ``UnsupportedFeatureError``. The +``False`` and ``format_optimizer_hint_expression`` raises ``UnsupportedFeatureError``. The ClickHouse way to set something per statement is the ``SETTINGS`` clause on the statement itself (``https://clickhouse.com/docs/reference/statements/select``, "Settings in SELECT Query"), which this backend renders for the settings it @@ -43,8 +43,15 @@ from rhosocial.activerecord.backend.dialect.base import SQLDialectBase -class OptimizerHintType(Enum): - """Types of ClickHouse optimizer hints.""" +class OptimizerHintType(str, Enum): + """Types of ClickHouse optimizer hints. + + A ``str`` enum whose ``__str__`` is ``str.__str__``: a member *is* the word + it carries, so an artifact names the hint type as a plain JSON string + (§11.3). + """ + + __str__ = str.__str__ SET_VAR = "SET_VAR" @@ -80,4 +87,4 @@ def __init__(self, dialect: "SQLDialectBase", hints: List[SetVarHint]): @property def format_method(self) -> str: - return "format_optimizer_hint" + return "format_optimizer_hint_expression" diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/partition.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/partition.py index 65d3187..ef97383 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/partition.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/partition.py @@ -62,9 +62,9 @@ __all__ = [ - "ClickHouseDropPartitionExpression", - "ClickHouseDetachPartitionExpression", - "ClickHouseAttachPartitionExpression", + "ClickHouseDropPartitionStatement", + "ClickHouseDetachPartitionStatement", + "ClickHouseAttachPartitionStatement", ] @@ -156,7 +156,7 @@ def __repr__(self) -> str: ) -class ClickHouseDropPartitionExpression(_ClickHousePartitionIdExpression): +class ClickHouseDropPartitionStatement(_ClickHousePartitionIdExpression): """``ALTER TABLE ... DROP PARTITION ID`` — deletes a partition's data. The partition's parts are tagged inactive and deleted in the background @@ -170,12 +170,12 @@ def format_method(self) -> str: return "format_drop_partition_statement" -class ClickHouseDetachPartitionExpression(_ClickHousePartitionIdExpression): +class ClickHouseDetachPartitionStatement(_ClickHousePartitionIdExpression): """``ALTER TABLE ... DETACH PARTITION ID`` — moves a partition aside. Unlike ``DROP``, the data survives in the table's ``detached/`` directory, so it can be inspected on disk and put back with - :class:`ClickHouseAttachPartitionExpression`. The server forgets the + :class:`ClickHouseAttachPartitionStatement`. The server forgets the partition until it is attached again. """ @@ -186,12 +186,12 @@ def format_method(self) -> str: return "format_detach_partition_statement" -class ClickHouseAttachPartitionExpression(_ClickHousePartitionIdExpression): +class ClickHouseAttachPartitionStatement(_ClickHousePartitionIdExpression): """``ALTER TABLE ... ATTACH PARTITION ID`` — re-adds a detached partition. Reads the partition back from the ``detached/`` directory and returns its rows to the table; the inverse of - :class:`ClickHouseDetachPartitionExpression`. + :class:`ClickHouseDetachPartitionStatement`. """ verb = "ATTACH" diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/rename_table.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/rename_table.py index 692a35d..b060cdd 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/rename_table.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/rename_table.py @@ -18,7 +18,7 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class ClickHouseRenameTableExpression(BaseExpression): +class ClickHouseRenameTableStatement(BaseExpression): """Represent a ClickHouse ``RENAME TABLE ...`` atomic multi-table statement. Attributes: diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/routine.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/routine.py index fea4187..330439c 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/routine.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/routine.py @@ -74,7 +74,7 @@ def validate(self, strict: bool = True) -> None: ) -class ClickHouseCreateProcedureExpression(ClickHouseRoutineExpression): +class ClickHouseCreateProcedureStatement(ClickHouseRoutineExpression): """Represent ``CREATE PROCEDURE``.""" @property @@ -82,7 +82,7 @@ def format_method(self) -> str: return "format_create_procedure_statement" -class ClickHouseDropProcedureExpression(ClickHouseRoutineExpression): +class ClickHouseDropProcedureStatement(ClickHouseRoutineExpression): """Represent ``DROP PROCEDURE [IF EXISTS]``.""" def __init__( @@ -100,7 +100,7 @@ def format_method(self) -> str: return "format_drop_procedure_statement" -class ClickHouseCreateFunctionExpression(ClickHouseRoutineExpression): +class ClickHouseCreateFunctionStatement(ClickHouseRoutineExpression): """Represent ``CREATE FUNCTION`` (stored function).""" def __init__( @@ -127,7 +127,7 @@ def format_method(self) -> str: return "format_create_function_statement" -class ClickHouseDropFunctionExpression(ClickHouseRoutineExpression): +class ClickHouseDropFunctionStatement(ClickHouseRoutineExpression): """Represent ``DROP FUNCTION [IF EXISTS]`` (stored function).""" def __init__( @@ -145,7 +145,7 @@ def format_method(self) -> str: return "format_drop_function_statement" -class ClickHouseCallExpression(BaseExpression): +class ClickHouseCallStatement(BaseExpression): """Represent ``CALL procedure_name([args])``. Attributes: diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/show.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/show.py index 79fd27b..9a9d28c 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/show.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/show.py @@ -10,7 +10,12 @@ following the expression-dialect pattern used throughout the codebase. Key design: -- Expressions collect parameters (table, schema, options) +- **Every parameter is a constructor parameter.** A node is complete the moment + it is built, so ``get_params()`` reads the declared fields and nothing has to + be filled in afterwards. The ``schema()`` / ``full()`` / ``like()`` / + ``for_user()`` setters this module used to carry made "the node" a thing that + was only sometimes finished, and an artifact built from one could not say + which state it had captured (expression-dialect §16.1). - Expressions hold a dialect reference - to_sql() delegates to dialect.format_show_* methods - Dialect handles actual SQL generation @@ -30,7 +35,7 @@ here renders SQL the server would reject. """ -from typing import Any, Dict, Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLQueryAndParams @@ -41,75 +46,58 @@ class ShowExpression(BaseExpression): """Base class for ClickHouse SHOW command expressions. - All ClickHouse SHOW expressions inherit from this class and provide - fluent API for setting parameters. + All ClickHouse SHOW expressions inherit from this class. ``schema`` is the + ClickHouse database the statement is qualified by, and it is declared here + because every subclass that can carry one declares it in its own + ``__init__`` too: ``get_params()`` reads the *class's own* signature, so a + qualifier declared on the base alone would be invisible to the artifact and + silently dropped from the rendered statement. """ - def __init__(self, dialect: "ClickHouseDialect"): + def __init__(self, dialect: "ClickHouseDialect", *, schema: Optional[str] = None): super().__init__(dialect) - self._schema: Optional[str] = None - - def schema(self, name: str) -> "ShowExpression": - """Set the schema/database name. - - Args: - name: Schema or database name. - - Returns: - Self for method chaining. - """ - self._schema = name - return self - - def get_params(self) -> Dict[str, Any]: - """Get all parameters. - - Returns: - Dictionary containing all parameters. - """ - params: Dict[str, Any] = {} - if self._schema is not None: - params["schema"] = self._schema - return params + self.schema: Optional[str] = schema def to_sql(self) -> SQLQueryAndParams: """Generate SQL. Subclasses must implement this method.""" raise NotImplementedError("Subclasses must implement to_sql() method") -class ShowCreateTableExpression(ShowExpression): +class ShowCreateTableStatement(ShowExpression): """Expression for SHOW CREATE TABLE command.""" - def __init__(self, dialect: "ClickHouseDialect", table: str): - super().__init__(dialect) - self._table = table - - def get_params(self) -> Dict[str, Any]: - params = super().get_params() - params["table"] = self._table - return params + def __init__( + self, + dialect: "ClickHouseDialect", + table: str, + *, + schema: Optional[str] = None, + ): + super().__init__(dialect, schema=schema) + self.table: str = table def to_sql(self) -> SQLQueryAndParams: - return self._dialect.format_show_create_table(self) + return self._dialect.format_show_create_table_statement(self) -class ShowCreateViewExpression(ShowExpression): +class ShowCreateViewStatement(ShowExpression): """Expression for SHOW CREATE VIEW command.""" - def __init__(self, dialect: "ClickHouseDialect", view_name: str): - super().__init__(dialect) - self._view_name = view_name - - def get_params(self) -> Dict[str, Any]: - params = super().get_params() - params["view_name"] = self._view_name - return params + def __init__( + self, + dialect: "ClickHouseDialect", + view_name: str, + *, + schema: Optional[str] = None, + ): + super().__init__(dialect, schema=schema) + self.view_name: str = view_name def to_sql(self) -> SQLQueryAndParams: - return self._dialect.format_show_create_view(self) + return self._dialect.format_show_create_view_statement(self) -class ShowColumnsExpression(ShowExpression): +class ShowColumnsStatement(ShowExpression): """Expression for SHOW [FULL] COLUMNS command. ClickHouse has this statement; the table is mandatory (bare ``SHOW COLUMNS`` @@ -126,66 +114,38 @@ def __init__( like_pattern: Optional[str] = None, schema: Optional[str] = None, ): - super().__init__(dialect) - self._table = table - self._full: bool = full - self._like_pattern: Optional[str] = like_pattern - if schema is not None: - self._schema = schema - - def full(self, value: bool = True) -> "ShowColumnsExpression": - """Include additional column information (privileges, comments).""" - self._full = value - return self - - def like(self, pattern: str) -> "ShowColumnsExpression": - """Filter columns by name pattern.""" - self._like_pattern = pattern - return self - - def get_params(self) -> Dict[str, Any]: - params = super().get_params() - params["table"] = self._table - params["full"] = self._full - if self._like_pattern: - params["like_pattern"] = self._like_pattern - return params + super().__init__(dialect, schema=schema) + self.table: str = table + self.full: bool = full + self.like_pattern: Optional[str] = like_pattern def to_sql(self) -> SQLQueryAndParams: - return self._dialect.format_show_columns(self) + return self._dialect.format_show_columns_statement(self) -class ShowIndexExpression(ShowExpression): +class ShowIndexStatement(ShowExpression): """Expression for SHOW INDEX command. ClickHouse has this statement "mostly for compatibility with MySQL". As with ``SHOW COLUMNS``, the database is part of the table reference. """ - def __init__(self, dialect: "ClickHouseDialect", table: str): - super().__init__(dialect) - self._table = table - - def get_params(self) -> Dict[str, Any]: - params = super().get_params() - params["table"] = self._table - return params + def __init__( + self, + dialect: "ClickHouseDialect", + table: str, + *, + schema: Optional[str] = None, + ): + super().__init__(dialect, schema=schema) + self.table: str = table def to_sql(self) -> SQLQueryAndParams: - return self._dialect.format_show_index(self) + return self._dialect.format_show_index_statement(self) -class ShowTablesExpression(ShowExpression): - """Expression for SHOW [FULL] TABLES command. - - ``full``, ``like_pattern`` and ``schema`` are accepted as keyword arguments - as well as set through the fluent methods. They have to be: ``get_params`` - emits all three, and deserialization rebuilds an expression by calling the - constructor with what ``get_params`` produced. A fluent-only field made the - round-trip impossible -- ``SHOW TABLES`` serialised fine and then failed to - rebuild with "unexpected keyword argument 'full'". The fluent methods are - kept because they read better in the call sites that use them. - """ +class ShowTablesStatement(ShowExpression): + """Expression for SHOW [FULL] TABLES command.""" def __init__( self, @@ -195,132 +155,94 @@ def __init__( like_pattern: Optional[str] = None, schema: Optional[str] = None, ): - super().__init__(dialect) - self._full: bool = full - self._like_pattern: Optional[str] = like_pattern - if schema is not None: - self._schema = schema - - def full(self, value: bool = True) -> "ShowTablesExpression": - """Include table type (BASE TABLE or VIEW).""" - self._full = value - return self - - def like(self, pattern: str) -> "ShowTablesExpression": - """Filter tables by name pattern.""" - self._like_pattern = pattern - return self - - def get_params(self) -> Dict[str, Any]: - params = super().get_params() - params["full"] = self._full - if self._like_pattern: - params["like_pattern"] = self._like_pattern - return params + super().__init__(dialect, schema=schema) + self.full: bool = full + self.like_pattern: Optional[str] = like_pattern def to_sql(self) -> SQLQueryAndParams: - return self._dialect.format_show_tables(self) + return self._dialect.format_show_tables_statement(self) -class ShowDatabasesExpression(ShowExpression): +class ShowDatabasesStatement(ShowExpression): """Expression for SHOW DATABASES command.""" - def __init__(self, dialect: "ClickHouseDialect"): + def __init__( + self, + dialect: "ClickHouseDialect", + *, + like_pattern: Optional[str] = None, + ): super().__init__(dialect) - self._like_pattern: Optional[str] = None - - def like(self, pattern: str) -> "ShowDatabasesExpression": - """Filter databases by name pattern.""" - self._like_pattern = pattern - return self - - def get_params(self) -> Dict[str, Any]: - params = super().get_params() - if self._like_pattern: - params["like_pattern"] = self._like_pattern - return params + self.like_pattern: Optional[str] = like_pattern def to_sql(self) -> SQLQueryAndParams: - return self._dialect.format_show_databases(self) + return self._dialect.format_show_databases_statement(self) -class ShowTableStatusExpression(ShowExpression): +class ShowTableStatusStatement(ShowExpression): """Expression for SHOW TABLE STATUS command. NOTE: MySQL-only command, not supported by ClickHouse. """ - def __init__(self, dialect: "ClickHouseDialect"): - super().__init__(dialect) - self._like_pattern: Optional[str] = None - - def like(self, pattern: str) -> "ShowTableStatusExpression": - """Filter tables by name pattern.""" - self._like_pattern = pattern - return self - - def get_params(self) -> Dict[str, Any]: - params = super().get_params() - if self._like_pattern: - params["like_pattern"] = self._like_pattern - return params + def __init__( + self, + dialect: "ClickHouseDialect", + *, + like_pattern: Optional[str] = None, + schema: Optional[str] = None, + ): + super().__init__(dialect, schema=schema) + self.like_pattern: Optional[str] = like_pattern def to_sql(self) -> SQLQueryAndParams: - return self._dialect.format_show_table_status(self) + return self._dialect.format_show_table_status_statement(self) -class ShowTriggersExpression(ShowExpression): +class ShowTriggersStatement(ShowExpression): """Expression for SHOW TRIGGERS command. NOTE: MySQL-only command, not supported by ClickHouse. """ - def __init__(self, dialect: "ClickHouseDialect"): - super().__init__(dialect) - self._table: Optional[str] = None - - def for_table(self, table: str) -> "ShowTriggersExpression": - """Filter triggers for a specific table.""" - self._table = table - return self - - def get_params(self) -> Dict[str, Any]: - params = super().get_params() - if self._table: - params["table"] = self._table - return params + def __init__( + self, + dialect: "ClickHouseDialect", + *, + table: Optional[str] = None, + schema: Optional[str] = None, + ): + super().__init__(dialect, schema=schema) + self.table: Optional[str] = table def to_sql(self) -> SQLQueryAndParams: - return self._dialect.format_show_triggers(self) + return self._dialect.format_show_triggers_statement(self) -class ShowCreateTriggerExpression(ShowExpression): +class ShowCreateTriggerStatement(ShowExpression): """Expression for SHOW CREATE TRIGGER command. NOTE: MySQL-only command, not supported by ClickHouse. """ - def __init__(self, dialect: "ClickHouseDialect", trigger: str): - super().__init__(dialect) - self._trigger = trigger - - def get_params(self) -> Dict[str, Any]: - params = super().get_params() - params["trigger"] = self._trigger - return params + def __init__( + self, + dialect: "ClickHouseDialect", + trigger: str, + *, + schema: Optional[str] = None, + ): + super().__init__(dialect, schema=schema) + self.trigger: str = trigger def to_sql(self) -> SQLQueryAndParams: - return self._dialect.format_show_create_trigger(self) + return self._dialect.format_show_create_trigger_statement(self) -class ShowVariablesExpression(ShowExpression): +class ShowVariablesStatement(ShowExpression): """Expression for SHOW VARIABLES command. NOTE: MySQL-only command, not supported by ClickHouse. - - ``session``, ``like_pattern`` and ``schema`` are keyword arguments as well as - fluent methods; see :class:`ShowTablesExpression` for why the constructor has - to accept what ``get_params`` emits. """ def __init__( @@ -331,46 +253,18 @@ def __init__( like_pattern: Optional[str] = None, schema: Optional[str] = None, ): - super().__init__(dialect) - self._like_pattern: Optional[str] = like_pattern - self._session: bool = session - if schema is not None: - self._schema = schema - - def like(self, pattern: str) -> "ShowVariablesExpression": - """Filter variables by name pattern.""" - self._like_pattern = pattern - return self - - def session(self, value: bool = True) -> "ShowVariablesExpression": - """Show session variables (default).""" - self._session = value - return self - - def global_vars(self) -> "ShowVariablesExpression": - """Show global variables.""" - self._session = False - return self - - def get_params(self) -> Dict[str, Any]: - params = super().get_params() - params["session"] = self._session - if self._like_pattern: - params["like_pattern"] = self._like_pattern - return params + super().__init__(dialect, schema=schema) + self.session: bool = session + self.like_pattern: Optional[str] = like_pattern def to_sql(self) -> SQLQueryAndParams: - return self._dialect.format_show_variables(self) + return self._dialect.format_show_variables_statement(self) -class ShowStatusExpression(ShowExpression): +class ShowStatusStatement(ShowExpression): """Expression for SHOW STATUS command. NOTE: MySQL-only command, not supported by ClickHouse. - - ``session``, ``like_pattern`` and ``schema`` are keyword arguments as well as - fluent methods; see :class:`ShowTablesExpression` for why the constructor has - to accept what ``get_params`` emits. """ def __init__( @@ -381,39 +275,15 @@ def __init__( like_pattern: Optional[str] = None, schema: Optional[str] = None, ): - super().__init__(dialect) - self._like_pattern: Optional[str] = like_pattern - self._session: bool = session - if schema is not None: - self._schema = schema - - def like(self, pattern: str) -> "ShowStatusExpression": - """Filter status by name pattern.""" - self._like_pattern = pattern - return self - - def session(self, value: bool = True) -> "ShowStatusExpression": - """Show session status (default).""" - self._session = value - return self - - def global_status(self) -> "ShowStatusExpression": - """Show global status.""" - self._session = False - return self - - def get_params(self) -> Dict[str, Any]: - params = super().get_params() - params["session"] = self._session - if self._like_pattern: - params["like_pattern"] = self._like_pattern - return params + super().__init__(dialect, schema=schema) + self.session: bool = session + self.like_pattern: Optional[str] = like_pattern def to_sql(self) -> SQLQueryAndParams: - return self._dialect.format_show_status(self) + return self._dialect.format_show_status_statement(self) -class ShowProcessListExpression(ShowExpression): +class ShowProcessListStatement(ShowExpression): """Expression for SHOW PROCESSLIST command. ClickHouse has this statement; it is ``SELECT * FROM system.processes``. @@ -428,170 +298,129 @@ def __init__( full: bool = False, schema: Optional[str] = None, ): - super().__init__(dialect) - self._full: bool = full - if schema is not None: - self._schema = schema - - def full(self, value: bool = True) -> "ShowProcessListExpression": - """Include full query text.""" - self._full = value - return self - - def get_params(self) -> Dict[str, Any]: - params = super().get_params() - params["full"] = self._full - return params + super().__init__(dialect, schema=schema) + self.full: bool = full def to_sql(self) -> SQLQueryAndParams: - return self._dialect.format_show_processlist(self) + return self._dialect.format_show_process_list_statement(self) -class ShowWarningsExpression(ShowExpression): +class ShowWarningsStatement(ShowExpression): """Expression for SHOW WARNINGS command. NOTE: MySQL-only command, not supported by ClickHouse. """ - def __init__(self, dialect: "ClickHouseDialect"): + def __init__( + self, + dialect: "ClickHouseDialect", + *, + limit: Optional[int] = None, + ): super().__init__(dialect) - self._limit: Optional[int] = None - - def limit(self, count: int) -> "ShowWarningsExpression": - """Limit number of warnings returned.""" - self._limit = count - return self - - def get_params(self) -> Dict[str, Any]: - params = super().get_params() - if self._limit is not None: - params["limit"] = self._limit - return params + self.limit: Optional[int] = limit def to_sql(self) -> SQLQueryAndParams: - return self._dialect.format_show_warnings(self) + return self._dialect.format_show_warnings_statement(self) -class ShowErrorsExpression(ShowExpression): +class ShowErrorsStatement(ShowExpression): """Expression for SHOW ERRORS command. NOTE: MySQL-only command, not supported by ClickHouse. """ - def __init__(self, dialect: "ClickHouseDialect"): + def __init__( + self, + dialect: "ClickHouseDialect", + *, + limit: Optional[int] = None, + ): super().__init__(dialect) - self._limit: Optional[int] = None - - def limit(self, count: int) -> "ShowErrorsExpression": - """Limit number of errors returned.""" - self._limit = count - return self - - def get_params(self) -> Dict[str, Any]: - params = super().get_params() - if self._limit is not None: - params["limit"] = self._limit - return params + self.limit: Optional[int] = limit def to_sql(self) -> SQLQueryAndParams: - return self._dialect.format_show_errors(self) + return self._dialect.format_show_errors_statement(self) -class ShowEnginesExpression(ShowExpression): +class ShowEnginesStatement(ShowExpression): """Expression for SHOW ENGINES command. ClickHouse has this statement; it is ``SELECT * FROM system.table_engines``. """ def to_sql(self) -> SQLQueryAndParams: - return self._dialect.format_show_engines(self) + return self._dialect.format_show_engines_statement(self) -class ShowCharsetExpression(ShowExpression): +class ShowCharsetStatement(ShowExpression): """Expression for SHOW CHARACTER SET command. NOTE: MySQL-only command, not supported by ClickHouse. """ - def __init__(self, dialect: "ClickHouseDialect"): + def __init__( + self, + dialect: "ClickHouseDialect", + *, + like_pattern: Optional[str] = None, + ): super().__init__(dialect) - self._like_pattern: Optional[str] = None - - def like(self, pattern: str) -> "ShowCharsetExpression": - """Filter character sets by name pattern.""" - self._like_pattern = pattern - return self - - def get_params(self) -> Dict[str, Any]: - params = super().get_params() - if self._like_pattern: - params["like_pattern"] = self._like_pattern - return params + self.like_pattern: Optional[str] = like_pattern def to_sql(self) -> SQLQueryAndParams: - return self._dialect.format_show_charset(self) + return self._dialect.format_show_charset_statement(self) -class ShowCollationExpression(ShowExpression): +class ShowCollationStatement(ShowExpression): """Expression for SHOW COLLATION command. NOTE: MySQL-only command, not supported by ClickHouse. """ - def __init__(self, dialect: "ClickHouseDialect"): + def __init__( + self, + dialect: "ClickHouseDialect", + *, + like_pattern: Optional[str] = None, + ): super().__init__(dialect) - self._like_pattern: Optional[str] = None - - def like(self, pattern: str) -> "ShowCollationExpression": - """Filter collations by name pattern.""" - self._like_pattern = pattern - return self - - def get_params(self) -> Dict[str, Any]: - params = super().get_params() - if self._like_pattern: - params["like_pattern"] = self._like_pattern - return params + self.like_pattern: Optional[str] = like_pattern def to_sql(self) -> SQLQueryAndParams: - return self._dialect.format_show_collation(self) + return self._dialect.format_show_collation_statement(self) -class ShowGrantsExpression(ShowExpression): +class ShowGrantsStatement(ShowExpression): """Expression for SHOW GRANTS command. ClickHouse has this statement. Its ``FOR`` clause takes access-entity names - only — there is no ``user@host`` form — so ``host`` is accepted and ignored. + only — there is no ``user@host`` form — so ``host`` is recorded and not + rendered. It stays a parameter because the callers that drive SHOW from a + MySQL-shaped interface pass it; the dialect formatter is where the fact that + ClickHouse cannot spell it is stated. """ - def __init__(self, dialect: "ClickHouseDialect"): + def __init__( + self, + dialect: "ClickHouseDialect", + *, + user: Optional[str] = None, + host: Optional[str] = None, + ): super().__init__(dialect) - self._user: Optional[str] = None - self._host: Optional[str] = None - - def for_user(self, user: str, host: Optional[str] = None) -> "ShowGrantsExpression": - """Show grants for a specific user.""" - self._user = user - self._host = host - return self - - def get_params(self) -> Dict[str, Any]: - params = super().get_params() - if self._user: - params["user"] = self._user - if self._host: - params["host"] = self._host - return params + self.user: Optional[str] = user + self.host: Optional[str] = host def to_sql(self) -> SQLQueryAndParams: - return self._dialect.format_show_grants(self) + return self._dialect.format_show_grants_statement(self) -class ShowPluginsExpression(ShowExpression): +class ShowPluginsStatement(ShowExpression): """Expression for SHOW PLUGINS command. NOTE: MySQL-only command, not supported by ClickHouse. """ def to_sql(self) -> SQLQueryAndParams: - return self._dialect.format_show_plugins(self) + return self._dialect.format_show_plugins_statement(self) diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/spatial.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/spatial.py index ad39e15..4dd7edd 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/spatial.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/spatial.py @@ -11,12 +11,12 @@ - ClickHouseSTWithinExpression - ClickHouseSTContainsExpression - ClickHouseSpatialLiteralExpression -- ClickHouseCreateSpatialIndexExpression +- ClickHouseCreateSpatialIndexStatement """ from typing import TYPE_CHECKING, Optional -from rhosocial.activerecord.backend.expression.bases import SQLValueExpression +from rhosocial.activerecord.backend.expression.bases import SQLValue from rhosocial.activerecord.backend.expression.mixins import ( AliasableMixin, ComparisonMixin, @@ -26,7 +26,7 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class ClickHouseSpatialLiteralExpression(AliasableMixin, SQLValueExpression): +class ClickHouseSpatialLiteralExpression(AliasableMixin, SQLValue): """ClickHouse spatial literal expression. Creates a geometry value from WKT. @@ -51,10 +51,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_spatial_literal" + return "format_spatial_literal_expression" -class ClickHouseSTGeomFromTextExpression(AliasableMixin, SQLValueExpression): +class ClickHouseSTGeomFromTextExpression(AliasableMixin, SQLValue): """ClickHouse ST_GeomFromText expression. Creates a geometry value from WKT. @@ -79,10 +79,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_st_geom_from_text" + return "format_st_geom_from_text_expression" -class ClickHouseSTGeomFromWKBExpression(AliasableMixin, SQLValueExpression): +class ClickHouseSTGeomFromWKBExpression(AliasableMixin, SQLValue): """ClickHouse ST_GeomFromWKB expression. Creates a geometry value from WKB. @@ -107,10 +107,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_st_geom_from_wkb" + return "format_st_geom_from_wkb_expression" -class ClickHouseSTAsTextExpression(AliasableMixin, SQLValueExpression): +class ClickHouseSTAsTextExpression(AliasableMixin, SQLValue): """ClickHouse ST_AsText expression. Converts a geometry to WKT. @@ -133,10 +133,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_st_as_text" + return "format_st_as_text_expression" -class ClickHouseSTAsGeoJSONExpression(AliasableMixin, SQLValueExpression): +class ClickHouseSTAsGeoJSONExpression(AliasableMixin, SQLValue): """ClickHouse ST_AsGeoJSON expression. Converts a geometry to GeoJSON. @@ -159,10 +159,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_st_as_geojson" + return "format_st_as_geo_json_expression" -class ClickHouseSTDistanceExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class ClickHouseSTDistanceExpression(AliasableMixin, ComparisonMixin, SQLValue): """ClickHouse ST_Distance expression. Returns the distance between two geometries. @@ -187,10 +187,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_st_distance" + return "format_st_distance_expression" -class ClickHouseSTWithinExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class ClickHouseSTWithinExpression(AliasableMixin, ComparisonMixin, SQLValue): """ClickHouse ST_Within expression. Returns whether one geometry is within another. @@ -215,10 +215,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_st_within" + return "format_st_within_expression" -class ClickHouseSTContainsExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class ClickHouseSTContainsExpression(AliasableMixin, ComparisonMixin, SQLValue): """ClickHouse ST_Contains expression. Returns whether one geometry contains another. @@ -243,14 +243,14 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_st_contains" + return "format_st_contains_expression" -class ClickHouseCreateSpatialIndexExpression(SQLValueExpression): +class ClickHouseCreateSpatialIndexStatement(SQLValue): """ClickHouse CREATE SPATIAL INDEX expression. Example: - >>> expr = ClickHouseCreateSpatialIndexExpression(dialect, 'idx', 'table', 'column') + >>> expr = ClickHouseCreateSpatialIndexStatement(dialect, 'idx', 'table', 'column') """ def __init__( @@ -268,7 +268,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_create_spatial_index" + return "format_create_spatial_index_statement" __all__ = [ @@ -280,5 +280,5 @@ def format_method(self) -> str: "ClickHouseSTDistanceExpression", "ClickHouseSTWithinExpression", "ClickHouseSTContainsExpression", - "ClickHouseCreateSpatialIndexExpression", + "ClickHouseCreateSpatialIndexStatement", ] diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/status.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/status.py new file mode 100644 index 0000000..3f8938c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/status.py @@ -0,0 +1,339 @@ +# src/rhosocial/activerecord/backend/impl/clickhouse/expression/status.py +"""ClickHouse status-introspection expressions. + +The status introspector held the catalog SQL itself: f-strings assembled at the +call site, and `SELECT ... WHERE x IN (` + placeholders built by counting a +list's length. A SQL string is a formatting artefact, so it belongs to the +dialect formatter, not to the layer that executes the query +(expression-dialect §16.2): the node collects the parameters, the formatter +writes the text, and the introspector only constructs and executes. + +Each node therefore carries only the parameters its query needs. Where the old +text bound a name list, the node binds the same list in the same order; where +the old text compared a name inline, the node takes it as a bound value. + +The names are inventories, not claims: every list a caller passes here is a +shortlist, and the server's intersection with it is what comes back. + +Every class here declares its own formatter. The five inventory reads share a +shape, but sharing it through a common base would put an intermediate class in +the tree that has no kind and no formatter -- a node-shaped thing that can +never render. The shape is therefore written out per class, which is also what +lets each one's projection differ without a mode parameter. +""" + +from typing import List, Optional, Sequence, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + from rhosocial.activerecord.backend.expression.bases import SQLPredicate + + +__all__ = [ + "ClickHouseSettingsQuery", + "ClickHouseServerSettingsQuery", + "ClickHouseMetricsQuery", + "ClickHouseEventsQuery", + "ClickHouseAsynchronousMetricsQuery", + "ClickHouseConnectionGaugesQuery", + "ClickHouseServerSettingQuery", + "ClickHouseSettingQuery", + "ClickHouseConnectionProbeQuery", + "ClickHouseDatabaseTotalSizeQuery", + "ClickHouseDiskListQuery", + "ClickHouseTableCountQuery", + "ClickHouseUserListQuery", + "ClickHouseGrantListQuery", + "ClickHouseRoleGrantListQuery", + "ClickHouseCurrentUserQuery", + "ClickHouseVersionQuery", + "ClickHouseProcessListQuery", + "ClickHouseReplicaListQuery", + "ClickHouseRowCountQuery", +] + + +class ClickHouseSettingsQuery(BaseExpression): + """Rows of ``system.settings`` for the requested names.""" + + def __init__(self, dialect: "SQLDialectBase", names: Sequence[str]): + super().__init__(dialect) + self.names: List[str] = list(names) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_settings_query" + + +class ClickHouseServerSettingsQuery(BaseExpression): + """Rows of ``system.server_settings`` for the requested names.""" + + def __init__(self, dialect: "SQLDialectBase", names: Sequence[str]): + super().__init__(dialect) + self.names: List[str] = list(names) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_server_settings_query" + + +class ClickHouseMetricsQuery(BaseExpression): + """Gauge rows of ``system.metrics`` for the requested names.""" + + def __init__(self, dialect: "SQLDialectBase", names: Sequence[str]): + super().__init__(dialect) + self.names: List[str] = list(names) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_metrics_query" + + +class ClickHouseEventsQuery(BaseExpression): + """Counter rows of ``system.events`` for the requested names.""" + + def __init__(self, dialect: "SQLDialectBase", names: Sequence[str]): + super().__init__(dialect) + self.names: List[str] = list(names) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_events_query" + + +class ClickHouseAsynchronousMetricsQuery(BaseExpression): + """Sampled rows of ``system.asynchronous_metrics`` for the requested names.""" + + def __init__(self, dialect: "SQLDialectBase", names: Sequence[str]): + super().__init__(dialect) + self.names: List[str] = list(names) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_asynchronous_metrics_query" + + +class ClickHouseConnectionGaugesQuery(BaseExpression): + """``metric``/``value`` rows of ``system.metrics`` for the requested names. + + Its own node rather than a flag on :class:`ClickHouseMetricsQuery`: the + projection is narrower (two columns, no description), and a parameter that + selects a projection is the "mode" field one contract per formatter exists + to keep out. + """ + + def __init__(self, dialect: "SQLDialectBase", names: Sequence[str]): + super().__init__(dialect) + self.names: List[str] = list(names) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_connection_gauges_query" + + +class ClickHouseServerSettingQuery(BaseExpression): + """One ``name``/``value`` row of ``system.server_settings``.""" + + def __init__(self, dialect: "SQLDialectBase", name: str): + super().__init__(dialect) + self.name = name + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_server_setting_query" + + +class ClickHouseSettingQuery(BaseExpression): + """One ``value`` row of ``system.settings``. + + The session-level counterpart of :class:`ClickHouseServerSettingQuery`: a + setting a query or the session can change lives in ``system.settings``, and + ``system.server_settings`` returns no row for it. + """ + + def __init__(self, dialect: "SQLDialectBase", name: str): + super().__init__(dialect) + self.name = name + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_setting_query" + + +class ClickHouseConnectionProbeQuery(BaseExpression): + """The liveness probe: a scalar ``SELECT 1`` with no relation. + + A connection check is a statement like any other, and the backend's health + check sends it. There is nothing to bind and nothing to name -- just a value + position -- so the node holds no parameters and the formatter writes the + constant. + """ + + def __init__(self, dialect: "SQLDialectBase"): + super().__init__(dialect) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_connection_probe_query" + + +class ClickHouseDatabaseTotalSizeQuery(BaseExpression): + """Total on-disk size of one database's tables.""" + + def __init__(self, dialect: "SQLDialectBase", database: str): + super().__init__(dialect) + self.database = database + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_database_total_size_query" + + +class ClickHouseDiskListQuery(BaseExpression): + """Every row of ``system.disks``, with its capacity and flags.""" + + def __init__(self, dialect: "SQLDialectBase"): + super().__init__(dialect) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_disk_list_query" + + +class ClickHouseTableCountQuery(BaseExpression): + """Table and view counts per database, from ``information_schema.TABLES``.""" + + def __init__(self, dialect: "SQLDialectBase", schemas: Sequence[str]): + super().__init__(dialect) + self.schemas: List[str] = list(schemas) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_table_count_query" + + +class ClickHouseUserListQuery(BaseExpression): + """Every row of ``system.users``.""" + + def __init__(self, dialect: "SQLDialectBase"): + super().__init__(dialect) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_user_list_query" + + +class ClickHouseGrantListQuery(BaseExpression): + """Database- and table-scoped grants, from ``system.grants``.""" + + def __init__(self, dialect: "SQLDialectBase"): + super().__init__(dialect) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_grant_list_query" + + +class ClickHouseRoleGrantListQuery(BaseExpression): + """Granted roles, from ``system.role_grants``.""" + + def __init__(self, dialect: "SQLDialectBase"): + super().__init__(dialect) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_role_grant_list_query" + + +class ClickHouseCurrentUserQuery(BaseExpression): + """The user this session authenticates as.""" + + def __init__(self, dialect: "SQLDialectBase"): + super().__init__(dialect) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_current_user_query" + + +class ClickHouseVersionQuery(BaseExpression): + """The server's own version string.""" + + def __init__(self, dialect: "SQLDialectBase"): + super().__init__(dialect) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_version_query" + + +class ClickHouseProcessListQuery(BaseExpression): + """The running queries, from ``system.processes``.""" + + def __init__(self, dialect: "SQLDialectBase"): + super().__init__(dialect) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_process_list_query" + + +class ClickHouseReplicaListQuery(BaseExpression): + """Replication summary rows from ``system.replicas``.""" + + def __init__(self, dialect: "SQLDialectBase", limit: Optional[int] = None): + super().__init__(dialect) + self.limit = limit + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_replica_list_query" + + +class ClickHouseRowCountQuery(BaseExpression): + """How many rows a predicate matches, as ``SELECT count()``. + + ClickHouse reports no affected-row count for a mutation, so the backend + pre-counts with this read. The predicate is the same node the mutation + carries -- it is not re-derived from text, which is what the concatenated + form did (it rendered the WHERE clause and then stripped the word back off + the front). + """ + + def __init__( + self, + dialect: "SQLDialectBase", + table: str, + where: "SQLPredicate", + schema_name: Optional[str] = None, + ): + super().__init__(dialect) + self.table = table + self.where = where + self.schema_name = schema_name + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_row_count_query" diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/storage_option.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/storage_option.py new file mode 100644 index 0000000..071bf04 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/storage_option.py @@ -0,0 +1,249 @@ +# src/rhosocial/activerecord/backend/impl/clickhouse/expression/storage_option.py +"""ClickHouse table storage clauses as expression nodes. + +``CREATE TABLE`` in ClickHouse ends with a run of storage clauses: + + ENGINE = MergeTree PARTITION BY toYYYYMM(ts) ORDER BY (ts, id) + TTL ts + INTERVAL 90 DAY DELETE SETTINGS index_granularity = 8192 + +The generic ``StorageOptionsExpression`` models this with a ``Dict[str, Any]`` +bag, which cannot express the clauses' types, their canonical order, or their +own spellings. This module gives each clause its own node: + +* :class:`ClickHouseTableStorageOption` -- one clause, an ``option`` member + plus a ``value`` whose shape the enum member decides. +* :class:`ClickHouseEngineSpec` -- an engine name and its parameters, the + value of ``ENGINE`` (``ReplacingMergeTree(version)``). +* :class:`ClickHouseSetting` -- one ``name = value`` pair, an item of + ``SETTINGS``. + +Every node declares its own formatting entry (``format_clickhouse_*``) and +therefore its own kind, so none of them collides with a core kind the way +``ClickHouseColumnDefinition`` does. ``ClickHouse`` is declared as a reserved +word in the package ``__init__``, so the derived kinds keep it whole. +""" + +from __future__ import annotations + +from enum import Enum +from typing import TYPE_CHECKING, Any, Sequence, Tuple, Union + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "CLICKHOUSE_STORAGE_CLAUSE_ORDER", + "ClickHouseEngineSpec", + "ClickHouseSetting", + "ClickHouseStorageOptionKind", + "ClickHouseTableStorageOption", +] + + +class ClickHouseStorageOptionKind(str, Enum): + """The storage clauses ClickHouse accepts after the column list. + + The members are ordered as ClickHouse's grammar orders them; a renderer + emits them in this order regardless of the order the caller supplied. + """ + + ENGINE = "ENGINE" + PRIMARY_KEY = "PRIMARY KEY" + PARTITION_BY = "PARTITION BY" + ORDER_BY = "ORDER BY" + SAMPLE_BY = "SAMPLE BY" + TTL = "TTL" + SETTINGS = "SETTINGS" + + @property + def keyword(self) -> str: + """The SQL keyword(s) this member renders as.""" + return self.value + + @property + def rank(self) -> int: + """Position in the canonical clause order.""" + return CLICKHOUSE_STORAGE_CLAUSE_ORDER.index(self) + + #: ``enum.StrEnum`` is 3.11+, and this project supports 3.10. Without this + #: line ``str(member)`` is ``"ClickHouseStorageOptionKind.ENGINE"``, which + #: is what the artifact would then carry -- a Python class name crossing + #: the wire. With it the wire says ``"ENGINE"``, the SQL keyword itself. + __str__ = str.__str__ + + +#: ClickHouse's clause order. Derived from the member declaration order so the +#: two cannot drift apart. +CLICKHOUSE_STORAGE_CLAUSE_ORDER: Tuple[ClickHouseStorageOptionKind, ...] = tuple( + ClickHouseStorageOptionKind +) + + +class ClickHouseEngineSpec(BaseExpression): + """A ClickHouse table engine and its parameters. + + ``ENGINE = MergeTree`` has no parameters; ``ReplacingMergeTree(version)`` + and ``Distributed('cluster', 'db', 'table', rand())`` do. The name is the + fixed engine vocabulary, so it is emitted verbatim; each parameter is an + expression node, never a raw fragment. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + engine: str, + *, + parameters: Sequence[BaseExpression] = (), + ): + super().__init__(dialect) + if not isinstance(engine, str) or not engine: + raise TypeError(f"engine must be a non-empty string, got {type(engine).__name__}") + for parameter in parameters: + if not isinstance(parameter, BaseExpression): + raise TypeError( + f"parameters must be expression nodes, " f"got {type(parameter).__name__}" + ) + self.engine = engine + self.parameters = tuple(parameters) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_engine_spec" + + +class ClickHouseSetting(BaseExpression): + """One ``name = value`` item of a ``SETTINGS`` clause. + + The value is a language-neutral scalar or an expression node. It is not a + raw string: what the caller wrote is not what gets emitted, the literal + formatter decides that. + """ + + def __init__(self, dialect: "SQLDialectBase", name: str, value: Any): + super().__init__(dialect) + if not isinstance(name, str) or not name: + raise TypeError(f"name must be a non-empty string, got {type(name).__name__}") + if not isinstance(value, (str, int, float, bool, BaseExpression)): + raise TypeError( + f"value must be a scalar or an expression node, " f"got {type(value).__name__}" + ) + self.name = name + self.value = value + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_setting" + + +class ClickHouseTableStorageOption(BaseExpression): + """One storage clause of a ClickHouse ``CREATE TABLE``. + + The node is singular on purpose: each clause has its own payload shape, so + one node carrying the whole run would have to degrade ``value`` to + ``Any``. A caller declares a sequence of these; the renderer orders them + by :attr:`ClickHouseStorageOptionKind.rank` and refuses a repeated clause. + + ``value`` is validated against ``option`` here, at construction, and + nowhere else: + + ================== ============================================= + ``option`` accepted ``value`` + ================== ============================================= + ``ENGINE`` :class:`ClickHouseEngineSpec` + ``PRIMARY_KEY`` sequence of expression nodes + ``PARTITION_BY`` sequence of expression nodes + ``ORDER_BY`` sequence of expression nodes + ``SAMPLE_BY`` expression node + ``TTL`` expression node + ``SETTINGS`` sequence of :class:`ClickHouseSetting` + ================== ============================================= + """ + + #: Members whose payload is a list of expressions. + LIST_MEMBERS = frozenset( + { + ClickHouseStorageOptionKind.PRIMARY_KEY, + ClickHouseStorageOptionKind.PARTITION_BY, + ClickHouseStorageOptionKind.ORDER_BY, + } + ) + + #: Members whose payload is a single expression. + EXPRESSION_MEMBERS = frozenset( + { + ClickHouseStorageOptionKind.SAMPLE_BY, + ClickHouseStorageOptionKind.TTL, + } + ) + + def __init__( + self, + dialect: "SQLDialectBase", + option: Union[ClickHouseStorageOptionKind, str], + *, + value: Any, + ): + super().__init__(dialect) + # A str-enum is a str on the wire: the artifact carries ``"ENGINE"``, + # never the member object, so the constructor has to accept the + # spelling back. Coercing here, at the single validation point, keeps + # the node reconstructible instead of making every reader special-case + # it. An unknown spelling raises the enum's own ``ValueError``. + if not isinstance(option, ClickHouseStorageOptionKind): + if isinstance(option, str): + option = ClickHouseStorageOptionKind(option) + else: + raise TypeError( + f"option must be a ClickHouseStorageOptionKind or its " + f"spelling, got {type(option).__name__}" + ) + self.option = option + self.value = self._checked_value(option, value) + + @staticmethod + def _checked_value(option: ClickHouseStorageOptionKind, value: Any) -> Any: + """Validate ``value`` against ``option`` and return it normalised.""" + if option is ClickHouseStorageOptionKind.ENGINE: + if not isinstance(value, ClickHouseEngineSpec): + raise TypeError( + f"ENGINE takes a ClickHouseEngineSpec, " f"got {type(value).__name__}" + ) + return value + + if option in ClickHouseTableStorageOption.EXPRESSION_MEMBERS: + if not isinstance(value, BaseExpression): + raise TypeError( + f"{option.keyword} takes an expression node, " f"got {type(value).__name__}" + ) + return value + + if option is ClickHouseStorageOptionKind.SETTINGS: + items = tuple(value) + if not items: + raise ValueError("SETTINGS takes at least one ClickHouseSetting") + for item in items: + if not isinstance(item, ClickHouseSetting): + raise TypeError( + f"SETTINGS takes ClickHouseSetting items, " f"got {type(item).__name__}" + ) + return items + + items = tuple(value) + if not items: + raise ValueError(f"{option.keyword} takes at least one expression") + for item in items: + if not isinstance(item, BaseExpression): + raise TypeError( + f"{option.keyword} takes expression nodes, " f"got {type(item).__name__}" + ) + return items + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_clickhouse_table_storage_option" diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/table_statement.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/table_statement.py index d8c5908..06dcaa5 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/table_statement.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/table_statement.py @@ -73,7 +73,7 @@ def _validate_common(self) -> None: raise ValueError("offset must be a non-negative integer") -class ClickHouseTableExpression(ClickHouseBaseTableStatement): +class ClickHouseTableStatement(ClickHouseBaseTableStatement): """MySQL's ``TABLE
`` simplified SELECT — a statement ClickHouse does not have. Fail-fast switch: ``to_sql()`` raises ``UnsupportedFeatureError`` through the @@ -110,7 +110,7 @@ def format_method(self) -> str: return "format_table_statement" -class ClickHouseValuesExpression(ClickHouseBaseTableStatement): +class ClickHouseValuesStatement(ClickHouseBaseTableStatement): """MySQL's ``VALUES ROW(...), ...`` table value constructor — not in ClickHouse. Fail-fast switch: ``to_sql()`` raises ``UnsupportedFeatureError`` through the diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/expression/wildcard.py b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/wildcard.py new file mode 100644 index 0000000..2670ef7 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/expression/wildcard.py @@ -0,0 +1,56 @@ +# src/rhosocial/activerecord/backend/impl/clickhouse/expression/wildcard.py +"""ClickHouse's own row-count wildcard: ``COUNT(DISTINCT *)``. + +Core splits the one asterisk into two by grammar position (spec 4.10): the +select-list ``*`` expands columns and belongs to ``AllColumnsWildcard``, while +``COUNT(*)``'s asterisk counts rows and belongs to ``RowCountWildcard``. Core +draws the line at the bare asterisk -- ``RowCountWildcard`` accepts no relation, +because standard ``COUNT(*)`` never qualifies it. + +ClickHouse adds one spelling the specification leaves to the backends that have +it: ``COUNT(DISTINCT *)``, which counts the distinct combinations of every +column's value rather than the rows (:file:`wildcard-survey.md` row B8; only +ClickHouse accepts it of the eleven measured backends). It is not a flag on +core's node -- the same asterisk answers a different question -- so it is a +derived node here, and the catalog names it +``ClickHouseRowCountDistinctWildcard``. + +**Reachability, and the core short-circuit it runs into.** Core's shared +``format_function_call`` answers *any* ``RowCountWildcard`` argument with the +bare asterisk before consulting a formatter, and takes the ``DISTINCT`` from the +call's own ``is_distinct`` flag rather than from the argument. So the full +statement is reached through that flag -- + + count(dialect, "*", is_distinct=True) -> COUNT(DISTINCT *) + +-- and this node's own ``to_sql()`` renders the argument text, ``DISTINCT *``. +The node names the capability; it does not carry the aggregate around it. The +same core short-circuit is recorded against the PostgreSQL and Snowflake +row-count wildcards, and changing it means changing core's shared formatter, +which this branch does not do. +""" + +from typing import TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.core import RowCountWildcard + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +class ClickHouseRowCountDistinctWildcard(RowCountWildcard): + """``COUNT(DISTINCT *)`` -- the distinct combinations, not the rows. + + Core's ``RowCountWildcard`` renders the bare ``*`` and is left alone; this + node adds the ``DISTINCT`` that only ClickHouse spells in this position. + + It takes no relation, exactly as core's does: ClickHouse writes the + argument as ``DISTINCT *`` and has no qualified form here, so there is no + relation slot to fill and none is added. + """ + + def __init__(self, dialect: "SQLDialectBase"): + super().__init__(dialect) + + +__all__ = ["ClickHouseRowCountDistinctWildcard"] diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/introspection/__init__.py b/src/rhosocial/activerecord/backend/impl/clickhouse/introspection/__init__.py index 2b83311..789cb63 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/introspection/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/introspection/__init__.py @@ -9,6 +9,9 @@ AsyncShowIntrospector — asynchronous ClickHouse-specific SHOW command sub-introspector SyncClickHouseStatusIntrospector — synchronous ClickHouse status introspector AsyncClickHouseStatusIntrospector -- asynchronous ClickHouse status introspector + +Also re-exports the ClickHouse-specific result types (`ClickHousePartitionInfo`, +`ClickHouseTableEngineInfo`) the two introspectors return. """ from .introspector import ( @@ -23,6 +26,10 @@ SyncClickHouseStatusIntrospector, AsyncClickHouseStatusIntrospector, ) +from .types import ( + ClickHousePartitionInfo, + ClickHouseTableEngineInfo, +) __all__ = [ "SyncClickHouseIntrospector", @@ -31,4 +38,6 @@ "AsyncShowIntrospector", "SyncClickHouseStatusIntrospector", "AsyncClickHouseStatusIntrospector", + "ClickHousePartitionInfo", + "ClickHouseTableEngineInfo", ] diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/clickhouse/introspection/introspector.py index d25acf0..563d7a0 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/introspection/introspector.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/introspection/introspector.py @@ -12,12 +12,12 @@ - SQL generation: Delegated to ClickHouseIntrospectionMixin.format_*_query() methods in the Dialect layer via Expression.to_sql() - Query execution: Handled by IntrospectorExecutor - - Result parsing: _parse_* methods in this module (pure Python, no I/O) + - Result parsing: parse_* methods in this module (pure Python, no I/O) Key behaviours: - Queries ClickHouse system.* tables (system.tables, system.columns, system.databases, system.parts, system.triggers, etc.) - - _parse_* methods are pure Python — shared by sync and async introspectors + - parse_* methods are pure Python — shared by sync and async introspectors Design principle: Sync and Async are separate and cannot coexist. - SyncClickHouseIntrospector: for synchronous backends @@ -32,23 +32,24 @@ SyncAbstractIntrospector, AsyncAbstractIntrospector, ) +from rhosocial.activerecord.backend.introspection.cache import IntrospectionCache from rhosocial.activerecord.backend.introspection.executor import ( SyncIntrospectorExecutor, AsyncIntrospectorExecutor, ) from rhosocial.activerecord.backend.introspection.types import ( - DatabaseInfo, - TableInfo, + IntrospectionDatabaseInfo, + IntrospectionTableInfo, TableType, - ColumnInfo, + IntrospectionColumnInfo, ColumnNullable, IndexInfo, IndexColumnInfo, IndexType, - ForeignKeyInfo, + IntrospectionForeignKeyInfo, ReferentialAction, - ViewInfo, - TriggerInfo, + IntrospectionViewInfo, + IntrospectionTriggerInfo, IntrospectionScope, ) from rhosocial.activerecord.backend.expression.types import DataType @@ -60,6 +61,14 @@ SyncClickHouseStatusIntrospector, AsyncClickHouseStatusIntrospector, ) +from .types import ( + ClickHousePartitionInfo, + ClickHouseTableEngineInfo, +) +from ..expression.introspection import ( + ClickHouseTableEngineQuery, + ClickHouseTablePartitionsQuery, +) class ClickHouseIntrospectorMixin(IntrospectorMixin): @@ -69,87 +78,107 @@ class ClickHouseIntrospectorMixin(IntrospectorMixin): from this mixin to share: - Default schema handling - ClickHouse version detection - - _parse_* implementations + - parse_* implementations SQL generation is delegated to the Dialect layer via Expression.to_sql() which calls ClickHouseIntrospectionMixin.format_*_query() methods. """ - def _get_default_schema(self) -> str: + @property + def default_schema(self) -> str: """Return the ClickHouse database name from the backend config.""" if hasattr(self._backend, "config") and hasattr(self._backend.config, "database"): return self._backend.config.database or "" return "" - def _get_version(self) -> tuple: - """Return the ClickHouse server version tuple from the backend. - - Falls back to ``(0, 0, 0)``, the tuple - :meth:`...backend.backend.ClickHouseBackend.get_server_version` itself uses - for "not adapted yet", rather than to an invented version — this used to - return ``(8, 0, 0)``, which is not a release ClickHouse has and which a - caller could not distinguish from a server-reported version. - """ - return getattr(self._backend, "_version", None) or (0, 0, 0) - # ------------------------------------------------------------------ # # Parse methods — pure Python, no I/O # ------------------------------------------------------------------ # - def _parse_database_info(self, rows: List[Dict[str, Any]]) -> DatabaseInfo: - version = self._get_version() - version_str = ".".join(str(v) for v in version) - db_name = self._get_default_schema() + def parse_database_info(self, rows: List[Dict[str, Any]]) -> IntrospectionDatabaseInfo: + """Report what ``system.databases`` answers, and nothing it does not. - db_row = rows[0] if rows else {} + The version comes from :attr:`server_version`, which reads the adapted + dialect and the backend's cache without I/O and never raises. When + nothing knows it yet the convention is ``version="unknown"`` with + ``version_tuple=(0, 0, 0)``: ``0.0.0`` is not a ClickHouse release, so + reporting it as a version would be a claim the server never made. - return DatabaseInfo( - name=db_name, + A ClickHouse database has no character set and no collation, so + ``encoding`` / ``collation`` stay ``None``. The storage engine the row + does answer is kept in ``extra``. + """ + db_row = rows[0] if rows else {} + version = self.server_version + if version is None: + version_str = "unknown" + version_tuple = (0, 0, 0) + else: + version_str = ".".join(str(part) for part in version) + version_tuple = version + + engine = db_row.get("ENGINE") + extra: Dict[str, Any] = {} + if engine: + extra["engine"] = engine + + return IntrospectionDatabaseInfo( + name=db_row.get("DATABASE_NAME") or self.default_schema, version=version_str, - version_tuple=version, + version_tuple=version_tuple, vendor="ClickHouse", - encoding=db_row.get("DEFAULT_CHARACTER_SET_NAME"), - collation=db_row.get("DEFAULT_COLLATION_NAME"), + encoding=None, + collation=None, + extra=extra, ) - def _parse_tables(self, rows: List[Dict[str, Any]], schema: Optional[str]) -> List[TableInfo]: - target_db = schema if schema is not None else self._get_default_schema() - table_type_map = { - "BASE TABLE": TableType.BASE_TABLE, - "VIEW": TableType.VIEW, - "SYSTEM VIEW": TableType.SYSTEM_TABLE, - } + def parse_tables(self, rows: List[Dict[str, Any]], schema: Optional[str]) -> List[IntrospectionTableInfo]: + """Read the kind of each relation from the engine that stores it. + + ``system.tables`` has no table-type column: what it reports is the + storage engine. The dialect owns the closed set of view engines, so the + kind is asked of :meth:`engine_kind` — the same declaration the table + and view queries filter on — rather than matched against strings here. + Rows read from the ``system`` database are system tables whatever their + engine. The engine itself is kept in ``extra``: it is the fact the + catalogue did answer, and the kind does not replace it. + """ + target_db = schema if schema is not None else self.default_schema tables = [] for row in rows: - t_type = table_type_map.get(row.get("TABLE_TYPE", "BASE TABLE"), TableType.BASE_TABLE) + row_db = row.get("database") or target_db + engine = row.get("ENGINE") or "" + kind = TableType.SYSTEM_TABLE if row_db == "system" else self.dialect.engine_kind(engine) + extra: Dict[str, Any] = {"engine": engine} if engine else {} tables.append( - TableInfo( + IntrospectionTableInfo( name=row["TABLE_NAME"], schema=target_db, - table_type=t_type, + table_type=kind, comment=row.get("TABLE_COMMENT"), row_count=row.get("TABLE_ROWS"), size_bytes=row.get("DATA_LENGTH"), auto_increment=None, # ClickHouse has no AUTO_INCREMENT create_time=str(row["CREATE_TIME"]) if row.get("CREATE_TIME") else None, update_time=str(row["UPDATE_TIME"]) if row.get("UPDATE_TIME") else None, + extra=extra, ) ) return tables - def _parse_columns( + def parse_columns( self, rows: List[Dict[str, Any]], table: str, schema: str, - ) -> List[ColumnInfo]: + ) -> List[IntrospectionColumnInfo]: columns = [] for row in rows: nullable = ColumnNullable.NULLABLE if row.get("IS_NULLABLE") == "YES" else ColumnNullable.NOT_NULL col_type = row.get("COLUMN_TYPE") or row.get("DATA_TYPE") or "VARCHAR" parsed = DataType.parse_data_type_str(self._backend.dialect, col_type) columns.append( - ColumnInfo( + IntrospectionColumnInfo( name=row["COLUMN_NAME"], table_name=table, schema=schema, @@ -172,7 +201,7 @@ def _parse_columns( ) return columns - def _parse_indexes( + def parse_indexes( self, rows: List[Dict[str, Any]], table: str, @@ -208,12 +237,12 @@ def _parse_indexes( ) return list(index_map.values()) - def _parse_foreign_keys( + def parse_foreign_keys( self, rows: List[Dict[str, Any]], table: str, schema: str, - ) -> List[ForeignKeyInfo]: + ) -> List[IntrospectionForeignKeyInfo]: action_map = { "NO_ACTION": ReferentialAction.NO_ACTION, "NO ACTION": ReferentialAction.NO_ACTION, @@ -224,13 +253,13 @@ def _parse_foreign_keys( "SET_DEFAULT": ReferentialAction.SET_DEFAULT, "SET DEFAULT": ReferentialAction.SET_DEFAULT, } - fk_map: Dict[str, ForeignKeyInfo] = {} + fk_map: Dict[str, IntrospectionForeignKeyInfo] = {} for row in rows: fk_name = row.get("CONSTRAINT_NAME", "") if fk_name not in fk_map: on_update_raw = (row.get("UPDATE_RULE") or "NO ACTION").upper() on_delete_raw = (row.get("DELETE_RULE") or "NO ACTION").upper() - fk_map[fk_name] = ForeignKeyInfo( + fk_map[fk_name] = IntrospectionForeignKeyInfo( name=fk_name, table_name=table, schema=schema, @@ -244,9 +273,9 @@ def _parse_foreign_keys( fk_map[fk_name].referenced_columns.append(row.get("REFERENCED_COLUMN_NAME", "")) return list(fk_map.values()) - def _parse_views(self, rows: List[Dict[str, Any]], schema: str) -> List[ViewInfo]: + def parse_views(self, rows: List[Dict[str, Any]], schema: str) -> List[IntrospectionViewInfo]: return [ - ViewInfo( + IntrospectionViewInfo( name=row.get("TABLE_NAME", row.get("VIEW_NAME", "")), schema=schema, definition=row.get("VIEW_DEFINITION"), @@ -256,16 +285,16 @@ def _parse_views(self, rows: List[Dict[str, Any]], schema: str) -> List[ViewInfo for row in rows ] - def _parse_view_info( + def parse_view_info( self, rows: List[Dict[str, Any]], view_name: str, schema: str, - ) -> Optional[ViewInfo]: + ) -> Optional[IntrospectionViewInfo]: if not rows: return None row = rows[0] - return ViewInfo( + return IntrospectionViewInfo( name=row.get("TABLE_NAME", row.get("VIEW_NAME", view_name)), schema=schema, definition=row.get("VIEW_DEFINITION"), @@ -273,9 +302,9 @@ def _parse_view_info( is_updatable=row.get("IS_UPDATABLE") == "YES", ) - def _parse_triggers(self, rows: List[Dict[str, Any]], schema: str) -> List[TriggerInfo]: + def parse_triggers(self, rows: List[Dict[str, Any]], schema: str) -> List[IntrospectionTriggerInfo]: return [ - TriggerInfo( + IntrospectionTriggerInfo( name=row.get("TRIGGER_NAME", ""), table_name=row.get("EVENT_OBJECT_TABLE", ""), schema=schema, @@ -286,6 +315,68 @@ def _parse_triggers(self, rows: List[Dict[str, Any]], schema: str) -> List[Trigg for row in rows ] + def parse_partitions( + self, + rows: List[Dict[str, Any]], + table: str, + database: str, + ) -> List[ClickHousePartitionInfo]: + """Parse the grouped ``system.parts`` rows into partition records. + + The aggregate aliases are the dataclass field names, so each row is read + by name and nothing is inferred from a position. ``int(... or 0)`` covers + the empty group a server never returns but a projection alias could still + report as ``NULL``: a sum over an empty set is ``NULL`` in SQL, so the + fallback states "no rows" rather than raising on the conversion. + """ + return [ + ClickHousePartitionInfo( + partition_id=row.get("partition_id", ""), + table_name=table, + database=database, + engine=row.get("engine") or "", + partition=row.get("partition"), + row_count=int(row.get("row_count") or 0), + bytes_on_disk=int(row.get("bytes_on_disk") or 0), + active_part_count=int(row.get("active_part_count") or 0), + min_modification_time=row.get("min_modification_time"), + max_modification_time=row.get("max_modification_time"), + ) + for row in rows + ] + + def parse_table_engine( + self, + rows: List[Dict[str, Any]], + table: str, + database: str, + ) -> Optional[ClickHouseTableEngineInfo]: + """Parse the single ``system.tables`` row, or ``None`` when there is none. + + ``total_rows`` / ``total_bytes`` are ``Nullable(UInt64)`` and are empty + for engines that do not track them; they stay ``None`` rather than being + reported as ``0``, which would be a claim the server did not make. + """ + if not rows: + return None + row = rows[0] + + def _number(value: Any) -> Optional[int]: + return None if value is None else int(value) + + return ClickHouseTableEngineInfo( + table_name=table, + database=database, + engine=row.get("engine") or "", + sorting_key=row.get("sorting_key"), + primary_key=row.get("primary_key"), + partition_key=row.get("partition_key"), + sampling_key=row.get("sampling_key"), + total_rows=_number(row.get("total_rows")), + total_bytes=_number(row.get("total_bytes")), + comment=row.get("comment"), + ) + class SyncClickHouseIntrospector(ClickHouseIntrospectorMixin, SyncAbstractIntrospector): """Synchronous introspector for ClickHouse backends. @@ -301,8 +392,13 @@ class SyncClickHouseIntrospector(ClickHouseIntrospectorMixin, SyncAbstractIntros variables = backend.introspector.show.variables(like="max_%") """ - def __init__(self, backend: Any, executor: SyncIntrospectorExecutor) -> None: - super().__init__(backend, executor) + def __init__( + self, + backend: Any, + executor: SyncIntrospectorExecutor, + cache: Optional[IntrospectionCache] = None, + ) -> None: + super().__init__(backend, executor, cache) self._show_instance: Optional[SyncShowIntrospector] = None self._status_instance: Optional[SyncClickHouseStatusIntrospector] = None @@ -324,9 +420,9 @@ def status(self) -> SyncClickHouseStatusIntrospector: # get_table_info override # ------------------------------------------------------------------ # - def get_table_info(self, table: str, schema: Optional[str] = None) -> Optional[TableInfo]: - key = self._make_cache_key(IntrospectionScope.TABLE, table, schema=schema) - cached = self._get_cached(key) + def get_table_info(self, table: str, schema: Optional[str] = None) -> Optional[IntrospectionTableInfo]: + key = self._cache.key(IntrospectionScope.TABLE, table, schema=schema) + cached = self._cache.get(key) if cached is not None: return cached @@ -340,9 +436,84 @@ def get_table_info(self, table: str, schema: Optional[str] = None) -> Optional[T info.indexes = self.list_indexes(table, schema) # ClickHouse has no foreign keys; keep the default empty list. info.foreign_keys = [] - self._set_cached(key, info) + self._cache.set(key, info) return info + # ------------------------------------------------------------------ # + # ClickHouse-specific reads + # ------------------------------------------------------------------ # + + def list_partitions( + self, + table_name: str, + database: Optional[str] = None, + ) -> List[ClickHousePartitionInfo]: + """List a table's partitions, aggregated from its active parts. + + ClickHouse calls its top-level namespace a *database*; this backend has + no schema layer, so ``database`` is that parameter and ``None`` means the + connected database. The name is not translated into anything: it is the + same string ``system.parts.database`` holds. + + The result is cached under ``IntrospectionScope.TABLE`` with + ``extra="partition"`` — the table scope because a partition belongs to a + table, and the extra namespace because the same table also answers + column, index and engine questions and must not answer this one. + + Args: + table_name: The table to read. + database: ClickHouse database; defaults to the connected database. + + Returns: + One :class:`ClickHousePartitionInfo` per ``partition_id``, ordered + by that id. A table with no parts (empty, or an engine that has none) + returns an empty list — the server has nothing to report, so nothing + is invented. + """ + target = database if database is not None else self.default_schema + key = self._cache.key(IntrospectionScope.TABLE, table_name, schema=target, extra="partition") + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = ClickHouseTablePartitionsQuery(self._backend.dialect, target, table_name).to_sql() + result = self.parse_partitions(self._executor.execute(sql, params), table_name, target) + self._cache.set(key, result) + return result + + def get_table_engine( + self, + table_name: str, + database: Optional[str] = None, + ) -> Optional[ClickHouseTableEngineInfo]: + """Read a table's engine and key expressions from ``system.tables``. + + ``database`` follows the same rule as :meth:`list_partitions`: it is a + ClickHouse database name, and ``None`` means the connected database. + + Cached under ``IntrospectionScope.TABLE`` with ``extra="engine"``. A + missing table is **not** cached: the cache's ``get`` uses ``None`` for + "no entry", so caching ``None`` could not be distinguished from a miss + anyway, and the next call re-asks the server. + + Args: + table_name: The table to read. + database: ClickHouse database; defaults to the connected database. + + Returns: + The table's :class:`ClickHouseTableEngineInfo`, or ``None`` if the + server has no such table. + """ + target = database if database is not None else self.default_schema + key = self._cache.key(IntrospectionScope.TABLE, table_name, schema=target, extra="engine") + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = ClickHouseTableEngineQuery(self._backend.dialect, target, table_name).to_sql() + result = self.parse_table_engine(self._executor.execute(sql, params), table_name, target) + if result is not None: + self._cache.set(key, result) + return result + class AsyncClickHouseIntrospector(ClickHouseIntrospectorMixin, AsyncAbstractIntrospector): """Asynchronous introspector for ClickHouse backends. @@ -358,8 +529,13 @@ class AsyncClickHouseIntrospector(ClickHouseIntrospectorMixin, AsyncAbstractIntr variables = await backend.introspector.show.variables(like="max_%") """ - def __init__(self, backend: Any, executor: AsyncIntrospectorExecutor) -> None: - super().__init__(backend, executor) + def __init__( + self, + backend: Any, + executor: AsyncIntrospectorExecutor, + cache: Optional[IntrospectionCache] = None, + ) -> None: + super().__init__(backend, executor, cache) self._show_instance: Optional[AsyncShowIntrospector] = None self._status_instance: Optional[AsyncClickHouseStatusIntrospector] = None @@ -381,9 +557,9 @@ def status(self) -> AsyncClickHouseStatusIntrospector: # get_table_info override # ------------------------------------------------------------------ # - async def get_table_info(self, table: str, schema: Optional[str] = None) -> Optional[TableInfo]: - key = self._make_cache_key(IntrospectionScope.TABLE, table, schema=schema) - cached = self._get_cached(key) + async def get_table_info(self, table: str, schema: Optional[str] = None) -> Optional[IntrospectionTableInfo]: + key = self._cache.key(IntrospectionScope.TABLE, table, schema=schema) + cached = self._cache.get(key) if cached is not None: return cached @@ -396,5 +572,53 @@ async def get_table_info(self, table: str, schema: Optional[str] = None) -> Opti info.columns = await self.list_columns(table, schema) info.indexes = await self.list_indexes(table, schema) info.foreign_keys = await self.list_foreign_keys(table, schema) - self._set_cached(key, info) + self._cache.set(key, info) return info + + # ------------------------------------------------------------------ # + # ClickHouse-specific reads + # ------------------------------------------------------------------ # + + async def list_partitions( + self, + table_name: str, + database: Optional[str] = None, + ) -> List[ClickHousePartitionInfo]: + """List a table's partitions, aggregated from its active parts. + + The asynchronous counterpart of the synchronous method: same parameters, + same result, same cache key. See + :meth:`SyncClickHouseIntrospector.list_partitions` for the naming rule + (``database`` is ClickHouse's own top-level namespace) and the scope. + """ + target = database if database is not None else self.default_schema + key = self._cache.key(IntrospectionScope.TABLE, table_name, schema=target, extra="partition") + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = ClickHouseTablePartitionsQuery(self._backend.dialect, target, table_name).to_sql() + result = self.parse_partitions(await self._executor.execute(sql, params), table_name, target) + self._cache.set(key, result) + return result + + async def get_table_engine( + self, + table_name: str, + database: Optional[str] = None, + ) -> Optional[ClickHouseTableEngineInfo]: + """Read a table's engine and key expressions from ``system.tables``. + + The asynchronous counterpart of the synchronous method: same parameters, + same result, same cache key and the same "missing table is not cached" + rule. + """ + target = database if database is not None else self.default_schema + key = self._cache.key(IntrospectionScope.TABLE, table_name, schema=target, extra="engine") + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = ClickHouseTableEngineQuery(self._backend.dialect, target, table_name).to_sql() + result = self.parse_table_engine(await self._executor.execute(sql, params), table_name, target) + if result is not None: + self._cache.set(key, result) + return result diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/introspection/show_introspector.py b/src/rhosocial/activerecord/backend/impl/clickhouse/introspection/show_introspector.py index eaa5258..c6773b7 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/introspection/show_introspector.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/introspection/show_introspector.py @@ -18,25 +18,25 @@ AsyncIntrospectorExecutor, ) from ..expression.show import ( - ShowCreateTableExpression, - ShowCreateViewExpression, - ShowColumnsExpression, - ShowIndexExpression, - ShowTablesExpression, - ShowDatabasesExpression, - ShowTableStatusExpression, - ShowTriggersExpression, - ShowCreateTriggerExpression, - ShowVariablesExpression, - ShowStatusExpression, - ShowProcessListExpression, - ShowWarningsExpression, - ShowErrorsExpression, - ShowEnginesExpression, - ShowCharsetExpression, - ShowCollationExpression, - ShowGrantsExpression, - ShowPluginsExpression, + ShowCreateTableStatement, + ShowCreateViewStatement, + ShowColumnsStatement, + ShowIndexStatement, + ShowTablesStatement, + ShowDatabasesStatement, + ShowTableStatusStatement, + ShowTriggersStatement, + ShowCreateTriggerStatement, + ShowVariablesStatement, + ShowStatusStatement, + ShowProcessListStatement, + ShowWarningsStatement, + ShowErrorsStatement, + ShowEnginesStatement, + ShowCharsetStatement, + ShowCollationStatement, + ShowGrantsStatement, + ShowPluginsStatement, ) @@ -47,7 +47,7 @@ class ShowMixin: from this mixin to share: - Dialect access - SQL generation helpers - - _parse_* static methods + - parse_* static methods """ @property @@ -60,7 +60,7 @@ def dialect(self): # ------------------------------------------------------------------ # @staticmethod - def _parse_create_table(rows: List[Dict], table: str): + def parse_create_table(rows: List[Dict], table: str): """Parse ``SHOW CREATE TABLE``. ClickHouse answers with a single column named ``statement`` @@ -81,7 +81,7 @@ def _parse_create_table(rows: List[Dict], table: str): ) @staticmethod - def _parse_create_view(rows: List[Dict], view_name: str): + def parse_create_view(rows: List[Dict], view_name: str): """Parse ``SHOW CREATE VIEW``. Single ``statement`` column, as for ``SHOW CREATE TABLE``; the @@ -99,7 +99,7 @@ def _parse_create_view(rows: List[Dict], view_name: str): ) @staticmethod - def _parse_columns(rows: List[Dict]): + def parse_columns(rows: List[Dict]): """Parse ``SHOW [FULL] COLUMNS``. ClickHouse's columns are lower-cased: ``field``, ``type``, ``null``, @@ -144,7 +144,7 @@ def _parse_columns(rows: List[Dict]): ] @staticmethod - def _parse_indexes(rows: List[Dict]): + def parse_indexes(rows: List[Dict]): """Parse ``SHOW INDEX``. ClickHouse answers with its own names, which differ from MySQL's in one @@ -185,7 +185,7 @@ def _parse_indexes(rows: List[Dict]): ] @staticmethod - def _parse_tables(rows: List[Dict]): + def parse_tables(rows: List[Dict]): """Parse ``SHOW [FULL] TABLES``. ClickHouse names the column ``name`` in both modes and adds an @@ -210,7 +210,7 @@ def _parse_tables(rows: List[Dict]): ] @staticmethod - def _parse_databases(rows: List[Dict]): + def parse_databases(rows: List[Dict]): """Parse ``SHOW DATABASES``. The column is ``name`` (verified on 26.7.3.19); MySQL calls it @@ -221,7 +221,7 @@ def _parse_databases(rows: List[Dict]): return [ShowDatabaseResult(name=row.get("name")) for row in rows] @staticmethod - def _parse_table_status(rows: List[Dict]): + def parse_table_status(rows: List[Dict]): from ..show.types import ShowTableStatusResult return [ @@ -249,7 +249,7 @@ def _parse_table_status(rows: List[Dict]): ] @staticmethod - def _parse_triggers(rows: List[Dict]): + def parse_triggers(rows: List[Dict]): from ..show.types import ShowTriggerResult return [ @@ -270,7 +270,7 @@ def _parse_triggers(rows: List[Dict]): ] @staticmethod - def _parse_create_trigger(rows: List[Dict], trigger: str): + def parse_create_trigger(rows: List[Dict], trigger: str): from ..show.types import ShowCreateTriggerResult if not rows: @@ -285,19 +285,19 @@ def _parse_create_trigger(rows: List[Dict], trigger: str): ) @staticmethod - def _parse_variables(rows: List[Dict]): + def parse_variables(rows: List[Dict]): from ..show.types import ShowVariableResult return [ShowVariableResult(variable_name=row.get("Variable_name"), value=row.get("Value")) for row in rows] @staticmethod - def _parse_status(rows: List[Dict]): + def parse_status(rows: List[Dict]): from ..show.types import ShowStatusResult return [ShowStatusResult(variable_name=row.get("Variable_name"), value=row.get("Value")) for row in rows] @staticmethod - def _parse_processlist(rows: List[Dict]): + def parse_processlist(rows: List[Dict]): """Parse ``SHOW PROCESSLIST``. ClickHouse's ``SHOW PROCESSLIST`` is ``system.processes`` verbatim, so the @@ -352,7 +352,7 @@ def _parse_processlist(rows: List[Dict]): return results @staticmethod - def _parse_warnings(rows: List[Dict]): + def parse_warnings(rows: List[Dict]): from ..show.types import ShowWarningResult return [ @@ -360,7 +360,7 @@ def _parse_warnings(rows: List[Dict]): ] @staticmethod - def _parse_engines(rows: List[Dict]): + def parse_engines(rows: List[Dict]): """Parse ``SHOW ENGINES``. ClickHouse's columns are its own, read here as the server sends them: @@ -409,7 +409,7 @@ def _parse_engines(rows: List[Dict]): ] @staticmethod - def _parse_charset(rows: List[Dict]): + def parse_charset(rows: List[Dict]): from ..show.types import ShowCharsetResult return [ @@ -423,7 +423,7 @@ def _parse_charset(rows: List[Dict]): ] @staticmethod - def _parse_collation(rows: List[Dict]): + def parse_collation(rows: List[Dict]): from ..show.types import ShowCollationResult return [ @@ -439,7 +439,7 @@ def _parse_collation(rows: List[Dict]): ] @staticmethod - def _parse_grants(rows: List[Dict]): + def parse_grants(rows: List[Dict]): """Parse ``SHOW GRANTS``. The statement returns **one column whose name is not a name at all**: @@ -473,7 +473,7 @@ def _parse_grants(rows: List[Dict]): return results @staticmethod - def _parse_plugins(rows: List[Dict]): + def parse_plugins(rows: List[Dict]): from ..show.types import ShowPluginResult return [ @@ -514,19 +514,15 @@ def _exec(self, sql: str, params: tuple) -> List[Dict[str, Any]]: def create_table(self, table: str, schema: Optional[str] = None): """Get CREATE TABLE statement for a table.""" - expr = ShowCreateTableExpression(self.dialect, table) - if schema: - expr.schema(schema) + expr = ShowCreateTableStatement(self.dialect, table, schema=schema) sql, params = expr.to_sql() - return self._parse_create_table(self._exec(sql, params), table) + return self.parse_create_table(self._exec(sql, params), table) def create_view(self, view_name: str, schema: Optional[str] = None): """Get CREATE VIEW statement for a view.""" - expr = ShowCreateViewExpression(self.dialect, view_name) - if schema: - expr.schema(schema) + expr = ShowCreateViewStatement(self.dialect, view_name, schema=schema) sql, params = expr.to_sql() - return self._parse_create_view(self._exec(sql, params), view_name) + return self.parse_create_view(self._exec(sql, params), view_name) def columns( self, @@ -536,23 +532,17 @@ def columns( like: Optional[str] = None, ): """Get column information for a table.""" - expr = ShowColumnsExpression(self.dialect, table) - if schema: - expr.schema(schema) - if full: - expr.full() - if like: - expr.like(like) + expr = ShowColumnsStatement( + self.dialect, table, full=full, like_pattern=like, schema=schema + ) sql, params = expr.to_sql() - return self._parse_columns(self._exec(sql, params)) + return self.parse_columns(self._exec(sql, params)) def indexes(self, table: str, schema: Optional[str] = None): """Get index information for a table.""" - expr = ShowIndexExpression(self.dialect, table) - if schema: - expr.schema(schema) + expr = ShowIndexStatement(self.dialect, table, schema=schema) sql, params = expr.to_sql() - return self._parse_indexes(self._exec(sql, params)) + return self.parse_indexes(self._exec(sql, params)) def tables( self, @@ -561,131 +551,99 @@ def tables( full: bool = False, ): """List tables in the database.""" - expr = ShowTablesExpression(self.dialect) - if schema: - expr.schema(schema) - if like: - expr.like(like) - if full: - expr.full() + expr = ShowTablesStatement( + self.dialect, schema=schema, like_pattern=like, full=full + ) sql, params = expr.to_sql() - return self._parse_tables(self._exec(sql, params)) + return self.parse_tables(self._exec(sql, params)) def databases(self, like: Optional[str] = None): """List databases.""" - expr = ShowDatabasesExpression(self.dialect) - if like: - expr.like(like) + expr = ShowDatabasesStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() - return self._parse_databases(self._exec(sql, params)) + return self.parse_databases(self._exec(sql, params)) def table_status(self, schema: Optional[str] = None, like: Optional[str] = None): """Get table status information.""" - expr = ShowTableStatusExpression(self.dialect) - if schema: - expr.schema(schema) - if like: - expr.like(like) + expr = ShowTableStatusStatement( + self.dialect, schema=schema, like_pattern=like + ) sql, params = expr.to_sql() - return self._parse_table_status(self._exec(sql, params)) + return self.parse_table_status(self._exec(sql, params)) def triggers(self, schema: Optional[str] = None, table: Optional[str] = None): """List triggers.""" - expr = ShowTriggersExpression(self.dialect) - if schema: - expr.schema(schema) - if table: - expr.for_table(table) + expr = ShowTriggersStatement(self.dialect, schema=schema, table=table) sql, params = expr.to_sql() - return self._parse_triggers(self._exec(sql, params)) + return self.parse_triggers(self._exec(sql, params)) def create_trigger(self, trigger: str, schema: Optional[str] = None): """Get CREATE TRIGGER statement.""" - expr = ShowCreateTriggerExpression(self.dialect, trigger) - if schema: - expr.schema(schema) + expr = ShowCreateTriggerStatement(self.dialect, trigger, schema=schema) sql, params = expr.to_sql() - return self._parse_create_trigger(self._exec(sql, params), trigger) + return self.parse_create_trigger(self._exec(sql, params), trigger) def variables(self, like: Optional[str] = None, session: bool = True): """Show server variables.""" - expr = ShowVariablesExpression(self.dialect) - if like: - expr.like(like) - if not session: - expr.global_vars() + expr = ShowVariablesStatement( + self.dialect, session=session, like_pattern=like + ) sql, params = expr.to_sql() - return self._parse_variables(self._exec(sql, params)) + return self.parse_variables(self._exec(sql, params)) def status(self, like: Optional[str] = None, session: bool = True): """Show server status.""" - expr = ShowStatusExpression(self.dialect) - if like: - expr.like(like) - if not session: - expr.global_status() + expr = ShowStatusStatement(self.dialect, session=session, like_pattern=like) sql, params = expr.to_sql() - return self._parse_status(self._exec(sql, params)) + return self.parse_status(self._exec(sql, params)) def processlist(self, full: bool = False): """Show process list.""" - expr = ShowProcessListExpression(self.dialect) - if full: - expr.full() + expr = ShowProcessListStatement(self.dialect, full=full) sql, params = expr.to_sql() - return self._parse_processlist(self._exec(sql, params)) + return self.parse_processlist(self._exec(sql, params)) def warnings(self, limit: Optional[int] = None): """Show warnings.""" - expr = ShowWarningsExpression(self.dialect) - if limit is not None: - expr.limit(limit) + expr = ShowWarningsStatement(self.dialect, limit=limit) sql, params = expr.to_sql() - return self._parse_warnings(self._exec(sql, params)) + return self.parse_warnings(self._exec(sql, params)) def errors(self, limit: Optional[int] = None): """Show errors.""" - expr = ShowErrorsExpression(self.dialect) - if limit is not None: - expr.limit(limit) + expr = ShowErrorsStatement(self.dialect, limit=limit) sql, params = expr.to_sql() - return self._parse_warnings(self._exec(sql, params)) + return self.parse_warnings(self._exec(sql, params)) def engines(self): """Show storage engines.""" - expr = ShowEnginesExpression(self.dialect) + expr = ShowEnginesStatement(self.dialect) sql, params = expr.to_sql() - return self._parse_engines(self._exec(sql, params)) + return self.parse_engines(self._exec(sql, params)) def charset(self, like: Optional[str] = None): """Show character sets.""" - expr = ShowCharsetExpression(self.dialect) - if like: - expr.like(like) + expr = ShowCharsetStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() - return self._parse_charset(self._exec(sql, params)) + return self.parse_charset(self._exec(sql, params)) def collation(self, like: Optional[str] = None): """Show collations.""" - expr = ShowCollationExpression(self.dialect) - if like: - expr.like(like) + expr = ShowCollationStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() - return self._parse_collation(self._exec(sql, params)) + return self.parse_collation(self._exec(sql, params)) def grants(self, user: Optional[str] = None, host: Optional[str] = None): """Show grants.""" - expr = ShowGrantsExpression(self.dialect) - if user: - expr.for_user(user, host) + expr = ShowGrantsStatement(self.dialect, user=user, host=host) sql, params = expr.to_sql() - return self._parse_grants(self._exec(sql, params)) + return self.parse_grants(self._exec(sql, params)) def plugins(self): """Show plugins.""" - expr = ShowPluginsExpression(self.dialect) + expr = ShowPluginsStatement(self.dialect) sql, params = expr.to_sql() - return self._parse_plugins(self._exec(sql, params)) + return self.parse_plugins(self._exec(sql, params)) class AsyncShowIntrospector(ShowMixin): @@ -715,19 +673,15 @@ async def _exec(self, sql: str, params: tuple) -> List[Dict[str, Any]]: async def create_table(self, table: str, schema: Optional[str] = None): """Get CREATE TABLE statement for a table.""" - expr = ShowCreateTableExpression(self.dialect, table) - if schema: - expr.schema(schema) + expr = ShowCreateTableStatement(self.dialect, table, schema=schema) sql, params = expr.to_sql() - return self._parse_create_table(await self._exec(sql, params), table) + return self.parse_create_table(await self._exec(sql, params), table) async def create_view(self, view_name: str, schema: Optional[str] = None): """Get CREATE VIEW statement for a view.""" - expr = ShowCreateViewExpression(self.dialect, view_name) - if schema: - expr.schema(schema) + expr = ShowCreateViewStatement(self.dialect, view_name, schema=schema) sql, params = expr.to_sql() - return self._parse_create_view(await self._exec(sql, params), view_name) + return self.parse_create_view(await self._exec(sql, params), view_name) async def columns( self, @@ -737,23 +691,17 @@ async def columns( like: Optional[str] = None, ): """Get column information for a table.""" - expr = ShowColumnsExpression(self.dialect, table) - if schema: - expr.schema(schema) - if full: - expr.full() - if like: - expr.like(like) + expr = ShowColumnsStatement( + self.dialect, table, full=full, like_pattern=like, schema=schema + ) sql, params = expr.to_sql() - return self._parse_columns(await self._exec(sql, params)) + return self.parse_columns(await self._exec(sql, params)) async def indexes(self, table: str, schema: Optional[str] = None): """Get index information for a table.""" - expr = ShowIndexExpression(self.dialect, table) - if schema: - expr.schema(schema) + expr = ShowIndexStatement(self.dialect, table, schema=schema) sql, params = expr.to_sql() - return self._parse_indexes(await self._exec(sql, params)) + return self.parse_indexes(await self._exec(sql, params)) async def tables( self, @@ -762,128 +710,96 @@ async def tables( full: bool = False, ): """List tables in the database.""" - expr = ShowTablesExpression(self.dialect) - if schema: - expr.schema(schema) - if like: - expr.like(like) - if full: - expr.full() + expr = ShowTablesStatement( + self.dialect, schema=schema, like_pattern=like, full=full + ) sql, params = expr.to_sql() - return self._parse_tables(await self._exec(sql, params)) + return self.parse_tables(await self._exec(sql, params)) async def databases(self, like: Optional[str] = None): """List databases.""" - expr = ShowDatabasesExpression(self.dialect) - if like: - expr.like(like) + expr = ShowDatabasesStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() - return self._parse_databases(await self._exec(sql, params)) + return self.parse_databases(await self._exec(sql, params)) async def table_status(self, schema: Optional[str] = None, like: Optional[str] = None): """Get table status information.""" - expr = ShowTableStatusExpression(self.dialect) - if schema: - expr.schema(schema) - if like: - expr.like(like) + expr = ShowTableStatusStatement( + self.dialect, schema=schema, like_pattern=like + ) sql, params = expr.to_sql() - return self._parse_table_status(await self._exec(sql, params)) + return self.parse_table_status(await self._exec(sql, params)) async def triggers(self, schema: Optional[str] = None, table: Optional[str] = None): """List triggers.""" - expr = ShowTriggersExpression(self.dialect) - if schema: - expr.schema(schema) - if table: - expr.for_table(table) + expr = ShowTriggersStatement(self.dialect, schema=schema, table=table) sql, params = expr.to_sql() - return self._parse_triggers(await self._exec(sql, params)) + return self.parse_triggers(await self._exec(sql, params)) async def create_trigger(self, trigger: str, schema: Optional[str] = None): """Get CREATE TRIGGER statement.""" - expr = ShowCreateTriggerExpression(self.dialect, trigger) - if schema: - expr.schema(schema) + expr = ShowCreateTriggerStatement(self.dialect, trigger, schema=schema) sql, params = expr.to_sql() - return self._parse_create_trigger(await self._exec(sql, params), trigger) + return self.parse_create_trigger(await self._exec(sql, params), trigger) async def variables(self, like: Optional[str] = None, session: bool = True): """Show server variables.""" - expr = ShowVariablesExpression(self.dialect) - if like: - expr.like(like) - if not session: - expr.global_vars() + expr = ShowVariablesStatement( + self.dialect, session=session, like_pattern=like + ) sql, params = expr.to_sql() - return self._parse_variables(await self._exec(sql, params)) + return self.parse_variables(await self._exec(sql, params)) async def status(self, like: Optional[str] = None, session: bool = True): """Show server status.""" - expr = ShowStatusExpression(self.dialect) - if like: - expr.like(like) - if not session: - expr.global_status() + expr = ShowStatusStatement(self.dialect, session=session, like_pattern=like) sql, params = expr.to_sql() - return self._parse_status(await self._exec(sql, params)) + return self.parse_status(await self._exec(sql, params)) async def processlist(self, full: bool = False): """Show process list.""" - expr = ShowProcessListExpression(self.dialect) - if full: - expr.full() + expr = ShowProcessListStatement(self.dialect, full=full) sql, params = expr.to_sql() - return self._parse_processlist(await self._exec(sql, params)) + return self.parse_processlist(await self._exec(sql, params)) async def warnings(self, limit: Optional[int] = None): """Show warnings.""" - expr = ShowWarningsExpression(self.dialect) - if limit is not None: - expr.limit(limit) + expr = ShowWarningsStatement(self.dialect, limit=limit) sql, params = expr.to_sql() - return self._parse_warnings(await self._exec(sql, params)) + return self.parse_warnings(await self._exec(sql, params)) async def errors(self, limit: Optional[int] = None): """Show errors.""" - expr = ShowErrorsExpression(self.dialect) - if limit is not None: - expr.limit(limit) + expr = ShowErrorsStatement(self.dialect, limit=limit) sql, params = expr.to_sql() - return self._parse_warnings(await self._exec(sql, params)) + return self.parse_warnings(await self._exec(sql, params)) async def engines(self): """Show storage engines.""" - expr = ShowEnginesExpression(self.dialect) + expr = ShowEnginesStatement(self.dialect) sql, params = expr.to_sql() - return self._parse_engines(await self._exec(sql, params)) + return self.parse_engines(await self._exec(sql, params)) async def charset(self, like: Optional[str] = None): """Show character sets.""" - expr = ShowCharsetExpression(self.dialect) - if like: - expr.like(like) + expr = ShowCharsetStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() - return self._parse_charset(await self._exec(sql, params)) + return self.parse_charset(await self._exec(sql, params)) async def collation(self, like: Optional[str] = None): """Show collations.""" - expr = ShowCollationExpression(self.dialect) - if like: - expr.like(like) + expr = ShowCollationStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() - return self._parse_collation(await self._exec(sql, params)) + return self.parse_collation(await self._exec(sql, params)) async def grants(self, user: Optional[str] = None, host: Optional[str] = None): """Show grants.""" - expr = ShowGrantsExpression(self.dialect) - if user: - expr.for_user(user, host) + expr = ShowGrantsStatement(self.dialect, user=user, host=host) sql, params = expr.to_sql() - return self._parse_grants(await self._exec(sql, params)) + return self.parse_grants(await self._exec(sql, params)) async def plugins(self): """Show plugins.""" - expr = ShowPluginsExpression(self.dialect) + expr = ShowPluginsStatement(self.dialect) sql, params = expr.to_sql() - return self._parse_plugins(await self._exec(sql, params)) + return self.parse_plugins(await self._exec(sql, params)) diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/introspection/status_introspector.py b/src/rhosocial/activerecord/backend/impl/clickhouse/introspection/status_introspector.py index 1c1b288..1c873af 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/introspection/status_introspector.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/introspection/status_introspector.py @@ -77,6 +77,25 @@ SyncAbstractStatusIntrospector, AsyncAbstractStatusIntrospector, ) +from rhosocial.activerecord.backend.impl.clickhouse.expression.status import ( + ClickHouseAsynchronousMetricsQuery, + ClickHouseConnectionGaugesQuery, + ClickHouseCurrentUserQuery, + ClickHouseDatabaseTotalSizeQuery, + ClickHouseDiskListQuery, + ClickHouseEventsQuery, + ClickHouseGrantListQuery, + ClickHouseMetricsQuery, + ClickHouseProcessListQuery, + ClickHouseReplicaListQuery, + ClickHouseRoleGrantListQuery, + ClickHouseServerSettingQuery, + ClickHouseServerSettingsQuery, + ClickHouseSettingsQuery, + ClickHouseTableCountQuery, + ClickHouseUserListQuery, + ClickHouseVersionQuery, +) # --------------------------------------------------------------------------- # @@ -398,7 +417,7 @@ def _get_vendor_name(self) -> str: """Get ClickHouse vendor name.""" return "ClickHouse" - def _parse_variable_value(self, value: Any) -> Any: + def parse_variable_value(self, value: Any) -> Any: """Parse variable value to appropriate Python type.""" if value is None: return None @@ -411,7 +430,7 @@ def _parse_variable_value(self, value: Any) -> Any: return value return value - def _parse_version_string(self, version_str: str) -> tuple: + def parse_version_string(self, version_str: str) -> tuple: """Parse ClickHouse version string to (major, minor, patch) tuple. ClickHouse numbers releases ``YY.M.patch``, so the major component is a @@ -450,7 +469,7 @@ def _is_clickhouse_version_at_least(self, version_str: str, major: int, minor: i Returns: True if version >= major.minor """ - parsed = self._parse_version_string(version_str) + parsed = self.parse_version_string(version_str) return parsed >= (major, minor, 0) def _create_status_item( @@ -468,7 +487,7 @@ def _create_status_item( """Create a StatusItem with parsed value.""" return StatusItem( name=name, - value=self._parse_variable_value(value), + value=self.parse_variable_value(value), category=category, description=description, unit=unit, @@ -641,9 +660,15 @@ def _filter_category(items: List[StatusItem], category: Optional[StatusCategory] return items return [item for item in items if item.category == category] - @staticmethod - def _placeholders(count: int, backend: Any) -> str: - return ", ".join([backend.dialect.p()] * count) + def _render(self, node) -> Tuple[str, tuple]: + """Render a status query node to ``(sql, params)``. + + Every read below goes through the dialect this way: the node is built + from its parameters, the dialect writes the statement, and the + introspector only executes it. There is no place left where a caller + counts placeholders or concatenates a WHERE tail. + """ + return node.to_sql() # ------------------------------------------------------------------ # # Overview assembly @@ -674,10 +699,10 @@ def _build_server_overview( """ replication_summary = None try: - result = self._backend.execute( - "SELECT database, table, is_leader, is_readonly, total_replicas " - "FROM system.replicas LIMIT 20" + sql, params = self._render( + ClickHouseReplicaListQuery(self._backend.dialect, limit=20) ) + result = self._backend.execute(sql, params) replication_summary = result.data except Exception: pass @@ -750,7 +775,8 @@ def _get_version_string(self) -> str: cannot mistake a guess for a server-reported version. """ try: - result = self._backend.execute("SELECT version()") + sql, params = self._render(ClickHouseVersionQuery(self._backend.dialect)) + result = self._backend.execute(sql, params) if result.data and result.data[0]: return str(next(iter(result.data[0].values()))) except Exception: @@ -782,27 +808,24 @@ def list_configuration(self, category: Optional[StatusCategory] = None) -> List[ read-only flag are the row's own columns. """ items: List[StatusItem] = [] + dialect = self._backend.dialect - settings_rows = self._rows( - "SELECT name, value, description, default, readonly, changed, type " - "FROM system.settings WHERE name IN (" - + self._placeholders(len(CLICKHOUSE_CONFIG_SETTINGS), self._backend) - + ")", - tuple(name for name, _ in CLICKHOUSE_CONFIG_SETTINGS), + settings_sql, settings_params = self._render( + ClickHouseSettingsQuery(dialect, [name for name, _ in CLICKHOUSE_CONFIG_SETTINGS]) ) + settings_rows = self._rows(settings_sql, settings_params) items.extend( self._build_setting_items( settings_rows, CLICKHOUSE_CONFIG_SETTINGS, "system.settings", "readonly" ) ) - server_rows = self._rows( - "SELECT name, value, description, default, changed, type, changeable_without_restart " - "FROM system.server_settings WHERE name IN (" - + self._placeholders(len(CLICKHOUSE_SERVER_SETTINGS), self._backend) - + ")", - tuple(name for name, _ in CLICKHOUSE_SERVER_SETTINGS), + server_sql, server_params = self._render( + ClickHouseServerSettingsQuery( + dialect, [name for name, _ in CLICKHOUSE_SERVER_SETTINGS] + ) ) + server_rows = self._rows(server_sql, server_params) items.extend( self._build_setting_items( server_rows, @@ -833,29 +856,26 @@ def list_performance_metrics(self, category: Optional[StatusCategory] = None) -> has been running merges, inserts and mutations. """ items: List[StatusItem] = [] + dialect = self._backend.dialect - gauge_rows = self._rows( - "SELECT metric, value, description FROM system.metrics WHERE metric IN (" - + self._placeholders(len(CLICKHOUSE_GAUGE_METRICS), self._backend) - + ")", - tuple(name for name, _ in CLICKHOUSE_GAUGE_METRICS), + gauge_sql, gauge_params = self._render( + ClickHouseMetricsQuery(dialect, [name for name, _ in CLICKHOUSE_GAUGE_METRICS]) ) + gauge_rows = self._rows(gauge_sql, gauge_params) items.extend(self._build_gauge_items(gauge_rows, CLICKHOUSE_GAUGE_METRICS, "system.metrics")) - event_rows = self._rows( - "SELECT event, value, description FROM system.events WHERE event IN (" - + self._placeholders(len(CLICKHOUSE_COUNTER_EVENTS), self._backend) - + ")", - tuple(name for name, _ in CLICKHOUSE_COUNTER_EVENTS), + event_sql, event_params = self._render( + ClickHouseEventsQuery(dialect, [name for name, _ in CLICKHOUSE_COUNTER_EVENTS]) ) + event_rows = self._rows(event_sql, event_params) items.extend(self._build_event_items(event_rows, CLICKHOUSE_COUNTER_EVENTS, "system.events")) - async_rows = self._rows( - "SELECT metric, value, description FROM system.asynchronous_metrics WHERE metric IN (" - + self._placeholders(len(CLICKHOUSE_ASYNC_METRICS), self._backend) - + ")", - tuple(name for name, _ in CLICKHOUSE_ASYNC_METRICS), + async_sql, async_params = self._render( + ClickHouseAsynchronousMetricsQuery( + dialect, [name for name, _ in CLICKHOUSE_ASYNC_METRICS] + ) ) + async_rows = self._rows(async_sql, async_params) items.extend( self._build_async_metric_items(async_rows, CLICKHOUSE_ASYNC_METRICS, "system.asynchronous_metrics") ) @@ -871,30 +891,28 @@ def get_connection_info(self) -> ConnectionInfo: gauges in :data:`OPEN_CONNECTION_METRICS`, because ClickHouse has no single ``Threads_connected`` to report. """ - gauge_rows = self._rows( - "SELECT metric, value FROM system.metrics WHERE metric IN (" - + self._placeholders(len(OPEN_CONNECTION_METRICS), self._backend) - + ")", - OPEN_CONNECTION_METRICS, + dialect = self._backend.dialect + gauge_sql, gauge_params = self._render( + ClickHouseConnectionGaugesQuery(dialect, list(OPEN_CONNECTION_METRICS)) ) + gauge_rows = self._rows(gauge_sql, gauge_params) gauges = {row.get("metric"): row.get("value") for row in gauge_rows} - server_rows = self._rows( - "SELECT name, value FROM system.server_settings " - f"WHERE name = {self._backend.dialect.p()}", - ("max_connections",), + server_sql, server_params = self._render( + ClickHouseServerSettingQuery(dialect, "max_connections") ) + server_rows = self._rows(server_sql, server_params) max_connections = server_rows[0].get("value") if server_rows else None def _num(name: str) -> Optional[int]: - value = self._parse_variable_value(gauges.get(name)) + value = self.parse_variable_value(gauges.get(name)) return value if isinstance(value, int) else None active = [v for v in (_num(name) for name in OPEN_CONNECTION_METRICS) if v is not None] return ConnectionInfo( active_count=sum(active) if active else None, - max_connections=self._parse_variable_value(max_connections), + max_connections=self.parse_variable_value(max_connections), idle_count=None, extra={ "http_connections": _num("HTTPConnection"), @@ -914,25 +932,24 @@ def get_storage_info(self) -> StorageInfo: """Get storage information from ``system.tables`` / ``system.disks``.""" total_size = None try: - result = self._backend.execute( - "SELECT sum(total_bytes) AS total_size FROM system.tables " - f"WHERE database = {self._backend.dialect.p()}", - (self._backend.config.database,), + sql, params = self._render( + ClickHouseDatabaseTotalSizeQuery( + self._backend.dialect, self._backend.config.database + ) ) + result = self._backend.execute(sql, params) if result and result.data: total_size = result.data[0].get("total_size") except Exception: pass - disks = self._rows( - "SELECT name, path, type, total_space, free_space, unreserved_space, keep_free_space, " - "is_encrypted, is_read_only, is_remote, is_broken FROM system.disks" - ) + disk_sql, disk_params = self._render(ClickHouseDiskListQuery(self._backend.dialect)) + disks = self._rows(disk_sql, disk_params) default_disk = next((d for d in disks if d.get("name") == "default"), None) return StorageInfo( - total_size_bytes=self._parse_variable_value(total_size), - free_space_bytes=self._parse_variable_value(default_disk.get("free_space")) if default_disk else None, + total_size_bytes=self.parse_variable_value(total_size), + free_space_bytes=self.parse_variable_value(default_disk.get("free_space")) if default_disk else None, extra={ "database": self._backend.config.database, "data_path": default_disk.get("path") if default_disk else None, @@ -962,14 +979,10 @@ def list_databases(self) -> List[DatabaseBriefInfo]: view_counts: Dict[str, int] = {} try: - placeholders = self._placeholders(len(db_names), self._backend) - result = self._backend.execute( - "SELECT table_schema, table_type, COUNT(*) as count " - "FROM information_schema.TABLES " - f"WHERE table_schema IN ({placeholders}) " - "GROUP BY table_schema, table_type", - tuple(db_names), + sql, params = self._render( + ClickHouseTableCountQuery(self._backend.dialect, db_names) ) + result = self._backend.execute(sql, params) if result and result.data: for row in result.data: schema = row.get("table_schema") or row.get("TABLE_SCHEMA") @@ -1003,16 +1016,15 @@ def list_users(self) -> List[UserInfo]: """ users = [] - user_rows = self._rows( - "SELECT name, id, auth_type, default_database, host_ip, host_names FROM system.users" - ) + user_sql, user_params = self._render(ClickHouseUserListQuery(self._backend.dialect)) + user_rows = self._rows(user_sql, user_params) if not user_rows: return users - grant_rows = self._rows( - "SELECT user_name, access_type, database, table, grant_option FROM system.grants " - "WHERE database IS NULL AND table IS NULL" + grant_sql, grant_params = self._render( + ClickHouseGrantListQuery(self._backend.dialect) ) + grant_rows = self._rows(grant_sql, grant_params) grants_by_user: Dict[str, List[str]] = {} for row in grant_rows: name = row.get("user_name") @@ -1020,7 +1032,10 @@ def list_users(self) -> List[UserInfo]: if name and access_type: grants_by_user.setdefault(name, []).append(str(access_type)) - role_rows = self._rows("SELECT user_name, role_name, granted_role_is_default FROM system.role_grants") + role_sql, role_params = self._render( + ClickHouseRoleGrantListQuery(self._backend.dialect) + ) + role_rows = self._rows(role_sql, role_params) roles_by_user: Dict[str, List[str]] = {} for row in role_rows: name = row.get("user_name") @@ -1058,7 +1073,8 @@ def get_session_info(self) -> SessionInfo: # Get current user try: - result = self._backend.execute("SELECT currentUser()", ()) + sql, params = self._render(ClickHouseCurrentUserQuery(self._backend.dialect)) + result = self._backend.execute(sql, params) if result and result.data: current_user = next(iter(result.data[0].values())) if current_user: @@ -1080,10 +1096,8 @@ def get_session_info(self) -> SessionInfo: def list_processes(self) -> List[ProcessInfo]: """List current running processes/queries via ``system.processes``.""" processes = [] - rows = self._rows( - "SELECT query_id, user, address, current_database, elapsed, query " - "FROM system.processes" - ) + sql, params = self._render(ClickHouseProcessListQuery(self._backend.dialect)) + rows = self._rows(sql, params) for row in rows: processes.append( ProcessInfo( @@ -1150,7 +1164,8 @@ async def get_overview(self) -> ServerOverview: async def _get_version_string(self) -> str: """Get ClickHouse version string — see the sync twin for the fallback rule.""" try: - result = await self._backend.execute("SELECT version()") + sql, params = self._render(ClickHouseVersionQuery(self._backend.dialect)) + result = await self._backend.execute(sql, params) if result.data and result.data[0]: return str(next(iter(result.data[0].values()))) except Exception: @@ -1179,27 +1194,24 @@ async def list_configuration(self, category: Optional[StatusCategory] = None) -> See the synchronous twin for why the name set follows the server. """ items: List[StatusItem] = [] + dialect = self._backend.dialect - settings_rows = await self._rows( - "SELECT name, value, description, default, readonly, changed, type " - "FROM system.settings WHERE name IN (" - + self._placeholders(len(CLICKHOUSE_CONFIG_SETTINGS), self._backend) - + ")", - tuple(name for name, _ in CLICKHOUSE_CONFIG_SETTINGS), + settings_sql, settings_params = self._render( + ClickHouseSettingsQuery(dialect, [name for name, _ in CLICKHOUSE_CONFIG_SETTINGS]) ) + settings_rows = await self._rows(settings_sql, settings_params) items.extend( self._build_setting_items( settings_rows, CLICKHOUSE_CONFIG_SETTINGS, "system.settings", "readonly" ) ) - server_rows = await self._rows( - "SELECT name, value, description, default, changed, type, changeable_without_restart " - "FROM system.server_settings WHERE name IN (" - + self._placeholders(len(CLICKHOUSE_SERVER_SETTINGS), self._backend) - + ")", - tuple(name for name, _ in CLICKHOUSE_SERVER_SETTINGS), + server_sql, server_params = self._render( + ClickHouseServerSettingsQuery( + dialect, [name for name, _ in CLICKHOUSE_SERVER_SETTINGS] + ) ) + server_rows = await self._rows(server_sql, server_params) items.extend( self._build_setting_items( server_rows, @@ -1223,29 +1235,26 @@ async def list_performance_metrics(self, category: Optional[StatusCategory] = No history-dependent. """ items: List[StatusItem] = [] + dialect = self._backend.dialect - gauge_rows = await self._rows( - "SELECT metric, value, description FROM system.metrics WHERE metric IN (" - + self._placeholders(len(CLICKHOUSE_GAUGE_METRICS), self._backend) - + ")", - tuple(name for name, _ in CLICKHOUSE_GAUGE_METRICS), + gauge_sql, gauge_params = self._render( + ClickHouseMetricsQuery(dialect, [name for name, _ in CLICKHOUSE_GAUGE_METRICS]) ) + gauge_rows = await self._rows(gauge_sql, gauge_params) items.extend(self._build_gauge_items(gauge_rows, CLICKHOUSE_GAUGE_METRICS, "system.metrics")) - event_rows = await self._rows( - "SELECT event, value, description FROM system.events WHERE event IN (" - + self._placeholders(len(CLICKHOUSE_COUNTER_EVENTS), self._backend) - + ")", - tuple(name for name, _ in CLICKHOUSE_COUNTER_EVENTS), + event_sql, event_params = self._render( + ClickHouseEventsQuery(dialect, [name for name, _ in CLICKHOUSE_COUNTER_EVENTS]) ) + event_rows = await self._rows(event_sql, event_params) items.extend(self._build_event_items(event_rows, CLICKHOUSE_COUNTER_EVENTS, "system.events")) - async_rows = await self._rows( - "SELECT metric, value, description FROM system.asynchronous_metrics WHERE metric IN (" - + self._placeholders(len(CLICKHOUSE_ASYNC_METRICS), self._backend) - + ")", - tuple(name for name, _ in CLICKHOUSE_ASYNC_METRICS), + async_sql, async_params = self._render( + ClickHouseAsynchronousMetricsQuery( + dialect, [name for name, _ in CLICKHOUSE_ASYNC_METRICS] + ) ) + async_rows = await self._rows(async_sql, async_params) items.extend( self._build_async_metric_items(async_rows, CLICKHOUSE_ASYNC_METRICS, "system.asynchronous_metrics") ) @@ -1261,30 +1270,28 @@ async def get_connection_info(self) -> ConnectionInfo: gauges in :data:`OPEN_CONNECTION_METRICS`, because ClickHouse has no single ``Threads_connected`` to report. """ - gauge_rows = await self._rows( - "SELECT metric, value FROM system.metrics WHERE metric IN (" - + self._placeholders(len(OPEN_CONNECTION_METRICS), self._backend) - + ")", - OPEN_CONNECTION_METRICS, + dialect = self._backend.dialect + gauge_sql, gauge_params = self._render( + ClickHouseConnectionGaugesQuery(dialect, list(OPEN_CONNECTION_METRICS)) ) + gauge_rows = await self._rows(gauge_sql, gauge_params) gauges = {row.get("metric"): row.get("value") for row in gauge_rows} - server_rows = await self._rows( - "SELECT name, value FROM system.server_settings " - f"WHERE name = {self._backend.dialect.p()}", - ("max_connections",), + server_sql, server_params = self._render( + ClickHouseServerSettingQuery(dialect, "max_connections") ) + server_rows = await self._rows(server_sql, server_params) max_connections = server_rows[0].get("value") if server_rows else None def _num(name: str) -> Optional[int]: - value = self._parse_variable_value(gauges.get(name)) + value = self.parse_variable_value(gauges.get(name)) return value if isinstance(value, int) else None active = [v for v in (_num(name) for name in OPEN_CONNECTION_METRICS) if v is not None] return ConnectionInfo( active_count=sum(active) if active else None, - max_connections=self._parse_variable_value(max_connections), + max_connections=self.parse_variable_value(max_connections), idle_count=None, extra={ "http_connections": _num("HTTPConnection"), @@ -1304,25 +1311,24 @@ async def get_storage_info(self) -> StorageInfo: """Get storage information from ``system.tables`` / ``system.disks``.""" total_size = None try: - result = await self._backend.execute( - "SELECT sum(total_bytes) AS total_size FROM system.tables " - f"WHERE database = {self._backend.dialect.p()}", - (self._backend.config.database,), + sql, params = self._render( + ClickHouseDatabaseTotalSizeQuery( + self._backend.dialect, self._backend.config.database + ) ) + result = await self._backend.execute(sql, params) if result and result.data: total_size = result.data[0].get("total_size") except Exception: pass - disks = await self._rows( - "SELECT name, path, type, total_space, free_space, unreserved_space, keep_free_space, " - "is_encrypted, is_read_only, is_remote, is_broken FROM system.disks" - ) + disk_sql, disk_params = self._render(ClickHouseDiskListQuery(self._backend.dialect)) + disks = await self._rows(disk_sql, disk_params) default_disk = next((d for d in disks if d.get("name") == "default"), None) return StorageInfo( - total_size_bytes=self._parse_variable_value(total_size), - free_space_bytes=self._parse_variable_value(default_disk.get("free_space")) if default_disk else None, + total_size_bytes=self.parse_variable_value(total_size), + free_space_bytes=self.parse_variable_value(default_disk.get("free_space")) if default_disk else None, extra={ "database": self._backend.config.database, "data_path": default_disk.get("path") if default_disk else None, @@ -1346,14 +1352,10 @@ async def list_databases(self) -> List[DatabaseBriefInfo]: view_counts: Dict[str, int] = {} try: - placeholders = self._placeholders(len(db_names), self._backend) - result = await self._backend.execute( - "SELECT table_schema, table_type, COUNT(*) as count " - "FROM information_schema.TABLES " - f"WHERE table_schema IN ({placeholders}) " - "GROUP BY table_schema, table_type", - tuple(db_names), + sql, params = self._render( + ClickHouseTableCountQuery(self._backend.dialect, db_names) ) + result = await self._backend.execute(sql, params) if result and result.data: for row in result.data: schema = row.get("table_schema") or row.get("TABLE_SCHEMA") @@ -1387,16 +1389,15 @@ async def list_users(self) -> List[UserInfo]: """ users = [] - user_rows = await self._rows( - "SELECT name, id, auth_type, default_database, host_ip, host_names FROM system.users" - ) + user_sql, user_params = self._render(ClickHouseUserListQuery(self._backend.dialect)) + user_rows = await self._rows(user_sql, user_params) if not user_rows: return users - grant_rows = await self._rows( - "SELECT user_name, access_type, database, table, grant_option FROM system.grants " - "WHERE database IS NULL AND table IS NULL" + grant_sql, grant_params = self._render( + ClickHouseGrantListQuery(self._backend.dialect) ) + grant_rows = await self._rows(grant_sql, grant_params) grants_by_user: Dict[str, List[str]] = {} for row in grant_rows: name = row.get("user_name") @@ -1404,7 +1405,10 @@ async def list_users(self) -> List[UserInfo]: if name and access_type: grants_by_user.setdefault(name, []).append(str(access_type)) - role_rows = await self._rows("SELECT user_name, role_name, granted_role_is_default FROM system.role_grants") + role_sql, role_params = self._render( + ClickHouseRoleGrantListQuery(self._backend.dialect) + ) + role_rows = await self._rows(role_sql, role_params) roles_by_user: Dict[str, List[str]] = {} for row in role_rows: name = row.get("user_name") @@ -1442,7 +1446,8 @@ async def get_session_info(self) -> SessionInfo: # Get current user try: - result = await self._backend.execute("SELECT currentUser()", ()) + sql, params = self._render(ClickHouseCurrentUserQuery(self._backend.dialect)) + result = await self._backend.execute(sql, params) if result and result.data: current_user = next(iter(result.data[0].values())) if current_user: @@ -1464,10 +1469,8 @@ async def get_session_info(self) -> SessionInfo: async def list_processes(self) -> List[ProcessInfo]: """List current running processes/queries via ``system.processes``.""" processes = [] - rows = await self._rows( - "SELECT query_id, user, address, current_database, elapsed, query " - "FROM system.processes" - ) + sql, params = self._render(ClickHouseProcessListQuery(self._backend.dialect)) + rows = await self._rows(sql, params) for row in rows: processes.append( ProcessInfo( diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/introspection/types.py b/src/rhosocial/activerecord/backend/impl/clickhouse/introspection/types.py new file mode 100644 index 0000000..a6d9384 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/introspection/types.py @@ -0,0 +1,114 @@ +# src/rhosocial/activerecord/backend/impl/clickhouse/introspection/types.py +"""ClickHouse-specific introspection results. + +The core introspection types describe what every backend can be asked about — +tables, columns, indexes. ClickHouse exposes two further facts that those +shapes cannot carry: how a MergeTree table's data is divided into partitions on +disk, and the storage definition a table was created with. Both are read here +as frozen dataclasses, one row per answer. + +Naming, stated once and not translated anywhere below: + + ClickHouse has **no schema layer**. Its top-level namespace is called a + *database* (``system.databases``, ``system.tables.database``), and an object + is qualified by that database alone. The core introspection API calls that + slot ``schema`` because most backends have both; when the core hands this + backend a ``schema``, the value is a ClickHouse database name. The methods + in :mod:`.introspector` therefore name the parameter ``database`` — that is + what it is — and do not turn it into anything else. + +So ``ClickHousePartitionInfo.database`` and ``ClickHouseTableEngineInfo.database`` +hold a ClickHouse database name, the same string the server reports in +``system.parts.database`` / ``system.tables.database``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + +__all__ = [ + "ClickHousePartitionInfo", + "ClickHouseTableEngineInfo", +] + + +@dataclass(frozen=True) +class ClickHousePartitionInfo: + """One partition of a table, aggregated from its active parts. + + Built from ``system.parts`` grouped by ``partition_id``. Only *active* parts + are read, because inactive parts are those a merge has already replaced and + are waiting for removal: counting them would report the same rows twice. + + Args: + partition_id: ``system.parts.partition_id`` — the identity ClickHouse + itself uses (``DROP PARTITION ID '...'``), and the grouping key. + A table declared without ``PARTITION BY`` has the single id + ``'all'``. + table_name: The table the partition belongs to. + database: The ClickHouse database the table lives in. + engine: The table engine, as ``system.parts.engine`` reports it. + partition: ``system.parts.partition`` — the readable partition value + the key produced (e.g. ``'202601'`` for ``toYYYYMM(date)``, + ``'tuple()'`` for a table with no partition key). Kept beside the + id because the id is a hash-like token for many key expressions. + row_count: Sum of ``rows`` over the partition's active parts. + bytes_on_disk: Sum of ``bytes_on_disk`` over the active parts. + active_part_count: Number of active parts the partition consists of. + A partition with more than one active part is normal: parts merge + in the background. + min_modification_time: Earliest ``modification_time`` among the active + parts, or ``None`` when the server reports none. + max_modification_time: Latest ``modification_time`` among them. + """ + + partition_id: str + table_name: str + database: str + engine: str + partition: Optional[str] = None + row_count: int = 0 + bytes_on_disk: int = 0 + active_part_count: int = 0 + min_modification_time: Optional[datetime] = None + max_modification_time: Optional[datetime] = None + + +@dataclass(frozen=True) +class ClickHouseTableEngineInfo: + """The storage definition of one table, from ``system.tables``. + + Every field is the column of the same name on ``system.tables``. The key + expressions are the ones the table was declared with; ClickHouse reports + ``''`` (not ``None``) for a key the table does not have, so the empty string + is passed through unchanged rather than being turned into a missing value + the server never said. + + Args: + table_name: ``system.tables.name``. + database: The ClickHouse database the table lives in. + engine: ``engine`` — the short engine name (``MergeTree``, + ``ReplicatedMergeTree``, ``View``, ...). + sorting_key: ``sorting_key`` — the ``ORDER BY`` expression. + primary_key: ``primary_key`` — the primary key expression. + partition_key: ``partition_key`` — the ``PARTITION BY`` expression. + sampling_key: ``sampling_key`` — the ``SAMPLE BY`` expression. + total_rows: ``total_rows`` as the server reports it; ``None`` when the + server has no estimate (the column is ``Nullable(UInt64)`` and is + empty for engines that do not track rows). + total_bytes: ``total_bytes``, same treatment. + comment: ``comment`` — ``''`` when the table has none. + """ + + table_name: str + database: str + engine: str + sorting_key: Optional[str] = None + primary_key: Optional[str] = None + partition_key: Optional[str] = None + sampling_key: Optional[str] = None + total_rows: Optional[int] = None + total_bytes: Optional[int] = None + comment: Optional[str] = None diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/__init__.py index 02f45bd..a42765d 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/__init__.py @@ -1,5 +1,6 @@ # src/rhosocial/activerecord/backend/impl/clickhouse/mixins/__init__.py from .introspection import ClickHouseIntrospectionMixin +from .status import ClickHouseStatusQueryMixin from .transaction import ClickHouseTransactionMixin from .backend_mixin import ClickHouseBackendMixin from .trigger import ClickHouseTriggerMixin @@ -50,12 +51,15 @@ from .sequence import ClickHouseSequenceMixin from .constraint import ClickHouseConstraintMixin from .ddl_column import ClickHouseDDLColumnMixin +from .wildcard import ClickHouseWildcardMixin from .function import ClickHouseFunctionMixin from .column_type import ClickHouseColumnTypeMixin from .trim import ClickHouseTrimMixin +from .literal import ClickHouseLiteralValueMixin __all__ = [ "ClickHouseIntrospectionMixin", + "ClickHouseStatusQueryMixin", "ClickHouseTransactionMixin", "ClickHouseBackendMixin", "ClickHouseTriggerMixin", @@ -105,7 +109,9 @@ "ClickHouseSequenceMixin", "ClickHouseConstraintMixin", "ClickHouseDDLColumnMixin", + "ClickHouseWildcardMixin", "ClickHouseFunctionMixin", "ClickHouseColumnTypeMixin", "ClickHouseTrimMixin", + "ClickHouseLiteralValueMixin", ] diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/collation.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/collation.py index 96bcd8e..2baa349 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/collation.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/collation.py @@ -4,7 +4,7 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression.collation import CollateExpression + from rhosocial.activerecord.backend.expression.collation import CollateFunction class ClickHouseCollationMixin: @@ -20,7 +20,7 @@ def supports_column_collation(self) -> bool: """ return False - def validate_collation_name(self, expr: "CollateExpression") -> str: + def validate_collation_name(self, expr: "CollateFunction") -> str: """Validate ClickHouse collation names and return their SQL representation.""" if expr.collation_options: unsupported = ", ".join(sorted(expr.collation_options)) diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/column.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/column.py index 62e30b9..31ebc49 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/column.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/column.py @@ -3,8 +3,8 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - ModifyColumn, - ChangeColumn, + ChangeColumnAction as ChangeColumn, + ModifyColumnAction as ModifyColumn, ) diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/concurrency.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/concurrency.py index 56fe1dd..7b94ebe 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/concurrency.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/concurrency.py @@ -18,8 +18,13 @@ def connect(self): def _fetch_concurrency_hint(self) -> None: """Fetch max concurrent queries from ClickHouse and compute concurrency hint.""" try: + from ..expression.status import ClickHouseSettingQuery + + sql, params = ClickHouseSettingQuery( + self.dialect, "max_concurrent_queries" + ).to_sql() cursor = self._connection.cursor() - cursor.execute("SELECT value FROM system.settings WHERE name = 'max_concurrent_queries'") + cursor.execute(sql, params) row = cursor.fetchone() cursor.close() @@ -56,8 +61,13 @@ async def connect(self): async def _fetch_concurrency_hint(self) -> None: """Fetch max_concurrent_queries from ClickHouse server and compute concurrency hint.""" try: + from ..expression.status import ClickHouseSettingQuery + + sql, params = ClickHouseSettingQuery( + self.dialect, "max_concurrent_queries" + ).to_sql() cursor = await self._connection.cursor(dictionary=True) - await cursor.execute("SELECT value FROM system.settings WHERE name = 'max_concurrent_queries'") + await cursor.execute(sql, params) row = await cursor.fetchone() await cursor.close() diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/constraint.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/constraint.py index 224b3f8..7ea2b64 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/constraint.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/constraint.py @@ -1,59 +1,943 @@ # src/rhosocial/activerecord/backend/impl/clickhouse/mixins/constraint.py +"""ClickHouse constraint capability declarations. + +Every switch here is declared **in this package**, even where core's +``ConstraintMixin`` already defaults it to the same value. That is deliberate: +those mixins are shared with the dummy dialect and are exactly the kind of +default that can move, and a silent move of a core default would move +ClickHouse's measured contract with it. A declaration here is the pin. + +The measurements behind every value were taken against the instance +``tests/config/clickhouse_scenarios.yaml`` names ``clickhouse_http``: + +* **ClickHouse 26.7.3.19 (official build)**, HTTP interface on + ``127.0.0.1:18682``, database ``test_db``. + +Every refusal quoted below is the server's own error -- code and message text -- +so a claim can be re-checked without re-measuring the family. + +Three things about ClickHouse are worth reading before the individual +docstrings, because the first two invert what most SQL backends do and the +third is a trap a naive renderer falls into without any error at all. + +**1. ClickHouse has exactly two constraint kinds, and one of them is not a +constraint.** ``ADD CONSTRAINT`` accepts ``CHECK`` and ``ASSUME`` and nothing +else -- the parser says so itself:: + + ALTER TABLE probe_t ADD CONSTRAINT ck CHECK c > 0 -- accepted + ALTER TABLE probe_t ADD CONSTRAINT ak ASSUME c > 1000 -- accepted + ALTER TABLE probe_t ADD CONSTRAINT pk PRIMARY KEY (id) + -> Code: 62. DB::Exception: Syntax error: failed at position 41 (PRIMARY): + PRIMARY KEY (id). Expected one of: CHECK, ASSUME. (SYNTAX_ERROR) + ALTER TABLE probe_t ADD CONSTRAINT uq UNIQUE (id) + -> Code: 62 ... Expected one of: CHECK, ASSUME. (SYNTAX_ERROR) + ALTER TABLE probe_t ADD CONSTRAINT fk FOREIGN KEY (id) REFERENCES probe_t(id) + -> Code: 62 ... Expected one of: CHECK, ASSUME. (SYNTAX_ERROR) + +``CHECK`` is enforced at write time. ``ASSUME`` is **not**: it is an assertion +handed to the query planner, so no row is ever rejected for violating it:: + + -- table carries ASSUME ak CHECK... (ASSUME c > 1000), nothing else + INSERT INTO probe_t (id, c) VALUES (3, -5); -- accepted, row stored + + -- same table carrying CHECK ck (c > 0) + INSERT INTO probe_t (id, c) VALUES (1, -5); + -> Code: 469. DB::Exception: Constraint `ck` for table test_db.probe_cc + (...) is violated at row 1. Expression: (c > 0). Column values: + c = -5: While executing WaitForAsyncInsert. (VIOLATED_CONSTRAINT) + +So ``ASSUME`` is deliberately **not** routed through the ``CHECK`` capability: +it must stay a separate opt-in (:meth:`ClickHouseConstraintMixin.supports_assume_constraint`) +and must never be rendered under a name that implies enforcement. + +**2. Constraints are TABLE-LEVEL ONLY. A column cannot carry one.** Both the +bare and the named column-level spelling are syntax errors, and the expected-token +list the server prints is the whole column-modifier grammar:: + + ALTER TABLE probe_t ADD COLUMN e1 Int32 CHECK e1 > 0 + ALTER TABLE probe_t ADD COLUMN e2 Int32 CONSTRAINT cc CHECK (e2 > 0) + -> Code: 62. DB::Exception: Syntax error: failed at position 42 (CHECK): + ... Expected one of: SIGNED, UNSIGNED, COLLATE, NOT, NULL, DEFAULT, + MATERIALIZED, ALIAS, EPHEMERAL, AUTO_INCREMENT, COMMENT, CODEC, + STATISTICS, TTL, PRIMARY KEY, SETTINGS, FIRST, AFTER, token, Comma, + INTO OUTFILE, FORMAT, ParallelWithClause, PARALLEL WITH, end of query. + +The same list rejects column-level ``UNIQUE`` and ``REFERENCES`` (Code 62). +The modifiers that *are* in it (``NOT NULL``, ``DEFAULT``, ``MATERIALIZED``, +``ALIAS``, ``EPHEMERAL``, ``COMMENT``, ``CODEC``, ``STATISTICS``, ``TTL``, +``PRIMARY KEY``, ``SETTINGS``) may be written **in any order, each at most +once** -- a second ``COMMENT`` is refused with Code 62 and an expected-token +list that no longer contains ``COMMENT``. + +This is why the column-level switches below are ``False`` -- with the one +exception of the flat ``supports_primary_key_constraint``, which answers +``True`` because the thing it gates on this core is the ``PRIMARY KEY`` +**modifier** (accepted; see its docstring) rather than a constraint -- while +the table-level CHECK switch is ``True``: the asymmetry is the engine's, not a +mistake in the declarations. + +**3. The primary key is NOT a constraint.** It is an ENGINE parameter -- +``ENGINE = MergeTree PRIMARY KEY id ORDER BY id``, or just ``ORDER BY id`` -- +and it never appears in the constraint catalog:: + + CREATE TABLE probe_pk (id Int32, c Int32, PRIMARY KEY (id)) + ENGINE = MergeTree ORDER BY id; -- accepted + SELECT count() FROM system.constraints + WHERE database='test_db' AND table='probe_pk'; -- 0 + SELECT primary_key, sorting_key FROM system.tables + WHERE database='test_db' AND name='probe_pk'; -- '(id)', 'id' + SELECT name, is_in_primary_key FROM system.columns + WHERE database='test_db' AND table='probe_pk'; -- id -> 1, c -> 0 + +``SHOW CREATE TABLE`` agrees: the primary key comes back inside the ENGINE +block, with no ``CONSTRAINT`` clause anywhere. A column may also carry +``PRIMARY KEY`` as a *modifier* (it is in the expected-token list above), and +that modifier feeds the same engine parameters -- it still does not create a +row in ``system.constraints``. + +**Catalog shape, measured on 26.7.3.19** (``DESCRIBE TABLE system.constraints``). +This is worth stating because it is easy to assume a boolean column here: + + database String + table String + name String + type Enum8('CHECK' = 0, 'ASSUME' = 1) + expression String + +There is **no ``is_assume`` column**. The CHECK/ASSUME discriminator is the +``type`` enum's value, so a reader of ``system.constraints`` filters with +``type = 'ASSUME'`` rather than on a flag:: + + SELECT name, type, expression FROM system.constraints + WHERE database='test_db' AND table='probe_t' ORDER BY name; + -- ak | ASSUME | c > 1000 + -- ck | CHECK | c > 0 + +**4. The ``DEFAULT`` trap -- a constraint can be smuggled in as a column +default, and the server accepts it.** A column default is an arbitrary +expression, so a comparison in that position parses and is silently evaluated +to 0/1:: + + ALTER TABLE probe_pk ADD COLUMN d Int32 DEFAULT c > 0; -- accepted + INSERT INTO probe_pk (id, c) VALUES (1, 7), (2, -7); + SELECT id, c, d FROM probe_pk ORDER BY id; + -- (1, 7, 1) (2, -7, 0) + +Nothing is rejected and nothing is warned about: the intended constraint check +becomes a boolean default column. **The render path must reject this +construction itself** -- it cannot be delegated to the server, because the +server accepts it. That gate is a render-path concern and is not part of the +switch declarations below; the ``True`` here records only that the *server* +accepts an expression in the ``DEFAULT`` position. + +Naming note: the switches that core's protocol does not own -- +``supports_rename_constraint``, ``supports_alter_constraint``, +``supports_enable_disable_constraint``, the per-level +``supports_{column,table}_*_constraint`` family and +``supports_assume_constraint`` -- are ClickHouse's own, named from +``.claude/plan/2026-10-11/clickhouse-constraint-coverage.md``. Core's S3 wave +moved the constraint **nodes** (the fat ``ColumnConstraint`` / +``TableConstraint`` became a production plus a branch class) and deleted the +``ColumnConstraintType`` / ``TableConstraintType`` tags; it did not add a +protocol for these switches and did not take the ``REFERENCES`` clause or the +ALTER lifecycle verbs away. So they keep their names, and the measured value is +what the tests pin. + +**Where the narrowing went.** Core's S3 removed the kind enum, so the places +that used to branch on a ``constraint_type`` tag now branch on the branch +node's class, and they land on *public* formatters of this dialect: + +* column level -- no override. Core's own branch formatters read this + module's probes: ``format_column_check_constraint`` refuses on + ``supports_check_constraint`` (Code 62; a column cannot carry a CHECK), and + ``format_column_primary_key_constraint`` renders the ``PRIMARY KEY`` modifier + on ``supports_primary_key_constraint``, which is ``True`` because the + modifier is measured accepted while the per-level constraint switches stay + ``False``. +* table level, CREATE position -- :meth:`ClickHouseTableMixin.format_table_constraint` + narrows on the branch class: ``TablePrimaryKeyConstraint`` / + ``TableCheckConstraint`` render (both measured accepted in a column list), + and ``TableUniqueConstraint`` / ``TableForeignKeyConstraint`` / + ``TableExcludeConstraint`` refuse by name, measurement quoted. +* table level, ADD position -- :meth:`ClickHouseTableMixin.format_add_table_constraint_action` + narrows before delegating to core: ``ALTER TABLE ... ADD CONSTRAINT`` accepts + only ``CHECK`` and ``ASSUME``, so every other branch refuses there even + though the same branch renders in the column list. That asymmetry is the + engine's, not an inconsistency: ``ADD CONSTRAINT pk PRIMARY KEY (id)`` is + Code 62 while ``CREATE TABLE ... PRIMARY KEY (id)`` is accepted. + +No ``_require_*`` helper is needed on this backend, and the reason is a +measurement rather than an omission: every switch below is a statement-level +fact that does not move with the version (asserted by +``test_version_does_not_move_the_values``), so there is no kind x version matrix +for such a helper to carry. The per-kind judgement a helper would otherwise +hold lives in the two formatters above, where the branch class is in scope. +""" + +#: The engine every verdict in this module was measured on. Kept as a constant +#: so the docstrings and the tests cannot drift onto different releases. +MEASURED_ENGINE = "ClickHouse 26.7.3.19" class ClickHouseConstraintMixin: - """ClickHouse constraint support.""" + """ClickHouse constraint capability declarations. + + Every value below is a live measurement against ClickHouse 26.7.3.19; see + the module docstring for the instance, for the constraint catalog shape, + and for the three engine facts the individual docstrings assume. + """ + + # ------------------------------------------------------------------ + # Constraint kinds + # + # Note the deliberate asymmetry with every other SQL backend: ClickHouse's + # column level has no constraints at all -- the one exception is the + # ``PRIMARY KEY`` column *modifier*, which is an engine parameter rather + # than a constraint and is measured accepted (see + # ``supports_primary_key_constraint``) -- while its table level has one + # enforced kind (CHECK) plus one non-enforcing kind (ASSUME). Read this + # section top to bottom before assuming a value was transposed. + # ------------------------------------------------------------------ def supports_primary_key_constraint(self) -> bool: - """Whether PRIMARY KEY constraint is supported.""" + """True -- the switch is about the column modifier, and that parses. + + Read this together with :meth:`supports_table_primary_key_constraint` + and :meth:`supports_column_primary_key_constraint`, which are ``False``: + ClickHouse has no primary-key **constraint** (no catalog row, no + ``ADD CONSTRAINT ... PRIMARY KEY``), but it does accept a ``PRIMARY + KEY`` **column modifier**, and on this core the flat switch is consulted + in exactly one place -- core's ``format_column_primary_key_constraint``, + which emits that modifier into a column definition. The measured answer + for the thing this switch gates is therefore ``True``. + + What is measured, on 26.7.3.19. A primary key built through the + constraint-shaped spellings does not exist:: + + CREATE TABLE probe_pk (id Int32, c Int32, PRIMARY KEY (id)) + ENGINE = MergeTree ORDER BY id; -- accepted + SELECT count() FROM system.constraints + WHERE database='test_db' AND table='probe_pk'; -- 0 + SELECT primary_key, sorting_key FROM system.tables + WHERE database='test_db' AND name='probe_pk'; -- '(id)', 'id' + + ALTER TABLE probe_pk ADD CONSTRAINT pk1 PRIMARY KEY (id) + -> Code: 62. DB::Exception: Syntax error: failed at position 41 + (PRIMARY): PRIMARY KEY (id). + Expected one of: CHECK, ASSUME. (SYNTAX_ERROR) + + ``system.constraints`` on this release has no ``PRIMARY KEY`` value at + all -- its ``type`` enum is ``Enum8('CHECK' = 0, 'ASSUME' = 1)`` -- so a + primary key cannot be one, and the constraint claim is carried by the + two per-level switches above, both ``False``. + + The column modifier, in contrast, is accepted, and it feeds the same + engine parameters:: + + ALTER TABLE probe_pk ADD COLUMN y Int32 PRIMARY KEY -- accepted + + ``SHOW CREATE TABLE`` puts the key inside the ENGINE block with no + ``CONSTRAINT`` clause anywhere. So the keyword parses in the column + grammar, and the resulting column reads back as + ``system.columns.is_in_primary_key = 1``. + + Why the distinction is load-bearing rather than pedantic: core's model + layer appends a ``ColumnPrimaryKeyConstraint`` to the column constraints + of every non-composite ``__primary_key__`` field, and this package's own + fixtures declare a PRIMARY KEY column constraint on each table's ``id`` + column. Answering ``False`` here would refuse to render a CREATE TABLE + for any model with a primary key -- a statement the server accepts -- + while claiming nothing the measurements support. The refusal the + measurements do support happens one level up: ``ADD CONSTRAINT ... + PRIMARY KEY`` is Code 62, so + :meth:`ClickHouseTableMixin.format_add_table_constraint_action` + narrows the ADD position to CHECK before core renders it. + """ return True def supports_unique_constraint(self) -> bool: - """ClickHouse does not support UNIQUE constraints.""" + """False: ClickHouse has no UNIQUE constraint, at either level. + + ClickHouse 26.7.3.19:: + + ALTER TABLE probe_t ADD CONSTRAINT uq1 UNIQUE (id) + -> Code: 62. DB::Exception: Syntax error: failed at position 41 + (UNIQUE): UNIQUE (id). + Expected one of: CHECK, ASSUME. (SYNTAX_ERROR) + ALTER TABLE probe_t ADD COLUMN e3 Int32 UNIQUE + -> Code: 62 ... Expected one of: SIGNED, UNSIGNED, COLLATE, NOT, + NULL, DEFAULT, MATERIALIZED, ALIAS, EPHEMERAL, AUTO_INCREMENT, + COMMENT, CODEC, STATISTICS, TTL, PRIMARY KEY, SETTINGS, FIRST, + AFTER, token, Comma, ... + CREATE TABLE probe_u (id Int32, c Int32, CONSTRAINT u1 UNIQUE (c)) + ENGINE = MergeTree ORDER BY id; -- Code: 62, same message + + Uniqueness in ClickHouse is not a constraint but an *engine + characteristic*: a ``ReplacingMergeTree`` / ``MergeTree`` deduplicates + on the sorting key eventually, at merge time, and never rejects a row. + A caller asking for a UNIQUE constraint wants rejection at write time, + which no ClickHouse engine offers -- so this is a real ``False``, not a + "not implemented yet". + """ return False def supports_not_null_constraint(self) -> bool: - """Whether NOT NULL constraint is supported.""" + """True: ``NOT NULL`` is accepted as a column modifier. + + ClickHouse 26.7.3.19:: + + ALTER TABLE probe_t ADD COLUMN x Int32 NOT NULL -- accepted + + ``NOT NULL`` appears in the parser's own expected-token list for a + column definition, alongside ``NULL``. What the switch must not be + read as claiming is that nullability is stored as a constraint: in + ClickHouse nullability is part of the *type* (``Nullable(T)`` versus + ``T``), and ``system.columns`` reports it there rather than in any + constraint catalog. The keyword parses and the resulting column is not + nullable, which is what the capability means. + """ return True + def supports_check_constraint(self) -> bool: + """False -- and the reason is the column level, not CHECK. + + **A table-level CHECK is real, enforced, and supported on this + backend** (see :meth:`supports_table_check_constraint`, which is + ``True``). What does not exist is a *column-level* CHECK, and this + switch is the one core's column-level branch formatter consults:: + + # backend/dialect/mixins/ddl_column.py, + # format_column_check_constraint(ColumnCheckConstraint) + if not self.supports_check_constraint(): + raise UnsupportedFeatureError(self.name, "CHECK column constraint") + + so answering ``True`` here would make that formatter emit ``CHECK + (...)`` into a column definition -- which ClickHouse rejects:: + + ALTER TABLE probe_t ADD COLUMN e1 Int32 CHECK e1 > 0 + ALTER TABLE probe_t ADD COLUMN e2 Int32 CONSTRAINT cc CHECK (e2 > 0) + -> Code: 62. DB::Exception: Syntax error: failed at position 42 + (CHECK): CHECK e1 > 0. Expected one of: SIGNED, UNSIGNED, + COLLATE, NOT, NULL, DEFAULT, MATERIALIZED, ALIAS, EPHEMERAL, + AUTO_INCREMENT, COMMENT, CODEC, STATISTICS, TTL, PRIMARY KEY, + SETTINGS, FIRST, AFTER, token, Comma, INTO OUTFILE, FORMAT, + ParallelWithClause, PARALLEL WITH, end of query. + + The value is ``False`` so that the column path refuses the constraint + by name, which is the truthful outcome; the table path is covered by + :meth:`supports_table_check_constraint`. + + Core's S3 already split the constraint **nodes**, so the kind is now a + branch class (``ColumnCheckConstraint``) rather than a tag -- but it did + not split this *switch*. Both halves stay answerable and only this pair + of names tells them apart, which is why ``supports_check_constraint`` + (``False``) and ``supports_table_check_constraint`` (``True``) coexist. + """ + return False + def supports_foreign_key_constraint(self) -> bool: - """ClickHouse does not support FOREIGN KEY constraints.""" + """False: ClickHouse has no FOREIGN KEY, at either level. + + ClickHouse 26.7.3.19:: + + ALTER TABLE probe_t ADD CONSTRAINT fk1 FOREIGN KEY (id) + REFERENCES probe_t(id) + -> Code: 62. DB::Exception: Syntax error: failed at position 41 + (FOREIGN): FOREIGN KEY (id) REFERENCES probe_t(id). + Expected one of: CHECK, ASSUME. (SYNTAX_ERROR) + ALTER TABLE probe_t ADD COLUMN e4 Int32 REFERENCES probe_t(id) + -> Code: 62 ... Expected one of: SIGNED, UNSIGNED, COLLATE, ... + + There is no referential-integrity mechanism to build on, so this is + not an "unsupported for now": ``REFERENCES`` is absent from both the + table-constraint grammar and the column-modifier grammar. + """ return False def supports_fk_on_delete(self) -> bool: + """False: there is no foreign key to attach a referential action to. + + ClickHouse 26.7.3.19 rejects ``FOREIGN KEY`` itself with Code 62 + ("Expected one of: CHECK, ASSUME."), so the ``ON DELETE`` spelling is + unreachable rather than merely unimplemented -- see + :meth:`supports_foreign_key_constraint` for the measured statement. + + Declared here so the ``False`` is a measurement rather than an + inherited default that a core change could quietly flip. + """ return False def supports_fk_on_update(self) -> bool: + """False: same reason as :meth:`supports_fk_on_delete`. + + ClickHouse 26.7.3.19 rejects ``FOREIGN KEY`` at the parser (Code 62, + "Expected one of: CHECK, ASSUME."), so ``ON UPDATE`` -- the clause even + the SQL-standard front runners such as Oracle 21c lack -- has nothing + to attach to here. + """ return False - def supports_add_constraint(self) -> bool: - """ClickHouse does not support ALTER TABLE ADD CONSTRAINT.""" + def supports_exclude_constraint(self) -> bool: + """False: ``EXCLUDE`` is not among the two kinds ClickHouse has. + + ClickHouse 26.7.3.19 enumerates the complete accepted set whenever a + third kind is attempted:: + + Expected one of: CHECK, ASSUME. + + No ``EXCLUDE`` appears, and neither PostgreSQL 19beta4 nor any other + measured sibling would accept it at this position either. Core already + defaults this to ``False``; the declaration records the measurement. + """ return False - def supports_drop_constraint(self) -> bool: - """ClickHouse does not support ALTER TABLE DROP CONSTRAINT.""" + # ------------------------------------------------------------------ + # Column level: ClickHouse has no column constraints at all. + # + # These five are the per-level names from the coverage plan. They are + # declared rather than left to be inferred so that "ClickHouse has no + # column constraints" is stated once, explicitly, next to the evidence, + # instead of being a reader's job to deduce from the table-level values. + # Core's S3 wave moved the constraint *nodes* and did not add a protocol + # for these per-level switches, so they keep their names. + # ------------------------------------------------------------------ + + def supports_column_primary_key_constraint(self) -> bool: + """False: a column's ``PRIMARY KEY`` is an engine modifier, not a constraint. + + ClickHouse 26.7.3.19 accepts ``ALTER TABLE ... ADD COLUMN y Int32 + PRIMARY KEY`` -- ``PRIMARY KEY`` is in the parser's expected-token list + for a column -- but the result is not a constraint: ``y`` simply reads + back as ``is_in_primary_key = 1`` in ``system.columns`` and + ``system.tables.primary_key`` moves; ``system.constraints`` stays empty + (measured ``count() = 0`` on a table created with ``PRIMARY KEY (id)``). + + The switch is about the *constraint*, so it answers ``False``. + + Note the deliberate split with the flat + :meth:`supports_primary_key_constraint`, which is ``True`` on the same + release: that switch gates the ``PRIMARY KEY`` column *modifier* (which + renders), this one names the per-level *constraint* (which does not + exist). One boolean cannot carry both halves, which is why the two + coexist; see the flat switch's docstring for the full measurement. + """ return False - def supports_check_constraint(self) -> bool: - """Whether CHECK constraints are enforced.""" + def supports_column_unique_constraint(self) -> bool: + """False: column-level ``UNIQUE`` is not in the column grammar. + + ClickHouse 26.7.3.19:: + + ALTER TABLE probe_t ADD COLUMN e3 Int32 UNIQUE + -> Code: 62. DB::Exception: Syntax error: failed at position 42 + (UNIQUE): UNIQUE. Expected one of: SIGNED, UNSIGNED, COLLATE, + NOT, NULL, DEFAULT, MATERIALIZED, ALIAS, EPHEMERAL, + AUTO_INCREMENT, COMMENT, CODEC, STATISTICS, TTL, PRIMARY KEY, + SETTINGS, FIRST, AFTER, token, Comma, ... + + The expected-token list is the entire column-modifier set, and + ``UNIQUE`` is not in it. + """ return False - def supports_constraint_enforced(self) -> bool: - """Whether ENFORCED/NOT ENFORCED constraint control is supported.""" + def supports_column_check_constraint(self) -> bool: + """False: a column cannot carry a CHECK, in either spelling. + + ClickHouse 26.7.3.19 rejects both ``c Int32 CHECK c > 0`` and + ``c Int32 CONSTRAINT cc CHECK (c > 0)`` with Code 62, listing the whole + accepted column-modifier set -- ``SIGNED, UNSIGNED, COLLATE, NOT, NULL, + DEFAULT, MATERIALIZED, ALIAS, EPHEMERAL, AUTO_INCREMENT, COMMENT, + CODEC, STATISTICS, TTL, PRIMARY KEY, SETTINGS, FIRST, AFTER, token, + Comma, ...`` -- which contains neither ``CHECK`` nor ``CONSTRAINT``. + + Constraints are table-level only on this engine; see + :meth:`supports_table_check_constraint` for the kind that does exist. + """ return False + def supports_column_foreign_key_constraint(self) -> bool: + """False: ``REFERENCES`` is not in the column-modifier grammar. + + ClickHouse 26.7.3.19:: + + ALTER TABLE probe_t ADD COLUMN e4 Int32 REFERENCES probe_t(id) + -> Code: 62. DB::Exception: Syntax error: failed at position 42 + (REFERENCES): REFERENCES probe_t(id). Expected one of: SIGNED, + UNSIGNED, COLLATE, NOT, NULL, DEFAULT, MATERIALIZED, ALIAS, + EPHEMERAL, AUTO_INCREMENT, COMMENT, CODEC, STATISTICS, TTL, + PRIMARY KEY, SETTINGS, FIRST, AFTER, token, Comma, ... + """ + return False + + def supports_column_nullable_constraint(self) -> bool: + """True: ``NULL`` / ``NOT NULL`` are accepted column modifiers. + + ClickHouse 26.7.3.19 lists both ``NULL`` and ``NOT`` among the tokens a + column definition may carry (``ALTER TABLE ... ADD COLUMN x Int32 NOT + NULL`` is accepted), and :meth:`supports_not_null_constraint` carries + the measurement. Declared separately because the coverage plan treats + the nullable and not-null halves as two switches. + + The underlying fact is still that nullability lives in the type + (``Nullable(T)``), not in a constraint catalog. + """ + return True + + # ------------------------------------------------------------------ + # Column value expressions -- the neighbouring capability, and the one + # with the sharpest edge on this engine. + # ------------------------------------------------------------------ + + def supports_default_column_value_expression(self) -> bool: + """True: a column ``DEFAULT`` may be an arbitrary expression. + + ClickHouse 26.7.3.19 accepts any expression in the ``DEFAULT`` position + of a column definition -- ``DEFAULT now()``, ``DEFAULT if(a, b, c)``, + ``DEFAULT a + b`` -- and evaluates it per row on write. + + **This is also the backend's sharpest trap, and it is why the ``True`` + here must not be read as licence to render anything.** A column default + is an arbitrary expression, so a *comparison* parses in that position + too, is accepted, and is silently evaluated to 0/1:: + + ALTER TABLE probe_pk ADD COLUMN d Int32 DEFAULT c > 0; -- accepted + INSERT INTO probe_pk (id, c) VALUES (1, 7), (2, -7); + SELECT id, c, d FROM probe_pk ORDER BY id; + -- (1, 7, 1) + -- (2, -7, 0) + + No error, no warning: an intended constraint check has become a boolean + default column. A renderer that turns a CHECK-shaped predicate into a + column ``DEFAULT`` will therefore *succeed* and produce a table that + stores the violating row. The refusal has to happen in the render path + before the SQL is emitted -- the server will not do it. That gate is a + render-path concern rather than a switch value, so this declaration + records only that the server accepts an expression here. + """ + return True + + def supports_generated_column(self) -> bool: + """False: no SQL-standard ``GENERATED ... AS (...)`` computed column. + + ClickHouse has no generated-column clause; the nearest spellings are + its own four default kinds -- ``MATERIALIZED`` / ``ALIAS`` / + ``EPHEMERAL`` / ``DEFAULT`` -- which are rendered from + :class:`~rhosocial.activerecord.backend.impl.clickhouse.expression.column.ClickHouseColumnDefinition` + rather than from a ``GENERATED`` constraint. ``ALIAS`` in particular is + not stored at all and ``EPHEMERAL`` is not visible after INSERT, so + neither should be reached through a generic "generated column" request. + + Measured on 26.7.3.19: the parser prints the complete column-modifier + set when any column definition is rejected, and ``GENERATED`` is not in + it (see :meth:`supports_column_check_constraint` for the list quoted in + full -- ``SIGNED, UNSIGNED, COLLATE, NOT, NULL, DEFAULT, MATERIALIZED, + ALIAS, EPHEMERAL, AUTO_INCREMENT, COMMENT, CODEC, STATISTICS, TTL, + PRIMARY KEY, SETTINGS, FIRST, AFTER, token, Comma, ...``). + + Distinct from :meth:`supports_default_column_value_expression`, which is + ``True``: a generated column and a defaulted column are different + mechanisms even where both carry an expression. + """ + return False + + # ------------------------------------------------------------------ + # Table level: CHECK is the one enforced kind, ASSUME the one that is not. + # ------------------------------------------------------------------ + + def supports_table_check_constraint(self) -> bool: + """True: ``CONSTRAINT CHECK `` is accepted and enforced. + + ClickHouse 26.7.3.19, at both statement shapes:: + + ALTER TABLE probe_t ADD CONSTRAINT ck CHECK c > 0 + -- accepted; SELECT name, type, expression FROM system.constraints + -- ck | CHECK | c > 0 + CREATE TABLE probe_ct (id Int32, c Int32, + CONSTRAINT ckc CHECK c > 0) + ENGINE = MergeTree ORDER BY id; -- accepted + + Enforcement is real, at write time, with a dedicated error code:: + + INSERT INTO probe_t (id, c) VALUES (1, -5); + -> Code: 469. DB::Exception: Constraint `ck` for table + test_db.probe_cc (...) is violated at row 1. Expression: (c > 0). + Column values: c = -5: While executing WaitForAsyncInsert. + (VIOLATED_CONSTRAINT) + + The expression is **not** restricted to a comparison grammar the way + some siblings' ``CHECK`` is; ``MODIFY CONSTRAINT ck CHECK c > 100`` + re-reads as ``expression = 'c > 100'`` in ``system.constraints``, so an + arbitrary expression is stored and evaluated. Performance note: every + INSERT is evaluated against every CHECK expression, so a table carrying + several of them pays for all of them on every write. + + Table-level only -- see :meth:`supports_check_constraint` for why the + flat switch answers ``False`` on this release. + """ + return True + + def supports_table_primary_key_constraint(self) -> bool: + """False: the primary key lives in the ENGINE parameter block. + + ClickHouse 26.7.3.19, ``CREATE TABLE probe_pk (id Int32, c Int32, + PRIMARY KEY (id)) ENGINE = MergeTree ORDER BY id`` is accepted, but + ``system.constraints`` for that table holds **0 rows** while + ``system.tables.primary_key`` reads ``(id)``, ``sorting_key`` reads + ``id`` and ``system.columns.is_in_primary_key`` reads ``1`` for ``id`` + and ``0`` for ``c``. ``SHOW CREATE TABLE`` puts the key back inside + the ENGINE block with no ``CONSTRAINT`` clause anywhere. + + ``ADD CONSTRAINT ... PRIMARY KEY`` is Code 62 ("Expected one of: CHECK, + ASSUME."), so there is no ALTER-side spelling either. See + :meth:`supports_primary_key_constraint` for the full measurement. + """ + return False + + def supports_table_unique_constraint(self) -> bool: + """False: only CHECK and ASSUME are in the table-constraint grammar. + + ClickHouse 26.7.3.19 states the complete set itself, twice:: + + ALTER TABLE probe_t ADD CONSTRAINT uq1 UNIQUE (id) + -> Code: 62 ... Expected one of: CHECK, ASSUME. (SYNTAX_ERROR) + CREATE TABLE probe_u (id Int32, c Int32, CONSTRAINT u1 UNIQUE (c)) + ENGINE = MergeTree ORDER BY id; -- Code: 62, same message + """ + return False + + def supports_table_foreign_key_constraint(self) -> bool: + """False: only CHECK and ASSUME are in the table-constraint grammar. + + ClickHouse 26.7.3.19:: + + ALTER TABLE probe_t ADD CONSTRAINT fk1 FOREIGN KEY (id) + REFERENCES probe_t(id) + -> Code: 62 ... Expected one of: CHECK, ASSUME. (SYNTAX_ERROR) + CREATE TABLE probe_f (id Int32, c Int32, + CONSTRAINT f1 FOREIGN KEY (c) REFERENCES probe_ct(id)) + ENGINE = MergeTree ORDER BY id; -- Code: 62, same message + """ + return False + + def supports_table_exclude_constraint(self) -> bool: + """False: ``EXCLUDE`` is absent from the two accepted kinds. + + ClickHouse 26.7.3.19 answers every rejected third kind with the same + enumeration -- ``Expected one of: CHECK, ASSUME.`` -- and ``EXCLUDE`` + is not in it. Core already defaults this to ``False``; the declaration + records the measurement. + """ + return False + + def supports_assume_constraint(self) -> bool: + """True: ClickHouse's second constraint kind, ``ASSUME ``. + + ClickHouse 26.7.3.19, accepted at both statement shapes:: + + ALTER TABLE probe_t ADD CONSTRAINT ak ASSUME c > 1000 + CREATE TABLE probe_ta (id Int32, c Int32, + CONSTRAINT akc ASSUME c > 0) + ENGINE = MergeTree ORDER BY id; + ALTER TABLE probe_t MODIFY CONSTRAINT ak ASSUME c > 0 -- accepted + + and visible in the catalog through the ``type`` enum:: + + SELECT name, type, expression FROM system.constraints + WHERE table='probe_t'; + -- ak | ASSUME | c > 1000 + + **It is not enforced.** With ``ak`` the only constraint on a table:: + + INSERT INTO probe_t (id, c) VALUES (3, -5); -- accepted, stored + + That is the whole reason this switch exists and is separate from + :meth:`supports_table_check_constraint`: an expression that can never + reject a row is not a constraint, and rendering it under a CHECK-shaped + node would tell the caller their predicate is being enforced when the + server will happily store the violating row. Callers opt in explicitly. + + Name note: this is the backend-only switch from + ``.claude/plan/2026-10-11/clickhouse-constraint-coverage.md``. There is + no core protocol for it and no core branch class for it -- core's + table-constraint branches are PRIMARY KEY, UNIQUE, CHECK, FOREIGN KEY + and EXCLUDE -- so it survives S3 with its name and its measured value. + A renderer must never spell ``ASSUME`` where ``CHECK`` is implied. + """ + return True + + # ------------------------------------------------------------------ + # SQL-standard clauses ClickHouse has no spelling for + # ------------------------------------------------------------------ + def supports_fk_match(self) -> bool: - """Whether MATCH {SIMPLE|PARTIAL|FULL} is supported.""" + """False: no ``MATCH {SIMPLE|PARTIAL|FULL}``, and no foreign key to carry it. + + ClickHouse 26.7.3.19 rejects ``FOREIGN KEY`` itself at the parser (Code + 62, "Expected one of: CHECK, ASSUME."), so ``MATCH`` is unreachable + rather than unimplemented. Unlike MySQL 8, which parses ``MATCH FULL`` + and then silently drops it, there is nothing here that would be + accepted-and-ignored. + """ return False + def supports_constraint_naming(self) -> bool: + """True: ``CONSTRAINT `` is accepted on both constraint kinds. + + ClickHouse 26.7.3.19, at the table level and inside a column list:: + + ALTER TABLE probe_t ADD CONSTRAINT ck CHECK c > 0 -- accepted + ALTER TABLE probe_t ADD CONSTRAINT ak ASSUME c > 1000 -- accepted + CREATE TABLE probe_ct (id Int32, c Int32, + CONSTRAINT ckc CHECK c > 0) + ENGINE = MergeTree ORDER BY id; -- accepted + + and the name is what the constraint catalog is keyed by, so it is not + merely tolerated:: + + SELECT name, type, expression FROM system.constraints + WHERE database='test_db' AND table='probe_ct'; + -- ckc | CHECK | c > 0 + + Declared here rather than left to core's default of ``True``: core's + default is shared with the dummy dialect and is exactly the kind of + value that can move, and on this backend the answer is *load-bearing* + rather than cosmetic -- the name is how a later + ``ALTER TABLE ... DROP CONSTRAINT`` or ``MODIFY CONSTRAINT`` reaches + the constraint again. + + The column level has no constraint to name at all (Code 62; see + :meth:`supports_column_check_constraint`), so this switch speaks only + about the table-level ``CONSTRAINT `` prefix. Core consults it in + ``format_table_constraint``; this backend renders the prefix itself, so + the value is pinned by test rather than by a render. + """ + return True + def supports_deferrable_constraint(self) -> bool: - """Whether DEFERRABLE constraints are supported.""" + """False: no ``DEFERRABLE`` / ``INITIALLY``, and nothing to defer. + + ClickHouse 26.7.3.19 prints the same two-token expectation for a third + constraint kind and a deferral clause alike (``Expected one of: CHECK, + ASSUME.``); more fundamentally there is no transaction to defer to -- + ClickHouse mutations are asynchronous and are not rolled back, which is + why even the enforced ``CHECK`` fails the INSERT itself (Code 469) + instead of the COMMIT. + + Oracle 21c is the one measured backend where ``DEFERRABLE`` is real; + that does not carry over. + """ return False - def supports_generated_column(self) -> bool: - """Whether generated (computed) columns are supported.""" + def supports_constraint_enforced(self) -> bool: + """False: no ``ENFORCED`` / ``NOT ENFORCED`` clause. + + ClickHouse 26.7.3.19 accepts ``ADD CONSTRAINT name CHECK `` only + in that spelling -- the expectation list is ``CHECK, ASSUME`` and + nothing follows the expression, so a trailing ``ENFORCED`` / + ``NOT ENFORCED`` is not part of the grammar. + + Enforcement is not a clause to be spelled here either: it is decided by + *which kind* you picked. ``CHECK`` enforces (Code 469 at write time), + ``ASSUME`` does not (the violating row is stored). So a caller asking + for ``NOT ENFORCED`` on a CHECK is asking for the ASSUME behaviour and + must ask for it as ASSUME, not as a modifier -- see + :meth:`supports_assume_constraint`. + """ return False - def supports_default_column_value_expression(self) -> bool: - """Whether DEFAULT column values can use expressions.""" - return True # ClickHouse supports expressions in DEFAULT values + # ------------------------------------------------------------------ + # ALTER TABLE constraint lifecycle + # + # Measured: the ALTER command grammar contains exactly three constraint + # commands -- ADD CONSTRAINT, MODIFY CONSTRAINT, DROP CONSTRAINT. Every + # other lifecycle verb is a Code 62 syntax error, and for VALIDATE / + # ENABLE / DISABLE the error message enumerates that list verbatim. + # ------------------------------------------------------------------ + + def supports_add_constraint(self) -> bool: + """True: ``ALTER TABLE ... ADD CONSTRAINT`` exists -- for CHECK and ASSUME. + + ClickHouse 26.7.3.19:: + + ALTER TABLE probe_t ADD CONSTRAINT ck CHECK c > 0 -- accepted + ALTER TABLE probe_t ADD CONSTRAINT ak ASSUME c > 1000 -- accepted + + and for every other kind the parser names the complete accepted set:: + + ALTER TABLE probe_t ADD CONSTRAINT pk1 PRIMARY KEY (id) + ALTER TABLE probe_t ADD CONSTRAINT uq1 UNIQUE (id) + ALTER TABLE probe_t ADD CONSTRAINT fk1 FOREIGN KEY (id) REFERENCES ... + -> Code: 62. DB::Exception: Syntax error: ... Expected one of: + CHECK, ASSUME. (SYNTAX_ERROR) + + So this switch is ``True`` while + :meth:`supports_primary_key_constraint`, + :meth:`supports_unique_constraint` and + :meth:`supports_foreign_key_constraint` are ``False``: the *operation* + exists, and it exists only for two *kinds*. A single boolean cannot + carry both halves, which is precisely why the core S3 split asks for + one switch per kind. + """ + return True + + def supports_drop_constraint(self) -> bool: + """True: ``ALTER TABLE ... DROP CONSTRAINT name`` is accepted. + + ClickHouse 26.7.3.19:: + + ALTER TABLE probe_t ADD CONSTRAINT ck CHECK c > 0 -- accepted + ALTER TABLE probe_t DROP CONSTRAINT ck -- accepted + SELECT count() FROM system.constraints + WHERE table='probe_t' AND name='ck'; -- 0 + + Dropping a constraint that is not there is an **error**, not a no-op:: + + ALTER TABLE probe_t DROP CONSTRAINT nope + -> Code: 36. DB::Exception: Wrong constraint name. + Cannot find constraint `nope` to drop. (BAD_ARGUMENTS) + + So the idempotent path is ``DROP CONSTRAINT IF EXISTS``, declared by + :class:`ClickHouseDDLColumnMixin` -- catching Code 36 instead would be + the wrong shape here, and would not be portable to MySQL at all (which + has no ``DROP CONSTRAINT IF EXISTS`` and fails at the parser). + + Note the namespace consequence: because the primary key is not a + constraint, it cannot be named here. Changing a ClickHouse primary key + is an ENGINE change, not a constraint drop. + """ + return True + + def supports_alter_constraint(self) -> bool: + """False: SQL's ``ALTER CONSTRAINT`` verb is not in ClickHouse's grammar. + + ClickHouse 26.7.3.19:: + + ALTER TABLE probe_t ALTER CONSTRAINT ak CHECK c > 0 + -> Code: 62. DB::Exception: Syntax error: failed at position 28 + (CONSTRAINT): CONSTRAINT ak CHECK c > 0. + Expected COLUMN. (SYNTAX_ERROR) + + The parser wanted the ``COLUMN`` of ``RENAME COLUMN``, which is what + ``ALTER TABLE ... ALTER`` accepts. **ClickHouse does have a way to + redefine an existing constraint**, and it is a different keyword:: + + ALTER TABLE probe_t MODIFY CONSTRAINT ck CHECK c > 100 + -- accepted; system.constraints re-reads expression = 'c > 100' + ALTER TABLE probe_t MODIFY CONSTRAINT ak ASSUME c > 0 + -- accepted + + ``MODIFY`` is not the ``ALTER`` this switch is named after -- it cannot + add enforcement control, only replace the expression in place -- so the + answer is ``False`` and the ``MODIFY`` spelling is described here rather + than being claimed by this switch. + + The probe takes no argument (pattern 1 of the reconciliation rules): a + probe answers "is this capability family present on this server", and + the kind is not part of that question. + """ + return False + + def supports_alter_constraint_enforced(self) -> bool: + """False: there is no ``ALTER CONSTRAINT`` to hang enforcement control on. + + ClickHouse 26.7.3.19 rejects the verb itself -- ``ALTER TABLE ... ALTER + CONSTRAINT`` is Code 62 ("Expected COLUMN."), see + :meth:`supports_alter_constraint` -- and the ADD side has no + ``ENFORCED`` / ``NOT ENFORCED`` to carry either + (:meth:`supports_constraint_enforced`). + + The probe takes no argument (pattern 1). ClickHouse's answer does not + vary by kind, and the reason it used to be written as a per-kind probe + was a signature core no longer calls: neither CHECK nor ASSUME has an + enforcement flag to flip. It is the choice of *kind* that decides + enforcement, and that choice is not revisable after the fact. + + Where the per-kind half lives: nowhere, on this backend -- see + :meth:`supports_constraint_enforced`, whose answer is the same for every + kind, and the module docstring's "Where the narrowing went". + """ + return False + + def supports_validate_constraint(self) -> bool: + """False: ``VALIDATE CONSTRAINT`` is not in the ALTER TABLE grammar. + + ClickHouse 26.7.3.19 is unusually explicit about this -- the error for + an unrecognised ALTER command enumerates the entire accepted command + set, and the constraint commands in it are exactly three:: + + ALTER TABLE probe_t VALIDATE CONSTRAINT ak + -> Code: 62. DB::Exception: Syntax error: failed at position 22 + (VALIDATE): ... Expected one of: ON, a list of ALTER commands, + ALTER command, ADD COLUMN, RENAME COLUMN, MATERIALIZE COLUMN, + DROP PARTITION, ... ADD CONSTRAINT, MODIFY CONSTRAINT, + DROP CONSTRAINT, DETACH PARTITION, ... + + So there is nothing to validate *with* -- and nothing that needs to be: + a CHECK is enforced on every INSERT as it happens (Code 469), so the + rows already in the table were checked when they were written. Adding + a CHECK does not re-examine history. + """ + return False + + def supports_constraint_novalidate(self) -> bool: + """False: no ``NOT VALID`` clause, on the ADD path or anywhere else. + + ClickHouse 26.7.3.19, against a table-level CHECK:: + + ALTER TABLE probe_t ADD CONSTRAINT ck CHECK c > 0 NOT VALID + -> Code: 62. DB::Exception: Syntax error: ... Expected one of: + CHECK, ASSUME. (SYNTAX_ERROR) + + The expectation list is emitted right after ``CONSTRAINT ``, so + the only continuations are the two kinds. Nothing about a ClickHouse + CHECK needs an unvalidated state -- it is evaluated per INSERT and + there is no DDL-time backfill to defer -- but that is an observation, + not a substitute for the rejection: the clause is simply not spelled. + """ + return False + + def supports_rename_constraint(self) -> bool: + """False: no ``RENAME CONSTRAINT ... TO ...``. + + ClickHouse 26.7.3.19:: + + ALTER TABLE probe_t RENAME CONSTRAINT ak TO ak2 + -> Code: 62. DB::Exception: Syntax error: failed at position 29 + (CONSTRAINT): CONSTRAINT ak TO ak2. + Expected COLUMN. (SYNTAX_ERROR) + + The parser wanted the ``COLUMN`` of ``RENAME COLUMN``; the constraint + commands in the ALTER grammar are ADD / MODIFY / DROP only (enumerated + verbatim by :meth:`supports_validate_constraint`). SQL Server's + stored-procedure escape hatch has no ClickHouse equivalent either. + + Name note: this is one of the switches core's protocol does not own, so + core S3 -- which moved the constraint nodes, not the ALTER lifecycle -- + left it alone. + """ + return False + + def supports_enable_disable_constraint(self) -> bool: + """False: ``ENABLE`` / ``DISABLE CONSTRAINT`` are not ClickHouse keywords. + + ClickHouse 26.7.3.19, against a live constraint:: + + ALTER TABLE probe_t ENABLE CONSTRAINT ak + -> Code: 62. DB::Exception: Syntax error: failed at position 22 + (ENABLE): ... Expected one of: ON, a list of ALTER commands, + ALTER command, ADD COLUMN, RENAME COLUMN, ... ADD CONSTRAINT, + MODIFY CONSTRAINT, DROP CONSTRAINT, ... + ALTER TABLE probe_t DISABLE CONSTRAINT ak + -> Code: 62 ... same expectation list, failed at (DISABLE) + + The listed expectation is the complete ALTER command set, and it holds + no ENABLE or DISABLE. This is not a near miss: of the backends + measured this session only Oracle spells these two, and PostgreSQL's + similar-looking ``ENABLE TRIGGER`` is a different object. + + What to reach for instead: ``DROP CONSTRAINT`` then ``ADD CONSTRAINT``. + Name note: this is one of the switches core's protocol does not own, so + core S3 -- which moved the constraint nodes, not the ALTER lifecycle -- + left it alone. + """ + return False diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/cte.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/cte.py index b3cb094..7637481 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/cte.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/cte.py @@ -4,7 +4,7 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError if TYPE_CHECKING: # pragma: no cover - from rhosocial.activerecord.backend.expression import bases + from rhosocial.activerecord.backend.expression.query_sources import CTEClause class ClickHouseCTEMixin: @@ -22,12 +22,12 @@ def supports_materialized_cte(self) -> bool: """ClickHouse accepts the ``AS MATERIALIZED`` CTE hint. The negative spelling, ``AS NOT MATERIALIZED``, is a syntax error on - ClickHouse 26.7 (``Code: 62``, measured); :meth:`format_cte_expression` + ClickHouse 26.7 (``Code: 62``, measured); :meth:`format_cte_clause` refuses it by name rather than rendering it. """ return True - def format_cte_expression(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + def format_cte_clause(self, expr: "CTEClause") -> Tuple[str, tuple]: """Format a CTE definition, refusing the spelling ClickHouse lacks. The states core renders are kept: neither hint renders nothing and @@ -44,4 +44,4 @@ def format_cte_expression(self, expr: "bases.BaseExpression") -> Tuple[str, tupl "ClickHouse spells only AS MATERIALIZED; NOT MATERIALIZED is a " "syntax error (measured: Code 62).", ) - return super().format_cte_expression(expr) + return super().format_cte_clause(expr) diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_column.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_column.py index 625f289..8a5227b 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_column.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_column.py @@ -1,12 +1,8 @@ # src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_column.py -from typing import Any, Dict, List, Tuple, TYPE_CHECKING +from enum import Enum +from typing import Tuple -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - -if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - TableConstraint, - ) +from rhosocial.activerecord.backend.expression.bases import BaseExpression class ClickHouseDDLColumnMixin: @@ -21,32 +17,79 @@ def supports_drop_column_if_exists(self) -> bool: return True def supports_drop_constraint_if_exists(self) -> bool: - """Whether DROP CONSTRAINT IF EXISTS is supported.""" - return False + """True: ``DROP CONSTRAINT IF EXISTS`` is accepted and is a silent no-op + when the constraint is absent. + + ClickHouse 26.7.3.19 (official build), measured on the instance + ``tests/config/clickhouse_scenarios.yaml`` names ``clickhouse_http`` + (``127.0.0.1:18682``, database ``test_db``):: + + ALTER TABLE probe_t DROP CONSTRAINT nope + -> Code: 36. DB::Exception: Wrong constraint name. + Cannot find constraint `nope` to drop. (BAD_ARGUMENTS) + + ALTER TABLE probe_t DROP CONSTRAINT IF EXISTS nope + -- accepted; system.constraints unchanged, no result set, no error + + So on this backend the ``IF EXISTS`` form is **the** idempotent path, + exactly as it is on MariaDB, PostgreSQL and SQL Server. The plain form + is a real error, so catching Code 36 instead of spelling ``IF EXISTS`` + would be both worse and less portable -- MySQL has no + ``DROP CONSTRAINT IF EXISTS`` at all and fails at the parser (1064). + + Declared here (and in this package) rather than left to core's + ``DDLColumnMixin`` default of ``False``: that default is shared with the + dummy dialect and is exactly the kind of value that can move silently. + + Core renders the clause itself, as of its ``4875ea3b`` fix (before it, + core read this probe but never spelled the two words, and this module + carried an override to add them; that override is gone -- keeping it + now splices a second ``IF EXISTS`` and produces + ``DROP CONSTRAINT IF EXISTS IF EXISTS ``). + + Only CHECK and ASSUME can be named here -- they are the only kinds that + exist. The primary key is not a constraint and cannot be dropped by + name; see + :meth:`ClickHouseConstraintMixin.supports_drop_constraint`. + """ + return True def supports_generated_columns(self) -> bool: """Whether generated columns are supported (alias for protocol).""" return False - def format_add_table_constraint_action(self, action: Any) -> Tuple[str, tuple]: - raise UnsupportedFeatureError( - self.name, "ADD CONSTRAINT", - suggestion="ClickHouse does not support table constraints." - ) - def format_add_column_action(self, action) -> Tuple[str, tuple]: column_sql, column_params = self.format_column_definition(action.column) parts = [] - if getattr(action, "if_not_exists", None) is True: + if action.if_not_exists is True: parts.append("ADD COLUMN IF NOT EXISTS") else: parts.append("ADD COLUMN") parts.append(column_sql) - after = getattr(action, "after", None) - if after: - parts.append(f"AFTER {self.format_identifier(after)}") + # The core AddColumnAction carries no position field, so there is + # nothing here to place. ClickHouse's ``AFTER `` arrives through + # ``ClickHouseAddColumnAction.after``, which has its own entry below. return " ".join(parts), column_params + def format_clickhouse_add_column_action(self, action) -> Tuple[str, tuple]: + """``ADD COLUMN ... AFTER `` — the ClickHouse position clause. + + ClickHouse places a new column with ``AFTER `` (also ``FIRST``); + no other backend has the clause, which is why it lives on the derived + node and gets its own entry rather than being probed for on the core + action. ``after=None`` renders exactly what the core entry renders, so + the clause is a position the caller asked for and nothing else. + + The name is a bare identifier, not a value: ``format_identifier`` quotes + it (backticks) and warns on a reserved word rather than emitting SQL the + server would refuse. Documented on + ``https://clickhouse.com/docs/sql-reference/statements/alter/column``. + """ + sql, params = self.format_add_column_action(action) + if action.after is not None: + sql = f"{sql} AFTER {self.format_identifier(action.after)}" + return sql, params + def format_alter_column_action(self, action) -> Tuple[str, tuple]: """Format ALTER TABLE ... ALTER/MODIFY COLUMN for a default change. @@ -80,16 +123,22 @@ def format_alter_column_action(self, action) -> Tuple[str, tuple]: Note that ClickHouse does require a literal default expression here, so the value is inlined rather than parameterised. """ - operation = getattr(action.operation, "value", None) or str(action.operation) + # ``operation`` accepts the enum or its raw spelling; which one it + # is is a type question, so it is asked with isinstance rather than + # probed for a ``value`` attribute (the core formatter reads it the + # same way). + operation = ( + action.operation.value if isinstance(action.operation, Enum) else str(action.operation) + ) col_name = self.format_identifier(action.column_name) if operation == "DROP DEFAULT": return f"MODIFY COLUMN {col_name} REMOVE DEFAULT", () if operation == "SET DEFAULT": - new_value = getattr(action, "new_value", None) + new_value = action.new_value if isinstance(new_value, str): - escaped = self._escape_sql_string(new_value) + escaped = self.escape_sql_string(new_value) return f"ALTER COLUMN {col_name} DEFAULT '{escaped}'", () if isinstance(new_value, bool): return f"ALTER COLUMN {col_name} DEFAULT {1 if new_value else 0}", () @@ -97,93 +146,10 @@ def format_alter_column_action(self, action) -> Tuple[str, tuple]: raise ValueError("SET DEFAULT requires a default value") if isinstance(new_value, (int, float)): return f"ALTER COLUMN {col_name} DEFAULT {new_value}", () - if hasattr(new_value, "to_sql"): + if isinstance(new_value, BaseExpression): value_sql, value_params = new_value.to_sql() return f"ALTER COLUMN {col_name} DEFAULT {value_sql}", tuple(value_params) return f"ALTER COLUMN {col_name} DEFAULT {new_value}", () return super().format_alter_column_action(action) - def format_table_constraint( - self, - t_const: "TableConstraint" - ) -> Tuple[str, tuple]: - """Format a table-level constraint. - - ClickHouse has two table-level constraint forms and they are not these: - ``CONSTRAINT name CHECK `` and ``CONSTRAINT name ASSUME `` - (https://clickhouse.com/docs/reference/statements/create/table#constraints). - Only ``PRIMARY KEY`` is rendered from the SQL-standard set. - - ``UNIQUE`` and ``FOREIGN KEY`` refuse rather than emit, and the reason is - not symmetric: - - * ``UNIQUE (col)`` is a ``SYNTAX_ERROR`` on 26.7.3.19 — ``Code: 62. - Syntax error: failed at position 50 ((): (col)). Expected one of: NULL, - NOT, DEFAULT, MATERIALIZED, EPHEMERAL, ALIAS, AUTO_INCREMENT, TTL, - PRIMARY KEY, data type, identifier. (SYNTAX_ERROR)`` — so rendering it - would be an error the caller sees immediately. - * ``FOREIGN KEY`` **without** a ``CONSTRAINT`` name is *accepted and - silently discarded*, and that is why it refuses. Measured on - 26.7.3.19: ``CREATE TABLE c (id UInt32, pid UInt32, FOREIGN KEY (pid) - REFERENCES p(id)) ENGINE = MergeTree ORDER BY id`` succeeds, but - ``SHOW CREATE TABLE c`` reports no foreign key, - ``system.tables.create_table_query`` does not contain the string - ``FOREIGN``, an orphan row inserts and reads back fine, and dropping - the referenced parent also succeeds. (The *named* form ``CONSTRAINT fk - FOREIGN KEY ...`` is a ``SYNTAX_ERROR`` expecting ``CHECK`` or - ``ASSUME``, as are the inline column-level ``REFERENCES p(id)`` and - the form written after the storage clauses.) - - So emitting it would be the worst of the three outcomes: no error at - DDL time, no record in the schema, and no enforcement at write time. - The referential-integrity bug would surface in production data with - nothing in the server's metadata to explain it. Refusing turns that - into an ``UnsupportedFeatureError`` at the point of declaration. - """ - from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - TableConstraintType, - ) - parts = [] - params: List[Any] = [] - - if t_const.constraint_type == TableConstraintType.PRIMARY_KEY: - if t_const.columns: - cols_str = ', '.join(self.format_identifier(c) for c in t_const.columns) - parts.append(f"PRIMARY KEY ({cols_str})") - elif t_const.constraint_type == TableConstraintType.UNIQUE: - raise UnsupportedFeatureError( - self.name, "UNIQUE table constraint", - suggestion="ClickHouse does not support UNIQUE constraints." - ) - elif t_const.constraint_type == TableConstraintType.FOREIGN_KEY: - raise UnsupportedFeatureError( - self.name, "FOREIGN KEY constraint", - suggestion=( - "ClickHouse does not support FOREIGN KEY constraints, and an " - "unnamed FOREIGN KEY (...) REFERENCES ... clause is accepted and " - "then silently discarded, so emitting one would produce a schema " - "with no foreign key and no enforcement. Maintain referential " - "integrity in the application, or use CONSTRAINT ... CHECK." - ) - ) - - return ' '.join(parts), tuple(params) - - def format_storage_options(self, storage_options: Dict[str, Any]) -> str: - """ - Format storage options for ClickHouse. - - Args: - storage_options: Dict with keys like 'ENGINE', 'ORDER BY', 'PARTITION BY' - - Returns: - Formatted storage options string (e.g., "ENGINE = MergeTree() ORDER BY id") - """ - parts = [] - for key, value in storage_options.items(): - if isinstance(value, str): - parts.append(f"{key} = {value}") - else: - parts.append(f"{key} = {value}") - return ' '.join(parts) diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_database.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_database.py index c6edbc7..05b3bf8 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_database.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_database.py @@ -2,6 +2,7 @@ """ClickHouse database DDL mixin.""" from __future__ import annotations +from enum import Enum from typing import Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError @@ -9,9 +10,9 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements.ddl_database import ( - AlterDatabaseExpression, - CreateDatabaseExpression, - DropDatabaseExpression, + AlterDatabaseStatement, + CreateDatabaseStatement, + DropDatabaseStatement, ) @@ -46,7 +47,7 @@ def supports_database_comment(self) -> bool: return True def format_create_database_statement( - self, expr: CreateDatabaseExpression + self, expr: CreateDatabaseStatement ) -> Tuple[str, tuple]: """Format ``CREATE DATABASE``. @@ -56,7 +57,7 @@ def format_create_database_statement( """ if not isinstance(expr.database, Database): raise TypeError( - f"CreateDatabaseExpression.database must be a Database, " + f"CreateDatabaseStatement.database must be a Database, " f"got {type(expr.database).__name__}" ) parts = ["CREATE DATABASE"] @@ -66,16 +67,21 @@ def format_create_database_statement( if expr.comment: escaped_comment = expr.comment.replace("'", "''") parts.append(f"COMMENT '{escaped_comment}'") - engine = getattr(expr, "engine", None) - if engine: - parts.append(f"ENGINE = {engine}") - on_cluster = getattr(expr, "on_cluster", None) - if on_cluster: - parts.append(f"ON CLUSTER {self.format_identifier(on_cluster)}") + # ``ENGINE`` / ``ON CLUSTER`` are ClickHouse clauses and live on this + # backend's own node; the generic CreateDatabaseStatement carries + # neither, and which one arrived is a type question asked with + # isinstance rather than probed field by field. + from ..expression.database import ClickHouseCreateDatabaseExpression + + if isinstance(expr, ClickHouseCreateDatabaseExpression): + if expr.engine: + parts.append(f"ENGINE = {expr.engine}") + if expr.on_cluster: + parts.append(f"ON CLUSTER {self.format_identifier(expr.on_cluster)}") return " ".join(parts), () def format_drop_database_statement( - self, expr: DropDatabaseExpression + self, expr: DropDatabaseStatement ) -> Tuple[str, tuple]: """Format ``DROP DATABASE``. @@ -85,23 +91,36 @@ def format_drop_database_statement( """ if not isinstance(expr.database, Database): raise TypeError( - f"DropDatabaseExpression.database must be a Database, " + f"DropDatabaseStatement.database must be a Database, " f"got {type(expr.database).__name__}" ) parts = ["DROP DATABASE"] if expr.if_exists: parts.append("IF EXISTS") parts.append(expr.database.to_sql()[0]) - sync = getattr(expr, "sync", False) - if sync: + # ``SYNC`` is a ClickHouse clause and lives on this backend's own + # node; the generic DropDatabaseStatement does not carry it. + from ..expression.database import ClickHouseDropDatabaseExpression + + if isinstance(expr, ClickHouseDropDatabaseExpression) and expr.sync: parts.append("SYNC") return " ".join(parts), () def format_alter_database_statement( - self, expr: AlterDatabaseExpression + self, expr: AlterDatabaseStatement ) -> Tuple[str, tuple]: """Format ``ALTER DATABASE``. + ClickHouse's ALTER DATABASE takes a list of ALTER commands, and + ``MODIFY SETTING`` / ``MODIFY COMMENT`` are the two this branch + spells -- see :meth:`supports_alter_database`. It has no + ``RENAME TO``: the server's own answer to that spelling is a syntax + error ("Expected one of: ON, a list of ALTER commands, ALTER command, + MODIFY SETTING, MODIFY COMMENT"), and a database is renamed with + ``RENAME DATABASE TO ``, which is a different statement. + So the action is refused by name rather than emitted in a form the + server rejects. + Raises: TypeError: ``expr.database`` is not a Database. Anything else would have its own name rendered as the altered database's. @@ -110,34 +129,37 @@ def format_alter_database_statement( """ if not isinstance(expr.database, Database): raise TypeError( - f"AlterDatabaseExpression.database must be a Database, " + f"AlterDatabaseStatement.database must be a Database, " f"got {type(expr.database).__name__}" ) from rhosocial.activerecord.backend.expression.statements.ddl_database import ( AlterDatabaseAction, ) - parts = ["ALTER DATABASE"] - parts.append(expr.database.to_sql()[0]) - # ``RENAME TO`` is the one action core's enum names and ClickHouse spells - # the same way. ``MODIFY COMMENT`` was read off this branch by name and - # core's AlterDatabaseAction has no such member, so every ALTER DATABASE - # raised AttributeError before reaching any of this; an action ClickHouse - # has no form for is reported rather than silently dropped. + # ``MODIFY COMMENT`` was read off this branch by name and core's + # AlterDatabaseAction has no such member, so every ALTER DATABASE + # raised AttributeError before reaching any of this; an action + # ClickHouse has no form for is reported rather than silently + # dropped. ``action`` accepts the enum or its raw spelling; which one + # it is is a type question, so it is asked with isinstance rather than + # probed field by field. + action = expr.action.value if isinstance(expr.action, Enum) else expr.action if expr.action == AlterDatabaseAction.RENAME_TO: - target = expr.target - if not target: - raise UnsupportedFeatureError( - self.name, "ALTER DATABASE ... RENAME TO", - suggestion="RENAME TO requires the new database name.", - ) - parts.append(f"RENAME TO {self.format_identifier(str(target))}") - else: raise UnsupportedFeatureError( self.name, - f"ALTER DATABASE {getattr(expr.action, 'value', expr.action)}", - suggestion="ClickHouse supports ALTER DATABASE ... RENAME TO.", + "ALTER DATABASE ... RENAME TO", + suggestion=( + "ClickHouse has no ALTER DATABASE ... RENAME TO; rename the " + "database with RENAME DATABASE TO ." + ), ) - return " ".join(parts), () + raise UnsupportedFeatureError( + self.name, + f"ALTER DATABASE {action}", + suggestion=( + "ClickHouse's ALTER DATABASE takes MODIFY SETTING / MODIFY " + "COMMENT." + ), + ) __all__ = ['ClickHouseDatabaseMixin'] diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_rename_table.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_rename_table.py index feef458..a713f7b 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_rename_table.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_rename_table.py @@ -3,7 +3,7 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.impl.clickhouse.expression.rename_table import ( - ClickHouseRenameTableExpression, + ClickHouseRenameTableStatement, ) @@ -23,7 +23,7 @@ def supports_multi_table_rename(self) -> bool: def format_rename_table_statement( self, - expr: "ClickHouseRenameTableExpression", + expr: "ClickHouseRenameTableStatement", ) -> Tuple[str, tuple]: """Format a ClickHouse ``RENAME TABLE ...`` statement.""" expr.validate(strict=self.strict_validation) 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 5337c95..2d2b9d7 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table.py @@ -1,6 +1,5 @@ # src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table.py from typing import Any, List, Tuple, TYPE_CHECKING -import re from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.objects import Table @@ -9,23 +8,30 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements.ddl_table import ( ColumnDefinition, - CreateTableAsExpression, - CreateTableCloneExpression, - CreateTableExpression, - CreateTableLikeExpression, - StorageOptionsExpression, + CreateTableAsStatement, + CreateTableCloneStatement, + CreateTableStatement, + CreateTableLikeStatement, IndexDefinition, TableConstraint, ) +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnPrimaryKeyConstraint, +) -class ClickHouseTableMixin: - """ClickHouse table DDL implementation.""" +from .ddl_table_engine import ClickHouseTableEngineMixin - @staticmethod - def _validate_data_type(data_type: str) -> bool: - """Validate data type string, allowing single quotes for ClickHouse ENUM types.""" - return bool(re.fullmatch(r"[A-Za-z0-9\s\(\),\']+", data_type)) + +class ClickHouseTableMixin(ClickHouseTableEngineMixin): + """ClickHouse table DDL implementation. + + The storage clause renderers (``ENGINE``, ``ORDER BY``, ``SETTINGS`` and + their nodes) are the tail of ``CREATE TABLE``, so they live in + :class:`ClickHouseTableEngineMixin` and this class inherits them: the + table DDL protocol describes one surface, and the conformance suite pairs + it with one mixin. + """ def supports_create_table_like(self) -> bool: """ClickHouse expresses a structure copy with ``AS ``, not ``LIKE``. @@ -36,6 +42,15 @@ def supports_create_table_like(self) -> bool: """ return True + + def supports_create_table_as_bind_parameters(self) -> bool: + """ClickHouse accepts bind parameters in a CTAS body. + + Measured on 26.7.3: ``CREATE TABLE t AS SELECT %s FROM s`` is + accepted and the table is created. ClickHouse's "parameters" are + interpolated client-side, so the value is baked into the text. + """ + return True def supports_create_table_clone(self) -> bool: """ClickHouse has ``CREATE TABLE target CLONE AS source``.""" return True @@ -66,7 +81,7 @@ def supports_storage_engine_option(self) -> bool: def supports_charset_option(self) -> bool: return True - def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[str, tuple]: + def format_create_table_statement(self, expr: "CreateTableStatement") -> Tuple[str, tuple]: """Format CREATE TABLE statement for ClickHouse. The target is read from ``expr.table`` as a schema object and rendered by @@ -76,14 +91,14 @@ def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[ database. Raises: - TypeError: ``CreateTableExpression.table`` is not a Table. Any other + TypeError: ``CreateTableStatement.table`` is not a Table. Any other object kind carries its own ``format_method``, so the dialect would call that kind's formatter and render a well-formed ``CREATE TABLE`` whose name is a view, an index or a sequence. """ if not isinstance(expr.table, Table): raise TypeError( - f"CreateTableExpression.table must be a Table, " + f"CreateTableStatement.table must be a Table, " f"got {type(expr.table).__name__}" ) if expr.tablespace: @@ -96,12 +111,18 @@ def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[ self.name, "table INHERITS", "ClickHouse does not support table inheritance.", ) + if expr.storage_options is not None: + raise UnsupportedFeatureError( + self.name, "STORAGE OPTIONS", + "ClickHouse has no generic storage-options clause. Declare the " + "clauses on ClickHouseCreateTableOptions(table_storage_options=...) " + "instead.", + ) all_params: List[Any] = [] options_part = "" - table_options = getattr(expr, "table_options", None) - if table_options is not None: - options_sql, options_params = table_options.to_sql() + if expr.table_options is not None: + options_sql, options_params = expr.table_options.to_sql() if options_sql: options_part = options_sql all_params.extend(options_params) @@ -115,6 +136,13 @@ def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[ parts.append("IF NOT EXISTS") parts.append(expr.table.to_sql()[0]) + # ``ON CLUSTER`` follows the table name, before the column list -- it is + # not a header qualifier and cannot come from + # ``format_clickhouse_create_table_options``. + on_cluster = getattr(expr.table_options, "on_cluster", None) + if on_cluster is not None: + parts.append(f"ON CLUSTER {self.format_identifier(on_cluster)}") + column_parts = [] for col_def in expr.columns: col_sql, col_params = self.format_column_definition(col_def) @@ -133,15 +161,15 @@ def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[ parts.append(f"({', '.join(column_parts)})") - if expr.storage_options: - storage_sql, storage_params = self._format_table_storage_options(expr.storage_options) - if storage_sql: - parts.append(storage_sql) - all_params.extend(storage_params) + storage_sql, storage_params = self._format_table_storage_clauses( + expr.table_options + ) + if storage_sql: + parts.append(storage_sql) + all_params.extend(storage_params) - table_options = getattr(expr, "table_options", None) - 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) + if expr.table_options is not None and expr.table_options.comment is not None: + comment_sql, _ = self.format_table_comment_clause(expr.table_options.comment) parts.append(comment_sql.strip()) if expr.partition is not None: @@ -152,14 +180,26 @@ def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[ return " ".join(parts), tuple(all_params) - def _format_table_storage_options(self, storage_options: Any) -> Tuple[str, tuple]: - """Render storage options from either a mapping or a StorageOptionsExpression.""" - if isinstance(storage_options, dict): - return self.format_table_engine_clauses(storage_options) - return storage_options.to_sql() + def _format_table_storage_clauses(self, table_options: Any) -> Tuple[str, tuple]: + """Render the storage clause run a ClickHouse options node declares. + + The order is the grammar's, not the caller's: clauses are sorted by + :attr:`ClickHouseStorageOptionKind.rank` before rendering, so a caller + that declares them out of order still gets ClickHouse's order. + """ + clauses = getattr(table_options, "table_storage_options", ()) + if not clauses: + return "", () + params: List[Any] = [] + rendered: List[str] = [] + for clause in sorted(clauses, key=lambda item: item.option.rank): + clause_sql, clause_params = clause.to_sql() + rendered.append(clause_sql) + params.extend(clause_params) + return " ".join(rendered), tuple(params) def format_create_table_like_statement( - self, expr: "CreateTableLikeExpression" + self, expr: "CreateTableLikeStatement" ) -> Tuple[str, tuple]: """Format ClickHouse ``CREATE TABLE target AS source``. @@ -167,23 +207,23 @@ def format_create_table_like_statement( ``CREATE TABLE target CLONE AS source`` -- which also copies data -- is a *different* statement and lives in :meth:`format_create_table_clone_statement`, driven by - ``CreateTableCloneExpression``. It used to be mentioned in this + ``CreateTableCloneStatement``. It used to be mentioned in this docstring as if it were something this method could emit; it never was. Raises: - TypeError: ``CreateTableLikeExpression.table`` or ``.like_table`` is + TypeError: ``CreateTableLikeStatement.table`` or ``.like_table`` is not a Table. Either one would otherwise render its own name as the target or the copied table's. UnsupportedFeatureError: The form is not available. """ if not isinstance(expr.table, Table): raise TypeError( - f"CreateTableLikeExpression.table must be a Table, " + f"CreateTableLikeStatement.table must be a Table, " f"got {type(expr.table).__name__}" ) if not isinstance(expr.like_table, Table): raise TypeError( - f"CreateTableLikeExpression.like_table must be a Table, " + f"CreateTableLikeStatement.like_table must be a Table, " f"got {type(expr.like_table).__name__}" ) if not self.supports_create_table_like(): @@ -202,7 +242,7 @@ def format_create_table_like_statement( return " ".join(parts), () def format_create_table_as_statement( - self, expr: "CreateTableAsExpression" + self, expr: "CreateTableAsStatement" ) -> Tuple[str, tuple]: """Format ClickHouse ``CREATE TABLE ... AS ``. @@ -232,7 +272,7 @@ def format_create_table_as_statement( return super().format_create_table_as_statement(expr) def format_create_table_clone_statement( - self, expr: "CreateTableCloneExpression" + self, expr: "CreateTableCloneStatement" ) -> Tuple[str, tuple]: """Format ClickHouse ``CREATE TABLE target CLONE AS source``. @@ -245,7 +285,7 @@ def format_create_table_clone_statement( https://clickhouse.com/docs/sql-reference/statements/create/table#create-table-clone-as Raises: - TypeError: ``CreateTableCloneExpression.table`` or ``.source_table`` + TypeError: ``CreateTableCloneStatement.table`` or ``.source_table`` is not a Table. Either one would otherwise render its own name as the target or the cloned table's. UnsupportedFeatureError: A clause ClickHouse's ``CLONE AS`` has no @@ -253,12 +293,12 @@ def format_create_table_clone_statement( """ if not isinstance(expr.table, Table): raise TypeError( - f"CreateTableCloneExpression.table must be a Table, " + f"CreateTableCloneStatement.table must be a Table, " f"got {type(expr.table).__name__}" ) if not isinstance(expr.source_table, Table): raise TypeError( - f"CreateTableCloneExpression.source_table must be a Table, " + f"CreateTableCloneStatement.source_table must be a Table, " f"got {type(expr.source_table).__name__}" ) if not self.supports_create_table_clone(): @@ -314,16 +354,23 @@ def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, tu params: List[Any] = list(type_params) for constraint in col_def.constraints: + # S3: the branch names the kind and carries that kind's own fields. + # ``is_auto_increment`` belongs to the PRIMARY KEY branch, so reading + # it off the production would be a field the production does not + # declare. + if ( + isinstance(constraint.branch, ColumnPrimaryKeyConstraint) + and constraint.branch.is_auto_increment + ): + raise UnsupportedFeatureError( + self.name, "AUTO_INCREMENT column", + suggestion="ClickHouse does not support AUTO_INCREMENT; use UUID or an explicit value." + ) suffix, cp = self.format_column_constraint(constraint) constraint_text = suffix.strip() if constraint_text: parts.append(constraint_text) params.extend(list(cp)) - if constraint.is_auto_increment: - raise UnsupportedFeatureError( - self.name, "AUTO_INCREMENT column", - 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: @@ -353,42 +400,127 @@ def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, tu return " ".join(parts), tuple(params) def format_table_constraint(self, t_const: "TableConstraint") -> Tuple[str, tuple]: - """Format a table-level constraint. + """Format a table-level constraint, narrowing on the branch class. + + Core's S3 split moved the kind out of a ``constraint_type`` tag and into + the branch node, so the dispatch here reads the branch's class instead + of comparing a tag: ``constraint_type`` is gone as a field, and what + the production keeps in its place is a property that raises + ``AttributeError`` and names the branch. That is the point of the split + -- the kind is a class, so it cannot be misspelled. + + ClickHouse has two table-level constraint forms and neither is in the + SQL-standard set this used to dispatch on: ``CONSTRAINT name CHECK + `` and ``CONSTRAINT name ASSUME `` + (https://clickhouse.com/docs/reference/statements/create/table#constraints). + Only ``PRIMARY KEY`` is rendered from the standard set. + + ``UNIQUE``, ``FOREIGN KEY`` and ``EXCLUDE`` refuse rather than emit, and + the reasons are not symmetric: + + * ``UNIQUE (col)`` is a ``SYNTAX_ERROR`` on 26.7.3.19 -- ``Code: 62. + Syntax error: failed at position 50 ((): (col)). Expected one of: NULL, + NOT, DEFAULT, MATERIALIZED, EPHEMERAL, ALIAS, AUTO_INCREMENT, TTL, + PRIMARY KEY, data type, identifier. (SYNTAX_ERROR)`` -- so rendering it + would be an error the caller sees immediately. + * ``FOREIGN KEY`` **without** a ``CONSTRAINT`` name is *accepted and + silently discarded*, and that is why it refuses. Measured on + 26.7.3.19: ``CREATE TABLE c (id UInt32, pid UInt32, FOREIGN KEY (pid) + REFERENCES p(id)) ENGINE = MergeTree ORDER BY id`` succeeds, but + ``SHOW CREATE TABLE c`` reports no foreign key, + ``system.tables.create_table_query`` does not contain the string + ``FOREIGN``, an orphan row inserts and reads back fine, and dropping + the referenced parent also succeeds. (The *named* form ``CONSTRAINT fk + FOREIGN KEY ...`` is a ``SYNTAX_ERROR`` expecting ``CHECK`` or + ``ASSUME``, as are the inline column-level ``REFERENCES p(id)`` and + the form written after the storage clauses.) + + So emitting it would be the worst of the three outcomes: no error at + DDL time, no record in the schema, and no enforcement at write time. + The referential-integrity bug would surface in production data with + nothing in the server's metadata to explain it. Refusing turns that + into an ``UnsupportedFeatureError`` at the point of declaration. Raises: - TypeError: ``t_const.foreign_key_table`` is not a Table. The check - runs before the kind dispatch so the error names the wrong type - rather than reporting FOREIGN KEY as unsupported, which is a - different mistake and sends the caller looking in the wrong place. + TypeError: the FOREIGN KEY branch's referenced object is not a + :class:`Table`. The check runs before the kind dispatch so the + error names the wrong type rather than reporting FOREIGN KEY as + unsupported, which is a different mistake and sends the caller + looking in the wrong place. """ - from rhosocial.activerecord.backend.expression.statements import TableConstraintType - foreign_key_table = getattr(t_const, "foreign_key_table", None) - if foreign_key_table is not None and not isinstance(foreign_key_table, Table): - raise TypeError( - f"TableConstraint.foreign_key_table must be a Table, " - f"got {type(foreign_key_table).__name__}" - ) - parts = [] + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + TableCheckConstraint, + TableExcludeConstraint, + TableForeignKeyConstraint, + TablePrimaryKeyConstraint, + TableUniqueConstraint, + ) + + branch = t_const.branch + if isinstance(branch, TableForeignKeyConstraint): + foreign_key_table = branch.references[0] + if foreign_key_table is not None and not isinstance(foreign_key_table, Table): + raise TypeError( + f"TableForeignKeyConstraint.references must pair a Table with " + f"its columns, got {type(foreign_key_table).__name__}" + ) + + parts: List[str] = [] params: List[Any] = [] if t_const.name: parts.append(f"CONSTRAINT {self.format_identifier(t_const.name)}") - if t_const.constraint_type == TableConstraintType.PRIMARY_KEY: - if t_const.columns: - cols_str = ", ".join(self.format_identifier(c) for c in t_const.columns) + if isinstance(branch, TablePrimaryKeyConstraint): + if branch.columns: + cols_str = ", ".join(self.format_identifier(c) for c in branch.columns) parts.append(f"PRIMARY KEY ({cols_str})") - elif t_const.constraint_type == TableConstraintType.UNIQUE: + elif isinstance(branch, TableCheckConstraint): + branch_sql, branch_params = self.format_table_check_constraint(branch) + parts.append(branch_sql) + params.extend(branch_params) + elif isinstance(branch, TableUniqueConstraint): raise UnsupportedFeatureError( self.name, "UNIQUE table constraint", suggestion="ClickHouse does not support UNIQUE constraints." ) - elif t_const.constraint_type == TableConstraintType.FOREIGN_KEY: + elif isinstance(branch, TableForeignKeyConstraint): raise UnsupportedFeatureError( self.name, "FOREIGN KEY constraint", - suggestion="ClickHouse does not support FOREIGN KEY constraints." + suggestion=( + "ClickHouse does not support FOREIGN KEY constraints, and an " + "unnamed FOREIGN KEY (...) REFERENCES ... clause is accepted and " + "then silently discarded, so emitting one would produce a schema " + "with no foreign key and no enforcement. Maintain referential " + "integrity in the application, or use CONSTRAINT ... CHECK." + ) + ) + elif isinstance(branch, TableExcludeConstraint): + raise UnsupportedFeatureError( + self.name, "EXCLUDE table constraint", + suggestion=( + "ClickHouse's table-constraint grammar is CHECK and ASSUME; the " + "server prints 'Expected one of: CHECK, ASSUME.' for every third " + "kind (Code 62, measured on 26.7.3.19)." + ) ) + # The production-level traits. ClickHouse has no spelling for either, so + # both refuse here rather than being dropped silently; rendering them + # explicitly is what keeps the override faithful to core's shape. + if ( + t_const.deferrable + or t_const.not_deferrable + or t_const.initially_deferred + or t_const.initially_immediate + ): + raise UnsupportedFeatureError( + self.name, "DEFERRABLE / INITIALLY table constraint" + ) + enforcement = self.format_constraint_enforcement(t_const) + if enforcement: + parts.append(enforcement) + return " ".join(parts), tuple(params) def format_index_definition(self, idx_def: "IndexDefinition") -> Tuple[str, tuple]: @@ -414,16 +546,6 @@ def format_index_definition(self, idx_def: "IndexDefinition") -> Tuple[str, tupl parts.append(f"GRANULARITY {granularity}") return " ".join(parts), () - def format_storage_options(self, expr: "StorageOptionsExpression") -> Tuple[str, tuple]: - """Format ClickHouse table storage options. - - Delegates to ``format_table_engine_clauses`` using the expression's - options mapping. Values are rendered verbatim (not quoted) because - ClickHouse storage option values are SQL fragments (engine names, - column lists, etc.). - """ - return self.format_table_engine_clauses(expr.options) - def supports_if_not_exists_table(self) -> bool: """Whether CREATE TABLE IF NOT EXISTS is supported.""" return True diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table_engine.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table_engine.py index 3a53410..0562084 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table_engine.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table_engine.py @@ -16,7 +16,10 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, List, Tuple + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.bases import BaseExpression class ClickHouseTableEngineSupport: @@ -56,61 +59,100 @@ def supports_array_join(self) -> bool: class ClickHouseTableEngineMixin(ClickHouseTableEngineSupport): - """ClickHouse table engine / storage clause rendering. + """ClickHouse table storage clause rendering. - Renders the ClickHouse-specific table clauses appended after the column - list in ``CREATE TABLE``: ``ENGINE``, ``ORDER BY``, ``PARTITION BY``, - ``PRIMARY KEY``, ``SAMPLE BY``, ``TTL`` and ``SETTINGS``. + Renders the clauses ClickHouse appends to ``CREATE TABLE`` after the + column list: ``ENGINE``, ``PRIMARY KEY``, ``PARTITION BY``, ``ORDER BY``, + ``SAMPLE BY``, ``TTL`` and ``SETTINGS``. - Usage: pass these clauses through the ``CreateTableExpression`` - ``dialect_options`` or ``storage_options`` mapping. + Each clause is one :class:`ClickHouseTableStorageOption` node. The nodes + are declared on ``ClickHouseCreateTableOptions``; the statement renderer + orders them by :attr:`ClickHouseStorageOptionKind.rank` and joins them. """ - def format_table_engine_clauses(self, storage_options: Optional[Dict[str, Any]]) -> Tuple[str, tuple]: - """Format ClickHouse storage clauses from a storage-options mapping. - - Accepted keys (case-insensitive): + def format_clickhouse_engine_spec(self, expr: Any) -> Tuple[str, tuple]: + """Render an engine name and its parameters. - - ``ENGINE`` / ``engine`` — ``ENGINE = MergeTree()`` - - ``ORDER BY`` / ``order_by`` — ``ORDER BY (col1, col2)`` - - ``PARTITION BY`` / ``partition_by``— ``PARTITION BY (expr)`` - - ``PRIMARY KEY`` / ``primary_key`` — ``PRIMARY KEY (col)`` - - ``SAMPLE BY`` / ``sample_by`` — ``SAMPLE BY (expr)`` - - ``TTL`` / ``ttl`` — ``TTL expr`` - - ``SETTINGS`` / ``settings`` — ``SETTINGS k = v, ...`` - - Values are inserted verbatim (not quoted). + ``ENGINE = MergeTree`` has no parameters; ``ReplacingMergeTree(version)`` + renders the parameter node in the parentheses. + """ + if not expr.parameters: + return expr.engine, () + params: List[Any] = [] + rendered: List[str] = [] + for parameter in expr.parameters: + parameter_sql, parameter_params = parameter.to_sql() + rendered.append(parameter_sql) + params.extend(parameter_params) + return f"{expr.engine}({', '.join(rendered)})", tuple(params) + + def format_clickhouse_setting(self, expr: Any) -> Tuple[str, tuple]: + """Render one ``name = value`` pair of a ``SETTINGS`` clause. + + A scalar goes through :meth:`format_literal`, so the caller's text is + never pasted into the statement; a node renders itself. + """ + value = expr.value + if isinstance(value, BaseExpression): + value_sql, params = value.to_sql() + return f"{expr.name} = {value_sql}", params + return f"{expr.name} = {self.format_literal(value)}", () + + def format_clickhouse_table_storage_option(self, expr: Any) -> Tuple[str, tuple]: + """Render one ClickHouse table storage clause. + + The clause's spelling is decided here, not by the caller: the caller + chooses the option and supplies a typed payload, the renderer owns + the keyword. + """ + from ..expression.storage_option import ( + ClickHouseStorageOptionKind, + ClickHouseTableStorageOption, + ) + + option = expr.option + if option is ClickHouseStorageOptionKind.ENGINE: + engine_sql, engine_params = self.format_clickhouse_engine_spec(expr.value) + return f"ENGINE = {engine_sql}", engine_params + + if option is ClickHouseStorageOptionKind.SETTINGS: + params: List[Any] = [] + rendered: List[str] = [] + for item in expr.value: + item_sql, item_params = self.format_clickhouse_setting(item) + rendered.append(item_sql) + params.extend(item_params) + return f"SETTINGS {', '.join(rendered)}", tuple(params) + + if option in ClickHouseTableStorageOption.EXPRESSION_MEMBERS: + value_sql, value_params = expr.value.to_sql() + return f"{option.keyword} {value_sql}", value_params + + # Remaining members: PRIMARY KEY / PARTITION BY / ORDER BY. + params = [] + rendered = [] + for item in expr.value: + item_sql, item_params = item.to_sql() + rendered.append(item_sql) + params.extend(item_params) + body = rendered[0] if len(rendered) == 1 else f"({', '.join(rendered)})" + return f"{option.keyword} {body}", tuple(params) + + def format_clickhouse_create_table_options(self, expr: Any) -> Tuple[str, tuple]: + """Render the ClickHouse ``CREATE`` header qualifier. + + ClickHouse places ``OR REPLACE`` between ``CREATE`` and ``TABLE``, the + position the generic renderer reserves for this entry. ``ON CLUSTER`` + is **not** a header qualifier -- it follows the table name -- so the + statement renderer reads it off the node and places it there. """ - if not storage_options: - return "", () parts: List[str] = [] - for raw_key, value in storage_options.items(): - key = str(raw_key).upper().replace("_", " ") - if key == "ENGINE": - parts.append(f"ENGINE = {value}") - elif key == "ORDER BY": - parts.append(f"ORDER BY {self.format_clause_list(value)}") - elif key == "PARTITION BY": - parts.append(f"PARTITION BY {self.format_clause_list(value)}") - elif key == "PRIMARY KEY": - parts.append(f"PRIMARY KEY {self.format_clause_list(value)}") - elif key == "SAMPLE BY": - parts.append(f"SAMPLE BY {self.format_clause_list(value)}") - elif key == "TTL": - parts.append(f"TTL {value}") - elif key == "SETTINGS": - parts.append(f"SETTINGS {value}") - else: - parts.append(f"{key} = {value}") + if expr.or_replace: + if not self.supports_create_or_replace_table(): + raise UnsupportedFeatureError(self.name, "CREATE OR REPLACE TABLE") + parts.append("OR REPLACE") return " ".join(parts), () - @staticmethod - def format_clause_list(value: Any) -> str: - """Format a column/expression list, accepting str, list, or tuple.""" - if isinstance(value, (list, tuple)): - return "(" + ", ".join(str(v) for v in value) + ")" - return str(value) - class ClickHouseQueryClauseMixin(ClickHouseTableEngineSupport): """ClickHouse query clause rendering (FINAL, ARRAY JOIN, SAMPLE). diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table_statement.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table_statement.py index 65dfaab..b8f30b7 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table_statement.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/ddl_table_statement.py @@ -11,6 +11,14 @@ class ClickHouseTableStatementMixin: ClickHouse exposes equivalent functionality through ``SELECT * FROM
`` and explicit ``SELECT ... UNION ALL ...`` row constructors. All methods fail fast. + + Measured on 26.7.3: ClickHouse has a ``values`` *table function*, so + ``SELECT * FROM VALUES(1, 2)`` is accepted -- but that is a different + shape from the SQL standard's table value constructor, which is + ``VALUES (1), (2)``, and the standard spelling is a syntax error there + ("failed at position 21"). The framework renders the standard shape, so + the answer is False: the capability is not the one this probe asks + about. """ def supports_table_statement(self) -> bool: diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/dml.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/dml.py index 0d7a470..9edb08e 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/dml.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/dml.py @@ -5,7 +5,7 @@ from rhosocial.activerecord.backend.expression.objects import Table if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression.statements import InsertExpression + from rhosocial.activerecord.backend.expression.statements import InsertStatement class ClickHouseDMLOperationMixin: @@ -40,7 +40,7 @@ def format_on_conflict_clause(self, expr: Any) -> Tuple[str, tuple]: ), ) - def format_insert_statement(self, expr: "InsertExpression") -> Tuple[str, tuple]: + def format_insert_statement(self, expr: "InsertStatement") -> Tuple[str, tuple]: """Format a ClickHouse INSERT statement. ClickHouse does not support INSERT IGNORE / REPLACE INTO / ON CONFLICT; @@ -55,18 +55,22 @@ def format_insert_statement(self, expr: "InsertExpression") -> Tuple[str, tuple] """ if not isinstance(expr.into, Table): raise TypeError( - f"InsertExpression.into must be a Table, " + f"InsertStatement.into must be a Table, " f"got {type(expr.into).__name__}" ) if self.strict_validation: expr.validate(strict=True) - if getattr(expr, "replace", False): + # The core node spells these ``or_replace`` / ``or_ignore``; reading + # the names the contract declares makes the two refusals below real + # (they used to probe ``replace`` / ``ignore``, fields no InsertStatement + # carries, so an INSERT OR REPLACE silently rendered as a plain INSERT). + if expr.or_replace: raise UnsupportedFeatureError( self.name, "REPLACE INTO", suggestion="ClickHouse does not support REPLACE INTO." ) - if getattr(expr, "ignore", False): + if expr.or_ignore: raise UnsupportedFeatureError( self.name, "INSERT IGNORE", suggestion="ClickHouse does not support INSERT IGNORE." @@ -96,16 +100,14 @@ def format_insert_statement(self, expr: "InsertExpression") -> Tuple[str, tuple] if isinstance(expr.source, DefaultValuesSource): parts.append("DEFAULT VALUES") elif isinstance(expr.source, ValuesSource): - all_rows_sql = [] - for row in expr.source.values_list: - row_sql, row_params = [], [] - for val in row: - s, p = val.to_sql() - row_sql.append(s) - row_params.extend(p) - all_rows_sql.append(f"({', '.join(row_sql)})") - all_params.extend(row_params) - parts.append("VALUES " + ", ".join(all_rows_sql)) + # A cell is either an expression or a bare host value, and the + # core owns that decision in one place -- re-rendering the rows + # here (the old loop called ``val.to_sql()`` unconditionally, so + # a bare scalar cell crashed) would fork it. ClickHouse's VALUES + # spelling is the base's, so the whole branch is the delegation. + source_sql, source_params = self.format_insert_values_source(expr.source) + parts.append(source_sql) + all_params.extend(source_params) elif isinstance(expr.source, SelectSource): s_sql, s_params = expr.source.select_query.to_sql() parts.append(s_sql) diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/explain.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/explain.py index 64db51f..5ed7905 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/explain.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/explain.py @@ -1,8 +1,8 @@ # src/rhosocial/activerecord/backend/impl/clickhouse/mixins/explain.py -from typing import Tuple, TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression.statements import ExplainExpression + from rhosocial.activerecord.backend.expression.statements import ExplainStatement class ClickHouseExplainMixin: @@ -12,9 +12,11 @@ def supports_explain_analyze(self) -> bool: """Whether EXPLAIN ANALYZE is supported. Accepted syntax-wise from ClickHouse 26.7; older maintained lines - (25.8 LTS, 26.3 LTS) reject the ANALYZE keyword. + (25.8 LTS, 26.3 LTS) reject the ANALYZE keyword. Answered through + ``version_at_least`` so an unadapted dialect says "no" instead of + raising just to report a capability. """ - return self.version >= (26, 7, 0) + return self.version_at_least((26, 7, 0)) def supports_explain_format(self, format_type: str) -> bool: """Check if specific EXPLAIN format is supported. @@ -26,7 +28,7 @@ def supports_explain_format(self, format_type: str) -> bool: return True return False - def format_explain_statement(self, explain_expr: "ExplainExpression") -> tuple: + def format_explain_statement(self, explain_expr: "ExplainStatement") -> tuple: """Build the ClickHouse EXPLAIN SQL string and return (sql, params). ClickHouse syntax variants: diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/fulltext.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/fulltext.py index f5078c4..614e807 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/fulltext.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/fulltext.py @@ -36,7 +36,7 @@ def format_fulltext_index_options( "hasAllTokens / hasAnyTokens. tokenbf_v1 still works but is deprecated from 26.2.", ) - def format_match_against(self, expr) -> Tuple[str, tuple]: + def format_match_against_expression(self, expr) -> Tuple[str, tuple]: raise UnsupportedFeatureError( self.name, "MATCH ... AGAINST", suggestion="ClickHouse does not support MATCH ... AGAINST; use a text inverted index " diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/function.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/function.py index f8b2a54..0851ef7 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/function.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/function.py @@ -1,5 +1,8 @@ # src/rhosocial/activerecord/backend/impl/clickhouse/mixins/function.py -from typing import Dict +from typing import Dict, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression.bases import BaseExpression class ClickHouseFunctionMixin: @@ -48,17 +51,55 @@ def supports_functions(self) -> Dict[str, bool]: return result def _is_clickhouse_function_supported(self, func_name: str) -> bool: - """Check if a ClickHouse-specific function is supported based on version.""" + """Check if a ClickHouse-specific function is supported based on version. + + Both bounds are answered through :meth:`version_at_least`, which never + raises: a capability check is a question, not a formatter, so an + unadapted dialect reports "not confirmed" rather than failing. + """ version_range = self._CLICKHOUSE_FUNCTION_VERSIONS.get(func_name) if version_range is None: return True min_version, max_version = version_range - if min_version is not None and self.version < min_version: + if min_version is not None and not self.version_at_least(min_version): return False - if max_version is not None and self.version > max_version: - return False + if max_version is not None: + # "at most max_version" is the complement of "at least the first + # version after it"; version components are integers, so no + # version falls strictly between the two tuples. + first_after = (max_version[0], max_version[1], max_version[2] + 1) + if self.version_at_least(first_after): + return False return True + + def format_char_length_function(self, expr: "BaseExpression") -> Tuple[str, tuple]: + """Render ``lengthUTF8(operand)`` -- the character count. + + ClickHouse's ``LENGTH`` answers in **bytes**, so the core's default + spelling answers a different question than the node asks. Measured + on the scenario server ``tests/config/clickhouse_scenarios.yaml`` + names ``clickhouse_http`` (ClickHouse 26.7.3.19, HTTP on + ``127.0.0.1:18682``):: + + SELECT length('héllo') -- 6: five characters, six UTF-8 bytes + SELECT lengthUTF8('héllo') -- 5: the character count + SELECT CHAR_LENGTH('héllo') -- 5: accepted as an alias on this + -- version; lengthUTF8 is the + -- spelling whose availability + -- spans every version this + -- backend declares + + ``expr.unit`` is deliberately not read, the same choice the core's + default makes: it records which length semantics the caller asked + for, and this entry point writes the one spelling that carries the + character answer. + """ + operand_sql, operand_params = expr.operand.to_sql() + sql = f"lengthUTF8({operand_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(operand_params) diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/introspection.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/introspection.py index 3c10e85..9c119a1 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/introspection.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/introspection.py @@ -1,17 +1,21 @@ # src/rhosocial/activerecord/backend/impl/clickhouse/mixins/introspection.py -from typing import List, Tuple, TYPE_CHECKING +from typing import List, Optional, Tuple, TYPE_CHECKING if TYPE_CHECKING: from rhosocial.activerecord.backend.introspection.types import IntrospectionScope + from ..expression.introspection import ( + ClickHouseTableEngineQuery, + ClickHouseTablePartitionsQuery, + ) from rhosocial.activerecord.backend.expression.introspection import ( - DatabaseInfoExpression, - TableListExpression, - ColumnInfoExpression, - IndexInfoExpression, - ForeignKeyExpression, - ViewListExpression, - ViewInfoExpression, - TriggerListExpression, + DatabaseInfoQueryStatement, + TableListQueryStatement, + ColumnInfoQueryStatement, + IndexInfoQueryStatement, + ForeignKeyQueryStatement, + ViewListQueryStatement, + ViewInfoQueryStatement, + TriggerListQueryStatement, ) @@ -24,8 +28,8 @@ class ClickHouseIntrospectionMixin: The format_*_query methods are called by Expression.to_sql() to generate database-specific SQL using the ClickHouse system database - (system.databases, system.tables, system.columns, system.views, - system.data_skipping_indices, system.parts). + (system.databases, system.tables, system.columns, + system.data_skipping_indices). Architecture flow: Introspector._build_*_sql() [base class] @@ -33,21 +37,48 @@ class ClickHouseIntrospectionMixin: → Dialect.format_*_query() [this mixin] → Returns SQL and parameters + Every query here is a :class:`~...expression.statements.dql.QueryStatement` + tree: the projection, the conjunction of conditions and the ordering are + nodes, and this module only decides *what* is asked. A string assembled one + clause at a time showed no single place the query existed, and its bind + parameters were counted by hand (`offset=len(sql_params)`) -- a count that + had to be kept in step with every branch above it. The tree carries the + order instead, because a `Literal` either binds or inlines at its own + position and the formatter concatenates the parameters in projection order + (expression-dialect §16.2). + + Which values bind and which inline is exactly what the concatenated form + did, so the parameter tuples stay byte-identical: the schema/table/view + names and the engine list are bound, while the fixed SQL words (`'system'`, + the view-engine exclusion, the constant projection cells) are inline. + ClickHouse supports introspection via system tables. Foreign key and trigger introspection are NOT supported (ClickHouse has neither FKs nor triggers), so those supports_* methods return False. - NOTE: Every ``format_*_query`` below already reads a ClickHouse system - table — ``system.databases``, ``system.tables``, ``system.columns``, - ``system.data_skipping_indices``, and the ``engine LIKE '%View'`` subset of - ``system.tables``. (An earlier note here claimed these still emitted MySQL - ``information_schema`` SQL "inherited from the MySQL backend"; that was - stale, they had already been rewritten.) Column aliases such as - ``AS TABLE_NAME`` are kept because the result parsers upstream read those - names; the *sources* are ClickHouse's own. Foreign-key and trigger queries - have no ClickHouse system table at all and raise. + The projection aliases (``AS TABLE_NAME`` and friends) are kept because the + result parsers upstream read those names; the sources are ClickHouse's own + ``system.*`` tables. Foreign-key and trigger queries have no ClickHouse + system table at all and raise. """ + def supports_partition_introspection(self) -> bool: + """ClickHouse answers for a table's partitions from ``system.parts``. + + The capability for the ``introspect partitions`` family. The + parts view is live, so a partition with no active parts has + nothing to report and the answer is aggregated.""" + return True + + def supports_table_engine_introspection(self) -> bool: + """ClickHouse answers for a table's storage definition. + + The capability for the ``introspect table-engine`` family: the engine + a table was created with and the key expressions it declares, which + on this engine are the closest thing a schema has to a primary key. + """ + return True + # ========== Capability Detection ========== def supports_introspection(self) -> bool: @@ -75,7 +106,7 @@ def supports_foreign_key_introspection(self) -> bool: return False def supports_view_introspection(self) -> bool: - """ClickHouse supports view introspection via system.views.""" + """ClickHouse supports view introspection via system.tables.""" return True def supports_trigger_introspection(self) -> bool: @@ -94,95 +125,295 @@ def get_supported_introspection_scopes(self) -> List["IntrospectionScope"]: IntrospectionScope.VIEW, ] + # ========== Shared building blocks ========== + + def _system_relation(self, name: str): + """One ClickHouse ``system`` table as a ``FROM`` source. + + ClickHouse qualifies an object with the database alone, and the object + carries it in ``catalog_name`` (see ``ClickHouseNamespaceMixin``), so + ``system.tables`` is a table named ``tables`` in the ``system`` + catalog. Writing the dotted name as one opaque string would put a + namespace into an identifier, which is the one thing the object model + exists to keep apart. + """ + from rhosocial.activerecord.backend.expression import NamedRelationRef + from rhosocial.activerecord.backend.expression.objects import Table + + return NamedRelationRef(self, Table(self, name, catalog_name="system")) + + def _column(self, name: str, alias: Optional[str] = None): + """A ``system.*`` column, optionally projected under another name.""" + from rhosocial.activerecord.backend.expression import Column + + return Column(self, name, alias=alias) + + def _equals(self, left, right): + """``left = right`` as a predicate node.""" + from rhosocial.activerecord.backend.expression import ComparisonPredicate + + return ComparisonPredicate(self, "eq", left, right) + + def _not_equals(self, left, right): + """``left <> right`` as a predicate node.""" + from rhosocial.activerecord.backend.expression import ComparisonPredicate + + return ComparisonPredicate(self, "ne", left, right) + + def _all_of(self, *predicates): + """The conjunction of the given predicates, or ``None`` when there are none.""" + from rhosocial.activerecord.backend.expression import LogicalPredicate + + kept = [p for p in predicates if p is not None] + if not kept: + return None + return LogicalPredicate(self, "AND", *kept) + + def _where(self, *predicates): + """A ``WhereClause`` over the conjunction, or ``None`` for an unfiltered read.""" + from rhosocial.activerecord.backend.expression import WhereClause + + condition = self._all_of(*predicates) + return None if condition is None else WhereClause(self, condition) + + def _order_by(self, *columns: str): + """``ORDER BY`` over the named columns, in the order given.""" + from rhosocial.activerecord.backend.expression import OrderByClause + + return OrderByClause(self, [self._column(name) for name in columns]) + + def _group_by(self, *columns: str): + """``GROUP BY`` over the named columns, in the order given.""" + from rhosocial.activerecord.backend.expression import GroupByHavingClause + + return GroupByHavingClause(self, group_by=[self._column(name) for name in columns]) + + def _constant(self, value, alias: str, data_type): + """A constant projection carrying *alias* and its declared type. + + A ``Literal`` has no name to give -- a literal is the one node in the + tree with nothing to alias, because a bind parameter belongs to the + driver and inline text is text. So a constant cell is cast to the type + the position declares and the cast carries the alias. The type is the + honest half of that: ``0 AS AUTO_INCREMENT`` reads back as whatever the + engine decides a bare integer literal is, while the cast states the + column's type as the parser expects it. + """ + from rhosocial.activerecord.backend.expression import Literal + from rhosocial.activerecord.backend.expression.core import CastExpression + + return CastExpression( + self, + Literal(self, value, inline=True), + data_type, + alias=alias, + ) + + def _text_constant(self, value: str, alias: str): + """A string constant projection, typed ``String``.""" + from ..expression.types import ClickHouseStringType + + return self._constant(value, alias, ClickHouseStringType(self)) + + def _int_constant(self, value: int, alias: str): + """An integer constant projection, typed ``Int64``.""" + from ..expression.types import ClickHouseInt64Type + + return self._constant(value, alias, ClickHouseInt64Type(self)) + # ========== Query Formatting ========== - def format_database_info_query(self, expr: "DatabaseInfoExpression") -> Tuple[str, tuple]: - """Format database information query against ClickHouse system.databases.""" + def format_database_info_query_statement(self, expr: "DatabaseInfoQueryStatement") -> Tuple[str, tuple]: + """Format database information query against ClickHouse system.databases. + + A ClickHouse database answers its name and its storage engine. It has + no per-database character set and no per-database collation, so neither + is projected: the previous form aliased the engine as + ``DEFAULT_CHARACTER_SET_NAME`` and invented an empty collation, which + reported two facts the server never answered. + """ + from rhosocial.activerecord.backend.expression import Literal, QueryStatement + params = expr.get_params() schema = params.get("schema", "") - sql = ( - "SELECT engine AS DEFAULT_CHARACTER_SET_NAME, '' AS DEFAULT_COLLATION_NAME " - "FROM system.databases " - f"WHERE name = {self.get_parameter_placeholder(0)}" + + query = QueryStatement( + self, + select=[ + self._column("name", alias="DATABASE_NAME"), + self._column("engine", alias="ENGINE"), + ], + from_=self._system_relation("databases"), + where=self._where(self._equals(self._column("name"), Literal(self, schema))), ) - return (sql, (schema,)) + return query.to_sql() - def format_table_list_query(self, expr: "TableListExpression") -> Tuple[str, tuple]: + def format_table_list_query_statement(self, expr: "TableListQueryStatement") -> Tuple[str, tuple]: """Format table list query against ClickHouse system.tables.""" + from rhosocial.activerecord.backend.expression import Literal, QueryStatement + params = expr.get_params() schema = params.get("schema", "") include_views = params.get("include_views", True) include_system = params.get("include_system", False) table_type = params.get("table_type") - p = self.get_parameter_placeholder - conditions = [f"database = {p(0)}"] - sql_params: list = [schema] + conditions = [self._equals(self._column("database"), Literal(self, schema))] - if not include_system: - conditions.append("database != 'system'") + # ``include_system`` excludes the engine's own database. Asking for + # system tables by kind *is* that request already, so the exclusion is + # not added: otherwise the two answers ("system" and "not system") + # conjoin and no row can satisfy both. + if not include_system and not self.is_system_table_type(table_type): + conditions.append( + self._not_equals(self._column("database"), Literal(self, "system", inline=True)) + ) if not include_views: # The set of engines that store a view is declared, not guessed at # with a substring match: `LIKE '%View'` also matched anything whose # engine name merely ended in "View", and said nothing about which # kinds were being included when `include_views` was true. - engine_condition, engine_params = self.engine_kind_condition( - include_views=False, offset=len(sql_params), - ) - conditions.append(engine_condition) - sql_params.extend(engine_params) + conditions.append(self.engine_kind_predicate(include_views=False)) if table_type: - conditions.append(f"engine = {p(len(sql_params))}") - sql_params.append(table_type) - - where = " AND ".join(conditions) - sql = ( - "SELECT name AS TABLE_NAME, engine AS TABLE_TYPE, " - "comment AS TABLE_COMMENT, total_rows AS TABLE_ROWS, " - "total_bytes AS DATA_LENGTH, 0 AS AUTO_INCREMENT, " - "metadata_modification_time AS MODIFICATION_TIME " - f"FROM system.tables WHERE {where} ORDER BY name" + conditions.append(self.table_type_predicate(table_type)) + + query = QueryStatement( + self, + select=[ + self._column("name", alias="TABLE_NAME"), + # The projection names the column the catalogue has. The kind + # (base table / view / materialized view) is derived from this + # engine by the parser; aliasing it as TABLE_TYPE made a + # storage-engine name look like a standard table type. + self._column("engine", alias="ENGINE"), + self._column("comment", alias="TABLE_COMMENT"), + self._column("total_rows", alias="TABLE_ROWS"), + self._column("total_bytes", alias="DATA_LENGTH"), + self._int_constant(0, "AUTO_INCREMENT"), + self._column("metadata_modification_time", alias="MODIFICATION_TIME"), + ], + from_=self._system_relation("tables"), + where=self._where(*conditions), + order_by=self._order_by("name"), + ) + return query.to_sql() + + def format_column_info_query_statement(self, expr: "ColumnInfoQueryStatement") -> Tuple[str, tuple]: + """Format column information query against ClickHouse system.columns. + + ``IS_NULLABLE`` is derived from the type name: ClickHouse encodes + nullability in the type (``Nullable(String)``), not in a column flag, so + the projection tests the rendered type for that prefix. + + The ``%`` in that pattern is written doubled, and the doubling is + load-bearing. The introspector executor executes this statement through + a cursor directly rather than through ``Backend.execute``, so the + backend's ``_escape_literal_percent`` -- which every other statement + gains on the way to the driver -- does not run here. A single ``%`` + reaches clickhouse-connect's ``%`` interpolation and is read as the + start of a format specifier: measured in CI, + ``ValueError: unsupported format character "'" (0x27) at index 134``. + """ + from rhosocial.activerecord.backend.expression import ( + FunctionCall, + LikeOperator, + LikePredicate, + Literal, + QueryStatement, ) - return (sql, tuple(sql_params)) - def format_column_info_query(self, expr: "ColumnInfoExpression") -> Tuple[str, tuple]: - """Format column information query against ClickHouse system.columns.""" params = expr.get_params() table = params.get("table_name", "") schema = params.get("schema", "") - p = self.get_parameter_placeholder - sql = ( - "SELECT name AS COLUMN_NAME, position AS ORDINAL_POSITION, " - "default_expression AS COLUMN_DEFAULT, " - "IF(type LIKE 'Nullable(%%', 'YES', 'NO') AS IS_NULLABLE, " - "type AS DATA_TYPE, 0 AS CHARACTER_MAXIMUM_LENGTH, " - "numeric_precision AS NUMERIC_PRECISION, " - "numeric_scale AS NUMERIC_SCALE, " - "type AS COLUMN_TYPE, '' AS COLUMN_KEY, '' AS EXTRA, " - "comment AS COLUMN_COMMENT, '' AS CHARACTER_SET_NAME, " - "'' AS COLLATION_NAME " - "FROM system.columns " - f"WHERE database = {p(0)} AND table = {p(1)} " - "ORDER BY position" + + query = QueryStatement( + self, + select=[ + self._column("name", alias="COLUMN_NAME"), + self._column("position", alias="ORDINAL_POSITION"), + self._column("default_expression", alias="COLUMN_DEFAULT"), + FunctionCall( + self, + "IF", + LikePredicate( + self, + LikeOperator.LIKE, + self._column("type"), + # Doubled on purpose. This statement is executed by the + # introspector executor, which calls ``cursor.execute`` + # directly -- it does not pass through + # ``Backend.execute``, so the backend's + # ``_escape_literal_percent`` never runs on it and the + # driver's ``%`` interpolation would read a lone ``%`` as + # the start of a format specifier and raise + # ``ValueError: unsupported format character "'"``. + # Measured in CI on 3.10 / ClickHouse 25.8. + Literal(self, "Nullable(%%", inline=True), + ), + Literal(self, "YES", inline=True), + Literal(self, "NO", inline=True), + alias="IS_NULLABLE", + ), + self._column("type", alias="DATA_TYPE"), + self._int_constant(0, "CHARACTER_MAXIMUM_LENGTH"), + self._column("numeric_precision", alias="NUMERIC_PRECISION"), + self._column("numeric_scale", alias="NUMERIC_SCALE"), + self._column("type", alias="COLUMN_TYPE"), + self._text_constant("", "COLUMN_KEY"), + self._text_constant("", "EXTRA"), + self._column("comment", alias="COLUMN_COMMENT"), + self._text_constant("", "CHARACTER_SET_NAME"), + self._text_constant("", "COLLATION_NAME"), + ], + from_=self._system_relation("columns"), + where=self._where( + self._equals(self._column("database"), Literal(self, schema)), + self._equals(self._column("table"), Literal(self, table)), + ), + order_by=self._order_by("position"), ) - return (sql, (schema, table)) + return query.to_sql() + + def format_index_info_query_statement(self, expr: "IndexInfoQueryStatement") -> Tuple[str, tuple]: + """Format index information query against ClickHouse system.data_skipping_indices. + + ClickHouse's only index rows are the primary key (reported through + ``SHOW INDEX``) and data skipping indices, which live here. The six + constant cells restate what the engine does not have: no uniqueness, no + prefixes, no packed form, no per-index nullability. + """ + from rhosocial.activerecord.backend.expression import Literal, QueryStatement + from ..expression.types import ClickHouseNullableType, ClickHouseStringType - def format_index_info_query(self, expr: "IndexInfoExpression") -> Tuple[str, tuple]: - """Format index information query against ClickHouse system.data_skipping_indices.""" params = expr.get_params() table = params.get("table_name", "") schema = params.get("schema", "") - p = self.get_parameter_placeholder - sql = ( - "SELECT name AS INDEX_NAME, 0 AS NON_UNIQUE, 1 AS SEQ_IN_INDEX, " - "'' AS COLUMN_NAME, type AS INDEX_TYPE, NULL AS SUB_PART, 'YES' AS NULLABLE " - "FROM system.data_skipping_indices " - f"WHERE database = {p(0)} AND table = {p(1)} " - "ORDER BY name" + + query = QueryStatement( + self, + select=[ + self._column("name", alias="INDEX_NAME"), + self._int_constant(0, "NON_UNIQUE"), + self._int_constant(1, "SEQ_IN_INDEX"), + self._text_constant("", "COLUMN_NAME"), + self._column("type", alias="INDEX_TYPE"), + self._constant( + None, + "SUB_PART", + ClickHouseNullableType(self, inner_type=ClickHouseStringType(self)), + ), + self._text_constant("YES", "NULLABLE"), + ], + from_=self._system_relation("data_skipping_indices"), + where=self._where( + self._equals(self._column("database"), Literal(self, schema)), + self._equals(self._column("table"), Literal(self, table)), + ), + order_by=self._order_by("name"), ) - return (sql, (schema, table)) + return query.to_sql() - def format_foreign_key_query(self, expr: "ForeignKeyExpression") -> Tuple[str, tuple]: + def format_foreign_key_query_statement(self, expr: "ForeignKeyQueryStatement") -> Tuple[str, tuple]: """ClickHouse has no foreign keys; raise UnsupportedFeatureError.""" from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError @@ -191,52 +422,153 @@ def format_foreign_key_query(self, expr: "ForeignKeyExpression") -> Tuple[str, t "ClickHouse does not support foreign keys." ) - def format_view_list_query(self, expr: "ViewListExpression") -> Tuple[str, tuple]: + def format_view_list_query_statement(self, expr: "ViewListQueryStatement") -> Tuple[str, tuple]: """Format view list query against ClickHouse system.tables (View engines).""" + from rhosocial.activerecord.backend.expression import Literal, QueryStatement + params = expr.get_params() schema = params.get("schema", "") include_system = params.get("include_system", False) - p = self.get_parameter_placeholder - - engine_condition, engine_params = self.engine_kind_condition( - include_views=True, offset=1, - ) - conditions = [f"database = {p(0)}", engine_condition] - sql_params: list = [schema, *engine_params] + conditions = [ + self._equals(self._column("database"), Literal(self, schema)), + self.engine_kind_predicate(include_views=True), + ] if not include_system: - conditions.append("database != 'system'") + conditions.append( + self._not_equals(self._column("database"), Literal(self, "system", inline=True)) + ) - where = " AND ".join(conditions) - sql = ( - "SELECT name AS TABLE_NAME, create_table_query AS VIEW_DEFINITION, " - "'NONE' AS CHECK_OPTION, 'NO' AS IS_UPDATABLE " - f"FROM system.tables WHERE {where} ORDER BY name" + query = QueryStatement( + self, + select=[ + self._column("name", alias="TABLE_NAME"), + self._column("create_table_query", alias="VIEW_DEFINITION"), + self._text_constant("NONE", "CHECK_OPTION"), + self._text_constant("NO", "IS_UPDATABLE"), + ], + from_=self._system_relation("tables"), + where=self._where(*conditions), + order_by=self._order_by("name"), ) - return (sql, tuple(sql_params)) + return query.to_sql() - def format_view_info_query(self, expr: "ViewInfoExpression") -> Tuple[str, tuple]: + def format_view_info_query_statement(self, expr: "ViewInfoQueryStatement") -> Tuple[str, tuple]: """Format single view information query against ClickHouse system.tables.""" + from rhosocial.activerecord.backend.expression import Literal, QueryStatement + params = expr.get_params() view_name = params.get("view_name", "") schema = params.get("schema", "") - p = self.get_parameter_placeholder - engine_condition, engine_params = self.engine_kind_condition( - include_views=True, offset=2, - ) - sql = ( - "SELECT name AS TABLE_NAME, create_table_query AS VIEW_DEFINITION, " - "'NONE' AS CHECK_OPTION, 'NO' AS IS_UPDATABLE " - "FROM system.tables " - f"WHERE database = {p(0)} AND name = {p(1)} AND {engine_condition}" + + query = QueryStatement( + self, + select=[ + self._column("name", alias="TABLE_NAME"), + self._column("create_table_query", alias="VIEW_DEFINITION"), + self._text_constant("NONE", "CHECK_OPTION"), + self._text_constant("NO", "IS_UPDATABLE"), + ], + from_=self._system_relation("tables"), + where=self._where( + self._equals(self._column("database"), Literal(self, schema)), + self._equals(self._column("name"), Literal(self, view_name)), + self.engine_kind_predicate(include_views=True), + ), ) - return (sql, (schema, view_name, *engine_params)) + return query.to_sql() - def format_trigger_list_query(self, expr: "TriggerListExpression") -> Tuple[str, tuple]: + def format_trigger_list_query_statement(self, expr: "TriggerListQueryStatement") -> Tuple[str, tuple]: """ClickHouse has no triggers; raise UnsupportedFeatureError.""" from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError raise UnsupportedFeatureError( "ClickHouse", "trigger introspection", "ClickHouse does not support triggers." - ) \ No newline at end of file + ) + + # ========== Backend-specific reads ========== + + def format_clickhouse_table_partitions_query( + self, expr: "ClickHouseTablePartitionsQuery" + ) -> Tuple[str, tuple]: + """One row per partition of a table, aggregated from ``system.parts``. + + The filter ``active = 1`` is what makes the aggregate mean what it says. + ``system.parts`` keeps a part until its replacement has been written and + the old one is ready to be removed, so a table that has just merged + reports both the sources and the result at once. Summing every row would + then count the same rows twice; summing the active parts counts each row + once, and ``COUNT(*)`` is the number of parts the partition currently + consists of. + + ``min(partition)`` and ``min(engine)`` read the two group-invariant + columns: every part of a partition carries the same readable partition + value and the same engine, so the minimum is that value. ClickHouse's + own ``any`` aggregate would say this more directly, but the shared + function formatter upper-cases every function name and ClickHouse + rejects ``ANY`` (measured on 26.7.3.19: ``Function with name `ANY` does + not exist``), while ``MIN`` is accepted; a column outside the ``GROUP BY`` + still has to be aggregated, so it is read this way. + """ + from rhosocial.activerecord.backend.expression import Literal, QueryStatement + from rhosocial.activerecord.backend.expression.functions.aggregate import ( + count, + max_, + min_, + sum_, + ) + + query = QueryStatement( + self, + select=[ + self._column("partition_id"), + min_(self, self._column("partition"), alias="partition"), + min_(self, self._column("engine"), alias="engine"), + count(self, "*", alias="active_part_count"), + sum_(self, self._column("rows"), alias="row_count"), + sum_(self, self._column("bytes_on_disk"), alias="bytes_on_disk"), + min_(self, self._column("modification_time"), alias="min_modification_time"), + max_(self, self._column("modification_time"), alias="max_modification_time"), + ], + from_=self._system_relation("parts"), + where=self._where( + self._equals(self._column("database"), Literal(self, expr.database)), + self._equals(self._column("table"), Literal(self, expr.table)), + self._equals(self._column("active"), Literal(self, 1)), + ), + group_by_having=self._group_by("partition_id"), + order_by=self._order_by("partition_id"), + ) + return query.to_sql() + + def format_clickhouse_table_engine_query( + self, expr: "ClickHouseTableEngineQuery" + ) -> Tuple[str, tuple]: + """The storage definition of one table, from ``system.tables``. + + One row is expected; a name that does not exist yields no rows, which is + the introspector's ``None``. The key columns come back as the server + stores them: an absent key is ``''``, not ``NULL``. + """ + from rhosocial.activerecord.backend.expression import Literal, QueryStatement + + query = QueryStatement( + self, + select=[ + self._column("engine"), + self._column("sorting_key"), + self._column("primary_key"), + self._column("partition_key"), + self._column("sampling_key"), + self._column("total_rows"), + self._column("total_bytes"), + self._column("comment"), + ], + from_=self._system_relation("tables"), + where=self._where( + self._equals(self._column("database"), Literal(self, expr.database)), + self._equals(self._column("name"), Literal(self, expr.table)), + ), + ) + return query.to_sql() diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/json.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/json.py index 99cefcf..bf0f89f 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/json.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/json.py @@ -2,19 +2,20 @@ from typing import Union, Any, List, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.bases import BaseExpression if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.advanced_functions import ( - JSONDocumentExpression, - JSONTextExpression, + JSONDocumentValue, + JSONTextValue, ) #: Core has one class per JSON operator, and this dialect renders either #: from the same code path -- so the parameter is a union, not one of the - #: two. ``JSONTextExpression`` subclasses ``JSONDocumentExpression`` so a + #: two. ``JSONTextValue`` subclasses ``JSONDocumentValue`` so a #: path chain can keep going after ``->>``; that inheritance is about #: accessors, not about ``->>`` yielding a document. - JSONPathNode = Union[JSONDocumentExpression, JSONTextExpression] + JSONPathNode = Union[JSONDocumentValue, JSONTextValue] class ClickHouseJSONFunctionMixin: @@ -30,10 +31,10 @@ class ClickHouseJSONFunctionMixin: } def supports_json_type(self) -> bool: - return self.version >= (26, 0, 0) + return self.version_at_least((26, 0, 0)) def supports_json_merge_patch(self) -> bool: - return self.version >= (26, 0, 0) + return self.version_at_least((26, 0, 0)) def supports_json_table(self) -> bool: return False @@ -51,12 +52,15 @@ def format_json_function_expression(self, expr: "JSONPathNode") -> Tuple[str, tu Simple dotted paths (``$.a.b``) are split into key arguments; complex paths (arrays, wildcards, filters) fall back to ``JSON_VALUE``. """ - if hasattr(expr.column, "to_sql"): + # ``column`` is declared as an expression or a bare name; which one it + # is is a type question, so it is asked with isinstance rather than + # probing for a ``to_sql`` attribute. + if isinstance(expr.column, BaseExpression): col_sql, col_params = expr.column.to_sql() else: col_sql, col_params = self.format_identifier(str(expr.column)), () - escaped_path = self._escape_sql_string(expr.path) + escaped_path = self.escape_sql_string(expr.path) is_simple = ( expr.path.startswith("$") and "{" not in expr.path @@ -68,14 +72,14 @@ def format_json_function_expression(self, expr: "JSONPathNode") -> Tuple[str, tu if expr.operation == "->": if is_simple: parts = [p for p in expr.path.lstrip("$.").split(".") if p] - args = "".join(f", '{self._escape_sql_string(p)}'" for p in parts) + args = "".join(f", '{self.escape_sql_string(p)}'" for p in parts) sql = f"JSONExtractRaw({col_sql}{args})" else: sql = f"JSON_VALUE({col_sql}, '{escaped_path}')" elif expr.operation == "->>": if is_simple: parts = [p for p in expr.path.lstrip("$.").split(".") if p] - args = "".join(f", '{self._escape_sql_string(p)}'" for p in parts) + args = "".join(f", '{self.escape_sql_string(p)}'" for p in parts) sql = f"JSONExtractString({col_sql}{args})" else: sql = f"JSON_VALUE({col_sql}, '{escaped_path}')" @@ -91,37 +95,34 @@ def format_json_function_expression(self, expr: "JSONPathNode") -> Tuple[str, tu def supports_json_function(self, function_name: str) -> bool: if function_name in self._JSON_FUNCTION_VERSIONS: - return self.version >= self._JSON_FUNCTION_VERSIONS[function_name] - return self.version >= (26, 0, 0) + return self.version_at_least(self._JSON_FUNCTION_VERSIONS[function_name]) + return self.version_at_least((26, 0, 0)) - def format_json_extract(self, expr) -> Tuple[str, tuple]: + def format_json_extract_expression(self, expr) -> Tuple[str, tuple]: """Format JSONExtract function.""" all_paths = [expr.path] - if hasattr(expr, "paths") and expr.paths: + if expr.paths: all_paths.extend(expr.paths) ph = self.get_parameter_placeholder() placeholders = ", ".join([ph for _ in all_paths]) sql = f"JSONExtract({expr.json_column}, {placeholders})" - alias = getattr(expr, "alias", None) - if alias: - sql = f"{sql} AS {self.format_identifier(alias)}" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, tuple(all_paths) - def format_json_unquote(self, expr) -> Tuple[str, tuple]: + def format_json_unquote_expression(self, expr) -> Tuple[str, tuple]: sql = f"JSONExtractString({expr.json_val})" - alias = getattr(expr, "alias", None) - if alias: - sql = f"{sql} AS {self.format_identifier(alias)}" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, () - def format_json_object(self, expr) -> Tuple[str, tuple]: + def format_json_object_expression(self, expr) -> Tuple[str, tuple]: """Format map function (ClickHouse equivalent of JSON_OBJECT).""" - pairs = getattr(expr, "pairs", []) + pairs = expr.pairs if not pairs: sql = "map()" - alias = getattr(expr, "alias", None) - if alias: - sql = f"{sql} AS {self.format_identifier(alias)}" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, () ph = self.get_parameter_placeholder() @@ -135,25 +136,23 @@ def format_json_object(self, expr) -> Tuple[str, tuple]: params.append(value) sql = f"map({', '.join(parts)})" - alias = getattr(expr, "alias", None) - if alias: - sql = f"{sql} AS {self.format_identifier(alias)}" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, tuple(params) - def format_json_array(self, expr) -> Tuple[str, tuple]: + def format_json_array_expression(self, expr) -> Tuple[str, tuple]: """Format ClickHouse array literal (equivalent to JSON_ARRAY).""" - values = getattr(expr, "values", []) + values = expr.values if not values: return "[]", () ph = self.get_parameter_placeholder() placeholders = ", ".join([ph for _ in values]) sql = f"[{placeholders}]" - alias = getattr(expr, "alias", None) - if alias: - sql = f"{sql} AS {self.format_identifier(alias)}" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, tuple(values) - def format_json_contains(self, expr) -> Tuple[str, tuple]: + def format_json_contains_expression(self, expr) -> Tuple[str, tuple]: """Format JSON_CONTAINS approximation for ClickHouse. ClickHouse has no direct JSON_CONTAINS equivalent. This uses @@ -161,26 +160,25 @@ def format_json_contains(self, expr) -> Tuple[str, tuple]: reasonable approximation for path-based existence checks. """ ph = self.get_parameter_placeholder() - path = getattr(expr, "path", None) + path = expr.path if path: sql = f"isNotNull(JSONExtract({expr.json_column}, {ph}, {ph}))" params = (expr.value, path) else: sql = f"isNotNull(JSONExtract({expr.json_column}, {ph}))" params = (expr.value,) - alias = getattr(expr, "alias", None) - if alias: - sql = f"{sql} AS {self.format_identifier(alias)}" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, params - def format_json_set(self, expr) -> Tuple[str, tuple]: + def format_json_set_expression(self, expr) -> Tuple[str, tuple]: """Format JSON_SET approximation for ClickHouse. ClickHouse has no direct JSON_SET equivalent. This uses mapUpdate on a Map-typed JSON extraction as an approximation. """ all_pairs = [(expr.path, expr.value)] - if hasattr(expr, "path_value_pairs") and expr.path_value_pairs: + if expr.path_value_pairs: all_pairs.extend(expr.path_value_pairs) ph = self.get_parameter_placeholder() @@ -195,60 +193,55 @@ def format_json_set(self, expr) -> Tuple[str, tuple]: map_expr = f"map({', '.join(parts)})" sql = f"assumeNotNull(mapUpdate(JSONExtract({expr.json_column}, 'Map(String, String)'), {map_expr}))" - alias = getattr(expr, "alias", None) - if alias: - sql = f"{sql} AS {self.format_identifier(alias)}" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, tuple(params) - def format_json_remove(self, expr) -> Tuple[str, tuple]: + def format_json_remove_expression(self, expr) -> Tuple[str, tuple]: """Format JSON_REMOVE approximation for ClickHouse. ClickHouse has no direct JSON_REMOVE equivalent. This uses mapRemove on a Map-typed JSON extraction as an approximation. """ all_paths = [expr.path] - if hasattr(expr, "paths") and expr.paths: + if expr.paths: all_paths.extend(expr.paths) ph = self.get_parameter_placeholder() placeholders = ", ".join([ph for _ in all_paths]) sql = f"mapRemove(JSONExtract({expr.json_column}, 'Map(String, String)'), {placeholders})" - alias = getattr(expr, "alias", None) - if alias: - sql = f"{sql} AS {self.format_identifier(alias)}" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, tuple(all_paths) - def format_json_type(self, expr) -> Tuple[str, tuple]: + def format_json_type_expression(self, expr) -> Tuple[str, tuple]: sql = f"JSONType({expr.json_val})" - alias = getattr(expr, "alias", None) - if alias: - sql = f"{sql} AS {self.format_identifier(alias)}" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, () - def format_json_valid(self, expr) -> Tuple[str, tuple]: + def format_json_valid_expression(self, expr) -> Tuple[str, tuple]: sql = f"JSON_VALID({expr.json_val})" - alias = getattr(expr, "alias", None) - if alias: - sql = f"{sql} AS {self.format_identifier(alias)}" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, () - def format_json_search(self, expr) -> Tuple[str, tuple]: + def format_json_search_expression(self, expr) -> Tuple[str, tuple]: """Format JSON_SEARCH approximation for ClickHouse. ClickHouse has no direct JSON_SEARCH equivalent. This uses JSONExtractString + LIKE as a basic text search approximation. """ - one_or_all = "'all'" if getattr(expr, "all", False) else "'one'" + one_or_all = "'all'" if expr.all else "'one'" ph = self.get_parameter_placeholder() - path = getattr(expr, "path", None) + path = expr.path if path: sql = f"JSONExtractString({expr.json_column}, {ph}) LIKE {ph} AND {one_or_all} = 'one'" params = (path, expr.search_str) else: sql = f"JSONExtractString({expr.json_column}) LIKE {ph} AND {one_or_all} = 'one'" params = (expr.search_str,) - alias = getattr(expr, "alias", None) - if alias: - sql = f"{sql} AS {self.format_identifier(alias)}" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, params def format_json_table_expression(self, expr) -> Tuple[str, tuple]: diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/json_duality_view.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/json_duality_view.py index 8ff9383..4aef265 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/json_duality_view.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/json_duality_view.py @@ -18,13 +18,13 @@ def supports_json_duality_view(self) -> bool: def supports_json_duality_view_dml(self) -> bool: return False - def format_create_json_duality_view_statement(self, expr: Any) -> Tuple[str, tuple]: + def format_clickhouse_create_json_duality_view_statement(self, expr: Any) -> Tuple[str, tuple]: raise UnsupportedFeatureError( self.name, "JSON Duality Views", suggestion="ClickHouse has no JSON Duality Views; use JSON type + JSONExtract* functions.", ) - def format_drop_json_duality_view_statement(self, expr: Any) -> Tuple[str, tuple]: + def format_clickhouse_drop_json_duality_view_statement(self, expr: Any) -> Tuple[str, tuple]: raise UnsupportedFeatureError( self.name, "JSON Duality Views", suggestion="ClickHouse has no JSON Duality Views.", diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/literal.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/literal.py new file mode 100644 index 0000000..3a69597 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/literal.py @@ -0,0 +1,91 @@ +# src/rhosocial/activerecord/backend/impl/clickhouse/mixins/literal.py +"""ClickHouse's tier-2 value literals. + +``SQLDialectBase.format_literal`` is the single entry point for writing a +value as SQL text, and it resolves in three tiers: the portable scalars it +knows, the types a backend spells for itself, and the literal-construction +protocol. This module is ClickHouse's second tier. ClickHouse has two such +types and no more: + +* ``bytes`` becomes ``unhex('0102')``. ClickHouse has no blob literal; the + string functions are byte-oriented, and ``unhex`` is the documented way + to build one. Measured on the scenario server named by + ``tests/config/clickhouse_scenarios.yaml`` (``clickhouse_http``: + **ClickHouse 26.7.3.19 official build**, HTTP on ``127.0.0.1:18682``, + database ``test_db``):: + + CREATE TABLE probe_tier2_bin (id Int32, s String DEFAULT unhex('0102')) + ENGINE = MergeTree ORDER BY id -- accepted + INSERT INTO probe_tier2_bin (id) VALUES (1) + SELECT hex(s) FROM probe_tier2_bin -- '0102': the exact bytes + +* ``UUID`` becomes the bare quoted text. The ``UUID`` type parses the + canonical form from a string, so a ``UUID DEFAULT + '01234567-89ab-cdef-0123-456789abcdef'`` was measured accepted and a row + read back the same text through ``toString(u)``. + +**``timedelta`` is deliberately left to the base's refusal.** The grammar's +``INTERVAL`` is date-arithmetic syntax -- ``INTERVAL n UNIT``, with the unit +part of the spelling -- while a ``timedelta`` carries no unit, so a renderer +would have to invent one. The measurements that close the question, on the +same instance:: + + CREATE TABLE probe_iv (id Int32, s String DEFAULT INTERVAL '1' DAY) ... + INSERT ... -> SELECT s = '1' -- accepted, but the unit is silently + -- dropped: the stored value is the + -- number, not the span + SELECT INTERVAL '1' DAY + INTERVAL '2' HOUR + -> toTypeName = Tuple(IntervalDay, IntervalHour) + -- a mixed-unit span is not one interval + +``IntervalDay`` columns exist and accept ``DEFAULT INTERVAL '3' DAY`` (read +back as ``3``), which makes the omission a real choice rather than an +absence: the one spelling that works ties the literal to the column's own +interval unit, and the host value carries no unit to tie it to. Leaving +``timedelta`` out of this override is what makes the base's explicit +failure the answer. + +Everything else falls through to the base implementation, which is where +the portable forms -- NULL, booleans, numbers, quoted ISO dates -- and the +literal-construction protocol live. Those portable forms were measured +accepted verbatim as DEFAULTs on the same instance (``Date DEFAULT +'2026-01-01'``, ``DateTime DEFAULT '2026-01-01 12:00:00'``, ``Bool DEFAULT +TRUE`` / ``FALSE``), so the base spellings are ClickHouse's own and no +override is added for them. +""" + +import uuid +from typing import Any + + +class ClickHouseLiteralValueMixin: + """ClickHouse's tier-2 branches of :meth:`format_literal`. + + Composed **ahead of** ``SQLDialectBase`` in the dialect's base list -- + unlike every other ClickHouse mixin, which only adds methods the base + lacks and may sit anywhere. This one overrides ``format_literal``, and a + mixin placed after the class it overrides never wins. + """ + + def format_literal(self, value: Any) -> str: + """Render *value* as inline SQL text, ClickHouse's spelling where it + has one. + + Args: + value: The host value to write into the statement. + + Returns: + The escaped literal text, without a bind parameter. + + Raises: + TypeError: The base implementation refuses a type ClickHouse has + no literal form for (``timedelta`` among them, on purpose -- + see the module docstring), and this is the same refusal. + """ + if isinstance(value, (bytes, bytearray)): + # Lowercase hex, the form ``bytes.hex()`` spells; ``unhex`` + # accepts either case. + return f"unhex('{bytes(value).hex()}')" + if isinstance(value, uuid.UUID): + return self.format_literal(str(value)) + return super().format_literal(value) diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/load_xml.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/load_xml.py index 0743aa3..f8fb9e1 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/load_xml.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/load_xml.py @@ -16,7 +16,7 @@ class ClickHouseLoadXMLLMixin: def supports_load_xml(self) -> bool: return False - def format_load_xml_statement(self, expr: Any) -> Tuple[str, tuple]: + def format_clickhouse_load_xml_statement(self, expr: Any) -> Tuple[str, tuple]: raise UnsupportedFeatureError( self.name, "LOAD XML", suggestion="ClickHouse has no LOAD XML; use a format parser or input() table function.", diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/materialized_view.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/materialized_view.py index 9769f5c..759ff3f 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/materialized_view.py @@ -14,16 +14,22 @@ from being dropped between the statement that wrote it and the statement that reads it back. """ -from typing import Any, Optional, Tuple, TYPE_CHECKING +from typing import Any, Tuple, TYPE_CHECKING, Union from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.objects import MaterializedView, Table if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.expression.statements.ddl_view import ( + CreateMaterializedViewStatement, + DropMaterializedViewStatement, + RefreshMaterializedViewStatement, + ) from ..expression.materialized_view import ( - ClickHouseCreateMaterializedViewExpression, - ClickHouseModifyMaterializedViewRefreshExpression, - ClickHouseRefreshMaterializedViewExpression, + ClickHouseCreateMaterializedViewStatement, + ClickHouseDropMaterializedViewStatement, + ClickHouseModifyMaterializedViewRefreshStatement, + ClickHouseRefreshMaterializedViewStatement, ClickHouseRefreshSchedule, ) @@ -39,6 +45,15 @@ def supports_materialized_view(self) -> bool: """ClickHouse supports incremental and refreshable materialized views.""" return True + + def supports_materialized_view_bind_parameters(self) -> bool: + """ClickHouse accepts bind parameters in a materialized view body. + + Measured on 26.7.3: a materialized view whose definition binds is + accepted, because ClickHouse's "parameters" are interpolated + client-side and the value is baked into the stored definition. + """ + return True def supports_refresh_materialized_view(self) -> bool: """Refreshable MVs are refreshed through ``SYSTEM REFRESH VIEW``.""" return True @@ -69,13 +84,17 @@ def supports_with_data_clause(self) -> bool: # ------------------------------------------------------------------ def format_create_materialized_view_statement( - self, expr: "ClickHouseCreateMaterializedViewExpression" + self, + expr: Union[ + "ClickHouseCreateMaterializedViewStatement", "CreateMaterializedViewStatement" + ], ) -> 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). + unsupported clauses are rejected and whose ClickHouse clauses + it cannot declare). Returns: Tuple of (SQL string, params tuple from the defining query). @@ -83,37 +102,60 @@ def format_create_materialized_view_statement( Raises: TypeError: ``expr.view`` is not a MaterializedView, or ``expr.to_table`` is present and is not a Table. The generic - ``CreateMaterializedViewExpression`` carries neither kind of + ``CreateMaterializedViewStatement`` carries neither kind of check, and every object renders its own name, so without this a View or a Table would produce a well-formed CREATE MATERIALIZED VIEW naming something else. - UnsupportedFeatureError: for clauses ClickHouse does not have. + UnsupportedFeatureError: for clauses ClickHouse does not have. The + WITH DATA / WITH NO DATA duo is gated the way core gates it: + only a requested spelling is refused (by name); with neither + requested the statement renders the dialect's default form, + without the clause. """ - if not isinstance(getattr(expr, "view", None), MaterializedView): + if not isinstance(expr.view, MaterializedView): raise TypeError( - f"CreateMaterializedViewExpression.view must be a MaterializedView, " - f"got {type(getattr(expr, 'view', None)).__name__}" + f"CreateMaterializedViewStatement.view must be a MaterializedView, " + f"got {type(expr.view).__name__}" ) - to_table = getattr(expr, "to_table", None) + from ..expression.materialized_view import ClickHouseCreateMaterializedViewStatement + + if isinstance(expr, ClickHouseCreateMaterializedViewStatement): + # The ClickHouse node: every clause below is a declared field on it, + # and its constructor has already validated the shape ClickHouse + # requires (TO or ENGINE, POPULATE without REFRESH, ...). + to_table = expr.to_table + engine = expr.engine + else: + # The generic statement: its clauses are the ones ClickHouse cannot + # express, and each is refused by the name of its spelling -- the + # WITH DATA / WITH NO DATA duo included, and only when a spelling + # is requested. With neither requested there is nothing to refuse, + # and the statement renders ClickHouse's default form. + self._reject_unsupported_clauses(expr, "CREATE MATERIALIZED VIEW") + # TO and ENGINE are not fields of the generic node; a caller that + # carries them attaches them on the instance, and they are read + # here exactly as the ClickHouse node's own fields are. + to_table = getattr(expr, "to_table", None) + engine = getattr(expr, "engine", None) + if to_table is None and engine is None: + # Nothing anchors the view: ClickHouse requires a TO target + # table or an ENGINE, and the generic node provides neither. + raise UnsupportedFeatureError( + self.name, + "CREATE MATERIALIZED VIEW", + "ClickHouse requires a TO target table or an ENGINE; use " + "ClickHouseCreateMaterializedViewStatement.", + ) + if to_table is not None and not isinstance(to_table, Table): raise TypeError( - f"CreateMaterializedViewExpression.to_table must be a Table, " + f"CreateMaterializedViewStatement.to_table must be a Table, " f"got {type(to_table).__name__}" ) - 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 = self._mv_target(expr) - 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.", - ) + # The clauses of the ClickHouse spelling are declared on this backend's + # own node only, so a generic statement reaching the render below + # carries none of them; ``getattr`` keeps one render path for both. parts = ["CREATE"] if getattr(expr, "or_replace", False): parts.append("OR REPLACE") @@ -135,7 +177,6 @@ def format_create_materialized_view_statement( 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}") @@ -144,7 +185,10 @@ def format_create_materialized_view_statement( refresh = getattr(expr, "refresh", None) if refresh is not None: - parts.append(self._format_refresh_clause(refresh)) + # The schedule is a node of its own; the dialect spells it through + # its ``format_method``, the same dispatch every other clause goes + # through. + parts.append(refresh.to_sql()[0]) if getattr(expr, "empty", False): parts.append("EMPTY") @@ -154,11 +198,16 @@ def format_create_materialized_view_statement( comment = getattr(expr, "comment", None) if comment is not None: - parts.append(f"COMMENT '{self._escape_sql_string(str(comment))}'") + 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]: + def format_drop_materialized_view_statement( + self, + expr: Union[ + "ClickHouseDropMaterializedViewStatement", "DropMaterializedViewStatement" + ], + ) -> Tuple[str, tuple]: """Format ``DROP VIEW [IF EXISTS]`` — ClickHouse has no ``DROP MATERIALIZED VIEW``. The keyword comes from :meth:`drop_object_keyword`, which reads the @@ -172,67 +221,86 @@ def format_drop_materialized_view_statement(self, expr: Any) -> Tuple[str, tuple UnsupportedFeatureError: ``CASCADE`` or ``RESTRICT``, neither of which ClickHouse accepts (measured: Code 62). """ - view = getattr(expr, "view", None) + view = expr.view if not isinstance(view, MaterializedView): raise TypeError( - f"DropMaterializedViewExpression.view must be a MaterializedView, " + f"DropMaterializedViewStatement.view must be a MaterializedView, " f"got {type(view).__name__}" ) - if getattr(expr, "cascade", False): - raise UnsupportedFeatureError(self.name, "DROP MATERIALIZED VIEW CASCADE") - if getattr(expr, "restrict", False): - raise UnsupportedFeatureError(self.name, "DROP MATERIALIZED VIEW RESTRICT") + from ..expression.materialized_view import ClickHouseDropMaterializedViewStatement + + if not isinstance(expr, ClickHouseDropMaterializedViewStatement): + # ``CASCADE`` / ``RESTRICT`` are fields of the generic statement; + # this backend's own node does not carry them, because the engine + # has no such clause to spell. + if expr.cascade: + raise UnsupportedFeatureError(self.name, "DROP MATERIALIZED VIEW CASCADE") + if expr.restrict: + raise UnsupportedFeatureError(self.name, "DROP MATERIALIZED VIEW RESTRICT") parts = ["DROP", self.drop_object_keyword(view)] - if getattr(expr, "if_exists", False): + if expr.if_exists: parts.append("IF EXISTS") parts.append(view.to_sql()[0]) return " ".join(parts), () def format_refresh_materialized_view_statement( - self, expr: "ClickHouseRefreshMaterializedViewExpression" + self, + expr: Union[ + "ClickHouseRefreshMaterializedViewStatement", "RefreshMaterializedViewStatement" + ], ) -> Tuple[str, tuple]: """Format ``SYSTEM REFRESH VIEW`` (optionally followed by ``SYSTEM WAIT VIEW``). Raises: TypeError: ``expr.view`` is not a MaterializedView. The generic - ``RefreshMaterializedViewExpression`` would otherwise render any + ``RefreshMaterializedViewStatement`` would otherwise render any object's name as the view refreshed. UnsupportedFeatureError: ``CONCURRENTLY``, ``WITH DATA`` or ``WITH NO DATA``, none of which ClickHouse spells this way. """ - view = getattr(expr, "view", None) + view = expr.view if not isinstance(view, MaterializedView): raise TypeError( - f"RefreshMaterializedViewExpression.view must be a MaterializedView, " + f"RefreshMaterializedViewStatement.view must be a MaterializedView, " f"got {type(view).__name__}" ) - 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", False): - raise UnsupportedFeatureError( - self.name, - "REFRESH MATERIALIZED VIEW WITH DATA", - "A ClickHouse refresh always repopulates the target.", - ) - if getattr(expr, "no_data", False): - raise UnsupportedFeatureError( - self.name, - "REFRESH MATERIALIZED VIEW WITH NO DATA", - "A ClickHouse refresh always repopulates the target.", - ) + from ..expression.materialized_view import ClickHouseRefreshMaterializedViewStatement + + if isinstance(expr, ClickHouseRefreshMaterializedViewStatement): + # This backend's own node: ``SYSTEM WAIT VIEW`` is its spelling of + # "block until the refresh completes". + wait = expr.wait + else: + # The generic statement: it carries the standard clauses ClickHouse + # cannot spell, and no ``wait`` -- which is this backend's own field. + if expr.concurrent: + raise UnsupportedFeatureError( + self.name, + "REFRESH MATERIALIZED VIEW CONCURRENTLY", + "ClickHouse refreshes through SYSTEM REFRESH VIEW; use wait=True to " + "block until the refresh completes.", + ) + if expr.with_data: + raise UnsupportedFeatureError( + self.name, + "REFRESH MATERIALIZED VIEW WITH DATA", + "A ClickHouse refresh always repopulates the target.", + ) + if expr.no_data: + raise UnsupportedFeatureError( + self.name, + "REFRESH MATERIALIZED VIEW WITH NO DATA", + "A ClickHouse refresh always repopulates the target.", + ) + wait = False target = view.to_sql()[0] statement = f"SYSTEM REFRESH VIEW {target}" - if getattr(expr, "wait", False): + if wait: statement = f"{statement}; SYSTEM WAIT VIEW {target}" return statement, () def format_modify_materialized_view_refresh_statement( - self, expr: "ClickHouseModifyMaterializedViewRefreshExpression" + self, expr: "ClickHouseModifyMaterializedViewRefreshStatement" ) -> Tuple[str, tuple]: """Format ``ALTER TABLE ... MODIFY REFRESH``. @@ -242,14 +310,13 @@ def format_modify_materialized_view_refresh_statement( each upstream through its own ``to_sql()``, so a Table there would name a table in a list documented as materialized views. """ - view = getattr(expr, "view", None) + view = expr.view if not isinstance(view, MaterializedView): raise TypeError( f"ModifyMaterializedViewRefreshExpression.view must be a " f"MaterializedView, got {type(view).__name__}" ) - depends_on = getattr(getattr(expr, "schedule", None), "depends_on", ()) or () - for position, upstream in enumerate(depends_on): + for position, upstream in enumerate(expr.schedule.depends_on): if not isinstance(upstream, MaterializedView): raise TypeError( f"ClickHouseRefreshSchedule.depends_on must hold " @@ -257,27 +324,29 @@ def format_modify_materialized_view_refresh_statement( f"{type(upstream).__name__} at position {position}" ) parts = ["ALTER TABLE"] - if getattr(expr, "if_exists", False): + if expr.if_exists: parts.append("IF EXISTS") parts.append(view.to_sql()[0]) - 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}") + # The schedule is a node of its own; its formatter carries the REFRESH + # spelling, and MODIFY prefixes the keyword the statement needs. + parts.append(f"MODIFY {expr.schedule.to_sql()[0]}") return " ".join(parts), () # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ - def _mv_target(self, expr: Any) -> Optional[Table]: - """Return the ``TO`` target table *expr* names, or ``None``. + def format_clickhouse_refresh_schedule( + self, schedule: "ClickHouseRefreshSchedule" + ) -> Tuple[str, tuple]: + """Render the ``REFRESH`` clause of a refreshable materialized view. - The target is a :class:`Table` with its own ``catalog_name``: which - database a materialized view writes into is the target's business. + The schedule is an expression node (``format_method`` names this + method), so both ``CREATE MATERIALIZED VIEW ... REFRESH ...`` and + ``ALTER TABLE ... MODIFY REFRESH`` reach the one spelling through the + node's own ``to_sql()``. """ - target = getattr(expr, "to_table", None) - return target if isinstance(target, Table) else None + return self._format_refresh_clause(schedule), () def _format_refresh_clause(self, schedule: "ClickHouseRefreshSchedule") -> str: """Render a ``REFRESH ...`` clause body (without the leading keyword).""" @@ -305,41 +374,55 @@ def _format_refresh_clause(self, schedule: "ClickHouseRefreshSchedule") -> str: return " ".join(parts) 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() + """Return the defining query SQL, accepting an expression or raw SQL. - def _reject_unsupported_clauses(self, expr: Any, feature: str) -> None: - """Reject generic-expression clauses ClickHouse cannot express.""" - if getattr(expr, "column_aliases", None): + ``query`` is a declared field of both node kinds this mixin renders: + the generic statement's and this backend's own expression's. + """ + if expr.query is None: + raise ValueError("CREATE MATERIALIZED VIEW requires a defining query") + if isinstance(expr.query, str): + return expr.query, () + query_sql, query_params = expr.query.to_sql() + if query_params and not self.supports_materialized_view_bind_parameters(): + self.refuse_ddl_bind_parameters("CREATE MATERIALIZED VIEW with bound parameters") + return query_sql, query_params + + def _reject_unsupported_clauses( + self, expr: "CreateMaterializedViewStatement", feature: str + ) -> None: + """Reject generic-statement clauses ClickHouse cannot express. + + Every field read here is declared on the generic + ``CreateMaterializedViewStatement``; this backend's own node accepts + none of them, which is why the create formatter calls this helper on + the generic statement only. + """ + if expr.column_aliases: raise UnsupportedFeatureError( self.name, f"{feature} COLUMN ALIASES", "ClickHouse materialized views take column names from the query.", ) - if getattr(expr, "tablespace", None): + if expr.tablespace: raise UnsupportedFeatureError( self.name, f"{feature} TABLESPACE", "ClickHouse materializes into a target table or an ENGINE.", ) - if getattr(expr, "storage_options", None): + if expr.storage_options: raise UnsupportedFeatureError( self.name, f"{feature} STORAGE PARAMETERS", "ClickHouse materializes into a target table or an ENGINE.", ) - if getattr(expr, "with_data", False): + if expr.with_data: raise UnsupportedFeatureError( self.name, f"{feature} WITH DATA", "ClickHouse uses POPULATE (incremental) or EMPTY (refreshable) instead.", ) - if getattr(expr, "no_data", False): + if expr.no_data: raise UnsupportedFeatureError( self.name, f"{feature} WITH NO DATA", diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/namespace.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/namespace.py index 6489215..021ed54 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/namespace.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/namespace.py @@ -40,12 +40,13 @@ test on earth catches it, because the statement is still well-formed. """ -from typing import Any, List, Tuple, TYPE_CHECKING +from typing import List, Optional, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.expression.objects import MaterializedView, View if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.expression.objects import RelationObject, SchemaObject + from rhosocial.activerecord.backend.introspection.types import TableType class ClickHouseNamespaceMixin: @@ -63,6 +64,14 @@ class ClickHouseNamespaceMixin: #: swept in by ``LIKE '%View'`` without anyone having said so. VIEW_ENGINES: Tuple[str, ...] = ("View", "MaterializedView", "LiveView", "WindowView") + #: Which introspection kind each view engine stores. The groups are + #: disjoint and their union is :attr:`VIEW_ENGINES`; ``MaterializedView`` + #: is the one that reports as its own kind rather than as a plain view. + VIEW_ENGINE_KINDS: Tuple[Tuple[str, Tuple[str, ...]], ...] = ( + ("VIEW", ("View", "LiveView", "WindowView")), + ("MATERIALIZED_VIEW", ("MaterializedView",)), + ) + #: The DDL keyword ClickHouse uses to remove each kind of relation. #: ClickHouse has no ``DROP MATERIALIZED VIEW``: a materialized view is #: removed with ``DROP VIEW``, same as a plain view. Keying the mapping by @@ -137,25 +146,151 @@ def drop_object_keyword(self, obj: "RelationObject") -> str: suggestion=f"{self.name} can only drop views and materialized views.", ) - def engine_kind_condition( - self, - *, - include_views: bool, - offset: int, - ) -> Tuple[str, List[Any]]: - """Render the ``system.tables`` engine filter that selects views or tables. + def engine_kind(self, engine: str) -> "TableType": + """The relation kind an engine stores, in the introspection vocabulary. + + The Python counterpart of :meth:`engine_kind_predicate`: the result + parser reads an engine string and needs the same closed set the SQL + filter uses, so the split lives here rather than in a second table + inside the introspector. An engine this backend does not know is an + ordinary table; the engine string itself stays available to the caller. + + Args: + engine: A ``system.tables.engine`` value. + + Returns: + ``TableType.VIEW`` for the view engines that are plain views, + ``TableType.MATERIALIZED_VIEW`` for ``MaterializedView``, and + ``TableType.BASE_TABLE`` otherwise. + """ + from rhosocial.activerecord.backend.introspection.types import TableType + + for kind_name, engines in self.VIEW_ENGINE_KINDS: + if engine in engines: + return TableType[kind_name] + return TableType.BASE_TABLE + + def resolve_table_type(self, table_type) -> Optional["TableType"]: + """Read a ``table_type`` filter in the introspection vocabulary. + + The accepted spellings are a :class:`TableType` member, its name + (``"VIEW"``, ``"BASE_TABLE"``…), and the standard ``"TABLE"`` for a base + table. An engine name is **not** a kind and resolves to ``None``; the + filter keeps it as the engine comparison it used to be. + + Args: + table_type: The filter value, or ``None`` for no filter. + + Returns: + The matching member, or ``None`` when the value is an engine name + (or no filter was given). + """ + from rhosocial.activerecord.backend.introspection.types import TableType + + if table_type is None: + return None + if isinstance(table_type, TableType): + return table_type + spelled = str(table_type).strip().upper().replace(" ", "_") + if spelled == "TABLE": + spelled = TableType.BASE_TABLE.name + return TableType.__members__.get(spelled) + + def is_system_table_type(self, table_type) -> bool: + """Whether a ``table_type`` filter asks for system tables.""" + from rhosocial.activerecord.backend.introspection.types import TableType + + return self.resolve_table_type(table_type) is TableType.SYSTEM_TABLE + + def table_type_predicate(self, table_type): + """The ``system.tables`` test that selects one relation kind. + + ``table_type`` is read in the introspection vocabulary + (``TableType``), not as an engine name. ``VIEW`` has to select the + engines that *store* a view; comparing ``engine = 'VIEW'`` matched + nothing, because no ClickHouse engine is called ``VIEW``. A value that + is not a kind is kept as an engine name, so an engine filter such as + ``MergeTree`` still selects what it selected before. + + Kinds this catalogue reports no rows for answer with an always-false + predicate. The answer is "none"; widening to every engine would claim + rows the parser never reports as that kind. + + Args: + table_type: A :class:`TableType` member, its name (``"VIEW"``, + ``"BASE_TABLE"``…), the standard ``"TABLE"`` spelling, or an + engine name. + + Returns: + A predicate ready to join into a conjunction. + """ + from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + InPredicate, + Literal, + LogicalPredicate, + ) + from rhosocial.activerecord.backend.introspection.types import TableType + + kind = self.resolve_table_type(table_type) + if kind is None: + return ComparisonPredicate( + self, "eq", Column(self, "engine"), Literal(self, table_type) + ) + if kind is TableType.SYSTEM_TABLE: + # System tables are identified by their database, not by an + # engine: the system database mixes several storage engines. + return ComparisonPredicate( + self, + "eq", + Column(self, "database"), + Literal(self, "system", inline=True), + ) + if kind is TableType.BASE_TABLE: + return LogicalPredicate( + self, + "NOT", + InPredicate(self, Column(self, "engine"), Literal(self, self.VIEW_ENGINES)), + ) + for kind_name, engines in self.VIEW_ENGINE_KINDS: + if TableType[kind_name] is kind: + return InPredicate(self, Column(self, "engine"), Literal(self, engines)) + + return ComparisonPredicate( + self, "eq", Literal(self, 1, inline=True), Literal(self, 0, inline=True) + ) + + def engine_kind_predicate(self, *, include_views: bool): + """The ``system.tables`` engine test that selects views or tables. + + The closed set travels as a bound list, exactly as it did when the + fragment was text: the engines are values, and this backend's + `` IN (%s, %s, ...)`` and ``NOT IN`` are what ``InPredicate`` + and its negation render. Nothing here counts placeholders -- the + predicate's parameters are the ones it holds, and the enclosing + statement concatenates them in projection order. Args: include_views: ``True`` selects the view engines, ``False`` selects everything that is not one of them. - offset: Index of the first bind placeholder in the statement, so the - returned values land in the right parameter positions. Returns: - ``(sql_fragment, params)`` where the fragment reads - ``engine IN (...)`` or ``engine NOT IN (...)``. + An ``InPredicate`` (``engine IN (...)``) or its negation + (``engine NOT IN (...)``), ready to join into a conjunction. """ - p = self.get_parameter_placeholder - holes = ", ".join(p(offset + i) for i in range(len(self.VIEW_ENGINES))) - operator = "IN" if include_views else "NOT IN" - return f"engine {operator} ({holes})", list(self.VIEW_ENGINES) \ No newline at end of file + from rhosocial.activerecord.backend.expression import ( + Column, + InPredicate, + Literal, + LogicalPredicate, + ) + + membership = InPredicate( + self, + Column(self, "engine"), + Literal(self, self.VIEW_ENGINES), + ) + if include_views: + return membership + return LogicalPredicate(self, "NOT", membership) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/optimizer_hint.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/optimizer_hint.py index ac2c62d..5e8947e 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/optimizer_hint.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/optimizer_hint.py @@ -18,7 +18,7 @@ def supports_optimizer_hint(self) -> bool: def supports_hypergraph_optimizer(self) -> bool: return False - def format_optimizer_hint(self, expr: Any) -> Tuple[str, tuple]: + def format_optimizer_hint_expression(self, expr: Any) -> Tuple[str, tuple]: raise UnsupportedFeatureError( self.name, "optimizer hints", suggestion="ClickHouse does not support /*+ SET_VAR */ hints; use SETTINGS.", diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/partition.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/partition.py index 9690d7e..5990656 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/partition.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/partition.py @@ -21,9 +21,9 @@ if TYPE_CHECKING: # pragma: no cover from ..expression.partition import ( - ClickHouseAttachPartitionExpression, - ClickHouseDetachPartitionExpression, - ClickHouseDropPartitionExpression, + ClickHouseAttachPartitionStatement, + ClickHouseDetachPartitionStatement, + ClickHouseDropPartitionStatement, ) @@ -174,7 +174,7 @@ def _refuse_mysql_partition(self, feature: str) -> NoReturn: # ------------------------------------------------------------------ def format_drop_partition_statement( - self, expr: "ClickHouseDropPartitionExpression" + self, expr: "ClickHouseDropPartitionStatement" ) -> Tuple[str, tuple]: """Render ``ALTER TABLE ... DROP PARTITION ID ''``. @@ -184,7 +184,7 @@ def format_drop_partition_statement( return self._format_partition_id_statement(expr, "DROP") def format_detach_partition_statement( - self, expr: "ClickHouseDetachPartitionExpression" + self, expr: "ClickHouseDetachPartitionStatement" ) -> Tuple[str, tuple]: """Render ``ALTER TABLE ... DETACH PARTITION ID ''``. @@ -194,7 +194,7 @@ def format_detach_partition_statement( return self._format_partition_id_statement(expr, "DETACH") def format_attach_partition_statement( - self, expr: "ClickHouseAttachPartitionExpression" + self, expr: "ClickHouseAttachPartitionStatement" ) -> Tuple[str, tuple]: """Render ``ALTER TABLE ... ATTACH PARTITION ID ''``. @@ -211,7 +211,7 @@ def _format_partition_id_statement(self, expr, verb: str) -> Tuple[str, tuple]: caller, not derived from a column value, so there is no query-planning benefit in parameterising it. """ - partition_id = getattr(expr, "partition_id", None) + partition_id = expr.partition_id if not isinstance(partition_id, str) or not partition_id.strip(): raise UnsupportedFeatureError( self.name, diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/set_operation.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/set_operation.py index 6bf673d..a63ad9c 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/set_operation.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/set_operation.py @@ -1,9 +1,19 @@ # src/rhosocial/activerecord/backend/impl/clickhouse/mixins/set_operation.py from typing import Tuple, TYPE_CHECKING -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.expression import bases +#: The node's operation words mapped to the spelling ClickHouse needs. Every +#: entry carries the explicit ALL/DISTINCT modifier the grammar demands -- +#: see the method docstring for why none of them can drop it. +_OP_SPELLINGS = { + "union": "UNION DISTINCT", + "union_all": "UNION ALL", + "intersect": "INTERSECT DISTINCT", + "except": "EXCEPT DISTINCT", +} + class ClickHouseSetOperationMixin: """ClickHouse set operation support (UNION, INTERSECT, EXCEPT).""" @@ -24,67 +34,37 @@ def supports_except(self) -> bool: """EXCEPT is supported.""" return True - def supports_set_operation_order_by(self) -> bool: - """Set operations support ORDER BY.""" - return True - - def supports_set_operation_limit_offset(self) -> bool: - """Set operations support LIMIT and OFFSET.""" - return True + def format_set_operation(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """Format a set operation with an explicit ALL/DISTINCT modifier. - def format_set_operation_expression(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: - """Format set operations with an explicit ALL/DISTINCT modifier. - - ``all_`` spells ``ALL`` and ``distinct`` spells ``DISTINCT``. The - unspecified state maps to the grammar's required default, ``DISTINCT``: - ClickHouse rejects a bare ``UNION`` when ``union_default_mode`` is + The node carries the operation as a **word** (``union`` / + ``union_all`` / ``intersect`` / ``except``); this method maps each + word to the spelling this backend writes. Every spelling carries an + explicit ``ALL`` or ``DISTINCT``, because ClickHouse's grammar has no + bare form: it rejects a bare ``UNION`` when ``union_default_mode`` is empty (the default) -- measured 26.7.3.19: ``Code: 558. Expected ALL or DISTINCT in SelectWithUnion query`` -- and a bare SQL-standard - ``UNION`` means ``UNION DISTINCT``. The collision between the - unspecified and explicit-DISTINCT renderings is therefore a property - of the grammar, not a dropped parameter; the pair's four states are - pinned in ``test_clause_pair_guard.py``. - """ - left, right = expr.left, expr.right - operation = expr.operation - all_ = expr.all_ - distinct = expr.distinct - alias = expr.alias - order_by_clause = expr.order_by_clause - limit_offset_clause = expr.limit_offset_clause - for_update_clause = expr.for_update_clause + ``UNION`` means ``UNION DISTINCT``. So ``union`` and ``union_all`` + are told apart by the modifier rather than by the keyword, and + ``intersect`` / ``except`` take the same required ``DISTINCT`` default. - if for_update_clause and not self.supports_set_operation_for_update(): - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - raise UnsupportedFeatureError( - self.name, - "FOR UPDATE in set operations", - "ClickHouse does not support FOR UPDATE clauses.", - ) + The former ``all_`` / ``distinct`` flag pair is gone with the trailing + clauses that no longer live on the node: ordering, row limiting and + locking of a compound query are statement-level clauses, not part of + this node, so this formatter renders the two operands and the + operator word and nothing else. - left_sql, left_params = left.to_sql() - right_sql, right_params = right.to_sql() - if all_: - modifier = "ALL" - elif distinct: - modifier = "DISTINCT" - else: - # Unspecified: the grammar requires a modifier and has no accepted - # bare form (measured Code 558), so the dialect spells its - # required default, DISTINCT. - modifier = "DISTINCT" - base_sql = f"{left_sql} {operation} {modifier} {right_sql}" - all_params = list(left_params + right_params) + Args: + expr: The node exposing ``op``, ``left`` and ``right``. - sql_parts = [base_sql] - if alias: - sql_parts.append(f"AS {self.format_identifier(alias)}") - if order_by_clause: - order_by_sql, order_by_params = order_by_clause.to_sql() - sql_parts.append(order_by_sql) - all_params.extend(order_by_params) - if limit_offset_clause: - limit_offset_sql, limit_offset_params = limit_offset_clause.to_sql() - sql_parts.append(limit_offset_sql) - all_params.extend(limit_offset_params) - return " ".join(sql_parts), tuple(all_params) + Returns: + Tuple of (SQL string, parameters tuple), carrying the operands' + parameters in order. + """ + spelling = _OP_SPELLINGS[expr.op] + left_sql, left_params = expr.left.to_sql() + right_sql, right_params = expr.right.to_sql() + return ( + f"{left_sql} {spelling} {right_sql}", + tuple(left_params) + tuple(right_params), + ) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/spatial.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/spatial.py index e0803ba..4fa829d 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/spatial.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/spatial.py @@ -37,55 +37,55 @@ def supports_surface_type(self) -> bool: def supports_geometry_collection_type(self) -> bool: return False - def format_spatial_literal(self, expr) -> Tuple[str, tuple]: + def format_spatial_literal_expression(self, expr) -> Tuple[str, tuple]: raise UnsupportedFeatureError( self.name, "spatial types", suggestion="ClickHouse has no spatial types; store WKT as String.", ) - def format_st_geom_from_text(self, expr) -> Tuple[str, tuple]: + def format_st_geom_from_text_expression(self, expr) -> Tuple[str, tuple]: raise UnsupportedFeatureError( self.name, "ST_GeomFromText", suggestion="ClickHouse has no ST_* spatial function family.", ) - def format_st_geom_from_wkb(self, expr) -> Tuple[str, tuple]: + def format_st_geom_from_wkb_expression(self, expr) -> Tuple[str, tuple]: raise UnsupportedFeatureError( self.name, "ST_GeomFromWKB", suggestion="ClickHouse has no ST_* spatial function family.", ) - def format_st_as_text(self, expr) -> Tuple[str, tuple]: + def format_st_as_text_expression(self, expr) -> Tuple[str, tuple]: raise UnsupportedFeatureError( self.name, "ST_AsText", suggestion="ClickHouse has no ST_* spatial function family.", ) - def format_st_as_geojson(self, expr) -> Tuple[str, tuple]: + def format_st_as_geo_json_expression(self, expr) -> Tuple[str, tuple]: raise UnsupportedFeatureError( self.name, "ST_AsGeoJSON", suggestion="ClickHouse has no ST_* spatial function family.", ) - def format_st_distance(self, expr) -> Tuple[str, tuple]: + def format_st_distance_expression(self, expr) -> Tuple[str, tuple]: raise UnsupportedFeatureError( self.name, "ST_Distance", suggestion="ClickHouse has no ST_* spatial function family.", ) - def format_st_within(self, expr) -> Tuple[str, tuple]: + def format_st_within_expression(self, expr) -> Tuple[str, tuple]: raise UnsupportedFeatureError( self.name, "ST_Within", suggestion="ClickHouse has no ST_* spatial function family.", ) - def format_st_contains(self, expr) -> Tuple[str, tuple]: + def format_st_contains_expression(self, expr) -> Tuple[str, tuple]: raise UnsupportedFeatureError( self.name, "ST_Contains", suggestion="ClickHouse has no ST_* spatial function family.", ) - def format_create_spatial_index(self, expr) -> Tuple[str, tuple]: + def format_create_spatial_index_statement(self, expr) -> Tuple[str, tuple]: raise UnsupportedFeatureError( self.name, "SPATIAL indexes", suggestion="ClickHouse has no SPATIAL indexes; use a skip index declared as " diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/status.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/status.py new file mode 100644 index 0000000..a72b468 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/status.py @@ -0,0 +1,488 @@ +# src/rhosocial/activerecord/backend/impl/clickhouse/mixins/status.py +"""ClickHouse format_* entry points for the status-introspection nodes. + +Every ``system.*`` read the status introspector performs is a node in +``..expression.status``; this mixin is the dialect side of that pair. Each +formatter writes one statement in full, so the whole query is visible in one +place instead of being assembled by the caller from a prefix, a counted run of +placeholders and a suffix. +""" + +from typing import Tuple, TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from ..expression.status import ( + ClickHouseAsynchronousMetricsQuery, + ClickHouseConnectionGaugesQuery, + ClickHouseConnectionProbeQuery, + ClickHouseCurrentUserQuery, + ClickHouseDatabaseTotalSizeQuery, + ClickHouseDiskListQuery, + ClickHouseEventsQuery, + ClickHouseGrantListQuery, + ClickHouseMetricsQuery, + ClickHouseProcessListQuery, + ClickHouseReplicaListQuery, + ClickHouseRoleGrantListQuery, + ClickHouseRowCountQuery, + ClickHouseServerSettingQuery, + ClickHouseServerSettingsQuery, + ClickHouseSettingQuery, + ClickHouseSettingsQuery, + ClickHouseTableCountQuery, + ClickHouseUserListQuery, + ClickHouseVersionQuery, + ) + + +class ClickHouseStatusQueryMixin: + """``format_clickhouse_*`` entry points for the status reads.""" + + # ========== Shared building blocks ========== + + def _system_source(self, name: str): + """One ClickHouse ``system`` table as a ``FROM`` source.""" + from rhosocial.activerecord.backend.expression import NamedRelationRef + from rhosocial.activerecord.backend.expression.objects import Table + + return NamedRelationRef(self, Table(self, name, catalog_name="system")) + + def _relation_source(self, catalog: str, name: str): + """One table as a ``FROM`` source, in the given catalog.""" + from rhosocial.activerecord.backend.expression import NamedRelationRef + from rhosocial.activerecord.backend.expression.objects import Table + + return NamedRelationRef(self, Table(self, name, catalog_name=catalog)) + + def _column(self, name: str, alias=None): + """A column reference, optionally projected under another name.""" + from rhosocial.activerecord.backend.expression import Column + + return Column(self, name, alias=alias) + + def _equals(self, left, right): + """``left = right`` as a predicate node.""" + from rhosocial.activerecord.backend.expression import ComparisonPredicate + + return ComparisonPredicate(self, "eq", left, right) + + def _in(self, column_name: str, values): + """`` IN ()`` over bound values.""" + from rhosocial.activerecord.backend.expression import ( + Column, + InPredicate, + Literal, + ) + + return InPredicate(self, Column(self, column_name), Literal(self, tuple(values))) + + def _is_null(self, column_name: str): + """`` IS NULL``.""" + from rhosocial.activerecord.backend.expression import Column, IsNullPredicate + + return IsNullPredicate(self, Column(self, column_name)) + + def _where(self, *predicates): + """A ``WhereClause`` over the conjunction, or ``None`` when empty.""" + from rhosocial.activerecord.backend.expression import LogicalPredicate, WhereClause + + kept = [p for p in predicates if p is not None] + if not kept: + return None + return WhereClause(self, LogicalPredicate(self, "AND", *kept)) + + # ========== Inventory reads ========== + + def format_clickhouse_settings_query(self, expr: "ClickHouseSettingsQuery") -> Tuple[str, tuple]: + """``system.settings`` rows for the requested names.""" + from rhosocial.activerecord.backend.expression import QueryStatement + + query = QueryStatement( + self, + select=[ + self._column("name"), + self._column("value"), + self._column("description"), + self._column("default"), + self._column("readonly"), + self._column("changed"), + self._column("type"), + ], + from_=self._system_source("settings"), + where=self._where(self._in("name", expr.names)), + ) + return query.to_sql() + + def format_clickhouse_server_settings_query( + self, expr: "ClickHouseServerSettingsQuery" + ) -> Tuple[str, tuple]: + """``system.server_settings`` rows for the requested names.""" + from rhosocial.activerecord.backend.expression import QueryStatement + + query = QueryStatement( + self, + select=[ + self._column("name"), + self._column("value"), + self._column("description"), + self._column("default"), + self._column("changed"), + self._column("type"), + self._column("changeable_without_restart"), + ], + from_=self._system_source("server_settings"), + where=self._where(self._in("name", expr.names)), + ) + return query.to_sql() + + def format_clickhouse_metrics_query(self, expr: "ClickHouseMetricsQuery") -> Tuple[str, tuple]: + """Gauge rows of ``system.metrics`` for the requested names.""" + from rhosocial.activerecord.backend.expression import QueryStatement + + query = QueryStatement( + self, + select=[ + self._column("metric"), + self._column("value"), + self._column("description"), + ], + from_=self._system_source("metrics"), + where=self._where(self._in("metric", expr.names)), + ) + return query.to_sql() + + def format_clickhouse_events_query(self, expr: "ClickHouseEventsQuery") -> Tuple[str, tuple]: + """Counter rows of ``system.events`` for the requested names.""" + from rhosocial.activerecord.backend.expression import QueryStatement + + query = QueryStatement( + self, + select=[ + self._column("event"), + self._column("value"), + self._column("description"), + ], + from_=self._system_source("events"), + where=self._where(self._in("event", expr.names)), + ) + return query.to_sql() + + def format_clickhouse_asynchronous_metrics_query( + self, expr: "ClickHouseAsynchronousMetricsQuery" + ) -> Tuple[str, tuple]: + """Sampled rows of ``system.asynchronous_metrics`` for the requested names.""" + from rhosocial.activerecord.backend.expression import QueryStatement + + query = QueryStatement( + self, + select=[ + self._column("metric"), + self._column("value"), + self._column("description"), + ], + from_=self._system_source("asynchronous_metrics"), + where=self._where(self._in("metric", expr.names)), + ) + return query.to_sql() + + def format_clickhouse_connection_gauges_query( + self, expr: "ClickHouseConnectionGaugesQuery" + ) -> Tuple[str, tuple]: + """``metric``/``value`` rows of ``system.metrics`` for the requested names.""" + from rhosocial.activerecord.backend.expression import QueryStatement + + query = QueryStatement( + self, + select=[self._column("metric"), self._column("value")], + from_=self._system_source("metrics"), + where=self._where(self._in("metric", expr.names)), + ) + return query.to_sql() + + # ========== Single-value reads ========== + + def format_clickhouse_server_setting_query( + self, expr: "ClickHouseServerSettingQuery" + ) -> Tuple[str, tuple]: + """One ``name``/``value`` row of ``system.server_settings``.""" + from rhosocial.activerecord.backend.expression import Literal, QueryStatement + + query = QueryStatement( + self, + select=[self._column("name"), self._column("value")], + from_=self._system_source("server_settings"), + where=self._where(self._equals(self._column("name"), Literal(self, expr.name))), + ) + return query.to_sql() + + def format_clickhouse_database_total_size_query( + self, expr: "ClickHouseDatabaseTotalSizeQuery" + ) -> Tuple[str, tuple]: + """Total on-disk size of one database's tables.""" + from rhosocial.activerecord.backend.expression import Literal, QueryStatement + from rhosocial.activerecord.backend.expression.functions.aggregate import sum_ + + query = QueryStatement( + self, + select=[sum_(self, self._column("total_bytes"), alias="total_size")], + from_=self._system_source("tables"), + where=self._where(self._equals(self._column("database"), Literal(self, expr.database))), + ) + return query.to_sql() + + def format_clickhouse_disk_list_query( + self, expr: "ClickHouseDiskListQuery" + ) -> Tuple[str, tuple]: + """Every row of ``system.disks``, with its capacity and flags.""" + from rhosocial.activerecord.backend.expression import QueryStatement + + query = QueryStatement( + self, + select=[ + self._column("name"), + self._column("path"), + self._column("type"), + self._column("total_space"), + self._column("free_space"), + self._column("unreserved_space"), + self._column("keep_free_space"), + self._column("is_encrypted"), + self._column("is_read_only"), + self._column("is_remote"), + self._column("is_broken"), + ], + from_=self._system_source("disks"), + ) + return query.to_sql() + + def format_clickhouse_table_count_query( + self, expr: "ClickHouseTableCountQuery" + ) -> Tuple[str, tuple]: + """Table and view counts per database, from ``information_schema.TABLES``.""" + from rhosocial.activerecord.backend.expression import QueryStatement + from rhosocial.activerecord.backend.expression.functions.aggregate import count + + query = QueryStatement( + self, + select=[ + self._column("table_schema"), + self._column("table_type"), + count(self, "*", alias="count"), + ], + from_=self._relation_source("information_schema", "TABLES"), + where=self._where(self._in("table_schema", expr.schemas)), + group_by_having=self._group_by("table_schema", "table_type"), + ) + return query.to_sql() + + def _group_by(self, *column_names: str): + """``GROUP BY`` over the named columns.""" + from rhosocial.activerecord.backend.expression import GroupByHavingClause + + return GroupByHavingClause(self, group_by=[self._column(n) for n in column_names]) + + # ========== User / role reads ========== + + def format_clickhouse_user_list_query( + self, expr: "ClickHouseUserListQuery" + ) -> Tuple[str, tuple]: + """Every row of ``system.users``.""" + from rhosocial.activerecord.backend.expression import QueryStatement + + query = QueryStatement( + self, + select=[ + self._column("name"), + self._column("id"), + self._column("auth_type"), + self._column("default_database"), + self._column("host_ip"), + self._column("host_names"), + ], + from_=self._system_source("users"), + ) + return query.to_sql() + + def format_clickhouse_grant_list_query( + self, expr: "ClickHouseGrantListQuery" + ) -> Tuple[str, tuple]: + """Database- and table-scoped grants, from ``system.grants``. + + ``database IS NULL AND table IS NULL`` is what isolates the + instance-wide grant rows from the per-database ones: the same table + holds both, and only the former is what an account's access types are + read from. + """ + from rhosocial.activerecord.backend.expression import QueryStatement + + query = QueryStatement( + self, + select=[ + self._column("user_name"), + self._column("access_type"), + self._column("database"), + self._column("table"), + self._column("grant_option"), + ], + from_=self._system_source("grants"), + where=self._where(self._is_null("database"), self._is_null("table")), + ) + return query.to_sql() + + def format_clickhouse_role_grant_list_query( + self, expr: "ClickHouseRoleGrantListQuery" + ) -> Tuple[str, tuple]: + """Granted roles, from ``system.role_grants``.""" + from rhosocial.activerecord.backend.expression import QueryStatement + + query = QueryStatement( + self, + select=[ + self._column("user_name"), + self._column("role_name"), + self._column("granted_role_is_default"), + ], + from_=self._system_source("role_grants"), + ) + return query.to_sql() + + # ========== Session / process reads ========== + + def format_clickhouse_current_user_query( + self, expr: "ClickHouseCurrentUserQuery" + ) -> Tuple[str, tuple]: + """The user this session authenticates as.""" + from rhosocial.activerecord.backend.expression import FunctionCall, QueryStatement + + query = QueryStatement( + self, + select=[FunctionCall(self, "currentUser")], + ) + return query.to_sql() + + def format_clickhouse_setting_query( + self, expr: "ClickHouseSettingQuery" + ) -> Tuple[str, tuple]: + """One ``value`` of ``system.settings``. + + The session-level counterpart of the server-settings read: a setting a + query or the session can change has its row here and none in + ``system.server_settings``. + """ + from rhosocial.activerecord.backend.expression import Literal, QueryStatement + + query = QueryStatement( + self, + select=[self._column("value")], + from_=self._system_source("settings"), + where=self._where(self._equals(self._column("name"), Literal(self, expr.name))), + ) + return query.to_sql() + + def format_clickhouse_connection_probe_query( + self, expr: "ClickHouseConnectionProbeQuery" + ) -> Tuple[str, tuple]: + """``SELECT 1``: the connection liveness probe. + + A scalar with no relation, so the statement is a ``QueryStatement`` + whose select list is the one constant and whose ``from_`` is absent. + """ + from rhosocial.activerecord.backend.expression import Literal, QueryStatement + + query = QueryStatement( + self, + select=[Literal(self, 1, inline=True)], + ) + return query.to_sql() + + def format_clickhouse_version_query(self, expr: "ClickHouseVersionQuery") -> Tuple[str, tuple]: + """The server's own version string.""" + from rhosocial.activerecord.backend.expression import FunctionCall, QueryStatement + + query = QueryStatement( + self, + select=[FunctionCall(self, "version")], + ) + return query.to_sql() + + def format_clickhouse_process_list_query( + self, expr: "ClickHouseProcessListQuery" + ) -> Tuple[str, tuple]: + """The running queries, from ``system.processes``. + + ``current_database`` rather than MySQL's ``currentDatabase``: measured on + 26.7.3.19, ``SELECT currentDatabase FROM system.processes`` resolves as a + *value* (the string ``default``) on every row, so nothing errors and the + column quietly holds the same constant six thousand times. The column + name is the one the table actually has. + """ + from rhosocial.activerecord.backend.expression import QueryStatement + + query = QueryStatement( + self, + select=[ + self._column("query_id"), + self._column("user"), + self._column("address"), + self._column("current_database"), + self._column("elapsed"), + self._column("query"), + ], + from_=self._system_source("processes"), + ) + return query.to_sql() + + def format_clickhouse_replica_list_query( + self, expr: "ClickHouseReplicaListQuery" + ) -> Tuple[str, tuple]: + """Replication summary rows from ``system.replicas``. + + The row cap is **inline text**, not a bind parameter: it is an inventory + cap this backend chose, not caller data, and the concatenated form wrote + ``LIMIT 20`` into the statement. Binding it would have changed the + parameter tuple for a value nobody passes in. + """ + from rhosocial.activerecord.backend.expression import Literal, QueryStatement + from rhosocial.activerecord.backend.expression.query_parts import LimitOffsetClause + + query = QueryStatement( + self, + select=[ + self._column("database"), + self._column("table"), + self._column("is_leader"), + self._column("is_readonly"), + self._column("total_replicas"), + ], + from_=self._system_source("replicas"), + limit_offset=( + LimitOffsetClause(self, limit=Literal(self, expr.limit, inline=True)) + if expr.limit is not None + else None + ), + ) + return query.to_sql() + + # ========== Mutation pre-count ========== + + def format_clickhouse_row_count_query( + self, expr: "ClickHouseRowCountQuery" + ) -> Tuple[str, tuple]: + """``SELECT count()`` over the predicate a mutation is about to apply.""" + from rhosocial.activerecord.backend.expression import ( + NamedRelationRef, + QueryStatement, + WhereClause, + ) + from rhosocial.activerecord.backend.expression.functions.aggregate import count + from rhosocial.activerecord.backend.expression.objects import Table + + query = QueryStatement( + self, + select=[count(self, "*")], + from_=NamedRelationRef( + self, Table(self, expr.table, catalog_name=expr.schema_name) + ), + where=WhereClause(self, expr.where), + ) + return query.to_sql() diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/transaction.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/transaction.py index 721ab37..e7c612b 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/transaction.py @@ -91,7 +91,7 @@ def _reject_wait_clause(self, expr) -> None: f"ClickHouse does not support the {feature} transaction clause.", ) - def format_set_transaction(self, expr) -> Tuple[str, tuple]: + def format_set_transaction_statement(self, expr) -> Tuple[str, tuple]: """ClickHouse does not support transactions.""" from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError @@ -101,7 +101,7 @@ def format_set_transaction(self, expr) -> Tuple[str, tuple]: "ClickHouse does not support SET TRANSACTION." ) - def format_begin_transaction(self, expr) -> Tuple[str, tuple]: + def format_begin_transaction_statement(self, expr) -> Tuple[str, tuple]: """ClickHouse does not support transactions.""" from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/truncate.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/truncate.py index 3d6c96d..3ba5ae8 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/truncate.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/truncate.py @@ -6,7 +6,7 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.expression.statements.ddl_truncate import ( - TruncateExpression, + TruncateStatement, ) @@ -34,7 +34,7 @@ def supports_truncate_restrict(self) -> bool: """ClickHouse TRUNCATE has no RESTRICT spelling (measured: Code 62).""" return False - def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tuple]: + def format_truncate_statement(self, expr: "TruncateStatement") -> Tuple[str, tuple]: """Format ClickHouse ``TRUNCATE [TABLE] tbl_name``. Raises: @@ -46,7 +46,7 @@ def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tu """ if not isinstance(expr.table, Table): raise TypeError( - f"TruncateExpression.table must be a Table, " + f"TruncateStatement.table must be a Table, " f"got {type(expr.table).__name__}" ) if expr.restart_identity or expr.continue_identity: diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/types.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/types.py index 96b89ae..5eb1fed 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/types.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/types.py @@ -161,8 +161,24 @@ def _refuse_unsigned_integer( ``bigint`` names, which are the same concepts reached by their own dispatch keys — refuse it the same way and point at the class that does own the unsigned column. + + The shape check runs first, and this is the only gate there is: the + expression collects ``unsigned`` without judging it, because only the + formatter knows which backend must honour it, so a non-boolean arrives + here rather than being rejected at construction. A truth test cannot + see it — ``unsigned=0``, ``unsigned=""`` and ``unsigned=None`` are all + falsy, so they would pass the test below and render a signed ``Int8`` + (or a core ``TINYINT``) while reporting success. ``bool`` is also the + only shape that means what the field says: the flag *is* the identity, + so ``"yes"`` and ``1`` are not spellings of ``True`` but wrong values. """ - if not data_type.unsigned: + unsigned = data_type.unsigned + if not isinstance(unsigned, bool): + raise TypeError( + f"unsigned must be a bool, got {type(unsigned).__name__}; it " + f"becomes a type modifier in the rendered DDL." + ) + if not unsigned: return raise UnsupportedFeatureError( self.name, @@ -252,11 +268,24 @@ def _refuse_unsigned_numeric( ``UnsupportedFeatureError``, not ``ValueError``: this is a declaration the grammar cannot express at all rather than a wrong value, and the two exceptions do not share a base class. This method runs **before** - ``_check_spelling`` in every caller, so a request wrong in two ways is + ``check_spelling`` in every caller, so a request wrong in two ways is told about the signedness first — that one is a stronger and less recoverable statement than a misspelled word. + + The shape check is first for the reason given in + :meth:`_refuse_unsigned_integer`, and it covers the same four numeric + concepts through their own dispatch keys: a non-boolean reaches this + gate unjudged, and a falsy one — ``0``, ``""``, ``None``, an empty + container — is invisible to the truth test below, so it would render a + signed ``Float32`` / ``Float64`` / ``Decimal(P, S)`` and report success. """ - if not data_type.unsigned: + unsigned = data_type.unsigned + if not isinstance(unsigned, bool): + raise TypeError( + f"unsigned must be a bool, got {type(unsigned).__name__}; it " + f"becomes a type modifier in the rendered DDL." + ) + if not unsigned: return raise UnsupportedFeatureError( self.name, @@ -833,7 +862,7 @@ def format_data_type_integer(self, data_type: IntegerType) -> Tuple[str, tuple]: and ``ClickHouseUInt32Type()`` are the same ``UInt32`` column, and this backend keeps one class per column. """ - self._check_spelling(data_type, IntegerType) + self.check_spelling(data_type, IntegerType) self._refuse_unsigned_integer(data_type, "Int32", "ClickHouseUInt32Type") return "Int32", () @@ -844,7 +873,7 @@ def format_data_type_bigint(self, data_type: BigIntType) -> Tuple[str, tuple]: ``unsigned`` is refused rather than ignored; see :meth:`_refuse_unsigned_integer`.""" - self._check_spelling(data_type, BigIntType) + self.check_spelling(data_type, BigIntType) self._refuse_unsigned_integer(data_type, "Int64", "ClickHouseUInt64Type") return "Int64", () @@ -854,7 +883,7 @@ def format_data_type_smallint(self, data_type: SmallIntType) -> Tuple[str, tuple ``unsigned`` is refused rather than ignored; see :meth:`_refuse_unsigned_integer`.""" - self._check_spelling(data_type, SmallIntType) + self.check_spelling(data_type, SmallIntType) self._refuse_unsigned_integer(data_type, "Int16", "ClickHouseUInt16Type") return "Int16", () @@ -873,7 +902,7 @@ def format_data_type_tinyint(self, data_type: TinyIntType) -> Tuple[str, tuple]: ``unsigned`` is refused here and points at that class instead. See :meth:`_refuse_unsigned_integer`. """ - self._check_spelling(data_type, TinyIntType) + self.check_spelling(data_type, TinyIntType) self._refuse_unsigned_integer(data_type, "Int8", "ClickHouseUInt8Type") return "Int8", () @@ -883,7 +912,7 @@ def format_data_type_varchar(self, data_type: VarCharType) -> Tuple[str, tuple]: ``VARCHAR`` and ``CHARACTER VARYING`` are accepted for the same reason as the integer spellings — the concept was asked for and this is its only storage.""" - self._check_spelling(data_type, VarCharType) + self.check_spelling(data_type, VarCharType) return "String", () def format_data_type_char(self, data_type: CharType) -> Tuple[str, tuple]: @@ -897,7 +926,7 @@ def format_data_type_char(self, data_type: CharType) -> Tuple[str, tuple]: becomes. Both spellings are accepted for the reason given on :meth:`format_data_type_varchar`. """ - self._check_spelling(data_type, CharType) + self.check_spelling(data_type, CharType) return "String", () def format_data_type_text(self, data_type: TextType) -> Tuple[str, tuple]: @@ -907,7 +936,7 @@ def format_data_type_text(self, data_type: TextType) -> Tuple[str, tuple]: spelling and ``String`` is the concept's only storage here, so it is accepted rather than refused. """ - self._check_spelling(data_type, TextType) + self.check_spelling(data_type, TextType) return "String", () def format_data_type_boolean(self, data_type: BooleanType) -> Tuple[str, tuple]: @@ -918,7 +947,7 @@ def format_data_type_boolean(self, data_type: BooleanType) -> Tuple[str, tuple]: this is the one concept whose core spelling list happens to be exactly ClickHouse's vocabulary. """ - self._check_spelling(data_type, BooleanType) + self.check_spelling(data_type, BooleanType) return "Bool", () def format_data_type_date(self, data_type: DateType) -> Tuple[str, tuple]: @@ -990,7 +1019,7 @@ def format_data_type_double(self, data_type: DoubleType) -> Tuple[str, tuple]: see :meth:`_refuse_unsigned_numeric`. """ self._refuse_unsigned_numeric(data_type, "Float64") - self._check_spelling(data_type, DoubleType) + self.check_spelling(data_type, DoubleType) return "Float64", () def format_data_type_real(self, data_type: RealType) -> Tuple[str, tuple]: @@ -1023,7 +1052,7 @@ def format_data_type_decimal(self, data_type: DecimalType) -> Tuple[str, tuple]: and the caller is told so by name rather than handed a signed column. """ self._refuse_unsigned_numeric(data_type, "Decimal(P, S)") - self._check_spelling(data_type, DecimalType) + self.check_spelling(data_type, DecimalType) p = data_type.precision s = data_type.scale if p is not None and s is not None: @@ -1054,7 +1083,7 @@ def format_data_type_blob(self, data_type: BlobType) -> Tuple[str, tuple]: Not ``FixedString``: there is no length to honour, and ``FixedString`` zero-pads, so using it here would change the value. """ - self._check_spelling(data_type, BlobType) + self.check_spelling(data_type, BlobType) return "String", () def format_data_type_uuid(self, data_type: UUIDType) -> Tuple[str, tuple]: diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/view.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/view.py index f7a3093..5053417 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/view.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/view.py @@ -6,8 +6,8 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements import ( - CreateViewExpression, - DropViewExpression, + CreateViewStatement, + DropViewStatement, ) @@ -22,6 +22,15 @@ def supports_temporary_view(self) -> bool: """Whether CREATE TEMPORARY VIEW is supported.""" return True + + def supports_create_view_bind_parameters(self) -> bool: + """ClickHouse accepts bind parameters in a view body. + + Measured on 26.7.3: a view whose definition binds is accepted, + because ClickHouse's "parameters" are interpolated client-side and + the value is baked into the stored definition. + """ + return True def supports_if_exists_view(self) -> bool: """Whether DROP VIEW IF EXISTS is supported.""" return True @@ -46,11 +55,11 @@ def supports_restrict_view(self) -> bool: """Whether RESTRICT is supported in DROP VIEW (measured: Code 62).""" return False - def format_create_view_statement(self, expr: "CreateViewExpression") -> Tuple[str, tuple]: + def format_create_view_statement(self, expr: "CreateViewStatement") -> Tuple[str, tuple]: """Format CREATE VIEW statement for ClickHouse. Raises: - TypeError: ``CreateViewExpression.view`` is not a View. A + TypeError: ``CreateViewStatement.view`` is not a View. A materialized view passed here would render as a well-formed ``CREATE VIEW`` over the MV's name; an Index or a Table would do the same. The object carries its own ``format_method``, so the @@ -58,7 +67,7 @@ def format_create_view_statement(self, expr: "CreateViewExpression") -> Tuple[st """ if not isinstance(expr.view, View): raise TypeError( - f"CreateViewExpression.view must be a View, " + f"CreateViewStatement.view must be a View, " f"got {type(expr.view).__name__}" ) parts = ["CREATE"] @@ -80,6 +89,8 @@ def format_create_view_statement(self, expr: "CreateViewExpression") -> Tuple[st parts.append(f"({cols})") query_sql, query_params = expr.query.to_sql() + if query_params and not self.supports_create_view_bind_parameters(): + self.refuse_ddl_bind_parameters("CREATE VIEW with bound parameters") parts.append(f"AS {query_sql}") if expr.options and expr.options.check_option: @@ -93,18 +104,18 @@ def format_create_view_statement(self, expr: "CreateViewExpression") -> Tuple[st return " ".join(parts), query_params - def format_drop_view_statement(self, expr: "DropViewExpression") -> Tuple[str, tuple]: + def format_drop_view_statement(self, expr: "DropViewStatement") -> Tuple[str, tuple]: """Format DROP VIEW statement for ClickHouse. Raises: - TypeError: ``DropViewExpression.view`` is not a View. Anything else + TypeError: ``DropViewStatement.view`` is not a View. Anything else would have its own name rendered as the dropped view's. UnsupportedFeatureError: ``CASCADE`` or ``RESTRICT``, neither of which ClickHouse accepts (measured: Code 62). """ if not isinstance(expr.view, View): raise TypeError( - f"DropViewExpression.view must be a View, " + f"DropViewStatement.view must be a View, " f"got {type(expr.view).__name__}" ) if expr.cascade: diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/wildcard.py b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/wildcard.py new file mode 100644 index 0000000..00aa36a --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/mixins/wildcard.py @@ -0,0 +1,109 @@ +# src/rhosocial/activerecord/backend/impl/clickhouse/mixins/wildcard.py +"""ClickHouse's wildcard formatters: the two asterisk positions. + +Core split the asterisk by grammar position (spec 4.10) and gave each position +its own formatter: ``format_all_columns_wildcard`` for the select list and +RETURNING, ``format_row_count_wildcard`` for ``COUNT``'s argument. A dialect +overrides the one whose *spelling* differs, and ClickHouse's row-count position +does: + +* ``COUNT(*)`` -- the bare asterisk, which core already spells, so nothing here + changes it; +* ``COUNT(DISTINCT *)`` -- the distinct combinations of every column's value + rather than the rows. Only ClickHouse accepts it of the eleven backends + measured (:file:`wildcard-survey.md`, row B8), so it is the backend's own + node (:class:`~...expression.wildcard.ClickHouseRowCountDistinctWildcard`) + and this override is what spells it. + +The select-list position is core's verbatim -- ClickHouse writes ``*`` and +``rel.*`` exactly as the shared formatter does -- so ``format_all_columns_wildcard`` +is not overridden here. + +**Evidence level: measured.** On the scenario server named by +``tests/config/clickhouse_scenarios.yaml`` (``clickhouse_http``: ClickHouse +26.7.3.19 official build, HTTP on ``127.0.0.1:18682``, database +``test_db``):: + + INSERT INTO probe_distinct_star VALUES (1, 1), (1, 1), (1, 2), (2, 2) + SELECT count() FROM probe_distinct_star -- 4 + SELECT count(DISTINCT *) FROM probe_distinct_star -- 3, answered as + -- countDistinct(a, b) + +so the extension is accepted and counts the distinct combinations of every +column's value, exactly the different count this node exists to spell. The +rendering contract is verified by test. +""" + +from typing import Tuple, TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.expression.bases import BaseExpression + + +class ClickHouseWildcardMixin: + """Render ClickHouse's row-count wildcard, and delegate everything else. + + The check is a class test, never a missing-attribute probe: core's + ``RowCountWildcard`` is the bare asterisk and has no distinctness field to + look for, and asking for one anyway is the reflection the project's rule 9.8 + forbids. Anything that is not the ClickHouse node is core's bare ``*``, so + ``super()`` answers it -- which keeps core's plain ``COUNT(*)`` path + byte-identical. + """ + + def format_row_count_wildcard( + self, expr: "BaseExpression" + ) -> Tuple[str, tuple]: + """Render ``COUNT``'s argument: the bare ``*``, or ``DISTINCT *``. + + Core's node renders ``*``; the ClickHouse node adds the ``DISTINCT``, + which is what makes it a different count rather than a modifier on the + same one. + + Args: + expr: The wildcard being rendered. + + Returns: + A ``(sql, params)`` tuple; ``params`` is empty. + """ + from rhosocial.activerecord.backend.impl.clickhouse.expression.wildcard import ( + ClickHouseRowCountDistinctWildcard, + ) + + if not isinstance(expr, ClickHouseRowCountDistinctWildcard): + return super().format_row_count_wildcard(expr) + return "DISTINCT *", () + + def format_count_function(self, expr: "BaseExpression") -> Tuple[str, tuple]: + """Render ``COUNT`` over the row-count position: ``(*)`` or ``(DISTINCT *)``. + + Core's shared aggregate helper answers every ``RowCountWildcard`` + with the bare asterisk -- which would silently drop this backend's + distinct node's spelling and return a count of the wrong thing. + ``DISTINCT`` is not a modifier the aggregate carries: core refuses + ``count(dialect, "*", is_distinct=True)`` at construction (the + row-count wildcard counts rows, which have no duplicates), so the + extension reaches the statement as a different *operand* -- this + backend's own node -- and the classed entry point is where that + operand is consulted. + + Args: + expr: The :class:`CountFunction` whose operand may be the + ClickHouse distinct row-count wildcard. + + Returns: + A ``(sql, params)`` tuple; ``params`` is empty. + """ + from rhosocial.activerecord.backend.impl.clickhouse.expression.wildcard import ( + ClickHouseRowCountDistinctWildcard, + ) + + if isinstance(expr.operand, ClickHouseRowCountDistinctWildcard): + sql = "COUNT(DISTINCT *)" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, () + return super().format_count_function(expr) + + +__all__ = ["ClickHouseWildcardMixin"] diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/admin.py b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/admin.py index 2b00fe9..30adada 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/admin.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/admin.py @@ -9,28 +9,28 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.impl.clickhouse.expression.admin import ( - ClickHouseBinlogExpression, - ClickHouseCacheIndexExpression, - ClickHouseCloneExpression, - ClickHouseCreateUserExpression, - ClickHouseDoExpression, - ClickHouseDropUserExpression, - ClickHouseFlushExpression, - ClickHouseGrantExpression, - ClickHouseHandlerCloseExpression, - ClickHouseHandlerOpenExpression, - ClickHouseHandlerReadExpression, - ClickHouseHelpExpression, - ClickHouseInstallComponentExpression, - ClickHouseInstallPluginExpression, - ClickHouseKillExpression, - ClickHouseLoadIndexIntoCacheExpression, - ClickHouseResetExpression, - ClickHouseRestartExpression, - ClickHouseRevokeExpression, - ClickHouseShutdownExpression, - ClickHouseUninstallComponentExpression, - ClickHouseUninstallPluginExpression, + ClickHouseBinlogStatement, + ClickHouseCacheIndexStatement, + ClickHouseCloneStatement, + ClickHouseCreateUserStatement, + ClickHouseDoStatement, + ClickHouseDropUserStatement, + ClickHouseFlushStatement, + ClickHouseGrantStatement, + ClickHouseHandlerCloseStatement, + ClickHouseHandlerOpenStatement, + ClickHouseHandlerReadStatement, + ClickHouseHelpStatement, + ClickHouseInstallComponentStatement, + ClickHouseInstallPluginStatement, + ClickHouseKillStatement, + ClickHouseLoadIndexIntoCacheStatement, + ClickHouseResetStatement, + ClickHouseRestartStatement, + ClickHouseRevokeStatement, + ClickHouseShutdownStatement, + ClickHouseUninstallComponentStatement, + ClickHouseUninstallPluginStatement, ) @runtime_checkable @@ -51,129 +51,129 @@ class ClickHouseAdminCommandSupport(Protocol): def supports_flush(self) -> bool: ... - def format_flush_statement(self, expr: "ClickHouseFlushExpression") -> Tuple[str, tuple]: + def format_flush_statement(self, expr: "ClickHouseFlushStatement") -> Tuple[str, tuple]: ... def supports_reset(self) -> bool: ... - def format_reset_statement(self, expr: "ClickHouseResetExpression") -> Tuple[str, tuple]: + def format_reset_statement(self, expr: "ClickHouseResetStatement") -> Tuple[str, tuple]: ... def supports_cache_index(self) -> bool: ... - def format_cache_index_statement(self, expr: "ClickHouseCacheIndexExpression") -> Tuple[str, tuple]: + def format_cache_index_statement(self, expr: "ClickHouseCacheIndexStatement") -> Tuple[str, tuple]: ... def supports_load_index_into_cache(self) -> bool: ... def format_load_index_into_cache_statement( - self, expr: "ClickHouseLoadIndexIntoCacheExpression" + self, expr: "ClickHouseLoadIndexIntoCacheStatement" ) -> Tuple[str, tuple]: ... def supports_install_component(self) -> bool: ... - def format_install_component_statement(self, expr: "ClickHouseInstallComponentExpression") -> Tuple[str, tuple]: + def format_install_component_statement(self, expr: "ClickHouseInstallComponentStatement") -> Tuple[str, tuple]: ... def supports_uninstall_component(self) -> bool: ... def format_uninstall_component_statement( - self, expr: "ClickHouseUninstallComponentExpression" + self, expr: "ClickHouseUninstallComponentStatement" ) -> Tuple[str, tuple]: ... def supports_install_plugin(self) -> bool: ... - def format_install_plugin_statement(self, expr: "ClickHouseInstallPluginExpression") -> Tuple[str, tuple]: + def format_install_plugin_statement(self, expr: "ClickHouseInstallPluginStatement") -> Tuple[str, tuple]: ... def supports_uninstall_plugin(self) -> bool: ... - def format_uninstall_plugin_statement(self, expr: "ClickHouseUninstallPluginExpression") -> Tuple[str, tuple]: + def format_uninstall_plugin_statement(self, expr: "ClickHouseUninstallPluginStatement") -> Tuple[str, tuple]: ... def supports_clone(self) -> bool: ... - def format_clone_statement(self, expr: "ClickHouseCloneExpression") -> Tuple[str, tuple]: + def format_clone_statement(self, expr: "ClickHouseCloneStatement") -> Tuple[str, tuple]: ... def supports_restart(self) -> bool: ... - def format_restart_statement(self, expr: "ClickHouseRestartExpression") -> Tuple[str, tuple]: + def format_restart_statement(self, expr: "ClickHouseRestartStatement") -> Tuple[str, tuple]: ... def supports_binlog(self) -> bool: ... - def format_binlog_statement(self, expr: "ClickHouseBinlogExpression") -> Tuple[str, tuple]: + def format_binlog_statement(self, expr: "ClickHouseBinlogStatement") -> Tuple[str, tuple]: ... def supports_handler(self) -> bool: ... - def format_handler_open_statement(self, expr: "ClickHouseHandlerOpenExpression") -> Tuple[str, tuple]: + def format_handler_open_statement(self, expr: "ClickHouseHandlerOpenStatement") -> Tuple[str, tuple]: ... - def format_handler_read_statement(self, expr: "ClickHouseHandlerReadExpression") -> Tuple[str, tuple]: + def format_handler_read_statement(self, expr: "ClickHouseHandlerReadStatement") -> Tuple[str, tuple]: ... - def format_handler_close_statement(self, expr: "ClickHouseHandlerCloseExpression") -> Tuple[str, tuple]: + def format_handler_close_statement(self, expr: "ClickHouseHandlerCloseStatement") -> Tuple[str, tuple]: ... def supports_do(self) -> bool: ... - def format_do_statement(self, expr: "ClickHouseDoExpression") -> Tuple[str, tuple]: + def format_do_statement(self, expr: "ClickHouseDoStatement") -> Tuple[str, tuple]: ... def supports_kill(self) -> bool: ... - def format_kill_statement(self, expr: "ClickHouseKillExpression") -> Tuple[str, tuple]: + def format_kill_statement(self, expr: "ClickHouseKillStatement") -> Tuple[str, tuple]: ... def supports_shutdown(self) -> bool: ... - def format_shutdown_statement(self, expr: "ClickHouseShutdownExpression") -> Tuple[str, tuple]: + def format_shutdown_statement(self, expr: "ClickHouseShutdownStatement") -> Tuple[str, tuple]: ... def supports_help(self) -> bool: ... - def format_help_statement(self, expr: "ClickHouseHelpExpression") -> Tuple[str, tuple]: + def format_help_statement(self, expr: "ClickHouseHelpStatement") -> Tuple[str, tuple]: ... def supports_create_user(self) -> bool: ... - def format_create_user_statement(self, expr: "ClickHouseCreateUserExpression") -> Tuple[str, tuple]: + def format_create_user_statement(self, expr: "ClickHouseCreateUserStatement") -> Tuple[str, tuple]: ... def supports_drop_user(self) -> bool: ... - def format_drop_user_statement(self, expr: "ClickHouseDropUserExpression") -> Tuple[str, tuple]: + def format_drop_user_statement(self, expr: "ClickHouseDropUserStatement") -> Tuple[str, tuple]: ... def supports_grant(self) -> bool: ... - def format_grant_statement(self, expr: "ClickHouseGrantExpression") -> Tuple[str, tuple]: + def format_grant_statement(self, expr: "ClickHouseGrantStatement") -> Tuple[str, tuple]: ... def supports_revoke(self) -> bool: ... - def format_revoke_statement(self, expr: "ClickHouseRevokeExpression") -> Tuple[str, tuple]: + def format_revoke_statement(self, expr: "ClickHouseRevokeStatement") -> Tuple[str, tuple]: ... diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/dml.py b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/dml.py index 0e0c3ac..99fa249 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/dml.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/dml.py @@ -9,11 +9,11 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.expression.statements import ( - InsertExpression, + InsertStatement, OnConflictClause, ) from rhosocial.activerecord.backend.impl.clickhouse.expression.load_data import ( - ClickHouseLoadDataExpression, + ClickHouseLoadDataStatement, ) @@ -54,14 +54,14 @@ def supports_load_data(self) -> bool: """ ... - def format_load_data_statement(self, expr: "ClickHouseLoadDataExpression") -> Tuple[str, tuple]: + def format_load_data_statement(self, expr: "ClickHouseLoadDataStatement") -> Tuple[str, tuple]: """Format LOAD DATA INFILE statement. Raises: UnsupportedFeatureError: ClickHouse does not support LOAD DATA INFILE. Args: - expr: ClickHouseLoadDataExpression instance + expr: ClickHouseLoadDataStatement instance Returns: Tuple of (SQL string, parameters tuple) @@ -82,11 +82,11 @@ def format_on_conflict_clause(self, expr: "OnConflictClause") -> Tuple[str, tupl """ ... - def format_insert_statement(self, expr: "InsertExpression") -> Tuple[str, tuple]: + def format_insert_statement(self, expr: "InsertStatement") -> Tuple[str, tuple]: """Format INSERT statement. Args: - expr: InsertExpression instance + expr: InsertStatement instance Returns: Tuple of (SQL string, parameters tuple) diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/fulltext_search.py b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/fulltext_search.py index 4d35d11..25a81a8 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/fulltext_search.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/fulltext_search.py @@ -57,7 +57,7 @@ def supports_fulltext_query_expansion(self) -> bool: """Whether query expansion mode is supported (ClickHouse 5.6.7+).""" ... - def format_match_against(self, expr) -> Tuple[str, tuple]: + def format_match_against_expression(self, expr) -> Tuple[str, tuple]: """Format MATCH ... AGAINST expression.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/json.py b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/json.py index f888195..91ea9c4 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/json.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/json.py @@ -52,42 +52,42 @@ def supports_json_function(self, function_name: str) -> bool: """ ... - def format_json_extract(self, expr) -> Tuple[str, tuple]: + def format_json_extract_expression(self, expr) -> Tuple[str, tuple]: """Format JSON_EXTRACT function call.""" ... - def format_json_unquote(self, expr) -> Tuple[str, tuple]: + def format_json_unquote_expression(self, expr) -> Tuple[str, tuple]: """Format JSON_UNQUOTE function call.""" ... - def format_json_object(self, expr) -> Tuple[str, tuple]: + def format_json_object_expression(self, expr) -> Tuple[str, tuple]: """Format JSON_OBJECT function call.""" ... - def format_json_array(self, expr) -> Tuple[str, tuple]: + def format_json_array_expression(self, expr) -> Tuple[str, tuple]: """Format JSON_ARRAY function call.""" ... - def format_json_contains(self, expr) -> Tuple[str, tuple]: + def format_json_contains_expression(self, expr) -> Tuple[str, tuple]: """Format JSON_CONTAINS function call.""" ... - def format_json_set(self, expr) -> Tuple[str, tuple]: + def format_json_set_expression(self, expr) -> Tuple[str, tuple]: """Format JSON_SET function call.""" ... - def format_json_remove(self, expr) -> Tuple[str, tuple]: + def format_json_remove_expression(self, expr) -> Tuple[str, tuple]: """Format JSON_REMOVE function call.""" ... - def format_json_type(self, expr) -> Tuple[str, tuple]: + def format_json_type_expression(self, expr) -> Tuple[str, tuple]: """Format JSON_TYPE function call.""" ... - def format_json_valid(self, expr) -> Tuple[str, tuple]: + def format_json_valid_expression(self, expr) -> Tuple[str, tuple]: """Format JSON_VALID function call.""" ... - def format_json_search(self, expr) -> Tuple[str, tuple]: + def format_json_search_expression(self, expr) -> Tuple[str, tuple]: """Format JSON_SEARCH function call.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/json_duality_view.py b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/json_duality_view.py index dbde6d5..6f58e89 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/json_duality_view.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/json_duality_view.py @@ -9,49 +9,47 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.impl.clickhouse.expression.json_duality_view import ( - CreateJsonDualityViewExpression, - DropJsonDualityViewExpression, + ClickHouseCreateJsonDualityViewStatement, + ClickHouseDropJsonDualityViewStatement, ) @runtime_checkable class ClickHouseJsonDualityViewSupport(Protocol): - """ClickHouse JSON Duality View protocol (ClickHouse 9.7+). - - Feature Source: ClickHouse 9.7.0 (2026-04-21) - - JSON Duality Views provide a document-relational duality layer: - - CREATE JSON RELATIONAL DUALITY VIEW with JSON_DUALITY_OBJECT - - WITH(INSERT,UPDATE,DELETE) annotations per object level - - DML via single JSON `data` column - - Optimistic locking via _metadata.etag on UPDATE - - Official Documentation: - - JSON Duality Views: https://dev.clickhouse.com/doc/refman/9.7/en/json-duality-views.html - - Version Requirements: - - JSON Duality Views: ClickHouse 9.7.0+ + """ClickHouse JSON Duality View protocol -- a switch that answers no. + + The protocol exists so a caller can *ask*; the answer is always no. + ClickHouse has no duality views at any version, and there is nothing to + name a version for: ``CREATE`` enumerates the objects it can make on + refusal, and a duality view is not on the list -- the three measurements + are recorded in ``expression/json_duality_view.py``. What the server has + for reacting to a row is ``CREATE FUNCTION ... AS`` plus a MATERIALIZED + VIEW; for relational JSON, the ``JSON`` type with ``JSONExtract*``. """ def supports_json_duality_view(self) -> bool: - """Whether JSON Duality Views are supported (ClickHouse 9.7+).""" + """Whether JSON Duality Views are supported -- always ``False`` here.""" ... def supports_json_duality_view_dml(self) -> bool: - """Whether DML on JSON Duality Views is supported (ClickHouse 9.7+).""" + """Whether DML on JSON Duality Views is supported -- always ``False`` here.""" ... - def format_create_json_duality_view_statement(self, expr: "CreateJsonDualityViewExpression") -> Tuple[str, tuple]: + def format_clickhouse_create_json_duality_view_statement( + self, expr: "ClickHouseCreateJsonDualityViewStatement" + ) -> Tuple[str, tuple]: """Format CREATE JSON RELATIONAL DUALITY VIEW statement. Args: - expr: CreateJsonDualityViewExpression instance + expr: ClickHouseCreateJsonDualityViewStatement instance Returns: Tuple of (SQL string, parameters tuple) """ ... - def format_drop_json_duality_view_statement(self, expr: "DropJsonDualityViewExpression") -> Tuple[str, tuple]: + def format_clickhouse_drop_json_duality_view_statement( + self, expr: "ClickHouseDropJsonDualityViewStatement" + ) -> Tuple[str, tuple]: """Format DROP VIEW statement for a JSON Duality View.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/load_xml.py b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/load_xml.py index a6c3741..a3d9a53 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/load_xml.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/load_xml.py @@ -9,7 +9,7 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.impl.clickhouse.expression.load_xml import ( - ClickHouseLoadXMLEXpression, + ClickHouseLoadXMLStatement, ) @runtime_checkable @@ -26,6 +26,6 @@ def supports_load_xml(self) -> bool: """Whether LOAD XML is supported.""" ... - def format_load_xml_statement(self, expr: "ClickHouseLoadXMLEXpression") -> Tuple[str, tuple]: + def format_clickhouse_load_xml_statement(self, expr: "ClickHouseLoadXMLStatement") -> Tuple[str, tuple]: """Format a LOAD XML statement.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/modify_column.py b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/modify_column.py index eea28bd..cb8d41f 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/modify_column.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/modify_column.py @@ -9,8 +9,8 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - ChangeColumn, - ModifyColumn, + ChangeColumnAction as ChangeColumn, + ModifyColumnAction as ModifyColumn, ) @runtime_checkable diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/optimizer_hint.py b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/optimizer_hint.py index b5783d7..b4e2c77 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/optimizer_hint.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/optimizer_hint.py @@ -39,7 +39,7 @@ def supports_hypergraph_optimizer(self) -> bool: """Whether the hypergraph optimizer is available (ClickHouse 9.7+).""" ... - def format_optimizer_hint(self, expr: "ClickHouseOptimizerHintExpression") -> Tuple[str, tuple]: + def format_optimizer_hint_expression(self, expr: "ClickHouseOptimizerHintExpression") -> Tuple[str, tuple]: """Format optimizer hint expression. Args: diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/partition.py b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/partition.py index 3667472..e0bb3f6 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/partition.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/partition.py @@ -12,9 +12,9 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.impl.clickhouse.expression.partition import ( - ClickHouseAttachPartitionExpression, - ClickHouseDetachPartitionExpression, - ClickHouseDropPartitionExpression, + ClickHouseAttachPartitionStatement, + ClickHouseDetachPartitionStatement, + ClickHouseDropPartitionStatement, ) @@ -81,7 +81,7 @@ def supports_attach_partition(self) -> bool: ... def format_drop_partition_statement( - self, expr: "ClickHouseDropPartitionExpression" + self, expr: "ClickHouseDropPartitionStatement" ) -> Tuple[str, tuple]: """Format ``ALTER TABLE ... DROP PARTITION ID ''``. @@ -95,7 +95,7 @@ def format_drop_partition_statement( ... def format_detach_partition_statement( - self, expr: "ClickHouseDetachPartitionExpression" + self, expr: "ClickHouseDetachPartitionStatement" ) -> Tuple[str, tuple]: """Format ``ALTER TABLE ... DETACH PARTITION ID ''``. @@ -109,7 +109,7 @@ def format_detach_partition_statement( ... def format_attach_partition_statement( - self, expr: "ClickHouseAttachPartitionExpression" + self, expr: "ClickHouseAttachPartitionStatement" ) -> Tuple[str, tuple]: """Format ``ALTER TABLE ... ATTACH PARTITION ID ''``. diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/rename_table.py b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/rename_table.py index 73d48a4..660efab 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/rename_table.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/rename_table.py @@ -9,7 +9,7 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.impl.clickhouse.expression.rename_table import ( - ClickHouseRenameTableExpression, + ClickHouseRenameTableStatement, ) @runtime_checkable @@ -34,6 +34,6 @@ def supports_multi_table_rename(self) -> bool: """Whether multiple rename pairs in one statement are supported.""" ... - def format_rename_table_statement(self, expr: "ClickHouseRenameTableExpression") -> Tuple[str, tuple]: + def format_rename_table_statement(self, expr: "ClickHouseRenameTableStatement") -> Tuple[str, tuple]: """Format a ClickHouse RENAME TABLE statement.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/routine.py b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/routine.py index 2ddb4b6..1b3833c 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/routine.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/routine.py @@ -9,11 +9,11 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.impl.clickhouse.expression.routine import ( - ClickHouseCallExpression, - ClickHouseCreateFunctionExpression, - ClickHouseCreateProcedureExpression, - ClickHouseDropFunctionExpression, - ClickHouseDropProcedureExpression, + ClickHouseCallStatement, + ClickHouseCreateFunctionStatement, + ClickHouseCreateProcedureStatement, + ClickHouseDropFunctionStatement, + ClickHouseDropProcedureStatement, ) @runtime_checkable @@ -41,22 +41,22 @@ def supports_call(self) -> bool: """Whether CALL is supported.""" ... - def format_create_procedure_statement(self, expr: "ClickHouseCreateProcedureExpression") -> Tuple[str, tuple]: + def format_create_procedure_statement(self, expr: "ClickHouseCreateProcedureStatement") -> Tuple[str, tuple]: """Format CREATE PROCEDURE.""" ... - def format_drop_procedure_statement(self, expr: "ClickHouseDropProcedureExpression") -> Tuple[str, tuple]: + def format_drop_procedure_statement(self, expr: "ClickHouseDropProcedureStatement") -> Tuple[str, tuple]: """Format DROP PROCEDURE.""" ... - def format_create_function_statement(self, expr: "ClickHouseCreateFunctionExpression") -> Tuple[str, tuple]: + def format_create_function_statement(self, expr: "ClickHouseCreateFunctionStatement") -> Tuple[str, tuple]: """Format CREATE FUNCTION (stored function).""" ... - def format_drop_function_statement(self, expr: "ClickHouseDropFunctionExpression") -> Tuple[str, tuple]: + def format_drop_function_statement(self, expr: "ClickHouseDropFunctionStatement") -> Tuple[str, tuple]: """Format DROP FUNCTION.""" ... - def format_call_statement(self, expr: "ClickHouseCallExpression") -> Tuple[str, tuple]: + def format_call_statement(self, expr: "ClickHouseCallStatement") -> Tuple[str, tuple]: """Format CALL.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/spatial.py b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/spatial.py index 9f1c22e..27c3428 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/spatial.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/spatial.py @@ -65,38 +65,38 @@ def supports_geometry_collection_type(self) -> bool: """Whether GEOMETRYCOLLECTION is supported.""" ... - def format_spatial_literal(self, expr) -> Tuple[str, tuple]: + def format_spatial_literal_expression(self, expr) -> Tuple[str, tuple]: """Format spatial literal from WKT.""" ... - def format_st_geom_from_text(self, expr) -> Tuple[str, tuple]: + def format_st_geom_from_text_expression(self, expr) -> Tuple[str, tuple]: """Format ST_GeomFromText function call.""" ... - def format_st_geom_from_wkb(self, expr) -> Tuple[str, tuple]: + def format_st_geom_from_wkb_expression(self, expr) -> Tuple[str, tuple]: """Format ST_GeomFromWKB function call.""" ... - def format_st_as_text(self, expr) -> Tuple[str, tuple]: + def format_st_as_text_expression(self, expr) -> Tuple[str, tuple]: """Format ST_AsText function call.""" ... - def format_st_as_geojson(self, expr) -> Tuple[str, tuple]: + def format_st_as_geo_json_expression(self, expr) -> Tuple[str, tuple]: """Format ST_AsGeoJSON function call.""" ... - def format_st_distance(self, expr) -> Tuple[str, tuple]: + def format_st_distance_expression(self, expr) -> Tuple[str, tuple]: """Format ST_Distance function call.""" ... - def format_st_within(self, expr) -> Tuple[str, tuple]: + def format_st_within_expression(self, expr) -> Tuple[str, tuple]: """Format ST_Within function call.""" ... - def format_st_contains(self, expr) -> Tuple[str, tuple]: + def format_st_contains_expression(self, expr) -> Tuple[str, tuple]: """Format ST_Contains function call.""" ... - def format_create_spatial_index(self, expr) -> Tuple[str, tuple]: + def format_create_spatial_index_statement(self, expr) -> Tuple[str, tuple]: """Format CREATE SPATIAL INDEX statement.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/table.py b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/table.py index 1e37697..c920733 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/table.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/table.py @@ -20,7 +20,7 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableLikeExpression, + CreateTableLikeStatement, ) @@ -62,6 +62,16 @@ class ClickHouseTableSupport( - Various storage engines: ClickHouse 5.5+ """ + def supports_create_table_as_bind_parameters(self) -> bool: + """Whether this statement's body may carry bind parameters. + + Defaults to ``False``: a DDL statement carries no bind parameters + unless the dialect says it can, so a dialect that does not answer + refuses rather than emitting a statement the engine may reject. A + dialect whose engine accepts them returns ``True``. + """ + ... # pragma: no cover + def supports_inline_index(self) -> bool: """Whether an inline data-skipping INDEX may appear in CREATE TABLE.""" ... @@ -81,7 +91,7 @@ def supports_charset_option(self) -> bool: ... def format_create_table_like_statement( - self, expr: "CreateTableLikeExpression" + self, expr: "CreateTableLikeStatement" ) -> Tuple[str, tuple]: """Format the ClickHouse ``CREATE TABLE ... AS `` structure copy. @@ -107,8 +117,20 @@ def format_index_definition(self, idx_def: Any) -> Tuple[str, tuple]: """Format an inline data-skipping INDEX definition within CREATE TABLE.""" ... - def format_storage_options(self, expr: Any) -> Tuple[str, tuple]: - """Format ClickHouse table storage options (ENGINE, CHARSET, etc.).""" + def format_clickhouse_create_table_options(self, expr: Any) -> Tuple[str, tuple]: + """Format the ClickHouse ``CREATE`` header qualifier (``OR REPLACE``).""" + ... + + def format_clickhouse_engine_spec(self, expr: Any) -> Tuple[str, tuple]: + """Format an engine name and its parameters (``ReplacingMergeTree(version)``).""" + ... + + def format_clickhouse_setting(self, expr: Any) -> Tuple[str, tuple]: + """Format one ``name = value`` pair of a ``SETTINGS`` clause.""" + ... + + def format_clickhouse_table_storage_option(self, expr: Any) -> Tuple[str, tuple]: + """Format one ClickHouse table storage clause (``ENGINE``, ``ORDER BY``, …).""" ... def supports_ilike(self) -> bool: diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/table_statement.py b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/table_statement.py index 1ecd839..5a1cc1c 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/table_statement.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/table_statement.py @@ -9,8 +9,8 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.impl.clickhouse.expression.table_statement import ( - ClickHouseTableExpression, - ClickHouseValuesExpression, + ClickHouseTableStatement, + ClickHouseValuesStatement, ) @runtime_checkable @@ -32,10 +32,10 @@ def supports_values_table_constructor(self) -> bool: """Whether VALUES as a table value constructor is supported.""" ... - def format_table_statement(self, expr: "ClickHouseTableExpression") -> Tuple[str, tuple]: + def format_table_statement(self, expr: "ClickHouseTableStatement") -> Tuple[str, tuple]: """Format a TABLE statement.""" ... - def format_values_statement(self, expr: "ClickHouseValuesExpression") -> Tuple[str, tuple]: + def format_values_statement(self, expr: "ClickHouseValuesStatement") -> Tuple[str, tuple]: """Format a VALUES table value constructor.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/trigger.py b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/trigger.py index ae633a0..515475a 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/trigger.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/protocols/trigger.py @@ -9,8 +9,8 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.expression.statements.ddl_trigger import ( - CreateTriggerExpression, - DropTriggerExpression, + CreateTriggerStatement, + DropTriggerStatement, ) @runtime_checkable @@ -72,22 +72,22 @@ def supports_trigger_when(self) -> bool: """ ... - def format_create_trigger_statement(self, expr: "CreateTriggerExpression") -> Tuple[str, tuple]: + def format_create_trigger_statement(self, expr: "CreateTriggerStatement") -> Tuple[str, tuple]: """Format CREATE TRIGGER statement. Args: - expr: CreateTriggerExpression instance + expr: CreateTriggerStatement instance Returns: Tuple of (SQL string, parameters tuple) """ ... - def format_drop_trigger_statement(self, expr: "DropTriggerExpression") -> Tuple[str, tuple]: + def format_drop_trigger_statement(self, expr: "DropTriggerStatement") -> Tuple[str, tuple]: """Format DROP TRIGGER statement. Args: - expr: DropTriggerExpression instance + expr: DropTriggerStatement instance Returns: Tuple of (SQL string, parameters tuple) diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/show/__init__.py b/src/rhosocial/activerecord/backend/impl/clickhouse/show/__init__.py index ff6f7a6..f3b0934 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/show/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/show/__init__.py @@ -22,25 +22,25 @@ from ..expression.show import ( ShowExpression, - ShowCreateTableExpression, - ShowCreateViewExpression, - ShowColumnsExpression, - ShowIndexExpression, - ShowTablesExpression, - ShowDatabasesExpression, - ShowTableStatusExpression, - ShowTriggersExpression, - ShowCreateTriggerExpression, - ShowVariablesExpression, - ShowStatusExpression, - ShowProcessListExpression, - ShowWarningsExpression, - ShowErrorsExpression, - ShowEnginesExpression, - ShowCharsetExpression, - ShowCollationExpression, - ShowGrantsExpression, - ShowPluginsExpression, + ShowCreateTableStatement, + ShowCreateViewStatement, + ShowColumnsStatement, + ShowIndexStatement, + ShowTablesStatement, + ShowDatabasesStatement, + ShowTableStatusStatement, + ShowTriggersStatement, + ShowCreateTriggerStatement, + ShowVariablesStatement, + ShowStatusStatement, + ShowProcessListStatement, + ShowWarningsStatement, + ShowErrorsStatement, + ShowEnginesStatement, + ShowCharsetStatement, + ShowCollationStatement, + ShowGrantsStatement, + ShowPluginsStatement, ) from .dialect import ClickHouseShowDialectMixin from .functionality import ClickHouseShowFunctionality @@ -49,25 +49,25 @@ __all__ = [ # Expression classes "ShowExpression", - "ShowCreateTableExpression", - "ShowCreateViewExpression", - "ShowColumnsExpression", - "ShowIndexExpression", - "ShowTablesExpression", - "ShowDatabasesExpression", - "ShowTableStatusExpression", - "ShowTriggersExpression", - "ShowCreateTriggerExpression", - "ShowVariablesExpression", - "ShowStatusExpression", - "ShowProcessListExpression", - "ShowWarningsExpression", - "ShowErrorsExpression", - "ShowEnginesExpression", - "ShowCharsetExpression", - "ShowCollationExpression", - "ShowGrantsExpression", - "ShowPluginsExpression", + "ShowCreateTableStatement", + "ShowCreateViewStatement", + "ShowColumnsStatement", + "ShowIndexStatement", + "ShowTablesStatement", + "ShowDatabasesStatement", + "ShowTableStatusStatement", + "ShowTriggersStatement", + "ShowCreateTriggerStatement", + "ShowVariablesStatement", + "ShowStatusStatement", + "ShowProcessListStatement", + "ShowWarningsStatement", + "ShowErrorsStatement", + "ShowEnginesStatement", + "ShowCharsetStatement", + "ShowCollationStatement", + "ShowGrantsStatement", + "ShowPluginsStatement", # Dialect mixin "ClickHouseShowDialectMixin", # Functionality classes diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/show/dialect.py b/src/rhosocial/activerecord/backend/impl/clickhouse/show/dialect.py index cd0790a..f3dd2cd 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/show/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/show/dialect.py @@ -8,8 +8,7 @@ The mixin is added to ClickHouseDialect to provide SHOW command support. All methods follow the pattern: - Accept an expression parameter -- Extract parameters from expression.get_params() -- Generate SQL string and parameter tuple +- Read the node's declared fields and render them - Return (sql, params) tuple Nine of these are ClickHouse statements and produce SQL: @@ -42,29 +41,28 @@ from typing import Optional, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from rhosocial.activerecord.backend.expression.objects import Table, View if TYPE_CHECKING: from ..expression.show import ( - ShowCreateTableExpression, - ShowCreateViewExpression, - ShowColumnsExpression, - ShowIndexExpression, - ShowTablesExpression, - ShowDatabasesExpression, - ShowTableStatusExpression, - ShowTriggersExpression, - ShowCreateTriggerExpression, - ShowVariablesExpression, - ShowStatusExpression, - ShowProcessListExpression, - ShowWarningsExpression, - ShowErrorsExpression, - ShowEnginesExpression, - ShowCharsetExpression, - ShowCollationExpression, - ShowGrantsExpression, - ShowPluginsExpression, + ShowCreateTableStatement, + ShowCreateViewStatement, + ShowColumnsStatement, + ShowIndexStatement, + ShowTablesStatement, + ShowDatabasesStatement, + ShowTableStatusStatement, + ShowTriggersStatement, + ShowCreateTriggerStatement, + ShowVariablesStatement, + ShowStatusStatement, + ShowProcessListStatement, + ShowWarningsStatement, + ShowErrorsStatement, + ShowEnginesStatement, + ShowCharsetStatement, + ShowCollationStatement, + ShowGrantsStatement, + ShowPluginsStatement, ) @@ -97,28 +95,26 @@ def _qualified_table(self, table: str, schema: Optional[str] = None) -> str: # ========== SHOW CREATE Statements ========== - def format_show_create_table(self, expr: "ShowCreateTableExpression") -> Tuple[str, tuple]: + def format_show_create_table_statement(self, expr: "ShowCreateTableStatement") -> Tuple[str, tuple]: """Format SHOW CREATE TABLE statement. The name goes through ``format_table_object``, so the ``schema`` slot -- a ClickHouse database -- becomes the object's ``catalog_name`` and is rendered by the same code that renders every other qualified name. """ - params = expr.get_params() - table = Table( - self, params["table"], catalog_name=params.get("schema") - ).to_sql()[0] + from rhosocial.activerecord.backend.expression.objects import Table + + table = Table(self, expr.table, catalog_name=expr.schema).to_sql()[0] return f"SHOW CREATE TABLE {table}", () - def format_show_create_view(self, expr: "ShowCreateViewExpression") -> Tuple[str, tuple]: + def format_show_create_view_statement(self, expr: "ShowCreateViewStatement") -> Tuple[str, tuple]: """Format SHOW CREATE VIEW statement.""" - params = expr.get_params() - view = View( - self, params["view_name"], catalog_name=params.get("schema") - ).to_sql()[0] + from rhosocial.activerecord.backend.expression.objects import View + + view = View(self, expr.view_name, catalog_name=expr.schema).to_sql()[0] return f"SHOW CREATE VIEW {view}", () - def format_show_create_trigger(self, expr: "ShowCreateTriggerExpression") -> Tuple[str, tuple]: + def format_show_create_trigger_statement(self, expr: "ShowCreateTriggerStatement") -> Tuple[str, tuple]: """Format SHOW CREATE TRIGGER statement. Note: @@ -132,7 +128,7 @@ def format_show_create_trigger(self, expr: "ShowCreateTriggerExpression") -> Tup # ========== SHOW COLUMNS/INDEX ========== - def format_show_columns(self, expr: "ShowColumnsExpression") -> Tuple[str, tuple]: + def format_show_columns_statement(self, expr: "ShowColumnsStatement") -> Tuple[str, tuple]: """Format SHOW [FULL] COLUMNS FROM
[LIKE ]. ClickHouse has this statement and answers it with lower-cased column @@ -145,26 +141,20 @@ def format_show_columns(self, expr: "ShowColumnsExpression") -> Tuple[str, tuple ``EXTENDED`` is also accepted and documented but has no effect, so it is not rendered. """ - params = expr.get_params() - table = params["table"] - schema = params.get("schema") - full = params.get("full", False) - like_pattern = params.get("like_pattern") - parts = ["SHOW"] - if full: + if expr.full: parts.append("FULL") parts.append("COLUMNS FROM") - parts.append(self._qualified_table(table, schema)) + parts.append(self._qualified_table(expr.table, expr.schema)) - sql_params = () - if like_pattern: + sql_params: tuple = () + if expr.like_pattern: parts.append(f"LIKE {self.p()}") - sql_params = (like_pattern,) + sql_params = (expr.like_pattern,) return " ".join(parts), sql_params - def format_show_index(self, expr: "ShowIndexExpression") -> Tuple[str, tuple]: + def format_show_index_statement(self, expr: "ShowIndexStatement") -> Tuple[str, tuple]: """Format SHOW INDEX FROM
. ClickHouse has this statement, explicitly "for compatibility with MySQL", @@ -173,44 +163,33 @@ def format_show_index(self, expr: "ShowIndexExpression") -> Tuple[str, tuple]: ``PRIMARY`` / ``MINMAX`` / ``BLOOM_FILTER`` / ``SET`` / ... rather than ``BTREE``. See ``https://clickhouse.com/docs/reference/statements/show``. """ - params = expr.get_params() - table = params["table"] - schema = params.get("schema") - return f"SHOW INDEX FROM {self._qualified_table(table, schema)}", () + return f"SHOW INDEX FROM {self._qualified_table(expr.table, expr.schema)}", () # ========== SHOW TABLES/DATABASES ========== - def format_show_tables(self, expr: "ShowTablesExpression") -> Tuple[str, tuple]: + def format_show_tables_statement(self, expr: "ShowTablesStatement") -> Tuple[str, tuple]: """Format SHOW [FULL] TABLES statement.""" - params = expr.get_params() - schema = params.get("schema") - full = params.get("full", False) - like_pattern = params.get("like_pattern") - parts = ["SHOW"] - if full: + if expr.full: parts.append("FULL") parts.append("TABLES") - if schema: - parts.append(f"FROM {self.format_identifier(schema)}") + if expr.schema: + parts.append(f"FROM {self.format_identifier(expr.schema)}") - sql_params = () - if like_pattern: + sql_params: tuple = () + if expr.like_pattern: parts.append(f"LIKE {self.p()}") - sql_params = (like_pattern,) + sql_params = (expr.like_pattern,) return " ".join(parts), sql_params - def format_show_databases(self, expr: "ShowDatabasesExpression") -> Tuple[str, tuple]: + def format_show_databases_statement(self, expr: "ShowDatabasesStatement") -> Tuple[str, tuple]: """Format SHOW DATABASES statement.""" - params = expr.get_params() - like_pattern = params.get("like_pattern") - - if like_pattern: - return f"SHOW DATABASES LIKE {self.p()}", (like_pattern,) + if expr.like_pattern: + return f"SHOW DATABASES LIKE {self.p()}", (expr.like_pattern,) return "SHOW DATABASES", () - def format_show_table_status(self, expr: "ShowTableStatusExpression") -> Tuple[str, tuple]: + def format_show_table_status_statement(self, expr: "ShowTableStatusStatement") -> Tuple[str, tuple]: """Format SHOW TABLE STATUS statement. Note: @@ -224,7 +203,7 @@ def format_show_table_status(self, expr: "ShowTableStatusExpression") -> Tuple[s # ========== SHOW TRIGGERS ========== - def format_show_triggers(self, expr: "ShowTriggersExpression") -> Tuple[str, tuple]: + def format_show_triggers_statement(self, expr: "ShowTriggersStatement") -> Tuple[str, tuple]: """Format SHOW TRIGGERS statement. Note: @@ -238,7 +217,7 @@ def format_show_triggers(self, expr: "ShowTriggersExpression") -> Tuple[str, tup # ========== SHOW VARIABLES/STATUS ========== - def format_show_variables(self, expr: "ShowVariablesExpression") -> Tuple[str, tuple]: + def format_show_variables_statement(self, expr: "ShowVariablesStatement") -> Tuple[str, tuple]: """Format SHOW VARIABLES statement. Note: @@ -250,7 +229,7 @@ def format_show_variables(self, expr: "ShowVariablesExpression") -> Tuple[str, t suggestion="Query system.settings instead.", ) - def format_show_status(self, expr: "ShowStatusExpression") -> Tuple[str, tuple]: + def format_show_status_statement(self, expr: "ShowStatusStatement") -> Tuple[str, tuple]: """Format SHOW STATUS statement. Note: @@ -264,7 +243,7 @@ def format_show_status(self, expr: "ShowStatusExpression") -> Tuple[str, tuple]: # ========== SHOW PROCESSLIST/WARNINGS/ERRORS ========== - def format_show_processlist(self, expr: "ShowProcessListExpression") -> Tuple[str, tuple]: + def format_show_process_list_statement(self, expr: "ShowProcessListStatement") -> Tuple[str, tuple]: """Format SHOW PROCESSLIST. ClickHouse's ``SHOW PROCESSLIST`` is ``SELECT * FROM system.processes``: @@ -279,7 +258,7 @@ def format_show_processlist(self, expr: "ShowProcessListExpression") -> Tuple[st """ return "SHOW PROCESSLIST", () - def format_show_warnings(self, expr: "ShowWarningsExpression") -> Tuple[str, tuple]: + def format_show_warnings_statement(self, expr: "ShowWarningsStatement") -> Tuple[str, tuple]: """Format SHOW WARNINGS statement. Note: @@ -291,7 +270,7 @@ def format_show_warnings(self, expr: "ShowWarningsExpression") -> Tuple[str, tup suggestion="Query system.query_log or system.text_log instead.", ) - def format_show_errors(self, expr: "ShowErrorsExpression") -> Tuple[str, tuple]: + def format_show_errors_statement(self, expr: "ShowErrorsStatement") -> Tuple[str, tuple]: """Format SHOW ERRORS statement. Note: @@ -305,7 +284,7 @@ def format_show_errors(self, expr: "ShowErrorsExpression") -> Tuple[str, tuple]: # ========== SHOW ENGINES/CHARSET/COLLATION ========== - def format_show_engines(self, expr: "ShowEnginesExpression") -> Tuple[str, tuple]: + def format_show_engines_statement(self, expr: "ShowEnginesStatement") -> Tuple[str, tuple]: """Format SHOW ENGINES. ClickHouse's ``SHOW ENGINES`` is ``SELECT * FROM system.table_engines``. @@ -320,7 +299,7 @@ def format_show_engines(self, expr: "ShowEnginesExpression") -> Tuple[str, tuple """ return "SHOW ENGINES", () - def format_show_charset(self, expr: "ShowCharsetExpression") -> Tuple[str, tuple]: + def format_show_charset_statement(self, expr: "ShowCharsetStatement") -> Tuple[str, tuple]: """Format SHOW CHARACTER SET statement. Note: @@ -332,7 +311,7 @@ def format_show_charset(self, expr: "ShowCharsetExpression") -> Tuple[str, tuple suggestion="ClickHouse does not support MySQL character sets; query system.character_sets instead.", ) - def format_show_collation(self, expr: "ShowCollationExpression") -> Tuple[str, tuple]: + def format_show_collation_statement(self, expr: "ShowCollationStatement") -> Tuple[str, tuple]: """Format SHOW COLLATION statement. Note: @@ -346,7 +325,7 @@ def format_show_collation(self, expr: "ShowCollationExpression") -> Tuple[str, t # ========== SHOW GRANTS/PLUGINS ========== - def format_show_grants(self, expr: "ShowGrantsExpression") -> Tuple[str, tuple]: + def format_show_grants_statement(self, expr: "ShowGrantsStatement") -> Tuple[str, tuple]: """Format SHOW GRANTS [FOR ]. ClickHouse's grammar is ``SHOW GRANTS [FOR user1 [, user2 ...]] [WITH @@ -359,13 +338,11 @@ def format_show_grants(self, expr: "ShowGrantsExpression") -> Tuple[str, tuple]: statement. ``SHOW GRANTS FOR CURRENT USER`` is also a ``SYNTAX_ERROR`` here; omitting ``FOR`` is how the current user's grants are read. """ - params = expr.get_params() - user = params.get("user") - if not user: + if not expr.user: return "SHOW GRANTS", () - return f"SHOW GRANTS FOR {self.format_identifier(user)}", () + return f"SHOW GRANTS FOR {self.format_identifier(expr.user)}", () - def format_show_plugins(self, expr: "ShowPluginsExpression") -> Tuple[str, tuple]: + def format_show_plugins_statement(self, expr: "ShowPluginsStatement") -> Tuple[str, tuple]: """Format SHOW PLUGINS statement. Note: diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/show/functionality.py b/src/rhosocial/activerecord/backend/impl/clickhouse/show/functionality.py index ae933bf..2e311cf 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/show/functionality.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/show/functionality.py @@ -51,15 +51,15 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from ..expression.show import ( - ShowColumnsExpression, - ShowCreateTableExpression, - ShowCreateViewExpression, - ShowDatabasesExpression, - ShowEnginesExpression, - ShowGrantsExpression, - ShowIndexExpression, - ShowProcessListExpression, - ShowTablesExpression, + ShowColumnsStatement, + ShowCreateTableStatement, + ShowCreateViewStatement, + ShowDatabasesStatement, + ShowEnginesStatement, + ShowGrantsStatement, + ShowIndexStatement, + ShowProcessListStatement, + ShowTablesStatement, ) if TYPE_CHECKING: @@ -109,20 +109,20 @@ def _show_parser(): def _parse_create_table_result(self, result, table: str): """Parse ``SHOW CREATE TABLE`` — a single ``statement`` column.""" - return self._show_parser()._parse_create_table(result.data, table) + return self._show_parser().parse_create_table(result.data, table) def _parse_create_view_result(self, result, view_name: str): """Parse ``SHOW CREATE VIEW`` — a single ``statement`` column, as above.""" - return self._show_parser()._parse_create_view(result.data, view_name) + return self._show_parser().parse_create_view(result.data, view_name) def _parse_columns_result(self, result): """Parse ``SHOW [FULL] COLUMNS`` — ``field`` / ``type`` / ``null`` / ``key`` / ``default`` / ``extra``, plus the three ``FULL`` columns.""" - return self._show_parser()._parse_columns(result.data) + return self._show_parser().parse_columns(result.data) def _parse_indexes_result(self, result): """Parse ``SHOW INDEX`` — ``table`` / ``key_name`` / ``pk_col`` / ...""" - return self._show_parser()._parse_indexes(result.data) + return self._show_parser().parse_indexes(result.data) def _parse_tables_result(self, result): """Parse ``SHOW [FULL] TABLES`` — columns ``name`` and, under ``FULL``, ``engine``. @@ -238,7 +238,7 @@ def _parse_status_result(self, result): def _parse_processlist_result(self, result): """Parse ``SHOW PROCESSLIST`` — ``system.processes``' own columns.""" - return self._show_parser()._parse_processlist(result.data) + return self._show_parser().parse_processlist(result.data) def _parse_warnings_result(self, result): """Parse SHOW WARNINGS result.""" @@ -268,7 +268,7 @@ def _parse_errors_result(self, result): def _parse_engines_result(self, result): """Parse ``SHOW ENGINES`` — ``name`` plus the eight ``supports_*`` flags.""" - return self._show_parser()._parse_engines(result.data) + return self._show_parser().parse_engines(result.data) def _parse_charset_result(self, result): """Parse SHOW CHARACTER SET result.""" @@ -302,7 +302,7 @@ def _parse_collation_result(self, result): def _parse_grants_result(self, result): """Parse ``SHOW GRANTS`` — one column, named after the statement text.""" - return self._show_parser()._parse_grants(result.data) + return self._show_parser().parse_grants(result.data) def _parse_plugins_result(self, result): """Parse SHOW PLUGINS result.""" @@ -334,9 +334,7 @@ def create_table(self, table: str, schema: Optional[str] = None): ShowCreateTableResult with table name and CREATE statement, or None if table doesn't exist. """ - expr = ShowCreateTableExpression(self.dialect, table) - if schema: - expr.schema(schema) + expr = ShowCreateTableStatement(self.dialect, table, schema=schema) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_create_table_result(result, table) @@ -353,9 +351,7 @@ def create_view(self, view_name: str, schema: Optional[str] = None): Returns: ShowCreateViewResult with view details, or None if view doesn't exist. """ - expr = ShowCreateViewExpression(self.dialect, view_name) - if schema: - expr.schema(schema) + expr = ShowCreateViewStatement(self.dialect, view_name, schema=schema) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_create_view_result(result, view_name) @@ -375,13 +371,9 @@ def columns( and reads ClickHouse's own output columns. See ``https://clickhouse.com/docs/reference/statements/show``. """ - expr = ShowColumnsExpression(self.dialect, table) - if schema: - expr.schema(schema) - if full: - expr.full() - if like: - expr.like(like) + expr = ShowColumnsStatement( + self.dialect, table, full=full, like_pattern=like, schema=schema + ) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_columns_result(result) @@ -395,9 +387,7 @@ def indexes(self, table: str, schema: Optional[str] = None): output columns — the primary key columns plus one row per data skipping index. See ``https://clickhouse.com/docs/reference/statements/show``. """ - expr = ShowIndexExpression(self.dialect, table) - if schema: - expr.schema(schema) + expr = ShowIndexStatement(self.dialect, table, schema=schema) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_indexes_result(result) @@ -420,13 +410,9 @@ def tables( Returns: List of ShowTableResult objects. """ - expr = ShowTablesExpression(self.dialect) - if schema: - expr.schema(schema) - if like: - expr.like(like) - if full: - expr.full() + expr = ShowTablesStatement( + self.dialect, schema=schema, like_pattern=like, full=full + ) sql, params = expr.to_sql() result = self._backend.execute(sql, params) @@ -443,9 +429,7 @@ def databases(self, like: Optional[str] = None): Returns: List of ShowDatabaseResult objects. """ - expr = ShowDatabasesExpression(self.dialect) - if like: - expr.like(like) + expr = ShowDatabasesStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() result = self._backend.execute(sql, params) @@ -529,9 +513,7 @@ def processlist(self, full: bool = False): symmetry and ignored: the statement has no ``FULL`` variant and already reports the full query text in ``query``. """ - expr = ShowProcessListExpression(self.dialect) - if full: - expr.full() + expr = ShowProcessListStatement(self.dialect, full=full) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_processlist_result(result) @@ -572,7 +554,7 @@ def engines(self): the eight ``supports_*`` capability flags and the engine's own ``description`` / ``syntax`` / ``introduced_in`` documentation columns. """ - expr = ShowEnginesExpression(self.dialect) + expr = ShowEnginesStatement(self.dialect) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_engines_result(result) @@ -620,9 +602,7 @@ def grants(self, user: Optional[str] = None, host: Optional[str] = None): The statement returns a single column whose name follows the output format, so the parser reads the column's value rather than a named key. """ - expr = ShowGrantsExpression(self.dialect) - if user: - expr.for_user(user, host) + expr = ShowGrantsStatement(self.dialect, user=user, host=host) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_grants_result(result) diff --git a/src/rhosocial/activerecord/backend/impl/clickhouse/show/types.py b/src/rhosocial/activerecord/backend/impl/clickhouse/show/types.py index d146555..f6da0e1 100644 --- a/src/rhosocial/activerecord/backend/impl/clickhouse/show/types.py +++ b/src/rhosocial/activerecord/backend/impl/clickhouse/show/types.py @@ -101,7 +101,7 @@ class ShowCreateTriggerResult: NOTE: MySQL-only command, not supported by ClickHouse. ``SHOW CREATE TRIGGER`` is a ``SYNTAX_ERROR`` on 26.7.3.19 and the server has no triggers at all; - ``format_show_create_trigger`` refuses with "ClickHouse does not support + ``format_show_create_trigger_statement`` refuses with "ClickHouse does not support triggers". A SQL UDF (``CREATE FUNCTION ... AS``) invoked from the application is the ClickHouse equivalent. @@ -174,7 +174,7 @@ class ShowTableStatusResult: NOTE: MySQL-only command, not supported by ClickHouse: ``SHOW TABLE STATUS`` is the one MySQL ``SHOW`` the server answers with ``UNKNOWN_TABLE``-style - failure rather than a bare parse error on 26.7.3.19. ``format_show_table_status`` + failure rather than a bare parse error on 26.7.3.19. ``format_show_table_status_statement`` refuses and points at ``system.tables``. Contains extensive table metadata. @@ -322,7 +322,7 @@ class ShowTriggerResult: ClickHouse has no triggers at all: ``SHOW TRIGGERS`` is a ``SYNTAX_ERROR`` on 26.7.3.19 and the server's own grammar after ``SHOW`` offers no - ``TRIGGERS``. The statement is not emitted — ``format_show_triggers`` + ``TRIGGERS``. The statement is not emitted — ``format_show_triggers_statement`` raises ``UnsupportedFeatureError`` with "ClickHouse does not support triggers" — so the refusal stays the negotiation point for a caller moving between backends. ``CREATE FUNCTION ... AS`` (an SQL UDF) plus a MATERIALIZED diff --git a/tests/plugins/expression_xdist.py b/tests/plugins/expression_xdist.py new file mode 100644 index 0000000..f2aa752 --- /dev/null +++ b/tests/plugins/expression_xdist.py @@ -0,0 +1,142 @@ +# tests/plugins/expression_xdist.py +"""Default the expression test area to parallel when it is the only target. + +The org rules (expression-dialect §12.1) make pytest-xdist parallel the +default configuration of the expression test directory. The worker count, +however, is read by pytest-xdist before any conftest loads -- a conftest +cannot inject ``-n`` -- so the default lives in this repository-local +plugin, registered from ``pyproject.toml`` with ``-p +plugins.expression_xdist`` and therefore active for every run in this +repository. + +The plugin is deliberately narrow in what it touches: + +* **Availability**: with pytest-xdist not installed it enables nothing -- + installation is not this plugin's business, and the run proceeds serially + the way it always has. +* **Scope**: only when every non-option argument names a path inside the + expression area (``feature/backend/expression``) is ``-n auto`` appended. + A whole-repository run, a mixed run, or a run of any other directory keeps + the serial default; the live scenario tests have their own parallel + runner (``--scenario-parallel``) and are not dragged into a worker pool + they did not ask for. +* **Choice respected**: an explicit ``-n`` / ``--numprocesses`` is never + overridden. An explicit ``--dist`` is never overridden either, except + that ``loadscope`` and ``loadfile`` are refused outright (§12.1: they + bind whole modules together and hide case-to-case dependencies). + ``-n`` without ``--dist`` gets ``--dist=loadgroup`` appended, because + loadgroup is the one scheduling the rules allow. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +#: The area this plugin defaults to parallel. Relative to the repository +#: root, which is where ``pyproject.toml`` -- and therefore this plugin's +#: registration -- lives. +_EXPRESSION_AREA = os.path.join( + "tests", + "rhosocial", + "activerecord_clickhouse_test", + "feature", + "backend", + "expression", +) + +#: Options that take a separate value, so the value must not be mistaken +#: for a path argument when scoping the invocation. ``-p`` matters most: +#: this plugin is itself registered as ``-p plugins.expression_xdist``, and +#: its own name must not be read as a test target. +_VALUE_TAKING = frozenset( + { + "-p", "-k", "-m", "-o", "-r", "-W", "--tb", "--deselect", "--basetemp", + "--maxfail", "--rootdir", "--import-mode", "--pdbcls", "--lf", + "--lfnf", "--cov", "--cov-config", "--cov-report", + "--capture", "--color", "--junitxml", "--result-log", + "-n", "--numprocesses", "--dist", + } +) + +_FORBIDDEN_DISTS = ("loadscope", "loadfile") + + +def _looks_like_path(arg: str) -> bool: + """Whether *arg* names a test target rather than an option or a value.""" + return not arg.startswith("-") + + +def _expression_area_root() -> Path: + """The absolute expression-area directory for this repository. + + This module is imported from ``tests/plugins``, two levels below the + repository root. + """ + return Path(__file__).resolve().parent.parent.parent / _EXPRESSION_AREA + + +def _inside_expression_area(arg: str) -> bool: + path = Path(os.path.abspath(arg)) + return path == _expression_area_root() or _expression_area_root() in path.parents + + +def pytest_load_initial_conftests(args, early_config, parser): + """Append the parallel default when the run is expression-only.""" + if not _xdist_available(): + print( + "[expression_xdist] pytest-xdist is not installed; the " + "expression area runs serially (install it to get the " + "parallel default the test rules ask for)" + ) + return + + targets = [] + skip_next = False + for arg in args: + if skip_next: + skip_next = False + continue + if arg in _VALUE_TAKING: + skip_next = True + continue + if arg.startswith("--") and "=" in arg: + name = arg.split("=", 1)[0] + if name in _VALUE_TAKING: + continue + continue + if _looks_like_path(arg): + targets.append(arg) + + if not targets or not all(_inside_expression_area(t) for t in targets): + return + + def _option_value(flag: str): + for index, arg in enumerate(args): + if arg == flag and index + 1 < len(args): + return args[index + 1] + if arg.startswith(flag + "="): + return arg.split("=", 1)[1] + return None + + if _option_value("--dist") in _FORBIDDEN_DISTS: + raise SystemExit( + "[expression_xdist] --dist=loadscope/loadfile are not allowed " + "for the expression area (expression-dialect §12.1: they bind " + "whole modules together; use --dist=loadgroup)" + ) + + if _option_value("-n") is None and _option_value("--numprocesses") is None: + args.extend(["-n", "auto"]) + if _option_value("--dist") is None: + args.extend(["--dist", "loadgroup"]) + + +def _xdist_available() -> bool: + """The availability condition: the plugin enables nothing without xdist, + and installing it is nobody's business but the environment's.""" + try: + import xdist # noqa: F401 + except ImportError: + return False + return True diff --git a/tests/providers/basic.py b/tests/providers/basic.py index 5480c07..9a279fc 100644 --- a/tests/providers/basic.py +++ b/tests/providers/basic.py @@ -388,14 +388,14 @@ def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, tabl def _reset_table_sync(self, model_class: Type[ActiveRecord], table_name: str) -> None: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.basic import TABLE_EXPRESSIONS from providers.fixtures._common import to_clickhouse_ddl_sql options = ExecutionOptions(stmt_type=StatementType.DDL) backend = model_class.__backend__ try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend.dialect, table=Table(backend, table_name), if_exists=True, @@ -605,14 +605,14 @@ async def _setup_async_model( async def _reset_table_async(self, model_class: Type[ActiveRecord], table_name: str) -> None: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.basic import TABLE_EXPRESSIONS from providers.fixtures._common import to_clickhouse_ddl_sql options = ExecutionOptions(stmt_type=StatementType.DDL) backend = model_class.__backend__ try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend.dialect, table=Table(backend, table_name), if_exists=True, diff --git a/tests/providers/events.py b/tests/providers/events.py index 70ba8d0..7615a9a 100644 --- a/tests/providers/events.py +++ b/tests/providers/events.py @@ -116,7 +116,7 @@ def __init__(self): def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.events import TABLE_EXPRESSIONS from providers.fixtures._common import to_clickhouse_ddl_sql @@ -128,7 +128,7 @@ def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, tabl options = ExecutionOptions(stmt_type=StatementType.DDL) # ClickHouse has no FK constraints; just drop the old table. try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance, table_name), if_exists=True, @@ -184,7 +184,7 @@ async def _setup_async_model( ) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.events import TABLE_EXPRESSIONS from providers.fixtures._common import to_clickhouse_ddl_sql @@ -196,7 +196,7 @@ async def _setup_async_model( options = ExecutionOptions(stmt_type=StatementType.DDL) # ClickHouse has no FK constraints; just drop the old table. try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance, table_name), if_exists=True, diff --git a/tests/providers/fixtures/__init__.py b/tests/providers/fixtures/__init__.py index 496ff7e..9cb9f67 100644 --- a/tests/providers/fixtures/__init__.py +++ b/tests/providers/fixtures/__init__.py @@ -2,18 +2,19 @@ """ClickHouse-specific DDL expression fixtures for the testsuite providers. Each module in this package exposes a ``TABLE_EXPRESSIONS`` mapping of -table name -> factory callable ``Callable[[DialectLike, str], CreateTableExpression]``. +table name -> factory callable ``Callable[[DialectLike, str], CreateTableStatement]``. -The factory functions build :class:`CreateTableExpression` instances that emit +The factory functions build :class:`CreateTableStatement` instances that emit ClickHouse-compatible DDL (ENGINE / CHARSET / COLLATE / inline indexes / FK with ON DELETE CASCADE / TINYINT(1) / DATETIME(6) / ENUM / JSON). The pre-existing ``.sql`` schema files under ``tests/rhosocial/activerecord_clickhouse_test/feature//schema/`` remain as the authoritative reference for what the expressions here must produce; they are simply no longer read at runtime by the providers. -Because the ClickHouse backend's ``format_storage_options`` emits quoted values -(e.g. ``ENGINE='InnoDB'``) while the reference SQL files use the unquoted -form, each factory routes its expression through :func:`to_clickhouse_ddl_sql` -which post-processes the storage option clause to drop the quotes, producing -DDL equivalent to the reference ``.sql`` files. +The storage clauses are declared as expression nodes +(:func:`default_table_options` builds the clause run every table shares), so +the engine and sort-key values are rendered unquoted by the backend itself. +Each factory still routes its expression through :func:`to_clickhouse_ddl_sql`, +which drops the constraints ClickHouse rejects, producing DDL equivalent to the +reference ``.sql`` files. """ diff --git a/tests/providers/fixtures/_common.py b/tests/providers/fixtures/_common.py index 1416819..7160631 100644 --- a/tests/providers/fixtures/_common.py +++ b/tests/providers/fixtures/_common.py @@ -1,22 +1,20 @@ # tests/providers/fixtures/_common.py """Shared helpers for the ClickHouse DDL expression fixtures. -The ClickHouse backend's :meth:`format_storage_options` quotes string option -values (``ENGINE='InnoDB'``) and :meth:`format_table_constraint` does not -emit ``ON DELETE`` / ``ON UPDATE`` referential actions for -:class:`ForeignKeyConstraint` instances. The authoritative ``.sql`` schema -files under ``tests/rhosocial/activerecord_clickhouse_test/feature//schema/`` -use the unquoted storage form and inline ``FOREIGN KEY ... ON DELETE CASCADE`` -clauses. - -Rather than modifying the ClickHouse backend library source (which is out of -scope for this refactor), :func:`to_clickhouse_ddl_sql` post-processes the SQL -produced by ``CreateTableExpression.to_sql()`` so that the generated DDL -matches the reference files byte-for-byte for the bits that matter: - -* ``KEY='value'`` storage options become ``KEY=value``. -* ``FOREIGN KEY ... REFERENCES ...`` clauses have their declared - ``on_delete`` / ``on_update`` referential actions appended. +The storage clauses ClickHouse appends to ``CREATE TABLE`` are declared as +expression nodes -- one :class:`ClickHouseTableStorageOption` per clause, on a +:class:`ClickHouseCreateTableOptions` -- so the rendered DDL is already +ClickHouse spelling: unquoted engine and sort-key values, clauses in grammar +order. :func:`default_table_options` is the single place the shared clause run +is built; a fixture whose sort key differs passes ``order_by``. + +What the core renderer still emits and ClickHouse rejects is removed here: +``AUTO_INCREMENT``, ``UNIQUE`` and ``FOREIGN KEY`` -- the reference schemas +declare them and ClickHouse has no such constraints. ``ON DELETE`` / +``ON UPDATE`` referential actions are re-injected onto inline foreign keys +before the constraint columns are stripped, keeping the DDL equivalent to the +reference ``.sql`` files under +``tests/rhosocial/activerecord_clickhouse_test/feature//schema/``. Providers call :func:`create_table_sql` instead of ``expr.to_sql()`` directly to obtain the canonical ``(sql, params)`` tuple. @@ -25,17 +23,24 @@ from __future__ import annotations import re -from typing import Tuple +from typing import Sequence, Tuple from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, + Column, + CreateTableStatement, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ReferentialAction +from rhosocial.activerecord.backend.impl.clickhouse.expression import ( + ClickHouseCreateTableOptions, + ClickHouseEngineSpec, + ClickHouseSetting, + ClickHouseStorageOptionKind, + ClickHouseTableStorageOption, +) -_STORAGE_OPTION_RE = re.compile(r"([A-Z_ ]+=)'([^']*)'") _FK_RE = re.compile( r"(FOREIGN KEY \([`A-Za-z0-9_ ,]+\) REFERENCES [`A-Za-z0-9_.]+ \([`A-Za-z0-9_ ,]+\))" r"(?=[,)])" @@ -44,22 +49,48 @@ _AUTOINC_RE = re.compile(r" AUTO_INCREMENT", re.IGNORECASE) _UNIQUE_RE = re.compile(r"\s+UNIQUE", re.IGNORECASE) _FOREIGN_KEY_COL_RE = re.compile( - r",\s+FOREIGN KEY \([`A-Za-z0-9_ ,]+\) REFERENCES [`A-Za-z0-9_.]+ \([`A-Za-z0-9_ ,]+\)( ON DELETE \w+)?( ON UPDATE \w+)?", - re.IGNORECASE, -) -# Replace MySQL storage options with ClickHouse equivalents -_ENGINE_RE = re.compile( - r"ENGINE\s*=\s*InnoDB\s*(?:DEFAULT CHARSET\s*=\s*\w+(?:\s+COLLATE\s*=\s*\w+)?)?", + r",\s+FOREIGN KEY \([`A-Za-z0-9_ ,]+\) REFERENCES [`A-Za-z0-9_.]+ \([`A-Za-z0-9_ ,]+\)" + r"( ON DELETE \w+)?( ON UPDATE \w+)?", re.IGNORECASE, ) -def _strip_storage_option_quotes(sql: str) -> str: - """Drop single quotes around storage option values (``ENGINE='InnoDB'`` -> ``ENGINE=InnoDB``).""" - return _STORAGE_OPTION_RE.sub(lambda m: f"{m.group(1)}{m.group(2)}", sql) +def default_table_options( + dialect, + *, + order_by: Sequence[str] = ("id",), +) -> ClickHouseCreateTableOptions: + """The clause run every fixture table shares. + + ``ENGINE = MergeTree`` and the two lightweight-update settings are the + same everywhere; only the sort key varies, so it is the one argument. + """ + return ClickHouseCreateTableOptions( + dialect, + table_storage_options=[ + ClickHouseTableStorageOption( + dialect, + ClickHouseStorageOptionKind.ENGINE, + value=ClickHouseEngineSpec(dialect, "MergeTree"), + ), + ClickHouseTableStorageOption( + dialect, + ClickHouseStorageOptionKind.ORDER_BY, + value=[Column(dialect, name) for name in order_by], + ), + ClickHouseTableStorageOption( + dialect, + ClickHouseStorageOptionKind.SETTINGS, + value=[ + ClickHouseSetting(dialect, "enable_block_number_column", 1), + ClickHouseSetting(dialect, "enable_block_offset_column", 1), + ], + ), + ], + ) -def _append_fk_actions(expr: CreateTableExpression, sql: str) -> str: +def _append_fk_actions(expr: CreateTableStatement, sql: str) -> str: """Append ``ON DELETE`` / ``ON UPDATE`` clauses to inline FK constraints. The ClickHouse dialect's ``format_table_constraint`` does not currently emit @@ -72,20 +103,25 @@ def _append_fk_actions(expr: CreateTableExpression, sql: str) -> str: fk_suffix_by_clause: dict = {} for t_const in expr.table_constraints: from rhosocial.activerecord.backend.expression.statements import ( - TableConstraintType, ForeignKeyConstraint, + TableForeignKeyConstraint, ) - if t_const.constraint_type != TableConstraintType.FOREIGN_KEY: + + # S3: the kind is the branch class now; ``constraint_type`` is gone as a + # field and what the production keeps in its place raises AttributeError. + branch = getattr(t_const, "branch", None) + if not isinstance(branch, TableForeignKeyConstraint): continue if not isinstance(t_const, ForeignKeyConstraint): continue - cols = ", ".join(f"`{c}`" for c in (t_const.columns or [])) - ref_cols = ", ".join(f"`{c}`" for c in (t_const.foreign_key_columns or [])) - ref_table = f"`{t_const.foreign_key_table}`" + cols = ", ".join(f"`{c}`" for c in (branch.columns or [])) + ref_table_obj, ref_columns = branch.references + ref_cols = ", ".join(f"`{c}`" for c in (ref_columns or [])) + ref_table = f"`{ref_table_obj}`" clause = f"FOREIGN KEY ({cols}) REFERENCES {ref_table} ({ref_cols})" suffix_parts = [] - on_delete = getattr(t_const, "on_delete", None) - on_update = getattr(t_const, "on_update", None) + on_delete = getattr(branch, "on_delete", None) + on_update = getattr(branch, "on_update", None) if on_delete and on_delete != ReferentialAction.NO_ACTION: suffix_parts.append(f"ON DELETE {on_delete.value}") if on_update and on_update != ReferentialAction.NO_ACTION: @@ -99,38 +135,33 @@ def repl(match: re.Match) -> str: return _FK_RE.sub(repl, sql) -def to_clickhouse_ddl_sql(expr: CreateTableExpression) -> Tuple[str, tuple]: - """Generate canonical ClickHouse DDL for a :class:`CreateTableExpression`. +def to_clickhouse_ddl_sql(expr: CreateTableStatement) -> Tuple[str, tuple]: + """Generate canonical ClickHouse DDL for a :class:`CreateTableStatement`. Post-processes the dialect output to: - 1. Drop quotes around storage option values. - 2. Re-insert ``ON DELETE`` / ``ON UPDATE`` referential actions for + 1. Re-insert ``ON DELETE`` / ``ON UPDATE`` referential actions for inline ``FOREIGN KEY`` table constraints. - 3. Remove ``AUTO_INCREMENT``, ``UNIQUE``, and ``FOREIGN KEY`` clauses + 2. Remove ``AUTO_INCREMENT``, ``UNIQUE``, and ``FOREIGN KEY`` clauses that ClickHouse does not support. - 4. Replace MySQL ``ENGINE=InnoDB`` storage options with ClickHouse - ``ENGINE = MergeTree`` / ``ORDER BY`` settings. Returns the ``(sql, params)`` tuple suitable for ``backend.execute``. """ sql, params = expr.to_sql() - sql = _strip_storage_option_quotes(sql) sql = _append_fk_actions(expr, sql) sql = _AUTOINC_RE.sub("", sql) sql = _UNIQUE_RE.sub("", sql) sql = _FOREIGN_KEY_COL_RE.sub("", sql) - sql = _ENGINE_RE.sub("ENGINE = MergeTree", sql) return sql, params -def create_table_sql(expr: CreateTableExpression) -> Tuple[str, tuple]: +def create_table_sql(expr: CreateTableStatement) -> Tuple[str, tuple]: """Public alias for :func:`to_clickhouse_ddl_sql`.""" return to_clickhouse_ddl_sql(expr) -def drop_table(dialect, table_name: str) -> DropTableExpression: +def drop_table(dialect, table_name: str) -> DropTableStatement: """Build a canonical ``DROP TABLE IF EXISTS`` expression.""" - return DropTableExpression( + return DropTableStatement( dialect=dialect, table=Table(dialect, table_name), if_exists=True, diff --git a/tests/providers/fixtures/basic.py b/tests/providers/fixtures/basic.py index 981d526..7058d16 100644 --- a/tests/providers/fixtures/basic.py +++ b/tests/providers/fixtures/basic.py @@ -1,7 +1,7 @@ # tests/providers/fixtures/basic.py """DDL expressions for the ``feature/basic`` table group (ClickHouse). -Each factory builds a :class:`CreateTableExpression` whose generated ClickHouse +Each factory builds a :class:`CreateTableStatement` whose generated ClickHouse DDL is semantically equivalent to the reference ``.sql`` schema files under ``tests/rhosocial/activerecord_clickhouse_test/feature/basic/schema/``. Those ``.sql`` files are kept as the authoritative reference and are no longer @@ -18,16 +18,16 @@ from typing import Callable, Dict from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, + CreateTableStatement, ) from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ColumnNotNullConstraint, ColumnDefaultConstraint, IndexDefinition, TableConstraint, - TableConstraintType, + TablePrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import ( BigIntType, @@ -53,16 +53,9 @@ from . import _common -# Standard ClickHouse table options. -_DEFAULT_STORAGE_OPTIONS = { - "ENGINE": "MergeTree", - "ORDER BY": "id", - "SETTINGS": "enable_block_number_column = 1, enable_block_offset_column = 1", -} - -def to_sql(expr: CreateTableExpression): - """Route a CreateTableExpression through the canonical ClickHouse DDL post-processor.""" +def to_sql(expr: CreateTableStatement): + """Route a CreateTableStatement through the canonical ClickHouse DDL post-processor.""" return _common.to_clickhouse_ddl_sql(expr) @@ -70,29 +63,29 @@ def to_sql(expr: CreateTableExpression): # basic/users.sql # --------------------------------------------------------------------------- -def create_users_table(dialect, table_name: str = "users") -> CreateTableExpression: - return CreateTableExpression( +def create_users_table(dialect, table_name: str = "users") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "username", VarCharType(dialect, length=191), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, length=191), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "age", IntegerType(dialect)), ColumnDefinition(dialect, "balance", FloatType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "is_active", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "created_at", TextType(dialect)), ColumnDefinition(dialect, "updated_at", TextType(dialect)), ], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -100,19 +93,19 @@ def create_users_table(dialect, table_name: str = "users") -> CreateTableExpress # basic/type_cases.sql # --------------------------------------------------------------------------- -def create_type_cases_table(dialect, table_name: str = "type_cases") -> CreateTableExpression: - return CreateTableExpression( +def create_type_cases_table(dialect, table_name: str = "type_cases") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", CharType(dialect, length=36), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "username", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "email", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "tiny_int", TextType(dialect)), ColumnDefinition(dialect, "small_int", TextType(dialect)), ColumnDefinition(dialect, "big_int", TextType(dialect)), @@ -130,7 +123,7 @@ def create_type_cases_table(dialect, table_name: str = "type_cases") -> CreateTa ColumnDefinition(dialect, "array_val", TextType(dialect)), ColumnDefinition(dialect, "is_active", TextType(dialect)), ], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -138,36 +131,36 @@ def create_type_cases_table(dialect, table_name: str = "type_cases") -> CreateTa # basic/type_tests.sql # --------------------------------------------------------------------------- -def create_type_tests_table(dialect, table_name: str = "type_tests") -> CreateTableExpression: - return CreateTableExpression( +def create_type_tests_table(dialect, table_name: str = "type_tests") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", CharType(dialect, length=36), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "string_field", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="test string")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "test string"))]), ColumnDefinition(dialect, "int_field", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=42)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 42))]), ColumnDefinition(dialect, "float_field", FloatType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=3.14)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 3.14))]), ColumnDefinition(dialect, "decimal_field", DoubleType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=10.99)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 10.99))]), ColumnDefinition(dialect, "bool_field", TinyIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "datetime_field", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "json_field", JsonType(dialect)), ColumnDefinition(dialect, "nullable_field", ClickHouseNullableType(dialect, inner_type=ClickHouseStringType(dialect))), ], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -175,32 +168,32 @@ def create_type_tests_table(dialect, table_name: str = "type_tests") -> CreateTa # basic/validated_field_users.sql # --------------------------------------------------------------------------- -def create_validated_field_users_table(dialect, table_name: str = "validated_field_users") -> CreateTableExpression: +def create_validated_field_users_table(dialect, table_name: str = "validated_field_users") -> CreateTableStatement: # ClickHouse ENUM column for status. status_enum = ClickHouseEnum8Type(dialect, values=[('active', 1), ('inactive', 2), ('banned', 3), ('pending', 4), ('suspended', 5)]) - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "username", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "age", IntegerType(dialect)), ColumnDefinition(dialect, "balance", DecimalType(dialect, precision=10, scale=2)), ColumnDefinition(dialect, "credit_score", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "status", status_enum, - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="active")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "active"))]), ColumnDefinition(dialect, "is_active", TinyIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -208,21 +201,21 @@ def create_validated_field_users_table(dialect, table_name: str = "validated_fie # basic/validated_users.sql # --------------------------------------------------------------------------- -def create_validated_users_table(dialect, table_name: str = "validated_users") -> CreateTableExpression: - return CreateTableExpression( +def create_validated_users_table(dialect, table_name: str = "validated_users") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "username", VarCharType(dialect, length=50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "age", ClickHouseNullableType(dialect, inner_type=IntegerType(dialect))), ], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -230,14 +223,14 @@ def create_validated_users_table(dialect, table_name: str = "validated_users") - # basic/pydantic_validated_models.sql # --------------------------------------------------------------------------- -def create_pydantic_validated_models_table(dialect, table_name: str = "pydantic_validated_models") -> CreateTableExpression: - return CreateTableExpression( +def create_pydantic_validated_models_table(dialect, table_name: str = "pydantic_validated_models") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "code", VarCharType(dialect, length=32)), ColumnDefinition(dialect, "quantity", IntegerType(dialect)), ColumnDefinition(dialect, "step_count", IntegerType(dialect)), @@ -248,7 +241,7 @@ def create_pydantic_validated_models_table(dialect, table_name: str = "pydantic_ ColumnDefinition(dialect, "normalized_name", VarCharType(dialect, length=50)), ColumnDefinition(dialect, "created_token", VarCharType(dialect, length=255)), ], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -256,22 +249,22 @@ def create_pydantic_validated_models_table(dialect, table_name: str = "pydantic_ # basic/bulk_users.sql # --------------------------------------------------------------------------- -def create_bulk_users_table(dialect, table_name: str = "bulk_users") -> CreateTableExpression: - return CreateTableExpression( +def create_bulk_users_table(dialect, table_name: str = "bulk_users") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "age", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ColumnDefinition(dialect, "email", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="")]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, ""))]), ], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -279,27 +272,27 @@ def create_bulk_users_table(dialect, table_name: str = "bulk_users") -> CreateTa # basic/posts.sql # --------------------------------------------------------------------------- -def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpression: - return CreateTableExpression( +def create_posts_table(dialect, table_name: str = "posts") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "author", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "title", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "content", TextType(dialect)), ColumnDefinition(dialect, "published_at", ClickHouseDateTime64Type(dialect, precision=6)), ColumnDefinition(dialect, "published", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ColumnDefinition(dialect, "created_at", ClickHouseDateTime64Type(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", ClickHouseDateTime64Type(dialect, precision=6)), ], indexes=[IndexDefinition(dialect, name="idx_author", columns=["author"])], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -307,31 +300,31 @@ def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpress # basic/comments.sql # --------------------------------------------------------------------------- -def create_comments_table(dialect, table_name: str = "comments") -> CreateTableExpression: - return CreateTableExpression( +def create_comments_table(dialect, table_name: str = "comments") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "post_ref", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "author", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "text", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "created_at", ClickHouseDateTime64Type(dialect, precision=6), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "updated_at", ClickHouseDateTime64Type(dialect, precision=6)), ColumnDefinition(dialect, "approved", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ], indexes=[ IndexDefinition(dialect, name="idx_post_ref", columns=["post_ref"]), IndexDefinition(dialect, name="idx_author", columns=["author"]), ], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -339,21 +332,21 @@ def create_comments_table(dialect, table_name: str = "comments") -> CreateTableE # basic/column_mapping_items.sql # --------------------------------------------------------------------------- -def create_column_mapping_items_table(dialect, table_name: str = "column_mapping_items") -> CreateTableExpression: - return CreateTableExpression( +def create_column_mapping_items_table(dialect, table_name: str = "column_mapping_items") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "item_total", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "remarks", IntegerType(dialect)), ], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -361,22 +354,22 @@ def create_column_mapping_items_table(dialect, table_name: str = "column_mapping # basic/mixed_annotation_items.sql # --------------------------------------------------------------------------- -def create_mixed_annotation_items_table(dialect, table_name: str = "mixed_annotation_items") -> CreateTableExpression: - return CreateTableExpression( +def create_mixed_annotation_items_table(dialect, table_name: str = "mixed_annotation_items") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "tags", TextType(dialect)), ColumnDefinition(dialect, "meta", TextType(dialect)), ColumnDefinition(dialect, "description", TextType(dialect)), ColumnDefinition(dialect, "status", TextType(dialect)), ], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -384,16 +377,16 @@ def create_mixed_annotation_items_table(dialect, table_name: str = "mixed_annota # basic/type_adapter_tests.sql (no ENGINE/CHARSET in reference file) # --------------------------------------------------------------------------- -def create_type_adapter_tests_table(dialect, table_name: str = "type_adapter_tests") -> CreateTableExpression: - return CreateTableExpression( +def create_type_adapter_tests_table(dialect, table_name: str = "type_adapter_tests") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=True, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), # Optional[T] model fields must map to Nullable columns so that None # is stored/returned as SQL NULL instead of ClickHouse's empty string. ColumnDefinition(dialect, "optional_name", ClickHouseNullableType(dialect, inner_type=VarCharType(dialect, length=255))), @@ -404,7 +397,7 @@ def create_type_adapter_tests_table(dialect, table_name: str = "type_adapter_tes ColumnDefinition(dialect, "custom_bool", VarCharType(dialect, length=3)), ColumnDefinition(dialect, "optional_custom_bool", ClickHouseNullableType(dialect, inner_type=VarCharType(dialect, length=3))), ], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -412,31 +405,27 @@ def create_type_adapter_tests_table(dialect, table_name: str = "type_adapter_tes # basic/order_items.sql (composite PK) # --------------------------------------------------------------------------- -def create_composite_pk_order_items_table(dialect, table_name: str = "order_items") -> CreateTableExpression: - return CreateTableExpression( +def create_composite_pk_order_items_table(dialect, table_name: str = "order_items") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "order_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "product_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "quantity", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "unit_price", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ], table_constraints=[ - TableConstraint(dialect, constraint_type=TableConstraintType.PRIMARY_KEY, - columns=["order_id", "product_id"]), + TableConstraint(dialect, + branch=TablePrimaryKeyConstraint(dialect, columns=["order_id", "product_id"])), ], - storage_options={ - "ENGINE": "MergeTree", - "ORDER BY": ("order_id", "product_id"), - "SETTINGS": "enable_block_number_column = 1, enable_block_offset_column = 1", - }, + table_options=_common.default_table_options(dialect, order_by=("order_id", "product_id")), ) @@ -444,31 +433,27 @@ def create_composite_pk_order_items_table(dialect, table_name: str = "order_item # basic/store_inventory.sql (composite PK, no FK) # --------------------------------------------------------------------------- -def create_store_inventory_table(dialect, table_name: str = "store_inventory") -> CreateTableExpression: - return CreateTableExpression( +def create_store_inventory_table(dialect, table_name: str = "store_inventory") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "store_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "product_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "batch_id", VarCharType(dialect, length=64), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "stock", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ], table_constraints=[ - TableConstraint(dialect, constraint_type=TableConstraintType.PRIMARY_KEY, - columns=["store_id", "product_id", "batch_id"]), + TableConstraint(dialect, + branch=TablePrimaryKeyConstraint(dialect, columns=["store_id", "product_id", "batch_id"])), ], - storage_options={ - "ENGINE": "MergeTree", - "ORDER BY": ("store_id", "product_id", "batch_id"), - "SETTINGS": "enable_block_number_column = 1, enable_block_offset_column = 1", - }, + table_options=_common.default_table_options(dialect, order_by=("store_id", "product_id", "batch_id")), ) @@ -476,20 +461,20 @@ def create_store_inventory_table(dialect, table_name: str = "store_inventory") - # basic/orders.sql (single PK, no FK in composite-PK scenario) # --------------------------------------------------------------------------- -def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpression: - return CreateTableExpression( +def create_orders_table(dialect, table_name: str = "orders") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "total", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "created_at", TextType(dialect)), ColumnDefinition(dialect, "updated_at", TextType(dialect)), ], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -497,22 +482,22 @@ def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpre # basic/product.sql (single PK) # --------------------------------------------------------------------------- -def create_product_table(dialect, table_name: str = "product") -> CreateTableExpression: - return CreateTableExpression( +def create_product_table(dialect, table_name: str = "product") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=True, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "name", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "price", FloatType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "quantity", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) diff --git a/tests/providers/fixtures/events.py b/tests/providers/fixtures/events.py index 2b74736..b904d23 100644 --- a/tests/providers/fixtures/events.py +++ b/tests/providers/fixtures/events.py @@ -13,13 +13,13 @@ from typing import Callable, Dict from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, + CreateTableStatement, ) from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ColumnNotNullConstraint, ColumnNullableConstraint, ColumnDefaultConstraint, ) from rhosocial.activerecord.backend.expression.types import ( BigIntType, @@ -33,15 +33,8 @@ from . import _common -# Standard ClickHouse table options. -_DEFAULT_STORAGE_OPTIONS = { - "ENGINE": "MergeTree", - "ORDER BY": "id", - "SETTINGS": "enable_block_number_column = 1, enable_block_offset_column = 1", -} - -def to_sql(expr: CreateTableExpression): +def to_sql(expr: CreateTableStatement): return _common.to_clickhouse_ddl_sql(expr) @@ -49,27 +42,27 @@ def to_sql(expr: CreateTableExpression): # events/event_tests.sql # --------------------------------------------------------------------------- -def create_event_tests_table(dialect, table_name: str = "event_tests") -> CreateTableExpression: - return CreateTableExpression( +def create_event_tests_table(dialect, table_name: str = "event_tests") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "status", VarCharType(dialect, length=50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="draft")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "draft"))]), ColumnDefinition(dialect, "revision", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "content", TextType(dialect)), ColumnDefinition(dialect, "created_at", ClickHouseDateTime64Type(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", ClickHouseDateTime64Type(dialect, precision=6)), ], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -77,25 +70,25 @@ def create_event_tests_table(dialect, table_name: str = "event_tests") -> Create # events/event_tracking_models.sql # --------------------------------------------------------------------------- -def create_event_tracking_models_table(dialect, table_name: str = "event_tracking_models") -> CreateTableExpression: - return CreateTableExpression( +def create_event_tracking_models_table(dialect, table_name: str = "event_tracking_models") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "title", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "content", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "view_count", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ColumnDefinition(dialect, "last_viewed_at", ClickHouseDateTime64Type(dialect, precision=6), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNullableConstraint(dialect))]), ], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) diff --git a/tests/providers/fixtures/mixins.py b/tests/providers/fixtures/mixins.py index a257a2f..b094149 100644 --- a/tests/providers/fixtures/mixins.py +++ b/tests/providers/fixtures/mixins.py @@ -13,13 +13,13 @@ from typing import Callable, Dict from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, + CreateTableStatement, ) from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ColumnNotNullConstraint, ColumnNullableConstraint, ColumnDefaultConstraint, ) from rhosocial.activerecord.backend.expression.types import ( BigIntType, @@ -35,15 +35,8 @@ from . import _common -# Standard ClickHouse table options. -_DEFAULT_STORAGE_OPTIONS = { - "ENGINE": "MergeTree", - "ORDER BY": "id", - "SETTINGS": "enable_block_number_column = 1, enable_block_offset_column = 1", -} - -def to_sql(expr: CreateTableExpression): +def to_sql(expr: CreateTableStatement): return _common.to_clickhouse_ddl_sql(expr) @@ -51,30 +44,30 @@ def to_sql(expr: CreateTableExpression): # mixins/combined_articles.sql # --------------------------------------------------------------------------- -def create_combined_articles_table(dialect, table_name: str = "combined_articles") -> CreateTableExpression: - return CreateTableExpression( +def create_combined_articles_table(dialect, table_name: str = "combined_articles") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "title", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "content", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "status", VarCharType(dialect, length=50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="draft")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "draft"))]), ColumnDefinition(dialect, "created_at", ClickHouseDateTime64Type(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", ClickHouseDateTime64Type(dialect, precision=6)), ColumnDefinition(dialect, "version", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "deleted_at", ClickHouseDateTime64Type(dialect, precision=6), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNullableConstraint(dialect))]), ], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -82,23 +75,23 @@ def create_combined_articles_table(dialect, table_name: str = "combined_articles # mixins/tasks.sql # --------------------------------------------------------------------------- -def create_tasks_table(dialect, table_name: str = "tasks") -> CreateTableExpression: - return CreateTableExpression( +def create_tasks_table(dialect, table_name: str = "tasks") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "title", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "is_completed", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ColumnDefinition(dialect, "deleted_at", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNullableConstraint(dialect))]), ], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -106,22 +99,22 @@ def create_tasks_table(dialect, table_name: str = "tasks") -> CreateTableExpress # mixins/timestamped_posts.sql # --------------------------------------------------------------------------- -def create_timestamped_posts_table(dialect, table_name: str = "timestamped_posts") -> CreateTableExpression: - return CreateTableExpression( +def create_timestamped_posts_table(dialect, table_name: str = "timestamped_posts") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "title", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "content", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "created_at", ClickHouseDateTime64Type(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", ClickHouseDateTime64Type(dialect, precision=6)), ], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -129,24 +122,24 @@ def create_timestamped_posts_table(dialect, table_name: str = "timestamped_posts # mixins/versioned_products.sql # --------------------------------------------------------------------------- -def create_versioned_products_table(dialect, table_name: str = "versioned_products") -> CreateTableExpression: - return CreateTableExpression( +def create_versioned_products_table(dialect, table_name: str = "versioned_products") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "price", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "version", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) diff --git a/tests/providers/fixtures/query.py b/tests/providers/fixtures/query.py index 707b47b..8294b4c 100644 --- a/tests/providers/fixtures/query.py +++ b/tests/providers/fixtures/query.py @@ -1,7 +1,7 @@ # tests/providers/fixtures/query.py """DDL expressions for the ``feature/query`` table group (ClickHouse). -Each factory builds a :class:`CreateTableExpression` whose generated ClickHouse DDL +Each factory builds a :class:`CreateTableStatement` whose generated ClickHouse DDL is semantically equivalent to the reference ``.sql`` schema files under ``tests/rhosocial/activerecord_clickhouse_test/feature/query/schema/``. Those ``.sql`` files are kept as the authoritative reference and are no longer @@ -18,13 +18,13 @@ from typing import Callable, Dict from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, + CreateTableStatement, ) from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ColumnNotNullConstraint, ColumnNullableConstraint, ColumnDefaultConstraint, ForeignKeyConstraint, IndexDefinition, ReferentialAction, @@ -48,17 +48,10 @@ from . import _common -# Standard ClickHouse table options. -_DEFAULT_STORAGE_OPTIONS = { - "ENGINE": "MergeTree", - "ORDER BY": "id", - "SETTINGS": "enable_block_number_column = 1, enable_block_offset_column = 1", -} - _CASCADE = ReferentialAction.CASCADE -def to_sql(expr: CreateTableExpression): +def to_sql(expr: CreateTableStatement): return _common.to_clickhouse_ddl_sql(expr) @@ -66,31 +59,31 @@ def to_sql(expr: CreateTableExpression): # query/users.sql # --------------------------------------------------------------------------- -def create_users_table(dialect, table_name: str = "users") -> CreateTableExpression: - return CreateTableExpression( +def create_users_table(dialect, table_name: str = "users") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "username", VarCharType(dialect, length=191), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, length=191), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), # Optional[T] model fields must map to Nullable columns so that None # is stored/returned as SQL NULL instead of ClickHouse's zero value. ColumnDefinition(dialect, "age", ClickHouseNullableType(dialect, inner_type=IntegerType(dialect))), ColumnDefinition(dialect, "balance", DoubleType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "is_active", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "created_at", ClickHouseDateTime64Type(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", ClickHouseDateTime64Type(dialect, precision=6)), ], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -98,28 +91,28 @@ def create_users_table(dialect, table_name: str = "users") -> CreateTableExpress # query/posts.sql # --------------------------------------------------------------------------- -def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpression: - return CreateTableExpression( +def create_posts_table(dialect, table_name: str = "posts") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "user_id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "title", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "content", TextType(dialect)), ColumnDefinition(dialect, "status", VarCharType(dialect, length=50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="published")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "published"))]), ColumnDefinition(dialect, "created_at", ClickHouseDateTime64Type(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", ClickHouseDateTime64Type(dialect, precision=6)), ], indexes=[IndexDefinition(dialect, name="idx_user_id", columns=["user_id"]), IndexDefinition(dialect, name="idx_status", columns=["status"])], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -127,27 +120,27 @@ def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpress # query/comments.sql # --------------------------------------------------------------------------- -def create_comments_table(dialect, table_name: str = "comments") -> CreateTableExpression: - return CreateTableExpression( +def create_comments_table(dialect, table_name: str = "comments") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "user_id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "post_id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "content", TextType(dialect)), ColumnDefinition(dialect, "is_hidden", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ColumnDefinition(dialect, "created_at", ClickHouseDateTime64Type(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", ClickHouseDateTime64Type(dialect, precision=6)), ], indexes=[IndexDefinition(dialect, name="idx_user_id", columns=["user_id"]), IndexDefinition(dialect, name="idx_post_id", columns=["post_id"])], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -155,29 +148,29 @@ def create_comments_table(dialect, table_name: str = "comments") -> CreateTableE # query/orders.sql # --------------------------------------------------------------------------- -def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpression: - return CreateTableExpression( +def create_orders_table(dialect, table_name: str = "orders") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "user_id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "order_number", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "total_amount", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "status", VarCharType(dialect, length=50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="pending")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "pending"))]), ColumnDefinition(dialect, "created_at", ClickHouseDateTime64Type(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", ClickHouseDateTime64Type(dialect, precision=6)), ], indexes=[IndexDefinition(dialect, name="idx_user_id", columns=["user_id"])], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -185,31 +178,31 @@ def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpre # query/order_items.sql # --------------------------------------------------------------------------- -def create_order_items_table(dialect, table_name: str = "order_items") -> CreateTableExpression: - return CreateTableExpression( +def create_order_items_table(dialect, table_name: str = "order_items") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "order_id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "product_name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "quantity", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "unit_price", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "subtotal", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "created_at", ClickHouseDateTime64Type(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", ClickHouseDateTime64Type(dialect, precision=6)), ], indexes=[IndexDefinition(dialect, name="idx_order_id", columns=["order_id"])], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -217,22 +210,22 @@ def create_order_items_table(dialect, table_name: str = "order_items") -> Create # query/profiles.sql # --------------------------------------------------------------------------- -def create_profiles_table(dialect, table_name: str = "profiles") -> CreateTableExpression: - return CreateTableExpression( +def create_profiles_table(dialect, table_name: str = "profiles") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "user_id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "bio", TextType(dialect)), ColumnDefinition(dialect, "avatar_url", VarCharType(dialect, length=512)), ColumnDefinition(dialect, "created_at", ClickHouseDateTime64Type(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", ClickHouseDateTime64Type(dialect, precision=6)), ], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -240,18 +233,18 @@ def create_profiles_table(dialect, table_name: str = "profiles") -> CreateTableE # query/json_users.sql # --------------------------------------------------------------------------- -def create_json_users_table(dialect, table_name: str = "json_users") -> CreateTableExpression: - return CreateTableExpression( +def create_json_users_table(dialect, table_name: str = "json_users") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "username", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "age", ClickHouseNullableType(dialect, inner_type=IntegerType(dialect))), ColumnDefinition(dialect, "created_at", ClickHouseDateTime64Type(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", ClickHouseDateTime64Type(dialect, precision=6)), @@ -263,7 +256,7 @@ def create_json_users_table(dialect, table_name: str = "json_users") -> CreateTa ColumnDefinition(dialect, "subscription", JsonType(dialect)), ColumnDefinition(dialect, "preferences", JsonType(dialect)), ], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -271,25 +264,25 @@ def create_json_users_table(dialect, table_name: str = "json_users") -> CreateTa # query/nodes.sql (self-referential FK) # --------------------------------------------------------------------------- -def create_nodes_table(dialect, table_name: str = "nodes") -> CreateTableExpression: - return CreateTableExpression( +def create_nodes_table(dialect, table_name: str = "nodes") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "parent_id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNullableConstraint(dialect))]), ColumnDefinition(dialect, "value", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "created_at", ClickHouseDateTime64Type(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", ClickHouseDateTime64Type(dialect, precision=6)), ], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -297,20 +290,20 @@ def create_nodes_table(dialect, table_name: str = "nodes") -> CreateTableExpress # query/searchable_items.sql # --------------------------------------------------------------------------- -def create_searchable_items_table(dialect, table_name: str = "searchable_items") -> CreateTableExpression: - return CreateTableExpression( +def create_searchable_items_table(dialect, table_name: str = "searchable_items") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "name", VarCharType(dialect, length=255)), ColumnDefinition(dialect, "tags", TextType(dialect)), ColumnDefinition(dialect, "created_at", ClickHouseDateTime64Type(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", ClickHouseDateTime64Type(dialect, precision=6)), ], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -318,30 +311,30 @@ def create_searchable_items_table(dialect, table_name: str = "searchable_items") # query/extended_orders.sql # --------------------------------------------------------------------------- -def create_extended_orders_table(dialect, table_name: str = "extended_orders") -> CreateTableExpression: - return CreateTableExpression( +def create_extended_orders_table(dialect, table_name: str = "extended_orders") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "user_id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "order_number", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "total_amount", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "status", VarCharType(dialect, length=50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="pending")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "pending"))]), ColumnDefinition(dialect, "priority", VarCharType(dialect, length=50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="medium")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "medium"))]), ColumnDefinition(dialect, "region", VarCharType(dialect, length=50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="default")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "default"))]), ColumnDefinition(dialect, "category", VarCharType(dialect, length=255)), ColumnDefinition(dialect, "product", VarCharType(dialect, length=255)), ColumnDefinition(dialect, "department", VarCharType(dialect, length=255)), @@ -354,7 +347,7 @@ def create_extended_orders_table(dialect, table_name: str = "extended_orders") - IndexDefinition(dialect, name="idx_status", columns=["status"]), IndexDefinition(dialect, name="idx_priority", columns=["priority"]), IndexDefinition(dialect, name="idx_region", columns=["region"])], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) @@ -362,30 +355,30 @@ def create_extended_orders_table(dialect, table_name: str = "extended_orders") - # query/extended_order_items.sql # --------------------------------------------------------------------------- -def create_extended_order_items_table(dialect, table_name: str = "extended_order_items") -> CreateTableExpression: - return CreateTableExpression( +def create_extended_order_items_table(dialect, table_name: str = "extended_order_items") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "order_id", BigIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "product_name", VarCharType(dialect, length=255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "quantity", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "price", DecimalType(dialect, precision=10, scale=2), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "category", VarCharType(dialect, length=255)), ColumnDefinition(dialect, "region", VarCharType(dialect, length=50)), ColumnDefinition(dialect, "created_at", ClickHouseDateTime64Type(dialect, precision=6)), ColumnDefinition(dialect, "updated_at", ClickHouseDateTime64Type(dialect, precision=6)), ], indexes=[IndexDefinition(dialect, name="idx_order_id", columns=["order_id"])], - storage_options=dict(_DEFAULT_STORAGE_OPTIONS), + table_options=_common.default_table_options(dialect), ) diff --git a/tests/providers/mixins.py b/tests/providers/mixins.py index 3df68b4..90abd9b 100644 --- a/tests/providers/mixins.py +++ b/tests/providers/mixins.py @@ -136,7 +136,7 @@ def __init__(self): def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.mixins import TABLE_EXPRESSIONS from providers.fixtures._common import to_clickhouse_ddl_sql @@ -148,7 +148,7 @@ def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, tabl options = ExecutionOptions(stmt_type=StatementType.DDL) # ClickHouse has no FK constraints; just drop the old table. try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance, table_name), if_exists=True, @@ -210,7 +210,7 @@ async def _setup_async_model( ) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.mixins import TABLE_EXPRESSIONS from providers.fixtures._common import to_clickhouse_ddl_sql @@ -222,7 +222,7 @@ async def _setup_async_model( options = ExecutionOptions(stmt_type=StatementType.DDL) # ClickHouse has no FK constraints; just drop the old table. try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance, table_name), if_exists=True, diff --git a/tests/providers/query.py b/tests/providers/query.py index 97b96e1..4ec9fb4 100644 --- a/tests/providers/query.py +++ b/tests/providers/query.py @@ -159,7 +159,7 @@ def __init__(self): def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.query import TABLE_EXPRESSIONS from providers.fixtures._common import to_clickhouse_ddl_sql @@ -171,7 +171,7 @@ def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, tabl options = ExecutionOptions(stmt_type=StatementType.DDL) # ClickHouse has no FK constraints; just drop the old table. try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance, table_name), if_exists=True, @@ -210,7 +210,7 @@ def _setup_multiple_models( def _reset_schema(self, backend_instance, table_name: str) -> None: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.query import TABLE_EXPRESSIONS from providers.fixtures._common import to_clickhouse_ddl_sql @@ -219,7 +219,7 @@ def _reset_schema(self, backend_instance, table_name: str) -> None: self._active_backends.append(backend_instance) # ClickHouse has no FK constraints; just drop the old table. try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance, table_name), if_exists=True, @@ -305,7 +305,7 @@ def setup_order_item_model(self, scenario_name: str) -> Type[ActiveRecord]: """Set up the composite-PK OrderItem model for the query feature tests.""" from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.basic import TABLE_EXPRESSIONS as BASIC_EXPRS from providers.fixtures._common import to_clickhouse_ddl_sql @@ -317,7 +317,7 @@ def setup_order_item_model(self, scenario_name: str) -> Type[ActiveRecord]: options = ExecutionOptions(stmt_type=StatementType.DDL) # ClickHouse has no FK constraints; just drop the old table. try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance, "order_items"), if_exists=True, @@ -439,7 +439,7 @@ async def _setup_model_async( ) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.query import TABLE_EXPRESSIONS from providers.fixtures._common import to_clickhouse_ddl_sql @@ -456,7 +456,7 @@ async def _setup_model_async( options = ExecutionOptions(stmt_type=StatementType.DDL) # ClickHouse has no FK constraints; just drop the old table. try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance, table_name), if_exists=True, @@ -607,7 +607,7 @@ async def setup_order_item_model(self, scenario_name: str) -> Type[AsyncActiveRe """Set up the composite-PK AsyncOrderItem model for the query feature tests.""" from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.impl.clickhouse.backend.async_backend import ( AsyncClickHouseBackend, @@ -623,7 +623,7 @@ async def setup_order_item_model(self, scenario_name: str) -> Type[AsyncActiveRe options = ExecutionOptions(stmt_type=StatementType.DDL) # ClickHouse has no FK constraints; just drop the old table. try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance, "order_items"), if_exists=True, diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/README.md b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/README.md index d85deeb..25d24b3 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/README.md +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/README.md @@ -10,7 +10,7 @@ ClickHouse backend tests organized by common subject (taxonomy per | `adapters/` | value adapters, column mapping | | `backend/` | backend-object lifecycle/ops | | `cli/` | CLI blackbox | -| `ddl/` | DDL generation/coverage, CreateTableExpression diff | +| `ddl/` | DDL generation/coverage, CreateTableStatement diff | | `dialect/` | dialect capabilities, security, mixins, unsupported features, live-backend dialect | | `dml/` | CRUD via backend object | | `expression/` | JSON, datetime-interval expressions | diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/adapters/test_adapters_table_mixins.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/adapters/test_adapters_table_mixins.py index 51cb800..95da828 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/adapters/test_adapters_table_mixins.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/adapters/test_adapters_table_mixins.py @@ -147,11 +147,19 @@ class Color(Enum): class TestTableMixin: - def test_validate_data_type(self): - assert ClickHouseTableMixin._validate_data_type("UInt32") is True - assert ClickHouseTableMixin._validate_data_type("Decimal(10, 2)") is True - assert ClickHouseTableMixin._validate_data_type("Enum8('a')") is True - assert ClickHouseTableMixin._validate_data_type("bad sql; DROP") is False + def test_a_type_name_is_a_name(self): + """ClickHouse has no character filter of its own: a name is a name. + + The grammar is the framework's rule for what a name may be, and the + server decides whether the type exists. + """ + from rhosocial.activerecord.backend.expression.type_name import ( + is_valid_type_name, + ) + + assert is_valid_type_name("UInt32") + assert is_valid_type_name("Decimal(10, 2)") + assert not is_valid_type_name("bad sql; DROP") def test_supports_flags(self): m = ClickHouseTableMixin() @@ -160,13 +168,24 @@ def test_supports_flags(self): assert m.supports_storage_engine_option() is True assert m.supports_charset_option() is True - def test_format_storage_options(self, dialect): - from rhosocial.activerecord.backend.expression.statements import StorageOptionsExpression - # dialect exposes format_storage_options via MRO (ClickHouseTableMixin) - result, params = dialect.format_storage_options(StorageOptionsExpression(dialect, {"ENGINE": "MergeTree()", "ORDER BY": "id"})) - assert "ENGINE = MergeTree()" in result - assert "ORDER BY id" in result - assert "'MergeTree()'" not in result + def test_storage_options_are_declared_as_nodes(self, dialect): + """One node renders one clause; the generic slot has no ClickHouse answer.""" + from rhosocial.activerecord.backend.impl.clickhouse.expression import ( + ClickHouseEngineSpec, + ClickHouseStorageOptionKind, + ClickHouseTableStorageOption, + ) + + assert not hasattr(ClickHouseTableMixin, "format_storage_options_expression") + clause = ClickHouseTableStorageOption( + dialect, + ClickHouseStorageOptionKind.ENGINE, + value=ClickHouseEngineSpec(dialect, "MergeTree"), + ) + sql, params = dialect.format_clickhouse_table_storage_option(clause) + assert sql == "ENGINE = MergeTree" + assert params == () + assert "'" not in sql def test_format_index_definition(self, dialect): from types import SimpleNamespace @@ -185,32 +204,49 @@ def test_format_index_definition_unique_raises(self, dialect): dialect.format_index_definition(idx) def test_format_table_constraint_primary_key(self, dialect): - from rhosocial.activerecord.backend.expression.statements import TableConstraintType - from types import SimpleNamespace - tc = SimpleNamespace(name="pk", constraint_type=TableConstraintType.PRIMARY_KEY, columns=["id"]) + from rhosocial.activerecord.backend.expression.statements import ( + TableConstraint, + TablePrimaryKeyConstraint, + ) + + tc = TableConstraint( + dialect, branch=TablePrimaryKeyConstraint(dialect, ["id"]), name="pk" + ) result, _ = dialect.format_table_constraint(tc) assert "PRIMARY KEY" in result assert "id" in result def test_format_table_constraint_unique_raises(self, dialect): - from rhosocial.activerecord.backend.expression.statements import TableConstraintType from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - from types import SimpleNamespace - tc = SimpleNamespace(name=None, constraint_type=TableConstraintType.UNIQUE, columns=["email"]) + from rhosocial.activerecord.backend.expression.statements import ( + TableConstraint, + TableUniqueConstraint, + ) + + tc = TableConstraint(dialect, branch=TableUniqueConstraint(dialect, ["email"])) with pytest.raises(UnsupportedFeatureError): dialect.format_table_constraint(tc) def test_format_table_constraint_fk_raises(self, dialect): - from rhosocial.activerecord.backend.expression.statements import TableConstraintType from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - from types import SimpleNamespace - tc = SimpleNamespace(name=None, constraint_type=TableConstraintType.FOREIGN_KEY, columns=["user_id"]) + from rhosocial.activerecord.backend.expression.objects import Table + from rhosocial.activerecord.backend.expression.statements import ( + TableConstraint, + TableForeignKeyConstraint, + ) + + tc = TableConstraint( + dialect, + branch=TableForeignKeyConstraint( + dialect, ["user_id"], (Table(dialect, "users"), ["id"]) + ), + ) with pytest.raises(UnsupportedFeatureError): dialect.format_table_constraint(tc) def test_format_column_definition_auto_increment_raises(self, dialect): from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - from rhosocial.activerecord.backend.expression.statements import ColumnConstraint, ColumnConstraintType + from rhosocial.activerecord.backend.expression.statements import ColumnConstraint, ColumnPrimaryKeyConstraint from rhosocial.activerecord.backend.expression.statements import ColumnDefinition from rhosocial.activerecord.backend.expression.types import IntegerType @@ -218,7 +254,7 @@ def test_format_column_definition_auto_increment_raises(self, dialect): dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)], + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, True))], ) with pytest.raises(UnsupportedFeatureError): dialect.format_column_definition(col_def) diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/cli/test_cli_blackbox.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/cli/test_cli_blackbox.py index fce3519..0acdb5b 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/cli/test_cli_blackbox.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/cli/test_cli_blackbox.py @@ -6,14 +6,17 @@ Live server scenarios come from tests/config/clickhouse_scenarios.yaml. """ +import argparse import io import json from contextlib import redirect_stderr, redirect_stdout import pytest +from providers.scenarios import get_scenario_raw from rhosocial.activerecord.backend.impl.clickhouse.__main__ import main -from providers.scenarios import get_scenario_raw +from rhosocial.activerecord.backend.impl.clickhouse.cli import introspect as introspect_mod +from rhosocial.activerecord.backend.impl.clickhouse.cli.introspect import INTROSPECT_TYPES COMMANDS = [ "info", @@ -33,11 +36,16 @@ def conn_args(): """Connection args from the first available scenario.""" backend_cls, config = get_scenario_raw("clickhouse_84") return [ - "--host", config.host, - "--port", str(config.port), - "--database", config.database, - "--user", config.username, - "--password", config.password, + "--host", + config.host, + "--port", + str(config.port), + "--database", + config.database, + "--user", + config.username, + "--password", + config.password, ] @@ -106,9 +114,342 @@ def test_describe(self, tmp_path): ) env = dict(os.environ, PYTHONPATH=os.pathsep.join([str(mod_dir)] + sys.path)) proc = subprocess.run( - [sys.executable, "-m", "rhosocial.activerecord.backend.impl.clickhouse", - "named-connection", "--describe", "connections.prod_db"], - capture_output=True, text=True, env=env, + [ + sys.executable, + "-m", + "rhosocial.activerecord.backend.impl.clickhouse", + "named-connection", + "--describe", + "connections.prod_db", + ], + capture_output=True, + text=True, + env=env, ) assert proc.returncode == 0, proc.stderr - assert "Resolved Configuration" in (proc.stdout + proc.stderr) \ No newline at end of file + assert "Resolved Configuration" in (proc.stdout + proc.stderr) + + +# --------------------------------------------------------------------------- +# Offline introspect coverage: a fake backend answers, so no server is needed. +# --------------------------------------------------------------------------- + +#: The types whose NAME positional is a table name. The rest are asked without +#: one: this class of test is about the type reaching its branch, not NAME. +_NAME_FOR = { + "columns": "users", + "indexes": "users", + "partitions": "users", + "table-engine": "users", +} + + +def parse_introspect_args(argv): + parser = argparse.ArgumentParser(prog="clickhouse") + subparsers = parser.add_subparsers(dest="command") + introspect_mod.create_parser(subparsers) + return parser.parse_args(argv) + + +class FakeIntrospector: + """Records which catalogue question the CLI reached.""" + + def __init__(self): + self.calls = [] + + def list_tables(self, schema=None, include_system=False): + self.calls.append(("list_tables", schema, include_system)) + return [{"name": "users", "table_type": "BASE_TABLE"}] + + def list_views(self, schema=None): + self.calls.append(("list_views", schema)) + return [{"name": "v1", "definition": "SELECT 1"}] + + def get_view_info(self, view_name, schema=None): + self.calls.append(("get_view_info", view_name, schema)) + if view_name != "v1": + return None + return {"name": "v1", "definition": "SELECT 1", "schema": schema} + + def list_columns(self, table_name, schema=None): + self.calls.append(("list_columns", table_name, schema)) + return [{"name": "id", "data_type": "Int64"}] + + def list_indexes(self, table_name, schema=None): + self.calls.append(("list_indexes", table_name, schema)) + return [{"name": "idx_id"}] + + def list_partitions(self, table_name, database=None): + self.calls.append(("list_partitions", table_name, database)) + return [{"partition_id": "all"}] + + def get_table_engine(self, table_name, database=None): + self.calls.append(("get_table_engine", table_name, database)) + return {"table_name": table_name, "engine": "MergeTree"} + + def get_database_info(self): + self.calls.append(("get_database_info",)) + return {"name": "app", "version": "26.7.1"} + + +class FakeAsyncIntrospector: + """The same questions, answered as coroutines. + + Only the unsuffixed names exist here, exactly as on the real + ``AsyncClickHouseIntrospector`` -- the sync and async introspectors share + one method name, so a call to ``list_tables_async`` fails here the same way + it failed against the real object. + """ + + def __init__(self, sync): + self._sync = sync + self.calls = sync.calls + + async def list_tables(self, schema=None, include_system=False): + return self._sync.list_tables(schema=schema, include_system=include_system) + + async def list_views(self, schema=None): + return self._sync.list_views(schema=schema) + + async def get_view_info(self, view_name, schema=None): + return self._sync.get_view_info(view_name, schema=schema) + + async def list_columns(self, table_name, schema=None): + return self._sync.list_columns(table_name, schema=schema) + + async def list_indexes(self, table_name, schema=None): + return self._sync.list_indexes(table_name, schema=schema) + + async def list_partitions(self, table_name, database=None): + return self._sync.list_partitions(table_name, database=database) + + async def get_table_engine(self, table_name, database=None): + return self._sync.get_table_engine(table_name, database=database) + + async def get_database_info(self): + return self._sync.get_database_info() + + +class FakeBackend: + last_instance = None + + def __init__(self, connection_config=None): + self.connection_config = connection_config + self._connection = None + self.disconnect_calls = 0 + self.introspector = FakeIntrospector() + FakeBackend.last_instance = self + + def connect(self): + self._connection = object() + + def introspect_and_adapt(self): + """The real backend learns the server version here before rendering.""" + self.adapt_calls = getattr(self, "adapt_calls", 0) + 1 + + def disconnect(self): + self.disconnect_calls += 1 + self._connection = None + + +class FakeAsyncBackend(FakeBackend): + def __init__(self, connection_config=None): + super().__init__(connection_config) + self.introspector = FakeAsyncIntrospector(self.introspector) + + async def connect(self): + self._connection = object() + + async def introspect_and_adapt(self): + self.adapt_calls = getattr(self, "adapt_calls", 0) + 1 + + async def disconnect(self): + self.disconnect_calls += 1 + self._connection = None + + +class TestIntrospectBranches: + @pytest.mark.parametrize("introspect_type", INTROSPECT_TYPES) + @pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"]) + def test_every_declared_type_reaches_the_introspector( + self, monkeypatch, capsys, introspect_type, use_async + ): + """Each declared type lands on a branch, with or without ``--async``. + + The asynchronous handler used to call ``list_tables_async`` and four + more names the introspector does not have: sync and async are two + classes with one set of method names here. The resulting + ``AttributeError`` was swallowed by the catch-all into "Error during + introspection" and exit 1, so the async path answered nothing while + reading like a server failure. Reaching the introspector at all is the + thing the misspelt names did not do. + """ + backend_class = FakeAsyncBackend if use_async else FakeBackend + monkeypatch.setattr(introspect_mod, "ClickHouseBackend", FakeBackend) + monkeypatch.setattr(introspect_mod, "AsyncClickHouseBackend", FakeAsyncBackend) + + argv = ["introspect", introspect_type] + if introspect_type in _NAME_FOR: + argv.append(_NAME_FOR[introspect_type]) + if use_async: + argv.append("--async") + argv += ["--database", "app", "-o", "json"] + + introspect_mod.handle(parse_introspect_args(argv)) + + backend = backend_class.last_instance + assert backend.introspector.calls, f"'{introspect_type}' never reached the introspector" + assert backend.disconnect_calls == 1 + assert "Error during introspection" not in capsys.readouterr().err + + +class TestIntrospectName: + @pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"]) + def test_views_with_a_name_describes_one_view(self, monkeypatch, capsys, use_async): + """``views NAME`` answers for that view, not for the whole database.""" + backend_class = FakeAsyncBackend if use_async else FakeBackend + monkeypatch.setattr(introspect_mod, "ClickHouseBackend", FakeBackend) + monkeypatch.setattr(introspect_mod, "AsyncClickHouseBackend", FakeAsyncBackend) + + argv = ["introspect", "views", "v1"] + if use_async: + argv.append("--async") + argv += ["--database", "app", "-o", "json"] + + introspect_mod.handle(parse_introspect_args(argv)) + + data = json.loads(capsys.readouterr().out) + assert [item["name"] for item in data] == ["v1"] + assert data[0]["definition"] + assert backend_class.last_instance.introspector.calls == [("get_view_info", "v1", None)] + + @pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"]) + def test_an_unknown_view_exits_1(self, monkeypatch, capsys, use_async): + """A named view that does not exist is a failed request, not empty output.""" + monkeypatch.setattr(introspect_mod, "ClickHouseBackend", FakeBackend) + monkeypatch.setattr(introspect_mod, "AsyncClickHouseBackend", FakeAsyncBackend) + + argv = ["introspect", "views", "nope"] + if use_async: + argv.append("--async") + argv += ["--database", "app"] + + with pytest.raises(SystemExit) as excinfo: + introspect_mod.handle(parse_introspect_args(argv)) + assert excinfo.value.code == 1 + assert "View 'nope' not found" in capsys.readouterr().err + + @pytest.mark.parametrize("introspect_type", ["tables", "database"]) + def test_a_name_the_type_cannot_use_exits_2(self, monkeypatch, capsys, introspect_type): + """A NAME that would be ignored is refused instead of swallowed. + + ``tables`` and ``database`` answer for the whole database. They used to + accept a name and drop it, so the run printed a full listing that read + as though it had been narrowed to one object. + """ + monkeypatch.setattr(introspect_mod, "ClickHouseBackend", FakeBackend) + FakeBackend.last_instance = None + + with pytest.raises(SystemExit) as excinfo: + introspect_mod.handle( + parse_introspect_args(["introspect", introspect_type, "users", "--database", "app"]) + ) + assert excinfo.value.code == 2 + assert "takes no name" in capsys.readouterr().err + assert FakeBackend.last_instance is None + + +class TestTheAsyncRefusal: + def test_async_is_refused_with_a_sentence_not_a_traceback(self, capsys): + """``AsyncClickHouseBackend`` raises in its constructor. + + Uncaught, that reached the user as a traceback out of ``handle()``. + The refusal is the same sentence ``query`` uses. + """ + with pytest.raises(SystemExit) as excinfo: + introspect_mod.handle( + parse_introspect_args(["introspect", "tables", "--database", "app", "--async"]) + ) + + assert excinfo.value.code == 1 + assert "--async is not supported" in capsys.readouterr().err + + +class FakeDialect: + """Every capability switch answers True unless a test says otherwise.""" + + def __getattr__(self, name): + if name.startswith("supports_"): + return lambda: True + raise AttributeError(name) + + +class TestIntrospectCapabilities: + def test_the_advertised_types_are_the_capability_mapping(self): + """One source for the list: the mapping, not a second hand-written copy.""" + assert list(introspect_mod._TYPE_CAPABILITIES) == INTROSPECT_TYPES + + def test_every_type_names_a_switch_the_dialect_has(self): + """A type whose switch does not exist would be un-gated, not gated.""" + from rhosocial.activerecord.backend.impl.clickhouse.dialect import ClickHouseDialect + + dialect = ClickHouseDialect(version=(26, 7, 1)) + missing = [ + (cli_type, switch) + for cli_type, switch in introspect_mod._TYPE_CAPABILITIES.items() + if not hasattr(dialect, switch) + ] + assert missing == [] + + def test_the_types_the_help_offers(self): + """``--help`` lists what the parser accepts, and nothing it does not.""" + out, _, exc = run_cli(["introspect", "--help"]) + assert exc is not None and exc.code == 0 + for introspect_type in INTROSPECT_TYPES: + assert introspect_type in out + assert "foreign-keys" not in out + assert "triggers" not in out + + def test_the_aggregate_table_type_is_gone(self): + """``table`` answered three catalogue questions and could not be JSON.""" + assert "table" not in INTROSPECT_TYPES + with pytest.raises(SystemExit) as excinfo: + parse_introspect_args(["introspect", "table", "users"]) + assert excinfo.value.code == 2 + + @pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"]) + def test_a_type_the_server_cannot_answer_exits_2(self, monkeypatch, capsys, use_async): + """A declared family the connected server lacks is refused, not run. + + "This server has no views" and "this database has none" are different + answers; an empty list would read as the second. + """ + + class GatedDialect(FakeDialect): + def supports_view_introspection(self): + return False + + class GatedBackend(FakeBackend): + def __init__(self, connection_config=None): + super().__init__(connection_config) + self.dialect = GatedDialect() + + class GatedAsyncBackend(FakeAsyncBackend): + def __init__(self, connection_config=None): + super().__init__(connection_config) + self.dialect = GatedDialect() + + monkeypatch.setattr(introspect_mod, "ClickHouseBackend", GatedBackend) + monkeypatch.setattr(introspect_mod, "AsyncClickHouseBackend", GatedAsyncBackend) + + argv = ["introspect", "views"] + if use_async: + argv.append("--async") + argv += ["--database", "app"] + + with pytest.raises(SystemExit) as excinfo: + introspect_mod.handle(parse_introspect_args(argv)) + + assert excinfo.value.code == 2 + assert "views introspection is not supported by this server" in capsys.readouterr().err + assert GatedBackend.last_instance.introspector.calls == [] diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/README.md b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/README.md index fe553ad..37689ed 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/README.md +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/README.md @@ -2,12 +2,12 @@ DDL statement and expression-level tests (no live server needed). -- `test_create_table_expression_diff.py` — `CreateTableExpression.diff()` +- `test_create_table_expression_diff.py` — `CreateTableStatement.diff()` for the ClickHouse dialect: hook overrides (`MODIFY COLUMN` type changes, property/index/constraint changes → rebuild), DiffPlan/RebuildPlan shapes, rendered SQL, and regressions for the pre-override behaviors. - `test_create_table_like.py` — ClickHouse structure copy via - `CreateTableLikeExpression` rendered as `CREATE TABLE ... AS ` + `CreateTableLikeStatement` rendered as `CREATE TABLE ... AS ` (ClickHouse has no `LIKE` keyword): IF NOT EXISTS / TEMPORARY combinations, schema-qualified sources, and the explicit-schema form. - `test_ddl_generation.py` — `format_create_table_statement` output: type diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_create_table_like.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_create_table_like.py index a8b0e58..2668aca 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_create_table_like.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_create_table_like.py @@ -14,16 +14,16 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression import ( ColumnDefinition, - CreateTableExpression, - CreateTableLikeExpression, + CreateTableStatement, + CreateTableLikeStatement, ) from rhosocial.activerecord.backend.expression.objects import Table, View from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraint, - ColumnConstraintType, + ColumnPrimaryKeyConstraint, ColumnNotNullConstraint, ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableCloneExpression, + CreateTableCloneStatement, CreateTableCloneMode, ) from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType @@ -31,22 +31,22 @@ def _statement(dialect, table, like_table, **kwargs): - """Build a CreateTableLikeExpression naming *table* and *like_table*.""" - return CreateTableLikeExpression( + """Build a CreateTableLikeStatement naming *table* and *like_table*.""" + return CreateTableLikeStatement( dialect=dialect, table=table, like_table=like_table, **kwargs ) def _create_table(dialect, table, columns, **kwargs): - """Build a CreateTableExpression naming *table*.""" - return CreateTableExpression( + """Build a CreateTableStatement naming *table*.""" + return CreateTableStatement( dialect=dialect, table=table, columns=columns, **kwargs ) def _clone(dialect, table, source_table, **kwargs): - """Build a CreateTableCloneExpression naming *table* and *source_table*.""" - return CreateTableCloneExpression( + """Build a CreateTableCloneStatement naming *table* and *source_table*.""" + return CreateTableCloneStatement( dialect=dialect, table=table, source_table=source_table, **kwargs ) @@ -157,20 +157,31 @@ def test_a_view_where_a_table_belongs_is_refused(self): expr.to_sql() def test_explicit_schema_still_renders(self): - """The explicit-schema form is unaffected by the AS override.""" + """The explicit-schema form is unaffected by the AS override. + + The ``id`` column carries a PRIMARY KEY column constraint as incidental + test data, and it renders: on this core the flat + ``supports_primary_key_constraint`` gates the ``PRIMARY KEY`` column + *modifier* (which the server accepts -- it is in the parser's + expected-token list for a column), not the constraint object + (``system.constraints`` holds 0 rows for a table built with + ``PRIMARY KEY (id)``; ``ADD CONSTRAINT ... PRIMARY KEY`` is Code 62 -- + the per-level switches carry that answer and are both ``False``). See + ``supports_primary_key_constraint`` in ``mixins/constraint.py``. + """ dialect = ClickHouseDialect() columns = [ ColumnDefinition( dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)], + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))], ), ColumnDefinition( dialect, "name", VarCharType(length=255, dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], ), ] sql, params = _create_table(dialect, Table(dialect, "users"), columns).to_sql() @@ -261,7 +272,7 @@ def test_create_table_refuses_a_non_table_target(self): dialect = ClickHouseDialect() columns = [ColumnDefinition(dialect, "id", IntegerType(dialect))] expr = _create_table(dialect, "users_copy", columns) - with pytest.raises(TypeError, match="CreateTableExpression.table must be a Table"): + with pytest.raises(TypeError, match="CreateTableStatement.table must be a Table"): expr.to_sql() def test_create_table_refuses_a_view_target(self): diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_ddl_coverage.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_ddl_coverage.py index ea55f8f..0ce75a3 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_ddl_coverage.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_ddl_coverage.py @@ -19,11 +19,11 @@ import pytest from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - AlterColumn, - AlterTableExpression, + AlterColumnAction, + AlterTableStatement, ColumnAlterOperation, ) -from rhosocial.activerecord.backend.expression.statements.ddl_truncate import TruncateExpression +from rhosocial.activerecord.backend.expression.statements.ddl_truncate import TruncateStatement from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.impl.clickhouse import expression as clickhouse_expr @@ -39,18 +39,18 @@ class TestRenameTable: """Test ClickHouse RENAME TABLE statement.""" def test_single_rename(self, dialect): - expr = clickhouse_expr.ClickHouseRenameTableExpression(dialect, [("old_name", "new_name")]) + expr = clickhouse_expr.ClickHouseRenameTableStatement(dialect, [("old_name", "new_name")]) sql, params = expr.to_sql() assert sql == "RENAME TABLE `old_name` TO `new_name`" assert params == () def test_multi_rename(self, dialect): - expr = clickhouse_expr.ClickHouseRenameTableExpression(dialect, [("a", "b"), ("c", "d")]) + expr = clickhouse_expr.ClickHouseRenameTableStatement(dialect, [("a", "b"), ("c", "d")]) sql, params = expr.to_sql() assert sql == "RENAME TABLE `a` TO `b`, `c` TO `d`" def test_empty_raises(self, dialect): - expr = clickhouse_expr.ClickHouseRenameTableExpression(dialect, []) + expr = clickhouse_expr.ClickHouseRenameTableStatement(dialect, []) with pytest.raises(ValueError, match="at least one"): expr.to_sql() @@ -63,7 +63,7 @@ class TestTruncateTable: """Test ClickHouse TRUNCATE TABLE statement.""" def test_basic(self, dialect): - sql, params = TruncateExpression(dialect, table=Table(dialect, "users")).to_sql() + sql, params = TruncateStatement(dialect, table=Table(dialect, "users")).to_sql() assert sql == "TRUNCATE TABLE `users`" assert params == () @@ -75,11 +75,11 @@ def test_supports_flags(self, dialect): def test_restart_identity_unsupported(self, dialect): with pytest.raises(UnsupportedFeatureError): - TruncateExpression(dialect, table=Table(dialect, "users"), restart_identity=True).to_sql() + TruncateStatement(dialect, table=Table(dialect, "users"), restart_identity=True).to_sql() def test_cascade_unsupported(self, dialect): with pytest.raises(UnsupportedFeatureError): - TruncateExpression(dialect, table=Table(dialect, "users"), cascade=True).to_sql() + TruncateStatement(dialect, table=Table(dialect, "users"), cascade=True).to_sql() class TestAlterColumnDefault: @@ -94,29 +94,31 @@ class TestAlterColumnDefault: """ def test_set_default_string(self, dialect): - action = AlterColumn(dialect, "col", ColumnAlterOperation.SET_DEFAULT, new_value="ABC") - sql, params = AlterTableExpression(dialect, Table(dialect, "t"), [action]).to_sql() + action = AlterColumnAction(dialect, "col", ColumnAlterOperation.SET_DEFAULT, new_value="ABC") + sql, params = AlterTableStatement(dialect, Table(dialect, "t"), [action]).to_sql() assert "ALTER COLUMN `col` DEFAULT 'ABC'" in sql assert params == () def test_set_default_integer(self, dialect): - action = AlterColumn(dialect, "num", ColumnAlterOperation.SET_DEFAULT, new_value=5) - sql, _ = AlterTableExpression(dialect, Table(dialect, "t"), [action]).to_sql() + action = AlterColumnAction(dialect, "num", ColumnAlterOperation.SET_DEFAULT, new_value=5) + sql, _ = AlterTableStatement(dialect, Table(dialect, "t"), [action]).to_sql() assert "ALTER COLUMN `num` DEFAULT 5" in sql def test_drop_default_uses_modify_remove(self, dialect): - action = AlterColumn(dialect, "col", ColumnAlterOperation.DROP_DEFAULT) - sql, params = AlterTableExpression(dialect, Table(dialect, "t"), [action]).to_sql() + action = AlterColumnAction(dialect, "col", ColumnAlterOperation.DROP_DEFAULT) + sql, params = AlterTableStatement(dialect, Table(dialect, "t"), [action]).to_sql() assert "MODIFY COLUMN `col` REMOVE DEFAULT" in sql assert params == () def test_no_set_or_drop_default_keyword_is_rendered(self, dialect): """The two spellings the server rejects must not appear in the output.""" - set_sql, _ = AlterTableExpression( - dialect, Table(dialect, "t"), [AlterColumn(dialect, "col", ColumnAlterOperation.SET_DEFAULT, new_value=1)] + set_sql, _ = AlterTableStatement( + dialect, + Table(dialect, "t"), + [AlterColumnAction(dialect, "col", ColumnAlterOperation.SET_DEFAULT, new_value=1)], ).to_sql() - drop_sql, _ = AlterTableExpression( - dialect, Table(dialect, "t"), [AlterColumn(dialect, "col", ColumnAlterOperation.DROP_DEFAULT)] + drop_sql, _ = AlterTableStatement( + dialect, Table(dialect, "t"), [AlterColumnAction(dialect, "col", ColumnAlterOperation.DROP_DEFAULT)] ).to_sql() assert "SET DEFAULT" not in set_sql assert "DROP DEFAULT" not in drop_sql @@ -136,16 +138,16 @@ def test_both_renderings_execute_on_the_server(self, clickhouse_backend_single): backend.execute("CREATE TABLE ar_alter_default (id UInt32) ENGINE = MergeTree ORDER BY id") try: d = backend.dialect - backend.execute(*AlterTableExpression( + backend.execute(*AlterTableStatement( d, Table(d, "ar_alter_default"), - [AlterColumn(d, "id", ColumnAlterOperation.SET_DEFAULT, new_value=7)], + [AlterColumnAction(d, "id", ColumnAlterOperation.SET_DEFAULT, new_value=7)], ).to_sql()) create = backend.execute("SHOW CREATE TABLE ar_alter_default").data[0]["statement"] assert "DEFAULT 7" in create - backend.execute(*AlterTableExpression( + backend.execute(*AlterTableStatement( d, Table(d, "ar_alter_default"), - [AlterColumn(d, "id", ColumnAlterOperation.DROP_DEFAULT)], + [AlterColumnAction(d, "id", ColumnAlterOperation.DROP_DEFAULT)], ).to_sql()) create = backend.execute("SHOW CREATE TABLE ar_alter_default").data[0]["statement"] assert "DEFAULT" not in create diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_ddl_generation.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_ddl_generation.py index 30cd3e2..527b648 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_ddl_generation.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_ddl_generation.py @@ -8,18 +8,67 @@ import pytest -from rhosocial.activerecord.backend.impl.clickhouse.dialect import ClickHouseDialect -from rhosocial.activerecord.backend.expression.statements import ColumnDefinition, CreateTableExpression +from rhosocial.activerecord.backend.expression.core import Column, FunctionCall from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + CreateTableStatement, +) from rhosocial.activerecord.backend.expression.types import ( + BooleanType, + DateTimeType, + DecimalType, IntegerType, VarCharType, - DecimalType, - DateTimeType, - BooleanType, +) +from rhosocial.activerecord.backend.impl.clickhouse.dialect import ClickHouseDialect +from rhosocial.activerecord.backend.impl.clickhouse.expression import ( + ClickHouseCreateTableOptions, + ClickHouseEngineSpec, + ClickHouseSetting, + ClickHouseStorageOptionKind, + ClickHouseTableStorageOption, ) +def _storage(dialect, engine=None, *, order_by=(), partition_by=None, settings=()): + """Build the ClickHouse storage clause run for a CREATE TABLE.""" + clauses = [] + if engine is not None: + clauses.append( + ClickHouseTableStorageOption( + dialect, + ClickHouseStorageOptionKind.ENGINE, + value=ClickHouseEngineSpec(dialect, engine), + ) + ) + if order_by: + clauses.append( + ClickHouseTableStorageOption( + dialect, + ClickHouseStorageOptionKind.ORDER_BY, + value=[Column(dialect, name) for name in order_by], + ) + ) + if partition_by is not None: + clauses.append( + ClickHouseTableStorageOption( + dialect, + ClickHouseStorageOptionKind.PARTITION_BY, + value=[partition_by], + ) + ) + if settings: + clauses.append( + ClickHouseTableStorageOption( + dialect, + ClickHouseStorageOptionKind.SETTINGS, + value=[ClickHouseSetting(dialect, name, value) for name, value in settings], + ) + ) + return clauses + + @pytest.fixture def dialect(): return ClickHouseDialect(version=(26, 7, 3)) @@ -28,34 +77,40 @@ def dialect(): class TestClickHouseDDLGeneration: def test_create_table_basic(self, dialect): """CREATE TABLE with ClickHouse storage options.""" - expr = CreateTableExpression( + expr = CreateTableStatement( dialect, Table(dialect, "users"), [ ColumnDefinition(dialect, "id", IntegerType(dialect)), ColumnDefinition(dialect, "name", VarCharType(length=100, dialect=dialect)), ], - storage_options={"ENGINE": "MergeTree()", "ORDER BY": "id"}, + table_options=ClickHouseCreateTableOptions( + dialect, + table_storage_options=_storage(dialect, "MergeTree", order_by=("id",)), + ), ) sql, _ = expr.to_sql() assert "CREATE TABLE" in sql - assert "ENGINE = MergeTree()" in sql - assert "ORDER BY = id" in sql or "ORDER BY id" in sql + assert "ENGINE = MergeTree" in sql + assert "ORDER BY `id`" in sql def test_create_table_if_not_exists(self, dialect): - expr = CreateTableExpression( + expr = CreateTableStatement( dialect, Table(dialect, "events"), [ColumnDefinition(dialect, "id", IntegerType(dialect))], if_not_exists=True, - storage_options={"ENGINE": "MergeTree()", "ORDER BY": "id"}, + table_options=ClickHouseCreateTableOptions( + dialect, + table_storage_options=_storage(dialect, "MergeTree", order_by=("id",)), + ), ) sql, _ = expr.to_sql() assert "IF NOT EXISTS" in sql def test_create_table_types_map_to_clickhouse(self, dialect): """Core types map to ClickHouse equivalents.""" - expr = CreateTableExpression( + expr = CreateTableStatement( dialect, Table(dialect, "t"), [ @@ -65,7 +120,6 @@ def test_create_table_types_map_to_clickhouse(self, dialect): ColumnDefinition(dialect, "ts", DateTimeType(dialect)), ColumnDefinition(dialect, "active", BooleanType(dialect)), ], - storage_options={"ENGINE": "MergeTree()", "ORDER BY": "id"}, ) sql, _ = expr.to_sql() assert "Int32" in sql # IntegerType @@ -76,38 +130,60 @@ def test_create_table_types_map_to_clickhouse(self, dialect): def test_create_table_engine_clauses(self, dialect): """Full ENGINE / ORDER BY / PARTITION BY clauses.""" - expr = CreateTableExpression( + expr = CreateTableStatement( dialect, Table(dialect, "metrics"), [ ColumnDefinition(dialect, "id", IntegerType(dialect)), ColumnDefinition(dialect, "ts", DateTimeType(dialect)), ], - storage_options={ - "ENGINE": "MergeTree()", - "ORDER BY": "id", - "PARTITION BY": "toYYYYMM(ts)", - }, + table_options=ClickHouseCreateTableOptions( + dialect, + table_storage_options=_storage( + dialect, + "MergeTree", + order_by=("id",), + partition_by=FunctionCall(dialect, "toYYYYMM", Column(dialect, "ts")), + ), + ), ) sql, _ = expr.to_sql() - assert "ENGINE = MergeTree()" in sql - assert "PARTITION BY = toYYYYMM(ts)" in sql or "PARTITION BY toYYYYMM(ts)" in sql + assert "ENGINE = MergeTree" in sql + assert "PARTITION BY TOYYYYMM(" in sql class TestClickHouseTableEngineClauses: def test_table_engine_clauses_formatting(self, dialect): - result, params = dialect.format_table_engine_clauses({ - "ENGINE": "MergeTree()", - "ORDER BY": ["id", "ts"], - "PARTITION BY": "toYYYYMM(ts)", - "TTL": "ts + INTERVAL 30 DAY", - "SETTINGS": "index_granularity = 8192", - }) - assert "ENGINE = MergeTree()" in result - assert "ORDER BY (id, ts)" in result - assert "PARTITION BY toYYYYMM(ts)" in result + from rhosocial.activerecord.backend.expression.operators import RawSQL + + clauses = _storage( + dialect, + "MergeTree", + order_by=("id", "ts"), + partition_by=FunctionCall(dialect, "toYYYYMM", Column(dialect, "ts")), + settings=[("index_granularity", 8192)], + ) + clauses.append( + ClickHouseTableStorageOption( + dialect, + ClickHouseStorageOptionKind.TTL, + value=RawSQL(dialect, "ts + INTERVAL 30 DAY"), + ) + ) + rendered = [] + for clause in sorted(clauses, key=lambda item: item.option.rank): + clause_sql, _ = clause.to_sql() + rendered.append(clause_sql) + result = " ".join(rendered) + + assert "ENGINE = MergeTree" in result + assert "ORDER BY (`id`, `ts`)" in result + assert "PARTITION BY TOYYYYMM(" in result assert "TTL ts + INTERVAL 30 DAY" in result assert "SETTINGS index_granularity = 8192" in result + # ENGINE comes first, SETTINGS last, whatever order they were declared in. + assert result.startswith("ENGINE = ") + assert result.endswith("= 8192") def test_table_engine_capabilities(self, dialect): assert dialect.supports_table_engine() is True @@ -175,3 +251,45 @@ def test_parse_enum_type(self, dialect): sql, _ = parsed.to_sql() assert sql.startswith("Enum8(") assert "'a'" in sql and "'b'" in sql + + +class TestClickHouseStorageOptionContract: + """The validation and spelling rules the option nodes own.""" + + def test_repeated_option_is_refused_at_construction(self, dialect): + engine = ClickHouseEngineSpec(dialect, "MergeTree") + + def clause(): + return ClickHouseTableStorageOption( + dialect, ClickHouseStorageOptionKind.ENGINE, value=engine + ) + + with pytest.raises(ValueError, match="repeats ENGINE"): + ClickHouseCreateTableOptions(dialect, table_storage_options=[clause(), clause()]) + + def test_wrong_payload_names_the_member_and_the_type(self, dialect): + with pytest.raises(TypeError, match="ENGINE takes a ClickHouseEngineSpec, got str"): + ClickHouseTableStorageOption( + dialect, ClickHouseStorageOptionKind.ENGINE, value="MergeTree" + ) + + def test_empty_expression_list_is_refused(self, dialect): + with pytest.raises(ValueError, match="ORDER BY takes at least one expression"): + ClickHouseTableStorageOption( + dialect, ClickHouseStorageOptionKind.ORDER_BY, value=[] + ) + + def test_option_accepts_its_spelling_back(self, dialect): + """A reader rebuilding the node hands the artifact's word, not a member.""" + clause = ClickHouseTableStorageOption( + dialect, "ORDER BY", value=[Column(dialect, "id")] + ) + assert clause.option is ClickHouseStorageOptionKind.ORDER_BY + assert clause.to_sql() == ("ORDER BY `id`", ()) + + def test_unknown_spelling_is_refused(self, dialect): + with pytest.raises(ValueError, match="NOT A CLAUSE"): + ClickHouseTableStorageOption( + dialect, "NOT A CLAUSE", value=[Column(dialect, "id")] + ) + 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 index 08deb9e..4c3a92c 100644 --- 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 @@ -12,13 +12,19 @@ from rhosocial.activerecord.backend.expression.core import Column, Literal from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.expression.statements import ( - ColumnConstraintType, - CreateTableExpression, + ColumnCheckConstraint, + ColumnDefaultConstraint, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, + CreateTableStatement, CreateTableOptions, IndexDefinition, StorageOptionsExpression, + TableCheckConstraint, TableConstraint, - TableConstraintType, + TablePrimaryKeyConstraint, + TableUniqueConstraint, ) from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements.ddl_partition import ( @@ -51,22 +57,24 @@ NATIVE_TYPES = (ClickHouseStringType(), ClickHouseInt64Type()) SQL_TYPE = UseSqlType(*NATIVE_TYPES) VALUE_COLUMN = UseColumn("physical_value") +# ``UseConstraint`` takes the branch **class** and that branch's own fields +# (core's S3 split): the kind is no longer a tag argument. DEFAULT_CONSTRAINT = UseConstraint( - ColumnConstraintType.DEFAULT, + ColumnDefaultConstraint, name="dflt_value", - default_value=17, + value=17, ) UNIQUE_CONSTRAINT = UseConstraint( - ColumnConstraintType.UNIQUE, + ColumnUniqueConstraint, name="uq_value", ) CHECK_CONDITION = ComparisonPredicate( None, "=", Column(None, "physical_value"), Literal(None, "active") ) CHECK_CONSTRAINT = UseConstraint( - ColumnConstraintType.CHECK, + ColumnCheckConstraint, name="ck_value", - check_condition=CHECK_CONDITION, + condition=CHECK_CONDITION, ) FIRST_INDEX_CONDITION = object() FIRST_INCLUDE_COLUMNS = ["id"] @@ -102,17 +110,16 @@ def generated_expression(dialect): TABLE_CONSTRAINT_ONE = TableConstraint( None, - TableConstraintType.UNIQUE, + branch=TableUniqueConstraint(None, ["physical_value", "quantity"]), name="uq_physical_quantity", - columns=["physical_value", "quantity"], ) TABLE_CONSTRAINT_TWO = TableConstraint( None, - TableConstraintType.CHECK, - name="ck_physical_quantity", - check_condition=ComparisonPredicate( - None, "=", Column(None, "physical_value"), Literal(None, "active") + branch=TableCheckConstraint( + None, + ComparisonPredicate(None, "=", Column(None, "physical_value"), Literal(None, "active")), ), + name="ck_physical_quantity", ) TABLE_CONSTRAINTS = [TABLE_CONSTRAINT_ONE, TABLE_CONSTRAINT_TWO] TABLE_INDEX_ONE = ClickHouseIndexDefinition( @@ -142,9 +149,10 @@ def generated_expression(dialect): TABLE_TABLESPACE = "ts_data" COMPOSITE_CONSTRAINT = TableConstraint( None, - TableConstraintType.CHECK, + # A composite PRIMARY KEY, which is what a two-column key lands on: the + # branch carries the columns, the production carries the name. + branch=TablePrimaryKeyConstraint(None, ["tenant_id", "order_id"]), name="ck_composite", - columns=["tenant_id", "order_id"], ) @@ -183,7 +191,7 @@ def table_tablespace(cls): @classmethod def create_table_statement_classes(cls): - return CreateTableExpression + return CreateTableStatement class DeclarationModel( @@ -211,7 +219,7 @@ class DeclarationModel( CLICKHOUSE_OPTIONS[0], CLICKHOUSE_OPTIONS[1], ] = None - quantity: Annotated[int, UseConstraint(ColumnConstraintType.DEFAULT, default_value=7)] = 1 + quantity: Annotated[int, UseConstraint(ColumnDefaultConstraint, value=7)] = 1 __table_constraints__ = TABLE_CONSTRAINTS __table_indexes__ = TABLE_INDEXES @@ -242,7 +250,7 @@ class AsyncDeclarationModel( CLICKHOUSE_OPTIONS[0], CLICKHOUSE_OPTIONS[1], ] = None - quantity: Annotated[int, UseConstraint(ColumnConstraintType.DEFAULT, default_value=7)] = 1 + quantity: Annotated[int, UseConstraint(ColumnDefaultConstraint, value=7)] = 1 __table_constraints__ = TABLE_CONSTRAINTS __table_indexes__ = TABLE_INDEXES @@ -344,15 +352,15 @@ def _assert_declaration_model(model): ) value_constraints = model.column_constraints("value") - assert [item.constraint_type for item in value_constraints] == [ - ColumnConstraintType.DEFAULT, - ColumnConstraintType.UNIQUE, - ColumnConstraintType.CHECK, + assert [type(item.branch) for item in value_constraints] == [ + ColumnDefaultConstraint, + ColumnUniqueConstraint, + ColumnCheckConstraint, ] assert value_constraints[0] is DEFAULT_CONSTRAINT.constraint assert value_constraints[1] is UNIQUE_CONSTRAINT.constraint assert value_constraints[2] is CHECK_CONSTRAINT.constraint - assert value_constraints[2].check_condition is CHECK_CONDITION + assert value_constraints[2].branch.condition is CHECK_CONDITION value_indexes = model.column_indexes("value") assert [item.name for item in value_indexes] == [ @@ -387,15 +395,15 @@ def _assert_declaration_model(model): 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, + assert [type(item.branch) for item in id_constraints] == [ + ColumnPrimaryKeyConstraint, + ColumnNotNullConstraint, ] quantity_constraints = model.column_constraints("quantity") assert model.model_fields["quantity"].default == 1 - assert quantity_constraints[0].constraint_type == ColumnConstraintType.DEFAULT - assert quantity_constraints[0].default_value == 7 - assert quantity_constraints[1].constraint_type == ColumnConstraintType.NOT_NULL + assert type(quantity_constraints[0].branch) is ColumnDefaultConstraint + assert quantity_constraints[0].branch.value == 7 + assert type(quantity_constraints[1].branch) is ColumnNotNullConstraint assert model.table_options() is TABLE_OPTIONS assert [item.or_replace for item in model.table_options()] == [True, False] @@ -404,7 +412,7 @@ def _assert_declaration_model(model): assert model.table_partition().keys[0].name == "physical_value" assert model.table_inherits() is TABLE_INHERITS assert model.table_tablespace() == TABLE_TABLESPACE - assert model.create_table_statement_classes() is CreateTableExpression + assert model.create_table_statement_classes() is CreateTableStatement assert model.table_constraints() == TABLE_CONSTRAINTS assert all( actual is expected @@ -483,16 +491,16 @@ def _assert_composite_model(model): } for field in ("tenant_ref", "order_ref"): constraints = model.column_constraints(field) - assert [item.constraint_type for item in constraints] == [ - ColumnConstraintType.NOT_NULL, + assert [type(item.branch) for item in constraints] == [ + ColumnNotNullConstraint, ] constraints = model.table_constraints() assert constraints[0] is COMPOSITE_CONSTRAINT - assert constraints[-1].constraint_type == TableConstraintType.PRIMARY_KEY - assert constraints[-1].columns == ["tenant_id", "order_id"] + assert type(constraints[-1].branch) is TablePrimaryKeyConstraint + assert constraints[-1].branch.columns == ["tenant_id", "order_id"] assert all( - item.constraint_type != TableConstraintType.PRIMARY_KEY + type(item.branch) is not TablePrimaryKeyConstraint for item in constraints[:-1] ) @@ -558,7 +566,7 @@ def test_collected_clickhouse_type_and_options_render_minimal_column(): try: column = option.column_definition_class()(dialect, "physical_value", data_type) option.apply_to(column) - expression = CreateTableExpression(dialect, Table(dialect, "rendered"), [column]) + expression = CreateTableStatement(dialect, Table(dialect, "rendered"), [column]) rendered = expression.to_sql() finally: data_type.dialect = None diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_literal_default_readbacks.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_literal_default_readbacks.py new file mode 100644 index 0000000..4133e81 --- /dev/null +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_literal_default_readbacks.py @@ -0,0 +1,156 @@ +# tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_literal_default_readbacks.py +"""The tier-2 literal spellings, verified end to end against the live server. + +Moved here from the expression area (``feature/backend/expression`` used to +hold them beside the render-only literal tests): these cases open a connection, +create tables and read rows back, which is backend integration by the org +rules (expression-dialect §12) -- expression tests stay construction → render, +and every check that needs a real database belongs in the scenario-driven +area this package already runs. + +The two spellings under test are the backend's whole tier-2 override: +``unhex('...')`` for bytes and the bare quoted text for a UUID, each exercised +as a column DEFAULT -- the path a DDL position actually takes -- with an +INSERT and a read-back to prove the stored value round-trips. + +Every verdict below was measured against the instance +``tests/config/clickhouse_scenarios.yaml`` names ``clickhouse_http``: +**ClickHouse 26.7.3.19 (official build)**, HTTP on ``127.0.0.1:18682``, +database ``test_db``. The statements and readbacks behind each verdict are +recorded in ``mixins/literal.py``. +""" + +import uuid as uuidlib + +_UUID = uuidlib.UUID("01234567-89ab-cdef-0123-456789abcdef") +_UUID_TEXT = "01234567-89ab-cdef-0123-456789abcdef" + + +def _table_options(dialect): + """The clause run these probes need: ``MergeTree`` sorted by ``id``.""" + from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.impl.clickhouse.expression import ( + ClickHouseCreateTableOptions, + ClickHouseEngineSpec, + ClickHouseStorageOptionKind, + ClickHouseTableStorageOption, + ) + + return ClickHouseCreateTableOptions( + dialect, + table_storage_options=[ + ClickHouseTableStorageOption( + dialect, + ClickHouseStorageOptionKind.ENGINE, + value=ClickHouseEngineSpec(dialect, "MergeTree"), + ), + ClickHouseTableStorageOption( + dialect, + ClickHouseStorageOptionKind.ORDER_BY, + value=[Column(dialect, "id")], + ), + ], + ) + + +class TestLiteralDefaultReadbacks: + """CREATE TABLE with the literal as a DEFAULT, INSERT, read back, compare.""" + + def _drop(self, backend, name): + backend.execute(f"DROP TABLE IF EXISTS {name}") + + def test_binary_default_roundtrip(self, clickhouse_backend): + from rhosocial.activerecord.backend.expression.objects import Table + from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, + ColumnDefaultConstraint, + ColumnDefinition, + CreateTableStatement, + ) + from rhosocial.activerecord.backend.expression.types import ( + IntegerType, + VarCharType, + ) + + backend = clickhouse_backend + dialect = backend.dialect + name = "probe_literal_bin" + self._drop(backend, name) + try: + # The bare bytes scalar: the DEFAULT clause normalises it into + # an inline Literal, which is the path under test. + create = CreateTableStatement( + dialect, + Table(dialect, name), + [ + ColumnDefinition(dialect, "id", IntegerType(dialect)), + ColumnDefinition( + dialect, + "s", + VarCharType(dialect=dialect), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnDefaultConstraint(dialect, b"\x01\x02"), + ) + ], + ), + ], + table_options=_table_options(dialect), + ) + sql, params = create.to_sql() + assert params == () + assert "unhex('0102')" in sql + backend.execute(sql, params) + backend.execute(f"INSERT INTO {name} (id) VALUES (1)") + result = backend.execute(f"SELECT hex(s) FROM {name}") + assert [row["hex(s)"] for row in result.data] == ["0102"] + finally: + self._drop(backend, name) + + def test_uuid_default_roundtrip(self, clickhouse_backend): + from rhosocial.activerecord.backend.expression.objects import Table + from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, + ColumnDefaultConstraint, + ColumnDefinition, + CreateTableStatement, + ) + from rhosocial.activerecord.backend.expression.types import ( + IntegerType, + UUIDType, + ) + + backend = clickhouse_backend + dialect = backend.dialect + name = "probe_literal_uuid" + self._drop(backend, name) + try: + create = CreateTableStatement( + dialect, + Table(dialect, name), + [ + ColumnDefinition(dialect, "id", IntegerType(dialect)), + ColumnDefinition( + dialect, + "u", + UUIDType(dialect), + constraints=[ + ColumnConstraint( + dialect, + branch=ColumnDefaultConstraint(dialect, _UUID), + ) + ], + ), + ], + table_options=_table_options(dialect), + ) + sql, params = create.to_sql() + assert params == () + assert f"'{_UUID_TEXT}'" in sql + backend.execute(sql, params) + backend.execute(f"INSERT INTO {name} (id) VALUES (1)") + result = backend.execute(f"SELECT toString(u) FROM {name}") + assert [row["toString(u)"] for row in result.data] == [_UUID_TEXT] + finally: + self._drop(backend, name) diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_partition_maintenance.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_partition_maintenance.py index 34de8f9..d15f613 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_partition_maintenance.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/ddl/test_partition_maintenance.py @@ -25,9 +25,9 @@ PartitionStrategy, ) from rhosocial.activerecord.backend.impl.clickhouse.expression import ( - ClickHouseAttachPartitionExpression, - ClickHouseDetachPartitionExpression, - ClickHouseDropPartitionExpression, + ClickHouseAttachPartitionStatement, + ClickHouseDetachPartitionStatement, + ClickHouseDropPartitionStatement, ) TABLE = "ar_clickhouse_partition_events" @@ -102,7 +102,7 @@ class TestPartitionMaintenanceClausesExecute: def test_detach_then_attach_round_trips(self, partitioned_backend): dialect = partitioned_backend.dialect - detach_sql, detach_params = ClickHouseDetachPartitionExpression( + detach_sql, detach_params = ClickHouseDetachPartitionStatement( dialect, TABLE, "202601" ).to_sql() assert detach_sql == f"ALTER TABLE `{TABLE}` DETACH PARTITION ID '202601'" @@ -110,7 +110,7 @@ def test_detach_then_attach_round_trips(self, partitioned_backend): assert _partition_ids(partitioned_backend) == ["202602"] assert _row_count(partitioned_backend) == 1 - attach_sql, attach_params = ClickHouseAttachPartitionExpression( + attach_sql, attach_params = ClickHouseAttachPartitionStatement( dialect, TABLE, "202601" ).to_sql() assert attach_sql == f"ALTER TABLE `{TABLE}` ATTACH PARTITION ID '202601'" @@ -119,7 +119,7 @@ def test_detach_then_attach_round_trips(self, partitioned_backend): assert _row_count(partitioned_backend) == 3 def test_drop_removes_the_partition(self, partitioned_backend): - sql, params = ClickHouseDropPartitionExpression( + sql, params = ClickHouseDropPartitionStatement( partitioned_backend.dialect, TABLE, "202601" ).to_sql() partitioned_backend.execute(sql, params) 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 index 97ed8a4..7b73956 100644 --- 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 @@ -20,19 +20,19 @@ DropTypeSupport, ) from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from rhosocial.activerecord.backend.expression import Literal +from rhosocial.activerecord.backend.expression.core import Literal from rhosocial.activerecord.backend.expression.objects import Domain, Type from rhosocial.activerecord.backend.expression.statements import ( - AlterDomainExpression, - AlterTypeExpression, - CreateDomainExpression, - CreateTypeExpression, + AlterDomainStatement, + AlterTypeStatement, + CreateDomainStatement, + CreateTypeStatement, DomainCheckConstraint, DomainNullability, DomainValueExpression, DropDomainDefaultAction, - DropDomainExpression, - DropTypeExpression, + DropDomainStatement, + DropTypeStatement, ) from rhosocial.activerecord.backend.expression.statements.ddl_type import ( TypeAlterAction, @@ -179,19 +179,19 @@ def test_type_formatters_and_expressions_fail_fast(dialect: ClickHouseDialect) - action = _NegativeTypeAlterAction(dialect) status = Type(dialect, "status") expressions = ( - CreateTypeExpression( + CreateTypeStatement( dialect, status, definition, if_not_exists=True, ), - AlterTypeExpression( + AlterTypeStatement( dialect, status, [action], if_exists=True, ), - DropTypeExpression(dialect, status, if_exists=True), + DropTypeStatement(dialect, status, if_exists=True), definition, action, ) @@ -204,24 +204,23 @@ def test_type_formatters_and_expressions_fail_fast(dialect: ClickHouseDialect) - def test_domain_formatters_and_expressions_fail_fast(dialect: ClickHouseDialect) -> None: - condition = DomainValueExpression(dialect) > Literal( - dialect, - 0, - inline_literals=True, - ) + # A domain CHECK is a DDL position: the literal states ``inline=True`` + # itself, per node -- the caller's call, and no lift pass exists to make + # it for them (the old ``DdlLiteral`` class-level spelling is gone). + condition = DomainValueExpression(dialect) > Literal(dialect, 0, inline=True) check = DomainCheckConstraint(dialect, condition, name="positive") action = DropDomainDefaultAction(dialect) positive = Domain(dialect, "positive") expressions = ( - CreateDomainExpression( + CreateDomainStatement( dialect, positive, IntegerType(dialect), checks=[check], collation="en_US", ), - AlterDomainExpression(dialect, positive, [action]), - DropDomainExpression(dialect, positive), + AlterDomainStatement(dialect, positive, [action]), + DropDomainStatement(dialect, positive), DomainValueExpression(dialect), check, action, 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 c9d0d36..04a32ee 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 @@ -68,7 +68,7 @@ def test_materialized_view_supported(self, dialect): 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. + ClickHouseCreateMaterializedViewStatement is the entry point. """ assert dialect.supports_materialized_view() is True assert dialect.supports_refresh_materialized_view() is True @@ -137,32 +137,74 @@ def test_limit_only(self, dialect): assert sql == "LIMIT %s" assert params == [10] - def test_storage_options_clickhouse_syntax(self, dialect): - """ENGINE/ORDER BY values must NOT be quoted.""" - from rhosocial.activerecord.backend.expression.statements import StorageOptionsExpression - result, params = dialect.format_storage_options(StorageOptionsExpression(dialect, { - "ENGINE": "MergeTree()", - "ORDER BY": "id", - "PARTITION BY": "toYYYYMM(created_at)", - })) - assert "ENGINE = MergeTree()" in result - assert "ORDER BY = id" in result or "ORDER BY id" in result - assert "'MergeTree()'" not in result # no quotes + def test_storage_options_slot_is_refused_by_name(self, dialect): + """The generic ``WITH (...)`` slot has no ClickHouse spelling. + + ClickHouse's storage clauses are declared on + ``ClickHouseCreateTableOptions``; a caller that reaches for the generic + slot is told so instead of getting a silently different statement. + """ + from rhosocial.activerecord.backend.expression.objects import Table + from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + CreateTableStatement, + StorageOptionsExpression, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + + expr = CreateTableStatement( + dialect, + Table(dialect, "t"), + [ColumnDefinition(dialect, "id", IntegerType(dialect))], + storage_options=StorageOptionsExpression(dialect, {"ENGINE": "MergeTree"}), + ) + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() def test_table_engine_clauses(self, dialect): """ClickHouse table-engine-specific clause formatting.""" - result, params = dialect.format_table_engine_clauses({ - "ENGINE": "MergeTree()", - "ORDER BY": ["id", "created_at"], - "PARTITION BY": "toYYYYMM(created_at)", - "TTL": "created_at + INTERVAL 30 DAY", - "SETTINGS": "index_granularity = 8192", - }) - assert "ENGINE = MergeTree()" in result - assert "ORDER BY (id, created_at)" in result - assert "PARTITION BY toYYYYMM(created_at)" in result + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.operators import RawSQL + from rhosocial.activerecord.backend.impl.clickhouse.expression import ( + ClickHouseEngineSpec, + ClickHouseSetting, + ClickHouseStorageOptionKind, + ClickHouseTableStorageOption, + ) + + kind = ClickHouseStorageOptionKind + clauses = [ + ClickHouseTableStorageOption( + dialect, kind.ENGINE, value=ClickHouseEngineSpec(dialect, "MergeTree") + ), + ClickHouseTableStorageOption( + dialect, + kind.ORDER_BY, + value=[Column(dialect, "id"), Column(dialect, "created_at")], + ), + ClickHouseTableStorageOption( + dialect, + kind.TTL, + value=RawSQL(dialect, "created_at + INTERVAL 30 DAY"), + ), + ClickHouseTableStorageOption( + dialect, + kind.SETTINGS, + value=[ClickHouseSetting(dialect, "index_granularity", 8192)], + ), + ] + rendered = [] + for clause in sorted(clauses, key=lambda item: item.option.rank): + clause_sql, _ = clause.to_sql() + rendered.append(clause_sql) + result = " ".join(rendered) + + assert "ENGINE = MergeTree" in result + assert "ORDER BY (`id`, `created_at`)" in result assert "TTL created_at + INTERVAL 30 DAY" in result assert "SETTINGS index_granularity = 8192" in result + # ENGINE/ORDER BY values must NOT be quoted as strings. + assert "'MergeTree'" not in result def test_unsupported_insert_options_raise(self, dialect): """INSERT IGNORE / REPLACE capability flags are False for ClickHouse.""" @@ -172,10 +214,10 @@ def test_unsupported_insert_options_raise(self, dialect): def test_transaction_formatting_raises(self, dialect): """Transaction SQL generation must fail fast for ClickHouse.""" - from rhosocial.activerecord.backend.expression import BeginTransactionExpression + from rhosocial.activerecord.backend.expression import BeginTransactionStatement with pytest.raises(UnsupportedFeatureError): - dialect.format_begin_transaction(BeginTransactionExpression(dialect)) + dialect.format_begin_transaction_statement(BeginTransactionStatement(dialect)) class TestClickHouseExplain: 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 bdf594b..8060552 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 @@ -13,7 +13,7 @@ from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, ) from rhosocial.activerecord.backend.expression import ColumnCommentClause, TableCommentClause from rhosocial.activerecord.backend.expression.objects import Table @@ -32,11 +32,8 @@ def dialect(): def test_clickhouse_format_column_definition_default_string_escaping(dialect): """Test DEFAULT constraint string is escaped in ClickHouse.""" - constraint = ColumnConstraint( - dialect, - ColumnConstraintType.DEFAULT, - default_value="test's value", - ) + constraint = ColumnConstraint(dialect, + branch=ColumnDefaultConstraint(dialect, "test's value")) col_def = ColumnDefinition( dialect, @@ -62,18 +59,44 @@ def test_clickhouse_format_column_definition_comment_string_escaping(dialect): assert "Comment with ''single quote''" in sql -def test_clickhouse_escape_sql_string(dialect): - """Test ClickHouse inherits _escape_sql_string.""" - result = dialect._escape_sql_string("Table's comment") +def test_clickhouseescape_sql_string(dialect): + """Test ClickHouse inherits escape_sql_string.""" + result = dialect.escape_sql_string("Table's comment") assert result == "Table''s comment" -def test_clickhouse_validate_data_type(dialect): - """Test ClickHouse inherits _validate_data_type.""" - assert dialect._validate_data_type("VARCHAR(255)") - assert dialect._validate_data_type("INT") - assert dialect._validate_data_type("BIGINT") - assert not dialect._validate_data_type("INT; DROP TABLE users--") +def test_clickhousea_type_position_refuses_a_payload(dialect): + """A type name is a name, and the grammar is what says so. + + The backend has no character filter of its own any more: the type + position is guarded by the grammar, which is the framework's rule for + what a name may be, and the server decides whether the type exists. + """ + from rhosocial.activerecord.backend.expression import Literal + from rhosocial.activerecord.backend.expression.statements import ( + AlterColumnAction, + ) + from rhosocial.activerecord.backend.expression.type_name import ( + InvalidTypeNameError, + ) + from rhosocial.activerecord.backend.expression.xml import ( + XMLSerializeExpression, + ) + + # A name is a name: the position takes it. + XMLSerializeExpression(dialect, Literal(dialect, "x"), "TEXT") + AlterColumnAction(dialect, "c", "SET DATA TYPE", new_value="TEXT") + + # A value that is not a name is refused, and refused before the + # statement is ever rendered. + with pytest.raises(InvalidTypeNameError): + XMLSerializeExpression( + dialect, Literal(dialect, "x"), "TEXT; DROP TABLE users--" + ) + with pytest.raises(InvalidTypeNameError): + AlterColumnAction( + dialect, "c", "SET DATA TYPE", new_value="TEXT; DROP TABLE users--" + ) def test_clickhouse_format_column_definition_data_type_validation(dialect): @@ -173,22 +196,22 @@ def test_clickhouse_format_cast_expression_rejects_injection(dialect): class TestClickHouseEscapeSqlStringBackslash: - """Tests for ClickHouse _escape_sql_string with backslash escaping.""" + """Tests for ClickHouse escape_sql_string with backslash escaping.""" - def test_escape_sql_string_backslash_escaped(self, dialect): + def testescape_sql_string_backslash_escaped(self, dialect): """Test backslash is properly escaped in ClickHouse.""" - result = dialect._escape_sql_string("test\\value") + result = dialect.escape_sql_string("test\\value") assert "\\\\" in result - def test_escape_sql_string_backslash_and_quote(self, dialect): + def testescape_sql_string_backslash_and_quote(self, dialect): """Test both backslash and single quote are escaped.""" - result = dialect._escape_sql_string("test\\'value") + result = dialect.escape_sql_string("test\\'value") assert "\\\\" in result assert "''" in result - def test_escape_sql_string_preserves_others(self, dialect): + def testescape_sql_string_preserves_others(self, dialect): """Test other characters are preserved.""" - result = dialect._escape_sql_string('test"double"value') + result = dialect.escape_sql_string('test"double"value') assert 'test"double"value' in result @@ -426,11 +449,11 @@ class TestClickHouseCreateTableCommentEscaping: def test_create_table_comment_escaped(self, dialect): """Test table-level COMMENT is properly escaped.""" from rhosocial.activerecord.backend.expression.statements import ( - CreateTableExpression, + CreateTableStatement, CreateTableOptions, ) - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "test_table"), columns=[], @@ -448,11 +471,11 @@ def test_create_table_comment_escaped(self, dialect): def test_create_table_comment_with_backslash(self, dialect): """Test table-level COMMENT with backslash is properly escaped.""" from rhosocial.activerecord.backend.expression.statements import ( - CreateTableExpression, + CreateTableStatement, CreateTableOptions, ) - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "test_table"), columns=[], @@ -465,38 +488,103 @@ def test_create_table_comment_with_backslash(self, dialect): # ============================================================ -# format_storage_options — ClickHouse native ENGINE / ORDER BY / TTL syntax +# storage clauses — ClickHouse's ENGINE / ORDER BY / SETTINGS nodes # ============================================================ -def test_storage_options_normal_key_and_value(dialect): - """ClickHouse storage option key/value are joined with ' = ' and not quoted.""" - from rhosocial.activerecord.backend.expression.statements import StorageOptionsExpression - sql, params = dialect.format_storage_options(StorageOptionsExpression(dialect, {"ENGINE": "MergeTree()"})) - assert "ENGINE = MergeTree()" in sql +def _clause(dialect, option, value): + from rhosocial.activerecord.backend.impl.clickhouse.expression import ( + ClickHouseTableStorageOption, + ) + + return ClickHouseTableStorageOption(dialect, option, value=value) -def test_storage_options_multiple_keys(dialect): - """Multiple storage options are space separated.""" - from rhosocial.activerecord.backend.expression.statements import StorageOptionsExpression - sql, params = dialect.format_storage_options(StorageOptionsExpression(dialect, {"ENGINE": "MergeTree()", "ORDER BY": "id"})) - assert "ENGINE = MergeTree()" in sql - assert "ORDER BY id" in sql +def test_storage_clause_engine_is_emitted_unquoted(dialect): + """An engine name is vocabulary, not a string: no quotes, no escaping.""" + from rhosocial.activerecord.backend.impl.clickhouse.expression import ( + ClickHouseEngineSpec, + ClickHouseStorageOptionKind, + ) + sql, params = dialect.format_clickhouse_table_storage_option( + _clause( + dialect, + ClickHouseStorageOptionKind.ENGINE, + ClickHouseEngineSpec(dialect, "MergeTree"), + ) + ) + assert sql == "ENGINE = MergeTree" + assert params == () + assert "'" not in sql -def test_storage_options_int_value(dialect): - """Integer value is rendered without quotes.""" - from rhosocial.activerecord.backend.expression.statements import StorageOptionsExpression - sql, params = dialect.format_storage_options(StorageOptionsExpression(dialect, {"PARTITION_BY": 1000})) - assert "PARTITION BY 1000" in sql +def test_storage_clause_order_by_places_each_expression(dialect): + """A sort key is expression nodes; a single one is not parenthesised.""" + from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.impl.clickhouse.expression import ( + ClickHouseStorageOptionKind, + ) -def test_storage_options_string_value_preserved(dialect): - """ClickHouse storage option values are NOT quoted (engine/expression names).""" - from rhosocial.activerecord.backend.expression.statements import StorageOptionsExpression - sql, params = dialect.format_storage_options(StorageOptionsExpression(dialect, {"ENGINE": "MergeTree()"})) - assert "ENGINE = MergeTree()" == sql - assert "'" not in sql + single = _clause( + dialect, + ClickHouseStorageOptionKind.ORDER_BY, + [Column(dialect, "id")], + ) + assert dialect.format_clickhouse_table_storage_option(single) == ("ORDER BY `id`", ()) + + composite = _clause( + dialect, + ClickHouseStorageOptionKind.ORDER_BY, + [Column(dialect, "a"), Column(dialect, "b")], + ) + assert dialect.format_clickhouse_table_storage_option(composite) == ( + "ORDER BY (`a`, `b`)", + (), + ) + + +def test_storage_clause_setting_int_value_is_unquoted(dialect): + """An integer setting reaches the SQL as a number, not as text.""" + from rhosocial.activerecord.backend.impl.clickhouse.expression import ( + ClickHouseSetting, + ClickHouseStorageOptionKind, + ) + + sql, params = dialect.format_clickhouse_table_storage_option( + _clause( + dialect, + ClickHouseStorageOptionKind.SETTINGS, + [ClickHouseSetting(dialect, "index_granularity", 8192)], + ) + ) + assert sql == "SETTINGS index_granularity = 8192" + assert params == () + + +def test_storage_clause_setting_string_value_is_a_literal(dialect): + """A string setting is formatted as a literal -- the caller's text is never pasted. + + What holds for every other value in the tree holds here too: the caller + supplies data, the dialect decides how it is spelled. A quote in the value + is doubled by ``format_literal``, so it stays inside the literal instead of + ending it. + """ + from rhosocial.activerecord.backend.impl.clickhouse.expression import ( + ClickHouseSetting, + ClickHouseStorageOptionKind, + ) + + payload = "x'; DROP TABLE users--" + sql, params = dialect.format_clickhouse_table_storage_option( + _clause( + dialect, + ClickHouseStorageOptionKind.SETTINGS, + [ClickHouseSetting(dialect, "storage_policy", payload)], + ) + ) + assert sql == "SETTINGS storage_policy = 'x''; DROP TABLE users--'" + assert sql.count("'") == 4 # ============================================================ diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_dql_set_operation_for_update.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_dql_set_operation_for_update.py index bf4c599..4d6f01d 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_dql_set_operation_for_update.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_dql_set_operation_for_update.py @@ -1,12 +1,19 @@ # tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_dql_set_operation_for_update.py -"""Q01: ClickHouse must fail fast on set-operation FOR UPDATE.""" +"""Q01: ClickHouse must fail fast on FOR UPDATE. + +FOR UPDATE is a statement-level clause, not part of a set operation. The +``SetOperation`` node carries exactly ``op`` / ``left`` / ``right``, so a +compound query cannot smuggle a lock clause in -- and where FOR UPDATE +actually lives, the statement, ClickHouse still refuses it rather than +emitting SQL the server rejects. +""" import pytest from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression import Column, ForUpdateClause -from rhosocial.activerecord.backend.expression.query_sources import SetOperationExpression -from rhosocial.activerecord.backend.expression.statements import QueryExpression +from rhosocial.activerecord.backend.expression.query_sources import SetOperation +from rhosocial.activerecord.backend.expression.statements import QueryStatement from rhosocial.activerecord.backend.impl.clickhouse.dialect import ClickHouseDialect @@ -15,24 +22,37 @@ def dialect(): return ClickHouseDialect(version=(24, 3, 0)) -def test_set_operation_for_update_raises(dialect): - expr = SetOperationExpression( - dialect, - left=QueryExpression(dialect, select=[Column(dialect, "id")], from_="t1"), - right=QueryExpression(dialect, select=[Column(dialect, "id")], from_="t2"), - operation="UNION", - for_update_clause=ForUpdateClause(dialect), - ) - with pytest.raises(UnsupportedFeatureError, match="FOR UPDATE in set operations"): - expr.to_sql() +def test_set_operation_cannot_carry_a_for_update_clause(dialect): + """The node has no slot for one: the keyword is not a construction parameter.""" + with pytest.raises(TypeError): + SetOperation( + dialect, + op="union", + left=QueryStatement(dialect, select=[Column(dialect, "id")], from_="t1"), + right=QueryStatement(dialect, select=[Column(dialect, "id")], from_="t2"), + for_update_clause=ForUpdateClause(dialect), + ) -def test_set_operation_without_for_update_renders(dialect): - expr = SetOperationExpression( +def test_set_operation_renders_without_a_lock_clause(dialect): + expr = SetOperation( dialect, - left=QueryExpression(dialect, select=[Column(dialect, "id")], from_="t1"), - right=QueryExpression(dialect, select=[Column(dialect, "id")], from_="t2"), - operation="UNION", + op="union", + left=QueryStatement(dialect, select=[Column(dialect, "id")], from_="t1"), + right=QueryStatement(dialect, select=[Column(dialect, "id")], from_="t2"), ) sql, _ = expr.to_sql() assert "UNION DISTINCT" in sql + assert "FOR UPDATE" not in sql + + +def test_query_statement_for_update_raises(dialect): + """Where FOR UPDATE belongs, ClickHouse still refuses it.""" + expr = QueryStatement( + dialect, + select=[Column(dialect, "id")], + from_="t1", + for_update=ForUpdateClause(dialect), + ) + with pytest.raises(UnsupportedFeatureError, match="FOR UPDATE"): + expr.to_sql() \ No newline at end of file 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 index c1ad293..9534cb1 100644 --- 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 @@ -17,24 +17,23 @@ import pytest from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from rhosocial.activerecord.backend.expression import Column, QueryExpression -from rhosocial.activerecord.backend.expression.objects import Table -from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression import Column, QueryStatement from rhosocial.activerecord.backend.expression.objects import ( MaterializedView, Table, ) +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from rhosocial.activerecord.backend.expression.statements.ddl_view import ( - CreateMaterializedViewExpression, - DropMaterializedViewExpression, - RefreshMaterializedViewExpression, + CreateMaterializedViewStatement, + DropMaterializedViewStatement, + RefreshMaterializedViewStatement, ) from rhosocial.activerecord.backend.impl.clickhouse.dialect import ClickHouseDialect from rhosocial.activerecord.backend.impl.clickhouse.expression.materialized_view import ( - ClickHouseCreateMaterializedViewExpression, - ClickHouseDropMaterializedViewExpression, - ClickHouseModifyMaterializedViewRefreshExpression, - ClickHouseRefreshMaterializedViewExpression, + ClickHouseCreateMaterializedViewStatement, + ClickHouseDropMaterializedViewStatement, + ClickHouseModifyMaterializedViewRefreshStatement, + ClickHouseRefreshMaterializedViewStatement, ClickHouseRefreshSchedule, ) @@ -47,7 +46,7 @@ def dialect(): def _generic_query(dialect): - return QueryExpression( + return QueryStatement( dialect=dialect, select=[Column(dialect, "grp")], from_=NamedRelationRef(dialect, Table(dialect, "src")), @@ -76,7 +75,7 @@ def test_formatter_ownership(self, dialect): class TestIncrementalMaterializedView: def test_engine_form(self, dialect): """Without TO an ENGINE is mandatory — that is ClickHouse's rule.""" - expr = ClickHouseCreateMaterializedViewExpression( + expr = ClickHouseCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv_agg"), QUERY, engine="MergeTree ORDER BY grp" ) sql, params = expr.to_sql() @@ -88,13 +87,13 @@ def test_engine_form(self, dialect): assert params == () def test_to_table_form(self, dialect): - expr = ClickHouseCreateMaterializedViewExpression( + expr = ClickHouseCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv_to"), QUERY, to_table=Table(dialect, "target_table") ) assert "TO `target_table`" in expr.to_sql()[0] def test_to_table_with_columns(self, dialect): - expr = ClickHouseCreateMaterializedViewExpression( + expr = ClickHouseCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv_to"), QUERY, @@ -104,13 +103,13 @@ def test_to_table_with_columns(self, dialect): assert "TO `target` (grp, s)" in expr.to_sql()[0] def test_populate(self, dialect): - expr = ClickHouseCreateMaterializedViewExpression( + expr = ClickHouseCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), QUERY, to_table=Table(dialect, "target"), populate=True ) assert "POPULATE" in expr.to_sql()[0] def test_database_qualified(self, dialect): - expr = ClickHouseCreateMaterializedViewExpression( + expr = ClickHouseCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv_agg", catalog_name="analytics"), QUERY, @@ -125,7 +124,7 @@ def test_to_target_keeps_its_own_database(self, dialect): table of the same name in a different database -- silently, because the SQL stayed well-formed. """ - expr = ClickHouseCreateMaterializedViewExpression( + expr = ClickHouseCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv", catalog_name="analytics"), QUERY, @@ -137,7 +136,7 @@ def test_to_target_keeps_its_own_database(self, dialect): def test_to_target_may_lack_a_database(self, dialect): """A target with no catalog_name falls back to the connection default.""" - expr = ClickHouseCreateMaterializedViewExpression( + expr = ClickHouseCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv", catalog_name="analytics"), QUERY, @@ -146,7 +145,7 @@ def test_to_target_may_lack_a_database(self, dialect): assert "TO `rollup`" in expr.to_sql()[0] def test_on_cluster_and_comment(self, dialect): - expr = ClickHouseCreateMaterializedViewExpression( + expr = ClickHouseCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), QUERY, @@ -160,18 +159,18 @@ def test_on_cluster_and_comment(self, dialect): assert "COMMENT 'nightly rollup'" in sql def test_or_replace_and_if_not_exists(self, dialect): - expr = ClickHouseCreateMaterializedViewExpression( + expr = ClickHouseCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), QUERY, to_table=Table(dialect, "t"), or_replace=True ) assert expr.to_sql()[0].startswith("CREATE OR REPLACE MATERIALIZED VIEW") - expr = ClickHouseCreateMaterializedViewExpression( + expr = ClickHouseCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), QUERY, to_table=Table(dialect, "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( + ClickHouseCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), QUERY, @@ -183,21 +182,21 @@ def test_or_replace_and_if_not_exists_are_exclusive(self, dialect): 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, MaterializedView(dialect, "mv"), QUERY) + ClickHouseCreateMaterializedViewStatement(dialect, MaterializedView(dialect, "mv"), QUERY) def test_bare_string_view_is_rejected(self, dialect): """A string cannot say which object it names, nor which database.""" with pytest.raises(TypeError, match="MaterializedView object"): - ClickHouseCreateMaterializedViewExpression(dialect, "mv", QUERY, engine="E") + ClickHouseCreateMaterializedViewStatement(dialect, "mv", QUERY, engine="E") def test_bare_string_target_is_rejected(self, dialect): with pytest.raises(TypeError, match="Table object"): - ClickHouseCreateMaterializedViewExpression( + ClickHouseCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), QUERY, to_table="target" ) def test_clause_order(self, dialect): - expr = ClickHouseCreateMaterializedViewExpression( + expr = ClickHouseCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), QUERY, @@ -213,7 +212,7 @@ def test_clause_order(self, dialect): assert sql.index(" AS ") < sql.index("COMMENT") def test_expression_query_accepted(self, dialect): - expr = ClickHouseCreateMaterializedViewExpression( + expr = ClickHouseCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), _generic_query(dialect), to_table=Table(dialect, "t") ) sql, _ = expr.to_sql() @@ -222,44 +221,44 @@ def test_expression_query_accepted(self, dialect): class TestRefreshableMaterializedView: def test_every(self, dialect): - expr = ClickHouseCreateMaterializedViewExpression( + expr = ClickHouseCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), QUERY, to_table=Table(dialect, "t"), - refresh=ClickHouseRefreshSchedule(every="1 DAY"), + refresh=ClickHouseRefreshSchedule(dialect, 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( + expr = ClickHouseCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), QUERY, to_table=Table(dialect, "t"), - refresh=ClickHouseRefreshSchedule(every="1 MONTH", offset="5 DAY 2 HOUR"), + refresh=ClickHouseRefreshSchedule(dialect, 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( + expr = ClickHouseCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), QUERY, to_table=Table(dialect, "t"), - refresh=ClickHouseRefreshSchedule(after="30 MINUTE"), + refresh=ClickHouseRefreshSchedule(dialect, after="30 MINUTE"), ) assert "REFRESH AFTER 30 MINUTE" in expr.to_sql()[0] def test_randomize_for_and_settings(self, dialect): - expr = ClickHouseCreateMaterializedViewExpression( + expr = ClickHouseCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), QUERY, to_table=Table(dialect, "t"), - refresh=ClickHouseRefreshSchedule( + refresh=ClickHouseRefreshSchedule(dialect, every="1 HOUR", randomize_for="1 HOUR", settings={"refresh_retries": 5} ), ) @@ -268,22 +267,22 @@ def test_randomize_for_and_settings(self, dialect): assert "SETTINGS refresh_retries = 5" in sql def test_depends_on(self, dialect): - expr = ClickHouseCreateMaterializedViewExpression( + expr = ClickHouseCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), QUERY, to_table=Table(dialect, "t"), - refresh=ClickHouseRefreshSchedule(depends_on=[MaterializedView(dialect, "upstream_mv")]), + refresh=ClickHouseRefreshSchedule(dialect, depends_on=[MaterializedView(dialect, "upstream_mv")]), ) assert "DEPENDS ON `upstream_mv`" in expr.to_sql()[0] def test_depends_on_keeps_each_own_database(self, dialect): - expr = ClickHouseCreateMaterializedViewExpression( + expr = ClickHouseCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), QUERY, to_table=Table(dialect, "t"), - refresh=ClickHouseRefreshSchedule( + refresh=ClickHouseRefreshSchedule(dialect, depends_on=[ MaterializedView(dialect, "a", catalog_name="db1"), MaterializedView(dialect, "b", catalog_name="db2"), @@ -294,32 +293,32 @@ def test_depends_on_keeps_each_own_database(self, dialect): def test_depends_on_rejects_bare_strings(self, dialect): with pytest.raises(TypeError, match="MaterializedView objects"): - ClickHouseRefreshSchedule( + ClickHouseRefreshSchedule(dialect, every="1 HOUR", depends_on=["upstream_mv"] ).validate() def test_append_and_append_incremental(self, dialect): base = dict(dialect=dialect, view=MaterializedView(dialect, "mv"), query=QUERY, to_table=Table(dialect, "t")) - append = ClickHouseCreateMaterializedViewExpression( - **base, refresh=ClickHouseRefreshSchedule(every="1 HOUR", append=True) + append = ClickHouseCreateMaterializedViewStatement( + **base, refresh=ClickHouseRefreshSchedule(dialect, every="1 HOUR", append=True) ) assert "APPEND" in append.to_sql()[0] assert "APPEND INCREMENTAL" not in append.to_sql()[0] - incremental = ClickHouseCreateMaterializedViewExpression( + incremental = ClickHouseCreateMaterializedViewStatement( **base, - refresh=ClickHouseRefreshSchedule( + refresh=ClickHouseRefreshSchedule(dialect, 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( + expr = ClickHouseCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), QUERY, to_table=Table(dialect, "t"), - refresh=ClickHouseRefreshSchedule(every="1 DAY"), + refresh=ClickHouseRefreshSchedule(dialect, every="1 DAY"), empty=True, ) assert "EMPTY" in expr.to_sql()[0] @@ -327,37 +326,37 @@ def test_empty_skips_first_refresh(self, dialect): 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( + ClickHouseCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), QUERY, to_table=Table(dialect, "t"), - refresh=ClickHouseRefreshSchedule(), + refresh=ClickHouseRefreshSchedule(dialect, ), ) 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() + ClickHouseRefreshSchedule(dialect, 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() + ClickHouseRefreshSchedule(dialect, 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( + ClickHouseCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), QUERY, to_table=Table(dialect, "t"), populate=True, - refresh=ClickHouseRefreshSchedule(every="1 HOUR"), + refresh=ClickHouseRefreshSchedule(dialect, every="1 HOUR"), ) def test_empty_requires_refresh(self, dialect): with pytest.raises(ValueError, match="EMPTY is only meaningful"): - ClickHouseCreateMaterializedViewExpression( + ClickHouseCreateMaterializedViewStatement( dialect, MaterializedView(dialect, "mv"), QUERY, to_table=Table(dialect, "t"), empty=True ) @@ -365,7 +364,7 @@ def test_empty_requires_refresh(self, dialect): class TestRefreshAndDrop: def test_drop_uses_drop_view(self, dialect): """ClickHouse removes materialized views with DROP VIEW.""" - expr = ClickHouseDropMaterializedViewExpression(dialect, MaterializedView(dialect, "mv_agg")) + expr = ClickHouseDropMaterializedViewStatement(dialect, MaterializedView(dialect, "mv_agg")) assert expr.to_sql()[0] == "DROP VIEW IF EXISTS `mv_agg`" def test_drop_keyword_follows_the_object_kind(self, dialect): @@ -375,27 +374,27 @@ def test_drop_keyword_follows_the_object_kind(self, dialect): dialect.drop_object_keyword(Table(dialect, "t")) def test_drop_without_if_exists(self, dialect): - expr = ClickHouseDropMaterializedViewExpression( + expr = ClickHouseDropMaterializedViewStatement( dialect, MaterializedView(dialect, "mv_agg"), if_exists=False ) assert expr.to_sql()[0] == "DROP VIEW `mv_agg`" def test_drop_database_qualified(self, dialect): - expr = ClickHouseDropMaterializedViewExpression( + expr = ClickHouseDropMaterializedViewStatement( dialect, MaterializedView(dialect, "mv", catalog_name="analytics") ) assert "`analytics`.`mv`" in expr.to_sql()[0] def test_drop_rejects_a_bare_string(self, dialect): with pytest.raises(TypeError, match="MaterializedView object"): - ClickHouseDropMaterializedViewExpression(dialect, "mv") + ClickHouseDropMaterializedViewStatement(dialect, "mv") def test_system_refresh_view(self, dialect): - expr = ClickHouseRefreshMaterializedViewExpression(dialect, MaterializedView(dialect, "mv_ref")) + expr = ClickHouseRefreshMaterializedViewStatement(dialect, MaterializedView(dialect, "mv_ref")) assert expr.to_sql()[0] == "SYSTEM REFRESH VIEW `mv_ref`" def test_system_refresh_and_wait(self, dialect): - expr = ClickHouseRefreshMaterializedViewExpression( + expr = ClickHouseRefreshMaterializedViewStatement( dialect, MaterializedView(dialect, "mv_ref"), wait=True ) assert expr.to_sql()[0] == ( @@ -403,8 +402,8 @@ def test_system_refresh_and_wait(self, dialect): ) def test_modify_refresh_schedule(self, dialect): - expr = ClickHouseModifyMaterializedViewRefreshExpression( - dialect, MaterializedView(dialect, "mv_ref"), ClickHouseRefreshSchedule(every="30 MINUTE") + expr = ClickHouseModifyMaterializedViewRefreshStatement( + dialect, MaterializedView(dialect, "mv_ref"), ClickHouseRefreshSchedule(dialect, every="30 MINUTE") ) assert expr.to_sql()[0] == ( "ALTER TABLE `mv_ref` MODIFY REFRESH EVERY 30 MINUTE" @@ -412,16 +411,16 @@ def test_modify_refresh_schedule(self, dialect): def test_modify_refresh_requires_schedule(self, dialect): with pytest.raises(ValueError, match="at least one of EVERY"): - ClickHouseModifyMaterializedViewRefreshExpression( - dialect, MaterializedView(dialect, "mv_ref"), ClickHouseRefreshSchedule() + ClickHouseModifyMaterializedViewRefreshStatement( + dialect, MaterializedView(dialect, "mv_ref"), ClickHouseRefreshSchedule(dialect, ) ) def test_generic_refresh_routes_here(self, dialect): - expr = RefreshMaterializedViewExpression(dialect=dialect, view=MaterializedView(dialect, "mv_ref")) + expr = RefreshMaterializedViewStatement(dialect=dialect, view=MaterializedView(dialect, "mv_ref")) assert expr.to_sql()[0] == "SYSTEM REFRESH VIEW `mv_ref`" def test_generic_concurrent_is_rejected(self, dialect): - expr = RefreshMaterializedViewExpression( + expr = RefreshMaterializedViewStatement( dialect=dialect, view=MaterializedView(dialect, "mv_ref"), concurrent=True ) with pytest.raises(UnsupportedFeatureError) as exc: @@ -430,7 +429,7 @@ def test_generic_concurrent_is_rejected(self, dialect): def test_generic_with_data_is_rejected(self, dialect): """``with_data=True`` is the WITH DATA spelling; ClickHouse has neither.""" - expr = RefreshMaterializedViewExpression( + expr = RefreshMaterializedViewStatement( dialect=dialect, view=MaterializedView(dialect, "mv_ref"), with_data=True ) with pytest.raises(UnsupportedFeatureError) as exc: @@ -439,7 +438,7 @@ def test_generic_with_data_is_rejected(self, dialect): def test_generic_with_no_data_is_rejected(self, dialect): """``no_data=True`` is the WITH NO DATA spelling.""" - expr = RefreshMaterializedViewExpression( + expr = RefreshMaterializedViewStatement( dialect=dialect, view=MaterializedView(dialect, "mv_ref"), no_data=True ) with pytest.raises(UnsupportedFeatureError) as exc: @@ -449,7 +448,7 @@ def test_generic_with_no_data_is_rejected(self, dialect): class TestRejectsNonClickHouseClauses: def test_generic_column_aliases(self, dialect): - expr = CreateMaterializedViewExpression( + expr = CreateMaterializedViewStatement( dialect=dialect, view=MaterializedView(dialect, "mv"), query=QUERY, column_aliases=["a"] ) with pytest.raises(UnsupportedFeatureError) as exc: @@ -457,7 +456,7 @@ def test_generic_column_aliases(self, dialect): assert "COLUMN ALIASES" in str(exc.value) def test_generic_tablespace(self, dialect): - expr = CreateMaterializedViewExpression( + expr = CreateMaterializedViewStatement( dialect=dialect, view=MaterializedView(dialect, "mv"), query=QUERY, tablespace="fast_ssd" ) with pytest.raises(UnsupportedFeatureError) as exc: @@ -465,7 +464,7 @@ def test_generic_tablespace(self, dialect): assert "TABLESPACE" in str(exc.value) def test_generic_storage_options(self, dialect): - expr = CreateMaterializedViewExpression( + expr = CreateMaterializedViewStatement( dialect=dialect, view=MaterializedView(dialect, "mv"), query=QUERY, storage_options={"fillfactor": 70} ) with pytest.raises(UnsupportedFeatureError): @@ -477,7 +476,7 @@ def test_generic_with_no_data(self, dialect): ``no_data=True`` is the explicit spelling; the old ``with_data=False`` was the pre-split sentinel and is now simply "unspecified". """ - expr = CreateMaterializedViewExpression( + expr = CreateMaterializedViewStatement( dialect=dialect, view=MaterializedView(dialect, "mv"), query=QUERY, no_data=True ) with pytest.raises(UnsupportedFeatureError) as exc: @@ -487,7 +486,7 @@ def test_generic_with_no_data(self, dialect): def test_generic_with_data(self, dialect): """``with_data=True`` is refused by name too, not silently dropped.""" - expr = CreateMaterializedViewExpression( + expr = CreateMaterializedViewStatement( dialect=dialect, view=MaterializedView(dialect, "mv"), query=QUERY, with_data=True ) with pytest.raises(UnsupportedFeatureError) as exc: @@ -496,7 +495,7 @@ def test_generic_with_data(self, dialect): assert "POPULATE" in str(exc.value) # points at the ClickHouse alternative def test_generic_drop_cascade(self, dialect): - expr = DropMaterializedViewExpression( + expr = DropMaterializedViewStatement( dialect=dialect, view=MaterializedView(dialect, "mv"), if_exists=True, cascade=True ) with pytest.raises(UnsupportedFeatureError) as exc: @@ -505,6 +504,6 @@ def test_generic_drop_cascade(self, dialect): 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=MaterializedView(dialect, "mv"), query=QUERY) + expr = CreateMaterializedViewStatement(dialect=dialect, view=MaterializedView(dialect, "mv"), query=QUERY) with pytest.raises(UnsupportedFeatureError): expr.to_sql() \ No newline at end of file diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_unsupported_features.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_unsupported_features.py index 5c6c445..92dac5c 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_unsupported_features.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dialect/test_unsupported_features.py @@ -45,19 +45,19 @@ PartitionStrategy, ) from rhosocial.activerecord.backend.expression.statements.ddl_trigger import ( - CreateTriggerExpression, - DropTriggerExpression, + CreateTriggerStatement, + DropTriggerStatement, TriggerEvent, TriggerTiming, ) from rhosocial.activerecord.backend.impl.clickhouse.dialect import ClickHouseDialect from rhosocial.activerecord.backend.impl.clickhouse.expression.admin import ( - ClickHouseFlushExpression, - ClickHouseKillExpression, + ClickHouseFlushStatement, + ClickHouseKillStatement, FlushOption, ) from rhosocial.activerecord.backend.impl.clickhouse.expression.json_duality_view import ( - CreateJsonDualityViewExpression, + ClickHouseCreateJsonDualityViewStatement, DualityObjectSpec, ) from rhosocial.activerecord.backend.impl.clickhouse.expression.json_table import ( @@ -65,7 +65,7 @@ JSONTableColumn, ) from rhosocial.activerecord.backend.impl.clickhouse.expression.load_xml import ( - ClickHouseLoadXMLEXpression, + ClickHouseLoadXMLStatement, ) from rhosocial.activerecord.backend.impl.clickhouse.expression.maintenance import ( ClickHouseAnalyzeTableExpression, @@ -78,17 +78,17 @@ SetVarHint, ) from rhosocial.activerecord.backend.impl.clickhouse.expression.routine import ( - ClickHouseCallExpression, - ClickHouseCreateProcedureExpression, + ClickHouseCallStatement, + ClickHouseCreateProcedureStatement, ) from rhosocial.activerecord.backend.impl.clickhouse.expression.spatial import ( - ClickHouseCreateSpatialIndexExpression, + ClickHouseCreateSpatialIndexStatement, ClickHouseSpatialLiteralExpression, ClickHouseSTGeomFromTextExpression, ) from rhosocial.activerecord.backend.impl.clickhouse.expression.table_statement import ( - ClickHouseTableExpression, - ClickHouseValuesExpression, + ClickHouseTableStatement, + ClickHouseValuesStatement, ) @@ -109,7 +109,7 @@ def test_supports_flags(self, dialect): def test_format_create_trigger_raises(self, dialect): with pytest.raises(UnsupportedFeatureError): - CreateTriggerExpression( + CreateTriggerStatement( dialect, Trigger(dialect, "trg"), Table(dialect, "tbl"), @@ -120,7 +120,7 @@ def test_format_create_trigger_raises(self, dialect): def test_format_drop_trigger_raises(self, dialect): with pytest.raises(UnsupportedFeatureError): - DropTriggerExpression( + DropTriggerStatement( dialect, Trigger(dialect, "trg"), Table(dialect, "tbl") ).to_sql() @@ -143,7 +143,7 @@ def test_format_st_geom_from_text_raises(self, dialect): def test_format_create_spatial_index_raises(self, dialect): with pytest.raises(UnsupportedFeatureError): - ClickHouseCreateSpatialIndexExpression(dialect, "idx", "tbl", "col").to_sql() + ClickHouseCreateSpatialIndexStatement(dialect, "idx", "tbl", "col").to_sql() class TestOptimizerHintStub: @@ -165,11 +165,11 @@ def test_supports_flags(self, dialect): def test_format_table_statement_raises(self, dialect): with pytest.raises(UnsupportedFeatureError): - ClickHouseTableExpression(dialect, "tbl").to_sql() + ClickHouseTableStatement(dialect, "tbl").to_sql() def test_format_values_statement_raises(self, dialect): with pytest.raises(UnsupportedFeatureError): - ClickHouseValuesExpression(dialect, [[1]]).to_sql() + ClickHouseValuesStatement(dialect, [[1]]).to_sql() class TestMaintenanceStub: @@ -199,15 +199,15 @@ def test_supports_flags(self, dialect): def test_format_create_procedure_raises(self, dialect): with pytest.raises(UnsupportedFeatureError): - ClickHouseCreateProcedureExpression(dialect, Procedure(dialect, "proc")).to_sql() + ClickHouseCreateProcedureStatement(dialect, Procedure(dialect, "proc")).to_sql() def test_format_call_statement_raises(self, dialect): with pytest.raises(UnsupportedFeatureError): - ClickHouseCallExpression(dialect, Procedure(dialect, "proc")).to_sql() + ClickHouseCallStatement(dialect, Procedure(dialect, "proc")).to_sql() def test_bare_string_routine_is_rejected(self, dialect): with pytest.raises(TypeError, match="Function or Procedure object"): - ClickHouseCreateProcedureExpression(dialect, "proc") + ClickHouseCreateProcedureStatement(dialect, "proc") class TestLoadXmlStub: @@ -216,7 +216,7 @@ def test_supports_load_xml(self, dialect): def test_format_load_xml_raises(self, dialect): with pytest.raises(UnsupportedFeatureError): - ClickHouseLoadXMLEXpression(dialect, "/tmp/data.xml", "tbl").to_sql() + ClickHouseLoadXMLStatement(dialect, "/tmp/data.xml", "tbl").to_sql() class TestAdminCommandStub: @@ -233,11 +233,11 @@ def test_supports_flags(self, dialect): def test_format_flush_raises(self, dialect): with pytest.raises(UnsupportedFeatureError): - ClickHouseFlushExpression(dialect, [FlushOption.TABLES]).to_sql() + ClickHouseFlushStatement(dialect, [FlushOption.TABLES]).to_sql() def test_format_kill_raises(self, dialect): with pytest.raises(UnsupportedFeatureError): - ClickHouseKillExpression(dialect, 1).to_sql() + ClickHouseKillStatement(dialect, 1).to_sql() class TestJsonDualityViewStub: @@ -247,7 +247,9 @@ def test_supports_flags(self, dialect): def test_format_create_json_duality_view_raises(self, dialect): with pytest.raises(UnsupportedFeatureError): - CreateJsonDualityViewExpression(dialect, "v", DualityObjectSpec()).to_sql() + ClickHouseCreateJsonDualityViewStatement( + dialect, "v", DualityObjectSpec() + ).to_sql() class TestFullTextStub: diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dml/test_insert_value_cells.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dml/test_insert_value_cells.py new file mode 100644 index 0000000..f7a0ca7 --- /dev/null +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/dml/test_insert_value_cells.py @@ -0,0 +1,104 @@ +# tests/rhosocial/activerecord_clickhouse_test/feature/backend/dml/test_insert_value_cells.py +"""INSERT VALUES cells: expressions render, bare host values bind. + +The VALUES branch of the ClickHouse INSERT formatter delegates to the +core's ``format_insert_values_source`` (core ``8e2e4d82``), which is the one +place that decides a cell is an expression (rendered through its own +``to_sql()``) or a bare host value (a bind parameter). The old branch here +re-implemented the rows and called ``to_sql()`` unconditionally, so a bare +scalar cell crashed before it ever reached the driver. +""" + +import pytest + +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.statements import ( + InsertStatement, + ValuesSource, +) +from rhosocial.activerecord.backend.impl.clickhouse.dialect import ClickHouseDialect + +#: The dialect floor the formatter's behaviour is pinned at; the cell +#: decision itself carries no version gate. +MEASURED_VERSION = (26, 7, 3) + + +@pytest.fixture() +def dialect() -> ClickHouseDialect: + return ClickHouseDialect(version=MEASURED_VERSION) + + +class TestValueCells: + def test_expression_cells_render_through_their_own_to_sql(self, dialect): + stmt = InsertStatement( + dialect, + into=Table(dialect, "t"), + columns=["id", "name"], + source=ValuesSource( + dialect, + [[Literal(dialect, 1), Literal(dialect, "alice")]], + ), + ) + sql, params = stmt.to_sql() + assert sql == "INSERT INTO `t` (`id`, `name`) VALUES (%s, %s)" + assert params == (1, "alice") + + def test_bare_host_value_cells_bind(self, dialect): + stmt = InsertStatement( + dialect, + into=Table(dialect, "t"), + columns=["id", "name"], + source=ValuesSource(dialect, [[1, "alice"]]), + ) + sql, params = stmt.to_sql() + assert sql == "INSERT INTO `t` (`id`, `name`) VALUES (%s, %s)" + assert params == (1, "alice") + + def test_mixed_cells_share_one_row(self, dialect): + """A row may mix an expression with a bare host value; each cell + takes its own path and the parameters line up in order.""" + stmt = InsertStatement( + dialect, + into=Table(dialect, "t"), + columns=["id", "name"], + source=ValuesSource(dialect, [[Literal(dialect, 7), "bob"]]), + ) + sql, params = stmt.to_sql() + assert sql == "INSERT INTO `t` (`id`, `name`) VALUES (%s, %s)" + assert params == (7, "bob") + + def test_bare_cells_across_several_rows(self, dialect): + stmt = InsertStatement( + dialect, + into=Table(dialect, "t"), + columns=["id"], + source=ValuesSource(dialect, [[1], [2], [3]]), + ) + sql, params = stmt.to_sql() + assert sql == "INSERT INTO `t` (`id`) VALUES (%s), (%s), (%s)" + assert params == (1, 2, 3) + + def test_refusals_still_precede_the_source(self, dialect): + """The delegation did not move the gates: OR REPLACE / OR IGNORE / + ON CONFLICT are refused before any row renders.""" + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + + with pytest.raises(UnsupportedFeatureError, match="REPLACE INTO"): + InsertStatement( + dialect, + into=Table(dialect, "t"), + columns=["id"], + source=ValuesSource(dialect, [[1]]), + or_replace=True, + ).to_sql() + with pytest.raises(UnsupportedFeatureError, match="INSERT IGNORE"): + InsertStatement( + dialect, + into=Table(dialect, "t"), + columns=["id"], + source=ValuesSource(dialect, [[1]]), + or_ignore=True, + ).to_sql() diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/conftest.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/conftest.py new file mode 100644 index 0000000..169d4f7 --- /dev/null +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/conftest.py @@ -0,0 +1,29 @@ +# tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/conftest.py +"""The expression test area: construction → render, no I/O, always parallel. + +This directory holds every expression-level test of this backend -- the +round-trip matrix, the literal spellings, the signature and function-entry +checks. The org rules (expression-dialect §12) give the area three +properties, and the tests here are written to all three: + +* every case carries its own dialect instance and shares no mutable state; +* nothing opens a connection, creates a database, or touches the filesystem, + so the whole area runs without the scenario server; +* the cases are therefore independent, and parallelism is the default + scheduling, not an option. + +The parallel default itself cannot live in a conftest: pytest-xdist reads +its worker count before any conftest loads, so a conftest cannot inject +``-n``. The default is wired one level out, in the repository-local plugin +``tests/plugins/expression_xdist.py`` (registered from ``pyproject.toml``), +which appends ``-n auto --dist=loadgroup`` when the invocation names only +this directory and the caller has not chosen a worker count itself. The +plugin is the availability check too: with pytest-xdist absent it enables +nothing and says so. + +Serial cases: a test that genuinely cannot share a worker marks +``xdist_group`` explicitly and states the reason beside the mark. None does +today -- no case here has I/O to serialize around. ``--dist=loadscope`` and +``--dist=loadfile`` are not used: they bind whole modules together and would +hide exactly the case-to-case dependencies parallelism exists to expose. +""" diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/expression_roundtrip_harness.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/expression_roundtrip_harness.py new file mode 100644 index 0000000..6c1ce86 --- /dev/null +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/expression_roundtrip_harness.py @@ -0,0 +1,1237 @@ +# tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression_roundtrip_harness.py +""" +Expression serialization round-trip / contract test helpers (ClickHouse-local copy). + +Vendored from the shared testsuite at +``python-activerecord-testsuite@4ef2551`` (formerly +``src/rhosocial/activerecord/testsuite/utils/expression.py``) when that suite's +``fix/column-constraint-gates`` branch dropped the module in ``e05edb0`` with +the direction "round-trip coverage moves to the owning repository": the suite +keeps the ActiveRecord-layer contracts and must not grow an opinion about +which expressions exist, so each backend now owns the harness its own matrix +runs on. Fix drift here deliberately; do not re-import it from the suite. + +The split-registry adaptation follows the mysql repository's own vendoring +(``python-activerecord-mysql@8961ea1``), so the nine backends keep one shape: +``register_all`` registers into the framework's ``default_registry()`` through +``register_node``. That is the whole registration surface since core +``679d9817`` made an artifact plain JSON: the registry owns the kind -> +class table and nothing else, the separate enum and dataclass vocabulary +tables are gone (a str enum is a JSON scalar, and a constructor accepts the +serialized form and normalises), and core's own contract test pins their +absence so they cannot quietly return. + +This module is the test-only home (NOT shipped as part of the core library's +runtime API) for constructing expression instances and asserting they survive +all three serializations. + +After registering a class into the ExpressionRegistry, expression instances +must satisfy: + +- ``get_params()`` reflects every ``__init__`` parameter +- A round-trip through the three encoding channels returns a structurally + equivalent instance (``get_params()`` equal) +- When the chosen backend's dialect supports ``to_sql()``, the SQL keyword + rendering is identical +""" + +import collections.abc +import dataclasses +import inspect +import re +import typing +import warnings +from typing import Any, Callable, Dict, Optional, Type + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +# Annotation fragments that mean "this parameter names a relation object". +# Word boundaries matter: ``TableConstraint`` and ``AddTableConstraintAction`` +# are constraint actions, not relations, and a substring test would hand them a +# Table. ``SchemaObject`` is the base every catalogue object shares, so an +# annotation naming it wants an object too. +_MENTIONS_RELATION = re.compile( + r"\b(Table|View|MaterializedView|Index|Sequence|Type|Domain|Function|Trigger" + r"|Schema|Database|RelationObject|SchemaObject|NodeTable|EdgeTable)\b" +) + +# An INSERT's data source. It is an expression now, so it needs a real one -- +# the generic placeholder cannot invent the rows a VALUES list is made of. +_MENTIONS_INSERT_SOURCE = re.compile(r"\bInsertDataSource\b") + + +def _empty_container_for(annotation: Any, dialect: Any): + """Return an empty container for a parameterised alias, else None. + + A parameterised alias like ``List[str]`` or ``Dict[str, BaseExpression]`` + says what the *elements* are, never the container itself, so there is no + catalogue object to hand back -- only an empty container. Deciding this + before the relation-name test is what keeps ``typing.List[str]`` from being + read as a ``Table``: ``typing.get_origin`` on it gives ``list``, but the + relation test only ever saw the alias' own name, and ``List[str]`` matches + ``Sequence``/``Table``-family fragments well enough to be mistaken for one. + + Returns ``None`` when ``annotation`` is not a parameterised container, which + is the caller's signal to keep reading. + """ + origin = typing.get_origin(annotation) + if origin is None: + return None + if origin in (list, tuple, set, frozenset): + return [] + if origin is dict: + return {} + # ``Sequence[str]`` and friends resolve to the collections.abc ABC rather + # than to ``list``, so match on the abstract base instead of the concrete + # one the annotation happens to name. + if isinstance(origin, type) and issubclass(origin, (collections.abc.Sequence, collections.abc.Set)): + return [] + if isinstance(origin, type) and issubclass(origin, collections.abc.Mapping): + return {} + return None + + +def _mentions_expression_class(annotation: Any, raw: Any) -> bool: + """Whether the annotation names an expression node, whatever it is called. + + This used to be a substring test for ``"Expression"``, which is a coupling + to the class *names*: renaming ``QueryExpression`` to ``QueryStatement`` + (规范 4.9: a statement is a statement) silently stopped matching, the + parameter fell through to the ``"x"`` fallback, and the failure surfaced + later as ``'str' object has no attribute 'to_sql'`` from inside a + formatter. The structural question -- does this annotation name a + ``BaseExpression`` subclass? -- does not care what the class is called. + """ + from rhosocial.activerecord.backend.expression.bases import BaseExpression + + candidates = [raw] + if typing.get_origin(annotation) is not None: + candidates.extend(typing.get_args(annotation)) + for candidate in candidates: + if isinstance(candidate, type) and issubclass(candidate, BaseExpression): + return True + return False + + +def _is_predicate_class(annotation: Any, raw: Any) -> bool: + """Whether the annotation names a predicate node, whatever it is called. + + Same structural question as :func:`_mentions_expression_class` answers for + expressions, asked of the predicate family: a rename inside the family must + not change what this heuristic builds either. + """ + from rhosocial.activerecord.backend.expression.bases import SQLPredicate + + for candidate in (raw, annotation): + if isinstance(candidate, type) and issubclass(candidate, SQLPredicate): + return True + return False + + +def _placeholder_for(param: inspect.Parameter, dialect: Any = None): + """Return a heuristic construction value for a required parameter.""" + annotation = param.annotation + if annotation is not inspect.Parameter.empty: + origin = typing.get_origin(annotation) + raw = annotation if origin is None else origin + if isinstance(annotation, str): + text = annotation + else: + text = getattr(raw, "__name__", "") + container = _empty_container_for(annotation, dialect) + if container is not None: + return container + if raw in (list, tuple, set): + return [] + if raw is dict: + return {} + if _MENTIONS_RELATION.search(text): + return _relation(dialect, text) + if _MENTIONS_INSERT_SOURCE.search(text): + return _values_source(dialect) + if raw is str or "str" in text: + return "x" + # A typed value expression wraps the call it renders, so the parameter + # wants a FunctionCall and not a bare name. This must be tested before + # the str branch above: "FunctionCall" contains no "str", but the + # fallback below returns "x" for it, which the wrapper then calls + # to_sql() on. + if "FunctionCall" in text: + return _function_call(dialect) + if "DataType" in text or "Type" in text.split(".")[-1:]: + from rhosocial.activerecord.backend.expression.types import IntegerType + + return IntegerType(dialect) + if raw is int: + return 1 + if raw is float: + return 1.0 + if raw is bool: + return True + # A predicate slot wants a predicate, not a value. SQLPredicate is a + # BaseExpression subclass like the values are, so the structural + # expression branch below hands it a Literal -- and the constructors + # that type a parameter as a predicate refuse a Literal on their + # isinstance check (core's DomainCheckConstraint.condition is the one + # in the matrix today). Asked structurally when the annotation + # resolves; the name test beside it covers forward references. + if (not isinstance(annotation, str) and _is_predicate_class(annotation, raw)) or ( + "Predicate" in text + ): + return _comparison(dialect) + # Asked structurally when the annotation resolves: what a class is + # called says nothing about what it is, and a rename must not change + # what this heuristic builds. The substring tests beside it remain for + # forward references, which introspection cannot resolve -- and they + # must list every name the library might grow, which is exactly why + # the structural test is the one that leads. + if ( + "Expression" in text + or "Subquery" in text + or "Statement" in text + or (not isinstance(annotation, str) and _mentions_expression_class(annotation, raw)) + ): + return _literal(dialect) + + name = param.name + if name in ("value",): + return 1 + if name in ("values", "columns", "args", "params", "predicates", "expressions"): + return [] + if name in ("left", "right") or "predicate" in name or "condition" in name: + return _comparison(dialect) + if "expression" in name or name in ("expr", "operand", "subquery", "query"): + return _literal(dialect) + return "x" + + +def _literal(dialect): + from rhosocial.activerecord.backend.expression.core import Literal + + return Literal(dialect, 1) + + +def _function_call(dialect): + """A niladic function call, for a parameter that wraps one. + + The typed value expressions (``BooleanValue`` and its siblings) + hold a ``call`` and render it through ``to_sql()``. Handing them a string + builds an instance that cannot render, and the failure surfaces as + ``'str' object has no attribute 'to_sql'`` from inside the wrapper rather + than as a construction problem. + """ + from rhosocial.activerecord.backend.expression.core import FunctionCall + + return FunctionCall(dialect, "FUNC", niladic=True) + + +def _comparison(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate + + return ComparisonPredicate(dialect, "=", Column(dialect, "a"), _literal(dialect)) + + +def _try_construct(cls, dialect): + """Construct an instance of cls with heuristic arguments; None on failure.""" + sig = inspect.signature(cls.__init__) + # DataType-family declares dialect as an optional trailing keyword. + has_dialect = "dialect" in sig.parameters + first_param = next((p for p in sig.parameters if p != "self"), None) + dialect_as_kw = first_param != "dialect" + args = [] + kwargs = {} + skipped_defaulted_positional = False + for pname, param in sig.parameters.items(): + if pname in ("self", "dialect"): + continue + if param.kind == inspect.Parameter.VAR_KEYWORD: + continue + if param.kind == inspect.Parameter.VAR_POSITIONAL: + if not skipped_defaulted_positional: + args.append(_literal(dialect)) + continue + if param.default is not inspect.Parameter.empty: + if param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: + skipped_defaulted_positional = True + continue + placeholder = _placeholder_for(param, dialect) + # placeholder None → default case; real value provided otherwise + if param.kind == inspect.Parameter.KEYWORD_ONLY: + kwargs[pname] = placeholder + else: + args.append(placeholder) + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + if not has_dialect: + return cls(*args, **kwargs) + if dialect_as_kw: + return cls(*args, dialect=dialect, **kwargs) + return cls(dialect, *args, **kwargs) + except Exception: + return None + + +def special_constructors(): + """Explicit constructors for classes whose ``__init__`` rejects heuristic + placeholder values. Keyed by a suffix that matches the class's full + module.ClassName. Registered by name only — this is a static registry and + backends may register their own entries for backend-specific expressions. + + **A key that matches no class is dead code, and nothing says so.** + :func:`make_instance` dispatches on ``full_name.endswith(key)``, so + renaming a class turns its entry into a literal that matches nothing: the + class then falls through to :func:`_try_construct`, the source is reported + as ``"heuristic"`` instead of ``"special"``, and if the heuristic also + fails the class is silently reported as unconstructible rather than as + mis-registered. This is how ``"advanced_functions.JSONExpression"`` came to + match nothing at all once core split that one class into + ``JSONDocumentValue`` and ``JSONTextValue``. + + The failure is one-directional, which is what makes it dangerous: renaming + the key once breaks the match silently, and a *single* key covering both + new names is not expressible, because ``endswith`` compares a suffix. A + class that was split needs one entry per resulting class. + + :func:`register_special_constructor` writes to the module-level + ``_SPECIAL_REGISTRY``, which is what :func:`make_instance` reads. Note that + this function rebuilds its dict on every call, so a registration made + elsewhere is **not** visible to a caller that invokes + ``special_constructors()`` directly — core's expression-contract test does + exactly that, and therefore sees only the literals below. + """ + return { + "statements.ddl_table.ColumnDefinition": _column_definition, + "statements.ddl_table.ColumnConstraint": _column_constraint, + "statements.ddl_table.ForeignKeyConstraint": _foreign_key_constraint, + "statements.ddl_table.TableConstraint": _table_constraint, + "statements.ddl_table.ColumnForeignKeyConstraint": _column_fk_constraint, + "statements.ddl_table.TableForeignKeyConstraint": _table_fk_constraint, + "statements.ddl_table.TablePrimaryKeyConstraint": _table_primary_key, + "statements.ddl_table.TableUniqueConstraint": _table_unique, + "statements.dml.ValuesSource": _values_source, + "statements.ddl_sequence.CreateSequenceStatement": _create_sequence_expr, + "statements.ddl_sequence.DropSequenceStatement": _drop_sequence_expr, + "statements.ddl_sequence.AlterSequenceStatement": _alter_sequence_expr, + "statements.ddl_type.CreateTypeStatement": _create_type_expr, + "statements.ddl_type.AlterTypeStatement": _alter_type_expr, + "statements.ddl_type.DropTypeStatement": _drop_type_expr, + "statements.ddl_domain.CreateDomainStatement": _create_domain_expr, + "statements.ddl_domain.AlterDomainStatement": _alter_domain_expr, + "statements.ddl_domain.DropDomainStatement": _drop_domain_expr, + "statements.ddl_function.CreateFunctionStatement": _create_function_expr, + "statements.ddl_function.DropFunctionStatement": _drop_function_expr, + "statements.ddl_schema.CreateSchemaStatement": _create_schema_expr, + "statements.ddl_schema.DropSchemaStatement": _drop_schema_expr, + "statements.ddl_database.CreateDatabaseStatement": _create_database_expr, + "statements.ddl_database.DropDatabaseStatement": _drop_database_expr, + # core splits what was one JSONExpression into a document and a text + # class, so one key cannot cover both: endswith matches a suffix. + "advanced_functions.JSONDocumentValue": _json_document_expr, + "advanced_functions.JSONTextValue": _json_text_expr, + "query_parts.JoinClause": _join_expr, + "statements.ddl_partition.PartitionClause": _partition_clause, + "statements.dml.OnConflictClause": _on_conflict, + "statements.dml.UpdateStatement": _update_expr, + "statements.dml.InsertStatement": _insert_expr, + "statements.dml.DeleteStatement": _delete_expr, + "statements.ddl_alter.AlterTableStatement": _alter_table_expr, + "types.array.ArrayType": _array_type, + "statements.ddl_view.CreateViewStatement": _create_view_expr, + "graph.GraphVertex": _graph_vertex, + "graph.GraphEdge": _graph_edge, + "graph.VertexTable": _node_table, + "graph.EdgeTableDefinition": _edge_table_definition, + "graph.GraphTableExpression": _graph_table_expr, + "graph.CreatePropertyGraphStatement": _create_property_graph_expr, + "graph.DropPropertyGraphStatement": _drop_property_graph_expr, + "graph.AlterPropertyGraphStatement": _alter_property_graph_expr, + "datetime.ExtractExpression": _extract_expr, + "datetime.DatePartExpression": _datepart_expr, + "datetime.DateTruncExpression": _datetrunc_expr, + "datetime.IntervalExpression": _interval_expr, + "datetime.DatetimeDiffExpression": _datetime_diff_expr, + "datetime.DatetimeSubtractExpression": _datetime_sub_expr, + "datetime.DatetimeAddExpression": _datetime_add_expr, + } + + +def _column_definition(dialect): + from rhosocial.activerecord.backend.expression.statements import ColumnDefinition + from rhosocial.activerecord.backend.expression.types import IntegerType + + return ColumnDefinition(dialect, "col", IntegerType(dialect)) + + +# ---------------------------------------------------------------- constraint nodes +# A constraint is a clause plus the branch that says which kind it is, so the +# two levels need a branch each: the clause cannot be built without saying what +# it constrains, and the branches whose payload is a relation or a column list +# need one the heuristic guess has no way to supply. + + +def _column_constraint(dialect): + from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, + ColumnNotNullConstraint, + ) + + return ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)) + + +def _foreign_key_constraint(dialect): + """The construction sugar over a table-level FOREIGN KEY branch. + + Same reason as the branch itself: what it points at has to be a real + ``Table``, which a guessed placeholder cannot be. + """ + from rhosocial.activerecord.backend.expression.objects import Table + from rhosocial.activerecord.backend.expression.statements import ( + ForeignKeyConstraint, + ) + + return ForeignKeyConstraint( + dialect, + columns=["col"], + foreign_key_table=Table(dialect, "parent"), + foreign_key_columns=["id"], + ) + + +def _table_constraint(dialect): + from rhosocial.activerecord.backend.expression.statements import ( + TableConstraint, + TableUniqueConstraint, + ) + + return TableConstraint(dialect, branch=TableUniqueConstraint(dialect, ["col"])) + + +def _column_fk_constraint(dialect): + from rhosocial.activerecord.backend.expression.objects import Table + from rhosocial.activerecord.backend.expression.statements import ( + ColumnForeignKeyConstraint, + ) + + return ColumnForeignKeyConstraint(dialect, (Table(dialect, "parent"), ["id"])) + + +def _table_fk_constraint(dialect): + from rhosocial.activerecord.backend.expression.objects import Table + from rhosocial.activerecord.backend.expression.statements import ( + TableForeignKeyConstraint, + ) + + return TableForeignKeyConstraint(dialect, ["col"], (Table(dialect, "parent"), ["id"])) + + +def _table_primary_key(dialect): + from rhosocial.activerecord.backend.expression.statements import ( + TablePrimaryKeyConstraint, + ) + + return TablePrimaryKeyConstraint(dialect, ["col"]) + + +def _table_unique(dialect): + from rhosocial.activerecord.backend.expression.statements import TableUniqueConstraint + + return TableUniqueConstraint(dialect, ["col"]) + + +def _json_document_expr(dialect): + """``->``: a JSON *document*, reached by path. + + ``path`` and ``operation`` keep their defaults, so this renders exactly + what the pre-split single ``JSONExpression`` factory rendered. + """ + from rhosocial.activerecord.backend.expression.advanced_functions import ( + JSONDocumentValue, + ) + from rhosocial.activerecord.backend.expression.core import Literal + + return JSONDocumentValue(dialect, Literal(dialect, '{"a": 1}'), path="$.a") + + +def _json_text_expr(dialect): + """``->>``: the *text* of a JSON path. + + ``JSONTextValue`` declares no ``__init__`` of its own, so it inherits + the document class's -- including ``operation="->"``. The default is + therefore inherited too, and this factory has to say ``->>`` explicitly. + Leaving it off would hand the contract test a ``JSONTextValue`` whose + ``operation`` says ``->``, which is the one thing the class exists to + distinguish. + + The rendered SQL is unaffected either way: the formatters branch on + ``expr.operation``, not on the class, and dispatch is on ``format_method``, + which both classes share. + """ + from rhosocial.activerecord.backend.expression.advanced_functions import ( + JSONTextValue, + ) + from rhosocial.activerecord.backend.expression.core import Literal + + return JSONTextValue( + dialect, Literal(dialect, '{"a": 1}'), path="$.a", operation="->>" + ) + + +def _values_source(dialect): + """A ``VALUES`` clause with one row of one column. + + ``ValuesSource`` rejects an empty ``values_list``, and the heuristic filler + returns ``[]`` for any ``list``-origin annotation -- which is why this class + needs an explicit constructor at all. ``[]`` would also be the wrong thing + to satisfy it with: a ``VALUES`` clause with no rows is not an INSERT + source, and the zero-row spelling already has its own class, + ``DefaultValuesSource``, sitting next to this one. + """ + from rhosocial.activerecord.backend.expression.core import Literal + from rhosocial.activerecord.backend.expression.statements.dml import ValuesSource + + return ValuesSource(dialect, [[Literal(dialect, 1)]]) + + +def _array_type(dialect): + """An array of integers -- one dimension. + + ``ArrayType`` requires an ``element_type`` and raises ``TypeError`` without + one ("an array with no element type is not a type"). Two things stop the + heuristic filler from reaching that requirement, and neither is fixable by + giving the parameter a default: + + * the filler skips defaulted parameters, so it calls ``ArrayType(dialect)`` + with nothing else; and + * ``dialect`` must come first and ``element_type`` must follow it, so a + *required* ``element_type`` after a defaulted ``dialect`` is not + expressible in Python at all. + + Making the parameter required therefore cannot help, and a default of + ``None`` is already what core declares. So the element type has to be + supplied here, as a concrete ``DataType``. + """ + from rhosocial.activerecord.backend.expression.types import ( + ArrayType, + IntegerType, + ) + + return ArrayType(dialect, element_type=IntegerType(dialect)) + + +def _sequence(dialect): + """A sequence, by kind.""" + from rhosocial.activerecord.backend.expression.objects import Sequence + + return Sequence(dialect, "s") + + +def _create_sequence_expr(dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_sequence import ( + CreateSequenceStatement, + ) + + return CreateSequenceStatement(dialect, _sequence(dialect)) + + +def _drop_sequence_expr(dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_sequence import ( + DropSequenceStatement, + ) + + return DropSequenceStatement(dialect, _sequence(dialect)) + + +def _alter_sequence_expr(dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_sequence import ( + AlterSequenceStatement, + ) + + return AlterSequenceStatement(dialect, _sequence(dialect)) + + +def _create_type_expr(dialect): + from rhosocial.activerecord.backend.impl.dummy.expression import _DummyTypeDefinition + from rhosocial.activerecord.backend.expression.objects import Type + from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + CreateTypeStatement, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + + return CreateTypeStatement( + dialect, Type(dialect, "t"), _DummyTypeDefinition(dialect, IntegerType(dialect)) + ) + + +def _alter_type_expr(dialect): + from rhosocial.activerecord.backend.impl.dummy.expression import _DummyTypeAlterAction + from rhosocial.activerecord.backend.expression.objects import Type + from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + AlterTypeStatement, + ) + + return AlterTypeStatement( + dialect, Type(dialect, "t"), [_DummyTypeAlterAction(dialect, "t2")] + ) + + +def _drop_type_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Type + from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + DropTypeStatement, + ) + + return DropTypeStatement(dialect, Type(dialect, "t")) + + +def _create_domain_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Domain + from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + CreateDomainStatement, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + + return CreateDomainStatement(dialect, Domain(dialect, "d"), IntegerType(dialect)) + + +def _alter_domain_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Literal + from rhosocial.activerecord.backend.expression.objects import Domain + from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + AlterDomainStatement, + SetDomainDefaultAction, + ) + + return AlterDomainStatement( + dialect, Domain(dialect, "d"), [SetDomainDefaultAction(dialect, Literal(dialect, 1))] + ) + + +def _drop_domain_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Domain + from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + DropDomainStatement, + ) + + return DropDomainStatement(dialect, Domain(dialect, "d")) + + +def _create_function_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Function + from rhosocial.activerecord.backend.expression.statements.ddl_function import ( + CreateFunctionStatement, + ) + + return CreateFunctionStatement(dialect, Function(dialect, "f")) + + +def _drop_function_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Function + from rhosocial.activerecord.backend.expression.statements.ddl_function import ( + DropFunctionStatement, + ) + + return DropFunctionStatement(dialect, Function(dialect, "f")) + + +def _create_schema_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Schema + from rhosocial.activerecord.backend.expression.statements.ddl_schema import ( + CreateSchemaStatement, + ) + + return CreateSchemaStatement(dialect, Schema(dialect, "s")) + + +def _drop_schema_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Schema + from rhosocial.activerecord.backend.expression.statements.ddl_schema import ( + DropSchemaStatement, + ) + + return DropSchemaStatement(dialect, Schema(dialect, "s")) + + +def _create_database_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Database + from rhosocial.activerecord.backend.expression.statements.ddl_database import ( + CreateDatabaseStatement, + ) + + return CreateDatabaseStatement(dialect, Database(dialect, "d")) + + +def _drop_database_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Database + from rhosocial.activerecord.backend.expression.statements.ddl_database import ( + DropDatabaseStatement, + ) + + return DropDatabaseStatement(dialect, Database(dialect, "d")) + + +def _node_table(dialect): + """A graph vertex table.""" + from rhosocial.activerecord.backend.expression.graph import VertexTable + from rhosocial.activerecord.backend.expression.objects import NodeTable + + return VertexTable(dialect, NodeTable(dialect, "n")) + + +def _edge_table_definition(dialect): + """The edge-table definition of CREATE PROPERTY GRAPH. + + Core renamed the clause: the class that renders ``table ... SOURCE KEY ... + DESTINATION KEY ...`` is ``EdgeTableDefinition``, because the table it + names is a relation object (:class:`objects.EdgeTable`) and two classes + with one name had one derived kind for two contracts. The old + ``graph.EdgeTable`` key here matched nothing once the rename landed -- the + documented failure mode of an ``endswith`` key -- so the key follows the + class. + """ + from rhosocial.activerecord.backend.expression.graph import EdgeTableDefinition + from rhosocial.activerecord.backend.expression.objects import ( + EdgeTable as EdgeTableObject, + ) + + return EdgeTableDefinition( + dialect, EdgeTableObject(dialect, "e"), ["s"], ["d"] + ) + + +def _graph_vertex(dialect): + from rhosocial.activerecord.backend.expression.graph import GraphVertex + from rhosocial.activerecord.backend.expression.objects import NodeTable + + return GraphVertex(dialect, "v", NodeTable(dialect, "n")) + + +def _graph_edge(dialect): + from rhosocial.activerecord.backend.expression.graph import ( + GraphEdge, + GraphEdgeDirection, + ) + from rhosocial.activerecord.backend.expression.objects import ( + EdgeTable as EdgeTableObject, + ) + + return GraphEdge(dialect, "e", EdgeTableObject(dialect, "e"), + GraphEdgeDirection.RIGHT) + + +def _graph_table_expr(dialect): + from rhosocial.activerecord.backend.expression.graph import ( + ColumnsClause, + GraphColumn, + GraphTableExpression, + MatchClause, + ) + from rhosocial.activerecord.backend.expression.objects import PropertyGraph + + return GraphTableExpression( + dialect, + PropertyGraph(dialect, "g"), + MatchClause( + dialect, + _graph_vertex(dialect), + _graph_edge(dialect), + _graph_vertex(dialect), + ), + ColumnsClause(dialect, GraphColumn("v", "name")), + ) + + +def _create_property_graph_expr(dialect): + from rhosocial.activerecord.backend.expression.graph import ( + CreatePropertyGraphStatement, + ) + from rhosocial.activerecord.backend.expression.objects import PropertyGraph + + return CreatePropertyGraphStatement( + dialect, PropertyGraph(dialect, "g"), [_node_table(dialect)] + ) + + +def _drop_property_graph_expr(dialect): + from rhosocial.activerecord.backend.expression.graph import ( + DropPropertyGraphStatement, + ) + from rhosocial.activerecord.backend.expression.objects import PropertyGraph + + return DropPropertyGraphStatement(dialect, PropertyGraph(dialect, "g")) + + +def _alter_property_graph_expr(dialect): + from rhosocial.activerecord.backend.expression.graph import ( + AlterPropertyGraphStatement, + ) + from rhosocial.activerecord.backend.expression.objects import PropertyGraph + + return AlterPropertyGraphStatement( + dialect, + PropertyGraph(dialect, "g"), + "ADD", + "VERTEX TABLES", + vertex_tables=[_node_table(dialect)], + ) + + +def _table(dialect, name="t"): + """The catalogue object a statement acts on.""" + from rhosocial.activerecord.backend.expression.objects import Table + + return Table(dialect, name) + + +def _relation(dialect, annotation): + """The object an annotation asks for, chosen by the kind it names. + + The kind is not decoration: CREATE VIEW will not accept a Table, and DROP + MATERIALIZED VIEW will not accept a View. Longest name first, because + ``MaterializedView`` contains ``View`` and the more specific kind is the one + an annotation naming it means. + """ + import rhosocial.activerecord.backend.expression.objects as objects + + for kind in ("MaterializedView", "ForeignTable", "NodeTable", "EdgeTable", + "Sequence", "Index", "View", "Table", "Type", "Domain", + "Function", "Trigger", "Schema", "Database"): + if re.search(rf"\b{kind}\b", annotation): + return getattr(objects, kind)(dialect, "t") + return _table(dialect) + + +def _source(dialect, name="t"): + """A row source over the object a statement acts on. + + A statement that *names* a relation takes the object; a statement that + *reads rows from* one takes a source over it. The two are not + interchangeable, so they get their own helper rather than one that guesses + which position it is filling. + """ + from rhosocial.activerecord.backend.expression.objects import Table + from rhosocial.activerecord.backend.expression.sources import NamedRelationRef + + return NamedRelationRef(dialect, Table(dialect, name)) + + +def _join_expr(dialect): + from rhosocial.activerecord.backend.expression.query_parts import JoinClause + from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate + from rhosocial.activerecord.backend.expression.core import Column + + return JoinClause( + dialect, + left_table=_source(dialect, "a"), + right_table=_source(dialect, "b"), + condition=ComparisonPredicate(dialect, "=", Column(dialect, "a"), Column(dialect, "b")), + ) + + +def _partition_clause(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.statements.ddl_partition import ( + PartitionClause, + PartitionStrategy, + ) + + return PartitionClause(dialect, method=PartitionStrategy.RANGE, keys=[Column(dialect, "created_at")]) + + +def _on_conflict(dialect): + from rhosocial.activerecord.backend.expression.statements.dml import OnConflictClause + + return OnConflictClause(dialect, do_nothing=True, conflict_target=["id"]) + + +def _insert_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Literal + from rhosocial.activerecord.backend.expression.statements.dml import ( + InsertStatement, + ValuesSource, + ) + + return InsertStatement(dialect, into=_table(dialect), source=ValuesSource(dialect, [[Literal(dialect, 1)]])) + + +def _delete_expr(dialect): + from rhosocial.activerecord.backend.expression.statements.dml import DeleteStatement + + return DeleteStatement(dialect, tables=_table(dialect)) + + +def _update_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Literal + from rhosocial.activerecord.backend.expression.statements.dml import UpdateStatement + + return UpdateStatement(dialect, table=_table(dialect), assignments={"a": Literal(dialect, 1)}) + + +def _alter_table_expr(dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AddColumnAction, + AlterTableStatement, + DropColumnAction, + ) + + column = _column_definition(dialect) + return AlterTableStatement( + dialect, + table=_table(dialect, "t"), + actions=[DropColumnAction(dialect, "a"), AddColumnAction(dialect, column=column)], + ) + + +def _create_view_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.objects import View + from rhosocial.activerecord.backend.expression.statements.ddl_view import CreateViewStatement + from rhosocial.activerecord.backend.expression.statements.dql import QueryStatement + + query = QueryStatement(dialect, select=[Column(dialect, "id")], from_=_source(dialect, "t")) + return CreateViewStatement(dialect, view=View(dialect, "v"), query=query) + + +def _extract_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.datetime import ExtractExpression + + return ExtractExpression(dialect, "YEAR", Column(dialect, "created_at")) + + +def _datepart_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.datetime import DatePartExpression + + return DatePartExpression(dialect, "YEAR", Column(dialect, "created_at")) + + +def _datetrunc_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.datetime import DateTruncExpression + + return DateTruncExpression(dialect, "year", Column(dialect, "created_at")) + + +def _interval_expr(dialect): + from rhosocial.activerecord.backend.expression.datetime import IntervalExpression + + return IntervalExpression(dialect, 7, "day") + + +def _datetime_diff_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.datetime import DatetimeDiffExpression + + return DatetimeDiffExpression(dialect, "day", Column(dialect, "a"), Column(dialect, "b")) + + +def _datetime_sub_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.datetime import ( + DatetimeSubtractExpression, + IntervalExpression, + ) + + return DatetimeSubtractExpression(dialect, Column(dialect, "a"), IntervalExpression(dialect, 1, "day")) + + +def _datetime_add_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.datetime import ( + DatetimeAddExpression, + IntervalExpression, + ) + + return DatetimeAddExpression(dialect, Column(dialect, "a"), IntervalExpression(dialect, 1, "day")) + + +_SPECIAL_REGISTRY: Dict[str, Callable] = special_constructors() + + +def register_special_constructor(fqn_suffix: str, factory: Callable) -> None: + """Register an explicit constructor for an expression class. + + ``fqn_suffix`` must match the tail of the class's fully qualified name + (e.g. ``"json.ClickHouseJSONObjectExpression"``). ``factory(dialect)`` must + return an expression instance. + """ + _SPECIAL_REGISTRY[fqn_suffix] = factory + + +def make_instance(cls: Type[BaseExpression], dialect: Any): + """Best-effort construction for an expression class. + + Returns (instance, source) where source is "special", "heuristic", or + (None, "failed"). + """ + full_name = f"{cls.__module__}.{cls.__name__}" + special = next((fn for s, fn in _SPECIAL_REGISTRY.items() if full_name.endswith(s)), None) + if special is not None: + try: + return special(dialect), "special" + except Exception: + return None, "special-failed" + instance = _try_construct(cls, dialect) + if instance is None: + return None, "failed" + return instance, "heuristic" + + +def collect_expression_classes(package_path: str) -> Dict[str, Type[BaseExpression]]: + """Walk a package subtree and collect every defined BaseExpression subclass.""" + import importlib + import pkgutil + + pkg = importlib.import_module(package_path) + classes: Dict[str, Type[BaseExpression]] = {} + for _, modname, _ in pkgutil.walk_packages(pkg.__path__, pkg.__name__ + "."): + try: + mod = importlib.import_module(modname) + except Exception: + continue + for name in dir(mod): + obj = getattr(mod, name, None) + if ( + isinstance(obj, type) + and obj.__module__ == modname + and issubclass(obj, BaseExpression) + and obj is not BaseExpression + and not inspect.isabstract(obj) + ): + classes[f"{modname}.{name}"] = obj + return classes + + +def register_all(classes: Dict[str, Type[BaseExpression]]) -> None: + """Hand the collected classes to the deserialization kind whitelist. + + Core split the class index out of the serialization module into a + registry: deserialization resolves a node to its class through the + framework's default registry, not through the ``(dialect, kind)`` render + registry -- the two are separate mechanisms and only the former names + expression classes. Core and the built-in backends populate that registry + on first use; registering one of those classes again is a no-op, and the + backend classes collected by the matrix are what this call adds. + """ + from rhosocial.activerecord.backend.expression.serialization import default_registry + + registry = default_registry() + for cls in classes.values(): + registry.register_node(cls) + + +def assert_params_equal(a: Any, b: Any, path: str = "params") -> None: + """Deep-compare two get_params() outputs, treating nested BaseExpression + instances as structurally equal and nested dataclasses as recursively compared. + """ + if a is b: + return + if isinstance(a, BaseExpression) and isinstance(b, BaseExpression): + assert_params_equal(a.get_params(), b.get_params(), path + ".") + return + if dataclasses.is_dataclass(a) and not isinstance(a, type): + assert dataclasses.is_dataclass(b) and not isinstance(b, type), ( + f"{path}: expected dataclass instance, got {type(b).__name__}" + ) + for f in dataclasses.fields(a): + assert_params_equal(getattr(a, f.name), getattr(b, f.name), f"{path}.{f.name}") + return + assert type(a) is type(b) or ( + isinstance(a, (list, tuple, dict)) and isinstance(b, (list, tuple, dict)) + ), f"{path}: type mismatch {type(a).__name__} vs {type(b).__name__}" + if isinstance(a, dict): + assert set(a) == set(b), f"{path}: keys differ {set(a) ^ set(b)}" + for k in a: + assert_params_equal(a[k], b[k], f"{path}.{k}") + return + if isinstance(a, (list, tuple)): + assert len(a) == len(b), f"{path}: length differ" + for i, (x, y) in enumerate(zip(a, b, strict=True)): + assert_params_equal(x, y, f"{path}[{i}]") + return + assert a == b, f"{path}: {a!r} != {b!r}" + + +def roundtrip_expression(fqn: str, instance: BaseExpression, dialect: Any) -> None: + """Assert an instance round-trips losslessly through dict / JSON / XML.""" + original = instance.get_params() + for restored in ( + _rt_dict(instance, dialect), + _rt_json(instance, dialect), + _rt_xml(instance, dialect), + ): + assert_params_equal(restored.get_params(), original, fqn) + + +def _rt_dict(instance, dialect): + from rhosocial.activerecord.backend.expression.serialization import deserialize, serialize + + return deserialize(serialize(instance), dialect) + + +def _rt_json(instance, dialect): + from rhosocial.activerecord.backend.expression.serialization import deserialize_json, serialize_json + + return deserialize_json(serialize_json(instance), dialect) + + +def _rt_xml(instance, dialect): + from rhosocial.activerecord.backend.expression.serialization import deserialize_xml, serialize_xml + + return deserialize_xml(serialize_xml(instance), dialect) + + +def sql_consistent(fqn: str, instance: BaseExpression, dialect: Any) -> None: + """Assert to_sql is identical after round-trip, when the dialect supports it.""" + try: + expected = instance.to_sql() + except Exception: + return + for restored in (_rt_dict(instance, dialect), _rt_json(instance, dialect), _rt_xml(instance, dialect)): + assert restored.to_sql() == expected, fqn + + +def parse_roundtrip_failures( + dialect: Any, + registry: Dict[str, Any], + *, + widening: Optional[Dict[str, Any]] = None, + skip: Any = (), +) -> Any: + """Sweep *registry* and return the parse round-trip failures as text. + + Every dialect's ``parse_type`` is the introspection inverse of its + ``format_data_type_`` renderings, and two invariants make a + dialect coherent: + + **String stability** -- parsing a rendering and re-rendering the + answer must produce the identical string. Whatever ``parse`` answers, + its answer must name the same storage the server holds, or the schema + differ would report a change no one made. + + **Class honesty** -- the answer is either the declared instance + itself (``parsed == declared``, the round-trip-equal case) or the + *documented* answer class for that declaration, recorded in the + owning backend's test file with its reason. + + This is the vendored equivalent of the testsuite's shared sweep + (``testsuite/utils/parse_contract.py``), which the testsuite deleted + together with its round-trip harness when round-trip coverage moved + to the owning repository. The two decisions in it that outlived that + move are unchanged: a class the registry cannot construct or cannot + render belongs to the round-trip matrix's own lists, and a + ``CustomType`` answer is never blessed for a rendered word. + + Args: + dialect: the dialect whose ``parse_type`` is under test. + registry: a mapping of fully-qualified name to class, as the + backend's round-trip module keeps it (``REGISTERED`` or + ``ALL_CLASSES``). Only concrete ``DataType`` subclasses are + swept. + widening: the backend's documented answer table, + ``{declared class name: answer}``. An entry here asserts the + parse answers with exactly that class and that the answer + re-renders the identical string, **unless** the value is a + ``(class name, expected string)`` tuple: that form is for the + documented answers whose re-render deliberately *differs*. + skip: class names to leave out of the sweep entirely, in addition + to the built-in ``CustomType`` skip. + + Returns: + A list of human-readable failures; empty is the contract met. + """ + from rhosocial.activerecord.backend.expression.types import CustomType, DataType + + widening = dict(widening or {}) + skipped = set(skip) | {"CustomType"} + failures = [] + for fqn in sorted(registry): + cls = registry[fqn] + if not (isinstance(cls, type) and issubclass(cls, DataType)): + continue + if cls is DataType or cls is CustomType: + continue + if cls.__name__ in skipped: + continue + instance, source = make_instance(cls, dialect) + if instance is None: + continue # the matrix's unbuildable list owns this class + try: + rendered, _params = instance.to_sql() + except Exception: + continue # the matrix's pinned non-renders own this class + try: + parsed = dialect.parse_type(rendered) + except Exception as exc: + failures.append( + "%s renders %r but parse_type raises %s: %s" + % (cls.__name__, rendered, type(exc).__name__, exc) + ) + continue + if isinstance(parsed, CustomType): + failures.append( + "%s renders %r but parse_type answers CustomType -- a " + "dialect must recognise its own renderings" + % (cls.__name__, rendered) + ) + continue + expected_entry = widening.get(cls.__name__) + if expected_entry is None: + if parsed != instance: + failures.append( + "%s renders %r but parse_type answers %r -- neither " + "the declaration nor a documented widening" + % (cls.__name__, rendered, parsed) + ) + continue + if isinstance(expected_entry, tuple): + expected, expected_restring = expected_entry + else: + expected, expected_restring = expected_entry, None + if type(parsed).__name__ != expected: + failures.append( + "%s renders %r; the documented answer is %s but " + "parse_type answers %s" + % (cls.__name__, rendered, expected, type(parsed).__name__) + ) + continue + try: + re_rendered, _ = parsed.to_sql() + except Exception as exc: + failures.append( + "%s renders %r; the documented answer %s re-renders with " + "%s: %s" + % (cls.__name__, rendered, expected, type(exc).__name__, exc) + ) + continue + if expected_restring is not None: + if re_rendered != expected_restring: + failures.append( + "%s renders %r; the documented answer %s re-renders " + "%r but the table says %r" + % (cls.__name__, rendered, expected, + re_rendered, expected_restring) + ) + elif re_rendered != rendered: + failures.append( + "%s renders %r but the documented answer %s re-renders " + "%r -- the strings must name the same storage, or the " + "table entry must record the documented re-render as a " + "(class, string) tuple" + % (cls.__name__, rendered, expected, re_rendered) + ) + return failures + + +__all__ = [ + "assert_params_equal", + "collect_expression_classes", + "make_instance", + "parse_roundtrip_failures", + "register_all", + "register_special_constructor", + "roundtrip_expression", + "sql_consistent", +] \ No newline at end of file diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/test_add_column_action.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/test_add_column_action.py new file mode 100644 index 0000000..aa4d39f --- /dev/null +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/test_add_column_action.py @@ -0,0 +1,94 @@ +# tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/test_add_column_action.py +"""``ADD COLUMN ... AFTER `` — the one clause on ClickHouse's ADD COLUMN. + +Core's ``AddColumnAction`` declares ``column`` and ``if_not_exists`` and stops +there: no other backend has a positional clause, so there is no core slot for +one to arrive through. ``ClickHouseAddColumnAction`` adds ClickHouse's ``AFTER`` +as its own parameter, and this module is what proves the parameter reaches the +SQL -- it was declared, stored, serialised, and read by nobody, which is why the +example that passes ``after="name"`` and reports the new column shifted into +position never shifted it. + +Construction -> render only, no connection (§12); the end-to-end half belongs +to the ddl area. + +The name is an identifier, not a value, so it is quoted the way every other +identifier this dialect writes is quoted -- including when the name is a +reserved word, which is the case a bare ``f"{sql} AFTER {after}"`` would turn +into a server-side syntax error. +""" + +import pytest + +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.statements import ( + AlterTableStatement, + ColumnDefinition, +) +from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AddColumnAction, +) +from rhosocial.activerecord.backend.impl.clickhouse.dialect import ClickHouseDialect +from rhosocial.activerecord.backend.impl.clickhouse.expression import ( + ClickHouseAddColumnAction, +) +from rhosocial.activerecord.backend.impl.clickhouse.expression.types import ( + ClickHouseUInt32Type, +) + + +@pytest.fixture +def dialect(): + return ClickHouseDialect() + + +def _alter(dialect, action): + return AlterTableStatement(dialect, Table(dialect, "users"), [action]).to_sql() + + +def _action(dialect, cls, **kwargs): + return cls( + dialect, + ColumnDefinition(dialect, "age", ClickHouseUInt32Type(dialect)), + **kwargs, + ) + + +class TestAfterClause: + """``after`` is a position the caller asked for, so it must be in the SQL.""" + + def test_no_position_renders_exactly_what_the_core_entry_renders(self, dialect): + assert _alter(dialect, _action(dialect, ClickHouseAddColumnAction)) == ( + "ALTER TABLE `users` ADD COLUMN `age` UInt32", + (), + ) + + def test_the_core_action_is_untouched_by_the_derived_one(self, dialect): + """Two kinds, two entries: the core node keeps its own spelling.""" + assert _alter(dialect, _action(dialect, AddColumnAction)) == ( + "ALTER TABLE `users` ADD COLUMN `age` UInt32", + (), + ) + + def test_after_names_the_position(self, dialect): + assert _alter( + dialect, _action(dialect, ClickHouseAddColumnAction, after="name") + ) == ("ALTER TABLE `users` ADD COLUMN `age` UInt32 AFTER `name`", ()) + + def test_after_composes_with_if_not_exists(self, dialect): + """Both modifiers are the caller's, so both must reach the same line.""" + assert _alter( + dialect, + _action( + dialect, ClickHouseAddColumnAction, if_not_exists=True, after="name" + ), + ) == ( + "ALTER TABLE `users` ADD COLUMN IF NOT EXISTS `age` UInt32 AFTER `name`", + (), + ) + + def test_after_quotes_a_reserved_word(self, dialect): + """Unquoted, this is the server's syntax error rather than our error.""" + assert _alter( + dialect, _action(dialect, ClickHouseAddColumnAction, after="select") + ) == ("ALTER TABLE `users` ADD COLUMN `age` UInt32 AFTER `select`", ()) \ No newline at end of file diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/test_clickhouse_literal_values.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/test_clickhouse_literal_values.py new file mode 100644 index 0000000..4c1cf41 --- /dev/null +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/test_clickhouse_literal_values.py @@ -0,0 +1,136 @@ +# tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/test_clickhouse_literal_values.py +"""ClickHouse's tier-2 literal spellings, pinned to live measurements. + +``Literal`` is the one node that carries a value and the one decision that +matters at a syntax position: bind, or write the text. The text itself comes +from ``format_literal``, whose second tier is the backend's own -- and this +backend has exactly two spellings there: ``unhex('...')`` for bytes and the +bare quoted text for a UUID. + +Every literal asserted here was measured against the instance +``tests/config/clickhouse_scenarios.yaml`` names ``clickhouse_http``: +**ClickHouse 26.7.3.19 (official build)**, HTTP on ``127.0.0.1:18682``, +database ``test_db``. The statements and readbacks behind each verdict are +recorded in ``mixins/literal.py``; this module pins the rendered text only. +The live end-to-end verification (CREATE TABLE with the literal as a DEFAULT, +INSERT, read back, compare) moved to the integration area it belongs to -- +``../ddl/test_literal_default_readbacks.py`` -- per the org rules +(expression-dialect §12): expression tests stay construction → render. + +The tier-1 kinds (NULL, booleans, numbers, strings, dates) render through +the base's portable spellings and were measured accepted verbatim as +DEFAULTs on Date / DateTime / Bool columns, so they are asserted for the +wiring only -- exact text, no override expected. + +``timedelta`` is deliberately not spelled: the base refuses it, and the +module docstring in ``mixins/literal.py`` records the measurements that make +the refusal the answer rather than a gap. +""" + +import datetime +import uuid as uuidlib + +import pytest + +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.impl.clickhouse.dialect import ClickHouseDialect +from rhosocial.activerecord.backend.impl.clickhouse.mixins import ( + ClickHouseLiteralValueMixin, +) +from rhosocial.activerecord.backend.dialect.base import SQLDialectBase + +#: The exact release every verdict below was measured on. Hard-coded on +#: purpose, the same way ``test_clickhouse_constraint_capabilities.py`` does +#: it: reading it back from the dialect would let a version bump silently +#: re-label the evidence. +MEASURED_VERSION = (26, 7, 3) + +_UUID = uuidlib.UUID("01234567-89ab-cdef-0123-456789abcdef") +_UUID_TEXT = "01234567-89ab-cdef-0123-456789abcdef" + + +@pytest.fixture() +def dialect() -> ClickHouseDialect: + return ClickHouseDialect(version=MEASURED_VERSION) + + +class TestBindingDecision: + """``inline`` decides bind-or-text; the dialect's placeholder is ClickHouse's.""" + + def test_data_position_binds_the_host_value(self, dialect): + node = Literal(dialect, 42) + sql, params = node.to_sql() + assert sql == "%s" + assert params == (42,) + + def test_inline_position_writes_text(self, dialect): + node = Literal(dialect, 42, inline=True) + sql, params = node.to_sql() + assert sql == "42" + assert params == () + + +class TestTierOneSpellings: + """The base's portable forms, measured accepted verbatim on 26.7.3.19.""" + + @pytest.mark.parametrize( + ("value", "expected"), + [ + (None, "NULL"), + (True, "TRUE"), + (False, "FALSE"), + (42, "42"), + (1.5, "1.5"), + ("it's", "'it''s'"), + (datetime.date(2026, 1, 1), "'2026-01-01'"), + (datetime.time(12, 30), "'12:30:00'"), + (datetime.datetime(2026, 1, 1, 12, 0), "'2026-01-01 12:00:00'"), + ], + ) + def test_base_spelling_verbatim(self, dialect, value, expected): + assert dialect.format_literal(value) == expected + + +class TestTierTwoSpellings: + """ClickHouse's own two: unhex for bytes, quoted text for UUID.""" + + def test_binary_renders_unhex(self, dialect): + assert dialect.format_literal(b"\x01\x02") == "unhex('0102')" + + def test_binary_via_the_literal_node(self, dialect): + node = Literal(dialect, b"\x01\x02", inline=True) + sql, params = node.to_sql() + assert sql == "unhex('0102')" + assert params == () + + def test_uuid_renders_bare_quoted_text(self, dialect): + assert dialect.format_literal(_UUID) == f"'{_UUID_TEXT}'" + + def test_uuid_via_the_literal_node(self, dialect): + node = Literal(dialect, _UUID, inline=True) + sql, params = node.to_sql() + assert sql == f"'{_UUID_TEXT}'" + assert params == () + + def test_timedelta_is_refused_by_the_base(self, dialect): + """No override for ``timedelta``: the grammar's INTERVAL is + date-arithmetic syntax whose unit is part of the spelling, and a + mixed-unit span degrades to a tuple (see ``mixins/literal.py``), so + the base's explicit refusal is the answer.""" + with pytest.raises(TypeError, match="no inline SQL literal form"): + dialect.format_literal(datetime.timedelta(days=1)) + + def test_override_is_structurally_narrow(self): + """The override spells exactly the two host types it owns; a third + would be a decision this package did not record.""" + import inspect + + branches = inspect.getsource(ClickHouseLiteralValueMixin.format_literal) + assert branches.count("isinstance(value,") == 2 + + def test_backend_mixin_outranks_the_base(self): + """``ClickHouseLiteralValueMixin`` must resolve ahead of + ``SQLDialectBase`` in the MRO: it overrides ``format_literal``, and a + mixin placed after the class it overrides never wins.""" + mro = ClickHouseDialect.__mro__ + assert mro.index(ClickHouseLiteralValueMixin) < mro.index(SQLDialectBase) diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/test_datetime_interval_expressions.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/test_datetime_interval_expressions.py index f5f1bf7..5e0fab1 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/test_datetime_interval_expressions.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/test_datetime_interval_expressions.py @@ -3,7 +3,7 @@ import pytest -from rhosocial.activerecord.backend.expression import Column, Literal, QueryExpression +from rhosocial.activerecord.backend.expression import Column, Literal, QueryStatement from rhosocial.activerecord.backend.expression.functions import ( date_add, date_diff, @@ -142,7 +142,7 @@ def test_alias_and_cast(self, clickhouse_dialect: ClickHouseDialect): def test_query_expression_integration(self, clickhouse_dialect: ClickHouseDialect): shifted = date_add(clickhouse_dialect, Column(clickhouse_dialect, "created_at"), 1, "day") - query = QueryExpression( + query = QueryStatement( clickhouse_dialect, select=[extract(clickhouse_dialect, "year", Column(clickhouse_dialect, "created_at"))], from_="events", diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/test_expression_roundtrip_all.py similarity index 58% rename from tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_expression_roundtrip_all.py rename to tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/test_expression_roundtrip_all.py index e49d108..d0d6ddd 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/test_expression_roundtrip_all.py @@ -1,4 +1,4 @@ -# tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_expression_roundtrip_all.py +# tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/test_expression_roundtrip_all.py """ Serialization coverage for every expression class ClickHouse can render. @@ -94,15 +94,52 @@ class whose formatter this dialect never mixed in read as "ClickHouse lacks the collect only the backend's own package, so core's classes have no systematic coverage anywhere except inside core -- which is how postgres's ``comment.py:42`` read a field core had removed months earlier and stayed green. -Discovery walks both package trees; it never reads ``ExpressionRegistry``, which -is process-global and grows as sibling modules import other backends, so the -matrix's contents would otherwise depend on which files pytest imported first. +Discovery walks both package trees; it never reads the serialization class +index, which is process-global and grows as sibling modules import other +backends, so the matrix's contents would otherwise depend on which files +pytest imported first. No database is involved. The matrix drives the dialect, which is where SQL generation happens; nothing here opens a connection. + +The baseline this table was last reconciled against +=================================================== + +Core ``debde516`` (following ``679d9817``, which made a node's kind its identity +and its payload plain JSON). Every number in the tables below is a statement +about that tree: :func:`test_unmodelled_formatter_list_is_exact`, +:func:`test_unconstructible_list_is_exact`, +:func:`test_pinned_non_render_really_does_not_render` and +:func:`test_pinned_non_roundtrip_refuses_as_pinned` all fail when core moves +underneath, which is the point -- they are what make a core change visible here +instead of on the backend that has to answer for it. Where core moved between +the two baselines, this file says which direction: + +* ``debde516`` gave NaN and the infinities value kinds of their own, so the + closed set :class:`TestLiteralValueKinds` pins is sixteen, not fourteen + (see :attr:`TestLiteralValueKinds._CASES`). +* ``ComparisonPredicate`` began normalising its operator, so the introspective + constructor's placeholder stopped satisfying it and the class took a + registered constructor rather than an UNCONSTRUCTIBLE exemption. +* ``CustomType``'s ``raw`` became keyword-only, and the type root's refusal now + carries the generic dispatch's own spelling. +* ``ForUpdateClause`` began normalising the word it stores, so its entry left + :data:`LEGITIMATE_NON_ROUNDTRIPS` rather than the table holding a reason that + no longer exists. + +Coverage at that baseline: 515 registered, 510 constructible, 5 pinned +unconstructible, 61 pinned non-render; rendered 327, unsupported 121, +unmodelled 32, non-render 30. +:func:`TestMatrixIntegrity.test_coverage_report` prints the current figures on +every run. """ +import datetime +import decimal +import enum import inspect +import math +import uuid from typing import Dict import pytest @@ -117,7 +154,17 @@ class whose formatter this dialect never mixed in read as "ClickHouse lacks the from rhosocial.activerecord.backend.expression.bases import BaseExpression from rhosocial.activerecord.backend.expression.core import Column, Literal from rhosocial.activerecord.backend.expression.datetime import ( + ExtractFieldExpression, TemporalOptionsExpression, + _ExtractBase, +) +from rhosocial.activerecord.backend.expression.functions.string import ( + ChrFunction, + LeftFunction, + OverlayExpression, + RightFunction, + SpaceFunction, + SubstringExpression, ) from rhosocial.activerecord.backend.expression.objects import ( Database, @@ -128,10 +175,16 @@ class whose formatter this dialect never mixed in read as "ClickHouse lacks the Table, View, ) -from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.operators import ArithmeticOperation +from rhosocial.activerecord.backend.expression.predicates import ( + LikeOperator, + ComparisonPredicate, + ILikePredicate, + LikePredicate, +) from rhosocial.activerecord.backend.expression.query_parts import JoinClause +from rhosocial.activerecord.backend.expression.query_sources import SetOperation from rhosocial.activerecord.backend.expression.serialization import ( - ExpressionRegistry, deserialize, deserialize_json, deserialize_xml, @@ -141,12 +194,12 @@ class whose formatter this dialect never mixed in read as "ClickHouse lacks the ) from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from rhosocial.activerecord.backend.expression.statements import ( - CreateTableExpression, - CreateTableLikeExpression, - CreateViewExpression, - DropViewExpression, - InsertExpression, - TruncateExpression, + CreateTableStatement, + CreateTableLikeStatement, + CreateViewStatement, + DropViewStatement, + InsertStatement, + TruncateStatement, ValuesSource, ddl_alter, ddl_comment, @@ -156,28 +209,33 @@ class whose formatter this dialect never mixed in read as "ClickHouse lacks the ) from rhosocial.activerecord.backend.expression.statements.ddl_database import ( AlterDatabaseAction, - AlterDatabaseExpression, - CreateDatabaseExpression, - DropDatabaseExpression, + AlterDatabaseStatement, + CreateDatabaseStatement, + DropDatabaseStatement, +) +from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AddTableConstraintAction, ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableCloneExpression, + CreateTableCloneStatement, TableConstraint, - TableConstraintType, + TableForeignKeyConstraint, ) from rhosocial.activerecord.backend.expression.statements.ddl_view import ( - CreateMaterializedViewExpression, - DropMaterializedViewExpression, - RefreshMaterializedViewExpression, + CreateMaterializedViewStatement, + DropMaterializedViewStatement, + RefreshMaterializedViewStatement, ) +from rhosocial.activerecord.backend.expression.statements.dql import QueryStatement from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType from rhosocial.activerecord.backend.impl.clickhouse.dialect import ClickHouseDialect from rhosocial.activerecord.backend.impl.clickhouse.mixins.namespace import ( ClickHouseNamespaceMixin, ) from rhosocial.activerecord.backend.impl.clickhouse.expression import ( - ClickHouseModifyMaterializedViewRefreshExpression, + ClickHouseModifyMaterializedViewRefreshStatement, ClickHouseRefreshSchedule, + admin as admin_module, alter_column, column as chexpr_column, dml as chexpr_dml, @@ -186,11 +244,12 @@ class whose formatter this dialect never mixed in read as "ClickHouse lacks the partition, rename_table, routine, + storage_option, ) from rhosocial.activerecord.backend.impl.clickhouse.expression import ( types as chexpr_types, ) -from rhosocial.activerecord.testsuite.utils.expression import ( +from expression_roundtrip_harness import ( collect_expression_classes, make_instance, register_all, @@ -228,16 +287,149 @@ def assert_params_equal(a, b, path="params"): return if isinstance(a, (list, tuple)): assert len(a) == len(b), f"{path}: lengths differ" - for i, (x, y) in enumerate(zip(a, b)): + for i, (x, y) in enumerate(zip(a, b, strict=True)): assert_params_equal(x, y, f"{path}[{i}]") return assert a == b, f"{path}: {a!r} != {b!r}" +def _channel_builders(instance, dialect): + """One lazy builder per encoding channel. + + Lazy on purpose: an eager tuple evaluated all three round-trips up front, + so a refusal on the first channel stopped the others from ever being + attempted -- and a pin that names one channel must leave the other two + demonstrably working, not merely unexamined. + """ + return ( + ("dict", lambda: deserialize(serialize(instance), dialect)), + ("json", lambda: deserialize_json(serialize_json(instance), dialect)), + ("xml", lambda: deserialize_xml(serialize_xml(instance), dialect)), + ) + + +def _roundtrip_pin(fqn: str, channel: str): + """The pin for one class on one channel, or ``None`` when it travels free.""" + entry = LEGITIMATE_NON_ROUNDTRIPS.get(fqn) + if entry is None: + return None + return entry["channels"].get(channel) + + +def _assert_pinned_channel(fqn, channel, build, instance, pin): + """Assert one pinned channel, in the shape its entry names. + + ``raises`` -- the channel refuses outright. ``word`` / ``enum-str`` -- the + restore succeeds but is type-lossy, asserted both ways: the strict params + comparison must fail (so the pin stays load-bearing; a core fix makes the + entry stale and this assertion demands its removal), and the normalised + comparison must hold (so the loss is exactly the member/word spelling and + nothing else moved). + """ + if pin[0] == "raises": + _, expected_type, fragment = pin + try: + build() + except Exception as exc: + assert isinstance(exc, expected_type), ( + f"{fqn}: the {channel} channel was pinned as refusing with " + f"{expected_type.__name__}, but it raised " + f"{type(exc).__name__}: {exc}" + ) + assert fragment in str(exc), ( + f"{fqn}: expected the {channel} refusal to mention " + f"{fragment!r}, but it said: {exc}" + ) + else: + raise AssertionError( + f"{fqn}: the {channel} channel is pinned in " + f"LEGITIMATE_NON_ROUNDTRIPS but the round-trip now succeeds. " + f"The reason it was pinned is gone -- remove the entry rather " + f"than leave a lie in the table." + ) + return + mode = pin[0] + restored = build() + try: + assert_params_equal(restored.get_params(), instance.get_params(), fqn) + except AssertionError: + pass # expected: the type loss is the pin's whole subject + else: + raise AssertionError( + f"{fqn}: the {channel} channel is pinned as type-lossy in " + f"LEGITIMATE_NON_ROUNDTRIPS but the round-trip is now exact; " + f"remove the stale entry." + ) + _enum_word_params_equal( + restored.get_params(), instance.get_params(), mode, f"{fqn}.{channel}" + ) + + +def _sql_params_equal(a, b) -> bool: + """Bind-parameter sequences compared as sequences. + + The artifact carries a tuple as an array (§4.8: JSON draws no distinction, + the kind does, and a constructor normalises on the way back), so a + round-tripped render may hand back a list where the original handed a + tuple. ``Subquery`` is the case in the tree: its ``query_params`` arrive + as a list on restore and its formatter passes them through. The values are + the contract; the container is not. Order still matters -- this is a + sequence comparison, not a set comparison. + """ + if isinstance(a, (list, tuple)) and isinstance(b, (list, tuple)): + return len(a) == len(b) and all( + _sql_params_equal(x, y) for x, y in zip(a, b, strict=True) + ) + return a == b + + +def _enum_word_params_equal(a, b, mode: str, path: str = "params") -> None: + """``assert_params_equal``, with a ``(str, Enum)`` member read as its word. + + The two modes are the two spellings an artifact can hand back for a + member: + + * ``"word"`` -- the member's own value, which is what the JSON channel + writes and what the XML channel writes when the enum sets + ``__str__ = str.__str__``; + * ``"enum-str"`` -- ``str(member)`` under the default ``Enum.__str__``, + the ``"EnumClass.MEMBER"`` spelling an enum without that assignment + produces on the XML channel. + + Asserts rather than returns, mirroring :func:`assert_params_equal`; a + difference beyond the member/word spelling fails here by name. + """ + + def _norm(x): + if isinstance(x, enum.Enum): + return str(x) if mode == "enum-str" else x.value + return x + + if isinstance(a, BaseExpression) and isinstance(b, BaseExpression): + _enum_word_params_equal( + a.get_params(), b.get_params(), mode, path + "." + ) + return + # No strict type check here, on purpose: the type difference between a + # member and the word that replaced it is exactly what this comparison + # exists to accept. Everything else still fails by name below. + if isinstance(a, dict): + assert set(a) == set(b), f"{path}: keys differ {set(a) ^ set(b)}" + for k in a: + _enum_word_params_equal(a[k], b[k], mode, f"{path}.{k}") + return + if isinstance(a, (list, tuple)): + assert len(a) == len(b), f"{path}: lengths differ" + for i, (x, y) in enumerate(zip(a, b, strict=True)): + _enum_word_params_equal(x, y, mode, f"{path}[{i}]") + return + assert _norm(a) == _norm(b), f"{path}: {a!r} != {b!r}" + + def _collect_matrix_classes() -> Dict[str, type]: """Every concrete expression class the two covered packages define. - Walked from the package, not read from ``ExpressionRegistry``: the registry + Walked from the package, not read from the serialization class index: that index is process-global and *grows* as other test modules import their own backends, so reading it makes this matrix's contents depend on which files pytest happened to import first. The matrix passed standalone and failed run @@ -245,12 +437,12 @@ def _collect_matrix_classes() -> Dict[str, type]: backend -- classes that were then rendered with the ClickHouse dialect, which is not a meaningful thing to assert. - Several modules export aliases of one class (``ddl_alter`` spells - ``AlterConstraint`` and ``ValidateConstraint`` two ways each). The same - object collected twice would be tested twice and reported twice, so the first - name wins and identity does the deduplication. + Several modules export aliases of one class (``ddl_alter`` used to spell + ``AlterConstraintAction`` and ``ValidateConstraintAction`` two ways + each). The same object collected twice would be tested twice and + reported twice, so the first name wins and identity does the + deduplication. """ - ExpressionRegistry._auto_register_builtins() found: Dict[str, type] = {} for package in COVERED_PACKAGES: found.update(collect_expression_classes(package)) @@ -267,10 +459,15 @@ def _collect_matrix_classes() -> Dict[str, type]: REGISTERED = _collect_matrix_classes() -# Deserialization resolves a class by name through ``ExpressionRegistry``. Core's -# builtins register themselves; the ClickHouse package's classes do not, so they -# are registered here or every round-trip of a backend class raises -# ``ExpressionDeserializationError`` before it can compare anything. +# Deserialization resolves a kind to its class through the framework's +# default registry. Core's own packages auto-populate it; the ClickHouse +# package's classes do not, so they are registered here or every round-trip +# of a backend class refuses by kind before it can compare anything. That +# is the whole registration surface: core 679d9817 made an artifact plain +# JSON, the registry owns the kind -> class table and nothing else, and the +# enum / dataclass vocabulary tables are gone -- a str enum is a JSON +# scalar, and every other conversion lives in the constructor that +# receives it. Core's artifact-contract test pins the absence. register_all(REGISTERED) @@ -304,6 +501,18 @@ def _column_predicate(dialect): return ComparisonPredicate(dialect, "=", Column(dialect, "a"), Column(dialect, "b")) +def _operand(dialect, name): + """A value expression with no bind parameters, for arithmetic operands.""" + return Column(dialect, name) + + +def _ddl_dml_query(dialect, table): + """A minimal SELECT, the operand a set operation needs.""" + return QueryStatement( + dialect, select=[Column(dialect, "id")], from_=Table(dialect, table) + ) + + def _integer_column(dialect, name="col"): """A column definition carrying a *dialect-bound* type. @@ -333,37 +542,38 @@ def register_specials(): # -- tables -------------------------------------------------------------- register_special_constructor( - "statements.ddl_table.CreateTableExpression", - lambda d: ddl_table.CreateTableExpression(d, _table(d), [_integer_column(d)]), + "statements.ddl_table.CreateTableStatement", + lambda d: ddl_table.CreateTableStatement(d, _table(d), [_integer_column(d)]), ) register_special_constructor( "statements.ddl_table.ColumnDefinition", _integer_column ) register_special_constructor( - "statements.ddl_alter.AddColumn", lambda d: ddl_alter.AddColumn(d, _integer_column(d)) + "statements.ddl_alter.AddColumnAction", + lambda d: ddl_alter.AddColumnAction(d, _integer_column(d)), ) register_special_constructor( - "statements.ddl_alter.ModifyColumn", - lambda d: ddl_alter.ModifyColumn(d, _integer_column(d)), + "statements.ddl_alter.ModifyColumnAction", + lambda d: ddl_alter.ModifyColumnAction(d, _integer_column(d)), ) # ``old_name`` comes first here, so the guess's positional order is wrong: # it built the column definition into ``old_name`` and the string into # ``column``, and the failure surfaced as an AttributeError at render time. register_special_constructor( - "statements.ddl_alter.ChangeColumn", - lambda d: ddl_alter.ChangeColumn(d, "old", _integer_column(d)), + "statements.ddl_alter.ChangeColumnAction", + lambda d: ddl_alter.ChangeColumnAction(d, "old", _integer_column(d)), ) register_special_constructor( - "statements.ddl_comment.CommentOnExpression", - lambda d: ddl_comment.CommentOnExpression(d, "table", _table(d), comment="c"), + "statements.ddl_comment.CommentOnStatement", + lambda d: ddl_comment.CommentOnStatement(d, "table", _table(d), comment="c"), ) # -- databases ----------------------------------------------------------- # ``RENAME TO`` is the action core's enum names; without it the guess picks # whatever sorts first. register_special_constructor( - "statements.ddl_database.AlterDatabaseExpression", - lambda d: ddl_database.AlterDatabaseExpression( + "statements.ddl_database.AlterDatabaseStatement", + lambda d: ddl_database.AlterDatabaseStatement( d, Database(d, "db"), action=AlterDatabaseAction.RENAME_TO, @@ -371,6 +581,28 @@ def register_specials(): ), ) + # -- closed word sets the guesser cannot satisfy -------------------------- + # Both nodes validate their operation word at construction and refuse + # anything outside a fixed set. ``make_instance`` guesses a string for the + # ``op`` parameter, so both are refused before they can be rendered -- and + # both are among the nodes worth rendering, so a real word is supplied here + # rather than a new entry in UNCONSTRUCTIBLE. + register_special_constructor( + "operators.ArithmeticOperation", + lambda d: ArithmeticOperation( + d, "add", _operand(d, "a"), _operand(d, "b") + ), + ) + register_special_constructor( + "query_sources.SetOperation", + lambda d: SetOperation( + d, + op="union", + left=_ddl_dml_query(d, "t1"), + right=_ddl_dml_query(d, "t2"), + ), + ) + # -- containers that need at least one member ---------------------------- register_special_constructor( "advanced_functions.CaseExpression", @@ -400,26 +632,97 @@ def register_specials(): ) # -- constraints and column pieces --------------------------------------- - # The guess hands the constraint-type parameter the bare string ``"x"``, which - # is not a ColumnConstraintType and not one of their string values, so the - # formatter's normalizer refuses it before any clause is spelled. + # The branch is a required keyword now, and an introspective constructor + # cannot guess one: ``branch=`` takes a node, not a value the guesser can + # read off the signature. ``ColumnNotNullConstraint`` is the cheapest branch + # that still renders, so it is what the round trip builds. register_special_constructor( "statements.ddl_table.ColumnConstraint", lambda d: ddl_table.ColumnConstraint( - d, ddl_table.ColumnConstraintType.NOT_NULL, name="c" + d, branch=ddl_table.ColumnNotNullConstraint(d), name="c" ), ) + # TABLE CHECK. The guesser's condition carries a binding ``Literal``, and a + # DDL position refuses bind parameters outright now (there is no lift + # pass any more -- the caller states ``inline=True`` where a position + # cannot bind). Two columns compose the same predicate with nothing that + # binds in it. + register_special_constructor( + "statements.ddl_table.TableCheckConstraint", + lambda d: ddl_table.TableCheckConstraint(d, _column_predicate(d)), + ) # REFERENCES needs at least one referenced column, or the clause is not a # clause; the guess supplies an empty list. register_special_constructor( "statements.ddl_table.ReferencesClause", lambda d: ddl_table.ReferencesClause(d, Table(d, "other"), ["b"]), ) + # ADD CONSTRAINT takes a constraint node, and the guess supplies a bare + # string, which reaches the formatter as ``constraint.branch`` and raises + # AttributeError. A PRIMARY KEY table branch is the cheapest one this + # dialect renders in that position. + register_special_constructor( + "statements.ddl_alter.AddTableConstraintAction", + lambda d: AddTableConstraintAction( + d, + ddl_table.TableConstraint( + d, branch=ddl_table.TablePrimaryKeyConstraint(d, ["a"]) + ), + ), + ) + # -- declared-result annotations: the source must be aliasable ------------ + # ``format_declared_result`` reads the alias of both the annotation and + # the node it annotates, and the rewritten ``Literal`` is deliberately + # not aliasable -- the guesser's Literal source reaches the formatter and + # dies on ``source.alias``. A Column source is what the node is for: a + # statement whose result type the caller declares, rendered unchanged + # with the knowledge attached. + from rhosocial.activerecord.backend.expression import declared as declared_module + + for _name in ( + "TextAnnotation", + "NumberAnnotation", + "IntegerAnnotation", + "BooleanAnnotation", + "TemporalAnnotation", + "BinaryAnnotation", + "UUIDAnnotation", + "JSONAnnotation", + "XMLAnnotation", + "ArrayAnnotation", + ): + register_special_constructor( + f"declared.{_name}", + lambda d, _n=_name: getattr(declared_module, _n)(d, _operand(d, "c")), + ) + + # -- predicates whose operands are value nodes --------------------------- + # The generic guess hands the operand slot a bare string, which the + # formatter then calls ``.to_sql()`` on. Both constructors spell operands + # the way the round-trip contract requires; ``Literal`` for the pattern + # rather than a string, because a bare string is not a value node. + register_special_constructor( + "predicates.LikePredicate", + lambda d: LikePredicate(d, LikeOperator.LIKE, Column(d, "a"), Literal(d, "%x%")), + ) + register_special_constructor( + "predicates.ILikePredicate", + lambda d: ILikePredicate(d, Column(d, "a"), "%x%"), + ) + # COMPARISON. Core normalised ``op`` through ``normalize_comparison_operator`` + # after this matrix was reconciled, and the guess hands ``Any`` the literal + # ``"x"`` -- not a comparison word, so the constructor refuses and the class + # drops to a quiet skip. A real operator word builds it, and the node then + # renders through the same entry every other predicate uses. + register_special_constructor( + "predicates.ComparisonPredicate", + lambda d: ComparisonPredicate(d, "=", Column(d, "a"), Literal(d, 1)), + ) # -- DML ----------------------------------------------------------------- register_special_constructor( - "statements.dml.MergeExpression", - lambda d: dml.MergeExpression( + "statements.dml.MergeStatement", + lambda d: dml.MergeStatement( d, target_table=_table(d), source=NamedRelationRef(d, Table(d, "src")), @@ -447,11 +750,13 @@ def register_specials(): ) # -- types --------------------------------------------------------------- + # ArrayType's element type is keyword-only now, so the guesser -- and the + # positional call this used to be -- both miss it. register_special_constructor( "types.array.ArrayType", lambda d: __import__( "rhosocial.activerecord.backend.expression.types.array", fromlist=["ArrayType"], - ).ArrayType(d, VarCharType(d, 10)) + ).ArrayType(d, element_type=VarCharType(d, 10)) ) register_special_constructor( "types.ClickHouseDecimalType", @@ -492,6 +797,77 @@ def register_specials(): ), ) + # -- datetime extracts: the field must name a DateTimeField -------------- + # Both the private shape base and the field-named extraction validate the + # field word at construction; the guesser's "x" is not one of the six. + # "YEAR" is, and renders EXTRACT(YEAR FROM `ts`). + register_special_constructor( + "datetime._ExtractBase", + lambda d: _ExtractBase(d, "YEAR", _operand(d, "ts")), + ) + register_special_constructor( + "datetime.ExtractFieldExpression", + lambda d: ExtractFieldExpression(d, "YEAR", _operand(d, "ts")), + ) + + # -- string functions whose parameters the guesser fills with bare names -- + # Every operand slot is annotated ``SQLValue`` -- a string annotation + # naming the base every value class shares -- and the guesser falls + # through to its bare-name fallback, so construction refuses. Real + # operands construct, and the renderings go through the shared scalar + # defaults ClickHouse agrees with (CHR, LEFT, RIGHT, SPACE are the + # standard spellings this engine accepts; SUBSTRING and OVERLAY render + # through their own entry points). + register_special_constructor( + "functions.string.ChrFunction", + lambda d: ChrFunction(d, Literal(d, 65)), + ) + register_special_constructor( + "functions.string.LeftFunction", + lambda d: LeftFunction(d, _operand(d, "name"), Literal(d, 1)), + ) + register_special_constructor( + "functions.string.RightFunction", + lambda d: RightFunction(d, _operand(d, "name"), Literal(d, 1)), + ) + register_special_constructor( + "functions.string.SpaceFunction", + lambda d: SpaceFunction(d, Literal(d, 3)), + ) + register_special_constructor( + "functions.string.SubstringExpression", + lambda d: SubstringExpression( + d, _operand(d, "name"), Literal(d, 1), Literal(d, 2) + ), + ) + register_special_constructor( + "functions.string.OverlayExpression", + lambda d: OverlayExpression( + d, + _operand(d, "name"), + _operand(d, "replacement"), + Literal(d, 1), + Literal(d, 2), + ), + ) + + # -- admin selectors: the enums validate their words at construction now -- + # The guesser fills a required enum-annotated parameter with "x", which + # the constructor refuses once it normalises (the word must name a + # member). Real members construct, and the renderings refuse on their own + # -- this dialect spells no RESET / HANDLER at all -- which the pinned + # renders above assert. + register_special_constructor( + "admin.ClickHouseResetStatement", + lambda d: admin_module.ClickHouseResetStatement(d, admin_module.ResetOption.MASTER), + ) + register_special_constructor( + "admin.ClickHouseHandlerReadStatement", + lambda d: admin_module.ClickHouseHandlerReadStatement( + d, Table(d, "t"), admin_module.HandlerReadMode.FIRST + ), + ) + # -- ClickHouse partitions ---------------------------------------------- # The MySQL-style declarative partitioning family was deleted outright: # ClickHouse declares partitioning as an arbitrary expression in CREATE @@ -499,9 +875,9 @@ def register_specials(): # three system.parts-addressed statements, each taking the table name # and the partition id, and each rendering through one shared base. for _name in ( - "ClickHouseDropPartitionExpression", - "ClickHouseDetachPartitionExpression", - "ClickHouseAttachPartitionExpression", + "ClickHouseDropPartitionStatement", + "ClickHouseDetachPartitionStatement", + "ClickHouseAttachPartitionStatement", ): register_special_constructor( f"partition.{_name}", @@ -515,7 +891,7 @@ def register_specials(): "ClickHouseChecksumTableExpression", "ClickHouseOptimizeTableExpression", "ClickHouseRepairTableExpression", - "ClickHouseTableMaintenanceExpression", + "ClickHouseTableMaintenanceStatement", ): register_special_constructor( f"maintenance.{_name}", @@ -526,39 +902,41 @@ def register_specials(): lambda d: routine.ClickHouseRoutineExpression(d, Procedure(d, "proc")), ) register_special_constructor( - "routine.ClickHouseCreateProcedureExpression", - lambda d: routine.ClickHouseCreateProcedureExpression(d, Procedure(d, "proc")), + "routine.ClickHouseCreateProcedureStatement", + lambda d: routine.ClickHouseCreateProcedureStatement(d, Procedure(d, "proc")), ) register_special_constructor( - "routine.ClickHouseDropProcedureExpression", - lambda d: routine.ClickHouseDropProcedureExpression(d, Procedure(d, "proc")), + "routine.ClickHouseDropProcedureStatement", + lambda d: routine.ClickHouseDropProcedureStatement(d, Procedure(d, "proc")), ) register_special_constructor( - "routine.ClickHouseCreateFunctionExpression", - lambda d: routine.ClickHouseCreateFunctionExpression( + "routine.ClickHouseCreateFunctionStatement", + lambda d: routine.ClickHouseCreateFunctionStatement( d, Function(d, "fn"), returns="integer" ), ) register_special_constructor( - "routine.ClickHouseDropFunctionExpression", - lambda d: routine.ClickHouseDropFunctionExpression(d, Function(d, "fn")), + "routine.ClickHouseDropFunctionStatement", + lambda d: routine.ClickHouseDropFunctionStatement(d, Function(d, "fn")), ) register_special_constructor( - "routine.ClickHouseCallExpression", - lambda d: routine.ClickHouseCallExpression(d, Procedure(d, "proc")), + "routine.ClickHouseCallStatement", + lambda d: routine.ClickHouseCallStatement(d, Procedure(d, "proc")), ) register_special_constructor( - "materialized_view.ClickHouseCreateMaterializedViewExpression", - lambda d: materialized_view.ClickHouseCreateMaterializedViewExpression( + "materialized_view.ClickHouseCreateMaterializedViewStatement", + lambda d: materialized_view.ClickHouseCreateMaterializedViewStatement( d, MaterializedView(d, "mv"), "SELECT 1", to_table=Table(d, "target") ), ) register_special_constructor( - "materialized_view.ClickHouseModifyMaterializedViewRefreshExpression", - lambda d: materialized_view.ClickHouseModifyMaterializedViewRefreshExpression( + "materialized_view.ClickHouseModifyMaterializedViewRefreshStatement", + lambda d: materialized_view.ClickHouseModifyMaterializedViewRefreshStatement( d, MaterializedView(d, "mv"), - materialized_view.ClickHouseRefreshSchedule(every="1 HOUR"), + # The schedule is an expression node now, so it binds to the + # dialect it renders through -- first argument, like every node. + materialized_view.ClickHouseRefreshSchedule(d, every="1 HOUR"), ), ) @@ -576,25 +954,46 @@ def register_specials(): ), ) register_special_constructor( - "alter_column.ClickHouseAddColumn", - lambda d: alter_column.ClickHouseAddColumn(d, _integer_column(d)), + "alter_column.ClickHouseAddColumnAction", + # ``after`` is given a value on purpose: it is a declared constructor + # parameter, so the artifact has to carry it. The render half -- that + # the clause reaches the SQL -- is test_add_column_action.py's job. + lambda d: alter_column.ClickHouseAddColumnAction( + d, _integer_column(d), after="name" + ), ) register_special_constructor( - "rename_table.ClickHouseRenameTableExpression", - lambda d: rename_table.ClickHouseRenameTableExpression(d, [("a", "b")]), + "rename_table.ClickHouseRenameTableStatement", + lambda d: rename_table.ClickHouseRenameTableStatement(d, [("a", "b")]), ) + + # -- ClickHouse CREATE TABLE storage clauses ------------------------------ + # ``option`` is a discriminator enum and ``value`` is keyword-only, so the + # guess supplies neither: it hands a string where the enum is expected and + # never reaches the payload. ENGINE is built because every ClickHouse table + # declares one, and it is the member whose payload is itself a node, so the + # artifact carries a nested expression rather than a bare scalar. + register_special_constructor( + "storage_option.ClickHouseTableStorageOption", + lambda d: storage_option.ClickHouseTableStorageOption( + d, + storage_option.ClickHouseStorageOptionKind.ENGINE, + value=storage_option.ClickHouseEngineSpec(d, "MergeTree"), + ), + ) + # CUSTOM. Its ``raw`` slot exists to carry the SQL type name and defaults # to the empty string, which the filler skips and the class refuses while # constructing. Handed a real name it builds -- and then the dialect # refuses it, which is what the existing LEGITIMATE_NON_RENDERS pin below # asserts. A constructor rather than a skip, so the pin has something to - # assert against. + # assert against. ``raw`` is keyword-only now, so the name is spelled. def _custom_type(dialect): from rhosocial.activerecord.backend.expression.types.custom import ( CustomType, ) - return CustomType(dialect, "String") + return CustomType(dialect, raw="String") register_special_constructor("types.custom.CustomType", _custom_type) @@ -631,23 +1030,20 @@ def _custom_type(dialect): # ALTER CONSTRAINT. ``name`` and ``constraint_type`` sit behind defaulted # positionals and are keyword-only, so the introspective constructor skips # them and the class refuses an incomplete action. - "rhosocial.activerecord.backend.expression.statements.ddl_alter.AlterConstraint", - # VALIDATE CONSTRAINT. Same shape as AlterConstraint: the required ``name`` - # is keyword-only behind a defaulted positional. - "rhosocial.activerecord.backend.expression.statements.ddl_alter.ValidateConstraint", + "rhosocial.activerecord.backend.expression.statements.ddl_alter.AlterConstraintAction", + # VALIDATE CONSTRAINT used to sit here with the same shape as + # AlterConstraint. Core moved ``constraint_name`` into the positional + # required slot, so the guesser's "x" builds it now -- and the built + # instance is what the graph of pinned renders asserts against. # ADD DOMAIN CHECK. Widens a SQLPredicate into a DomainCheckConstraint and - # needs one; the guess supplies a bare comparison whose literal would have to - # render as a bind parameter, which DDL cannot carry. + # needs one; the guess supplies a bare comparison whose literal would have + # to render as a bind parameter, which DDL cannot carry. "rhosocial.activerecord.backend.expression.statements.ddl_domain.AddDomainCheckAction", - # ENUM type. Its ``values`` list is keyword-only behind a defaulted - # positional, so the introspective constructor skips it and the type declares - # no members. - "rhosocial.activerecord.backend.expression.types.enum_.EnumType", # The three UUID nodes refuse in __init__, not in to_sql(): ClickHouse # spells no UUID SQL, so the answer is the same for every argument and no # registered constructor can change it. Measured with a valid ``which`` -- # an invalid one raises ValueError first and hides the dialect's answer. - "rhosocial.activerecord.backend.expression.uuid.UUIDCastExpression", + "rhosocial.activerecord.backend.expression.uuid.UUIDCastFunction", "rhosocial.activerecord.backend.expression.uuid.UUIDConstantExpression", "rhosocial.activerecord.backend.expression.uuid.UUIDGenerationExpression", ) @@ -663,6 +1059,11 @@ def _custom_type(dialect): # SQL/XML formatter at all -- so they are pinned by name in LEGITIMATE_NON_RENDERS # below instead, which is the stronger position: the gap is recorded against the # formatter it is actually about. +# +# ENUM TYPE used to be an entry here too, with a keyword-only ``values`` list the +# constructor skipped. Core moved the members into the constructor's reach, so it +# builds now -- and renders into the type map's refusal, which is the +# LEGITIMATE_NON_RENDERS pin the constructed instance makes assertable. #: Classes that construct but cannot render, for a reason belonging to their own @@ -680,7 +1081,7 @@ def _no_dialect_formatter(format_method: str) -> str: """The fragment ``to_sql()`` says when the dialect has no such formatter. ``BaseExpression.to_sql()`` raises ``UnsupportedFeatureError`` naming the - method it wanted, so pinning ``"format_pivot_expression"`` here records *which* + method it wanted, so pinning ``"format_pivot"`` here records *which* formatter is absent -- the blanket ``UnsupportedFeatureError`` branch in :func:`assert_sql_roundtrip_classified` already allows the type on its own. @@ -703,14 +1104,40 @@ class on purpose: if a class changed the formatter it dispatches on, the NotImplementedError, _NO_FORMATTER, ), - "rhosocial.activerecord.backend.expression.bases.SQLValueExpression": ( + "rhosocial.activerecord.backend.expression.bases.SQLValue": ( + NotImplementedError, + _NO_FORMATTER, + ), + # The column-definition clause base. Its subclasses each name their own + # formatter; the base names only what every column clause has in common. + "rhosocial.activerecord.backend.expression.statements.ddl_table.ColumnClause": ( + NotImplementedError, + _NO_FORMATTER, + ), + # The constraint-branch bases, new with core's two-level constraint nodes. + # A branch names which alternative of the constraint production this is; + # rendering the branch is the clause dispatcher's job, so the bases name no + # formatter of their own and each concrete branch overrides ``format_method``. + "rhosocial.activerecord.backend.expression.statements.ddl_table.ColumnConstraintBranch": ( + NotImplementedError, + _NO_FORMATTER, + ), + "rhosocial.activerecord.backend.expression.statements.ddl_table.TableConstraintBranch": ( NotImplementedError, _NO_FORMATTER, ), - "rhosocial.activerecord.backend.expression.datetime._TemporalValueExpression": ( + "rhosocial.activerecord.backend.expression.datetime._TemporalValue": ( NotImplementedError, _NO_FORMATTER, ), + # The declared-result shape base. It names ``format_declared_result`` and + # the dialect has one, but the base carries none of the surface state the + # formatter reads (``alias`` lives on the DeclaredValueType half); the ten + # concrete annotations each render through the source they annotate. + "rhosocial.activerecord.backend.expression.declared._Annotation": ( + AttributeError, + "no attribute 'alias'", + ), "rhosocial.activerecord.backend.expression.introspection.IntrospectionExpression": ( NotImplementedError, _NO_FORMATTER, @@ -750,16 +1177,16 @@ class on purpose: if a class changed the formatter it dispatches on, the NotImplementedError, _NO_FORMATTER, ), - "rhosocial.activerecord.backend.expression.transaction.TransactionExpression": ( + "rhosocial.activerecord.backend.expression.transaction.TransactionStatement": ( NotImplementedError, _NO_FORMATTER, ), # Introspection query bases: each concrete subclass names its own # ``format_*_query``. The message differs from the group above only in # spelling, which is why each entry pins its own fragment. - "rhosocial.activerecord.backend.expression.introspection.TableInfoExpression": ( + "rhosocial.activerecord.backend.expression.introspection.TableInfoQueryStatement": ( NotImplementedError, - "Subclass must implement format_table_info_query", + "Subclass must implement format_table_info_query_statement", ), # The ClickHouse roots, same shape as the core ones above: a base holding # what every subclass shares, with no formatting method of its own. @@ -783,15 +1210,19 @@ class on purpose: if a class changed the formatter it dispatches on, the # groups below report, and it says so in the same spelling. The type root is # the other kind: every concrete type declares its own generic ``name`` for # ``format_data_type`` to dispatch on, and this one declares none, so there is - # no name to look up at all. TypeError rather than UnsupportedFeatureError, - # because there the class is incomplete rather than the dialect being unable. + # no name to look up at all. It now says so in the dispatch's own spelling -- + # "does not support the generic type None (no format_data_type_None)" -- the + # same message every other undeclared name produces, so the pin follows the + # class from its own refusal to the generic one. TypeError rather than + # UnsupportedFeatureError, because there the class is incomplete rather than + # the dialect being unable. "rhosocial.activerecord.backend.expression.sources.base.TableSource": ( UnsupportedFeatureError, _no_dialect_formatter("format_table_source"), ), "rhosocial.activerecord.backend.expression.types._base.DataType": ( TypeError, - "does not declare a valid generic type name", + "does not support the generic type None (no format_data_type_None)", ), # ---- core features ClickHouse does not declare a protocol for --------- @@ -812,30 +1243,30 @@ class on purpose: if a class changed the formatter it dispatches on, the # two spellings, so a caller could not tell "ClickHouse does not have this" # from "this backend is broken". Core made it one spelling, and these entries # now say so while naming the specific formatter that is missing. - "rhosocial.activerecord.backend.expression.pivot.PivotExpression": ( + "rhosocial.activerecord.backend.expression.pivot.Pivot": ( UnsupportedFeatureError, - _no_dialect_formatter("format_pivot_expression"), + _no_dialect_formatter("format_pivot"), ), - "rhosocial.activerecord.backend.expression.pivot.UnpivotExpression": ( + "rhosocial.activerecord.backend.expression.pivot.Unpivot": ( UnsupportedFeatureError, - _no_dialect_formatter("format_unpivot_expression"), + _no_dialect_formatter("format_unpivot"), ), - "rhosocial.activerecord.backend.expression.predicates.ILIKEExpression": ( + "rhosocial.activerecord.backend.expression.predicates.ILikePredicate": ( UnsupportedFeatureError, - _no_dialect_formatter("format_ilike_expression"), + _no_dialect_formatter("format_ilike_predicate"), ), - "rhosocial.activerecord.backend.expression.statements.ddl_comment.CommentOnExpression": ( + "rhosocial.activerecord.backend.expression.statements.ddl_comment.CommentOnStatement": ( UnsupportedFeatureError, - _no_dialect_formatter("format_comment_statement"), + _no_dialect_formatter("format_comment_on_statement"), ), # ClickHouse has a database and no inner schema, so CREATE/DROP SCHEMA is # not in its grammar at all. These two are the direct consequence of that # decision, which is stated once in the namespace mixin. - "rhosocial.activerecord.backend.expression.statements.ddl_schema.CreateSchemaExpression": ( + "rhosocial.activerecord.backend.expression.statements.ddl_schema.CreateSchemaStatement": ( UnsupportedFeatureError, _no_dialect_formatter("format_create_schema_statement"), ), - "rhosocial.activerecord.backend.expression.statements.ddl_schema.DropSchemaExpression": ( + "rhosocial.activerecord.backend.expression.statements.ddl_schema.DropSchemaStatement": ( UnsupportedFeatureError, _no_dialect_formatter("format_drop_schema_statement"), ), @@ -847,15 +1278,15 @@ class on purpose: if a class changed the formatter it dispatches on, the # of a dialect that had itself declared the feature absent. Naming a sequence # is a separate capability and is untouched: SequenceNameMixin stays, so # Sequence(dialect, "s").to_sql() still renders. - "rhosocial.activerecord.backend.expression.statements.ddl_sequence.CreateSequenceExpression": ( + "rhosocial.activerecord.backend.expression.statements.ddl_sequence.CreateSequenceStatement": ( UnsupportedFeatureError, _no_dialect_formatter("format_create_sequence_statement"), ), - "rhosocial.activerecord.backend.expression.statements.ddl_sequence.DropSequenceExpression": ( + "rhosocial.activerecord.backend.expression.statements.ddl_sequence.DropSequenceStatement": ( UnsupportedFeatureError, _no_dialect_formatter("format_drop_sequence_statement"), ), - "rhosocial.activerecord.backend.expression.statements.ddl_sequence.AlterSequenceExpression": ( + "rhosocial.activerecord.backend.expression.statements.ddl_sequence.AlterSequenceStatement": ( UnsupportedFeatureError, _no_dialect_formatter("format_alter_sequence_statement"), ), @@ -866,31 +1297,31 @@ class on purpose: if a class changed the formatter it dispatches on, the # which is the gap being named rather than worked around. "rhosocial.activerecord.backend.expression.xml.XMLAggExpression": ( UnsupportedFeatureError, - _no_dialect_formatter("format_xmlagg_expression"), + _no_dialect_formatter("format_xml_agg_expression"), ), "rhosocial.activerecord.backend.expression.xml.XMLAttributesExpression": ( UnsupportedFeatureError, - _no_dialect_formatter("format_xmlattributes_expression"), + _no_dialect_formatter("format_xml_attributes_expression"), ), "rhosocial.activerecord.backend.expression.xml.XMLCommentExpression": ( UnsupportedFeatureError, - _no_dialect_formatter("format_xmlcomment_expression"), + _no_dialect_formatter("format_xml_comment_expression"), ), "rhosocial.activerecord.backend.expression.xml.XMLConcatExpression": ( UnsupportedFeatureError, - _no_dialect_formatter("format_xmlconcat_expression"), + _no_dialect_formatter("format_xml_concat_expression"), ), "rhosocial.activerecord.backend.expression.xml.XMLElementExpression": ( UnsupportedFeatureError, - _no_dialect_formatter("format_xmlelement_expression"), + _no_dialect_formatter("format_xml_element_expression"), ), - "rhosocial.activerecord.backend.expression.xml.XMLExistsExpression": ( + "rhosocial.activerecord.backend.expression.xml.XMLExistsPredicate": ( UnsupportedFeatureError, - _no_dialect_formatter("format_xmlexists_expression"), + _no_dialect_formatter("format_xmlexists_predicate"), ), "rhosocial.activerecord.backend.expression.xml.XMLForestExpression": ( UnsupportedFeatureError, - _no_dialect_formatter("format_xmlforest_expression"), + _no_dialect_formatter("format_xml_forest_expression"), ), "rhosocial.activerecord.backend.expression.xml.XMLPIExpression": ( UnsupportedFeatureError, @@ -898,23 +1329,23 @@ class on purpose: if a class changed the formatter it dispatches on, the ), "rhosocial.activerecord.backend.expression.xml.XMLParseExpression": ( UnsupportedFeatureError, - _no_dialect_formatter("format_xmlparse_expression"), + _no_dialect_formatter("format_xml_parse_expression"), ), "rhosocial.activerecord.backend.expression.xml.XMLQueryExpression": ( UnsupportedFeatureError, - _no_dialect_formatter("format_xmlquery_expression"), + _no_dialect_formatter("format_xml_query_expression"), ), "rhosocial.activerecord.backend.expression.xml.XMLRootExpression": ( UnsupportedFeatureError, - _no_dialect_formatter("format_xmlroot_expression"), + _no_dialect_formatter("format_xml_root_expression"), ), "rhosocial.activerecord.backend.expression.xml.XMLSerializeExpression": ( UnsupportedFeatureError, - _no_dialect_formatter("format_xmlserialize_expression"), + _no_dialect_formatter("format_xml_serialize_expression"), ), "rhosocial.activerecord.backend.expression.xml.XMLTableExpression": ( UnsupportedFeatureError, - _no_dialect_formatter("format_xmltable_expression"), + _no_dialect_formatter("format_xml_table_expression"), ), # SQL/PGQ. ClickHouse mixes in GraphMixin, whose MATCH formatters exist and # refuse with UnsupportedFeatureError; the GRAPH_TABLE and CREATE/DROP/ALTER @@ -929,9 +1360,14 @@ class on purpose: if a class changed the formatter it dispatches on, the UnsupportedFeatureError, _no_dialect_formatter("format_vertex_table"), ), - "rhosocial.activerecord.backend.expression.graph.EdgeTable": ( + # The edge-table definition of CREATE PROPERTY GRAPH, the same declared + # gap as the graph rows around it: the DDL half of SQL/PGQ lives in + # mixins this dialect does not compose. (The clause core used to call + # ``EdgeTable`` is ``EdgeTableDefinition`` now -- one name, two contracts, + # and the relation object kept the old one.) + "rhosocial.activerecord.backend.expression.graph.EdgeTableDefinition": ( UnsupportedFeatureError, - _no_dialect_formatter("format_edge_table"), + _no_dialect_formatter("format_edge_table_definition"), ), "rhosocial.activerecord.backend.expression.graph.ColumnsClause": ( UnsupportedFeatureError, @@ -941,15 +1377,15 @@ class on purpose: if a class changed the formatter it dispatches on, the UnsupportedFeatureError, _no_dialect_formatter("format_table_properties_clause"), ), - "rhosocial.activerecord.backend.expression.graph.CreatePropertyGraphExpression": ( + "rhosocial.activerecord.backend.expression.graph.CreatePropertyGraphStatement": ( UnsupportedFeatureError, _no_dialect_formatter("format_create_property_graph_statement"), ), - "rhosocial.activerecord.backend.expression.graph.DropPropertyGraphExpression": ( + "rhosocial.activerecord.backend.expression.graph.DropPropertyGraphStatement": ( UnsupportedFeatureError, _no_dialect_formatter("format_drop_property_graph_statement"), ), - "rhosocial.activerecord.backend.expression.graph.AlterPropertyGraphExpression": ( + "rhosocial.activerecord.backend.expression.graph.AlterPropertyGraphStatement": ( UnsupportedFeatureError, _no_dialect_formatter("format_alter_property_graph_statement"), ), @@ -976,6 +1412,16 @@ class on purpose: if a class changed the formatter it dispatches on, the TypeError, "does not support the generic type 'interval'", ), + # ENUM. ClickHouse's own enum types are sized and signed + # (ClickHouseEnum8Type / ClickHouseEnum16Type, registered above); the + # generic name has no formatter here, so the type map reports the + # substitute. It constructs -- the members are in the constructor's + # reach now -- and the constructed instance is what makes this pin + # assertable. + "rhosocial.activerecord.backend.expression.types.enum_.EnumType": ( + TypeError, + "does not support the generic type 'enum'", + ), "rhosocial.activerecord.backend.expression.types.datetime_.TimeTzType": ( TypeError, "does not support the generic type 'timetz'", @@ -995,6 +1441,126 @@ class on purpose: if a class changed the formatter it dispatches on, the } +# --------------------------------------------------------------------------- +# Classes whose own tree refuses their round-trip +# --------------------------------------------------------------------------- +# +# A fourth verdict, needed by core ``679d9817``: the artifact is plain JSON +# now, and the contract (组织规则 §10) puts one conversion in one place -- +# "构造函数接受该参数的序列化形式并归一成宿主类型;反序列化器只按名字传参". +# Every entry below is a class whose *own* tree breaks that contract, so the +# restore is refused or comes back as something the original never was. The +# reason belongs to the tree that defines the class, not to this backend. +# +# Each entry pins the failing channels, and each channel pins one of three +# shapes: +# +# * ``("raises", Exc, fragment)`` -- the channel refuses outright. +# * ``("word",)`` -- the restore succeeds but is type-lossy: a +# ``(str, Enum)`` member comes back as its plain word (the JSON channel +# writes a member's underlying string; the XML channel writes +# ``str(member)``, which equals the word when the enum sets +# ``__str__ = str.__str__``). The constructor stores it raw instead of +# normalising, so the restored params differ in type and agree in value. +# Asserted both ways: strictly different (so the pin stays load-bearing -- +# a core fix makes this entry stale) and equal under member-≡-word +# normalisation (so the loss is exactly the spelling, nothing else moved). +# * ``("enum-str",)`` -- the same, but the enum lacks ``__str__ = +# str.__str__``, so the XML channel writes ``"EnumClass.MEMBER"``: neither +# the member nor its word. Asserted equal under member-≡-``Enum.__str__`` +# normalisation, which pins the corrupted spelling itself -- the moment +# core spells the enum properly, this entry goes. +# +# A channel an entry does not name must still round-trip; that is asserted per +# class, so a pin can never quietly swallow a whole class. +LEGITIMATE_NON_ROUNDTRIPS: Dict[str, Dict[str, object]] = { + # -- the datetime family: ``DateTimeField`` / ``IntervalUnit`` were + # ``(str, Enum)`` without ``__str__ = str.__str__``, so the XML + # channel wrote ``"DateTimeField.YEAR"`` and the constructor's own + # normaliser refused it. Core gave the nine families the missing + # ``__str__``, so the XML channel now writes "year" / "day" and all + # three channels round-trip exactly. No rows left here; the block + # stays to say which family this used to be about. + # -- ``mode`` is declared ``CreateTableCloneMode`` with a member default; + # the JSON and XML channels write the word (``"CLONE"``), and the + # constructor demands the member -- the ``isinstance`` half of the + # contract without the normalise half. + "rhosocial.activerecord.backend.expression.statements.ddl_table.CreateTableCloneStatement": { + "channels": { + "json": ("raises", TypeError, "mode must be a CreateTableCloneMode"), + "xml": ("raises", TypeError, "mode must be a CreateTableCloneMode"), + }, + }, + # -- ``options`` is a ``ViewOptions`` *instance*, a plain class in a + # parameter slot: the codec writes nodes, scalars, and containers of + # those two, and a bare ``options or ViewOptions()`` puts one in every + # CREATE VIEW. Serialization refuses before any round-trip can start. + "rhosocial.activerecord.backend.expression.statements.ddl_view.CreateViewStatement": { + "channels": { + "dict": ("raises", TypeError, "ViewOptions is not one of the value kinds"), + "json": ("raises", TypeError, "ViewOptions is not one of the value kinds"), + "xml": ("raises", TypeError, "ViewOptions is not one of the value kinds"), + }, + }, + # -- ``ColumnsClause`` carries ``GraphColumn`` instances, another plain + # class in a parameter slot (GraphTableExpression holds the clause, so + # the refusal propagates to it too; ColumnsClause itself is only built + # with a Literal child here and travels fine). + "rhosocial.activerecord.backend.expression.graph.GraphTableExpression": { + "channels": { + "dict": ("raises", TypeError, "GraphColumn is not one of the value kinds"), + "json": ("raises", TypeError, "GraphColumn is not one of the value kinds"), + "xml": ("raises", TypeError, "GraphColumn is not one of the value kinds"), + }, + }, + # -- ``storage_type`` defaults to a ``GeneratedColumnType`` member. It was + # a plain (non-str) enum the artifact refused outright; core made it a + # str enum, so the writer carries it and the dict channel passes the + # member through untouched. JSON and XML hand back the word, which the + # constructor stores raw -- value-faithful, type-lossy. + "rhosocial.activerecord.backend.expression.statements.ddl_table.ColumnGeneratedConstraint": { + "channels": {"json": ("word",), "xml": ("word",)}, + }, + # -- ``on_delete`` / ``on_update`` default to ``ReferentialAction`` + # members. Same shape as ``storage_type`` above: core made the enum a + # str enum, the writer carries it, the dict channel keeps the member, + # and JSON / XML come back with the word. + "rhosocial.activerecord.backend.expression.statements.ddl_table.ForeignKeyConstraint": { + "channels": {"json": ("word",), "xml": ("word",)}, + }, + # -- the word-form family: ``(str, Enum)`` members spelled properly + # (``__str__ = str.__str__``), carried by constructors that store the + # serialized word raw. The JSON and XML channels each hand back the + # word; the restore is value-faithful and type-lossy. + "rhosocial.activerecord.backend.expression.graph.GraphEdge": { + "channels": {"json": ("word",), "xml": ("word",)}, + }, + "rhosocial.activerecord.backend.expression.statements.ddl_database.AlterDatabaseStatement": { + "channels": {"json": ("word",), "xml": ("word",)}, + }, + "rhosocial.activerecord.backend.expression.statements.ddl_trigger.CreateTriggerStatement": { + "channels": {"json": ("word",), "xml": ("word",)}, + }, + "rhosocial.activerecord.backend.expression.statements.dml.MergeAction": { + "channels": {"json": ("word",), "xml": ("word",)}, + }, + "rhosocial.activerecord.backend.expression.statements.dml.MergeStatement": { + # The loss sits one level down, in the ``MergeAction`` child. + "channels": {"json": ("word",), "xml": ("word",)}, + }, + # -- ``XMLParseDocumentType`` / ``XMLSerializeDocumentType`` are + # ``(str, Enum)`` and now set ``__str__ = str.__str__`` too, so both + # JSON and XML write the word ("DOCUMENT") and the constructor stores + # it raw: value-faithful, type-lossy on both channels. + "rhosocial.activerecord.backend.expression.xml.XMLParseExpression": { + "channels": {"json": ("word",), "xml": ("word",)}, + }, + "rhosocial.activerecord.backend.expression.xml.XMLSerializeExpression": { + "channels": {"json": ("word",), "xml": ("word",)}, + }, +} + + # --------------------------------------------------------------------------- # The dispatch gap: a missing formatter is a decision, not an omission # --------------------------------------------------------------------------- @@ -1055,26 +1621,27 @@ def _dispatched_formatter(exc): #: ``trimBoth``/``trimLeft``/``trimRight`` -- so none of the four appears here. UNMODELLED_FORMATTERS = { "SQL/XML (ClickHouse mixes in no SQL/XML formatter at all)": ( - "format_xmlagg_expression", - "format_xmlattributes_expression", - "format_xmlcomment_expression", - "format_xmlconcat_expression", - "format_xmlelement_expression", - "format_xmlexists_expression", - "format_xmlforest_expression", + "format_passing_clause", + "format_xml_agg_expression", + "format_xml_attributes_expression", + "format_xml_comment_expression", + "format_xml_concat_expression", + "format_xml_element_expression", + "format_xmlexists_predicate", + "format_xml_forest_expression", "format_xmlpi_expression", - "format_xmlparse_expression", - "format_xmlquery_expression", - "format_xmlroot_expression", - "format_xmlserialize_expression", - "format_xmltable_expression", + "format_xml_parse_expression", + "format_xml_query_expression", + "format_xml_root_expression", + "format_xml_serialize_expression", + "format_xml_table_expression", ), "SQL/PGQ (ClickHouse mixes in GraphMixin, whose MATCH formatters exist " "and refuse; the GRAPH_TABLE half lives in GraphTableMixin, which is not " "mixed in)": ( "format_graph_table_expression", "format_vertex_table", - "format_edge_table", + "format_edge_table_definition", "format_graph_columns_clause", "format_table_properties_clause", "format_create_property_graph_statement", @@ -1082,7 +1649,7 @@ def _dispatched_formatter(exc): "format_alter_property_graph_statement", ), "COMMENT ON (the dialect mixes in no comment-DDL mixin; ClickHouse " - "comments ride on table and column definitions)": ("format_comment_statement",), + "comments ride on table and column definitions)": ("format_comment_on_statement",), "SCHEMA (ClickHouse has a database and no inner schema, so CREATE/DROP " "SCHEMA is not in its grammar at all)": ( "format_create_schema_statement", @@ -1095,11 +1662,11 @@ def _dispatched_formatter(exc): "format_alter_sequence_statement", ), "ILIKE (the dialect mixes in no ILIKE mixin; case-insensitive matching " - "is ClickHouse's positionCaseInsensitive)": ("format_ilike_expression",), + "is ClickHouse's positionCaseInsensitive)": ("format_ilike_predicate",), "the core PIVOT / UNPIVOT expressions (the dialect mixes in no pivot " "mixin)": ( - "format_pivot_expression", - "format_unpivot_expression", + "format_pivot", + "format_unpivot", ), "the TableSource root (every concrete row source overrides its " "formatter; the root carries nothing but an alias and is never " @@ -1189,18 +1756,19 @@ def assert_sql_roundtrip_classified(fqn, instance, dialect): ) return "non-render" - for channel, decoded in ( - ("dict", deserialize(serialize(instance), dialect)), - ("json", deserialize_json(serialize_json(instance), dialect)), - ("xml", deserialize_xml(serialize_xml(instance), dialect)), - ): + for channel, build in _channel_builders(instance, dialect): + pin = _roundtrip_pin(fqn, channel) + if pin is not None: + _assert_pinned_channel(fqn, channel, build, instance, pin) + continue + decoded = build() decoded_sql, decoded_params = decoded.to_sql() assert decoded_sql == expected_sql, ( f"{fqn}: {channel} round-trip changed the SQL.\n" f" original: {expected_sql!r}\n" f" {channel}: {decoded_sql!r}" ) - assert decoded_params == expected_params, ( + assert _sql_params_equal(decoded_params, expected_params), ( f"{fqn}: {channel} round-trip changed the bind parameters.\n" f" original: {expected_params!r}\n" f" {channel}: {decoded_params!r}" @@ -1245,27 +1813,19 @@ def test_get_params_roundtrip_across_encodings( self, expr_case, clickhouse_matrix_dialect ): fqn, instance = expr_case - original = instance.get_params() - assert_params_equal( - deserialize(serialize(instance), clickhouse_matrix_dialect).get_params(), - original, - fqn, - ) - assert_params_equal( - deserialize_json( - serialize_json(instance), clickhouse_matrix_dialect - ).get_params(), - original, - fqn, - ) - assert_params_equal( - deserialize_xml( - serialize_xml(instance), clickhouse_matrix_dialect - ).get_params(), - original, - fqn, - ) + for channel, build in _channel_builders(instance, clickhouse_matrix_dialect): + pin = _roundtrip_pin(fqn, channel) + if pin is not None: + # The refusal is asserted per channel right here, so the pin + # cannot go stale without this test failing. + _assert_pinned_channel(fqn, channel, build, instance, pin) + continue + assert_params_equal( + build().get_params(), + instance.get_params(), + f"{fqn}.{channel}", + ) def test_to_sql_roundtrip_classified(self, expr_case, clickhouse_matrix_dialect): """A render must survive the round-trip; a non-render must be classified.""" @@ -1349,6 +1909,129 @@ def test_quote_flags_are_honoured_per_level(self): ).to_sql() == ("`analytics`.users", ()) +class TestLiteralValueKinds: + """The one ``Literal`` class carries sixteen value kinds, named flatly. + + Core ``679d9817`` made the artifact plain JSON and folded the value classes + into a single ``Literal`` whose kind comes from the host value -- so the + matrix's per-class rows now cover the *class*, and this test covers the + *kinds*: each one travels as one flat JSON scalar under its own wire name, + with no marker, no wrapper and no second table. Binary is base64 text, an + interval is its ISO 8601 spelling, a decimal its exact text -- the + language-neutral spellings spec 4.8 asks for -- and every kind comes back + as the host type it left as, through all three channels. + + ``debde516`` added the two non-finite floats as kinds of their own + (``nan_value``, ``infinity_value``) rather than letting them ride on + ``float_value``, which is why the set is sixteen and not fourteen: NaN is not + equal to itself and JSON has no NaN literal, so a round-trip that stored it + as a number could not tell "read back" from "read back wrong". Both travel as + their spelling, and ``infinity_value`` carries its sign in the text. + + The cases are written out per kind rather than generated because each is a + statement about what the wire must carry, and a generated case makes no + statement. Rendering is deliberately not asserted here: whether a kind + binds or spells inline, and what text this dialect writes, is the + literal-values file's question -- this is the artifact's. + """ + + #: (wire kind, host value, artifact scalar, restored host type), in + #: :class:`LiteralKind` declaration order -- the order ``of()`` tries them, + #: which is why ``boolean_value`` precedes ``integer_value``, ``nan_value`` + #: precedes ``decimal_value`` and ``timestamp_value`` precedes ``date_value``. + _CASES = ( + ("null_value", None, None, type(None)), + ("boolean_value", True, True, bool), + ("integer_value", 7, 7, int), + ("float_value", 0.5, 0.5, float), + ("nan_value", float("nan"), "nan", float), + ("infinity_value", float("inf"), "+inf", float), + ("decimal_value", decimal.Decimal("1.25"), "1.25", decimal.Decimal), + ("string_value", "hi", "hi", str), + ("binary_value", b"\x01\x02", "AQI=", bytes), + ( + "timestamp_value", + datetime.datetime(2020, 1, 2, 3, 4, 5), + "2020-01-02 03:04:05", + datetime.datetime, + ), + ("date_value", datetime.date(2020, 1, 2), "2020-01-02", datetime.date), + ("time_value", datetime.time(3, 4, 5), "03:04:05", datetime.time), + ( + "interval_value", + datetime.timedelta(days=1, hours=2), + "P1DT2H", + datetime.timedelta, + ), + ("list_value", [1, "a"], [1, "a"], list), + ("tuple_value", (1, "a"), [1, "a"], tuple), + ("uuid_value", uuid.UUID(int=7), "00000000-0000-0000-0000-000000000007", uuid.UUID), + ) + + def test_the_kind_set_is_exactly_these_sixteen(self): + """A seventeenth kind must be added here deliberately, not discovered. + + The closed set is the contract: the wire names every value kind there + is, and a reader builds its allow-list from it. A kind that appears in + :class:`LiteralKind` without a case here has no stated artifact shape. + """ + from rhosocial.activerecord.backend.expression.values import LiteralKind + + assert tuple(kind.wire for kind in LiteralKind) == tuple( + wire for wire, *_ in self._CASES + ) + + @pytest.mark.parametrize( + ("wire", "host", "flat", "host_type"), + _CASES, + ids=[wire for wire, *_ in _CASES], + ) + def test_each_kind_travels_as_one_flat_scalar( + self, clickhouse_matrix_dialect, wire, host, flat, host_type + ): + d = clickhouse_matrix_dialect + instance = Literal(d, host) + assert serialize(instance) == { + "kind": wire, + "params": {"value": flat, "inline": False}, + }, wire + for channel, build in _channel_builders(instance, d): + restored = build() + assert type(restored.value) is host_type, ( + f"{wire}/{channel}: came back as {type(restored.value).__name__}" + ) + # NaN is the one host value that is not equal to itself, so ``==`` + # would fail on a perfectly faithful round-trip. Both non-finite + # kinds are compared by their spelling, which is what the wire + # actually carries. + if isinstance(host, float) and math.isnan(host): + assert isinstance(restored.value, float) and math.isnan( + restored.value + ), f"{wire}/{channel}: {restored.value!r}" + continue + assert restored.value == host, wire + + @pytest.mark.parametrize("host", [float("-inf")], ids=["negative_infinity"]) + def test_negative_infinity_carries_its_sign_in_the_text( + self, clickhouse_matrix_dialect, host + ): + """One wire kind, two spellings: the sign is the payload. + + ``infinity_value`` is a single kind for both directions, so the text is + what tells them apart -- ``+inf`` and ``-inf``. Pinning only the + positive direction would leave ``-inf`` free to come back as ``+inf``. + """ + d = clickhouse_matrix_dialect + instance = Literal(d, host) + assert serialize(instance) == { + "kind": "infinity_value", + "params": {"value": "-inf", "inline": False}, + } + for channel, build in _channel_builders(instance, d): + restored = build() + assert restored.value == host, f"negative infinity/{channel}" + + class TestObjectKindChecks: """A statement handed the wrong object kind must be refused while rendering. @@ -1374,7 +2057,7 @@ def _dialect(self): def test_create_table_refuses_a_view(self): d = self._dialect() - expr = CreateTableExpression( + expr = CreateTableStatement( d, View(d, "users"), [ddl_table.ColumnDefinition(d, "id", IntegerType(d))] ) with pytest.raises(TypeError, match="must be a Table, got View"): @@ -1382,38 +2065,38 @@ def test_create_table_refuses_a_view(self): def test_create_table_like_refuses_an_index(self): d = self._dialect() - expr = CreateTableLikeExpression(d, Index(d, "i"), Table(d, "users")) + expr = CreateTableLikeStatement(d, Index(d, "i"), Table(d, "users")) with pytest.raises(TypeError, match="must be a Table, got Index"): expr.to_sql() def test_create_table_clone_refuses_a_view_source(self): d = self._dialect() - expr = CreateTableCloneExpression(d, Table(d, "t"), View(d, "v")) + expr = CreateTableCloneStatement(d, Table(d, "t"), View(d, "v")) with pytest.raises(TypeError, match="source_table must be a Table, got View"): expr.to_sql() def test_create_view_refuses_a_materialized_view(self): d = self._dialect() - expr = CreateViewExpression( + expr = CreateViewStatement( d, MaterializedView(d, "mv"), "SELECT 1" ) - with pytest.raises(TypeError, match="CreateViewExpression.view must be a View"): + with pytest.raises(TypeError, match="CreateViewStatement.view must be a View"): expr.to_sql() def test_drop_view_refuses_a_table(self): d = self._dialect() - with pytest.raises(TypeError, match="DropViewExpression.view must be a View"): - DropViewExpression(d, Table(d, "t")).to_sql() + with pytest.raises(TypeError, match="DropViewStatement.view must be a View"): + DropViewStatement(d, Table(d, "t")).to_sql() def test_generic_materialized_view_ddl_refuses_a_table(self): """The generic expression has no kind check of its own, so the formatter's is the only one.""" d = self._dialect() with pytest.raises(TypeError, match="must be a MaterializedView"): - CreateMaterializedViewExpression(d, Table(d, "t"), "SELECT 1").to_sql() + CreateMaterializedViewStatement(d, Table(d, "t"), "SELECT 1").to_sql() with pytest.raises(TypeError, match="must be a MaterializedView"): - DropMaterializedViewExpression(d, Table(d, "t")).to_sql() + DropMaterializedViewStatement(d, Table(d, "t")).to_sql() with pytest.raises(TypeError, match="must be a MaterializedView"): - RefreshMaterializedViewExpression(d, Table(d, "t")).to_sql() + RefreshMaterializedViewStatement(d, Table(d, "t")).to_sql() def test_refresh_schedule_refuses_a_non_view_upstream(self): """``DEPENDS ON`` renders each entry through its own ``to_sql()``. @@ -1422,15 +2105,15 @@ def test_refresh_schedule_refuses_a_non_view_upstream(self): and nothing about the SQL would look wrong. """ d = self._dialect() - schedule = ClickHouseRefreshSchedule(every="1 HOUR") - expr = ClickHouseModifyMaterializedViewRefreshExpression( + schedule = ClickHouseRefreshSchedule(d, every="1 HOUR") + expr = ClickHouseModifyMaterializedViewRefreshStatement( d, MaterializedView(d, "mv"), schedule ) - # Mutated *after* construction on purpose. The schedule is a plain - # mutable dataclass and its own ``validate()`` runs in the constructor, so - # the only way a wrong kind reaches the renderer is a later assignment. - # The formatter is where the value is consumed, so that is where it is - # checked -- a constructor check would already have been bypassed. + # Mutated *after* construction on purpose. The schedule validates in + # its own constructor path, so the only way a wrong kind reaches the + # renderer is a later assignment. The formatter is where the value is + # consumed, so that is where it is checked -- a constructor check + # would already have been bypassed. schedule.depends_on = (Table(d, "t"),) with pytest.raises(TypeError, match="depends_on must hold MaterializedView"): expr.to_sql() @@ -1438,9 +2121,9 @@ def test_refresh_schedule_refuses_a_non_view_upstream(self): def test_database_ddl_refuses_a_table(self): d = self._dialect() for expr in ( - CreateDatabaseExpression(d, Table(d, "t")), - DropDatabaseExpression(d, Table(d, "t")), - AlterDatabaseExpression( + CreateDatabaseStatement(d, Table(d, "t")), + DropDatabaseStatement(d, Table(d, "t")), + AlterDatabaseStatement( d, Table(d, "t"), action=AlterDatabaseAction.RENAME_TO, target="x" ), ): @@ -1456,30 +2139,42 @@ def test_alter_database_reports_an_action_it_has_no_form_for(self): before reaching any clause. """ d = self._dialect() - expr = AlterDatabaseExpression( + expr = AlterDatabaseStatement( d, Database(d, "db"), action=AlterDatabaseAction.SET_PROPERTY, target="x" ) with pytest.raises(UnsupportedFeatureError): expr.to_sql() - def test_alter_database_renders_rename_to(self): + def test_alter_database_refuses_rename_to(self): + """ClickHouse has no ``ALTER DATABASE ... RENAME TO``. + + The server's own answer to that spelling is a syntax error -- "Expected + one of: ON, a list of ALTER commands, ALTER command, MODIFY SETTING, + MODIFY COMMENT" -- and a database is renamed with ``RENAME DATABASE + TO ``, which is a different statement. So the action is + refused by name, and the refusal names the spelling that does work. + """ d = self._dialect() - expr = AlterDatabaseExpression( + expr = AlterDatabaseStatement( d, Database(d, "db"), action=AlterDatabaseAction.RENAME_TO, target="renamed" ) - assert expr.to_sql()[0] == "ALTER DATABASE `db` RENAME TO `renamed`" + with pytest.raises(UnsupportedFeatureError) as excinfo: + expr.to_sql() + message = str(excinfo.value) + assert "ALTER DATABASE ... RENAME TO" in message, message + assert "RENAME DATABASE" in message, message def test_truncate_refuses_a_view(self): d = self._dialect() - with pytest.raises(TypeError, match="TruncateExpression.table must be a Table"): - TruncateExpression(d, View(d, "v")).to_sql() + with pytest.raises(TypeError, match="TruncateStatement.table must be a Table"): + TruncateStatement(d, View(d, "v")).to_sql() def test_insert_refuses_an_index(self): d = self._dialect() - expr = InsertExpression( + expr = InsertStatement( d, into=Index(d, "i"), source=ValuesSource(d, [[Literal(d, 1)]]) ) - with pytest.raises(TypeError, match="InsertExpression.into must be a Table"): + with pytest.raises(TypeError, match="InsertStatement.into must be a Table"): expr.to_sql() def test_foreign_key_target_must_be_a_table(self): @@ -1488,15 +2183,20 @@ def test_foreign_key_target_must_be_a_table(self): Otherwise a caller who passed the wrong object is told FOREIGN KEY is unsupported -- a true statement about a different mistake, and one that sends them looking in the wrong place. + + The branch carries the references, so the check reads + ``TableForeignKeyConstraint.references`` rather than the production's + (deleted) ``foreign_key_table`` field. """ d = self._dialect() constraint = TableConstraint( d, - TableConstraintType.FOREIGN_KEY, - columns=["a"], - foreign_key_table=View(d, "other"), + branch=TableForeignKeyConstraint(d, ["a"], (View(d, "other"), None)), ) - with pytest.raises(TypeError, match="foreign_key_table must be a Table, got View"): + with pytest.raises( + TypeError, + match="TableForeignKeyConstraint.references must pair a Table with its columns, got View", + ): self._dialect().format_table_constraint(constraint) def test_column_definition_type_must_be_a_data_type(self): @@ -1517,7 +2217,6 @@ def test_unconstructible_list_is_exact(self, clickhouse_matrix_dialect): a constructor and the entry is stale; a class that fails to build without being named would become a silent skip. Both fail here. """ - ExpressionRegistry._auto_register_builtins() actual = tuple( sorted( fqn @@ -1554,6 +2253,46 @@ def test_legitimate_non_renders_are_real_classes(self): f"{sorted(unknown)}" ) + def test_legitimate_non_roundtrips_are_real_classes(self): + """Every pinned round-trip entry names a collected class, a real + channel, and a shape the assertions know how to check.""" + unknown = set(LEGITIMATE_NON_ROUNDTRIPS) - set(REGISTERED) + assert not unknown, ( + f"LEGITIMATE_NON_ROUNDTRIPS names classes that were not " + f"registered: {sorted(unknown)}" + ) + for fqn, entry in LEGITIMATE_NON_ROUNDTRIPS.items(): + bad = set(entry["channels"]) - {"dict", "json", "xml"} + assert not bad, f"{fqn}: pins unknown channels {sorted(bad)}" + for channel, pin in entry["channels"].items(): + assert pin[0] in ("raises", "word", "enum-str"), ( + f"{fqn}/{channel}: unknown pin shape {pin[0]!r}" + ) + + def test_pinned_non_roundtrip_refuses_as_pinned(self, clickhouse_matrix_dialect): + """Each pinned round-trip entry still refuses exactly as it claims. + + Three directions, per entry: every pinned channel fails in its pinned + shape; every channel the pin does NOT name still round-trips (so a + pin can never quietly swallow a whole class); and the class still + constructs, so what is pinned is the restore and not the constructor. + """ + for fqn, entry in LEGITIMATE_NON_ROUNDTRIPS.items(): + instance, source = make_instance(REGISTERED[fqn], clickhouse_matrix_dialect) + assert instance is not None, ( + f"{fqn} is pinned as a round-trip refusal but could not be " + f"constructed ({source})" + ) + for channel, build in _channel_builders(instance, clickhouse_matrix_dialect): + pin = entry["channels"].get(channel) + if pin is not None: + _assert_pinned_channel(fqn, channel, build, instance, pin) + else: + restored = build() + assert_params_equal( + restored.get_params(), instance.get_params(), f"{fqn}.{channel}" + ) + def test_pinned_non_render_really_does_not_render(self, clickhouse_matrix_dialect): """Each pinned entry still raises what it claims, for the stated reason. @@ -1635,7 +2374,6 @@ class that appeared after import is caught. The package walk is used rather than the registry because the registry also holds whatever backends other test modules happened to import. """ - ExpressionRegistry._auto_register_builtins() expected = set(_collect_matrix_classes()) assert expected == set(REGISTERED), ( "the set of expression classes the covered packages define changed " @@ -1657,19 +2395,34 @@ class that appeared after import is caught. The package walk is used def test_every_covered_class_is_registered_for_deserialization(self): """A class in the matrix can be found again when deserializing. - Deserialization looks the class up by name, so a class the matrix - renders but the registry cannot resolve would round-trip into the wrong - thing or nothing at all. + Deserialization resolves a kind to its class through the framework's + default registry, so a class the matrix renders but the registry + cannot resolve would round-trip into the wrong thing or nothing at + all. The kind is the only identity the artifact carries, so the check + resolves each class's own kind and compares what comes back. """ - ExpressionRegistry._auto_register_builtins() - unresolved = sorted(set(REGISTERED) - set(ExpressionRegistry._registry)) + from rhosocial.activerecord.backend.expression.registry import kind_of + from rhosocial.activerecord.backend.expression.serialization import ( + default_registry, + ) + + registry = default_registry() + unresolved = [] + for fqn, cls in REGISTERED.items(): + try: + resolved = registry.resolve_node(kind_of(cls)) + except Exception: + unresolved.append(fqn) + continue + if resolved is not cls: + unresolved.append(fqn) + unresolved.sort() assert not unresolved, ( f"the matrix covers classes the registry cannot resolve: {unresolved}" ) def test_coverage_report(self, clickhouse_matrix_dialect): """Surface what the matrix covers, so coverage stays transparent.""" - ExpressionRegistry._auto_register_builtins() branches: Dict[str, int] = {} constructible = 0 for fqn in sorted(REGISTERED): diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/test_expression_signatures.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/test_expression_signatures.py index 832e18f..933956a 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/test_expression_signatures.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/test_expression_signatures.py @@ -16,9 +16,9 @@ from rhosocial.activerecord.backend.impl.clickhouse.dialect import ClickHouseDialect from rhosocial.activerecord.backend.impl.clickhouse.expression.partition import ( - ClickHouseAttachPartitionExpression, - ClickHouseDetachPartitionExpression, - ClickHouseDropPartitionExpression, + ClickHouseAttachPartitionStatement, + ClickHouseDetachPartitionStatement, + ClickHouseDropPartitionStatement, ) @@ -32,9 +32,9 @@ class TestClickHousePartitionIdExpressions: """Signature compliance for the three ``ALTER TABLE ... PARTITION ID`` clauses.""" EXPRESSIONS = ( - (ClickHouseDropPartitionExpression, "format_drop_partition_statement", "DROP"), - (ClickHouseDetachPartitionExpression, "format_detach_partition_statement", "DETACH"), - (ClickHouseAttachPartitionExpression, "format_attach_partition_statement", "ATTACH"), + (ClickHouseDropPartitionStatement, "format_drop_partition_statement", "DROP"), + (ClickHouseDetachPartitionStatement, "format_detach_partition_statement", "DETACH"), + (ClickHouseAttachPartitionStatement, "format_attach_partition_statement", "ATTACH"), ) @pytest.mark.parametrize("expr_class,formatter,verb", EXPRESSIONS) diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/test_json_expressions.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/test_json_expressions.py index 7fb31b9..afbeeab 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/test_json_expressions.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/expression/test_json_expressions.py @@ -67,11 +67,19 @@ def test_json_object_basic(self): assert "name" in params assert "age" in params - def test_json_object_with_kwargs(self): - """Test JSON object creation with keyword arguments.""" + def test_json_object_with_a_dict(self): + """Test JSON object creation from a mapping. + + The members arrive as ``data``; the ``**kwargs`` channel this test + used was removed because an artifact matches its parameters by name + (§4.11.4) and a ``**kwargs`` parameter cannot be, so the class refuses + that shape. The dict form renders the same ``map(...)`` either way. + """ dialect = ClickHouseDialect(version=(26, 7, 1)) - expr = ClickHouseJSONObjectExpression(dialect, name="John", age=30).as_("obj") + expr = ClickHouseJSONObjectExpression( + dialect, {"name": "John", "age": 30} + ).as_("obj") sql, params = expr.to_sql() assert "map" in sql diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/functions/test_json_functions.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/functions/test_json_functions.py index 1c523f6..a5104b3 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/functions/test_json_functions.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/functions/test_json_functions.py @@ -90,8 +90,9 @@ def test_format_json_extract_single_path(self): def test_format_json_extract_multiple_paths(self): """Test JSONExtract with multiple paths.""" dialect = ClickHouseDialect(version=(26, 7, 3)) - expr = ClickHouseJSONExtractExpression(dialect, "data", "$.name") - expr.paths = ["$.age", "$.city"] + expr = ClickHouseJSONExtractExpression( + dialect, "data", "$.name", paths=["$.age", "$.city"] + ) sql, params = expr.to_sql() diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/functions/test_string_function_entry_points.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/functions/test_string_function_entry_points.py new file mode 100644 index 0000000..1f1a157 --- /dev/null +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/functions/test_string_function_entry_points.py @@ -0,0 +1,71 @@ +# tests/rhosocial/activerecord_clickhouse_test/feature/backend/functions/test_string_function_entry_points.py +"""The classed string function nodes on ClickHouse's entry points. + +``CharLengthFunction`` is the one with a ClickHouse-specific answer: the +engine's ``LENGTH`` counts **bytes**, so this dialect renders the node as +``lengthUTF8`` -- the character count. The measurement, on the scenario +server ``tests/config/clickhouse_scenarios.yaml`` names ``clickhouse_http`` +(**ClickHouse 26.7.3.19**, HTTP on ``127.0.0.1:18682``, database +``test_db``):: + + SELECT length('héllo') -- 6 (bytes) + SELECT lengthUTF8('héllo') -- 5 (characters) + +``UPPER`` / ``LOWER`` are ClickHouse's own spellings, so they render through +the shared defaults. +""" + +import pytest + +from rhosocial.activerecord.backend.expression.core import Column +from rhosocial.activerecord.backend.expression.functions import ( + CharLengthFunction, + LowerFunction, + UpperFunction, +) +from rhosocial.activerecord.backend.impl.clickhouse.dialect import ClickHouseDialect + +#: The exact release the lengthUTF8 verdict was measured on; see the module +#: docstring for the measured answers. +MEASURED_VERSION = (26, 7, 3) + + +@pytest.fixture() +def dialect() -> ClickHouseDialect: + return ClickHouseDialect(version=MEASURED_VERSION) + + +class TestCharLength: + """``CHAR_LENGTH`` is character semantics, and ``lengthUTF8`` carries it.""" + + def test_renders_length_utf8(self, dialect): + node = CharLengthFunction(dialect, Column(dialect, "name")) + sql, params = node.to_sql() + assert sql == "lengthUTF8(`name`)" + assert params == () + + def test_alias_rides_along(self, dialect): + node = CharLengthFunction(dialect, Column(dialect, "name")).as_("len") + sql, params = node.to_sql() + assert sql == "lengthUTF8(`name`) AS `len`" + assert params == () + + def test_live_engine_answers_characters(self, clickhouse_backend): + """The spelling the dialect picks answers the character question on + the live engine, where ``LENGTH`` would answer 6 (bytes).""" + result = clickhouse_backend.execute("SELECT lengthUTF8('héllo')") + assert [row["lengthUTF8('héllo')"] for row in result.data] == [5] + + +class TestSharedSpellings: + """``UPPER`` / ``LOWER`` exist natively; the shared defaults are the answer.""" + + def test_upper(self, dialect): + sql, params = UpperFunction(dialect, Column(dialect, "name")).to_sql() + assert sql == "UPPER(`name`)" + assert params == () + + def test_lower(self, dialect): + sql, params = LowerFunction(dialect, Column(dialect, "name")).to_sql() + assert sql == "LOWER(`name`)" + assert params == () diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/introspection/test_introspection_parsing.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/introspection/test_introspection_parsing.py new file mode 100644 index 0000000..3330538 --- /dev/null +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/introspection/test_introspection_parsing.py @@ -0,0 +1,233 @@ +# tests/rhosocial/activerecord_clickhouse_test/feature/backend/introspection/test_introspection_parsing.py +"""What the ClickHouse introspection parsers report, without a server. + +Three defects are pinned here, all in the half of introspection that needs no +connection: + +* ``system.tables`` answers an *engine*, not a standard table type. The parser + used to look up ``TABLE_TYPE`` against ``BASE TABLE`` / ``VIEW`` / + ``SYSTEM VIEW``, which no ClickHouse server reports, so every relation came + back as a base table and the engine name stood in for the kind. The kind is + now derived from the engine through the dialect's declared view-engine set, + and the engine travels beside it in ``extra``. +* ``system.databases`` answers a name and an engine; it has no per-database + charset or collation. The parser used to report the engine as ``encoding`` + and ``0.0.0`` as the version. It now reports ``encoding=None`` and reads the + version through ``server_version``, falling back to the ``"unknown"`` + convention rather than to a release number that does not exist. +* The table-list ``table_type`` filter compared it to ``engine`` verbatim, so + ``VIEW`` selected nothing (no engine is called ``VIEW``). It now reads the + introspection vocabulary and selects the engines that store that kind. + +Only a dialect and a two-attribute backend stub are needed; nothing here talks +to ClickHouse. +""" + +from types import SimpleNamespace + +import pytest + +from rhosocial.activerecord.backend.expression.introspection import ( + DatabaseInfoQueryStatement, + TableListQueryStatement, +) +from rhosocial.activerecord.backend.impl.clickhouse.dialect import ClickHouseDialect +from rhosocial.activerecord.backend.impl.clickhouse.introspection import ( + SyncClickHouseIntrospector, +) +from rhosocial.activerecord.backend.introspection.types import TableType + + +class _StubBackend: + """The facts the parsers read from their backend, and nothing else.""" + + def __init__(self, dialect, database="analytics", cached_version=None): + self.dialect = dialect + self.config = SimpleNamespace(database=database) + self._cached_version = cached_version + + def get_cached_server_version(self): + return self._cached_version + + +def _introspector(dialect, database="analytics", cached_version=None): + return SyncClickHouseIntrospector( + _StubBackend(dialect, database, cached_version), executor=None + ) + + +@pytest.fixture(scope="module") +def dialect(): + """The scenario server's version shape (26.7.3).""" + return ClickHouseDialect(version=(26, 7, 3)) + + +def _table_row(name, engine): + return { + "TABLE_NAME": name, + "ENGINE": engine, + "TABLE_COMMENT": "", + "TABLE_ROWS": 0, + "DATA_LENGTH": 0, + } + + +class TestTableKindFromEngine: + """The kind comes from the engine, and the engine is not discarded.""" + + @pytest.mark.parametrize( + "engine, expected", + [ + ("MergeTree", TableType.BASE_TABLE), + ("ReplicatedMergeTree", TableType.BASE_TABLE), + ("Log", TableType.BASE_TABLE), + ("View", TableType.VIEW), + ("LiveView", TableType.VIEW), + ("WindowView", TableType.VIEW), + ("MaterializedView", TableType.MATERIALIZED_VIEW), + ], + ) + def test_engine_maps_to_its_relation_kind(self, dialect, engine, expected): + (info,) = _introspector(dialect).parse_tables([_table_row("rel", engine)], "analytics") + assert info.table_type is expected + assert info.extra == {"engine": engine} + + def test_every_declared_view_engine_is_a_view_kind(self, dialect): + for engine in dialect.VIEW_ENGINES: + assert dialect.engine_kind(engine) in ( + TableType.VIEW, + TableType.MATERIALIZED_VIEW, + ) + + def test_kind_groups_partition_the_declared_view_engines(self, dialect): + """The per-kind groups and the SQL filter read one closed set.""" + grouped = [engine for _, engines in dialect.VIEW_ENGINE_KINDS for engine in engines] + assert sorted(grouped) == sorted(dialect.VIEW_ENGINES) + assert len(grouped) == len(set(grouped)) + + def test_only_materialized_view_gets_its_own_kind(self, dialect): + materialized = dict(dialect.VIEW_ENGINE_KINDS)["MATERIALIZED_VIEW"] + for engine in materialized: + assert dialect.engine_kind(engine) is TableType.MATERIALIZED_VIEW + + def test_rows_from_the_system_database_are_system_tables(self, dialect): + (info,) = _introspector(dialect, database="system").parse_tables( + [_table_row("query_log", "MergeTree")], "system" + ) + assert info.table_type is TableType.SYSTEM_TABLE + assert info.extra == {"engine": "MergeTree"} + + def test_a_known_table_is_still_a_base_table_in_a_user_database(self, dialect): + (info,) = _introspector(dialect).parse_tables( + [_table_row("events", "MergeTree")], "analytics" + ) + assert info.table_type is TableType.BASE_TABLE + + +class TestDatabaseInfo: + """Version, encoding and collation are what the server answered.""" + + def test_version_comes_from_the_adapted_dialect(self, dialect): + info = _introspector(dialect).parse_database_info( + [{"DATABASE_NAME": "analytics", "ENGINE": "Atomic"}] + ) + assert info.name == "analytics" + assert info.version == "26.7.3" + assert info.version_tuple == (26, 7, 3) + assert info.encoding is None + assert info.collation is None + assert info.extra == {"engine": "Atomic"} + + def test_version_uses_the_cached_server_version_without_adapting(self): + introspector = _introspector( + ClickHouseDialect(), cached_version=(25, 8, 0) + ) + info = introspector.parse_database_info([]) + assert info.version == "25.8.0" + assert info.version_tuple == (25, 8, 0) + + def test_unknown_version_is_reported_as_unknown_not_as_zero(self): + introspector = _introspector(ClickHouseDialect()) + info = introspector.parse_database_info([]) + assert info.version == "unknown" + assert info.version_tuple == (0, 0, 0) + # The schema still answers what it is, and nothing invents a charset. + assert info.name == "analytics" + assert info.encoding is None + assert info.collation is None + assert info.extra == {} + + def test_database_info_query_projects_no_charset(self, dialect): + sql, params = DatabaseInfoQueryStatement(dialect, schema="analytics").to_sql() + assert params == ("analytics",) + assert "`engine` AS `ENGINE`" in sql + assert "DEFAULT_CHARACTER_SET_NAME" not in sql + assert "DEFAULT_COLLATION_NAME" not in sql + + +class TestTableTypeFilter: + """``table_type`` is a kind, and an engine name is still accepted.""" + + def test_view_selects_the_engines_that_store_a_view(self, dialect): + sql, params = TableListQueryStatement( + dialect, schema="analytics", table_type="VIEW" + ).to_sql() + assert "`engine` IN (%s, %s, %s)" in sql + assert "`engine` = %s" not in sql + assert params == ("analytics", "View", "LiveView", "WindowView") + + def test_materialized_view_selects_only_its_engine(self, dialect): + sql, params = TableListQueryStatement( + dialect, schema="analytics", table_type=TableType.MATERIALIZED_VIEW + ).to_sql() + assert "`engine` IN (%s)" in sql + assert params == ("analytics", "MaterializedView") + + @pytest.mark.parametrize("spelling", ["BASE_TABLE", "TABLE"]) + def test_base_table_is_everything_outside_the_view_engines( + self, dialect, spelling + ): + sql, params = TableListQueryStatement( + dialect, schema="analytics", table_type=spelling + ).to_sql() + assert "NOT (`engine` IN (%s, %s, %s, %s))" in sql + assert params == ( + "analytics", + "View", + "MaterializedView", + "LiveView", + "WindowView", + ) + + def test_an_engine_name_is_still_an_engine_filter(self, dialect): + sql, params = TableListQueryStatement( + dialect, schema="analytics", table_type="MergeTree" + ).to_sql() + assert "`engine` = %s" in sql + assert params == ("analytics", "MergeTree") + + def test_system_tables_are_selected_by_their_database(self, dialect): + sql, params = TableListQueryStatement( + dialect, schema="system", table_type="SYSTEM_TABLE" + ).to_sql() + assert "`database` = 'system'" in sql + # Asking for system tables by kind is the request ``include_system`` + # exists for; the exclusion must not be conjoined with it. + assert "`database` <> 'system'" not in sql + assert params == ("system",) + + def test_a_kind_this_catalogue_never_reports_selects_nothing(self, dialect): + sql, params = TableListQueryStatement( + dialect, schema="analytics", table_type="PARTITIONED_TABLE" + ).to_sql() + assert "1 = 0" in sql + assert params == ("analytics",) + + def test_the_list_tables_entry_point_uses_the_same_filter(self, dialect): + """``list_tables`` — what the CLI calls — asks the corrected question.""" + introspector = _introspector(dialect) + sql, params = introspector.table_list_statement( + schema=None, include_system=False, table_type="VIEW" + ).to_sql() + assert "`engine` IN (%s, %s, %s)" in sql + assert params == ("analytics", "View", "LiveView", "WindowView") diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/introspection/test_introspection_partitions.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/introspection/test_introspection_partitions.py new file mode 100644 index 0000000..fe689e5 --- /dev/null +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/introspection/test_introspection_partitions.py @@ -0,0 +1,395 @@ +# tests/rhosocial/activerecord_clickhouse_test/feature/backend/introspection/test_introspection_partitions.py +"""Partitions and table-engine introspection, against a live ClickHouse server. + +What is pinned here: + +* ``list_partitions`` really groups ``system.parts`` by ``partition_id`` and the + aggregate means what it says -- row counts add up across the parts of a + partition, and only active parts are read; +* ``get_table_engine`` reports the definition the table was created with; +* both reads are cached under the table scope with an ``extra`` namespace, so a + warm call sends **no** statement to the server; +* the CLI reaches the same methods through ``introspect partitions`` / + ``introspect table-engine`` and renders them through the output provider; +* the result types are frozen, so a cached answer cannot be mutated by one + caller and handed to the next. + +The live tests skip themselves when no server is reachable. The scenario is the +one the rest of the backend tests use (``tests/config/clickhouse_scenarios.yaml`` +or ``CLICKHOUSE_SCENARIOS_CONFIG_PATH``), read through ``providers.scenarios`` so +this file cannot drift to its own connection settings. +""" + +import io +import json +import socket +from contextlib import redirect_stderr, redirect_stdout +from dataclasses import FrozenInstanceError + +import pytest + +from rhosocial.activerecord.backend.impl.clickhouse.__main__ import main +from rhosocial.activerecord.backend.impl.clickhouse.cli.introspect import ( + INTROSPECT_TYPES, +) +from rhosocial.activerecord.backend.impl.clickhouse.dialect import ClickHouseDialect +from rhosocial.activerecord.backend.impl.clickhouse.expression.introspection import ( + ClickHouseTableEngineQuery, + ClickHouseTablePartitionsQuery, +) +from rhosocial.activerecord.backend.impl.clickhouse.introspection.types import ( + ClickHousePartitionInfo, + ClickHouseTableEngineInfo, +) + +TABLE = "test_intro_partitions_ch" + + +# --------------------------------------------------------------------------- # +# Skip mechanism: a live scenario is required, and its absence is a skip +# --------------------------------------------------------------------------- # + + +@pytest.fixture(scope="module") +def live_server(): + """Skip the whole live group when the configured server is not there.""" + from providers.scenarios import get_enabled_scenarios + + scenarios = get_enabled_scenarios() + if not scenarios: + pytest.skip("No ClickHouse scenarios configured") + config = next(iter(scenarios.values())) + try: + with socket.create_connection((config["host"], int(config["port"])), timeout=2): + pass + except OSError as error: + pytest.skip(f"ClickHouse unreachable at {config['host']}:{config['port']}: {error}") + return config + + +@pytest.fixture +def partitioned_table(live_server, clickhouse_backend): + """A MergeTree table with two monthly partitions and three rows.""" + backend = clickhouse_backend + backend.execute(f"DROP TABLE IF EXISTS {TABLE}") + backend.execute( + f""" + CREATE TABLE {TABLE} ( + id UInt32, + event_date Date, + label String + ) ENGINE = MergeTree() + PARTITION BY toYYYYMM(event_date) + ORDER BY id + """ + ) + backend.execute( + f""" + INSERT INTO {TABLE} (id, event_date, label) VALUES + (1, toDate('2026-01-05'), 'alpha'), + (2, toDate('2026-01-19'), 'beta'), + (3, toDate('2026-02-02'), 'gamma') + """ + ) + backend.introspector.clear_cache() + yield backend + backend.execute(f"DROP TABLE IF EXISTS {TABLE}") + + +class QuestionCounter: + """Delegating executor that counts the statements introspection sends.""" + + def __init__(self, inner): + self._inner = inner + self.questions = 0 + + def execute(self, sql, params=()): + self.questions += 1 + return self._inner.execute(sql, params) + + +# --------------------------------------------------------------------------- # +# SQL nodes (no server needed) +# --------------------------------------------------------------------------- # + + +@pytest.fixture(scope="module") +def dialect(): + return ClickHouseDialect(version=(26, 7, 3)) + + +class TestQueryNodes: + def test_partitions_query_groups_active_parts(self, dialect): + sql, params = ClickHouseTablePartitionsQuery(dialect, "test_db", "events").to_sql() + assert "FROM `system`.`parts`" in sql + assert "COUNT(*) AS `active_part_count`" in sql + assert "SUM(`rows`) AS `row_count`" in sql + assert "SUM(`bytes_on_disk`) AS `bytes_on_disk`" in sql + assert "MIN(`modification_time`) AS `min_modification_time`" in sql + assert "MAX(`modification_time`) AS `max_modification_time`" in sql + assert "GROUP BY `partition_id`" in sql + assert "ORDER BY `partition_id`" in sql + # Only active parts are aggregated: inactive parts are replaced data. + assert "`active` = %s" in sql + assert params == ("test_db", "events", 1) + + def test_engine_query_projects_the_definition(self, dialect): + sql, params = ClickHouseTableEngineQuery(dialect, "test_db", "events").to_sql() + assert "FROM `system`.`tables`" in sql + for column in ( + "`engine`", + "`sorting_key`", + "`primary_key`", + "`partition_key`", + "`sampling_key`", + "`total_rows`", + "`total_bytes`", + "`comment`", + ): + assert column in sql + assert "`database` = %s AND `name` = %s" in sql + assert params == ("test_db", "events") + + +# --------------------------------------------------------------------------- # +# Result types (no server needed) +# --------------------------------------------------------------------------- # + + +class TestResultTypes: + def test_partition_info_is_frozen(self): + item = ClickHousePartitionInfo( + partition_id="202601", + table_name="events", + database="test_db", + engine="MergeTree", + ) + with pytest.raises(FrozenInstanceError): + item.row_count = 1 + + def test_table_engine_info_is_frozen(self): + item = ClickHouseTableEngineInfo( + table_name="events", + database="test_db", + engine="MergeTree", + ) + with pytest.raises(FrozenInstanceError): + item.engine = "Log" + + def test_cli_declares_the_new_introspection_types(self): + assert "partitions" in INTROSPECT_TYPES + assert "table-engine" in INTROSPECT_TYPES + + +# --------------------------------------------------------------------------- # +# Live results +# --------------------------------------------------------------------------- # + + +class TestPartitions: + def test_partitions_are_grouped_by_partition_id(self, partitioned_table): + partitions = partitioned_table.introspector.list_partitions(TABLE) + + assert [p.partition_id for p in partitions] == ["202601", "202602"] + assert [p.partition for p in partitions] == ["202601", "202602"] + assert {p.partition_id: p.row_count for p in partitions} == { + "202601": 2, + "202602": 1, + } + for item in partitions: + assert item.table_name == TABLE + assert item.database == partitioned_table.config.database + assert item.engine == "MergeTree" + assert item.bytes_on_disk > 0 + assert item.active_part_count >= 1 + assert item.min_modification_time is not None + assert item.max_modification_time >= item.min_modification_time + + def test_rows_add_up_to_the_table_total(self, partitioned_table): + partitions = partitioned_table.introspector.list_partitions(TABLE) + assert sum(p.row_count for p in partitions) == 3 + assert sum(p.bytes_on_disk for p in partitions) > 0 + + def test_table_without_parts_returns_an_empty_list(self, partitioned_table): + empty_table = f"{TABLE}_empty" + partitioned_table.execute(f"DROP TABLE IF EXISTS {empty_table}") + partitioned_table.execute( + f"CREATE TABLE {empty_table} (id UInt32) ENGINE = MergeTree() ORDER BY id" + ) + try: + assert partitioned_table.introspector.list_partitions(empty_table) == [] + finally: + partitioned_table.execute(f"DROP TABLE IF EXISTS {empty_table}") + + def test_missing_table_returns_an_empty_list(self, partitioned_table): + assert partitioned_table.introspector.list_partitions("no_such_table_xyz") == [] + + +class TestTableEngine: + def test_engine_and_key_expressions(self, partitioned_table): + info = partitioned_table.introspector.get_table_engine(TABLE) + + assert info is not None + assert info.table_name == TABLE + assert info.database == partitioned_table.config.database + assert info.engine == "MergeTree" + assert info.sorting_key == "id" + assert info.primary_key == "id" + assert info.partition_key == "toYYYYMM(event_date)" + assert info.sampling_key == "" + assert info.total_rows == 3 + assert info.total_bytes > 0 + assert info.comment == "" + + def test_missing_table_returns_none(self, partitioned_table): + assert partitioned_table.introspector.get_table_engine("no_such_table_xyz") is None + + def test_database_argument_names_the_clickhouse_database(self, partitioned_table): + database = partitioned_table.config.database + info = partitioned_table.introspector.get_table_engine(TABLE, database=database) + assert info is not None + assert info.database == database + # Same answer whether the database is implicit or named. + assert info == partitioned_table.introspector.get_table_engine(TABLE) + + +class TestCache: + def test_warm_reads_send_no_questions(self, partitioned_table): + introspector = partitioned_table.introspector + counter = QuestionCounter(introspector._executor) + introspector._executor = counter + try: + introspector.clear_cache() + + cold_start = counter.questions + partitions = introspector.list_partitions(TABLE) + engine = introspector.get_table_engine(TABLE) + cold_questions = counter.questions - cold_start + + warm_start = counter.questions + warm_partitions = introspector.list_partitions(TABLE) + warm_engine = introspector.get_table_engine(TABLE) + warm_questions = counter.questions - warm_start + + assert cold_questions == 2 + assert warm_questions == 0 + assert warm_partitions is partitions + assert warm_engine is engine + finally: + introspector._executor = counter._inner + + def test_table_scope_invalidation_drops_both_families(self, partitioned_table): + introspector = partitioned_table.introspector + introspector.clear_cache() + introspector.list_partitions(TABLE) + introspector.get_table_engine(TABLE) + before = len(introspector.cache) + + introspector.invalidate_cache(scope=None, name=TABLE) + + assert len(introspector.cache) < before + + +# --------------------------------------------------------------------------- # +# CLI +# --------------------------------------------------------------------------- # + + +def run_cli(argv): + out, err = io.StringIO(), io.StringIO() + exit_code = None + with redirect_stdout(out), redirect_stderr(err): + try: + main(argv) + except SystemExit as exc: + exit_code = exc.code + return out.getvalue(), err.getvalue(), exit_code + + +@pytest.fixture +def cli_env(live_server): + """A backend and CLI args on the scenario's own connection settings. + + The table is created through the same connection the CLI will use, so the + CLI cannot be pointed at a different database than the fixture populated. + """ + from providers.scenarios import get_scenario_raw + + backend_class, config = get_scenario_raw("clickhouse_http") + backend = backend_class(connection_config=config) + backend.connect() + backend.execute(f"DROP TABLE IF EXISTS {TABLE}") + backend.execute( + f""" + CREATE TABLE {TABLE} ( + id UInt32, + event_date Date, + label String + ) ENGINE = MergeTree() + PARTITION BY toYYYYMM(event_date) + ORDER BY id + """ + ) + backend.execute( + f""" + INSERT INTO {TABLE} (id, event_date, label) VALUES + (1, toDate('2026-01-05'), 'alpha'), + (2, toDate('2026-01-19'), 'beta'), + (3, toDate('2026-02-02'), 'gamma') + """ + ) + conn_args = [ + "--host", config.host, + "--port", str(config.port), + "--database", config.database, + "--user", config.username, + "--password", config.password, + ] + try: + yield conn_args + finally: + backend.execute(f"DROP TABLE IF EXISTS {TABLE}") + backend.disconnect() + + +class TestCli: + def test_partitions_json(self, cli_env): + out, err, exit_code = run_cli( + ["introspect", "partitions", TABLE] + cli_env + ["-o", "json"] + ) + + assert exit_code is None, f"stderr: {err}\nstdout: {out}" + data = json.loads(out) + assert [row["partition_id"] for row in data] == ["202601", "202602"] + assert {row["partition_id"]: row["row_count"] for row in data} == { + "202601": 2, + "202602": 1, + } + assert all(row["engine"] == "MergeTree" for row in data) + + def test_table_engine_json(self, cli_env): + out, err, exit_code = run_cli( + ["introspect", "table-engine", TABLE] + cli_env + ["-o", "json"] + ) + + assert exit_code is None, f"stderr: {err}\nstdout: {out}" + data = json.loads(out) + assert len(data) == 1 + assert data[0]["engine"] == "MergeTree" + assert data[0]["partition_key"] == "toYYYYMM(event_date)" + assert data[0]["sorting_key"] == "id" + assert data[0]["total_rows"] == 3 + + def test_partitions_require_a_table_name(self, cli_env): + out, err, exit_code = run_cli(["introspect", "partitions"] + cli_env) + + assert exit_code == 1 + assert "Table name is required" in err + + def test_table_engine_requires_the_table_to_exist(self, cli_env): + out, err, exit_code = run_cli( + ["introspect", "table-engine", "no_such_table_xyz"] + cli_env + ) + + assert exit_code == 1 + assert "not found" in err diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/introspection/test_introspection_query_nodes.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/introspection/test_introspection_query_nodes.py new file mode 100644 index 0000000..ae59bb9 --- /dev/null +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/introspection/test_introspection_query_nodes.py @@ -0,0 +1,170 @@ +# tests/rhosocial/activerecord_clickhouse_test/feature/backend/introspection/test_introspection_query_nodes.py +""" +The introspection and status reads are expression trees, not SQL strings. + +Every statement this backend asks the server about its own catalogs is built +from nodes -- ``QueryStatement``, ``Column``, ``ComparisonPredicate``, +``InPredicate``, ``WhereClause``, ``OrderByClause``, ``Literal`` -- and rendered +by the dialect. What this file pins is the half that can be checked without a +server: the projection, in order and with its aliases; the parameter tuple, in +order; and the engine-specific spellings that a concatenated string used to +carry without anything asserting them. + +Why this matters beyond tidiness: a string assembled one clause at a time has no +single place the query exists, and its bind parameters were counted by hand +(``offset=len(sql_params)``). Nothing checked the count against the branches +above it. The tree carries the order instead -- a ``Literal`` binds or inlines at +its own position -- so the tuple below is the whole parameter contract. + +No database is involved; a dialect instance is the only input. +""" + +import pytest + +from rhosocial.activerecord.backend.expression.introspection import ( + ColumnInfoQueryStatement, + DatabaseInfoQueryStatement, + IndexInfoQueryStatement, + TableListQueryStatement, + ViewInfoQueryStatement, + ViewListQueryStatement, +) +from rhosocial.activerecord.backend.impl.clickhouse.dialect import ClickHouseDialect +from rhosocial.activerecord.backend.impl.clickhouse.expression.status import ( + ClickHouseConnectionProbeQuery, + ClickHouseDatabaseTotalSizeQuery, + ClickHouseDiskListQuery, + ClickHouseSettingQuery, + ClickHouseTableCountQuery, +) + + +@pytest.fixture(scope="module") +def dialect(): + """The scenario server's version shape (26.7.3).""" + return ClickHouseDialect(version=(26, 7, 3)) + + +class TestIntrospectionQueriesAreTrees: + """Each ``format_*_query`` builds a statement and hands it to the dialect.""" + + def test_database_info_reads_system_databases_by_name(self, dialect): + sql, params = DatabaseInfoQueryStatement(dialect, schema="analytics").to_sql() + assert "FROM `system`.`databases`" in sql + assert "`name` AS `DATABASE_NAME`" in sql + assert "`engine` AS `ENGINE`" in sql + # ClickHouse has no per-database charset or collation; the query asks + # for neither rather than projecting a value the server never answers. + assert "DEFAULT_CHARACTER_SET_NAME" not in sql + assert "DEFAULT_COLLATION_NAME" not in sql + assert params == ("analytics",) + + def test_table_list_keeps_the_projection_order_and_aliases(self, dialect): + sql, params = TableListQueryStatement(dialect, schema="default").to_sql() + assert sql.index("`name` AS `TABLE_NAME`") < sql.index("`engine` AS `ENGINE`") + assert sql.index("`engine` AS `ENGINE`") < sql.index("`comment` AS `TABLE_COMMENT`") + assert sql.index("`metadata_modification_time` AS `MODIFICATION_TIME`") > sql.index( + "`total_bytes` AS `DATA_LENGTH`" + ) + # The engine is an engine, not a table type: the kind is derived by the + # parser from the declared view-engine set. + assert "TABLE_TYPE" not in sql + assert params == ("default",) + + def test_table_list_excludes_views_with_the_declared_engine_set(self, dialect): + sql, params = TableListQueryStatement( + dialect, schema="default", include_views=False + ).to_sql() + assert "NOT (`engine` IN (" in sql + assert params == ( + "default", + "View", + "MaterializedView", + "LiveView", + "WindowView", + ) + + def test_table_list_excludes_the_system_database_inline(self, dialect): + """``'system'`` is a fixed word of the query, so it never becomes a parameter.""" + sql, params = TableListQueryStatement(dialect, schema="default").to_sql() + assert "`database` <> 'system'" in sql + assert "system" not in params + + def test_column_info_derives_nullability_from_the_type_name(self, dialect): + sql, params = ColumnInfoQueryStatement(dialect, "users", schema="default").to_sql() + # Doubled `%`: this statement bypasses Backend.execute, so it bypasses + # the escape that adds the second `%` for the driver's interpolation. + assert "IF(`type` LIKE 'Nullable(%%', 'YES', 'NO') AS `IS_NULLABLE`" in sql + assert params == ("default", "users") + assert sql.endswith("ORDER BY `position`") + + def test_index_info_reports_clickhouses_own_columns(self, dialect): + sql, params = IndexInfoQueryStatement(dialect, "users", schema="default").to_sql() + assert "FROM `system`.`data_skipping_indices`" in sql + assert "CAST(NULL AS Nullable(String)) AS `SUB_PART`" in sql + assert params == ("default", "users") + + def test_view_list_binds_the_engine_set_and_inlines_the_system_exclusion( + self, dialect + ): + sql, params = ViewListQueryStatement(dialect, schema="default").to_sql() + assert "`engine` IN (" in sql + assert "`database` <> 'system'" in sql + assert params == ( + "default", + "View", + "MaterializedView", + "LiveView", + "WindowView", + ) + + def test_view_info_filters_by_name_then_engine(self, dialect): + sql, params = ViewInfoQueryStatement(dialect, "v", schema="default").to_sql() + assert "`name` = %s" in sql + assert params == ("default", "v", "View", "MaterializedView", "LiveView", "WindowView") + + +class TestStatusQueriesAreTrees: + """The status reads build the same kind of tree, one formatter each.""" + + def test_setting_query_binds_the_setting_name(self, dialect): + sql, params = ClickHouseSettingQuery(dialect, "max_concurrent_queries").to_sql() + assert sql == ( + "SELECT `value` FROM `system`.`settings` WHERE `name` = %s" + ) + assert params == ("max_concurrent_queries",) + + def test_database_total_size_uses_the_sum_aggregate(self, dialect): + sql, params = ClickHouseDatabaseTotalSizeQuery(dialect, "default").to_sql() + assert sql == ( + "SELECT SUM(`total_bytes`) AS `total_size` FROM `system`.`tables` " + "WHERE `database` = %s" + ) + assert params == ("default",) + + def test_disk_list_projects_every_flag(self, dialect): + sql, params = ClickHouseDiskListQuery(dialect).to_sql() + for column in ( + "`name`", + "`path`", + "`type`", + "`total_space`", + "`free_space`", + "`unreserved_space`", + "`keep_free_space`", + "`is_encrypted`", + "`is_read_only`", + "`is_remote`", + "`is_broken`", + ): + assert column in sql + assert params == () + + def test_table_count_groups_by_schema_and_type(self, dialect): + sql, params = ClickHouseTableCountQuery(dialect, ["default", "analytics"]).to_sql() + assert "FROM `information_schema`.`TABLES`" in sql + assert "GROUP BY `table_schema`, `table_type`" in sql + assert params == ("default", "analytics") + + def test_connection_probe_is_a_relation_less_scalar(self, dialect): + assert ClickHouseConnectionProbeQuery(dialect).to_sql() == ("SELECT 1", ()) diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/introspection/test_show_output_shape.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/introspection/test_show_output_shape.py index 1915077..3b2014d 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/introspection/test_show_output_shape.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/introspection/test_show_output_shape.py @@ -63,28 +63,28 @@ class TestCreateStatementOutputShape: VIEW_ROW = {"statement": "CREATE VIEW default.v\n(\n `id` Int64\n)\nAS SELECT id"} def test_create_table_reads_the_statement_column(self): - result = ShowMixin._parse_create_table([self.TABLE_ROW], "t") + result = ShowMixin.parse_create_table([self.TABLE_ROW], "t") assert result.table_name == "t" assert result.create_statement == self.TABLE_ROW["statement"] def test_create_view_reads_the_statement_column(self): - result = ShowMixin._parse_create_view([self.VIEW_ROW], "v") + result = ShowMixin.parse_create_view([self.VIEW_ROW], "v") assert result.view_name == "v" assert result.create_statement == self.VIEW_ROW["statement"] def test_create_view_leaves_mysql_only_fields_empty(self): - result = ShowMixin._parse_create_view([self.VIEW_ROW], "v") + result = ShowMixin.parse_create_view([self.VIEW_ROW], "v") assert result.character_set_client is None assert result.collation_connection is None def test_no_rows_returns_none(self): - assert ShowMixin._parse_create_table([], "t") is None - assert ShowMixin._parse_create_view([], "v") is None + assert ShowMixin.parse_create_table([], "t") is None + assert ShowMixin.parse_create_view([], "v") is None def test_mysql_column_names_are_not_consulted(self): """A row carrying only MySQL's names must not be mistaken for a result.""" - assert ShowMixin._parse_create_table([{"Create Table": "..."}], "t").create_statement == "" - assert ShowMixin._parse_create_view([{"Create View": "..."}], "v").create_statement == "" + assert ShowMixin.parse_create_table([{"Create Table": "..."}], "t").create_statement == "" + assert ShowMixin.parse_create_view([{"Create View": "..."}], "v").create_statement == "" class TestListOutputShape: @@ -92,13 +92,13 @@ class TestListOutputShape: def test_tables_reads_name(self): rows = [{"name": "a"}, {"name": "b"}] - result = ShowMixin._parse_tables(rows) + result = ShowMixin.parse_tables(rows) assert [(r.name, r.table_type) for r in result] == [("a", None), ("b", None)] def test_full_tables_reads_name_and_engine(self): """Under ``FULL`` the second column is the storage engine, not a table type.""" rows = [{"name": "a", "engine": "MergeTree"}, {"name": "v", "engine": "View"}] - result = ShowMixin._parse_tables(rows) + result = ShowMixin.parse_tables(rows) assert [(r.name, r.table_type) for r in result] == [ ("a", "MergeTree"), ("v", "View"), @@ -106,14 +106,14 @@ def test_full_tables_reads_name_and_engine(self): def test_databases_reads_name(self): rows = [{"name": "default"}, {"name": "system"}] - assert [r.name for r in ShowMixin._parse_databases(rows)] == ["default", "system"] + assert [r.name for r in ShowMixin.parse_databases(rows)] == ["default", "system"] def test_mysql_column_names_are_not_consulted(self): """MySQL's ``Tables_in_`` / ``Database`` names must not be looked for.""" mysql_rows = [{"Tables_in_db": "a", "Table_type": "BASE TABLE"}] - result = ShowMixin._parse_tables(mysql_rows) + result = ShowMixin.parse_tables(mysql_rows) assert [(r.name, r.table_type) for r in result] == [(None, None)] - assert [r.name for r in ShowMixin._parse_databases([{"Database": "default"}])] == [None] + assert [r.name for r in ShowMixin.parse_databases([{"Database": "default"}])] == [None] # Every row below is a row the 26.7.3.19 server returned, quoted column for @@ -182,30 +182,30 @@ class TestColumnsOutputShape: """``SHOW [FULL] COLUMNS`` answers lower-cased ``field``/``type``/``key``/...""" def test_columns_read_the_lowercase_names(self): - result = ShowMixin._parse_columns(COLUMN_ROWS) + result = ShowMixin.parse_columns(COLUMN_ROWS) assert [(r.field, r.type, r.null, r.key) for r in result] == [ ("al", "UInt8", "NO", ""), ("id", "UInt32", "NO", "PRI SOR"), ] def test_columns_carry_default_and_extra(self): - result = ShowMixin._parse_columns(COLUMN_ROWS) + result = ShowMixin.parse_columns(COLUMN_ROWS) assert [(r.default, r.extra) for r in result] == [("id", ""), (None, "")] def test_full_columns_read_collation_comment_and_privileges(self): """The three extra columns appear only under ``FULL``, in lower case.""" - result = ShowMixin._parse_columns(COLUMN_FULL_ROWS) + result = ShowMixin.parse_columns(COLUMN_FULL_ROWS) assert (result[0].collation, result[0].comment, result[0].privileges) == ( None, "", "", ) def test_plain_columns_leave_the_full_only_columns_empty(self): - result = ShowMixin._parse_columns(COLUMN_ROWS) + result = ShowMixin.parse_columns(COLUMN_ROWS) assert all(r.collation is None and r.comment is None and r.privileges is None for r in result) def test_mysql_column_names_are_not_consulted(self): """MySQL's ``Field``/``Type``/``Key`` must not be looked for.""" - result = ShowMixin._parse_columns( + result = ShowMixin.parse_columns( [{"Field": "id", "Type": "int", "Null": "NO", "Key": "PRI", "Default": None, "Extra": ""}] ) assert [(r.field, r.type, r.key) for r in result] == [(None, None, None)] @@ -215,25 +215,25 @@ class TestIndexOutputShape: """``SHOW INDEX`` answers ``pk_col`` where MySQL answers ``Column_name``.""" def test_index_reads_pk_col_as_the_column_name(self): - result = ShowMixin._parse_indexes(INDEX_ROWS) + result = ShowMixin.parse_indexes(INDEX_ROWS) assert [r.column_name for r in result] == ["", "id"] def test_index_reports_clickhouse_index_types(self): - result = ShowMixin._parse_indexes(INDEX_ROWS) + result = ShowMixin.parse_indexes(INDEX_ROWS) assert [r.index_type for r in result] == ["BLOOM_FILTER", "PRIMARY"] def test_skipping_index_keeps_its_expression(self): - skip, primary = ShowMixin._parse_indexes(INDEX_ROWS) + skip, primary = ShowMixin.parse_indexes(INDEX_ROWS) assert (skip.key_name, skip.expression, skip.collation) == ("idx_s", "s", None) assert (primary.key_name, primary.expression, primary.collation) == ("PRIMARY", "", "A") def test_index_carries_no_mysql_btree_default(self): """``index_type`` must come from the server, not default to ``BTREE``.""" - result = ShowMixin._parse_indexes([{"table": "t", "non_unique": 1, "key_name": "PRIMARY", "seq_in_index": 1}]) + result = ShowMixin.parse_indexes([{"table": "t", "non_unique": 1, "key_name": "PRIMARY", "seq_in_index": 1}]) assert result[0].index_type is None def test_mysql_column_names_are_not_consulted(self): - result = ShowMixin._parse_indexes( + result = ShowMixin.parse_indexes( [{"Table": "t", "Key_name": "PRIMARY", "Column_name": "id", "Index_type": "BTREE"}] ) assert [(r.table_name, r.column_name, r.index_type) for r in result] == [(None, None, None)] @@ -243,7 +243,7 @@ class TestProcessListOutputShape: """``SHOW PROCESSLIST`` is ``system.processes``, so its columns are that table's.""" def test_processlist_maps_query_id_user_address_and_elapsed(self): - row = ShowMixin._parse_processlist(PROCESSLIST_ROWS)[0] + row = ShowMixin.parse_processlist(PROCESSLIST_ROWS)[0] assert row.id == "0a944dd1-8a7e-4fd2-9733-e9e44f4d9f53" assert row.user == "root" assert row.host == "::ffff:192.168.65.1:39550" @@ -253,12 +253,12 @@ def test_processlist_maps_query_id_user_address_and_elapsed(self): def test_command_and_state_have_no_clickhouse_source(self): """``system.processes`` has no per-process command or state column.""" - row = ShowMixin._parse_processlist(PROCESSLIST_ROWS)[0] + row = ShowMixin.parse_processlist(PROCESSLIST_ROWS)[0] assert row.command is None assert row.state is None def test_mysql_column_names_are_not_consulted(self): - result = ShowMixin._parse_processlist( + result = ShowMixin.parse_processlist( [{"Id": 1, "User": "root", "Host": "localhost", "Command": "Query", "Time": 0}] ) assert [(r.id, r.user, r.host, r.command, r.time) for r in result] == [ @@ -270,24 +270,24 @@ class TestEnginesOutputShape: """``SHOW ENGINES`` is ``system.table_engines``: ``name`` plus ``supports_*``.""" def test_engines_read_the_name_column(self): - assert ShowMixin._parse_engines(ENGINE_ROWS)[0].engine == "AggregatingMergeTree" + assert ShowMixin.parse_engines(ENGINE_ROWS)[0].engine == "AggregatingMergeTree" def test_engines_read_all_eight_supports_flags(self): - row = ShowMixin._parse_engines(ENGINE_ROWS)[0] + row = ShowMixin.parse_engines(ENGINE_ROWS)[0] assert (row.supports_settings, row.supports_skipping_indices) == (1, 1) assert (row.supports_projections, row.supports_sort_order) == (1, 1) assert (row.supports_ttl, row.supports_replication) == (1, 0) assert (row.supports_deduplication, row.supports_parallel_insert) == (0, 1) def test_engines_read_the_documentation_columns(self): - row = ShowMixin._parse_engines(ENGINE_ROWS)[0] + row = ShowMixin.parse_engines(ENGINE_ROWS)[0] assert row.description == "# AggregatingMergeTree" assert row.syntax == "AGGREGATING" assert row.related == ["ReplicatedAggregatingMergeTree"] def test_mysql_column_names_are_not_consulted(self): """MySQL's ``Engine``/``Support``/``XA`` must not be looked for.""" - result = ShowMixin._parse_engines( + result = ShowMixin.parse_engines( [{"Engine": "InnoDB", "Support": "DEFAULT", "Transactions": "YES", "XA": "NO", "Savepoints": "NO"}] ) assert result[0].engine is None @@ -297,32 +297,32 @@ class TestGrantsOutputShape: """``SHOW GRANTS`` labels its only column after the statement plus the format.""" def test_grants_reads_the_native_protocol_column_name(self): - assert ShowMixin._parse_grants(GRANT_ROWS[:1])[0].grants.startswith("GRANT SOURCES") + assert ShowMixin.parse_grants(GRANT_ROWS[:1])[0].grants.startswith("GRANT SOURCES") def test_grants_reads_the_for_user_column_name(self): - assert ShowMixin._parse_grants(GRANT_ROWS[1:])[0].grants == ( + assert ShowMixin.parse_grants(GRANT_ROWS[1:])[0].grants == ( "GRANT SET DEFINER ON * TO root WITH GRANT OPTION" ) def test_grants_falls_back_to_the_single_column_value(self): """HTTP with ``default_format=JSONEachRow`` names the bare column ``GRANTS``.""" - assert ShowMixin._parse_grants([{"GRANTS": "GRANT SELECT ON *.* TO u"}])[0].grants == ( + assert ShowMixin.parse_grants([{"GRANTS": "GRANT SELECT ON *.* TO u"}])[0].grants == ( "GRANT SELECT ON *.* TO u" ) def test_grants_reads_a_row_whose_column_is_unrecognised(self): """The statement has exactly one column; its value is the grant either way.""" - assert ShowMixin._parse_grants([{"SHOW GRANTS": "GRANT ALL ON *.* TO u"}])[0].grants == ( + assert ShowMixin.parse_grants([{"SHOW GRANTS": "GRANT ALL ON *.* TO u"}])[0].grants == ( "GRANT ALL ON *.* TO u" ) def test_mysql_column_name_is_not_required(self): """MySQL's fixed ``Grants for`` is not the lookup key; the value is.""" - result = ShowMixin._parse_grants([{"Grants for root@localhost": "GRANT ALL"}]) + result = ShowMixin.parse_grants([{"Grants for root@localhost": "GRANT ALL"}]) assert result[0].grants == "GRANT ALL" def test_an_empty_row_yields_no_grant(self): - assert ShowMixin._parse_grants([{}])[0].grants is None + assert ShowMixin.parse_grants([{}])[0].grants is None class TestShowDialectRendersWhatTheServerAccepts: @@ -336,16 +336,16 @@ def dialect(self): def _columns(self, dialect, **kw): from rhosocial.activerecord.backend.impl.clickhouse.expression.show import ( - ShowColumnsExpression, + ShowColumnsStatement, ) - expr = ShowColumnsExpression(dialect, "t") - if kw.get("schema"): - expr.schema(kw["schema"]) - if kw.get("full"): - expr.full() - if kw.get("like"): - expr.like(kw["like"]) + expr = ShowColumnsStatement( + dialect, + "t", + schema=kw.get("schema"), + full=bool(kw.get("full")), + like_pattern=kw.get("like"), + ) return expr.to_sql() def test_columns_sql(self, dialect): @@ -357,38 +357,42 @@ def test_columns_sql(self, dialect): def test_index_sql_uses_one_table_reference(self, dialect): """``SHOW INDEX FROM t FROM db`` is a SYNTAX_ERROR; ``db.t`` is the form.""" from rhosocial.activerecord.backend.impl.clickhouse.expression.show import ( - ShowIndexExpression, + ShowIndexStatement, ) - expr = ShowIndexExpression(dialect, "t").schema("db") + expr = ShowIndexStatement(dialect, "t", schema="db") assert expr.to_sql() == ("SHOW INDEX FROM `db`.`t`", ()) def test_processlist_sql_never_emits_full(self, dialect): """``SHOW FULL PROCESSLIST`` is a SYNTAX_ERROR on 26.7.3.19.""" from rhosocial.activerecord.backend.impl.clickhouse.expression.show import ( - ShowProcessListExpression, + ShowProcessListStatement, ) - assert ShowProcessListExpression(dialect).full().to_sql() == ("SHOW PROCESSLIST", ()) + assert ShowProcessListStatement(dialect).to_sql() == ("SHOW PROCESSLIST", ()) + assert ShowProcessListStatement(dialect, full=True).to_sql() == ( + "SHOW PROCESSLIST", + (), + ) def test_engines_sql(self, dialect): from rhosocial.activerecord.backend.impl.clickhouse.expression.show import ( - ShowEnginesExpression, + ShowEnginesStatement, ) - assert ShowEnginesExpression(dialect).to_sql() == ("SHOW ENGINES", ()) + assert ShowEnginesStatement(dialect).to_sql() == ("SHOW ENGINES", ()) def test_grants_sql_drops_the_mysql_host_part(self, dialect): """``SHOW GRANTS FOR root@localhost`` is UNKNOWN_ROLE on 26.7.3.19.""" from rhosocial.activerecord.backend.impl.clickhouse.expression.show import ( - ShowGrantsExpression, + ShowGrantsStatement, ) - assert ShowGrantsExpression(dialect).to_sql() == ("SHOW GRANTS", ()) - assert ShowGrantsExpression(dialect).for_user("root").to_sql() == ( + assert ShowGrantsStatement(dialect).to_sql() == ("SHOW GRANTS", ()) + assert ShowGrantsStatement(dialect, user="root").to_sql() == ( "SHOW GRANTS FOR `root`", (), ) - assert ShowGrantsExpression(dialect).for_user("root", "localhost").to_sql() == ( + assert ShowGrantsStatement(dialect, user="root", host="localhost").to_sql() == ( "SHOW GRANTS FOR `root`", (), ) 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 92590a8..e116692 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 @@ -429,14 +429,14 @@ class TestClickHouseExpressionDialectSeparation: """ EXPRESSION_DIALECT_PAIRS = [ - ("ClickHouseJSONExtractExpression", "format_json_extract"), - ("ClickHouseJSONObjectExpression", "format_json_object"), - ("ClickHouseJSONArrayExpression", "format_json_array"), - ("ClickHouseJSONContainsExpression", "format_json_contains"), - ("ClickHouseDropPartitionExpression", "format_drop_partition_statement"), - ("ClickHouseDetachPartitionExpression", "format_detach_partition_statement"), - ("ClickHouseAttachPartitionExpression", "format_attach_partition_statement"), - ("ClickHouseRenameTableExpression", "format_rename_table_statement"), + ("ClickHouseJSONExtractExpression", "format_json_extract_expression"), + ("ClickHouseJSONObjectExpression", "format_json_object_expression"), + ("ClickHouseJSONArrayExpression", "format_json_array_expression"), + ("ClickHouseJSONContainsExpression", "format_json_contains_expression"), + ("ClickHouseDropPartitionStatement", "format_drop_partition_statement"), + ("ClickHouseDetachPartitionStatement", "format_detach_partition_statement"), + ("ClickHouseAttachPartitionStatement", "format_attach_partition_statement"), + ("ClickHouseRenameTableStatement", "format_rename_table_statement"), ] @pytest.mark.parametrize("expr_name,format_method", EXPRESSION_DIALECT_PAIRS) @@ -632,3 +632,43 @@ def test_mixin_public_methods_are_declared_in_protocol(self, protocol, mixin): assert not undeclared, ( f"{mixin.__name__} implements these methods not declared in {protocol.__name__}: {undeclared}" ) + + +class TestTheBackendOwnAnswersAreTheEffectiveOnes: + """A backend mixin's method must resolve to a backend class. + + The other checks in this file are about the *declaration*: the mixin + declares the method, and the protocol declares it too. This one is + about the *resolution*. A core class that wins the MRO makes the + backend's implementation dead code, and the answer a caller gets is + core's rather than the backend's -- so the backend never says what it + means about itself, and nothing else in the suite notices: the + declaration is there either way. + + A protocol class is a declaration rather than an implementation (its + body is ``...``), so a core class may legitimately precede it; only + the mixins are held to this. + """ + + def test_no_backend_method_is_shadowed_by_a_core_class(self): + mro = type(clickhouse_dialect.ClickHouseDialect()).__mro__ + own = "rhosocial.activerecord.backend.impl.clickhouse" + shadowed = [] + for klass in mro: + if not klass.__module__.startswith(own): + continue + if not klass.__name__.endswith("Mixin"): + continue + for name in vars(klass): + if not name.startswith(("format_", "supports_", "get_")): + continue + if not callable(vars(klass)[name]): + continue + winner = next((k for k in mro if name in k.__dict__), None) + if winner is None or winner.__module__.startswith(own): + continue + shadowed.append(f"{klass.__name__}.{name} -> {winner.__name__}") + assert not shadowed, ( + "a core class wins these backend methods, so the backend's " + f"implementation is dead code: {shadowed}" + ) diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/query/test_explain.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/query/test_explain.py index d188aa7..2db6b14 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/query/test_explain.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/query/test_explain.py @@ -9,7 +9,7 @@ import pytest from rhosocial.activerecord.backend.explain import SyncExplainBackendProtocol -from rhosocial.activerecord.backend.expression import RawSQLExpression +from rhosocial.activerecord.backend.expression import RawSQL from rhosocial.activerecord.backend.expression.statements import ExplainOptions from rhosocial.activerecord.backend.impl.clickhouse.explain import ( ClickHouseExplainResult, @@ -76,39 +76,39 @@ def test_sync_backend_implements_protocol(self, clickhouse_backend_single): class TestSyncExplainBasic: def test_explain_returns_clickhouse_explain_result(self, indexed_backend): dialect = indexed_backend.dialect - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = indexed_backend.explain(expr) assert isinstance(result, ClickHouseExplainResult) def test_result_has_rows(self, indexed_backend): dialect = indexed_backend.dialect - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = indexed_backend.explain(expr) assert len(result.rows) > 0 def test_result_row_type(self, indexed_backend): dialect = indexed_backend.dialect - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = indexed_backend.explain(expr) for row in result.rows: assert isinstance(row, ClickHouseExplainRow) def test_result_has_sql(self, indexed_backend): dialect = indexed_backend.dialect - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = indexed_backend.explain(expr) assert "explain_orders" in result.sql.lower() assert result.sql.upper().startswith("EXPLAIN") def test_result_has_duration(self, indexed_backend): dialect = indexed_backend.dialect - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = indexed_backend.explain(expr) assert result.duration >= 0.0 def test_result_has_raw_rows(self, indexed_backend): dialect = indexed_backend.dialect - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = indexed_backend.explain(expr) assert isinstance(result.raw_rows, list) assert len(result.raw_rows) == len(result.rows) @@ -116,7 +116,7 @@ def test_result_has_raw_rows(self, indexed_backend): def test_row_fields_present(self, indexed_backend): """Verify ClickHouseExplainRow has expected attribute names.""" dialect = indexed_backend.dialect - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = indexed_backend.explain(expr) row = result.rows[0] # All expected fields must exist (may be None for some) @@ -140,7 +140,7 @@ def test_explain_analyze(self, indexed_backend): dialect = indexed_backend.dialect if not dialect.supports_explain_analyze(): pytest.skip("ClickHouse version does not support EXPLAIN ANALYZE") - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = indexed_backend.explain(expr, ExplainOptions(analyze=True)) assert isinstance(result, ClickHouseExplainResult) assert result.sql.upper().startswith("EXPLAIN ANALYZE") 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 9f9344c..9b6c773 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 @@ -154,7 +154,7 @@ class TestClickHouseDDLFeatures: def test_materialized_view(self, clickhouse_backend, sample_table): """ClickHouse materialized views via raw SQL. - Deliberately not routed through ``CreateMaterializedViewExpression``: + Deliberately not routed through ``CreateMaterializedViewStatement``: 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 diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clause_pair_guard.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clause_pair_guard.py index 05c62ec..4ec26d4 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clause_pair_guard.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clause_pair_guard.py @@ -42,7 +42,7 @@ instead of the generic four-state check. **The four master probes core's gate round consults.** Core now consults -``supports_materialized_cte()`` in every ``format_cte_expression``, +``supports_materialized_cte()`` in every ``format_cte_clause``, ``supports_truncate()`` in ``format_truncate_statement``, and the new ``supports_with_data_clause()`` in the CTAS / CREATE MATERIALIZED VIEW / REFRESH MATERIALIZED VIEW renderers; the wait pair added to the two @@ -79,28 +79,28 @@ View, ) from rhosocial.activerecord.backend.expression.query_sources import ( - CTEExpression, - SetOperationExpression, + CTEClause, + SetOperation, ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableAsExpression, - DropTableExpression, + CreateTableAsStatement, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.statements.ddl_truncate import ( - TruncateExpression, + TruncateStatement, ) from rhosocial.activerecord.backend.expression.statements.ddl_view import ( - CreateMaterializedViewExpression, - DropMaterializedViewExpression, - DropViewExpression, - RefreshMaterializedViewExpression, + CreateMaterializedViewStatement, + DropMaterializedViewStatement, + DropViewStatement, + RefreshMaterializedViewStatement, ) from rhosocial.activerecord.backend.expression.statements.dql import ( - QueryExpression, + QueryStatement, ) from rhosocial.activerecord.backend.expression.transaction import ( - BeginTransactionExpression, - SetTransactionExpression, + BeginTransactionStatement, + SetTransactionStatement, ) from rhosocial.activerecord.backend.impl.clickhouse.dialect import ClickHouseDialect @@ -113,17 +113,16 @@ def _table(d: ClickHouseDialect, name: str = "t") -> Table: return Table(d, name) -def _query(d: ClickHouseDialect) -> QueryExpression: - return QueryExpression(d, select=[Column(d, "id")], from_=_table(d)) +def _query(d: ClickHouseDialect) -> QueryStatement: + return QueryStatement(d, select=[Column(d, "id")], from_=_table(d)) -def _union(d: ClickHouseDialect, **kw: Any) -> SetOperationExpression: - return SetOperationExpression( +def _union(d: ClickHouseDialect, op: str = "union") -> SetOperation: + return SetOperation( d, - left=QueryExpression(d, select=[Column(d, "a")], from_=_table(d, "t1")), - right=QueryExpression(d, select=[Column(d, "a")], from_=_table(d, "t2")), - operation="UNION", - **kw, + op=op, + left=QueryStatement(d, select=[Column(d, "a")], from_=_table(d, "t1")), + right=QueryStatement(d, select=[Column(d, "a")], from_=_table(d, "t2")), ) @@ -137,7 +136,7 @@ def _generic_mv_with_target(d: ClickHouseDialect, **kw: Any) -> Any: would be refused for the missing target and the pair could not be observed at all. """ - expr = CreateMaterializedViewExpression( + expr = CreateMaterializedViewStatement( d, MaterializedView(d, "mv"), _query(d), **kw ) expr.to_table = _table(d, "target") @@ -185,8 +184,8 @@ def _case( #: refusal happens before the pair is read, so no state is silently dropped. PAIR_CASES: Tuple[PairCase, ...] = ( _case( - "TruncateExpression.cascade", - lambda d, **kw: TruncateExpression(d, _table(d), **kw), + "TruncateStatement.cascade", + lambda d, **kw: TruncateStatement(d, _table(d), **kw), "cascade", "restrict", Expected(sql="TRUNCATE TABLE `t`"), @@ -194,8 +193,8 @@ def _case( Expected(refused_as="TRUNCATE ... RESTRICT"), ), _case( - "TruncateExpression.restart_identity", - lambda d, **kw: TruncateExpression(d, _table(d), **kw), + "TruncateStatement.restart_identity", + lambda d, **kw: TruncateStatement(d, _table(d), **kw), "restart_identity", "continue_identity", Expected(sql="TRUNCATE TABLE `t`"), @@ -203,8 +202,8 @@ def _case( Expected(refused_as="TRUNCATE ... CONTINUE IDENTITY"), ), _case( - "DropTableExpression.cascade", - lambda d, **kw: DropTableExpression(d, _table(d), **kw), + "DropTableStatement.cascade", + lambda d, **kw: DropTableStatement(d, _table(d), **kw), "cascade", "restrict", Expected(sql="DROP TABLE `t`"), @@ -212,8 +211,8 @@ def _case( Expected(refused_as="DROP TABLE ... RESTRICT"), ), _case( - "DropViewExpression.cascade", - lambda d, **kw: DropViewExpression(d, View(d, "v"), **kw), + "DropViewStatement.cascade", + lambda d, **kw: DropViewStatement(d, View(d, "v"), **kw), "cascade", "restrict", Expected(sql="DROP VIEW `v`"), @@ -221,8 +220,8 @@ def _case( Expected(refused_as="DROP VIEW RESTRICT"), ), _case( - "DropMaterializedViewExpression.cascade", - lambda d, **kw: DropMaterializedViewExpression( + "DropMaterializedViewStatement.cascade", + lambda d, **kw: DropMaterializedViewStatement( d, MaterializedView(d, "mv"), **kw ), "cascade", @@ -232,8 +231,8 @@ def _case( Expected(refused_as="DROP MATERIALIZED VIEW RESTRICT"), ), _case( - "RefreshMaterializedViewExpression.with_data", - lambda d, **kw: RefreshMaterializedViewExpression( + "RefreshMaterializedViewStatement.with_data", + lambda d, **kw: RefreshMaterializedViewStatement( d, MaterializedView(d, "mv"), **kw ), "with_data", @@ -243,7 +242,7 @@ def _case( Expected(refused_as="REFRESH MATERIALIZED VIEW WITH NO DATA"), ), _case( - "CreateMaterializedViewExpression.with_data", + "CreateMaterializedViewStatement.with_data", _generic_mv_with_target, "with_data", "no_data", @@ -257,8 +256,8 @@ def _case( Expected(refused_as="CREATE MATERIALIZED VIEW WITH NO DATA"), ), _case( - "CreateTableAsExpression.with_data", - lambda d, **kw: CreateTableAsExpression(d, _table(d), _query(d), **kw), + "CreateTableAsStatement.with_data", + lambda d, **kw: CreateTableAsStatement(d, _table(d), _query(d), **kw), "with_data", "no_data", Expected(sql="CREATE TABLE `t` AS SELECT `id` FROM `t`"), @@ -266,8 +265,8 @@ def _case( Expected(refused_as="CREATE TABLE ... WITH NO DATA"), ), _case( - "CTEExpression.materialized", - lambda d, **kw: CTEExpression(d, "c", _query(d), **kw), + "CTEClause.materialized", + lambda d, **kw: CTEClause(d, "c", _query(d), **kw), "materialized", "not_materialized", Expected(sql="`c` AS (SELECT `id` FROM `t`)"), @@ -278,10 +277,13 @@ def _case( PAIR_IDS = [case.case_id for case in PAIR_CASES] -#: Every consumed pair, including the set-operation qualifier that has its own -#: class (its grammar has no bare form, so its "neither" state maps to the -#: required default rather than to "nothing"). -ALL_CONSUMED_PAIRS = frozenset(PAIR_IDS) | {"SetOperationExpression.all_"} +#: Every consumed pair. The set operation is deliberately absent: its +#: qualifier is no longer a boolean pair at all. ``all_`` / ``distinct`` are +#: gone with the trailing clauses, and the ALL/DISTINCT choice is now carried by +#: the ``union`` / ``union_all`` operation word -- a closed word set the node +#: validates at construction, not two flags that could be set independently. +#: ``TestSetOperationSpelling`` guards that mapping instead. +ALL_CONSUMED_PAIRS = frozenset(PAIR_IDS) def _render(case: PairCase, **kwargs: Any) -> Tuple[str, str]: @@ -360,76 +362,66 @@ def test_all_four_outcomes_are_pairwise_distinct(self, case): ) -class TestSetOperationQualifier: - """``SetOperationExpression.all_`` / ``distinct`` -- the documented exception. +class TestSetOperationSpelling: + """The four operation words and the spelling each one gets here. ClickHouse's grammar has no bare UNION under the default ``union_default_mode`` (measured: ``Code: 558. Expected ALL or DISTINCT in - SelectWithUnion query``). The dialect therefore spells the unspecified - state with the grammar's required default, ``DISTINCT``; it does not emit - the rejected bare form and it does not refuse the ORM's default - ``union()`` path (``SetOperationQuery`` constructs the expression without - either parameter). The explicit ``distinct=True`` state renders the same - SQL: on this dialect the two requests cannot be distinguished in SQL, - because there is no second spelling for "no qualifier". This is the one - pair whose four states are not pairwise distinguishable in SQL -- the - audit's recorded reason ("ClickHouse: falsy forces DISTINCT, so - 'unspecified' is unreachable there") -- and the collision is pinned below - so it cannot change silently. + SelectWithUnion query``), so every rendering carries an explicit ALL or + DISTINCT. The node names the operation with a word and the dialect maps it, + which is what makes ``union`` and ``union_all`` distinguishable here: they + differ by the modifier, not only by the keyword. + + The former ``all_`` / ``distinct`` flag pair is gone, so the collision this + class used to record -- "unspecified" and "explicit DISTINCT" rendering + identically -- cannot occur: the word set has no duplicate member, and the + node refuses anything outside it at construction. """ - def test_neither_spells_the_grammar_required_default(self): + def test_union_spells_distinct(self): sql, _ = _union(_dialect()).to_sql() assert sql == ( "SELECT `a` FROM `t1` UNION DISTINCT SELECT `a` FROM `t2`" ), sql - def test_all_spells_all(self): - sql, _ = _union(_dialect(), all_=True).to_sql() + def test_union_all_spells_all(self): + sql, _ = _union(_dialect(), "union_all").to_sql() assert sql == "SELECT `a` FROM `t1` UNION ALL SELECT `a` FROM `t2`", sql - def test_distinct_spells_distinct(self): - sql, _ = _union(_dialect(), distinct=True).to_sql() - assert sql == ( - "SELECT `a` FROM `t1` UNION DISTINCT SELECT `a` FROM `t2`" - ), sql - - def test_both_parameters_are_api_misuse(self): - with pytest.raises( - ValueError, match="all_ and distinct are mutually exclusive" - ): - _union(_dialect(), all_=True, distinct=True) - - def test_the_default_and_explicit_distinct_collide_by_grammar(self): - """The one intentional collision: pinned, not accidental. - - If this ever stops colliding, the dialect gained a bare-UNION spelling - (e.g. a changed ``union_default_mode`` assumption) and the guard's - exception must be revisited. - """ - neither = _union(_dialect()).to_sql()[0] - explicit = _union(_dialect(), distinct=True).to_sql()[0] - assert neither == explicit + def test_the_two_union_words_are_distinguishable(self): + assert _union(_dialect()).to_sql()[0] != _union(_dialect(), "union_all").to_sql()[0] def test_intersect_and_except_keep_the_required_modifier(self): """The same required-default mapping applies to every set operation.""" d = _dialect() - for operation, expected in ( - ("INTERSECT", "INTERSECT DISTINCT"), - ("EXCEPT", "EXCEPT DISTINCT"), + for op, expected in ( + ("intersect", "INTERSECT DISTINCT"), + ("except", "EXCEPT DISTINCT"), ): - expr = SetOperationExpression( - d, - left=QueryExpression( - d, select=[Column(d, "a")], from_=_table(d, "t1") - ), - right=QueryExpression( - d, select=[Column(d, "a")], from_=_table(d, "t2") - ), - operation=operation, - ) + expr = _union(d, op) sql, _ = expr.to_sql() - assert expected in sql, (operation, sql) + assert expected in sql, (op, sql) + + def test_the_word_is_case_insensitive_and_normalized(self): + d = _dialect() + expr = _union(d, "UNION_ALL") + assert expr.op == "union_all" + assert "UNION ALL" in expr.to_sql()[0] + + def test_every_operation_word_is_covered_and_distinct(self): + """Each word renders its own SQL, and no bare form is ever emitted.""" + d = _dialect() + rendered = {} + for op in ("union", "union_all", "intersect", "except"): + sql, _ = _union(d, op).to_sql() + rendered[op] = sql + keyword = sql.split(" FROM `t1` ")[1].split(" SELECT")[0] + assert "ALL" in keyword or "DISTINCT" in keyword, (op, sql) + assert len(set(rendered.values())) == 4, rendered + + def test_an_unknown_operation_word_is_refused(self): + with pytest.raises(ValueError, match="op must be one of"): + _union(_dialect(), "minus") class TestGuardIsNotVacuous: @@ -442,16 +434,15 @@ def test_every_case_has_two_distinct_parameters(self): def test_every_pair_is_covered(self): """The pairs this dialect consumes are all in the table.""" expected = { - "TruncateExpression.cascade", - "TruncateExpression.restart_identity", - "DropTableExpression.cascade", - "DropViewExpression.cascade", - "DropMaterializedViewExpression.cascade", - "RefreshMaterializedViewExpression.with_data", - "CreateMaterializedViewExpression.with_data", - "CreateTableAsExpression.with_data", - "CTEExpression.materialized", - "SetOperationExpression.all_", + "TruncateStatement.cascade", + "TruncateStatement.restart_identity", + "DropTableStatement.cascade", + "DropViewStatement.cascade", + "DropMaterializedViewStatement.cascade", + "RefreshMaterializedViewStatement.with_data", + "CreateMaterializedViewStatement.with_data", + "CreateTableAsStatement.with_data", + "CTEClause.materialized", } assert expected == ALL_CONSUMED_PAIRS, ( f"pairs in scope but not guarded: " @@ -463,29 +454,44 @@ def test_every_pair_is_covered(self): def test_the_distinguishability_check_rejects_a_fabricated_collision(self): """A checker that cannot see a collision makes every verdict vacuous. - The fabricated case reuses the one real SQL collision this dialect has - -- the set-operation required default -- as its sentinel: two of the - three non-ValueError states render identically, so a checker that - claimed pairwise distinguishability here would be lying. + The fabricated case is built by hand rather than borrowed from the + table: its builder ignores which flag was set and renders the same SQL + for all three renderable states, refusing only the fourth. Two of the + outcomes therefore collide, so a checker that claimed pairwise + distinguishability here would be lying. (It cannot be borrowed from the + table: the set operation used to be the one real collision on this + dialect, and the word-form rewrite made it fully distinguishable.) """ + union_sql = "SELECT `a` FROM `t1` UNION DISTINCT SELECT `a` FROM `t2`" + + def _colliding(d: ClickHouseDialect, **kw: Any) -> Any: + if sum(bool(v) for v in kw.values()) > 1: + raise ValueError("a_set and b_set are mutually exclusive") + return _union(d) + fabricated = _case( "fabricated", - _union, - "all_", - "distinct", - Expected(sql="SELECT `a` FROM `t1` UNION DISTINCT SELECT `a` FROM `t2`"), - Expected(sql="SELECT `a` FROM `t1` UNION ALL SELECT `a` FROM `t2`"), - Expected(sql="SELECT `a` FROM `t1` UNION DISTINCT SELECT `a` FROM `t2`"), + _colliding, + "a_set", + "b_set", + Expected(sql=union_sql), + Expected(sql=union_sql), + Expected(sql=union_sql), ) outcomes = [ _render(fabricated), - _render(fabricated, all_=True), - _render(fabricated, distinct=True), + _render(fabricated, a_set=True), + _render(fabricated, b_set=True), ] - assert len(set(outcomes)) == 2, ( + assert len(set(outcomes)) == 1, ( "the sentinel is expected to collide; if it does not, this guard's " "distinguishability assertion is not testing what it claims" ) + both = _render(fabricated, a_set=True, b_set=True) + assert both[0] == "ValueError", both + assert len(set(outcomes) | {both}) != 4, ( + "a colliding pair must not yield four distinct outcomes" + ) class TestProbesMatchTheMeasuredGrammar: @@ -543,7 +549,7 @@ def test_materialized_cte_probe_is_true_and_materialized_renders(self): assert d.supports_materialized_cte() is True owner = d.supports_materialized_cte.__qualname__ assert owner == "ClickHouseCTEMixin.supports_materialized_cte" - sql, _params = CTEExpression(d, "c", _query(d), materialized=True).to_sql() + sql, _params = CTEClause(d, "c", _query(d), materialized=True).to_sql() assert sql == "`c` AS MATERIALIZED (SELECT `id` FROM `t`)" def test_materialized_cte_negative_spelling_is_refused_by_name(self): @@ -553,7 +559,7 @@ def test_materialized_cte_negative_spelling_is_refused_by_name(self): so the by-name refusal is the faithful outcome.""" d = _dialect() with pytest.raises(UnsupportedFeatureError) as excinfo: - CTEExpression(d, "c", _query(d), not_materialized=True).to_sql() + CTEClause(d, "c", _query(d), not_materialized=True).to_sql() assert excinfo.value.dialect_name == "ClickHouse" assert excinfo.value.feature_name == "CTE NOT MATERIALIZED" @@ -562,7 +568,7 @@ def test_truncate_probe_is_true_and_the_statement_renders(self): assert d.supports_truncate() is True owner = d.supports_truncate.__qualname__ assert owner == "ClickHouseTruncateMixin.supports_truncate" - sql, _params = TruncateExpression(d, _table(d)).to_sql() + sql, _params = TruncateStatement(d, _table(d)).to_sql() assert sql == "TRUNCATE TABLE `t`" def test_with_data_clause_is_declared_false_by_this_dialect(self): @@ -591,21 +597,21 @@ def test_with_data_gate_refusals_are_this_dialects_own(self): d = _dialect() cases = ( ( - CreateTableAsExpression(d, _table(d), _query(d), with_data=True), + CreateTableAsStatement(d, _table(d), _query(d), with_data=True), "CREATE TABLE ... WITH DATA", ), ( - CreateTableAsExpression(d, _table(d), _query(d), no_data=True), + CreateTableAsStatement(d, _table(d), _query(d), no_data=True), "CREATE TABLE ... WITH NO DATA", ), (_generic_mv_with_target(d, with_data=True), "CREATE MATERIALIZED VIEW WITH DATA"), (_generic_mv_with_target(d, no_data=True), "CREATE MATERIALIZED VIEW WITH NO DATA"), ( - RefreshMaterializedViewExpression(d, MaterializedView(d, "mv"), with_data=True), + RefreshMaterializedViewStatement(d, MaterializedView(d, "mv"), with_data=True), "REFRESH MATERIALIZED VIEW WITH DATA", ), ( - RefreshMaterializedViewExpression(d, MaterializedView(d, "mv"), no_data=True), + RefreshMaterializedViewStatement(d, MaterializedView(d, "mv"), no_data=True), "REFRESH MATERIALIZED VIEW WITH NO DATA", ), ) @@ -631,8 +637,8 @@ class TestWaitPairRefusesByName: @pytest.mark.parametrize( "expression_class", - [BeginTransactionExpression, SetTransactionExpression], - ids=["BeginTransactionExpression", "SetTransactionExpression"], + [BeginTransactionStatement, SetTransactionStatement], + ids=["BeginTransactionStatement", "SetTransactionStatement"], ) @pytest.mark.parametrize( "parameter,spelling", [("wait", "WAIT"), ("no_wait", "NO WAIT")] @@ -649,8 +655,8 @@ def test_either_spelling_refuses_by_name( @pytest.mark.parametrize( "expression_class", - [BeginTransactionExpression, SetTransactionExpression], - ids=["BeginTransactionExpression", "SetTransactionExpression"], + [BeginTransactionStatement, SetTransactionStatement], + ids=["BeginTransactionStatement", "SetTransactionStatement"], ) def test_neither_spelling_still_refuses_the_whole_statement( self, expression_class @@ -662,8 +668,8 @@ def test_neither_spelling_still_refuses_the_whole_statement( @pytest.mark.parametrize( "expression_class", - [BeginTransactionExpression, SetTransactionExpression], - ids=["BeginTransactionExpression", "SetTransactionExpression"], + [BeginTransactionStatement, SetTransactionStatement], + ids=["BeginTransactionStatement", "SetTransactionStatement"], ) def test_both_spellings_are_api_misuse(self, expression_class): d = _dialect() diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_constraint_capabilities.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_constraint_capabilities.py new file mode 100644 index 0000000..0577aa0 --- /dev/null +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_constraint_capabilities.py @@ -0,0 +1,551 @@ +# tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_constraint_capabilities.py +"""ClickHouse constraint capability switches, pinned to live measurements. + +Every value asserted here was measured against the instance +``tests/config/clickhouse_scenarios.yaml`` names ``clickhouse_http``: +**ClickHouse 26.7.3.19 (official build)**, HTTP on ``127.0.0.1:18682``, +database ``test_db``. The statements, error codes and catalog reads behind each +value are recorded in the docstrings of +``rhosocial.activerecord.backend.impl.clickhouse.mixins.constraint``; this +module pins the values and checks that they are load-bearing. + +Three guards beyond the values themselves: + +* **declared here, not inherited.** Every switch must be declared by a class in + *this* package, so a change to a core default cannot silently move + ClickHouse's contract. That matters more here than elsewhere: core's + ``ConstraintMixin`` defaults most constraint switches to ``True``, which is + the opposite of what a live ClickHouse says about almost all of them. +* **the engine has two constraint kinds, not five.** ``CHECK`` and ``ASSUME`` + are the complete set -- the parser prints ``Expected one of: CHECK, ASSUME`` + for every third kind. The table below asserts that PK / UNIQUE / FK are + ``False`` at *both* levels while table CHECK is ``True``, because a reader + who only saw the flat switch could easily assume the opposite. +* **the primary key is an engine parameter, not a constraint.** Asserted + separately because it is the one verdict that looks like a mistake. + +Not covered here, on purpose: what the *renderers* do with these values. Two +known gaps are named in the notes below rather than encoded as assertions, +because a test that pins today's wrong behaviour is worse than no test. +""" + +import re + +import pytest +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.core import Column +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AddTableConstraintAction, + AlterConstraintAction, + DropTableConstraintAction, + ValidateConstraintAction, +) +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnCheckConstraint, + ColumnPrimaryKeyConstraint, + ConstraintValidation, + TableCheckConstraint, + TableConstraint, +) + +from rhosocial.activerecord.backend.impl.clickhouse.dialect import ClickHouseDialect + +#: The exact release every verdict below was measured on. Hard-coded on +#: purpose: reading it back from the dialect would let a version bump silently +#: re-label the evidence. +MEASURED_VERSION = (26, 7, 3) + +#: A hard-coded measurement: the draft this file grew out of wrote every switch +#: against this one instance, and every switch is a statement-level fact rather +#: than a version gate, so a witness is a quoted server answer or ``accepted``. +_MEASURED_WITNESS = re.compile( + r"(26\.7\.3|Code: \d+|accepted|Expected one of)", re.IGNORECASE +) + +#: Every capability this package declares must come from this package. +BACKEND_PACKAGE = "rhosocial.activerecord.backend.impl.clickhouse." + +#: (switch name, measured value) pairs. The value is hard-coded on purpose: +#: reading it back from the dialect would make the test unable to see a +#: moved default. +#: +#: Grouped the way the engine groups them, because that grouping *is* the +#: finding: ClickHouse's column level has no constraints at all, its table +#: level has exactly one enforced kind, and the ALTER grammar carries exactly +#: three constraint commands. +MEASURED_SWITCHES = ( + # -- constraint kinds, table level ------------------------------------ + # Only CHECK is in the grammar. The other three are the "Expected one of: + # CHECK, ASSUME." rejections, at ADD CONSTRAINT time and at CREATE TABLE + # time alike. + ("supports_table_check_constraint", True), + ("supports_table_primary_key_constraint", False), + ("supports_table_unique_constraint", False), + ("supports_table_foreign_key_constraint", False), + ("supports_table_exclude_constraint", False), + # The second kind. NOT enforced -- a violating row is stored -- so it gets + # its own opt-in switch and must never share a name implying enforcement. + ("supports_assume_constraint", True), + # -- constraint kinds, flat switches ---------------------------------- + # Flat ``supports_check_constraint`` is False even though table CHECK is + # real: on this core it is the switch the *column-level* renderer + # consults, and a column cannot carry a CHECK (Code 62). See the + # docstring of ``supports_check_constraint``. + ("supports_check_constraint", False), + # The primary key is ``ENGINE = MergeTree PRIMARY KEY id ORDER BY id``. + # system.constraints holds 0 rows for a table built with ``PRIMARY KEY``; + # system.tables.primary_key / system.columns.is_in_primary_key hold it. + # The flat switch is True anyway, and that is not a transposition: on this + # core it gates the ``PRIMARY KEY`` column *modifier* (core's + # format_column_primary_key_constraint consults exactly this probe), and + # that modifier is measured accepted in the column grammar. Answering + # False would refuse to render a CREATE TABLE for any model with a + # single-column primary key -- core's model layer appends a + # ColumnPrimaryKeyConstraint to every non-composite ``__primary_key__`` + # field, so the refusal would fire on every such table. + ("supports_primary_key_constraint", True), + ("supports_unique_constraint", False), + ("supports_foreign_key_constraint", False), + ("supports_not_null_constraint", True), + # -- constraint kinds, column level ----------------------------------- + # ClickHouse constraints are TABLE-LEVEL ONLY. Both spellings -- + # ``c Int32 CHECK c > 0`` and ``c Int32 CONSTRAINT cc CHECK (c > 0)`` -- + # are Code 62, and the expected-token list the server prints is the whole + # column-modifier grammar, which contains neither CHECK nor CONSTRAINT. + ("supports_column_check_constraint", False), + ("supports_column_primary_key_constraint", False), + ("supports_column_unique_constraint", False), + ("supports_column_foreign_key_constraint", False), + ("supports_column_nullable_constraint", True), + # -- column value expressions ----------------------------------------- + # The neighbouring capability, and the one with the sharpest edge: a + # column DEFAULT accepts a comparison and silently evaluates it to 0/1, + # so ``True`` here is not licence to render one. + ("supports_default_column_value_expression", True), + ("supports_generated_column", False), + # -- FK actions and SQL-standard clauses (no FK exists to carry them) --- + ("supports_fk_on_delete", False), + ("supports_fk_on_update", False), + ("supports_fk_match", False), + ("supports_deferrable_constraint", False), + ("supports_constraint_enforced", False), + ("supports_exclude_constraint", False), + # -- ALTER TABLE constraint lifecycle ---------------------------------- + # The ALTER grammar enumerates exactly three constraint commands: ADD + # CONSTRAINT, MODIFY CONSTRAINT, DROP CONSTRAINT. Everything else is a + # Code 62 whose error message lists that set verbatim. + ("supports_add_constraint", True), + ("supports_drop_constraint", True), + ("supports_drop_constraint_if_exists", True), + ("supports_alter_constraint", False), + ("supports_alter_constraint_enforced", False), + ("supports_validate_constraint", False), + ("supports_constraint_novalidate", False), + ("supports_rename_constraint", False), + ("supports_enable_disable_constraint", False), +) + + +def _dialect(): + return ClickHouseDialect(MEASURED_VERSION) + + +def _declaring_class(switch_name): + """The first class in the dialect's MRO that declares ``switch_name``.""" + for cls in ClickHouseDialect.__mro__: + if switch_name in cls.__dict__: + return cls + raise AssertionError(f"no class in the MRO declares {switch_name}()") + + +def _predicate(d): + return ComparisonPredicate(d, "=", Column(d, "a"), Column(d, "b")) + + +def _check(d, **kwargs): + """A named table-level CHECK -- the one kind ClickHouse enforces.""" + return TableConstraint( + d, + branch=TableCheckConstraint(d, _predicate(d)), + name="ck", + **kwargs, + ) + + +class TestMeasuredConstraintSwitchValues: + @pytest.mark.parametrize( + "switch_name,expected", MEASURED_SWITCHES, ids=[s for s, _ in MEASURED_SWITCHES] + ) + def test_matches_measurement(self, switch_name, expected): + dialect = _dialect() + assert getattr(dialect, switch_name)() is expected, ( + f"{switch_name}() no longer matches the 26.7.3.19 measurement; see " + f"the docstring in mixins/constraint.py for the statement that decided it." + ) + + @pytest.mark.parametrize( + "switch_name,_expected", MEASURED_SWITCHES, ids=[s for s, _ in MEASURED_SWITCHES] + ) + def test_declared_by_this_backend(self, switch_name, _expected): + """A moved core default must not move ClickHouse's contract silently. + + Core's ``ConstraintMixin`` defaults ``supports_primary_key_constraint``, + ``supports_unique_constraint``, ``supports_check_constraint``, + ``supports_fk_match``, ``supports_deferrable_constraint``, + ``supports_constraint_enforced``, ``supports_add_constraint`` and + ``supports_drop_constraint`` to ``True``. Four of those are wrong for a + live ClickHouse, so an inherited answer is not an acceptable answer. + """ + declaring = _declaring_class(switch_name) + assert declaring.__module__.startswith(BACKEND_PACKAGE), ( + f"{switch_name} is inherited from {declaring.__module__}; declare it " + f"in this backend's mixins so the measured value is explicit." + ) + + def test_version_does_not_move_the_values(self): + """The values are statement-level facts, not version gates. + + Measured on 26.7.3.19 only. Asserting they hold on 24.x or 25.x would + be a claim this backend has not made, so the check is just that no + version branch exists for them. + """ + for version in ((24, 8, 0), MEASURED_VERSION, (27, 0, 0)): + dialect = ClickHouseDialect(version) + for switch_name, expected in MEASURED_SWITCHES: + assert ( + getattr(dialect, switch_name)() is expected + ), f"{switch_name} moved with version {version}" + + +class TestSwitchResolutionIsOwnedByThisBackend: + """The resolved answer is this backend's, not a core default reached past it. + + ``_declaring_class`` walks the MRO from the dialect outwards, so the first + class that has the name in ``__dict__`` is the one that answers. That is + the whole point of declaring locally: a later mixin in the same MRO cannot + quietly take a switch back. These guards fail the moment that happens, + rather than leaving it to be noticed in a rendering difference. + + The distinction matters more here than on most backends: core's + ``ConstraintMixin`` defaults most constraint switches to ``True``, which is + the opposite of what a live ClickHouse says about almost all of them, so an + inherited answer would not merely be unpinned -- it would be wrong. + """ + + @pytest.mark.parametrize( + "switch_name,_expected", MEASURED_SWITCHES, ids=[s for s, _ in MEASURED_SWITCHES] + ) + def test_the_declaring_class_lives_in_this_package(self, switch_name, _expected): + """The class that answers is a ClickHouse one, whatever mixin owns it. + + Not every switch belongs to ``ClickHouseConstraintMixin``: + ``supports_drop_constraint_if_exists`` is declared by the DDL-column + mixin. What matters is that the answer comes from this package and that + the class it comes from is findable, so a reader tracing the contract + knows where to look. + """ + declaring = _declaring_class(switch_name) + assert declaring.__module__.startswith(BACKEND_PACKAGE), ( + f"{switch_name} resolves to {declaring.__module__}.{declaring.__qualname__}; " + f"a core default answers first, so this backend's measured value is " + f"never consulted." + ) + + @pytest.mark.parametrize( + "switch_name,_expected", MEASURED_SWITCHES, ids=[s for s, _ in MEASURED_SWITCHES] + ) + def test_probes_take_no_argument(self, switch_name, _expected): + """A probe answers one question and takes no kind. + + The signature is the contract: core calls ``supports_*()`` with no + arguments, so a parameterised probe is either never consulted or + consulted with a guess. ``(self)`` and nothing else -- checked on the + bound method, so a defaulted parameter cannot slip back in. + """ + import inspect + + bound = getattr(_dialect(), switch_name) + assert not [ + param + for param in inspect.signature(bound).parameters.values() + if param.default is not inspect.Parameter.empty + and param.kind + not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD) + ], f"{switch_name} still takes a parameter; a probe names no kind." + + +class TestEverySwitchCarriesItsMeasurement: + """A switch whose docstring says only ``True`` is a guess with a name. + + The rule this enforces is the one the whole draft was written for: a value + is trustworthy when the statement that produced it is next to it. So each + declared switch's docstring has to quote something falsifiable -- the + measured server version, the server's own error code, or its ``accepted`` + verdict -- and a switch that starts documenting without one fails here + instead of quietly becoming a guess with a name. + """ + + @pytest.mark.parametrize( + "switch_name,_expected", MEASURED_SWITCHES, ids=[s for s, _ in MEASURED_SWITCHES] + ) + def test_docstring_quotes_the_measurement(self, switch_name, _expected): + declaring = _declaring_class(switch_name) + doc = (declaring.__dict__[switch_name].__doc__ or "").strip() + assert doc, f"{switch_name} has no docstring at all." + assert _MEASURED_WITNESS.search(doc), ( + f"{switch_name}()'s docstring quotes no measurement. Either paste the " + f"statement the value came from with the server's own answer, or say " + f"why there is no statement to quote." + ) + + def test_the_measured_engine_is_pinned_in_the_constraint_mixin(self): + """The measurement date is a constant, so docstrings and tests cannot drift. + + ``MEASURED_ENGINE`` is what the module's docstrings cite; the two must + name the same release, or the evidence stops identifying its source. + """ + from rhosocial.activerecord.backend.impl.clickhouse.mixins.constraint import ( + MEASURED_ENGINE, + ) + + assert "26.7.3" in MEASURED_ENGINE + + +class TestTheTwoConstraintKindsAreTheWholeSet: + """The finding the whole matrix hangs on, asserted on its own. + + Every rejected third kind gets the same answer from the parser, so the + switch values above are not a list of independent verdicts -- they are one + verdict (``CHECK, ASSUME``) applied five times. If core S3 ever splits these + switches per kind, this is the test that catches a kind accidentally + answering ``True``. + """ + + def test_only_check_and_assume_are_advertised_as_kinds(self): + dialect = _dialect() + advertised = { + name: getattr(dialect, name)() + for name in ( + "supports_table_check_constraint", + "supports_table_primary_key_constraint", + "supports_table_unique_constraint", + "supports_table_foreign_key_constraint", + "supports_table_exclude_constraint", + "supports_assume_constraint", + ) + } + assert advertised == { + "supports_table_check_constraint": True, + "supports_table_primary_key_constraint": False, + "supports_table_unique_constraint": False, + "supports_table_foreign_key_constraint": False, + "supports_table_exclude_constraint": False, + "supports_assume_constraint": True, + } + + def test_assume_is_not_offered_as_an_enforcing_kind(self): + """``ASSUME`` must stay separable from ``CHECK``. + + Measured: with ``ak ASSUME c > 1000`` the only constraint on a table, + ``INSERT INTO probe_t (id, c) VALUES (3, -5)`` is accepted and stored; + with ``ck CHECK c > 0`` the same INSERT is Code 469 + ``VIOLATED_CONSTRAINT``. An expression that cannot reject a row is not + a constraint, so it must never be folded into the CHECK switch -- and + ``supports_constraint_enforced`` must stay ``False`` so there is no way + to ask for "a CHECK that is not enforced" and be handed an ASSUME. + """ + dialect = _dialect() + assert dialect.supports_table_check_constraint() is True + assert dialect.supports_assume_constraint() is True + # There is no spelling that silently turns one into the other. + assert dialect.supports_constraint_enforced() is False + + +class TestPrimaryKeyIsNotAConstraint: + def test_the_constraint_claim_stays_on_the_per_level_switches(self): + """The one verdict that reads like a mistake, so it is spelled out. + + ClickHouse *has* primary keys. What it does not have is a primary-key + **constraint**: ``system.constraints`` for a table built with + ``PRIMARY KEY (id)`` measures 0 rows, ``system.tables.primary_key`` + reads ``(id)``, ``sorting_key`` reads ``id`` and + ``system.columns.is_in_primary_key`` reads 1 for ``id``. ``ADD + CONSTRAINT ... PRIMARY KEY`` is Code 62. + + The flat switch is the exception and is ``True`` on purpose: it gates + the ``PRIMARY KEY`` column *modifier*, which the server accepts, not + the constraint. See :meth:`supports_primary_key_constraint` in + ``mixins/constraint.py`` for the full measurement. + """ + dialect = _dialect() + assert dialect.supports_table_primary_key_constraint() is False + assert dialect.supports_column_primary_key_constraint() is False + + def test_primary_key_renders_the_column_modifier(self): + """The consequence of the flat ``True`` above. + + Core's ``format_column_primary_key_constraint`` consults + ``supports_primary_key_constraint()`` and, with ``True``, renders the + ``PRIMARY KEY`` column modifier -- the one spelling the server + accepts (``ALTER TABLE ... ADD COLUMN y Int32 PRIMARY KEY`` is + accepted on 26.7.3.19; the modifier is in the parser's expected-token + list for a column). + + The formatter takes the *branch* now, not the production -- core's S3 + split moved the dispatch onto the branch's own ``format_method``. + """ + d = _dialect() + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnConstraint, + ) + + branch = ColumnPrimaryKeyConstraint(d) + assert d.format_column_primary_key_constraint(branch) == (" PRIMARY KEY", ()) + sql, params = d.format_column_constraint( + ColumnConstraint(d, branch=branch, name="pk") + ) + assert "PRIMARY KEY" in sql + assert params == () + + +class TestConstraintsAreTableLevelOnly: + def test_column_check_is_refused_by_name(self): + """A column cannot carry a CHECK, in either spelling. + + The ``False`` on ``supports_check_constraint`` is what makes this + refusal happen, and it is the correct outcome even though table-level + CHECK is real (``supports_table_check_constraint`` is ``True``): both + ``c Int32 CHECK c > 0`` and ``c Int32 CONSTRAINT cc CHECK (c > 0)`` are + Code 62 on 26.7.3.19. + """ + d = _dialect() + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnConstraint, + ) + + branch = ColumnCheckConstraint(d, _predicate(d)) + with pytest.raises(UnsupportedFeatureError): + d.format_column_check_constraint(branch) + with pytest.raises(UnsupportedFeatureError): + d.format_column_constraint(ColumnConstraint(d, branch=branch)) + + +class TestDropConstraintRenders: + """The one ALTER-side operation whose renderer this backend re-spells.""" + + def test_drop_constraint_renders(self): + d = _dialect() + sql, params = DropTableConstraintAction(d, "ck").to_sql() + assert sql == "DROP CONSTRAINT `ck`" + assert params == () + + def test_drop_constraint_if_exists_is_the_idempotent_path(self): + """The plain form is Code 36; ``IF EXISTS`` is a silent no-op. + + Measured on 26.7.3.19:: + + ALTER TABLE probe_t DROP CONSTRAINT nope + -> Code: 36. DB::Exception: Wrong constraint name. + Cannot find constraint `nope` to drop. (BAD_ARGUMENTS) + ALTER TABLE probe_t DROP CONSTRAINT IF EXISTS nope + -- accepted; system.constraints unchanged + + Core both reads this switch and renders the clause, as of its + ``4875ea3b`` fix. Before that fix core read the switch but never spelled + the two words, and this backend carried an override to add them; that + override is gone, because keeping it now splices a second ``IF EXISTS`` + (``DROP CONSTRAINT IF EXISTS IF EXISTS ``). The assertion below is + therefore also the regression guard for the duplicate clause. + """ + d = _dialect() + assert d.supports_drop_constraint_if_exists() is True + assert DropTableConstraintAction(d, "ck").to_sql()[0] == "DROP CONSTRAINT `ck`" + assert ( + DropTableConstraintAction(d, "ck", if_exists=True).to_sql()[0] + == "DROP CONSTRAINT IF EXISTS `ck`" + ) + + +class TestAlterSideRefusals: + """The lifecycle verbs ClickHouse's grammar does not contain. + + All are refused by name before any SQL is produced, which is what the + ``False`` switches are for. ``AlterConstraintAction`` takes ``constraint_kind=`` + (the branch class) rather than the deleted ``constraint_type=`` tag. + + The branch class and not the SQL spelling, on this core: ``AlterConstraintAction`` + resolves a string ``constraint_kind`` by comparing it against + ``branch.__name__.replace("Table", "").upper()``, which is + ``"CHECKCONSTRAINT"``, so ``"CHECK"`` does not match and raises + ``ValueError``. That is core's business and not this repository's to fix; + the branch class is the form that works. + """ + + def test_validate_constraint_refused(self): + d = _dialect() + with pytest.raises(UnsupportedFeatureError): + ValidateConstraintAction(d, "ck").to_sql() + + def test_alter_constraint_enforced_refused(self): + d = _dialect() + action = AlterConstraintAction(d, "ck", enforced=True, constraint_kind=TableCheckConstraint) + with pytest.raises(UnsupportedFeatureError): + action.to_sql() + + def test_alter_constraint_not_enforced_refused(self): + d = _dialect() + action = AlterConstraintAction(d, "ck", not_enforced=True, constraint_kind=TableCheckConstraint) + with pytest.raises(UnsupportedFeatureError): + action.to_sql() + + def test_add_constraint_not_valid_refused(self): + d = _dialect() + action = AddTableConstraintAction(d, _check(d, validation=ConstraintValidation.NOVALIDATE)) + with pytest.raises(UnsupportedFeatureError): + action.to_sql() + + +class TestAddConstraintRenders: + """``ALTER TABLE ... ADD CONSTRAINT`` is accepted for CHECK, so it renders. + + ``supports_add_constraint()`` was ``False`` before the switch-correction pass + and is now ``True`` on the strength of two accepted statements on 26.7.3.19:: + + ALTER TABLE probe_t ADD CONSTRAINT ck CHECK c > 0 -- accepted + ALTER TABLE probe_t ADD CONSTRAINT ak ASSUME c > 1000 -- accepted + + while the ClickHouse override of ``format_add_table_constraint_action`` still + raised unconditionally with "ClickHouse does not support table constraints" -- + a message that had stopped being true. The override is gone: core's + implementation renders the production through the ClickHouse + ``format_table_constraint``, which is where the kind is narrowed. + + Only CHECK is reachable. ``ASSUME`` is not one of core's five table-constraint + branches, so it has no node and no spelling here; that is recorded in + ``supports_assume_constraint``'s docstring as a deliberate consequence, not + as a gap. + """ + + def test_add_constraint_renders_check(self): + d = _dialect() + assert d.supports_add_constraint() is True + sql, params = AddTableConstraintAction(d, _check(d)).to_sql() + assert sql == "ADD CONSTRAINT `ck` CHECK (`a` = `b`)" + assert params == () + + def test_add_constraint_refuses_unique(self): + """The table-level narrowing, reached through the ALTER path. + + ``format_table_constraint`` refuses the branch by name, so the ALTER + action inherits the refusal rather than needing its own. + """ + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + TableConstraint, + TableUniqueConstraint, + ) + + d = _dialect() + constraint = TableConstraint(d, branch=TableUniqueConstraint(d, ["a"])) + with pytest.raises(UnsupportedFeatureError): + AddTableConstraintAction(d, constraint).to_sql() diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_database_ddl.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_database_ddl.py index adebe96..c19ca58 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_database_ddl.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_database_ddl.py @@ -3,8 +3,8 @@ from rhosocial.activerecord.backend.expression.objects import Database from rhosocial.activerecord.backend.expression.statements.ddl_database import ( - CreateDatabaseExpression, - DropDatabaseExpression, + CreateDatabaseStatement, + DropDatabaseStatement, ) from rhosocial.activerecord.backend.impl.clickhouse.dialect import ClickHouseDialect @@ -21,12 +21,12 @@ def test_database_capabilities(): def test_create_database_renders(): dialect = _dialect() - sql, params = CreateDatabaseExpression(dialect, database=Database(dialect, "app")).to_sql() + sql, params = CreateDatabaseStatement(dialect, database=Database(dialect, "app")).to_sql() assert "CREATE DATABASE" in sql assert params == () def test_drop_database_renders(): dialect = _dialect() - sql, _ = DropDatabaseExpression(dialect, database=Database(dialect, "app")).to_sql() + sql, _ = DropDatabaseStatement(dialect, database=Database(dialect, "app")).to_sql() assert "DROP DATABASE" in sql \ No newline at end of file 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 1f73b52..6fc35d5 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 @@ -6,9 +6,9 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression import ( Column, - CreateTableExpression, - CreateViewExpression, - QueryExpression, + CreateTableStatement, + CreateViewStatement, + QueryStatement, ) from rhosocial.activerecord.backend.expression.objects import Table, View from rhosocial.activerecord.backend.expression.sources import NamedRelationRef @@ -27,10 +27,10 @@ class TestClickHouseViewCapabilityGating: def test_create_view_check_option_gated(self): """WITH CHECK OPTION must fail fast when the capability is off.""" dialect = ClickHouseDialect() - query = QueryExpression( + query = QueryStatement( dialect, select=[Column(dialect, "id")], from_=NamedRelationRef(dialect, Table(dialect, "t")) ) - expr = CreateViewExpression( + expr = CreateViewStatement( dialect, view=View(dialect, "v"), query=query, @@ -78,7 +78,7 @@ def test_schema_ddl_probes_absent(self): class TestClickHouseTableDeclarationGating: def test_table_declaration_defaults_are_absent(self): dialect = ClickHouseDialect(version=(26, 7, 3)) - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, Table(dialect, "plain_table_defaults"), [ColumnDefinition(dialect, "id", IntegerType(dialect))], @@ -93,7 +93,7 @@ def test_table_declaration_defaults_are_absent(self): def test_table_inherits_is_propagated_and_rejected(self): dialect = ClickHouseDialect(version=(26, 7, 3)) assert dialect.supports_table_inheritance() is False - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, Table(dialect, "inherited"), [ColumnDefinition(dialect, "id", IntegerType(dialect))], @@ -106,7 +106,7 @@ def test_table_inherits_is_propagated_and_rejected(self): def test_table_tablespace_is_propagated_and_rejected(self): dialect = ClickHouseDialect(version=(26, 7, 3)) assert dialect.supports_table_tablespace() is False - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, Table(dialect, "tablespaced"), [ColumnDefinition(dialect, "id", IntegerType(dialect))], diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_wildcards.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_wildcards.py new file mode 100644 index 0000000..40a7e7c --- /dev/null +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_wildcards.py @@ -0,0 +1,194 @@ +# tests/rhosocial/activerecord_clickhouse_test/feature/backend/test_clickhouse_wildcards.py +"""ClickHouse's wildcard positions, in the two contracts core split apart. + +Core split the asterisk by grammar position (spec 4.10): the select-list ``*`` +expands columns and belongs to ``AllColumnsWildcard``, while ``COUNT(*)``'s +asterisk counts rows and belongs to ``RowCountWildcard``. Each position names +its own formatter, so a dialect overrides the one whose *spelling* differs. + +ClickHouse differs in the row-count position only, and in one way: +``COUNT(DISTINCT *)`` counts the distinct combinations of every column's value +where ``COUNT(*)`` counts the rows. Of the eleven backends the wildcard survey +measured, only ClickHouse accepts it, so it is this backend's own node +(:class:`ClickHouseRowCountDistinctWildcard`) and this backend's own +``format_row_count_wildcard`` override. + +**Evidence level: re-measured on the scenario server.** +``tests/config/clickhouse_scenarios.yaml`` names ``clickhouse_http`` +(**ClickHouse 26.7.3.19**, HTTP on ``127.0.0.1:18682``, database +``test_db``):: + + CREATE TABLE probe_distinct_star (a Int32, b Int32) + ENGINE = MergeTree ORDER BY (a, b) + INSERT INTO probe_distinct_star VALUES (1, 1), (1, 1), (1, 2), (2, 2) + SELECT count() FROM probe_distinct_star -- 4 + SELECT count(DISTINCT *) FROM probe_distinct_star -- 3, answered as + -- countDistinct(a, b) + +so the spelling is accepted and the semantics are what this file says: the +distinct combinations of every column's value where ``COUNT(*)`` counts the +rows. The assertions below verify the *rendering contract* -- which node +renders which words. +""" + +import pytest + +from rhosocial.activerecord.backend.expression import ( + AllColumnsWildcard, + Column, + RowCountWildcard, +) +from rhosocial.activerecord.backend.expression.core import FunctionCall +from rhosocial.activerecord.backend.expression.functions.aggregate import count, sum_ +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.impl.clickhouse.dialect import ClickHouseDialect +from rhosocial.activerecord.backend.impl.clickhouse.mixins.wildcard import ( + ClickHouseWildcardMixin, +) + + +@pytest.fixture +def dialect(): + return ClickHouseDialect() + + +class TestAllColumnsPosition: + """The select-list ``*`` is core's verbatim, so nothing is overridden.""" + + def test_bare_asterisk(self, dialect): + assert AllColumnsWildcard(dialect).to_sql() == ("*", ()) + + def test_qualified_asterisk_uses_the_query_s_spelling(self, dialect): + """The qualifier is the name the query itself uses -- core's rule.""" + wc = AllColumnsWildcard( + dialect, + relation=NamedRelationRef(dialect, Table(dialect, "users"), alias="u"), + ) + assert wc.to_sql() == ("`u`.*", ()) + + def test_clickhouse_does_not_override_the_all_columns_formatter(self): + """ClickHouse spells ``*`` / ``rel.*`` exactly as core does. + + Asserted on the class rather than by rendering, because the point is + that no override exists: a future override would make the two spellings + need keeping in step, and this test is where that decision would have to + be recorded. + """ + assert "format_all_columns_wildcard" not in ClickHouseWildcardMixin.__dict__ + + +class TestRowCountPosition: + """The row-count position is the one ClickHouse spells differently.""" + + def test_bare_asterisk_is_core_s(self, dialect): + """``COUNT(*)`` stays core's bare asterisk -- the override delegates.""" + assert RowCountWildcard(dialect).to_sql() == ("*", ()) + + def test_count_of_the_bare_asterisk_is_unchanged(self, dialect): + assert count(dialect, "*").to_sql() == ("COUNT(*)", ()) + + def test_distinct_asterisk_is_this_backend_s_node(self, dialect): + """``COUNT(DISTINCT *)`` is ClickHouse-only, so it is ClickHouse's node.""" + from rhosocial.activerecord.backend.impl.clickhouse.expression import ( + ClickHouseRowCountDistinctWildcard, + ) + + wc = ClickHouseRowCountDistinctWildcard(dialect) + assert wc.to_sql() == ("DISTINCT *", ()) + + def test_the_distinct_spelling_reaches_the_aggregate(self, dialect): + """The full statement, through the operand the aggregate carries. + + Core ruled that ``DISTINCT`` is not a modifier the row-count wildcard + can take: ``count(dialect, "*", is_distinct=True)`` is refused at + construction, because the bare asterisk counts rows and rows have no + duplicates. The extension therefore reaches the statement as a + different *operand* -- this backend's own node -- and the classed + ``COUNT`` entry point consults it rather than answering every + row-count wildcard with the bare asterisk. + """ + from rhosocial.activerecord.backend.impl.clickhouse.expression import ( + ClickHouseRowCountDistinctWildcard, + ) + + assert count( + dialect, ClickHouseRowCountDistinctWildcard(dialect) + ).to_sql() == ("COUNT(DISTINCT *)", ()) + assert count( + dialect, ClickHouseRowCountDistinctWildcard(dialect), alias="n" + ).to_sql() == ("COUNT(DISTINCT *) AS `n`", ()) + + def test_the_modifier_path_is_refused_at_construction(self, dialect): + """The pre-classing path -- ``DISTINCT`` as a keyword on the plain + row-count wildcard -- is refused by the core, and this backend does + not reopen it: the engine's extension is a different operand, not a + modifier on the count.""" + with pytest.raises(ValueError, match="no duplicates"): + count(dialect, "*", is_distinct=True) + + def test_the_node_is_a_row_count_wildcard(self, dialect): + """It extends core's node, so it is admissible wherever that one is.""" + from rhosocial.activerecord.backend.impl.clickhouse.expression import ( + ClickHouseRowCountDistinctWildcard, + ) + + assert issubclass(ClickHouseRowCountDistinctWildcard, RowCountWildcard) + + def test_the_override_is_reached_before_core(self): + """The mixin must precede ``ExpressionMixin`` in the MRO. + + C3 gives the earlier name in the dialect's base list priority. Listed + after ``ExpressionMixin``, this override would never be consulted: the + node would still render, as core's bare ``*``, and nothing about the SQL + would look wrong until the server returned a different number. + """ + from rhosocial.activerecord.backend.dialect.mixins.expression import ( + ExpressionMixin, + ) + + mro = ClickHouseDialect.__mro__ + assert mro.index(ClickHouseWildcardMixin) < mro.index(ExpressionMixin) + + +class TestTheCountContractIsUnchanged: + """Core's aggregate contract, asserted here because it is what the node relies on. + + ``RowCountWildcard`` -- and so its ClickHouse derivative -- is admissible + only as ``COUNT``'s argument: ``COUNT`` refuses the all-columns wildcard + (it expands columns rather than counting rows) and the other aggregates + refuse every wildcard. None of that is this backend's to change; the + assertions record the contract the node is built against. + """ + + def test_count_refuses_the_all_columns_wildcard(self, dialect): + with pytest.raises(ValueError, match="all-columns wildcard"): + count(dialect, AllColumnsWildcard(dialect)) + + @pytest.mark.parametrize("factory", [sum_]) + def test_the_other_aggregates_refuse_a_row_count_wildcard(self, dialect, factory): + with pytest.raises(ValueError, match="row-count wildcard"): + factory(dialect, RowCountWildcard(dialect)) + + def test_count_accepts_a_column(self, dialect): + assert count(dialect, Column(dialect, "a")).to_sql() == ("COUNT(`a`)", ()) + + def test_a_plain_function_call_can_hold_the_node(self, dialect): + """The node renders wherever a value is expected, not only via ``count``. + + The plain ``FunctionCall`` is the user-defined-function escape hatch + (core keeps the classed nodes for the standard functions), and the + shared call formatter answers a row-count wildcard with the bare + asterisk -- so this is not the spelling's entry point: the classed + ``COUNT`` above is, and the distinct answer reaches a statement only + through it. What this pins is that the UDF path stays honest about + what core renders rather than growing a backend special case. + """ + from rhosocial.activerecord.backend.impl.clickhouse.expression import ( + ClickHouseRowCountDistinctWildcard, + ) + + call = FunctionCall( + dialect, "COUNT", ClickHouseRowCountDistinctWildcard(dialect) + ) + assert call.to_sql() == ("COUNT(*)", ()) diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/transactions/test_transaction_backend.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/transactions/test_transaction_backend.py index 23dfc66..f8d7660 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/transactions/test_transaction_backend.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/transactions/test_transaction_backend.py @@ -64,8 +64,8 @@ def test_dialect_transaction_sql_raises(self, clickhouse_backend): """Transaction SQL generation must raise UnsupportedFeatureError.""" d = clickhouse_backend.dialect with pytest.raises(UnsupportedFeatureError): - from rhosocial.activerecord.backend.expression import BeginTransactionExpression - d.format_begin_transaction(BeginTransactionExpression(d)) + from rhosocial.activerecord.backend.expression import BeginTransactionStatement + d.format_begin_transaction_statement(BeginTransactionStatement(d)) def test_normal_operations_still_work(self, clickhouse_backend, test_table): """Non-transactional DML works normally.""" diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/types/test_clickhouse_type_protocol.py b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/types/test_clickhouse_type_protocol.py index 981f623..4b0c762 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/backend/types/test_clickhouse_type_protocol.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/backend/types/test_clickhouse_type_protocol.py @@ -940,7 +940,7 @@ def test_a_spelling_outside_the_list_is_refused_by_name(self, concept): def test_clickhouse_spells_all_of_a_concepts_spellings(self): """``accepted`` may be an explicit tuple for a backend that spells only some of a concept. ClickHouse spells all of them, so the concept class is - passed to ``_check_spelling`` — asserted here so narrowing any one of them + passed to ``check_spelling`` — asserted here so narrowing any one of them has to be deliberate rather than a copy-paste slip.""" dialect = ClickHouseDialect() # ``clob`` is not a ClickHouse word, yet it is accepted and rendered @@ -1201,14 +1201,14 @@ def test_format_unsupported_type_raises(self): from rhosocial.activerecord.backend.expression.types import CustomType dialect = ClickHouseDialect() with pytest.raises(TypeError, match="does not support the generic type 'custom'"): - CustomType(dialect, "SomeTypeWeDoNotKnow").to_sql() + CustomType(dialect, raw="SomeTypeWeDoNotKnow").to_sql() def test_format_unknown_type_error_names_the_substitute(self): """The message has to say what to use, not just that nothing is there.""" from rhosocial.activerecord.backend.expression.types import CustomType dialect = ClickHouseDialect() with pytest.raises(TypeError, match="ClickHouseStringType"): - CustomType(dialect, "SomeTypeWeDoNotKnow").to_sql() + CustomType(dialect, raw="SomeTypeWeDoNotKnow").to_sql() # ── ``unsigned`` is refused, not dropped ───────────────────────────── @@ -1855,7 +1855,6 @@ class TestGeometryCollectionIsNotTheGenericGeometry: def test_a_collection_is_not_the_generic_geometry_type(self): from rhosocial.activerecord.backend.impl.clickhouse.expression.types import ( ClickHouseGeometryCollectionType, - ClickHouseGeometryType, ) dialect = ClickHouseDialect() @@ -2021,18 +2020,32 @@ class TestParseRoundTripSweep: @pytest.fixture(scope="class") def registry(self): - """The round-trip module's registry, loaded from the parent - - directory. Executing it also registers its special constructors, - which the sweep's ``make_instance`` consults -- the same - registrations the full suite performs at collection time. + """The round-trip module's registry, loaded from the expression area. + + Executing it also registers its special constructors, which the sweep's + ``make_instance`` consults -- the same registrations the full suite + performs at collection time. The matrix moved under + ``feature/backend/expression/`` when this file's neighbours were + gathered there (the literal spellings sit beside it), so the path is + spelled as ``parent.parent / "expression"`` rather than a file name in + this directory's parent. """ import importlib.util + import sys from pathlib import Path - path = Path(__file__).resolve().parent.parent / ( - "test_expression_roundtrip_all.py" + path = ( + Path(__file__).resolve().parent.parent + / "expression" + / "test_expression_roundtrip_all.py" ) + # The matrix imports its harness by bare name, which works under pytest + # because collection puts the test file's own directory on sys.path. + # Loading it by file spec from here does not, so the directory is put on + # the path explicitly -- the same effect, without depending on which + # directory pytest happens to be collecting. + if str(path.parent) not in sys.path: + sys.path.insert(0, str(path.parent)) spec = importlib.util.spec_from_file_location( "clickhouse_rt_registry", path, @@ -2044,7 +2057,10 @@ def registry(self): return registry def test_every_rendered_type_parses_back_coherently(self, registry): - from rhosocial.activerecord.testsuite.utils.parse_contract import ( + # The sweep is vendored beside the matrix (the testsuite deleted its + # shared harness with round-trip coverage moving to each backend); + # make_instance is the same function the matrix registers with. + from expression_roundtrip_harness import ( parse_roundtrip_failures, ) diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/basic/fields/test_derived_field.py b/tests/rhosocial/activerecord_clickhouse_test/feature/basic/fields/test_derived_field.py index 40ecb63..9a14978 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/basic/fields/test_derived_field.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/basic/fields/test_derived_field.py @@ -6,7 +6,7 @@ from rhosocial.activerecord.base import DerivedField, UseColumn from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.backend.expression import Column, Literal -from rhosocial.activerecord.backend.expression.operators import BinaryArithmeticExpression +from rhosocial.activerecord.backend.expression.operators import ArithmeticOperation class TestDerivedFieldDeclaration: @@ -21,8 +21,8 @@ def test_descriptor_class_access(self, product_class): def test_derived_field_with_static_expression(self, product_class): """Test DerivedField constructed with a pre-built expression object (non-callable path).""" dialect = product_class.backend().dialect - static_expr = BinaryArithmeticExpression( - dialect, "*", Column(dialect, "price"), Literal(dialect, 2) + static_expr = ArithmeticOperation( + dialect, "mul", Column(dialect, "price"), Literal(dialect, 2) ) df = DerivedField(static_expr) resolved = df.resolve(dialect) @@ -141,8 +141,8 @@ def test_derived_dict_with_lambda(self, product_class): self._insert(product_class, "D1", 100.0, 4) results = product_class.find_all( derived={ - "my_discount": lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 0.8) + "my_discount": lambda d: ArithmeticOperation( + d, "mul", Column(d, "price"), Literal(d, 0.8) ) } ) @@ -160,7 +160,7 @@ def test_derived_dict_with_derived_field_instance(self, product_class): def test_derived_dict_with_expression_object(self, product_class): self._insert(product_class, "D3", 80.0, 5) dialect = product_class.backend().dialect - expr = BinaryArithmeticExpression(dialect, "*", Column(dialect, "price"), Literal(dialect, 0.5)) + expr = ArithmeticOperation(dialect, "mul", Column(dialect, "price"), Literal(dialect, 0.5)) results = product_class.find_all( derived={"half_price": expr} ) @@ -193,8 +193,8 @@ def test_extra_derived_basic(self, product_class): self._insert(product_class, "F", 100.0, 10) results = product_class.find_all( extra_derived={ - "triple_price": lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 3) + "triple_price": lambda d: ArithmeticOperation( + d, "mul", Column(d, "price"), Literal(d, 3) ) } ) @@ -206,8 +206,8 @@ def test_extra_derived_conflict_raises(self, product_class): with pytest.raises(ValueError, match="conflicts with a declared derived field"): product_class.find_all( extra_derived={ - "discounted_price": lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 0.5) + "discounted_price": lambda d: ArithmeticOperation( + d, "mul", Column(d, "price"), Literal(d, 0.5) ) } ) @@ -217,8 +217,8 @@ def test_derived_and_extra_derived_together(self, product_class): results = product_class.find_all( derived=True, extra_derived={ - "double_qty": lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "quantity"), Literal(d, 2) + "double_qty": lambda d: ArithmeticOperation( + d, "mul", Column(d, "quantity"), Literal(d, 2) ) } ) @@ -334,8 +334,8 @@ class ConflictModel(ActiveRecord): id: Optional[int] = None discount_rate: Annotated[float, UseColumn("disc")] discounted: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 0.9) + lambda d: ArithmeticOperation( + d, "mul", Column(d, "price"), Literal(d, 0.9) ), ), UseColumn("disc")]] @@ -346,8 +346,8 @@ class NoConflict(ActiveRecord): id: Optional[int] = None discount_rate: Annotated[float, UseColumn("rate")] discounted: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 0.9) + lambda d: ArithmeticOperation( + d, "mul", Column(d, "price"), Literal(d, 0.9) ), ), UseColumn("disc")]] diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/basic/fields/test_derived_field_async.py b/tests/rhosocial/activerecord_clickhouse_test/feature/basic/fields/test_derived_field_async.py index d205397..a068eb0 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/basic/fields/test_derived_field_async.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/basic/fields/test_derived_field_async.py @@ -6,7 +6,7 @@ from rhosocial.activerecord.base import DerivedField, UseColumn from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.backend.expression import Column, Literal -from rhosocial.activerecord.backend.expression.operators import BinaryArithmeticExpression +from rhosocial.activerecord.backend.expression.operators import ArithmeticOperation class TestAsyncDerivedFieldDeclaration: @@ -21,8 +21,8 @@ async def test_descriptor_class_access(self, async_product_class): async def test_derived_field_with_static_expression(self, async_product_class): """Test DerivedField constructed with a pre-built expression object (non-callable path).""" dialect = async_product_class.backend().dialect - static_expr = BinaryArithmeticExpression( - dialect, "*", Column(dialect, "price"), Literal(dialect, 2) + static_expr = ArithmeticOperation( + dialect, "mul", Column(dialect, "price"), Literal(dialect, 2) ) df = DerivedField(static_expr) resolved = df.resolve(dialect) @@ -141,8 +141,8 @@ async def test_derived_dict_with_lambda(self, async_product_class): await self._insert(async_product_class, "D1", 100.0, 4) results = await async_product_class.find_all( derived={ - "my_discount": lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 0.8) + "my_discount": lambda d: ArithmeticOperation( + d, "mul", Column(d, "price"), Literal(d, 0.8) ) } ) @@ -160,7 +160,7 @@ async def test_derived_dict_with_derived_field_instance(self, async_product_clas async def test_derived_dict_with_expression_object(self, async_product_class): await self._insert(async_product_class, "D3", 80.0, 5) dialect = async_product_class.backend().dialect - expr = BinaryArithmeticExpression(dialect, "*", Column(dialect, "price"), Literal(dialect, 0.5)) + expr = ArithmeticOperation(dialect, "mul", Column(dialect, "price"), Literal(dialect, 0.5)) results = await async_product_class.find_all( derived={"half_price": expr} ) @@ -193,8 +193,8 @@ async def test_extra_derived_basic(self, async_product_class): await self._insert(async_product_class, "F", 100.0, 10) results = await async_product_class.find_all( extra_derived={ - "triple_price": lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 3) + "triple_price": lambda d: ArithmeticOperation( + d, "mul", Column(d, "price"), Literal(d, 3) ) } ) @@ -206,8 +206,8 @@ async def test_extra_derived_conflict_raises(self, async_product_class): with pytest.raises(ValueError, match="conflicts with a declared derived field"): await async_product_class.find_all( extra_derived={ - "discounted_price": lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 0.5) + "discounted_price": lambda d: ArithmeticOperation( + d, "mul", Column(d, "price"), Literal(d, 0.5) ) } ) @@ -217,8 +217,8 @@ async def test_derived_and_extra_derived_together(self, async_product_class): results = await async_product_class.find_all( derived=True, extra_derived={ - "double_qty": lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "quantity"), Literal(d, 2) + "double_qty": lambda d: ArithmeticOperation( + d, "mul", Column(d, "quantity"), Literal(d, 2) ) } ) @@ -334,8 +334,8 @@ class ConflictModel(ActiveRecord): id: Optional[int] = None discount_rate: Annotated[float, UseColumn("disc")] discounted: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 0.9) + lambda d: ArithmeticOperation( + d, "mul", Column(d, "price"), Literal(d, 0.9) ), ), UseColumn("disc")]] @@ -346,8 +346,8 @@ class NoConflict(ActiveRecord): id: Optional[int] = None discount_rate: Annotated[float, UseColumn("rate")] discounted: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 0.9) + lambda d: ArithmeticOperation( + d, "mul", Column(d, "price"), Literal(d, 0.9) ), ), UseColumn("disc")]] 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 93188d6..74434f6 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/basic/fixtures/models.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/basic/fixtures/models.py @@ -488,7 +488,7 @@ class IntToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(str, int) # Python 'str' can be converted to DB 'int' + self.register_type(str, int) # Python 'str' can be converted to DB 'int' def _do_to_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Any: # The 'notes' attribute on the model is a str, which we convert to an int for the DB. @@ -532,7 +532,7 @@ class ListToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(list, str) + self.register_type(list, str) def _do_to_database(self, value: List[str], target_type: Type, options: Optional[Dict[str, Any]] = None) -> Optional[str]: if value is None: @@ -589,7 +589,7 @@ class JsonToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(dict, str) + self.register_type(dict, str) def _do_to_database(self, value: Dict, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Optional[str]: if value is None: @@ -767,7 +767,7 @@ class AsyncMappedOrderItem(CompositePKMixin, AsyncActiveRecord): # ============================================================================= from rhosocial.activerecord.base import DerivedField -from rhosocial.activerecord.backend.expression import Column, Literal, BinaryArithmeticExpression +from rhosocial.activerecord.backend.expression import Column, Literal, ArithmeticOperation class Product(ActiveRecord): @@ -779,12 +779,12 @@ class Product(ActiveRecord): quantity: int discounted_price: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Literal(d, 0.9)), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.9)), )]] total_value: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Column(d, "quantity") + lambda d: ArithmeticOperation( + d, "mul", Column(d, "price"), Column(d, "quantity") ), )]] @@ -798,12 +798,12 @@ class AsyncProduct(AsyncActiveRecord): quantity: int discounted_price: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Literal(d, 0.9)), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.9)), )]] total_value: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Column(d, "quantity") + lambda d: ArithmeticOperation( + d, "mul", Column(d, "price"), Column(d, "quantity") ), )]] @@ -818,12 +818,12 @@ class ProductFormA(ActiveRecord): quantity: int discounted_price: ClassVar[DerivedField] = DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Literal(d, 0.9)), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.9)), ) total_value: ClassVar[DerivedField] = DerivedField( - lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Column(d, "quantity") + lambda d: ArithmeticOperation( + d, "mul", Column(d, "price"), Column(d, "quantity") ), ) @@ -837,12 +837,12 @@ class AsyncProductFormA(AsyncActiveRecord): quantity: int discounted_price: ClassVar[DerivedField] = DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Literal(d, 0.9)), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.9)), ) total_value: ClassVar[DerivedField] = DerivedField( - lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Column(d, "quantity") + lambda d: ArithmeticOperation( + d, "mul", Column(d, "price"), Column(d, "quantity") ), ) @@ -908,12 +908,12 @@ class ProductWithColumnAndAdapter(ActiveRecord): quantity: int discounted_price: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Literal(d, 0.9)), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.9)), ), UseColumn("disc")]] total_int: ClassVar[Annotated[int, DerivedField( - lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Column(d, "quantity") + lambda d: ArithmeticOperation( + d, "mul", Column(d, "price"), Column(d, "quantity") ), ), UseAdapter(PriceToIntAdapter(), int)]] @@ -927,12 +927,12 @@ class AsyncProductWithColumnAndAdapter(AsyncActiveRecord): quantity: int discounted_price: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Literal(d, 0.9)), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.9)), ), UseColumn("disc")]] total_int: ClassVar[Annotated[int, DerivedField( - lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Column(d, "quantity") + lambda d: ArithmeticOperation( + d, "mul", Column(d, "price"), Column(d, "quantity") ), ), UseAdapter(PriceToIntAdapter(), int)]] 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 900034b..3a7b604 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 @@ -527,7 +527,7 @@ class IntToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(str, int) + self.register_type(str, int) def _do_to_database(self, value: Any, target_type: Type, options: Dict[str, Any] | None = None) -> Any: return int(value) @@ -572,7 +572,7 @@ class ListToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(list, str) + self.register_type(list, str) def _do_to_database(self, value: List[str], target_type: Type, options: Dict[str, Any] | None = None) -> str | None: if value is None: @@ -618,7 +618,7 @@ class JsonToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(dict, str) + self.register_type(dict, str) def _do_to_database(self, value: Dict, target_type: Type, options: Dict[str, Any] | None = None) -> str | None: if value is 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 c348093..5a6f600 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 @@ -515,7 +515,7 @@ class IntToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(str, int) + self.register_type(str, int) def _do_to_database(self, value: Any, target_type: Type, options: Dict[str, Any] | None = None) -> Any: return int(value) @@ -556,7 +556,7 @@ class ListToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(list, str) + self.register_type(list, str) def _do_to_database(self, value: List[str], target_type: Type, options: Dict[str, Any] | None = None) -> str | None: if value is None: @@ -602,7 +602,7 @@ class JsonToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(dict, str) + self.register_type(dict, str) def _do_to_database(self, value: Dict, target_type: Type, options: Dict[str, Any] | None = None) -> str | None: if value is 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 d0e8ce2..f0f74f5 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 @@ -502,7 +502,7 @@ class IntToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(str, int) + self.register_type(str, int) def _do_to_database(self, value: Any, target_type: Type, options: Dict[str, Any] | None = None) -> Any: return int(value) @@ -543,7 +543,7 @@ class ListToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(list, str) + self.register_type(list, str) def _do_to_database(self, value: List[str], target_type: Type, options: Dict[str, Any] | None = None) -> str | None: if value is None: @@ -589,7 +589,7 @@ class JsonToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(dict, str) + self.register_type(dict, str) def _do_to_database(self, value: Dict, target_type: Type, options: Dict[str, Any] | None = None) -> str | None: if value is None: diff --git a/tests/rhosocial/activerecord_clickhouse_test/feature/relation/fixtures/models.py b/tests/rhosocial/activerecord_clickhouse_test/feature/relation/fixtures/models.py index 2ae695e..d975fed 100644 --- a/tests/rhosocial/activerecord_clickhouse_test/feature/relation/fixtures/models.py +++ b/tests/rhosocial/activerecord_clickhouse_test/feature/relation/fixtures/models.py @@ -14,7 +14,7 @@ from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord from rhosocial.activerecord.base import DerivedField, FieldProxy from rhosocial.activerecord.backend.expression import Column, Literal -from rhosocial.activerecord.backend.expression.operators import BinaryArithmeticExpression +from rhosocial.activerecord.backend.expression.operators import ArithmeticOperation from rhosocial.activerecord.backend.expression.functions import ( json_extract_text, length, concat, coalesce ) @@ -160,7 +160,7 @@ class Post(ActiveRecord): # DerivedField: hotness score hotness: ClassVar[Annotated[int, DerivedField( - lambda d: BinaryArithmeticExpression(d, "+", Column(d, "view_count"), Literal(d, 1)), + lambda d: ArithmeticOperation(d, "add", Column(d, "view_count"), Literal(d, 1)), )]] # DerivedField (JSON): first tag from metadata @@ -255,7 +255,7 @@ class AsyncPost(AsyncActiveRecord): )]] hotness: ClassVar[Annotated[int, DerivedField( - lambda d: BinaryArithmeticExpression(d, "+", Column(d, "view_count"), Literal(d, 1)), + lambda d: ArithmeticOperation(d, "add", Column(d, "view_count"), Literal(d, 1)), )]] first_tag: ClassVar[Annotated[str, DerivedField(