The dosi-engine semantics contract¶
The OSI core spec deliberately leaves execution semantics implicit: a metric is a name plus a raw SQL string, a relationship is a column mapping, and a field is at most "is a time dimension". This document is the normative contract for how dosi-engine fills those gaps. It is versioned with the engine; behavioral changes here are breaking changes.
Rules are labeled S-<area>-<n> for reference from issues and tests.
1. Expressions¶
- S-EXPR-1 Every
Expressionis compiled from itsANSI_SQLdialect entry when present, else the first SQL-family entry (SNOWFLAKE,DATABRICKS).MDX/TABLEAU/MAQLentries are ignored; an expression with only non-SQL entries is a compile error for metrics and a warning for fields. - S-EXPR-2 Expression fragments are parsed as a single scalar expression. Anything else — multiple projections, trailing aliases, statements — is a parse error (this is also the injection barrier: user text never reaches SQL by string concatenation anywhere in the engine).
2. Metric inference¶
- S-METRIC-1 A metric expression must contain at least one aggregate
call. Supported aggregates:
SUM,COUNT,COUNT(DISTINCT x),AVG,MIN,MAX. Any other aggregate function isunsupported_aggregate. - S-METRIC-2 Classification, applied to the parenthesis-stripped root:
- a single aggregate call → aggregate metric;
agg / agg(both sides bare aggregates) → ratio; the division is wrappedCAST(numerator AS DOUBLE) / denominatorto avoid integer division on truncating engines;- anything else → expression; each aggregate subtree becomes a
measure, the surrounding arithmetic is preserved verbatim (including
NULLIF,CASE, …). NoteSUM(a)/NULLIF(SUM(b),0)is an expression, not a ratio, and is not double-cast — the authored SQL wins. - S-METRIC-3 Rejected in metric expressions, as structured errors:
window functions (
window_in_metric), subqueries (subquery_in_metric), nested aggregates (nested_aggregate), column references outside any aggregate (bare_column_in_metric), aggregates over no column (SUM(1)→bare_column_in_metric), multi-columnCOUNT(DISTINCT a, b), and references to other metrics — metric references live exclusively in the DATUSderiveextension key (docs/datus-extensions.md#d-derive), never in expression SQL; theexpressionof a derived metric stays a self-contained flattened form.
3. Measures (synthesized)¶
- S-MEASURE-1 Each distinct aggregate call becomes a measure named
{dataset}_{stem}_{suffix}: stem = the aggregate argument rendered and sanitized (lowercase, non-alphanumeric runs →_); suffix =sum,count,count_distinct,average,min,max;COUNT(*)→{dataset}_rows_count. - S-MEASURE-2 Measures dedupe by signature (dataset + aggregate kind +
distinct + normalized argument), within and across metrics. Two different
aggregates that sanitize to the same name are a compile error
(
measure_name_collision) — never silently merged.
4. Column → dataset attribution¶
- S-ATTR-1
dataset.columnresolves exactly; the qualifier must be a dataset name and the column one of its declared fields. Metric and filter columns reference fields (which may themselves be expressions), not raw physical columns. - S-ATTR-2 A bare column resolves iff exactly one dataset declares a
field with that name; zero →
unknown_column, several →ambiguous_column(with candidates). - S-ATTR-3 All columns inside one aggregate must belong to one dataset
(
cross_dataset_aggregateotherwise). - S-ATTR-4
COUNT(*)attributes to the unique dataset of the metric's other measures, or to the model's only dataset; otherwisecount_star_needs_dataset.
5. Joins¶
- S-JOIN-1 Relationships are the only join source. Edges are followed
strictly many→one (
from→to), so walking a path never multiplies the origin's rows. Joins are on the AND-ed equality of the relationship's column pairs, and areLEFT JOINby default (orphan many-side rows survive into a NULL dimension bucket). A relationship may declareINNERvia the Datusjoin_typeextension (drop orphans — attribution semantics; docs/datus-extensions.md#d-join). - S-JOIN-2 A target dataset is reachable iff exactly one simple path
exists (edge-level: parallel relationships are distinct paths). Zero paths
→
no_join_path; several →ambiguous_join_pathwith each candidate path spelled out. Maximum path length: 6 hops. - S-JOIN-3 Self-relationships and cyclic walks are never followed.
6. Fan-out protection & branch assignment¶
The correctness core. Terms: a measure's home is the dataset its columns live on; the required set of a query is every dataset referenced by group-by dims and filters.
- S-FAN-1 Candidate evaluation bases for a measure: datasets that reach
(S-JOIN-2) both the measure's home and the entire required set.
Duplicate-sensitive aggregates (
SUM,AVG,COUNT) are additionally restricted to their home — evaluating them over a fanned-out join would double-count.COUNT DISTINCT,MIN,MAXmay evaluate anywhere. - S-FAN-2 Within one metric, if a common candidate hosts all its
measures, the whole metric evaluates there (preferring a base that is
home to one of the measures). Consequence: a cross-dataset ratio like
SUM(fact.x) / COUNT(DISTINCT dim.k)always evaluates over the fact join — it counts entities observed in the fact — with or without a group-by. - S-FAN-3 Otherwise each measure aggregates at its own home's grain in
its own branch. Branches all produce the requested dim columns and merge
FULL OUTER JOINon them; the k-th branch joins on the null-safe equality ofCOALESCE(m0.key … m(k-1).key)andmk.key(see S-FAN-5), and final keys project asCOALESCEacross branches. Zero group-by keys →CROSS JOIN. Where a dialect cannot express that join — the Postgres family requires an equi condition in a FULL JOIN, MySQL/TiDB have no FULL JOIN, and ClickHouse cannot use aCOALESCEas a join key — the same merge is emitted as akeysCTE (UNIONof every branch's key tuples) that each branchLEFT JOINs back. Both shapes yield identical rows; the choice is invisible in the result and visible in--explain/generated SQL only. - S-FAN-4 A duplicate-sensitive measure whose home has no candidate base
is a
fan_out_riskerror, with a retry hint. The engine never silently emits a double-counting query. - S-FAN-5 A group-key value that is NULL merges as one row across branches,
not one per branch: the merge join is null-safe, rendered as the portable
(a = b OR (a IS NULL AND b IS NULL))(a bare=would leave each branch's NULL bucket unmatched — SQLNULL = NULLis unknown). ClickHouse rejects that OR-expansion as a join key, so there — and only there — it is emitted as the equivalent single predicateIS NOT DISTINCT FROM. - S-FAN-6 A bare count metric (its value is exactly one COUNT / COUNT
DISTINCT measure) reads 0, not NULL, for a group present only in other
branches — a count over no rows is 0. This applies only when the whole metric
is the count; a count embedded in a ratio or expression keeps NULL so it
propagates (and a count denominator never becomes a literal
0divisor). A metric's Datusfill_nulls_withextension overrides this default and applies to any metric kind (docs/datus-extensions.md#d-fill).
7. Dimensions, grains, time¶
- S-TIME-1 Group-by items are
dataset.fieldor a bare unique field name; output columns are named{field}, or{field}__{grain}when a query-time grain is applied. Duplicate output names are an error. - S-TIME-2 Grains (
day,week,month,quarter,year) apply only to fields declareddimension.is_time: true, lowering toDATE_TRUNC(per-dialect idioms where the dialect lacks it: MySQL/TiDB useDATE()/STR_TO_DATE(DATE_FORMAT(...))/YEARWEEKrewrites, ISO weeks starting Monday). - S-TIME-3 A time range is half-open
[start, end)over ISO dates, compiled asfield >= DATE start AND field < DATE endbefore aggregation, against: the explicitly named time dimension, else the single time item in the group-by (metric_timecounts as one), else — with no time item at all — each metric's primary time dimension (S-TIME-5). Only a group-by holding several time items still needs an explicit name (time_range_needs_dimension). - S-TIME-4 OSI core carries no granularity metadata; native grain is
whatever the field expression yields, unless the field declares one via
the Datus
time_granularityextension (docs/datus-extensions.md#d-grain) — then requesting a strictly finer grain is agrain_too_fineerror. Window metrics (period-over-period / rolling / cumulative) are the Datus D-WINDOW extension consuming the S-TIME-5 axis — see window-extension.md; a time spine remains proposed upstream — see rfc-time-semantics.md. - S-TIME-5 Every metric may have a primary (aggregation) time
dimension, resolved as: the metric-level Datus
time_dimension(docs/datus-extensions.md#d-time), else the unique primary time among the metric's datasets — a dataset's being its explicittime_dimensionextension, else its singleis_timefield (this last inference reads no extension and applies in basic mode too). The reserved query namemetric_timegroups/filters by it: in a multi-branch plan each branch substitutes its own primary time column under the shared output name (metric_time/metric_time__{grain}) and branches merge on that name, so unrelated facts align on their respective business time axes. A metric with no resolvable primary time isno_primary_time_dimension; two metrics sharing one aggregation branch with different primary times aremetric_time_conflict. A model field literally namedmetric_timeis shadowed by the reserved name — qualify it asdataset.metric_time.
8. Filters¶
- S-FILTER-1
--wherefilters are scalar boolean SQL over dimension fields, applied before aggregation in every branch. Columns resolve per S-ATTR rules; filter datasets join into each branch like dims. - S-FILTER-2 Rejected in filters: subqueries, window functions
(
unsupported_filter), aggregates (aggregate_in_where; HAVING-style metric filters are a later phase).
9. Datasets¶
- S-DATA-1 A
sourcecontaining whitespace is an inline query (compiled as a derived table); otherwise it is a table reference split on.into up to catalog.schema.table. Identifier quoting is not yet supported. - S-DATA-2 Within a branch, each dataset appears at most once and is aliased by its dataset name (no self-joins in v1).
10. Errors¶
- S-ERR-1 Every compile/query error carries a stable snake_case
code, a human message, the names involved,candidateswhere a bad reference has alternatives, andsuggested_retrywhere a rewrite would succeed.--format jsonemits the full structure. Error text is not a stable API; codes are.