From 4bb39d9162922b7ee1169828d3bdbb112a18d5db Mon Sep 17 00:00:00 2001 From: prql-bot <107324867+prql-bot@users.noreply.github.com> Date: Tue, 11 Aug 2026 06:51:29 +0000 Subject: [PATCH 1/7] fix: stop dropping select items whose name collides with a table qualifier --- prqlc/prqlc/src/sql/gen_projection.rs | 15 ++++++++++----- prqlc/prqlc/tests/integration/sql.rs | 19 +++++++++++++++++++ ...ce__stdlib__tuple__tuple-functions__4.snap | 1 + 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/prqlc/prqlc/src/sql/gen_projection.rs b/prqlc/prqlc/src/sql/gen_projection.rs index eba54e0c3d62..05ca949fb1b1 100644 --- a/prqlc/prqlc/src/sql/gen_projection.rs +++ b/prqlc/prqlc/src/sql/gen_projection.rs @@ -121,14 +121,19 @@ pub(super) fn translate_wildcards(ctx: &AnchorContext, cols: Vec) -> (Vec) { - // Dropping all duplicated identifiers - let mut seen = HashSet::new(); + // Dropping all duplicated identifiers. Qualified references and aliases are + // tracked separately: a table qualifier such as the `t` of `t.a` lives in a + // different namespace to an output column name, so it must not make a later + // `… AS t` look like a repeat. + let mut seen_idents = HashSet::new(); + let mut seen_aliases = HashSet::new(); items.retain(|select_item| match select_item { + // Compare the whole path, so that `t.a` and `u.a` both survive while a + // repeat of `t.a` does not. SelectItem::UnnamedExpr(sql_ast::Expr::CompoundIdentifier(idents)) => { - // If any of the identifiers hadn't been seen yet, retain the expr - idents.iter().any(|ident| seen.insert(ident.clone())) + seen_idents.insert(idents.clone()) } - SelectItem::ExprWithAlias { alias, .. } => seen.insert(alias.clone()), + SelectItem::ExprWithAlias { alias, .. } => seen_aliases.insert(alias.clone()), _ => true, }); } diff --git a/prqlc/prqlc/tests/integration/sql.rs b/prqlc/prqlc/tests/integration/sql.rs index d9265c1bfc52..7c9a6d77377b 100644 --- a/prqlc/prqlc/tests/integration/sql.rs +++ b/prqlc/prqlc/tests/integration/sql.rs @@ -1186,6 +1186,25 @@ fn test_sort_in_nested_join_with_extra_derive_and_select() { ); } +#[test] +fn test_alias_matching_table_qualifier_is_kept() { + // The select-item deduplication used to track table qualifiers and output + // aliases in one set, so `tbl` from `tbl.a` made `x.b AS tbl` look like a + // repeat and the column was dropped from the projection. + assert_snapshot!(compile(r#" + from tbl + join x (==id) + select {tbl.a, tbl = x.b} + "#).unwrap(), @r" + SELECT + tbl.a, + x.b AS tbl + FROM + tbl + INNER JOIN x ON tbl.id = x.id + "); +} + #[test] fn test_sort_in_nested_append() { assert_snapshot!(compile(r#" diff --git a/web/book/tests/documentation/snapshots/documentation__book__reference__stdlib__tuple__tuple-functions__4.snap b/web/book/tests/documentation/snapshots/documentation__book__reference__stdlib__tuple__tuple-functions__4.snap index c0cc4296a999..0bd32353c082 100644 --- a/web/book/tests/documentation/snapshots/documentation__book__reference__stdlib__tuple__tuple-functions__4.snap +++ b/web/book/tests/documentation/snapshots/documentation__book__reference__stdlib__tuple__tuple-functions__4.snap @@ -17,6 +17,7 @@ SELECT invoices.processed, shipments.id, shipments.invoice_id, + shipments.date_of, shipments.shipped_on FROM invoices From 2bd4a248de592f57da1d2ebd31900d03b27b76aa Mon Sep 17 00:00:00 2001 From: prql-bot <107324867+prql-bot@users.noreply.github.com> Date: Tue, 11 Aug 2026 06:53:06 +0000 Subject: [PATCH 2/7] docs: changelog entry for the select-item dedup fix --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6ab4d4c68fb..e3ef93d017a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ **Fixes**: +- Stop silently dropping a select item whose output name matches a table + qualifier used earlier in the same projection. `select {tbl.a, tbl = x.b}` + lost its second column, as did a join between two relations that share a + column name. (@prql-bot, #6181) + **Documentation**: **Web**: From d5cd2d35a862ee48f3049faa00a911e33de3749e Mon Sep 17 00:00:00 2001 From: prql-bot <107324867+prql-bot@users.noreply.github.com> Date: Tue, 11 Aug 2026 07:13:50 +0000 Subject: [PATCH 3/7] test: cover group-key/aggregate name collision in select dedup --- CHANGELOG.md | 8 +++++--- prqlc/prqlc/tests/integration/sql.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3ef93d017a4..1a7801f89d42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,9 +23,11 @@ **Fixes**: - Stop silently dropping a select item whose output name matches a table - qualifier used earlier in the same projection. `select {tbl.a, tbl = x.b}` - lost its second column, as did a join between two relations that share a - column name. (@prql-bot, #6181) + qualifier or column reference used earlier in the same projection. + `select {tbl.a, tbl = x.b}` lost its second column, as did a join between two + relations that share a column name, and + `group {a} (aggregate {a = sum b})` lost the aggregate entirely. + (@prql-bot, #6181) **Documentation**: diff --git a/prqlc/prqlc/tests/integration/sql.rs b/prqlc/prqlc/tests/integration/sql.rs index 7c9a6d77377b..557cbfac7494 100644 --- a/prqlc/prqlc/tests/integration/sql.rs +++ b/prqlc/prqlc/tests/integration/sql.rs @@ -1205,6 +1205,32 @@ fn test_alias_matching_table_qualifier_is_kept() { "); } +#[test] +fn test_aggregate_matching_group_key_is_kept() { + // Same root cause as above, reached without a join: with a single relation + // the table prefix is omitted, so the group key renders as a one-part + // compound identifier and used to claim the name `a` for the aggregate's + // alias too — the aggregate was dropped and the query silently returned + // only the group key. + // + // The two output columns share a name because the resolver doesn't + // disambiguate a group key against a same-named aggregate the way it does + // inside `select` (which renames to `_expr_0`); that's separate from this + // dedup pass, which should not be deciding it by deletion. + assert_snapshot!(compile(r#" + from tbl + group {a} (aggregate {a = sum b}) + "#).unwrap(), @r" + SELECT + a, + COALESCE(SUM(b), 0) AS a + FROM + tbl + GROUP BY + a + "); +} + #[test] fn test_sort_in_nested_append() { assert_snapshot!(compile(r#" From eff3fc6c6a78ca784fcf5bb36b7ba368ed66dc4e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 07:14:07 +0000 Subject: [PATCH 4/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- CHANGELOG.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a7801f89d42..35e5724aec18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,9 +25,8 @@ - Stop silently dropping a select item whose output name matches a table qualifier or column reference used earlier in the same projection. `select {tbl.a, tbl = x.b}` lost its second column, as did a join between two - relations that share a column name, and - `group {a} (aggregate {a = sum b})` lost the aggregate entirely. - (@prql-bot, #6181) + relations that share a column name, and `group {a} (aggregate {a = sum b})` + lost the aggregate entirely. (@prql-bot, #6181) **Documentation**: From 6b16150c64de0304eea7466653bb057febffa70e Mon Sep 17 00:00:00 2001 From: prql-bot <107324867+prql-bot@users.noreply.github.com> Date: Tue, 11 Aug 2026 07:22:28 +0000 Subject: [PATCH 5/7] docs: note that the group-key/aggregate collision is surfaced, not resolved --- CHANGELOG.md | 6 ++++-- prqlc/prqlc/tests/integration/sql.rs | 6 +++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35e5724aec18..e0c6c86d8961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,8 +25,10 @@ - Stop silently dropping a select item whose output name matches a table qualifier or column reference used earlier in the same projection. `select {tbl.a, tbl = x.b}` lost its second column, as did a join between two - relations that share a column name, and `group {a} (aggregate {a = sum b})` - lost the aggregate entirely. (@prql-bot, #6181) + relations that share a column name. `group {a} (aggregate {a = sum b})` lost + the aggregate; it now emits both columns, though they still share the name + `a`, so referencing that relation downstream stays ambiguous. (@prql-bot, + #6181) **Documentation**: diff --git a/prqlc/prqlc/tests/integration/sql.rs b/prqlc/prqlc/tests/integration/sql.rs index 557cbfac7494..60d96073b715 100644 --- a/prqlc/prqlc/tests/integration/sql.rs +++ b/prqlc/prqlc/tests/integration/sql.rs @@ -1216,7 +1216,11 @@ fn test_aggregate_matching_group_key_is_kept() { // The two output columns share a name because the resolver doesn't // disambiguate a group key against a same-named aggregate the way it does // inside `select` (which renames to `_expr_0`); that's separate from this - // dedup pass, which should not be deciding it by deletion. + // dedup pass, which should not be deciding it by deletion. The ambiguity is + // therefore not resolved by this change, only surfaced: as a final + // projection both columns come back, but wrapping this in a CTE and + // selecting `a` from it is ambiguous SQL — an error on stricter engines, + // and the group key rather than the sum on SQLite. assert_snapshot!(compile(r#" from tbl group {a} (aggregate {a = sum b}) From 8e02d54d4c783746ad90f3f04d91df30947d4f3b Mon Sep 17 00:00:00 2001 From: prql-bot <107324867+prql-bot@users.noreply.github.com> Date: Tue, 11 Aug 2026 07:44:39 +0000 Subject: [PATCH 6/7] fix: narrow select-item dedup to the table-qualifier collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep unqualified identifiers in the output-name namespace rather than the qualified-path one, so the group-key/aggregate collision keeps its current behaviour. Emitting both columns there produces two outputs named `a`, and `sort a` on top compiles to an `ORDER BY a` that Postgres rejects as ambiguous and SQLite/DuckDB bind to the aggregate instead of the group key — a regression for queries that run today. That half needs the resolver-side disambiguation, not deletion by the dedup pass. --- CHANGELOG.md | 9 +++------ prqlc/prqlc/src/sql/gen_projection.rs | 23 ++++++++++++++--------- prqlc/prqlc/tests/integration/sql.rs | 27 +++++++++++---------------- 3 files changed, 28 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0c6c86d8961..e3ef93d017a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,12 +23,9 @@ **Fixes**: - Stop silently dropping a select item whose output name matches a table - qualifier or column reference used earlier in the same projection. - `select {tbl.a, tbl = x.b}` lost its second column, as did a join between two - relations that share a column name. `group {a} (aggregate {a = sum b})` lost - the aggregate; it now emits both columns, though they still share the name - `a`, so referencing that relation downstream stays ambiguous. (@prql-bot, - #6181) + qualifier used earlier in the same projection. `select {tbl.a, tbl = x.b}` + lost its second column, as did a join between two relations that share a + column name. (@prql-bot, #6181) **Documentation**: diff --git a/prqlc/prqlc/src/sql/gen_projection.rs b/prqlc/prqlc/src/sql/gen_projection.rs index 05ca949fb1b1..220e88559950 100644 --- a/prqlc/prqlc/src/sql/gen_projection.rs +++ b/prqlc/prqlc/src/sql/gen_projection.rs @@ -121,19 +121,24 @@ pub(super) fn translate_wildcards(ctx: &AnchorContext, cols: Vec) -> (Vec) { - // Dropping all duplicated identifiers. Qualified references and aliases are - // tracked separately: a table qualifier such as the `t` of `t.a` lives in a - // different namespace to an output column name, so it must not make a later - // `… AS t` look like a repeat. + // Dropping all duplicated identifiers. Qualified references and output + // names are tracked separately: a table qualifier such as the `t` of `t.a` + // lives in a different namespace to an output column name, so it must not + // make a later `… AS t` look like a repeat. let mut seen_idents = HashSet::new(); - let mut seen_aliases = HashSet::new(); + let mut seen_names = HashSet::new(); items.retain(|select_item| match select_item { - // Compare the whole path, so that `t.a` and `u.a` both survive while a - // repeat of `t.a` does not. SelectItem::UnnamedExpr(sql_ast::Expr::CompoundIdentifier(idents)) => { - seen_idents.insert(idents.clone()) + match idents.as_slice() { + // An unqualified `a` carries no qualifier and is itself the + // output name, so it belongs in the same namespace as aliases. + [only] => seen_names.insert(only.clone()), + // Compare the whole path, so that `t.a` and `u.a` both survive + // while a repeat of `t.a` does not. + _ => seen_idents.insert(idents.clone()), + } } - SelectItem::ExprWithAlias { alias, .. } => seen_aliases.insert(alias.clone()), + SelectItem::ExprWithAlias { alias, .. } => seen_names.insert(alias.clone()), _ => true, }); } diff --git a/prqlc/prqlc/tests/integration/sql.rs b/prqlc/prqlc/tests/integration/sql.rs index 60d96073b715..f80af1882c18 100644 --- a/prqlc/prqlc/tests/integration/sql.rs +++ b/prqlc/prqlc/tests/integration/sql.rs @@ -1206,28 +1206,23 @@ fn test_alias_matching_table_qualifier_is_kept() { } #[test] -fn test_aggregate_matching_group_key_is_kept() { - // Same root cause as above, reached without a join: with a single relation - // the table prefix is omitted, so the group key renders as a one-part - // compound identifier and used to claim the name `a` for the aggregate's - // alias too — the aggregate was dropped and the query silently returned - // only the group key. +fn test_aggregate_matching_group_key() { + // TODO: this is wrong — the aggregate is missing from the projection. // - // The two output columns share a name because the resolver doesn't - // disambiguate a group key against a same-named aggregate the way it does - // inside `select` (which renames to `_expr_0`); that's separate from this - // dedup pass, which should not be deciding it by deletion. The ambiguity is - // therefore not resolved by this change, only surfaced: as a final - // projection both columns come back, but wrapping this in a CTE and - // selecting `a` from it is ambiguous SQL — an error on stricter engines, - // and the group key rather than the sum on SQLite. + // An unqualified group key occupies the same output-name namespace as the + // aggregate's alias, so the dedup pass drops the aggregate. Emitting both + // is not the fix on its own: they would share the name `a`, and `sort a` + // on top of this then compiles to `ORDER BY a`, which Postgres rejects as + // ambiguous and SQLite/DuckDB bind to the aggregate rather than the group + // key. The real fix belongs in the resolver, which disambiguates a + // same-named pair inside `select` (renaming to `_expr_0`) but not in + // `group`/`aggregate`. assert_snapshot!(compile(r#" from tbl group {a} (aggregate {a = sum b}) "#).unwrap(), @r" SELECT - a, - COALESCE(SUM(b), 0) AS a + a FROM tbl GROUP BY From 84a598b127a257136db1af1ac8ba556586d07927 Mon Sep 17 00:00:00 2001 From: prql-bot <107324867+prql-bot@users.noreply.github.com> Date: Tue, 11 Aug 2026 07:56:03 +0000 Subject: [PATCH 7/7] docs: describe both halves of the dedup fix in the changelog --- CHANGELOG.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3ef93d017a4..62983127b3d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,10 +22,11 @@ **Fixes**: -- Stop silently dropping a select item whose output name matches a table - qualifier used earlier in the same projection. `select {tbl.a, tbl = x.b}` - lost its second column, as did a join between two relations that share a - column name. (@prql-bot, #6181) +- Stop silently dropping a select item whose name collides with an identifier + used earlier in the same projection — a table qualifier, or a column of the + same name from another relation. `select {tbl.a, tbl = x.b}` lost its second + column, as did a join between two relations that share a column name. + (@prql-bot, #6181) **Documentation**: