diff --git a/backend/parsers/windmill-parser-sql/src/lib.rs b/backend/parsers/windmill-parser-sql/src/lib.rs index 931f470d3c..dc094a0a7b 100644 --- a/backend/parsers/windmill-parser-sql/src/lib.rs +++ b/backend/parsers/windmill-parser-sql/src/lib.rs @@ -826,6 +826,29 @@ fn parse_duckdb_file(code: &str) -> anyhow::Result>> { } args.append(&mut parse_sql_sanitized_interpolation(code)); + + // A `// partitioned` script receives its resolved partition as a job arg + // named `partition` (windmill_common::partition::PARTITION_ARG), and duckdb + // binds named parameters only when they appear in the parsed signature — + // so auto-declare it (as `-- $partition (text)` would) to make `$partition` + // usable without a manual declaration. An explicit declaration wins. + // `has_default` keeps the field optional: the platform resolves the value + // at run start when it is not passed explicitly. + if !args.iter().any(|arg| arg.name == "partition") + && windmill_parser::asset_parser::parse_pipeline_annotations(code) + .partition + .is_some() + { + args.push(Arg { + name: "partition".to_string(), + typ: Typ::Str(None), + default: None, + otyp: Some("text".to_string()), + has_default: true, + oidx: None, + otyp_inferred: false, + }); + } Ok(Some(args)) } @@ -1985,4 +2008,63 @@ SELECT x Ok(()) } + + #[test] + fn test_parse_duckdb_partitioned_auto_declares_partition() -> anyhow::Result<()> { + let code = r#"// partitioned daily +// materialize ducklake://main/sales_daily +SELECT $partition AS day, count(*) AS n FROM sales WHERE day = $partition +"#; + let args = parse_duckdb_sig(code)?.args; + assert_eq!( + args, + vec![Arg { + otyp: Some("text".to_string()), + name: "partition".to_string(), + typ: Typ::Str(None), + default: None, + has_default: true, + oidx: None, + otyp_inferred: false, + }] + ); + + // `--`-style annotation headers auto-declare too. + let dash_code = "-- partitioned hourly\nSELECT $partition AS h\n"; + assert_eq!(parse_duckdb_sig(dash_code)?.args, args); + + Ok(()) + } + + #[test] + fn test_parse_duckdb_partitioned_explicit_declaration_wins() -> anyhow::Result<()> { + let code = r#"// partitioned daily +-- $partition (text) +-- $limit (int) = 10 +SELECT * FROM sales WHERE day = $partition LIMIT $limit +"#; + let args = parse_duckdb_sig(code)?.args; + // No duplicate: the explicit (required) declaration is kept as-is. + assert_eq!(args.iter().filter(|a| a.name == "partition").count(), 1); + assert_eq!( + args[0], + Arg { + otyp: Some("text".to_string()), + name: "partition".to_string(), + typ: Typ::Str(None), + default: None, + has_default: false, + oidx: None, + otyp_inferred: false, + } + ); + Ok(()) + } + + #[test] + fn test_parse_duckdb_unpartitioned_does_not_declare_partition() -> anyhow::Result<()> { + let code = "SELECT 1 AS partition_count\n"; + assert_eq!(parse_duckdb_sig(code)?.args, vec![]); + Ok(()) + } } diff --git a/backend/windmill-worker/src/duckdb_executor.rs b/backend/windmill-worker/src/duckdb_executor.rs index a932be102d..c0574a8f1d 100644 --- a/backend/windmill-worker/src/duckdb_executor.rs +++ b/backend/windmill-worker/src/duckdb_executor.rs @@ -1534,6 +1534,39 @@ mod tests { assert!(!rewritten.contains("-- $file")); } + // A `// partitioned` script referencing `$partition` needs no manual + // `-- $partition (text)` declaration: the parser auto-declares the arg, so + // the executor binds the injected `partition` job arg instead of failing + // with duckdb's "Wrong number of parameters" at prepare time. + #[test] + fn partitioned_auto_declares_partition_arg() { + let script = "// partitioned daily\n\ + // materialize ducklake://main/sales_daily\n\ + SELECT $partition AS day, count(*) AS n FROM dl.sales WHERE day = $partition"; + + let sig = parse_duckdb_sig(script).expect("sig parses").args; + let partition_arg = sig + .iter() + .find(|a| a.name == "partition") + .expect("`partition` auto-declared"); + assert_eq!(partition_arg.otyp.as_deref(), Some("text")); + + // The wrapped query keeps the `$partition` references so the parsed sig + // binds them at run time. + let (rewritten, _) = build_materialized_query( + script, + Some("2026-07-02"), + &std::collections::HashMap::new(), + ) + .expect("materialize builds") + .expect("materialize present"); + let rewritten = rewritten.expect("managed mode rewrites the query"); + assert!( + rewritten.contains("$partition"), + "wrapped query must keep the `$partition` reference, got:\n{rewritten}" + ); + } + // SCD2 managed mode wraps the SELECT into the diff → close-old → open-new // shape (unit-covered in the parser's codegen tests); here we pin the // executor-level wiring: the natural key flows through and the wrap is diff --git a/cli/src/commands/pipeline/pipeline.ts b/cli/src/commands/pipeline/pipeline.ts index 6c10c09add..fd9be73d2e 100644 --- a/cli/src/commands/pipeline/pipeline.ts +++ b/cli/src/commands/pipeline/pipeline.ts @@ -40,6 +40,7 @@ import { generatePipelineDocs } from "./docs.ts"; import { type UploadBinding, devUploadKey, + parseArgBinding, parseS3Uri, parseUploadBinding, s3Arg, @@ -469,6 +470,7 @@ async function run( json?: boolean; local?: boolean; upload?: string[]; + arg?: string[]; defaultTs?: "bun" | "deno"; }, folder: string, @@ -524,6 +526,24 @@ async function run( await enrichDeployedNonAutorunTriggers(workspace.workspaceId, graph); } + // Resolve a `--upload`/`--arg` script token to its graph node, with an + // actionable error when the short name is ambiguous or matches nothing. + const resolveScriptTokenOrThrow = (tok: string, flag: string): string => { + const id = resolveToken(graph, tok); + if (!id || !id.startsWith("script:")) { + const matches = graph.runnables.filter( + (r) => r.usage_kind === "script" && (r.path.split("/").pop() ?? r.path) === tok, + ); + if (matches.length > 1) { + throw new Error( + `${flag} '${tok}' matches multiple scripts (${matches.map((r) => r.path).sort().join(", ")}) — use the full path.`, + ); + } + throw new Error(`${flag} '${tok}' matched no script in f/${f}.`); + } + return id; + }; + // `--upload