ClickHouse: schema objects¶
Views, materialized views, projections and dictionaries are declared on the model, created and changed by
migrations and compared by hare drift, as on PostgreSQL — see Views, functions, sequences and
access. A connection to a cluster runs every statement of the schema on
all its servers.
Views¶
Meta.views with View(name, query) is a ClickHouse view — a table of the View engine, renamed as one
and not dropped with the table it reads. hare drift compares a view's query as the server parses it, so
a query written in other words but the same is no drift.
Materialized views¶
A ClickHouse materialized view follows the rows inserted into the table its query reads: each inserted
block goes through the query and into the view. ClickhouseMaterializedView says where its rows go:
from hare.ddl import RawSQLTerm
from hare.dialects.clickhouse.schema_objects.clickhouse_materialized_view import ClickhouseMaterializedView
class Visit(Model):
site = fields.CharField(max_length=50)
class Meta:
materialized_views = [
# A storage of its own, summed by site as its parts merge.
ClickhouseMaterializedView(
"visits_by_site",
RawSQLTerm("SELECT site, count() AS visits FROM visit GROUP BY site"),
engine="SummingMergeTree",
order_by=("site",),
),
# Rows written into another table.
ClickhouseMaterializedView(
"visits_to_archive", RawSQLTerm("SELECT * FROM visit"), to="visit_archive"
),
# The whole query again every hour.
ClickhouseMaterializedView(
"top_sites",
RawSQLTerm("SELECT site, count() AS visits FROM visit GROUP BY site ORDER BY visits DESC LIMIT 10"),
refresh="EVERY 1 HOUR",
),
]
| Argument | Meaning |
|---|---|
to |
The table the view writes its rows into, instead of a storage of its own. |
engine, order_by, partition_by |
The view's own storage — default a MergeTree sorted by unique_columns. A key is a column of the view, an expression of hare over the fields of the model declaring the view that the view selects under their own names (Trunc("visited_at", "month")), or a RawSQLTerm. |
refresh |
The schedule the server runs the whole query on — "EVERY 1 HOUR", "AFTER 30 MINUTE", with OFFSET and RANDOMIZE FOR. The view then holds the rows of its last refresh. |
append |
With refresh: a refresh adds its rows instead of replacing them. |
depends_on |
With a refresh of EVERY a period: the refreshed views this one is refreshed after. With AFTER it raises ConfigurationError — ClickHouse takes dependencies of an EVERY schedule alone. |
with_data |
Fill the view with the rows its query gives now, when it is created. Default True. |
A plain MaterializedView is a view of its own MergeTree sorted by its unique_columns. A view's own
sorted storage is created with allow_nullable_key = 1: its columns are its query's, and a key column
may hold NULL.
- Filling. A view created
with_datais filled by anINSERT ... SELECTof its query right after it is created, never byPOPULATE— which loses the rows inserted while it runs. On a cluster the fill runs on the server the connection is to, over that server's rows: a view whose query reads a table that isn't distributed — or reads tables hare doesn't know, raw SQL withoutread_columns— raisesUnSupportedErrorbefore anything is created. Read distributed tables (named inread_columns), or declare the viewwith_data=Falseand fill it on every server yourself. refresh_materialized_view()refreshes a view ofrefreshat once and waits for it, its rows replaced in one go. Any other view is emptied and filled again — its rows missing in between, soconcurrently=TrueraisesUnSupportedErrorfor it.- Changes. A migration changes the query of a view writing
toa table (MODIFY QUERY) and the schedule of a refreshed one (MODIFY REFRESH) in place; any other change drops the view and creates it again — a storage of its own filled by the new query, a table written to keeping its rows. Rolling back the removal of a view writingtoa table creates it again over the rows its drop left there, not filled again; only adding a view fills the table with the rows its query gives. - A table remade by a migration takes its views with it: they are dropped before the copy of its rows and created again after — a view would otherwise take the copied rows a second time.
hare drift compares the whole statement of a materialized view as the server writes it — the query,
the target table, the engine, the sort, the partitioning and the schedule.
Projections¶
A projection keeps the rows of a table a second time inside each part, sorted or aggregated as its query says; the server reads it in place of the table where a query fits it.
from hare.dialects.clickhouse.schema_objects import ClickhouseProjection
from hare.query.functions import Count
from hare.query.queryset import ModelQuery
class Meta:
table_options = [
ClickhouseTableOptions(
projections=(ClickhouseProjection("by_site", ModelQuery().values("site").annotate(visits=Count("id"))),)
)
]
The query is a queryset of the table's model — ModelQuery() in the model's own Meta — written as a
SELECT without FROM in ClickHouse's SQL; it selects, groups and orders, and a filter, a join or a limit
raises ConfigurationError (a projection keeps every row: take a queryset without a model's default filter, a
soft delete's too). Its order is a sorting key: ascending, written without a direction — an order by -field
raises ConfigurationError. hare knows the columns it reads: a migration changing one drops the projection
before and adds it after, and renaming one remakes the table. Or RawSQLTerm of a SELECT without FROM,
with GROUP BY or ORDER BY, written as it is — its columns are the ones you name in read_columns=(...). A
migration file keeps a queryset's projection as its SQL with its read_columns. A migration adds a projection
with ADD PROJECTION and builds it for the stored rows with MATERIALIZE PROJECTION, which finishes before
the migration goes on; it drops one with DROP PROJECTION.
A table with projections gets the settings rebuilding them as rows are deleted and merged away — a
lightweight DELETE and a ReplacingMergeTree merge then keep the projections right.
Dictionaries¶
A dictionary keeps rows of a table in the server's memory; a value of them is read by its key with
DictGet in place of a JOIN. ClickhouseDictionary is ClickHouse's hare.ddl.Dictionary — the base
every dialect's dictionary subclasses.
from hare.dialects.clickhouse.functions import DictGet
from hare.dialects.clickhouse.schema_objects import ClickhouseDictionary
class Country(Model):
code = fields.CharField(max_length=2, primary_key=True)
name = fields.CharField(max_length=50)
class Meta:
dictionaries = [ClickhouseDictionary("country_names", key=("code",), attributes=("name",), lifetime=300)]
await Visit.objects.annotate(
country=DictGet("country_names", "name", "country_code", output_field=fields.CharField(max_length=50))
)
await Country.objects.reload_dictionary("country_names")
| Argument | Meaning |
|---|---|
key |
The fields a row is looked up by. |
attributes |
The fields a lookup gives. |
layout |
How the server keeps the rows, with its arguments — "COMPLEX_KEY_HASHED()" (default) takes a key of any fields — see layout |
lifetime |
After how many seconds the dictionary is loaded again — a number, or (least, greatest) for the server to pick a moment in between; 0 (default) for never. A DIRECT layout takes none — another value raises ConfigurationError, and its statement has no LIFETIME. |
source |
RawSQLTerm of another source than the model's table, as SOURCE(...) takes it. hare drift compares it as the server writes it — a query in double quotes it keeps in backticks, the text inside as it is. |
layout¶
How the server keeps the rows, with its arguments — "COMPLEX_KEY_HASHED()" (default) takes a key of any
fields; "HASHED()", "FLAT()" a key of one unsigned 64-bit integer — for any other key the server
keeps it as its COMPLEX_KEY_ layout ("HASHED()" as COMPLEX_KEY_HASHED()), which hare drift takes
as the declared one. "DIRECT()"/"COMPLEX_KEY_DIRECT()" keep no rows: each lookup reads the source.
The dictionary is loaded from the model's table by the connection's own user and password, so a row written to
the table is read through the dictionary after its next load — reload_dictionary() loads it at once.
DictGet(..., default=...) gives a value for a key the dictionary doesn't hold (dictGetOrDefault); without,
the server gives the attribute type's own default. A key is a field name, an expression or a literal (a text
in Value(...) — a bare one names a field); a key of several fields is looked up by a list or a tuple of
them. A dictionary a model declares is checked when the query is built: the attribute is the name of a field
it gives (or that field's column — name reads the column title of CharField(source_field="title")), the
keys are as many as its key fields, and a key literal is checked as a value of its field; anything else raises
QueryError/ValidationError. A dictionary declared elsewhere is read as named.
A password never reaches the migration file: the connection's own is put into the statement when the
dictionary is created ([HIDDEN] in hare sqlmigrate), and a {env:NAME} in a source is replaced by
the environment variable NAME then. The migration operations are AddDictionary, AlterDictionary,
RemoveDictionary and RenameDictionary, written by makemigrations. The dictionaries of a model are
dropped with its table, and dropped and created again when a migration remakes or renames the table — the
server drops and renames no table a dictionary reads.
Cluster¶
A connection with cluster=<name> belongs to a cluster of servers:
- Every statement of the schema —
CREATE,ALTER,DROP,TRUNCATEof tables, views and dictionaries — runsON CLUSTER <name>, on every server. A database of theReplicatedengine replicates its schema by itself: the connection notices it when it opens and writes noON CLUSTER. SQL you write —execute_script(),execute(),RunSQL— is sent as written: name the cluster in it yourself. - The journal of applied migrations is a
ReplicatedMergeTree, so every server sees the same migrations. - A table remade by a migration on a cluster must be replicated or distributed — a table keeping rows of
its own on each server is refused, as only the rows of one server would be copied. So is a replicated
table on a cluster of several shards whose replica path names the shard (
{shard}, as the server's default path does): each shard keeps rows of its own — distribute it over its shards instead. - A remade replicated table — on a cluster or on one server — needs a replica path of its own: the new
table is created beside the old one, so an engine declaring a path without
{uuid}(a fixed one, or one by{table}, which keeps the name the table was created with) is refused before anything changes. Declare the engine without arguments (the server'sdefault_replica_path), or with a path naming{uuid}.
ClickhouseTableOptions(distributed_over=...) spreads a model's rows over the servers:
class Hit(Model):
class Meta:
table_options = [
ClickhouseTableOptions(
engine="ReplicatedMergeTree",
distributed_over="hit_local",
sharding_key=RawSQLTerm("cityHash64(id)"),
)
]
A migration creates the local table hit_local of the engine and the options on every server, and the
model's table hit as a Distributed table over it — a distributed_over naming the model's table
itself raises ConfigurationError. Rows are read and written through hit, each
written to the shard its sharding_key picks (any shard without one). A mutation, a lightweight DELETE
and a change of the storage go to hit_local on every server; a change of columns to both tables. Each
server runs an update() or a delete() over its own rows, reading there its own rows of every table its
condition or values name, and no GLOBAL exists for a mutation — so one reading a table that isn't
distributed (a relation, a subquery, Exists(); a replicated table too: a replica path by {shard} keeps
rows per shard) raises UnSupportedError instead of leaving the rows of the other servers unchanged.
Distribute that table, or read the keys first and change the rows by pk__in. A delete() running the
cascade of Model.delete() (a model other models point at) reads the keys of its rows first itself. A
table remade is copied through a distributed table over the new local ones, each row to its shard.
mutations_sync=2 waits for every replica. A ReplicatedMergeTree local table keeps its replicas by the
server's {shard} and {replica} macros — the servers of each shard need a {shard} of their own:
servers of two shards sharing one are replicas of one table, each holding every row and each applying a
change of columns sent ON CLUSTER again (DUPLICATE_COLUMN). On a cluster whose shards have several
replicas the local table needs a Replicated engine: one of another engine keeps a row on the replica it
is written to, while a read may ask another, so creating it raises UnSupportedError — in a migration
and in generate_schemas() alike, before any table is created.
hare drift compares both tables of a distributed model — its local table hit_local, which it
doesn't report as untracked, and the Distributed table hit — with the shadow's; a column the two
tables hold another way (ALTER TABLE hit_local MODIFY COLUMN ... alone) comes back as an AlterField. A
replicated engine declared without its replica path and name (engine="ReplicatedMergeTree") is the same as
the one the server gave the table from its default_replica_path and default_replica_name; one declared
with a path naming {database} or {table}, which the server fills with the names on creating the table,
is the same as it too.
A query reading hit runs on every shard, each over its own rows — so every table it joins (a relation,
a many-to-many link, annotate(Count(...))) and every subquery of its __in, exclude() and Exists()
is read once, on the server the query is sent to, and sent to the shards: hare writes GLOBAL JOIN and
GLOBAL IN there. A table keeping rows of its own on each server, another distributed model and the rows
of another shard are all seen; without GLOBAL a shard would read only its own part of them, and the
server refuses two distributed tables in one statement.