On this page
- How it works
- Joins that cannot repeat rows
- Examples
- A many to one join adds up
- A measure of the one side counts once in each group
- A one to many join counts each order once in each group
- A measure that reads both sides of a repeating join
- Several row sets in one model
- Two row sets meet on the dimensions they share
- A measure cannot be grouped by a dimension its rows do not have
- Total a measure without that dimension
- Where you see it
- Rules
- Common errors
Model Fields Measures
Measures across joins and unions
Know how a measure counts its rows when a model joins other tables or stacks row sets.
A measure totals its own rows: the rows of the sources its formula reads. The model’s other joins decide which of those rows count. They also let a reader group or filter by the joined columns. They never multiply a measure’s rows. So a join cannot count an order twice, and a total stays right, whatever the model joins.
How it works
Each row counts once in each group, however many rows it matches. Take one order with three events: created, paid and shipped. Group Units by event type, and the order’s units count once under each of the three types. In the total, they count once.
| The join, seen from the measure’s rows | Each row counts | The groups add up to the total |
|---|---|---|
| Many to one, such as an order to its customer | Once, in the one group its match is in | Yes |
| From the one side, such as a customer’s measure grouped by an order’s region | Once in each group it matches | No |
| One to many, such as an order to its events | Once in each group it matches | No |
When the groups do not add up, each group is still right. It answers its own question, such as “how many units were in orders that were shipped”. Read the total from the measure itself, never from a sum of the groups.
Joins that cannot repeat rows
Some joins cannot repeat a measure’s rows. The compiler keeps the measure’s formula as written across them:
| Join | Why it cannot repeat rows |
|---|---|
A RELATIONSHIP declared many_to_one or one_to_one, for a measure of the side before the join | Each row matches at most one joined row. |
A RELATIONSHIP declared one_to_many or one_to_one, for a measure of the joined side | Each joined row matches at most one row of the side before it. |
A grouped or DISTINCT source, matched on every one of its keys | The source has one row for each key. |
A source that keeps the first row of each ROW_NUMBER() OVER (PARTITION BY …), matched on its partition columns | The source has one row for each partition. |
| A source with one row | Every row matches the same row. |
Any other join is treated as able to repeat rows. Then the compiler works each measure out on its own rows first, and counts each row once in each group. The same is true when another model passes the measure through or evaluates it. A filter or grouping on a side that model joins applies to the rows it reaches, and never multiplies them.
The compiler trusts the cardinality you declare in a shared model. many_to_one does not remove duplicate keys. In a query, a measure the query declares is worked out on its own rows first unless the compiler proves from the sources’ SQL that the join cannot repeat them, whatever cardinality the query declares. A query is read at the grain of its groups, so a cardinality written there is easy to get wrong. Check each join on the warehouse with kater model check-join, or in the join panel of the Shared models editor. Its Measures line says how measures behave across that join. See Relationships and joins.
Examples
A many to one join adds up
semantic.shop joins each order to its customer, many to one:
FROM kater_dataset.orders AS o-- @label CustomerLEFT JOIN RELATIONSHIP('customer', kater_dataset.customers AS c ON o.customer_id = c.customer_id, 'many_to_one')Orders and Units by the customer’s loyalty tier:
SELECT shop."loyalty tier", shop.count_distinct__order_id, shop.sum__quantityFROM workspace.shop AS shopGROUP BY shop."loyalty tier"ORDER BY shop."loyalty tier"| Loyalty Tier | Orders | Units |
|---|---|---|
| bronze | 724 | 2,245 |
| gold | 708 | 2,099 |
| silver | 795 | 2,444 |
| null | 773 | 2,366 |
The tiers add up to 3,000 orders and 9,154 units, the shop’s totals. Each order has one customer, so it lands in one tier. The null row holds the orders of customers with no tier.
A measure of the one side counts once in each group
Customers by the region of their orders. For this example, the customers dataset also declares a sum of lifetime value, and semantic.shop passes it through:
-- In kater_dataset.customers MEASURE('sum__lifetime_value', KATER__SUM(customers.lifetime_value)) AS "Lifetime Value Total"
-- In semantic.shop c.sum__lifetime_value,SELECT shop.region, shop.count_distinct__customer_id, shop.sum__lifetime_valueFROM workspace.shop AS shopGROUP BY shop.regionORDER BY shop.region| Region | Customers | Lifetime Value Total |
|---|---|---|
| AMER | 232 | 555,873.67 |
| APAC | 229 | 546,040.93 |
| EMEA | 231 | 551,840.58 |
| LATAM | 228 | 541,884.92 |
| null | 56 | 127,825.21 |
The same measures with no grouping:
SELECT shop.count_distinct__customer_id, shop.sum__lifetime_valueFROM workspace.shop AS shop| Customers | Lifetime Value Total |
|---|---|
| 240 | 573,460.22 |
The regions add up to 976 customers, but the shop has 240. Most customers ordered in more than one region, and each counts once in every region they ordered in. Each row is right: AMER had 232 customers. The total is right too: 573,460.22 is the sum over the customers table. A plain SQL SUM over the same join counts each customer once for each order, and gives 7,209,398.55.
A one to many join counts each order once in each group
This semantic model joins each order to its events, one to many:
SELECT o.order_id, o.region, e.eventtype, o.sum__quantity, o.count_distinct__order_id, e.count__event_idFROM kater_dataset.orders AS o-- @label EventsLEFT JOIN RELATIONSHIP('events', kater_dataset.order_events AS e ON o.order_id = e.order_id, 'one_to_many')kater model check-join confirms the cardinality on the warehouse:
events: 3,000 of 3,000 rows match. Each matches at most 4 rows.The joined model has 7,436 rows and 3,000 key values.The rows fit one to many.A query of its workspace, activity, groups Units, Orders and Events by event type:
SELECT activity.eventtype, activity.sum__quantity, activity.count_distinct__order_id, activity.count__event_idFROM workspace.activity AS activityGROUP BY activity.eventtypeORDER BY activity.eventtype| Event Type | Units | Orders | Events |
|---|---|---|---|
| created | 9,154 | 3,000 | 3,000 |
| delivered | 2,336 | 755 | 755 |
| paid | 6,815 | 2,231 | 2,231 |
| shipped | 4,424 | 1,450 | 1,450 |
The same measures with no grouping:
SELECT activity.sum__quantity, activity.count_distinct__order_id, activity.count__event_idFROM workspace.activity AS activity| Units | Orders | Events |
|---|---|---|
| 9,154 | 3,000 | 7,436 |
Every order has a created event, so the created row holds every order. An order that was also paid and shipped counts under those types too. So the Units rows add up to 22,729, and the total stays 9,154. A plain SQL SUM of the quantity over the same join gives 22,729 for the total. It adds each order once for each event.
A measure that reads both sides of a repeating join
Orders per Customer, declared in semantic.shop:
MEASURE('orders_per_customer', EVAL(o.count_distinct__order_id) / NULLIF(EVAL(c.count_distinct__customer_id), 0)) AS "Orders per Customer",The compiler refuses the model with unsupported_measure_context: “orders_per_customer evaluates measures that read different sources, and this model’s joins repeat one’s rows for the other. Declare it in a model where they read one source, or evaluate each in the query that needs it.”
The join is many to one toward customers, so it repeats each customer’s row once for each order. A formula that reads both sides in this model would read those copies. Declare the measure in a query of the workspace, where each measure is worked out on its own rows first:
SELECT shop."loyalty tier", shop.count_distinct__order_id, shop.count_distinct__customer_id, -- @format number(1) MEASURE( 'orders_per_customer', EVAL(shop.count_distinct__order_id) / NULLIF(EVAL(shop.count_distinct__customer_id), 0) ) AS "Orders per Customer"FROM workspace.shop AS shopGROUP BY shop."loyalty tier"ORDER BY shop."loyalty tier"| Loyalty Tier | Orders | Customers | Orders per Customer |
|---|---|---|---|
| bronze | 724 | 59 | 12.3 |
| gold | 708 | 58 | 12.2 |
| silver | 795 | 63 | 12.6 |
| null | 773 | 60 | 12.9 |
Each tier’s ratio divides its own two totals. The same holds for Events per Order on the activity model: the model refuses it, and a query of its workspace gives AMER 2.41.
Several row sets in one model
A model can hold several row sets as UNION ALL branches, such as web orders and store orders. Read the union through a SELECT * CTE, and declare each dimension. A merged dimension is a new field, so it needs a new name. That is true even when every branch reads a field of that name. DIMENSION('region', base.region) is refused, because region is already a dimension of kater_dataset.orders.
This semantic model, channel_units, stacks the shop’s web orders and store orders. The store branch writes NULL for the campaign:
WITH unioned AS ( SELECT o.region, o.order_month, o.campaign, o.quantity AS web_units, CAST(NULL AS INTEGER) AS store_units FROM kater_dataset.orders AS o WHERE o.channel = 'web' UNION ALL SELECT o.region, o.order_month, CAST(NULL AS VARCHAR) AS campaign, NULL AS web_units, o.quantity AS store_units FROM kater_dataset.orders AS o WHERE o.channel = 'store'), base AS ( SELECT * FROM unioned)SELECT DIMENSION('union__region', base.region) AS "Region", DIMENSION('union__order_month', base.order_month) AS "Order Month", DIMENSION('union__campaign', base.campaign) AS "Campaign", MEASURE('web_units', SUM(base.web_units)) AS "Web Units", MEASURE('store_units', SUM(base.store_units)) AS "Store Units"FROM baseEach measure totals the branches its aggregate reads. Web Units totals the web rows, and Store Units totals the store rows. The row sets meet on the dimensions they share. A branch that writes NULL for a dimension, directly or through a cast, does not carry it. The compiler works all of this out from the SQL. Nothing is declared.
Two row sets meet on the dimensions they share
Web Units and Store Units by region, from the workspace channels that reads channel_units.
SELECT channels.union__region, channels.web_units, channels.store_unitsFROM workspace.channels AS channelsGROUP BY channels.union__regionORDER BY channels.union__region| Region | Web Units | Store Units |
|---|---|---|
| AMER | 495 | 534 |
| APAC | 520 | 545 |
| EMEA | 577 | 605 |
| LATAM | 504 | 605 |
| null | 53 | 41 |
Both branches carry the region, so each measure totals its own rows for each region. The columns add up to 2,149 web units and 2,330 store units.
A measure cannot be grouped by a dimension its rows do not have
Store Units by campaign.
SELECT channels.union__campaign, EVAL(channels.store_units)FROM workspace.channels AS channelsGROUP BY channels.union__campaignThe compiler refuses it with dimension_outside_measure_rows: “Store Units can’t be grouped by Campaign. Some of the rows it totals have no Campaign, so they would all count toward an empty Campaign. Remove Campaign from the GROUP BY, or write EVAL(store_units, REMOVE(union__campaign)) to total Store Units without it.”
A filter on the campaign, such as WHERE channels.union__campaign = 'spring', is refused with the same code: “Store Units can’t be filtered by Campaign. Some of the rows it totals have no Campaign, so the filter would drop them all.”
Total a measure without that dimension
Web Units by campaign, beside all Store Units.
SELECT channels.union__campaign, EVAL(channels.web_units), EVAL(channels.store_units, REMOVE(channels.union__campaign))FROM workspace.channels AS channelsGROUP BY channels.union__campaignORDER BY channels.union__campaign| Campaign | Web Units | Store Units |
|---|---|---|
| holiday | 510 | 2,330 |
| none | 530 | 2,330 |
| spring | 508 | 2,330 |
| summer | 601 | 2,330 |
| null | null | 2,330 |
REMOVE takes the campaign out of the grouping for Store Units only, so every row shows all 2,330 store units. Web Units keeps the grouping. The null row is the store rows, which have no campaign, so it has no web units. See Change a measure’s context for REMOVE.
Where you see it
A measure’s definition card lists, under Inputs, the dimensions a reader can group or filter it by. A dimension outside the measure’s rows is left out there and listed under Not allowed. Open the Definitions view, or run kater model explain store_units --relation semantic.channel_units:
| Inputs | |
|---|---|
| Group or filter by | any of 2 dimensions: Region, Order Month |
| Required | none |
| Not allowed | Campaign |
The workspace keeps the limit when it passes Store Units on, so a query of channels meets it too.
Rules
- A measure totals its own rows. The rows are those of the sources its formula reads, and nothing else.
- Joins never multiply a measure’s rows. Each row counts once in each group, however many rows it matches.
- Groups from a repeating join do not add up. Each group is right. Read the total from the measure, not from a sum of the groups.
- A declared cardinality changes no data. The compiler trusts it. Check each join on the warehouse.
- A formula reads one set of rows. A measure that evaluates two sources across a repeating join is refused. Declare it in a query of the model.
- A plain aggregate has no such guard. A plain SQL
SUMover a one to many join counts each row once for each match. Use a measure. - Each union branch carries only the dimensions it writes. A measure cannot be grouped or filtered by a dimension that its branch writes as
NULL.
Common errors
| Code | Cause | Fix |
|---|---|---|
unsupported_measure_context | A measure evaluates measures of two sources, and a join of the model repeats one’s rows for the other. The code has other causes too, see Pass through, evaluate or declare. | Declare the measure in a query of the model, or in a model where both measures read one source. |
dimension_outside_measure_rows | A query groups or filters a measure by a dimension that the union branch it totals writes as NULL. | Remove the dimension, or write EVAL(measure, REMOVE(dimension)) to total the measure without it. |
field_name_in_scope | A merged union dimension takes the name of a field that a branch reads. | Give it a new name, such as union__region. |
relationship_pruned_row_effect | Warning. A query reads nothing from a join that can repeat rows, so the compiler leaves the join out. | Nothing, for a measure: it gives the same total either way. Units is 9,154 with the events join and without it. For raw rows, see Relationships and joins. |