Understand LOD — calculate at one grain, display at another¶
LOD handles calculations that first group records one way, then use those results in a query grouped another way. It lets you define an intermediate calculation separately from the final presentation.
This page explains the concepts and choices, summarizes the key limits, and runs a first query with a complete model before explaining filter scope. When you write a model, look up individual rules in the LOD authoring reference. A complete runnable case is the commerce walkthrough.
What is LOD?¶
LOD stands for Level of Detail. For a metric calculation, it describes what you group by and how detailed that grouping is. This is the calculation's grain.
Consider a small question: how much has each paying user acquired through ads spent on average? In the walkthrough's data through May 31, 2030, that channel has two buyers:
| Buyer | Orders | Lifetime spending |
|---|---|---|
| U01 | 4 | CNY 500 |
| U04 | 1 | CNY 300 |
The answer is (500 + 300) / 2 = 400. First total spending per buyer, then
average those two totals. U01 must not contribute more times to the average
simply because they placed more orders.
There are two groupings here: calculate per buyer, then display per
acquisition channel. The first is the calculation grain; the second is the
display grain. Grouping fields are also called dimensions. The query's
group_by controls the display; an LOD declaration controls the intermediate
calculation that needs its own grouping.
In Dosi, you declare LOD in the semantic model. Queries still select metrics and grouping fields; Dosi generates SQL for the calculation stages. LOD is a DATUS extension to Apache Ossie (formerly OSI). The database does not need a function named LOD.
When do you need LOD?¶
Ordinary aggregation usually suffices when you only need a sum, count or average at the query's grouping. Consider LOD for these requirements:
- Classify or filter by an aggregate. Count purchases per buyer before classifying buyers as once-only or repeat buyers.
- Aggregate intermediate results. Total spending per buyer before averaging buyers within each acquisition channel.
- Compare results at different groupings or scopes. Display daily app sales alongside daily site sales or the app's monthly total.
Before choosing an expression, state which records enter the calculation and how many times each entity contributes. Returning a number is not sufficient to establish that it answers the business question.
Choose among four uses¶
First decide whether you need a reusable field or a metric returned by the query. A field can be classified, filtered, projected into detail rows or aggregated again. A metric is a statistical result selected by a query.
| What you need | Use | Declaration in the model (commerce walkthrough) |
|---|---|---|
| An aggregate per entity that you can use as a field | fixed field | fixed(paid_total, orders.user_id): each buyer's lifetime payments |
| A finer calculation within each display group, followed by an average, maximum or other aggregate | include metric | AVG(include(order_count, orders.user_id)): count orders per buyer, then average |
| A coarser subtotal within the selected records | exclude metric | exclude(paid_total, orders.paid_date): remove date to get the channel's period total |
| A baseline grouped by specified fields, unaffected by ordinary display filters | fixed metric | fixed(paid_total, orders.paid_date): daily whole-site sales |
The first argument of a declaration is an existing metric, called the
base metric: here paid_total is the payment total and order_count is
the order count. The remaining arguments are grouping fields.
fixed appears twice because the result has different uses. Buyer lifetime
spending needs to be a field that another metric averages. Daily site sales
can be a metric displayed next to daily app sales. Decide how you will use
the result before choosing a fixed field or a fixed metric.
include means calculate at a finer grouping first: to show average purchase
frequency by channel, count orders per buyer within each channel, then average.
exclude means remove some grouping: when displaying channel and date, remove
date to obtain the channel total for the selected period.
These definitions can share one model. They cannot necessarily share one query; see the combination limits.
Check the limits before designing a query¶
LOD definitions can coexist in one model even when a query cannot combine them. Keep these limits in mind before choosing report columns:
- No nested LOD. A base metric and its grouping keys cannot depend on another LOD value, even indirectly.
- Include metrics form a separate query from their own fact's other metrics. They cannot share a query with that fact's ordinary metrics, exclude/fixed metrics or fields that read LOD; another fact's metrics are fine. Include metrics queried together need the same base dataset and ordered dimension list.
- Re-aggregating fixed fields changes the counting unit. Query these metrics separately from raw-row metrics; only one fixed-key family can supply re-aggregated values in a query. Using a fixed field only for grouping or filtering still permits ordinary metrics.
- A keyed fixed metric requires its exact keys in
group_by. A time grain such as:dayis not the same key. To aggregate fixed results to a coarser grouping, use a fixed field and an ordinary aggregate. - Window metrics and
time_rangescannot be combined with LOD. Execute separate queries. Exclude/fixed metrics also cannot share a query with LOD fields. Metrics of another fact table can share a query with any LOD metric: each fact aggregates on its own and the results align on the grouping fields. - Use datus mode. Basic mode does not execute an equivalent LOD calculation. LOD also does not automatically fill missing dates or entities.
The complete limits and alternatives distinguish unsupported combinations from supported ones.
Run a minimal model¶
Start with two buyers and three orders. Save this SQL as lod-minimal-seed.sql:
CREATE TABLE orders (
order_id VARCHAR PRIMARY KEY,
user_id VARCHAR,
paid_amount DECIMAL(12,2)
);
INSERT INTO orders VALUES
('O01', 'U01', 100),
('O02', 'U01', 300),
('O03', 'U02', 200);
Save the following complete model as lod-minimal.yaml. It needs no other model files.
paid_total sums payments; buyer_lifetime_paid fixes that sum by buyer;
mean_lifetime_paid averages the resulting buyer values.
version: 0.2.0.dev0
semantic_model:
- name: lod_minimal
datasets:
- name: orders
source: orders
primary_key:
- order_id
fields:
- name: order_id
expression:
dialects:
- dialect: ANSI_SQL
expression: order_id
datatype: String
custom_extensions:
- vendor_name: DATUS
data: '{"v":"1.11","is_dimension":false}'
- name: user_id
expression:
dialects:
- dialect: ANSI_SQL
expression: user_id
datatype: String
custom_extensions:
- vendor_name: DATUS
data: '{"v":"1.11","is_dimension":false}'
- name: paid_amount
expression:
dialects:
- dialect: ANSI_SQL
expression: paid_amount
datatype: Decimal
custom_extensions:
- vendor_name: DATUS
data: '{"v":"1.11","is_dimension":false}'
- name: buyer_lifetime_paid
expression:
dialects:
- dialect: ANSI_SQL
expression: SUM(orders.paid_amount)
datatype: Decimal
custom_extensions:
- vendor_name: DATUS
data: '{"v":"1.11","is_dimension":false,"lod":{"expr":"fixed(paid_total, orders.user_id)"}}'
metrics:
- name: paid_total
expression:
dialects:
- dialect: ANSI_SQL
expression: SUM(orders.paid_amount)
- name: mean_lifetime_paid
expression:
dialects:
- dialect: ANSI_SQL
expression: AVG(orders.buyer_lifetime_paid)
With Dosi and the DuckDB CLI installed, run these commands in the directory containing both files. Use a new database file:
duckdb lod-minimal.duckdb < lod-minimal-seed.sql
dosi validate --model lod-minimal.yaml --strict
dosi query --model lod-minimal.yaml --metrics mean_lifetime_paid \
--execute --db lod-minimal.duckdb --format json
The returned mean_lifetime_paid is 300: U01 contributes 400 once and U02
contributes 200 once, so (400 + 200) / 2 = 300. Averaging the three raw order
amounts would return 200 and answer a different question. Even though the fixed
value is available on each order, its re-aggregation counts each buyer once.
The fixed declaration belongs to the field's custom_extensions; its first
argument names the existing base metric. The field's ordinary expression
repeats that base metric's complete SQL. data is a JSON-encoded string.
For include, exclude and fixed metrics, put the declaration under
derive: {type: "lod", expr: ...} in the metric's DATUS JSON. Include's core SQL
wraps the base aggregate in the declared outer aggregate; exclude/fixed metrics
use a SUM wrapper, even if their base is COUNT or MAX. For example,
fixed(paid_total, orders.paid_date) uses SUM(SUM(orders.paid_amount)).
Over a compose, ratio or expression base the wrapper holds the base's whole
expression, as in SUM(COALESCE(SUM(orders.amount), 0) - COALESCE(SUM(refunds.amount), 0))
for exclude(net_revenue).
This nested SQL represents calculation stages for Dosi to validate and lower;
it is not SQL to execute directly against the database.
See the expression rules and the
commerce model for complete examples of these forms.
Filters decide which records enter the calculation¶
Grouping determines how records are aggregated; filters determine which records participate. Fixed means a fixed grouping, not unconditional access to all history.
A query has two kinds of conditions:
- Context (
context_filter, CLI--context-filter) and the time range (time_range) restrict the calculation input, including fixed calculations. Each calculation reads them on the dataset it aggregates: the time range lands on that dataset's own time field, and a dataset without one is read whole, with a warning. Acontext_filtercondition applies where that dataset can read it through a declared relationship or key pairing; one it cannot read is skipped and reported in the result'swarnings. Define a period that differs from the query's in its base metric (cross-table rules). - Ordinary filters (
where_sql, CLI--where) select which rows are displayed. They narrow ordinary metrics and include/exclude inputs, but not the input offixed.
In the app sales report, April 2, 2030 has CNY 160 of app sales and CNY 500 of site sales. App sales for all of April total CNY 570. One report row needs these three scopes:
| Result | Records it needs | Use |
|---|---|---|
| Daily app sales: 160 | App orders on April 2 | Ordinary payment metric displayed by date and channel |
| Daily site sales: 500 | All channels' orders on April 2 | Metric fixed by date, preserving the whole-site scope |
| App monthly total: 570 | App orders throughout April | Exclude date while retaining channel |
The query supplies April as its time range and app as an ordinary display
filter. The time range scopes all three calculations. The app filter scopes
the daily numerator and channel subtotal, but not the fixed daily site
baseline. App's share of that day's site sales is therefore 160 / 500 = 0.32.
The walkthrough's question 4
runs this query and shows its results.
Choose a filter location by its purpose:
| Purpose | Location | Effect on a fixed calculation |
|---|---|---|
| Restrict the records or time used for calculation | context_filter, time_range |
Restricts the input; the time range lands on each dataset's own time field, a condition only where that dataset can read it |
| Select ordinary field values to display | where_sql |
Does not narrow the fixed input; does narrow include/exclude inputs |
| Select by a computed fixed value or selected metric | where_sql |
Runs after the corresponding value has been computed |
The last two rows both use where_sql and need no extra marking. Dosi decides
when a condition runs from the fields it references: a condition on ordinary
fields selects display records before calculation; a condition on a fixed
field or a selected metric runs after those values exist. For example,
orders.channel = 'app' is the first kind and
orders.buyer_order_count >= 2 is the second.
Restrictions in a dataset's source always apply. Define independent periods
through parameters. See
detailed filter rules for mixed AND/OR
predicates, related dimensions and time encodings.
Turn a business question into a model¶
Write down these five decisions in ordinary language before choosing syntax:
- What does one source row represent? Declare actual primary keys. A repeating identifier must not be declared unique merely to pass validation.
- Which entities belong in the result? Distinguish no activity from being outside the population. Decide whether missing records mean zero or NULL.
- What do you calculate first, and what do you display? Prefer a fixed field when the intermediate value will be classified, filtered or aggregated.
- How often should each entity contribute? Per-person, per-record and per-day averages have different denominators. Define each side of a ratio.
- Which conditions affect calculation, and which select results? Define separate observation windows and explicit key mappings for other datasets.
Choose the simplest expression that preserves these decisions. A total divided by an entity count equals an average over entities only when populations and weights agree. Check the model against independent raw-data calculations, then change the data and query parameters and check again.
Next steps¶
- Run the commerce walkthrough: one model, five questions, with expected results to compare against.
- While writing a model, use the LOD authoring reference: base-metric requirements, declaration format, parameterized windows, cross-dataset calculations, which query combinations must be split, and validation.