Lotus ClickHouse
ClickHouse source adapter for Lotus. Run Lotus queries, dashboards, and AI-assisted exploration against a ClickHouse cluster the same way you would against Postgres, MySQL, or SQLite.
A Lotus statement here is ordinary SQL text, but it is ClickHouse SQL: typed {$0:Type} placeholders instead of $1, no transactions, and a read-only guarantee the server enforces rather than the session — see Writing Queries.
0.1.0 — first release. The adapter is complete against the
Lotus.Source.Adapters.Ecto.Dialectcontract in Lotus v1 and its integration suite runs against a live ClickHouse server, but its own surface — the type mapping, the editor configuration, the safety defaults — has no production users yet and may still move. Pin accordingly.
Installation
Add both lotus and lotus_clickhouse to your mix.exs:
def deps do
[
{:lotus, "~> 1.0"},
{:lotus_clickhouse, "~> 0.1"}
]
end
This pulls in ecto_ch (the Ecto adapter for ClickHouse) and ch (the underlying HTTP driver). Requires Elixir 1.18 or later. CI runs the suite on Elixir 1.18, 1.19 and 1.20 against ClickHouse 24.8.
Configuring a data source
A ClickHouse source is a plain Ecto repo, exactly like a Postgres one:
defmodule MyApp.ClickHouseRepo do
use Ecto.Repo,
otp_app: :my_app,
adapter: Ecto.Adapters.ClickHouse
end
config :my_app, MyApp.ClickHouseRepo,
hostname: "localhost",
port: 8123,
scheme: "http",
database: "analytics",
username: "default",
password: "secret",
pool_size: 5
config :lotus,
storage_repo: MyApp.Repo,
default_source: "postgres",
source_adapters: [Lotus.Source.Adapters.ClickHouse],
data_sources: %{
"postgres" => MyApp.Repo,
"clickhouse" => MyApp.ClickHouseRepo
}
Listing the adapter in :source_adapters is what makes the repo resolvable. The generated can_handle?/1 claims any repo whose __adapter__/0 is Ecto.Adapters.ClickHouse, so :data_sources entries stay bare repo modules — no map form, no :adapter key.
Then start the repo in your supervision tree alongside your other repos. Full walkthrough in Installation.
What works
- Query execution through
repo.query/3, returning%{columns:, rows:, num_rows:}.execute_query/4is the one callback this package overrides rather than inheriting, because ClickHouse has no transactions (see below). - Server-enforced read-only. Every query goes out with ClickHouse's
readonly: 1per-query setting, so the server rejectsINSERT/CREATE/DROP/ALTER/TRUNCATEwith error 164 (READONLY). That is a stronger guarantee than a session-levelSET TRANSACTION READ ONLY, and it is checked by integration tests for all five statement kinds. - Typed parameters.
param_placeholder/3emits ClickHouse's{$N:Type}form (0-indexed), mapping Lotus type atoms toString,Int64,Float64,Bool,DateandDateTime.limit_offset_placeholders/2emitsUInt64. - Filters, sorts and pagination on the shared Ecto SQL injectors: filters wrap the statement in a
WITH _base AS (...)CTE and appendWHERE, sorts wrap it inWITH _sorted AS (...)and appendORDER BY, pagination wraps it inSELECT * FROM (...) AS lotus_sub LIMIT ? OFFSET ?. Supported operators are the core nine::eq,:neq,:gt,:lt,:gte,:lte,:like,:is_null,:is_not_null. - Exact counts via core's count-spec strategy:
count: :exactputs aSELECT COUNT(*) FROM (...)statement instatement.meta[:count_spec]for the caller to run through the same adapter. - Statement rewriting for Lotus's
{{var}}templates —'%{{q}}%'becomes'%' || {{q}} || '%'(ClickHouse takes||as concatenation), and'{{email}}'sheds its quotes so the value binds as a parameter instead of landing inside a string literal. - Schema introspection against
system.databases,system.tablesandsystem.columns.describe_table/3reports the raw ClickHouse type string, nullability (from theNullable(...)wrapper), the default kind rather than the default expression, and primary-key membership fromis_in_primary_key. Views and materialized views are excluded unless the caller asks for them. - Visibility preflight.
extract_accessed_resources/2runsEXPLAIN ASTand scrapesTableIdentifiernodes, resolving aliases back to real table names, so Lotus's visibility rules are actually enforced for this source. IfEXPLAIN ASTfails it falls back to aFROM/JOINregex over the SQL text. - Query plans.
query_plan/3runs plainEXPLAIN(also withreadonly: 1) and joins the rows into one string. - Type mapping covering the integer, float, decimal, string, date, UUID, JSON, map, enum and IP families, recursively through the
Nullable,LowCardinalityandArraywrappers. Anything unrecognised maps to:text. - AI integration —
ai_context/0declaressql:clickhouse, an example query, ClickHouse syntax notes (PREWHERE,FINAL, approximate aggregations,SETTINGS), error-pattern hints for codes 60, 47, 62 and 164 plus memory-limit failures, andgeneration,optimizationandexplanationall enabled. - Editor support — 370 ClickHouse function completions with signatures, plus ClickHouse keywords, the full type vocabulary, and
prewhere/final/sample/settings/formatas context boundaries.
What this adapter does differently, or not at all
- No transactions.
ecto_chdoes not implementEcto.Adapter.Transaction.execute_in_transaction/3checks a connection out of the pool and runs the function on it; there is no rollback, andexecute_query/4therefore bypasses core's shared helper, which expectsrepo.rollback/1to exist. In practice this costs nothing, because Lotus uses this source read-only. - No schema hierarchy.
supports_feature?(:schema_hierarchy)isfalse, the same answer MySQL gives: the database is configured on the repo rather than browsed, anddefault_schemas/1returns just that one database.hierarchy_label/0is still"Databases"for the label the UI prints. ClickHouse itself will happily runother_db.table, but a table outside the repo's database is not indefault_schemas/1, so visibility rules have to allow it explicitly. - No search path.
supports_feature?(:search_path)isfalseandset_search_path/2is a no-op — a caller-supplied:search_pathis ignored for this source. - No statement timeout.
set_statement_timeout/2is a no-op. The:timeoutoption bounds the HTTP client, not the server: a runaway query can keep burning ClickHouse CPU after the caller has given up. If that matters, putSETTINGS max_execution_time = Nin the query, or set a server-side limit on the repo's:settingsor the ClickHouse user profile. - No
make_interval.supports_feature?(:make_interval)isfalse, so core's PostgresINTERVAL '{{n}} days'rewrite does not apply. Write date arithmetic with ClickHouse functions instead —subtractDays(today(), {{days}}). See Writing Queries. - Arrays are declared but not bound as one value.
supports_feature?(:arrays)istruebecause ClickHouse has a realArraytype, but list variables still expand into N placeholders through core's shared SQL path. Binding a whole list as a single parameter is not something this dialect emits a type for. - Introspection reads
system.*, which is also denied.builtin_denies/1blocks thesystemdatabase outright, so a query cannot read it — but the adapter's own introspection queries go straight torepo.query!/2and are not subject to those rules. That is the intended split; it is worth knowing the deny list is about user statements. - Identifier quoting is double quotes.
quote_identifier/1emits"col", doubling any embedded". ClickHouse accepts backticks too, and its own docs lean on them, but everything this adapter generates uses double quotes.
Safety
Two independent layers, and they block different things.
Core's SQL sanitizer (sanitize_query/3, shared by every Ecto-backed adapter) rejects multi-statement input and, when read_only is set, any statement matching \b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE|VACUUM|ANALYZE|CALL|LOCK)\b. It is a keyword regex, so it is blunt in both directions: a column literally named analyze_count in a SELECT trips it, and it knows nothing of ClickHouse-specific mutations such as OPTIMIZE or SYSTEM.
ClickHouse's readonly=1 is what actually stops writes. It is a server setting applied to every query the adapter executes, and it covers the ClickHouse-specific verbs the regex misses. Both layers are lifted together by read_only: false, which exists for controlled callers such as the test harness inserting fixtures.
Separately, builtin_denies/1 hides relations from queries before any host visibility rule is consulted: the whole system, INFORMATION_SCHEMA and information_schema databases, plus the Ecto migration table (from the repo's :migration_source, defaulting to schema_migrations) and the six lotus_* metadata tables — each listed both unqualified and qualified with the repo's configured database. builtin_schema_denies/1 keeps the same three system databases out of schema listings.
One caveat worth stating plainly: the AI syntax notes and error patterns above only reach the LLM prompt if you also list this adapter in config :lotus, :trusted_source_adapters. Otherwise core strips the context down to :language alone.
Development
mix deps.get
mix test.setup # docker compose up -d
mix test
The docker-compose service publishes ClickHouse on non-standard host ports — 9123 for HTTP and 9100 for the native protocol — so it does not collide with a ClickHouse you may already be running on 8123. The test repo in config/test.exs expects exactly that: localhost:9123, user default, password clickhouse, database lotus_test.
Nearly the whole suite needs that server. Only test/lotus_clickhouse/dialect_test.exs is pure; test/test_helper.exs connects and creates the test_users, test_posts and test_events tables before any test runs, so with no server up the suite fails at startup rather than skipping. Lotus's own metadata tables live in an in-memory SQLite repo, so no second server is needed. Tests are async: false throughout — ClickHouse has no Ecto.Adapters.SQL.Sandbox, so isolation is a TRUNCATE between tests.
mix format --check-formatted
mix compile --warnings-as-errors
mix credo --strict
Guides
- Installation — step-by-step setup, connection options, ClickHouse Cloud.
- Writing Queries — placeholders, variables, what the pipeline wraps around your SQL, and the ClickHouse-specific pitfalls.
- How It Works — the adapter/dialect split and every callback, for anyone reading or extending the code.
License
MIT — see LICENSE.