Skip to content

LOD authoring reference

This page organizes the rules by the questions that come up while writing a model or tracking down an error. If LOD is new to you, read Understand LOD first; it covers the concepts, how to choose among the four uses, and the basic filter distinction. Field and metric names in the examples come from the commerce walkthrough.

Terms used throughout this page:

  • Base metric: the existing metric named by an LOD declaration's first argument, such as paid_total in fixed(paid_total, orders.user_id).
  • Fixed field: a field declaring fixed; the result can be grouped, filtered and aggregated again.
  • LOD metric: a metric declaring include, exclude or fixed; the result is returned directly by the query.
  • Context: a query's context_filter and time_range, which restrict the source records used for calculation. Across datasets, only conditions readable through declared relationships or key pairings propagate; see across tables. In contrast, an ordinary filter (where_sql) selects display rows and does not narrow fixed inputs.
  • Host: the dataset on which a fixed field is defined. It decides which rows the result has; see across tables.

What can be a base metric?

A base must resolve to one aggregate over one dataset, or a filter derivation of that aggregate. Supported functions are SUM, COUNT, COUNT with DISTINCT, AVG, MIN and MAX. A bare COUNT(*) in a multiple-dataset model needs an explicit home through D-DATASET. Counting a declared non-null identifier also makes ownership explicit.

A LOD metric's base may also be built from several such aggregates: a compose, a ratio or an expression metric. The LOD evaluates each aggregate at its grain and applies the base's own arithmetic, fill included, so exclude(net_revenue) over revenue − refunds is the whole query's revenue minus its refunds, even over two facts; dividing by it gives a share of the total. The authored expression nests the base's whole expression inside the outer aggregate, for example SUM(COALESCE(SUM(orders.amount), 0) - COALESCE(SUM(refunds.amount), 0)). Each aggregate is bucketed on its member's time axis, as in the compose itself, and a fixed level of detail applies the query's time range on those axes; a compose over the level of detail keeps them. An include over aggregates of one dataset on two axes is refused, since it rolls each dataset up once.

An include over a base spanning several datasets computes each dataset's aggregates on its own rows, per display row and declared dimension, then merges them on those groupings before applying the base's arithmetic. A grouping that any of the datasets has is one inner row. An aggregate whose dataset has no rows there is NULL, as a missing member of a cross-fact compose is at the display grain, unless the base's fill replaces it. The outer aggregate skips NULLs. Without a fill, AVG(include(net_revenue, orders.customer_id)) leaves out a customer with orders but no refunds; with fill: 0 that customer counts as revenue − 0. Each declared dimension must be every dataset's own: a field of one of them, paired through D-CONFORM with a field of each other one. Over several datasets, exclude() and fixed() take no grouping fields and compute over the whole query; for a keyed one, put the LOD on each member and compose the results.

A fixed field's base is always a single aggregate. A window result, another LOD metric, or a compose with either as a member cannot be a base. Neither the base nor its grouping fields may depend on a LOD field, even indirectly through an ordinary calculated field. Changing declaration order does not permit nesting. An include base must not share its name with a field on its own dataset.

Fixed fields: choose the grouping and reuse the value

A fixed field uses fixed. Its first argument is the base metric name; subsequent arguments are fixed grouping fields, separated by commas. Those fields belong to the dataset containing the fixed field. Unqualified names resolve there too, but explicit dataset names make ownership clear. Several fields form one combined grouping, not separate calculations per field.

The walkthrough's fixed(paid_total, orders.user_id) totals payments per user. A same-dataset calculation may omit every grouping field to compute one value for its whole calculation population. A cross-dataset calculation may not. A field cannot use include/exclude or wrap its fixed declaration in an aggregate.

Queries can read, group by, filter on or sort the field; ordinary calculated fields can read it too. When an ordinary metric aggregates a fixed field again, each fixed group contributes once within each display group. The ads-channel buyer mean in Understand LOD therefore averages two buyers, not five repeated order-level values. An entity appearing in several display groups contributes once in each one; those groups' entity counts need not be additive.

What the aggregate reads What contributes once
A stored column or raw-row count Each source record
A fixed field Each distinct fixed group within the display group
A calculated field reading only LOD values and constants The fixed groups of those LOD values
A calculated field also reading a stored column Each source record
A filter metric whose condition reads a LOD value What its base aggregates; a filtered row count still counts source records

Using a fixed field only for grouping does not change raw-row metrics' weights. Fields with different fixed groupings may be displayed or filtered together, but a query may reaggregate only values with the same fixed grouping fields. It cannot also request a metric weighted by source records. This holds even when two metrics spell one expression: a filter metric over a row count counts source records, while the same CASE WHEN written directly as a metric reads only LOD values and counts fixed groups.

For a same-dataset calculation, a NULL key forms its own group and matches that group's fixed value. Distinct counting still excludes NULL, so the distinct key count is not the number of all fixed groups including the NULL group. Matching a combined grouping preserves every component.

LOD metrics: return the calculation as a metric

A metric declaration also takes the base metric name first, followed by dimension fields. These fields must belong to the base metric's dataset and must be qualified with their dataset names.

Type Declaration requirement Grouping and calculation
include At least one dimension; exactly one outer aggregate Add the declared dimensions to the display grouping, compute the base, then apply the outer aggregate
exclude Dimensions optional; no outer aggregate in the declaration Remove the declared dimensions and recompute the base; omitting dimensions removes all display grouping
Metric fixed Grouping fields optional; no outer aggregate in the declaration Compute at exactly those fields; omitting them produces an overall baseline

The include outer function may be SUM, COUNT, AVG, MIN or MAX; an outer DISTINCT is not accepted. The walkthrough's AVG(include(order_count, orders.user_id)) computes buyer order counts, then averages them. Without group_by, include still computes the declared finer grouping before aggregating to an overall result. A display time grain applies inside that finer calculation. Ordinary filters and context both scope its input.

Exclude also honors ordinary filters and context. It recomputes at the coarser grouping instead of adding displayed values, so distinct counts must be recalculated there too. Every field a keyed exclude names must be in the query's group_by, at any time grain: excluding a field the query does not group by would remove nothing and leave the base, so the query is refused with lod_dimension_not_grouped. A keyless exclude needs no grouping; without group_by, it is the base over selected records.

Metric fixed honors context and ignores ordinary display filters. Every fixed field must appear unchanged in the query's group_by, without a time grain, or the query is refused with lod_dimension_not_grouped. The metric catalog lists the fields a keyed exclude or fixed names as required_dimensions, and the refusal lists the ones missing as missing_dimensions. A keyless overall baseline also works without grouping. To aggregate fixed results further, use a fixed field and an ordinary aggregate; wrapping a fixed metric declaration in another aggregate is unsupported.

Ordinary, exclude and fixed metrics on one fact may share a query, with compose for ratios or other arithmetic. Multiple include metrics may share a query when their base datasets and ordered dimension list agree; their bases and outer functions may differ. Include cannot share that query with ordinary metrics of a dataset its base reads, exclude/fixed metrics or LOD fields actually read by the query.

Where do declarations belong?

A field or metric retains its normal expression.dialects list with an ANSI_SQL expression. Metric SQL references declared semantic fields using dataset-qualified names, not undeclared physical columns.

Put the extension in the field or metric's custom_extensions, with vendor_name: DATUS. data is a JSON-encoded string. Its v member is the extension version string; 1.11 supports this page's rules. Optional requires lists capability keys: lod for fixed fields and derive for derived metrics.

Definition Location inside the JSON object
Fixed field lod.expr contains the fixed declaration
LOD metric derive.type is lod; derive.expr contains include, exclude or fixed
Filtered base metric derive.type is filter; derive.base names the base and derive.where contains the predicate
Arithmetic over metrics derive.type is compose; derive.expr uses metric names in an arithmetic expression

This is the walkthrough's buyer lifetime-spending field, placed in the orders dataset's fields list. The full model also defines its paid_total base.

- 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)"}}'

LOD metrics go in the model's metrics list. This is the average number of orders per buyer within each channel, based on order_count (COUNT(DISTINCT orders.order_id)):

- name: mean_orders
  expression:
    dialects:
      - dialect: ANSI_SQL
        expression: AVG(COUNT(DISTINCT orders.order_id))
  custom_extensions:
    - vendor_name: DATUS
      data: '{"v":"1.11","derive":{"type":"lod","expr":"AVG(include(order_count, orders.user_id))"}}'

Why are both SQL and an LOD declaration required?

expression describes what is calculated; the DATUS declaration describes its grouping and stages. They must agree. Core SQL must be fully expanded, not a metric name, a constant placeholder or a fixed/include/exclude declaration.

Definition Required core SQL
Fixed field The base metric's complete aggregate SQL
Include metric The declared outer function around the base metric's aggregate SQL
Exclude or fixed metric SUM around the base metric's aggregate SQL, even when the base uses a different function
Ordinary reaggregation of a fixed field An ordinary aggregate over the field reference
Compose metric Each metric name replaced by that metric's complete core SQL

The lifetime-spending field above has core SQL SUM(orders.paid_amount). The ordinary buyer-mean metric uses AVG(orders.buyer_lifetime_paid); the include metric in the previous section is AVG(COUNT(DISTINCT orders.order_id)).

Exclude and fixed metrics are the most confusing case. This is the walkthrough's daily site sales:

- name: site_amount
  expression:
    dialects:
      - dialect: ANSI_SQL
        expression: SUM(SUM(orders.paid_amount))
  custom_extensions:
    - vendor_name: DATUS
      data: '{"v":"1.11","derive":{"type":"lod","expr":"fixed(paid_total, orders.paid_date)"}}'

The inner SUM(orders.paid_amount) is the SQL of the base metric paid_total. The outer SUM is a fixed wrapper meaning "take the base metric's result once more at another grain". It is SUM even when the base uses COUNT or MAX: a fixed metric based on order_count is written SUM(COUNT(DISTINCT orders.order_id)). The outer SUM exists only so Dosi can check that the SQL agrees with the declaration; it does not add baseline values together. Nested aggregate SQL cannot run directly as ordinary SQL. Dosi uses the declaration to split it into valid stages. A mismatch returns derive_expression_mismatch.

A filtered base's core SQL expands its predicate with parameter defaults; it does not retain parameter markers. A filtered row-count COUNT uses a conditional expression returning 1 for a matching record and NULL otherwise. Do not replace this 1 with an identifier merely because the SQL is equivalent. For a sum or distinct count of values, return the aggregated value on a match and NULL otherwise, then apply the aggregate. A fixed field repeats that filtered aggregate SQL and references the filtered metric by name in lod.expr.

Metric names belong in derive.expr, not in place of core SQL. Keep casts and zero-denominator handling consistent between compose declarations and their fully expanded core expressions.

Different periods: define independent parameterized windows

When a fixed field needs its own period, define a filtered base metric with parameters for that period, then reference it from the field. Put params beside derive in the base metric's DATUS JSON, not on the fixed field.

These are the walkthrough's two definitions for reference-period payments. First the parameterized base metric, in the metrics list:

- name: reference_paid_amount
  expression:
    dialects:
      - dialect: ANSI_SQL
        expression: SUM(CASE WHEN orders.paid_date_key >= 0 AND orders.paid_date_key < 99991231 THEN orders.paid_amount END)
  custom_extensions:
    - vendor_name: DATUS
      data: '{"v":"1.11",
        "derive":{"type":"filter","base":"paid_total",
          "where":"orders.paid_date_key >= :reference_start AND orders.paid_date_key < :reference_end"},
        "params":[{"name":"reference_start","type":"int","default":0,"min":0,"max":99991231},
                  {"name":"reference_end","type":"int","default":99991231,"min":0,"max":99991231}]}'

Then the fixed field on users that references it, one value per user:

- name: reference_paid
  expression:
    dialects:
      - dialect: ANSI_SQL
        expression: SUM(CASE WHEN orders.paid_date_key >= 0 AND orders.paid_date_key < 99991231 THEN orders.paid_amount END)
  datatype: Decimal
  custom_extensions:
    - vendor_name: DATUS
      data: '{"v":"1.11","is_dimension":false,"lod":{"expr":"fixed(reference_paid_amount, users.user_id)"}}'

The 0 and 99991231 in the core SQL are the parameter defaults substituted in, as the previous section requires. A query selects April with --param reference_start=20300401 --param reference_end=20300501.

Each parameter declares name, type and default. Types int and float may set min/max; string may set allowed or leave its domain open. Allowed values and numeric bounds are mutually exclusive. With neither, datus_param_unbounded is a compilation warning, not by itself a validate --strict failure. Declare known business domains. Integer bounds do not validate date codes, and D-PARAM has no date type.

A predicate references a parameter with a colon followed by its name. Supply values through query params or CLI --param, not SQL concatenation. Unbound parameters use defaults.

One parameter value goes to every metric in the query that declares a parameter of that name: metrics the query selects directly, and base metrics behind the fixed fields the query reads. Two periods you want to control separately therefore need different names, such as the walkthrough's reference_start for the reference period and observation_start for the observation period; sharing one name forces both periods to be the same. The value must also satisfy the type and domain of every declaration of that name. A fixed field's calculation accepts one value per parameter, never a list.

The value reaches a fixed field's base however the query reads the field: through grouping, filtering, detail projection or a calculated field that depends on it. If no metric in the query declares the name, the query returns unknown_metric_param, usually because of a misspelling. A wrong type, a value outside the domain or a list for a fixed field's parameter returns param_out_of_domain.

When display records and a fixed field observe different periods on the same dataset, put the display date predicate in ordinary where_sql and the field's period in its parameterized base. A query time_range enters the fixed calculation too, intersecting the periods. A field's predicate adds constraints; it does not override source restrictions, time range or context.

For automatic query time-range routing, declare a native time dimension or use D-FORMAT for its encoding. Ordinary integer date-code fields may also be compared directly with integer parameters without being declared as time axes.

Across tables: decide where the result belongs

A fixed field may aggregate records from another dataset. The dataset containing the field is its host. It decides which records can appear in the result; entities present only in the aggregated dataset do not add host rows. Fixed grouping fields still belong to the host, and each needs an explicit correspondence to the other dataset.

Declared correspondence How the aggregated dataset obtains a grouping field
Direct many-to-one relationship between the two, in either direction Paired key column
A many-to-one path from the aggregated dataset to the host Host field through that path
D-CONFORM pairing Paired field in the other fact

Matching names do not establish correspondence. Missing or conflicting mappings, or a cross-dataset fixed field without grouping keys, are rejected. A regular relationship uses from, to, from_columns and to_columns, defaulting to many-to-one. D-CONFORM uses the same column-pair lists with DATUS cardinality: "many_to_many". It aligns results without joining fact records; see D-CONFORM.

Without matching records, a cross-dataset COUNT is 0 and other aggregates are NULL. A cross-dataset NULL key matches nothing, unlike a same-dataset NULL group. SUM returns NULL for both no rows and all-NULL values. Use an ordinary calculated field with COALESCE when the business meaning specifically requires zero.

The query's time range lands on the aggregated dataset's own time field, whatever the two date columns are called and whether or not they are paired; a dataset with no time field is read whole, with a time_axis_missing warning. A context_filter conjunct applies wherever the aggregated dataset can read it, including many-to-one paths or paired columns. One it cannot read is skipped with a context_skipped warning, and one no part of the query can read is refused (context_unreadable). A condition that reaches the other dataset only through a paired column stored in another time format returns not_implemented; put the period in the time range instead. Ordinary where_sql still does not narrow a fixed input, so a query that needs the field over all of its history puts its own dates there (S-FILTER-2).

Classify two facts independently, then align them

Two facts recording business activity may each define a fixed field and declare those fields as a common D-CONFORM dimension. Each computes its own field and metrics before their grouped results align. Either paired dimension name can be used, including when requesting only the other fact's metric. Both sides' groups are retained; missing COUNT defaults to 0 and SUM stays NULL unless another fill policy is declared. Adding the two counts does not form a distinct union: the same entity may be present on both sides.

This differs from a fixed field that directly aggregates the other fact. Requesting both facts' metrics grouped by that host field returns fan_out_risk. Ordinary relationship join columns cannot read LOD values; D-CONFORM pairing is not that kind of detail join.

Detailed filter rules

A context_filter cannot read a LOD value because it scopes the records used to calculate that value. Conditions on fixed fields apply after their values exist and never feed back into any fixed input. Selected LOD metrics may be used in result filters, after calculation but before sorting and limit; they cannot be grouping dimensions. Filtering an unselected metric returns result_filter_metric_not_selected. One result predicate mixing a metric with an ungrouped field returns result_filter_unprojected.

Each AND conjunct is placed separately. An OR, NOT or comparison mixing raw and fixed fields executes as a whole after the fixed value is available; do not split an OR into separate filters. Relationships needed only for display dimensions or ordinary filters affect displayed records, not fixed inputs, even for INNER relationships. Put a related condition in context when matching records should define the calculation population.

Time ranges are half-open. A fixed time grouping key keeps its original value and storage encoding internally; a display time grain applies when grouping the fixed results again. Include applies that grain inside its finer calculation. A LOD field, or a field depending on one, must not declare dimension.is_time: true. A date datatype is allowed but does not make it an axis for query time ranges.

Query the LOD definitions

Use dosi query to select metrics and grouping fields. The query properties group_by, where_sql, context_filter, time_range, order_by, limit and params correspond to CLI --group-by, --where, --context-filter, --start-time/--end-time, --order, --limit and --param. --metrics supplies metric names. There is no additional query-side LOD language.

Use dosi select for detail rows, with --from for the root dataset and --fields for projected fields. It can read, filter or sort fixed fields on that root, including ones with bases on other datasets. Reading a joined dataset's LOD field or a calculated field depending on it is unsupported; make that dataset the root if its records are the required population.

Detail results preserve root-row multiplicity. --distinct removes duplicate output rows, not records used in LOD calculations. Ordinary fields reachable through many-to-one relationships can be read. Traversing to the many side is refused to avoid multiplying records. Detail field bases use the same --param binding rules.

Limits: which calculations need separate queries?

Restrictions apply to fields and metrics actually read by one query, not to whether their definitions can coexist in a model. Preserve the business population and denominators when splitting queries.

These combinations can share one query:

Combination in one query Notes
Raw-row metrics with fixed fields only as groups or filters For example walkthrough question 1, buyers and payments grouped into once-only and repeat buyers
Reaggregation of values with the same fixed grouping fields Each fixed group contributes once within each display group
Several fixed groupings only displayed or filtered Only one of them may supply reaggregated values
Ordinary, exclude/fixed and compose metrics on the same fact For example walkthrough question 4
Several include metrics Same base datasets, and the same declared dimensions in the same order
LOD metrics with another fact's metrics Each fact aggregates in its own branch, a LOD over its own fact's rows, filters and dates; the results align on the grouping fields

These combinations need a different formulation or separate queries. When splitting, give every query the same grouping, context and time range, then align results on the grouping fields, so each part keeps the same business population and denominators.

Combination in one query What to do
Fixed-value reaggregation plus raw-row metrics Split in two: one query for metrics reaggregating fixed values, one for raw-row metrics
Reaggregation of two different fixed groupings One query per fixed grouping
Include mixed with ordinary metrics of a fact its base reads, exclude/fixed metrics or LOD fields Query the include metrics alone and the rest separately, as walkthrough question 3 does
Exclude/fixed metrics mixed with LOD fields read, grouped or filtered Split into two queries
Keyed exclude fields or metric fixed keys absent from query grouping (lod_dimension_not_grouped) Add those fields to group_by: an exclude's at any grain, a fixed metric's unchanged with no time grain. To roll a fixed result up to a coarser grouping, use a fixed field and an ordinary aggregate
LOD with window metrics or time_ranges Run them separately; for a multi-period comparison, one query per period
A fixed field of a dataset the metric only joins, as a group or a condition Query a metric of that dataset, or declare the fixed field on the metric's own dataset
LOD nested in base or grouping dependencies, or used as a window base No alternative today; LOD cannot nest
LOD values in ordinary relationship join columns or time axes No alternative today
Attribution of LOD metrics, conditions or dimensions Unsupported. Discovery marks LOD metrics non-attributable and may skip LOD dimensions with a warning

Illegal declaration locations, missing bases, unqualified metric dimensions, ambiguous correspondence and nesting are authoring errors. Unsupported query combinations may return not_implemented, fan_out_risk or another specific code; they are not all the same error. A display dimension colliding with a different fixed key's output name returns duplicate_output_name.

Without grouping, empty-input aggregation returns one row: COUNT is 0 and other aggregates are NULL. With grouping, empty input produces no result groups. An outer COUNT counts non-NULL intermediate values, not necessarily source records. LOD does not synthesize calendar rows or missing entities.

Validation, versions and compatibility

Validate the model in datus mode, then compile and execute intended queries. Check unequal record counts per entity, missing matches, filter placement, date boundaries and zero denominators. Compare with independent raw SQL, then vary data and parameters to test the calculation rules.

dosi validate --osi-datus checks structure and compilation. --strict also fails on authoring-lint warnings, including field declarations, but does not treat every compilation warning as an error. dosi info shows the running engine's extension capabilities.

Declare real primary keys, relationship columns and dimension roles. Time routing requires dimension.is_time: true. For a technical field that should not be recommended as an analysis dimension, put is_dimension: false in that field's DATUS JSON; true explicitly recommends it. This does not prohibit an explicitly requested grouping. A core dimension object alone does not clear every role warning: an identifier that is neither a key nor a relationship column may still produce underdeclared_field. Declare its actual role rather than inventing uniqueness. Fixed or LOD-dependent fields cannot be time axes. Generic relationship validation may also report a non-unique target for an intentional fact pairing; do not invent primary keys to silence that warning.

The document shape Dosi accepts

Dosi requires root version: "0.2.0.dev0" and one model per document, in either of two shapes: a semantic_model list with one entry holding the model's name, datasets, relationships and metrics, or those fields directly at the root. This engine rejects version: "0.1.1".

Upstream 0.2.0.dev0 is a mutable development specification: newer snapshots moved the model to the root without changing the version string. Until an upstream release ships that change, prefer the list shape, which the 0.2.0.dev0 schema validates. See One model per document.

Basic mode does not perform the same LOD calculation

Basic mode ignores DATUS extensions with warnings. An aggregate field without active LOD metadata is dropped with aggregate_field_dropped, and references fail. Nested aggregate representations of LOD metrics and their compositions also fail closed. Basic mode does not provide equivalent results.

Shared interfaces are documented in the semantics contract, extension envelope, CLI reference, detail queries and errors.