Semantic layer¶
The semantic layer is ZLens's shared business vocabulary. It's a superset of dbt's MetricFlow — ZLens imports dbt's semantic models as-is and lets you extend them with DQL-local definitions.
Object types¶
| Object | Purpose | Example |
|---|---|---|
| Metric | A measure with aggregation | sum(orders.amount) |
| Dimension | An attribute to slice by | customers.segment |
| Hierarchy | An ordered drill path | year → quarter → month → day |
| Cube | A pre-joined fact+dim set | orders_by_customer_region |
| Segment | A named boolean filter | active_customers |
YAML shape (DQL-local)¶
# semantic/finance.yaml
metrics:
- name: revenue
label: Revenue
description: Gross revenue
type: sum
sql: amount
table: orders
domain: finance
dimensions:
- name: segment
label: Customer segment
type: string
sql: segment
table: customers
hierarchies:
- name: time
levels:
- { name: year, label: Year }
- { name: quarter, label: Quarter }
- { name: month, label: Month }
- { name: day, label: Day }
dbt import¶
ZLens reads target/manifest.json directly — see
Import a dbt project. Imported metrics and
dimensions appear in the Semantic panel alongside DQL-local ones.
Which engine runs a metric¶
| Project | Engine | Runs |
|---|---|---|
| dbt project with MetricFlow installed, or dbt Cloud configured | MetricFlow / dbt Cloud | every dbt metric type |
| Everything else, including warehouse-first projects with no dbt | DQL native compiler | simple metrics, and ratios of two simple metrics |
An mf executable on the PATH does not make a project a MetricFlow project:
without a dbt_project.yml (or a dbt setting in dql.config.json) the native
compiler serves it. When the native compiler cannot run a metric it says why
(for example "premium_growth is a derived metric; the DQL native semantic engine
runs simple and ratio metrics only") instead of asking for dbt parse.
Ratio metrics¶
A ratio divides two simple metrics, as MetricFlow does: both sides are
aggregated at the requested dimensions and time grain, then divided once per
row. loss_ratio = incurred_losses / written_premium is SUM(losses) / SUM(premium)
for each line of business, never an average of per-row ratios. Sides on
different fact tables are aggregated separately and joined on the grain, so
neither fact multiplies the other. A zero denominator gives NULL. A filter on
the numerator or denominator (numerator: {name: incurred_losses, filter: ...})
applies to that side only. A side that is itself a ratio, derived, or
cumulative metric needs MetricFlow.
Joins¶
The native compiler joins along dbt entities the way MetricFlow does:
- Foreign → primary (many-to-one): a claim amount's
claimjoins the claims model. A metric's ownfiltermay read a column of any model reached this way; the model is joined for it. - Primary/unique ↔ primary/unique (one-to-one): two models keyed by the
same entity join on that key. A subtype table such as
loss_payment, one row per claim amount that is a loss payment, filters or groups claim amounts withoutclaim_amount_id in (from loss_payment)in the measure. A dimension that reads no column (has_loss_payment: 1) is the value when the subtype row exists and NULL when it does not. - Composite keys: an entity
exprmay be an expression (policy_id || '-' || coverage_code) or a column list (policy_id, coverage_code). Each column is qualified on its own side. - Model-level
primary_entity:names a keyless model (a bridge or a fact with no single-column grain). Its dimensions are addressed<primary_entity>__<dimension>, exactly as MetricFlow addresses them, andzlens semantic import dbtwrites that name into the imported YAML (qualified_name), so semantic models need not be named after their entity.
Bridges: why claim money cannot reach the agent¶
The compiler never walks an entity join backwards. Claim amounts reach the
policy (claim amount → claim → policy), but policy_party_role holds several
rows per policy (the holder and the agent), so going on to a party role would
repeat every claim amount once per role. MetricFlow refuses the same path, and
also stops at two hops. The compiler refuses the block and names the step:
party_role lives on policy_party_role, which incurred_losses reaches only through insurance_policies → policy_party_role: one insurance_policies row has many policy_party_role rows, ...
Model the rows you need as one row per policy — for example a policy_agent
dbt model (where Party_Role_Code = 'AG') whose entity policy is declared
type: unique, with primary_entity: policy_agent at model level. Keeping
insurance_policies the only primary model for policy keeps it the target
of every foreign policy; policy_agent then joins it one-to-one, and a claim
metric groups by the agent in both engines (claim__policy__agent_party_id is
two hops for MetricFlow).
CLI¶
zlens semantic list # list everything
zlens semantic validate # check all refs resolve
zlens semantic query 'revenue by segment in 2024' # NL → SQL preview
zlens semantic pull # re-sync from dbt
Referencing from cells¶
// In a DQL cell
@metric("revenue") by @dim("customer.segment") for 2024
The compiler resolves refs, joins the needed tables, and emits SQL for the target warehouse dialect.