Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/adr/0001-drop-semantic-mutate-op.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# ADR 0001: Unify calculated measures and post-aggregation `mutate` on a single ibis-expression primitive

- **Status:** Implemented — Phases 1+2 landed on `hussain/feat/calc-measure-analyzer` (merged to `main`); Phase 3 landed on `hussain/feat/drop-semantic-mutate-op-phase3`.
- **Amended 2026-08-23:** the base-class fallback that materialized `.mutate()` chained after `filter`/`order_by`/`limit` on an aggregate (the `_build_post_aggregate_model` wrapper) was removed. That spelling now raises `QueryError`: a query result is a plain table, so row math over it is spelled `.to_untagged().mutate(...)`, while `.mutate()` directly on the aggregate (the measure-path desugar this ADR describes) and calc measures on the model remain the semantic spellings. In the same change, output sinks (`execute`/`sql`/`to_pandas`/…) began refusing definition-side expressions (bare models, filters, joins, `group_by` without `aggregate`) — see `test_execute_guard.py`.
- **Date:** 2026-05-08 (revised 2026-05-09 to reflect landed work; revised 2026-06-10 for Phase 3 completion)
- **Deciders:** BSL maintainers
- **Related code (current state):** `src/boring_semantic_layer/calc_analyzer.py` (new), `src/boring_semantic_layer/calc_compiler.py` (new), `src/boring_semantic_layer/nested_compile.py` (new — extracted from deleted `compile_all.py`), `src/boring_semantic_layer/ops.py` (`CalcMeasure`, `_classify_measure`, `_build_aggregation_plan`, `_compile_aggregation`, `_apply_calc_specs`; `SemanticMutateOp` — deleted in Phase 3), `src/boring_semantic_layer/expr.py` (`SemanticMutate` deleted; `.mutate()` survives as a desugaring alias), `src/boring_semantic_layer/measure_scope.py` (`MeasureScope`/`ColumnScope` thin proxies; curated AST removed), `src/boring_semantic_layer/serialization/extract.py` (resolver-tree calc serialization), `src/boring_semantic_layer/tests/test_mutate_compositions.py` (Phase 3 composition pins).
Expand Down
20 changes: 10 additions & 10 deletions docs/md/doc/query-methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,29 +428,29 @@ Here's a simple example:
```query_window_example
from ibis import _

# First aggregate to daily level
# First aggregate to origin level
daily_flights = (
flights_st
.group_by("origin")
.aggregate("flight_count", "total_distance")
.order_by("origin")
)

# Then apply window function for cumulative distance
window_spec = xo.window(order_by="origin")

# Then apply window functions directly on the aggregate — the window
# carries its own ordering, and the result stays a semantic query
result = daily_flights.mutate(
cumulative_distance=_.total_distance.cumsum(),
cumulative_distance=lambda t: t.total_distance.sum().over(
rows=(None, 0), order_by="origin"
),
flight_rank=lambda t: xo.rank().over(xo.window(order_by=xo.desc(t.flight_count)))
).limit(10)
).order_by("origin").limit(10)
```

<bslquery code-block="query_window_example"></bslquery>

**Key points:**
- Window functions are applied **after** `.aggregate()` using `.mutate()`
- Use `.order_by()` to establish row order for window operations
- Combine with `xo.window()` for advanced sliding window calculations
- Window functions are applied via `.mutate()` **directly on the aggregate** (before `.order_by()`/`.limit()` — after those the result is a plain table and `.mutate()` raises)
- Give each window its own ordering via the keyword form of `.over()` (e.g. `rows=(None, 0), order_by="origin"`)
- For row math over a *filtered* result, drop to ibis explicitly with `.to_untagged().mutate(...)`

For comprehensive examples including lag/lead, moving averages, and ranking, see [Window Functions](/advanced/windowing).

Expand Down
79 changes: 41 additions & 38 deletions docs/md/doc/windowing.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,16 +84,21 @@ daily_revenue = (
sales_st
.group_by("sale_date")
.aggregate("total_revenue")
.order_by("sale_date")
)

# Add window functions for lag/lead
# Add window functions for lag/lead — applied directly on the aggregate,
# each window carrying its own ordering
result = daily_revenue.mutate(
prev_day_revenue=_.total_revenue.lag(),
next_day_revenue=_.total_revenue.lead(),
day_over_day_change=_.total_revenue - _.total_revenue.lag(),
pct_change=((_.total_revenue - _.total_revenue.lag()) / _.total_revenue.lag() * 100).round(2)
).limit(10)
prev_day_revenue=lambda t: t.total_revenue.lag().over(order_by="sale_date"),
next_day_revenue=lambda t: t.total_revenue.lead().over(order_by="sale_date"),
day_over_day_change=lambda t: (
t.total_revenue - t.total_revenue.lag().over(order_by="sale_date")
),
pct_change=lambda t: (
(t.total_revenue - t.total_revenue.lag().over(order_by="sale_date"))
/ t.total_revenue.lag().over(order_by="sale_date") * 100
).round(2),
).order_by("sale_date").limit(10)
```

<bslquery code-block="query_lag_lead"></bslquery>
Expand All @@ -114,17 +119,15 @@ daily_revenue = (
sales_st
.group_by("sale_date")
.aggregate("total_revenue")
.order_by("sale_date")
)

# Calculate cumulative sum and running average
window_unbounded = xo.window(rows=(None, 0), order_by="sale_date")

result = daily_revenue.mutate(
cumulative_revenue=_.total_revenue.cumsum(),
days_count=lambda t: t.count().over(window_unbounded),
avg_daily_so_far=lambda t: (t.cumulative_revenue / t.days_count).round(2)
).limit(10)
cumulative_revenue=lambda t: t.total_revenue.sum().over(window_unbounded),
avg_daily_so_far=lambda t: t.total_revenue.mean().over(window_unbounded).round(2),
).order_by("sale_date").limit(10)
```

<bslquery code-block="query_running_total"></bslquery>
Expand All @@ -141,16 +144,15 @@ daily_revenue = (
sales_st
.group_by("sale_date")
.aggregate("total_revenue")
.order_by("sale_date")
)

# 7-day moving average
window_7d = xo.window(rows=(-6, 0), order_by="sale_date")

result = daily_revenue.mutate(
ma_7day=_.total_revenue.mean().over(window_7d).round(2),
ma_7day_sum=_.total_revenue.sum().over(window_7d).round(2),
).limit(10)
ma_7day=lambda t: t.total_revenue.mean().over(window_7d).round(2),
ma_7day_sum=lambda t: t.total_revenue.sum().over(window_7d).round(2),
).order_by("sale_date").limit(10)
```

<bslquery code-block="query_moving_average"></bslquery>
Expand All @@ -171,15 +173,14 @@ category_revenue = (
sales_st
.group_by("product_category")
.aggregate("total_revenue", "sale_count")
.order_by(_.total_revenue.desc())
)

# Add rank columns
# Add rank columns (each window carries its own ordering)
result = category_revenue.mutate(
rank=lambda t: xo.rank().over(xo.window(order_by=xo.desc(t.total_revenue))),
dense_rank=lambda t: xo.dense_rank().over(xo.window(order_by=xo.desc(t.total_revenue))),
row_number=lambda t: xo.row_number().over(xo.window(order_by=xo.desc(t.total_revenue))),
)
).order_by(_.total_revenue.desc())
```

<bslquery code-block="query_ranking"></bslquery>
Expand All @@ -201,15 +202,17 @@ weekly_revenue = (
.mutate(week_start=_.sale_date.truncate("W"))
.group_by("week_start")
.aggregate("total_revenue")
.order_by("week_start")
)

# Calculate week-over-week changes
result = weekly_revenue.mutate(
prev_week_revenue=_.total_revenue.lag(),
wow_change=_.total_revenue - _.total_revenue.lag(),
wow_pct_change=((_.total_revenue - _.total_revenue.lag()) / _.total_revenue.lag() * 100).round(2)
).limit(10)
prev_week_revenue=lambda t: t.total_revenue.lag().over(order_by="week_start"),
wow_change=lambda t: t.total_revenue - t.total_revenue.lag().over(order_by="week_start"),
wow_pct_change=lambda t: (
(t.total_revenue - t.total_revenue.lag().over(order_by="week_start"))
/ t.total_revenue.lag().over(order_by="week_start") * 100
).round(2),
).order_by("week_start").limit(10)
```

<bslquery code-block="query_week_over_week"></bslquery>
Expand All @@ -230,11 +233,12 @@ top_days = (
.limit(10)
)

# Calculate cumulative percentage
result = top_days.mutate(
cumulative_revenue=_.total_revenue.cumsum(),
total_top10=_.total_revenue.sum(),
pct_of_top10=(_.total_revenue.cumsum() / _.total_revenue.sum() * 100).round(2)
# A limited query result is a plain table — row math over it drops to
# ibis explicitly via .to_untagged()
result = top_days.to_untagged().mutate(
cumulative_revenue=lambda t: t.total_revenue.cumsum(),
total_top10=lambda t: t.total_revenue.sum(),
pct_of_top10=lambda t: (t.total_revenue.cumsum() / t.total_revenue.sum() * 100).round(2),
)
```

Expand All @@ -254,30 +258,29 @@ weekend_revenue = (
.filter(_.is_weekend)
.group_by("sale_date")
.aggregate("total_revenue")
.order_by("sale_date")
)

# 3-weekend moving average
window_3 = xo.window(rows=(-2, 0), order_by="sale_date")

result = weekend_revenue.mutate(
ma_3weekend=_.total_revenue.mean().over(window_3).round(2),
prev_weekend=_.total_revenue.lag(),
weekend_change=_.total_revenue - _.total_revenue.lag()
).limit(10)
ma_3weekend=lambda t: t.total_revenue.mean().over(window_3).round(2),
prev_weekend=lambda t: t.total_revenue.lag().over(order_by="sale_date"),
weekend_change=lambda t: t.total_revenue - t.total_revenue.lag().over(order_by="sale_date"),
).order_by("sale_date").limit(10)
```

<bslquery code-block="query_window_filter"></bslquery>

## Key Takeaways

- **Window functions operate after aggregation**: They work on query results, not raw data
- **Order matters**: Most window functions require `order_by()` for meaningful results
- **Window functions go on the aggregate**: apply `.mutate()` directly on the `aggregate()` result, before `.order_by()`/`.limit()` (after those the result is a plain table and `.mutate()` raises — use `.to_untagged().mutate(...)` there)
- **Each window carries its own ordering**: pass `order_by=` inside the window (e.g. `.over(rows=(-6, 0), order_by="sale_date")` or `.lag().over(order_by="sale_date")`)
- **Flexible windows**: Define windows by rows (`rows=(n, m)`) or ranges
- **Common patterns**:
- `lag()/lead()` for period-over-period comparisons
- `cumsum()` for running totals
- `.over(window)` for moving averages
- `.sum().over(rows=(None, 0), order_by=...)` for running totals
- `.mean().over(window)` for moving averages
- `rank()`, `row_number()` for ranking
- **Combine with filters**: Focus window calculations on specific subsets

Expand Down
9 changes: 5 additions & 4 deletions docs/md/prompts/query/langchain/tool-query-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,13 @@ model.group_by("category").aggregate("revenue").order_by(ibis.desc("revenue")).l
**CRITICAL**: `.limit()` in query limits data **before** calculations. Use `limit` parameter for display-only limiting.

## Window Functions
`.mutate()` for post-aggregation transforms - **MUST** come after `.order_by()`:
Apply `.mutate()` directly on the aggregate (before `.order_by()`/`.limit()` — after those the result is a plain table and `.mutate()` raises); the window carries its own ordering via the keyword form of `.over()`:
```python
model.group_by("week").aggregate("count").order_by("week").mutate(
rolling_avg=lambda t: t.count.mean().over(ibis.window(rows=(-9, 0), order_by="week"))
)
model.group_by("week").aggregate("count").mutate(
rolling_avg=lambda t: t["count"].mean().over(rows=(-9, 0), order_by="week")
).order_by("week")
```
On a filtered result, drop to ibis first: `.filter(...).to_untagged().mutate(...)`.
**More**: `get_documentation(topic="windowing")`

## Chart
Expand Down
9 changes: 5 additions & 4 deletions docs/md/skills/claude-code/bsl-query-expert/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,12 +204,13 @@ model.group_by("category").aggregate("revenue").order_by(ibis.desc("revenue")).l
**CRITICAL**: `.limit()` in query limits data **before** calculations. Use `limit` parameter for display-only limiting.

## Window Functions
`.mutate()` for post-aggregation transforms - **MUST** come after `.order_by()`:
Apply `.mutate()` directly on the aggregate (before `.order_by()`/`.limit()` — after those the result is a plain table and `.mutate()` raises); the window carries its own ordering via the keyword form of `.over()`:
```python
model.group_by("week").aggregate("count").order_by("week").mutate(
rolling_avg=lambda t: t.count.mean().over(ibis.window(rows=(-9, 0), order_by="week"))
)
model.group_by("week").aggregate("count").mutate(
rolling_avg=lambda t: t["count"].mean().over(rows=(-9, 0), order_by="week")
).order_by("week")
```
On a filtered result, drop to ibis first: `.filter(...).to_untagged().mutate(...)`.
**More**: `get_documentation(topic="windowing")`

## Chart
Expand Down
9 changes: 5 additions & 4 deletions docs/md/skills/codex/bsl-query-expert.codex
Original file line number Diff line number Diff line change
Expand Up @@ -203,12 +203,13 @@ model.group_by("category").aggregate("revenue").order_by(ibis.desc("revenue")).l
**CRITICAL**: `.limit()` in query limits data **before** calculations. Use `limit` parameter for display-only limiting.

## Window Functions
`.mutate()` for post-aggregation transforms - **MUST** come after `.order_by()`:
Apply `.mutate()` directly on the aggregate (before `.order_by()`/`.limit()` — after those the result is a plain table and `.mutate()` raises); the window carries its own ordering via the keyword form of `.over()`:
```python
model.group_by("week").aggregate("count").order_by("week").mutate(
rolling_avg=lambda t: t.count.mean().over(ibis.window(rows=(-9, 0), order_by="week"))
)
model.group_by("week").aggregate("count").mutate(
rolling_avg=lambda t: t["count"].mean().over(rows=(-9, 0), order_by="week")
).order_by("week")
```
On a filtered result, drop to ibis first: `.filter(...).to_untagged().mutate(...)`.
**More**: `get_documentation(topic="windowing")`

## Chart
Expand Down
9 changes: 5 additions & 4 deletions docs/md/skills/cursor/bsl-query-expert.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -205,12 +205,13 @@ model.group_by("category").aggregate("revenue").order_by(ibis.desc("revenue")).l
**CRITICAL**: `.limit()` in query limits data **before** calculations. Use `limit` parameter for display-only limiting.

## Window Functions
`.mutate()` for post-aggregation transforms - **MUST** come after `.order_by()`:
Apply `.mutate()` directly on the aggregate (before `.order_by()`/`.limit()` — after those the result is a plain table and `.mutate()` raises); the window carries its own ordering via the keyword form of `.over()`:
```python
model.group_by("week").aggregate("count").order_by("week").mutate(
rolling_avg=lambda t: t.count.mean().over(ibis.window(rows=(-9, 0), order_by="week"))
)
model.group_by("week").aggregate("count").mutate(
rolling_avg=lambda t: t["count"].mean().over(rows=(-9, 0), order_by="week")
).order_by("week")
```
On a filtered result, drop to ibis first: `.filter(...).to_untagged().mutate(...)`.
**More**: `get_documentation(topic="windowing")`

## Chart
Expand Down
Loading
Loading