The consumer guide for queries now lives at guides/queries-and-read-models.md; this file remains the original design record.
Status: mostly a record of what’s already built, verified directly — plus a short, explicitly-marked list of proposed additions that are not implemented. Grew out of the Rails integration design doc’s reasoning about associations and batch reads; kept separate because this is a language/persistence-layer concern, not a driving-adapter one.
query and read_model are two separate, sibling constructs, both built on the same
shared option vocabulary (QuerySpecification::Common::DSL):
where(field: value) # comparators: eq, ne, gt, gte, lt, lte, in, contains
order_by(field, direction = :asc)
limit(value)
offset(value)
cursor(value)
consistency(mode, timeout: nil)
freshness(mode, max_age: nil)
authorize(policy, tenant: nil)
nulls(mode)
inspect_query(mode = :sql)
use_index(name)
read_model (ReadModelBuilder) additionally has reference_to/include — a real,
already-built answer to “through relationships, like SQL” at the scale of GATHERING
several aggregates’ own rows into one projected shape. This was gotten wrong once in
this project’s own reasoning before being checked directly, and is worth stating
precisely now that it’s wrong a second way: it used to read “plain query never
crosses an aggregate boundary, but read_model does” — true when written, no longer
true. A plain query’s dotted where field can now HOP through a reference_to
attribute to filter on the referenced aggregate’s own field (QuerySpecification::HopPath,
Runtime::ReferenceHop) — a different capability than read_model’s, answering a
different question. A hop FILTERS one aggregate’s own rows by a fact about what they
point at (Account.OpenForSuspendedCustomers, in examples/banking: where(status:
"open"); where(:"customer.status" => "suspended") — still just Account rows back,
narrowed by a fact about each one’s own Customer); read_model GATHERS rows from
several aggregates into one new, wider shape. Neither replaces the other:
query "OpenForSuspendedCustomers" do
where(status: "open")
where(:"customer.status" => "suspended")
order_by :number
end
read_model’s own reference_to/include is not dormant scaffolding either — there’s
a live consumer (adapters/driven/sqlite/projection.rb) and real corpus usage:
read_model "CustomerPortfolio" do
reference_to Customer
include Customer
include Account
include ATMCard
include Transfer
end
read_model "ComplianceDashboard" do
reference_to Account
include Account
include CardPayment
end
include’s cardinality (one record vs. a collection) is inferred automatically —
many: target != @reference_target — the root is singular, everything else is a
collection. Note separately: ReadModel::Specification#joins is a different,
unrelated field on a different class, with zero DSL method that populates it and zero
adapter that reads it — genuinely dormant, unlike reference_to/include, which are
both live. Don’t confuse the two when reading the code.
on: — naming WHICH many-side collection an option applies to (ADR 0055)where/order_by/limit/offset apply to exactly one collection — the single
included aggregate whose head is “many.” A read model with zero many-side heads has
nothing for them to filter; with exactly one, that head is unambiguous and needs no
extra word. With two or more, an option now names its target explicitly:
read_model "NovelSummary" do
reference_to Novel
include Novel
include Character
include Part
include Timeline
include Note
# `on: Character` — narrows THIS collection alone; Part/Timeline/Note
# are still gathered in full, untouched.
where(superseded_by: nil, on: Character)
end
on: takes the included TYPE (matching include’s own argument, resolved the same
way reference_to/include already resolve theirs) — not the include’s own as:
alias. Omitting on: still works exactly as before when there’s exactly one many-side
head; declaring where/order_by/limit/offset with no on: while several
many-side heads exist is still refused, for the same reason it always was — nothing
says which collection was meant. on: is scoped to read_model alone: a plain query
has only ever had one collection to mean, so where(..., on: X) there is a plain,
loud ArgumentError, not a silently-ignored option.
Rust parity: not yet, on purpose. The Rust kernel’s own ReadModelDef carries
exactly one eligible head’s worth of where/order_by/limit/offset, not a per-head map —
a real, separate porting effort (see ADR 0055). The Ruby-side and Rust-side codegen
generators both refuse to generate a read model that combines several many-side heads
with any declared option at all, cleanly, rather than risk applying an option to the
wrong head.
Whether include’s behavior on an absent match reads like an inner join (row dropped)
or a left join (row kept, empty collection) was not traced through
sqlite/projection.rb here. Worth confirming directly before relying on it for a case
where a zero-match include must not silently drop the root record — e.g. a compliance
dashboard for accounts with no disputed payments should very plausibly still list
the account.
count/sum/avg/min/max, with group_by). The sharpest gap,
motivated by the corpus’s own existing shape rather than speculation —
ComplianceDashboard reads like exactly the kind of read model that wants “total
disputed amount” or “count of open disputes,” and nothing in the current vocabulary
computes anything; every option filters, sorts, paginates, or composes raw rows.having, pairing directly with aggregation — filtering on a computed value is a
different operation from where (which filters before any computation), for the
same reason SQL keeps the two separate.distinct — a plain, common primitive with no equivalent in comparators.rb or
Options.include’s optionality, named rather than inferred. This
is the same problem as the unverified inner-vs-left question above, seen from the
DSL-authoring side: right now there’s no lever to say “this include is required,
drop the row if absent” versus “this include is optional, keep the row with an
empty collection” — many: only ever answers cardinality, never optionality.
Adding the lever would resolve the ambiguity rather than leave it to be discovered
by whatever the adapter happens to do.JOIN. A hop resolves today by running one
ordinary query against the hop’s target aggregate and folding the ids it answers
back in as a local in clause (Runtime::ReferenceHop) — correct on every adapter,
including a mix of them, but two round trips where a shared Postgres connection
could in principle answer one JOINed query instead. A real, bounded optimization
layered on a correct baseline, not a prerequisite for one — worth doing if a hop
query’s two-round-trip cost is ever actually measured to matter, not before.EraGuard, the translation arc) —
this may already exist there under a different name. Worth naming because it’s
unusually well-motivated for this specific project: consistency/freshness
already govern time-related read semantics without touching which era’s shape to
read against, and a project this invested in versioned, historically-honest domain
shapes is exactly where “as of era N” would earn its place if it isn’t already
solved elsewhere.