ClickHouse support¶
alchemiq provides a first-class ClickHouse integration under the [clickhouse]
extra: an annotation-first model base, three MergeTree-family engine descriptors,
a typed repository, buffered inserts, and a custom migration runner (Alembic does
not support ClickHouse).
pip install "alchemiq[clickhouse]"
ClickHouse and PostgreSQL have separate SQLAlchemy metadata and mapper registries, so the two never interfere.
Declaring a model¶
Subclass ClickHouseModel and set Meta.engine to one of the three
supported engine descriptors. order_by is required - ClickHouse uses the
ORDER BY key for sorting and (in ReplacingMergeTree) for deduplication:
import datetime as dt
from alchemiq.clickhouse import ClickHouseModel, MergeTree, ReplacingMergeTree
from alchemiq.clickhouse.types import DateTime64, UInt32
class PageView(ClickHouseModel):
event_time: dt.datetime = DateTime64(3)
user_id: int = UInt32()
class Meta:
engine = MergeTree(order_by=("event_time", "user_id"))
Engine descriptors¶
Engine |
Use case |
|---|---|
|
General-purpose append-only analytics |
|
Deduplication by ORDER BY key; required for soft-delete |
|
Aggregate-function columns |
All three share the same keyword arguments:
Parameter |
Description |
|---|---|
|
Tuple of column names or a SQL expression string (required) |
|
Optional partition expression |
|
Optional PRIMARY KEY (subset of ORDER BY) |
|
Optional TTL expression, e.g. |
|
Optional SAMPLE BY expression |
|
Optional dict of ENGINE-level settings |
ReplacingMergeTree also accepts version (column name for dedup version)
and is_deleted (column name for tombstone flag).
Soft-delete models¶
Set Meta.soft_delete = True with ReplacingMergeTree. alchemiq injects
is_deleted, _version, and deleted_at columns automatically:
class Document(ClickHouseModel):
key: int = UInt32()
body: str
class Meta:
soft_delete = True
engine = ReplacingMergeTree(order_by=("key",))
SELECT ... FINAL retains the row with the highest _version and filters
out rows where is_deleted=1. Use repo.with_deleted() or
repo.only_deleted() to bypass the filter.
Creating and dropping tables¶
from alchemiq.clickhouse import create_clickhouse_tables, drop_clickhouse_tables
await create_clickhouse_tables() # CREATE TABLE IF NOT EXISTS for every model
await drop_clickhouse_tables() # DROP TABLE IF EXISTS (reverse order)
ClickHouseRepository¶
ClickHouseRepository is the data-access surface for one ClickHouse model.
Instantiate it directly or subclass with a type parameter:
from alchemiq.clickhouse import ClickHouseRepository
# direct
repo = ClickHouseRepository(PageView)
await repo.insert(PageView(event_time=dt.datetime.now(dt.UTC), user_id=42))
# typed subclass
class PageViewRepo(ClickHouseRepository[PageView]):
pass
rows = await PageViewRepo().filter(user_id=42).order_by("event_time").all()
Filtering¶
Q objects work the same way as for PostgreSQL:
from alchemiq import Q
rows = await repo.filter(Q(user_id__gt=100)).order_by("-event_time").limit(50).all()
Insert methods¶
Method |
Description |
|---|---|
|
Insert one or more model instances immediately |
|
Optimised batch insert |
|
Returns a |
Buffered insertion¶
BufferedInserter accumulates rows in memory and flushes to ClickHouse when
max_rows is reached or every flush_interval seconds:
async with repo.buffered(max_rows=1000, flush_interval=5.0) as buf:
for event in events:
await buf.add(event)
# all rows flushed on exit
Raw SQL¶
repo.raw executes a literal SQL string and returns results as plain dicts
(or as model instances when as_model=True):
rows = await repo.raw(
"SELECT region, sum(amount) AS total FROM _sale GROUP BY region ORDER BY region"
)
# rows == [{"region": "EU", "total": 30}, ...]
instances = await repo.raw(
"SELECT * FROM page_view WHERE user_id = {uid:UInt32}",
params={"uid": 42},
as_model=True,
)
Soft-delete on ClickHouse¶
ClickHouseRepository.delete(**lookups) is supported for models with
Meta.soft_delete = True. It does not issue a SQL DELETE - instead it
appends a tombstone row (same ORDER BY key, is_deleted=1); a subsequent
SELECT ... FINAL collapses the key to the latest version and hides the row.
The lookups must supply every column in the ORDER BY key, or
UnsupportedOperationError is raised eagerly before any IO.
repo = ClickHouseRepository(Document)
await repo.insert(Document(key=1, body="hello"))
await repo.delete(key=1) # tombstone inserted; row hidden under FINAL
await repo.restore(key=1) # un-delete (live marker with is_deleted=0)
await repo.cleanup() # OPTIMIZE ... FINAL CLEANUP - physically drop tombstones
delete raises UnsupportedOperationError if the model is not
soft-delete (there is no physical/hard row DELETE on ClickHouse).
Unsupported operations¶
ClickHouse is append-only. The following methods raise
UnsupportedOperationError:
update(...)andbulk_update(...)- ClickHouse has no row UPDATEget_or_create(...)andupdate_or_create(...)- ClickHouse has no upsert; useinsert/bulk_insert
Outbox integration¶
ClickHousePublisher is a Publisher adapter that writes outbox messages
directly into a ClickHouse table in batches. Wire it into a Relay the same
way as any other publisher:
from alchemiq import Relay
from alchemiq.clickhouse import ClickHousePublisher
relay = Relay(ClickHousePublisher(EventRepo), batch_size=500)
await relay.run()
Migrations¶
alchemiq ships its own migration runner for ClickHouse (Alembic does not support
it). Migrations are Python classes with up and down methods. Applied
revisions are stored in a _alchemiq_migrations MergeTree table in ClickHouse
itself.
CLI commands¶
alchemiq makemigrations --db clickhouse
alchemiq migrate --db clickhouse
alchemiq rollback --db clickhouse
alchemiq history --db clickhouse # list migrations with applied markers
alchemiq showsql --db clickhouse # print DDL for pending migrations (reads history, runs nothing)
alchemiq history reads the _alchemiq_migrations table and prints each
revision with a [x] (applied) or [ ] (pending) marker.
alchemiq showsql connects to ClickHouse to read the applied-revision history
and prints the DDL SQL for pending migrations without executing it.
PostgreSQL-only features¶
The following alchemiq features are not available for ClickHouse:
Feature |
Reason |
|---|---|
|
ClickHouse has no ACID transactions |
Optimistic locking ( |
Requires row-level UPDATE |
Physical/hard row |
Append-only storage (soft-delete via tombstone is supported) |
|
PG-specific; use CH |
Alembic migrations |
Alembic has no ClickHouse dialect |
Native upsert |
ClickHouse has no upsert; use |
Model signals on write |
No ORM-level flush |