diff --git a/prqlc/prqlc/src/semantic/resolver/functions.rs b/prqlc/prqlc/src/semantic/resolver/functions.rs index cd424c04d141..e00dceb021b1 100644 --- a/prqlc/prqlc/src/semantic/resolver/functions.rs +++ b/prqlc/prqlc/src/semantic/resolver/functions.rs @@ -120,14 +120,29 @@ impl Resolver<'_> { inner_closure.env = func_env.into_exprs(); - let (got, missing) = inner_closure.params.split_at(inner_closure.args.len()); - let missing = missing.to_vec(); - inner_closure.params = got.to_vec(); + // 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 + let mut wrapper_params = Vec::with_capacity(missing.len()); + for (i, param) in missing.iter().enumerate() { + let param_name = format!("_partial_{i}"); + let substitute_arg = Expr::new(Ident::from_path(vec![ + NS_PARAM.to_string(), + param_name.clone(), + ])); + inner_closure.args.push(substitute_arg); + wrapper_params.push(FuncParam { + name: param_name, + ty: param.ty.clone(), + default_value: None, + }); + } Expr::new(ExprKind::Func(Box::new(Func { name_hint: None, args: vec![], - params: missing, + params: wrapper_params, body: Box::new(Expr::new(ExprKind::Func(inner_closure))), // these don't matter diff --git a/prqlc/prqlc/tests/integration/sql.rs b/prqlc/prqlc/tests/integration/sql.rs index 1a5f4998bc9a..ecf74a6431d9 100644 --- a/prqlc/prqlc/tests/integration/sql.rs +++ b/prqlc/prqlc/tests/integration/sql.rs @@ -6969,3 +6969,39 @@ fn test_aggregate_with_operations_and_filter() { COALESCE(SUM(customer_id), 0) * 2 > 0 "); } + +/// Regression test for issue #5661: partial application of transforms in +/// user-defined functions. +/// +/// When a user defines a function that wraps a transform with fewer parameters +/// than the transform requires (e.g., `let foo = a -> take a`), the missing +/// parameters should be propagated to the wrapper function, allowing it to work +/// correctly in pipelines. +#[test] +fn test_partial_application_of_transform() { + // Basic case: wrapping `take` with a single parameter + assert_snapshot!(compile(r#" + let foo = a -> take a + from invoices | foo 10 + "#).unwrap(), @r" + SELECT + * + FROM + invoices + LIMIT + 10 + "); + + // Same behavior as explicit two-parameter version + assert_snapshot!(compile(r#" + let foo = a r -> take a r + from invoices | foo 10 + "#).unwrap(), @r" + SELECT + * + FROM + invoices + LIMIT + 10 + "); +} diff --git a/web/book/src/reference/declarations/functions.md b/web/book/src/reference/declarations/functions.md index 879583eb337c..a49d3ffd7577 100644 --- a/web/book/src/reference/declarations/functions.md +++ b/web/book/src/reference/declarations/functions.md @@ -107,3 +107,44 @@ derive { overhead_share = (cost_share overhead), } ``` + +## Partial application + +Functions can be partially applied, which is useful for creating reusable +transform wrappers. When a function returns a partially-applied transform, the +missing parameters are automatically propagated. + +For example, we can create a `top_n` function that wraps `take`: + +```prql +let top_n = n -> take n + +from invoices +top_n 10 +``` + +This works because `take` requires two arguments (the count and the relation), +but `top_n` only provides one. The relation parameter is automatically filled in +from the pipeline. + +We can also compose multiple partial applications: + +```prql +let top_n = n -> take n +let add_constant = x -> derive {constant = x} + +let my_pipeline = (top_n 5 | add_constant 42) + +from invoices +my_pipeline +``` + +Or store a fully-configured transform for reuse: + +```prql +let top_n = n -> take n +let top_5 = top_n 5 + +from invoices +top_5 +``` diff --git a/web/book/tests/documentation/snapshots/documentation__book__reference__declarations__functions__partial-application__0.snap b/web/book/tests/documentation/snapshots/documentation__book__reference__declarations__functions__partial-application__0.snap new file mode 100644 index 000000000000..47c630e5c74e --- /dev/null +++ b/web/book/tests/documentation/snapshots/documentation__book__reference__declarations__functions__partial-application__0.snap @@ -0,0 +1,10 @@ +--- +source: web/book/tests/documentation/book.rs +expression: "let top_n = n -> take n\n\nfrom invoices\ntop_n 10\n" +--- +SELECT + * +FROM + invoices +LIMIT + 10 diff --git a/web/book/tests/documentation/snapshots/documentation__book__reference__declarations__functions__partial-application__1.snap b/web/book/tests/documentation/snapshots/documentation__book__reference__declarations__functions__partial-application__1.snap new file mode 100644 index 000000000000..fd9ac7804c86 --- /dev/null +++ b/web/book/tests/documentation/snapshots/documentation__book__reference__declarations__functions__partial-application__1.snap @@ -0,0 +1,11 @@ +--- +source: web/book/tests/documentation/book.rs +expression: "let top_n = n -> take n\nlet add_constant = x -> derive {constant = x}\n\nlet my_pipeline = (top_n 5 | add_constant 42)\n\nfrom invoices\nmy_pipeline\n" +--- +SELECT + *, + 42 AS constant +FROM + invoices +LIMIT + 5 diff --git a/web/book/tests/documentation/snapshots/documentation__book__reference__declarations__functions__partial-application__2.snap b/web/book/tests/documentation/snapshots/documentation__book__reference__declarations__functions__partial-application__2.snap new file mode 100644 index 000000000000..bd523af77e99 --- /dev/null +++ b/web/book/tests/documentation/snapshots/documentation__book__reference__declarations__functions__partial-application__2.snap @@ -0,0 +1,10 @@ +--- +source: web/book/tests/documentation/book.rs +expression: "let top_n = n -> take n\nlet top_5 = top_n 5\n\nfrom invoices\ntop_5\n" +--- +SELECT + * +FROM + invoices +LIMIT + 5