Skip to content

Commit 39767a6

Browse files
authored
Merge pull request #2910 from GaijinEntertainment/bbatkin/sqlite-linq-join-groupby-alias
sqlite_linq: resolve `_.<alias>` after _join via projection registry (chunk N+2)
2 parents 0dbf1cc + cdfd587 commit 39767a6

12 files changed

Lines changed: 852 additions & 138 deletions

benchmarks/sql/join_groupby_count.das

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,35 @@ require _common public
66
// _join(cars, dealers) |> _group_by(_.Brand) |> _select((Brand, N=_._1|>count)) — joined+grouped,
77
// per-group counts returned as an array of (Brand, N) tuples (no terminal count;
88
// the inner `count()` is the per-bucket reducer).
9-
// SQL: SELECT brand, COUNT(*) FROM Cars c JOIN Dealers d ON c.dealer_id = d.id GROUP BY brand.
10-
// sqlite_linq's `_group_by` after `_join` doesn't currently lower (key column is from
11-
// the joined projection, not a base table column). Follow-up TODO 2026-05-25.
9+
// SQL (m1): `SELECT ("t0"."brand"), COUNT(*) FROM "Cars" AS "t0" INNER JOIN "Dealers" AS "t1"
10+
// ON "t0"."dealer_id" = "t1"."id" GROUP BY ("t0"."brand")`. push_group_key resolves the
11+
// `_.Brand` key against the join's `into` projection registry — emits the qualified source
12+
// column (`"t0"."brand"`) rather than the bare alias name. Chunk N+2 (sqlite_linq.das).
1213
// Decs (m4): plan_decs_group_by's `isDecsJoin` adapter mode (Theme 3 Phase 1, audit C3).
1314
// Single pass — hashB-collect + srcA-probe emits per-pair result-lam bind directly
1415
// into plan_group_by_core's bucket update. Vs the tier-2 cascade's 3 intermediate
1516
// allocations (dealers-array → join-array → group-map → output-array). plan_group_by_core
1617
// itself is untouched — the abstraction holds.
1718
// Array (m3f): no array-side join splice; cascade baseline for comparison.
1819

20+
def run_m1(b : B?; n : int) {
21+
with_sqlite(":memory:") $(db) {
22+
fixture_db(db, n)
23+
b |> run("m1_sql/{n}", n) {
24+
let groups <- _sql(db |> select_from(type<Car>)
25+
|> _join(db |> select_from(type<Dealer>),
26+
$(c : Car, d : Dealer) => c.dealer_id == d.id,
27+
$(c : Car, d : Dealer) => (Brand = c.brand, DealerId = d.id))
28+
|> _group_by(_.Brand)
29+
|> _select((Brand = _._0, N = _._1 |> count())))
30+
b |> accept(groups)
31+
if (empty(groups)) {
32+
b->failNow()
33+
}
34+
}
35+
}
36+
}
37+
1938
def run_m3f(b : B?; n : int) {
2039
let cars <- fixture_array(n)
2140
let dealers <- fixture_dealers_array()
@@ -51,6 +70,11 @@ def run_m4(b : B?; n : int) {
5170
}
5271
}
5372

73+
[benchmark]
74+
def join_groupby_count_m1(b : B?) {
75+
run_m1(b, 100000)
76+
}
77+
5478
[benchmark]
5579
def join_groupby_count_m3f(b : B?) {
5680
run_m3f(b, 100000)

benchmarks/sql/join_groupby_to_array.das

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,35 @@ options persistent_heap
44
require _common public
55

66
// _join(cars, dealers) |> _group_by(_.Brand) |> _select((Brand, Total=sum price)) |> to_array.
7-
// SQL: SELECT brand, SUM(price) FROM Cars c JOIN Dealers d ON c.dealer_id = d.id GROUP BY brand.
8-
// sqlite_linq's `_group_by` after `_join` doesn't currently lower (key column is from
9-
// the joined projection). Follow-up TODO 2026-05-25.
7+
// SQL (m1): `SELECT ("t0"."brand"), SUM("t0"."price") FROM "Cars" AS "t0" INNER JOIN
8+
// "Dealers" AS "t1" ON "t0"."dealer_id" = "t1"."id" GROUP BY ("t0"."brand")`.
9+
// push_group_key resolves the `_.Brand` key, and try_translate_group_aggregate resolves
10+
// the inner `_select(_.Price)` to the qualified column — both consult the join's `into`
11+
// projection snapshot. Chunk N+2 (sqlite_linq.das).
1012
// Decs (m4): plan_decs_group_by's `isDecsJoin` adapter mode (Theme 3 Phase 1, audit C3).
1113
// Same shape as join_groupby_count.das but with a reducing aggregate. Single pass,
1214
// no intermediate allocations.
1315
// Array (m3f): no array-side join splice; cascade baseline for comparison.
1416

17+
def run_m1(b : B?; n : int) {
18+
with_sqlite(":memory:") $(db) {
19+
fixture_db(db, n)
20+
b |> run("m1_sql/{n}", n) {
21+
let groups <- _sql(db |> select_from(type<Car>)
22+
|> _join(db |> select_from(type<Dealer>),
23+
$(c : Car, d : Dealer) => c.dealer_id == d.id,
24+
$(c : Car, d : Dealer) => (Brand = c.brand, Price = c.price))
25+
|> _group_by(_.Brand)
26+
|> _select((Brand = _._0,
27+
Total = _._1 |> _select(_.Price) |> sum())))
28+
b |> accept(groups)
29+
if (empty(groups)) {
30+
b->failNow()
31+
}
32+
}
33+
}
34+
}
35+
1536
def run_m3f(b : B?; n : int) {
1637
let cars <- fixture_array(n)
1738
let dealers <- fixture_dealers_array()
@@ -49,6 +70,11 @@ def run_m4(b : B?; n : int) {
4970
}
5071
}
5172

73+
[benchmark]
74+
def join_groupby_to_array_m1(b : B?) {
75+
run_m1(b, 100000)
76+
}
77+
5278
[benchmark]
5379
def join_groupby_to_array_m3f(b : B?) {
5480
run_m3f(b, 100000)

benchmarks/sql/results.md

Lines changed: 115 additions & 121 deletions
Large diffs are not rendered by default.

benchmarks/sql/sqlite_linq_gaps.md

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,19 +51,24 @@ require them.
5151

5252
---
5353

54-
## Other deferred lowerings (independent, each its own PR)
54+
## ~~`_group_by` after `_join`~~ — closed in PR #2910
55+
56+
Originally catalogued 2 cells as blocked on resolving `_group_by` keys against
57+
the join's `into` projection. Closed in PR #2910 (chunk N+2) via a central
58+
`pred_to_sql` extension that consults a snapshot of the join's projection
59+
registry — single hook transitively enables alias resolution in `_group_by` /
60+
`_having` / `_order_by` / `try_translate_group_aggregate` / computed-expression
61+
keys.
5562

56-
### `_group_by` after `_join` — group key from joined projection
63+
**Closed cells**:
64+
- `join_groupby_count``_join |> _group_by(_.Brand) |> _select((Brand=_._0, N=_._1|>count()))`.
65+
- `join_groupby_to_array``_join |> _group_by(_.Brand) |> _select((Brand=_._0, Total=_._1|>_select(_.Price)|>sum()))`.
5766

58-
Benches: [`join_groupby_count`](join_groupby_count.das),
59-
[`join_groupby_to_array`](join_groupby_to_array.das).
67+
The fix surfaced one residual: HAVING with an alias-resolved aggregate in the
68+
`_sql(...)` runtime form hits a typer-ordering quirk; `_sql_text` emit is
69+
correct, runtime path deferred to chunk N+3.
6070

61-
`sqlite_linq`'s `_group_by` after `_join` doesn't lower today because the
62-
group key column comes from the join's `into` projection, not from a
63-
base-table column. The lowering would need to either emit the `JOIN` as a
64-
subquery and `GROUP BY` over the subquery's column, or surface the
65-
projection-column → SQL-fragment mapping into `_group_by`'s key-extractor
66-
logic. Originally TODO'd 2026-05-25.
71+
## Other deferred lowerings (independent, each its own PR)
6772

6873
### `COUNT(DISTINCT computed-expr)`
6974

doc/source/reference/tutorials/sql_15_join.rst

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,49 @@ Single-column and named-tuple projections both work. Per-source
9090
projections (``_select(...)`` on the right side) are rejected ---
9191
the join's ``into`` lambda is the only projection.
9292

93+
Joining + grouping
94+
==================
95+
96+
``_group_by`` / ``_having`` / ``_order_by`` after ``_join`` accept the
97+
join's ``into``-projection alias names as keys, not base-table fields.
98+
The translator resolves each alias through the join's projection
99+
registry and emits the underlying qualified column (``"t0"."brand"``,
100+
``"t1"."Id"``) in the GROUP BY / HAVING / ORDER BY clauses, so the
101+
SQL composes cleanly with the JOIN:
102+
103+
.. code-block:: das
104+
105+
let groups <- _sql(db |> select_from(type<Car>)
106+
|> _join(db |> select_from(type<Dealer>),
107+
$(c : Car, d : Dealer) => c.dealer_id == d.id,
108+
$(c : Car, d : Dealer) => (Brand = c.brand, Price = c.price))
109+
|> _group_by(_.Brand)
110+
|> _select((Brand = _._0, N = _._1 |> count())))
111+
// SELECT ("t0"."brand"), COUNT(*)
112+
// FROM "Cars" AS "t0" INNER JOIN "Dealers" AS "t1"
113+
// ON "t0"."dealer_id" = "t1"."id"
114+
// GROUP BY ("t0"."brand")
115+
116+
Per-group aggregates over a projection alias resolve the same way ---
117+
``_._1 |> _select(_.Price) |> sum()`` becomes ``SUM("t0"."price")``,
118+
not the bare alias:
119+
120+
.. code-block:: das
121+
122+
let totals <- _sql(db |> select_from(type<Car>)
123+
|> _join(db |> select_from(type<Dealer>),
124+
$(c : Car, d : Dealer) => c.dealer_id == d.id,
125+
$(c : Car, d : Dealer) => (Brand = c.brand, Price = c.price))
126+
|> _group_by(_.Brand)
127+
|> _select((Brand = _._0,
128+
Total = _._1 |> _select(_.Price) |> sum())))
129+
// ... SUM("t0"."price") ... GROUP BY ("t0"."brand")
130+
131+
``_order_by`` and computed-expression group keys (``_group_by(_.Price / 100)``)
132+
read aliases through the same registry. Aliases that don't appear in
133+
the join's ``into`` projection reject with a clear macro_error listing
134+
the valid alias names.
135+
93136
What's not shipped
94137
==================
95138

modules/dasSQLITE/API_REWORK.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5570,6 +5570,46 @@ All 4 m1 lanes backfilled in `benchmarks/sql/{distinct_by_order_take,distinct_by
55705570
- **Pre-distinct phase-divert composition** (Copilot R1 #2909). Chains like `take(N) |> _distinct_by(K)` or `skip(N) |> _distinct_by(K)` trigger `divert_to_inner` BEFORE `_distinct_by` peels — `q.innerSql`/`q.innerBindExprs` get populated by the take's inner subquery, and chunk N+1's passthrough finalize would overwrite the inner SQL while orphaning the take's bind. Currently rejected with a clear macro_error pointing the user to restructure as `_distinct_by(K) |> take(N)` (cap-after-distinct semantics). A future composition would 2-level wrap: `SELECT … FROM (SELECT *, MIN(pk) FROM ({prior_innerSql}) GROUP BY K) AS t0` to preserve cap-before-distinct semantics — needs design + tests for arbitrary phase-divert combinations.
55715571
- **Whole-row projection as inner subquery** (Copilot R1 #2909). `_group_by(K) |> _select((K=_._0, R=_._1 |> first())) |> _where(P) |> to_array` triggers `divert_to_inner` (PHASE_WHERE forces a SELECT divert), which would wrap the grouped+first projection as inner. The row entry expands to N source columns without `AS <alias>` under `force_aliases=true`, and `apply_passthrough_projection` expects 1 column per record name. Currently rejected with a clear macro_error pointing the user to restructure (run the wrap-triggering op before `_group_by`, or split into separate `_sql` calls). Resolution options: (a) emit deterministic per-field aliases (`"col1" AS "<RecordName>_col1"`) under `force_aliases` and teach `apply_passthrough_projection` to reconstruct the row entry from those, or (b) keep the rejection and document the constraint. Same PR could lift the v1 single-key-only constraint on `first()` projections.
55725572

5573+
## Shipped — chunk N+2: `_group_by` / `_having` / `_order_by` after `_join` via projection-alias resolution (branch `bbatkin/sqlite-linq-join-groupby-alias`)
5574+
5575+
### Surface in this PR
5576+
5577+
Closes 2 SQL bench cells from [`benchmarks/sql/sqlite_linq_gaps.md`](../../benchmarks/sql/sqlite_linq_gaps.md)'s "`_group_by` after `_join`" section. After this PR, every chain operator that references a column reads the join's `into`-projection alias through a single registry — fields named in the `into` lambda's named tuple (`(Brand = c.brand, Price = c.price)`) become first-class names for subsequent `_group_by` / `_having` / `_order_by` / aggregate / computed-key operations:
5578+
5579+
- `_join(...) |> _group_by(_.Brand) |> _select((Brand=_._0, N=_._1|>count()))` lowers to `SELECT ("t0"."brand"), COUNT(*) FROM "Cars" AS "t0" INNER JOIN ... GROUP BY ("t0"."brand")`.
5580+
- `_join(...) |> _group_by(_.Brand) |> _select((Brand=_._0, Total=_._1|>_select(_.Price)|>sum()))` lowers to `... SUM("t0"."price") ... GROUP BY ("t0"."brand")`.
5581+
- `_join(...) |> _having(_._1 |> _select(_.Price) |> sum() > 200) |> _select(...)` resolves the alias inside HAVING.
5582+
- `_join(...) |> _order_by(_.Brand)` resolves the alias inside ORDER BY.
5583+
- `_join(...) |> _group_by(_.Price / 100) |> _select(...)` resolves the alias inside a computed group key.
5584+
5585+
### Architecture
5586+
5587+
Single load-bearing change: `pred_to_sql`'s column-reference branch consults a new `joinProjRecordNames` snapshot of the join's `into` projection (captured at the end of `process_join_call`, preserved across `analyze_grouped_projection`'s clear-and-repopulate of `projRecordNames`). New helpers `find_projection_alias(q, name)` and `render_projection_alias_sql(q, idx)` resolve aliases to qualified SQL fragments. Bare-`_.Field` fast paths in `push_group_key`, `collect_order_keys`, and `try_translate_group_aggregate` mirror the same lookup so they don't bypass `pred_to_sql`. v1 supports `t0` / `t1` aliases (the 2-source `_join` shape); other aliases (multi-source / nested joins) reject loudly.
5588+
5589+
### Constraints (v1)
5590+
5591+
- 2-source `_join` only (carries forward from chunks N / N+1).
5592+
- The join's `into` lambda's named-tuple aliases are the field namespace post-join — base-table column names ARE NOT available unqualified. Users referring to base-table fields by name post-join get a `not a projection alias` error listing the valid alias names.
5593+
- HAVING with alias-resolved aggregate (`_._1 |> _select(_.X) |> sum() > N`) has a known typer-ordering quirk: in compilation units with multiple `_sql` calls referencing the same chain shape, the typer pre-folds the predicate's aggregate side and the runtime `_sql` form fails to bind `_` for the row builder. The SQL emit itself is correct (verified via `_sql_text`), so chains that don't need to materialize the predicate body at runtime work. Investigating in chunk N+3.
5594+
5595+
### Tests
5596+
5597+
`tests/dasSQLITE/test_33_join_groupby.das` — 12 new tests covering Arm B (4: emit + runtime × 2 chain shapes), Arm C (4: emit + runtime + min/max/avg + multi-aggregate), and transitive coverage via HAVING / ORDER BY / computed-key (4: emit-shape + runtime mix). 3 new `failed_*.das` rejection probes:
5598+
- `failed_join_groupby_unknown_alias.das``_group_by(_.NotAnAlias)` after join rejects with alias list
5599+
- `failed_join_groupby_aggregate_unknown_alias.das``_._1 |> _select(_.NotAField) |> sum()` rejects similarly
5600+
- `failed_join_order_by_unknown_alias.das``_order_by(_.NotAnAlias)` after join rejects similarly
5601+
5602+
### Benches
5603+
5604+
Both `benchmarks/sql/join_groupby_count.das` and `benchmarks/sql/join_groupby_to_array.das` got `run_m1` lanes. INTERP m1 beats array-fold m3f baselines (158 vs 184 ns/op for count; 191 vs 216 for to_array). `benchmarks/sql/results.md` refreshed. `sqlite_linq_gaps.md` "`_group_by` after `_join`" section struck.
5605+
5606+
### Deferred to chunk N+3
5607+
5608+
- **HAVING with alias-resolved aggregate in `_sql(...)` runtime form** — typer-ordering quirk noted above. `_sql_text(...)` emit is correct; only the row-binder in `_sql(...)` is affected. Workaround for now: split the chain into a separate `_sql_text` probe for verification, or restructure to use bare `count()` predicates.
5609+
- Nested / multi-way joins (`_join |> _join |> ...`) — chunks N / N+1 already constrain to 2-source; `render_projection_alias_sql` rejects aliases beyond `t0`/`t1`. A future extension would generalize `source_rootType_for_alias` over an N-source join chain.
5610+
- HAVING / ORDER BY / computed-key composition with `_distinct_by` (orthogonal to this chunk's _join surface; deferred from N+1's "where-then-distinct" follow-up).
5611+
- Real window functions — still no bench needs them.
5612+
55735613
## Plan (to be written after all tutorials are reviewed)
55745614

55755615
_TBD — this section is filled in once we have notes from every tutorial._

0 commit comments

Comments
 (0)