How to Model Role-Playing Dimensions¶
This guide shows how to handle the role-playing dimension pattern, where the same table is joined via multiple relationships. A common example is an airports table joined to flights as both the departure airport and the arrival airport. The USING keyword on metrics tells the extension which join path to use.
Prerequisites:
A working multi-table semantic view with relationships (see Multi-Table Semantic Views)
Understanding of how
RELATIONSHIPSmaps to JOIN clauses
The Problem¶
When the same table appears as the target of multiple relationships, dimensions from that table are ambiguous. The extension cannot determine which join path to use unless every metric in the query names one with a USING clause.
Consider flights with departure and arrival airports:
CREATE TABLE airports (airport_code VARCHAR, city VARCHAR, country VARCHAR);
CREATE TABLE flights (
flight_id INTEGER,
departure_code VARCHAR,
arrival_code VARCHAR,
carrier VARCHAR
);
Both departure_code and arrival_code point to airports. A dimension like city from airports is ambiguous: is it the departure city or the arrival city?
Define a Role-Playing View¶
Declare two named relationships to the same target table. Then use USING on metrics to select which relationship path each metric traverses.
CREATE SEMANTIC VIEW flight_analytics AS
TABLES (
f AS flights PRIMARY KEY (flight_id),
a AS airports PRIMARY KEY (airport_code)
)
RELATIONSHIPS (
dep_airport AS f(departure_code) REFERENCES a,
arr_airport AS f(arrival_code) REFERENCES a
)
DIMENSIONS (
a.city AS a.city,
a.country AS a.country,
f.carrier AS f.carrier
)
METRICS (
f.departure_count USING (dep_airport) AS COUNT(*),
f.arrival_count USING (arr_airport) AS COUNT(*)
);
The USING (dep_airport) clause tells the extension: when this metric is queried alongside a dimension from the airports table, use the dep_airport relationship to resolve the join path.
Snowflake’s documentation writes the same view with one plain count and a
metric per role over it. A metric can name another metric on its own table,
and the referenced aggregate is computed through the referencing metric’s
USING path:
METRICS (
f.flight_count AS COUNT(*),
f.departure_count USING (dep_airport) AS f.flight_count,
f.arrival_count USING (arr_airport) AS f.flight_count
);
Both forms give the same numbers. See METRICS for the rules.
Query with USING Context¶
Departures by city: the departure_count metric’s USING context resolves city via dep_airport:
SELECT * FROM SEMANTIC_VIEW(flight_analytics
DIMENSIONS city
METRICS departure_count
) ORDER BY city;
Arrivals by city: the arrival_count metric’s USING context resolves city via arr_airport:
SELECT * FROM SEMANTIC_VIEW(flight_analytics
DIMENSIONS city
METRICS arrival_count
) ORDER BY city;
Departures and arrivals by city in one query: each metric is grouped by
city through its own relationship, and the two results are merged on
city. A city that only one role reaches gets NULL for the other
metric:
SELECT * FROM SEMANTIC_VIEW(flight_analytics
DIMENSIONS city
METRICS departure_count, arrival_count
) ORDER BY city;
Added in version 0.18.0: Metrics with different USING paths to the same table can be queried
together with a dimension on that table. Before, the query was refused as
ambiguous.
Every metric needs a path: a metric without USING cannot say which
airport a city is, so grouping it by city is refused, also when another
metric in the query names a path:
-- Refused: flight_count names no relationship to airports.
SELECT * FROM SEMANTIC_VIEW(flight_analytics
DIMENSIONS city
METRICS departure_count, flight_count
);
Changed in version 0.18.0: A metric without USING used to borrow the path of a co-queried metric
that had one, so its numbers changed with what else was in the query. It is
now refused, as in Snowflake.
A dimension one relationship further on follows the same path. If
airports references a regions table, departure_count by a region
dimension groups by the departure airport’s region, and departure_count,
arrival_count by region merges the two per region, as by city.
A window metric names its path through its inner metric: a window over
departure_count is grouped by departure city. Two window metrics over
different paths cannot be grouped by city together, and neither can metrics
on different paths beside a semi-additive metric whose NON ADDITIVE BY
dimension is not queried; query those metrics separately.
A metric on a table that has no relationship to airports, such as a count
of flight legs on a legs table under flights, cannot name a path,
because a USING relationship must start at the metric’s own table. Grouping
it by city is refused, as in Snowflake.
Non-ambiguous dimensions: the carrier dimension comes from the flights table (not airports), so it works with any metric without ambiguity:
SELECT * FROM SEMANTIC_VIEW(flight_analytics
DIMENSIONS carrier
METRICS departure_count, arrival_count
) ORDER BY carrier;
Derived Metrics with Role-Playing¶
A derived metric cannot have USING itself, but it can combine metrics that do:
METRICS (
f.departure_count USING (dep_airport) AS COUNT(*),
f.arrival_count USING (arr_airport) AS COUNT(*),
total_flights AS departure_count + arrival_count
);
total_flights depends on both dep_airport and arr_airport. Querying total_flights with a non-ambiguous dimension like carrier works:
SELECT * FROM SEMANTIC_VIEW(flight_analytics
DIMENSIONS carrier
METRICS total_flights
) ORDER BY carrier;
Querying total_flights by city works too: each component is grouped by
city through its own path, and the derived metric is computed from the
merged components. Where a city is reached by only one path, the other
component is NULL and so is the sum.
Changed in version 0.18.0: A derived metric over different USING paths used to be refused with a
role-played dimension.
Aggregating a Fact on the Role-Playing Table¶
Changed in version 0.14.0: A USING metric can now aggregate a fact declared on the role-playing
table. Such a metric used to fail with Referenced table "a" not found.
A metric can aggregate a fact declared on the role-playing table itself. Its
USING picks which instance of the table the fact is read from, so the same
fact gives a different number per role. Recreate the two tables with an
elevation and some flights:
CREATE OR REPLACE TABLE airports (
airport_code VARCHAR, city VARCHAR, country VARCHAR, elevation INTEGER
);
INSERT INTO airports VALUES
('DEN', 'Denver', 'US', 5434),
('SFO', 'San Francisco', 'US', 13),
('LHR', 'London', 'UK', 83);
CREATE OR REPLACE TABLE flights (
flight_id INTEGER,
departure_code VARCHAR,
arrival_code VARCHAR,
carrier VARCHAR
);
INSERT INTO flights VALUES
(1, 'SFO', 'DEN', 'UA'),
(2, 'DEN', 'LHR', 'UA'),
(3, 'LHR', 'SFO', 'BA');
CREATE SEMANTIC VIEW flight_elevation AS
TABLES (
f AS flights PRIMARY KEY (flight_id),
a AS airports PRIMARY KEY (airport_code)
)
RELATIONSHIPS (
dep_airport AS f(departure_code) REFERENCES a,
arr_airport AS f(arrival_code) REFERENCES a
)
FACTS (
a.elev AS a.elevation
)
DIMENSIONS (
f.carrier AS f.carrier
)
METRICS (
f.dep_elev USING (dep_airport) AS SUM(a.elev),
f.arr_elev USING (arr_airport) AS SUM(a.elev),
elev_climb AS arr_elev - dep_elev
);
dep_elev sums the departure airports’ elevations and arr_elev the
arrival airports’. elev_climb composes both roles and keeps them apart:
SELECT * FROM SEMANTIC_VIEW(flight_elevation
DIMENSIONS carrier
METRICS dep_elev, arr_elev, elev_climb
) ORDER BY carrier;
┌─────────┬──────────┬──────────┬────────────┐
│ carrier │ dep_elev │ arr_elev │ elev_climb │
├─────────┼──────────┼──────────┼────────────┤
│ BA │ 83 │ 13 │ -70 │
│ UA │ 5447 │ 5517 │ 70 │
└─────────┴──────────┴──────────┴────────────┘
The metric’s USING must name exactly one relationship to the table the fact
lives on (spelling that relationship twice, in any case, still names one). The
role carries through to a window metric over such a metric
(f.dep_elev_by_flight AS SUM(dep_elev) OVER (PARTITION BY flight_id)) and
to a semi-additive one.
A member that reaches such a fact with no single role to pick is rejected as
ambiguous at query time (see Troubleshooting). That covers a metric
without USING (including one used as a window metric’s inner aggregate), a
dimension, and a fact on a table reached only through the role-playing table.
Earlier versions answered these with the first-declared relationship’s numbers.
Declare such metrics on the referencing table, as above. A metric declared on
the role-playing table itself (a.total_elev AS SUM(a.elevation)) cannot
name a role, because a USING relationship must start at the metric’s own
table. It is rejected as ambiguous too.
Relationship names may be quoted identifiers, including names with spaces or
embedded quotes: declare "Dep Airport" AS f(departure_code) REFERENCES a and
pick it with USING ("Dep Airport"). Names match the way DuckDB matches
identifiers, so USING (dep_airport) also finds a relationship declared as
"Dep_Airport". Before v0.14.0 a quoted name on a role-playing relationship
made every query through it fail with a parser error.
Inspect the Scoped Aliases¶
Use explain_semantic_view() to see how the extension creates scoped aliases for role-playing joins:
SELECT * FROM explain_semantic_view('flight_analytics',
dimensions := ['city'],
metrics := ['departure_count']
);
The expanded SQL shows the airports table joined with a scoped alias (e.g., a__dep_airport) that reflects which relationship path was used.
Role-Playing vs an Ambiguous Diamond¶
Changed in version 0.11.0: A table reached from two different source tables – a join “diamond” – is
rejected at CREATE time. Earlier versions accepted it and joined the
shared table through whichever relationship was declared first, which gave
wrong numbers when the two paths lead to different rows.
Role-playing and a diamond both reach one table by two routes, but only one of them tells the extension which route a query means:
Role-playing (supported): several named relationships from one source table to one target, like
dep_airportandarr_airportfromflightstoairportsabove.USINGon a metric picks the route.Diamond (rejected): one target reached from two different source tables. In the view below,
regionsis reached from bothcustomersandstores, so aregiondimension could mean the customer’s region or the store’s:
CREATE SEMANTIC VIEW sales AS
TABLES (
o AS orders PRIMARY KEY (order_id),
c AS customers PRIMARY KEY (customer_id),
s AS stores PRIMARY KEY (store_id),
r AS regions PRIMARY KEY (region_id)
)
RELATIONSHIPS (
order_customer AS o(customer_id) REFERENCES c,
order_store AS o(store_id) REFERENCES s,
customer_region AS c(region_id) REFERENCES r,
store_region AS s(region_id) REFERENCES r
)
DIMENSIONS (
r.region AS r.name
)
METRICS (
o.revenue AS SUM(o.amount)
);
diamond: 'r' is reachable from multiple tables ('c', 's'); the join path is
ambiguous. Declare the target under a second table alias so it is joined once
per path.
Fix it as the message says: declare regions once per path, under two
aliases, and give each alias its own dimension. Each route then has its own
table and there is nothing left to choose between:
CREATE SEMANTIC VIEW sales AS
TABLES (
o AS orders PRIMARY KEY (order_id),
c AS customers PRIMARY KEY (customer_id),
s AS stores PRIMARY KEY (store_id),
cr AS regions PRIMARY KEY (region_id),
sr AS regions PRIMARY KEY (region_id)
)
RELATIONSHIPS (
order_customer AS o(customer_id) REFERENCES c,
order_store AS o(store_id) REFERENCES s,
customer_region AS c(region_id) REFERENCES cr,
store_region AS s(region_id) REFERENCES sr
)
DIMENSIONS (
cr.customer_region AS cr.name,
sr.store_region AS sr.name
)
METRICS (
o.revenue AS SUM(o.amount)
);
A query can now group by customer_region, store_region or both.
Two relationships from one source to two different targets, like
orders → customers and orders → products in Multi-Table Semantic Views,
are neither pattern and need nothing extra.
Troubleshooting¶
- “dimension is ambiguous” error
This occurs when a dimension comes from a role-playing table and some metric in the query (or the query itself, with no metrics) names no
USINGpath to it. Solutions:Give every queried metric a
USINGthat targets the desired relationship, for example by writingf.departures USING (dep_airport) AS f.flight_countover the plain metric.Use a dimension from a non-ambiguous table (like the base table).
- Multiple USING paths for the same table
Changed in version 0.18.0.
Co-queried metrics with different
USINGpaths to the dimension’s table are each grouped through their own path and merged (see Query with USING Context). This used to raise an ambiguity error.- Dimension on a descendant of a role-playing table
Changed in version 0.18.0.
A dimension on a table reached through a role-playing table – for example
region_nameonregionswhereairportsreferencesregions– follows the path each metric’sUSINGnames, as in Snowflake:departure_countbyregion_namegroups by the departure airport’s region, anddeparture_count, arrival_countbyregion_namemerges the two per region. A metric with noUSINGis refused as ambiguous, as it is for a dimension directly onairports. Since 0.11.0 and until 0.18.0 every such query was refused; before 0.11.0 it silently used the first-declared relationship.- Facts on a role-playing table
Changed in version 0.11.0.
A fact sourced on a role-playing table (or a descendant of one) is now rejected. Facts carry no
USINGcontext, so the role was always unresolvable – previously such a fact silently read the first-declared relationship’s rows.- A member references a fact on a role-playing table
Changed in version 0.14.0.
A metric or dimension whose expression reaches a fact on a role-playing table (or on a table reached only through one) is rejected as ambiguous unless it is a metric whose
USINGnames exactly one relationship to the fact’s own table. Previously such a member silently read the first-declared relationship’s rows. AddUSINGto the metric, or declare the fact on a non-role-playing table. See Aggregating a Fact on the Role-Playing Table.- A metric is declared on a role-playing table
Changed in version 0.14.0.
A metric whose own table is a role-playing table (or is reached only through one) is rejected as ambiguous:
USINGcannot be attached to it, since aUSINGrelationship must start at the metric’s own table. Previously, when no fan trap stopped it (one-to-one relationships), it silently read the first-declared relationship’s rows. Declare the metric on the referencing table withUSINGinstead. See Aggregating a Fact on the Role-Playing Table.The one exception is the base table, the first table in
TABLES. A metric declared on it is accepted when it is queried without dimensions, alongside only other base-table metrics, and with a filter (if any) that names only base-table members. Nothing is joined then, so there is no relationship to choose. Grouping it by a dimension, even one on the base table, or giving itNON ADDITIVE BYis still rejected.- Semi-additive metric snapshotting on a role-playing dimension
Changed in version 0.11.0.
A metric written
m USING (<relationship>) NON ADDITIVE BY (<dim on the role-played table>)now selects the snapshot for the role itsUSINGnames, instead of an arbitrary one. A semi-additive metric that snapshots on a role-playing dimension but has noUSINGto disambiguate the role is now rejected as ambiguous at query time – the same error a directly-queried role-playing dimension raises. See How to Use Semi-Additive Metrics.