use serde::Serialize;
use zhao_core::adapters::AdapterVocabulary;
use zhao_core::diff::Change;
use zhao_core::model::{JoinKind, Materialization, NodeId, ParsedProject, Upstream};
use zhao_core::rules::{Finding, FindingDetail, Severity};
pub const STALENESS_WARNING: &str = "analysis may be stale, consider rebasing";
#[derive(Debug, Serialize)]
pub struct Report {
pub changes: Vec<ChangeJson>,
pub findings: Vec<FindingJson>,
#[serde(skip_serializing_if = "Option::is_none")]
pub staleness_warning: Option<String>,
pub impacted_models: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub defer_plan: Option<DeferPlanJson>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recommended_command: Option<String>,
pub schema_evolution_warnings: Vec<SchemaEvolutionWarningJson>,
}
impl Report {
pub fn new(changes: &[Change], findings: &[Finding]) -> Self {
Self {
changes: changes.iter().map(ChangeJson::from).collect(),
findings: findings.iter().map(FindingJson::from).collect(),
staleness_warning: None,
impacted_models: Vec::new(),
defer_plan: None,
recommended_command: None,
schema_evolution_warnings: Vec::new(),
}
}
fn impacted_node_ids(&self) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut node_ids = Vec::new();
for change in &self.changes {
let node_id = change.node().to_string();
if seen.insert(node_id.clone()) {
node_ids.push(node_id);
}
}
for finding in &self.findings {
if finding.severity() == SeverityJson::Pass {
continue;
}
let node_id = finding.impacted_node().to_string();
if seen.insert(node_id.clone()) {
node_ids.push(node_id);
}
}
node_ids
}
pub fn with_staleness_warning(mut self, is_stale: bool) -> Self {
self.staleness_warning = is_stale.then(|| STALENESS_WARNING.to_string());
self
}
pub fn with_impacted_models(mut self, vocabulary: &dyn AdapterVocabulary) -> Self {
self.impacted_models = self
.impacted_node_ids()
.iter()
.map(|id| vocabulary.node_display_name(id))
.collect();
self
}
pub fn with_defer_plan(
mut self,
current: &ParsedProject,
vocabulary: &dyn AdapterVocabulary,
settings: &DeferSettings,
) -> Self {
let build = self.impacted_node_ids();
self.defer_plan = if build.is_empty() {
None
} else {
Some(DeferPlanJson::compute(current, build, vocabulary, settings))
};
self
}
pub fn with_recommended_command(
mut self,
subcommand: Option<&str>,
dbt_command: &str,
target_label: Option<&str>,
) -> Self {
self.recommended_command = match subcommand {
Some(subcommand) if !self.impacted_models.is_empty() => {
let mut command = format!(
"{dbt_command} {subcommand} --select {}",
self.impacted_models.join(" ")
);
if let Some(target) = target_label {
command.push_str(" --target ");
command.push_str(target);
}
Some(command)
}
_ => None,
};
self
}
pub fn with_schema_evolution_warnings(mut self, current: &ParsedProject) -> Self {
self.schema_evolution_warnings = self
.changes
.iter()
.filter(|change| change.is_column_change())
.filter_map(|change| {
let node = current.node(&NodeId::new(change.node()))?;
(node.materialization == Materialization::Incremental).then(|| {
SchemaEvolutionWarningJson {
node: change.node().to_string(),
message: format!(
"if this incrementally-materialized model already exists in your \
target environment, this change requires manual schema \
evolution: {}",
change.describe()
),
change_description: change.describe(),
}
})
})
.collect();
self
}
pub fn with_live_relation_checks(
mut self,
mut check: impl FnMut(&str) -> Option<bool>,
) -> Self {
self.schema_evolution_warnings
.retain_mut(|warning| match check(&warning.node) {
Some(true) => {
warning.message = format!(
"this incrementally-materialized model exists in your target \
environment; this change requires manual schema evolution: {}",
warning.change_description
);
true
}
Some(false) => false,
None => true,
});
self
}
pub fn is_breaking(&self) -> bool {
self.findings
.iter()
.any(|f| f.severity() == SeverityJson::Error)
}
}
#[derive(Debug, Clone, Default)]
pub struct DeferSettings {
pub target: Option<String>,
pub state: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct DeferPlanJson {
pub build: Vec<String>,
pub defer: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
}
impl DeferPlanJson {
fn compute(
current: &ParsedProject,
build: Vec<String>,
vocabulary: &dyn AdapterVocabulary,
settings: &DeferSettings,
) -> Self {
let build_set: std::collections::HashSet<&str> = build.iter().map(String::as_str).collect();
let mut visited: std::collections::HashSet<String> = build.iter().cloned().collect();
let mut deferred = std::collections::BTreeSet::new();
let mut frontier: Vec<NodeId> = build.iter().map(|id| NodeId::new(id.clone())).collect();
while let Some(node_id) = frontier.pop() {
for edge in ¤t.edges {
if edge.downstream != node_id {
continue;
}
let Upstream::Node(upstream_id) = &edge.upstream else {
continue;
};
let upstream_id_string = upstream_id.to_string();
if visited.insert(upstream_id_string.clone()) {
if !build_set.contains(upstream_id_string.as_str()) {
deferred.insert(upstream_id_string);
}
frontier.push(upstream_id.clone());
}
}
}
let build_names: Vec<String> = build
.iter()
.map(|id| vocabulary.node_display_name(id))
.collect();
let defer_names: Vec<String> = deferred
.iter()
.map(|id| vocabulary.node_display_name(id))
.collect();
Self {
build: build_names,
defer: defer_names,
target: settings.target.clone(),
state: settings.state.clone(),
}
}
}
#[derive(Debug, Serialize)]
pub struct SchemaEvolutionWarningJson {
pub node: String,
pub message: String,
#[serde(skip)]
change_description: String,
}
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ChangeJson {
ColumnAdded { node: String, column: String },
ColumnRemoved { node: String, column: String },
ColumnTypeChanged {
node: String,
column: String,
from_type: String,
to_type: String,
},
ColumnExpressionChanged {
node: String,
column: String,
from_expression: Option<String>,
to_expression: Option<String>,
},
JoinChanged {
node: String,
position: usize,
from_kind: Option<String>,
to_kind: Option<String>,
},
StructFieldAdded {
node: String,
column: String,
field: String,
},
StructFieldRemoved {
node: String,
column: String,
field: String,
},
StructFieldTypeChanged {
node: String,
column: String,
field: String,
from_type: String,
to_type: String,
},
}
impl ChangeJson {
fn node(&self) -> &str {
match self {
ChangeJson::ColumnAdded { node, .. }
| ChangeJson::ColumnRemoved { node, .. }
| ChangeJson::ColumnTypeChanged { node, .. }
| ChangeJson::JoinChanged { node, .. }
| ChangeJson::StructFieldAdded { node, .. }
| ChangeJson::StructFieldRemoved { node, .. }
| ChangeJson::StructFieldTypeChanged { node, .. }
| ChangeJson::ColumnExpressionChanged { node, .. } => node,
}
}
fn describe(&self) -> String {
match self {
ChangeJson::ColumnAdded { column, .. } => format!("+ column added: {column}"),
ChangeJson::ColumnRemoved { column, .. } => format!("- column removed: {column}"),
ChangeJson::ColumnExpressionChanged { column, .. } => {
format!("~ column expression changed: {column}")
}
ChangeJson::ColumnTypeChanged {
column,
from_type,
to_type,
..
} => format!("~ column type changed: {column} ({from_type} -> {to_type})"),
ChangeJson::JoinChanged {
position,
from_kind,
to_kind,
..
} => format!(
"~ join changed at position {position}: {} -> {}",
from_kind.as_deref().unwrap_or("none"),
to_kind.as_deref().unwrap_or("none")
),
ChangeJson::StructFieldAdded { column, field, .. } => {
format!("+ struct field added: {column}.{field}")
}
ChangeJson::StructFieldRemoved { column, field, .. } => {
format!("- struct field removed: {column}.{field}")
}
ChangeJson::StructFieldTypeChanged {
column,
field,
from_type,
to_type,
..
} => {
format!("~ struct field type changed: {column}.{field} ({from_type} -> {to_type})")
}
}
}
fn is_column_change(&self) -> bool {
!matches!(self, ChangeJson::JoinChanged { .. })
}
}
impl From<&Change> for ChangeJson {
fn from(change: &Change) -> Self {
match change {
Change::ColumnAdded { node, column } => ChangeJson::ColumnAdded {
node: node.to_string(),
column: column.to_string(),
},
Change::ColumnRemoved { node, column } => ChangeJson::ColumnRemoved {
node: node.to_string(),
column: column.to_string(),
},
Change::ColumnTypeChanged {
node,
column,
from_type,
to_type,
} => ChangeJson::ColumnTypeChanged {
node: node.to_string(),
column: column.to_string(),
from_type: from_type.clone(),
to_type: to_type.clone(),
},
Change::ColumnExpressionChanged {
node,
column,
from_expression,
to_expression,
} => ChangeJson::ColumnExpressionChanged {
node: node.to_string(),
column: column.to_string(),
from_expression: from_expression.clone(),
to_expression: to_expression.clone(),
},
Change::JoinChanged {
node,
position,
from_kind,
to_kind,
} => ChangeJson::JoinChanged {
node: node.to_string(),
position: *position,
from_kind: from_kind.map(join_kind_slug),
to_kind: to_kind.map(join_kind_slug),
},
Change::StructFieldAdded {
node,
column,
field,
} => ChangeJson::StructFieldAdded {
node: node.to_string(),
column: column.to_string(),
field: field.to_string(),
},
Change::StructFieldRemoved {
node,
column,
field,
} => ChangeJson::StructFieldRemoved {
node: node.to_string(),
column: column.to_string(),
field: field.to_string(),
},
Change::StructFieldTypeChanged {
node,
column,
field,
from_type,
to_type,
} => ChangeJson::StructFieldTypeChanged {
node: node.to_string(),
column: column.to_string(),
field: field.to_string(),
from_type: from_type.clone(),
to_type: to_type.clone(),
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SeverityJson {
Error,
Warn,
Pass,
}
impl From<Severity> for SeverityJson {
fn from(severity: Severity) -> Self {
match severity {
Severity::Error => SeverityJson::Error,
Severity::Warn => SeverityJson::Warn,
Severity::Pass => SeverityJson::Pass,
}
}
}
#[derive(Debug, Serialize)]
#[serde(tag = "rule", rename_all = "kebab-case")]
pub enum FindingJson {
ColumnRemovedWithActiveReferences {
severity: SeverityJson,
node: String,
column: String,
reached: String,
reached_column: String,
},
ColumnTypeNarrowed {
severity: SeverityJson,
node: String,
column: String,
from_type: String,
to_type: String,
},
JoinCardinalityLoosened {
severity: SeverityJson,
node: String,
position: usize,
from_kind: String,
to_kind: String,
},
ColumnAdded {
severity: SeverityJson,
node: String,
column: String,
},
ColumnExpressionChanged {
severity: SeverityJson,
node: String,
column: String,
reached: String,
reached_column: String,
},
StructFieldRemoved {
severity: SeverityJson,
node: String,
column: String,
field: String,
},
StructFieldAdded {
severity: SeverityJson,
node: String,
column: String,
field: String,
},
StructFieldTypeNarrowed {
severity: SeverityJson,
node: String,
column: String,
field: String,
from_type: String,
to_type: String,
},
}
impl FindingJson {
fn severity(&self) -> SeverityJson {
match self {
FindingJson::ColumnRemovedWithActiveReferences { severity, .. }
| FindingJson::ColumnTypeNarrowed { severity, .. }
| FindingJson::JoinCardinalityLoosened { severity, .. }
| FindingJson::ColumnAdded { severity, .. }
| FindingJson::ColumnExpressionChanged { severity, .. }
| FindingJson::StructFieldRemoved { severity, .. }
| FindingJson::StructFieldAdded { severity, .. }
| FindingJson::StructFieldTypeNarrowed { severity, .. } => *severity,
}
}
fn rule_name(&self) -> &'static str {
match self {
FindingJson::ColumnRemovedWithActiveReferences { .. } => {
"column-removed-with-active-references"
}
FindingJson::ColumnTypeNarrowed { .. } => "column-type-narrowed",
FindingJson::JoinCardinalityLoosened { .. } => "join-cardinality-loosened",
FindingJson::ColumnAdded { .. } => "column-added",
FindingJson::ColumnExpressionChanged { .. } => "column-expression-changed",
FindingJson::StructFieldRemoved { .. } => "struct-field-removed",
FindingJson::StructFieldAdded { .. } => "struct-field-added",
FindingJson::StructFieldTypeNarrowed { .. } => "struct-field-type-narrowed",
}
}
fn impacted_node(&self) -> &str {
match self {
FindingJson::ColumnRemovedWithActiveReferences { reached, .. }
| FindingJson::ColumnExpressionChanged { reached, .. } => reached,
FindingJson::ColumnTypeNarrowed { node, .. }
| FindingJson::JoinCardinalityLoosened { node, .. }
| FindingJson::ColumnAdded { node, .. }
| FindingJson::StructFieldRemoved { node, .. }
| FindingJson::StructFieldAdded { node, .. }
| FindingJson::StructFieldTypeNarrowed { node, .. } => node,
}
}
}
impl From<&Finding> for FindingJson {
fn from(finding: &Finding) -> Self {
let severity = finding.severity.into();
match &finding.detail {
FindingDetail::ColumnRemovedWithActiveReferences {
node,
column,
reached,
reached_column,
} => FindingJson::ColumnRemovedWithActiveReferences {
severity,
node: node.to_string(),
column: column.to_string(),
reached: reached.to_string(),
reached_column: reached_column.to_string(),
},
FindingDetail::ColumnTypeNarrowed {
node,
column,
from_type,
to_type,
} => FindingJson::ColumnTypeNarrowed {
severity,
node: node.to_string(),
column: column.to_string(),
from_type: from_type.clone(),
to_type: to_type.clone(),
},
FindingDetail::JoinCardinalityLoosened {
node,
position,
from_kind,
to_kind,
} => FindingJson::JoinCardinalityLoosened {
severity,
node: node.to_string(),
position: *position,
from_kind: join_kind_slug(*from_kind),
to_kind: join_kind_slug(*to_kind),
},
FindingDetail::ColumnExpressionChanged {
node,
column,
reached,
reached_column,
} => FindingJson::ColumnExpressionChanged {
severity,
node: node.to_string(),
column: column.to_string(),
reached: reached.to_string(),
reached_column: reached_column.to_string(),
},
FindingDetail::ColumnAdded { node, column } => FindingJson::ColumnAdded {
severity,
node: node.to_string(),
column: column.to_string(),
},
FindingDetail::StructFieldRemoved {
node,
column,
field,
} => FindingJson::StructFieldRemoved {
severity,
node: node.to_string(),
column: column.to_string(),
field: field.to_string(),
},
FindingDetail::StructFieldAdded {
node,
column,
field,
} => FindingJson::StructFieldAdded {
severity,
node: node.to_string(),
column: column.to_string(),
field: field.to_string(),
},
FindingDetail::StructFieldTypeNarrowed {
node,
column,
field,
from_type,
to_type,
} => FindingJson::StructFieldTypeNarrowed {
severity,
node: node.to_string(),
column: column.to_string(),
field: field.to_string(),
from_type: from_type.clone(),
to_type: to_type.clone(),
},
}
}
}
fn join_kind_slug(kind: JoinKind) -> String {
match kind {
JoinKind::Inner => "inner",
JoinKind::Left => "left",
JoinKind::Right => "right",
JoinKind::Full => "full",
JoinKind::Cross => "cross",
}
.to_string()
}
const BREAKING_COLOR: &str = "\x1b[1;31m";
const WARN_COLOR: &str = "\x1b[1;33m";
const COLOR_RESET: &str = "\x1b[0m";
fn colorize(text: &str, color: &str, use_color: bool) -> String {
if use_color {
format!("{color}{text}{COLOR_RESET}")
} else {
text.to_string()
}
}
pub fn render_text(report: &Report, vocabulary: &dyn AdapterVocabulary, use_color: bool) -> String {
let mut out = String::new();
let node_term = vocabulary.node_term();
if let Some(warning) = &report.staleness_warning {
out.push_str(&format!("warning: {warning}\n\n"));
}
if report.findings.is_empty() && report.changes.is_empty() {
out.push_str("No changes detected.\n");
return out;
}
out.push_str("Changed:\n");
for (node, changes) in group_by_node(&report.changes, ChangeJson::node) {
out.push_str(&format!(" {node_term} {node}:\n"));
for change in changes {
out.push_str(&format!(" {}\n", change.describe()));
}
}
let impactful: Vec<&FindingJson> = report
.findings
.iter()
.filter(|f| f.severity() != SeverityJson::Pass)
.collect();
if !impactful.is_empty() {
out.push_str("\nDownstream impact:\n");
for (node, findings) in group_by_node(&impactful, |f: &&FindingJson| f.impacted_node()) {
out.push_str(&format!(" {node_term} {node}:\n"));
for finding in findings {
let label = match finding.severity() {
SeverityJson::Error => colorize("BREAKING", BREAKING_COLOR, use_color),
SeverityJson::Warn => colorize("WARN", WARN_COLOR, use_color),
SeverityJson::Pass => unreachable!("filtered out above"),
};
out.push_str(&format!(
" [{label}] {} ({})\n",
describe_impact(finding, node_term),
finding.rule_name()
));
}
}
}
let models_changed = report
.changes
.iter()
.map(ChangeJson::node)
.collect::<std::collections::HashSet<_>>()
.len();
let columns_changed = report
.changes
.iter()
.filter(|c| c.is_column_change())
.count();
let breaking = report
.findings
.iter()
.filter(|f| f.severity() == SeverityJson::Error)
.count();
let warning = report
.findings
.iter()
.filter(|f| f.severity() == SeverityJson::Warn)
.count();
out.push_str(&format!(
"\nSummary: {models_changed} {node_term}(s) changed, {columns_changed} column(s) \
changed, {breaking} breaking, {warning} warning\n"
));
if !report.impacted_models.is_empty() {
out.push_str(&format!(
"\nImpacted models: {}\n",
report.impacted_models.join(", ")
));
}
if let Some(plan) = &report.defer_plan {
out.push_str("\nDefer plan:\n");
out.push_str(&format!(" Build: {}\n", plan.build.join(", ")));
out.push_str(&format!(
" Defer (assumed available): {}\n",
if plan.defer.is_empty() {
"none".to_string()
} else {
plan.defer.join(", ")
}
));
if let Some(target) = &plan.target {
out.push_str(&format!(" Target: {target}\n"));
}
if let Some(state) = &plan.state {
out.push_str(&format!(" State: {state}\n"));
}
}
if let Some(command) = &report.recommended_command {
out.push_str(&format!("\nRecommended command: {command}\n"));
}
if !report.schema_evolution_warnings.is_empty() {
out.push_str("\nSchema evolution:\n");
for warning in &report.schema_evolution_warnings {
out.push_str(&format!(
" {node_term} {}: {}\n",
warning.node, warning.message
));
}
}
out
}
fn group_by_node<'a, T, F>(items: &'a [T], key: F) -> Vec<(&'a str, Vec<&'a T>)>
where
F: Fn(&'a T) -> &'a str,
{
let mut order: Vec<&'a str> = Vec::new();
let mut groups: std::collections::HashMap<&'a str, Vec<&'a T>> =
std::collections::HashMap::new();
for item in items {
let node = key(item);
groups
.entry(node)
.or_insert_with(|| {
order.push(node);
Vec::new()
})
.push(item);
}
order
.into_iter()
.map(|node| {
(
node,
groups.remove(node).expect("present for every ordered key"),
)
})
.collect()
}
fn describe_impact(finding: &FindingJson, node_term: &str) -> String {
match finding {
FindingJson::ColumnRemovedWithActiveReferences {
node,
column,
reached_column,
..
} => {
format!(
"{column} removed from {node_term} {node} breaks reference via {reached_column}"
)
}
FindingJson::ColumnTypeNarrowed {
column,
from_type,
to_type,
..
} => {
format!("{column} type narrowed from {from_type} to {to_type}")
}
FindingJson::JoinCardinalityLoosened {
position,
from_kind,
to_kind,
..
} => {
format!("join at position {position} loosened from {from_kind} to {to_kind}")
}
FindingJson::ColumnAdded { column, .. } => {
format!("{column} added")
}
FindingJson::ColumnExpressionChanged {
node,
column,
reached,
reached_column,
..
} => {
if node == reached {
format!("expression of {column} changed")
} else {
format!(
"{reached_column} derives from {column}, whose expression changed in {node_term} {node}"
)
}
}
FindingJson::StructFieldRemoved { column, field, .. } => {
format!("{field} removed from struct column {column}")
}
FindingJson::StructFieldAdded { column, field, .. } => {
format!("{field} added to struct column {column}")
}
FindingJson::StructFieldTypeNarrowed {
column,
field,
from_type,
to_type,
..
} => {
format!("{column}.{field} type narrowed from {from_type} to {to_type}")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use zhao_core::adapters::dbt::DbtVocabulary;
use zhao_core::model::{JoinKind as CoreJoinKind, NodeId};
fn all_finding_variants() -> Vec<Finding> {
let node = NodeId::new("model.a");
vec![
Finding {
severity: Severity::Error,
detail: FindingDetail::ColumnRemovedWithActiveReferences {
node: node.clone(),
column: zhao_core::model::ColumnName::new("id"),
reached: NodeId::new("model.b"),
reached_column: zhao_core::model::ColumnName::new("a_id"),
},
},
Finding {
severity: Severity::Warn,
detail: FindingDetail::ColumnTypeNarrowed {
node: node.clone(),
column: zhao_core::model::ColumnName::new("amount"),
from_type: "bigint".to_string(),
to_type: "int".to_string(),
},
},
Finding {
severity: Severity::Warn,
detail: FindingDetail::ColumnExpressionChanged {
node: node.clone(),
column: zhao_core::model::ColumnName::new("amount"),
reached: NodeId::new("model.b"),
reached_column: zhao_core::model::ColumnName::new("total"),
},
},
Finding {
severity: Severity::Warn,
detail: FindingDetail::JoinCardinalityLoosened {
node: node.clone(),
position: 0,
from_kind: CoreJoinKind::Inner,
to_kind: CoreJoinKind::Left,
},
},
Finding {
severity: Severity::Pass,
detail: FindingDetail::ColumnAdded {
node: node.clone(),
column: zhao_core::model::ColumnName::new("new_col"),
},
},
Finding {
severity: Severity::Error,
detail: FindingDetail::StructFieldRemoved {
node: node.clone(),
column: zhao_core::model::ColumnName::new("payload"),
field: zhao_core::model::ColumnName::new("legacy_flag"),
},
},
Finding {
severity: Severity::Pass,
detail: FindingDetail::StructFieldAdded {
node: node.clone(),
column: zhao_core::model::ColumnName::new("payload"),
field: zhao_core::model::ColumnName::new("email"),
},
},
Finding {
severity: Severity::Warn,
detail: FindingDetail::StructFieldTypeNarrowed {
node,
column: zhao_core::model::ColumnName::new("payload"),
field: zhao_core::model::ColumnName::new("amount"),
from_type: "bigint".to_string(),
to_type: "int".to_string(),
},
},
]
}
#[test]
fn finding_json_rule_name_matches_its_serialized_json_tag() {
for finding in &all_finding_variants() {
let json = FindingJson::from(finding);
let serialized: serde_json::Value =
serde_json::to_value(&json).expect("should serialize");
assert_eq!(
serialized["rule"]
.as_str()
.expect("rule should be a string"),
json.rule_name(),
"rule_name() drifted from the derived JSON tag for {json:?}"
);
}
}
#[test]
fn render_text_reports_no_changes_detected_when_nothing_changed() {
let report = Report::new(&[], &[]);
assert_eq!(
render_text(&report, &DbtVocabulary, false),
"No changes detected.\n"
);
}
#[test]
fn render_text_produces_the_three_part_report_using_the_adapters_vocabulary() {
let changes = vec![
Change::ColumnAdded {
node: NodeId::new("model.a"),
column: zhao_core::model::ColumnName::new("new_col"),
},
Change::ColumnRemoved {
node: NodeId::new("model.a"),
column: zhao_core::model::ColumnName::new("id"),
},
];
let findings = vec![Finding {
severity: Severity::Error,
detail: FindingDetail::ColumnRemovedWithActiveReferences {
node: NodeId::new("model.a"),
column: zhao_core::model::ColumnName::new("id"),
reached: NodeId::new("model.b"),
reached_column: zhao_core::model::ColumnName::new("a_id"),
},
}];
let report = Report::new(&changes, &findings);
let text = render_text(&report, &DbtVocabulary, false);
assert!(text.contains("model model.a"), "{text}");
assert!(!text.contains("Node "), "{text}");
assert!(!text.contains("Origin "), "{text}");
assert!(text.contains("Changed:\n model model.a:\n"), "{text}");
assert!(text.contains("+ column added: new_col"), "{text}");
assert!(text.contains("- column removed: id"), "{text}");
assert!(
text.contains("Downstream impact:\n model model.b:\n"),
"{text}"
);
assert!(
text.contains("[BREAKING]") && text.contains("column-removed-with-active-references"),
"{text}"
);
assert!(
text.contains(
"Summary: 1 model(s) changed, 2 column(s) changed, 1 breaking, 0 warning"
),
"{text}"
);
}
#[test]
fn render_text_excludes_pass_severity_findings_from_downstream_impact() {
let changes = vec![Change::ColumnAdded {
node: NodeId::new("model.a"),
column: zhao_core::model::ColumnName::new("new_col"),
}];
let findings = vec![Finding {
severity: Severity::Pass,
detail: FindingDetail::ColumnAdded {
node: NodeId::new("model.a"),
column: zhao_core::model::ColumnName::new("new_col"),
},
}];
let report = Report::new(&changes, &findings);
let text = render_text(&report, &DbtVocabulary, false);
assert!(text.contains("Changed:"), "{text}");
assert!(
!text.contains("Downstream impact:"),
"a pass-severity finding must not produce a Downstream impact section: {text}"
);
assert!(text.contains("0 breaking, 0 warning"), "{text}");
}
#[test]
fn render_text_with_use_color_false_contains_no_ansi_escapes() {
let changes = vec![Change::ColumnRemoved {
node: NodeId::new("model.a"),
column: zhao_core::model::ColumnName::new("id"),
}];
let findings = vec![Finding {
severity: Severity::Error,
detail: FindingDetail::ColumnRemovedWithActiveReferences {
node: NodeId::new("model.a"),
column: zhao_core::model::ColumnName::new("id"),
reached: NodeId::new("model.b"),
reached_column: zhao_core::model::ColumnName::new("a_id"),
},
}];
let report = Report::new(&changes, &findings);
let text = render_text(&report, &DbtVocabulary, false);
assert!(
!text.contains('\x1b'),
"no ANSI escape byte should appear when use_color is false: {text:?}"
);
}
#[test]
fn render_text_with_use_color_true_contains_ansi_escapes_for_breaking_and_warn() {
let node = NodeId::new("model.a");
let findings = vec![
Finding {
severity: Severity::Error,
detail: FindingDetail::ColumnRemovedWithActiveReferences {
node: node.clone(),
column: zhao_core::model::ColumnName::new("id"),
reached: NodeId::new("model.b"),
reached_column: zhao_core::model::ColumnName::new("a_id"),
},
},
Finding {
severity: Severity::Warn,
detail: FindingDetail::ColumnTypeNarrowed {
node,
column: zhao_core::model::ColumnName::new("amount"),
from_type: "bigint".to_string(),
to_type: "int".to_string(),
},
},
];
let report = Report::new(&[], &findings);
let text = render_text(&report, &DbtVocabulary, true);
assert!(
text.contains('\x1b'),
"an ANSI escape byte should appear somewhere when use_color is true: {text:?}"
);
assert!(text.contains("BREAKING"), "{text}");
assert!(text.contains("WARN"), "{text}");
}
#[test]
fn with_impacted_models_includes_exactly_the_downstream_impact_nodes() {
let findings = vec![
Finding {
severity: Severity::Error,
detail: FindingDetail::ColumnRemovedWithActiveReferences {
node: NodeId::new("model.zhao_dbt_test.stg_customers"),
column: zhao_core::model::ColumnName::new("id"),
reached: NodeId::new("model.zhao_dbt_test.dim_customers"),
reached_column: zhao_core::model::ColumnName::new("a_id"),
},
},
Finding {
severity: Severity::Warn,
detail: FindingDetail::ColumnTypeNarrowed {
node: NodeId::new("model.zhao_dbt_test.stg_customers"),
column: zhao_core::model::ColumnName::new("amount"),
from_type: "bigint".to_string(),
to_type: "int".to_string(),
},
},
Finding {
severity: Severity::Pass,
detail: FindingDetail::ColumnAdded {
node: NodeId::new("model.zhao_dbt_test.stg_orders"),
column: zhao_core::model::ColumnName::new("new_col"),
},
},
];
let report = Report::new(&[], &findings).with_impacted_models(&DbtVocabulary);
assert_eq!(
report.impacted_models,
vec!["dim_customers".to_string(), "stg_customers".to_string()],
"should include dim_customers (via the reached Finding) and stg_customers \
(via the type-narrowed Finding on itself) exactly once each, and never \
stg_orders (only a pass-severity Finding, not Downstream impact)"
);
}
#[test]
fn with_impacted_models_includes_a_node_that_no_longer_exists_anywhere_but_its_id() {
let findings = vec![Finding {
severity: Severity::Error,
detail: FindingDetail::ColumnRemovedWithActiveReferences {
node: NodeId::new("model.zhao_dbt_test.stg_customers"),
column: zhao_core::model::ColumnName::new("id"),
reached: NodeId::new("model.zhao_dbt_test.deleted_downstream_model"),
reached_column: zhao_core::model::ColumnName::new("a_id"),
},
}];
let report = Report::new(&[], &findings).with_impacted_models(&DbtVocabulary);
assert_eq!(
report.impacted_models,
vec!["deleted_downstream_model".to_string()],
);
}
#[test]
fn with_impacted_models_is_empty_when_nothing_is_impactful() {
let report = Report::new(&[], &[]).with_impacted_models(&DbtVocabulary);
assert_eq!(report.impacted_models, Vec::<String>::new());
let findings = vec![Finding {
severity: Severity::Pass,
detail: FindingDetail::ColumnAdded {
node: NodeId::new("model.zhao_dbt_test.stg_customers"),
column: zhao_core::model::ColumnName::new("new_col"),
},
}];
let report = Report::new(&[], &findings).with_impacted_models(&DbtVocabulary);
assert_eq!(report.impacted_models, Vec::<String>::new());
}
#[test]
fn impacted_models_lists_changed_models_first_then_downstream_readers() {
let changes = vec![
Change::ColumnAdded {
node: NodeId::new("model.zhao_dbt_test.stg_customers"),
column: zhao_core::model::ColumnName::new("new_col"),
},
Change::ColumnExpressionChanged {
node: NodeId::new("model.zhao_dbt_test.dim_customers"),
column: zhao_core::model::ColumnName::new("total"),
from_expression: Some("a + 1".to_string()),
to_expression: Some("a + 2".to_string()),
},
];
let findings = vec![
Finding {
severity: Severity::Warn,
detail: FindingDetail::ColumnExpressionChanged {
node: NodeId::new("model.zhao_dbt_test.dim_customers"),
column: zhao_core::model::ColumnName::new("total"),
reached: NodeId::new("model.zhao_dbt_test.dim_customers"),
reached_column: zhao_core::model::ColumnName::new("total"),
},
},
Finding {
severity: Severity::Warn,
detail: FindingDetail::ColumnExpressionChanged {
node: NodeId::new("model.zhao_dbt_test.dim_customers"),
column: zhao_core::model::ColumnName::new("total"),
reached: NodeId::new("model.zhao_dbt_test.fct_orders"),
reached_column: zhao_core::model::ColumnName::new("amount"),
},
},
];
let report = Report::new(&changes, &findings).with_impacted_models(&DbtVocabulary);
assert_eq!(
report.impacted_models,
vec!["stg_customers", "dim_customers", "fct_orders"]
);
}
#[test]
fn render_text_describes_a_column_expression_change_and_what_it_reaches() {
let changes = vec![Change::ColumnExpressionChanged {
node: NodeId::new("model.zhao_dbt_test.dim_customers"),
column: zhao_core::model::ColumnName::new("total"),
from_expression: Some("a + 1".to_string()),
to_expression: Some("a + 2".to_string()),
}];
let findings = vec![Finding {
severity: Severity::Warn,
detail: FindingDetail::ColumnExpressionChanged {
node: NodeId::new("model.zhao_dbt_test.dim_customers"),
column: zhao_core::model::ColumnName::new("total"),
reached: NodeId::new("model.zhao_dbt_test.fct_orders"),
reached_column: zhao_core::model::ColumnName::new("amount"),
},
}];
let text = render_text(&Report::new(&changes, &findings), &DbtVocabulary, false);
assert!(
text.contains("~ column expression changed: total"),
"{text}"
);
assert!(
text.contains("amount derives from total, whose expression changed"),
"{text}"
);
assert!(text.contains("(column-expression-changed)"), "{text}");
}
#[test]
fn render_text_appends_the_impacted_models_line_when_present() {
let findings = vec![Finding {
severity: Severity::Error,
detail: FindingDetail::ColumnRemovedWithActiveReferences {
node: NodeId::new("model.zhao_dbt_test.stg_customers"),
column: zhao_core::model::ColumnName::new("id"),
reached: NodeId::new("model.zhao_dbt_test.stg_customers"),
reached_column: zhao_core::model::ColumnName::new("id"),
},
}];
let report = Report::new(&[], &findings)
.with_impacted_models(&DbtVocabulary)
.with_staleness_warning(false);
let text = render_text(&report, &DbtVocabulary, false);
assert!(text.contains("Impacted models: stg_customers"), "{text}");
}
#[test]
fn render_text_omits_the_impacted_models_line_when_absent() {
let report = Report::new(&[], &[]);
let text = render_text(&report, &DbtVocabulary, false);
assert!(!text.contains("Impacted models:"), "{text}");
}
fn impacted_models_finding(node: &str) -> Finding {
Finding {
severity: Severity::Error,
detail: FindingDetail::ColumnRemovedWithActiveReferences {
node: NodeId::new(node),
column: zhao_core::model::ColumnName::new("id"),
reached: NodeId::new(node),
reached_column: zhao_core::model::ColumnName::new("id"),
},
}
}
#[test]
fn with_recommended_command_is_none_when_subcommand_is_not_configured() {
let findings = vec![impacted_models_finding("model.zhao_dbt_test.stg_customers")];
let report = Report::new(&[], &findings)
.with_impacted_models(&DbtVocabulary)
.with_recommended_command(None, "dbt", None);
assert!(report.recommended_command.is_none());
}
#[test]
fn with_recommended_command_is_none_when_nothing_is_impacted_even_if_configured() {
let report = Report::new(&[], &[]).with_recommended_command(Some("run"), "dbt", None);
assert!(report.recommended_command.is_none());
}
#[test]
fn with_recommended_command_builds_a_select_command_from_impacted_models() {
let findings = vec![
impacted_models_finding("model.zhao_dbt_test.stg_customers"),
impacted_models_finding("model.zhao_dbt_test.dim_customers"),
];
let report = Report::new(&[], &findings)
.with_impacted_models(&DbtVocabulary)
.with_recommended_command(Some("run"), "dbt", None);
assert_eq!(
report.recommended_command.as_deref(),
Some("dbt run --select stg_customers dim_customers")
);
}
#[test]
fn with_recommended_command_uses_the_configured_dbt_command_wrapper() {
let findings = vec![impacted_models_finding("model.zhao_dbt_test.stg_customers")];
let report = Report::new(&[], &findings)
.with_impacted_models(&DbtVocabulary)
.with_recommended_command(Some("build"), "uv run dbt", None);
assert_eq!(
report.recommended_command.as_deref(),
Some("uv run dbt build --select stg_customers")
);
}
#[test]
fn with_recommended_command_appends_target_when_a_label_is_given() {
let findings = vec![impacted_models_finding("model.zhao_dbt_test.stg_customers")];
let report = Report::new(&[], &findings)
.with_impacted_models(&DbtVocabulary)
.with_recommended_command(Some("run"), "dbt", Some("prod"));
assert_eq!(
report.recommended_command.as_deref(),
Some("dbt run --select stg_customers --target prod")
);
}
#[test]
fn render_text_appends_the_recommended_command_line_when_present() {
let findings = vec![impacted_models_finding("model.zhao_dbt_test.stg_customers")];
let report = Report::new(&[], &findings)
.with_impacted_models(&DbtVocabulary)
.with_recommended_command(Some("run"), "dbt", None);
let text = render_text(&report, &DbtVocabulary, false);
assert!(
text.contains("Recommended command: dbt run --select stg_customers"),
"{text}"
);
}
#[test]
fn render_text_omits_the_recommended_command_line_when_absent() {
let report = Report::new(&[], &[]);
let text = render_text(&report, &DbtVocabulary, false);
assert!(!text.contains("Recommended command:"), "{text}");
}
fn project_with_edges(edges: Vec<zhao_core::model::LineageEdge>) -> ParsedProject {
ParsedProject {
seed_node_ids: Default::default(),
nodes: Vec::new(),
origins: Vec::new(),
edges,
}
}
fn node_edge(upstream: &str, downstream: &str) -> zhao_core::model::LineageEdge {
zhao_core::model::LineageEdge {
upstream: Upstream::Node(NodeId::new(upstream)),
downstream: NodeId::new(downstream),
column: None,
}
}
fn node_with_materialization(
id: &str,
materialization: Materialization,
) -> zhao_core::model::Node {
zhao_core::model::Node {
id: NodeId::new(id),
name: id.to_string(),
columns: Vec::new(),
joins: Vec::new(),
materialization,
}
}
fn project_with_nodes(nodes: Vec<zhao_core::model::Node>) -> ParsedProject {
ParsedProject {
seed_node_ids: Default::default(),
nodes,
origins: Vec::new(),
edges: Vec::new(),
}
}
#[test]
fn with_defer_plan_separates_build_from_transitive_upstream_dependencies() {
let current = project_with_edges(vec![
node_edge(
"model.zhao_dbt_test.stg_orders",
"model.zhao_dbt_test.dim_customers",
),
node_edge(
"model.zhao_dbt_test.raw_base",
"model.zhao_dbt_test.stg_orders",
),
]);
let findings = vec![Finding {
severity: Severity::Warn,
detail: FindingDetail::ColumnTypeNarrowed {
node: NodeId::new("model.zhao_dbt_test.dim_customers"),
column: zhao_core::model::ColumnName::new("amount"),
from_type: "bigint".to_string(),
to_type: "int".to_string(),
},
}];
let report = Report::new(&[], &findings).with_defer_plan(
¤t,
&DbtVocabulary,
&DeferSettings::default(),
);
let plan = report.defer_plan.expect("plan should be present");
assert_eq!(plan.build, vec!["dim_customers"]);
assert_eq!(plan.defer, vec!["raw_base", "stg_orders"]);
}
#[test]
fn with_defer_plan_defer_is_empty_not_absent_when_only_an_origin_is_upstream() {
let current = project_with_edges(vec![zhao_core::model::LineageEdge {
upstream: Upstream::Origin(zhao_core::model::OriginId::new("source.raw.customers")),
downstream: NodeId::new("model.zhao_dbt_test.stg_customers"),
column: None,
}]);
let findings = vec![Finding {
severity: Severity::Warn,
detail: FindingDetail::ColumnTypeNarrowed {
node: NodeId::new("model.zhao_dbt_test.stg_customers"),
column: zhao_core::model::ColumnName::new("amount"),
from_type: "bigint".to_string(),
to_type: "int".to_string(),
},
}];
let report = Report::new(&[], &findings).with_defer_plan(
¤t,
&DbtVocabulary,
&DeferSettings::default(),
);
let plan = report.defer_plan.expect("plan should be present");
assert_eq!(plan.build, vec!["stg_customers"]);
assert!(plan.defer.is_empty());
}
#[test]
fn with_defer_plan_never_defers_a_node_thats_also_being_built() {
let current = project_with_edges(vec![node_edge(
"model.zhao_dbt_test.stg_customers",
"model.zhao_dbt_test.dim_customers",
)]);
let findings = vec![
Finding {
severity: Severity::Warn,
detail: FindingDetail::ColumnTypeNarrowed {
node: NodeId::new("model.zhao_dbt_test.stg_customers"),
column: zhao_core::model::ColumnName::new("amount"),
from_type: "bigint".to_string(),
to_type: "int".to_string(),
},
},
Finding {
severity: Severity::Warn,
detail: FindingDetail::ColumnTypeNarrowed {
node: NodeId::new("model.zhao_dbt_test.dim_customers"),
column: zhao_core::model::ColumnName::new("amount"),
from_type: "bigint".to_string(),
to_type: "int".to_string(),
},
},
];
let report = Report::new(&[], &findings).with_defer_plan(
¤t,
&DbtVocabulary,
&DeferSettings::default(),
);
let plan = report.defer_plan.expect("plan should be present");
assert!(!plan.defer.contains(&"stg_customers".to_string()));
}
#[test]
fn with_defer_plan_is_none_when_nothing_is_impactful() {
let current = project_with_edges(Vec::new());
let report = Report::new(&[], &[]).with_defer_plan(
¤t,
&DbtVocabulary,
&DeferSettings::default(),
);
assert!(report.defer_plan.is_none());
}
#[test]
fn render_text_appends_the_defer_plan_when_present() {
let current = project_with_edges(vec![node_edge(
"model.zhao_dbt_test.stg_orders",
"model.zhao_dbt_test.dim_customers",
)]);
let findings = vec![Finding {
severity: Severity::Warn,
detail: FindingDetail::ColumnTypeNarrowed {
node: NodeId::new("model.zhao_dbt_test.dim_customers"),
column: zhao_core::model::ColumnName::new("amount"),
from_type: "bigint".to_string(),
to_type: "int".to_string(),
},
}];
let report = Report::new(&[], &findings).with_defer_plan(
¤t,
&DbtVocabulary,
&DeferSettings::default(),
);
let text = render_text(&report, &DbtVocabulary, false);
assert!(text.contains("Defer plan:"), "{text}");
assert!(text.contains("Build: dim_customers"), "{text}");
assert!(
text.contains("Defer (assumed available): stg_orders"),
"{text}"
);
}
#[test]
fn render_text_omits_the_defer_plan_section_when_absent() {
let report = Report::new(&[], &[]);
let text = render_text(&report, &DbtVocabulary, false);
assert!(!text.contains("Defer plan:"), "{text}");
}
#[test]
fn defer_settings_with_a_state_path_surface_it_on_the_plan() {
let current = project_with_edges(vec![node_edge(
"model.zhao_dbt_test.stg_orders",
"model.zhao_dbt_test.dim_customers",
)]);
let findings = vec![Finding {
severity: Severity::Warn,
detail: FindingDetail::ColumnTypeNarrowed {
node: NodeId::new("model.zhao_dbt_test.dim_customers"),
column: zhao_core::model::ColumnName::new("amount"),
from_type: "bigint".to_string(),
to_type: "int".to_string(),
},
}];
let settings = DeferSettings {
target: Some("prod".to_string()),
state: Some("artifacts/prod/manifest.json".to_string()),
};
let report =
Report::new(&[], &findings).with_defer_plan(¤t, &DbtVocabulary, &settings);
let plan = report.defer_plan.as_ref().expect("plan should be present");
assert_eq!(plan.target.as_deref(), Some("prod"));
assert_eq!(plan.state.as_deref(), Some("artifacts/prod/manifest.json"));
let text = render_text(&report, &DbtVocabulary, false);
assert!(text.contains("Target: prod"), "{text}");
assert!(
text.contains("State: artifacts/prod/manifest.json"),
"{text}"
);
}
#[test]
fn defer_settings_with_only_a_target_produce_no_state() {
let current = project_with_edges(Vec::new());
let findings = vec![Finding {
severity: Severity::Warn,
detail: FindingDetail::ColumnTypeNarrowed {
node: NodeId::new("model.zhao_dbt_test.dim_customers"),
column: zhao_core::model::ColumnName::new("amount"),
from_type: "bigint".to_string(),
to_type: "int".to_string(),
},
}];
let settings = DeferSettings {
target: Some("prod".to_string()),
state: None,
};
let report =
Report::new(&[], &findings).with_defer_plan(¤t, &DbtVocabulary, &settings);
let plan = report.defer_plan.expect("plan should be present");
assert_eq!(plan.target.as_deref(), Some("prod"));
assert!(plan.state.is_none());
}
#[test]
fn defer_settings_with_only_a_state_surface_it_with_no_target() {
let current = project_with_edges(Vec::new());
let findings = vec![Finding {
severity: Severity::Warn,
detail: FindingDetail::ColumnTypeNarrowed {
node: NodeId::new("model.zhao_dbt_test.dim_customers"),
column: zhao_core::model::ColumnName::new("amount"),
from_type: "bigint".to_string(),
to_type: "int".to_string(),
},
}];
let settings = DeferSettings {
target: None,
state: Some("artifacts/prod/manifest.json".to_string()),
};
let report =
Report::new(&[], &findings).with_defer_plan(¤t, &DbtVocabulary, &settings);
let plan = report.defer_plan.as_ref().expect("plan should be present");
assert!(plan.target.is_none());
assert_eq!(plan.state.as_deref(), Some("artifacts/prod/manifest.json"));
let text = render_text(&report, &DbtVocabulary, false);
assert!(!text.contains("Target:"), "{text}");
assert!(text.contains("State:"), "{text}");
}
#[test]
fn a_state_path_with_spaces_is_surfaced_verbatim() {
let current = project_with_edges(Vec::new());
let findings = vec![Finding {
severity: Severity::Warn,
detail: FindingDetail::ColumnTypeNarrowed {
node: NodeId::new("model.zhao_dbt_test.dim_customers"),
column: zhao_core::model::ColumnName::new("amount"),
from_type: "bigint".to_string(),
to_type: "int".to_string(),
},
}];
let settings = DeferSettings {
target: None,
state: Some("artifacts/My Manifests/prod/manifest.json".to_string()),
};
let report =
Report::new(&[], &findings).with_defer_plan(¤t, &DbtVocabulary, &settings);
let plan = report.defer_plan.expect("plan should be present");
assert_eq!(
plan.state.as_deref(),
Some("artifacts/My Manifests/prod/manifest.json")
);
}
#[test]
fn default_defer_settings_produce_neither_target_nor_state() {
let current = project_with_edges(Vec::new());
let findings = vec![Finding {
severity: Severity::Warn,
detail: FindingDetail::ColumnTypeNarrowed {
node: NodeId::new("model.zhao_dbt_test.dim_customers"),
column: zhao_core::model::ColumnName::new("amount"),
from_type: "bigint".to_string(),
to_type: "int".to_string(),
},
}];
let report = Report::new(&[], &findings).with_defer_plan(
¤t,
&DbtVocabulary,
&DeferSettings::default(),
);
let plan = report.defer_plan.expect("plan should be present");
assert!(plan.target.is_none());
assert!(plan.state.is_none());
}
#[test]
fn schema_evolution_warning_fires_for_a_schema_change_on_an_incremental_node() {
let current = project_with_nodes(vec![node_with_materialization(
"model.a",
Materialization::Incremental,
)]);
let changes = vec![Change::ColumnAdded {
node: NodeId::new("model.a"),
column: zhao_core::model::ColumnName::new("new_col"),
}];
let report = Report::new(&changes, &[]).with_schema_evolution_warnings(¤t);
assert_eq!(report.schema_evolution_warnings.len(), 1);
assert_eq!(report.schema_evolution_warnings[0].node, "model.a");
}
#[test]
fn schema_evolution_warning_never_fires_for_a_table_node() {
let current = project_with_nodes(vec![node_with_materialization(
"model.a",
Materialization::Table,
)]);
let changes = vec![Change::ColumnAdded {
node: NodeId::new("model.a"),
column: zhao_core::model::ColumnName::new("new_col"),
}];
let report = Report::new(&changes, &[]).with_schema_evolution_warnings(¤t);
assert!(report.schema_evolution_warnings.is_empty());
}
#[test]
fn schema_evolution_warning_never_fires_for_ephemeral_or_other_materializations() {
for materialization in [
Materialization::View,
Materialization::Ephemeral,
Materialization::Other("materialized_view".to_string()),
] {
let current = project_with_nodes(vec![node_with_materialization(
"model.a",
materialization.clone(),
)]);
let changes = vec![Change::ColumnAdded {
node: NodeId::new("model.a"),
column: zhao_core::model::ColumnName::new("new_col"),
}];
let report = Report::new(&changes, &[]).with_schema_evolution_warnings(¤t);
assert!(
report.schema_evolution_warnings.is_empty(),
"{materialization:?} should never produce a schema evolution warning"
);
}
}
#[test]
fn schema_evolution_warning_never_fires_for_a_join_change() {
let current = project_with_nodes(vec![node_with_materialization(
"model.a",
Materialization::Incremental,
)]);
let changes = vec![Change::JoinChanged {
node: NodeId::new("model.a"),
position: 0,
from_kind: Some(CoreJoinKind::Inner),
to_kind: Some(CoreJoinKind::Left),
}];
let report = Report::new(&changes, &[]).with_schema_evolution_warnings(¤t);
assert!(report.schema_evolution_warnings.is_empty());
}
#[test]
fn with_live_relation_checks_upgrades_a_confirmed_warning_to_definitive_wording() {
let current = project_with_nodes(vec![node_with_materialization(
"model.a",
Materialization::Incremental,
)]);
let changes = vec![Change::ColumnAdded {
node: NodeId::new("model.a"),
column: zhao_core::model::ColumnName::new("new_col"),
}];
let report = Report::new(&changes, &[])
.with_schema_evolution_warnings(¤t)
.with_live_relation_checks(|_node| Some(true));
assert_eq!(report.schema_evolution_warnings.len(), 1);
let message = &report.schema_evolution_warnings[0].message;
assert!(
!message.starts_with("if "),
"a confirmed-existing warning should no longer be phrased conditionally: {message}"
);
assert!(
message.contains("exists in your target environment"),
"{message}"
);
}
#[test]
fn with_live_relation_checks_drops_a_confirmed_absent_warning() {
let current = project_with_nodes(vec![node_with_materialization(
"model.a",
Materialization::Incremental,
)]);
let changes = vec![Change::ColumnAdded {
node: NodeId::new("model.a"),
column: zhao_core::model::ColumnName::new("new_col"),
}];
let report = Report::new(&changes, &[])
.with_schema_evolution_warnings(¤t)
.with_live_relation_checks(|_node| Some(false));
assert!(report.schema_evolution_warnings.is_empty());
}
#[test]
fn with_live_relation_checks_leaves_an_undetermined_warning_unchanged() {
let current = project_with_nodes(vec![node_with_materialization(
"model.a",
Materialization::Incremental,
)]);
let changes = vec![Change::ColumnAdded {
node: NodeId::new("model.a"),
column: zhao_core::model::ColumnName::new("new_col"),
}];
let before = Report::new(&changes, &[]).with_schema_evolution_warnings(¤t);
let original_message = before.schema_evolution_warnings[0].message.clone();
let after = before.with_live_relation_checks(|_node| None);
assert_eq!(after.schema_evolution_warnings.len(), 1);
assert_eq!(after.schema_evolution_warnings[0].message, original_message);
}
#[test]
fn schema_evolution_warning_message_is_phrased_conditionally() {
let current = project_with_nodes(vec![node_with_materialization(
"model.a",
Materialization::Incremental,
)]);
let changes = vec![Change::ColumnRemoved {
node: NodeId::new("model.a"),
column: zhao_core::model::ColumnName::new("old_col"),
}];
let report = Report::new(&changes, &[]).with_schema_evolution_warnings(¤t);
let message = &report.schema_evolution_warnings[0].message;
assert!(
message.starts_with("if "),
"message should be phrased as a conditional, not asserted as fact: {message}"
);
}
#[test]
fn schema_evolution_warning_never_contains_ddl() {
let current = project_with_nodes(vec![node_with_materialization(
"model.a",
Materialization::Incremental,
)]);
let changes = vec![Change::ColumnAdded {
node: NodeId::new("model.a"),
column: zhao_core::model::ColumnName::new("new_col"),
}];
let report = Report::new(&changes, &[]).with_schema_evolution_warnings(¤t);
let text = render_text(&report, &DbtVocabulary, false);
for ddl_keyword in ["ALTER TABLE", "ADD COLUMN", "DROP COLUMN", "CREATE TABLE"] {
assert!(
!text.to_uppercase().contains(ddl_keyword),
"found DDL-shaped text {ddl_keyword:?} in: {text}"
);
}
}
#[test]
fn render_text_appends_the_schema_evolution_section_when_present() {
let current = project_with_nodes(vec![node_with_materialization(
"model.a",
Materialization::Incremental,
)]);
let changes = vec![Change::ColumnAdded {
node: NodeId::new("model.a"),
column: zhao_core::model::ColumnName::new("new_col"),
}];
let report = Report::new(&changes, &[]).with_schema_evolution_warnings(¤t);
let text = render_text(&report, &DbtVocabulary, false);
assert!(text.contains("Schema evolution:"), "{text}");
assert!(text.contains("model model.a:"), "{text}");
}
#[test]
fn render_text_omits_the_schema_evolution_section_when_absent() {
let current = project_with_nodes(vec![node_with_materialization(
"model.a",
Materialization::Table,
)]);
let changes = vec![Change::ColumnAdded {
node: NodeId::new("model.a"),
column: zhao_core::model::ColumnName::new("new_col"),
}];
let report = Report::new(&changes, &[]).with_schema_evolution_warnings(¤t);
let text = render_text(&report, &DbtVocabulary, false);
assert!(!text.contains("Schema evolution:"), "{text}");
}
#[test]
fn render_text_reports_a_struct_field_removal_as_breaking() {
let changes = vec![Change::StructFieldRemoved {
node: NodeId::new("model.a"),
column: zhao_core::model::ColumnName::new("payload"),
field: zhao_core::model::ColumnName::new("legacy_flag"),
}];
let findings = vec![Finding {
severity: Severity::Error,
detail: FindingDetail::StructFieldRemoved {
node: NodeId::new("model.a"),
column: zhao_core::model::ColumnName::new("payload"),
field: zhao_core::model::ColumnName::new("legacy_flag"),
},
}];
let report = Report::new(&changes, &findings);
let text = render_text(&report, &DbtVocabulary, false);
assert!(
text.contains("- struct field removed: payload.legacy_flag"),
"{text}"
);
assert!(
text.contains("Downstream impact:\n model model.a:\n"),
"{text}"
);
assert!(
text.contains("[BREAKING]") && text.contains("struct-field-removed"),
"{text}"
);
assert!(
text.contains("legacy_flag removed from struct column payload"),
"{text}"
);
assert!(text.contains("1 breaking, 0 warning"), "{text}");
}
#[test]
fn render_text_reports_a_struct_field_addition_as_informational_only() {
let changes = vec![Change::StructFieldAdded {
node: NodeId::new("model.a"),
column: zhao_core::model::ColumnName::new("payload"),
field: zhao_core::model::ColumnName::new("email"),
}];
let findings = vec![Finding {
severity: Severity::Pass,
detail: FindingDetail::StructFieldAdded {
node: NodeId::new("model.a"),
column: zhao_core::model::ColumnName::new("payload"),
field: zhao_core::model::ColumnName::new("email"),
},
}];
let report = Report::new(&changes, &findings);
let text = render_text(&report, &DbtVocabulary, false);
assert!(
text.contains("+ struct field added: payload.email"),
"{text}"
);
assert!(
!text.contains("Downstream impact:"),
"a pass-severity struct field addition must not produce Downstream impact: {text}"
);
assert!(text.contains("0 breaking, 0 warning"), "{text}");
}
#[test]
fn render_text_reports_a_struct_field_type_narrowing_as_a_warning() {
let changes = vec![Change::StructFieldTypeChanged {
node: NodeId::new("model.a"),
column: zhao_core::model::ColumnName::new("payload"),
field: zhao_core::model::ColumnName::new("amount"),
from_type: "bigint".to_string(),
to_type: "int".to_string(),
}];
let findings = vec![Finding {
severity: Severity::Warn,
detail: FindingDetail::StructFieldTypeNarrowed {
node: NodeId::new("model.a"),
column: zhao_core::model::ColumnName::new("payload"),
field: zhao_core::model::ColumnName::new("amount"),
from_type: "bigint".to_string(),
to_type: "int".to_string(),
},
}];
let report = Report::new(&changes, &findings);
let text = render_text(&report, &DbtVocabulary, false);
assert!(
text.contains("~ struct field type changed: payload.amount (bigint -> int)"),
"{text}"
);
assert!(
text.contains("[WARN]") && text.contains("struct-field-type-narrowed"),
"{text}"
);
assert!(text.contains("0 breaking, 1 warning"), "{text}");
}
#[test]
fn json_report_serializes_struct_field_changes_and_findings() {
let changes = vec![Change::StructFieldRemoved {
node: NodeId::new("model.a"),
column: zhao_core::model::ColumnName::new("payload"),
field: zhao_core::model::ColumnName::new("legacy_flag"),
}];
let findings = vec![Finding {
severity: Severity::Error,
detail: FindingDetail::StructFieldRemoved {
node: NodeId::new("model.a"),
column: zhao_core::model::ColumnName::new("payload"),
field: zhao_core::model::ColumnName::new("legacy_flag"),
},
}];
let report = Report::new(&changes, &findings);
let json: serde_json::Value =
serde_json::to_value(&report).expect("report should serialize");
assert_eq!(json["changes"][0]["type"], "struct_field_removed");
assert_eq!(json["changes"][0]["column"], "payload");
assert_eq!(json["changes"][0]["field"], "legacy_flag");
assert_eq!(json["findings"][0]["rule"], "struct-field-removed");
assert_eq!(json["findings"][0]["severity"], "error");
assert_eq!(json["findings"][0]["field"], "legacy_flag");
}
#[test]
fn schema_evolution_warning_fires_for_a_struct_field_change_on_an_incremental_node() {
let current = project_with_nodes(vec![node_with_materialization(
"model.a",
Materialization::Incremental,
)]);
let changes = vec![Change::StructFieldRemoved {
node: NodeId::new("model.a"),
column: zhao_core::model::ColumnName::new("payload"),
field: zhao_core::model::ColumnName::new("legacy_flag"),
}];
let report = Report::new(&changes, &[]).with_schema_evolution_warnings(¤t);
assert_eq!(report.schema_evolution_warnings.len(), 1);
assert_eq!(report.schema_evolution_warnings[0].node, "model.a");
}
}