Skip to main content

AQL Validation Rules

This page lists the constraints AQL enforces. These rules either fail validation or produce wrong results when broken. For advisory guidance on writing maintainable AQL, see AQL Best Practices. For the mental model of when filters and metrics fire, see Order of Operations.

Model rules

Dimension types must match the underlying column type

Mismatched types cause runtime errors and incorrect results. Holistics does not auto-cast because consistent casting between incompatible types (text to datetime, for example) isn't always possible. Silent casts also hide performance and correctness problems.

// ✅ Good
dimension created_at {
type: 'datetime' // matches timestamp column
}

// ❌ Bad
dimension created_at {
type: 'text' // timestamp column declared as text
}

Relationship rules

Relationships must match the actual data cardinality

Marking a many-to-many as many-to-one produces wrong aggregations (fan-out, double-counting). Verify cardinality against the data, not against your assumption of the schema.

// ✅ Good: verified many-to-one
relationship(orders.user_id > users.id, true)

// ❌ Bad: actually many-to-many
relationship(users.id > products.id, true)

Both keys referenced in a relationship must exist

Missing keys fail validation.

// ✅ Good
Model orders { dimension user_id { type: 'number' } }
Model users { dimension id { type: 'number', primary_key: true } }
relationship(orders.user_id > users.id, true)

// ❌ Bad: customer_id doesn't exist on orders
relationship(orders.customer_id > users.id, true)

The "one" side of a relationship must have unique values

Fan-out prevention relies on uniqueness of the key on the "one" side. Non-unique keys produce incorrect aggregations.

// ✅ Good: cities.id is a primary key
relationship(users.city_id > cities.id, true)

// ❌ Bad: cities.name isn't unique (many cities share names)
relationship(users.city_name > cities.name, true)

Relationship paths between models must be unambiguous

Multiple paths between the same pair of models create ambiguity AQL can't resolve. See Common Relationship Problems for worked examples.

// ❌ Bad: circular reference creates two paths between orders and users
relationship(orders.user_id > users.id, true)
relationship(users.current_order_id > orders.id, true)

// ✅ Good: clear hierarchy
relationship(orders.user_id > users.id, true)
relationship(order_items.order_id > orders.id, true)

Dimension expression rules

Dimensions can only reference fields from accessible models

A dimension can reach its own model's fields plus any model connected via a many-to-one (or chain of many-to-one) relationship. Reaching the "many" side directly fails.

// ✅ Good: direct many-to-one
dimension customer_name {
model: orders
definition: @aql users.name ;;
}

// ✅ Good: transitive many-to-one chain
dimension customer_city {
model: orders
definition: @aql cities.name ;; // orders → users → cities
}

// ❌ Bad: trying to reach the many side
dimension user_orders {
model: users
definition: @aql orders.id ;; // a user has many orders
}

// ❌ Bad: no relationship path
dimension product_category {
model: users
definition: @aql products.category ;;
}

Use dimensionalize() to access the many side of a relationship

To pull aggregated information from the "many" side into a dimension, wrap with dimensionalize(). This collapses the aggregation to a fixed grain so the result behaves as a dimension.

// ✅ Good
dimension total_spent {
model: users
definition: @aql sum(orders.amount) | dimensionalize(users.id) ;;
}

// ❌ Bad: direct access to many side
dimension last_order_amount {
model: users
definition: @aql orders.amount ;;
}

dimensionalize() must appear once, at the top level

Nesting dimensionalize() inside other expressions or applying it more than once produces undefined behavior.

// ✅ Good
dimension customer_lifetime_value {
model: users
definition: @aql sum(orders.amount) | dimensionalize(users.id) ;;
}

// ❌ Bad: nested dimensionalize
dimension complex_calc {
model: users
definition: @aql
sum(orders.amount) | dimensionalize(users.id) +
count(reviews.id) | dimensionalize(users.id)
;;
}

// ❌ Bad: dimensionalize not at top level
dimension avg_order_by_category {
model: categories
definition: @aql
avg(sum(orders.amount) | dimensionalize(users.id))
;;
}

Metric expression rules

Cross-model aggregations must specify a source table

When the expression combines fields from multiple models, AQL can't infer which table sets the aggregation grain. State it explicitly.

// ✅ Good: explicit source table
metric revenue {
definition: @aql sum(order_items, order_items.quantity * products.price) ;;
}

// ❌ Bad: ambiguous source
metric revenue {
definition: @aql sum(order_items.quantity * products.price) ;;
}

group() must be followed by select() or filter()

Aggregate functions can technically follow group() directly, but it raises WARN-300 and obscures the intermediate table. Use select() or filter() to keep the grouped table explicit.

// ✅ Good
metric avg_items_per_order {
definition: @aql
orders
| group(orders.id)
| select(item_count: count(order_items.id))
| avg(item_count)
;;
}

// ⚠️ Triggers WARN-300
metric avg_items_per_order {
definition: @aql
orders
| group(orders.id)
| avg(count(order_items.id))
;;
}

Fields referenced in LoD functions must exist and be in scope

of_all(), exclude(), and dimensionalize() arguments must resolve to a real dimension reachable from the current model context.

// ✅ Good
metric percent_of_total {
definition: @aql safe_divide(revenue * 100, revenue | of_all(products)) ;;
}

// ❌ Bad: 'product' doesn't exist; the model is 'products'
metric percent_of_total {
definition: @aql safe_divide(revenue * 100, revenue | of_all(product)) ;;
}

See also


Open Markdown
Let us know what you think about this document :)