Detail queries¶
Dosi answers two kinds of question over one semantic model.
| Question | Command | Returns |
|---|---|---|
| What was revenue last month, by region? | dosi query |
aggregated metrics |
| Show me the 100 largest orders from Enterprise customers | dosi select |
rows |
Both compile through the same planner, the same relationship graph and the
same dialect backends. They differ in one operator: query aggregates,
select projects.
$ dosi select \
--from orders \
--fields orders.order_id,orders.amount,customers.name \
--where "customers.tier = 'Enterprise'" \
--order -amount \
--limit 100
You never write a JOIN. You name the fields you want; Dosi works out which datasets they live on, which relationships connect them, and whether that connection is safe.
--from is the grain, not the FROM clause¶
--from orders means one output row per order. It does not mean the SQL
may only mention orders.
Fields, filters, ordering and the time range may all reference other datasets. Dosi collects every dataset the query needs, finds the relationship path to each, and emits the joins:
customers is never projected, but the filter needs it, so it is joined.
The result is still one row per order.
This works because Dosi follows relationships in one direction only: from the many side to the one side. Each hop attaches at most one row of the target to each row you already have, so the grain you asked for survives every join in the path.
Put plainly: an order has exactly one customer, so pulling customers.name
in adds a column to the row — it never adds a row to the result. It is a
lookup, the way VLOOKUP fills a column in a spreadsheet without changing
how many rows the sheet has.
The other direction is where hand-written SQL quietly goes wrong. One
customer has many orders, so joining from customers down to orders
splits each customer into several rows — and a SUM over any
customer-level column then counts that customer once per order. That is the
classic double-count, and nothing errors: the query succeeds and the number
is simply wrong. Dosi will not emit it. Ask for an order field from
--from customers and the query is refused with
detail_fanout, which hands back the grain that does
answer the question (--from orders) instead of a duplicated result.
Three ways to name a field¶
| Spelling | Example | When |
|---|---|---|
| bare field | amount |
the name is unique across the model |
| dataset-qualified | orders.amount |
the usual case |
| relationship-prefixed | orders_to_buyer.name |
you want to choose the join path |
The third form matters when a model reaches the same dataset more than one
way. Suppose orders links to regions both directly (where it shipped)
and through customers (where the buyer lives). Asking for
regions.region_name is genuinely ambiguous, and Dosi says so rather than
picking:
$ dosi select --from orders --fields regions.region_name --format json
{
"code": "ambiguous_join_path",
"candidates": [
"orders -[orders_to_customers]-> customers, customers -[customers_to_regions]-> regions",
"orders -[orders_to_ship_region]-> regions"
],
"suggested_retry": "name the path you mean by prefixing the field with its relationships, one of: orders_to_customers.customers_to_regions.<field> | orders_to_ship_region.<field>"
}
Paste one of those prefixes and the query runs:
Relationship prefixes work in --where too, at any path length, and on the
metric plane's --group-by — the spelling is the same everywhere.
Output column names¶
An output column is the reference with the root prefix removed and each
remaining . turned into __:
Reference (root orders) |
Column |
|---|---|
orders.order_id |
order_id |
customers.name |
customers__name |
orders_to_ship_region.region_name |
orders_to_ship_region__region_name |
orders.created_at:month |
created_at__month |
Root fields stay bare because that is what you asked about; everything else
carries where it came from, so two name columns from two datasets cannot
collide silently. Two fields that would produce the same column name are a
duplicate_output_name error.
When Dosi refuses¶
The engine never returns rows it cannot vouch for. Three refusals are worth recognizing.
detail_fanout — you asked for a dataset that is only reachable against
the join direction. One customer has many orders, so projecting an order
field from --from customers would turn one customer into several rows:
$ dosi select --from customers --fields customers.name,orders.order_id --format json
{
"code": "detail_fanout",
"suggested_retry": "query at the finer grain instead: --from orders and project the customers fields you need"
}
The fix is in the message: ask the question at the finer grain. --from
orders --fields orders.order_id,customers.name gives you the same
information, one row per order, with no duplication.
ambiguous_join_path — several paths reach the field. Name one, as
above.
metric_in_detail_query — a metric needs an aggregation, a time range
and a grouping, none of which a detail query has. The message names the
command that does have them.
unknown_query_key — the query carries a key it does not define,
usually a misspelling. It is refused rather than ignored, because an ignored
distinct or filter returns plausible rows that answer another question.
candidates lists the keys the query defines.
Every refusal carries a machine-readable code, the names involved, and a
suggested_retry. See Errors.
Time ranges¶
--start-time / --end-time apply a half-open [start, end) window. With
no --time-dimension, the range routes to the root dataset's primary time
dimension:
$ dosi select \
--from orders \
--fields orders.order_id,orders.amount \
--start-time 2026-01-01 --end-time 2026-02-01
If the root dataset has no primary time dimension and the projection has no single time field, Dosi asks you to name one rather than guessing.
Level-of-detail fields¶
A D-LOD fixed field of the root is a value per
key — active days per user, payments per player — and a detail query reads it
like any other field: project it, filter on it, order by it. Each root row is
joined with its key's row of the rollup, which has one row per key, so the
value attaches to the row and the row count stays the root's.
$ dosi select --from sessions --fields sessions.user_id \
--where "sessions.active_day >= 2" \
--context-filter "sessions.channel_id = 2" \
--start-time 2025-01-01 --end-time 2025-02-01 --distinct
The layers are the metric plane's (filter placement):
--start-time/--end-timeand--context-filterare context. They filter the rows and also scope the rollup: above, the days are counted on channel 2 in January.--whereis a dimension filter. It picks rows once the value is attached and never reaches the rollup. Movingchannel_id = 2into--wherecounts January days on every channel, then keeps the channel-2 rows — a different list. A--wherecondition may mix a stored column and a value (active_day > 2 OR user_id = 'E').--context-filtercannot read a level-of-detail field: it feeds the rollup that computes it (unsupported_filter).
A cross-dataset field works the same way —
--where "flights.open_alert_count = 0" lists the flights with no OPEN
alert — and a context condition reaches the other dataset only where it can
read it. When a field has a window of its own on the root's dataset, keep the
query's main window in --where, not the time range, or the two windows
intersect inside the rollup. In this version the field must live on the
--from dataset; one on a joined dataset is refused with the root to use.
A field's own window is its base's parameters,
and --param NAME=VALUE ("params" elsewhere) binds them — January actives
with no February login:
$ dosi select --from logins --fields logins.player_id --distinct \
--where "logins.login_day BETWEEN 20250101 AND 20250131 AND logins.window_days = 0" \
--param window_start=20250201 --param window_end=20250228
Each parameter takes one value, since a field is one column, and a
parameter that no field the query reads declares is unknown_metric_param.
Duplicate rows¶
A row is one row of the root, so a list of ids read off a fact table
repeats each id once per fact row: one line per login, not one per player.
--distinct ("distinct": true on every other surface) drops the rows whose
projected values coincide — SELECT DISTINCT:
It collapses rows; it does not change which rows exist, so a fan-out is still refused rather than deduplicated after the fact.
Row caps¶
--limit defaults to 100 and is clamped to 10000. This applies on
every surface, so an agent that forgets a limit gets a sample, not a table
scan.
Beyond the CLI¶
The same DetailQuery reaches every surface, with identical semantics and
identical errors.
$ curl -s localhost:8080/v1/select/execute \
-H 'content-type: application/json' \
-d '{"from":"orders","fields":["orders.order_id","customers.name"],"limit":10}'
Also POST /v1/select/compile and POST /v1/select/explain. See the
REST API.
Tools select, compile_select and explain_select. See the
MCP server.
Checking the join before trusting the rows¶
--explain prints the logical plan: the root, every join it chose, and the
projection.
$ dosi select --from orders --fields orders.order_id,customers.name --explain
logical plan
Project[order_id, customers__name]
JoinRelated[orders_to_customers -> customers, left]
ReadDataset[orders]
Joins are LEFT by default, so a row with no match survives with NULLs
rather than disappearing. A relationship may ask for INNER instead — see
Control joins & null-fill.
Limits in this version¶
- A query may reach each dataset by one path. Two named paths to the same dataset need two table aliases, which do not exist yet, so the engine refuses rather than quietly reading one join twice.
- Order keys must name projected columns. Project the field you want to order by.
- Metrics and fields cannot be mixed in one query. Run both planes and join the results yourself. A per-entity value is the exception: declare it as a level-of-detail field and read it here.
- A level-of-detail field must live on the
--fromdataset.
See also¶
- CLI reference — every flag
- Semantics contract — the S-SELECT and S-VIA rules
- Errors — the full code list