AshClickhouse supports the standard Ash query API. Most features map directly to ClickHouse SQL.
Basic CRUD
# Create
{:ok, user} = Ash.create(MyApp.User, %{name: "John", email: "john@example.com"})
# Read
users = Ash.read!(MyApp.User)
# Update
Ash.update!(user, %{name: "Jane"})
# Destroy
Ash.destroy!(user)Filtering
Full WHERE support via Ash.Query / action filters:
MyApp.User
|> Ash.Query.filter(age > 18 and name == "John")
|> Ash.read!()Boolean expressions, nested expressions, and changeset_filter are supported.
Relationship filters (filter_relationship, unrelated exists,
aggregate_relationship) are not supported yet.
Untranslatable filters fail closed
If a filter cannot be expressed in ClickHouse SQL (e.g. an operator the data
layer doesn't translate), the query raises by default. This is deliberate:
silently dropping such a filter would make the query less restrictive than
intended — a real risk for base_filter tenant scoping or soft-delete. To
revert to the old warning-only behaviour, set:
# config/config.exs
config :ash_clickhouse, :raise_on_untranslatable_filter, falseSorting, limit, offset, distinct
MyApp.User
|> Ash.Query.sort(age: :desc)
|> Ash.Query.limit(10)
|> Ash.Query.offset(20)
|> Ash.Query.distinct(:name)
|> Ash.read!()Keyset pagination is not supported — use offset/limit.
Selecting fields
MyApp.User
|> Ash.Query.select([:id, :name])
|> Ash.read!()Aggregates
Native aggregates: count, sum, avg, min, max.
# Aggregate on a query (name, kind, field)
MyApp.User
|> Ash.Query.aggregate(:total, :count, :id)
|> Ash.read!()
# Aggregate defined on the resource
MyApp.User
|> Ash.Query.load(:total_count)
|> Ash.read!()Relationship aggregates (attached to each record on read) support belongs_to,
has_many, and has_one relationships. They are computed with a single batched
query per aggregate across the whole result set (not one query per record),
and decoded using the aggregated field's actual Ash type — so Decimal columns
keep their precision and count/sum/etc. return the correct Elixir numeric
type. Multi-hop relationship paths are not supported and fall back to the
aggregate's default_value.
aggregate_filter and aggregate_sort are not supported.
Bulk create
Ash.bulk_create!(
[%{name: "A"}, %{name: "B"}],
MyApp.User,
:create
)Bulk create is implemented as a batched INSERT (default batch size 1000,
max 100_000). Per-resource insert_opts (e.g. async_insert: 1) are applied.
Update / destroy queries
These map to ClickHouse ALTER TABLE ... UPDATE / DELETE mutations. They are
triggered by Ash's bulk update/destroy actions when run against a filtered
query, and ClickHouse applies the change in a single pass:
# Zero a negative age in one ALTER TABLE ... UPDATE
MyApp.User
|> Ash.Query.filter(age < 0)
|> Ash.bulk_update(:update, %{age: 0})
# Delete all status = "deleted" rows
MyApp.User
|> Ash.Query.filter(status: "deleted")
|> Ash.bulk_destroy(:destroy, %{})Mutations run asynchronously by default; control this with the resource's
mutations_sync option (1 = wait on current replica, 2 = wait on all
replicas, nil = async).
String matching: contains vs starts_with/ends_with
contains is case-insensitive (it uses ClickHouse's positionCaseInsensitive),
while starts_with and ends_with are case-sensitive (they use LIKE). This
asymmetry is deliberate — positionCaseInsensitive treats %/_ literally so it
can't be used for prefix/suffix matching — but it's easy to trip over when switching
between the three:
# case-insensitive substring search
MyApp.User |> Ash.Query.filter(contains(name, "john")) |> Ash.read!()
# case-sensitive prefix / suffix
MyApp.User |> Ash.Query.filter(starts_with(name, "John")) |> Ash.read!()
MyApp.User |> Ash.Query.filter(ends_with(name, "@example.com")) |> Ash.read!()Streaming
stream is supported and yields rows from the result set without
materializing the full query into memory — the natural choice for large OLAP
scans. Decoded records are emitted one at a time:
MyApp.User
|> Ash.Query.filter(age > 18)
|> Ash.stream!()
|> Enum.take(10_000)In-memory calculations and relationship aggregates are applied per chunk, so
streaming returns the same records as Ash.read!/2.
Returned records are locally reconstructed
create/update/bulk_create return records built from the values you submitted
rather than re-querying ClickHouse. ClickHouse has no RETURNING clause, so any
server-computed column (e.g. a default value, an auto-incrementing counter, or a
DEFAULT now() timestamp) will not appear in the returned struct. If you need the
server-computed value, read the row back explicitly.
Bulk create: no cross-chunk rollback
bulk_create splits rows into chunks (default 1000, max 100_000) and inserts each
chunk with a separate statement. ClickHouse has no transactions, so if a later chunk
fails, earlier chunks are already committed and are not rolled back. Design
callers to tolerate partial success (or keep batches small enough to be atomic for
your tolerance).
Multitenancy in queries
See Multitenancy. Tenant is set via Ash.Query.set_tenant/2
and applied as a filter (attribute strategy) or stored on the query for
context-based scoping.