Files
windmill/backend/windmill-common/tests/asset_schema_capture.rs
Ruben FiszelandClaude Opus 4.8 ade74b297f feat: capture managed-materialize output schema as asset metadata (#2a) (#9812)
* 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>
2026-06-26 19:46:48 +02:00

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);
}