Capability report · 20 Oct 2026
Five semantic layers, eighteen capabilities
AQL, Databricks, Snowflake, dbt and Cube, scored on whether each has a named construct for the job. The split runs through level of detail and time intelligence. Fan-out separates them most sharply.
- Holistics AQL171 constrained
- Databricks Metric Views86 constrained · 3 absent · 1 undocumented
- Snowflake Semantic Views74 constrained · 3 absent · 4 undocumented
- dbt MetricFlow75 constrained · 3 absent · 3 undocumented
- Cube (Tesseract)135 constrained
The matrix
18 capability rows, scored on whether the layer has a named construct for the job. Five more rows describe shape and carry no verdict. Every dbt and Cube cell marked tested was run locally on the same data. Turn layers off to compare two at a time.
- first-classFirst-class
- a named construct for the job
- constrainedConstrained
- reachable, but a caveat limits it: a required group-by, a second object, warehouse SQL
- absentAbsent
- no path inside the layer
- ?undocumentedUndocumented
- nothing in the docs, weaker than no
- tested
- confirmed by a local run
| Capability | AQL | Databricks | Snowflake | dbt | Cube |
|---|---|---|---|---|---|
| Date trunc on a dimension at query time | first-class | absent | first-class | first-class | first-class |
| Cross-model dimension expression | first-class | constrained | first-class | absenttested | first-classtested |
| Multi-fact measures | first-class | constrained | first-class | first-classtested | first-classtested |
| Fan-out handling | first-class | constrained | absent | absenttested | first-classtested |
| Subquery filter (table condition) | first-class | ?undocumented | ?undocumented | constrainedtested | constrainedtested |
| Dimensionalize an aggregate | first-class | first-class | first-class | constrained | constrainedtested |
| Nested aggregation | first-class | absent | constrained | ?undocumented | first-classtested |
| Drop a dimension from the grain | first-class | constrained | constrained | ?undocumented | first-class |
| Previous period | first-class | first-class | constrained | constrainedtested | first-classtested |
| Arbitrary period | first-class | absent | absent | absenttested | first-classtested |
| Running total | first-class | first-class | ?undocumented | first-classtested | first-classtested |
| Trailing period | first-class | first-class | ?undocumented | constrainedtested | first-classtested |
| Period to date | first-class | first-class | ?undocumented | first-classtested | first-classtested |
| Date hierarchy aware | first-class | constrained | absent | first-classtested | first-classtested |
| Semi-additive measure | constrained | first-class | first-class | first-classtested | constrainedtested |
| Window function around a metric | first-class | constrained | constrained | ?undocumented | constrainedtested |
| Window function in a dimension | first-class | first-class | first-class | first-classtested | constrainedtested |
| Materialization and aggregate awareness | first-class | first-class | first-class | constrained | first-class |
| Feature | AQL | Databricks | Snowflake | dbt | Cube |
|---|---|---|---|---|---|
| Defining language | AML + AQL | YAML | SQL DDL | YAML | YAML / JS |
| Query interface | explore { } | MEASURE() | AGG() | CLI, JDBC, GraphQL | REST, SQL, GraphQL |
| Modeling semantic | graph | star / snowflake | graph | graph | graph + rooted views |
| Cardinality supported | + many_to_many, range, filters | many_to_one, one_to_many | + one_to_one, ASOF, range | inferred, to-one only | one_to_one, one_to_many, many_to_one |
| Targeting a table in the semantic layer | tiered resolution | dot path | named + USING | path in the name | shortest path, join_path |
What the matrix says
Six conclusions from the matrix, including where AQL is behind.
Cube is the closest peer
Cube has a named construct for 13 of 18 rows, and the tests back most of them. Its multi-stage measures cover nested aggregation, fixed grains, filter overrides and time shifts. Its fan-out rewrite is the same distinct-key idea as AQL’s. It falls short of AQL on the dimension side. A sub_query dimension takes the grain of its cube’s primary key, and a window function cannot sit in a dimension.
Fan-out sorts the five into three groups
AQL and Cube rewrite the query: they count the one side once per group key, from the distinct keys that survive the join. Databricks answers only through a second view rooted at the fine grain. Snowflake and dbt return an error. dbt calls fan-out support “pending”, and the request in metricflow#963 has been open since January 2024.
See the detailLevel of detail is still the structural gap
Databricks and Snowflake compute a level of detail as a SQL window function, so the value lands on every joined row. dbt has no construct. AQL’s group() | select() and Cube’s multi-stage grain build a real inner table and aggregate it, so one metric works grouped by year, by country or by nothing.
Every layer names the time shapes; two read the grain
Databricks pins offsets to declared fields. Snowflake declares no shape. dbt names every shape, but windows and offsets need metric_time in the query. Cube and AQL read the report’s grain. Under a mid-month date filter no layer limits the window to the filtered rows; Cube even reads past the end of the range.
Where AQL is behind
Semi-additive measures are one block in Databricks, Snowflake and dbt, and a composition in AQL and Cube. Snowflake’s ASOF join needs only a start date; AQL’s range relationship needs both bounds. Every other layer speaks SQL, JDBC or GraphQL to outside tools. Retail calendars have no AQL construct.
Addressing the visualization axes is AQL’s alone
AQL window functions can partition and order by 'rows', 'columns', 'x_axis' or 'legend'. That removes the window-around-a-metric constraint. Databricks and Snowflake share that constraint, and Cube shows it as a GROUP BY error. dbt has no window over a metric at all.
Capability by capability
Open a row to see how each layer spells it, in its own syntax. Where the report ran a worked example, it sits under the tabs.
Surface
How you declare the layer, and how a consumer asks it a question.
Defining languageAQLAML + AQLDatabricksYAMLSnowflakeSQL DDLdbtYAMLCubeYAML / JS
Holistics AQLAML + AQL
Two languages. AML is a typed block language for structure; AQL is the expression language inside it, marked by @aql.
Model orders {
dimension id {}
dimension created_at {}
measure total_orders {
definition: @aql count(orders.id) ;;
}
}Databricks Metric ViewsYAML
Metric view definitions are YAML documents: source, joins, dimensions, measures. Expressions inside are SQL fragments.
Snowflake Semantic ViewsSQL DDL
A semantic view is a database object created with DDL: CREATE SEMANTIC VIEW with TABLES, RELATIONSHIPS, DIMENSIONS, FACTS, METRICS clauses.
dbt MetricFlowYAML in dbt models
Semantic models and metrics are YAML inside dbt model property files. The latest spec nests semantic_model under each model entry; metrics that span models sit under a top-level metrics: key. Expressions are SQL fragments.
models:
- name: orders
semantic_model: {enabled: true}
agg_time_dimension: ordered_at
columns:
- name: order_id
entity: {type: primary, name: order}
- name: customer_id
entity: {type: foreign, name: customer}Cube (Tesseract)YAML or JavaScript
Cubes and views in YAML or JavaScript, mixed freely in one project. YAML takes Jinja and Python, so a model can generate members in a loop.
cubes:
- name: orders
sql_table: orders
dimensions:
- {name: id, sql: id, type: number, primary_key: true}
measures:
- {name: count, type: count}Query interfaceAQLexplore { }DatabricksMEASURE()SnowflakeAGG()dbtCLI, JDBC, GraphQLCubeREST, SQL, GraphQL
Holistics AQLexplore expression
explore {
dimensions {
orders.status,
orders.created_at | month()
}
measures {
total_orders: count(orders.id)
}
filters {
orders.created_at matches @1995
}
}The explore expression is the compiler IR. The drag-and-drop UI emits one, and it picks the source tables from the fields you named. Drop to pipe form when you want control: orders | group(users.name) | select(…). There is no SQL entry point into the dataset; consumers come in through the UI, the API, or the MCP server.
Databricks Metric ViewsSQL
SELECT
order_status,
order_date,
MEASURE(unique_customers),
MEASURE(t7d_customers)
FROM orders_metric_view
WHERE date_trunc('year', order_date)
= '1995-01-01'
GROUP BY ALL
ORDER BY 1, 2;GROUP BY ALL saves repeating the projection.
Snowflake Semantic ViewsSQL
SELECT
order_status,
order_date,
AGG(unique_customers),
AGG(t7d_customers)
FROM orders_metric_view
WHERE date_trunc('year', order_date)
= '1995-01-01'
GROUP BY order_status, order_date
ORDER BY 1, 2;Same shape as Databricks with a different wrapper name and an explicit group by.
dbt MetricFlowmetrics + group by
dbt sl query --metrics order_total \
--group-by metric_time__monthA query is metrics, group-by, where, order and limit. JDBC wraps it in {{ semantic_layer.query(...) }}; GraphQL and a Python SDK take the same fields. There is no free SQL over the metrics, and the APIs need a paid plan.
Cube (Tesseract)REST, SQL API
SELECT status, MEASURE(count)
FROM orders
GROUP BY 1;REST JSON, GraphQL, and a Postgres-wire SQL API with MEASURE(), so any Postgres client connects. Cube Cloud adds DAX and MDX on the Enterprise plan.
Date trunc on a dimension at query timeAQLfirst-classDatabricksabsentSnowflakefirst-classdbtfirst-classCubefirst-class
Holistics AQLfirst-class
Truncation is a function you apply at query time, so one date dimension serves every grain.
orders.created_at | month()
orders.created_at | quarter()
orders.created_at | date_trunc('week')Against Databricks, one created_at replaces five pre-declared copies of it, and the time functions read the report's grain from the query.
Databricks Metric Viewsabsent
No. Every grain you want to slice by has to be pre-declared as its own field in the view.
Snowflake Semantic Viewsfirst-class
Yes. The truncation is an expression in the query, so one date dimension serves every grain.
SELECT
DATE_TRUNC('month', order_date) AS month,
AGG(orders.order_average_value)
FROM tpch_analysis
GROUP BY 1;dbt MetricFlowfirst-class
Group by metric_time__month or .grain('month') at query time. A grain finer than the declared one fails with an error. Custom grains such as fiscal_year come from the time spine.
Cube (Tesseract)first-class
Any built-in granularity at query time. Custom granularities take an interval plus an offset or origin; the SQL and GraphQL APIs cannot name them.
granularities:
- name: fiscal_year
interval: 1 year
origin: "2025-04-01"Join graph
What shapes the model can hold, and how a metric reaches a table across them.
Modeling semanticAQLgraphDatabricksstar / snowflakeSnowflakegraphdbtgraphCubegraph + rooted views
orders, and shipments has nowhere to hang without its own metric view or a bridge table. In a graph each metric starts from its own model and walks to the shared dimension, so adding a third fact changes nothing that already exists.Holistics AQLgraph
A dataset is a set of models plus relationships between them. Each metric starts from its own source model and walks the graph from there, so nothing in the dataset is the root.
Databricks Metric Viewsrooted tree
Star or snowflake schema, rooted at one source. Looker and Omni use the same model, where a view has a root and every table joins from it.
Snowflake Semantic Viewsgraph
Graph based, in the same family as Power BI and Holistics. No privileged root table.
dbt MetricFlowgraph
Semantic models joined by named entities. No table is the root. Each metric starts from its own semantic model, and facts meet on shared entities.
Cube (Tesseract)graph, rooted views
Cubes with directed joins form a graph. A view has a root, its first cube; a multi-fact view picks the root per query. Without a view, Cube takes the shortest join path and does not promise it is stable.
Cardinality supportedAQL+ many_to_many, range, filtersDatabricksmany_to_one, one_to_manySnowflake+ one_to_one, ASOF, rangedbtinferred, to-one onlyCubeone_to_one, one_to_many, many_to_one
Holistics AQLfour types, three knobs
relationship(
cities.country_code > countries.code,
true, 'two_way', nullable=false
)
// > many_to_one
// - one_to_oneShort form covers a single-column equality join. Long form adds composite keys (an on block per extra pair), relationship filters (where.from, where.to), and two non-equality types whose match is a Boolean AQL expression holding the full join condition:
RelationshipConfig {
rel: RangeRelationship {
type: 'range'
from: FieldRef { model: 'dl_quotes', field: 'id' }
to: FieldRef { model: 'dl_price_bands', field: 'id' }
match: @aql
dl_quotes.currency == dl_price_bands.currency
and dl_quotes.quoted_at >= dl_price_bands.valid_from
and dl_quotes.quoted_at < dl_price_bands.valid_to
;;
}
active: true
}RangeRelationship matches each source row to at most one target row. ManyToManyRelationship has the same shape and allows several, for example a subscription matched to every period it overlaps. Per relationship, direction decides whether filters and grouping flow back to the fact side, nullable=false asserts referential integrity so the compiler emits an INNER JOIN, and rlp_propagation routes row-level permission filters independently of direction.
Gap. No ASOF keyword. A range match needs both bounds on the target row, so a table with only a start date needs a derived end date before it can join as of a date.
Databricks Metric Viewsmany_to_one
Only many_to_one for dimension tables. Supporting fact tables can be joined one_to_many, but then only columns of that table may appear inside an aggregation. Identical to Looker.
Snowflake Semantic Viewsrichest set
RELATIONSHIPS (
orders (o_custkey)
REFERENCES customers (c_custkey),
orders (o_cust_id, o_ord_date)
REFERENCES customer_address(
ca_cust_id, ASOF ca_start_date),
my_events(event_timestamp)
REFERENCES my_time_periods(
BETWEEN start_time AND end_time
EXCLUSIVE)
)Composite keys, one_to_one, ASOF joins, and range joins are all declarative. ASOF is the one shape here that no other layer declares from a start date alone; the demo below shows how close each gets.
dbt MetricFlowto-one only
Entity types (primary, unique, foreign, natural) set the cardinality. Only to-one joins are allowed. The docs' join table marks primary to foreign as "Fan-out (Not allowed)". SCD type 2 works through natural entities and validity_params. Composite keys only as a concatenated expr.
- name: start_date
dimension:
type: time
validity_params: {is_start: true}Cube (Tesseract)three types, all LEFT JOIN
joins:
- name: users
relationship: many_to_one
sql: "{CUBE}.user_id = {users.id}"All joins generate LEFT JOIN. Many-to-many goes through an associative cube. The join sql is a free ON clause. tested A range condition and an ASOF match written as a correlated subquery both work, and the one side is still deduplicated. One join per pair of cubes; a second path needs extends.
The relationship you need is
Declarable, with composite keys in long form, and reusable across datasets.
relationship(orders.customer_id > customers.id, true)Declarable. The only shape it has, and the one it is built around.
joins:
- name: customers
on: source.o_custkey = customers.c_custkeyDeclarable, with composite keys.
orders (o_custkey)
REFERENCES customers (c_custkey)Declarable as entities:
- name: customer_id
entity: {type: foreign, name: customer}Declarable. One join per pair of cubes.
- name: customers
relationship: many_to_one
sql: "{CUBE}.customer_id = {customers.id}"Declarable when the target has an end date. A range relationship matches the one address row whose validity window contains the order date. A table with only ca_start_date needs ca_end_date derived first, which is the step ASOF saves.
rel: RangeRelationship {
type: 'range'
from: FieldRef { model: 'orders', field: 'id' }
to: FieldRef { model: 'customer_address', field: 'id' }
match: @aql
orders.o_cust_id == customer_address.ca_cust_id
and orders.o_ord_date >= customer_address.ca_start_date
and orders.o_ord_date < customer_address.ca_end_date
;;
}No path. Only equality joins are declarable, so the as-of match has to be pre-computed in the source query the metric view reads from.
One keyword. The address a customer had on the day the order was placed, without a self-join.
orders (o_cust_id, o_ord_date)
REFERENCES customer_address(
ca_cust_id, ASOF ca_start_date)SCD type 2 only. A natural entity with validity_params start and end picks the version valid at the date. It needs both bounds, like AQL's range.
Raw SQL only. A correlated subquery in the join picks the latest row at or before the date. Revenue by rate gave 142, 81 and 110, all correct. tested
- name: rates
relationship: many_to_one
sql: >
{rates}.valid_from = (SELECT MAX(r.valid_from)
FROM rates r WHERE r.valid_from <= {CUBE}.sold_at)One relationship. The bounds are written out in match, so inclusive or exclusive is the operator you pick.
rel: RangeRelationship {
type: 'range'
from: FieldRef { model: 'my_events', field: 'id' }
to: FieldRef { model: 'my_time_periods', field: 'id' }
match: @aql
my_events.event_timestamp >= my_time_periods.start_time
and my_events.event_timestamp < my_time_periods.end_time
;;
}No path. Same reason: the join predicate has to be an equality.
One clause. Matching an event to the period that contains it, with the upper bound exclusive.
my_events(event_timestamp)
REFERENCES my_time_periods(
BETWEEN start_time AND end_time
EXCLUSIVE)No path. Joins are entity equality.
One join. The bounds go in the ON clause. Revenue by quarter was correct, and the quarter target grouped by sale month stayed 100, not 300 for March. tested
- name: periods
relationship: many_to_one
sql: "{CUBE}.sold_at >= {periods}.start_date
AND {CUBE}.sold_at < {periods}.end_date"Targeting a table in the semantic layerAQLtiered resolutionDatabricksdot pathSnowflakenamed + USINGdbtpath in the nameCubeshortest path, join_path
Holistics AQLtiered resolution
Relationships are named objects, reusable across datasets, and a metric activates the ones it needs:
sum(order_items.revenue)
| with_relationships(
order_items.order_id > orders.id,
order_items.country_id > countries.id
)Close to Snowflake's USING, with fewer restrictions. When several paths exist, the compiler ranks them, and on a tie it raises an error that names the ambiguity.
Role playing is the same idea as the Snowflake alias, spelled with extend:
const customer_region = regions.extend({})
const supplier_region = regions.extend({})Databricks Metric Viewsdot path
Join paths work like Holistics explore v1. A field's name spells out the route from the source, so it records how far the table sits from the root.
A A.field
├─ B A.B.field
│ └─ C A.B.C.field
└─ D A.D.fieldRenaming a join or re-rooting the view changes every reference through it.
Snowflake Semantic Viewsnamed + USING
RELATIONSHIPS (
table_a_to_table_b AS
table_a(col_1) REFERENCES table_b(col_1)
)
METRICS (
table_a.metric_a
USING (table_a_to_table_b,
table_a_to_table_c)
)Role playing is an alias on the table itself:
customer_region AS my_db.my_schema.region
PRIMARY KEY (r_regionkey)
COMMENT = 'Region for customers',
supplier_region AS my_db.my_schema.region
PRIMARY KEY (r_regionkey)
COMMENT = 'Region for suppliers'dbt MetricFlowentity path
user__country_code 1 hop
order__user__country_code 2 hops (max)The dimension name spells the entity path, so two paths are two dimensions and never ambiguous. Two hops at most. For role playing, declare several entity names over the same column.
Cube (Tesseract)shortest path
Members can carry a path ({b.d.id}), views fix one with join_path, and queries can pass joinHints. Otherwise Cube takes the shortest path. tested Adding tickets.order_id changed "tickets by order status" from 3 / 2 to 1 / 1 with no change to the query.
Cross-model dimension expressionAQLfirst-classDatabricksconstrainedSnowflakefirst-classdbtabsentCubefirst-class
Holistics AQLfirst-class
Same as Snowflake. Reference the field, the compiler resolves the join, and multi-hop is fine while every hop is many-to-one. The many side is a collection rather than a value, so it has to be collapsed first:
// error: many rows
orders | select(orders.discount * order_items.value)
// fine
orders | select(orders.discount * sum(order_items.value))Databricks Metric Viewsconstrained
Everything referenced must be reachable as the "one" side from the root's side. The rooted tree decides what a dimension is allowed to see.
Snowflake Semantic Viewsfirst-class
Same as AQL. A dimension on the many side reads dimensions on the one side freely; the reverse direction requires a fact aggregate plus dimensionalize.
dbt MetricFlowabsenttested
tested A dimension expr is SQL over its own table. Reading the user's credit from an orders dimension fails both ways:
expr: "amount / {{ Dimension('user__credit_limit') }}"
-> 'Dimension' is undefined
expr: "amount / credit_limit"
-> Referenced column "credit_limit" not foundThe latest spec's derived_semantics has the same scope. Other models are reachable only as whole dimensions at query time, or through an upstream dbt SQL model.
Cube (Tesseract)first-classtested
tested Reference a member of a joined cube and Cube adds the joins, transitively:
- name: amount_share_of_credit
sql: "{CUBE}.amount * 1.0 / {users.credit_limit}"
type: number
- name: user_region # orders -> users -> countries
sql: "{countries.region}"
type: stringBoth returned correct values, including a dimension that reads another cube's dimension.
From an order_items query, reference
Works.
order_items | select(order_items.id, products.name)Works, as long as products hangs off the root as the "one" side.
Works. The relationship and its direction are enough.
Works as a group-by: product__name.
Works. {products.name} in a dimension or as a group-by.
Works. Every hop is many-to-one, so the chain is unambiguous.
order_items | select(order_items.id, merchants.name)
// walks order_items -> products -> merchantsOnly along the tree. The field name spells the path from the root, and the field exists only if that path does.
A.B.C.fieldWorks. No root, so no path to spell.
Works up to two hops: product__merchant__name.
Works transitively. tested
No. The compiler error says the many side is a collection, not a value.
// error: many rows
orders | select(orders.discount * order_items.value)No. There is no single row to point at.
No. Same reason.
No. Primary to foreign is a fan-out join, so the query fails at resolution.
No error, a silent fan-out. A users dimension sql: "{orders.amount_value}" returns one row per order: Alice shows 100, 50 and 30. Counts stay deduplicated. tested
Works. Aggregate the many side, and the reference works with no model change.
orders | select(orders.discount * sum(order_items.value))Works for one table. Join the fact one_to_many and only that table's own columns may appear inside the aggregation.
Works. Collapse it and the reference is legal.
Only as a filter. Metric() can compare a per-entity aggregate, but not appear in an expression.
Works as a sub_query dimension on the one side. tested
Multi-fact measuresAQLfirst-classDatabricksconstrainedSnowflakefirst-classdbtfirst-classCubefirst-class
Holistics AQLfirst-class
This follows from the graph model. Each metric names its source model, walks its own path to the shared dimensions, and the compiler assembles one CTE per branch and joins on the shared grain. Nothing about the dataset changes when a third fact arrives, and no table has to be nominated as root.
Databricks Metric Viewsconstrained
Make the bridge table the root and go many_to_one from it out to each fact. Unclear how that scales past two facts.
Snowflake Semantic Viewsfirst-class
Same as AQL. A metric starts its calculation from its own source table.
dbt MetricFlowfirst-classtested
tested Each metric aggregates in its own subquery; results meet in a FULL OUTER JOIN on the group-by. A derived tickets / orders gave 0.5 and 0.5 by country and 0.667 / 0.333 by month. A query on a dimension that only one fact reaches fails at resolution.
Cube (Tesseract)first-classtested
tested A multi-fact view runs one aggregating subquery per fact and joins on the shared dimensions. A measure that combines facts is multi_stage; it gave the same correct ratios. For a dimension that only one fact reaches, Cube walks the many-to-one join backwards.
Fan-out handlingAQLfirst-classDatabricksconstrainedSnowflakeabsentdbtabsentCubefirst-class
Count users, slice by order status. The join multiplies each user row by their orders, so a naive COUNT(users.id) counts the same person once per order. Every layer has to answer this, and the five answers fall into three kinds: AQL and Cube rewrite the query, Databricks needs a second view, and the Snowflake and dbt queries fail with an error.
Holistics AQLfirst-class
AQL resolves it automatically for measures defined in AML. When the measure sits on the coarser model, the compiler aggregates it inside that model first, then inner-joins it to the distinct keys that survive the fan-out join.
WITH total_active_users AS (
SELECT id AS user_id, COUNT(id) AS total_users
FROM users GROUP BY 1
), distinct_user_id AS (
SELECT DISTINCT u.id AS user_id
FROM users u LEFT JOIN orders o ON u.id = o.user_id
WHERE o.status IS NOT NULL
)
SELECT d.user_id, t.total_users
FROM total_active_users t
INNER JOIN distinct_user_id d USING (user_id)Databricks Metric Viewsconstrained
Join a fact one_to_many and Databricks counts that table at its own grain, then joins the answer back. Each customer stays one row however many orders they have, and two such joins never multiply each other.
source: samples.tpch.customer
joins:
- name: orders
source: samples.tpch.orders
on: orders.o_custkey = source.c_custkey
cardinality: one_to_many
measures:
- name: order_count
expr: count(distinct orders.o_orderkey)The catch. A one_to_many table works in measures but is forbidden in fields, so you cannot group by anything on the many side. "Customers by order status" needs a second view rooted at orders instead, which is why this is constrained and not first-class. See Joins in metric views.
Snowflake Semantic Viewsabsent
Snowflake has no fan-out resolution. A query that groups a metric by a dimension from a finer table fails with a compilation error.
[42601][10956] SQL compilation error:
Invalid dimension specified: The dimension
entity 'ORDERS' has a higher level of
granularity than the base metric entity
'CUSTOMERS'.The docs state the rule as a requirement. The dimension's logical table must have an equal or lower level of granularity than the metric's. Which pairs are legal is not something you can read off the model, so there is a command for asking:
SHOW SEMANTIC DIMENSIONS
IN sv_window_function_example
FOR METRIC avg_7_days_sales_quantity;As a consequence, the layer has no answer to an ordinary question such as "users who have ordered, by order status".
dbt MetricFlowabsenttested
tested A one-side metric grouped by a many-side dimension fails at resolution, before MetricFlow builds any SQL:
The given input does not match any of the
available group-by-items for SimpleMetric('credit').
* No valid join paths exist from the measure
to the group-by-item.
(fan-out join support is pending).Workarounds need a new metric each: a count_distinct on the many side, or a Metric() filter per status value. Open issue: metricflow#963.
Cube (Tesseract)first-classtested
tested Cube takes the distinct (group key, primary key) pairs, joins them back to the one side, and aggregates:
SELECT keys.status, sum(u.credit_limit)
FROM (SELECT DISTINCT o.status, u.id
FROM orders o LEFT JOIN users u
ON o.user_id = u.id) keys
LEFT JOIN users u ON keys.id = u.id
GROUP BY 1Correct in plain cubes, multi-fact views and rooted views. A view rooted at users also keeps users with no orders, under a null status.
The report you want
One query in the same dataset. Users are counted inside users, then inner-joined to the distinct user ids that survive the join.
explore {
dimensions { orders.status }
measures { count(users.id) }
}Not from a customer-rooted view. Make customer the source and orders has to join one_to_many, which is forbidden in fields, so o_orderstatus cannot be a dimension at all. No measure fixes that; the dimension is unavailable.
Answering it takes a different metric view, defined separately and rooted the other way round:
# a second metric view, rooted at orders
source: samples.tpch.orders
joins:
- name: customer
source: samples.tpch.customer
on: source.o_custkey = customer.c_custkey
fields:
- name: order_status
expr: o_orderstatus
measures:
- name: customers
expr: COUNT(DISTINCT o_custkey)That second view answers the question correctly. It is also its own object, with its own name to keep in sync, its own grants, and a DISTINCT that you maintain by hand.
Compilation error. A second view does not help, because the rule applies to the pair of metric and dimension.
[42601][10956] SQL compilation error:
Invalid dimension specified: The dimension
entity 'ORDERS' has a higher level of
granularity than the base metric entity
'CUSTOMERS'.Error. MetricFlow lists no orders dimension for a users metric. tested
Rewritten, correct: cancelled 2 users / 4000, completed 2 / 3000. tested
Works. An ordinary many-to-one hop, no rewrite needed.
Works. Root at orders and join customer many_to_one. A dimension lookup matches at most one row, so nothing can multiply.
Works. The dimension's table is coarser than the metric's, which the granularity rule allows.
Works. An ordinary to-one join.
Works.
Works. Two measures from two models, one CTE each, joined on the shared grain.
Works, and this is what one_to_many is for. Root at customer, join orders one_to_many. The orders branch is aggregated at the source grain and blended, so each customer appears once.
measures:
- name: order_count
expr: count(distinct orders.o_orderkey)
- name: customer_count
expr: count(distinct c_custkey)Works. The dimension sits at or above both metrics' grain, so neither pair breaks the rule.
Works. Each fact in its own subquery, then a full outer join. tested
Works. One subquery per fact. tested
Filtering
Choosing which rows a metric counts, when the condition lives outside the metric's own model.
Subquery filter (table condition)AQLfirst-classDatabricks?undocumentedSnowflake?undocumenteddbtconstrainedCubeconstrained
Filter a metric by whether a key turns up in a table you derive on the spot. This is the general mechanism that the semi-additive composition is built out of.
Holistics AQLfirst-class
The right side of in is any table expression, so it can filter, walk relationships and aggregate before the metric sees a single row.
sum(orders.amount) | where(
orders.user_id in users
| filter(users.country == 'US')
| select(users.id)
)Simpler conditions compile to the same shape. A single-model condition becomes a table condition on the primary key; a cross-model condition becomes one on every dimension it touches. Comparing a dimension against a metric compiles to the same shape, so where(balances.date == max(balances.record_date)) works.
Writing the table condition yourself decides between two readings. Averaging only the items with quantity over 3 is not the same as averaging every item in an order that contains one, and writing the table condition yourself is how you say which you meant. See where: table condition.
Databricks Metric Views?undocumented
not foundNothing in the joins or level-of-detail pages. A measure expression is evaluated against the view's own joined relation, so there is no scope in which to query another table. You would filter in the source query instead, which fixes the condition for every consumer of the view.
Snowflake Semantic Views?undocumented
not foundNo construct in the DDL reference. The one place the docs speak to subqueries is window metrics, where a subquery in PARTITION BY or ORDER BY is an error.
dbt MetricFlowconstrainedtested
filter: |
{{ Metric('orders', group_by=['user']) }} > 0tested The inner metric is a per-entity subquery, left-joined and filtered after the join. It compares an aggregate value against a constant, which is narrower than set membership. Exactly one entity, no multi-hop.
Cube (Tesseract)constrainedtested
No named table condition, but raw SQL works. tested A segment with {CUBE}.id IN (SELECT user_id FROM orders WHERE status = 'completed') gave 2 users and 3000 credit. The same predicate in a measure filters entry was correct by country. A filter on a sub_query dimension (total_amount over 100) gave 1 user. The subquery is warehouse SQL, outside the model.
Level of detail
Computing a metric at a grain the report is not using. Databricks and Snowflake express level of detail as a SQL window function, so the answer lands on every joined row instead of collapsing to a value. AQL and Cube build an inner aggregate. dbt has no construct for it.
Dimensionalize an aggregateAQLfirst-classDatabricksfirst-classSnowflakefirst-classdbtconstrainedCubeconstrained
Databricks, Snowflake and AQL are much the same here. Each takes a partition list, the fields the aggregate is computed over, and the result stops following the query's grouping. Only the syntax differs. dbt and Cube are narrower. dbt computes per entity only inside a filter, and Cube only at the grain of the cube that holds the dimension.
Holistics AQLfirst-class
dimension customer_lifetime_value {
definition: @aql
sum(orders.amount) | dimensionalize(users.id)
;;
}Partition list: the argument, users.id. Databricks, Snowflake and AQL all return a dimension, and in each the value is distributed across the rows it was computed over.
Databricks Metric Viewsfirst-class
fields:
- name: priority_total_price
expr: SUM(o_totalprice)
OVER (PARTITION BY o_orderpriority)Partition list: o_orderpriority.
Snowflake Semantic Viewsfirst-class
FACTS (
sales.total_units AS SUM(units_sold) OVER (),
sales.revenue_by_category AS
SUM(revenue) OVER (PARTITION BY category)
)Partition list: category, or empty parentheses for the whole set. Declare the aggregation with no window at all and it partitions by the primary key.
dbt MetricFlowconstrained
Metric(..., group_by=[entity]) computes per entity but only inside filter or where. Grouping by a metric is listed as future work, and group_by takes dimensions or entities only.
Cube (Tesseract)constrainedtested
- name: total_amount # on cube users
sql: "{orders.amount}"
type: number
sub_query: truetested The grain is the primary key of the cube that declares it: Alice 180, Bob 70, Carol 60. A per-country value needs a countries cube. An undocumented multi-stage dimension with add_group_by: [country] returned each user's own value, not the country total.
Nested aggregationAQLfirst-classDatabricksabsentSnowflakeconstraineddbt?undocumentedCubefirst-class
Holistics AQLfirst-class
An inner table is built, aggregated, and then aggregated again. The inner dimension never has to appear in the query.
metric avg_monthly_acquisition {
definition: @aql
users
| group(users.created_at | month())
| select(new_customers)
| average()
;;
}Drop that into a report grouped by year and you get average monthly acquisition per year; group by country instead and you get it per country. The metric has no year logic and adds no requirement to the query. Cube's multi-stage grain is the only other construct that builds an inner table this way.
Databricks Metric Viewsabsent
No. A fixed level-of-detail field is consumed alongside an aggregate rather than inside one, and the docs require the wrapper:
measures:
- name: pct_of_priority_total
expr: SUM(o_totalprice)
/ ANY_VALUE(priority_total_price)The value is distributed to every row of the snowflake, so aggregating it double counts. ANY_VALUE() is how you read one copy of it, not a way of nesting, and the distribution runs before query-level filtering. See Level of detail expressions.
Snowflake Semantic Viewsconstrained
METRICS (
store_sales.total_sales_quantity AS
SUM(ss_quantity),
store_sales.avg_sales_quantity AS
AVG(total_sales_quantity)
OVER (PARTITION BY EXCLUDING
date.date, date.year)
)Any dimension named in EXCLUDING becomes required in the query's group by. The metric computes the right number, and the query returns it at the grain of the excluded dimension. The three cases are worked below.
dbt MetricFlow?undocumented
not foundNo metric of a metric at another grain. Derived metrics do arithmetic at the query grain; cumulative period_agg re-aggregates over time only.
Cube (Tesseract)first-classtested
- name: avg_amount_per_user
multi_stage: true
sql: "{amount}"
type: avg
grain:
include: [orders.user_id]tested The inner stage groups by the query's dimensions plus grain.include. Correct with no dimensions (103.33), by country (125, 60), by status (110, 45), by month, under a filter, and with the grain itself in the query. The docs warn that without grain on a combining measure, inputs are cross joined with no error.
Show average monthly acquisition
The metric is unchanged. Only the report changes.
explore {
dimensions { orders.created_at | year() }
measures { avg_monthly_acquisition }
}Cannot express it. The inner monthly figure has to be read alongside an aggregate, so there is no way to aggregate it again.
The right number, at the wrong grain. The finer date goes in the EXCLUDING list at definition time, and the query is then obliged to group by it anyway. On a real view:
orders.avg_order_count AS AVG(order_count)
OVER (PARTITION BY EXCLUDING orders.order_date)
select order_date, order_year,
agg(orders.avg_order_count)
from tpch_rev_analysis group by 1, 2;| Order year | Order date | Avg order count |
|---|---|---|
| 1992 | 1992-08-06 | 620.461 |
| 1992 | 1992-05-26 | 620.461 |
| 1992 | 1992-08-19 | 620.461 |
| 1992 | 1992-10-08 | 620.461 |
| 1992 | and 500 more dates | 620.461 |
One row per order date. The same value on every one.
620.461 is the correct average with order_date excluded. It is also repeated on every one of several hundred date rows, because the dimension the metric excluded is the one the query is forced to carry. The report asked for a row per year and got a row per date, all identical.
No construct.
One multi-stage measure with the month in grain.include; the year stays in the outer query.
The metric is unchanged.
explore {
dimensions { countries.name }
measures { avg_monthly_acquisition }
}Cannot express it. Same reason.
The right number, at the wrong grain. The same metric works, no second definition needed: it excludes the month, so grouping by country gives each country's monthly average. The query API still insists the excluded month appears, so what comes back is a row per country per month with the country's figure repeated down each one.
No construct.
The same measure.
The metric is unchanged. One number.
explore { measures { avg_monthly_acquisition } }Cannot express it.
Error. The bare value needs a query without the excluded dimension, and the query API requires that dimension:
010260 (42601): SQL compilation error:
Invalid semantic view query: Dimension
'DATE.DATE' used in a window function metric
must be requested in the query.The number exists inside the metric. No query returns it on its own row. Which metric and dimension pairs are legal is not readable from the view either; see fan-out handling for the command that tells you.
No construct.
The same measure with no dimensions; the tested per-user average gave 103.33.
Drop a dimension from the grainAQLfirst-classDatabricksconstrainedSnowflakeconstraineddbt?undocumentedCubefirst-class
Holistics AQLfirst-class
Four related functions, with an explicit argument for whether query filters apply.
// deny a model or dimension from the grain
count(orders.id) | of_all(countries)
count(orders.id) | of_all(orders.status, keep_filters: true)
// the inverse: allow only these
metric | keep_grains(users)
// viz-aware shorthand
percent_of_total(orders.count, 'row_total')
// branch on which pivot level is active
case(
when: is_at_level(cities.name),
then: sum(sales.amount) / (sum(sales.amount) | of_all(cities.name)),
when: is_at_level(countries.name),
then: sum(sales.amount) / (sum(sales.amount) | of_all(countries.name)),
else: 1
)Databricks has no keep_filters parameter. Without it you get percent of all time; with it you get percent of the visible set, which is what "these five countries account for 54% of sales, and the US is 21% of those five" needs.
Databricks Metric Viewsconstrained
- name: all_priorities_sales
expr: SUM(o_totalprice)
window:
- order: order_priority
range: all
semiadditive: lastThis skips both the filter and the grouping. There is no option to keep filters yet, which rules out the common "percent of the filtered set" case.
Snowflake Semantic Viewsconstrained
conjectureSwapping the AVG above for ANY_VALUE() is the likely route, but the same rule applies: the excluded dimension must appear in the query, so the same value repeats across every row of the group.
dbt MetricFlow?undocumented
not foundRatio and derived inputs take filter and alias only. Nothing removes a group-by item, so a percent-of-total denominator follows the query grouping.
Cube (Tesseract)first-class
- name: country_revenue
multi_stage: true
sql: "{revenue}"
type: sum
grain:
keep_only: [country]
filter:
exclude: [status]grain.keep_only and grain.exclude set the inner group-by; filter.exclude and keep_only decide which query filters reach it. That is AQL's keep_filters choice.
Time
Five period shapes, hierarchy awareness, and semi-additivity. Databricks spells the shapes as <code>window:</code> blocks pinned to declared fields; Snowflake declares none; dbt uses cumulative and derived metrics that need <code>metric_time</code> in the query; Cube uses <code>rolling_window</code> and <code>time_shift</code>; AQL has a named function per shape that reads the report's grain.
Previous periodAQLfirst-classDatabricksfirst-classSnowflakeconstraineddbtconstrainedCubefirst-class
Holistics AQLfirst-class
sum_revenue
| relative_period(orders.created_at, interval(-1 year))
// or, relative to the report's own grain
count(orders.id)
| relative_period(orders.created_at, -1)One definition covers every grain. Viewing March 2024 gives March 2023, viewing Q3 2024 gives Q3 2024 minus a year. Build the comparison on top with arithmetic and the YoY percentage adapts too:
metric revenue_yoy_pct {
definition: @aql
safe_divide(
(sum_revenue - revenue_previous_year) * 100.0,
revenue_previous_year
)
;;
}Databricks Metric Viewsfirst-class
- name: monthly_sales_py
expr: SUM(sales)
window:
- order: month
range: current
semiadditive: last
offset: -12 monthThe offset is pinned to a declared month field, so a quarterly view needs a different measure.
Snowflake Semantic Viewsconstrained
No named construct, but the comparison can live in the metric as a window function:
METRICS (
sales.revenue AS SUM(amount),
sales.revenue_ly AS
LAG(revenue, 12) OVER (ORDER BY date.month)
)That makes month a required dimension in every query touching it, by the same rule as nested aggregation. And LAG needs the twelve intervening months present to reach back, so showing four means fetching sixteen and discarding twelve afterwards:
WITH wide AS (
SELECT month,
AGG(sales.revenue) AS revenue,
AGG(sales.revenue_ly) AS revenue_ly
FROM sales_analysis
WHERE month >= DATEADD(month, -16, CURRENT_DATE)
GROUP BY month
)
SELECT * FROM wide
WHERE month >= DATEADD(month, -4, CURRENT_DATE);The metric holds the offset, and ORDER BY date.month fixes the grain at definition time, so a quarterly view is a second metric.
dbt MetricFlowconstrainedtested
metrics:
- {name: revenue, alias: rv,
offset_window: 1 month}tested Correct at month grain (Apr 151). The query must group by metric_time; a date filter alone fails with an error. At year grain the offset joins day by day, so a Mar 31 sale drops out (302 against 303).
Cube (Tesseract)first-classtested
time_shift:
- {interval: 1 month, type: prior}tested Correct at month and year grain, and under a date filter alone (April filter gives March, 151). With no time dimension and no date filter, the shift silently does nothing.
Last four periods against a year earlier, viewed
One metric.
sum_revenue
| relative_period(orders.created_at, interval(-1 year))One measure, pinned to the declared month field.
window:
- order: month
range: current
offset: -12 monthThe query fetches sixteen months to show four. LAG(revenue, 12) sits in the metric, which makes month mandatory in the query; the twelve months it needed to reach back are discarded by a WHERE outside the CTE.
One derived metric, with metric_time__month required in the query. tested
One measure. time_shift: [{interval: 1 year, type: prior}]. tested
The same metric, untouched. interval(-1 year) resolves against whatever grain the report is using.
A second measure. The offset is pinned to a field, so the quarterly view needs its own.
window:
- order: quarter
range: current
offset: -4 quarterA second metric. ORDER BY date.month is baked into the definition, so quarters need their own LAG(revenue, 4). The query fetches eight quarters to show four.
The same metric. An offset works at any grain, but at coarse grains it joins day by day and loses month-end days.
The same measure.
Arbitrary periodAQLfirst-classDatabricksabsentSnowflakeabsentdbtabsentCubefirst-class
Holistics AQLfirst-class
count(orders.id)
| exact_period(orders.created_at,
@2022-07-01 - 2022-09-01)Compare anything against a named window: back-to-school season, a launch fortnight, last fiscal year. It overrides the report's time filter rather than intersecting with it, which is the difference between exact_period() and where() and is the behaviour you want for a baseline. Datetime literals cover @2022, @2022-04-01, and @(last 2 weeks). The dashboard period-comparison UI compiles down to this and relative_period().
Cube is the only other layer that overrides the query filter. dbt combines the two filters, so the baseline disappears under any other date range. Databricks and Snowflake have nothing here.
Databricks Metric Viewsabsent
No.
Snowflake Semantic Viewsabsent
No.
dbt MetricFlowabsenttested
tested A metric with a fixed March filter returns null under an April query filter: the two filters are combined, so the baseline cannot outlive the report's range.
Cube (Tesseract)first-classtested
filter:
exclude: [sales.sold_at]
include:
- {member: sales.sold_at, operator: inDateRange,
values: ["2024-03-01", "2024-03-31"]}tested Under an April filter the measure still returns March (151). Grouped by month, the value shows only on the March row.
Running totalAQLfirst-classDatabricksfirst-classSnowflake?undocumenteddbtfirst-classCubefirst-class
Holistics AQLfirst-class
orders.total_orders
| running_total(orders.created_at)
// multiple running dimensions
running_total(orders.total_orders,
orders.created_at, orders.status)
// fill the holes a pivot leaves behind
running_total(orders.total_orders,
orders.created_at, fill_missing: true)keep_filters defaults to true here (the opposite of of_all), so a running total respects the visible range unless you opt out. And fill_missing: true exists because a pivot has no row for a status that saw no orders that day, so the running total would flatline; this fills those cells. Omit the running dimension entirely and it runs along whatever date dimensions the report has.
Databricks Metric Viewsfirst-class
measures:
- name: running_total_sales
expr: SUM(o_totalprice)
window:
- order: date
range: cumulative
semiadditive: lastSnowflake Semantic Views?undocumented
not foundNo documented construct for it as a metric shape.
dbt MetricFlowfirst-classtested
type: cumulative
type_params: {measure: sales_amount}tested By default a coarse grain shows the first day of the period: March 142, not the month-end 193. period_agg: last fixes it. With a date filter and no time group-by, it sums the daily totals: 6205 against 223.
Cube (Tesseract)first-classtested
rolling_window:
trailing: unboundedtested Month-end values (March 193). Under a date range ending Apr 10, April still shows 293, which includes a sale on Apr 20. On DuckDB a time series needs a date range.
Trailing periodAQLfirst-classDatabricksfirst-classSnowflake?undocumenteddbtconstrainedCubefirst-class
Holistics AQLfirst-class
count(orders.id)
| trailing_period(orders.created_at, interval(3 months))
// or in the report's own grain units
count(orders.id)
| trailing_period(orders.created_at, 3)It re-times the metric instead of averaging it. Trailing 3-month cancellation rate is cancelled orders in the window divided by orders in the window, which is not the mean of three monthly rates. That distinction is what makes it correct on ratio metrics, and it follows because the whole metric is evaluated in a shifted context.
Databricks Metric Viewsfirst-class
measures:
- name: t7d_customers
expr: COUNT(DISTINCT o_custkey)
window:
- order: date
range: trailing 7 day
semiadditive: lastSnowflake Semantic Views?undocumented
not foundNo documented construct.
dbt MetricFlowconstrainedtested
cumulative_type_params:
window: 30 daystested A window metric without metric_time in the query fails: "does not accumulate over all-time".
Cube (Tesseract)first-classtested
rolling_window:
trailing: 30 daytested March gives 51, the window Mar 2 to 31.
Period to dateAQLfirst-classDatabricksfirst-classSnowflake?undocumenteddbtfirst-classCubefirst-class
Holistics AQLfirst-class
count(orders.id)
| period_to_date('year', orders.created_at)One call, one date dimension, no pre-declared month and year fields, because truncation happens at query time. The docs specify the behaviour when the report grain and the reset period differ. A YTD metric shown monthly anchors on the last date in the current period, so March 2022 covers 1 Jan to 31 Mar; a MTD metric shown yearly anchors on the last month with data.
Databricks Metric Viewsfirst-class
fields:
- name: date
expr: o_orderdate
- name: month
expr: DATE_TRUNC('MONTH', date)
- name: year
expr: DATE_TRUNC('year', date)
measures:
- name: ytd_sales
expr: SUM(o_totalprice)
window:
- order: date
range: cumulative
semiadditive: last
- order: year
range: current
semiadditive: lastThree declared date fields and a two-level window stack to say "year to date".
Snowflake Semantic Views?undocumented
not foundNo documented construct.
dbt MetricFlowfirst-classtested
cumulative_type_params:
grain_to_date: yeartested Year to date for March is 107 by default (the Mar 1 value) and 158 with period_agg: last.
Cube (Tesseract)first-classtested
rolling_window:
type: to_date
granularity: yeartested March gives 158. Month to date at year grain sums the whole year (298).
Date hierarchy awareAQLfirst-classDatabricksconstrainedSnowflakeabsentdbtfirst-classCubefirst-class
Holistics AQLfirst-class
Hierarchy awareness shows up in three places rather than one construct. Every time function documents how it behaves when the report grain is finer or coarser than its period. rollup() in the explore dimensions produces the subtotal rows. And is_at_level() lets a metric ask which level it is currently being evaluated at, which is what makes per-level percentages (city of country, country of continent, continent of total) a single metric definition.
explore {
dimensions {
rows { rollup(order_items.created_at | month()) }
}
measures {
orders: count(order_items.order_id)
}
}Databricks Metric Viewsconstrained
Yes, with a small caveat. See Group by a date hierarchy field.
Snowflake Semantic Viewsabsent
Confirmed absent. Snowflake has window functions but no time semantic, so a "sales last year" metric declared once cannot be queried at day, month and year level. Databricks, AQL, dbt and Cube can.
dbt MetricFlowfirst-classtested
tested One definition at day, month and year. Coarse grains re-aggregate with period_agg, which defaults to the period start; offsets compute daily and then roll up.
Cube (Tesseract)first-classtested
tested Granularity is a query-time choice; rolling windows end at the period end. hierarchies declare drill paths for clients that show them.
Semi-additive measureAQLconstrainedDatabricksfirst-classSnowflakefirst-classdbtfirst-classCubeconstrained
Holistics AQLconstrained
The one row where AQL is behind. There is no declarative construct; you compose it out of the table condition, and you own the correctness. The naive version is two functions:
sum(balances.bank_balance)
| where(dim_dates.date == max(balances.record_date))That is right per customer and wrong in the total, because "the latest date" resolves per group. Which fix you want depends on the question. Last balance date across all customers:
sum(balances.bank_balance) | where(
dim_dates.date == (
max(balances.record_date) | of_all(balances.user_name)
)
)Or each customer's own last balance date, summed:
unique(balances.user_name)
| select(
user_name: balances.user_name,
sum_balance: sum(balances.bank_balance)
| where(dim_dates.date == max(balances.record_date))
)
| sum(sum_balance)Inferring the last date from the rest of the expression would be a guess, and the two spellings above differ by exactly that guess. Databricks, Snowflake and dbt take the guess for you inside a keyword. AQL makes you write down which one you meant. It takes three lines, and the naive version gives the wrong total.
Databricks Metric Viewsfirst-class
measures:
- name: semiadditive_balance
expr: SUM(balance)
window:
- order: date
range: current
semiadditive: lastSnowflake Semantic Viewsfirst-class
METRICS (
bank_accounts.m_account_balance
NON ADDITIVE BY (
year_dim DESC NULLS FIRST,
month_dim DESC NULLS FIRST,
day_dim DESC NULLS FIRST
)
AS SUM(balance)
)dbt MetricFlowfirst-classtested
non_additive_dimension:
name: snapshot_date
window_choice: max
window_groupings: [user]tested Correct with window_groupings: March 200, April 260. Without it the latest date is taken across all users: March shows 120, and grouped by user Alice is null.
Cube (Tesseract)constrainedtested
- name: last_balance
sql: "arg_max({CUBE}.balance, {CUBE}.snapshot_date)"
type: number
- name: balance_last_per_user
multi_stage: true
sql: "{last_balance}"
type: sum
grain: {include: [balances.user_id]}tested Correct (200, 260), but the aggregate is warehouse SQL. number_agg, which the docs list, fails validation outside a multi-stage measure.
tested It stays correct under a fan-out. Grouped by order status, through balances to users to orders, it gave 260 for completed (Alice 200, Bob 60) and 200 for cancelled. Cube collects the distinct balance keys first and runs arg_max on the rows joined back by primary key. A raw sum(balance) in a type: number measure is deduplicated the same way (760, not inflated).
Windows
Secondary calculations: a window wrapped around a metric, and a window computed inside a dimension.
Window function around a metricAQLfirst-classDatabricksconstrainedSnowflakeconstraineddbt?undocumentedCubeconstrained
Holistics AQLfirst-class
Eighteen window functions across navigation (previous, next, first_value, last_value, nth_value), ranking (rank, dense_rank, ntile, percent_rank), and framed aggregation (window_sum, window_avg, window_min, window_max, window_count, plus the four variance and deviation forms).
window_avg(count(orders.id), -2..0,
order: orders.created_at,
partition: orders.status)partition and order can also address the visualization axes instead of dimensions, which no other layer offers:
window_sum(orders.total_revenue, order: 'rows')
window_sum(orders.total_revenue, order: 'rows',
partition: 'columns')
window_sum(orders.total_revenue, order: 'rows' | desc())This avoids MISSING_AGGREGATION. The window addresses the pivot's row and column axes, so nothing has to be added to the group by and the metric stays reusable. Aliases 'x_axis' and 'legend' read better in chart contexts.
And when the window's own ordering or partitioning dimension is missing from the report's grain, AQL computes a specified answer. It differs by function family; both cases are worked below.
One current limit: window aggregate functions accept only aggregate metrics as input. The other layers have the same restriction.
Databricks Metric Viewsconstrained
Yes, but you cannot partition or order by a dimension that is not in the group by:
[MISSING_AGGREGATION] The non-aggregating
expression "order_month" is based on columns
which are not participating in the GROUP BY
clause. Add the columns or the expression to
the GROUP BY, aggregate the expression, or use
"any_value(order_month)" if you do not care
which of the values within a group is
returned. SQLSTATE: 42803Every suggested fix changes the query rather than the metric.
Snowflake Semantic Viewsconstrained
Yes, and with the same constraint as Databricks: the partition or order cannot address a dimension the query has not grouped by. Only the error text differs.
dbt MetricFlow?undocumented
not foundNo metric type or query parameter applies rank or lag over a result. order_by plus limit gives top-N; lag is the derived-metric offset.
Cube (Tesseract)constrainedtested
tested Multi-stage type: rank ranks within groups. A raw SUM({revenue}) OVER () in a measure works over the returned rows. A window ordered by a dimension fails:
LAG({revenue}) OVER (ORDER BY {sold_at})
-> column "sold_at" must appear in the
GROUP BY clauseOther window functions run only through the SQL API, which skips pre-aggregations.
Window ordered or partitioned by a dimension the report is not grouped by
Adds the dimension, then computes. The missing dimension joins the level-of-detail context first, so the window still has rows to average over.
window_avg(count(orders.id), 0..0,
order: orders.created_at | quarter())| Grain | Orders | Window avg |
|---|---|---|
| 2021 Q1 | 120 | 120 |
| 2021 Q2 | 160 | 160 |
| 2021 Q3 | 200 | 200 |
| 2021 Q4 | 240 | 240 |
| 2021 subtotal | 720 | 180 |
| 2022 subtotal | 1040 | 260 |
| Grand total | 1760 | 220 |
Illustrative. 2021's subtotal is the mean of its four quarters, not of 720.
In a report grouped by year, the 2021 subtotal comes back as the average of 2021's four quarters, and the grand total as the average of every quarter, even though the grand total's own context is empty. See dimensions not available in the LoD context.
Error. The report is grouped by year, the window orders by quarter, and quarter is not in the group by.
[MISSING_AGGREGATION] The non-aggregating
expression "order_month" is based on columns
which are not participating in the GROUP BY
clause. SQLSTATE: 42803Error. Same rule as in nested aggregation: a dimension used in a window-function metric has to be requested in the query.
No window over a metric.
Error. LAG(...) OVER (ORDER BY {sold_at}) fails with a GROUP BY error. tested
Returns null. A row with no country has no rank, so the grand total row gets null.
rank(order: orders.total_orders,
partition: countries.name)| Country | Orders | Rank |
|---|---|---|
| Vietnam | 980 | 1 |
| Japan | 740 | 2 |
| Chile | 410 | 3 |
| Grand total | 2130 | null |
Illustrative. The grand total row carries no country, so the rank is null rather than an error.
This is why ranks come back blank on a pivot's grand total row: that row carries no dimensions at all. The docs advise keeping these as ad-hoc calculations, because they read badly outside a pivot.
Error. Same MISSING_AGGREGATION; the family of the function makes no difference.
Error. Same rule.
No window over a metric.
No error, rank 1. A type: rank over statuses with grain.exclude: [status], queried by country without status, returns 1 for every row. With status in the query it ranks correctly within each country. tested
Window function in a dimensionAQLfirst-classDatabricksfirst-classSnowflakefirst-classdbtfirst-classCubeconstrained
Holistics AQLfirst-class
The same behaviour. A window function in a dimension evaluates before filters, so a rank dimension ranks the whole table. AQL publishes its order of operations, so the behaviour is predictable.
Databricks Metric Viewsfirst-class
Yes. Semantically the snowflake is one table, so a window function's row domain is the root table.
Snowflake Semantic Viewsfirst-class
Yes.
dbt MetricFlowfirst-classtested
tested expr: "ROW_NUMBER() OVER (ORDER BY sold_at)" returns 1 to 10, and 5 to 10 under a March filter: the window runs before filters, as in the other layers.
Cube (Tesseract)constrainedtested
tested A window in a dimension fails: GROUP BY clause cannot contain window functions!. The documented route is a derived table in the cube's own sql.
Materialization
What the layer does when the fact table gets big.
Materialization and aggregate awarenessAQLfirst-classDatabricksfirst-classSnowflakefirst-classdbtconstrainedCubefirst-class
Holistics AQLfirst-class
Yes, as PreAggregates plus query rewriting. See Aggregate Awareness.
Databricks Metric Viewsfirst-class
Yes. See Metric view materialization.
Snowflake Semantic Viewsfirst-class
Yes. See Semantic view materializations.
dbt MetricFlowconstrained
Saved queries with exports write tables on a dbt job. Declarative caching serves only queries that match a saved query's inputs, on Enterprise plans, and skips row-level security. No rollup from a finer cache is documented.
Cube (Tesseract)first-class
Pre-aggregations in Cube Store, matched to queries automatically, including view members. Each multi-stage subquery matches separately, all or nothing per query. count_distinct_approx rolls up across grains.
Pitfalls found in testing
Each returns a wrong or surprising number with no error, or fails with an error on an ordinary query. All ran on dbt-core 1.12.6 with MetricFlow 0.213.0, and Cube 1.8.8 with Tesseract, over one DuckDB file.
dbt MetricFlow
A cumulative metric with a date filter and no time group-by sums its daily values.
6205 against 223RowA previous-period metric needs
errorRowmetric_timein the group-by; a date filter alone fails with an error.A month offset at year grain joins day by day and drops month-end days.
302 against 303RowA cumulative metric at month or year grain shows the first day of the period by default.
March 142 against 193RowA semi-additive measure without
March 120 against 200; Alice null by userRowwindow_groupingstakes the latest date across all entities.A metric's fixed date filter is added to the query's date filter.
March baseline null under an April filterRowA one-side metric grouped by a many-side dimension fails at resolution.
errorRow
Cube (Tesseract)
A rolling window under a date range reads past the range end.
April 293 includes Apr 20; range ends Apr 10Row
333, the unshifted totalRowtime_shiftwith no time dimension and no date filter does nothing.Month to date at year grain sums the whole year.
298RowAdding a relationship changes the join path of an existing query.
3 / 2 becomes 1 / 1RowA calendar-mapped
prior year equals the same dayRowtime_shiftreturned no shift (v1.8.8).A window function in a dimension, or a window ordered by a dimension, fails.
GROUP BY errorRowA one-side dimension that reads a many-side column fans out with no error.
Alice appears 3 timesRowA rank whose partition dimension is missing from the query returns 1 for every row.
rank 1 per countryRow
Beyond the matrix: where AQL is behind
A field-by-field read of the dbt and Cube specs, checked against AML and the AMQL compiler. One clear modeling edge remains, retail calendars. The rest concern surface, caching and metadata.
Has it: dbt
Conversion metrics
AMQL: a compositiontested
dbt declares “visit, then purchase of the same product within 7 days” in one block. Its SQL is a range join plus a pick of the closest earlier visit. AMQL reaches the same numbers with a many_to_many relationship and a dimensionalize. By visit product: dbt A 1, B 1; AMQL A 1, B 1. The plain count gives A 2, B 1.
dimension purchases.last_visit_at =
dimensionalize(max(visits.visited_at), purchases.id);
metric attributed = count(purchases.id)
| where(visits.visited_at == purchases.last_visit_at);Has it: dbt, Cube
Retail calendars
AMQL: partial
A fiscal year that starts on a fixed date is an offset. Shift the date dimension, and the AQL time functions follow it. A 4-5-4 retail calendar has 4- and 5-week months and 53-week years. No offset produces it, and AQL time functions step by calendar intervals only.
Has it: Cube
Calendar-mapped time shift
AMQL: missingtested
A calendar cube maps each date to a comparison date, so Fri 2024-03-01 compares with Fri 2023-03-03. In Cube 1.8.8 the mapped column replaced the date in the join, the filter and the group-by, so the shift cancelled and returned the same day (33, not 22).
Has it: dbt
Gap filling
AMQL: partial
dbt join_to_timespine returns a row per date and fill_nulls_with: 0 coalesces it. The AMQL compiler has a time_spine function, not yet documented.
Has it: Cube, dbt
SQL query endpoint
AMQL: missing
Cube speaks the Postgres wire protocol, and dbt offers JDBC and GraphQL. AQL is reached through the UI, the REST API and the MCP server.
Has it: Cube
Cache keyed on data change
AMQL: missing
refresh_key: {sql: SELECT MAX(updated_at) FROM orders} drops the cache when data changes. The Holistics cache is time-based per report.
Has it: Cube
Query filters in model SQL
AMQL: partial
FILTER_PARAMS injects a filter on an ordinary dimension into the cube’s base SQL, for partition pruning. AMQL query models do this for param fields only.
Has it: Cube
Access policies per member
AMQL: partial
access_policy hides or masks members per group, also in the metadata API. AMQL has row-level permission and per-field masking; hidden is not a security feature.
Has it: Cube, dbt
Defaults, exports, metadata
AMQL: partial or missing
Cube view default_filters switch off when the user filters the member. dbt saved queries write a named table with exports. Both accept free-form meta and tags. AML has none of the three.
What this is based on
Vendor docs, a live Snowflake semantic view, and local runs on the same small dataset.
Databricks, Snowflake and AQL
From an earlier review on 19 Oct 2026. These columns rest on the vendor docs, a live Snowflake semantic view, and the Holistics reference docs.
dbt and Cube
Docs read on 19 and 20 Oct 2026: dbt joins, cumulative, derived, conversion, metric filters; Cube measures, joins, multi-fact views, calendar cubes.
Local runs: dbt-core 1.12.6 with MetricFlow 0.213.0, and Cube 1.8.8 with Tesseract, over one DuckDB file. The data is four users (one with no orders), six orders, three tickets, ten sales across 2023 and 2024, seven balance snapshots, and visit and purchase events. The AMQL conversion composition ran through the AMQL compiler on Postgres.
Scoring
First-class means a named construct exists for the job. Constrained means you can get there, but a caveat changes what the metric can be used for: a required group-by, a missing parameter, a second object, or warehouse-specific SQL. Absent means no path inside the layer. Undocumented means the docs show nothing, which is weaker than no.