[Beta] Controlling filter and grouping paths
Introduction
When you explore data in Holistics, using a dimension with a metric means the metric gets grouped or sliced by that dimension. If the dimension and metric come from different models, Holistics uses the relationships between them to generate the proper joins alongside the grouping.
As long as a relationship path exists between two models, Holistics will find a way to join them. This is usually what you want, but sometimes combining a dimension and metric from different models doesn't make analytical sense.
This document explains how to control the filtering and grouping behavior between models.
How to control filter and grouping behavior
Relationships in a dataset have a property called filter_direction that controls which direction filters and groupings can flow between two models.
Dataset ecommerce {
...
models: [dim_users, fact_orders]
relationships: [
relationship(fact_orders.user_id > dim_users.id, true, 'one_way')
// ^^^^^^^^
// 'one_way' or 'two_way'
// defaults to 'two_way' if not specified
]
}
Available values
| Value | Behavior | When to use |
|---|---|---|
one_way | Filters and groupings flow only from the "one" side (dimension) to the "many" side (fact). | Standard dimension to fact relationships. Use this as your default for * Star schema or * Galaxy schema. |
two_way | Filters and groupings can flow in both directions. This is the default if not specified. | * Many-to-many relationships * 1:1 relationships |
If you don't specify filter_direction, it defaults to two_way.
How it works
Given a relationship fact_orders.user_id > dim_users.id:
- With
one_way: You can groupfact_ordersmetrics bydim_usersdimensions, but not the reverse. The dimension can filter and segment the fact, but the fact cannot reach back to filter or segment the dimension. - With
two_way(default): You can group in both directions. This allows more flexibility but can create unintended join paths in complex schemas.
Example use cases
When to use one_way
The most common use case for one_way is preventing invalid metric and dimension combinations in multi-fact setups like galaxy schemas. When you have multiple fact tables sharing common dimensions, bidirectional relationships can create unintended join paths that produce misleading results.
For example, in an e-commerce dataset with fact_orders and fact_inventory both connected to dim_products, you want to ensure that inventory metrics can only be grouped by product dimensions, not by user or order dimensions.
- [HTML node] — Dimension. Filters flow from here to dim_users, then to fact_orders.
- [HTML node] — Dimension shared by dim_cities and fact_orders.
- [HTML node] — Fact table. Its metrics can be grouped by users, cities, products, merchants, and categories.
- [HTML node] — Dimension of dim_products.
- [HTML node] — Dimension shared by fact_orders and fact_inventory.
- [HTML node] — Dimension of dim_products.
- [HTML node] — Fact table. Its metrics can only be grouped by product, merchant, and category dimensions.
- dim-cities → dim-users
- dim-users → fact-orders
- dim-products → fact-orders
- dim-merchants → dim-products
- dim-categories → dim-products
- dim-products → fact-inventory
Dataset ecommerce {
...
models: [
dim_users, dim_products, dim_cities,
dim_merchants, dim_categories,
fact_orders, fact_inventory
]
relationships: [
relationship(fact_orders.user_id > dim_users.id, true, 'one_way'),
relationship(fact_orders.product_id > dim_products.id, true, 'one_way'),
relationship(fact_inventory.product_id > dim_products.id, true, 'one_way'),
relationship(dim_users.city_id > dim_cities.id, true, 'one_way'),
relationship(dim_products.merchant_id > dim_merchants.id, true, 'one_way'),
relationship(dim_products.category_id > dim_categories.id, true, 'one_way')
]
}
For a detailed walkthrough of this scenario, see Controlling which dimensions can be used with a metric.
When to use two_way
Use two_way when you genuinely need filters and groupings to flow in both directions between two models. Common scenarios include:
Dimension to dimension analysis
When you need to analyze one dimension filtered by another dimension, traversing through a fact table, you may need two_way to allow the filter to flow in both directions.
Consider this model: dim_users → fact_orders ← dim_products. If you want to answer questions like "How many unique products (from a specific category) were purchased by each user age group?", the query needs to traverse from dim_users through the fact tables to reach dim_products.
If all relationships are set to one_way, the filter flows from dim_users to fact_orders, but stops there. It cannot continue to dim_products because that would require flowing in the reverse direction. To enable this traversal, you would need to set the relationship between fact_orders and dim_products to two_way.
- [HTML node] — Dimension. Its filters flow to fact_orders, but fact_orders cannot filter it back.
- [HTML node] — Fact table joining users and products.
- [HTML node] — Dimension. two_way lets a filter from dim_users reach it through fact_orders.
- dim-users → fact-orders (one_way)
- dim-products → fact-orders (two_way)
Dataset ecommerce {
...
models: [dim_users, fact_orders, dim_products]
relationships: [
relationship(fact_orders.user_id > dim_users.id, true, 'one_way'),
relationship(fact_orders.product_id > dim_products.id, true, 'two_way')
]
}
1:1 relationships
When two models have a true one-to-one relationship, there's no risk of data fan-out in either direction, so two_way is appropriate.
For example, if each user can only be the admin of one merchant, and each merchant can only have one admin user, the relationship between dim_users and dim_merchants is 1:1. Grouping merchants by user attributes or users by merchant attributes will never inflate the results.
For 1:1 relationships, filter_direction is always two_way and cannot be changed to one_way.
Overriding filter direction at the metric level
Sometimes you want to keep one_way as the default for safety, but allow specific metrics to traverse in the reverse direction. You can do this using with_relationships() to override the filter direction for individual metrics.
For example, say you want to answer: "What's the latest user sign-up date for each order status?" This query needs to go from fact_orders.status to dim_users.sign_up_date, which is the reverse of the normal dimension to fact flow. If the relationship is set to one_way, this query would be blocked.
Instead of changing the dataset relationship to two_way (which would affect all queries), you can override it just for this metric:
Dataset ecommerce {
// ... models and relationships ...
metric latest_user_signup_by_order_status {
label: "Latest User Sign-up Date"
definition: @aql
max(dim_users.sign_up_date)
| with_relationships(
relationship(fact_orders.user_id > dim_users.id, true, 'two_way')
)
;;
}
}
This approach keeps the default one_way protection for all other queries while allowing this specific metric to use bidirectional traversal.
Row-level permission and filter direction
Row-level permission (RLP) filters also travel through your relationships, but they do not follow filter_direction. By default they flow in both directions, so one rule can reach every connected model even when every relationship is one_way. If you need to restrict that, set the rlp_propagation property on the relationship.
The two properties do different jobs. filter_direction controls what end users can group and filter by. rlp_propagation controls how far a permission filter can travel.
Available values
The rlp_propagation property supports the following values to control how permission filters flow through your relationships:
| Value | Behavior |
|---|---|
'two_way' (default when not specified) | Permission filters flow in both directions, regardless of filter_direction. |
'one_way' | Permission filters flow only from the "one" side to the "many" side, even when filter_direction is 'two_way'. |
'inherit' | Permission filters follow the relationship's filter_direction. |
Default behavior: permission filters reach every connected model
Let's return to the ecommerce dataset from When to use one_way, now with a total_orders metric and a permission rule on dim_cities.region so each user only sees data for their permitted regions. Here the rule is defined with row-level permission as-code; the behavior is the same for rules created through the UI:
Dataset ecommerce {
...
models: [
dim_users, dim_products, dim_cities,
dim_merchants, dim_categories,
fact_orders, fact_inventory
]
relationships: [
relationship(fact_orders.user_id > dim_users.id, true, 'one_way'),
relationship(fact_orders.product_id > dim_products.id, true, 'one_way'),
relationship(fact_inventory.product_id > dim_products.id, true, 'one_way'),
relationship(dim_users.city_id > dim_cities.id, true, 'one_way'),
relationship(dim_products.merchant_id > dim_merchants.id, true, 'one_way'),
relationship(dim_products.category_id > dim_categories.id, true, 'one_way')
]
metric total_orders {
label: 'Total Orders'
type: 'number'
definition: @aql count(fact_orders.id);;
}
permission regional_access {
field: r(dim_cities.region)
operator: 'matches_user_attribute'
value: 'region' // user attribute
}
}
Break total_orders down by dim_products.name, without any city field:
explore {
dimensions {
dim_products.name
}
measures {
total_orders
}
}
This works. The main query is filtered through dim_cities → dim_users → fact_orders. The separate query that fetches product names is filtered through dim_cities → dim_users → fact_orders → dim_products. That last hop runs against the one_way direction of the fact_orders > dim_products relationship, but permission filters are allowed to cross it because rlp_propagation defaults to two_way.
- permission regional_access field: dim_cities.region — Row-level permission rule defined on dim_cities
- [HTML node] — The permission rule filters this model on region.
- [HTML node] — Filtered through dim_cities.
- [HTML node] — The main query for total_orders is filtered here through dim_users.
- [HTML node] — The value-fetch query for product names is filtered through fact_orders. That hop runs against one_way, but rlp_propagation defaults to two_way so the permission filter is allowed to cross.
- Main query: total_orders, filtered via cities → users → orders Value-fetch query: dim_products.name, filtered via cities → users → orders → products
- permission filter
- against one_way, still allowed: rlp_propagation defaults to two_way
- dim-cities → dim-users (one_way)
- dim-users → fact-orders (one_way)
- dim-products → fact-orders (one_way)
- permission → dim-cities (filters region)
- dim-cities → dim-users (1)
- dim-users → fact-orders (2)
- fact-orders → dim-products (3)
This only affects security propagation. End users still cannot group or filter against the one_way direction, so the guardrails you set up with filter_direction stay intact.
The problem: over-filtering through shared dimensions
Because permission filters flow both ways, a rule can pass through a shared dimension into a fact table it was never meant to filter. Add an inventory metric to the dataset above:
Dataset ecommerce {
...
metric total_stock {
label: 'Total Stock'
type: 'number'
definition: @aql sum(fact_inventory.quantity);;
}
}
fact_inventory holds warehouse stock. It has no region of its own, so a regional manager should see the full inventory. Now explore total_stock by dim_products.name:
explore {
dimensions {
dim_products.name
}
measures {
total_stock
}
}
The permission filter flows dim_cities → dim_users → fact_orders → dim_products → fact_inventory. The manager sees stock only for products that users in their region have ordered. Products nobody in the region has bought drop out of the result, with no warning.
The fix: rlp_propagation='one_way'
To keep the permission on the orders side, set rlp_propagation='one_way' on the fact_orders > dim_products relationship. Permission filters can then only flow from products to orders:
Dataset ecommerce {
...
relationships: [
relationship(fact_orders.user_id > dim_users.id, true, 'one_way'),
relationship(fact_orders.product_id > dim_products.id, true, 'one_way', rlp_propagation='one_way'),
relationship(fact_inventory.product_id > dim_products.id, true, 'one_way'),
relationship(dim_users.city_id > dim_cities.id, true, 'one_way'),
relationship(dim_products.merchant_id > dim_merchants.id, true, 'one_way'),
relationship(dim_products.category_id > dim_categories.id, true, 'one_way')
]
}
The permission filter now stops at fact_orders. total_stock by product returns the full inventory, and total_orders on its own is still filtered by region.
This has a cost. The rule can no longer reach dim_products, fact_inventory, dim_merchants, or dim_categories. Holistics checks every Explore against the permission rules on the dataset. If an Explore uses a field from a model the rule cannot filter, Holistics blocks the query rather than return unfiltered data:
Some permission rules are not applied in this Explore. This is likely due to a missing relationship between the models.
This includes breakdowns. total_orders by dim_products.name now fails too, because the query that fetches product names cannot be filtered. Users under the regional_access rule cannot explore inventory, products, merchants, or categories in this dataset at all.
So 'one_way' is a choice between over-filtering and blocking. Use it when you would rather block those Explores than show partially filtered data. If those models need to stay explorable under the rule, keep the default. Or move inventory reporting into a separate dataset without this permission rule. See the row-level permission FAQ for the other cause of this error.
When to use 'inherit'
Set rlp_propagation='inherit' when your filter_direction settings already mark where permission filters should stop. On a one_way relationship it behaves like 'one_way', and on a two_way relationship like 'two_way'. Since the default is 'two_way', you need to set this explicitly.
You can also write rlp_propagation='two_way' on a relationship to make the intent obvious. It has no effect, since that is the default.