Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions docs-mintlify/docs/data-modeling/views.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -285,11 +285,10 @@ straightforward.

### Define a metric on a view when it spans cubes

The one exception is a metric whose parts live in different cubes, so there is
no single cube it could belong to. A view can define its own
[measures][ref-view-measures] and [dimensions][ref-view-dimensions] as long as
their `sql` only combines members the view already includes — a member that
reads a column instead is rejected at compile time:
The exception is a metric whose parts live in different cubes. A view can define
its own [measures][ref-view-measures] and [dimensions][ref-view-dimensions] as
long as their `sql` only combines members the view already includes — a member
that reads a column instead is rejected at compile time:

<CodeGroup>

Expand Down Expand Up @@ -328,6 +327,11 @@ inflate the numerator. If the cubes don't join to each other at all, see
[multi-fact views][ref-multi-fact-views]. The full example, with the `cubes`
block, is on the [view reference][ref-view-measures].

A cube can also own such a metric, by referencing the other cube's member
directly — that keeps it defined once for every view that includes it, at the
cost of one cube naming another. The [average order value
recipe][ref-recipe-aov] compares the two placements.

### Control visibility

Not every view should be publicly accessible. Use [`public`][ref-view-public]
Expand Down Expand Up @@ -499,6 +503,7 @@ parameters.
[ref-view-dimensions]: /reference/data-modeling/view#dimensions
[ref-multi-stage]: /reference/data-modeling/measures#multi_stage
[ref-multi-fact-views]: /docs/data-modeling/multi-fact-views
[ref-recipe-aov]: /recipes/data-modeling/average-order-value#where-to-put-the-measure
[ref-view-folders]: /reference/data-modeling/view#folders
[ref-access-policies]: /reference/data-modeling/data-access-policies
[ref-ai-context]: /docs/data-modeling/ai-context
Expand Down
81 changes: 78 additions & 3 deletions docs-mintlify/recipes/data-modeling/average-order-value.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -230,9 +230,9 @@ since one is keyed by day and the other by timestamp.

### 2. Define AOV on the view

Neither cube can define AOV — neither can reference the other's measures. Define
it as a [measure of the view][ref-view-measures] and mark it
[`multi_stage`][ref-multi-stage]:
AOV can live on the view or on either cube — see [where to put
it](#where-to-put-the-measure) below. On the view it is a [measure of the
view][ref-view-measures], marked [`multi_stage`][ref-multi-stage]:

<CodeGroup>

Expand Down Expand Up @@ -331,8 +331,83 @@ measure, looks for a single join tree covering both fact cubes, and fails with

</Note>

## Where to put the measure

A metric spanning two facts does not have to live on a view. A cube measure may
reference another cube's measure, which makes it derived rather than owned by
its cube — the same property a view measure has — so AOV can sit on either fact
cube instead:

<CodeGroup>

```yaml title="YAML"
cubes:
- name: sales_line_item
# …

measures:
- name: transactions_without_returns
sql: transaction_id
type: count_distinct
filters:
- sql: "{CUBE}.transaction_type <> 'EXCHANGE'"
- sql: "{CUBE}.fulfillment_channel_group IN ('IN_STORE', 'SHIP_FROM_STORE')"

- name: aov_basket
type: number
format: currency
multi_stage: true
sql: "{item_location_sales.sales_amount} / NULLIF({CUBE.transactions_without_returns}, 0)"
```

```javascript title="JavaScript"
cube(`sales_line_item`, {
// …

measures: {
transactions_without_returns: {
sql: `transaction_id`,
type: `count_distinct`,
filters: [
{ sql: `${CUBE}.transaction_type <> 'EXCHANGE'` },
{ sql: `${CUBE}.fulfillment_channel_group IN ('IN_STORE', 'SHIP_FROM_STORE')` }
]
},

aov_basket: {
type: `number`,
format: `currency`,
multi_stage: true,
sql: `${item_location_sales.sales_amount} / NULLIF(${CUBE.transactions_without_returns}, 0)`
}
}
})
```

</CodeGroup>

Both placements plan identically — the same per-fact subqueries, stitched the
same way, divided in the same final stage — and `multi_stage` is required either
way. What differs is reuse and coupling:

| | On a cube | On a view |
| --- | --- | --- |
| Reuse | Defined once; every view including it gets it | Redefined in each view that needs it |
| Coupling | The cube names the other cube's measure | The cubes stay unaware of each other |
| Query path | Available as `sales_line_item.aov_basket` too | Only through the view |

Prefer the cube when the metric is part of the model that several views expose —
it keeps [shared logic in cubes][ref-views-shared-logic]. Prefer the view when
the pairing is a presentation choice for one audience, or when the cubes belong
to different domains and you would rather not have one reference the other.

A cube-owned measure reaches the other fact whether or not the view naming it
Comment thread
paveltiunov marked this conversation as resolved.
also includes that fact, so a view can expose AOV without exposing
`sales_amount`.

[ref-multi-fact-views]: /docs/data-modeling/multi-fact-views
[ref-multi-stage]: /reference/data-modeling/measures#multi_stage
[ref-measure-filters]: /reference/data-modeling/measures#filters
[ref-view-measures]: /reference/data-modeling/view#measures
[ref-views-shared-logic]: /docs/data-modeling/views#keep-shared-logic-in-cubes
[link-tesseract]: https://cube.dev/blog/introducing-tesseract
5 changes: 4 additions & 1 deletion docs-mintlify/reference/data-modeling/view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -571,7 +571,9 @@ If you'd like to override the [metadata][ref-dim-meta] of a member, you can use
The `measures` parameter defines measures on the view itself. A view measure is
always _derived_: its `sql` may only reference members that the view already
includes, never columns of a table. Use it for a metric whose parts come from
different cubes, which therefore has no single cube to live in.
different cubes. (A cube can own such a metric too, by referencing the other
cube's member; the [average order value recipe][ref-recipe-aov] compares the two
placements.)

Reference the included members as `{CUBE.member}` (or `{view_name.member}`); a
bare `{member}` does not resolve inside a view.
Expand Down Expand Up @@ -1112,6 +1114,7 @@ The `access_policy` parameter is used to configure [access policies][ref-ref-dap
[ref-ref-cubes]: /reference/data-modeling/cube
[ref-ref-multi-stage]: /reference/data-modeling/measures#multi_stage
[ref-multi-fact-views]: /docs/data-modeling/multi-fact-views
[ref-recipe-aov]: /recipes/data-modeling/average-order-value#where-to-put-the-measure
[ref-ref-hierarchies]: /reference/data-modeling/hierarchies
[ref-ref-dap]: /reference/data-modeling/data-access-policies
[ref-rest-query-ops]: /reference/core-data-apis/rest-api/query-format#filters-operators
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,16 @@ cubes:
filters:
- sql: "{CUBE}.transaction_type <> 'EXCHANGE'"
- sql: "{CUBE}.fulfillment_channel_group IN ('IN_STORE', 'SHIP_FROM_STORE')"
# The same ratio, owned by this cube instead of the view. Referencing the
# other fact's measure is what makes it not owned by this cube, which is
# what a derived member has to be; the \`multi_stage\` rule is unchanged.
- name: aov_basket
type: number
multi_stage: true
sql: "{item_location_sales.sales_amount} / NULLIF({CUBE.transactions_without_returns}, 0)"
- name: aov_basket_single_stage
type: number
sql: "{item_location_sales.sales_amount} / NULLIF({CUBE.transactions_without_returns}, 0)"

- name: item_location_sales
sql: >
Expand Down Expand Up @@ -139,6 +149,8 @@ views:
includes:
- transactions_without_returns
- net_sale_transactions
- name: aov_basket
alias: aov_basket_from_cube
# The shared dimension cubes sit at root-level join paths so their
# dimensions are common to both facts.
- join_path: dates
Expand Down Expand Up @@ -326,6 +338,86 @@ describe('Multi-fact derived measure defined on a view', () => {
});
});

// The same ratio, owned by `sales_line_item` rather than by the view. A cube
// measure that references another cube's measure is not owned by its cube -
// the same property a view measure has - so a metric spanning two facts does
// not need a view to live in. It can sit in the model, and every view that
// includes it gets it.
describe('Multi-fact derived measure defined on a cube', () => {
it('divides the two facts once both have been aggregated to the query grain', () => {
const sql = buildSql({
measures: ['sales_line_item.aov_basket'],
dimensions: ['locations.region'],
});

expect(sql).toMatch(SALES_AMOUNT_AGGREGATE);
expect(sql).toMatch(TRANSACTIONS_AGGREGATE);
expect(sql).toMatch(RATIO_OVER_AGGREGATES);
expect(sql).not.toMatch(/sum\("item_location_sales"\.sales_amount\) \/ NULLIF/);
});

it('plans the same way whichever fact owns it', () => {
// Only the emitted column alias should differ between the two placements,
// so normalising it makes the two plans directly comparable.
const normalize = (sql: string) => sql
.replace(/"(retail_analysis|sales_line_item)__aov_basket"/g, '"__aov"')
.replace(/"retail_analysis__region"/g, '"__region"')
.replace(/"locations__region"/g, '"__region"');

const onView = buildSql({
measures: ['retail_analysis.aov_basket'],
dimensions: ['retail_analysis.region'],
});
const onCube = buildSql({
measures: ['sales_line_item.aov_basket'],
dimensions: ['locations.region'],
});

expect(normalize(onCube)).toEqual(normalize(onView));
});

it('is reachable through a view that includes it', () => {
const sql = buildSql({
measures: ['retail_analysis.aov_basket_from_cube'],
dimensions: ['retail_analysis.region'],
});

expect(sql).toMatch(RATIO_OVER_AGGREGATES);
});

it('reaches the other fact even when the query names only its own cube', () => {
const sql = buildSql({ measures: ['sales_line_item.aov_basket'] });

// No dimensions, so the two legs are aggregated whole and stitched anyway.
expect(sql).toMatch(SALES_AMOUNT_AGGREGATE);
expect(sql).toMatch(TRANSACTIONS_AGGREGATE);
expect(sql).toMatch(RATIO_OVER_AGGREGATES);
});

it('divides the two facts on the shared date spine', () => {
const sql = buildSql({
measures: ['sales_line_item.aov_basket'],
timeDimensions: [{ dimension: 'dates.date', granularity: 'day' }],
});

expect(sql).toContain('DATE_TRUNC(\'day\', "item_location_sales".date) = "dates".date');
expect(sql).toContain('DATE_TRUNC(\'day\', "sales_line_item".sold_at) = "dates".date');
expect(sql).toMatch(RATIO_OVER_AGGREGATES);
});

// Current behaviour, pinned - the same limit as on the view. Owning the
// measure buys the cube nothing here: without `multi_stage` the planner still
// looks for one join tree covering both facts and there is none.
it('cannot plan the ratio when the cube measure is not multi_stage', () => {
// The cubes are listed in the order the planner collected them, which
// differs from the view-owned case, so only their presence is pinned.
expect(() => buildSql({
measures: ['sales_line_item.aov_basket_single_stage'],
dimensions: ['locations.region'],
})).toThrow(/Can't find join path to join (?=.*item_location_sales)(?=.*sales_line_item)/);
});
});

// The other reason a view measure spanning cubes wants `multi_stage`: even when
// the cubes DO join, a plain calculated measure is evaluated inside the single
// joined scan, so a `sum` on the one side is taken over rows the join has
Expand Down
Loading