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
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,8 @@ E2E_DUCKDB_INSPECTION_PATHS := \
tests/e2e/src/sqlbuild/cli/commands/main/clone \
tests/e2e/src/sqlbuild/cli/commands/main/diff \
tests/e2e/src/sqlbuild/cli/commands/main/janitor \
tests/e2e/src/sqlbuild/cli/commands/main/plan
tests/e2e/src/sqlbuild/cli/commands/main/plan \
tests/e2e/src/sqlbuild/cli/commands/main/review

E2E_DUCKDB_INTEGRATIONS_PATHS := tests/e2e/src/sqlbuild/integrations

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//! The singular word a change report uses for a node kind.

use crate::definition_changes::_helpers::aspects::kind_words;

/// `model`, `SQL macro`, `source`, or `kind` itself for an unlabelled kind.
#[must_use]
pub fn kind_label(kind: &str) -> &str {
kind_words(kind).0
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub mod changes_summary;
pub mod compare_definitions;
pub mod definition_digest;
pub mod definition_snapshot_json;
pub mod kind_label;
pub mod node_key;
pub mod parse_definition_snapshot;
pub mod selection_json;
Expand Down
1 change: 1 addition & 0 deletions crates/sqlbuild-analysis/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ pub mod query_analysis;
pub mod relation_names;
pub mod required_columns;
pub mod resource_diagnostics;
pub mod review_command;
pub mod semantic_checks;
pub mod semantic_usage;
pub mod semantic_validation;
Expand Down
54 changes: 54 additions & 0 deletions crates/sqlbuild-analysis/src/review_command/_helpers/graph.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//! Reachability over the review graph.

use std::collections::{BTreeMap, BTreeSet};

use crate::review_command::models::ReviewGraph;

/// Every node reachable downstream from `roots`, including the roots.
pub(crate) fn descendants(graph: &ReviewGraph, roots: &BTreeSet<String>) -> BTreeSet<String> {
let mut reached: BTreeSet<String> = BTreeSet::new();
let mut pending: Vec<String> = roots.iter().cloned().collect();
while let Some(name) = pending.pop() {
if !reached.insert(name.clone()) {
continue;
}
if let Some(next) = graph.downstream.get(&name) {
pending.extend(next.iter().cloned());
}
}
reached
}

/// Each node's direct upstream nodes.
pub(crate) fn upstream_of(graph: &ReviewGraph) -> BTreeMap<&str, Vec<&str>> {
let mut upstream: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for (name, children) in &graph.downstream {
for child in children {
upstream
.entry(child.as_str())
.or_default()
.push(name.as_str());
}
}
upstream
}

/// The nodes of `within` from which some node of `targets` is reachable, including the targets.
pub(crate) fn ancestors_within(
graph: &ReviewGraph,
targets: &BTreeSet<String>,
within: &BTreeSet<String>,
) -> BTreeSet<String> {
let upstream: BTreeMap<&str, Vec<&str>> = upstream_of(graph);
let mut reached: BTreeSet<String> = BTreeSet::new();
let mut pending: Vec<&str> = targets.iter().map(String::as_str).collect();
while let Some(name) = pending.pop() {
if !within.contains(name) || !reached.insert(name.to_owned()) {
continue;
}
if let Some(parents) = upstream.get(name) {
pending.extend(parents.iter().copied());
}
}
reached
}
106 changes: 106 additions & 0 deletions crates/sqlbuild-analysis/src/review_command/_helpers/json.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//! The parts of the versioned review JSON document.

use serde_json::{Value, json};

use crate::review_command::constants::{FINDING_NODE, NO_COMPARISON_MODE, NOT_VALIDATED_NODE};
use crate::review_command::models::{
NodeComparison, NodeReport, ReviewFinding, ReviewReport, ValidationCounts,
};

pub(crate) fn comparison_json(comparison: &NodeComparison) -> Value {
json!({
"mode": comparison.mode,
"complete": comparison.complete,
"target_relation": comparison.target_relation,
"branch_relation": comparison.branch_relation,
"target_rows": comparison.target_rows,
"branch_rows": comparison.branch_rows,
"compared_rows": comparison.compared_rows,
"unique_key": comparison.unique_key,
"added_columns": comparison.added_columns,
"removed_columns": comparison.removed_columns,
"type_changes": comparison.type_changes.iter().map(|(column, target, branch)| {
json!({"column": column, "target": target, "branch": branch})
}).collect::<Vec<Value>>(),
"branch_only_keys": comparison.branch_only_keys,
"target_only_keys": comparison.target_only_keys,
"columns": comparison.columns.iter().map(|column| json!({
"column": column.column,
"metric": column.metric,
"target": column.target,
"branch": column.branch,
"limit": column.limit,
})).collect::<Vec<Value>>(),
"unchanged_columns": comparison.unchanged_columns,
})
}

pub(crate) fn node_json(node: &NodeReport) -> Value {
json!({
"name": node.name,
"kind": node.kind,
"critical": node.critical,
"changed": node.changed,
"status": node.status,
"reason": node.reason,
"build_status": node.build_status,
"comparison": node.comparison.as_ref().map(comparison_json),
"affected_columns": node.affected_columns,
})
}

pub(crate) fn finding_json(finding: &ReviewFinding) -> Value {
json!({
"node": finding.node,
"kind": finding.kind,
"metric": finding.metric,
"column": finding.column,
"target": finding.target_value,
"branch": finding.branch_value,
"limit": finding.limit,
"examples": finding.examples.iter().map(|example| json!({
"key": example.key,
"target": example.target,
"branch": example.branch,
})).collect::<Vec<Value>>(),
})
}

pub(crate) fn counts_json(counts: &ValidationCounts) -> Value {
json!({
"selected": counts.selected,
"total": counts.total,
"skipped": counts.total.saturating_sub(counts.selected),
"passed": counts.passed,
"failed": counts.failed,
"failures": counts.failures,
})
}

pub(crate) fn summary_json(report: &ReviewReport) -> Value {
let built: usize = report
.nodes
.iter()
.filter(|node| node.build_status.is_some())
.count();
let compared: usize = report
.nodes
.iter()
.filter(|node| {
node.comparison
.as_ref()
.is_some_and(|comparison| comparison.mode != NO_COMPARISON_MODE)
})
.count();
json!({
"changed": report.plan.changed.len() + report.changed_declarations.len(),
"validated": report.plan.validated.len(),
"built": built,
"compared": compared,
"tests": report.tests.selected,
"findings": report.findings.len(),
"nodes_with_findings": report.nodes.iter().filter(|node| node.status == FINDING_NODE).count(),
"not_validated": report.nodes.iter().filter(|node| node.status == NOT_VALIDATED_NODE).count(),
"incomplete": report.incomplete.len(),
})
}
177 changes: 177 additions & 0 deletions crates/sqlbuild-analysis/src/review_command/_helpers/markdown.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
//! The blocks of the Markdown review, each rendered on its own.

use crate::review_command::_helpers::sections::{
Style, example_lines, finding_text, impact_tree, summary_counts,
};
use crate::review_command::_helpers::wording::counted;
use crate::review_command::models::{NodeComparison, NodeReport, ReviewReport};

pub(crate) const MARKDOWN: Style = Style { markdown: true };
const PLAIN: Style = Style { markdown: false };

/// One row of the per-model evidence table.
struct EvidenceRow {
column: String,
metric: String,
target: String,
branch: String,
limit: String,
}

impl EvidenceRow {
fn exact(column: &str, metric: &str, (target, branch): (&str, &str)) -> Self {
Self {
column: column.to_owned(),
metric: metric.to_owned(),
target: target.to_owned(),
branch: branch.to_owned(),
limit: "exact match".to_owned(),
}
}

fn cells(&self) -> [&str; 5] {
[
&self.column,
&self.metric,
&self.target,
&self.branch,
&self.limit,
]
}
}

fn cell(text: &str) -> String {
text.replace('|', "\\|").replace('\n', " ")
}

/// A `## title` section listing `lines`, or `empty` when there are none.
pub(crate) fn section(title: &str, lines: &[String], empty: &str) -> String {
let body: String = if lines.is_empty() {
format!("{empty}\n")
} else {
lines.iter().map(|line| format!("- {line}\n")).collect()
};
format!("\n## {title}\n\n{body}")
}

/// The one-row summary table of counts.
pub(crate) fn summary_table(report: &ReviewReport) -> String {
let counts: Vec<(&str, usize)> = summary_counts(report);
let labels: Vec<&str> = counts.iter().map(|(label, _)| *label).collect();
let values: Vec<String> = counts.iter().map(|(_, count)| count.to_string()).collect();
format!(
"| {} |\n|{}\n| {} |\n",
labels.join(" | "),
"---:|".repeat(counts.len()),
values.join(" | ")
)
}

/// The numbered findings with their example keys.
pub(crate) fn findings(report: &ReviewReport) -> String {
let against: &str = &report.baseline.against;
if report.findings.is_empty() {
return "\n## Findings\n\nNo findings.\n".to_owned();
}
let mut lines: Vec<String> = Vec::new();
for (index, finding) in report.findings.iter().enumerate() {
lines.push(format!(
"{}. {}\n",
index + 1,
finding_text(finding, against, MARKDOWN)
));
lines.extend(
example_lines(finding, MARKDOWN)
.into_iter()
.map(|example| format!(" - {example}\n")),
);
}
format!("\n## Findings\n\n{}", lines.concat())
}

/// The lineage tree of the changed set in a fenced block.
pub(crate) fn impact(report: &ReviewReport) -> String {
let tree: Vec<String> = impact_tree(report, PLAIN);
if tree.is_empty() {
return "\n## Downstream impact\n\nNo changed models or seeds.\n".to_owned();
}
let lines: String = tree.iter().map(|line| format!("{line}\n")).collect();
format!("\n## Downstream impact\n\n```text\n{lines}```\n")
}

fn evidence_rows(comparison: &NodeComparison) -> Vec<EvidenceRow> {
let added = comparison
.added_columns
.iter()
.map(|column| EvidenceRow::exact(column, "column", ("absent", "added")));
let removed = comparison
.removed_columns
.iter()
.map(|column| EvidenceRow::exact(column, "column", ("present", "removed")));
let retyped = comparison
.type_changes
.iter()
.map(|(column, target, branch)| EvidenceRow::exact(column, "type", (target, branch)));
let values = comparison.columns.iter().map(|column| EvidenceRow {
column: column.column.clone(),
metric: column.metric.clone(),
target: column.target.clone(),
branch: column.branch.clone(),
limit: column.limit.clone(),
});
added.chain(removed).chain(retyped).chain(values).collect()
}

fn table_row(row: &EvidenceRow) -> String {
let cells: Vec<String> = row.cells().iter().map(|text| cell(text)).collect();
format!("| {} |\n", cells.join(" | "))
}

fn evidence_table(rows: &[EvidenceRow], against: &str) -> String {
if rows.is_empty() {
return String::new();
}
let body: String = rows.iter().map(table_row).collect();
format!(
"| Column | Metric | {} | branch | Limit |\n|---|---|---|---|---|\n{body}\n",
cell(against)
)
}

/// One collapsed block of the columns that changed in one model.
fn node_evidence(node: &NodeReport, comparison: &NodeComparison, against: &str) -> String {
let rows: Vec<EvidenceRow> = evidence_rows(comparison);
let rows_text: String = match (comparison.target_rows, comparison.branch_rows) {
(Some(target), Some(branch)) => format!("rows {against} {target}, branch {branch}; "),
_ => String::new(),
};
format!(
"\n<details><summary>{}: {}</summary>\n\n{}{rows_text}{} compared with no difference ({} comparison).\n\n</details>\n",
cell(&node.name),
counted(rows.len(), "difference", "differences"),
evidence_table(&rows, against),
counted(
comparison.unchanged_columns,
"other column",
"other columns"
),
comparison.mode.replace('_', " ")
)
}

fn compared_evidence(node: &NodeReport, against: &str) -> Option<String> {
let comparison: &NodeComparison = node.comparison.as_ref()?;
Some(node_evidence(node, comparison, against))
}

/// The per-model evidence of every node whose comparison found a difference.
pub(crate) fn evidence(nodes: &[&NodeReport], against: &str) -> String {
let blocks: String = nodes
.iter()
.filter_map(|node| compared_evidence(node, against))
.collect();
if blocks.is_empty() {
return String::new();
}
format!("\n## Per-model evidence\n{blocks}")
}
8 changes: 8 additions & 0 deletions crates/sqlbuild-analysis/src/review_command/_helpers/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
pub(crate) mod graph;
pub(crate) mod json;
pub(crate) mod markdown;
pub(crate) mod outcome;
pub(crate) mod scope;
pub(crate) mod sections;
pub(crate) mod text;
pub(crate) mod wording;
Loading
Loading