Guide: Datus extensions¶
Two behaviors that Apache Ossie (formerly OSI) leaves undefined, which you can control per-model:
- Join type — whether rows with no match are kept or dropped when a metric joins to a dimension.
- Null fill — show
0(or any number) instead of a blank for a group with no data.
You set these inside a model's custom_extensions, an official Ossie field. Your
model stays 100% valid Ossie — tools that don't know Datus simply ignore these,
and dosi-engine only uses them to refine behavior at points Ossie doesn't specify.
The precise contract is in datus-extensions.md; this page
is the how-to.
All Datus extensions share one shape — a custom_extensions entry whose
vendor_name is DATUS and whose data is a small JSON string:
1. Choose how joins handle unmatched rows¶
The problem¶
Say you measure revenue on line_items and want it broken down by
orders.status. Some line items point to an order that isn't in your orders
table (a late-arriving row, a soft-deleted order, a data-quality gap). What
should happen to their revenue?
By default dosi-engine keeps them (a LEFT JOIN): the unmatched revenue lands in
a row where status is blank (NULL). That is the right answer for
reconciliation — every dollar is accounted for, even the orphaned ones.
But if you're doing attribution — "revenue by status" should only count revenue that actually has a status — you want those orphans dropped instead.
The fix¶
Declare the relationship INNER with the join_type option:
relationships:
- name: line_items_to_orders
from: line_items
to: orders
from_columns: [order_id]
to_columns: [order_id]
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.0", "join_type": "inner"}'
What changes¶
Same query, orphan revenue of 50:
join_type |
status |
revenue |
|---|---|---|
left (default) |
paid | 300 |
| (blank) | 50 ← orphans kept | |
inner |
paid | 300 |
| (orphan row dropped) |
Which to pick¶
| Use… | When |
|---|---|
left (default, or omit) |
Reconciliation / audit — no row should silently disappear. |
inner |
Attribution — a measure should only count where the joined entity exists. |
It's a property of the relationship, so the choice is consistent across every query that follows that join — not something each query has to remember.
2. Show a number instead of a blank for empty groups¶
The problem¶
You ask for order_count and signups by city. A city had signups but no
orders this period. Its order_count comes back blank (NULL) instead of
0. On a dashboard that reads as "no data" when the true answer is "zero
orders".
dosi-engine already fills a plain count metric with 0 automatically (a count
of nothing is zero). But a SUM — say revenue — stays blank by default,
because "sum of no rows" is genuinely undefined (is it 0, or unknown?). You
decide per metric.
The fix¶
Add fill_nulls_with to the metric:
metrics:
- name: revenue
expression:
dialects:
- dialect: ANSI_SQL
expression: SUM(orders.amount)
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.0", "fill_nulls_with": 0}'
What changes¶
revenue and signups by city, where Denver has signups but no orders:
city |
revenue (default) |
revenue (fill_nulls_with: 0) |
|---|---|---|
| Austin | 900 | 900 |
| Denver | (blank) | 0 |
fill_nulls_with works for any metric — SUM, a ratio, an expression — and any
number (0 is by far the most common). It also overrides the automatic count
fill if you want a count filled with something other than 0.
One thing it won't do¶
It fills a metric's final value, never a piece inside it. A ratio like
revenue / order_count is filled as a whole; the order_count in its
denominator is never quietly turned into 0 (that would divide by zero). You
get a filled ratio, computed safely.
3. Name each table's business time axis¶
The problem¶
A query says "revenue and inventory moves, by month, last quarter" — but
which column is "time"? orders has order_date and ship_date; the
inventory table has move_date. Without a declaration, the engine refuses to
guess: you must spell out --group-by orders.order_date:month and can never
align two tables' different time columns in one query.
The fix¶
Declare the primary time dimension — once per dataset, overridable per metric:
datasets:
- name: orders
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.1", "time_dimension": "order_date"}'
fields:
- name: order_date
dimension: { is_time: true }
- name: ship_date
dimension: { is_time: true }
metrics:
- name: shipped_revenue # same SUM, but on the shipping axis
expression:
dialects: [{ dialect: ANSI_SQL, expression: SUM(orders.amount) }]
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.1", "time_dimension": "ship_date"}'
A dataset with exactly one is_time field doesn't even need the
extension — that field is the primary time automatically.
What changes¶
The reserved query name metric_time now works:
Each metric is truncated on its own table's primary time column and the
results align on the shared metric_time__month output. A time range with no
--time-dimension (and no time column in the group-by) filters each metric's
primary time instead of erroring.
Three companions¶
time_granularity(on a time field): the grain the column is stored at.'{"time_granularity": "month"}'on a monthly-snapshot column makes a:dayrequest a cleargrain_too_fineerror instead of silently wrong numbers.time(on a time field): what the column physically holds, when it is not a real date.'{"time": {"format": "%Y%m%d"}}'on a'20260101'VARCHAR is what lets the engine group it at all — and it keeps the time filter a baredt >= '20260101', so partition pruning survives. See D-FORMAT.dataset(on a metric): aCOUNT(*)names no column, so in a multi-dataset model the engine cannot attribute it.'{"dataset": "chat_record"}'pins it — only where the SQL itself is silent; aggregates that name columns keep their own datasets.
4. Compare periods and accumulate over time¶
The problem¶
"How does this month compare to last month?" "What's the 3-month moving
average?" "Revenue year-to-date?" — none of these is expressible as one Ossie
aggregate, and writing LAG(...) OVER (...) inside a metric expression is
rejected outright (window_in_metric): raw window SQL can't be validated,
recomputed across groupings, or re-ranged safely.
The fix¶
Declare the derivation on the metric with the window key; the metric's
own expression stays the plain base aggregate:
metrics:
- name: revenue_mom_growth # month-over-month growth
expression:
dialects: [{ dialect: ANSI_SQL, expression: "SUM(orders.amount)" }]
custom_extensions:
- vendor_name: DATUS
data: '{"v": 1, "window": {"type": "pop", "offset": "1 month"}}'
- name: revenue_3m_avg # 3-month moving average
custom_extensions:
- vendor_name: DATUS
data: '{"v": 1, "window": {"type": "rolling", "function": "avg", "periods": 3}}'
- name: revenue_ytd # year-to-date total
custom_extensions:
- vendor_name: DATUS
data: '{"v": 1, "window": {"type": "cumulative", "function": "sum", "reset": "year"}}'
Query them like any metric — group by the time axis with a grain
(orders.order_date:month or metric_time:month); every other group-by
dimension partitions the window (MoM within each region, for example):
dosi query --metrics revenue,revenue_mom_growth,revenue_ytd \
--group-by orders.region --group-by metric_time:month \
--start-time 2025-05-01 --end-time 2025-11-01
What changes¶
Period-over-period compiles to a calendar-correct self-join (a month with no prior month reads NULL — never "the previous existing row"), and the engine automatically loads the lookback: a May start fetches April, a YTD start mid-year fetches back to January 1, and the output is trimmed back to what you asked for.
One query can mix all of the above — MoM next to a rolling average next to
YTD and QTD — with a single caveat: a never-resetting running total can't
share a query with lookback/reset metrics when a start time is set (the
widened scan would change what it accumulates; the engine tells you so).
Rolling metrics also take "require_full_window": true to return NULL for
the first buckets instead of averaging a partial frame.
Two gotchas¶
- The time axis must be in the group-by with a grain — forgetting it is a structured error whose retry hint names the exact item to add.
- Give offsets a
--start-time: without one the first period in your data simply has no predecessor and reads NULL (correctly).
For the full surface — the general offset/frame form the sugars desugar
to, value|delta|percent_change|ratio calculations, reset semantics, and the
v1 restrictions — see window-extension.md.
Let the query pick the width¶
If you find yourself declaring revenue_1m_avg, revenue_3m_avg and
revenue_6m_avg, declare the width as a query-time parameter instead:
add a params entry and put {"param": "n"} where the number was.
- name: revenue_moving_avg # n-month moving average
custom_extensions:
- vendor_name: DATUS
data: '{"v": "1.5", "params": [{"name": "n", "type": "int", "default": 3, "min": 1, "max": 12}], "window": {"type": "rolling", "function": "avg", "periods": {"param": "n"}}}'
One metric, three columns (revenue_moving_avg__n_1 and friends). A query
that binds nothing gets the default, so the metric behaves exactly like the
fixed one it replaces. The same works for an offset count, an ntile bucket
count, an nth_value position, and a literal inside a derive filter — the
declaration rules, the domain (which values a query is allowed to ask for),
and the output contract are in
parameterized-metrics.md.
Quick reference¶
| Goal | Where | Option | Values | Default |
|---|---|---|---|---|
| Drop vs keep unmatched join rows | relationship | join_type |
"left", "inner" |
"left" |
| Fill empty-group metric value | metric | fill_nulls_with |
any number | count → 0, else blank |
| Name the business time axis | dataset / metric | time_dimension |
field name (metric: ds.field too) |
the single is_time field, else none |
| Declare a column's stored grain | time field | time_granularity |
day…year |
unknown, any grain allowed |
| Declare how a time column is stored | time field | time |
format (date, %Y%m%d, %Y%m, …) + storage |
assumed native DATE/TIMESTAMP |
Pin a COUNT(*)'s home table |
metric | dataset |
dataset name | SQL-derived, else error |
| Derive PoP / rolling / cumulative | metric | window |
pop | rolling | cumulative | offset/frame |
plain aggregate |
| Let the query choose a width / literal | metric | params |
typed parameters filling window or derive holes |
fixed at modeling time |
| Record that a metric derives from others | metric | derive |
filter | compose | lod (+ window.base) |
self-contained metric |
| Name a metric's measure explicitly | metric | measure |
{"name": …} |
sanitized stem |
| Use a per-entity aggregate as an axis | field | lod |
{"expr": "fixed(base, dims…)"} |
the field is a stored column |
| Align two facts on a shared dimension without joining them | relationship | cardinality |
many_to_many |
the relationship is a many→one join |
derive and measure have no walkthrough here on purpose: they are written
by the modeling tool, not by hand — the metric's expression and its derive
payload have to stay in step, and the engine enforces that. See
datus-extensions.md for what they carry and
what a consumer gets out of them.
lod is the one option that changes what a field is: its value is a
per-entity aggregate rather than a stored column, so "how many users were
active exactly N days" becomes an ordinary group-by. Because the value depends
on the filter context, where each predicate applies is part of the contract —
read datus-extensions.md before authoring one.
Good to know¶
- Your model stays valid Ossie.
custom_extensionsis a standard Ossie field;dosi validateand the upstream Ossie validator both pass. Non-Datus tools ignore theDATUSentry. - Absent = today's behavior. Add these only where you want to change the default; everything else is unaffected.
- Typos are caught, not ignored. A malformed
datuspayload (bad JSON, orjoin_type: "outer", or a non-number fill) is a clear compile error, so a mistake fails loudly instead of silently doing the default. - See the plan.
dosi query --explainshows each join's kind (left/inner), so you can confirm the extension took effect. "v"is optional — but worth stamping. Leave it out and everything behaves exactly as shown above. Include it ("v": "1.1", matching whatdosi inforeports) and the engine can tell you when the two of you disagree: it warns if your model uses an option newer than the version you declared, and warns naming the options it had to drop if your model is newer than the engine. Rundosi infoto see the version an engine implements and every option it reads.- Extensions only apply in datus mode (the default). Running with
--osi-basic— strict standard Ossie — ignores theDATUSentries and prints a warning naming the default that applies instead; the model still loads and queries. See cli.md.
For the exact JSON schema, versioning, and precedence rules, see datus-extensions.md.