mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-14 08:02:58 +00:00
* feat: capture managed-materialize output schema as asset metadata (#2a) After a managed `// materialize` run, capture the producer's output schema via a DESCRIBE folded into the existing one-row summary read (no extra round-trip) and persist it in a new versioned `materialized_asset_schema` sidecar table. This is the producer-side capture that pipeline parity gap #2b (save-time consumer-ref contract enforcement) will read back. - materialized_asset_schema sidecar (asset-level grain), versioned: a new version row is inserted only when the captured column set changes. - output_schema column added to the materialize summary codegen. - worker extracts + records the schema on a successful materialize. - /assets/asset_schemas read endpoint exposing the evolution history. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: address CI review on schema capture (partition col, order, status gate) - exclude the synthetic `_wm_partition` column from the captured schema for partitioned assets, so the recorded contract is the producer's logical output, not Windmill's storage detail (claude/cubic P1). - make the captured column list explicitly ordered (`row_number()` over the DESCRIBE + `list(... ORDER BY)`), so the `list()` aggregate can't reorder columns and spuriously bump the schema version (cubic P2). - gate the API `record_materialization` schema upsert on a `Materialized` status, so a failed/running write (or a client attaching a schema to one) can't advance the schema history (cubic P2). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: address Codex review (manual-mode schema gate + auth contract docs) - gate output_schema extraction on the managed (`Some((Some(_), _))`) path so a `// materialize manual` run — whose result is the user's own query output — can't persist a caller-shaped `output_schema` into materialized_asset_schema (Codex P2). Verified e2e: a manual run returning a fabricated `output_schema:[{injected,EVIL}]` records the partition but writes no schema version, while the managed path still captures normally. - document the authorization contract on the new public `record_asset_schema` and `list_asset_schemas` helpers: they perform no access control (mirroring the materialized_partition siblings) and require callers to pass a workspace-authorized executor (Codex P1). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(frontend): schema-history tab on the ducklake asset node (#2a) Adds a "Schema" tab to DucklakeAssetPanel surfacing the captured output-schema versions persisted by the materialize run. Master-detail (mirrors the History tab): the version list (newest first, newest auto-selected) shows column count + snapshot + capture time; selecting a version renders its column/type table. Reads the GET /assets/asset_schemas endpoint via raw fetch, matching the sibling PartitionStatusGrid convention (these materialization endpoints are not in the generated client). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: schema tab is strategy-aware (history vs fixed schema) Only a whole-table `replace` producer (CREATE OR REPLACE) can change columns run-to-run; `append`/`merge`/partitioned writes INSERT into a fixed-schema table, so their schema is pinned at first materialize and the "history" framing is degenerate (always one version). - backend: surface the managed `materialize_strategy` (`replace`/`append`/ `merge`) on the asset-graph runnable node, alongside the existing `partition_kind` (same parse-from-annotation path). - frontend: the pipeline page derives `schemaCanEvolve` for the selected asset from its write-producer (`replace` && not partitioned) and threads it to the Schema tab. Evolvable → master-detail version history; fixed → a single current-schema table with a short "schema is fixed" note. Unknown defaults to evolvable so real history is never hidden. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: schemaCanEvolve fails open on unknown producer strategy Previously a producer present but missing `materialize_strategy` (e.g. a draft-overlay runnable, synthesized without the field) fell through to canEvolve=false, hiding captured history behind the fixed-schema view — contradicting the "unknown defaults to evolvable" intent. Now the fixed view shows only when *every* producer is a known insert-style write (append/merge, or partitioned replace); any producer with unknown (missing) strategy is treated as evolvable, so real history is never hidden. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
106 lines
3.7 KiB
Rust
106 lines
3.7 KiB
Rust
/*!
|
|
* Tests the schema-capture versioning contract (gap #2a):
|
|
* `record_asset_schema` inserts a new `materialized_asset_schema` version only
|
|
* when the captured column set changes, re-affirms the latest row in place when
|
|
* it doesn't, and `list_asset_schemas` returns the evolution history newest
|
|
* first.
|
|
*/
|
|
|
|
use sqlx::{Pool, Postgres};
|
|
use windmill_common::assets::AssetKind;
|
|
use windmill_common::materialization::{list_asset_schemas, record_asset_schema, SchemaColumn};
|
|
|
|
const WS: &str = "test-workspace";
|
|
const PATH: &str = "analytics/orders";
|
|
|
|
fn col(name: &str, ty: &str) -> SchemaColumn {
|
|
SchemaColumn { name: name.to_string(), data_type: ty.to_string() }
|
|
}
|
|
|
|
async fn record(db: &Pool<Postgres>, cols: &[SchemaColumn], snapshot_id: i64) -> bool {
|
|
let mut tx = db.begin().await.expect("begin");
|
|
let inserted = record_asset_schema(
|
|
&mut tx,
|
|
WS,
|
|
AssetKind::Ducklake,
|
|
PATH,
|
|
cols,
|
|
Some(snapshot_id),
|
|
None,
|
|
)
|
|
.await
|
|
.expect("record asset schema");
|
|
tx.commit().await.expect("commit");
|
|
inserted
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn first_capture_inserts_version_one(db: Pool<Postgres>) {
|
|
let cols = [col("order_id", "BIGINT"), col("status", "VARCHAR")];
|
|
assert!(
|
|
record(&db, &cols, 10).await,
|
|
"first capture inserts a version"
|
|
);
|
|
|
|
let versions = list_asset_schemas(&db, WS, AssetKind::Ducklake, PATH)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(versions.len(), 1);
|
|
assert_eq!(versions[0].version, 1);
|
|
assert_eq!(versions[0].snapshot_id, Some(10));
|
|
assert_eq!(versions[0].columns.0, cols);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn unchanged_schema_reaffirms_without_new_version(db: Pool<Postgres>) {
|
|
let cols = [col("order_id", "BIGINT")];
|
|
record(&db, &cols, 10).await;
|
|
// Identical column set on a later snapshot: no new version, but the latest
|
|
// row's snapshot_id advances.
|
|
assert!(
|
|
!record(&db, &cols, 20).await,
|
|
"unchanged schema must not insert a new version"
|
|
);
|
|
|
|
let versions = list_asset_schemas(&db, WS, AssetKind::Ducklake, PATH)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(versions.len(), 1, "still a single version");
|
|
assert_eq!(versions[0].version, 1);
|
|
assert_eq!(versions[0].snapshot_id, Some(20), "snapshot re-affirmed");
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn changed_schema_bumps_version_newest_first(db: Pool<Postgres>) {
|
|
record(&db, &[col("order_id", "BIGINT")], 10).await;
|
|
// A column added → schema changed → new version.
|
|
let evolved = [col("order_id", "BIGINT"), col("amount", "DOUBLE")];
|
|
assert!(record(&db, &evolved, 20).await, "changed schema inserts v2");
|
|
|
|
let versions = list_asset_schemas(&db, WS, AssetKind::Ducklake, PATH)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(versions.len(), 2);
|
|
// Newest first.
|
|
assert_eq!(versions[0].version, 2);
|
|
assert_eq!(versions[0].columns.0, evolved);
|
|
assert_eq!(versions[1].version, 1);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn column_order_change_is_a_new_version(db: Pool<Postgres>) {
|
|
let a = [col("a", "BIGINT"), col("b", "VARCHAR")];
|
|
let reordered = [col("b", "VARCHAR"), col("a", "BIGINT")];
|
|
record(&db, &a, 10).await;
|
|
// Same columns, different physical order: the captured list is ordered, so a
|
|
// reorder is a real schema change (downstream `SELECT *` consumers see it).
|
|
assert!(
|
|
record(&db, &reordered, 20).await,
|
|
"column reorder is a distinct schema version"
|
|
);
|
|
let versions = list_asset_schemas(&db, WS, AssetKind::Ducklake, PATH)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(versions.len(), 2);
|
|
}
|