hecks

The consumer guide for queries now lives at guides/queries-and-read-models.md; this file remains the original design record.

The query DSL: what exists, and what might extend it

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.

What already exists, verified

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.

One thing left unverified, not fabricated

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.

Proposed additions — not implemented, listed in rough order of how well-motivated each is