From c2f3b4f5c51d58a9320ee44786f119d91dcf64b7 Mon Sep 17 00:00:00 2001 From: prql-bot <107324867+prql-bot@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:23:18 +0000 Subject: [PATCH] fix: unique wrapper param names for nested currying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a function returns a partially-applied function, the resolver materializes a wrapper whose synthesized params were named by a per-materialization index (_partial_0, _partial_1, ...). Nested partial applications share the NS_PARAM namespace, so these names collided across levels and a reference resolved to the wrong binding — currying through more than two let-bound wrappers produced the wrong result. Name the wrapper params with the resolver's global id generator so they are unique across materializations. Closes #5978 Co-Authored-By: Claude --- .../prqlc/src/semantic/resolver/functions.rs | 9 ++++--- prqlc/prqlc/tests/integration/sql.rs | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/prqlc/prqlc/src/semantic/resolver/functions.rs b/prqlc/prqlc/src/semantic/resolver/functions.rs index e00dceb021b1..f6cdd2d08342 100644 --- a/prqlc/prqlc/src/semantic/resolver/functions.rs +++ b/prqlc/prqlc/src/semantic/resolver/functions.rs @@ -123,10 +123,13 @@ impl Resolver<'_> { // Get the missing params (params that don't have args yet) let missing = inner_closure.params[inner_closure.args.len()..].to_vec(); - // Create wrapper params and add references to them as args to the inner closure + // Create wrapper params and add references to them as args to the inner closure. + // The param names must be globally unique: nested partial applications share the + // NS_PARAM namespace, so a per-materialization index would collide and resolve to + // the wrong binding (issue #5978). let mut wrapper_params = Vec::with_capacity(missing.len()); - for (i, param) in missing.iter().enumerate() { - let param_name = format!("_partial_{i}"); + for param in missing.iter() { + let param_name = format!("_partial_{}", self.id.gen()); let substitute_arg = Expr::new(Ident::from_path(vec![ NS_PARAM.to_string(), param_name.clone(), diff --git a/prqlc/prqlc/tests/integration/sql.rs b/prqlc/prqlc/tests/integration/sql.rs index 1f911b42efdf..31dc3744f78a 100644 --- a/prqlc/prqlc/tests/integration/sql.rs +++ b/prqlc/prqlc/tests/integration/sql.rs @@ -7385,6 +7385,33 @@ fn test_partial_application_of_transform() { "); } +/// Regression test for issue #5978: currying a function through more than two +/// `let`-bound wrappers produced the wrong result, because the wrapper params +/// synthesized during partial-application materialization were named by a +/// per-materialization index (`_partial_0`, `_partial_1`, …) and collided +/// across nested partial applications sharing the `NS_PARAM` namespace. +#[test] +fn test_nested_currying() { + // `y` (curried one arg at a time) must match `z` (curried directly). + assert_snapshot!(compile(r#" + let f1 = func a b c -> a + b + c + let f2 = func a b -> f1 a b + let f3 = func a -> f2 a + + from t | derive { + y = (((f3 100) 20) 3), + z = ((f2 100 20) 3), + } + "#).unwrap(), @" + SELECT + *, + 100 + 20 + 3 AS y, + 100 + 20 + 3 AS z + FROM + t + "); +} + #[test] fn test_tuple_map() { assert_snapshot!(compile(r###"