Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions dbt-project/models/marts/_incremental__models.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,14 @@ models:
processes orders newer than the loaded high-water mark
(max(order_date) from {{ this }}).
columns:
# cute-dbt#165 dogfood — unique/not_null on the merge key so the
# unit test's given/this/expect tables surface a description+tests
# column-header tooltip in the live PR-diff preview report.
- name: order_id
description: Order identifier (unique key for the incremental merge).
tests:
- unique
- not_null
- name: order_date
description: Order date; the incremental high-water-mark column.
- name: status
Expand Down
9 changes: 9 additions & 0 deletions dbt-project/models/staging/schema.yml
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
version: 2

# cute-dbt#165 dogfood — the staging columns mocked by the marts unit
# tests carry authored descriptions, so the given-table column-header
# tooltips surface description + column tests in the live PR-diff
# preview report.
models:
- name: stg_customers
columns:
- name: customer_id
description: Unique identifier for a customer (staging grain)
tests:
- unique
- not_null

- name: stg_orders
columns:
- name: order_id
description: Unique identifier for an order (staging grain)
tests:
- unique
- not_null
- name: status
description: Order lifecycle status as exported from the app
tests:
- accepted_values:
arguments:
Expand All @@ -23,10 +30,12 @@ models:
- name: stg_payments
columns:
- name: payment_id
description: Unique identifier for a payment (staging grain)
tests:
- unique
- not_null
- name: payment_method
description: Payment instrument used for this payment
tests:
- accepted_values:
arguments:
Expand Down
108 changes: 96 additions & 12 deletions examples/diff-showcase-report.html

Large diffs are not rendered by default.

108 changes: 96 additions & 12 deletions examples/jaffle-shop-report.html

Large diffs are not rendered by default.

108 changes: 96 additions & 12 deletions examples/playground-report.html

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions features/report_generation.feature
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ Feature: Generate a self-contained report from a compiled dbt manifest
And the file "report.html" exists
And the diff-scope banner states "0 unit tests in scope"

# cute-dbt#165 — column-header tooltips: the embedded payload carries each
# expected-table column's column-level data tests (the th tooltip's data
# source; resolved from manifest `test` nodes with `column_name` set and
# `attached_node` = the target model). The committed fixture's in-scope
# model declares unique + not_null on its key column, whose authored
# description is EMPTY (fusion serializes an unset description as "") — so
# the payload must carry tests-only metadata for that column, and NO entry
# at all for the undescribed, untested name columns (no empty bubbles).
Scenario: Expected-table columns carry their column-level data tests in the payload
Given the model "stg_orders" was modified relative to the baseline
And "stg_orders" has a unit test "test_stg_orders_dedup"
When I run cute-dbt with --manifest current.json --baseline-manifest baseline.json --out report.html
Then the report payload lists column tests "unique" and "not_null" for the expected column "customer_id"
And the report payload carries no column-header metadata for the expected column "first_name"

# The locked v0.1 policy: --baseline-manifest is REQUIRED. Omitting it is
# a clap usage error raised BEFORE the manifest is read (NOT a
# PreflightError; full-manifest reports are a documented trick — pass an
Expand Down
187 changes: 179 additions & 8 deletions src/adapters/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ use serde::Deserialize;

use crate::domain::{
Checksum, DependsOn, Manifest, ManifestMetadata, Node, NodeConfig, NodeId, PreflightError,
UnitTest, UnitTestExpect, UnitTestGiven,
TestMetadata, UnitTest, UnitTestExpect, UnitTestGiven,
};
use crate::ports::ManifestSource;
use serde_json::Value;
Expand Down Expand Up @@ -116,6 +116,19 @@ struct WireNode {
relation_name: Option<String>,
#[serde(default)]
columns: BTreeMap<String, WireColumn>,
/// Test-node attribution (cute-dbt#165): `column_name` is set iff the
/// test is column-scoped; `attached_node` is the declaring model;
/// `test_metadata` is the generic-test descriptor (absent on singular
/// SQL-file tests — fusion `#[skip_serializing_none]`s the key away).
/// The domain [`TestMetadata`] already deserializes the wire shape
/// verbatim (`name` + defaulted `namespace`/`kwargs`), so no `Wire*`
/// twin is needed — the [`Checksum`]/[`DependsOn`] reuse precedent.
#[serde(default)]
column_name: Option<String>,
#[serde(default)]
attached_node: Option<NodeId>,
#[serde(default)]
test_metadata: Option<TestMetadata>,
}

/// Tolerant wire projection of a node's `config` sub-object.
Expand All @@ -132,12 +145,18 @@ struct WireNodeConfig {
config: BTreeMap<String, Value>,
}

/// Tolerant wire projection of one `columns` map entry — only the
/// declared `data_type` is consumed for the `.contract` column-set diff.
/// Tolerant wire projection of one `columns` map entry — the declared
/// `data_type` feeds the `.contract` column-set diff, and the authored
/// `description` feeds the column-header tooltips (cute-dbt#165). fusion
/// always serializes `description` (an unset one as `""` — `dbt-schemas`
/// `dbt_column.rs` `serialize_dbt_column_desc`); the translation drops
/// empty descriptions so the domain map carries only real prose.
#[derive(Debug, Default, Deserialize)]
struct WireColumn {
#[serde(default)]
data_type: Option<String>,
#[serde(default)]
description: Option<String>,
}

impl WireNodeConfig {
Expand Down Expand Up @@ -296,11 +315,19 @@ impl WireManifest {
.into_iter()
.map(|(key, wire)| {
let id = NodeId::new(key);
let columns = wire
.columns
.into_iter()
.map(|(name, col)| (name, col.data_type))
.collect();
// One pass over the wire columns feeds both domain maps:
// name → data_type (the `.contract` column-set diff) and
// name → non-empty description (the cute-dbt#165
// column-header tooltips; fusion serializes an unset
// description as `""`, which is dropped here).
let mut columns = BTreeMap::new();
let mut column_descriptions = BTreeMap::new();
for (name, col) in wire.columns {
if let Some(desc) = col.description.filter(|d| !d.is_empty()) {
column_descriptions.insert(name.clone(), desc);
}
columns.insert(name, col.data_type);
}
let config = wire.config.into_domain();
let node = Node::new(
id.clone(),
Expand All @@ -313,6 +340,12 @@ impl WireManifest {
config,
wire.relation_name,
columns,
)
.with_column_descriptions(column_descriptions)
.with_test_attachment(
wire.column_name,
wire.attached_node,
wire.test_metadata,
);
(id, node)
})
Expand Down Expand Up @@ -752,6 +785,144 @@ mod tests {
assert!(manifest.macros().is_empty());
}

// ----- cute-dbt#165: column descriptions + test attribution ------

#[test]
fn parse_manifest_extracts_column_descriptions_and_drops_empty_ones() {
// fusion ALWAYS serializes `description` — an unset one as `""`
// (`serialize_dbt_column_desc`, dbt-schemas dbt_column.rs). The
// adapter keeps only non-empty prose; `columns` (name → data_type)
// is unchanged so `.contract` semantics cannot drift.
let json = format!(
r#"{{
"metadata": {{ "dbt_schema_version": "{V12_URL}" }},
"nodes": {{
"model.shop.dim_x": {{
"resource_type": "model",
"checksum": {{ "name": "sha256", "checksum": "abc" }},
"columns": {{
"id": {{ "name": "id", "description": "Primary key", "data_type": "integer" }},
"label": {{ "name": "label", "description": "" }},
"untyped": {{ "name": "untyped" }}
}}
}}
}}
}}"#
);
let manifest = parse_manifest(&json).expect("valid v12 manifest");
let node = manifest
.node(&NodeId::new("model.shop.dim_x"))
.expect("dim_x present");
assert_eq!(
node.column_descriptions().get("id").map(String::as_str),
Some("Primary key")
);
assert!(
!node.column_descriptions().contains_key("label"),
"an empty description (fusion's unset shape) is dropped"
);
assert!(!node.column_descriptions().contains_key("untyped"));
// The .contract input is untouched by the description ingest.
assert_eq!(node.columns().get("id"), Some(&Some("integer".to_owned())));
assert_eq!(node.columns().get("label"), Some(&None));
}

#[test]
fn parse_manifest_extracts_test_attribution_fields() {
// A column-scoped generic test (the jaffle-shop wire shape):
// column_name + attached_node + test_metadata incl. a
// package-qualified namespace and kwargs passthrough.
let json = format!(
r#"{{
"metadata": {{ "dbt_schema_version": "{V12_URL}" }},
"nodes": {{
"test.shop.accepted_values_orders_status.abc123": {{
"resource_type": "test",
"checksum": {{ "name": "none", "checksum": "" }},
"column_name": "status",
"attached_node": "model.shop.orders",
"test_metadata": {{
"name": "accepted_values",
"kwargs": {{
"column_name": "status",
"values": ["placed", "shipped"],
"model": "{{{{ get_where_subquery(ref('orders')) }}}}"
}},
"namespace": null
}}
}},
"test.shop.expect_between_orders_amount.def456": {{
"resource_type": "test",
"checksum": {{ "name": "none", "checksum": "" }},
"column_name": "amount",
"attached_node": "model.shop.orders",
"test_metadata": {{
"name": "expect_column_values_to_be_between",
"kwargs": {{ "min_value": 0 }},
"namespace": "dbt_expectations"
}}
}}
}}
}}"#
);
let manifest = parse_manifest(&json).expect("valid v12 manifest");
let t1 = manifest
.node(&NodeId::new(
"test.shop.accepted_values_orders_status.abc123",
))
.expect("test node present");
assert_eq!(t1.column_name(), Some("status"));
assert_eq!(t1.attached_node(), Some(&NodeId::new("model.shop.orders")));
let tm1 = t1.test_metadata().expect("test_metadata present");
assert_eq!(tm1.name(), "accepted_values");
assert_eq!(tm1.namespace(), None, "explicit-null namespace → None");
assert_eq!(
tm1.kwargs()["values"],
serde_json::json!(["placed", "shipped"])
);
let t2 = manifest
.node(&NodeId::new(
"test.shop.expect_between_orders_amount.def456",
))
.expect("test node present");
let tm2 = t2.test_metadata().expect("test_metadata present");
assert_eq!(tm2.namespace(), Some("dbt_expectations"));
}

#[test]
fn parse_manifest_tolerates_singular_and_model_level_tests() {
// The real playground fixture carries both shapes: singular
// (SQL-file) tests OMIT test_metadata entirely (fusion
// `#[skip_serializing_none]`), and model-level tests carry
// `column_name: null`. Explicit nulls must also parse (ADR-5 —
// fusion null-fills unset Options on other structs).
let json = format!(
r#"{{
"metadata": {{ "dbt_schema_version": "{V12_URL}" }},
"nodes": {{
"test.shop.singular_check": {{
"resource_type": "test",
"checksum": {{ "name": "sha256", "checksum": "aa" }}
}},
"test.shop.model_level_check": {{
"resource_type": "test",
"checksum": {{ "name": "sha256", "checksum": "bb" }},
"column_name": null,
"attached_node": null,
"test_metadata": null
}}
}}
}}"#
);
let manifest = parse_manifest(&json).expect("tolerant parse");
for id in ["test.shop.singular_check", "test.shop.model_level_check"] {
let node = manifest.node(&NodeId::new(id)).expect("node present");
assert!(node.column_name().is_none(), "{id}");
assert!(node.attached_node().is_none(), "{id}");
assert!(node.test_metadata().is_none(), "{id}");
}
}

// ----- parse_manifest: Stage-1 failure arms ---------------------

#[test]
Expand Down
Loading
Loading