mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 08:01:35 +00:00
fix: pair PG arg type with actual Rust binding to keep query_typed_raw safe (#8999)
* fix: pair PG arg type with actual Rust binding to keep query_typed_raw safe Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(pg): wrap encoder errors with arg context, add fallback test Followups on #8999 review: - Wrap rust-postgres "error serializing parameter N" failures with the arg name, JSON value kind, and asserted Postgres type plus a hint about an explicit cast — so users see actionable context instead of an opaque WrongType. - Drift-prevention meta-test: assert otyp_to_pg_type and convert_val agree on the Type for every recognised arg_t when the JSON value matches its natural Rust kind. Catches future drift if either side changes. - Integration test for the prepare + query_raw fallback path: confirms unrecognised arg_t (custom enum) is routed through prepare and the server-resolved type appears in the failure surface — flips into a test failure if a regression accidentally routes unrecognised types through query_typed_raw. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pg): add otyp_inferred flag + regex-based placeholder renumbering Two follow-ups from the review of #8999: 1. **Issue #1 (Number/Bool + explicit text decl in WHERE)** Add `Arg::otyp_inferred: bool` to the parser. The PG SQL parser sets it `true` only at the "no info → fall back to text" site (bare `$N`, no inline cast, no `-- $N (TYPE)` decl). All other arg sources keep it `false`. In `convert_val` this flag distinguishes: - explicit text-like target (`-- $1 (text)` or `$1::text`) — coerce `Bool`/`Number` → `Box<String>` so `WHERE text_col = $1` works (`text = text` operator). Pre-#8988 behaviour, restored. - parser-default text (bare `$N`) — bind the value's natural Rust type so the regression case (`Value::Bool` against a real `bool` column via `CAST AS bool`) keeps working. `Arg` is in `windmill-parser`; the new field has `#[serde(default)]` so persisted signatures stay backward-compatible. 2. **Issue #4 ($5/$50 substring rewrite collision)** Replace the per-index `String::replace` chain (which turned `$50` into `$10` when oidx=5 was processed first) with a single regex pass. `\d+` is greedy, so `$5` and `$50` match as distinct units; indices outside the mapping are left intact. 3. Tests: - parser: `test_parse_pgsql_otyp_inferred_flag` covers bare/inline- cast/decl/mixed shapes. - executor unit: `convert_val_bool_against_every_arg_t` and `convert_val_*_number_*` split each text-like target into explicit vs inferred expectations. - executor unit: `renumber_sparse_placeholders_no_collision`. - integration: `test_postgresql_arg_type_combinations` adds 4 cases covering decl(text)+Number/Bool in WHERE, bare $1+Bool, and sparse positional args ($5/$50). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pg+sdk): enum support, extended String arms, position-aware $N rewrite, SDK quality Backend: 1. **`AnyTextValue` ToSql/FromSql wrapper**: vanilla `tokio_postgres`'s `ToSql for String` / `FromSql for String` reject `Kind::Enum` and `Kind::Domain` even though the wire format is plain UTF-8. The wrapper accepts those kinds in both directions. End result: explicit `$1::my_enum` / `CAST($1 AS my_enum)` casts now round-trip without the ugly `CAST($1::text AS my_enum)` workaround, AND `SELECT enum_col` results come back as JSON strings instead of erroring at the FromSql layer. 2. **#10 — Value::String → numeric/real/double/oid/bool**. Without these arms, a string-encoded value (`"3.14"`, `"true"`) for a non-text / non-temporal arg_t fell through to `Box<String> + TEXT`, which then failed at the server (no implicit cast text→numeric in expression context). Now strings are parsed into the matching native type with clear error messages on parse failure. 3. **Position-aware `$N` rewrite**: replaces the regex-based renumbering (which fixed the `$5/$50` substring collision but still walked through string literals and comments, mangling `'price: $5'` etc.) with a walk over `parse_pg_statement_arg_positions` — the same string/comment/dollar-quote-aware tokenizer used for index discovery. Adds `parse_pg_statement_arg_positions` to the parser's public API. SDK: 4. **BigInt support**: `JSON.stringify(BigInt)` throws. The SDK now stringifies bigints before serialisation; the executor accepts numeric strings into BIGINT arg slots via the existing `Value::String → INT8` parsing arm. SDK-side `inferSqlType` is split so `BigInt` always resolves to `BIGINT` (was reaching `Number.isInteger(BigInt)` which returns false → wrong default). 5. **Homogeneous array auto-tag**: `${[1,2,3]}` against an `int[]` column now emits `$1::BIGINT[]` instead of `$1::JSON`. Detection covers primitive types only (number / bigint / string / boolean); mixed or nested arrays still fall back to JSON. Mixed int/float widens to `DOUBLE PRECISION[]`. 6. **`.query()` positional bug**: previously the `.query()` method abused the template-tag builder, which appended `$N::TYPE` after the user's literal SQL string instead of binding by position (`SELECT $1, $2` became `SELECT $1, $2$1::BIGINT`). Now `.query()` builds the executor-shaped content directly: a `-- $N argN (TYPE)` declaration block followed by the user's SQL verbatim. Tests: - Parser: `test_parse_pg_statement_arg_positions_skips_strings_and_comments` asserts string literals, comments, and dollar-quoted blocks don't produce positions (so renumbering doesn't mangle them). - Executor unit: `renumber_sparse_placeholders_no_collision_no_string_mangling` uses the new position-aware path and includes string-literal + comment + `$$…$$` cases. Existing convert_val tests grow to cover new String→numeric/real/double/oid/bool arms. - Integration: `test_postgresql_arg_type_combinations` adds 13 cases (enum round-trip both directions, string→numeric/real/double/bool/oid, string-literal `$N` non-mangling). The prepare-fallback test now asserts SUCCESS (not failure) for enum encoding via AnyTextValue. - SDK: new `typescript-client/tests/sqlUtils.test.ts` (42 tests) exhaustively covering inferSqlType primitives + arrays, parseTypeAnnotation, datatable() template tag (with all the new shapes — BigInt, homogeneous arrays, RawSql, schema preamble), datatable().query() positional, and ducklake() shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pg): replace DISCARD ALL with curated reset (preserves typeinfo cache) Found while exhaustively probing custom-type DX: every cached-connection reuse was running `DISCARD ALL`, whose included `DEALLOCATE ALL` deallocates *all* prepared statements server-side — including the typeinfo statements that tokio_postgres caches per-Client to resolve custom enum / domain Oids. tokio_postgres still held `Statement` objects whose names the server had forgotten, so the next custom-type query failed with intermittent "prepared statement \"sN\" does not exist" errors. The failure was easy to reproduce: any sequence that forced typeinfo lookup for two different custom-type kinds on the same cached connection (e.g. enum followed by domain) would hit it. Replace `DISCARD ALL` with a curated reset that explicitly targets the state we actually care about, *without* touching prepared statements: RESET ALL — GUC parameters (search_path, application _name, statement_timeout, …) RESET SESSION AUTHORIZATION — undoes both `SET SESSION AUTHORIZATION` and `SET ROLE` (RESET ALL does NOT — these aren't GUC parameters, so without this an elevated role from a previous job would silently leak) UNLISTEN * — drops LISTEN registrations CLOSE ALL — closes open cursors Trade-off: temp tables, advisory locks (session-scoped), and user-created PREPARE statements may persist across cached-connection reuse — rare in datatable / PG-script workloads. tokio_postgres's typeinfo cache survives intact, so custom enum / domain queries are fast on subsequent reuse. Tests: - `test_postgresql_custom_types_on_cached_connection` — runs 10× alternating enum + domain queries on a cached connection. Pre-fix this failed with `prepared statement "sN" does not exist` after the first reuse; post-fix passes. - `test_postgresql_set_role_does_not_leak_across_cached_connection` — switches `SET ROLE` and `SET SESSION AUTHORIZATION` to a non-postgres role, then runs a follow-up job and asserts current_user/session_user are restored. Specifically catches the case where someone might switch back to `RESET ALL` alone (which doesn't cover SET ROLE / SESSION AUTHORIZATION) and silently introduce a permission-leak vector. - All existing session-isolation tests (`test_postgresql_cached_connection_resets_session`, `test_postgresql_single_worker_session_isolation`, `test_postgresql_100_jobs_cached`) continue to pass. Found via end-to-end probing of datatable / PG-script DX, not previously covered: the existing isolation tests only did `SET ROLE postgres`, the connecting user, so the leak was invisible. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pg): address PR #8999 review (cubic + claude) cubic (P1, real bug): - `convert_vec_val` for `timetz` array asserted `Type::TIMETZ_ARRAY`, but chrono `NaiveTime` only encodes for TIME (same caveat as the scalar arm). Switch to `Type::TIME_ARRAY`; rely on PG's implicit `time→timetz` assignment cast at the column site. Add an explicit unit test. claude (#1, silent failure → explicit error): - `Bool` + explicit `(char)` / `(character)` decl previously silently bound BOOL, hoping the server would cast at the use site — but PG has no implicit `bool→char` and the resulting error ("operator does not exist: bool = char") was opaque. Now error at bind time with an actionable hint to use `bool` decl or pass the value as a "t"/"f" string. claude (#2, asymmetry doc): - Object/Array still coerce to text on `matches!(typ, Typ::Str(_))` (covers both explicit AND inferred-default text), unlike Bool/Number which key on `explicit_text_target`. The asymmetry is intentional (no implicit `jsonb → text` cast in expression context vs PG having implicit `bool/int → text` casts) — added a body comment so future maintainers don't try to "align" them. claude (#3, perf): - `parse_pg_statement_arg_indices` and `parse_pg_statement_arg_positions` walked the SQL tokenizer twice. Fold into a single pass that derives the index set from the position list. claude (#4, fmt drift): - `cargo fmt` over the parser crates I touched with perl scripts in the earlier commit (windmill-parser-{sql,bash,ts,go,php,java,csharp,nu,py, rust,graphql,yaml,r}). Net cosmetic. claude (#5, parseTypeAnnotation): - One-line caveat in the SDK's `parseTypeAnnotation` that the returned string is presence-only (e.g. `${x}::DOUBLE PRECISION` returns `"DOUBLE"`, `CAST(${x} AS int)` returns `"int)"` — neither matches a real PG type, but the only consumer just checks `!== undefined`). While here — discovered + fixed independently while exhaustively probing DX: - **Replace `DISCARD ALL` with curated reset** (`RESET ALL; RESET SESSION AUTHORIZATION; UNLISTEN *; CLOSE ALL;`). DISCARD's `DEALLOCATE ALL` killed tokio_postgres' typeinfo cache, producing intermittent `prepared statement "sN" does not exist` errors on custom-type queries after cached-conn reuse. New regression tests: `test_postgresql_custom_types_on_cached_connection` and `test_postgresql_set_role_does_not_leak_across_cached_connection` (the latter catches the case where someone might switch back to `RESET ALL` alone and silently introduce a permission-leak vector — RESET ALL doesn't cover SET ROLE / SET SESSION AUTHORIZATION). - **ISO-8601 timestamp results** (`pg_cell_to_json_value`). Pre-fix `TIMESTAMP` was rendered with a space separator ("2024-01-15 10:30:00") and `TIMESTAMPTZ` with " UTC" suffix ("2024-01-15 10:30:00 UTC") — neither parseable by `date-fns parseISO`, JavaScript `new Date()` is lenient enough to handle them but several frontend `App*Input.svelte` components use parseISO and fail silently. Switched to ISO-8601 with `T` separator and `+00:00` offset; arg-parsing path still accepts the legacy " UTC" suffix for back-compat. Test coverage: - 17/17 unit (`pg_executor::tests`) - 9/9 integration (`backend/tests/worker.rs`, `test_postgresql_*`) - 27/27 parser (`windmill-parser-sql`) - 42/42 SDK (`typescript-client/tests/sqlUtils.test.ts`) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pg): bounded one-shot warning on numeric precision loss + ISO-8601 + NaN handling Found while probing PG-script DX with millions of numeric cells: 1. **Numeric precision-loss warning**: `numeric` results are still serialised as JSON Number (back-compat — switching to JSON String would silently break user code doing arithmetic on results), but we now detect `Decimal -> f64 -> Decimal` round-trip failure and emit a single job-log warning recommending a `::text` cast in the SQL. Bounded by `NUMERIC_PRECISION_CHECK_BUDGET = 256` cells per query (one atomic load + one fetch_sub on the hot path; first lossy value short-circuits to a single load thereafter). Worst-case overhead on a 1M-cell numeric-heavy query: ~25µs of checks + 5ns × N atomic loads (vs. ~100ms unbounded). 2. **ISO-8601 timestamps**: `pg_cell_to_json_value` previously returned `"2024-01-15 10:30:00"` (TIMESTAMP) and `"2024-01-15 10:30:00 UTC"` (TIMESTAMPTZ) — neither parseable by date-fns `parseISO`, which is what the apps `App*Input.svelte` components use, so timestamp values silently failed to round-trip into date pickers. Switch to ISO-8601 (`T` separator + `+00:00` offset) on the result side; arg-parser continues to accept the legacy `" UTC"`-suffixed format for back-compat. 3. **Float NaN / Infinity results**: `Number::from_f64` returns None for NaN / ±Inf, which `pg_cell_to_json_value` was raising as "invalid json-float" — failing the *entire* query if any cell held one of these special values. Now serialise them as JSON strings ("NaN", "Infinity", "-Infinity") and let the rest of the row come through. Arg-side: `s.parse::<f64>()` already accepts the same strings. Tests: - `decimal_fits_f64_losslessly_predicate` — covers fits / doesn't-fit cases for the precision-loss predicate. - `precision_check_budget_caps_per_query_overhead` — locks in the budget cap and the loss-flag short-circuit. - All 9 PG integration tests + 17 unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pg): add pg_advisory_unlock_all to reset; warn on missing args; honor decl defaults While probing PG-script DX further found three more frictions: 1. **Advisory lock leak** (cubic P2): switching from `DISCARD ALL` to `RESET ALL; RESET SESSION AUTHORIZATION; UNLISTEN *; CLOSE ALL;` meant session-scoped advisory locks (`pg_advisory_lock`) leaked across cached-connection reuse. Add `SELECT pg_advisory_unlock_all()` to the chain — `DISCARD ALL` covered this implicitly via `DISCARD PLANS / DEALLOCATE / pg_advisory_unlock_all` and we lost it in the switch. 2. **Missing-arg silent NULL**: an arg declared in the SQL (e.g. `-- $1 amount (numeric)`) but not provided in the args object was bound as NULL with no error / warning. Misspelling the key in the args object silently produced a row of NULLs — a notorious DX debugging trap. Now: collect the names of declared-but-missing args during dispatch and emit a single one-shot warning to the job logs at end-of-query naming each one. Bound NULL is preserved for back-compat. 3. **Declaration defaults ignored**: `-- $1 a (int) = 5` carries `arg.default = Some(Number(5))`, but the dispatch fell straight to NULL when the arg was missing. Now: respect the default — user-supplied value > declaration default > NULL. Also fixes the warning logic above (only warn for args that *don't* have a default). Tests: existing 19 unit + 9 integration pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pg): multi-word PG types with [] suffix lost the array-ness; array arms accept stringified values Two more frictions found while probing SDK end-to-end against a real datatable resource: 1. **Multi-word array types lose the [] suffix in the parser**. `transform_types_with_spaces` recognises aliases for "double precision", "character varying", "timestamp with time zone", etc. but its return type was `&'a str` — only the bare alias, never with a trailing `[]`. The `RE_CODE_PGSQL` regex's `\w+` captures stop at the first space, so the regex's own `(?:\[\])?` array-suffix branch sees only `"double"` (not `"double precision[]"`); the `[]` was silently lost. Result: `$1::double precision[]` (which the SDK now emits for homogeneous float arrays via the new auto-tag) routed through `Value::Array → Type::JSONB` and the server failed with "cannot cast type jsonb to double precision[]". Fix: switch `transform_types_with_spaces` to return `Cow<'a, str>` and re-check the trailing bytes after a multi-word match. If they start with `[]`, return `format!("{alias}[]")` — Owned. Single-word types and the no-match path keep returning Borrowed slices, so no allocation in the hot path. 2. **Array arms in `convert_vec_val` rejected stringified values for numeric / int* / bool / oid / real / double**. The scalar `convert_val` already parses strings into the matching native type for these arg_ts, but the array variant only accepted JSON-native counterparts. Sending `["1.5", "2.5", "3.5"]` against `$1::numeric[]` (e.g. via `unnest` for bulk loading, or `JSON.stringify(BigInt[])` round-trip) failed with "Mixed types in array". Now the array arms mirror the scalar ones — `as_<native>().or_else(|| as_str().and_then(parse))` — so both shapes round-trip cleanly. Tests: 19 unit + 9 integration pass; existing parser tests cover the multi-word array forms (the regex-cap behaviour didn't break for single-word types, and Cow plumbing is transparent to all callers). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(parsers): add otyp_inferred field to Arg literals in tests + 3 missed src files CI failures: the perl-driven sweep that added `otyp_inferred: false` to every `Arg { ... }` literal when I introduced the field in the parser schema covered `src/lib.rs` files but missed: - parsers/windmill-parser-bash/src/lib.rs (mass-edited but a later format pass un-applied a few sites) - parsers/windmill-parser-go/src/lib.rs (same) - parsers/windmill-parser-graphql/src/lib.rs (same) - parsers/windmill-parser-nu/tests/tests.rs (test file — not swept the first time) - parsers/windmill-parser-ts/tests/tests.rs (test file — same) Also tightened the regex to handle `oidx: None` without the trailing comma (some test files had the field as the last initialiser line). `cargo build --features <CI feature combo> --workspace --all-targets` is clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sdk): Date → TIMESTAMPTZ; NaN / ±Infinity → string Two more frictions found while running the actual SDK end-to-end against a live datatable resource: 1. **JS `Date`** fell into the typeof "object" branch and was tagged `::JSON`. It worked accidentally for `${date}::timestamptz` via PG's `json → text → timestamptz` implicit cast chain, but `${date}` against a `timestamptz` column without a user-supplied cast bound the value as a JSON string and the comparison `timestamptz = json` failed. Now: `inferSqlType` recognises `Date` and tags `::TIMESTAMPTZ`; `serializeArgValue` emits `Date.toISOString()` so the executor's `Value::String → TIMESTAMPTZ` arm parses it cleanly. 2. **JS `NaN` / `±Infinity`** silently became NULL. `JSON.stringify(NaN)` returns `"null"` per the JS spec, so the value reached the executor as JSON null — the SDK's `::DOUBLE PRECISION` tag then bound a NULL double. Fix: detect non-finite numbers in `serializeArgValue` and stringify them as `"NaN" / "Infinity" / "-Infinity"`. The executor's `Value::String → FLOAT8` arm (`f64::from_str`) accepts these literals directly, and the result-side already renders the values as JSON strings (matching round-trip). SDK unit tests grow from 42 → 44 passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(pg): integration coverage for multi-word arrays + stringified array elements Locks in the two array fixes from the previous commit (`fix(pg): multi-word PG types with [] suffix lost the array-ness`) with end-to-end cases in `test_postgresql_arg_type_combinations`: - `double precision[]`, `character varying[]`, `timestamp without time zone[]` — verifies the parser keeps the `[]` suffix after multi-word alias resolution. - `numeric[]` / `int[]` / `bool[]` from stringified primitives — verifies the array arms of `convert_vec_val` apply the same string-coercion the scalar arms do. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style: fix indentation drift on otyp_inferred lines cargo fmt cleanup of leftover indentation where the perl-driven sweep that introduced the otyp_inferred field landed at the wrong column. No behaviour change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -134,6 +134,7 @@ fn parse_bash_file(code: &str) -> anyhow::Result<Option<Vec<Arg>>> {
|
||||
otyp: None,
|
||||
has_default: default.is_some(),
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
});
|
||||
} else {
|
||||
break;
|
||||
@@ -731,6 +732,7 @@ fn finalize_parameter(
|
||||
otyp,
|
||||
has_default,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -774,7 +776,8 @@ non_required="${5:-}"
|
||||
typ: Typ::Str(None),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -782,7 +785,8 @@ non_required="${5:-}"
|
||||
typ: Typ::Str(None),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -790,7 +794,8 @@ non_required="${5:-}"
|
||||
typ: Typ::Str(None),
|
||||
default: Some(json!("latest with spaces")),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -798,7 +803,8 @@ non_required="${5:-}"
|
||||
typ: Typ::Str(None),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -806,7 +812,8 @@ non_required="${5:-}"
|
||||
typ: Typ::Str(None),
|
||||
default: Some(json!("")),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
}
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -833,7 +840,8 @@ non_required="${5:-}"
|
||||
typ: Typ::Str(None),
|
||||
default: None,
|
||||
has_default: true, // Optional (not mandatory)
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("string".to_string()), // [string]
|
||||
@@ -841,7 +849,8 @@ non_required="${5:-}"
|
||||
typ: Typ::Str(None),
|
||||
default: None,
|
||||
has_default: true, // Optional (not mandatory)
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None, // No type annotation
|
||||
@@ -849,7 +858,8 @@ non_required="${5:-}"
|
||||
typ: Typ::Str(None),
|
||||
default: Some(json!("default value, with comma")),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("int".to_string()), // [int]
|
||||
@@ -857,7 +867,8 @@ non_required="${5:-}"
|
||||
typ: Typ::Int,
|
||||
default: Some(json!(3)),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None, // Type inferred from default value
|
||||
@@ -865,7 +876,8 @@ non_required="${5:-}"
|
||||
typ: Typ::Float,
|
||||
default: Some(json!(5.0)),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None, // Type inferred from default value
|
||||
@@ -873,7 +885,8 @@ non_required="${5:-}"
|
||||
typ: Typ::Int,
|
||||
default: Some(json!(5)),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None, // No type annotation
|
||||
@@ -881,7 +894,8 @@ non_required="${5:-}"
|
||||
typ: Typ::Str(None),
|
||||
default: None,
|
||||
has_default: true, // Optional (not mandatory)
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("PSCustomObject".to_string()), // [PSCustomObject]
|
||||
@@ -889,7 +903,8 @@ non_required="${5:-}"
|
||||
typ: Typ::Object(ObjectType::new(None, None)),
|
||||
default: None,
|
||||
has_default: true, // Optional (not mandatory)
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("string[]".to_string()), // [string[]]
|
||||
@@ -897,7 +912,8 @@ non_required="${5:-}"
|
||||
typ: Typ::List(Box::new(Typ::Str(None))),
|
||||
default: None,
|
||||
has_default: true, // Optional (not mandatory)
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("string".to_string()), // [string] (last type bracket with Mandatory and ValidateSet)
|
||||
@@ -909,7 +925,8 @@ non_required="${5:-}"
|
||||
])), // ValidateSet enum
|
||||
default: None,
|
||||
has_default: false, // Required (Mandatory attribute)
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
}
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -1462,7 +1479,8 @@ param(
|
||||
typ: Typ::Str(None),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -1470,7 +1488,8 @@ param(
|
||||
typ: Typ::Str(None),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -1478,7 +1497,8 @@ param(
|
||||
typ: Typ::Str(None),
|
||||
default: Some(json!("latest with spaces")),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -1486,7 +1506,8 @@ param(
|
||||
typ: Typ::Str(None),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -1494,7 +1515,8 @@ param(
|
||||
typ: Typ::Str(None),
|
||||
default: Some(json!("")),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
}
|
||||
],
|
||||
auto_kind: None,
|
||||
|
||||
@@ -77,7 +77,7 @@ pub fn parse_csharp_sig_meta(code: &str) -> anyhow::Result<CsharpMainSigMeta> {
|
||||
}
|
||||
}
|
||||
let (otyp, typ, name) = parse_csharp_typ(p_list_node, code)?;
|
||||
args.push(Arg { name, otyp, typ, default, has_default: false, oidx: None });
|
||||
args.push(Arg { name, otyp, typ, default, has_default: false, oidx: None, otyp_inferred: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ pub fn parse_go_sig(code: &str) -> anyhow::Result<MainArgSignature> {
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
}
|
||||
})
|
||||
.collect_vec();
|
||||
@@ -147,7 +148,10 @@ fn parse_go_typ(typ: &Expression) -> (Option<String>, Typ) {
|
||||
Typ::Object(ObjectType::new(None, Some(typs))),
|
||||
)
|
||||
}
|
||||
Expression::TypeInterface(_) => (Some("interface{}".to_string()), Typ::Object(ObjectType::new(None, Some(vec![])))),
|
||||
Expression::TypeInterface(_) => (
|
||||
Some("interface{}".to_string()),
|
||||
Typ::Object(ObjectType::new(None, Some(vec![]))),
|
||||
),
|
||||
Expression::TypeMap(_) => (
|
||||
Some("map[string]interface{}".to_string()),
|
||||
Typ::Object(ObjectType::new(None, Some(vec![]))),
|
||||
@@ -191,7 +195,8 @@ func main(x int, y string, z bool, l []string, o struct { Name string `json:"nam
|
||||
typ: Typ::Int,
|
||||
has_default: false,
|
||||
default: None,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("string".to_string()),
|
||||
@@ -199,7 +204,8 @@ func main(x int, y string, z bool, l []string, o struct { Name string `json:"nam
|
||||
typ: Typ::Str(None),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("bool".to_string()),
|
||||
@@ -207,7 +213,8 @@ func main(x int, y string, z bool, l []string, o struct { Name string `json:"nam
|
||||
typ: Typ::Bool,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("[]string".to_string()),
|
||||
@@ -215,18 +222,23 @@ func main(x int, y string, z bool, l []string, o struct { Name string `json:"nam
|
||||
typ: Typ::List(Box::new(Typ::Str(None))),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("struct { Name string `json:\"name\"` }".to_string()),
|
||||
name: "o".to_string(),
|
||||
typ: Typ::Object(ObjectType::new(None, Some(vec![ObjectProperty {
|
||||
key: "name".to_string(),
|
||||
typ: Box::new(Typ::Str(None))
|
||||
},]))),
|
||||
typ: Typ::Object(ObjectType::new(
|
||||
None,
|
||||
Some(vec![ObjectProperty {
|
||||
key: "name".to_string(),
|
||||
typ: Box::new(Typ::Str(None))
|
||||
},])
|
||||
)),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("interface{}".to_string()),
|
||||
@@ -234,7 +246,8 @@ func main(x int, y string, z bool, l []string, o struct { Name string `json:"nam
|
||||
typ: Typ::Object(ObjectType::new(None, Some(vec![]))),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("map[string]interface{}".to_string()),
|
||||
@@ -242,12 +255,13 @@ func main(x int, y string, z bool, l []string, o struct { Name string `json:"nam
|
||||
typ: Typ::Object(ObjectType::new(None, Some(vec![]))),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
],
|
||||
auto_kind: None,
|
||||
has_preprocessor: None,
|
||||
..Default::default()
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ fn parse_graphql_file(code: &str) -> anyhow::Result<Option<Vec<Arg>>> {
|
||||
otyp: Some(typ.unwrap()),
|
||||
has_default,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,7 +108,8 @@ query($i: Int, $arr: [String]!, $wahoo: String = "wahoo") {
|
||||
typ: Typ::Int,
|
||||
default: None,
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("[String]".to_string()),
|
||||
@@ -115,7 +117,8 @@ query($i: Int, $arr: [String]!, $wahoo: String = "wahoo") {
|
||||
typ: Typ::List(Box::new(Typ::Str(None))),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("String".to_string()),
|
||||
@@ -123,7 +126,8 @@ query($i: Int, $arr: [String]!, $wahoo: String = "wahoo") {
|
||||
typ: Typ::Str(None),
|
||||
default: Some(json!("wahoo")),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
}
|
||||
],
|
||||
auto_kind: None,
|
||||
|
||||
@@ -69,6 +69,7 @@ pub fn parse_java_sig_meta(code: &str) -> anyhow::Result<JavaMainSigMeta> {
|
||||
has_default: default.is_some(),
|
||||
default,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -256,7 +257,8 @@ class Main {
|
||||
typ: Typ::Bytes,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "b".into(),
|
||||
@@ -264,7 +266,8 @@ class Main {
|
||||
typ: Typ::Int,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "c".into(),
|
||||
@@ -272,7 +275,8 @@ class Main {
|
||||
typ: Typ::Int,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "d".into(),
|
||||
@@ -280,7 +284,8 @@ class Main {
|
||||
typ: Typ::Int,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "e".into(),
|
||||
@@ -288,7 +293,8 @@ class Main {
|
||||
typ: Typ::Float,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "f".into(),
|
||||
@@ -296,7 +302,8 @@ class Main {
|
||||
typ: Typ::Float,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "g".into(),
|
||||
@@ -304,7 +311,8 @@ class Main {
|
||||
typ: Typ::Bool,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "h".into(),
|
||||
@@ -312,7 +320,8 @@ class Main {
|
||||
typ: Typ::Str(None),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
]
|
||||
);
|
||||
@@ -338,7 +347,8 @@ class Main {
|
||||
typ: Typ::Bytes,
|
||||
default: Some(json!(null)),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "b".into(),
|
||||
@@ -346,7 +356,8 @@ class Main {
|
||||
typ: Typ::Int,
|
||||
default: Some(json!(null)),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "c".into(),
|
||||
@@ -354,7 +365,8 @@ class Main {
|
||||
typ: Typ::Int,
|
||||
default: Some(json!(null)),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "d".into(),
|
||||
@@ -362,7 +374,8 @@ class Main {
|
||||
typ: Typ::Int,
|
||||
default: Some(json!(null)),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "e".into(),
|
||||
@@ -370,7 +383,8 @@ class Main {
|
||||
typ: Typ::Float,
|
||||
default: Some(json!(null)),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "f".into(),
|
||||
@@ -378,7 +392,8 @@ class Main {
|
||||
typ: Typ::Float,
|
||||
default: Some(json!(null)),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "g".into(),
|
||||
@@ -386,7 +401,8 @@ class Main {
|
||||
typ: Typ::Bool,
|
||||
default: Some(json!(null)),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "h".into(),
|
||||
@@ -394,7 +410,8 @@ class Main {
|
||||
typ: Typ::Str(None),
|
||||
default: Some(json!(null)),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "i".into(),
|
||||
@@ -402,7 +419,8 @@ class Main {
|
||||
typ: Typ::Object(ObjectType::new(None, Some(vec![]))),
|
||||
default: Some(json!(null)),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
]
|
||||
);
|
||||
@@ -427,7 +445,8 @@ class Main {
|
||||
typ: Typ::List(Box::new(Typ::Int)),
|
||||
default: Some(json!(null)),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "b".into(),
|
||||
@@ -435,7 +454,8 @@ class Main {
|
||||
typ: Typ::List(Box::new(Typ::Object(ObjectType::new(None, Some(vec![]))))),
|
||||
default: Some(json!(null)),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "c".into(),
|
||||
@@ -443,7 +463,8 @@ class Main {
|
||||
typ: Typ::List(Box::new(Typ::Str(None))),
|
||||
default: Some(json!(null)),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
@@ -152,6 +152,7 @@ pub fn parse_nu_signature(code: &str) -> anyhow::Result<MainArgSignature> {
|
||||
has_default: default.is_some() || optional,
|
||||
default: default.or_else(|| if optional { Some(json!(null)) } else { None }),
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,8 @@ mod test {
|
||||
typ: Typ::Unknown,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "b".into(),
|
||||
@@ -35,7 +36,8 @@ mod test {
|
||||
typ: Typ::Unknown,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "c".into(),
|
||||
@@ -43,7 +45,8 @@ mod test {
|
||||
typ: Typ::Unknown,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "d".into(),
|
||||
@@ -51,7 +54,8 @@ mod test {
|
||||
typ: Typ::Unknown,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
}
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -80,7 +84,8 @@ mod test {
|
||||
typ: Typ::Unknown,
|
||||
default: Some(serde_json::Value::Null),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},],
|
||||
auto_kind: None,
|
||||
has_preprocessor: None,
|
||||
@@ -109,7 +114,8 @@ mod test {
|
||||
typ: Typ::Str(None),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "bar".into(),
|
||||
@@ -117,7 +123,8 @@ mod test {
|
||||
typ: Typ::Int,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -158,7 +165,8 @@ mod test {
|
||||
typ: Typ::Unknown,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "a2".into(),
|
||||
@@ -166,7 +174,8 @@ mod test {
|
||||
typ: Typ::Bool,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "a3".into(),
|
||||
@@ -174,7 +183,8 @@ mod test {
|
||||
typ: Typ::Int,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "a4".into(),
|
||||
@@ -182,7 +192,8 @@ mod test {
|
||||
typ: Typ::Float,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "a5".into(),
|
||||
@@ -190,7 +201,8 @@ mod test {
|
||||
typ: Typ::Datetime,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "a6".into(),
|
||||
@@ -198,7 +210,8 @@ mod test {
|
||||
typ: Typ::Str(None),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "a7".into(),
|
||||
@@ -206,7 +219,8 @@ mod test {
|
||||
typ: Typ::Object(ObjectType::new(None, Some(vec![]))),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "a8".into(),
|
||||
@@ -214,7 +228,8 @@ mod test {
|
||||
typ: Typ::List(Box::new(Typ::Unknown)),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "a9".into(),
|
||||
@@ -222,7 +237,8 @@ mod test {
|
||||
typ: Typ::List(Box::new(Typ::Object(ObjectType::new(None, Some(vec![]))))),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "a10".into(),
|
||||
@@ -230,7 +246,8 @@ mod test {
|
||||
typ: Typ::Unknown,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -262,7 +279,8 @@ mod test {
|
||||
typ: Typ::Unknown,
|
||||
default: Some(json!("Foo")),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "bar".into(),
|
||||
@@ -270,7 +288,8 @@ mod test {
|
||||
typ: Typ::Str(None),
|
||||
default: Some(json!("Bar")),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "bazz".into(),
|
||||
@@ -278,7 +297,8 @@ mod test {
|
||||
typ: Typ::Unknown,
|
||||
default: Some(json!(3)),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -375,7 +395,8 @@ mod test {
|
||||
typ: Typ::List(Box::new(Typ::Float)),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},],
|
||||
auto_kind: None,
|
||||
has_preprocessor: None,
|
||||
@@ -406,7 +427,8 @@ mod test {
|
||||
typ: Typ::Unknown,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "foo".into(),
|
||||
@@ -414,7 +436,8 @@ mod test {
|
||||
typ: Typ::List(Box::new(Typ::Float)),
|
||||
default: Some(json!([2, 3, 4])),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "b".into(),
|
||||
@@ -422,7 +445,8 @@ mod test {
|
||||
typ: Typ::Unknown,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -452,7 +476,8 @@ mod test {
|
||||
typ: Typ::Datetime,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},],
|
||||
auto_kind: None,
|
||||
has_preprocessor: None,
|
||||
@@ -515,7 +540,8 @@ mod test {
|
||||
typ: Typ::Unknown,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "b".into(),
|
||||
@@ -523,7 +549,8 @@ mod test {
|
||||
typ: Typ::Int,
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "c".into(),
|
||||
@@ -531,7 +558,8 @@ mod test {
|
||||
typ: Typ::Unknown,
|
||||
default: Some(serde_json::Value::Null),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "d".into(),
|
||||
@@ -539,7 +567,8 @@ mod test {
|
||||
typ: Typ::Str(None),
|
||||
default: Some(json!("foo")),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "bi".into(),
|
||||
@@ -547,7 +576,8 @@ mod test {
|
||||
typ: Typ::Unknown,
|
||||
default: Some(serde_json::Value::Null),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
}
|
||||
],
|
||||
auto_kind: None,
|
||||
|
||||
@@ -91,6 +91,7 @@ pub fn parse_php_signature(
|
||||
has_default: default.is_some(),
|
||||
default,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -146,7 +147,8 @@ function main(string $input1 = \"hey\", bool $input2 = false, int $input3 = 3, f
|
||||
typ: Typ::Str(None),
|
||||
has_default: true,
|
||||
default: Some(Value::String("hey".to_string())),
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -154,7 +156,8 @@ function main(string $input1 = \"hey\", bool $input2 = false, int $input3 = 3, f
|
||||
typ: Typ::Bool,
|
||||
has_default: true,
|
||||
default: Some(Value::Bool(false)),
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -162,7 +165,8 @@ function main(string $input1 = \"hey\", bool $input2 = false, int $input3 = 3, f
|
||||
typ: Typ::Int,
|
||||
has_default: true,
|
||||
default: Some(Value::Number(Number::from(3))),
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -170,7 +174,8 @@ function main(string $input1 = \"hey\", bool $input2 = false, int $input3 = 3, f
|
||||
typ: Typ::Float,
|
||||
has_default: true,
|
||||
default: Some(Value::Number(Number::from_f64(f64::from(4.5)).unwrap())),
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -178,7 +183,8 @@ function main(string $input1 = \"hey\", bool $input2 = false, int $input3 = 3, f
|
||||
typ: Typ::Resource("stripe".to_string()),
|
||||
has_default: false,
|
||||
default: None,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
}
|
||||
],
|
||||
auto_kind: None,
|
||||
|
||||
@@ -477,6 +477,7 @@ pub fn parse_python_signature(
|
||||
has_default: has_default || default.is_some(),
|
||||
default,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
@@ -716,7 +717,8 @@ def main(test1: str, name: datetime.datetime = datetime.now(), byte: bytes = byt
|
||||
typ: Typ::Str(None),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -724,7 +726,8 @@ def main(test1: str, name: datetime.datetime = datetime.now(), byte: bytes = byt
|
||||
typ: Typ::Datetime,
|
||||
default: Some(json!("<function call>")),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -732,7 +735,8 @@ def main(test1: str, name: datetime.datetime = datetime.now(), byte: bytes = byt
|
||||
typ: Typ::Bytes,
|
||||
default: Some(json!("<function call>")),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -740,7 +744,8 @@ def main(test1: str, name: datetime.datetime = datetime.now(), byte: bytes = byt
|
||||
typ: Typ::Str(None),
|
||||
default: Some(json!("wewe")),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -748,7 +753,8 @@ def main(test1: str, name: datetime.datetime = datetime.now(), byte: bytes = byt
|
||||
typ: Typ::Int,
|
||||
default: Some(json!(21)),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -756,7 +762,8 @@ def main(test1: str, name: datetime.datetime = datetime.now(), byte: bytes = byt
|
||||
typ: Typ::List(Box::new(Typ::Int)),
|
||||
default: Some(json!([1, 2])),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -764,7 +771,8 @@ def main(test1: str, name: datetime.datetime = datetime.now(), byte: bytes = byt
|
||||
typ: Typ::Bool,
|
||||
default: Some(json!(true)),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -806,7 +814,8 @@ def main(test1: str,
|
||||
typ: Typ::Str(None),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -814,7 +823,8 @@ def main(test1: str,
|
||||
typ: Typ::Datetime,
|
||||
default: Some(json!("<function call>")),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -822,7 +832,8 @@ def main(test1: str,
|
||||
typ: Typ::Bytes,
|
||||
default: Some(json!("<function call>")),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -830,7 +841,8 @@ def main(test1: str,
|
||||
typ: Typ::Resource("postgresql".to_string()),
|
||||
default: Some(json!("$res:g/all/resource")),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
}
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -867,7 +879,8 @@ def main(test1: str,
|
||||
typ: Typ::Str(None),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -875,7 +888,8 @@ def main(test1: str,
|
||||
typ: Typ::Resource("s3_object".to_string()),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -883,7 +897,8 @@ def main(test1: str,
|
||||
typ: Typ::Str(None),
|
||||
default: Some(json!("test")),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -891,7 +906,8 @@ def main(test1: str,
|
||||
typ: Typ::Bytes,
|
||||
default: Some(json!("<function call>")),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
}
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -925,7 +941,8 @@ def main(test1: Literal["foo", "bar"], test2: List[Literal["foo", "bar"]]): retu
|
||||
typ: Typ::Str(Some(vec!["foo".to_string(), "bar".to_string()])),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -936,7 +953,8 @@ def main(test1: Literal["foo", "bar"], test2: List[Literal["foo", "bar"]]): retu
|
||||
])))),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
}
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -969,7 +987,8 @@ def main(test1: DynSelect_foo): return
|
||||
typ: Typ::DynSelect("foo".to_string()),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
}],
|
||||
auto_kind: None,
|
||||
has_preprocessor: Some(false),
|
||||
@@ -1094,7 +1113,8 @@ def main(a: list, e: List[int], b: list = [1,2,3,4], c = [1,2,3,4], d = ["a", "b
|
||||
typ: Typ::List(Box::new(Typ::Str(None))),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -1102,7 +1122,8 @@ def main(a: list, e: List[int], b: list = [1,2,3,4], c = [1,2,3,4], d = ["a", "b
|
||||
typ: Typ::List(Box::new(Typ::Int)),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -1110,7 +1131,8 @@ def main(a: list, e: List[int], b: list = [1,2,3,4], c = [1,2,3,4], d = ["a", "b
|
||||
typ: Typ::List(Box::new(Typ::Int)),
|
||||
default: Some(json!([1, 2, 3, 4])),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -1118,7 +1140,8 @@ def main(a: list, e: List[int], b: list = [1,2,3,4], c = [1,2,3,4], d = ["a", "b
|
||||
typ: Typ::List(Box::new(Typ::Int)),
|
||||
default: Some(json!([1, 2, 3, 4])),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -1126,7 +1149,8 @@ def main(a: list, e: List[int], b: list = [1,2,3,4], c = [1,2,3,4], d = ["a", "b
|
||||
typ: Typ::List(Box::new(Typ::Str(None))),
|
||||
default: Some(json!(["a", "b"])),
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
}
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -1160,7 +1184,8 @@ def main(a: str, b: Optional[str], c: str | None): return
|
||||
typ: Typ::Str(None),
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -1168,7 +1193,8 @@ def main(a: str, b: Optional[str], c: str | None): return
|
||||
typ: Typ::Str(None),
|
||||
default: None,
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: None,
|
||||
@@ -1176,7 +1202,8 @@ def main(a: str, b: Optional[str], c: str | None): return
|
||||
typ: Typ::Str(None),
|
||||
default: None,
|
||||
has_default: true,
|
||||
oidx: None
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
],
|
||||
auto_kind: None,
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn parse_rust_signature(code: &str) -> anyhow::Result<MainArgSignature> {
|
||||
.iter()
|
||||
.map(|param| {
|
||||
let (otyp, typ, name) = parse_rust_typ(param);
|
||||
Arg { name, otyp, typ, default: None, has_default: false, oidx: None }
|
||||
Arg { name, otyp, typ, default: None, has_default: false, oidx: None, otyp_inferred: false }
|
||||
})
|
||||
.collect_vec();
|
||||
Ok(MainArgSignature {
|
||||
|
||||
@@ -281,6 +281,7 @@ fn parse_oracledb_file(code: &str) -> anyhow::Result<Option<Vec<Arg>>> {
|
||||
otyp: Some(typ),
|
||||
has_default,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -305,6 +306,7 @@ fn parse_oracledb_file(code: &str) -> anyhow::Result<Option<Vec<Arg>>> {
|
||||
otyp: Some(typ),
|
||||
has_default,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -331,6 +333,7 @@ fn parse_sql_sanitized_interpolation(code: &str) -> Vec<Arg> {
|
||||
otyp: Some(otyp.to_string()),
|
||||
has_default,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -360,6 +363,7 @@ fn parse_mysql_file(code: &str) -> anyhow::Result<Option<Vec<Arg>>> {
|
||||
otyp: Some(typ),
|
||||
has_default,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -384,6 +388,7 @@ fn parse_mysql_file(code: &str) -> anyhow::Result<Option<Vec<Arg>>> {
|
||||
otyp: Some(typ),
|
||||
has_default,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -518,7 +523,25 @@ fn run_on_sql_statement_matches<
|
||||
}
|
||||
|
||||
pub fn parse_pg_statement_arg_indices(code: &str) -> HashSet<i32> {
|
||||
let mut arg_indices = HashSet::new();
|
||||
parse_pg_statement_arg_positions(code)
|
||||
.into_iter()
|
||||
.map(|(idx, _)| idx)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Like `parse_pg_statement_arg_indices`, but also returns the byte range of
|
||||
/// each placeholder occurrence in `code` (excluding `$`, including the digits).
|
||||
/// The same string-/comment-/dollar-quote-aware tokenizer is used, so
|
||||
/// occurrences inside string literals and comments are correctly skipped —
|
||||
/// this is what callers need to renumber `$N → $M` without mangling literal
|
||||
/// SQL bytes that happen to match the `$\d+` pattern.
|
||||
///
|
||||
/// The returned vec is in source order. Each entry is `(idx, range)` where
|
||||
/// `idx` is the parameter number and `range` covers the `$N` digits (i.e.
|
||||
/// `code[range.start - 1 .. range.end]` is the full `$N` token, and
|
||||
/// `code[range]` is just the digits).
|
||||
pub fn parse_pg_statement_arg_positions(code: &str) -> Vec<(i32, std::ops::Range<usize>)> {
|
||||
let mut positions = Vec::new();
|
||||
run_on_sql_statement_matches(
|
||||
code,
|
||||
true,
|
||||
@@ -529,21 +552,24 @@ pub fn parse_pg_statement_arg_indices(code: &str) -> HashSet<i32> {
|
||||
.is_some_and(|&(_, next_char)| next_char.is_ascii_digit())
|
||||
},
|
||||
|_, chars| {
|
||||
let start = chars.peek().map(|&(i, _)| i).unwrap_or(0);
|
||||
let mut arg_idx = String::new();
|
||||
while let Some(&(_, char)) = chars.peek() {
|
||||
let mut end = start;
|
||||
while let Some(&(i, char)) = chars.peek() {
|
||||
if char.is_ascii_digit() {
|
||||
arg_idx.push(char);
|
||||
end = i + char.len_utf8();
|
||||
chars.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Ok(arg_idx) = arg_idx.parse::<i32>() {
|
||||
arg_indices.insert(arg_idx);
|
||||
positions.push((arg_idx, start..end));
|
||||
}
|
||||
},
|
||||
);
|
||||
arg_indices
|
||||
positions
|
||||
}
|
||||
|
||||
fn parse_pg_file(code: &str) -> anyhow::Result<Option<(Vec<Arg>, bool)>> {
|
||||
@@ -577,12 +603,16 @@ fn parse_pg_file(code: &str) -> anyhow::Result<Option<(Vec<Arg>, bool)>> {
|
||||
otyp: Some(typ),
|
||||
has_default,
|
||||
oidx: Some(idx),
|
||||
otyp_inferred: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: infer types from usage for non-explicitly-typed args
|
||||
let mut hm: HashMap<i32, String> = HashMap::new();
|
||||
// Second pass: infer types from usage for non-explicitly-typed args.
|
||||
// We track whether each entry came from an inline `$N::TYPE` cast or from
|
||||
// the parser's "text" fallback, so the executor can later distinguish
|
||||
// "user committed to text" from "no info, use a placeholder".
|
||||
let mut hm: HashMap<i32, (String, bool)> = HashMap::new();
|
||||
for cap in RE_CODE_PGSQL.captures_iter(code) {
|
||||
let idx = cap
|
||||
.get(1)
|
||||
@@ -594,15 +624,23 @@ fn parse_pg_file(code: &str) -> anyhow::Result<Option<(Vec<Arg>, bool)>> {
|
||||
continue;
|
||||
}
|
||||
|
||||
let typ = cap
|
||||
let cast = cap
|
||||
.get(2)
|
||||
.map(|cap| transform_types_with_spaces(&cap, &code))
|
||||
.unwrap_or("text");
|
||||
hm.insert(idx, typ.to_string());
|
||||
.map(|cap| transform_types_with_spaces(&cap, &code));
|
||||
let inferred_default = cast.is_none();
|
||||
let typ: std::borrow::Cow<str> = cast.unwrap_or(std::borrow::Cow::Borrowed("text"));
|
||||
// Prefer an explicit cast over a previously seen default — once we
|
||||
// have any inline cast for the index, lock it in.
|
||||
match hm.get(&idx) {
|
||||
Some((_, false)) => {} // already locked from explicit cast
|
||||
_ => {
|
||||
hm.insert(idx, (typ.into_owned(), inferred_default));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add inferred args
|
||||
for (i, v) in hm.iter() {
|
||||
for (i, (v, inferred)) in hm.iter() {
|
||||
let typ = v.to_lowercase();
|
||||
args.push(Arg {
|
||||
name: format!("${}", i),
|
||||
@@ -611,6 +649,7 @@ fn parse_pg_file(code: &str) -> anyhow::Result<Option<(Vec<Arg>, bool)>> {
|
||||
otyp: Some(typ),
|
||||
has_default: false,
|
||||
oidx: Some(*i),
|
||||
otyp_inferred: *inferred,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -646,6 +685,7 @@ fn parse_pg_file(code: &str) -> anyhow::Result<Option<(Vec<Arg>, bool)>> {
|
||||
otyp: oarg.otyp,
|
||||
has_default,
|
||||
oidx: oarg.oidx,
|
||||
otyp_inferred: oarg.otyp_inferred,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -657,8 +697,12 @@ fn parse_pg_file(code: &str) -> anyhow::Result<Option<(Vec<Arg>, bool)>> {
|
||||
}
|
||||
|
||||
// The regex doesn't parse types with space such as "character varying"
|
||||
// So we look for them manually and replace them with their shorter counterpart
|
||||
fn transform_types_with_spaces<'a>(cap: &Match<'a>, code: &str) -> &'a str {
|
||||
// So we look for them manually and replace them with their shorter counterpart.
|
||||
// Returns `Cow::Borrowed` for the trivial case (the regex's own match) and
|
||||
// `Cow::Owned` when we need to alias a multi-word type and/or append a `[]`
|
||||
// suffix that the regex's `\w+` capture didn't pick up.
|
||||
fn transform_types_with_spaces<'a>(cap: &Match<'a>, code: &str) -> std::borrow::Cow<'a, str> {
|
||||
use std::borrow::Cow;
|
||||
lazy_static! {
|
||||
static ref TYPES: [(&'static str, &'static str); 6] = [
|
||||
("character varying", "varchar"),
|
||||
@@ -671,20 +715,31 @@ fn transform_types_with_spaces<'a>(cap: &Match<'a>, code: &str) -> &'a str {
|
||||
}
|
||||
let typ = &code[cap.start()..];
|
||||
for (long_type, alias) in TYPES.iter() {
|
||||
let mut typ = typ;
|
||||
let mut rest = typ;
|
||||
let mut found_mismatch = false;
|
||||
for token in long_type.split(' ') {
|
||||
if typ.len() < token.len() || !typ[..token.len()].eq_ignore_ascii_case(token) {
|
||||
if rest.len() < token.len() || !rest[..token.len()].eq_ignore_ascii_case(token) {
|
||||
found_mismatch = true;
|
||||
break;
|
||||
}
|
||||
typ = typ[token.len()..].trim_start();
|
||||
rest = rest[token.len()..].trim_start();
|
||||
}
|
||||
if !found_mismatch {
|
||||
return alias;
|
||||
// The regex captured only the first word (`\w+`), so its `[]`
|
||||
// detection in `(?:\[\])?` matched against the wrong position
|
||||
// and is empty for multi-word types. Re-check the trailing
|
||||
// bytes after the multi-word match: if they start with `[]`,
|
||||
// append the array suffix to the alias so the dispatch routes
|
||||
// through `convert_vec_val` instead of binding as JSONB.
|
||||
let with_suffix = rest.starts_with("[]");
|
||||
return if with_suffix {
|
||||
Cow::Owned(format!("{alias}[]"))
|
||||
} else {
|
||||
Cow::Borrowed(*alias)
|
||||
};
|
||||
}
|
||||
}
|
||||
cap.as_str()
|
||||
Cow::Borrowed(cap.as_str())
|
||||
}
|
||||
|
||||
pub fn parse_sql_statement_named_params(code: &str, prefix: char) -> HashSet<String> {
|
||||
@@ -736,6 +791,7 @@ fn parse_bigquery_file(code: &str) -> anyhow::Result<Option<Vec<Arg>>> {
|
||||
otyp: Some(typ),
|
||||
has_default,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -765,6 +821,7 @@ fn parse_duckdb_file(code: &str) -> anyhow::Result<Option<Vec<Arg>>> {
|
||||
otyp: Some(typ),
|
||||
has_default,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -794,6 +851,7 @@ fn parse_snowflake_file(code: &str) -> anyhow::Result<Option<Vec<Arg>>> {
|
||||
otyp: Some(typ),
|
||||
has_default,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -823,6 +881,7 @@ fn parse_mssql_file(code: &str) -> anyhow::Result<Option<Vec<Arg>>> {
|
||||
otyp: Some(typ),
|
||||
has_default,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1006,6 +1065,7 @@ SELECT * FROM table WHERE token=$1::TEXT AND image=$2::BIGINT
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: Some(1),
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("bigint".to_string()),
|
||||
@@ -1014,6 +1074,7 @@ SELECT * FROM table WHERE token=$1::TEXT AND image=$2::BIGINT
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: Some(2),
|
||||
otyp_inferred: false,
|
||||
},
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -1048,6 +1109,7 @@ SELECT $2::TEXT;
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: Some(1),
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("text".to_string()),
|
||||
@@ -1056,6 +1118,7 @@ SELECT $2::TEXT;
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: Some(2),
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("text".to_string()),
|
||||
@@ -1064,6 +1127,7 @@ SELECT $2::TEXT;
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: Some(3),
|
||||
otyp_inferred: false,
|
||||
},
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -1216,6 +1280,54 @@ SELECT $2;"#;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_pg_statement_arg_positions_skips_strings_and_comments() -> anyhow::Result<()> {
|
||||
// Each occurrence's byte range covers JUST the digits (after `$`).
|
||||
let code = "SELECT $5, $50";
|
||||
let positions = parse_pg_statement_arg_positions(code);
|
||||
let collected: Vec<(i32, &str)> = positions
|
||||
.iter()
|
||||
.map(|(idx, range)| (*idx, &code[range.clone()]))
|
||||
.collect();
|
||||
assert_eq!(collected, vec![(5, "5"), (50, "50")]);
|
||||
|
||||
// String literals and comments must not produce positions — this is
|
||||
// what stops the do_postgresql_inner rewrite from mangling SQL like
|
||||
// `'price: $5'`.
|
||||
let code = "SELECT 'literal $5' AS lbl, $5 FROM t -- mention $5";
|
||||
let positions = parse_pg_statement_arg_positions(code);
|
||||
let positions_only: Vec<(i32, std::ops::Range<usize>)> = positions.clone();
|
||||
assert_eq!(
|
||||
positions_only.iter().map(|(i, _)| *i).collect::<Vec<_>>(),
|
||||
vec![5],
|
||||
"only the real $5 between 'lbl,' and 'FROM' should be returned"
|
||||
);
|
||||
// The single returned position is the real placeholder (between
|
||||
// `lbl, ` and ` FROM`).
|
||||
let (idx, range) = &positions[0];
|
||||
assert_eq!(*idx, 5);
|
||||
// `code[range.start - 1 .. range.end]` should be the full `$5` token.
|
||||
assert_eq!(&code[range.start - 1..range.end], "$5");
|
||||
|
||||
// Dollar-quoted blocks similarly skipped.
|
||||
let code = "SELECT $$body with $5 inside$$, $7 FROM t";
|
||||
let positions = parse_pg_statement_arg_positions(code);
|
||||
assert_eq!(
|
||||
positions.iter().map(|(i, _)| *i).collect::<Vec<_>>(),
|
||||
vec![7],
|
||||
"$5 inside $$...$$ is part of the string"
|
||||
);
|
||||
|
||||
// Repeat indices show up multiple times — caller can rewrite each.
|
||||
let code = "SELECT $1, $1, $2";
|
||||
let positions = parse_pg_statement_arg_positions(code);
|
||||
assert_eq!(
|
||||
positions.iter().map(|(i, _)| *i).collect::<Vec<_>>(),
|
||||
vec![1, 1, 2]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sql_blocks_non_pg_ignores_dollar_quotes() -> anyhow::Result<()> {
|
||||
// Non-Postgres dialects (MySQL/Oracle/BigQuery/Snowflake) pass `false`,
|
||||
@@ -1259,6 +1371,7 @@ SELECT ?, ?;
|
||||
default: Some(json!(3)),
|
||||
has_default: true,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("text".to_string()),
|
||||
@@ -1267,6 +1380,7 @@ SELECT ?, ?;
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -1300,6 +1414,7 @@ SELECT :param2;
|
||||
default: Some(json!(3)),
|
||||
has_default: true,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("text".to_string()),
|
||||
@@ -1308,6 +1423,7 @@ SELECT :param2;
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("text".to_string()),
|
||||
@@ -1316,6 +1432,7 @@ SELECT :param2;
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -1349,6 +1466,7 @@ SELECT @token;
|
||||
default: Some(json!("abc")),
|
||||
has_default: true,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("int64".to_string()),
|
||||
@@ -1357,6 +1475,7 @@ SELECT @token;
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -1390,6 +1509,7 @@ SELECT ?;
|
||||
default: Some(json!(3)),
|
||||
has_default: true,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("varchar".to_string()),
|
||||
@@ -1398,6 +1518,7 @@ SELECT ?;
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("varchar".to_string()),
|
||||
@@ -1406,6 +1527,7 @@ SELECT ?;
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
}
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -1439,6 +1561,7 @@ SELECT @P2;
|
||||
default: Some(json!(3)),
|
||||
has_default: true,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("varchar".to_string()),
|
||||
@@ -1447,6 +1570,7 @@ SELECT @P2;
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("varchar".to_string()),
|
||||
@@ -1455,6 +1579,7 @@ SELECT @P2;
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -1489,6 +1614,7 @@ SELECT * FROM table_name WHERE thing = :name4;
|
||||
default: Some(json!(3)),
|
||||
has_default: true,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("text".to_string()),
|
||||
@@ -1497,6 +1623,7 @@ SELECT * FROM table_name WHERE thing = :name4;
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("text".to_string()),
|
||||
@@ -1505,6 +1632,7 @@ SELECT * FROM table_name WHERE thing = :name4;
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -1536,6 +1664,7 @@ SELECT * FROM users WHERE id = $1 AND email = $2::text;
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: Some(1),
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("text".to_string()),
|
||||
@@ -1544,6 +1673,7 @@ SELECT * FROM users WHERE id = $1 AND email = $2::text;
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: Some(2),
|
||||
otyp_inferred: false,
|
||||
},
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -1575,6 +1705,7 @@ SELECT * FROM users LIMIT $1 OFFSET $2;
|
||||
default: Some(json!(10)),
|
||||
has_default: true,
|
||||
oidx: Some(1),
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("bigint".to_string()),
|
||||
@@ -1583,6 +1714,7 @@ SELECT * FROM users LIMIT $1 OFFSET $2;
|
||||
default: Some(json!(0)),
|
||||
has_default: true,
|
||||
oidx: Some(2),
|
||||
otyp_inferred: false,
|
||||
},
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -1618,6 +1750,7 @@ WHERE id = $1
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: Some(1),
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("text".to_string()),
|
||||
@@ -1626,6 +1759,7 @@ WHERE id = $1
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: Some(2),
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
otyp: Some("timestamptz".to_string()),
|
||||
@@ -1634,6 +1768,7 @@ WHERE id = $1
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: Some(3),
|
||||
otyp_inferred: false,
|
||||
},
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -1663,6 +1798,7 @@ SELECT * FROM users WHERE id = ANY($1);
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: Some(1),
|
||||
otyp_inferred: false,
|
||||
},],
|
||||
auto_kind: None,
|
||||
has_preprocessor: None,
|
||||
@@ -1693,6 +1829,7 @@ SELECT $1::integer;
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: Some(1),
|
||||
otyp_inferred: false,
|
||||
},],
|
||||
auto_kind: None,
|
||||
has_preprocessor: None,
|
||||
@@ -1703,6 +1840,62 @@ SELECT $1::integer;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_pgsql_otyp_inferred_flag() -> anyhow::Result<()> {
|
||||
// Bare `$N` (no inline cast, no decl) should produce otyp = "text"
|
||||
// *and* otyp_inferred = true. This is the signal the PG executor
|
||||
// uses to decide whether the user committed to a text target.
|
||||
let code_bare = "SELECT $1, $2";
|
||||
let args = parse_pgsql_sig(code_bare)?.args;
|
||||
let map: HashMap<String, (Option<String>, bool)> = args
|
||||
.into_iter()
|
||||
.map(|a| (a.name, (a.otyp, a.otyp_inferred)))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
map.get("$1").cloned(),
|
||||
Some((Some("text".to_string()), true)),
|
||||
"bare $1 → otyp_inferred true"
|
||||
);
|
||||
assert_eq!(
|
||||
map.get("$2").cloned(),
|
||||
Some((Some("text".to_string()), true)),
|
||||
"bare $2 → otyp_inferred true"
|
||||
);
|
||||
|
||||
// Inline `$N::TYPE` cast → otyp_inferred = false (user committed).
|
||||
let args = parse_pgsql_sig("SELECT $1::int, $2::text")?.args;
|
||||
let map: HashMap<String, (Option<String>, bool)> = args
|
||||
.into_iter()
|
||||
.map(|a| (a.name, (a.otyp, a.otyp_inferred)))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
map.get("$1").cloned(),
|
||||
Some((Some("int".to_string()), false))
|
||||
);
|
||||
assert_eq!(
|
||||
map.get("$2").cloned(),
|
||||
Some((Some("text".to_string()), false)),
|
||||
"explicit $2::text → otyp_inferred false (distinct from bare $2)"
|
||||
);
|
||||
|
||||
// Declaration `-- $N name (TYPE)` → otyp_inferred = false (decl is
|
||||
// explicit by definition).
|
||||
let args = parse_pgsql_sig("-- $1 name (text)\nSELECT $1")?.args;
|
||||
assert_eq!(args[0].otyp.as_deref(), Some("text"));
|
||||
assert!(!args[0].otyp_inferred);
|
||||
|
||||
// Mixed: $1 has decl, $2 is bare → flag differs per arg.
|
||||
let args = parse_pgsql_sig("-- $1 a (int)\nSELECT $1, $2")?.args;
|
||||
let map: HashMap<String, bool> = args
|
||||
.into_iter()
|
||||
.map(|a| (a.name, a.otyp_inferred))
|
||||
.collect();
|
||||
assert_eq!(map.get("a").copied(), Some(false), "$1 decl → not inferred");
|
||||
assert_eq!(map.get("$2").copied(), Some(true), "$2 bare → inferred");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_s3object_arg_per_dialect() -> anyhow::Result<()> {
|
||||
// Confirms that `(s3object)` is recognised as a resource-typed arg in every
|
||||
@@ -1782,6 +1975,7 @@ SELECT x
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},],
|
||||
auto_kind: None,
|
||||
has_preprocessor: None,
|
||||
|
||||
@@ -208,9 +208,7 @@ pub fn parse_relative_imports(code: &str, path: &str) -> anyhow::Result<Vec<Stri
|
||||
|
||||
/// Check if an import path is a relative import (starts with `./`, `../`, or `/`)
|
||||
fn is_relative_import(import_path: &str) -> bool {
|
||||
import_path.starts_with("./")
|
||||
|| import_path.starts_with("../")
|
||||
|| import_path.starts_with("/")
|
||||
import_path.starts_with("./") || import_path.starts_with("../") || import_path.starts_with("/")
|
||||
}
|
||||
|
||||
/// Normalize a path by resolving `.` and `..` components
|
||||
@@ -542,6 +540,7 @@ fn parse_param(
|
||||
default: None,
|
||||
has_default: ident.id.optional || nullable,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
})
|
||||
}
|
||||
// Pat::Object(ObjectPat { ... }) = todo!()
|
||||
@@ -596,13 +595,29 @@ fn parse_param(
|
||||
if typ == Typ::Unknown && dflt.is_some() {
|
||||
typ = json_to_typ(dflt.as_ref().unwrap(), false);
|
||||
}
|
||||
Ok(Arg { otyp, name, typ, default: dflt, has_default: true, oidx: None })
|
||||
Ok(Arg {
|
||||
otyp,
|
||||
name,
|
||||
typ,
|
||||
default: dflt,
|
||||
has_default: true,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
})
|
||||
}
|
||||
Pat::Object(ObjectPat { type_ann, .. }) => {
|
||||
let (typ, nullable) = eval_type_ann(symbol_table, type_resolver, &type_ann);
|
||||
*counter += 1;
|
||||
let name = format!("anon{}", counter);
|
||||
Ok(Arg { otyp: None, name, typ, default: None, has_default: nullable, oidx: None })
|
||||
Ok(Arg {
|
||||
otyp: None,
|
||||
name,
|
||||
typ,
|
||||
default: None,
|
||||
has_default: nullable,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
})
|
||||
}
|
||||
_ => Err(anyhow::anyhow!(
|
||||
"parameter syntax unsupported: `{}`: {:#?}",
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use windmill_parser::{Arg, MainArgSignature, ObjectProperty, ObjectType, Typ};
|
||||
use windmill_parser_ts::{parse_deno_signature, parse_expr_for_imports, parse_relative_imports};
|
||||
use windmill_parser_ts::{
|
||||
parse_deno_signature, parse_expr_for_imports, parse_relative_imports,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_imports_basic() {
|
||||
@@ -78,6 +80,7 @@ mod tests {
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "num_param".to_string(),
|
||||
@@ -86,6 +89,7 @@ mod tests {
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "bool_param".to_string(),
|
||||
@@ -94,6 +98,7 @@ mod tests {
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "any_param".to_string(),
|
||||
@@ -102,6 +107,7 @@ mod tests {
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -136,6 +142,7 @@ mod tests {
|
||||
default: Some(json!("World")),
|
||||
has_default: true,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "count".to_string(),
|
||||
@@ -144,6 +151,7 @@ mod tests {
|
||||
default: Some(json!(42)),
|
||||
has_default: true,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "enabled".to_string(),
|
||||
@@ -152,6 +160,7 @@ mod tests {
|
||||
default: Some(json!(true)),
|
||||
has_default: true,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -186,6 +195,7 @@ mod tests {
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "numbers".to_string(),
|
||||
@@ -194,6 +204,7 @@ mod tests {
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "items".to_string(),
|
||||
@@ -202,6 +213,7 @@ mod tests {
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -235,6 +247,7 @@ mod tests {
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},],
|
||||
auto_kind: None,
|
||||
has_preprocessor: Some(false),
|
||||
@@ -267,6 +280,7 @@ mod tests {
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},],
|
||||
auto_kind: None,
|
||||
has_preprocessor: Some(false),
|
||||
@@ -308,6 +322,7 @@ mod tests {
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
}],
|
||||
auto_kind: None,
|
||||
has_preprocessor: Some(false),
|
||||
@@ -347,6 +362,7 @@ mod tests {
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
}],
|
||||
auto_kind: None,
|
||||
has_preprocessor: Some(false),
|
||||
@@ -406,6 +422,7 @@ mod tests {
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
}],
|
||||
auto_kind: None,
|
||||
has_preprocessor: Some(false),
|
||||
@@ -440,6 +457,7 @@ mod tests {
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "base64_param".to_string(),
|
||||
@@ -448,6 +466,7 @@ mod tests {
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "email_param".to_string(),
|
||||
@@ -456,6 +475,7 @@ mod tests {
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "sql_param".to_string(),
|
||||
@@ -464,6 +484,7 @@ mod tests {
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -498,6 +519,7 @@ mod tests {
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "optional".to_string(),
|
||||
@@ -506,6 +528,7 @@ mod tests {
|
||||
default: None,
|
||||
has_default: true,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "with_default".to_string(),
|
||||
@@ -514,6 +537,7 @@ mod tests {
|
||||
default: Some(json!(false)),
|
||||
has_default: true,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -593,6 +617,7 @@ mod tests {
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
}],
|
||||
auto_kind: None,
|
||||
has_preprocessor: Some(false),
|
||||
@@ -623,6 +648,7 @@ mod tests {
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
}],
|
||||
auto_kind: None,
|
||||
has_preprocessor: Some(false),
|
||||
@@ -653,6 +679,7 @@ mod tests {
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
}],
|
||||
auto_kind: None,
|
||||
has_preprocessor: Some(false),
|
||||
@@ -686,6 +713,7 @@ mod tests {
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "numbers".to_string(),
|
||||
@@ -694,6 +722,7 @@ mod tests {
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
Arg {
|
||||
name: "plain".to_string(),
|
||||
@@ -702,6 +731,7 @@ mod tests {
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
},
|
||||
],
|
||||
auto_kind: None,
|
||||
@@ -744,6 +774,7 @@ mod tests {
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
}],
|
||||
auto_kind: None,
|
||||
has_preprocessor: Some(true),
|
||||
@@ -775,6 +806,7 @@ mod tests {
|
||||
default: None,
|
||||
has_default: false,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
}],
|
||||
auto_kind: None,
|
||||
has_preprocessor: Some(true),
|
||||
|
||||
@@ -50,6 +50,7 @@ pub fn parse_ansible_sig(inner_content: &str) -> anyhow::Result<MainArgSignature
|
||||
has_default: default.is_some(),
|
||||
default,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -68,6 +69,7 @@ pub fn parse_ansible_sig(inner_content: &str) -> anyhow::Result<MainArgSignature
|
||||
has_default: inv.default.is_some(),
|
||||
default: inv.default.map(|v| json!(format!("$res:{}", v))),
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -81,6 +83,7 @@ pub fn parse_ansible_sig(inner_content: &str) -> anyhow::Result<MainArgSignature
|
||||
has_default: false,
|
||||
default: None,
|
||||
oidx: None,
|
||||
otyp_inferred: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,15 @@ pub struct Arg {
|
||||
pub default: Option<serde_json::Value>,
|
||||
pub has_default: bool,
|
||||
pub oidx: Option<i32>,
|
||||
/// `true` when `otyp` is the parser's fallback default rather than a value
|
||||
/// the user (or SDK) actually wrote down. Currently only set by the PG SQL
|
||||
/// parser when a placeholder has no `-- $N name (TYPE)` declaration *and*
|
||||
/// no `$N::TYPE` inline cast — the otyp is `"text"` purely as a
|
||||
/// placeholder. Consumers that care about original intent (e.g. the PG
|
||||
/// executor deciding whether to coerce `Number → String` for a text
|
||||
/// target) should treat `otyp_inferred = true` as "type unknown".
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub otyp_inferred: bool,
|
||||
}
|
||||
|
||||
pub fn json_to_typ(js: &Value, precise_arrays: bool) -> Typ {
|
||||
|
||||
@@ -63,10 +63,7 @@ fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuil
|
||||
|
||||
/// Create an app with inline script via API
|
||||
async fn create_app_with_inline_script(port: u16, path: &str) -> anyhow::Result<()> {
|
||||
let url = format!(
|
||||
"http://localhost:{}/api/w/test-workspace/apps/create",
|
||||
port
|
||||
);
|
||||
let url = format!("http://localhost:{}/api/w/test-workspace/apps/create", port);
|
||||
let resp = authed(client().post(&url), SAME_WS_TOKEN)
|
||||
.json(&json!({
|
||||
"path": path,
|
||||
@@ -102,17 +99,18 @@ async fn create_app_with_inline_script(port: u16, path: &str) -> anyhow::Result<
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("create app failed: {} - {}", resp.status(), resp.text().await?);
|
||||
anyhow::bail!(
|
||||
"create app failed: {} - {}",
|
||||
resp.status(),
|
||||
resp.text().await?
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a raw app with inline script via API (uses regular app endpoint with rawapp type)
|
||||
async fn create_raw_app_with_inline_script(port: u16, path: &str) -> anyhow::Result<()> {
|
||||
let url = format!(
|
||||
"http://localhost:{}/api/w/test-workspace/apps/create",
|
||||
port
|
||||
);
|
||||
let url = format!("http://localhost:{}/api/w/test-workspace/apps/create", port);
|
||||
let resp = authed(client().post(&url), SAME_WS_TOKEN)
|
||||
.json(&json!({
|
||||
"path": path,
|
||||
@@ -146,12 +144,21 @@ async fn create_raw_app_with_inline_script(port: u16, path: &str) -> anyhow::Res
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("create raw app failed: {} - {}", resp.status(), resp.text().await?);
|
||||
anyhow::bail!(
|
||||
"create raw app failed: {} - {}",
|
||||
resp.status(),
|
||||
resp.text().await?
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_app_inline_script(port: u16, token: &str, app_path: &str, force_viewer: bool) -> anyhow::Result<String> {
|
||||
async fn run_app_inline_script(
|
||||
port: u16,
|
||||
token: &str,
|
||||
app_path: &str,
|
||||
force_viewer: bool,
|
||||
) -> anyhow::Result<String> {
|
||||
let url = format!(
|
||||
"http://localhost:{}/api/w/test-workspace/apps_u/execute_component/{}",
|
||||
port, app_path
|
||||
@@ -173,13 +180,22 @@ async fn run_app_inline_script(port: u16, token: &str, app_path: &str, force_vie
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("app inline script run failed: {} - {}", resp.status(), resp.text().await?);
|
||||
anyhow::bail!(
|
||||
"app inline script run failed: {} - {}",
|
||||
resp.status(),
|
||||
resp.text().await?
|
||||
);
|
||||
}
|
||||
let job_id = resp.text().await?;
|
||||
wait_for_job_result(port, token, &job_id).await
|
||||
}
|
||||
|
||||
async fn run_raw_app_inline_script(port: u16, token: &str, app_path: &str, force_viewer: bool) -> anyhow::Result<String> {
|
||||
async fn run_raw_app_inline_script(
|
||||
port: u16,
|
||||
token: &str,
|
||||
app_path: &str,
|
||||
force_viewer: bool,
|
||||
) -> anyhow::Result<String> {
|
||||
let url = format!(
|
||||
"http://localhost:{}/api/w/test-workspace/apps_u/execute_component/{}",
|
||||
port, app_path
|
||||
@@ -200,7 +216,11 @@ async fn run_raw_app_inline_script(port: u16, token: &str, app_path: &str, force
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("raw app inline script run failed: {} - {}", resp.status(), resp.text().await?);
|
||||
anyhow::bail!(
|
||||
"raw app inline script run failed: {} - {}",
|
||||
resp.status(),
|
||||
resp.text().await?
|
||||
);
|
||||
}
|
||||
let job_id = resp.text().await?;
|
||||
wait_for_job_result(port, token, &job_id).await
|
||||
@@ -215,8 +235,12 @@ async fn wait_for_job_result(port: u16, token: &str, job_id: &str) -> anyhow::Re
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
let resp = authed(client().get(&url), token).send().await?;
|
||||
if resp.status().is_success() {
|
||||
return Ok(resp.json::<serde_json::Value>().await?
|
||||
.as_str().unwrap_or("").to_string());
|
||||
return Ok(resp
|
||||
.json::<serde_json::Value>()
|
||||
.await?
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string());
|
||||
}
|
||||
}
|
||||
anyhow::bail!("timeout waiting for job result")
|
||||
@@ -268,24 +292,38 @@ async fn test_app_wm_end_user_email(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
|
||||
let app_path = "f/test/email_app";
|
||||
|
||||
in_test_worker(Connection::Sql(db.clone()), async move {
|
||||
// Create the app with inline script first
|
||||
create_app_with_inline_script(port, app_path).await?;
|
||||
in_test_worker(
|
||||
Connection::Sql(db.clone()),
|
||||
async move {
|
||||
// Create the app with inline script first
|
||||
create_app_with_inline_script(port, app_path).await?;
|
||||
|
||||
// Same workspace user (force_viewer mode works for workspace members)
|
||||
let result = run_app_inline_script(port, SAME_WS_TOKEN, app_path, true).await?;
|
||||
assert_eq!(result, SAME_WS_EMAIL, "same workspace user should get their email");
|
||||
// Same workspace user (force_viewer mode works for workspace members)
|
||||
let result = run_app_inline_script(port, SAME_WS_TOKEN, app_path, true).await?;
|
||||
assert_eq!(
|
||||
result, SAME_WS_EMAIL,
|
||||
"same workspace user should get their email"
|
||||
);
|
||||
|
||||
// Other workspace user (uses app's anonymous policy + token lookup)
|
||||
let result = run_app_inline_script(port, OTHER_WS_TOKEN, app_path, false).await?;
|
||||
assert_eq!(result, OTHER_WS_EMAIL, "other workspace user should get their email");
|
||||
// Other workspace user (uses app's anonymous policy + token lookup)
|
||||
let result = run_app_inline_script(port, OTHER_WS_TOKEN, app_path, false).await?;
|
||||
assert_eq!(
|
||||
result, OTHER_WS_EMAIL,
|
||||
"other workspace user should get their email"
|
||||
);
|
||||
|
||||
// No workspace user (uses app's anonymous policy + token lookup)
|
||||
let result = run_app_inline_script(port, NO_WS_TOKEN, app_path, false).await?;
|
||||
assert_eq!(result, NO_WS_EMAIL, "no workspace user should get their email");
|
||||
// No workspace user (uses app's anonymous policy + token lookup)
|
||||
let result = run_app_inline_script(port, NO_WS_TOKEN, app_path, false).await?;
|
||||
assert_eq!(
|
||||
result, NO_WS_EMAIL,
|
||||
"no workspace user should get their email"
|
||||
);
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}, port).await?;
|
||||
Ok::<(), anyhow::Error>(())
|
||||
},
|
||||
port,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -300,24 +338,38 @@ async fn test_raw_app_wm_end_user_email(db: Pool<Postgres>) -> anyhow::Result<()
|
||||
|
||||
let app_path = "f/test/email_raw_app";
|
||||
|
||||
in_test_worker(Connection::Sql(db.clone()), async move {
|
||||
// Create the raw app with inline script first
|
||||
create_raw_app_with_inline_script(port, app_path).await?;
|
||||
in_test_worker(
|
||||
Connection::Sql(db.clone()),
|
||||
async move {
|
||||
// Create the raw app with inline script first
|
||||
create_raw_app_with_inline_script(port, app_path).await?;
|
||||
|
||||
// Same workspace user (force_viewer mode works for workspace members)
|
||||
let result = run_raw_app_inline_script(port, SAME_WS_TOKEN, app_path, true).await?;
|
||||
assert_eq!(result, SAME_WS_EMAIL, "same workspace user should get their email");
|
||||
// Same workspace user (force_viewer mode works for workspace members)
|
||||
let result = run_raw_app_inline_script(port, SAME_WS_TOKEN, app_path, true).await?;
|
||||
assert_eq!(
|
||||
result, SAME_WS_EMAIL,
|
||||
"same workspace user should get their email"
|
||||
);
|
||||
|
||||
// Other workspace user (uses app's anonymous policy + token lookup)
|
||||
let result = run_raw_app_inline_script(port, OTHER_WS_TOKEN, app_path, false).await?;
|
||||
assert_eq!(result, OTHER_WS_EMAIL, "other workspace user should get their email");
|
||||
// Other workspace user (uses app's anonymous policy + token lookup)
|
||||
let result = run_raw_app_inline_script(port, OTHER_WS_TOKEN, app_path, false).await?;
|
||||
assert_eq!(
|
||||
result, OTHER_WS_EMAIL,
|
||||
"other workspace user should get their email"
|
||||
);
|
||||
|
||||
// No workspace user (uses app's anonymous policy + token lookup)
|
||||
let result = run_raw_app_inline_script(port, NO_WS_TOKEN, app_path, false).await?;
|
||||
assert_eq!(result, NO_WS_EMAIL, "no workspace user should get their email");
|
||||
// No workspace user (uses app's anonymous policy + token lookup)
|
||||
let result = run_raw_app_inline_script(port, NO_WS_TOKEN, app_path, false).await?;
|
||||
assert_eq!(
|
||||
result, NO_WS_EMAIL,
|
||||
"no workspace user should get their email"
|
||||
);
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}, port).await?;
|
||||
Ok::<(), anyhow::Error>(())
|
||||
},
|
||||
port,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -34,10 +34,7 @@ async fn test_error_handler_settings(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
after_set,
|
||||
Some("script/f/test/error_handler".to_string())
|
||||
);
|
||||
assert_eq!(after_set, Some("script/f/test/error_handler".to_string()));
|
||||
|
||||
// Verify extra_args
|
||||
let extra_args = sqlx::query_scalar!(
|
||||
@@ -162,7 +159,8 @@ export async function main(path: string, email: string, job_id: string, is_flow:
|
||||
priority: None,
|
||||
apply_preprocessor: false,
|
||||
concurrency_settings: ConcurrencySettings::default(),
|
||||
debouncing_settings: DebouncingSettings::default(), labels: None,
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
labels: None,
|
||||
})
|
||||
.run_until_complete(&db, false, server.addr.port())
|
||||
.await;
|
||||
@@ -285,7 +283,8 @@ async fn test_error_handler_muted_on_script(db: Pool<Postgres>) -> anyhow::Resul
|
||||
priority: None,
|
||||
apply_preprocessor: false,
|
||||
concurrency_settings: ConcurrencySettings::default(),
|
||||
debouncing_settings: DebouncingSettings::default(), labels: None,
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
labels: None,
|
||||
})
|
||||
.run_until_complete(&db, false, server.addr.port())
|
||||
.await;
|
||||
@@ -380,7 +379,8 @@ async fn test_error_handler_not_triggered_on_success(db: Pool<Postgres>) -> anyh
|
||||
priority: None,
|
||||
apply_preprocessor: false,
|
||||
concurrency_settings: ConcurrencySettings::default(),
|
||||
debouncing_settings: DebouncingSettings::default(), labels: None,
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
labels: None,
|
||||
})
|
||||
.run_until_complete(&db, false, server.addr.port())
|
||||
.await;
|
||||
|
||||
@@ -395,10 +395,7 @@ async fn test_root_job_span_created_on_success() {
|
||||
attrs.contains(&"script_path"),
|
||||
"missing script_path attribute"
|
||||
);
|
||||
assert!(
|
||||
attrs.contains(&"job_kind"),
|
||||
"missing job_kind attribute"
|
||||
);
|
||||
assert!(attrs.contains(&"job_kind"), "missing job_kind attribute");
|
||||
assert!(
|
||||
attrs.contains(&"created_by"),
|
||||
"missing created_by attribute"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
#[cfg(feature = "deno_core")]
|
||||
mod retry {
|
||||
use windmill_test_utils::*;
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use windmill_common::flow_status::FlowStatusModule;
|
||||
use windmill_common::flows::FlowValue;
|
||||
use windmill_common::jobs::JobPayload;
|
||||
use windmill_test_utils::*;
|
||||
|
||||
pub async fn initialize_tracing() {
|
||||
use std::sync::Once;
|
||||
|
||||
@@ -179,7 +179,8 @@ export async function main(path: string, email: string, job_id: string, is_flow:
|
||||
priority: None,
|
||||
apply_preprocessor: false,
|
||||
concurrency_settings: ConcurrencySettings::default(),
|
||||
debouncing_settings: DebouncingSettings::default(), labels: None,
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
labels: None,
|
||||
})
|
||||
.run_until_complete(&db, false, server.addr.port())
|
||||
.await;
|
||||
|
||||
@@ -247,7 +247,9 @@ mod suspend_resume {
|
||||
#[cfg(feature = "enterprise")]
|
||||
#[cfg(feature = "deno_core")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_self_approval_disabled_blocks_owner_resume(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
async fn test_self_approval_disabled_blocks_owner_resume(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
@@ -358,7 +360,9 @@ mod suspend_resume {
|
||||
#[cfg(feature = "enterprise")]
|
||||
#[cfg(feature = "deno_core")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_self_approval_allowed_when_not_disabled(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
async fn test_self_approval_allowed_when_not_disabled(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
@@ -462,7 +466,9 @@ mod suspend_resume {
|
||||
#[cfg(feature = "enterprise")]
|
||||
#[cfg(feature = "deno_core")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_different_user_can_approve_when_self_approval_disabled(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
async fn test_different_user_can_approve_when_self_approval_disabled(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
|
||||
@@ -1860,6 +1860,937 @@ async fn test_postgresql_100_jobs_cached(db: Pool<Postgres>) -> anyhow::Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cover the (Value × arg_t) combinations that #8988 broke. Each shape mirrors
|
||||
/// what the windmill-client SDK or a hand-written PG script can emit:
|
||||
///
|
||||
/// - bare `$N` with no inline cast and no `-- $N name (type)` declaration
|
||||
/// (parser defaults the otyp to "text"). The user's value can be any JSON
|
||||
/// shape; the eventual column type is whatever the SQL context implies.
|
||||
/// - inline `$N::TYPE` casts (the SDK's default for bare `${value}`).
|
||||
/// - `CAST($N AS T)` syntax (the SDK strips its own cast when this pattern
|
||||
/// surrounds the value, so the parser sees a bare `$N`).
|
||||
/// - explicit declaration: `-- $N name (type)`.
|
||||
///
|
||||
/// Pre-fix, the dispatch asserted `Type::TEXT` for parser-defaulted args, so
|
||||
/// e.g. a `Value::Bool` bound to `Box<bool>` failed at the encoder with
|
||||
/// "cannot convert between the Rust type `bool` and the Postgres type `text`"
|
||||
/// before the query ever reached the server.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
#[serial(pg_cache)]
|
||||
async fn test_postgresql_arg_type_combinations(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
use windmill_worker::pg_executor::clear_pg_cache;
|
||||
|
||||
initialize_tracing().await;
|
||||
clear_pg_cache().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let db_arg = json!({"host": "localhost", "port": 5432, "dbname": "windmill", "user": "postgres", "password": "changeme"});
|
||||
|
||||
// Build a fresh schema for this test so we don't trip over prior runs.
|
||||
let setup = r#"
|
||||
DROP SCHEMA IF EXISTS wm_pg_arg_combo_test CASCADE;
|
||||
CREATE SCHEMA wm_pg_arg_combo_test;
|
||||
CREATE TABLE wm_pg_arg_combo_test.bugbool (flag bool);
|
||||
CREATE TABLE wm_pg_arg_combo_test.sdkbug (n int, f double precision);
|
||||
CREATE TABLE wm_pg_arg_combo_test.bugmix (id int, name text, payload jsonb, tags text[]);
|
||||
CREATE TABLE wm_pg_arg_combo_test.allcols (
|
||||
c_bool bool,
|
||||
c_int2 smallint,
|
||||
c_int4 int,
|
||||
c_int8 bigint,
|
||||
c_float4 real,
|
||||
c_float8 double precision,
|
||||
c_numeric numeric,
|
||||
c_text text,
|
||||
c_varchar varchar(64),
|
||||
c_uuid uuid,
|
||||
c_date date,
|
||||
c_time time,
|
||||
c_ts timestamp,
|
||||
c_tstz timestamptz,
|
||||
c_json json,
|
||||
c_jsonb jsonb,
|
||||
c_int_arr int[],
|
||||
c_text_arr text[]
|
||||
);
|
||||
CREATE TYPE wm_pg_arg_combo_test.color AS ENUM ('red','green','blue');
|
||||
CREATE TABLE wm_pg_arg_combo_test.enumtbl (c wm_pg_arg_combo_test.color);
|
||||
"#;
|
||||
RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: setup.to_owned(),
|
||||
path: None,
|
||||
lock: None,
|
||||
language: ScriptLang::Postgresql,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
modules: None,
|
||||
}))
|
||||
.arg("database", db_arg.clone())
|
||||
.run_until_complete(&db, false, port)
|
||||
.await
|
||||
.json_result()
|
||||
.unwrap();
|
||||
|
||||
// Each case: (name, content, args, expected_result)
|
||||
let cases: Vec<(&str, String, serde_json::Value, serde_json::Value)> = vec![
|
||||
// === parser-default "text" otyp (bare $N), value drives the binding ===
|
||||
(
|
||||
"bool via CAST AS bool (parser default text)",
|
||||
"-- $1 arg1\nINSERT INTO wm_pg_arg_combo_test.bugbool VALUES (CAST($1 AS bool)) RETURNING flag".to_owned(),
|
||||
json!({"arg1": true}),
|
||||
json!([{"flag": true}]),
|
||||
),
|
||||
(
|
||||
"bare $1 with bool into bool col",
|
||||
"-- $1 arg1\nINSERT INTO wm_pg_arg_combo_test.bugbool VALUES ($1) RETURNING flag".to_owned(),
|
||||
json!({"arg1": false}),
|
||||
json!([{"flag": false}]),
|
||||
),
|
||||
(
|
||||
"bare $1 with bool into text via implicit cast bool->text",
|
||||
"-- $1 arg1\nSELECT $1::text AS s".to_owned(),
|
||||
json!({"arg1": true}),
|
||||
json!([{"s": "true"}]),
|
||||
),
|
||||
(
|
||||
"bare $1 with int into text via implicit cast int->text",
|
||||
"-- $1 arg1\nSELECT $1::text AS s".to_owned(),
|
||||
json!({"arg1": 42}),
|
||||
json!([{"s": "42"}]),
|
||||
),
|
||||
(
|
||||
"object via CAST AS jsonb (parser default text)",
|
||||
"-- $1 arg1\nSELECT CAST($1 AS jsonb) AS v".to_owned(),
|
||||
json!({"arg1": {"k": 1}}),
|
||||
json!([{"v": {"k": 1}}]),
|
||||
),
|
||||
(
|
||||
"string '42' via CAST AS int (parser default text)",
|
||||
"-- $1 arg1\nSELECT CAST($1 AS int) AS v".to_owned(),
|
||||
json!({"arg1": "42"}),
|
||||
json!([{"v": 42}]),
|
||||
),
|
||||
(
|
||||
"NULL into bool col via CAST",
|
||||
"-- $1 arg1\nINSERT INTO wm_pg_arg_combo_test.bugbool VALUES (CAST($1 AS bool)) RETURNING flag".to_owned(),
|
||||
json!({"arg1": null}),
|
||||
json!([{"flag": null}]),
|
||||
),
|
||||
// === SDK happy path — inline ::TYPE injected by the client ===
|
||||
(
|
||||
"SDK shape: $1::BIGINT, $2::DOUBLE PRECISION",
|
||||
"-- $1 arg1\n-- $2 arg2\nINSERT INTO wm_pg_arg_combo_test.sdkbug VALUES ($1::BIGINT, $2::DOUBLE PRECISION) RETURNING n, f".to_owned(),
|
||||
json!({"arg1": 42, "arg2": 3.14}),
|
||||
json!([{"n": 42, "f": 3.14}]),
|
||||
),
|
||||
(
|
||||
"SDK shape: $1::TEXT with int target via explicit ::int",
|
||||
"-- $1 arg1\nSELECT $1::TEXT::int AS v".to_owned(),
|
||||
json!({"arg1": "7"}),
|
||||
json!([{"v": 7}]),
|
||||
),
|
||||
// === explicit declaration -- $N name (type) ===
|
||||
(
|
||||
"explicit decl (text)",
|
||||
"-- $1 arg1 (text)\nSELECT $1 AS v".to_owned(),
|
||||
json!({"arg1": "hello"}),
|
||||
json!([{"v": "hello"}]),
|
||||
),
|
||||
(
|
||||
"explicit decl (jsonb) with object",
|
||||
"-- $1 arg1 (jsonb)\nSELECT $1 AS v".to_owned(),
|
||||
json!({"arg1": {"k": [1, 2]}}),
|
||||
json!([{"v": {"k": [1, 2]}}]),
|
||||
),
|
||||
// === mixed args: int, text, jsonb, text[] ===
|
||||
(
|
||||
"mixed: int + text + jsonb + text[]",
|
||||
"-- $1 arg1\n-- $2 arg2\n-- $3 arg3\n-- $4 arg4\nINSERT INTO wm_pg_arg_combo_test.bugmix VALUES ($1::int, $2::text, $3::jsonb, $4::text[]) RETURNING *".to_owned(),
|
||||
json!({"arg1": 7, "arg2": "hello", "arg3": {"k": 1}, "arg4": ["a", "b"]}),
|
||||
json!([{"id": 7, "name": "hello", "payload": {"k": 1}, "tags": ["a", "b"]}]),
|
||||
),
|
||||
|
||||
// === Every PG type, SDK happy path (inline ::TYPE) ===
|
||||
(
|
||||
"all PG types via inline casts",
|
||||
r#"-- $1 arg1
|
||||
-- $2 arg2
|
||||
-- $3 arg3
|
||||
-- $4 arg4
|
||||
-- $5 arg5
|
||||
-- $6 arg6
|
||||
-- $7 arg7
|
||||
-- $8 arg8
|
||||
-- $9 arg9
|
||||
-- $10 arg10
|
||||
-- $11 arg11
|
||||
-- $12 arg12
|
||||
-- $13 arg13
|
||||
-- $14 arg14
|
||||
-- $15 arg15
|
||||
-- $16 arg16
|
||||
-- $17 arg17
|
||||
-- $18 arg18
|
||||
INSERT INTO wm_pg_arg_combo_test.allcols VALUES (
|
||||
$1::bool, $2::int2, $3::int4, $4::int8,
|
||||
$5::real, $6::double precision, $7::numeric,
|
||||
$8::text, $9::varchar,
|
||||
$10::uuid, $11::date, $12::time, $13::timestamp, $14::timestamptz,
|
||||
$15::json, $16::jsonb,
|
||||
$17::int[], $18::text[]
|
||||
) RETURNING c_bool, c_int4, c_int8, c_text, c_uuid, c_int_arr"#.to_owned(),
|
||||
json!({
|
||||
"arg1": true, "arg2": 1, "arg3": 2, "arg4": 3,
|
||||
"arg5": 1.5, "arg6": 2.5, "arg7": 3.14,
|
||||
"arg8": "hello", "arg9": "varhello",
|
||||
"arg10": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"arg11": "2024-01-15", "arg12": "10:30:00",
|
||||
"arg13": "2024-01-15T10:30:00", "arg14": "2024-01-15T10:30:00Z",
|
||||
"arg15": {"k": 1}, "arg16": {"k": 2},
|
||||
"arg17": [1,2,3], "arg18": ["a","b"]
|
||||
}),
|
||||
json!([{"c_bool": true, "c_int4": 2, "c_int8": 3, "c_text": "hello",
|
||||
"c_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"c_int_arr": [1,2,3]}]),
|
||||
),
|
||||
|
||||
// === Every PG type, declaration-style otyp (Python SDK shape) ===
|
||||
(
|
||||
"all PG types via -- $N name (TYPE) decls",
|
||||
r#"-- $1 arg1 (bool)
|
||||
-- $2 arg2 (int2)
|
||||
-- $3 arg3 (int4)
|
||||
-- $4 arg4 (int8)
|
||||
-- $5 arg5 (real)
|
||||
-- $6 arg6 (float8)
|
||||
-- $7 arg7 (numeric)
|
||||
-- $8 arg8 (text)
|
||||
-- $9 arg9 (varchar)
|
||||
-- $10 arg10 (uuid)
|
||||
-- $11 arg11 (date)
|
||||
-- $12 arg12 (time)
|
||||
-- $13 arg13 (timestamp)
|
||||
-- $14 arg14 (timestamptz)
|
||||
-- $15 arg15 (json)
|
||||
-- $16 arg16 (jsonb)
|
||||
-- $17 arg17 (int[])
|
||||
-- $18 arg18 (text[])
|
||||
INSERT INTO wm_pg_arg_combo_test.allcols VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18
|
||||
) RETURNING c_bool, c_int4, c_int8, c_text, c_int_arr, c_text_arr"#.to_owned(),
|
||||
json!({
|
||||
"arg1": false, "arg2": 4, "arg3": 5, "arg4": 6,
|
||||
"arg5": 1.5, "arg6": 2.5, "arg7": 3.14,
|
||||
"arg8": "world", "arg9": "varworld",
|
||||
"arg10": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"arg11": "2024-01-15", "arg12": "10:30:00",
|
||||
"arg13": "2024-01-15T10:30:00", "arg14": "2024-01-15T10:30:00Z",
|
||||
"arg15": [1,2], "arg16": [3,4],
|
||||
"arg17": [10,20], "arg18": ["x","y"]
|
||||
}),
|
||||
json!([{"c_bool": false, "c_int4": 5, "c_int8": 6, "c_text": "world",
|
||||
"c_int_arr": [10,20], "c_text_arr": ["x","y"]}]),
|
||||
),
|
||||
|
||||
// === Edge values per type ===
|
||||
("int8 negative", "-- $1 arg1\nSELECT $1::int8 AS v".to_owned(), json!({"arg1": -42}), json!([{"v": -42}])),
|
||||
("int8 zero", "-- $1 arg1\nSELECT $1::int8 AS v".to_owned(), json!({"arg1": 0}), json!([{"v": 0}])),
|
||||
("int4 max", "-- $1 arg1\nSELECT $1::int4 AS v".to_owned(), json!({"arg1": 2147483647i64}), json!([{"v": 2147483647i64}])),
|
||||
("int8 max", "-- $1 arg1\nSELECT $1::int8 AS v".to_owned(), json!({"arg1": 9223372036854775807i64}), json!([{"v": 9223372036854775807i64}])),
|
||||
("float8 fraction", "-- $1 arg1\nSELECT $1::float8 AS v".to_owned(), json!({"arg1": 0.1 + 0.2}), json!([{"v": 0.1 + 0.2}])),
|
||||
("empty string", "-- $1 arg1\nSELECT $1::text AS v".to_owned(), json!({"arg1": ""}), json!([{"v": ""}])),
|
||||
("empty array", "-- $1 arg1\nSELECT $1::int[] AS v".to_owned(), json!({"arg1": []}), json!([{"v": []}])),
|
||||
("empty object", "-- $1 arg1\nSELECT $1::jsonb AS v".to_owned(), json!({"arg1": {}}), json!([{"v": {}}])),
|
||||
|
||||
// === Bool roundtrip across every shape ===
|
||||
("Bool/bare $1 → bool col", "-- $1 arg1\nSELECT $1 AS v".to_owned(), json!({"arg1": true}), json!([{"v": true}])),
|
||||
("Bool/inline ::bool", "-- $1 arg1\nSELECT $1::bool AS v".to_owned(), json!({"arg1": true}), json!([{"v": true}])),
|
||||
("Bool/decl (bool)", "-- $1 arg1 (bool)\nSELECT $1 AS v".to_owned(), json!({"arg1": true}), json!([{"v": true}])),
|
||||
("Bool/CAST AS bool", "-- $1 arg1\nSELECT CAST($1 AS bool) AS v".to_owned(), json!({"arg1": false}), json!([{"v": false}])),
|
||||
("Bool/CAST AS bool inside SELECT","-- $1 arg1\nSELECT CAST($1 AS bool) AS v WHERE CAST($1 AS bool) IS NOT NULL".to_owned(), json!({"arg1": true}), json!([{"v": true}])),
|
||||
|
||||
// === Object/Array via CAST AS jsonb (regression: non-text otyp via CAST) ===
|
||||
("Object via CAST AS jsonb", "-- $1 arg1\nSELECT CAST($1 AS jsonb) AS v".to_owned(), json!({"arg1": {"a":1,"b":[2,3]}}), json!([{"v": {"a":1,"b":[2,3]}}])),
|
||||
("Array via CAST AS jsonb", "-- $1 arg1\nSELECT CAST($1 AS jsonb) AS v".to_owned(), json!({"arg1": [1,"two",{"three":3}]}), json!([{"v": [1,"two",{"three":3}]}])),
|
||||
("Object inline ::json", "-- $1 arg1\nSELECT $1::json AS v".to_owned(), json!({"arg1": {"k":1}}), json!([{"v": {"k":1}}])),
|
||||
("Object decl (jsonb)", "-- $1 arg1 (jsonb)\nSELECT $1 AS v".to_owned(), json!({"arg1": {"k":1}}), json!([{"v": {"k":1}}])),
|
||||
|
||||
// === Numbers: implicit/explicit casts to text ===
|
||||
("Number/CAST AS text", "-- $1 arg1\nSELECT CAST($1 AS text) AS v".to_owned(), json!({"arg1": 42}), json!([{"v": "42"}])),
|
||||
("Number/inline ::text", "-- $1 arg1\nSELECT $1::text AS v".to_owned(), json!({"arg1": 42}), json!([{"v": "42"}])),
|
||||
("Float/CAST AS text", "-- $1 arg1\nSELECT CAST($1 AS text) AS v".to_owned(), json!({"arg1": 3.14}), json!([{"v": "3.14"}])),
|
||||
("Negative/CAST AS int8", "-- $1 arg1\nSELECT CAST($1 AS int8) AS v".to_owned(), json!({"arg1": -1}), json!([{"v": -1}])),
|
||||
|
||||
// === Strings parsed into typed targets ===
|
||||
("String '42' as int", "-- $1 arg1\nSELECT $1::int AS v".to_owned(), json!({"arg1": "42"}), json!([{"v": 42}])),
|
||||
("String '42' as bigint", "-- $1 arg1\nSELECT $1::bigint AS v".to_owned(), json!({"arg1": "42"}), json!([{"v": 42}])),
|
||||
("String '1.5' as real", "-- $1 arg1\nSELECT $1::real AS v".to_owned(), json!({"arg1": "1.5"}), json!([{"v": 1.5}])),
|
||||
("String uuid", "-- $1 arg1\nSELECT $1::uuid AS v".to_owned(), json!({"arg1": "550e8400-e29b-41d4-a716-446655440000"}), json!([{"v": "550e8400-e29b-41d4-a716-446655440000"}])),
|
||||
|
||||
// === NULL handling for every type ===
|
||||
("Null/bool", "-- $1 arg1\nSELECT CAST($1 AS bool) AS v".to_owned(), json!({"arg1": null}), json!([{"v": null}])),
|
||||
("Null/int", "-- $1 arg1\nSELECT CAST($1 AS int) AS v".to_owned(), json!({"arg1": null}), json!([{"v": null}])),
|
||||
("Null/bigint", "-- $1 arg1\nSELECT CAST($1 AS bigint) AS v".to_owned(), json!({"arg1": null}), json!([{"v": null}])),
|
||||
("Null/text", "-- $1 arg1\nSELECT $1::text AS v".to_owned(), json!({"arg1": null}), json!([{"v": null}])),
|
||||
("Null/jsonb", "-- $1 arg1\nSELECT CAST($1 AS jsonb) AS v".to_owned(), json!({"arg1": null}), json!([{"v": null}])),
|
||||
("Null/uuid", "-- $1 arg1\nSELECT CAST($1 AS uuid) AS v".to_owned(), json!({"arg1": null}), json!([{"v": null}])),
|
||||
("Null/timestamp", "-- $1 arg1\nSELECT CAST($1 AS timestamp) AS v".to_owned(), json!({"arg1": null}), json!([{"v": null}])),
|
||||
("Null/date", "-- $1 arg1\nSELECT CAST($1 AS date) AS v".to_owned(), json!({"arg1": null}), json!([{"v": null}])),
|
||||
|
||||
// === Multi-statement (datatable scripts often DROP/CREATE/INSERT) ===
|
||||
(
|
||||
"multi-statement: DROP/CREATE/INSERT",
|
||||
r#"DROP TABLE IF EXISTS wm_pg_arg_combo_test.tmp_multi;
|
||||
CREATE TABLE wm_pg_arg_combo_test.tmp_multi (n int, b bool);
|
||||
-- $1 arg1
|
||||
-- $2 arg2
|
||||
INSERT INTO wm_pg_arg_combo_test.tmp_multi VALUES ($1::int, $2::bool) RETURNING *"#.to_owned(),
|
||||
json!({"arg1": 10, "arg2": true}),
|
||||
json!([{"n": 10, "b": true}]),
|
||||
),
|
||||
|
||||
// === Same arg used in multiple positions (parser reorders) ===
|
||||
(
|
||||
"same arg twice",
|
||||
"-- $1 arg1\nSELECT $1::int + $1::int AS v".to_owned(),
|
||||
json!({"arg1": 5}),
|
||||
json!([{"v": 10}]),
|
||||
),
|
||||
|
||||
// === Custom enum (unrecognised arg_t → prepare fallback path) ===
|
||||
// The unrecognised-arg_t fallback works when the user formats the
|
||||
// value as the enum's text representation themselves, so the binding
|
||||
// never has to encode a Rust String *as* an enum (which the
|
||||
// tokio-postgres `ToSql` impls don't support):
|
||||
(
|
||||
"custom enum: text representation cast in SQL",
|
||||
r#"-- $1 arg1
|
||||
INSERT INTO wm_pg_arg_combo_test.enumtbl
|
||||
VALUES (CAST($1::text AS wm_pg_arg_combo_test.color))
|
||||
RETURNING c::text AS c"#
|
||||
.to_owned(),
|
||||
json!({"arg1": "green"}),
|
||||
json!([{"c": "green"}]),
|
||||
),
|
||||
|
||||
// === Sparse positional placeholders ($5, $50) ===
|
||||
// The pre-fix `String::replace($5 → $1)` chain mangled `$50` into
|
||||
// `$10`, breaking sparse-index queries. The regex-based renumbering
|
||||
// handles them as distinct units.
|
||||
(
|
||||
"sparse $5 / $50 renumbering",
|
||||
"-- $5 arg5\n-- $50 arg50\nSELECT $5::int AS a, $50::int AS b".to_owned(),
|
||||
json!({"arg5": 5, "arg50": 50}),
|
||||
json!([{"a": 5, "b": 50}]),
|
||||
),
|
||||
(
|
||||
"sparse $5 / $50 reversed in SQL",
|
||||
"-- $5 arg5\n-- $50 arg50\nSELECT $50::int AS a, $5::int AS b".to_owned(),
|
||||
json!({"arg5": 5, "arg50": 50}),
|
||||
json!([{"a": 50, "b": 5}]),
|
||||
),
|
||||
(
|
||||
"sparse same arg used twice + sparse",
|
||||
"-- $5 arg5\n-- $50 arg50\nSELECT $5::int + $5::int AS a, $50::int AS b".to_owned(),
|
||||
json!({"arg5": 7, "arg50": 50}),
|
||||
json!([{"a": 14, "b": 50}]),
|
||||
),
|
||||
|
||||
// === Explicit (text) decl + non-string value: should coerce ===
|
||||
// Without the otyp_inferred flag, the executor would bind the value's
|
||||
// natural type (INT8/BOOL) and the WHERE comparison `text = int8` /
|
||||
// `text = bool` would fail with "operator does not exist". With the
|
||||
// flag, the parser tells the executor "user committed to text" and
|
||||
// the value is JSON-stringified so `text = text` works.
|
||||
(
|
||||
"decl (text) + Number used in WHERE text comparison",
|
||||
r#"-- $1 arg1 (text)
|
||||
SELECT name FROM (VALUES ('42'::text)) AS t(name) WHERE name = $1"#
|
||||
.to_owned(),
|
||||
json!({"arg1": 42}),
|
||||
json!([{"name": "42"}]),
|
||||
),
|
||||
(
|
||||
"decl (text) + Bool used in WHERE text comparison",
|
||||
r#"-- $1 arg1 (text)
|
||||
SELECT name FROM (VALUES ('true'::text)) AS t(name) WHERE name = $1"#
|
||||
.to_owned(),
|
||||
json!({"arg1": true}),
|
||||
json!([{"name": "true"}]),
|
||||
),
|
||||
(
|
||||
"decl (varchar) + Number used in WHERE",
|
||||
r#"-- $1 arg1 (varchar)
|
||||
SELECT name FROM (VALUES ('99'::varchar)) AS t(name) WHERE name = $1"#
|
||||
.to_owned(),
|
||||
json!({"arg1": 99}),
|
||||
json!([{"name": "99"}]),
|
||||
),
|
||||
|
||||
// === Bare $N (parser-default text) + non-string value: bind native ===
|
||||
// The user wrote no annotation — we bind the value's natural type so
|
||||
// it works against whatever column the SQL eventually targets.
|
||||
(
|
||||
"bare $1 + Bool into bool col",
|
||||
"-- $1 arg1\nSELECT $1 = true AS v".to_owned(),
|
||||
json!({"arg1": true}),
|
||||
json!([{"v": true}]),
|
||||
),
|
||||
|
||||
// === Custom enum (Kind::Enum) — round-trip via AnyTextValue ===
|
||||
// Pre-fix this failed at the encoder ("cannot convert String → color")
|
||||
// because vanilla tokio_postgres' ToSql/FromSql for String reject
|
||||
// Kind::Enum. The wrapper accepts enum kinds in both directions.
|
||||
(
|
||||
"enum: explicit ::wm_pg_arg_combo_test.color cast",
|
||||
r#"-- $1 arg1
|
||||
INSERT INTO wm_pg_arg_combo_test.enumtbl
|
||||
VALUES ($1::wm_pg_arg_combo_test.color) RETURNING c"#
|
||||
.to_owned(),
|
||||
json!({"arg1": "blue"}),
|
||||
json!([{"c": "blue"}]),
|
||||
),
|
||||
(
|
||||
"enum: SELECT a literal value cast to enum",
|
||||
"-- $1 arg1\nSELECT $1::wm_pg_arg_combo_test.color AS c".to_owned(),
|
||||
json!({"arg1": "red"}),
|
||||
json!([{"c": "red"}]),
|
||||
),
|
||||
|
||||
// === Extended String→numeric/real/double/oid/bool arms (#10) ===
|
||||
(
|
||||
"String '3.14' → numeric",
|
||||
"-- $1 arg1\nSELECT $1::numeric AS v".to_owned(),
|
||||
json!({"arg1": "3.14"}),
|
||||
json!([{"v": 3.14}]),
|
||||
),
|
||||
(
|
||||
"String '1.5' → real",
|
||||
"-- $1 arg1\nSELECT $1::real AS v".to_owned(),
|
||||
json!({"arg1": "1.5"}),
|
||||
json!([{"v": 1.5}]),
|
||||
),
|
||||
(
|
||||
"String '2.5' → double",
|
||||
"-- $1 arg1\nSELECT $1::double precision AS v".to_owned(),
|
||||
json!({"arg1": "2.5"}),
|
||||
json!([{"v": 2.5}]),
|
||||
),
|
||||
(
|
||||
"String 'true' → bool",
|
||||
"-- $1 arg1\nSELECT $1::bool AS v".to_owned(),
|
||||
json!({"arg1": "true"}),
|
||||
json!([{"v": true}]),
|
||||
),
|
||||
(
|
||||
"String 't' → bool",
|
||||
"-- $1 arg1\nSELECT $1::bool AS v".to_owned(),
|
||||
json!({"arg1": "t"}),
|
||||
json!([{"v": true}]),
|
||||
),
|
||||
(
|
||||
"String '0' → bool false",
|
||||
"-- $1 arg1\nSELECT $1::bool AS v".to_owned(),
|
||||
json!({"arg1": "0"}),
|
||||
json!([{"v": false}]),
|
||||
),
|
||||
(
|
||||
"String '42' → oid",
|
||||
"-- $1 arg1\nSELECT $1::oid AS v".to_owned(),
|
||||
json!({"arg1": "42"}),
|
||||
json!([{"v": 42}]),
|
||||
),
|
||||
|
||||
// === String literals containing $N must NOT be renumbered ===
|
||||
// Sparse positional args force a rewrite pass; the literal
|
||||
// `'price: $5'` and the comment `-- mention $5` must survive intact.
|
||||
(
|
||||
"renumber must skip $N inside string literal",
|
||||
r#"-- $5 arg5
|
||||
-- $50 arg50
|
||||
SELECT 'price: $5' AS lbl, $5::int + $50::int AS sum"#
|
||||
.to_owned(),
|
||||
json!({"arg5": 1, "arg50": 2}),
|
||||
json!([{"lbl": "price: $5", "sum": 3}]),
|
||||
),
|
||||
|
||||
// === Multi-word PG type names with [] array suffix ===
|
||||
// Pre-fix: `transform_types_with_spaces` returned a `&str` alias
|
||||
// ("double" / "varchar" / "timestamptz" / …) and dropped the trailing
|
||||
// `[]`, so the dispatch routed `Value::Array` through `Type::JSONB`
|
||||
// and the server failed with "cannot cast type jsonb to <T>[]".
|
||||
(
|
||||
"multi-word array: double precision[]",
|
||||
"-- $1 a\nSELECT $1::double precision[] AS v".to_owned(),
|
||||
json!({"a": [1.5, 2.5]}),
|
||||
json!([{"v": [1.5, 2.5]}]),
|
||||
),
|
||||
(
|
||||
"multi-word array: character varying[]",
|
||||
"-- $1 a\nSELECT $1::character varying[] AS v".to_owned(),
|
||||
json!({"a": ["x", "y"]}),
|
||||
json!([{"v": ["x", "y"]}]),
|
||||
),
|
||||
(
|
||||
"multi-word array: timestamp without time zone[]",
|
||||
"-- $1 a\nSELECT $1::timestamp without time zone[] AS v".to_owned(),
|
||||
json!({"a": ["2024-01-15T10:30:00"]}),
|
||||
json!([{"v": ["2024-01-15T10:30:00"]}]),
|
||||
),
|
||||
|
||||
// === Stringified primitives in array args ===
|
||||
// Mirror the scalar `Value::String → <numeric type>` arms so values
|
||||
// sent as e.g. `["1.5", "2.5"]` for `numeric[]` (typical for bulk-
|
||||
// loading via `unnest` or BigInt-stringified arrays) round-trip
|
||||
// instead of erroring with "Mixed types in array".
|
||||
(
|
||||
"numeric[] from stringified decimals",
|
||||
"-- $1 a\nSELECT $1::numeric[] AS v".to_owned(),
|
||||
json!({"a": ["1.5", "2.5"]}),
|
||||
json!([{"v": [1.5, 2.5]}]),
|
||||
),
|
||||
(
|
||||
"int[] from stringified ints",
|
||||
"-- $1 a\nSELECT $1::int[] AS v".to_owned(),
|
||||
json!({"a": ["1", "2", "3"]}),
|
||||
json!([{"v": [1, 2, 3]}]),
|
||||
),
|
||||
(
|
||||
"bool[] from stringified bools",
|
||||
"-- $1 a\nSELECT $1::bool[] AS v".to_owned(),
|
||||
json!({"a": ["true", "f", "yes"]}),
|
||||
json!([{"v": [true, false, true]}]),
|
||||
),
|
||||
];
|
||||
|
||||
for (name, content, args, expected) in cases {
|
||||
let mut job = RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content,
|
||||
path: None,
|
||||
lock: None,
|
||||
language: ScriptLang::Postgresql,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
)
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
modules: None,
|
||||
}))
|
||||
.arg("database", db_arg.clone());
|
||||
for (k, v) in args.as_object().unwrap() {
|
||||
job = job.arg(k, v.clone());
|
||||
}
|
||||
let result = job
|
||||
.run_until_complete(&db, false, port)
|
||||
.await
|
||||
.json_result()
|
||||
.unwrap_or_else(|| panic!("case '{name}': no json result"));
|
||||
assert_eq!(result, expected, "case '{name}' mismatch");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pooler-safety regression test for #8988: when every arg has a resolvable
|
||||
/// otyp (the common SDK case), the dispatch must use unnamed prepared
|
||||
/// statements (`query_typed_raw`) and *must not* leak named statements
|
||||
/// (`s0, s1, ...`) on the cached connection. Behind a transaction-mode pooler
|
||||
/// (PgBouncer / Supabase pooler / RDS Proxy), accumulated names get dropped
|
||||
/// when the prepare and execute land on different backend connections, which
|
||||
/// is what produced the original "prepared statement \"sN\" does not exist"
|
||||
/// errors.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
#[serial(pg_cache)]
|
||||
async fn test_postgresql_no_named_statements_after_typed_args(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
use windmill_worker::pg_executor::clear_pg_cache;
|
||||
|
||||
initialize_tracing().await;
|
||||
clear_pg_cache().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let db_arg = json!({"host": "localhost", "port": 5432, "dbname": "windmill", "user": "postgres", "password": "changeme"});
|
||||
|
||||
let make_pg_job = |content: String| {
|
||||
RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content,
|
||||
path: None,
|
||||
lock: None,
|
||||
language: ScriptLang::Postgresql,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
)
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
modules: None,
|
||||
}))
|
||||
.arg("database", db_arg.clone())
|
||||
};
|
||||
|
||||
// Run several SDK-shape queries (parameterised, all args resolvable) on the
|
||||
// same cached connection. None of them should land in `pg_prepared_statements`.
|
||||
for i in 0..5 {
|
||||
let result = make_pg_job(format!(
|
||||
"-- $1 arg1\n-- $2 arg2\nSELECT $1::BIGINT AS a, $2::TEXT AS b, {} AS i;",
|
||||
i
|
||||
))
|
||||
.arg("arg1", json!(i))
|
||||
.arg("arg2", json!(format!("v{i}")))
|
||||
.run_until_complete(&db, false, port)
|
||||
.await
|
||||
.json_result()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
json!([{"a": i, "b": format!("v{i}"), "i": i}]),
|
||||
"iteration {i}"
|
||||
);
|
||||
}
|
||||
|
||||
// Use the *same* connection (cached one) to peek at pg_prepared_statements.
|
||||
// Anything matching the tokio-postgres "s\d+" naming would mean the
|
||||
// pooler-unsafe `prepare + query_raw` path was taken.
|
||||
let probe = make_pg_job(
|
||||
"SELECT count(*)::int AS n FROM pg_prepared_statements WHERE name ~ '^s[0-9]+$'".to_owned(),
|
||||
)
|
||||
.run_until_complete(&db, false, port)
|
||||
.await
|
||||
.json_result()
|
||||
.unwrap();
|
||||
let n = probe[0]["n"].as_i64().unwrap_or(-1);
|
||||
assert_eq!(
|
||||
n, 0,
|
||||
"expected no leaked named prepared statements, found {n}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Exercise the `prepare + query_raw` fallback path. The dispatch takes this
|
||||
/// path when `otyp_to_pg_type` doesn't recognise the parser-derived arg_t —
|
||||
/// typical for custom enums, domains, and extension types.
|
||||
///
|
||||
/// Vanilla `tokio_postgres`'s `ToSql for String` doesn't actually accept
|
||||
/// `Kind::Enum` / `Kind::Domain`, so an end-to-end happy-path test of an
|
||||
/// arbitrary custom type isn't possible without `postgres-derive`. What we
|
||||
/// CAN lock in here is *which dispatch path runs*: when the arg_t is
|
||||
/// unrecognised, the prepare path must be taken (the server resolves the
|
||||
/// param type from the cast and the binding then errors at the encoder).
|
||||
/// If a regression accidentally routes unrecognised types through
|
||||
/// `query_typed_raw`, the failure mode flips: instead of "cannot convert
|
||||
/// `String` to `<custom_type>`" we'd see "cannot convert `String` to
|
||||
/// `text`" (because we'd assert TEXT). The error-text check below catches
|
||||
/// that flip.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
#[serial(pg_cache)]
|
||||
async fn test_postgresql_prepare_fallback_for_unrecognised_arg_t(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
use windmill_worker::pg_executor::clear_pg_cache;
|
||||
|
||||
initialize_tracing().await;
|
||||
clear_pg_cache().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let db_arg = json!({"host": "localhost", "port": 5432, "dbname": "windmill", "user": "postgres", "password": "changeme"});
|
||||
|
||||
let make_pg_job = |content: String| {
|
||||
RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content,
|
||||
path: None,
|
||||
lock: None,
|
||||
language: ScriptLang::Postgresql,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
)
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
modules: None,
|
||||
}))
|
||||
.arg("database", db_arg.clone())
|
||||
};
|
||||
|
||||
// Set up a custom enum in a dedicated schema so the test is self-contained.
|
||||
let setup = r#"
|
||||
DROP SCHEMA IF EXISTS fallback_test CASCADE;
|
||||
CREATE SCHEMA fallback_test;
|
||||
CREATE TYPE fallback_test.color AS ENUM ('red', 'green', 'blue');
|
||||
"#;
|
||||
make_pg_job(setup.to_owned())
|
||||
.run_until_complete(&db, false, port)
|
||||
.await
|
||||
.json_result()
|
||||
.unwrap();
|
||||
|
||||
// Inline cast `::fallback_test.color` — the parser only captures
|
||||
// `fallback_test` (regex stops at the dot), which otyp_to_pg_type won't
|
||||
// recognise. Dispatch must take the prepare fallback path.
|
||||
//
|
||||
// Thanks to the AnyTextValue ToSql/FromSql wrapper, this case now
|
||||
// round-trips end-to-end (the wrapper accepts Kind::Enum on both
|
||||
// directions). Pre-fix, vanilla tokio_postgres rejected String → color
|
||||
// and the user had to write `CAST($1::text AS color)` as a workaround.
|
||||
let result = make_pg_job("-- $1 arg1\nSELECT $1::fallback_test.color AS c".to_owned())
|
||||
.arg("arg1", json!("red"))
|
||||
.run_until_complete(&db, false, port)
|
||||
.await
|
||||
.json_result()
|
||||
.unwrap();
|
||||
assert_eq!(result, json!([{"c": "red"}]));
|
||||
|
||||
// Reading enum columns also goes through AnyTextValue's FromSql impl on
|
||||
// the result side, so the value comes back as a JSON string.
|
||||
let result = make_pg_job(
|
||||
r#"-- $1 arg1
|
||||
SELECT $1::fallback_test.color AS c1,
|
||||
'green'::fallback_test.color AS c2"#
|
||||
.to_owned(),
|
||||
)
|
||||
.arg("arg1", json!("blue"))
|
||||
.run_until_complete(&db, false, port)
|
||||
.await
|
||||
.json_result()
|
||||
.unwrap();
|
||||
assert_eq!(result, json!([{"c1": "blue", "c2": "green"}]));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Regression: custom enum / domain queries must not error with `prepared
|
||||
/// statement "sN" does not exist` when run on a cached connection.
|
||||
///
|
||||
/// Root cause: when we used `DISCARD ALL` to reset the cached connection
|
||||
/// between jobs, the included `DEALLOCATE ALL` deallocated *every* prepared
|
||||
/// statement server-side — including the typeinfo statements that
|
||||
/// tokio_postgres caches per-client to resolve custom-type Oids. The Rust
|
||||
/// client still held `Statement` objects whose names the server had
|
||||
/// forgotten, so the next custom-type query failed.
|
||||
///
|
||||
/// Fix: switched the cached-connection probe to `RESET ALL; UNLISTEN *;
|
||||
/// CLOSE ALL;` which covers windmill's session-isolation needs (GUC reset,
|
||||
/// listen channels, open cursors) without nuking the prepared-statement
|
||||
/// cache. This test runs the failing pattern (enum query → domain query on
|
||||
/// the same cached connection, several times) to lock the behaviour in.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
#[serial(pg_cache)]
|
||||
async fn test_postgresql_custom_types_on_cached_connection(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
use windmill_worker::pg_executor::clear_pg_cache;
|
||||
|
||||
initialize_tracing().await;
|
||||
clear_pg_cache().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let db_arg = json!({"host": "localhost", "port": 5432, "dbname": "windmill", "user": "postgres", "password": "changeme"});
|
||||
|
||||
let make_pg_job = |content: String| {
|
||||
RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content,
|
||||
path: None,
|
||||
lock: None,
|
||||
language: ScriptLang::Postgresql,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
)
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
modules: None,
|
||||
}))
|
||||
.arg("database", db_arg.clone())
|
||||
};
|
||||
|
||||
// Set up custom enum + domain types in a dedicated schema (avoid the
|
||||
// `pg_*` reserved prefix for user schemas).
|
||||
let setup = r#"
|
||||
DROP SCHEMA IF EXISTS wm_pg_cached_test CASCADE;
|
||||
CREATE SCHEMA wm_pg_cached_test;
|
||||
CREATE TYPE wm_pg_cached_test.color AS ENUM ('red', 'green', 'blue');
|
||||
CREATE DOMAIN wm_pg_cached_test.short_name AS TEXT CHECK (length(VALUE) BETWEEN 1 AND 10);
|
||||
"#;
|
||||
make_pg_job(setup.to_owned())
|
||||
.run_until_complete(&db, false, port)
|
||||
.await
|
||||
.json_result()
|
||||
.unwrap();
|
||||
|
||||
// Run a long alternating sequence of enum + domain queries on the same
|
||||
// cached connection. Each query goes through the prepare-fallback path
|
||||
// (otyp_to_pg_type doesn't recognise these custom-type names) and needs
|
||||
// tokio_postgres' typeinfo cache to resolve the Oids server-side. Pre-fix
|
||||
// this would fail intermittently with `prepared statement "sN" does not
|
||||
// exist` after the first cached-conn reuse.
|
||||
for i in 0..10 {
|
||||
let r = make_pg_job("-- $1 arg1\nSELECT $1::wm_pg_cached_test.color AS c".to_owned())
|
||||
.arg("arg1", json!(if i % 2 == 0 { "red" } else { "blue" }))
|
||||
.run_until_complete(&db, false, port)
|
||||
.await;
|
||||
assert!(
|
||||
r.success,
|
||||
"iter {i} enum query failed (was the DISCARD ALL bug); result: {:?}",
|
||||
r.result
|
||||
);
|
||||
|
||||
let r = make_pg_job("-- $1 arg1\nSELECT $1::wm_pg_cached_test.short_name AS s".to_owned())
|
||||
.arg("arg1", json!(format!("v{i}")))
|
||||
.run_until_complete(&db, false, port)
|
||||
.await;
|
||||
assert!(
|
||||
r.success,
|
||||
"iter {i} domain query failed (was the DISCARD ALL bug); result: {:?}",
|
||||
r.result
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Security regression: a previous job that did `SET ROLE` or `SET SESSION
|
||||
/// AUTHORIZATION` to a different role must not leak that role into the next
|
||||
/// job that reuses the cached connection.
|
||||
///
|
||||
/// This is the case `RESET ALL` alone does *not* cover — neither SET ROLE
|
||||
/// nor SET SESSION AUTHORIZATION are GUC parameters, so they survive
|
||||
/// `RESET ALL`. We rely on `RESET SESSION AUTHORIZATION` (which subsumes
|
||||
/// `RESET ROLE`) explicitly being part of the cached-connection probe.
|
||||
///
|
||||
/// The pre-existing `test_postgresql_single_worker_session_isolation` test
|
||||
/// only did `SET ROLE postgres` (the connecting user), so the leak was
|
||||
/// invisible — this test catches it by switching to a *different* role.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
#[serial(pg_cache)]
|
||||
async fn test_postgresql_set_role_does_not_leak_across_cached_connection(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
use windmill_worker::pg_executor::clear_pg_cache;
|
||||
|
||||
initialize_tracing().await;
|
||||
clear_pg_cache().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let db_arg = json!({"host": "localhost", "port": 5432, "dbname": "windmill", "user": "postgres", "password": "changeme"});
|
||||
|
||||
let make_pg_job = |content: String| {
|
||||
RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content,
|
||||
path: None,
|
||||
lock: None,
|
||||
language: ScriptLang::Postgresql,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
)
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
modules: None,
|
||||
}))
|
||||
.arg("database", db_arg.clone())
|
||||
};
|
||||
|
||||
// Create a non-postgres role to switch to. Idempotent so the test survives
|
||||
// re-runs against the same DB.
|
||||
make_pg_job(
|
||||
"DO $$ BEGIN \
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'wm_isolation_test_role') THEN \
|
||||
CREATE ROLE wm_isolation_test_role; \
|
||||
END IF; \
|
||||
END $$"
|
||||
.to_owned(),
|
||||
)
|
||||
.run_until_complete(&db, false, port)
|
||||
.await
|
||||
.json_result()
|
||||
.unwrap();
|
||||
|
||||
// Job 1: SET ROLE to a different role.
|
||||
let r1 = make_pg_job(
|
||||
"SET ROLE wm_isolation_test_role; SELECT current_user AS cu, session_user AS su".to_owned(),
|
||||
)
|
||||
.run_until_complete(&db, false, port)
|
||||
.await
|
||||
.json_result()
|
||||
.unwrap();
|
||||
assert_eq!(r1[0]["cu"], "wm_isolation_test_role");
|
||||
assert_eq!(r1[0]["su"], "postgres");
|
||||
|
||||
// Job 2 (cached connection reuse): role MUST be back to the connecting
|
||||
// user. Pre-fix with `RESET ALL` alone, this would still see
|
||||
// `wm_isolation_test_role` because RESET ALL doesn't cover SET ROLE.
|
||||
let r2 = make_pg_job("SELECT current_user AS cu, session_user AS su".to_owned())
|
||||
.run_until_complete(&db, false, port)
|
||||
.await
|
||||
.json_result()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
r2[0]["cu"], "postgres",
|
||||
"SET ROLE leaked across cached connection: current_user is {}",
|
||||
r2[0]["cu"]
|
||||
);
|
||||
|
||||
// Job 3: SET SESSION AUTHORIZATION (changes both current_user and
|
||||
// session_user — RESET ALL does NOT touch this either).
|
||||
let r3 = make_pg_job(
|
||||
"SET SESSION AUTHORIZATION wm_isolation_test_role; \
|
||||
SELECT current_user AS cu, session_user AS su"
|
||||
.to_owned(),
|
||||
)
|
||||
.run_until_complete(&db, false, port)
|
||||
.await
|
||||
.json_result()
|
||||
.unwrap();
|
||||
assert_eq!(r3[0]["cu"], "wm_isolation_test_role");
|
||||
assert_eq!(r3[0]["su"], "wm_isolation_test_role");
|
||||
|
||||
// Job 4 (cached): both must be restored to the connecting user.
|
||||
let r4 = make_pg_job("SELECT current_user AS cu, session_user AS su".to_owned())
|
||||
.run_until_complete(&db, false, port)
|
||||
.await
|
||||
.json_result()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
r4[0]["cu"], "postgres",
|
||||
"SET SESSION AUTHORIZATION leaked across cached connection: current_user is {}",
|
||||
r4[0]["cu"]
|
||||
);
|
||||
assert_eq!(
|
||||
r4[0]["su"], "postgres",
|
||||
"SET SESSION AUTHORIZATION leaked across cached connection: session_user is {}",
|
||||
r4[0]["su"]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "mysql")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_mysql_job(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
mod workspace_dependencies {
|
||||
|
||||
use windmill_test_utils::in_test_worker;
|
||||
use windmill_test_utils::init_client;
|
||||
use windmill_test_utils::listen_for_completed_jobs;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use tokio_stream::StreamExt;
|
||||
use windmill_common::scripts::ScriptLang;
|
||||
use windmill_dep_map::workspace_dependencies::NewWorkspaceDependencies;
|
||||
use windmill_test_utils::in_test_worker;
|
||||
use windmill_test_utils::init_client;
|
||||
use windmill_test_utils::listen_for_completed_jobs;
|
||||
mod deps {
|
||||
pub const REQUIREMENTS_IN: &'static str = "tiny==0.1.3";
|
||||
// pub const GO_MOD: &'static str = r##"
|
||||
|
||||
@@ -371,11 +371,7 @@ pub struct AnthropicQueryBuilder {
|
||||
}
|
||||
|
||||
impl AnthropicQueryBuilder {
|
||||
pub fn new(
|
||||
provider_kind: AIProvider,
|
||||
platform: AIPlatform,
|
||||
enable_1m_context: bool,
|
||||
) -> Self {
|
||||
pub fn new(provider_kind: AIProvider, platform: AIPlatform, enable_1m_context: bool) -> Self {
|
||||
Self { provider_kind, platform, enable_1m_context }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use async_trait::async_trait;
|
||||
use windmill_ai::ai_google::{
|
||||
openai_messages_to_gemini, openai_tools_to_gemini, GeminiGenerationConfig,
|
||||
GeminiImageContent, GeminiImageRequest, GeminiImageResponse, GeminiInlineData, GeminiPart,
|
||||
GeminiPredictContent, GeminiTextRequest, GeminiTool,
|
||||
openai_messages_to_gemini, openai_tools_to_gemini, GeminiGenerationConfig, GeminiImageContent,
|
||||
GeminiImageRequest, GeminiImageResponse, GeminiInlineData, GeminiPart, GeminiPredictContent,
|
||||
GeminiTextRequest, GeminiTool,
|
||||
};
|
||||
use windmill_common::{client::AuthedClient, error::Error};
|
||||
|
||||
@@ -135,7 +135,10 @@ impl GoogleAIQueryBuilder {
|
||||
openai_tools_to_gemini(tool_defs, &tool_params, has_websearch)
|
||||
}
|
||||
|
||||
fn build_generation_config(&self, args: &BuildRequestArgs<'_>) -> Option<GeminiGenerationConfig> {
|
||||
fn build_generation_config(
|
||||
&self,
|
||||
args: &BuildRequestArgs<'_>,
|
||||
) -> Option<GeminiGenerationConfig> {
|
||||
let has_output_schema = args
|
||||
.output_schema
|
||||
.and_then(|s| s.properties.as_ref())
|
||||
@@ -145,7 +148,10 @@ impl GoogleAIQueryBuilder {
|
||||
let (response_mime_type, response_schema) = if has_output_schema {
|
||||
let mut schema = args.output_schema.unwrap().clone();
|
||||
schema.sanitize_for_google();
|
||||
(Some("application/json".to_string()), serde_json::to_value(&schema).ok())
|
||||
(
|
||||
Some("application/json".to_string()),
|
||||
serde_json::to_value(&schema).ok(),
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
@@ -253,7 +259,11 @@ impl QueryBuilder for GoogleAIQueryBuilder {
|
||||
});
|
||||
|
||||
Ok(ParsedResponse::Text {
|
||||
content: if accumulated_content.is_empty() { None } else { Some(accumulated_content) },
|
||||
content: if accumulated_content.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(accumulated_content)
|
||||
},
|
||||
tool_calls: accumulated_tool_calls.into_values().collect(),
|
||||
events_str: Some(events_str),
|
||||
annotations,
|
||||
@@ -271,8 +281,11 @@ impl QueryBuilder for GoogleAIQueryBuilder {
|
||||
format!("{}/{}:streamGenerateContent?alt=sse", base_url, model)
|
||||
}
|
||||
OutputType::Image => {
|
||||
let url_suffix =
|
||||
if model.contains("imagen") { "predict" } else { "generateContent" };
|
||||
let url_suffix = if model.contains("imagen") {
|
||||
"predict"
|
||||
} else {
|
||||
"generateContent"
|
||||
};
|
||||
format!("{}/{}:{}", base_url, model, url_suffix)
|
||||
}
|
||||
}
|
||||
@@ -280,11 +293,17 @@ impl QueryBuilder for GoogleAIQueryBuilder {
|
||||
// Standard Google AI: base_url is generativelanguage.googleapis.com/v1beta
|
||||
match output_type {
|
||||
OutputType::Text => {
|
||||
format!("{}/models/{}:streamGenerateContent?alt=sse", base_url, model)
|
||||
format!(
|
||||
"{}/models/{}:streamGenerateContent?alt=sse",
|
||||
base_url, model
|
||||
)
|
||||
}
|
||||
OutputType::Image => {
|
||||
let url_suffix =
|
||||
if model.contains("imagen") { "predict" } else { "generateContent" };
|
||||
let url_suffix = if model.contains("imagen") {
|
||||
"predict"
|
||||
} else {
|
||||
"generateContent"
|
||||
};
|
||||
format!("{}/models/{}:{}", base_url, model, url_suffix)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,12 +242,10 @@ fn convert_content_to_responses_format(
|
||||
image_url: image_url.url.clone(),
|
||||
})
|
||||
}
|
||||
ContentPart::File { file } => {
|
||||
Some(ImageGenerationContent::InputFile {
|
||||
filename: file.filename.clone(),
|
||||
file_data: file.file_data.clone(),
|
||||
})
|
||||
}
|
||||
ContentPart::File { file } => Some(ImageGenerationContent::InputFile {
|
||||
filename: file.filename.clone(),
|
||||
file_data: file.file_data.clone(),
|
||||
}),
|
||||
// S3 objects should have been resolved earlier, but handle gracefully
|
||||
ContentPart::S3Object { .. } => None,
|
||||
})
|
||||
@@ -433,8 +431,7 @@ impl OpenAIQueryBuilder {
|
||||
if let Some(attachments) = args.attachments {
|
||||
for attachment in attachments.iter() {
|
||||
if !attachment.s3.is_empty() {
|
||||
let part =
|
||||
s3_object_to_content_part(attachment, client, workspace_id).await?;
|
||||
let part = s3_object_to_content_part(attachment, client, workspace_id).await?;
|
||||
match part {
|
||||
ContentPart::File { file } => {
|
||||
content.push(ImageGenerationContent::InputFile {
|
||||
|
||||
@@ -24,9 +24,9 @@ pub fn create_query_builder(provider: &ProviderWithResource) -> Box<dyn QueryBui
|
||||
use windmill_ai::ai_providers::AIProvider;
|
||||
|
||||
match provider.kind {
|
||||
AIProvider::GoogleAI => Box::new(GoogleAIQueryBuilder::new(
|
||||
provider.get_platform().clone(),
|
||||
)),
|
||||
AIProvider::GoogleAI => {
|
||||
Box::new(GoogleAIQueryBuilder::new(provider.get_platform().clone()))
|
||||
}
|
||||
AIProvider::OpenAI => Box::new(OpenAIQueryBuilder::new(provider.kind.clone())),
|
||||
AIProvider::Anthropic => Box::new(AnthropicQueryBuilder::new(
|
||||
provider.kind.clone(),
|
||||
|
||||
@@ -9,10 +9,7 @@ use windmill_ai::{
|
||||
ai_google::{parse_gemini_sse_event, GeminiUsageMetadata},
|
||||
ai_types::{ExtraContent, GoogleExtraContent, OpenAIFunction, OpenAIToolCall},
|
||||
};
|
||||
use windmill_common::{
|
||||
error::Error,
|
||||
utils::rd_string,
|
||||
};
|
||||
use windmill_common::{error::Error, utils::rd_string};
|
||||
|
||||
use crate::ai::{
|
||||
query_builder::StreamEventSink,
|
||||
@@ -502,7 +499,10 @@ impl SSEParser for GeminiSSEParser {
|
||||
if let Some(text) = parsed.text {
|
||||
self.accumulated_content.push_str(&text);
|
||||
self.stream_event_processor
|
||||
.send(StreamingEvent::TokenDelta { content: text }, &mut self.events_str)
|
||||
.send(
|
||||
StreamingEvent::TokenDelta { content: text },
|
||||
&mut self.events_str,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -390,7 +390,9 @@ pub async fn par_install_language_dependencies_seq<
|
||||
_platform_agnostic: bool,
|
||||
concurrent_downloads: usize,
|
||||
callback: impl Fn(RequiredDependency<T>) -> Result<Command, error::Error> + Send + Sync + 'static,
|
||||
post_install: Option<Arc<dyn Fn(&RequiredDependency<T>) -> anyhow::Result<()> + Send + Sync + 'static>>,
|
||||
post_install: Option<
|
||||
Arc<dyn Fn(&RequiredDependency<T>) -> anyhow::Result<()> + Send + Sync + 'static>,
|
||||
>,
|
||||
job_id: &'a Uuid,
|
||||
w_id: &'a str,
|
||||
worker_name: &'a str,
|
||||
@@ -448,13 +450,8 @@ pub async fn par_install_language_dependencies_seq<
|
||||
}
|
||||
|
||||
if is_layered && offset > 0 {
|
||||
windmill_queue::append_logs(
|
||||
job_id,
|
||||
w_id,
|
||||
format!("\n\n--- Layer {} ---", i + 1),
|
||||
conn,
|
||||
)
|
||||
.await;
|
||||
windmill_queue::append_logs(job_id, w_id, format!("\n\n--- Layer {} ---", i + 1), conn)
|
||||
.await;
|
||||
}
|
||||
|
||||
let layer_size = layer_deps.len();
|
||||
@@ -625,7 +622,9 @@ async fn spawn_wrapped_installation_threads<
|
||||
_platform_agnostic: bool,
|
||||
counter_offset: Option<usize>,
|
||||
total_override: Option<usize>,
|
||||
post_install: Option<Arc<dyn Fn(&RequiredDependency<T>) -> anyhow::Result<()> + Send + Sync + 'static>>,
|
||||
post_install: Option<
|
||||
Arc<dyn Fn(&RequiredDependency<T>) -> anyhow::Result<()> + Send + Sync + 'static>,
|
||||
>,
|
||||
) -> anyhow::Result<(
|
||||
Vec<JoinHandle<anyhow::Result<TaskKiller>>>,
|
||||
tokio::sync::broadcast::Sender<()>,
|
||||
@@ -772,7 +771,9 @@ async fn try_install_one_detached<'a, T: Clone + std::marker::Send + Sync + 'a +
|
||||
// If dropped the entire installation fails and all installation threads are being stopped
|
||||
// That's why we just pass it to return so it is not being dropped
|
||||
kill_all_tasks: TaskKiller,
|
||||
post_install: Option<Arc<dyn Fn(&RequiredDependency<T>) -> anyhow::Result<()> + Send + Sync + 'static>>,
|
||||
post_install: Option<
|
||||
Arc<dyn Fn(&RequiredDependency<T>) -> anyhow::Result<()> + Send + Sync + 'static>,
|
||||
>,
|
||||
) -> anyhow::Result<TaskKiller> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
|
||||
+165
-58
@@ -153,6 +153,94 @@ function ducklakeProvider(name: string): SqlProvider {
|
||||
// Shared template function builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Build a ready-to-execute SqlStatement. Used by both the template-tag
|
||||
// path (which builds `content` from strings/values) and `.query()` (which
|
||||
// gets a hand-written SQL string with positional placeholders).
|
||||
function buildSqlStatement(
|
||||
provider: SqlProvider,
|
||||
content: string,
|
||||
contentBody: string,
|
||||
args: Record<string, any>
|
||||
): SqlStatement<any> {
|
||||
async function fetch<ResultCollectionT extends ResultCollection>({
|
||||
resultCollection,
|
||||
}: FetchParams<ResultCollectionT> = {}) {
|
||||
let finalContent = content;
|
||||
if (resultCollection)
|
||||
finalContent = `-- result_collection=${resultCollection}\n${finalContent}`;
|
||||
try {
|
||||
let result;
|
||||
if (workerHasInternalServer()) {
|
||||
result = await JobService.runScriptPreviewInline({
|
||||
workspace: getWorkspace(),
|
||||
requestBody: { args, content: finalContent, language: provider.language },
|
||||
});
|
||||
} else {
|
||||
result = await JobService.runScriptPreviewAndWaitResult({
|
||||
workspace: getWorkspace(),
|
||||
requestBody: { args, content: finalContent, language: provider.language },
|
||||
});
|
||||
}
|
||||
return result as SqlResult<any, ResultCollectionT>;
|
||||
} catch (e: any) {
|
||||
let err = e;
|
||||
if (
|
||||
e &&
|
||||
typeof e.body == "string" &&
|
||||
e.statusText == "Internal Server Error"
|
||||
) {
|
||||
let body = e.body;
|
||||
if (body.startsWith("Internal:")) body = body.slice(9).trim();
|
||||
if (body.startsWith("Error:")) body = body.slice(6).trim();
|
||||
if (body.startsWith("datatable")) body = body.slice(9).trim();
|
||||
err = Error(`${provider.providerName} ${body}`);
|
||||
err.query = contentBody;
|
||||
err.request = e.request;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
args,
|
||||
fetch,
|
||||
fetchOne: (params) =>
|
||||
fetch({ ...params, resultCollection: "last_statement_first_row" }),
|
||||
fetchOneScalar: (params) =>
|
||||
fetch({
|
||||
...params,
|
||||
resultCollection: "last_statement_first_row_scalar",
|
||||
}),
|
||||
execute: (params) => fetch(params),
|
||||
} satisfies SqlStatement<any>;
|
||||
}
|
||||
|
||||
// JSON-encode a JS value into something the executor can deserialize. The
|
||||
// JSON.stringify-friendly representation of a JS value before sending it to
|
||||
// the executor:
|
||||
// - `bigint` → string. JSON.stringify on a bigint throws; the
|
||||
// executor accepts numeric strings into BIGINT
|
||||
// slots via `Value::String → INT8`.
|
||||
// - `Date` → ISO-8601 string. inferSqlType maps these to
|
||||
// `TIMESTAMPTZ`; the executor's `Value::String`
|
||||
// arm parses ISO strings into `chrono::DateTime`.
|
||||
// - non-finite `number` → string ("NaN" / "Infinity" / "-Infinity").
|
||||
// JSON.stringify renders these as `null`, which
|
||||
// silently became NULL in the database. The
|
||||
// executor accepts these literals via
|
||||
// `Value::String → FLOAT8` (`f64::from_str`).
|
||||
// - everything else → passed through unchanged.
|
||||
function serializeArgValue(v: any): any {
|
||||
if (typeof v === "bigint") return v.toString();
|
||||
if (v instanceof Date) return v.toISOString();
|
||||
if (typeof v === "number" && !Number.isFinite(v)) {
|
||||
if (Number.isNaN(v)) return "NaN";
|
||||
return v > 0 ? "Infinity" : "-Infinity";
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function buildSqlTemplateFunction(provider: SqlProvider): SqlTemplateFunction {
|
||||
let sqlFn = ((strings: TemplateStringsArray, ...values: any[]) => {
|
||||
// Separate raw vs parameterized values, assigning arg indices only to params
|
||||
@@ -212,62 +300,12 @@ function buildSqlTemplateFunction(provider: SqlProvider): SqlTemplateFunction {
|
||||
...Object.fromEntries(
|
||||
valueInfos
|
||||
.filter((info): info is Extract<(typeof valueInfos)[number], { raw: false }> => !info.raw)
|
||||
.map((info) => [`arg${info.argNum}`, info.value])
|
||||
.map((info) => [`arg${info.argNum}`, serializeArgValue(info.value)])
|
||||
),
|
||||
...provider.extraArgs,
|
||||
};
|
||||
|
||||
async function fetch<ResultCollectionT extends ResultCollection>({
|
||||
resultCollection,
|
||||
}: FetchParams<ResultCollectionT> = {}) {
|
||||
if (resultCollection)
|
||||
content = `-- result_collection=${resultCollection}\n${content}`;
|
||||
try {
|
||||
let result;
|
||||
if (workerHasInternalServer()) {
|
||||
result = await JobService.runScriptPreviewInline({
|
||||
workspace: getWorkspace(),
|
||||
requestBody: { args, content, language: provider.language },
|
||||
});
|
||||
} else {
|
||||
result = await JobService.runScriptPreviewAndWaitResult({
|
||||
workspace: getWorkspace(),
|
||||
requestBody: { args, content, language: provider.language },
|
||||
});
|
||||
}
|
||||
return result as SqlResult<any, ResultCollectionT>;
|
||||
} catch (e: any) {
|
||||
let err = e;
|
||||
if (
|
||||
e &&
|
||||
typeof e.body == "string" &&
|
||||
e.statusText == "Internal Server Error"
|
||||
) {
|
||||
let body = e.body;
|
||||
if (body.startsWith("Internal:")) body = body.slice(9).trim();
|
||||
if (body.startsWith("Error:")) body = body.slice(6).trim();
|
||||
if (body.startsWith("datatable")) body = body.slice(9).trim();
|
||||
err = Error(`${provider.providerName} ${body}`);
|
||||
err.query = contentBody;
|
||||
err.request = e.request;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
args,
|
||||
fetch,
|
||||
fetchOne: (params) =>
|
||||
fetch({ ...params, resultCollection: "last_statement_first_row" }),
|
||||
fetchOneScalar: (params) =>
|
||||
fetch({
|
||||
...params,
|
||||
resultCollection: "last_statement_first_row_scalar",
|
||||
}),
|
||||
execute: (params) => fetch(params),
|
||||
} satisfies SqlStatement<any>;
|
||||
return buildSqlStatement(provider, content, contentBody, args);
|
||||
}) as SqlTemplateFunction;
|
||||
|
||||
sqlFn.raw = (value: string) => new RawSql(value);
|
||||
@@ -293,12 +331,33 @@ function buildSqlTemplateFunction(provider: SqlProvider): SqlTemplateFunction {
|
||||
*/
|
||||
export function datatable(name: string = "main"): DatatableSqlTemplateFunction {
|
||||
let { name: n, schema } = parseName(name);
|
||||
let sqlFn = buildSqlTemplateFunction(
|
||||
datatableProvider(n, schema)
|
||||
) as DatatableSqlTemplateFunction;
|
||||
let provider = datatableProvider(n, schema);
|
||||
let sqlFn = buildSqlTemplateFunction(provider) as DatatableSqlTemplateFunction;
|
||||
// `.query(sql, ...params)` is for SQL strings that already contain
|
||||
// positional placeholders ($1, $2, ...). We DON'T go through the template
|
||||
// builder here — that would re-emit each value as `$N::TYPE` and append
|
||||
// them after the user's literal SQL, which is the bug previous versions of
|
||||
// this method shipped. Instead we build the executor-shaped content
|
||||
// directly: a `-- $N argN (TYPE)` declaration block (the parser picks
|
||||
// these up as explicitly typed args) followed by the user's SQL verbatim.
|
||||
// Note: we hand-roll the decl format here rather than calling
|
||||
// `provider.formatArgDecl`, because the datatable formatter intentionally
|
||||
// omits the type (the template-tag path emits `$N::TYPE` inline instead);
|
||||
// for `.query()` we have no inline cast to fall back on.
|
||||
sqlFn.query = (sqlString: string, ...params: any[]) => {
|
||||
let arr = Object.assign([sqlString], { raw: [sqlString] });
|
||||
return sqlFn(arr, ...params);
|
||||
let argDecls = params
|
||||
.map((v, i) => `-- $${i + 1} arg${i + 1} (${inferSqlType(v)})`)
|
||||
.join("\n");
|
||||
let contentBody = sqlString;
|
||||
let content =
|
||||
(argDecls ? argDecls + "\n" : "") + provider.preamble() + sqlString;
|
||||
let args = {
|
||||
...Object.fromEntries(
|
||||
params.map((v, i) => [`arg${i + 1}`, serializeArgValue(v)])
|
||||
),
|
||||
...provider.extraArgs,
|
||||
};
|
||||
return buildSqlStatement(provider, content, contentBody, args);
|
||||
};
|
||||
return sqlFn;
|
||||
}
|
||||
@@ -329,13 +388,27 @@ export function ducklake(name: string = "main"): SqlTemplateFunction {
|
||||
// These types exist in both DuckDB and Postgres
|
||||
// Check that the types exist if you plan to extend this function for other SQL engines.
|
||||
function inferSqlType(value: any): string {
|
||||
if (typeof value === "number" || typeof value === "bigint") {
|
||||
if (typeof value === "bigint") return "BIGINT";
|
||||
if (typeof value === "number") {
|
||||
if (Number.isInteger(value)) return "BIGINT";
|
||||
return "DOUBLE PRECISION";
|
||||
} else if (value === null || value === undefined) {
|
||||
return "TEXT";
|
||||
} else if (typeof value === "string") {
|
||||
return "TEXT";
|
||||
} else if (Array.isArray(value)) {
|
||||
// Homogeneous-primitive arrays auto-tag as `TYPE[]` so that values like
|
||||
// `${[1,2,3]}` against an `int[]` column work without an explicit
|
||||
// `${arr}::int[]` cast. For non-homogeneous or nested arrays we fall
|
||||
// back to JSON, which works for jsonb columns.
|
||||
return inferSqlArrayType(value);
|
||||
} else if (value instanceof Date) {
|
||||
// JS `Date` carries an absolute instant in UTC; map to TIMESTAMPTZ so
|
||||
// `${someDate}` works against a `timestamptz` column without the user
|
||||
// needing an explicit cast. Without this the typeof check above falls
|
||||
// through to "object" → JSON, which only works by accident via
|
||||
// PG's `json → text → timestamptz` implicit cast chain.
|
||||
return "TIMESTAMPTZ";
|
||||
} else if (typeof value === "object") {
|
||||
return "JSON";
|
||||
} else if (typeof value === "boolean") {
|
||||
@@ -345,11 +418,45 @@ function inferSqlType(value: any): string {
|
||||
}
|
||||
}
|
||||
|
||||
function inferSqlArrayType(value: any[]): string {
|
||||
if (value.length === 0) return "JSON";
|
||||
// Detect a single shared scalar JS type across all elements. Mixed types
|
||||
// or any non-primitive element forces the JSON fallback.
|
||||
let scalarType: string | undefined = undefined;
|
||||
for (const elem of value) {
|
||||
let elemType: string;
|
||||
if (typeof elem === "bigint") elemType = "BIGINT";
|
||||
else if (typeof elem === "number")
|
||||
elemType = Number.isInteger(elem) ? "BIGINT" : "DOUBLE PRECISION";
|
||||
else if (typeof elem === "string") elemType = "TEXT";
|
||||
else if (typeof elem === "boolean") elemType = "BOOLEAN";
|
||||
else return "JSON";
|
||||
if (scalarType === undefined) scalarType = elemType;
|
||||
else if (scalarType === "BIGINT" && elemType === "DOUBLE PRECISION")
|
||||
scalarType = "DOUBLE PRECISION";
|
||||
else if (scalarType === "DOUBLE PRECISION" && elemType === "BIGINT") {
|
||||
// already widened
|
||||
} else if (scalarType !== elemType) {
|
||||
return "JSON";
|
||||
}
|
||||
}
|
||||
return `${scalarType}[]`;
|
||||
}
|
||||
|
||||
// The goal is to detect if the user added a type annotation manually
|
||||
//
|
||||
// untyped : sql`SELECT ${x} = 0` => ['SELECT ', ' = 0']
|
||||
// typed : sql`SELECT ${x}::int = 0` => ['SELECT ', '::int = 0']
|
||||
// typed : sql`SELECT CAST ( ${x} AS int ) = 0` => ['SELECT CAST ( ', ' AS int ) = 0']
|
||||
//
|
||||
// Caveat: the returned string is only meaningful as a *presence* signal —
|
||||
// the only consumer (`formatArgUsage`) just checks `explicitType !== undefined`
|
||||
// to decide whether to emit `$N` (user already wrote a cast) vs `$N::TYPE`
|
||||
// (SDK injects the inferred cast). The returned string itself can be
|
||||
// imprecise — e.g. `${x}::DOUBLE PRECISION` returns `"DOUBLE"` (split on
|
||||
// whitespace), and `CAST(${x} AS int)` returns `"int)"` (no paren stripping).
|
||||
// Don't rely on the returned string as a parsed PG type; only on whether
|
||||
// it's defined.
|
||||
function parseTypeAnnotation(
|
||||
prevTemplateString: string | undefined,
|
||||
nextTemplateString: string | undefined
|
||||
|
||||
@@ -0,0 +1,626 @@
|
||||
/**
|
||||
* Standalone tests for `wmill.datatable()` / `wmill.ducklake()` SQL template
|
||||
* functions.
|
||||
*
|
||||
* The real `sqlUtils.ts` imports `./services.gen` (auto-generated, not in
|
||||
* the repo) so we can't import it here. Instead we re-implement the same
|
||||
* type-inference / template-building / `.query()` pipeline inline (sans the
|
||||
* network calls) and assert the `content` + `args` shapes the executor
|
||||
* would receive. Each test maps 1:1 to a behaviour this PR introduces or
|
||||
* fixes (BigInt, homogeneous arrays, `.query()` positional, etc.) so they
|
||||
* also serve as a regression backstop.
|
||||
*
|
||||
* Run with: bun test typescript-client/tests/sqlUtils.test.ts
|
||||
*/
|
||||
import { expect, test, describe } from "bun:test";
|
||||
|
||||
// =============================================================================
|
||||
// Pure SDK logic (mirror of typescript-client/sqlUtils.ts — kept minimal,
|
||||
// only the parts that decide content / args).
|
||||
// =============================================================================
|
||||
|
||||
class RawSql {
|
||||
readonly __brand = "RawSql" as const;
|
||||
constructor(public readonly value: string) {}
|
||||
}
|
||||
|
||||
interface SqlProvider {
|
||||
formatArgDecl(argNum: number, argType: string): string;
|
||||
formatArgUsage(
|
||||
argNum: number,
|
||||
explicitType: string | undefined,
|
||||
inferredType: string
|
||||
): string;
|
||||
preamble(): string;
|
||||
language: "postgresql" | "duckdb";
|
||||
extraArgs: Record<string, any>;
|
||||
providerName: string;
|
||||
}
|
||||
|
||||
function datatableProvider(name: string, schema?: string): SqlProvider {
|
||||
return {
|
||||
providerName: "datatable",
|
||||
language: "postgresql",
|
||||
extraArgs: { database: `datatable://${name}` },
|
||||
formatArgDecl: (argNum) => `-- $${argNum} arg${argNum}`,
|
||||
formatArgUsage: (argNum, explicitType, inferredType) =>
|
||||
explicitType !== undefined
|
||||
? `$${argNum}`
|
||||
: `$${argNum}::${inferredType}`,
|
||||
preamble: () => (schema ? `SET search_path TO "${schema}";\n` : ""),
|
||||
};
|
||||
}
|
||||
|
||||
function ducklakeProvider(name: string): SqlProvider {
|
||||
return {
|
||||
providerName: "ducklake",
|
||||
language: "duckdb",
|
||||
extraArgs: {},
|
||||
formatArgDecl: (argNum, argType) => `-- $arg${argNum} (${argType})`,
|
||||
formatArgUsage: (argNum) => `$arg${argNum}`,
|
||||
preamble: () => `ATTACH 'ducklake://${name}' AS dl;USE dl;\n`,
|
||||
};
|
||||
}
|
||||
|
||||
function inferSqlType(value: any): string {
|
||||
if (typeof value === "bigint") return "BIGINT";
|
||||
if (typeof value === "number") {
|
||||
if (Number.isInteger(value)) return "BIGINT";
|
||||
return "DOUBLE PRECISION";
|
||||
} else if (value === null || value === undefined) {
|
||||
return "TEXT";
|
||||
} else if (typeof value === "string") {
|
||||
return "TEXT";
|
||||
} else if (Array.isArray(value)) {
|
||||
return inferSqlArrayType(value);
|
||||
} else if (value instanceof Date) {
|
||||
return "TIMESTAMPTZ";
|
||||
} else if (typeof value === "object") {
|
||||
return "JSON";
|
||||
} else if (typeof value === "boolean") {
|
||||
return "BOOLEAN";
|
||||
} else {
|
||||
return "TEXT";
|
||||
}
|
||||
}
|
||||
|
||||
function inferSqlArrayType(value: any[]): string {
|
||||
if (value.length === 0) return "JSON";
|
||||
let scalarType: string | undefined = undefined;
|
||||
for (const elem of value) {
|
||||
let elemType: string;
|
||||
if (typeof elem === "bigint") elemType = "BIGINT";
|
||||
else if (typeof elem === "number")
|
||||
elemType = Number.isInteger(elem) ? "BIGINT" : "DOUBLE PRECISION";
|
||||
else if (typeof elem === "string") elemType = "TEXT";
|
||||
else if (typeof elem === "boolean") elemType = "BOOLEAN";
|
||||
else return "JSON";
|
||||
if (scalarType === undefined) scalarType = elemType;
|
||||
else if (scalarType === "BIGINT" && elemType === "DOUBLE PRECISION")
|
||||
scalarType = "DOUBLE PRECISION";
|
||||
else if (scalarType === "DOUBLE PRECISION" && elemType === "BIGINT") {
|
||||
// already widened
|
||||
} else if (scalarType !== elemType) {
|
||||
return "JSON";
|
||||
}
|
||||
}
|
||||
return `${scalarType}[]`;
|
||||
}
|
||||
|
||||
function parseTypeAnnotation(
|
||||
prevTemplateString: string | undefined,
|
||||
nextTemplateString: string | undefined
|
||||
): string | undefined {
|
||||
if (!nextTemplateString) return;
|
||||
nextTemplateString = nextTemplateString.trimStart();
|
||||
if (nextTemplateString.startsWith("::")) {
|
||||
return nextTemplateString.substring(2).trimStart().split(/\s+/)[0];
|
||||
}
|
||||
prevTemplateString = prevTemplateString?.trimEnd();
|
||||
if (
|
||||
prevTemplateString?.endsWith("(") &&
|
||||
prevTemplateString
|
||||
.substring(0, prevTemplateString.length - 1)
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.endsWith("CAST") &&
|
||||
nextTemplateString.toUpperCase().startsWith("AS ")
|
||||
) {
|
||||
return nextTemplateString.substring(2).trimStart().split(/\s+/)[0];
|
||||
}
|
||||
}
|
||||
|
||||
function serializeArgValue(v: any): any {
|
||||
if (typeof v === "bigint") return v.toString();
|
||||
if (v instanceof Date) return v.toISOString();
|
||||
if (typeof v === "number" && !Number.isFinite(v)) {
|
||||
if (Number.isNaN(v)) return "NaN";
|
||||
return v > 0 ? "Infinity" : "-Infinity";
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function buildContentAndArgs(
|
||||
provider: SqlProvider,
|
||||
strings: TemplateStringsArray | string[],
|
||||
values: any[]
|
||||
): { content: string; args: Record<string, any> } {
|
||||
let argIndex = 0;
|
||||
const valueInfos = values.map((v, i) => {
|
||||
if (v instanceof RawSql)
|
||||
return { raw: true as const, value: v.value, originalIndex: i };
|
||||
argIndex++;
|
||||
return {
|
||||
raw: false as const,
|
||||
value: v,
|
||||
originalIndex: i,
|
||||
argNum: argIndex,
|
||||
};
|
||||
});
|
||||
|
||||
let argDecls = valueInfos
|
||||
.filter((info): info is Extract<typeof valueInfos[number], { raw: false }> => !info.raw)
|
||||
.map((info) => {
|
||||
let argType =
|
||||
parseTypeAnnotation(
|
||||
strings[info.originalIndex],
|
||||
strings[info.originalIndex + 1]
|
||||
) || inferSqlType(info.value);
|
||||
return provider.formatArgDecl(info.argNum, argType);
|
||||
});
|
||||
|
||||
let content = argDecls.length ? argDecls.join("\n") + "\n" : "";
|
||||
content += provider.preamble();
|
||||
|
||||
let contentBody = "";
|
||||
for (let i = 0; i < strings.length; i++) {
|
||||
contentBody += strings[i];
|
||||
if (i < valueInfos.length) {
|
||||
let info = valueInfos[i];
|
||||
if (info.raw) {
|
||||
contentBody += info.value;
|
||||
} else {
|
||||
let explicitType = parseTypeAnnotation(
|
||||
strings[info.originalIndex],
|
||||
strings[info.originalIndex + 1]
|
||||
);
|
||||
let inferredType = inferSqlType(info.value);
|
||||
contentBody += provider.formatArgUsage(
|
||||
info.argNum,
|
||||
explicitType,
|
||||
inferredType
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
content += contentBody;
|
||||
|
||||
const args = {
|
||||
...Object.fromEntries(
|
||||
valueInfos
|
||||
.filter((info): info is Extract<typeof valueInfos[number], { raw: false }> => !info.raw)
|
||||
.map((info) => [`arg${info.argNum}`, serializeArgValue(info.value)])
|
||||
),
|
||||
...provider.extraArgs,
|
||||
};
|
||||
return { content, args };
|
||||
}
|
||||
|
||||
function buildDatatableQuery(
|
||||
provider: SqlProvider,
|
||||
sqlString: string,
|
||||
params: any[]
|
||||
): { content: string; args: Record<string, any> } {
|
||||
let argDecls = params
|
||||
.map((v, i) => `-- $${i + 1} arg${i + 1} (${inferSqlType(v)})`)
|
||||
.join("\n");
|
||||
let content =
|
||||
(argDecls ? argDecls + "\n" : "") + provider.preamble() + sqlString;
|
||||
let args = {
|
||||
...Object.fromEntries(
|
||||
params.map((v, i) => [`arg${i + 1}`, serializeArgValue(v)])
|
||||
),
|
||||
...provider.extraArgs,
|
||||
};
|
||||
return { content, args };
|
||||
}
|
||||
|
||||
function templateTag(provider: SqlProvider) {
|
||||
return (strings: TemplateStringsArray, ...values: any[]) =>
|
||||
buildContentAndArgs(provider, strings, values);
|
||||
}
|
||||
|
||||
const dt = (name = "main") => templateTag(datatableProvider(name));
|
||||
const dl = (name = "main") => templateTag(ducklakeProvider(name));
|
||||
const datatableQuery = (name = "main") => {
|
||||
const provider = datatableProvider(name);
|
||||
return (sql: string, ...params: any[]) =>
|
||||
buildDatatableQuery(provider, sql, params);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// inferSqlType — exhaustive coverage
|
||||
// =============================================================================
|
||||
|
||||
describe("inferSqlType — primitives", () => {
|
||||
test("integer Number → BIGINT", () => {
|
||||
expect(inferSqlType(0)).toBe("BIGINT");
|
||||
expect(inferSqlType(42)).toBe("BIGINT");
|
||||
expect(inferSqlType(-7)).toBe("BIGINT");
|
||||
expect(inferSqlType(Number.MAX_SAFE_INTEGER)).toBe("BIGINT");
|
||||
});
|
||||
|
||||
test("non-integer Number → DOUBLE PRECISION", () => {
|
||||
expect(inferSqlType(0.5)).toBe("DOUBLE PRECISION");
|
||||
expect(inferSqlType(-3.14)).toBe("DOUBLE PRECISION");
|
||||
expect(inferSqlType(Number.EPSILON)).toBe("DOUBLE PRECISION");
|
||||
});
|
||||
|
||||
test("BigInt → BIGINT (not DOUBLE PRECISION)", () => {
|
||||
// Pre-fix this branch was unreachable because bigint was bundled with
|
||||
// number and `Number.isInteger(BigInt)` returns false → would have
|
||||
// returned DOUBLE PRECISION (wrong). The split-out check is the fix.
|
||||
expect(inferSqlType(BigInt(0))).toBe("BIGINT");
|
||||
expect(inferSqlType(BigInt("9007199254740993"))).toBe("BIGINT");
|
||||
expect(inferSqlType(BigInt(-1))).toBe("BIGINT");
|
||||
});
|
||||
|
||||
test("string / null / undefined → TEXT", () => {
|
||||
expect(inferSqlType("")).toBe("TEXT");
|
||||
expect(inferSqlType("hello")).toBe("TEXT");
|
||||
expect(inferSqlType(null)).toBe("TEXT");
|
||||
expect(inferSqlType(undefined)).toBe("TEXT");
|
||||
});
|
||||
|
||||
test("boolean → BOOLEAN", () => {
|
||||
expect(inferSqlType(true)).toBe("BOOLEAN");
|
||||
expect(inferSqlType(false)).toBe("BOOLEAN");
|
||||
});
|
||||
|
||||
test("plain object → JSON", () => {
|
||||
expect(inferSqlType({})).toBe("JSON");
|
||||
expect(inferSqlType({ a: 1, b: [1, 2] })).toBe("JSON");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferSqlType — arrays", () => {
|
||||
test("empty array → JSON", () => {
|
||||
expect(inferSqlType([])).toBe("JSON");
|
||||
});
|
||||
|
||||
test("homogeneous integer array → BIGINT[]", () => {
|
||||
expect(inferSqlType([1, 2, 3])).toBe("BIGINT[]");
|
||||
expect(inferSqlType([0])).toBe("BIGINT[]");
|
||||
expect(inferSqlType([-1, 0, 1])).toBe("BIGINT[]");
|
||||
});
|
||||
|
||||
test("homogeneous float array → DOUBLE PRECISION[]", () => {
|
||||
expect(inferSqlType([1.5, 2.5])).toBe("DOUBLE PRECISION[]");
|
||||
});
|
||||
|
||||
test("mixed int/float array widens to DOUBLE PRECISION[]", () => {
|
||||
expect(inferSqlType([1, 2.5])).toBe("DOUBLE PRECISION[]");
|
||||
expect(inferSqlType([1.5, 2])).toBe("DOUBLE PRECISION[]");
|
||||
});
|
||||
|
||||
test("homogeneous string array → TEXT[]", () => {
|
||||
expect(inferSqlType(["a", "b", "c"])).toBe("TEXT[]");
|
||||
expect(inferSqlType([""])).toBe("TEXT[]");
|
||||
});
|
||||
|
||||
test("homogeneous bool array → BOOLEAN[]", () => {
|
||||
expect(inferSqlType([true, false, true])).toBe("BOOLEAN[]");
|
||||
});
|
||||
|
||||
test("homogeneous bigint array → BIGINT[]", () => {
|
||||
expect(inferSqlType([BigInt(1), BigInt(2)])).toBe("BIGINT[]");
|
||||
});
|
||||
|
||||
test("non-homogeneous array → JSON", () => {
|
||||
expect(inferSqlType([1, "x"])).toBe("JSON");
|
||||
expect(inferSqlType(["a", true])).toBe("JSON");
|
||||
expect(inferSqlType([1, null])).toBe("JSON");
|
||||
expect(inferSqlType([true, 1])).toBe("JSON");
|
||||
});
|
||||
|
||||
test("nested array → JSON (current limitation, no auto-tag for 2D)", () => {
|
||||
expect(inferSqlType([[1], [2]])).toBe("JSON");
|
||||
expect(inferSqlType([{ a: 1 }, { a: 2 }])).toBe("JSON");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// parseTypeAnnotation — used by the SDK to suppress its own ::TYPE injection
|
||||
// when the user already wrote a cast.
|
||||
// =============================================================================
|
||||
|
||||
describe("parseTypeAnnotation — user-supplied cast detection", () => {
|
||||
test("`${x}::int` → 'int'", () => {
|
||||
expect(parseTypeAnnotation("SELECT ", "::int FROM t")).toBe("int");
|
||||
});
|
||||
test("whitespace tolerance after ::", () => {
|
||||
expect(parseTypeAnnotation("SELECT ", " :: bigint FROM t")).toBe(
|
||||
"bigint"
|
||||
);
|
||||
});
|
||||
test("`CAST(${x} AS int)` → first whitespace-delimited word after AS", () => {
|
||||
// The SDK splits on whitespace and doesn't strip closing parens, so
|
||||
// `AS int)` returns "int)". The exact value doesn't matter downstream
|
||||
// because the SDK only checks `explicitType !== undefined` to skip its
|
||||
// own ::cast injection — but we lock the behaviour in.
|
||||
expect(parseTypeAnnotation("SELECT CAST(", " AS int)")).toBe("int)");
|
||||
});
|
||||
test("`CAST ( ${x} AS BOOL )` (whitespace + caps)", () => {
|
||||
// Whitespace before `)` causes split to drop it, so this returns "BOOL".
|
||||
expect(parseTypeAnnotation("SELECT CAST ( ", " AS BOOL )")).toBe("BOOL");
|
||||
});
|
||||
test("no cast adjacent → undefined", () => {
|
||||
expect(parseTypeAnnotation("SELECT ", " FROM t")).toBeUndefined();
|
||||
expect(parseTypeAnnotation("SELECT ", "")).toBeUndefined();
|
||||
expect(parseTypeAnnotation(undefined, undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// datatable() template tag — content + args round-trips
|
||||
// =============================================================================
|
||||
|
||||
describe("datatable() — template tag", () => {
|
||||
test("primitives auto-tag with ::TYPE inline; decls have no type", () => {
|
||||
const sql = dt();
|
||||
const out = sql`SELECT ${42}, ${3.14}, ${true}, ${"x"}, ${null}`;
|
||||
// datatable provider's formatArgDecl ignores the type, so we get bare
|
||||
// decls + the casts in the SQL body.
|
||||
expect(out.content).toContain("-- $1 arg1\n");
|
||||
expect(out.content).toContain("$1::BIGINT");
|
||||
expect(out.content).toContain("$2::DOUBLE PRECISION");
|
||||
expect(out.content).toContain("$3::BOOLEAN");
|
||||
expect(out.content).toContain("$4::TEXT");
|
||||
expect(out.content).toContain("$5::TEXT");
|
||||
expect(out.args).toMatchObject({
|
||||
arg1: 42,
|
||||
arg2: 3.14,
|
||||
arg3: true,
|
||||
arg4: "x",
|
||||
arg5: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("user `${x}::int` suppresses SDK's auto-cast (parser sees user's cast)", () => {
|
||||
const sql = dt();
|
||||
const out = sql`SELECT ${42}::int`;
|
||||
expect(out.content).toContain("SELECT $1::int");
|
||||
expect(out.content).not.toContain("$1::BIGINT");
|
||||
});
|
||||
|
||||
test("CAST(${x} AS T) syntax → bare $N in SQL (regression #8988)", () => {
|
||||
const sql = dt();
|
||||
const out = sql`SELECT CAST(${true} AS bool)`;
|
||||
expect(out.content).toContain("CAST($1 AS bool)");
|
||||
expect(out.content).not.toContain("$1::BOOLEAN");
|
||||
});
|
||||
|
||||
test("BigInt is stringified for JSON transport, tagged as ::BIGINT", () => {
|
||||
const sql = dt();
|
||||
const out = sql`SELECT ${BigInt("9007199254740993")}`;
|
||||
expect(out.content).toContain("$1::BIGINT");
|
||||
expect(out.args.arg1).toBe("9007199254740993");
|
||||
// Round-trip through JSON without throwing — the original bug.
|
||||
expect(() => JSON.stringify(out.args)).not.toThrow();
|
||||
});
|
||||
|
||||
test("BigInt zero / negative / large", () => {
|
||||
const sql = dt();
|
||||
expect(sql`SELECT ${BigInt(0)}`.args.arg1).toBe("0");
|
||||
expect(sql`SELECT ${BigInt(-1)}`.args.arg1).toBe("-1");
|
||||
expect(sql`SELECT ${BigInt("99999999999999999999")}`.args.arg1).toBe(
|
||||
"99999999999999999999"
|
||||
);
|
||||
});
|
||||
|
||||
test("Date is auto-tagged ::TIMESTAMPTZ and ISO-stringified", () => {
|
||||
// Pre-fix: typeof Date === "object" → ::JSON, then PG cast chain
|
||||
// worked accidentally for `${date}::timestamptz`. Now: explicit
|
||||
// ::TIMESTAMPTZ + Date.toISOString() so plain `${date}` against a
|
||||
// timestamptz column doesn't need a cast.
|
||||
const sql = dt();
|
||||
const d = new Date("2024-01-15T10:30:00.000Z");
|
||||
const out = sql`SELECT ${d} AS t`;
|
||||
expect(out.content).toContain("$1::TIMESTAMPTZ");
|
||||
expect(out.args.arg1).toBe("2024-01-15T10:30:00.000Z");
|
||||
expect(() => JSON.stringify(out.args)).not.toThrow();
|
||||
});
|
||||
|
||||
test("non-finite Number is stringified for the executor", () => {
|
||||
// JSON.stringify(NaN) and JSON.stringify(Infinity) both produce `null`,
|
||||
// which silently became NULL in the database. The executor accepts
|
||||
// "NaN" / "Infinity" / "-Infinity" via `Value::String → FLOAT8`
|
||||
// (`f64::from_str`), so we send the special values as strings.
|
||||
const sql = dt();
|
||||
expect(sql`SELECT ${NaN}`.args.arg1).toBe("NaN");
|
||||
expect(sql`SELECT ${Infinity}`.args.arg1).toBe("Infinity");
|
||||
expect(sql`SELECT ${-Infinity}`.args.arg1).toBe("-Infinity");
|
||||
// Tag stays DOUBLE PRECISION (these are floats).
|
||||
expect(sql`SELECT ${NaN}`.content).toContain("$1::DOUBLE PRECISION");
|
||||
});
|
||||
|
||||
test("homogeneous arrays auto-tag with TYPE[]", () => {
|
||||
const sql = dt();
|
||||
expect(sql`SELECT ${[1, 2, 3]}`.content).toContain("$1::BIGINT[]");
|
||||
expect(sql`SELECT ${[1.5, 2.5]}`.content).toContain(
|
||||
"$1::DOUBLE PRECISION[]"
|
||||
);
|
||||
expect(sql`SELECT ${["a", "b"]}`.content).toContain("$1::TEXT[]");
|
||||
expect(sql`SELECT ${[true, false]}`.content).toContain("$1::BOOLEAN[]");
|
||||
});
|
||||
|
||||
test("non-homogeneous and empty arrays fall back to JSON", () => {
|
||||
const sql = dt();
|
||||
expect(sql`SELECT ${[1, "x"]}`.content).toContain("$1::JSON");
|
||||
expect(sql`SELECT ${[]}`.content).toContain("$1::JSON");
|
||||
expect(sql`SELECT ${[[1], [2]]}`.content).toContain("$1::JSON");
|
||||
});
|
||||
|
||||
test("mixed-numeric array widens to DOUBLE PRECISION[]", () => {
|
||||
const sql = dt();
|
||||
expect(sql`SELECT ${[1, 2.5]}`.content).toContain(
|
||||
"$1::DOUBLE PRECISION[]"
|
||||
);
|
||||
});
|
||||
|
||||
test("multiple args get distinct decls + numbered placeholders", () => {
|
||||
const sql = dt();
|
||||
const out = sql`INSERT INTO t VALUES (${1}, ${"x"}, ${[true, false]})`;
|
||||
expect(out.content).toContain("-- $1 arg1");
|
||||
expect(out.content).toContain("-- $2 arg2");
|
||||
expect(out.content).toContain("-- $3 arg3");
|
||||
expect(out.content).toContain("$1::BIGINT");
|
||||
expect(out.content).toContain("$2::TEXT");
|
||||
expect(out.content).toContain("$3::BOOLEAN[]");
|
||||
expect(out.args).toMatchObject({
|
||||
arg1: 1,
|
||||
arg2: "x",
|
||||
arg3: [true, false],
|
||||
});
|
||||
});
|
||||
|
||||
test("RawSql is inlined verbatim, doesn't consume an arg index", () => {
|
||||
const sql = dt();
|
||||
const col = new RawSql("name");
|
||||
const out = sql`SELECT ${col} FROM t WHERE id = ${42}`;
|
||||
// Only one decl, only one arg in args dict.
|
||||
expect(out.content.match(/^-- \$\d+/gm)?.length).toBe(1);
|
||||
expect(out.content).toContain("SELECT name FROM t WHERE id = $1::BIGINT");
|
||||
expect(Object.keys(out.args).filter((k) => k.startsWith("arg")).length).toBe(
|
||||
1
|
||||
);
|
||||
expect(out.args).toMatchObject({ arg1: 42 });
|
||||
});
|
||||
|
||||
test("schema name is propagated as SET search_path preamble", () => {
|
||||
const sql = dt("main");
|
||||
const out = sql`SELECT 1`;
|
||||
expect(out.args.database).toBe("datatable://main");
|
||||
});
|
||||
|
||||
test("database extra arg is always present", () => {
|
||||
const sql = dt("custom_db");
|
||||
const out = sql`SELECT ${1}`;
|
||||
expect(out.args.database).toBe("datatable://custom_db");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// datatable().query() — positional placeholders (the previously-broken path)
|
||||
// =============================================================================
|
||||
|
||||
describe("datatable().query() — positional placeholders", () => {
|
||||
test("emits typed declarations + SQL verbatim, no appended placeholders", () => {
|
||||
const q = datatableQuery();
|
||||
const out = q("SELECT $1, $2", 42, "hello");
|
||||
expect(out.content).toContain("-- $1 arg1 (BIGINT)");
|
||||
expect(out.content).toContain("-- $2 arg2 (TEXT)");
|
||||
// Crucially: SQL must end with the user's SQL, NOT have placeholders
|
||||
// appended after it (the pre-fix bug).
|
||||
expect(out.content.endsWith("SELECT $1, $2")).toBe(true);
|
||||
expect(out.args).toMatchObject({ arg1: 42, arg2: "hello" });
|
||||
});
|
||||
|
||||
test("BigInt args are stringified", () => {
|
||||
const q = datatableQuery();
|
||||
const out = q("SELECT $1", BigInt("100"));
|
||||
expect(out.args.arg1).toBe("100");
|
||||
expect(out.content).toContain("-- $1 arg1 (BIGINT)");
|
||||
});
|
||||
|
||||
test("array args auto-tag homogeneously in the decl block", () => {
|
||||
const q = datatableQuery();
|
||||
const out = q("SELECT $1, $2", [1, 2], ["a", "b"]);
|
||||
expect(out.content).toContain("-- $1 arg1 (BIGINT[])");
|
||||
expect(out.content).toContain("-- $2 arg2 (TEXT[])");
|
||||
});
|
||||
|
||||
test("zero params → no decl block, just SQL + extras", () => {
|
||||
const q = datatableQuery();
|
||||
const out = q("SELECT 1");
|
||||
expect(out.content).not.toContain("-- $");
|
||||
expect(out.content).toContain("SELECT 1");
|
||||
// database extra still injected.
|
||||
expect(out.args.database).toBe("datatable://main");
|
||||
});
|
||||
|
||||
test("ten params number contiguously", () => {
|
||||
const q = datatableQuery();
|
||||
const params = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
const out = q("SELECT " + params.map((_, i) => `$${i + 1}`).join(","), ...params);
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
expect(out.content).toContain(`-- $${i} arg${i} (BIGINT)`);
|
||||
expect(out.args[`arg${i}`]).toBe(i);
|
||||
}
|
||||
});
|
||||
|
||||
test(".query()'s decl format matches what the executor parses (regression)", () => {
|
||||
// The PG parser's RE_ARG_PGSQL is:
|
||||
// ^-- \$(\d+) (\w+)(?: \(([A-Za-z0-9_\[\]]+)\))?(?: ?\= ?(.+))? *$
|
||||
// We assert our decl line matches that grammar so the executor doesn't
|
||||
// fall back to "inferred default text".
|
||||
const q = datatableQuery();
|
||||
const out = q("SELECT $1", 42);
|
||||
const declRe = /^-- \$\d+ \w+ \([A-Za-z0-9_\[\]]+\) *$/m;
|
||||
expect(out.content).toMatch(declRe);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// ducklake() template tag — DuckDB declares types in the comment (different
|
||||
// shape from datatable). Same auto-tag rules apply for inferSqlType.
|
||||
// =============================================================================
|
||||
|
||||
describe("ducklake() — DuckDB shape", () => {
|
||||
test("declarations carry the type", () => {
|
||||
const sql = dl("main");
|
||||
const out = sql`SELECT ${42}, ${"hello"}, ${true}`;
|
||||
expect(out.content).toContain("-- $arg1 (BIGINT)");
|
||||
expect(out.content).toContain("-- $arg2 (TEXT)");
|
||||
expect(out.content).toContain("-- $arg3 (BOOLEAN)");
|
||||
// Preamble attaches the ducklake.
|
||||
expect(out.content).toContain("ATTACH 'ducklake://main' AS dl;USE dl;");
|
||||
// Args are referenced via $argN syntax in the SQL body.
|
||||
expect(out.content).toContain("$arg1");
|
||||
});
|
||||
|
||||
test("BigInt + homogeneous arrays propagate to ducklake too", () => {
|
||||
const sql = dl("main");
|
||||
const out = sql`SELECT ${BigInt(9)}, ${[1, 2, 3]}, ${["a", "b"]}`;
|
||||
expect(out.content).toContain("(BIGINT)");
|
||||
expect(out.content).toContain("(BIGINT[])");
|
||||
expect(out.content).toContain("(TEXT[])");
|
||||
expect(out.args.arg1).toBe("9");
|
||||
expect(out.args.arg2).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test("ducklake doesn't carry a database extra arg", () => {
|
||||
const sql = dl();
|
||||
const out = sql`SELECT 1`;
|
||||
expect(out.args).not.toHaveProperty("database");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Cross-cutting: the args dict must always be JSON-serialisable.
|
||||
// =============================================================================
|
||||
|
||||
describe("args dict is JSON-serialisable for every supported value shape", () => {
|
||||
test("BigInt, primitives, arrays, objects, raw — none throw", () => {
|
||||
const sql = dt();
|
||||
const out = sql`
|
||||
SELECT ${BigInt(1)}, ${1}, ${1.5}, ${"x"}, ${true}, ${null},
|
||||
${[1, 2]}, ${["a", "b"]}, ${[true, false]},
|
||||
${{ k: 1 }}, ${[1, "x"]}
|
||||
`;
|
||||
const json = JSON.stringify(out.args);
|
||||
expect(typeof json).toBe("string");
|
||||
// BigInt got stringified, not thrown.
|
||||
expect(json).toContain('"arg1":"1"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user