mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-21 08:02:26 +00:00
43044c2e28
* wip: partial work before earlyoom-recovery relaunch
* fix(pipelines): scaffold the strftime {partition} filter idiom (frontend-only)
The DuckDB materialize scaffold and the AI pipeline prompt now teach the
grain-agnostic `WHERE strftime(<ts_col>, '<fmt>') = {partition}` filter instead
of the naive `= TIMESTAMP {partition}` cast. `{partition}` substitutes to the
partition IDENTITY string (`2026-07-05T23`, `2026-W27`, `2026-07`), which is not
a valid DuckDB TIMESTAMP literal for any non-daily grain — so the naive form
raises a `Conversion Error` for hourly/weekly/monthly (only daily parses).
Adds a frontend unit test asserting the hourly scaffold emits the strftime
idiom (`%Y-%m-%dT%H`) for every grain and never scaffolds the naive TIMESTAMP
cast as executable SQL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(pipelines): scope strftime partition idiom to time grains
Review nit: `dynamic` partitioning's identity is a caller-supplied key, not a
timestamp, so `strftime` doesn't apply. Scope the scaffold + AI prompt claim to
time grains and add a `dynamic` example that filters on the user's own key.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(pipelines): wm_partition macro for grain-agnostic partition filters
The materialize runtime now injects a `wm_partition(ts)` temp macro as the first
setup statement of a time-partitioned script, so filtering the source to the
active slice is one grain-agnostic line — `WHERE wm_partition(<ts_col>) =
{partition}` — instead of a hand-written `strftime` format the author must keep
in lockstep with the resolver, or the `= TIMESTAMP {partition}` cast that only
parses for daily and Conversion-Errors for hourly/weekly/monthly.
The macro's format comes from `PartitionKind::default_time_format` in
windmill-parser, the same source the EE resolver reads to stamp the `{partition}`
identity, so the two can't drift. `dynamic` partitions get no macro (their
identity is a caller-supplied key → `WHERE <key_col> = {partition}`).
Replaces the earlier 9-line strftime comment block in the scaffold with the
single macro line; AI pipeline prompt and design doc updated to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(pipelines): verify wm_partition strftime parity vs chrono through real DuckDB
Runs the bundled DuckDB engine in-memory and asserts strftime renders every
grain format (daily/hourly/weekly `%G-W%V`/monthly) byte-for-byte identically to
chrono — the engine the resolver uses to stamp the `{partition}` identity —
across ISO-week year boundaries (2027-01-01 → 2026-W53 etc.). Also proves the
injected `wm_partition` macro buckets the whole slice and that the naive
`TIMESTAMP '<weekly|monthly identity>'` cast Conversion-Errors.
Closes the one cross-engine assumption the pure-Rust/frontend tests couldn't
reach (flagged by CI review for weekly ISO-week rendering).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore: update ee-repo-ref to 0de2412ff0734b11e12ba378c9bcc373ff9ae800
This commit updates the EE repository reference after PR #649 was merged in windmill-ee-private.
Previous ee-repo-ref: ad6c6685689d7741058e7d2c9ecbe95d982e6268
New ee-repo-ref: 0de2412ff0734b11e12ba378c9bcc373ff9ae800
Automated by sync-ee-ref workflow.
* fix(pipelines): classify CREATE TEMP MACRO as a DuckDB prepare-path setup statement
The FFI prepare/diagnostics pass only EXECUTES statements recognized by
is_setup_statement (ATTACH/USE/INSTALL/…); everything else is merely prepared.
`CREATE [OR REPLACE] TEMP MACRO` wasn't recognized, so on a `-- prepare` run of a
partitioned materialize the injected `wm_partition` macro was never created on
the connection, and the later generated `CREATE TABLE … SELECT … WHERE
wm_partition(...)` failed to bind ("function does not exist"). The same latent
gap affected the workspace-macro splicer, which injects TEMP MACRO blocks too.
Classify CREATE [OR REPLACE] TEMP|TEMPORARY MACRO as setup so it's executed
before dependent blocks and excluded from the PrepareQueryResult count
(persistent CREATE MACRO stays a user statement). Adds a prepare-path test that
fails without the fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
4058 lines
164 KiB
Rust
4058 lines
164 KiB
Rust
use std::cell::RefCell;
|
|
use std::env;
|
|
use std::ffi::{c_char, c_uint, CStr, CString};
|
|
use std::ptr::NonNull;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use libloading::{Library, Symbol};
|
|
use serde::Serialize;
|
|
use serde_json::value::RawValue;
|
|
use serde_json::{json, Value};
|
|
use uuid::Uuid;
|
|
use windmill_common::error::{to_anyhow, Error, Result};
|
|
use windmill_common::utils::sanitize_string_from_password;
|
|
use windmill_common::worker::{get_memory, to_raw_value, Connection, SqlResultCollectionStrategy};
|
|
use windmill_common::workspaces::{
|
|
get_datatable_resource_from_db_unchecked, get_ducklake_from_db_unchecked,
|
|
strip_fork_reserved_attach_args, DucklakeCatalogResourceType,
|
|
};
|
|
use windmill_common::PgDatabase;
|
|
use windmill_object_store::S3_PROXY_LAST_ERRORS_CACHE;
|
|
use windmill_parser_sql::{parse_duckdb_sig, parse_sql_blocks};
|
|
use windmill_queue::{append_logs, CanceledBy, MiniPulledJob};
|
|
use windmill_types::s3::S3Object;
|
|
|
|
use crate::agent_workers::{get_datatable_resource_from_agent_http, get_ducklake_from_agent_http};
|
|
use crate::common::{build_args_values, get_reserved_variables, OccupancyMetrics};
|
|
use crate::handle_child::run_future_with_polling_update_job_poller;
|
|
#[cfg(feature = "mysql")]
|
|
use crate::mysql_executor::MysqlDatabase;
|
|
use crate::sanitized_sql_params::sanitize_and_interpolate_unsafe_sql_args;
|
|
use crate::sql_utils::remove_comments;
|
|
use windmill_common::client::AuthedClient;
|
|
use windmill_object_store::DEFAULT_STORAGE;
|
|
|
|
// What a `// materialize` run records into `materialized_partition` once it
|
|
// finishes. `asset_path` is the full `<name>/<table>` (the asset identity);
|
|
// `partition` is "" for an unpartitioned (whole-table) materialization.
|
|
struct MaterializeExec {
|
|
asset_kind: windmill_common::assets::AssetKind,
|
|
asset_path: String,
|
|
partition: String,
|
|
// Number of `// data_test` checks the codegen embedded. Enforcement recovers
|
|
// the per-test outcomes from the summary row; if it recovers fewer than this
|
|
// (e.g. an FFI serialization change drops the column), we fail loud rather
|
|
// than silently pass declared-but-unverified tests.
|
|
n_data_tests: usize,
|
|
// `on_schema_change=warn` (default) on a positional persist-and-mutate write:
|
|
// the summary carries a `schema_drift` column the worker logs + returns.
|
|
on_schema_change: windmill_parser::asset_parser::OnSchemaChange,
|
|
// `on_schema_change=sync`: the pre-pass probe (setup + target ATTACH + the
|
|
// SELECT's/table's column reads) whose result drives the injected ALTER DDL.
|
|
// `None` for every other mode / non-persist-and-mutate strategy.
|
|
sync_prepass: Option<SyncPrepass>,
|
|
}
|
|
|
|
// Inputs for the `on_schema_change=sync` pre-pass: a probe query read against
|
|
// the live session and the target table it migrates. The worker runs the probe
|
|
// (same interpolation + ATTACH transform as the main query), diffs the SELECT's
|
|
// columns against the table's, and splices `ALTER TABLE … ADD/DROP COLUMN` DDL
|
|
// into the plan at the [`SYNC_ALTER_SENTINEL`] slot before the write.
|
|
struct SyncPrepass {
|
|
// Setup + synthetic target ATTACH + the two column reads. Carries `$args`/
|
|
// `{partition}` verbatim (the worker interpolates it like the main query).
|
|
probe_query: String,
|
|
// Target table within `_wm_target` (e.g. `orders` or `schema.orders`), used
|
|
// to build the quoted `ALTER TABLE _wm_target.<table>` statements.
|
|
target_table: String,
|
|
}
|
|
|
|
// Fetch and validate a custom data-test script's body. v1 custom tests are
|
|
// DuckDB scripts holding a single SELECT/CTE that returns the violating rows
|
|
// (dbt's singular-test convention); the worker embeds that query as a subquery
|
|
// check in the materialize connection (the single-statement constraint is
|
|
// enforced in sql_materialize.rs). Server workers only — agent (Http) workers
|
|
// have no script cache to read deployed content from.
|
|
async fn fetch_custom_test_body(conn: &Connection, w_id: &str, path: &str) -> Result<String> {
|
|
let Connection::Sql(db) = conn else {
|
|
return Err(Error::ExecutionErr(format!(
|
|
"data_test custom `{path}`: custom tests require a server worker (not supported on \
|
|
agent workers in v1)"
|
|
)));
|
|
};
|
|
let hash = windmill_common::get_latest_script_hash(db, path, w_id)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
Error::ExecutionErr(format!(
|
|
"data_test custom `{path}`: no deployed script found at this path"
|
|
))
|
|
})?;
|
|
let content =
|
|
crate::get_script_content_by_hash(&windmill_common::scripts::ScriptHash(hash), w_id, conn)
|
|
.await?;
|
|
if !matches!(
|
|
content.language,
|
|
Some(windmill_common::scripts::ScriptLang::DuckDb)
|
|
) {
|
|
return Err(Error::ExecutionErr(format!(
|
|
"data_test custom `{path}`: must be a DuckDB script returning the violating rows \
|
|
(got language {:?})",
|
|
content.language
|
|
)));
|
|
}
|
|
Ok(content.content)
|
|
}
|
|
|
|
// If `query` declares `// materialize <ducklake>`, return what to record plus,
|
|
// for the default managed mode, the rewritten managed-write SQL (in `manual`
|
|
// mode the script writes its own DDL, so the rewrite is `None`). The rewritten
|
|
// SQL contains a synthetic `ATTACH 'ducklake://<name>' AS _wm_target` that the
|
|
// normal ATTACH-transform pass resolves to real credentials — the same path as
|
|
// the user's own ATTACH. `// data_test` lines append verifier probes that run
|
|
// against the freshly-materialized target and raise (failing the run) on
|
|
// violation. Returns `None` when there is no materialize annotation or the
|
|
// target isn't a ducklake (only ducklake is materialized in v1).
|
|
fn build_materialized_query(
|
|
query: &str,
|
|
partition_value: Option<&str>,
|
|
// Custom (`// data_test <path>`) test bodies, pre-fetched by the caller
|
|
// (`fetch_custom_test_bodies`) so this stays pure/sync and unit-testable —
|
|
// the DB read is the only thing that needs a connection. Keyed by script path.
|
|
custom_test_bodies: &std::collections::HashMap<String, String>,
|
|
) -> Result<Option<(Option<String>, MaterializeExec)>> {
|
|
use windmill_parser::asset_parser::{
|
|
parse_pipeline_annotations, AssetKind as PAssetKind, DataTest,
|
|
};
|
|
use windmill_parser::sql_materialize::{
|
|
build_wrap_blocks, DataTestResolved, MaterializeStrategy, TARGET_ALIAS,
|
|
};
|
|
|
|
let ann = parse_pipeline_annotations(query);
|
|
let has_tests = !ann.data_tests.is_empty();
|
|
let Some(m) = ann.materialize else {
|
|
// Data tests run *against the materialized asset*; without a
|
|
// `// materialize` target there is nothing to test. Fail loudly rather
|
|
// than silently skip the declared checks.
|
|
if has_tests {
|
|
return Err(Error::ExecutionErr(
|
|
"data_test: requires a `// materialize` target — data tests run against the \
|
|
materialized asset"
|
|
.to_string(),
|
|
));
|
|
}
|
|
return Ok(None);
|
|
};
|
|
if m.target_kind != PAssetKind::Ducklake {
|
|
if has_tests {
|
|
return Err(Error::ExecutionErr(
|
|
"data_test: only `ducklake://` materialization targets support data tests in v1"
|
|
.to_string(),
|
|
));
|
|
}
|
|
return Ok(None);
|
|
}
|
|
let partitioned = ann.partition.is_some();
|
|
let partition = partition_value.unwrap_or("").to_string();
|
|
// Partition *resolution* is enterprise; in its absence a partitioned
|
|
// materialize only runs with an explicit `partition` arg. Fail loudly rather
|
|
// than silently materialize the wrong (empty) slice.
|
|
if partitioned && partition.is_empty() {
|
|
return Err(Error::ExecutionErr(
|
|
"materialize: a `// partitioned` script ran with no resolved partition — pass an \
|
|
explicit `partition` arg, or enable enterprise partition resolution"
|
|
.to_string(),
|
|
));
|
|
}
|
|
// Convention: `ducklake://<name>/<table>` — <name> is the configured
|
|
// ducklake (resolved like a user ATTACH), <table> is the rest.
|
|
let (ducklake_name, table) = m
|
|
.target_path
|
|
.split_once('/')
|
|
.unwrap_or((m.target_path.as_str(), ""));
|
|
let mut meta = MaterializeExec {
|
|
asset_kind: windmill_common::assets::AssetKind::Ducklake,
|
|
asset_path: m.target_path.clone(),
|
|
partition: partition.clone(),
|
|
n_data_tests: ann.data_tests.len(),
|
|
on_schema_change: m.on_schema_change,
|
|
// Set below once the strategy is known (managed persist-and-mutate only).
|
|
sync_prepass: None,
|
|
};
|
|
|
|
// `{partition}` → escaped SQL literal substitution, applied to the managed
|
|
// SELECT, its setup, and any custom-test body so a partitioned test can
|
|
// filter by the active slice. Always a complete `'…'` literal (with `'`
|
|
// doubled) whether or not the author quoted it, so a run caller can't break
|
|
// out and alter statement boundaries. The pre-quoted `'{partition}'` form is
|
|
// matched first so it doesn't become `''…''`. No-op when unpartitioned.
|
|
let lit = format!("'{}'", partition.replace('\'', "''"));
|
|
let substitute = |s: &str| -> String {
|
|
if !partitioned {
|
|
return s.to_string();
|
|
}
|
|
let tok = windmill_common::assets::PARTITION_TOKEN;
|
|
let quoted_tok = format!("'{tok}'");
|
|
s.replace("ed_tok, &lit).replace(tok, &lit)
|
|
};
|
|
|
|
if m.manual {
|
|
// Escape hatch: the script owns its DDL. We can't reliably attach the
|
|
// managed target or know the partition column it wrote, so data tests
|
|
// are not generated for manual mode in v1.
|
|
if has_tests {
|
|
return Err(Error::ExecutionErr(
|
|
"data_test: not supported with `// materialize manual` in v1 — use managed \
|
|
`// materialize`"
|
|
.to_string(),
|
|
));
|
|
}
|
|
return Ok(Some((None, meta)));
|
|
}
|
|
if table.is_empty() {
|
|
return Err(Error::ExecutionErr(format!(
|
|
"materialize: target `ducklake://{}` has no table (use ducklake://<name>/<table>)",
|
|
m.target_path
|
|
)));
|
|
}
|
|
let mut plan = classify_wrap_or_err(query)?;
|
|
plan.output = substitute(&plan.output);
|
|
for s in plan.setup.iter_mut() {
|
|
*s = substitute(s);
|
|
}
|
|
// Prepend the `wm_partition(ts)` helper macro for a time-partitioned
|
|
// materialize so the SELECT can filter to the active slice with
|
|
// `WHERE wm_partition(<ts_col>) = {partition}`. Runs first (setup precedes
|
|
// the wrapped SELECT and the write transaction), carries no `{partition}`
|
|
// token so it's inserted post-substitution, and reads its format from the
|
|
// same source the resolver stamped the identity with — so they can't drift.
|
|
if partitioned {
|
|
if let Some(macro_sql) = ann
|
|
.partition
|
|
.as_ref()
|
|
.and_then(windmill_parser::sql_materialize::wm_partition_macro)
|
|
{
|
|
plan.setup.insert(0, macro_sql);
|
|
}
|
|
}
|
|
// Deploy (`create_script_internal`) already rejects the invalid SCD2 combos,
|
|
// but preview/test runs reach the executor without a deploy — re-check so a
|
|
// bad combo fails with the same clear message instead of a raw DuckDB error.
|
|
m.validate(partitioned).map_err(Error::ExecutionErr)?;
|
|
let strategy = if m.scd2 {
|
|
// SCD2 needs a natural key to identify an entity across versions (checked
|
|
// by `validate` above, which guarantees a non-empty key here).
|
|
let key = m.unique_key.clone().ok_or_else(|| {
|
|
Error::ExecutionErr(
|
|
"materialize scd2: requires a natural key — add `key=<col>`".to_string(),
|
|
)
|
|
})?;
|
|
MaterializeStrategy::Scd2 { key, track: m.track.clone(), close_deleted: m.close_deleted }
|
|
} else if m.append {
|
|
MaterializeStrategy::Append
|
|
} else if let Some(uk) = m.unique_key {
|
|
MaterializeStrategy::Merge { unique_key: uk }
|
|
} else {
|
|
MaterializeStrategy::Replace
|
|
};
|
|
// Inline the partition as an escaped SQL literal (DuckLake has no bind for
|
|
// the partition column in our generated DDL).
|
|
let pval = lit.clone();
|
|
let synthetic_attach = format!("ATTACH 'ducklake://{ducklake_name}' AS {TARGET_ALIAS};");
|
|
|
|
// Resolve data tests (fetch + partition-substitute custom bodies) so codegen
|
|
// can embed every check's violating-row count in the materialize summary.
|
|
// The summary then carries the full per-test breakdown back to the worker,
|
|
// which runs them all and decides pass/fail (no abort-on-first). Empty when
|
|
// there are no `// data_test` lines.
|
|
let mut resolved = Vec::with_capacity(ann.data_tests.len());
|
|
for test in &ann.data_tests {
|
|
match test {
|
|
DataTest::Custom { path } => {
|
|
let raw = custom_test_bodies.get(path).ok_or_else(|| {
|
|
Error::ExecutionErr(format!(
|
|
"data_test custom `{path}`: body not fetched before codegen (internal)"
|
|
))
|
|
})?;
|
|
resolved
|
|
.push(DataTestResolved::Custom { path: path.clone(), body: substitute(raw) });
|
|
}
|
|
other => resolved.push(DataTestResolved::BuiltIn(other.clone())),
|
|
}
|
|
}
|
|
|
|
let cg_probe = windmill_parser::sql_materialize::MaterializeCodegen {
|
|
target_qualified: "",
|
|
select_sql: "",
|
|
partition_col: "_wm_partition",
|
|
partition_value_sql: "",
|
|
partitioned,
|
|
strategy: strategy.clone(),
|
|
on_schema_change: m.on_schema_change,
|
|
};
|
|
// `sync` needs a host round-trip: a pre-pass probe reads the SELECT's and the
|
|
// table's columns so the ALTER DDL can be computed, then spliced into the
|
|
// plan at the sentinel slot. Build the probe query here (setup + target
|
|
// ATTACH + the two column reads) while we still have the substituted SELECT +
|
|
// setup; the executor runs it through the same interpolation/ATTACH transform
|
|
// as the main query. Only for the positional persist-and-mutate strategies
|
|
// (scd2 / whole-table replace emit no sentinel).
|
|
if m.on_schema_change == windmill_parser::asset_parser::OnSchemaChange::Sync
|
|
&& cg_probe.is_persist_and_mutate()
|
|
{
|
|
meta.sync_prepass = Some(SyncPrepass {
|
|
probe_query: build_sync_probe_query(
|
|
&plan.setup,
|
|
&synthetic_attach,
|
|
&plan.output,
|
|
table,
|
|
),
|
|
target_table: table.to_string(),
|
|
});
|
|
}
|
|
|
|
let mat_plan = build_wrap_blocks(
|
|
&plan,
|
|
&synthetic_attach,
|
|
table,
|
|
&m.target_path,
|
|
"_wm_partition",
|
|
&pval,
|
|
partitioned,
|
|
strategy,
|
|
m.on_schema_change,
|
|
&resolved,
|
|
)
|
|
.map_err(Error::ExecutionErr)?;
|
|
// Enterprise seam: assembles the plan into the final statement list —
|
|
// verbatim on the public build (commit-then-test), restructured into
|
|
// write-audit-publish (guarded transaction, rollback on violation) on EE.
|
|
let blocks =
|
|
windmill_common::pipeline_advanced::finalize_materialize_query(mat_plan, &m.target_path);
|
|
|
|
Ok(Some((Some(blocks.join("\n")), meta)))
|
|
}
|
|
|
|
// Build the `on_schema_change=sync` pre-pass probe: the substituted setup + the
|
|
// synthetic target ATTACH, then two column reads unioned — the SELECT's columns
|
|
// (via `DESCRIBE`) tagged `sel`, and the target table's (via information_schema,
|
|
// which yields zero rows when the table doesn't exist yet, so a first
|
|
// materialize is a no-drift skip rather than an error) tagged `tbl`. The managed
|
|
// `_wm_partition` column is filtered out of the table side. Carries
|
|
// `$args`/`{partition}` verbatim; the caller interpolates + ATTACH-transforms it
|
|
// like the main query so the DESCRIBE binds run-time args.
|
|
fn build_sync_probe_query(
|
|
setup: &[String],
|
|
synthetic_attach: &str,
|
|
select_sql: &str,
|
|
table: &str,
|
|
) -> String {
|
|
let (schema, tname) = match table.split_once('.') {
|
|
Some((s, t)) => (Some(s), t),
|
|
None => (None, table),
|
|
};
|
|
let esc = |s: &str| s.replace('\'', "''");
|
|
let schema_filter = schema
|
|
.map(|s| format!(" AND table_schema = '{}'", esc(s)))
|
|
.unwrap_or_default();
|
|
let mut out = String::new();
|
|
let mut push_stmt = |s: &str| {
|
|
let t = s.trim_end();
|
|
if t.is_empty() {
|
|
return;
|
|
}
|
|
out.push_str(t);
|
|
if !t.ends_with(';') {
|
|
out.push(';');
|
|
}
|
|
out.push('\n');
|
|
};
|
|
for s in setup {
|
|
push_stmt(s);
|
|
}
|
|
push_stmt(synthetic_attach);
|
|
// Read the target's columns from `information_schema.columns` scoped to the
|
|
// attached target catalog via `table_catalog` (DuckDB's catalog-qualified
|
|
// `<db>.information_schema` does NOT exist; the unqualified view spans
|
|
// attached catalogs and distinguishes them by `table_catalog`). It yields
|
|
// zero rows for a not-yet-created table — a no-drift skip, not an error
|
|
// (unlike `pragma_table_info`/`DESCRIBE`).
|
|
out.push_str(&format!(
|
|
"SELECT 'sel' AS _wm_which, column_name AS _wm_name, column_type AS _wm_type \
|
|
FROM (DESCRIBE SELECT * FROM ({select_sql})) \
|
|
UNION ALL \
|
|
SELECT 'tbl' AS _wm_which, column_name AS _wm_name, data_type AS _wm_type \
|
|
FROM information_schema.columns \
|
|
WHERE table_catalog = '{alias}' AND table_name = '{tname}'{schema_filter} \
|
|
AND column_name <> '_wm_partition';",
|
|
alias = windmill_parser::sql_materialize::TARGET_ALIAS,
|
|
tname = esc(tname),
|
|
));
|
|
out
|
|
}
|
|
|
|
// Run the sync pre-pass probe (already interpolated + ATTACH-transformed by the
|
|
// caller) and turn its column diff into the `ALTER TABLE … ADD/DROP COLUMN` DDL
|
|
// to splice at the sentinel. Empty string when the table is fresh (no rows) or
|
|
// the SELECT already matches. The probe failing is fatal (no silent fallback).
|
|
async fn compute_sync_alter_ddl(
|
|
probe_blocks: Vec<String>,
|
|
job_args: Vec<Arg>,
|
|
target_table: String,
|
|
token: String,
|
|
base_internal_url: String,
|
|
w_id: String,
|
|
job_dir: String,
|
|
) -> Result<String> {
|
|
let n = probe_blocks.len();
|
|
let (result, _) = tokio::task::spawn_blocking(move || {
|
|
run_duckdb_ffi_safe(
|
|
probe_blocks.iter().map(String::as_str),
|
|
n,
|
|
job_args,
|
|
&token,
|
|
&base_internal_url,
|
|
&w_id,
|
|
&job_dir,
|
|
SqlResultCollectionStrategy::LastStatementAllRows,
|
|
)
|
|
})
|
|
.await
|
|
.map_err(|e| Error::from(to_anyhow(e)))
|
|
.and_then(|r| r)
|
|
.map_err(|e| {
|
|
Error::ExecutionErr(format!(
|
|
"on_schema_change=sync: the schema pre-pass probe failed ({e}); refusing to write"
|
|
))
|
|
})?;
|
|
|
|
let (added, removed) = parse_sync_drift(&result)?;
|
|
let tq = format!(
|
|
"{}.{}",
|
|
windmill_parser::sql_materialize::TARGET_ALIAS,
|
|
quote_qualified_table(&target_table)
|
|
);
|
|
let mut stmts = Vec::new();
|
|
for (name, typ) in &added {
|
|
if typ.is_empty() {
|
|
return Err(Error::ExecutionErr(format!(
|
|
"on_schema_change=sync: could not resolve a type for new column `{name}`"
|
|
)));
|
|
}
|
|
// `typ` is DuckDB's own DESCRIBE type name (e.g. BIGINT, VARCHAR,
|
|
// DECIMAL(10,2)) — a valid type expression, injected verbatim.
|
|
stmts.push(format!(
|
|
"ALTER TABLE {tq} ADD COLUMN \"{}\" {typ};",
|
|
name.replace('"', "\"\"")
|
|
));
|
|
}
|
|
for name in &removed {
|
|
stmts.push(format!(
|
|
"ALTER TABLE {tq} DROP COLUMN \"{}\";",
|
|
name.replace('"', "\"\"")
|
|
));
|
|
}
|
|
Ok(stmts.join("\n"))
|
|
}
|
|
|
|
// Parse the sync probe result rows (`_wm_which`/`_wm_name`/`_wm_type`) into the
|
|
// column diff: `added` = SELECT columns (with their types) absent from the
|
|
// table, `removed` = table columns absent from the SELECT. `_wm_partition` and
|
|
// empty names are ignored. An empty table side means the table doesn't exist
|
|
// yet — a no-op first materialize, so both lists are empty.
|
|
fn parse_sync_drift(result: &RawValue) -> Result<(Vec<(String, String)>, Vec<String>)> {
|
|
let root: Value = serde_json::from_str(result.get()).map_err(|e| {
|
|
Error::ExecutionErr(format!("on_schema_change=sync: bad probe result json: {e}"))
|
|
})?;
|
|
// The FFI may surface the rows as an array, a single object, or a JSON
|
|
// string — normalize to a list of row objects.
|
|
let owned;
|
|
let rows: Vec<&Value> = match &root {
|
|
Value::Array(a) => a.iter().collect(),
|
|
Value::Object(_) => vec![&root],
|
|
Value::String(s) => {
|
|
owned = serde_json::from_str::<Value>(s).unwrap_or(Value::Null);
|
|
match &owned {
|
|
Value::Array(a) => a.iter().collect(),
|
|
Value::Object(_) => vec![&owned],
|
|
_ => vec![],
|
|
}
|
|
}
|
|
_ => vec![],
|
|
};
|
|
let mut sel: Vec<(String, String)> = Vec::new();
|
|
let mut tbl: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
for row in rows {
|
|
let Some(o) = row.as_object() else { continue };
|
|
let name = o
|
|
.get("_wm_name")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
if name.is_empty() || name == "_wm_partition" {
|
|
continue;
|
|
}
|
|
let typ = o
|
|
.get("_wm_type")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
match o.get("_wm_which").and_then(|v| v.as_str()).unwrap_or("") {
|
|
"sel" => sel.push((name, typ)),
|
|
"tbl" => {
|
|
tbl.insert(name);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
if tbl.is_empty() {
|
|
return Ok((Vec::new(), Vec::new()));
|
|
}
|
|
let sel_names: std::collections::HashSet<&String> = sel.iter().map(|(n, _)| n).collect();
|
|
let added = sel
|
|
.iter()
|
|
.filter(|(n, _)| !tbl.contains(n))
|
|
.cloned()
|
|
.collect();
|
|
let removed = tbl.into_iter().filter(|n| !sel_names.contains(n)).collect();
|
|
Ok((added, removed))
|
|
}
|
|
|
|
// Fetch the deployed body of every `// data_test <path>` custom test declared in
|
|
// `query`, keyed by path, so the sync `build_materialized_query` can splice them
|
|
// in. The DB read is the only part of materialize codegen that needs a
|
|
// connection; isolating it here keeps the codegen pure and unit-testable.
|
|
// Server workers only (`fetch_custom_test_body` errors on agent workers). Empty
|
|
// when there are no custom tests.
|
|
async fn fetch_custom_test_bodies(
|
|
query: &str,
|
|
conn: &Connection,
|
|
w_id: &str,
|
|
) -> Result<std::collections::HashMap<String, String>> {
|
|
use windmill_parser::asset_parser::{parse_pipeline_annotations, DataTest};
|
|
let ann = parse_pipeline_annotations(query);
|
|
let mut bodies = std::collections::HashMap::new();
|
|
for test in &ann.data_tests {
|
|
if let DataTest::Custom { path } = test {
|
|
if !bodies.contains_key(path) {
|
|
let body = fetch_custom_test_body(conn, w_id, path).await?;
|
|
bodies.insert(path.clone(), body);
|
|
}
|
|
}
|
|
}
|
|
Ok(bodies)
|
|
}
|
|
|
|
// classify_wrap with the spec's actionable message turned into an executor error.
|
|
fn classify_wrap_or_err(query: &str) -> Result<windmill_parser::sql_materialize::WrapPlan> {
|
|
windmill_parser::sql_materialize::classify_wrap(query)
|
|
.map_err(|e| Error::ExecutionErr(e.message()))
|
|
}
|
|
|
|
// One workspace-macro registry row (`macro_definition`, written at deploy of a
|
|
// `// macros` library script). Shared with windmill-common so the registry
|
|
// cache can live there (main.rs evicts it on `notify_macro_registry_change`).
|
|
use windmill_common::assets::MacroRegistryEntry as MacroRow;
|
|
|
|
// The registry read runs on every DuckDB job (including the common macro-free
|
|
// case), so it's cached per workspace. Invalidation is primarily the
|
|
// transactional `notify_macro_registry_change` event (registry mutations →
|
|
// notify_event table → the poller in main.rs evicts); the TTL bounds
|
|
// staleness for anything that doesn't emit.
|
|
async fn fetch_macro_registry(
|
|
db: &windmill_common::DB,
|
|
w_id: &str,
|
|
) -> Result<std::sync::Arc<Vec<MacroRow>>> {
|
|
use windmill_common::assets::{
|
|
ExpiringMacroRegistry, MACRO_REGISTRY_CACHE, MACRO_REGISTRY_CACHE_DISABLED,
|
|
MACRO_REGISTRY_TTL,
|
|
};
|
|
// Cloud: unbounded workspace count makes the 1000-entry per-workspace
|
|
// cache churn instead of hit, and its per-entry memory (full registry
|
|
// text) is tenant-controlled — skip it there; self-hosted keeps the fast
|
|
// path.
|
|
let use_cache = !*windmill_common::worker::CLOUD_HOSTED
|
|
&& !MACRO_REGISTRY_CACHE_DISABLED.load(std::sync::atomic::Ordering::Relaxed);
|
|
if use_cache {
|
|
if let Some(e) = MACRO_REGISTRY_CACHE.get(w_id) {
|
|
if e.expires_at > std::time::Instant::now() {
|
|
return Ok(e.rows);
|
|
}
|
|
}
|
|
}
|
|
let rows = sqlx::query_as!(
|
|
MacroRow,
|
|
"SELECT name, params, body, is_table_macro, provider_path FROM macro_definition WHERE workspace_id = $1",
|
|
w_id
|
|
)
|
|
.fetch_all(db)
|
|
.await?;
|
|
let rows = std::sync::Arc::new(rows);
|
|
if use_cache {
|
|
MACRO_REGISTRY_CACHE.insert(
|
|
w_id.to_string(),
|
|
ExpiringMacroRegistry {
|
|
rows: rows.clone(),
|
|
expires_at: std::time::Instant::now() + MACRO_REGISTRY_TTL,
|
|
},
|
|
);
|
|
}
|
|
Ok(rows)
|
|
}
|
|
|
|
// Selection pass: seed = macros the (comment-stripped, transformed) blocks
|
|
// call, plus every macro of each `// use` library; then the transitive
|
|
// closure over macro bodies. Local definitions win — their names are removed
|
|
// from the injectable set entirely, so a later workspace-library deploy can
|
|
// never silently replace a script's own macro. Pure and separate from
|
|
// `plan_macro_injection` so the shell knows which provider libraries to
|
|
// fetch (their setup statements are injected too) before planning.
|
|
fn select_workspace_macros(
|
|
blocks: &[String],
|
|
registry: &[MacroRow],
|
|
use_libs: &[String],
|
|
) -> Result<std::collections::HashSet<String>> {
|
|
use std::collections::{BTreeMap, HashSet};
|
|
use windmill_parser::duckdb_macros::{detect_macro_calls, locally_defined_macro_names};
|
|
|
|
let local = locally_defined_macro_names(blocks);
|
|
let all_names: HashSet<String> = registry
|
|
.iter()
|
|
.map(|r| r.name.clone())
|
|
.filter(|n| !local.contains(n))
|
|
.collect();
|
|
let by_name: BTreeMap<&str, &MacroRow> =
|
|
registry.iter().map(|r| (r.name.as_str(), r)).collect();
|
|
|
|
let mut selected: HashSet<String> = HashSet::new();
|
|
for path in use_libs {
|
|
let lib_names: Vec<&str> = registry
|
|
.iter()
|
|
.filter(|r| &r.provider_path == path)
|
|
.map(|r| r.name.as_str())
|
|
.collect();
|
|
if lib_names.is_empty() {
|
|
return Err(Error::ExecutionErr(format!(
|
|
"`// use {path}`: no deployed macro library at this path"
|
|
)));
|
|
}
|
|
selected.extend(
|
|
lib_names
|
|
.into_iter()
|
|
.filter(|n| all_names.contains(*n))
|
|
.map(String::from),
|
|
);
|
|
}
|
|
selected.extend(detect_macro_calls(&blocks.join("\n"), &all_names));
|
|
|
|
// Transitive closure over macro bodies (a selected macro may call others).
|
|
let mut frontier: Vec<String> = selected.iter().cloned().collect();
|
|
while let Some(name) = frontier.pop() {
|
|
if let Some(row) = by_name.get(name.as_str()) {
|
|
for dep in detect_macro_calls(&row.body, &all_names) {
|
|
if selected.insert(dep.clone()) {
|
|
frontier.push(dep);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(selected)
|
|
}
|
|
|
|
// Fixpoint over library-level `// use`: a library may declare `// use` for
|
|
// dynamic calls its macro bodies make (string-hidden from lexical detection,
|
|
// e.g. inside `query('…')`). Those declarations are honored transitively —
|
|
// consuming a macro from lib B pulls in whatever B `// use`s, so the dynamic
|
|
// dependency stays encapsulated in the library instead of leaking to every
|
|
// consumer. `lib_uses` maps provider path → its parsed `// use` list; the
|
|
// shell grows it lazily and re-resolves until no new library appears.
|
|
// Returns the selected macro names plus the effective `// use` list
|
|
// (consumer's own first, in annotation order, then discovered libs).
|
|
fn resolve_macro_selection(
|
|
blocks: &[String],
|
|
registry: &[MacroRow],
|
|
consumer_use_libs: &[String],
|
|
lib_uses: &std::collections::BTreeMap<String, Vec<String>>,
|
|
) -> Result<(std::collections::HashSet<String>, Vec<String>)> {
|
|
let mut effective: Vec<String> = Vec::new();
|
|
for p in consumer_use_libs {
|
|
if !effective.contains(p) {
|
|
effective.push(p.clone());
|
|
}
|
|
}
|
|
loop {
|
|
let selected = select_workspace_macros(blocks, registry, &effective)?;
|
|
let mut relevant: Vec<String> = effective.clone();
|
|
for name in &selected {
|
|
if let Some(r) = registry.iter().find(|r| &r.name == name) {
|
|
if !relevant.contains(&r.provider_path) {
|
|
relevant.push(r.provider_path.clone());
|
|
}
|
|
}
|
|
}
|
|
let mut grew = false;
|
|
for lib in &relevant {
|
|
for u in lib_uses.get(lib).map(Vec::as_slice).unwrap_or(&[]) {
|
|
if !effective.contains(u) {
|
|
effective.push(u.clone());
|
|
grew = true;
|
|
}
|
|
}
|
|
}
|
|
if !grew {
|
|
return Ok((selected, effective));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Emit the blocks to inject: first every relevant library's setup statements
|
|
// (a macro body may reference its own lib's ATTACH, and DuckDB bind-checks
|
|
// at CREATE — so setup is injected for the provider of every selected macro,
|
|
// not just `// use` libs), then the selected definitions in dependency order.
|
|
// `lib_bodies` carries the parsed deployed content per provider path; setup
|
|
// order = `// use` libs in annotation order, then remaining providers sorted.
|
|
// Exact-duplicate setup statements across libraries are deduped (two libs
|
|
// attaching the same catalog the same way must not double-ATTACH). Pure —
|
|
// fetches happen in `inject_workspace_macros` — so this is unit-testable.
|
|
fn plan_macro_injection(
|
|
selected: &std::collections::HashSet<String>,
|
|
registry: &[MacroRow],
|
|
use_libs: &[String],
|
|
lib_bodies: &std::collections::BTreeMap<
|
|
String,
|
|
Vec<windmill_parser::duckdb_macros::LibStatement>,
|
|
>,
|
|
) -> Result<Vec<String>> {
|
|
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
|
use windmill_parser::duckdb_macros::{macro_create_statement, topo_order_macros, LibStatement};
|
|
|
|
let by_name: BTreeMap<&str, &MacroRow> =
|
|
registry.iter().map(|r| (r.name.as_str(), r)).collect();
|
|
|
|
// Providers whose setup must run: every `// use` lib (annotation order —
|
|
// even with zero selected macros, the explicit `use` carries its ATTACH
|
|
// side effects), then the provider of each selected macro (sorted).
|
|
let mut providers: Vec<&str> = use_libs.iter().map(String::as_str).collect();
|
|
let selected_providers: BTreeSet<&str> = selected
|
|
.iter()
|
|
.filter_map(|n| by_name.get(n.as_str()).map(|r| r.provider_path.as_str()))
|
|
.collect();
|
|
for p in selected_providers {
|
|
if !providers.contains(&p) {
|
|
providers.push(p);
|
|
}
|
|
}
|
|
|
|
let mut injected: Vec<String> = Vec::new();
|
|
let mut seen_setup: HashSet<String> = HashSet::new();
|
|
for provider in providers {
|
|
let Some(statements) = lib_bodies.get(provider) else {
|
|
return Err(Error::ExecutionErr(format!(
|
|
"macro library `{provider}` has registry entries but its deployed script could \
|
|
not be loaded"
|
|
)));
|
|
};
|
|
for s in statements {
|
|
if let LibStatement::Setup(stmt) = s {
|
|
let stmt = format!("{};", stmt.trim_end_matches(';').trim_end());
|
|
if seen_setup.insert(stmt.clone()) {
|
|
injected.push(stmt);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if selected.is_empty() {
|
|
return Ok(injected);
|
|
}
|
|
let defs: BTreeMap<String, String> = selected
|
|
.iter()
|
|
.filter_map(|n| by_name.get(n.as_str()).map(|r| (n.clone(), r.body.clone())))
|
|
.collect();
|
|
let order = topo_order_macros(selected, &defs).map_err(Error::ExecutionErr)?;
|
|
for name in order {
|
|
let r = by_name.get(name.as_str()).ok_or_else(|| {
|
|
Error::ExecutionErr(format!("workspace macro `{name}` has no registry row"))
|
|
})?;
|
|
injected.push(macro_create_statement(
|
|
&r.name,
|
|
&r.params,
|
|
r.is_table_macro,
|
|
&r.body,
|
|
));
|
|
}
|
|
Ok(injected)
|
|
}
|
|
|
|
// Weave the injected blocks into the user's statement list. DuckDB
|
|
// bind-checks macro bodies at CREATE, in both directions:
|
|
// - an injected definition that a LOCAL macro calls must land *before*
|
|
// that local definition (pulling its own injected dependencies with it);
|
|
// - an injected definition that references a local macro must stay *after*
|
|
// it — a conflict between the two requirements is an error, not a
|
|
// silent mis-ordering.
|
|
// The default slot (no local interplay) is after the leading prefix of setup
|
|
// statements and local definitions, as before. Injected setup statements
|
|
// (library ATTACHes) go at the earliest slot any injected definition landed
|
|
// on: injected bodies are self-contained w.r.t. their own library's setup
|
|
// and never depend on the consumer's setup.
|
|
fn weave_macro_blocks(blocks: Vec<String>, injected: Vec<String>) -> Result<Vec<String>> {
|
|
if injected.is_empty() {
|
|
return Ok(blocks);
|
|
}
|
|
use std::collections::HashSet;
|
|
use windmill_parser::duckdb_macros::{
|
|
detect_macro_calls, is_macro_definition, parse_macro_definition,
|
|
};
|
|
use windmill_parser::sql_materialize::{classify_block, BlockClass};
|
|
|
|
// Default slot: insert before the first block that is neither setup nor
|
|
// a local macro definition (i.e. after the whole leading prefix).
|
|
let default_slot = blocks
|
|
.iter()
|
|
.position(|b| !matches!(classify_block(b), BlockClass::Setup) && !is_macro_definition(b))
|
|
.unwrap_or(blocks.len());
|
|
|
|
// The user's own macro definitions, as placement anchors.
|
|
let locals: Vec<(usize, windmill_parser::duckdb_macros::ParsedMacro)> = blocks
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(i, b)| parse_macro_definition(b).map(|m| (i, m)))
|
|
.collect();
|
|
let local_names: HashSet<String> = locals.iter().map(|(_, m)| m.name.clone()).collect();
|
|
|
|
// Plan emits [lib setup…, definitions in topo order…].
|
|
let (setup, defs): (Vec<String>, Vec<String>) =
|
|
injected.into_iter().partition(|s| !is_macro_definition(s));
|
|
let def_metas: Vec<windmill_parser::duckdb_macros::ParsedMacro> = defs
|
|
.iter()
|
|
.map(|s| {
|
|
parse_macro_definition(s).ok_or_else(|| {
|
|
Error::ExecutionErr(format!(
|
|
"internal: generated macro statement failed to re-parse: {}",
|
|
s.chars().take(80).collect::<String>()
|
|
))
|
|
})
|
|
})
|
|
.collect::<Result<_>>()?;
|
|
let def_names: HashSet<String> = def_metas.iter().map(|m| m.name.clone()).collect();
|
|
|
|
// Per-injected-definition slot bounds from the local anchors. `slot = i`
|
|
// means "insert before block i".
|
|
let mut max_slot: Vec<usize> = vec![default_slot; defs.len()];
|
|
let mut min_slot: Vec<usize> = vec![0; defs.len()];
|
|
for (li, lm) in &locals {
|
|
let local_calls = detect_macro_calls(&lm.body, &def_names);
|
|
let referenced_locals_of: Vec<usize> = def_metas
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, dm)| detect_macro_calls(&dm.body, &local_names).contains(&lm.name))
|
|
.map(|(di, _)| di)
|
|
.collect();
|
|
for (di, dm) in def_metas.iter().enumerate() {
|
|
if local_calls.contains(&dm.name) {
|
|
max_slot[di] = max_slot[di].min(*li);
|
|
}
|
|
}
|
|
for di in referenced_locals_of {
|
|
min_slot[di] = min_slot[di].max(*li + 1);
|
|
}
|
|
}
|
|
// An injected definition must not land after any injected definition
|
|
// that depends on it: propagate upper bounds backwards through the topo
|
|
// order (dependents come later in `defs`).
|
|
let mut eff_slot = max_slot.clone();
|
|
for i in (0..defs.len()).rev() {
|
|
for j in (i + 1)..defs.len() {
|
|
if detect_macro_calls(&def_metas[j].body, &def_names).contains(&def_metas[i].name) {
|
|
eff_slot[i] = eff_slot[i].min(eff_slot[j]);
|
|
}
|
|
}
|
|
if min_slot[i] > eff_slot[i] {
|
|
return Err(Error::ExecutionErr(format!(
|
|
"workspace macro `{}` and this script's own macro definitions have conflicting \
|
|
order requirements (a local macro calls it while it references a local macro \
|
|
defined later); reorder the local definitions",
|
|
def_metas[i].name
|
|
)));
|
|
}
|
|
}
|
|
// Library setup runs before everything: the statements are self-contained
|
|
// (validated plain/non-managed at lib deploy, within-lib order preserved)
|
|
// and ANY consumer statement may depend on them at bind time — including
|
|
// a leading local macro definition that shadows a lib macro (so no def is
|
|
// injected) but still references the lib's ATTACHed catalog.
|
|
let mut out: Vec<String> = Vec::with_capacity(blocks.len() + defs.len() + setup.len());
|
|
out.extend(setup);
|
|
for slot in 0..=blocks.len() {
|
|
for (di, d) in defs.iter().enumerate() {
|
|
if eff_slot[di] == slot {
|
|
out.push(d.clone());
|
|
}
|
|
}
|
|
if let Some(b) = blocks.get(slot) {
|
|
out.push(b.clone());
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
// Workspace-macro injection (`// macros` libraries): fetch the registry, plan
|
|
// the needed `CREATE OR REPLACE TEMP MACRO` blocks and splice them into the
|
|
// job's statement list. Late-bound: every run reads the current registry, so a
|
|
// lib redeploy applies to the next run. On agent (Http) workers — no DB — the
|
|
// implicit path silently degrades (a called macro then fails with DuckDB's
|
|
// clear Catalog Error) but an explicit `// use` hard-errors like custom tests.
|
|
async fn inject_workspace_macros(
|
|
conn: &Connection,
|
|
w_id: &str,
|
|
is_macro_lib: bool,
|
|
use_libs: &[String],
|
|
blocks: Vec<String>,
|
|
) -> Result<Vec<String>> {
|
|
if is_macro_lib {
|
|
// A library run executes its own definitions; nothing to inject.
|
|
return Ok(blocks);
|
|
}
|
|
let db = match conn {
|
|
Connection::Sql(db) => db,
|
|
Connection::Http(_) => {
|
|
if !use_libs.is_empty() {
|
|
return Err(Error::ExecutionErr(
|
|
"`// use` requires a server worker (not supported on agent workers in v1)"
|
|
.to_string(),
|
|
));
|
|
}
|
|
return Ok(blocks);
|
|
}
|
|
};
|
|
let registry = fetch_macro_registry(db, w_id).await?;
|
|
if registry.is_empty() && use_libs.is_empty() {
|
|
return Ok(blocks);
|
|
}
|
|
if select_workspace_macros(&blocks, ®istry, use_libs)?.is_empty() && use_libs.is_empty() {
|
|
return Ok(blocks);
|
|
}
|
|
// Every relevant provider's deployed body is fetched: its setup
|
|
// statements are injected ahead of the definitions (a macro body may
|
|
// reference its own lib's ATTACH, which must run before the injected
|
|
// CREATE binds), and its own `// use` declarations are honored
|
|
// transitively — so the loop fetches lazily and re-resolves until no new
|
|
// library appears. Content fetches are cached by hash.
|
|
let mut lib_bodies: std::collections::BTreeMap<
|
|
String,
|
|
Vec<windmill_parser::duckdb_macros::LibStatement>,
|
|
> = Default::default();
|
|
let mut lib_uses: std::collections::BTreeMap<String, Vec<String>> = Default::default();
|
|
let (selected, effective_use) = loop {
|
|
let (selected, effective) =
|
|
resolve_macro_selection(&blocks, ®istry, use_libs, &lib_uses)?;
|
|
let mut relevant: Vec<String> = effective.clone();
|
|
for name in &selected {
|
|
if let Some(r) = registry.iter().find(|r| &r.name == name) {
|
|
if !relevant.contains(&r.provider_path) {
|
|
relevant.push(r.provider_path.clone());
|
|
}
|
|
}
|
|
}
|
|
let missing: Vec<String> = relevant
|
|
.into_iter()
|
|
.filter(|l| !lib_bodies.contains_key(l))
|
|
.collect();
|
|
if missing.is_empty() {
|
|
break (selected, effective);
|
|
}
|
|
for path in missing {
|
|
let content = fetch_macro_lib_body(conn, w_id, &path).await?;
|
|
let statements = windmill_parser::duckdb_macros::parse_macro_library(&content)
|
|
.map_err(|e| {
|
|
Error::ExecutionErr(format!("macro library `{path}`: invalid content: {e}"))
|
|
})?;
|
|
lib_uses.insert(
|
|
path.clone(),
|
|
windmill_parser::asset_parser::parse_pipeline_annotations(&content).use_libs,
|
|
);
|
|
lib_bodies.insert(path, statements);
|
|
}
|
|
};
|
|
let injected = plan_macro_injection(&selected, ®istry, &effective_use, &lib_bodies)?;
|
|
weave_macro_blocks(blocks, injected)
|
|
}
|
|
|
|
// Fetch a macro library's deployed body — for its setup statements; the
|
|
// macro definitions themselves come from the registry. Used both for `// use`
|
|
// libs and for the provider of every selected macro. Same server-worker
|
|
// fetch path as custom data tests.
|
|
async fn fetch_macro_lib_body(conn: &Connection, w_id: &str, path: &str) -> Result<String> {
|
|
let Connection::Sql(db) = conn else {
|
|
return Err(Error::ExecutionErr(
|
|
"workspace macros require a server worker (not supported on agent workers in v1)"
|
|
.to_string(),
|
|
));
|
|
};
|
|
let hash = windmill_common::get_latest_script_hash(db, path, w_id)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
Error::ExecutionErr(format!(
|
|
"macro library `{path}`: no deployed script found at this path"
|
|
))
|
|
})?;
|
|
let content =
|
|
crate::get_script_content_by_hash(&windmill_common::scripts::ScriptHash(hash), w_id, conn)
|
|
.await?;
|
|
if !matches!(
|
|
content.language,
|
|
Some(windmill_common::scripts::ScriptLang::DuckDb)
|
|
) {
|
|
return Err(Error::ExecutionErr(format!(
|
|
"macro library `{path}`: must be a DuckDB `// macros` library (got language {:?})",
|
|
content.language
|
|
)));
|
|
}
|
|
Ok(content.content)
|
|
}
|
|
|
|
// Pull a named i64 field (`snapshot_id` / `rows`) out of the trailing summary
|
|
// read — which in wrap mode is the job result. Shape-tolerant (object / array /
|
|
// nested), returns None if absent (literal mode, or capture failed).
|
|
fn extract_i64(result: &RawValue, field: &str) -> Option<i64> {
|
|
fn find(v: &Value, field: &str) -> Option<i64> {
|
|
match v {
|
|
Value::Number(n) => n.as_i64(),
|
|
Value::Object(m) => m.get(field).and_then(|x| find(x, field)),
|
|
Value::Array(a) => a.iter().find_map(|x| find(x, field)),
|
|
_ => None,
|
|
}
|
|
}
|
|
find(&serde_json::from_str::<Value>(result.get()).ok()?, field)
|
|
}
|
|
|
|
// One data test's outcome as carried by the materialize summary's `data_tests`
|
|
// column: its display name, how many rows violated it (0 = pass), and an
|
|
// optional bounded sample of the violating rows. The sample is decoration
|
|
// only — enforcement reads `violating`, never `sample` — so a NULL, dropped
|
|
// (over the size cap) or unparseable sample must never affect pass/fail.
|
|
struct DataTestOutcome {
|
|
name: String,
|
|
violating: i64,
|
|
sample: Option<Value>,
|
|
}
|
|
|
|
// Pull the per-test breakdown out of the materialize summary result. The
|
|
// `data_tests` column is a DuckLake list-of-struct `[{test, violating}, …]`;
|
|
// the FFI may surface it as a nested JSON array or as a JSON string, so accept
|
|
// both. Returns empty when there are no tests (the column is absent).
|
|
fn extract_data_tests(result: &RawValue) -> Vec<DataTestOutcome> {
|
|
fn collect(v: &Value, out: &mut Vec<DataTestOutcome>) {
|
|
if let Value::Array(arr) = v {
|
|
for item in arr {
|
|
if let Value::Object(o) = item {
|
|
if let Some(Value::String(name)) = o.get("test") {
|
|
let violating = o
|
|
.get("violating")
|
|
.and_then(|x| x.as_i64().or_else(|| x.as_f64().map(|f| f as i64)))
|
|
.unwrap_or(0);
|
|
// The probe serializes the sample as a JSON *string*
|
|
// (a VARCHAR through the FFI), deliberately — parse it
|
|
// only here, so sampled user columns named `rows` /
|
|
// `snapshot_id` stay invisible to the key-recursive
|
|
// `extract_i64` scans over the summary result. Accept
|
|
// a native array too (FFI JSON-column quirk); anything
|
|
// else (NULL, size-capped, garbage) degrades to None.
|
|
let sample = o.get("sample").and_then(|s| match s {
|
|
arr @ Value::Array(_) => Some(arr.clone()),
|
|
Value::String(txt) => serde_json::from_str::<Value>(txt)
|
|
.ok()
|
|
.filter(|v| v.is_array()),
|
|
_ => None,
|
|
});
|
|
out.push(DataTestOutcome { name: name.clone(), violating, sample });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
fn find_field(v: &Value) -> Option<&Value> {
|
|
match v {
|
|
Value::Object(o) => o.get("data_tests"),
|
|
Value::Array(a) => a.iter().find_map(find_field),
|
|
_ => None,
|
|
}
|
|
}
|
|
let mut out = Vec::new();
|
|
let Ok(root) = serde_json::from_str::<Value>(result.get()) else {
|
|
return out;
|
|
};
|
|
match find_field(&root) {
|
|
Some(arr @ Value::Array(_)) => collect(arr, &mut out),
|
|
// FFI serialized the list-of-struct as a JSON string — parse it.
|
|
Some(Value::String(s)) => {
|
|
if let Ok(parsed) = serde_json::from_str::<Value>(s) {
|
|
collect(&parsed, &mut out);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
out
|
|
}
|
|
|
|
// Pull the captured output schema out of the materialize summary's
|
|
// `output_schema` column (gap #2a): a list-of-struct `[{name, type}, …]` the
|
|
// codegen built from a `DESCRIBE`. Like `data_tests`, the FFI may surface it as
|
|
// a nested JSON array or a JSON string — accept both. Returns `None` when the
|
|
// column is absent (literal mode, manual mode, or capture failed) so the worker
|
|
// records the run without a schema rather than an empty one.
|
|
fn extract_schema(
|
|
result: &RawValue,
|
|
) -> Option<Vec<windmill_common::materialization::SchemaColumn>> {
|
|
use windmill_common::materialization::SchemaColumn;
|
|
fn collect(v: &Value) -> Option<Vec<SchemaColumn>> {
|
|
let Value::Array(arr) = v else { return None };
|
|
let mut out = Vec::with_capacity(arr.len());
|
|
for item in arr {
|
|
let o = item.as_object()?;
|
|
let name = o.get("name")?.as_str()?.to_string();
|
|
let data_type = o.get("type")?.as_str()?.to_string();
|
|
out.push(SchemaColumn { name, data_type });
|
|
}
|
|
Some(out)
|
|
}
|
|
fn find_field(v: &Value) -> Option<&Value> {
|
|
match v {
|
|
Value::Object(o) => o.get("output_schema"),
|
|
Value::Array(a) => a.iter().find_map(find_field),
|
|
_ => None,
|
|
}
|
|
}
|
|
let root = serde_json::from_str::<Value>(result.get()).ok()?;
|
|
match find_field(&root)? {
|
|
arr @ Value::Array(_) => collect(arr),
|
|
// FFI serialized the list-of-struct as a JSON string — parse it.
|
|
Value::String(s) => collect(&serde_json::from_str::<Value>(s).ok()?),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
// Pull the `on_schema_change=warn` drift out of the materialize summary's
|
|
// `schema_drift` column: a `{added: [..], removed: [..]}` struct (or NULL when
|
|
// the SELECT matched the table). Like `output_schema`, the FFI may surface it
|
|
// nested or as a JSON string — accept both. `None` when there is no drift.
|
|
fn extract_schema_drift(result: &RawValue) -> Option<(Vec<String>, Vec<String>)> {
|
|
fn str_list(v: Option<&Value>) -> Vec<String> {
|
|
match v {
|
|
Some(Value::Array(a)) => a
|
|
.iter()
|
|
.filter_map(|x| x.as_str().map(str::to_string))
|
|
.collect(),
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
fn find_field(v: &Value) -> Option<&Value> {
|
|
match v {
|
|
Value::Object(o) => o.get("schema_drift"),
|
|
Value::Array(a) => a.iter().find_map(find_field),
|
|
_ => None,
|
|
}
|
|
}
|
|
let root = serde_json::from_str::<Value>(result.get()).ok()?;
|
|
let field = find_field(&root)?;
|
|
let owned;
|
|
let obj = match field {
|
|
Value::Object(_) => field,
|
|
// FFI serialized the struct as a JSON string — parse it.
|
|
Value::String(s) => {
|
|
owned = serde_json::from_str::<Value>(s).ok()?;
|
|
&owned
|
|
}
|
|
// NULL / absent ⇒ no drift.
|
|
_ => return None,
|
|
};
|
|
let o = obj.as_object()?;
|
|
let added = str_list(o.get("added"));
|
|
let removed = str_list(o.get("removed"));
|
|
if added.is_empty() && removed.is_empty() {
|
|
None
|
|
} else {
|
|
Some((added, removed))
|
|
}
|
|
}
|
|
|
|
// Render the full pass/fail breakdown for a failed data-test run — every test,
|
|
// not just the first failure, so the user sees the whole picture in one place.
|
|
fn format_data_test_breakdown(asset_path: &str, tests: &[DataTestOutcome]) -> String {
|
|
let failed = tests.iter().filter(|t| t.violating > 0).count();
|
|
let mut lines = vec![format!(
|
|
"data tests failed on {asset_path} ({failed}/{} failed):",
|
|
tests.len()
|
|
)];
|
|
for t in tests {
|
|
if t.violating > 0 {
|
|
lines.push(format!(" ✗ {} — {} violating row(s)", t.name, t.violating));
|
|
} else {
|
|
lines.push(format!(" ✓ {}", t.name));
|
|
}
|
|
}
|
|
lines.join("\n")
|
|
}
|
|
|
|
// Best-effort record of a materialization outcome. On a Sql connection it writes
|
|
// the row directly; on an agent worker (Http, no direct DB) it posts to the API
|
|
// so state lands the same way. Never fails the job — a lost row degrades the
|
|
// grid, not the run.
|
|
async fn record_mat(
|
|
conn: &Connection,
|
|
w_id: &str,
|
|
job_id: Uuid,
|
|
meta: &MaterializeExec,
|
|
status: windmill_common::materialization::MaterializationStatus,
|
|
snapshot_id: Option<i64>,
|
|
row_count: Option<i64>,
|
|
// Captured output schema (gap #2a). Only set on a successful materialize;
|
|
// when present, also upserts a `materialized_asset_schema` version.
|
|
schema: Option<Vec<windmill_common::materialization::SchemaColumn>>,
|
|
error: Option<&str>,
|
|
) {
|
|
let req = windmill_common::materialization::RecordMaterializationRequest {
|
|
asset_kind: meta.asset_kind,
|
|
asset_path: meta.asset_path.clone(),
|
|
partition: meta.partition.clone(),
|
|
status,
|
|
snapshot_id,
|
|
row_count,
|
|
job_id: Some(job_id),
|
|
error: error.map(|e| e.to_string()),
|
|
schema: schema.clone(),
|
|
};
|
|
let res: anyhow::Result<()> = match conn {
|
|
Connection::Sql(db) => {
|
|
let partition_res = windmill_common::materialization::record_materialization(
|
|
db,
|
|
w_id,
|
|
req.asset_kind,
|
|
&req.asset_path,
|
|
&req.partition,
|
|
req.status,
|
|
req.snapshot_id,
|
|
req.row_count,
|
|
req.job_id,
|
|
req.error.as_deref(),
|
|
)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("{e:#}"));
|
|
// Schema capture is a separate, independently best-effort write (its
|
|
// own transaction for the per-asset advisory lock); a failure here
|
|
// must not lose the partition row above.
|
|
if let Some(cols) = schema.as_ref() {
|
|
if let Err(e) = record_asset_schema_best_effort(
|
|
db,
|
|
w_id,
|
|
meta.asset_kind,
|
|
&meta.asset_path,
|
|
cols,
|
|
snapshot_id,
|
|
job_id,
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!("failed to record captured asset schema: {e:#}");
|
|
}
|
|
}
|
|
partition_res
|
|
}
|
|
Connection::Http(client) => {
|
|
crate::agent_workers::record_materialization_from_agent_http(client, w_id, &req).await
|
|
}
|
|
};
|
|
if let Err(e) = res {
|
|
tracing::warn!("failed to record materialization state: {e:#}");
|
|
}
|
|
}
|
|
|
|
// Open a short transaction (needed for the per-asset advisory lock) and upsert
|
|
// the captured schema version. Isolated so its tx lifetime doesn't entangle the
|
|
// partition write.
|
|
async fn record_asset_schema_best_effort(
|
|
db: &windmill_common::DB,
|
|
w_id: &str,
|
|
asset_kind: windmill_common::assets::AssetKind,
|
|
asset_path: &str,
|
|
columns: &[windmill_common::materialization::SchemaColumn],
|
|
snapshot_id: Option<i64>,
|
|
job_id: Uuid,
|
|
) -> anyhow::Result<()> {
|
|
let mut tx = db.begin().await?;
|
|
windmill_common::materialization::record_asset_schema(
|
|
&mut tx,
|
|
w_id,
|
|
asset_kind,
|
|
asset_path,
|
|
columns,
|
|
snapshot_id,
|
|
Some(job_id),
|
|
)
|
|
.await?;
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn do_duckdb(
|
|
job: &MiniPulledJob,
|
|
client: &AuthedClient,
|
|
query: &str,
|
|
conn: &Connection,
|
|
mem_peak: &mut i32,
|
|
canceled_by: &mut Option<CanceledBy>,
|
|
worker_name: &str,
|
|
// TODO
|
|
#[allow(unused_variables)] column_order_ref: &mut Option<Vec<String>>,
|
|
occupancy_metrics: &mut OccupancyMetrics,
|
|
parent_runnable_path: Option<String>,
|
|
job_dir: &str,
|
|
run_inline: bool,
|
|
) -> Result<Box<RawValue>> {
|
|
let annotations = windmill_common::worker::SqlAnnotations::parse(query);
|
|
let collection_strategy =
|
|
if annotations.result_collection == SqlResultCollectionStrategy::Legacy {
|
|
// Before result_collection was introduced, duckdb ignored all statements results except the last one
|
|
SqlResultCollectionStrategy::LastStatementAllRows
|
|
} else {
|
|
annotations.result_collection
|
|
};
|
|
if annotations.return_last_result {
|
|
return Err(Error::ExecutionErr(
|
|
"return_last_result annotation is deprecated, use result_collection=last_statement_all_rows instead".to_string(),
|
|
));
|
|
}
|
|
|
|
let token = client.token.clone();
|
|
let hidden_passwords = Arc::new(Mutex::new(Vec::<String>::new()));
|
|
|
|
let result_f = async {
|
|
let mut hidden_passwords = hidden_passwords.clone();
|
|
let mut bigquery_credentials = None;
|
|
|
|
// Materialization (`// materialize`): rewrite a wrap script into managed
|
|
// DDL (its synthetic target ATTACH is resolved by the transform pass
|
|
// below, like the user's own ATTACH); a literal script is left as-is.
|
|
// `materialize` also carries what to record once the run finishes.
|
|
let partition_value: Option<String> = job
|
|
.args
|
|
.as_ref()
|
|
.and_then(|a| a.0.get(windmill_common::partition::PARTITION_ARG))
|
|
.and_then(|rv| serde_json::from_str::<String>(rv.get()).ok())
|
|
.filter(|s| !s.is_empty());
|
|
let materialize = if query.contains("materialize") || query.contains("data_test") {
|
|
// Custom-test bodies need a DB read; fetch them first so the codegen
|
|
// itself stays pure/sync.
|
|
let custom_test_bodies =
|
|
fetch_custom_test_bodies(query, conn, &job.workspace_id).await?;
|
|
build_materialized_query(query, partition_value.as_deref(), &custom_test_bodies)?
|
|
} else {
|
|
None
|
|
};
|
|
// Parse the signature from the ORIGINAL script: managed materialize wraps
|
|
// the trailing SELECT and strips line comments, which drops the
|
|
// `-- $name (type)` arg declarations while their `$name` references
|
|
// survive in the embedded SELECT. Parsing args here (pre-wrap) keeps them
|
|
// declared so they are still bound — and s3object args translated to
|
|
// `s3://` URIs — at run time.
|
|
let sig = parse_duckdb_sig(query)?.args;
|
|
|
|
// `// macros` / `// use` also come off the ORIGINAL script text (the
|
|
// materialize rewrite strips the annotation comments). Drives the
|
|
// workspace-macro injection after the ATTACH-transform pass below.
|
|
let macro_ann = windmill_parser::asset_parser::parse_pipeline_annotations(query);
|
|
|
|
let materialized_query;
|
|
let query: &str = match &materialize {
|
|
Some((Some(rewritten), _)) => {
|
|
materialized_query = rewritten.clone();
|
|
&materialized_query
|
|
}
|
|
_ => query,
|
|
};
|
|
|
|
// Managed materialize generates its own trailing summary row (asset /
|
|
// rows / snapshot_id / data_tests), and data-test enforcement below reads
|
|
// the `data_tests` column off that row. The row shape is ours, not the
|
|
// user's — so force the full-last-row strategy regardless of any
|
|
// `// result_collection` annotation, which would otherwise reshape it
|
|
// (e.g. a scalar mode drops every column but the first) and silently
|
|
// bypass test enforcement.
|
|
let collection_strategy = if matches!(&materialize, Some((Some(_), _))) {
|
|
SqlResultCollectionStrategy::LastStatementAllRows
|
|
} else {
|
|
collection_strategy
|
|
};
|
|
let mut job_args = build_args_values(job, client, conn).await?;
|
|
|
|
let reserved_variables =
|
|
get_reserved_variables(job, &client.token, conn, parent_runnable_path).await?;
|
|
|
|
let (query, _) =
|
|
&sanitize_and_interpolate_unsafe_sql_args(query, &sig, &job_args, &reserved_variables)?;
|
|
let mut query = transform_s3_uris(query).await?;
|
|
|
|
// `on_schema_change=sync`: interpolate the pre-pass probe with the same
|
|
// sig / args / reserved vars as the main query (before `sig` is consumed
|
|
// and the arg map drained just below) so its `DESCRIBE` of the SELECT
|
|
// binds `$args`/`$partition` the same way.
|
|
let sync_probe_interpolated = match materialize
|
|
.as_ref()
|
|
.and_then(|(_, m)| m.sync_prepass.as_ref())
|
|
{
|
|
Some(pp) => {
|
|
let (interp, _) = sanitize_and_interpolate_unsafe_sql_args(
|
|
&pp.probe_query,
|
|
&sig,
|
|
&job_args,
|
|
&reserved_variables,
|
|
)?;
|
|
Some(transform_s3_uris(&interp).await?)
|
|
}
|
|
None => None,
|
|
};
|
|
|
|
let job_args = {
|
|
let mut m = Vec::new();
|
|
for sig_arg in sig.into_iter() {
|
|
let json_value = job_args
|
|
.remove(&sig_arg.name)
|
|
.or_else(|| sig_arg.default)
|
|
.unwrap_or_else(|| json!(null));
|
|
|
|
if matches!(&sig_arg.otyp.as_ref().map(String::as_str), Some("s3object")) {
|
|
let s3_obj = serde_json::from_value::<S3Object>(json_value).map_err(|e| {
|
|
Error::ExecutionErr(format!("Failed to deserialize S3Object: {}", e))
|
|
})?;
|
|
let uri = format!(
|
|
"s3://{}/{}",
|
|
s3_obj.storage.as_deref().unwrap_or(DEFAULT_STORAGE),
|
|
s3_obj.s3
|
|
);
|
|
m.push(Arg {
|
|
json_value: serde_json::Value::String(uri),
|
|
name: sig_arg.name,
|
|
arg_type: "string".to_string(),
|
|
});
|
|
} else {
|
|
m.push(Arg {
|
|
json_value,
|
|
name: sig_arg.name,
|
|
arg_type: sig_arg.otyp.unwrap_or_else(|| "text".to_string()),
|
|
});
|
|
}
|
|
}
|
|
m
|
|
};
|
|
|
|
// `on_schema_change=sync` pre-pass: run the probe (through the same
|
|
// ATTACH transform as the main query), diff the SELECT's columns against
|
|
// the table's, and splice the `ALTER TABLE … ADD/DROP COLUMN` DDL into
|
|
// the plan at the sentinel slot (removing the sentinel when there is no
|
|
// drift). Runs before the main query so the ALTERs are in place when the
|
|
// BY NAME insert executes.
|
|
if let (Some(probe_sql), Some(pp)) = (
|
|
&sync_probe_interpolated,
|
|
materialize
|
|
.as_ref()
|
|
.and_then(|(_, m)| m.sync_prepass.as_ref()),
|
|
) {
|
|
let mut probe_blocks = vec![];
|
|
// Held for the whole probe (through compute_sync_alter_ddl): a
|
|
// `TYPE bigquery` ATTACH needs GOOGLE_APPLICATION_CREDENTIALS + the
|
|
// temp creds file set before DuckDB binds the extension, exactly as
|
|
// the main query path does — otherwise the probe DESCRIBE fails.
|
|
let mut probe_bigquery_credentials = None;
|
|
for query_block in parse_sql_blocks(probe_sql, true).iter() {
|
|
let query_block = remove_comments(query_block);
|
|
if let Some(parsed) = parse_attach_db_resource(query_block) {
|
|
probe_blocks.extend(
|
|
transform_attach_db_resource_query(
|
|
&parsed,
|
|
&job.id,
|
|
client,
|
|
&mut hidden_passwords,
|
|
)
|
|
.await?,
|
|
);
|
|
if parsed.db_type == "bigquery" {
|
|
probe_bigquery_credentials = Some(UseBigQueryCredentialsFile::new(
|
|
job.id,
|
|
parsed.resource_path,
|
|
)?);
|
|
}
|
|
} else if let Some(q) = transform_attach_ducklake(
|
|
&query_block,
|
|
conn,
|
|
&mut hidden_passwords,
|
|
&job.workspace_id,
|
|
materialize.as_ref().map(|(_, m)| m.asset_path.as_str()),
|
|
)
|
|
.await?
|
|
{
|
|
probe_blocks.extend(q);
|
|
} else if let Some(q) = transform_attach_datatable(
|
|
&query_block,
|
|
conn,
|
|
&mut hidden_passwords,
|
|
&job.workspace_id,
|
|
)
|
|
.await?
|
|
{
|
|
probe_blocks.extend(q);
|
|
} else {
|
|
probe_blocks.push(query_block.to_string());
|
|
}
|
|
}
|
|
// The probe `DESCRIBE`s the same SELECT as the main query, so it
|
|
// needs the same workspace-macro / `// use` library definitions
|
|
// injected (post-ATTACH), or a macro-calling SELECT fails to bind
|
|
// here and the fatal probe makes `sync` unusable with macros.
|
|
let probe_blocks = inject_workspace_macros(
|
|
conn,
|
|
&job.workspace_id,
|
|
macro_ann.macros,
|
|
¯o_ann.use_libs,
|
|
probe_blocks,
|
|
)
|
|
.await?;
|
|
let alter_ddl = compute_sync_alter_ddl(
|
|
probe_blocks,
|
|
job_args.clone(),
|
|
pp.target_table.clone(),
|
|
token.clone(),
|
|
client.base_internal_url.clone(),
|
|
job.workspace_id.clone(),
|
|
job_dir.to_string(),
|
|
)
|
|
.await?;
|
|
query = query.replace(
|
|
windmill_parser::sql_materialize::SYNC_ALTER_SENTINEL,
|
|
&alter_ddl,
|
|
);
|
|
// Kept alive until the probe finished; the main query path recreates
|
|
// its own credentials for its own ATTACH pass below.
|
|
drop(probe_bigquery_credentials);
|
|
}
|
|
|
|
let query_block_list = parse_sql_blocks(&query, true);
|
|
|
|
// Replace custom ATTACH statements with the real instructions
|
|
let query_block_list = {
|
|
let mut v = vec![];
|
|
for query_block in query_block_list.iter() {
|
|
let query_block = remove_comments(&query_block);
|
|
if let Some(parsed) = parse_attach_db_resource(query_block) {
|
|
v.extend(
|
|
transform_attach_db_resource_query(
|
|
&parsed,
|
|
&job.id,
|
|
client,
|
|
&mut hidden_passwords,
|
|
)
|
|
.await?,
|
|
);
|
|
if parsed.db_type == "bigquery" {
|
|
bigquery_credentials = Some(UseBigQueryCredentialsFile::new(
|
|
job.id,
|
|
parsed.resource_path,
|
|
)?);
|
|
}
|
|
} else if let Some(ducklake_query) = transform_attach_ducklake(
|
|
&query_block,
|
|
conn,
|
|
&mut hidden_passwords,
|
|
&job.workspace_id,
|
|
materialize.as_ref().map(|(_, m)| m.asset_path.as_str()),
|
|
)
|
|
.await?
|
|
{
|
|
v.extend(ducklake_query);
|
|
} else if let Some(datatable_query) = transform_attach_datatable(
|
|
&query_block,
|
|
conn,
|
|
&mut hidden_passwords,
|
|
&job.workspace_id,
|
|
)
|
|
.await?
|
|
{
|
|
v.extend(datatable_query);
|
|
} else {
|
|
v.push(query_block.to_string());
|
|
}
|
|
}
|
|
v
|
|
};
|
|
|
|
// Workspace macros: splice `CREATE OR REPLACE TEMP MACRO` blocks for
|
|
// registry macros this script calls (plus whole `// use` libraries)
|
|
// after the setup/ATTACH prefix — post-transform, so macro bodies can
|
|
// reference the attached catalogs when DuckDB bind-checks the CREATE.
|
|
let query_block_list = inject_workspace_macros(
|
|
conn,
|
|
&job.workspace_id,
|
|
macro_ann.macros,
|
|
¯o_ann.use_libs,
|
|
query_block_list,
|
|
)
|
|
.await?;
|
|
|
|
let base_internal_url = client.base_internal_url.clone();
|
|
let w_id = job.workspace_id.clone();
|
|
let job_dir = job_dir.to_string();
|
|
|
|
if annotations.prepare {
|
|
let result = tokio::task::spawn_blocking(move || {
|
|
prepare_duckdb_ffi_safe(
|
|
query_block_list.iter().map(String::as_str),
|
|
&token,
|
|
&base_internal_url,
|
|
&w_id,
|
|
&job_dir,
|
|
)
|
|
})
|
|
.await
|
|
.map_err(|e| Error::from(to_anyhow(e)))
|
|
.and_then(|r| r)?;
|
|
|
|
return Ok(result);
|
|
}
|
|
|
|
let result = tokio::task::spawn_blocking(move || {
|
|
run_duckdb_ffi_safe(
|
|
query_block_list.iter().map(String::as_str),
|
|
query_block_list.len(),
|
|
job_args,
|
|
&token,
|
|
&base_internal_url,
|
|
&w_id,
|
|
&job_dir,
|
|
collection_strategy,
|
|
)
|
|
})
|
|
.await
|
|
.map_err(|e| Error::from(to_anyhow(e)))
|
|
.and_then(|r| r);
|
|
|
|
let (result, column_order) = match result {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
if let Some((_, meta)) = &materialize {
|
|
record_mat(
|
|
conn,
|
|
&job.workspace_id,
|
|
job.id,
|
|
meta,
|
|
windmill_common::materialization::MaterializationStatus::Failed,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(&e.to_string()),
|
|
)
|
|
.await;
|
|
}
|
|
if let Some(s3_proxy_err) = S3_PROXY_LAST_ERRORS_CACHE.get(&client.token) {
|
|
return Err(Error::ExecutionErr(format!(
|
|
"{}\n\nS3 Related Error: {}",
|
|
e.to_string(),
|
|
s3_proxy_err,
|
|
)));
|
|
}
|
|
return Err(e);
|
|
}
|
|
};
|
|
|
|
if let Some((_, meta)) = &materialize {
|
|
// In wrap mode the job result is the summary read (snapshot_id +
|
|
// rows + the per-test breakdown); in literal mode there is none.
|
|
let snapshot_id = extract_i64(&result, "snapshot_id");
|
|
let row_count = extract_i64(&result, "rows");
|
|
// Data tests all ran (every check counted in one query); decide
|
|
// pass/fail here. Any violation fails the run — the write is already
|
|
// committed (like dbt), so the slice is recorded `Failed` and the
|
|
// cascade stops. The error lists *every* test so the user sees the
|
|
// whole picture, not just the first failure.
|
|
// Under enterprise write-audit-publish an in-transaction guard
|
|
// (the enterprise `finalize_materialize_query` restructure) already aborted a failing run before
|
|
// COMMIT — that surfaces on the Err path above with the same
|
|
// breakdown in the error string, and nothing was published; this
|
|
// post-commit path then only ever sees passing counts.
|
|
let tests = extract_data_tests(&result);
|
|
// Captured output schema (gap #2a) — recorded only on the successful
|
|
// path below, not on the failure paths (a failed run shouldn't
|
|
// advance the asset's recorded schema version). Managed mode ONLY:
|
|
// in `// materialize manual` the result is the user's own query
|
|
// output (we generate no summary), so an `output_schema` field there
|
|
// is caller-shaped and must not be trusted — `materialize` is
|
|
// `Some((Some(_), _))` for managed, `Some((None, _))` for manual.
|
|
let is_managed = matches!(&materialize, Some((Some(_), _)));
|
|
let schema = if is_managed {
|
|
extract_schema(&result)
|
|
} else {
|
|
None
|
|
};
|
|
// `on_schema_change=warn` (default): the summary carries a
|
|
// `schema_drift` column when the SELECT's columns diverged from the
|
|
// fixed table schema. The write already happened positionally (data
|
|
// may have landed in the wrong/old columns) — log it LOUDLY so the
|
|
// drift isn't silent. The drift also rides back in the job result
|
|
// (it's a column of the returned summary row).
|
|
if is_managed
|
|
&& meta.on_schema_change == windmill_parser::asset_parser::OnSchemaChange::Warn
|
|
{
|
|
if let Some((added, removed)) = extract_schema_drift(&result) {
|
|
let fmt = |cols: &[String]| {
|
|
if cols.is_empty() {
|
|
"(none)".to_string()
|
|
} else {
|
|
cols.join(", ")
|
|
}
|
|
};
|
|
let warning = format!(
|
|
"\n⚠️ SCHEMA DRIFT on {asset}: the SELECT's columns no longer match the \
|
|
existing table.\n added (in SELECT, not in table): {added}\n removed \
|
|
(in table, not in SELECT): {removed}\n The write proceeded POSITIONALLY \
|
|
against the existing table schema — data may have landed in the wrong \
|
|
columns. Set `on_schema_change=fail` to block this, or \
|
|
`on_schema_change=sync` to migrate the table automatically.\n",
|
|
asset = meta.asset_path,
|
|
added = fmt(&added),
|
|
removed = fmt(&removed),
|
|
);
|
|
append_logs(&job.id, &job.workspace_id, warning, conn).await;
|
|
}
|
|
}
|
|
// Defense-in-depth: codegen embedded `n_data_tests` checks, so the
|
|
// summary row must carry that many outcomes. Recovering fewer means
|
|
// the `data_tests` column was dropped/reshaped before we read it —
|
|
// fail loud rather than silently pass unverified tests.
|
|
if tests.len() < meta.n_data_tests {
|
|
let msg = format!(
|
|
"data tests on {}: expected {} test outcome(s) but recovered {} from the \
|
|
result — aborting to avoid a silent pass",
|
|
meta.asset_path,
|
|
meta.n_data_tests,
|
|
tests.len()
|
|
);
|
|
record_mat(
|
|
conn,
|
|
&job.workspace_id,
|
|
job.id,
|
|
meta,
|
|
windmill_common::materialization::MaterializationStatus::Failed,
|
|
snapshot_id,
|
|
row_count,
|
|
None,
|
|
Some(&msg),
|
|
)
|
|
.await;
|
|
return Err(Error::ExecutionErr(msg));
|
|
}
|
|
if tests.iter().any(|t| t.violating > 0) {
|
|
let breakdown = format_data_test_breakdown(&meta.asset_path, &tests);
|
|
record_mat(
|
|
conn,
|
|
&job.workspace_id,
|
|
job.id,
|
|
meta,
|
|
windmill_common::materialization::MaterializationStatus::Failed,
|
|
snapshot_id,
|
|
row_count,
|
|
None,
|
|
Some(&breakdown),
|
|
)
|
|
.await;
|
|
// Structured failure payload: the queue wraps it as
|
|
// `{"error": {...}}` (`WrappedError`) and result_processor
|
|
// derives the run description from the top-level `message`,
|
|
// so keep `message` at the top and add no `error` nesting of
|
|
// our own. Samples ride only on failed tests, only in this
|
|
// structured result — the message text stays counts-only.
|
|
let has_samples = tests.iter().any(|t| t.violating > 0 && t.sample.is_some());
|
|
let message = if has_samples {
|
|
// Wording must not match the UI's breakdown-line parsing
|
|
// (no ✓/✗, no `— N violating`), which older-result
|
|
// rendering still relies on.
|
|
format!(
|
|
"{breakdown}\n\nSamples of the violating rows are \
|
|
attached to this run's result (error.data_tests)."
|
|
)
|
|
} else {
|
|
breakdown
|
|
};
|
|
let data_tests = tests
|
|
.iter()
|
|
.map(|t| {
|
|
let mut o = json!({ "test": t.name, "violating": t.violating });
|
|
if t.violating > 0 {
|
|
if let Some(s) = &t.sample {
|
|
o["sample"] = s.clone();
|
|
}
|
|
}
|
|
o
|
|
})
|
|
.collect::<Vec<_>>();
|
|
return Err(Error::ExecutionRawError(to_raw_value(&json!({
|
|
"message": message,
|
|
"name": "ExecutionErr",
|
|
"step_id": job.flow_step_id,
|
|
"data_tests": data_tests,
|
|
}))));
|
|
}
|
|
record_mat(
|
|
conn,
|
|
&job.workspace_id,
|
|
job.id,
|
|
meta,
|
|
windmill_common::materialization::MaterializationStatus::Materialized,
|
|
snapshot_id,
|
|
row_count,
|
|
schema,
|
|
None,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
drop(bigquery_credentials);
|
|
|
|
*column_order_ref = column_order;
|
|
Ok(result)
|
|
};
|
|
|
|
let result = if run_inline {
|
|
result_f.await
|
|
} else {
|
|
run_future_with_polling_update_job_poller(
|
|
job.id,
|
|
job.timeout,
|
|
conn,
|
|
mem_peak,
|
|
canceled_by,
|
|
result_f,
|
|
worker_name,
|
|
&job.workspace_id,
|
|
&mut Some(occupancy_metrics),
|
|
Box::pin(futures::stream::once(async { 0 })),
|
|
)
|
|
.await
|
|
};
|
|
|
|
match result {
|
|
Ok(result) => Ok(result),
|
|
Err(e) => {
|
|
// Passwords might appear in the error message — and, for the
|
|
// structured data-test failure, in sampled row data read from an
|
|
// attached database — so every outgoing error is sanitized here.
|
|
let sanitize = |mut s: String| {
|
|
for pwd in hidden_passwords.lock().unwrap().iter() {
|
|
if let Some(sanitized) = sanitize_string_from_password(&s, &pwd.clone()) {
|
|
s = sanitized;
|
|
}
|
|
}
|
|
s
|
|
};
|
|
match e {
|
|
// The structured payload must keep its variant: flattening it
|
|
// to a string (the arm below) would strip the data-test
|
|
// samples result_processor places verbatim into the failed
|
|
// job's result. Sanitize its string *leaves*, not the
|
|
// serialized text: a secret containing `"` or `\` is
|
|
// JSON-escaped in the text, so a plain-substring pass would
|
|
// miss it — and samples carry raw row data from attached
|
|
// databases.
|
|
Error::ExecutionRawError(raw) => {
|
|
fn walk(v: &mut Value, f: &dyn Fn(String) -> String) {
|
|
match v {
|
|
Value::String(s) => *s = f(std::mem::take(s)),
|
|
Value::Array(a) => a.iter_mut().for_each(|x| walk(x, f)),
|
|
Value::Object(o) => o.values_mut().for_each(|x| walk(x, f)),
|
|
_ => {}
|
|
}
|
|
}
|
|
Err(match serde_json::from_str::<Value>(raw.get()) {
|
|
Ok(mut v) => {
|
|
walk(&mut v, &sanitize);
|
|
Error::ExecutionRawError(to_raw_value(&v))
|
|
}
|
|
// Not valid JSON (shouldn't happen) — redact as text.
|
|
Err(_) => Error::ExecutionErr(sanitize(raw.get().to_string())),
|
|
})
|
|
}
|
|
e => Err(Error::ExecutionErr(sanitize(e.to_string()))),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
thread_local! {
|
|
static DUCKDB_FFI_LIB_SINGLETON: RefCell<*const DuckDbFfiLib> = RefCell::new(std::ptr::null());
|
|
}
|
|
|
|
struct DuckDbFfiLib {
|
|
run_duckdb_ffi: Symbol<
|
|
'static,
|
|
unsafe extern "C" fn(
|
|
query_block_list: *const *const c_char,
|
|
query_block_list_count: usize,
|
|
job_args: *const c_char,
|
|
token: *const c_char,
|
|
base_internal_url: *const c_char,
|
|
w_id: *const c_char,
|
|
memory_limit: *const c_char,
|
|
temp_directory: *const c_char,
|
|
column_order_ptr: *mut *mut c_char,
|
|
collect_last_only: bool,
|
|
collect_first_row_only: bool,
|
|
) -> *mut c_char,
|
|
>,
|
|
prepare_duckdb_ffi: Option<
|
|
Symbol<
|
|
'static,
|
|
unsafe extern "C" fn(
|
|
query_block_list: *const *const c_char,
|
|
query_block_list_count: usize,
|
|
token: *const c_char,
|
|
base_internal_url: *const c_char,
|
|
w_id: *const c_char,
|
|
memory_limit: *const c_char,
|
|
temp_directory: *const c_char,
|
|
) -> *mut c_char,
|
|
>,
|
|
>,
|
|
free_cstr: Symbol<'static, unsafe extern "C" fn(string: *mut c_char) -> ()>,
|
|
}
|
|
|
|
impl DuckDbFfiLib {
|
|
fn get_singleton() -> Result<&'static DuckDbFfiLib> {
|
|
DUCKDB_FFI_LIB_SINGLETON.with(|cell| unsafe {
|
|
let mut singleton = cell.borrow_mut();
|
|
if singleton.is_null() {
|
|
let lib = DuckDbFfiLib::init()?;
|
|
let boxed_lib = Box::new(lib);
|
|
let lib_ptr = Box::leak(boxed_lib);
|
|
*singleton = lib_ptr as *const _;
|
|
Ok(NonNull::new_unchecked(*singleton as *mut DuckDbFfiLib).as_ref())
|
|
} else {
|
|
Ok(&**singleton)
|
|
}
|
|
})
|
|
}
|
|
fn init() -> Result<Self> {
|
|
let lib = unsafe {
|
|
Library::new(if cfg!(target_os = "macos") {
|
|
"libwindmill_duckdb_ffi_internal.dylib"
|
|
} else if cfg!(target_os = "windows") {
|
|
"windmill_duckdb_ffi_internal.dll"
|
|
} else {
|
|
"libwindmill_duckdb_ffi_internal.so"
|
|
})
|
|
.map_err(|e| {
|
|
Error::InternalErr(format!(
|
|
"Could not init duckdb. Make sure you have the latest windmill_duckdb_ffi_lib.{} alongside your binary : https://github.com/windmill-labs/windmill/releases \n{}",
|
|
if cfg!(target_os = "macos") { "dylib" }
|
|
else if cfg!(target_os = "windows") { "dll" }
|
|
else { "so" },
|
|
e.to_string()
|
|
))
|
|
})?
|
|
};
|
|
|
|
let lib = Box::leak(Box::new(lib));
|
|
|
|
// Version mismatch should only be possible on Windows agent workers
|
|
// We check for it because FFI interface mismatch will cause undefined behavior / crashes
|
|
unsafe {
|
|
let expected_version: c_uint = 2;
|
|
let get_version: Symbol<'static, unsafe extern "C" fn() -> c_uint> =
|
|
lib.get(b"get_version")
|
|
.map_err(|e| return Error::ExecutionErr(format!("Could not find get_version in the duckdb ffi library. If you are not using docker, consider manually upgrading windmill_duckdb_ffi_lib. {}", e.to_string())))?;
|
|
let actual_version = get_version();
|
|
if actual_version < expected_version {
|
|
return Err(Error::InternalErr(
|
|
format!("Incompatible duckdb ffi library version. Expected: {expected_version}, actual: {actual_version}. Please update to the latest windmill_duckdb_ffi_lib."),
|
|
));
|
|
} else if actual_version > expected_version {
|
|
return Err(Error::InternalErr(
|
|
format!("Incompatible duckdb ffi library version. Expected: {expected_version}, actual: {actual_version}. Please upgrade your worker to the latest windmill version."),
|
|
));
|
|
}
|
|
}
|
|
|
|
let prepare_duckdb_ffi = unsafe { lib.get(b"prepare_duckdb_ffi").ok() };
|
|
|
|
Ok(DuckDbFfiLib {
|
|
run_duckdb_ffi: unsafe { lib.get(b"run_duckdb_ffi").map_err(to_anyhow)? },
|
|
prepare_duckdb_ffi,
|
|
free_cstr: unsafe { lib.get(b"free_cstr").map_err(to_anyhow)? },
|
|
})
|
|
}
|
|
}
|
|
|
|
// 20% headroom for Rust runtime + DuckDB's untracked allocations. Mirrors
|
|
// DuckDB's own default ratio, but applied to the worker's cgroup budget
|
|
// instead of host RAM.
|
|
const DUCKDB_MEMORY_FRACTION: f64 = 0.8;
|
|
// Treat cgroup values above 1 PiB as "unlimited" (kernels report page-aligned
|
|
// huge numbers when uncapped). get_memory() falls back to host RAM in that
|
|
// case, which is exactly what we want to leave to DuckDB's own default.
|
|
const CGROUP_UNLIMITED_THRESHOLD: i64 = 1024 * 1024 * 1024 * 1024 * 1024;
|
|
|
|
// `DUCKDB_MEMORY_LIMIT` env override, else fraction of the worker's cgroup
|
|
// memory (as reported by windmill-common), else None (keep DuckDB's default).
|
|
fn resolve_duckdb_memory_limit() -> Option<String> {
|
|
if let Ok(v) = env::var("DUCKDB_MEMORY_LIMIT") {
|
|
let v = v.trim();
|
|
if !v.is_empty() {
|
|
return Some(v.to_string());
|
|
}
|
|
}
|
|
cgroup_bytes_to_duckdb_memory_limit(get_memory()?)
|
|
}
|
|
|
|
fn cgroup_bytes_to_duckdb_memory_limit(bytes: i64) -> Option<String> {
|
|
if bytes <= 0 || bytes >= CGROUP_UNLIMITED_THRESHOLD {
|
|
return None;
|
|
}
|
|
let mib = ((bytes as f64 * DUCKDB_MEMORY_FRACTION) as i64) / (1024 * 1024);
|
|
Some(format!("{}MiB", mib.max(64)))
|
|
}
|
|
|
|
// Read backend/windmill-duckdb-ffi-internal/README_DEV.md for details about why we use FFI
|
|
// The FFI returns errors as `ERROR <json-encoded-message>` (the message is
|
|
// serde_json::to_string'd). Decode the JSON string back so multi-line errors
|
|
// render with real newlines, not escaped `\n` inside wrapping quotes; fall back
|
|
// to the raw slice if it is somehow not a JSON string.
|
|
fn decode_ffi_error(result_str: &str) -> String {
|
|
let raw = result_str.strip_prefix("ERROR ").unwrap_or(result_str);
|
|
serde_json::from_str::<String>(raw).unwrap_or_else(|_| raw.to_string())
|
|
}
|
|
|
|
fn run_duckdb_ffi_safe<'a>(
|
|
query_block_list: impl Iterator<Item = &'a str>,
|
|
query_block_list_count: usize,
|
|
job_args: Vec<Arg>,
|
|
token: &str,
|
|
base_internal_url: &str,
|
|
w_id: &str,
|
|
job_dir: &str,
|
|
collection_strategy: SqlResultCollectionStrategy,
|
|
) -> Result<(Box<RawValue>, Option<Vec<String>>)> {
|
|
let query_block_list = query_block_list
|
|
.map(|s| {
|
|
CString::new(s).map_err(|e| {
|
|
Error::ExecutionErr(format!("Failed CString conversion: {}", e.to_string()))
|
|
})
|
|
})
|
|
.collect::<Result<Vec<_>>>()?;
|
|
let query_block_list = query_block_list
|
|
.iter()
|
|
.map(|s| s.as_ptr())
|
|
.collect::<Vec<_>>();
|
|
let job_args = serde_json::to_string(&job_args).map_err(to_anyhow)?;
|
|
|
|
let job_args = CString::new(job_args).map_err(to_anyhow)?;
|
|
let token = CString::new(token).map_err(to_anyhow)?;
|
|
let base_internal_url = CString::new(base_internal_url).map_err(to_anyhow)?;
|
|
let w_id = CString::new(w_id).map_err(to_anyhow)?;
|
|
let memory_limit =
|
|
CString::new(resolve_duckdb_memory_limit().unwrap_or_default()).map_err(to_anyhow)?;
|
|
let temp_directory = CString::new(job_dir).map_err(to_anyhow)?;
|
|
|
|
let run_duckdb_ffi = &DuckDbFfiLib::get_singleton()?.run_duckdb_ffi;
|
|
let free_cstr = &DuckDbFfiLib::get_singleton()?.free_cstr;
|
|
let mut column_order: *mut c_char = std::ptr::null_mut();
|
|
let result_str = unsafe {
|
|
let ptr = run_duckdb_ffi(
|
|
query_block_list.as_ptr(),
|
|
query_block_list_count,
|
|
job_args.as_ptr(),
|
|
token.as_ptr(),
|
|
base_internal_url.as_ptr(),
|
|
w_id.as_ptr(),
|
|
memory_limit.as_ptr(),
|
|
temp_directory.as_ptr(),
|
|
&mut column_order,
|
|
collection_strategy.collect_last_statement_only(query_block_list_count),
|
|
collection_strategy.collect_first_row_only(),
|
|
);
|
|
let str = CStr::from_ptr(ptr).to_string_lossy().to_string();
|
|
free_cstr(ptr);
|
|
str
|
|
};
|
|
|
|
let column_order = if column_order.is_null()
|
|
|| !collection_strategy.collect_last_statement_only(query_block_list_count)
|
|
|| collection_strategy.collect_scalar()
|
|
{
|
|
None
|
|
} else {
|
|
let str = unsafe { CStr::from_ptr(column_order).to_string_lossy().to_string() };
|
|
unsafe { free_cstr(column_order) };
|
|
Some(serde_json::from_str::<Vec<String>>(&str)?)
|
|
};
|
|
|
|
if result_str.starts_with("ERROR") {
|
|
Err(Error::ExecutionErr(decode_ffi_error(&result_str)))
|
|
} else {
|
|
let result = if collection_strategy == SqlResultCollectionStrategy::AllStatementsAllRows {
|
|
// Avoid parsing JSON
|
|
serde_json::value::RawValue::from_string(result_str).map_err(to_anyhow)?
|
|
} else {
|
|
let result =
|
|
serde_json::from_str::<Vec<Vec<Box<RawValue>>>>(&result_str).map_err(to_anyhow)?;
|
|
collection_strategy.collect(result)?
|
|
};
|
|
Ok((result, column_order))
|
|
}
|
|
}
|
|
|
|
fn prepare_duckdb_ffi_safe<'a>(
|
|
query_block_list: impl Iterator<Item = &'a str>,
|
|
token: &str,
|
|
base_internal_url: &str,
|
|
w_id: &str,
|
|
job_dir: &str,
|
|
) -> Result<Box<RawValue>> {
|
|
let query_block_list = query_block_list
|
|
.map(|s| {
|
|
CString::new(s).map_err(|e| {
|
|
Error::ExecutionErr(format!("Failed CString conversion: {}", e.to_string()))
|
|
})
|
|
})
|
|
.collect::<Result<Vec<_>>>()?;
|
|
let query_block_list = query_block_list
|
|
.iter()
|
|
.map(|s| s.as_ptr())
|
|
.collect::<Vec<_>>();
|
|
|
|
let token = CString::new(token).map_err(to_anyhow)?;
|
|
let base_internal_url = CString::new(base_internal_url).map_err(to_anyhow)?;
|
|
let w_id = CString::new(w_id).map_err(to_anyhow)?;
|
|
let memory_limit =
|
|
CString::new(resolve_duckdb_memory_limit().unwrap_or_default()).map_err(to_anyhow)?;
|
|
let temp_directory = CString::new(job_dir).map_err(to_anyhow)?;
|
|
|
|
let lib = DuckDbFfiLib::get_singleton()?;
|
|
let prepare_fn = lib.prepare_duckdb_ffi.as_ref().ok_or_else(|| {
|
|
Error::InternalErr(
|
|
"prepare_duckdb_ffi not available in duckdb ffi library. Please update to the latest windmill_duckdb_ffi_lib.".to_string(),
|
|
)
|
|
})?;
|
|
let free_cstr = &lib.free_cstr;
|
|
|
|
let result_str = unsafe {
|
|
let ptr = prepare_fn(
|
|
query_block_list.as_ptr(),
|
|
query_block_list.len(),
|
|
token.as_ptr(),
|
|
base_internal_url.as_ptr(),
|
|
w_id.as_ptr(),
|
|
memory_limit.as_ptr(),
|
|
temp_directory.as_ptr(),
|
|
);
|
|
let str = CStr::from_ptr(ptr).to_string_lossy().to_string();
|
|
free_cstr(ptr);
|
|
str
|
|
};
|
|
|
|
if result_str.starts_with("ERROR") {
|
|
Err(Error::ExecutionErr(decode_ffi_error(&result_str)))
|
|
} else {
|
|
Ok(serde_json::value::RawValue::from_string(result_str).map_err(to_anyhow)?)
|
|
}
|
|
}
|
|
|
|
struct ParsedAttachDbResource<'a> {
|
|
resource_path: &'a str,
|
|
name: &'a str,
|
|
db_type: &'a str,
|
|
extra_args: Option<&'a str>,
|
|
}
|
|
fn parse_attach_db_resource<'a>(query: &'a str) -> Option<ParsedAttachDbResource<'a>> {
|
|
lazy_static::lazy_static! {
|
|
static ref RE: regex::Regex = regex::Regex::new(r"(?i)ATTACH '(\$res:|res://)([^']+)' AS (\S+) \(TYPE (\w+)(.*)\)").unwrap();
|
|
}
|
|
|
|
for cap in RE.captures_iter(query) {
|
|
if let (Some(resource_path), Some(name), Some(db_type)) =
|
|
(cap.get(2), cap.get(3), cap.get(4))
|
|
{
|
|
let extra_args = cap.get(5).map(|m| query[m.start()..m.end()].trim());
|
|
return Some(ParsedAttachDbResource {
|
|
resource_path: query[resource_path.start()..resource_path.end()].trim(),
|
|
name: query[name.start()..name.end()].trim(),
|
|
db_type: query[db_type.start()..db_type.end()].trim(),
|
|
extra_args,
|
|
});
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn format_attach_db_conn_str(db_resource: Value, db_type: &str) -> Result<String> {
|
|
let s = match db_type.to_lowercase().as_str() {
|
|
"postgres" | "postgresql" => {
|
|
let res: PgDatabase = serde_json::from_value(db_resource)?;
|
|
res.to_uri()
|
|
}
|
|
#[cfg(feature = "mysql")]
|
|
"mysql" => {
|
|
let resource: MysqlDatabase = serde_json::from_value(db_resource)?;
|
|
format!(
|
|
"database={} host={} ssl_mode={} {} {} {}",
|
|
resource.database,
|
|
resource.host,
|
|
resource
|
|
.ssl
|
|
.map(|ssl| if ssl { "required" } else { "disabled" })
|
|
.unwrap_or("preferred"),
|
|
resource
|
|
.password
|
|
.map(|p| format!("password={}", p))
|
|
.unwrap_or_default(),
|
|
resource
|
|
.port
|
|
.map(|p| format!("port={}", p))
|
|
.unwrap_or_default(),
|
|
resource
|
|
.user
|
|
.map(|u| format!("user={}", u))
|
|
.unwrap_or_default(),
|
|
)
|
|
}
|
|
"bigquery" => {
|
|
let project_id: String = serde_json::from_value(
|
|
db_resource
|
|
.get("project_id")
|
|
.ok_or_else(|| {
|
|
Error::ExecutionErr("BigQuery resource must contain project_id".to_string())
|
|
})?
|
|
.to_owned(),
|
|
)
|
|
.map_err(|_e| Error::ExecutionErr("failed project_id deserialize".to_string()))?;
|
|
format!("project={}", project_id,)
|
|
}
|
|
_ => {
|
|
return Err(Error::ExecutionErr(format!(
|
|
"Unsupported db type in DuckDB ATTACH: {db_type}",
|
|
)))
|
|
}
|
|
};
|
|
Ok(s)
|
|
}
|
|
|
|
fn get_attach_db_install_str(db_type: &str) -> Result<&str> {
|
|
match db_type.to_lowercase().as_str() {
|
|
"postgres" => Ok("INSTALL postgres;"),
|
|
"mysql" => {
|
|
#[cfg(not(feature = "mysql"))]
|
|
return Err(Error::ExecutionErr(
|
|
"MySQL feature is not enabled".to_string(),
|
|
));
|
|
#[cfg(feature = "mysql")]
|
|
Ok("INSTALL mysql;")
|
|
}
|
|
"bigquery" => Ok("INSTALL bigquery FROM community;"),
|
|
_ => Err(Error::ExecutionErr(format!(
|
|
"Unsupported db type in DuckDB ATTACH: {}",
|
|
db_type
|
|
))),
|
|
}
|
|
}
|
|
|
|
async fn transform_attach_db_resource_query(
|
|
parsed: &ParsedAttachDbResource<'_>,
|
|
job_id: &Uuid,
|
|
client: &AuthedClient,
|
|
hidden_passwords: &mut Arc<Mutex<Vec<String>>>,
|
|
) -> Result<Vec<String>> {
|
|
let db_resource: Value = client
|
|
.get_resource_value_interpolated(parsed.resource_path, Some(job_id.to_string()))
|
|
.await?;
|
|
if let Some(pwd) = db_resource.get("password").and_then(|p| p.as_str()) {
|
|
hidden_passwords.lock().unwrap().push(pwd.to_string());
|
|
}
|
|
db_resource_to_attach_statements(db_resource, parsed.name, parsed.db_type, parsed.extra_args)
|
|
.await
|
|
}
|
|
|
|
async fn db_resource_to_attach_statements(
|
|
db_resource: Value,
|
|
ident_name: &str,
|
|
db_type: &str,
|
|
extra_args: Option<&str>,
|
|
) -> Result<Vec<String>> {
|
|
// Escape single quotes: the connection string is built from resource fields
|
|
// (host/db/user/password) and embedded in a single-quoted DuckDB literal, so an
|
|
// unescaped quote in any field would otherwise break out of the ATTACH statement.
|
|
let conn_str = format_attach_db_conn_str(db_resource, db_type)?.replace('\'', "''");
|
|
let attach_str = format!(
|
|
"ATTACH '{}' as {} (TYPE {}{});",
|
|
conn_str,
|
|
ident_name,
|
|
db_type,
|
|
extra_args.unwrap_or("")
|
|
)
|
|
.to_string();
|
|
|
|
Ok(vec![
|
|
get_attach_db_install_str(db_type)?.to_string(),
|
|
format!("LOAD {};", db_type),
|
|
attach_str,
|
|
])
|
|
}
|
|
|
|
async fn transform_attach_ducklake(
|
|
query: &str,
|
|
conn: &Connection,
|
|
hidden_passwords: &mut Arc<Mutex<Vec<String>>>,
|
|
w_id: &str,
|
|
materialize_target: Option<&str>,
|
|
) -> Result<Option<Vec<String>>> {
|
|
lazy_static::lazy_static! {
|
|
static ref RE: regex::Regex = regex::Regex::new(r"(?i)ATTACH\s*'ducklake(://[^':]+)?'\s*AS\s+([^ ;]+)\s*(\([^)]*\))?").unwrap();
|
|
}
|
|
let Some(cap) = RE.captures(query) else {
|
|
return Ok(None);
|
|
};
|
|
let name = cap.get(1).map(|m| &m.as_str()[3..]).unwrap_or("main");
|
|
let alias_name = cap.get(2).map(|m| m.as_str()).unwrap_or("");
|
|
let user_extra_args = cap
|
|
.get(3)
|
|
.map(|m| m.as_str()[1..m.as_str().len() - 1].to_string())
|
|
.unwrap_or_default();
|
|
|
|
let ducklake = match conn {
|
|
Connection::Http(client) => get_ducklake_from_agent_http(client, name, w_id).await?,
|
|
Connection::Sql(db) => get_ducklake_from_db_unchecked(name, w_id, db).await?,
|
|
};
|
|
// In a fork, METADATA_SCHEMA / DATA_PATH / OVERRIDE_DATA_PATH are owned by the fork
|
|
// resolution (DuckDB silently keeps the last occurrence of a duplicated option, so a
|
|
// user-supplied one would escape the fork namespace back to the parent's).
|
|
let user_extra_args = if ducklake.fork_defer.is_some() {
|
|
strip_fork_reserved_attach_args(&user_extra_args)
|
|
} else {
|
|
user_extra_args
|
|
};
|
|
let extra_args = if user_extra_args.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(", {}", user_extra_args)
|
|
};
|
|
let db_type = match ducklake.catalog.resource_type {
|
|
DucklakeCatalogResourceType::Instance => "postgres",
|
|
_ => ducklake.catalog.resource_type.as_ref(),
|
|
};
|
|
|
|
if let Some(pwd) = ducklake
|
|
.catalog_resource
|
|
.get("password")
|
|
.and_then(|p| p.as_str())
|
|
{
|
|
hidden_passwords.lock().unwrap().push(pwd.to_string());
|
|
}
|
|
|
|
// Escape single quotes: db_conn_str, storage and data_path are embedded in
|
|
// single-quoted DuckDB literals below, so an unescaped quote in a resource
|
|
// field would break out of the ATTACH statement.
|
|
let db_conn_str =
|
|
format_attach_db_conn_str(ducklake.catalog_resource, db_type)?.replace('\'', "''");
|
|
let storage = ducklake
|
|
.storage
|
|
.storage
|
|
.as_deref()
|
|
.unwrap_or(DEFAULT_STORAGE)
|
|
.replace('\'', "''");
|
|
let data_path = ducklake.storage.path.replace('\'', "''");
|
|
|
|
let extra_args = if let Some(default_extra_args) = ducklake.extra_args {
|
|
format!("{},{}", extra_args, default_extra_args)
|
|
} else {
|
|
extra_args
|
|
};
|
|
// Ducklake 0.3 only requires DATA_PATH at creation and then stores it internally in the catalog
|
|
// But it will fail if DATA_PATH changes afterwards which is annoying for us
|
|
// So we always enable override
|
|
let extra_args = if extra_args.contains("OVERRIDE_DATA_PATH") {
|
|
extra_args
|
|
} else {
|
|
format!(", OVERRIDE_DATA_PATH TRUE{extra_args}")
|
|
};
|
|
// Automatically migrate ducklake
|
|
let extra_args = if extra_args.contains("AUTOMATIC_MIGRATION") {
|
|
extra_args
|
|
} else {
|
|
format!(", AUTOMATIC_MIGRATION TRUE{extra_args}")
|
|
};
|
|
// In a fork, re-emit the fork-owned DATA_PATH as the LAST option: DuckDB keeps the last
|
|
// occurrence of a duplicated option, so this wins over anything the arg stripping might
|
|
// not recognize (e.g. a dollar-quoted literal), regardless of literal syntax. The
|
|
// METADATA_SCHEMA injected by the fork resolution is already last within `extra_args`.
|
|
let extra_args = if ducklake.fork_defer.is_some() {
|
|
format!("{extra_args}, DATA_PATH 's3://{storage}/{data_path}'")
|
|
} else {
|
|
extra_args
|
|
};
|
|
|
|
let attach_str = format!(
|
|
"ATTACH 'ducklake:{db_type}:{db_conn_str}' AS {alias_name} (DATA_PATH 's3://{storage}/{data_path}'{extra_args});",
|
|
);
|
|
|
|
let install_db_ext_str = get_attach_db_install_str(db_type)?;
|
|
let mut statements = vec![
|
|
"INSTALL ducklake;".to_string(),
|
|
install_db_ext_str.to_string(),
|
|
attach_str,
|
|
];
|
|
if let Some(defer) = ducklake.fork_defer.as_ref() {
|
|
statements.extend(fork_defer_statements(
|
|
name,
|
|
alias_name,
|
|
defer,
|
|
materialize_target,
|
|
hidden_passwords,
|
|
)?);
|
|
}
|
|
Ok(Some(statements))
|
|
}
|
|
|
|
// Double-quote a possibly schema-qualified table reference, each dotted segment
|
|
// independently (local copy of sql_materialize's private helper).
|
|
fn quote_qualified_table(name: &str) -> String {
|
|
name.split('.')
|
|
.map(|id| format!("\"{}\"", id.replace('"', "\"\"")))
|
|
.collect::<Vec<_>>()
|
|
.join(".")
|
|
}
|
|
|
|
// `schema.table` → `schema.table_current`: the SCD2 companion view lives next to its table.
|
|
fn current_companion(table: &str) -> String {
|
|
match table.rsplit_once('.') {
|
|
Some((s, t)) => format!("{s}.{t}_current"),
|
|
None => format!("{table}_current"),
|
|
}
|
|
}
|
|
|
|
/// Statements appended after a fork workspace's lake ATTACH: read-only attaches of the
|
|
/// ancestor namespaces plus `CREATE VIEW IF NOT EXISTS` defer views over the direct parent for
|
|
/// every table the fork has not materialized. When this job's managed materialize targets one
|
|
/// of the deferred tables, its defer view is dropped instead of created — the write must hit a
|
|
/// real fork table (`CREATE [OR REPLACE] TABLE` refuses to replace a view).
|
|
fn fork_defer_statements(
|
|
lake_name: &str,
|
|
alias_name: &str,
|
|
defer: &windmill_common::workspaces::DucklakeForkDefer,
|
|
materialize_target: Option<&str>,
|
|
hidden_passwords: &mut Arc<Mutex<Vec<String>>>,
|
|
) -> Result<Vec<String>> {
|
|
let mut stmts = vec![];
|
|
if defer.ancestors.is_empty() {
|
|
// Defer unavailable (an ancestor no longer defines this lake); the fork namespace
|
|
// still isolates writes.
|
|
return Ok(stmts);
|
|
}
|
|
for a in &defer.ancestors {
|
|
if let Some(pwd) = a.catalog_resource.get("password").and_then(|p| p.as_str()) {
|
|
hidden_passwords.lock().unwrap().push(pwd.to_string());
|
|
}
|
|
let db_type = match a.catalog.resource_type {
|
|
DucklakeCatalogResourceType::Instance => "postgres",
|
|
_ => a.catalog.resource_type.as_ref(),
|
|
};
|
|
stmts.push(get_attach_db_install_str(db_type)?.to_string());
|
|
let conn_str =
|
|
format_attach_db_conn_str(a.catalog_resource.clone(), db_type)?.replace('\'', "''");
|
|
let storage = a
|
|
.storage
|
|
.storage
|
|
.as_deref()
|
|
.unwrap_or(DEFAULT_STORAGE)
|
|
.replace('\'', "''");
|
|
let data_path = a.storage.path.replace('\'', "''");
|
|
let metadata_schema = a
|
|
.metadata_schema
|
|
.as_ref()
|
|
.map(|s| format!(", METADATA_SCHEMA '{}'", s.replace('\'', "''")))
|
|
.unwrap_or_default();
|
|
// The ancestor config's own non-reserved args (e.g. `ENCRYPTED true`) — an
|
|
// option-dependent lake wouldn't attach without them. Emitted FIRST: DuckDB keeps the
|
|
// last occurrence of a duplicated option, so the fork-owned DATA_PATH / READ_ONLY /
|
|
// METADATA_SCHEMA after them always win.
|
|
let extra_args = a
|
|
.extra_args
|
|
.as_ref()
|
|
.map(|e| format!("{e}, "))
|
|
.unwrap_or_default();
|
|
// READ_ONLY: a fork job must never write an ancestor namespace. No AUTOMATIC_MIGRATION
|
|
// / CREATE_IF_NOT_EXISTS: an ancestor lake that would need creating or migrating fails
|
|
// loudly rather than being mutated from a fork. IF NOT EXISTS: several ATTACH blocks in
|
|
// one script (e.g. the user's + the materialize synthetic one) emit the same ancestors.
|
|
stmts.push(format!(
|
|
"ATTACH IF NOT EXISTS 'ducklake:{db_type}:{conn_str}' AS {} ({extra_args}DATA_PATH 's3://{storage}/{data_path}', OVERRIDE_DATA_PATH TRUE, READ_ONLY{metadata_schema});",
|
|
a.alias
|
|
));
|
|
}
|
|
let parent_alias = &defer.ancestors[0].alias;
|
|
let target_table = materialize_target.and_then(|ap| {
|
|
let (l, t) = ap.split_once('/')?;
|
|
(l == lake_name).then_some(t)
|
|
});
|
|
let mut created_schemas = std::collections::HashSet::new();
|
|
for dt in &defer.defer_tables {
|
|
if Some(dt.table.as_str()) == target_table {
|
|
continue;
|
|
}
|
|
// Each view targets the NEAREST ancestor that physically owns the table (a direct
|
|
// parent that itself defers has no copy to bind against). Out-of-range index (never
|
|
// produced by the resolver, but the field crosses the agent wire) falls back to the
|
|
// direct parent rather than panicking.
|
|
let owner_alias = defer
|
|
.ancestors
|
|
.get(dt.ancestor_idx as usize)
|
|
.map(|a| a.alias.as_str())
|
|
.unwrap_or(parent_alias);
|
|
if let Some((schema, _)) = dt.table.rsplit_once('.') {
|
|
if created_schemas.insert(schema) {
|
|
stmts.push(format!(
|
|
"CREATE SCHEMA IF NOT EXISTS {alias_name}.{};",
|
|
quote_qualified_table(schema)
|
|
));
|
|
}
|
|
}
|
|
let q = quote_qualified_table(&dt.table);
|
|
stmts.push(format!(
|
|
"CREATE VIEW IF NOT EXISTS {alias_name}.{q} AS SELECT * FROM {owner_alias}.{q};"
|
|
));
|
|
if dt.with_current_view {
|
|
let qc = quote_qualified_table(¤t_companion(&dt.table));
|
|
stmts.push(format!(
|
|
"CREATE VIEW IF NOT EXISTS {alias_name}.{qc} AS SELECT * FROM {owner_alias}.{qc};"
|
|
));
|
|
}
|
|
}
|
|
if let Some(t) = target_table {
|
|
// View→table transition: replace the target's defer view(s) with the real table this
|
|
// job writes (`CREATE [OR REPLACE] TABLE` refuses to replace a view). Keyed on the
|
|
// catalog's ACTUAL live views — not on recorded materialization status, which after a
|
|
// failed run can't tell a defer view from a real table, and a mismatched DROP VIEW
|
|
// would wedge the asset. `_current` is dropped too when it is a view — SCD2 codegen
|
|
// recreates it with `IF NOT EXISTS`, which would otherwise silently keep a view over
|
|
// the parent.
|
|
for name in [t.to_string(), current_companion(t)] {
|
|
if defer.fork_views.iter().any(|v| v == &name) {
|
|
stmts.push(format!(
|
|
"DROP VIEW IF EXISTS {alias_name}.{};",
|
|
quote_qualified_table(&name)
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Ok(stmts)
|
|
}
|
|
|
|
async fn transform_attach_datatable(
|
|
query: &str,
|
|
conn: &Connection,
|
|
hidden_passwords: &mut Arc<Mutex<Vec<String>>>,
|
|
w_id: &str,
|
|
) -> Result<Option<Vec<String>>> {
|
|
lazy_static::lazy_static! {
|
|
static ref RE: regex::Regex = regex::Regex::new(r"(?i)ATTACH\s*'datatable(://[^':]+)?'\s*AS\s+([^ ;]+)").unwrap();
|
|
}
|
|
let Some(cap) = RE.captures(query) else {
|
|
return Ok(None);
|
|
};
|
|
let name = cap.get(1).map(|m| &m.as_str()[3..]).unwrap_or("main");
|
|
let alias_name = cap.get(2).map(|m| m.as_str()).unwrap_or("");
|
|
|
|
let db_resource = match conn {
|
|
Connection::Http(client) => {
|
|
get_datatable_resource_from_agent_http(client, name, w_id).await?
|
|
}
|
|
Connection::Sql(db) => get_datatable_resource_from_db_unchecked(db, w_id, name).await?,
|
|
};
|
|
let db_type = "postgres";
|
|
|
|
if let Some(pwd) = db_resource.get("password").and_then(|p| p.as_str()) {
|
|
hidden_passwords.lock().unwrap().push(pwd.to_string());
|
|
}
|
|
|
|
Ok(Some(
|
|
db_resource_to_attach_statements(db_resource, alias_name, db_type, None).await?,
|
|
))
|
|
}
|
|
|
|
async fn transform_s3_uris(query: &str) -> Result<String> {
|
|
let mut transformed_query = None;
|
|
lazy_static::lazy_static! {
|
|
static ref RE: regex::Regex = regex::Regex::new(r"'s3://([^'/]*)/([^']*)'").unwrap();
|
|
}
|
|
for cap in RE.captures_iter(query) {
|
|
if let (storage, Some(s3_path)) = (cap.get(1), cap.get(2)) {
|
|
let s3_path = s3_path.as_str();
|
|
let mut storage = storage.map(|m| m.as_str()).unwrap_or("");
|
|
if !storage.is_empty() {
|
|
continue;
|
|
}
|
|
let original_str_lit: String = format!("'s3://{}/{}'", storage, s3_path);
|
|
storage = DEFAULT_STORAGE;
|
|
|
|
let new_s3_lit = format!("'s3://{}/{}'", storage, s3_path);
|
|
transformed_query = Some(
|
|
transformed_query
|
|
.unwrap_or_else(|| query.to_string())
|
|
.replace(&original_str_lit, &new_s3_lit),
|
|
);
|
|
}
|
|
}
|
|
Ok(transformed_query.unwrap_or(query.to_string()))
|
|
}
|
|
|
|
// BigQuery extension requires a json file as credentials
|
|
// The file path is set as an env var by do_duckdb
|
|
// It is created by transform_attach_db_resource_query (when bigquery is detected)
|
|
// and deleted by do_duckdb after the query is executed.
|
|
//
|
|
// This relies on the fact that DuckDB does not run in native worker, so
|
|
// a worker will only run a single job at a time.
|
|
pub struct UseBigQueryCredentialsFile {
|
|
path: String,
|
|
}
|
|
impl UseBigQueryCredentialsFile {
|
|
fn new(job_id: Uuid, bigquery_resource: &str) -> Result<Self> {
|
|
let path = format!("/tmp/service-account-credentials-{}.json", job_id);
|
|
unsafe {
|
|
env::set_var("GOOGLE_APPLICATION_CREDENTIALS", &path);
|
|
}
|
|
std::fs::write(&path, bigquery_resource)
|
|
.map_err(|e| Error::ExecutionErr(format!("Failed to write BigQuery creds: {e}")))?;
|
|
Ok(Self { path })
|
|
}
|
|
}
|
|
impl Drop for UseBigQueryCredentialsFile {
|
|
fn drop(&mut self) {
|
|
unsafe {
|
|
env::remove_var("GOOGLE_APPLICATION_CREDENTIALS");
|
|
}
|
|
if matches!(std::fs::exists(&self.path), Ok(true)) {
|
|
let _ = std::fs::remove_file(&self.path);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Shared with ffi module
|
|
#[derive(Serialize, Clone, Debug, PartialEq, Default)]
|
|
pub struct Arg {
|
|
pub name: String,
|
|
pub arg_type: String,
|
|
pub json_value: serde_json::Value,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn decode_ffi_error_unescapes_multiline_and_strips_quotes() {
|
|
// Mirror the FFI: JSON-encode the raw DuckDB message, prefix "ERROR ".
|
|
let raw_msg = "Invalid Input Error: data tests failed on main/raw_orders \
|
|
(1/3 failed) — write rolled back, previous version left live:\n \
|
|
✓ not_null(order_id)\n ✗ accepted_values(status) — 12 violating row(s)";
|
|
let ffi = format!("ERROR {}", serde_json::to_string(raw_msg).unwrap());
|
|
let decoded = decode_ffi_error(&ffi);
|
|
assert_eq!(decoded, raw_msg);
|
|
// real newlines, no literal `\n`, no wrapping quotes
|
|
assert!(decoded.contains('\n') && !decoded.contains("\\n"));
|
|
assert!(!decoded.starts_with('"'));
|
|
// single-line error with inner quotes round-trips unescaped
|
|
let single = format!(
|
|
"ERROR {}",
|
|
serde_json::to_string("Binder Error: column \"x\" not found").unwrap()
|
|
);
|
|
assert_eq!(
|
|
decode_ffi_error(&single),
|
|
"Binder Error: column \"x\" not found"
|
|
);
|
|
// non-JSON payload falls back to the raw slice
|
|
assert_eq!(decode_ffi_error("ERROR not json"), "not json");
|
|
}
|
|
|
|
fn test_ancestor(wid: &str) -> windmill_common::workspaces::DucklakeAncestorAttach {
|
|
windmill_common::workspaces::DucklakeAncestorAttach {
|
|
workspace_id: wid.to_string(),
|
|
alias: format!("__wm_dl_lake_{}_abcd1234", wid.replace('-', "_")),
|
|
catalog: windmill_common::workspaces::DucklakeCatalog {
|
|
resource_type: windmill_common::workspaces::DucklakeCatalogResourceType::Instance,
|
|
resource_path: "cat_db".to_string(),
|
|
},
|
|
catalog_resource: serde_json::json!({
|
|
"host": "h", "dbname": "cat_db", "user": "u", "password": "pw"
|
|
}),
|
|
storage: windmill_common::workspaces::DucklakeStorage {
|
|
storage: None,
|
|
path: "lake".to_string(),
|
|
},
|
|
metadata_schema: None,
|
|
extra_args: None,
|
|
}
|
|
}
|
|
|
|
fn test_fork_defer_chain(
|
|
ancestor_wids: Vec<&str>,
|
|
defer_tables: Vec<(&str, bool, u32)>,
|
|
fork_views: Vec<&str>,
|
|
) -> windmill_common::workspaces::DucklakeForkDefer {
|
|
windmill_common::workspaces::DucklakeForkDefer {
|
|
ancestors: ancestor_wids.into_iter().map(test_ancestor).collect(),
|
|
defer_tables: defer_tables
|
|
.into_iter()
|
|
.map(
|
|
|(t, cur, idx)| windmill_common::materialization::ForkDeferTable {
|
|
table: t.to_string(),
|
|
with_current_view: cur,
|
|
ancestor_idx: idx,
|
|
},
|
|
)
|
|
.collect(),
|
|
fork_views: fork_views.into_iter().map(str::to_string).collect(),
|
|
}
|
|
}
|
|
|
|
fn test_fork_defer(
|
|
defer_tables: Vec<(&str, bool)>,
|
|
fork_views: Vec<&str>,
|
|
) -> windmill_common::workspaces::DucklakeForkDefer {
|
|
test_fork_defer_chain(
|
|
vec!["parent-ws"],
|
|
defer_tables.into_iter().map(|(t, c)| (t, c, 0)).collect(),
|
|
fork_views,
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn test_fork_defer_statements_ancestor_extra_args() {
|
|
// The ancestor config's own args (e.g. ENCRYPTED) must ride on its read-only attach —
|
|
// BEFORE the fork-owned options, so DuckDB's last-occurrence-wins keeps DATA_PATH /
|
|
// READ_ONLY / METADATA_SCHEMA authoritative even if the args tried to override them.
|
|
let mut defer = test_fork_defer(vec![("orders", false)], vec![]);
|
|
defer.ancestors[0].extra_args = Some("ENCRYPTED true".to_string());
|
|
let mut hp = Arc::new(Mutex::new(vec![]));
|
|
let stmts = fork_defer_statements("lake", "dl", &defer, None, &mut hp).unwrap();
|
|
let attach = stmts
|
|
.iter()
|
|
.find(|s| s.starts_with("ATTACH IF NOT EXISTS"))
|
|
.unwrap();
|
|
assert!(attach.contains("(ENCRYPTED true, DATA_PATH "), "{attach}");
|
|
assert!(
|
|
attach.find("ENCRYPTED true").unwrap() < attach.find("READ_ONLY").unwrap(),
|
|
"{attach}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_fork_defer_statements_chained_ancestors() {
|
|
// fork → parent → root: `orders` was only materialized in the root (idx 1) — its view
|
|
// must target the ROOT alias (the parent has no physical copy); `daily` owned by the
|
|
// direct parent (idx 0) targets the parent alias. Out-of-range idx falls back to the
|
|
// direct parent instead of panicking.
|
|
let defer = test_fork_defer_chain(
|
|
vec!["parent-ws", "root-ws"],
|
|
vec![("orders", false, 1), ("daily", false, 0), ("oob", false, 9)],
|
|
vec![],
|
|
);
|
|
let mut hp = Arc::new(Mutex::new(vec![]));
|
|
let stmts = fork_defer_statements("lake", "dl", &defer, None, &mut hp).unwrap();
|
|
let joined = stmts.join("\n");
|
|
assert!(
|
|
joined.contains(
|
|
"CREATE VIEW IF NOT EXISTS dl.\"orders\" AS SELECT * FROM __wm_dl_lake_root_ws_abcd1234.\"orders\""
|
|
),
|
|
"{joined}"
|
|
);
|
|
assert!(
|
|
joined.contains(
|
|
"CREATE VIEW IF NOT EXISTS dl.\"daily\" AS SELECT * FROM __wm_dl_lake_parent_ws_abcd1234.\"daily\""
|
|
),
|
|
"{joined}"
|
|
);
|
|
assert!(
|
|
joined.contains(
|
|
"CREATE VIEW IF NOT EXISTS dl.\"oob\" AS SELECT * FROM __wm_dl_lake_parent_ws_abcd1234.\"oob\""
|
|
),
|
|
"{joined}"
|
|
);
|
|
// Both ancestors attached read-only.
|
|
assert_eq!(
|
|
joined.matches("ATTACH IF NOT EXISTS").count(),
|
|
2,
|
|
"{joined}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_fork_defer_statements_shape() {
|
|
let defer = test_fork_defer(vec![("orders", false), ("dim", true)], vec![]);
|
|
let mut hp = Arc::new(Mutex::new(vec![]));
|
|
let stmts = fork_defer_statements("lake", "dl", &defer, None, &mut hp).unwrap();
|
|
let joined = stmts.join("\n");
|
|
// Ancestor attach: read-only, idempotent, never auto-migrating or auto-creating.
|
|
assert!(joined.contains("ATTACH IF NOT EXISTS"), "{joined}");
|
|
assert!(joined.contains("READ_ONLY"), "{joined}");
|
|
assert!(!joined.contains("AUTOMATIC_MIGRATION"), "{joined}");
|
|
assert!(!joined.contains("CREATE_IF_NOT_EXISTS"), "{joined}");
|
|
// Ancestor catalog password is hidden from logs.
|
|
assert_eq!(hp.lock().unwrap().as_slice(), ["pw"]);
|
|
// Defer views over the parent alias, plus the SCD2 `_current` companion.
|
|
assert!(
|
|
joined.contains(
|
|
"CREATE VIEW IF NOT EXISTS dl.\"orders\" AS SELECT * FROM __wm_dl_lake_parent_ws_abcd1234.\"orders\""
|
|
),
|
|
"{joined}"
|
|
);
|
|
assert!(joined.contains("dl.\"dim_current\""), "{joined}");
|
|
// No target → no transition drops.
|
|
assert!(!joined.contains("DROP VIEW"), "{joined}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_fork_defer_statements_target_transition() {
|
|
// Target currently a defer view → skip its CREATE, drop the view (+ companion).
|
|
let defer = test_fork_defer(vec![("orders", false)], vec!["orders", "orders_current"]);
|
|
let mut hp = Arc::new(Mutex::new(vec![]));
|
|
let stmts =
|
|
fork_defer_statements("lake", "_wm_target", &defer, Some("lake/orders"), &mut hp)
|
|
.unwrap();
|
|
let joined = stmts.join("\n");
|
|
assert!(!joined.contains("CREATE VIEW"), "{joined}");
|
|
assert!(
|
|
joined.contains("DROP VIEW IF EXISTS _wm_target.\"orders\";"),
|
|
"{joined}"
|
|
);
|
|
assert!(
|
|
joined.contains("DROP VIEW IF EXISTS _wm_target.\"orders_current\";"),
|
|
"{joined}"
|
|
);
|
|
|
|
// Target already a real table (NOT in fork_views, e.g. after a failed re-run whose
|
|
// status can't be trusted) → no DROP VIEW, or the job would wedge on a type mismatch.
|
|
let defer = test_fork_defer(vec![("orders", false)], vec![]);
|
|
let stmts =
|
|
fork_defer_statements("lake", "_wm_target", &defer, Some("lake/orders"), &mut hp)
|
|
.unwrap();
|
|
assert!(!stmts.join("\n").contains("DROP VIEW"), "{stmts:?}");
|
|
|
|
// Target in a different lake → this lake's defer views are untouched.
|
|
let defer = test_fork_defer(vec![("orders", false)], vec!["orders"]);
|
|
let stmts =
|
|
fork_defer_statements("lake", "dl", &defer, Some("other/orders"), &mut hp).unwrap();
|
|
let joined = stmts.join("\n");
|
|
assert!(
|
|
joined.contains("CREATE VIEW IF NOT EXISTS dl.\"orders\""),
|
|
"{joined}"
|
|
);
|
|
assert!(!joined.contains("DROP VIEW"), "{joined}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_fork_defer_statements_schema_qualified() {
|
|
let defer = test_fork_defer(vec![("staging.raw", false)], vec![]);
|
|
let mut hp = Arc::new(Mutex::new(vec![]));
|
|
let stmts = fork_defer_statements("lake", "dl", &defer, None, &mut hp).unwrap();
|
|
let joined = stmts.join("\n");
|
|
assert!(
|
|
joined.contains("CREATE SCHEMA IF NOT EXISTS dl.\"staging\";"),
|
|
"{joined}"
|
|
);
|
|
assert!(
|
|
joined.contains("CREATE VIEW IF NOT EXISTS dl.\"staging\".\"raw\""),
|
|
"{joined}"
|
|
);
|
|
}
|
|
|
|
fn mrow(name: &str, params: &str, body: &str, is_table: bool, provider: &str) -> MacroRow {
|
|
MacroRow {
|
|
name: name.to_string(),
|
|
params: params.to_string(),
|
|
body: body.to_string(),
|
|
is_table_macro: is_table,
|
|
provider_path: provider.to_string(),
|
|
}
|
|
}
|
|
|
|
fn blocks(list: &[&str]) -> Vec<String> {
|
|
list.iter().map(|s| s.to_string()).collect()
|
|
}
|
|
|
|
// Mimics the shell: resolve (incl. transitive library `// use`), parse the
|
|
// given lib sources, plan. `libs` maps provider path → deployed source
|
|
// (must cover every relevant provider).
|
|
fn select_and_plan(
|
|
b: &[String],
|
|
registry: &[MacroRow],
|
|
use_libs: &[&str],
|
|
libs: &[(&str, &str)],
|
|
) -> Result<Vec<String>> {
|
|
use windmill_parser::asset_parser::parse_pipeline_annotations;
|
|
use windmill_parser::duckdb_macros::parse_macro_library;
|
|
let use_libs: Vec<String> = use_libs.iter().map(|s| s.to_string()).collect();
|
|
let lib_uses = libs
|
|
.iter()
|
|
.map(|(p, src)| (p.to_string(), parse_pipeline_annotations(src).use_libs))
|
|
.collect();
|
|
let (selected, effective) = resolve_macro_selection(b, registry, &use_libs, &lib_uses)?;
|
|
let lib_bodies = libs
|
|
.iter()
|
|
.map(|(p, src)| (p.to_string(), parse_macro_library(src).unwrap()))
|
|
.collect();
|
|
plan_macro_injection(&selected, registry, &effective, &lib_bodies)
|
|
}
|
|
|
|
#[test]
|
|
fn macro_injection_detects_and_orders_transitively() {
|
|
// consumer calls `outer`; `outer` calls `inner` — both injected, inner first.
|
|
let registry = vec![
|
|
mrow("outer", "a", "inner(a) + 1", false, "f/lib/m"),
|
|
mrow("inner", "a", "a * 2", false, "f/lib/m"),
|
|
mrow("unused", "a", "a", false, "f/lib/m"),
|
|
];
|
|
let b = blocks(&["ATTACH 'x.duckdb' AS ext;", "SELECT outer(1);"]);
|
|
let injected = select_and_plan(&b, ®istry, &[], &[("f/lib/m", "")]).unwrap();
|
|
assert_eq!(
|
|
injected,
|
|
vec![
|
|
"CREATE OR REPLACE TEMP MACRO inner(a) AS a * 2;".to_string(),
|
|
"CREATE OR REPLACE TEMP MACRO outer(a) AS inner(a) + 1;".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn implicit_call_injects_provider_setup() {
|
|
// A macro whose body references its own lib's ATTACH must carry that
|
|
// setup even on the implicit (detection) path — DuckDB bind-checks the
|
|
// body at CREATE, so `ext` must be attached first.
|
|
let registry = vec![mrow(
|
|
"lookup",
|
|
"k",
|
|
"(SELECT v FROM ext.kv WHERE key = k)",
|
|
false,
|
|
"f/lib/m",
|
|
)];
|
|
let b = blocks(&["SELECT lookup('a');"]);
|
|
let injected = select_and_plan(
|
|
&b,
|
|
®istry,
|
|
&[],
|
|
&[(
|
|
"f/lib/m",
|
|
"ATTACH 'ext.duckdb' AS ext;\nCREATE MACRO lookup(k) AS (SELECT v FROM ext.kv WHERE key = k);",
|
|
)],
|
|
)
|
|
.unwrap();
|
|
assert_eq!(injected[0], "ATTACH 'ext.duckdb' AS ext;");
|
|
assert!(injected[1].contains("TEMP MACRO lookup(k)"));
|
|
}
|
|
|
|
#[test]
|
|
fn provider_lib_use_is_honored_transitively() {
|
|
// Lib B's macro calls `base_macro` inside a string (invisible to
|
|
// lexical detection), so B declares `// use f/lib/base`. A consumer
|
|
// that merely calls B's macro must still get base's whole library —
|
|
// the dynamic dependency is encapsulated in B, not leaked to every
|
|
// consumer.
|
|
let registry = vec![
|
|
mrow(
|
|
"str_macro",
|
|
"",
|
|
"(SELECT v FROM query('SELECT base_macro() AS v'))",
|
|
false,
|
|
"f/lib/b",
|
|
),
|
|
mrow("base_macro", "", "42", false, "f/lib/base"),
|
|
];
|
|
let b = blocks(&["SELECT str_macro();"]);
|
|
let injected = select_and_plan(
|
|
&b,
|
|
®istry,
|
|
&[],
|
|
&[
|
|
(
|
|
"f/lib/b",
|
|
"-- macros\n-- use f/lib/base\nCREATE MACRO str_macro() AS (SELECT v FROM query('SELECT base_macro() AS v'));",
|
|
),
|
|
("f/lib/base", "-- macros\nCREATE MACRO base_macro() AS 42;"),
|
|
],
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
injected
|
|
.iter()
|
|
.any(|s| s.contains("TEMP MACRO base_macro()")),
|
|
"{injected:?}"
|
|
);
|
|
assert!(injected
|
|
.iter()
|
|
.any(|s| s.contains("TEMP MACRO str_macro()")));
|
|
// Both defs are injected; string-hidden deps carry no topo edge, so
|
|
// their relative order falls back to name-sorted ties (deterministic
|
|
// for this input — the assertion pins the current behavior).
|
|
let base_idx = injected
|
|
.iter()
|
|
.position(|s| s.contains("TEMP MACRO base_macro()"))
|
|
.unwrap();
|
|
let str_idx = injected
|
|
.iter()
|
|
.position(|s| s.contains("TEMP MACRO str_macro()"))
|
|
.unwrap();
|
|
assert!(base_idx < str_idx, "{injected:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_setup_across_libs_is_deduped() {
|
|
// Two libs attaching the same catalog identically must not double-ATTACH.
|
|
let registry = vec![
|
|
mrow("m1", "a", "a", false, "f/lib/one"),
|
|
mrow("m2", "a", "a", false, "f/lib/two"),
|
|
];
|
|
let b = blocks(&["SELECT m1(1), m2(2);"]);
|
|
let src = "ATTACH 'ext.duckdb' AS ext;\nCREATE MACRO m1(a) AS a;";
|
|
let src2 = "ATTACH 'ext.duckdb' AS ext;\nCREATE MACRO m2(a) AS a;";
|
|
let injected = select_and_plan(
|
|
&b,
|
|
®istry,
|
|
&[],
|
|
&[("f/lib/one", src), ("f/lib/two", src2)],
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
injected
|
|
.iter()
|
|
.filter(|s| s.starts_with("ATTACH 'ext.duckdb'"))
|
|
.count(),
|
|
1
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn macro_injection_empty_when_nothing_called() {
|
|
let registry = vec![mrow("m", "a", "a", false, "f/lib/m")];
|
|
let b = blocks(&["SELECT 1;"]);
|
|
assert!(select_workspace_macros(&b, ®istry, &[])
|
|
.unwrap()
|
|
.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn local_definition_wins_over_registry() {
|
|
// A consumer defining its own `dbl` must never get the registry's
|
|
// version injected — a library deploy can't change this job.
|
|
let registry = vec![mrow("dbl", "a", "a * 10", false, "f/lib/m")];
|
|
let b = blocks(&["CREATE TEMP MACRO dbl(a) AS a * 2;", "SELECT dbl(4);"]);
|
|
assert!(select_workspace_macros(&b, ®istry, &[])
|
|
.unwrap()
|
|
.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn use_lib_setup_survives_when_no_macros_selected() {
|
|
// Every lib macro shadowed locally — the explicit `// use` must still
|
|
// carry the lib's setup statements (its ATTACH side effects).
|
|
let registry = vec![mrow("dbl", "a", "a * 10", false, "f/lib/m")];
|
|
let b = blocks(&["CREATE TEMP MACRO dbl(a) AS a * 2;", "SELECT dbl(4);"]);
|
|
let injected = select_and_plan(
|
|
&b,
|
|
®istry,
|
|
&["f/lib/m"],
|
|
&[(
|
|
"f/lib/m",
|
|
"ATTACH 'ext.duckdb' AS ext;\nCREATE MACRO dbl(a) AS a * 10;",
|
|
)],
|
|
)
|
|
.unwrap();
|
|
assert_eq!(injected, vec!["ATTACH 'ext.duckdb' AS ext;".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn weave_lands_after_local_definition_it_references() {
|
|
// Injected bodies may call a local macro (local-wins excludes it from
|
|
// the registry set), so the leading local CREATE must run first.
|
|
let b = blocks(&[
|
|
"ATTACH 'x' AS a;",
|
|
"CREATE MACRO local_dbl(a) AS a * 2;",
|
|
"SELECT registry_m(1);",
|
|
]);
|
|
let out = weave_macro_blocks(
|
|
b,
|
|
vec!["CREATE OR REPLACE TEMP MACRO registry_m(a) AS local_dbl(a) + 1;".into()],
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
out[2],
|
|
"CREATE OR REPLACE TEMP MACRO registry_m(a) AS local_dbl(a) + 1;"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn weave_lands_before_local_definition_that_calls_it() {
|
|
// The inverse direction: a LOCAL macro whose body calls a registry
|
|
// macro bind-checks at its own CREATE, so the injected definition
|
|
// (and its library setup) must come first.
|
|
let b = blocks(&[
|
|
"CREATE MACRO outer(x) AS shared_inner(x) + 1;",
|
|
"SELECT outer(1);",
|
|
]);
|
|
let out = weave_macro_blocks(
|
|
b,
|
|
vec![
|
|
"ATTACH 'ext.duckdb' AS ext;".into(),
|
|
"CREATE OR REPLACE TEMP MACRO shared_inner(x) AS x * 2;".into(),
|
|
],
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
out,
|
|
blocks(&[
|
|
"ATTACH 'ext.duckdb' AS ext;",
|
|
"CREATE OR REPLACE TEMP MACRO shared_inner(x) AS x * 2;",
|
|
"CREATE MACRO outer(x) AS shared_inner(x) + 1;",
|
|
"SELECT outer(1);",
|
|
])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn weave_pulls_injected_dependencies_before_the_calling_local() {
|
|
// outer(local) calls injected `mid`, whose body calls injected `base`:
|
|
// both must precede the local definition, base before mid.
|
|
let b = blocks(&["CREATE MACRO outer(x) AS mid(x) + 1;", "SELECT outer(1);"]);
|
|
let out = weave_macro_blocks(
|
|
b,
|
|
vec![
|
|
"CREATE OR REPLACE TEMP MACRO base(x) AS x * 2;".into(),
|
|
"CREATE OR REPLACE TEMP MACRO mid(x) AS base(x) + 1;".into(),
|
|
],
|
|
)
|
|
.unwrap();
|
|
let pos = |needle: &str| out.iter().position(|s| s.contains(needle)).unwrap();
|
|
assert!(pos("MACRO base(x)") < pos("MACRO mid(x)"));
|
|
assert!(pos("MACRO mid(x)") < pos("MACRO outer(x)"));
|
|
}
|
|
|
|
#[test]
|
|
fn weave_conflicting_local_order_errors() {
|
|
// local_a calls injected X; X references local_b, defined after
|
|
// local_a — unsatisfiable, must error rather than silently mis-order.
|
|
let b = blocks(&[
|
|
"CREATE MACRO local_a(x) AS conflicted(x);",
|
|
"CREATE MACRO local_b(x) AS x * 3;",
|
|
"SELECT local_a(1);",
|
|
]);
|
|
let err = weave_macro_blocks(
|
|
b,
|
|
vec!["CREATE OR REPLACE TEMP MACRO conflicted(x) AS local_b(x) + 1;".into()],
|
|
)
|
|
.unwrap_err()
|
|
.to_string();
|
|
assert!(err.contains("conflicting"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn use_lib_injects_all_macros_and_setup() {
|
|
let registry = vec![
|
|
mrow("m1", "a", "a", false, "f/lib/m"),
|
|
mrow("m2", "", "SELECT 1", true, "f/lib/m"),
|
|
];
|
|
let b = blocks(&["SELECT 'no calls here';"]);
|
|
let injected = select_and_plan(
|
|
&b,
|
|
®istry,
|
|
&["f/lib/m"],
|
|
&[(
|
|
"f/lib/m",
|
|
"ATTACH 'ext.duckdb' AS ext;\nCREATE MACRO m1(a) AS a;\nCREATE MACRO m2() AS TABLE SELECT 1;",
|
|
)],
|
|
)
|
|
.unwrap();
|
|
assert_eq!(injected[0], "ATTACH 'ext.duckdb' AS ext;");
|
|
assert!(injected[1..].iter().any(|s| s.contains("TEMP MACRO m1(a)")));
|
|
assert!(injected[1..]
|
|
.iter()
|
|
.any(|s| s.contains("TEMP MACRO m2() AS TABLE")));
|
|
}
|
|
|
|
#[test]
|
|
fn use_lib_without_registry_rows_errors() {
|
|
let b = blocks(&["SELECT 1;"]);
|
|
let err = select_workspace_macros(&b, &[], &["f/lib/gone".to_string()])
|
|
.unwrap_err()
|
|
.to_string();
|
|
assert!(err.contains("no deployed macro library"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn weave_lands_after_setup_prefix() {
|
|
let b = blocks(&[
|
|
"INSTALL ducklake;",
|
|
"ATTACH 'ducklake:postgres:...' AS lake (DATA_PATH 's3://x');",
|
|
"CREATE TABLE IF NOT EXISTS lake.t AS SELECT 1;",
|
|
"SELECT dbl(1);",
|
|
]);
|
|
let out = weave_macro_blocks(
|
|
b,
|
|
vec!["CREATE OR REPLACE TEMP MACRO dbl(a) AS a * 2;".into()],
|
|
)
|
|
.unwrap();
|
|
assert_eq!(out[2], "CREATE OR REPLACE TEMP MACRO dbl(a) AS a * 2;");
|
|
assert_eq!(out.len(), 5);
|
|
}
|
|
|
|
#[test]
|
|
fn weave_setup_runs_before_all_user_blocks() {
|
|
// Injected library setup is self-contained and anything in the
|
|
// consumer may depend on it at bind time — it always goes first.
|
|
let out = weave_macro_blocks(blocks(&["ATTACH 'x' AS a;"]), blocks(&["m;"])).unwrap();
|
|
assert_eq!(out, blocks(&["m;", "ATTACH 'x' AS a;"]));
|
|
let out = weave_macro_blocks(vec![], blocks(&["m;"])).unwrap();
|
|
assert_eq!(out, blocks(&["m;"]));
|
|
}
|
|
|
|
#[test]
|
|
fn weave_use_setup_precedes_local_shadow_that_references_it() {
|
|
// `// use` with every lib macro shadowed locally: no defs are
|
|
// injected, but the local shadow's body references the lib's
|
|
// ATTACHed catalog — the injected setup must still run first or the
|
|
// local CREATE bind-fails.
|
|
let b = blocks(&[
|
|
"CREATE TEMP MACRO kv_lookup(k) AS (SELECT v FROM ext.kv WHERE key = k);",
|
|
"SELECT kv_lookup('a');",
|
|
]);
|
|
let out = weave_macro_blocks(b, vec!["ATTACH 'ext.duckdb' AS ext;".into()]).unwrap();
|
|
assert_eq!(
|
|
out,
|
|
blocks(&[
|
|
"ATTACH 'ext.duckdb' AS ext;",
|
|
"CREATE TEMP MACRO kv_lookup(k) AS (SELECT v FROM ext.kv WHERE key = k);",
|
|
"SELECT kv_lookup('a');",
|
|
])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cgroup_bytes_unlimited_or_invalid_returns_none() {
|
|
assert_eq!(cgroup_bytes_to_duckdb_memory_limit(0), None);
|
|
assert_eq!(cgroup_bytes_to_duckdb_memory_limit(-1), None);
|
|
// 1 PiB sentinel: cgroup v1 reports ~i64::MAX when uncapped.
|
|
assert_eq!(
|
|
cgroup_bytes_to_duckdb_memory_limit(CGROUP_UNLIMITED_THRESHOLD),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cgroup_bytes_real_values_take_80_percent() {
|
|
// 1 GiB -> 80% -> 819 MiB (floored to MiB)
|
|
assert_eq!(
|
|
cgroup_bytes_to_duckdb_memory_limit(1024 * 1024 * 1024),
|
|
Some("819MiB".to_string())
|
|
);
|
|
// 4 GiB -> 3276 MiB
|
|
assert_eq!(
|
|
cgroup_bytes_to_duckdb_memory_limit(4 * 1024 * 1024 * 1024),
|
|
Some("3276MiB".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cgroup_bytes_tiny_values_floored_to_64mib() {
|
|
// Tiny cgroup must not produce a 0/unusable limit.
|
|
assert_eq!(
|
|
cgroup_bytes_to_duckdb_memory_limit(1024 * 1024),
|
|
Some("64MiB".to_string())
|
|
);
|
|
assert_eq!(
|
|
cgroup_bytes_to_duckdb_memory_limit(1),
|
|
Some("64MiB".to_string())
|
|
);
|
|
}
|
|
|
|
// Managed `// materialize` may take SQL args (e.g. an s3object uploaded on
|
|
// the run form). The wrap strips line comments — including the
|
|
// `-- $name (type)` declarations — so the executor parses the signature from
|
|
// the original script (done above, before the rewrite) while the `$name`
|
|
// references survive inside the wrapped SELECT. This pins both halves of that
|
|
// contract so a regression that drops either is caught.
|
|
#[test]
|
|
fn materialize_preserves_sql_args() {
|
|
let script = "-- materialize ducklake://main/rows\n\
|
|
-- $file (s3object)\n\
|
|
SELECT * FROM read_json_auto($file)";
|
|
|
|
// The signature is recoverable from the original (un-wrapped) script.
|
|
let sig = parse_duckdb_sig(script).expect("sig parses").args;
|
|
let file_arg = sig
|
|
.iter()
|
|
.find(|a| a.name == "file")
|
|
.expect("`$file` declared");
|
|
assert_eq!(file_arg.otyp.as_deref(), Some("s3object"));
|
|
|
|
// The wrapped query still references `$file`, so the parsed sig binds it.
|
|
// No custom data tests here, so no fetched bodies are needed.
|
|
let (rewritten, _) =
|
|
build_materialized_query(script, None, &std::collections::HashMap::new())
|
|
.expect("materialize builds")
|
|
.expect("materialize present");
|
|
let rewritten = rewritten.expect("managed mode rewrites the query");
|
|
assert!(
|
|
rewritten.contains("$file"),
|
|
"wrapped query must keep the `$file` reference, got:\n{rewritten}"
|
|
);
|
|
// The declaration comment is gone (wrap strips line comments) — which is
|
|
// exactly why the sig must come from the original, not the rewrite.
|
|
assert!(!rewritten.contains("-- $file"));
|
|
}
|
|
|
|
// A `// partitioned` script referencing `$partition` needs no manual
|
|
// `-- $partition (text)` declaration: the parser auto-declares the arg, so
|
|
// the executor binds the injected `partition` job arg instead of failing
|
|
// with duckdb's "Wrong number of parameters" at prepare time.
|
|
#[test]
|
|
fn partitioned_auto_declares_partition_arg() {
|
|
let script = "// partitioned daily\n\
|
|
// materialize ducklake://main/sales_daily\n\
|
|
SELECT $partition AS day, count(*) AS n FROM dl.sales WHERE day = $partition";
|
|
|
|
let sig = parse_duckdb_sig(script).expect("sig parses").args;
|
|
let partition_arg = sig
|
|
.iter()
|
|
.find(|a| a.name == "partition")
|
|
.expect("`partition` auto-declared");
|
|
assert_eq!(partition_arg.otyp.as_deref(), Some("text"));
|
|
|
|
// The wrapped query keeps the `$partition` references so the parsed sig
|
|
// binds them at run time.
|
|
let (rewritten, _) = build_materialized_query(
|
|
script,
|
|
Some("2026-07-02"),
|
|
&std::collections::HashMap::new(),
|
|
)
|
|
.expect("materialize builds")
|
|
.expect("materialize present");
|
|
let rewritten = rewritten.expect("managed mode rewrites the query");
|
|
assert!(
|
|
rewritten.contains("$partition"),
|
|
"wrapped query must keep the `$partition` reference, got:\n{rewritten}"
|
|
);
|
|
}
|
|
|
|
// SCD2 managed mode wraps the SELECT into the diff → close-old → open-new
|
|
// shape (unit-covered in the parser's codegen tests); here we pin the
|
|
// executor-level wiring: the natural key flows through and the wrap is
|
|
// generated (not the manual track-only path).
|
|
#[test]
|
|
fn materialize_scd2_wraps_with_history() {
|
|
// Primary spelling: `key=<col> history` on a merge.
|
|
let script = "-- materialize ducklake://main/dim key=id history track=name\n\
|
|
SELECT id, name FROM dl.src";
|
|
let (rewritten, _) =
|
|
build_materialized_query(script, None, &std::collections::HashMap::new())
|
|
.expect("materialize builds")
|
|
.expect("materialize present");
|
|
let rewritten = rewritten.expect("scd2 is managed — must rewrite");
|
|
assert!(
|
|
rewritten.contains("valid_from"),
|
|
"adds SCD2 columns:\n{rewritten}"
|
|
);
|
|
assert!(rewritten.contains("is_current"));
|
|
assert!(
|
|
rewritten.contains("_wm_scd2_changed"),
|
|
"captures changed keys"
|
|
);
|
|
assert!(
|
|
rewritten.contains("UPDATE _wm_target.dim SET valid_to"),
|
|
"closes prior version"
|
|
);
|
|
assert!(
|
|
rewritten.contains("CREATE VIEW IF NOT EXISTS _wm_target.dim_current"),
|
|
"emits the consumer-convenience current view"
|
|
);
|
|
// default is soft-delete — no deleted-key set without `deletes=close`
|
|
assert!(!rewritten.contains("_wm_scd2_deleted"));
|
|
assert!(!rewritten.contains("MERGE INTO"));
|
|
}
|
|
|
|
#[test]
|
|
fn materialize_scd2_deletes_close_wires_through() {
|
|
let script = "-- materialize ducklake://main/dim key=id history deletes=close\n\
|
|
SELECT id, name FROM dl.src";
|
|
let (rewritten, _) =
|
|
build_materialized_query(script, None, &std::collections::HashMap::new())
|
|
.expect("materialize builds")
|
|
.expect("materialize present");
|
|
let rewritten = rewritten.expect("scd2 is managed — must rewrite");
|
|
assert!(
|
|
rewritten.contains("_wm_scd2_deleted"),
|
|
"deletes=close adds the vanished-key set + close:\n{rewritten}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn materialize_scd2_requires_key() {
|
|
let script = "-- materialize scd2 ducklake://main/dim\nSELECT id, name FROM dl.src";
|
|
let err = match build_materialized_query(script, None, &std::collections::HashMap::new()) {
|
|
Err(e) => e,
|
|
Ok(_) => panic!("scd2 without key must error"),
|
|
};
|
|
assert!(
|
|
format!("{err}").contains("requires a natural key"),
|
|
"got: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn materialize_scd2_rejects_partitioned() {
|
|
let script = "-- pipeline\n-- partitioned daily\n\
|
|
-- materialize scd2 ducklake://main/dim key=id\nSELECT id, name FROM dl.src";
|
|
let err = match build_materialized_query(
|
|
script,
|
|
Some("2026-07-01"),
|
|
&std::collections::HashMap::new(),
|
|
) {
|
|
Err(e) => e,
|
|
Ok(_) => panic!("partitioned + scd2 must error"),
|
|
};
|
|
assert!(
|
|
format!("{err}").contains("not supported with scd2"),
|
|
"got: {err}"
|
|
);
|
|
}
|
|
|
|
// The rewritten SQL is the plan assembled by
|
|
// `pipeline_advanced::finalize_materialize_query`, so what it contains is
|
|
// build-dependent: the public assembly runs the plan verbatim (tests only
|
|
// in the post-commit summary breakdown), the enterprise assembly adds the
|
|
// in-transaction write-audit-publish guard (whose shape/placement is
|
|
// tested next to its implementation in windmill-common's
|
|
// `pipeline_advanced_ee`).
|
|
#[test]
|
|
fn materialize_rewrite_carries_data_test_summary() {
|
|
let script = "-- materialize ducklake://main/orders\n\
|
|
-- data_test not_null id\n\
|
|
SELECT id FROM dl.src";
|
|
let (rewritten, meta) =
|
|
build_materialized_query(script, None, &std::collections::HashMap::new())
|
|
.expect("materialize builds")
|
|
.expect("materialize present");
|
|
let rewritten = rewritten.expect("managed mode rewrites the query");
|
|
assert!(rewritten.contains("AS data_tests"));
|
|
assert_eq!(meta.n_data_tests, 1);
|
|
#[cfg(not(feature = "private"))]
|
|
assert!(
|
|
!rewritten.contains("error("),
|
|
"public assembly is commit-then-test (no guard)"
|
|
);
|
|
#[cfg(all(feature = "private", feature = "enterprise"))]
|
|
assert!(
|
|
rewritten.contains("error("),
|
|
"enterprise assembly places the WAP guard"
|
|
);
|
|
}
|
|
|
|
// on_schema_change=fail on a persist-and-mutate strategy (merge) emits the
|
|
// drift guard inside the write, and no sync artifacts.
|
|
#[test]
|
|
fn materialize_fail_emits_drift_guard() {
|
|
let script = "-- materialize ducklake://main/dim key=id on_schema_change=fail\n\
|
|
SELECT id, name FROM dl.src";
|
|
let (rewritten, meta) =
|
|
build_materialized_query(script, None, &std::collections::HashMap::new())
|
|
.expect("materialize builds")
|
|
.expect("materialize present");
|
|
let rewritten = rewritten.expect("managed mode rewrites the query");
|
|
assert!(
|
|
rewritten.contains("CAST(error(") && rewritten.contains("on_schema_change=fail"),
|
|
"fail emits the drift guard: {rewritten}"
|
|
);
|
|
assert!(!rewritten.contains("BY NAME"), "fail writes positionally");
|
|
assert!(meta.sync_prepass.is_none(), "fail needs no pre-pass probe");
|
|
}
|
|
|
|
// on_schema_change=sync writes BY NAME, emits the ALTER sentinel, and builds
|
|
// the pre-pass probe (DESCRIBE of the SELECT + information_schema read).
|
|
#[test]
|
|
fn materialize_sync_by_name_and_prepass() {
|
|
let script = "-- materialize ducklake://main/dim key=id on_schema_change=sync\n\
|
|
SELECT id, name FROM dl.src";
|
|
let (rewritten, meta) =
|
|
build_materialized_query(script, None, &std::collections::HashMap::new())
|
|
.expect("materialize builds")
|
|
.expect("materialize present");
|
|
let rewritten = rewritten.expect("managed mode rewrites the query");
|
|
assert!(
|
|
rewritten.contains("INSERT INTO _wm_target.dim BY NAME"),
|
|
"sync insert is name-mapped: {rewritten}"
|
|
);
|
|
assert!(
|
|
rewritten.contains(windmill_parser::sql_materialize::SYNC_ALTER_SENTINEL),
|
|
"sync emits the ALTER injection sentinel"
|
|
);
|
|
let pp = meta.sync_prepass.expect("sync builds a pre-pass probe");
|
|
assert_eq!(pp.target_table, "dim");
|
|
assert!(pp
|
|
.probe_query
|
|
.contains("DESCRIBE SELECT * FROM (SELECT id, name FROM dl.src)"));
|
|
assert!(pp.probe_query.contains("FROM information_schema.columns"));
|
|
assert!(pp
|
|
.probe_query
|
|
.contains("table_catalog = '_wm_target' AND table_name = 'dim'"));
|
|
assert!(pp
|
|
.probe_query
|
|
.contains("ATTACH 'ducklake://main' AS _wm_target;"));
|
|
}
|
|
|
|
// on_schema_change=warn (default) on a persist-and-mutate strategy folds the
|
|
// drift into the summary; no guard, no sync artifacts.
|
|
#[test]
|
|
fn materialize_warn_summary_carries_schema_drift() {
|
|
let script = "-- materialize ducklake://main/dim key=id\n\
|
|
SELECT id, name FROM dl.src";
|
|
let (rewritten, meta) =
|
|
build_materialized_query(script, None, &std::collections::HashMap::new())
|
|
.expect("materialize builds")
|
|
.expect("materialize present");
|
|
let rewritten = rewritten.expect("managed mode rewrites the query");
|
|
assert!(
|
|
rewritten.contains("AS schema_drift"),
|
|
"warn folds drift into the summary: {rewritten}"
|
|
);
|
|
// No schema-drift *fail* guard in warn mode (the keyed merge still emits
|
|
// its own duplicate-source-key guard, which is a different `error(...)`).
|
|
assert!(!rewritten.contains("on_schema_change=fail blocked"));
|
|
assert!(rewritten.contains("keyed merge on `id` blocked"));
|
|
assert!(!rewritten.contains("BY NAME"));
|
|
assert!(meta.sync_prepass.is_none());
|
|
}
|
|
|
|
// Whole-table replace (unpartitioned, no key/append) is not persist-and-
|
|
// mutate: even with on_schema_change=fail there is no guard / sentinel — the
|
|
// CREATE OR REPLACE already rebuilds the schema each run.
|
|
#[test]
|
|
fn materialize_whole_table_replace_ignores_guardrail() {
|
|
let script = "-- materialize ducklake://main/t on_schema_change=fail\n\
|
|
SELECT a, b FROM dl.src";
|
|
let (rewritten, meta) =
|
|
build_materialized_query(script, None, &std::collections::HashMap::new())
|
|
.expect("materialize builds")
|
|
.expect("materialize present");
|
|
let rewritten = rewritten.expect("managed mode rewrites the query");
|
|
assert!(rewritten.contains("CREATE OR REPLACE TABLE _wm_target.t"));
|
|
assert!(!rewritten.contains("on_schema_change=fail"));
|
|
assert!(!rewritten.contains(windmill_parser::sql_materialize::SYNC_ALTER_SENTINEL));
|
|
assert!(meta.sync_prepass.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn extract_schema_drift_parses_struct_and_string_and_null() {
|
|
// nested struct form
|
|
let r = raw(
|
|
r#"{"materialized":"ducklake://main/dim","schema_drift":{"added":["c"],"removed":["b"]}}"#,
|
|
);
|
|
let (added, removed) = extract_schema_drift(&r).expect("drift present");
|
|
assert_eq!(added, vec!["c".to_string()]);
|
|
assert_eq!(removed, vec!["b".to_string()]);
|
|
// FFI string-encoded form
|
|
let r = raw(r#"{"schema_drift":"{\"added\":[\"x\"],\"removed\":[]}"}"#);
|
|
let (added, removed) = extract_schema_drift(&r).expect("drift present");
|
|
assert_eq!(added, vec!["x".to_string()]);
|
|
assert!(removed.is_empty());
|
|
// NULL / both-empty ⇒ no drift
|
|
assert!(extract_schema_drift(&raw(r#"{"schema_drift":null}"#)).is_none());
|
|
assert!(
|
|
extract_schema_drift(&raw(r#"{"schema_drift":{"added":[],"removed":[]}}"#)).is_none()
|
|
);
|
|
assert!(extract_schema_drift(&raw(r#"{"rows":3}"#)).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_sync_drift_computes_added_removed_and_skips_fresh_table() {
|
|
// table (a, b) vs SELECT (a, c): add c, drop b; _wm_partition ignored
|
|
let r = raw(r#"[{"_wm_which":"sel","_wm_name":"a","_wm_type":"BIGINT"},
|
|
{"_wm_which":"sel","_wm_name":"c","_wm_type":"VARCHAR"},
|
|
{"_wm_which":"tbl","_wm_name":"a","_wm_type":"BIGINT"},
|
|
{"_wm_which":"tbl","_wm_name":"b","_wm_type":"BIGINT"}]"#);
|
|
let (added, removed) = parse_sync_drift(&r).unwrap();
|
|
assert_eq!(added, vec![("c".to_string(), "VARCHAR".to_string())]);
|
|
assert_eq!(removed, vec!["b".to_string()]);
|
|
// no `tbl` rows ⇒ table doesn't exist yet ⇒ no migration
|
|
let r = raw(r#"[{"_wm_which":"sel","_wm_name":"a","_wm_type":"BIGINT"}]"#);
|
|
let (added, removed) = parse_sync_drift(&r).unwrap();
|
|
assert!(added.is_empty() && removed.is_empty());
|
|
}
|
|
|
|
// Tests for parse_attach_db_resource function
|
|
#[test]
|
|
fn test_parse_attach_db_resource_postgres_res_prefix() {
|
|
let query = "ATTACH '$res:u/user/my_postgres' AS mydb (TYPE POSTGRES)";
|
|
let result = parse_attach_db_resource(query);
|
|
assert!(result.is_some());
|
|
let parsed = result.unwrap();
|
|
assert_eq!(parsed.resource_path, "u/user/my_postgres");
|
|
assert_eq!(parsed.name, "mydb");
|
|
assert_eq!(parsed.db_type, "POSTGRES");
|
|
assert!(parsed.extra_args.is_none() || parsed.extra_args.unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_attach_db_resource_res_protocol() {
|
|
let query = "ATTACH 'res://f/folder/database' AS db (TYPE postgresql)";
|
|
let result = parse_attach_db_resource(query);
|
|
assert!(result.is_some());
|
|
let parsed = result.unwrap();
|
|
assert_eq!(parsed.resource_path, "f/folder/database");
|
|
assert_eq!(parsed.name, "db");
|
|
assert_eq!(parsed.db_type, "postgresql");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_attach_db_resource_mysql() {
|
|
let query = "ATTACH '$res:u/admin/mysql_prod' AS mysql_db (TYPE mysql)";
|
|
let result = parse_attach_db_resource(query);
|
|
assert!(result.is_some());
|
|
let parsed = result.unwrap();
|
|
assert_eq!(parsed.resource_path, "u/admin/mysql_prod");
|
|
assert_eq!(parsed.name, "mysql_db");
|
|
assert_eq!(parsed.db_type, "mysql");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_attach_db_resource_bigquery() {
|
|
let query = "ATTACH '$res:u/user/bq_resource' AS bq (TYPE bigquery)";
|
|
let result = parse_attach_db_resource(query);
|
|
assert!(result.is_some());
|
|
let parsed = result.unwrap();
|
|
assert_eq!(parsed.resource_path, "u/user/bq_resource");
|
|
assert_eq!(parsed.name, "bq");
|
|
assert_eq!(parsed.db_type, "bigquery");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_attach_db_resource_with_extra_args() {
|
|
let query = "ATTACH '$res:u/user/db' AS mydb (TYPE POSTGRES, READ_ONLY)";
|
|
let result = parse_attach_db_resource(query);
|
|
assert!(result.is_some());
|
|
let parsed = result.unwrap();
|
|
assert_eq!(parsed.resource_path, "u/user/db");
|
|
assert_eq!(parsed.name, "mydb");
|
|
assert_eq!(parsed.db_type, "POSTGRES");
|
|
assert_eq!(parsed.extra_args.unwrap(), ", READ_ONLY");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_attach_db_resource_case_insensitive() {
|
|
let query = "attach '$res:u/user/db' as mydb (type postgres)";
|
|
let result = parse_attach_db_resource(query);
|
|
assert!(result.is_some());
|
|
let parsed = result.unwrap();
|
|
assert_eq!(parsed.resource_path, "u/user/db");
|
|
assert_eq!(parsed.name, "mydb");
|
|
assert_eq!(parsed.db_type, "postgres");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_attach_db_resource_no_match() {
|
|
let query = "SELECT * FROM table";
|
|
let result = parse_attach_db_resource(query);
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_attach_db_resource_regular_attach() {
|
|
// Regular ATTACH without $res: or res:// should not match
|
|
let query = "ATTACH 'mydb.duckdb' AS mydb (TYPE duckdb)";
|
|
let result = parse_attach_db_resource(query);
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
// Tests for format_attach_db_conn_str function
|
|
#[test]
|
|
fn test_format_attach_db_conn_str_postgres_full() {
|
|
let db_resource = json!({
|
|
"host": "localhost",
|
|
"port": 5432,
|
|
"user": "admin",
|
|
"password": "secret123",
|
|
"dbname": "mydb",
|
|
"sslmode": "require"
|
|
});
|
|
let result = format_attach_db_conn_str(db_resource, "postgres").unwrap();
|
|
// Should be in URI format: postgres://user:password@host:port/dbname?sslmode=require
|
|
assert!(result.starts_with("postgres://"));
|
|
assert!(result.contains("admin:secret123@localhost:5432/mydb"));
|
|
assert!(result.contains("sslmode=require"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_attach_db_conn_str_postgres_minimal() {
|
|
let db_resource = json!({
|
|
"host": "db.example.com",
|
|
"dbname": "production"
|
|
});
|
|
let result = format_attach_db_conn_str(db_resource, "postgres").unwrap();
|
|
// Should be in URI format with defaults: postgres://postgres:@host:5432/dbname?sslmode=prefer
|
|
assert!(result.starts_with("postgres://"));
|
|
assert!(result.contains("@db.example.com:5432/production"));
|
|
assert!(result.contains("sslmode=prefer"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_attach_db_conn_str_postgresql_alias() {
|
|
let db_resource = json!({
|
|
"host": "localhost",
|
|
"dbname": "test"
|
|
});
|
|
let result = format_attach_db_conn_str(db_resource, "postgresql").unwrap();
|
|
// Should be in URI format (postgresql is treated the same as postgres)
|
|
assert!(result.starts_with("postgres://"));
|
|
assert!(result.contains("@localhost:5432/test"));
|
|
assert!(result.contains("sslmode=prefer"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_attach_db_conn_str_bigquery() {
|
|
let db_resource = json!({
|
|
"project_id": "my-gcp-project"
|
|
});
|
|
let result = format_attach_db_conn_str(db_resource, "bigquery").unwrap();
|
|
assert_eq!(result, "project=my-gcp-project");
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_attach_db_conn_str_bigquery_missing_project_id() {
|
|
let db_resource = json!({
|
|
"other_field": "value"
|
|
});
|
|
let result = format_attach_db_conn_str(db_resource, "bigquery");
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().to_string().contains("project_id"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_attach_db_conn_str_unsupported_type() {
|
|
let db_resource = json!({});
|
|
let result = format_attach_db_conn_str(db_resource, "oracle");
|
|
assert!(result.is_err());
|
|
assert!(result
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("Unsupported db type"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_attach_db_conn_str_case_insensitive() {
|
|
let db_resource = json!({
|
|
"host": "localhost",
|
|
"dbname": "test"
|
|
});
|
|
let result = format_attach_db_conn_str(db_resource, "POSTGRES").unwrap();
|
|
// Should be in URI format
|
|
assert!(result.starts_with("postgres://"));
|
|
assert!(result.contains("@localhost:5432/test"));
|
|
}
|
|
|
|
#[cfg(feature = "mysql")]
|
|
#[test]
|
|
fn test_format_attach_db_conn_str_mysql_full() {
|
|
let db_resource = json!({
|
|
"host": "mysql.example.com",
|
|
"port": 3306,
|
|
"user": "root",
|
|
"password": "mysecret",
|
|
"database": "app_db",
|
|
"ssl": true
|
|
});
|
|
let result = format_attach_db_conn_str(db_resource, "mysql").unwrap();
|
|
assert!(result.contains("database=app_db"));
|
|
assert!(result.contains("host=mysql.example.com"));
|
|
assert!(result.contains("ssl_mode=required"));
|
|
assert!(result.contains("password=mysecret"));
|
|
assert!(result.contains("port=3306"));
|
|
assert!(result.contains("user=root"));
|
|
}
|
|
|
|
#[cfg(feature = "mysql")]
|
|
#[test]
|
|
fn test_format_attach_db_conn_str_mysql_ssl_disabled() {
|
|
let db_resource = json!({
|
|
"host": "localhost",
|
|
"database": "test",
|
|
"ssl": false
|
|
});
|
|
let result = format_attach_db_conn_str(db_resource, "mysql").unwrap();
|
|
assert!(result.contains("ssl_mode=disabled"));
|
|
}
|
|
|
|
// Tests for get_attach_db_install_str function
|
|
#[test]
|
|
fn test_get_attach_db_install_str_postgres() {
|
|
let result = get_attach_db_install_str("postgres").unwrap();
|
|
assert_eq!(result, "INSTALL postgres;");
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_attach_db_install_str_bigquery() {
|
|
let result = get_attach_db_install_str("bigquery").unwrap();
|
|
assert_eq!(result, "INSTALL bigquery FROM community;");
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_attach_db_install_str_unsupported() {
|
|
let result = get_attach_db_install_str("sqlite");
|
|
assert!(result.is_err());
|
|
assert!(result
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("Unsupported db type"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_attach_db_install_str_case_insensitive() {
|
|
let result = get_attach_db_install_str("POSTGRES").unwrap();
|
|
assert_eq!(result, "INSTALL postgres;");
|
|
}
|
|
|
|
#[cfg(feature = "mysql")]
|
|
#[test]
|
|
fn test_get_attach_db_install_str_mysql() {
|
|
let result = get_attach_db_install_str("mysql").unwrap();
|
|
assert_eq!(result, "INSTALL mysql;");
|
|
}
|
|
|
|
#[cfg(not(feature = "mysql"))]
|
|
#[test]
|
|
fn test_get_attach_db_install_str_mysql_disabled() {
|
|
let result = get_attach_db_install_str("mysql");
|
|
assert!(result.is_err());
|
|
assert!(result
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("MySQL feature is not enabled"));
|
|
}
|
|
|
|
// Tests for transform_s3_uris function
|
|
#[tokio::test]
|
|
async fn test_transform_s3_uris_empty_storage() {
|
|
let query = "SELECT * FROM read_parquet('s3:///path/to/file.parquet')";
|
|
let result = transform_s3_uris(query).await.unwrap();
|
|
assert_eq!(
|
|
result,
|
|
"SELECT * FROM read_parquet('s3://_default_/path/to/file.parquet')"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_transform_s3_uris_with_storage() {
|
|
// URIs with explicit storage should not be transformed
|
|
let query = "SELECT * FROM read_parquet('s3://mybucket/path/to/file.parquet')";
|
|
let result = transform_s3_uris(query).await.unwrap();
|
|
assert_eq!(result, query);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_transform_s3_uris_multiple_empty() {
|
|
let query = "SELECT * FROM read_parquet('s3:///file1.parquet') UNION SELECT * FROM read_parquet('s3:///file2.parquet')";
|
|
let result = transform_s3_uris(query).await.unwrap();
|
|
assert!(result.contains("s3://_default_/file1.parquet"));
|
|
assert!(result.contains("s3://_default_/file2.parquet"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_transform_s3_uris_no_s3() {
|
|
let query = "SELECT * FROM my_table";
|
|
let result = transform_s3_uris(query).await.unwrap();
|
|
assert_eq!(result, query);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_transform_s3_uris_mixed() {
|
|
let query = "SELECT * FROM read_parquet('s3:///default.parquet'), read_csv('s3://explicit/file.csv')";
|
|
let result = transform_s3_uris(query).await.unwrap();
|
|
assert!(result.contains("s3://_default_/default.parquet"));
|
|
assert!(result.contains("s3://explicit/file.csv"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_transform_s3_uris_nested_path() {
|
|
let query = "SELECT * FROM read_parquet('s3:///deep/nested/path/file.parquet')";
|
|
let result = transform_s3_uris(query).await.unwrap();
|
|
assert_eq!(
|
|
result,
|
|
"SELECT * FROM read_parquet('s3://_default_/deep/nested/path/file.parquet')"
|
|
);
|
|
}
|
|
|
|
// Tests for Arg struct
|
|
#[test]
|
|
fn test_arg_serialization() {
|
|
let arg = Arg {
|
|
name: "test_arg".to_string(),
|
|
arg_type: "string".to_string(),
|
|
json_value: json!("hello"),
|
|
};
|
|
let serialized = serde_json::to_string(&arg).unwrap();
|
|
assert!(serialized.contains("\"name\":\"test_arg\""));
|
|
assert!(serialized.contains("\"arg_type\":\"string\""));
|
|
assert!(serialized.contains("\"json_value\":\"hello\""));
|
|
}
|
|
|
|
#[test]
|
|
fn test_arg_serialization_number() {
|
|
let arg = Arg {
|
|
name: "count".to_string(),
|
|
arg_type: "integer".to_string(),
|
|
json_value: json!(42),
|
|
};
|
|
let serialized = serde_json::to_string(&arg).unwrap();
|
|
assert!(serialized.contains("\"json_value\":42"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_arg_serialization_null() {
|
|
let arg = Arg {
|
|
name: "optional".to_string(),
|
|
arg_type: "text".to_string(),
|
|
json_value: json!(null),
|
|
};
|
|
let serialized = serde_json::to_string(&arg).unwrap();
|
|
assert!(serialized.contains("\"json_value\":null"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_arg_serialization_array() {
|
|
let arg = Arg {
|
|
name: "items".to_string(),
|
|
arg_type: "array".to_string(),
|
|
json_value: json!([1, 2, 3]),
|
|
};
|
|
let serialized = serde_json::to_string(&arg).unwrap();
|
|
assert!(serialized.contains("\"json_value\":[1,2,3]"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_arg_serialization_object() {
|
|
let arg = Arg {
|
|
name: "config".to_string(),
|
|
arg_type: "object".to_string(),
|
|
json_value: json!({"key": "value"}),
|
|
};
|
|
let serialized = serde_json::to_string(&arg).unwrap();
|
|
assert!(serialized.contains("\"json_value\":{\"key\":\"value\"}"));
|
|
}
|
|
|
|
fn raw(s: &str) -> Box<RawValue> {
|
|
serde_json::from_str(s).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn extract_data_tests_parses_nested_array() {
|
|
// The real result shape: an array of one summary row carrying a nested
|
|
// `data_tests` array (how the FFI serialises the list-of-struct).
|
|
let r = raw(
|
|
r#"[{"rows":3,"snapshot_id":17,"materialized":"ducklake://a/b",
|
|
"data_tests":[{"test":"unique(order_id)","violating":0,"sample":null},
|
|
{"test":"accepted_values(status)","violating":2,
|
|
"sample":"[{\"id\":1,\"status\":\"bad\"}]"}]}]"#,
|
|
);
|
|
let out = extract_data_tests(&r);
|
|
assert_eq!(out.len(), 2);
|
|
assert_eq!(out[0].name, "unique(order_id)");
|
|
assert_eq!(out[0].violating, 0);
|
|
assert!(out[0].sample.is_none());
|
|
assert_eq!(out[1].name, "accepted_values(status)");
|
|
assert_eq!(out[1].violating, 2);
|
|
// The probe emits the sample as a JSON string; it parses to rows here.
|
|
assert_eq!(
|
|
out[1].sample,
|
|
Some(serde_json::json!([{"id": 1, "status": "bad"}]))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn extract_data_tests_handles_string_encoded_and_absent() {
|
|
// Fallback: some serialisations surface the list-of-struct as a JSON string.
|
|
let s = raw(r#"{"data_tests":"[{\"test\":\"not_null(x)\",\"violating\":1}]"}"#);
|
|
let out = extract_data_tests(&s);
|
|
assert_eq!(out.len(), 1);
|
|
assert_eq!(out[0].name, "not_null(x)");
|
|
assert_eq!(out[0].violating, 1);
|
|
assert!(out[0].sample.is_none());
|
|
// Absent column (no tests) -> empty, no panic.
|
|
assert!(extract_data_tests(&raw(r#"[{"rows":3}]"#)).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn extract_data_tests_sample_degrades_to_none_never_flips_outcome() {
|
|
// sample is optional by contract: native array accepted; non-array
|
|
// JSON, garbage text, and absence all degrade to None without
|
|
// touching `violating`.
|
|
let r = raw(r#"[{"data_tests":[
|
|
{"test":"a","violating":1,"sample":[{"id":9}]},
|
|
{"test":"b","violating":2,"sample":"{\"not\":\"an array\"}"},
|
|
{"test":"c","violating":3,"sample":"not json at all"},
|
|
{"test":"d","violating":4}]}]"#);
|
|
let out = extract_data_tests(&r);
|
|
assert_eq!(out.len(), 4);
|
|
assert_eq!(out[0].sample, Some(serde_json::json!([{"id": 9}])));
|
|
for (i, t) in out.iter().enumerate().skip(1) {
|
|
assert!(t.sample.is_none(), "test {} should have no sample", t.name);
|
|
assert_eq!(t.violating, i as i64 + 1);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn extract_schema_parses_nested_and_string_encoded() {
|
|
// Real shape: the summary row carries a nested `output_schema`
|
|
// list-of-struct from the DESCRIBE fold.
|
|
let r = raw(
|
|
r#"[{"materialized":"ducklake://a/b","rows":3,"snapshot_id":17,
|
|
"output_schema":[{"name":"order_id","type":"BIGINT"},
|
|
{"name":"status","type":"VARCHAR"}]}]"#,
|
|
);
|
|
let cols = extract_schema(&r).expect("schema present");
|
|
assert_eq!(cols.len(), 2);
|
|
assert_eq!(cols[0].name, "order_id");
|
|
assert_eq!(cols[0].data_type, "BIGINT");
|
|
assert_eq!(cols[1].name, "status");
|
|
assert_eq!(cols[1].data_type, "VARCHAR");
|
|
// Fallback: FFI serialised the list-of-struct as a JSON string.
|
|
let s = raw(r#"{"output_schema":"[{\"name\":\"x\",\"type\":\"INTEGER\"}]"}"#);
|
|
let cols = extract_schema(&s).expect("schema present");
|
|
assert_eq!(cols.len(), 1);
|
|
assert_eq!(cols[0].name, "x");
|
|
assert_eq!(cols[0].data_type, "INTEGER");
|
|
// Absent column (literal/manual mode) -> None, no panic.
|
|
assert!(extract_schema(&raw(r#"[{"rows":3}]"#)).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn format_data_test_breakdown_lists_all_with_marks() {
|
|
let tests = vec![
|
|
DataTestOutcome { name: "unique(order_id)".into(), violating: 1, sample: None },
|
|
DataTestOutcome { name: "not_null(user_id)".into(), violating: 0, sample: None },
|
|
DataTestOutcome {
|
|
name: "accepted_values(status)".into(),
|
|
violating: 2,
|
|
// The breakdown is counts-only by design — samples never
|
|
// appear in the error text.
|
|
sample: Some(serde_json::json!([{"status": "bad"}])),
|
|
},
|
|
];
|
|
let msg = format_data_test_breakdown("analytics/orders", &tests);
|
|
assert_eq!(
|
|
msg,
|
|
"data tests failed on analytics/orders (2/3 failed):\n \
|
|
✗ unique(order_id) — 1 violating row(s)\n \
|
|
✓ not_null(user_id)\n \
|
|
✗ accepted_values(status) — 2 violating row(s)"
|
|
);
|
|
}
|
|
}
|