diff --git a/Makefile b/Makefile index c449993398..d718172570 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/crates/sqlbuild-analysis/src/definition_changes/main/kind_label.rs b/crates/sqlbuild-analysis/src/definition_changes/main/kind_label.rs new file mode 100644 index 0000000000..7b49efbc78 --- /dev/null +++ b/crates/sqlbuild-analysis/src/definition_changes/main/kind_label.rs @@ -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 +} diff --git a/crates/sqlbuild-analysis/src/definition_changes/main/mod.rs b/crates/sqlbuild-analysis/src/definition_changes/main/mod.rs index 736bcacde4..dec9c6203a 100644 --- a/crates/sqlbuild-analysis/src/definition_changes/main/mod.rs +++ b/crates/sqlbuild-analysis/src/definition_changes/main/mod.rs @@ -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; diff --git a/crates/sqlbuild-analysis/src/lib.rs b/crates/sqlbuild-analysis/src/lib.rs index 376fd5733b..173c375e46 100644 --- a/crates/sqlbuild-analysis/src/lib.rs +++ b/crates/sqlbuild-analysis/src/lib.rs @@ -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; diff --git a/crates/sqlbuild-analysis/src/review_command/_helpers/graph.rs b/crates/sqlbuild-analysis/src/review_command/_helpers/graph.rs new file mode 100644 index 0000000000..d8a068a185 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/_helpers/graph.rs @@ -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) -> BTreeSet { + let mut reached: BTreeSet = BTreeSet::new(); + let mut pending: Vec = 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, + within: &BTreeSet, +) -> BTreeSet { + let upstream: BTreeMap<&str, Vec<&str>> = upstream_of(graph); + let mut reached: BTreeSet = 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 +} diff --git a/crates/sqlbuild-analysis/src/review_command/_helpers/json.rs b/crates/sqlbuild-analysis/src/review_command/_helpers/json.rs new file mode 100644 index 0000000000..fc8c1d6425 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/_helpers/json.rs @@ -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::>(), + "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::>(), + "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::>(), + }) +} + +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(), + }) +} diff --git a/crates/sqlbuild-analysis/src/review_command/_helpers/markdown.rs b/crates/sqlbuild-analysis/src/review_command/_helpers/markdown.rs new file mode 100644 index 0000000000..fbf812e435 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/_helpers/markdown.rs @@ -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 = 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 = 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 = 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 { + 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 = 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 = 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
{}: {}\n\n{}{rows_text}{} compared with no difference ({} comparison).\n\n
\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 { + 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}") +} diff --git a/crates/sqlbuild-analysis/src/review_command/_helpers/mod.rs b/crates/sqlbuild-analysis/src/review_command/_helpers/mod.rs new file mode 100644 index 0000000000..24689f1b13 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/_helpers/mod.rs @@ -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; diff --git a/crates/sqlbuild-analysis/src/review_command/_helpers/outcome.rs b/crates/sqlbuild-analysis/src/review_command/_helpers/outcome.rs new file mode 100644 index 0000000000..fe3b918266 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/_helpers/outcome.rs @@ -0,0 +1,34 @@ +//! The status and exit code a report's evidence decides. + +use crate::review_command::constants::{ + FAILED_NODE, FAILED_STATUS, FAILURE_EXIT_CODE, FINDINGS_EXIT_CODE, FINDINGS_STATUS, + INCOMPLETE_EXIT_CODE, INCOMPLETE_STATUS, PASS_EXIT_CODE, PASSED_STATUS, PLANNED_STATUS, +}; +use crate::review_command::models::ReviewReport; + +/// Whether the run failed: an error, a failed build, or a review schema it could not drop. +pub(crate) fn failed(report: &ReviewReport) -> bool { + report.error.is_some() + || report.nodes.iter().any(|node| node.status == FAILED_NODE) + || report + .schema + .as_ref() + .is_some_and(|schema| schema.created && !schema.dropped) +} + +/// `(status, exit code)`: failure wins over findings, findings over incomplete coverage. +pub(crate) fn outcome(report: &ReviewReport) -> (&'static str, i32) { + if failed(report) { + return (FAILED_STATUS, FAILURE_EXIT_CODE); + } + if report.dry_run { + return (PLANNED_STATUS, PASS_EXIT_CODE); + } + if !report.findings.is_empty() { + return (FINDINGS_STATUS, FINDINGS_EXIT_CODE); + } + if !report.incomplete.is_empty() { + return (INCOMPLETE_STATUS, INCOMPLETE_EXIT_CODE); + } + (PASSED_STATUS, PASS_EXIT_CODE) +} diff --git a/crates/sqlbuild-analysis/src/review_command/_helpers/scope.rs b/crates/sqlbuild-analysis/src/review_command/_helpers/scope.rs new file mode 100644 index 0000000000..e9c8ae7c3f --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/_helpers/scope.rs @@ -0,0 +1,284 @@ +//! The scope a downstream policy plans, the nodes a review cannot rebuild, and `--select` checks. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::review_command::_helpers::graph::{ancestors_within, descendants, upstream_of}; +use crate::review_command::_helpers::wording::policy_name; +use crate::review_command::constants::{ + OUT_OF_SCOPE_SELECTION_CODE, UNCHANGED_SELECTION_CODE, UNKNOWN_SELECTION_CODE, +}; +use crate::review_command::errors::ReviewPlanError; +use crate::review_command::models::{ + AddedNode, ChangedNode, DownstreamPolicy, NotValidated, ReviewGraph, SelectedToken, +}; + +/// What a change reaches, what of it a review can rebuild, and what the policy plans. +pub(crate) struct ReviewScope { + pub(crate) changed: BTreeSet, + pub(crate) affected: BTreeSet, + /// Affected SQL nodes reading an affected Python node, with that Python node. + pub(crate) readers: BTreeMap, + pub(crate) rebuildable: BTreeSet, + pub(crate) critical: BTreeSet, + /// The changed rebuildable nodes and the downstream the policy keeps. + pub(crate) planned: BTreeSet, +} + +/// The scope of `changed` over `graph` under `policy`. +pub(crate) fn review_scope( + graph: &ReviewGraph, + changed: &[ChangedNode], + policy: DownstreamPolicy, +) -> ReviewScope { + let changed: BTreeSet = changed + .iter() + .filter(|node| graph.nodes.contains_key(&node.name)) + .map(|node| node.name.clone()) + .collect(); + let affected: BTreeSet = descendants(graph, &changed); + let readers: BTreeMap = python_readers(graph, &affected); + let rebuildable: BTreeSet = affected + .iter() + .filter(|name| !readers.contains_key(*name)) + .filter(|name| graph.nodes.get(*name).is_some_and(|node| !node.python)) + .cloned() + .collect(); + let critical: BTreeSet = rebuildable + .iter() + .filter(|name| graph.nodes.get(*name).is_some_and(|node| node.critical)) + .cloned() + .collect(); + let changed_rebuildable: BTreeSet = + changed.intersection(&rebuildable).cloned().collect(); + let downstream: BTreeSet = rebuildable + .difference(&changed_rebuildable) + .cloned() + .collect(); + let mut planned: BTreeSet = changed_rebuildable.clone(); + planned.extend(policy_downstream( + graph, + policy, + (&changed_rebuildable, &downstream), + &critical, + )); + ReviewScope { + changed, + affected, + readers, + rebuildable, + critical, + planned, + } +} + +/// The planned nodes `select` keeps, or all of them without `--select`. +pub(crate) fn selected_scope( + (graph, policy): (&ReviewGraph, DownstreamPolicy), + scope: &ReviewScope, + select: Option<&[SelectedToken]>, +) -> Result, ReviewPlanError> { + let Some(tokens) = select else { + return Ok(scope.planned.clone()); + }; + let mut selected: BTreeSet = BTreeSet::new(); + for token in tokens { + check_token(token, (graph, policy), (&scope.affected, &scope.planned))?; + selected.extend(token.names.iter().cloned()); + } + Ok(scope.planned.intersection(&selected).cloned().collect()) +} + +/// The validated nodes in dependency order, and the affected nodes left out with the reason. +pub(crate) fn split_affected( + (graph, policy): (&ReviewGraph, DownstreamPolicy), + scope: &ReviewScope, + validated: &BTreeSet, +) -> (Vec, Vec) { + let mut kept: Vec = Vec::new(); + let mut left_out: Vec = Vec::new(); + for name in &graph.order { + if validated.contains(name) { + kept.push(name.clone()); + } else if scope.affected.contains(name) { + left_out.push(NotValidated { + name: name.clone(), + reason: not_validated_reason( + graph, + name, + (&scope.changed, &scope.planned, &scope.readers), + policy, + ), + }); + } + } + (kept, left_out) +} + +/// `names` in dependency order. +pub(crate) fn in_order(graph: &ReviewGraph, names: &BTreeSet) -> Vec { + graph + .order + .iter() + .filter(|name| names.contains(*name)) + .cloned() + .collect() +} + +fn selection_error(code: &str, message: String, help: &str) -> ReviewPlanError { + ReviewPlanError { + code: code.to_owned(), + message, + help: Some(help.to_owned()), + } +} + +/// Affected SQL nodes reading an affected Python node, directly or not, with that Python node. +pub(crate) fn python_readers( + graph: &ReviewGraph, + affected: &BTreeSet, +) -> BTreeMap { + let upstream: BTreeMap<&str, Vec<&str>> = upstream_of(graph); + let mut readers: BTreeMap = BTreeMap::new(); + for name in graph.order.iter().filter(|name| affected.contains(*name)) { + let Some(node) = graph.nodes.get(name) else { + continue; + }; + if node.python { + continue; + } + let source: Option = upstream + .get(name.as_str()) + .into_iter() + .flatten() + .filter(|parent| affected.contains(**parent)) + .find_map(|parent| match graph.nodes.get(*parent) { + Some(upstream_node) if upstream_node.python => Some((*parent).to_owned()), + _ => readers.get(*parent).cloned(), + }); + if let Some(source) = source { + readers.insert(name.clone(), source); + } + } + readers +} + +/// The downstream nodes the policy validates, through rebuildable nodes only. +pub(crate) fn policy_downstream( + graph: &ReviewGraph, + policy: DownstreamPolicy, + (changed, downstream): (&BTreeSet, &BTreeSet), + critical: &BTreeSet, +) -> BTreeSet { + match policy { + DownstreamPolicy::None => BTreeSet::new(), + DownstreamPolicy::All => downstream.clone(), + DownstreamPolicy::Critical => { + let reachable: BTreeSet = changed.union(downstream).cloned().collect(); + ancestors_within(graph, critical, &reachable) + .into_iter() + .filter(|name| downstream.contains(name)) + .collect() + } + } +} + +/// Rebuildable nodes between a change and a validated node, each with the node that needs it. +pub(crate) fn required_upstream( + graph: &ReviewGraph, + validated: &BTreeSet, + rebuildable: &BTreeSet, +) -> Vec { + let mut added: Vec = Vec::new(); + for name in graph.order.iter().filter(|name| validated.contains(*name)) { + let single: BTreeSet = BTreeSet::from([name.clone()]); + for upstream in ancestors_within(graph, &single, rebuildable) { + if !validated.contains(&upstream) && !added.iter().any(|node| node.name == upstream) { + added.push(AddedNode { + name: upstream, + required_by: name.clone(), + }); + } + } + } + let position: BTreeMap<&str, usize> = graph + .order + .iter() + .enumerate() + .map(|(index, name)| (name.as_str(), index)) + .collect(); + added.sort_by_key(|node| position.get(node.name.as_str()).copied()); + added +} + +/// Reject a token that matches nothing, only unchanged nodes, or only nodes outside the scope. +pub(crate) fn check_token( + token: &SelectedToken, + (graph, policy): (&ReviewGraph, DownstreamPolicy), + (affected, scope): (&BTreeSet, &BTreeSet), +) -> Result<(), ReviewPlanError> { + let known: Vec<&String> = token + .names + .iter() + .filter(|name| graph.nodes.contains_key(*name)) + .collect(); + if known.is_empty() { + return Err(selection_error( + UNKNOWN_SELECTION_CODE, + format!("--select {} matches no model or seed", token.token), + "select a changed model or seed by name, or a selector such as stg_orders+", + )); + } + if known.iter().any(|name| scope.contains(*name)) { + return Ok(()); + } + if known.iter().any(|name| affected.contains(*name)) { + return Err(selection_error( + OUT_OF_SCOPE_SELECTION_CODE, + format!( + "--select {} matches only downstream nodes that --downstream {} leaves out or \ + that read Python nodes", + token.token, + policy_name(policy) + ), + "pass --downstream all to review every downstream SQL node", + )); + } + Err(selection_error( + UNCHANGED_SELECTION_CODE, + format!( + "--select {} matches only nodes that did not change and are not downstream of a change", + token.token + ), + "a review validates changed nodes and their downstream; run sqb build for other nodes", + )) +} + +/// Why an affected node is left out of the review. +pub(crate) fn not_validated_reason( + graph: &ReviewGraph, + name: &str, + (changed, scope, readers): ( + &BTreeSet, + &BTreeSet, + &BTreeMap, + ), + policy: DownstreamPolicy, +) -> String { + if let Some(node) = graph.nodes.get(name).filter(|node| node.python) { + return format!("Python {}, not run by review", node.kind); + } + if let Some(python) = readers.get(name) { + return format!("reads Python node {python}, not rebuilt by review"); + } + if changed.contains(name) || scope.contains(name) { + return "not selected (--select)".to_owned(); + } + match policy { + DownstreamPolicy::Critical => { + "not on a path to a critical model (--downstream critical)".to_owned() + } + DownstreamPolicy::All | DownstreamPolicy::None => { + "downstream of a change (--downstream none)".to_owned() + } + } +} diff --git a/crates/sqlbuild-analysis/src/review_command/_helpers/sections.rs b/crates/sqlbuild-analysis/src/review_command/_helpers/sections.rs new file mode 100644 index 0000000000..ffe0f1b1e6 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/_helpers/sections.rs @@ -0,0 +1,452 @@ +//! The report sections the Markdown and terminal renderers share. + +use crate::review_command::constants::{CLONE_START, NO_COMPARISON_MODE}; +use std::collections::{BTreeMap, BTreeSet}; + +use crate::review_command::_helpers::outcome::outcome; +use crate::review_command::_helpers::wording::{counted, duration_text, joined, policy_name}; +use crate::review_command::constants::{ + FAILED_STATUS, FINDING_NODE, FINDINGS_STATUS, INCOMPLETE_STATUS, NOT_VALIDATED_NODE, + PASSED_STATUS, PLANNED_STATUS, +}; +use crate::review_command::models::{NodeReport, ReviewFinding, ReviewReport}; + +/// Whether names render as Markdown code spans. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Style { + pub(crate) markdown: bool, +} + +impl Style { + pub(crate) fn code(self, text: &str) -> String { + if self.markdown { + format!("`{}`", text.replace('`', "'")) + } else { + text.to_owned() + } + } +} + +/// `Review passed`, `Review needs a fix: 2 findings`, `Review incomplete`, `Review failed`. +pub(crate) fn headline(report: &ReviewReport) -> String { + let (status, _) = outcome(report); + match status { + PASSED_STATUS => "Review passed".to_owned(), + FINDINGS_STATUS => format!( + "Review needs a fix: {}", + counted(report.findings.len(), "finding", "findings") + ), + INCOMPLETE_STATUS => format!( + "Review incomplete: {}", + counted(report.incomplete.len(), "check", "checks") + ), + PLANNED_STATUS => "Review plan (dry run)".to_owned(), + FAILED_STATUS => "Review failed".to_owned(), + other => format!("Review {other}"), + } +} + +/// The one line under the title: refs, target, exit code, duration and version. +pub(crate) fn context_line(report: &ReviewReport, style: Style) -> String { + let (_, exit_code) = outcome(report); + let baseline = &report.baseline; + let short = |sha: &str| sha.chars().take(7).collect::(); + let mut parts: Vec = vec![format!( + "since {} ({})", + style.code(&baseline.since_ref), + short(&baseline.merge_base) + )]; + if !baseline.head.is_empty() { + parts.push(format!("head {}", short(&baseline.head))); + } + parts.push(format!("against {}", style.code(&baseline.against))); + parts.push(format!("exit {exit_code}")); + parts.push(duration_text(report.duration_seconds)); + parts.push(format!("sqlbuild {}", report.version)); + parts.join(" · ") +} + +pub(crate) fn built_count(report: &ReviewReport) -> usize { + report + .nodes + .iter() + .filter(|node| node.build_status.is_some()) + .count() +} + +pub(crate) fn compared_count(report: &ReviewReport) -> usize { + report + .nodes + .iter() + .filter(|node| { + node.comparison + .as_ref() + .is_some_and(|comparison| comparison.mode != NO_COMPARISON_MODE) + }) + .count() +} + +pub(crate) fn not_validated_count(report: &ReviewReport) -> usize { + report + .nodes + .iter() + .filter(|node| node.status == NOT_VALIDATED_NODE) + .count() +} + +/// `(label, count)` of the summary table, in column order. +pub(crate) fn summary_counts(report: &ReviewReport) -> Vec<(&'static str, usize)> { + vec![ + ( + "Changed", + report.plan.changed.len() + report.changed_declarations.len(), + ), + ("Built", built_count(report)), + ("Compared", compared_count(report)), + ("Tests", report.tests.selected), + ("Findings", report.findings.len()), + ("Not validated", not_validated_count(report)), + ("Incomplete", report.incomplete.len()), + ] +} + +/// `order_totals.amount: values differ in 2 of 5 rows (prod → branch), limit exact match`. +pub(crate) fn finding_text(finding: &ReviewFinding, against: &str, style: Style) -> String { + let subject: String = match &finding.column { + Some(column) => style.code(&format!("{}.{column}", finding.node)), + None => style.code(&finding.node), + }; + let values: String = match (&finding.target_value, &finding.branch_value) { + (Some(target), Some(branch)) => format!(": {against} {target} → branch {branch}"), + (Some(target), None) => format!(": {against} {target}"), + (None, Some(branch)) => format!(": branch {branch}"), + (None, None) => String::new(), + }; + format!( + "{subject} {}{values} (limit: {})", + finding.metric, finding.limit + ) +} + +/// `order_id=3: 12.5 → 13.0` lines of one finding. +pub(crate) fn example_lines(finding: &ReviewFinding, style: Style) -> Vec { + finding + .examples + .iter() + .map(|example| match (&example.target, &example.branch) { + (Some(target), Some(branch)) => format!( + "{}: {} → {}", + example.key, + style.code(target), + style.code(branch) + ), + (None, Some(branch)) => format!("{}: branch only {}", example.key, style.code(branch)), + (Some(target), None) => format!("{}: target only {}", example.key, style.code(target)), + (None, None) => example.key.clone(), + }) + .collect() +} + +fn node_status(node: &NodeReport) -> String { + let mut status: String = match node.status.as_str() { + NOT_VALIDATED_NODE => format!( + "not validated: {}", + node.reason.as_deref().unwrap_or("left out") + ), + other => match &node.reason { + Some(reason) => format!("{other}: {reason}"), + None => other.to_owned(), + }, + }; + if node.critical { + status.push_str(", critical"); + } + status +} + +fn node_line(node: &NodeReport, style: Style) -> String { + let mut line: String = format!("{} [{}]", style.code(&node.name), node_status(node)); + if !node.affected_columns.is_empty() { + let columns: Vec = node + .affected_columns + .iter() + .map(|column| style.code(column)) + .collect(); + line.push_str(&format!(" columns: {}", columns.join(", "))); + } + line +} + +/// One node waiting to be drawn in the impact tree, with the prefix its line starts with. +struct TreeEntry<'a> { + name: &'a str, + prefix: String, + last: bool, + is_root: bool, +} + +/// The impact tree, one line per node, from the changed roots downstream. +pub(crate) fn impact_tree(report: &ReviewReport, style: Style) -> Vec { + let nodes: BTreeMap<&str, &NodeReport> = report + .nodes + .iter() + .map(|node| (node.name.as_str(), node)) + .collect(); + let mut children: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); + let mut has_parent: BTreeSet<&str> = BTreeSet::new(); + for (upstream, downstream) in &report.impact_edges { + children + .entry(upstream.as_str()) + .or_default() + .push(downstream.as_str()); + has_parent.insert(downstream.as_str()); + } + let order: BTreeMap<&str, usize> = report + .nodes + .iter() + .enumerate() + .map(|(index, node)| (node.name.as_str(), index)) + .collect(); + for list in children.values_mut() { + list.sort_by_key(|name| order.get(name).copied().unwrap_or(usize::MAX)); + } + let mut lines: Vec = Vec::new(); + let mut shown: BTreeSet<&str> = BTreeSet::new(); + let roots: Vec<&NodeReport> = report + .nodes + .iter() + .filter(|node| node.changed || !has_parent.contains(node.name.as_str())) + .collect(); + for root in roots { + if shown.contains(root.name.as_str()) { + continue; + } + let mut stack: Vec> = vec![TreeEntry { + name: &root.name, + prefix: String::new(), + last: true, + is_root: true, + }]; + while let Some(TreeEntry { + name, + prefix, + last, + is_root, + }) = stack.pop() + { + let Some(node) = nodes.get(name) else { + continue; + }; + let branch: &str = if is_root { + "" + } else if last { + "└── " + } else { + "├── " + }; + if !shown.insert(name) { + lines.push(format!( + "{prefix}{branch}{} (shown above)", + style.code(name) + )); + continue; + } + lines.push(format!("{prefix}{branch}{}", node_line(node, style))); + let child_prefix: String = if is_root { + String::new() + } else if last { + format!("{prefix} ") + } else { + format!("{prefix}│ ") + }; + let next: &[&str] = children.get(name).map_or(&[], Vec::as_slice); + for (index, child) in next.iter().enumerate().rev() { + stack.push(TreeEntry { + name: child, + prefix: child_prefix.clone(), + last: index + 1 == next.len(), + is_root: false, + }); + } + } + } + lines +} + +/// `stg_orders (model): model SQL changed` lines. +pub(crate) fn changed_lines(report: &ReviewReport, style: Style) -> Vec { + let mut lines: Vec = report + .plan + .changed + .iter() + .map(|node| { + format!( + "{} ({}): {}", + style.code(&node.name), + node.kind, + joined(&node.reasons) + ) + }) + .collect(); + for (kind, name, reason) in &report.changed_declarations { + lines.push(format!("{kind} {}: {reason}", style.code(name))); + } + lines +} + +pub(crate) fn incomplete_lines(report: &ReviewReport, style: Style) -> Vec { + report + .incomplete + .iter() + .map(|check| { + format!( + "{} {}: {}", + style.code(&check.node), + check.check, + check.reason + ) + }) + .collect() +} + +pub(crate) fn baseline_lines(report: &ReviewReport, style: Style) -> Vec { + let against: String = style.code(&report.baseline.against); + let mut lines: Vec = Vec::new(); + if report.deferred.is_empty() { + lines.push(format!("Compared with {against}; no upstream read from it")); + } else { + let deferred: Vec = report + .deferred + .iter() + .map(|name| style.code(name)) + .collect(); + lines.push(format!( + "Unchanged upstream read from {against}: {}", + deferred.join(", ") + )); + } + for start in &report.incremental_starts { + let method: &str = if start.method == CLONE_START { + "cloned from the target" + } else { + "full refresh" + }; + lines.push(format!( + "Incremental {}: {method} ({})", + style.code(&start.node), + start.reason + )); + } + let new_nodes: Vec = report + .nodes + .iter() + .filter(|node| { + node.comparison.as_ref().is_some_and(|comparison| { + comparison.mode == NO_COMPARISON_MODE && comparison.target_relation.is_none() + }) + }) + .map(|node| style.code(&node.name)) + .collect(); + if !new_nodes.is_empty() { + lines.push(format!( + "Not in {against}, so built but not compared: {}", + new_nodes.join(", ") + )); + } + lines +} + +pub(crate) fn tests_lines(report: &ReviewReport) -> Vec { + let tests = &report.tests; + let audits = &report.audits; + let mut lines: Vec = vec![ + format!( + "Unit tests: {} of {} ran ({} unaffected skipped): {} passed, {} failed", + tests.selected, + tests.total, + tests.total.saturating_sub(tests.selected), + tests.passed, + tests.failed + ), + format!( + "Audits: {} ran: {} passed, {} failed", + audits.selected, audits.passed, audits.failed + ), + ]; + for failure in tests.failures.iter().chain(&audits.failures) { + lines.push(format!("failed: {failure}")); + } + lines +} + +pub(crate) fn run_lines(report: &ReviewReport, style: Style) -> Vec { + let mut lines: Vec = Vec::new(); + if let Some(schema) = &report.schema { + let qualified: String = match &schema.database { + Some(database) => format!("{database}.{}", schema.name), + None => schema.name.clone(), + }; + let state: String = match (&schema.error, schema.created, schema.dropped) { + (Some(error), _, _) => format!("not dropped: {error}"), + (None, false, _) => "not created".to_owned(), + (None, true, true) => "dropped".to_owned(), + (None, true, false) => "not dropped".to_owned(), + }; + lines.push(format!("Review schema {}: {state}", style.code(&qualified))); + } + let mut policy: String = format!("Downstream policy: {}", policy_name(report.plan.policy)); + if !report.plan.critical.is_empty() { + let critical: Vec = report + .plan + .critical + .iter() + .map(|name| style.code(name)) + .collect(); + policy.push_str(&format!(" (critical: {})", critical.join(", "))); + } + lines.push(policy); + for added in &report.plan.added { + lines.push(format!( + "Also built: {}, because {} depends on it", + style.code(&added.name), + style.code(&added.required_by) + )); + } + if !report.plan.hooks.is_empty() { + let hooked: Vec = report + .plan + .hooks + .iter() + .map(|name| style.code(name)) + .collect(); + lines.push(format!( + "Hooks run against the review schema: {}", + hooked.join(", ") + )); + } + if let Some(id) = &report.invocation_id { + lines.push(format!( + "Recall: {}", + style.code(&format!("sqb recall output {id}")) + )); + } + if let Some(error) = &report.error { + let mut line: String = format!("Error [{}]: {}", error.code, error.message); + if let Some(help) = &error.help { + line.push_str(&format!(" ({help})")); + } + lines.push(line); + } + lines +} + +/// Whether a node has comparison evidence worth a per-model section. +pub(crate) fn has_evidence(node: &NodeReport) -> bool { + node.status == FINDING_NODE + || node.comparison.as_ref().is_some_and(|comparison| { + !comparison.columns.is_empty() + || !comparison.added_columns.is_empty() + || !comparison.removed_columns.is_empty() + || !comparison.type_changes.is_empty() + }) +} diff --git a/crates/sqlbuild-analysis/src/review_command/_helpers/text.rs b/crates/sqlbuild-analysis/src/review_command/_helpers/text.rs new file mode 100644 index 0000000000..1701666daf --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/_helpers/text.rs @@ -0,0 +1,45 @@ +//! The blocks of the terminal review summary. + +use crate::review_command::_helpers::sections::{ + Style, example_lines, finding_text, summary_counts, +}; +use crate::review_command::models::ReviewReport; + +pub(crate) const PLAIN: Style = Style { markdown: false }; + +/// A titled block of indented lines, or nothing when there are none. +pub(crate) fn block(title: &str, lines: &[String]) -> String { + if lines.is_empty() { + return String::new(); + } + let body: String = lines.iter().map(|line| format!(" {line}\n")).collect(); + format!("\n{title}\n{body}") +} + +/// The numbered findings, each followed by its example keys. +pub(crate) fn finding_lines(report: &ReviewReport) -> Vec { + let against: &str = &report.baseline.against; + let mut lines: Vec = Vec::new(); + for (index, finding) in report.findings.iter().enumerate() { + lines.push(format!( + "{}. {}", + index + 1, + finding_text(finding, against, PLAIN) + )); + lines.extend( + example_lines(finding, PLAIN) + .into_iter() + .map(|example| format!(" {example}")), + ); + } + lines +} + +/// `label=count` pairs of the summary counts. +pub(crate) fn count_pairs(report: &ReviewReport) -> String { + summary_counts(report) + .iter() + .map(|(label, count)| format!("{}={count}", label.to_ascii_lowercase().replace(' ', "_"))) + .collect::>() + .join(" ") +} diff --git a/crates/sqlbuild-analysis/src/review_command/_helpers/wording.rs b/crates/sqlbuild-analysis/src/review_command/_helpers/wording.rs new file mode 100644 index 0000000000..05f67bed49 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/_helpers/wording.rs @@ -0,0 +1,45 @@ +//! Words the review renders for policies, statuses and counts. + +use crate::review_command::constants::{ + ALL_POLICY, CRITICAL_POLICY, NONE_POLICY, SECONDS_PER_MINUTE, +}; +use crate::review_command::models::DownstreamPolicy; + +pub(crate) fn policy_name(policy: DownstreamPolicy) -> &'static str { + match policy { + DownstreamPolicy::Critical => CRITICAL_POLICY, + DownstreamPolicy::All => ALL_POLICY, + DownstreamPolicy::None => NONE_POLICY, + } +} + +/// `1 model`, `2 models`. +pub(crate) fn counted(count: usize, singular: &str, plural: &str) -> String { + if count == 1 { + format!("1 {singular}") + } else { + format!("{count} {plural}") + } +} + +/// `3.25s` or `2m 05s`. +pub(crate) fn duration_text(seconds: f64) -> String { + let whole: u64 = seconds.round() as u64; + if whole < SECONDS_PER_MINUTE { + return format!("{seconds:.2}s"); + } + format!( + "{}m {:02}s", + whole / SECONDS_PER_MINUTE, + whole % SECONDS_PER_MINUTE + ) +} + +/// `a`, `a and b`, `a, b and c`. +pub(crate) fn joined(words: &[String]) -> String { + match words { + [] => String::new(), + [only] => only.clone(), + [init @ .., last] => format!("{} and {last}", init.join(", ")), + } +} diff --git a/crates/sqlbuild-analysis/src/review_command/constants.rs b/crates/sqlbuild-analysis/src/review_command/constants.rs new file mode 100644 index 0000000000..688eb2d1de --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/constants.rs @@ -0,0 +1,90 @@ +//! Report format, statuses, exit codes, error codes and review schema naming. + +/// The version of the review JSON document; additive fields keep it, breaking changes bump it. +pub const REVIEW_REPORT_FORMAT_VERSION: u32 = 1; + +pub const MODEL_KIND: &str = "model"; +pub const SEED_KIND: &str = "seed"; + +pub const CRITICAL_POLICY: &str = "critical"; +pub const ALL_POLICY: &str = "all"; +pub const NONE_POLICY: &str = "none"; + +pub const PASSED_STATUS: &str = "passed"; +pub const FINDINGS_STATUS: &str = "findings"; +pub const INCOMPLETE_STATUS: &str = "incomplete"; +pub const FAILED_STATUS: &str = "failed"; +pub const PLANNED_STATUS: &str = "planned"; + +pub const PASS_EXIT_CODE: i32 = 0; +pub const FINDINGS_EXIT_CODE: i32 = 1; +pub const INCOMPLETE_EXIT_CODE: i32 = 2; +pub const FAILURE_EXIT_CODE: i32 = 3; + +/// How a model was compared: not at all, schema only, every row, or a recent cursor window. +pub const NO_COMPARISON_MODE: &str = "none"; +pub const SCHEMA_ONLY_MODE: &str = "schema_only"; +pub const FULL_COMPARISON_MODE: &str = "full"; +pub const BOUNDED_COMPARISON_MODE: &str = "bounded"; + +/// How a changed incremental model or snapshot starts in the review schema. +pub const CLONE_START: &str = "clone"; +pub const FULL_REFRESH_START: &str = "full_refresh"; + +/// Durations shorter than this print as seconds, longer ones as minutes and seconds. +pub const SECONDS_PER_MINUTE: u64 = 60; + +/// Node statuses in the impact tree. +pub const BUILT_NODE: &str = "built"; +pub const OK_NODE: &str = "ok"; +pub const FINDING_NODE: &str = "finding"; +pub const FAILED_NODE: &str = "failed"; +pub const NOT_VALIDATED_NODE: &str = "not_validated"; + +/// A `--select` token that matched no project node. +pub const UNKNOWN_SELECTION_CODE: &str = "C761"; +/// A `--select` token that matched only unchanged nodes. +pub const UNCHANGED_SELECTION_CODE: &str = "C762"; +/// A `--select` token outside the downstream policy. +pub const OUT_OF_SCOPE_SELECTION_CODE: &str = "C763"; +/// `--against` holds none of the relations the review compares. +pub const MISSING_AGAINST_CODE: &str = "C774"; +/// The `--markdown` report could not be written. +pub const MARKDOWN_WRITE_CODE: &str = "C775"; +/// A node sets its own database and the review database is unknown. +pub const UNKNOWN_REVIEW_DATABASE_CODE: &str = "C776"; +/// A command-line usage error, such as an unknown option or an invalid choice. +pub const USAGE_ERROR_CODE: &str = "C900"; +/// The parser `prog` of `sqb review`, and the option that makes it print JSON. +pub const REVIEW_PROG: &str = "sqb review"; +pub const JSON_OPTION: &str = "--json"; + +/// The default `[review] schema_prefix`. +pub const DEFAULT_SCHEMA_PREFIX: &str = "review"; +/// The default `[review] critical_tags`. +pub const DEFAULT_CRITICAL_TAG: &str = "critical"; +/// Hex digits of the commit and of the invocation in a review schema name. +pub const SCHEMA_COMMIT_DIGITS: usize = 7; +pub const SCHEMA_RUN_DIGITS: usize = 6; +/// Characters of the `YYYYMMDDTHHMMSSZ` timestamp in a review schema name. +pub const SCHEMA_TIMESTAMP_LENGTH: usize = 16; +/// The largest month, day, hour, minute and second a review schema timestamp accepts. +pub const LAST_MONTH: u32 = 12; +pub const LAST_DAY: u32 = 31; +pub const LAST_HOUR: u32 = 23; +pub const LAST_MINUTE: u32 = 59; +pub const LAST_SECOND: u32 = 59; +/// How old a marked review schema must be before the janitor drops it, in days. +pub const LEFTOVER_REVIEW_SCHEMA_AGE_DAYS: i64 = 1; + +/// Finding kinds of failed checks, and the limit they missed. +pub const AUDIT_FINDING: &str = "audit"; +pub const TEST_FINDING: &str = "test"; +pub const PASSES_LIMIT: &str = "passes"; +/// The prefix of a failed unit test in the test counts. +pub const UNIT_TEST_LABEL: &str = "unit test "; + +/// The comparison limit of the minimum review: any difference is a finding. +pub const EXACT_LIMIT: &str = "exact match"; +/// Example keys shown per finding. +pub const MAX_FINDING_EXAMPLES: usize = 3; diff --git a/crates/sqlbuild-analysis/src/review_command/errors.rs b/crates/sqlbuild-analysis/src/review_command/errors.rs new file mode 100644 index 0000000000..ec9ae7f7db --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/errors.rs @@ -0,0 +1,9 @@ +//! The user errors that stop review planning. + +/// A user error that stops planning. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ReviewPlanError { + pub code: String, + pub message: String, + pub help: Option, +} diff --git a/crates/sqlbuild-analysis/src/review_command/main/changed_declarations.rs b/crates/sqlbuild-analysis/src/review_command/main/changed_declarations.rs new file mode 100644 index 0000000000..a1487d4132 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/main/changed_declarations.rs @@ -0,0 +1,27 @@ +//! The changed nodes a review does not build, such as macros, sources and settings. + +use crate::definition_changes::main::kind_label::kind_label; +use crate::definition_changes::models::DefinitionChanges; +use crate::review_command::constants::{MODEL_KIND, SEED_KIND}; + +/// `(kind, name, detail)` of every changed node other than a model or seed. +#[must_use] +pub fn changed_declarations(changes: &DefinitionChanges) -> Vec<(String, String, String)> { + changes + .changed + .values() + .filter(|change| change.kind != MODEL_KIND && change.kind != SEED_KIND) + .map(|change| { + let detail: String = if change.added { + "added".to_owned() + } else { + "changed".to_owned() + }; + ( + kind_label(&change.kind).to_owned(), + change.name.clone(), + detail, + ) + }) + .collect() +} diff --git a/crates/sqlbuild-analysis/src/review_command/main/downstream_policy_name.rs b/crates/sqlbuild-analysis/src/review_command/main/downstream_policy_name.rs new file mode 100644 index 0000000000..e847674bc5 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/main/downstream_policy_name.rs @@ -0,0 +1,10 @@ +//! The name a `--downstream` policy is written with. + +use crate::review_command::_helpers::wording::policy_name; +use crate::review_command::models::DownstreamPolicy; + +/// `critical`, `all` or `none`. +#[must_use] +pub fn downstream_policy_name(policy: DownstreamPolicy) -> &'static str { + policy_name(policy) +} diff --git a/crates/sqlbuild-analysis/src/review_command/main/mod.rs b/crates/sqlbuild-analysis/src/review_command/main/mod.rs new file mode 100644 index 0000000000..25f16188a8 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/main/mod.rs @@ -0,0 +1,11 @@ +pub mod changed_declarations; +pub mod downstream_policy_name; +pub mod node_change_reasons; +pub mod parse_downstream_policy; +pub mod parse_review_schema_name; +pub mod plan_review; +pub mod render_review_json; +pub mod render_review_markdown; +pub mod render_review_text; +pub mod review_outcome; +pub mod review_schema_name; diff --git a/crates/sqlbuild-analysis/src/review_command/main/node_change_reasons.rs b/crates/sqlbuild-analysis/src/review_command/main/node_change_reasons.rs new file mode 100644 index 0000000000..371afb342d --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/main/node_change_reasons.rs @@ -0,0 +1,24 @@ +//! Why one model or seed counts as changed since the base revision. + +use crate::definition_changes::main::selection_reasons::selection_reasons; +use crate::definition_changes::models::{DefinitionChanges, DefinitionSnapshot, ExecutedItem}; + +/// Why `key` changed: its definition, a declaration it uses, or a source or function it reads. +#[must_use] +pub fn node_change_reasons( + key: &str, + (reads, project_wide): (&[String], &[String]), + (current, changes): (&DefinitionSnapshot, &DefinitionChanges), +) -> Vec { + let mut declarations: Vec = project_wide.to_vec(); + if let Some(node) = current.nodes.get(key) { + declarations.extend(node.uses.iter().cloned()); + } + let item: ExecutedItem = ExecutedItem { + own_keys: vec![key.to_owned()], + executed: reads.to_vec(), + declarations, + ..ExecutedItem::default() + }; + selection_reasons(&item, current, changes) +} diff --git a/crates/sqlbuild-analysis/src/review_command/main/parse_downstream_policy.rs b/crates/sqlbuild-analysis/src/review_command/main/parse_downstream_policy.rs new file mode 100644 index 0000000000..9d8538cef8 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/main/parse_downstream_policy.rs @@ -0,0 +1,15 @@ +//! Read a `--downstream` or `[review] downstream` value. + +use crate::review_command::constants::{ALL_POLICY, CRITICAL_POLICY, NONE_POLICY}; +use crate::review_command::models::DownstreamPolicy; + +/// The policy `value` names, or `None` for an unknown value. +#[must_use] +pub fn parse_downstream_policy(value: &str) -> Option { + match value { + CRITICAL_POLICY => Some(DownstreamPolicy::Critical), + ALL_POLICY => Some(DownstreamPolicy::All), + NONE_POLICY => Some(DownstreamPolicy::None), + _ => None, + } +} diff --git a/crates/sqlbuild-analysis/src/review_command/main/parse_review_schema_name.rs b/crates/sqlbuild-analysis/src/review_command/main/parse_review_schema_name.rs new file mode 100644 index 0000000000..c6b3655b68 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/main/parse_review_schema_name.rs @@ -0,0 +1,55 @@ +//! The strict review schema name format. + +use crate::janitor_planning::models::ArchiveTimestamp; +use crate::review_command::constants::{ + LAST_DAY, LAST_HOUR, LAST_MINUTE, LAST_MONTH, LAST_SECOND, SCHEMA_COMMIT_DIGITS, + SCHEMA_RUN_DIGITS, SCHEMA_TIMESTAMP_LENGTH, +}; + +fn hex(text: &str, digits: usize) -> bool { + text.len() == digits && text.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +/// The decimal value of `bytes`, or `None` unless every byte is an ASCII digit. +fn decimal(bytes: &[u8]) -> Option { + let mut value: u32 = 0; + for byte in bytes { + if !byte.is_ascii_digit() { + return None; + } + value = value * 10 + u32::from(byte - b'0'); + } + Some(value) +} + +/// The `YYYYMMDDTHHMMSSZ` creation time of a review schema name, or `None` for any other name. +#[must_use] +pub fn parse_review_schema_name(name: &str) -> Option { + let mut parts = name.rsplitn(4, '_'); + let run: &str = parts.next()?; + let commit: &str = parts.next()?; + let timestamp: &str = parts.next()?; + let prefix: &str = parts.next()?; + if prefix.is_empty() || !hex(run, SCHEMA_RUN_DIGITS) || !hex(commit, SCHEMA_COMMIT_DIGITS) { + return None; + } + let upper: String = timestamp.to_ascii_uppercase(); + let bytes: &[u8] = upper.as_bytes(); + if bytes.len() != SCHEMA_TIMESTAMP_LENGTH || bytes[8] != b'T' || bytes[15] != b'Z' { + return None; + } + let parsed = ArchiveTimestamp { + year: decimal(&bytes[0..4])?, + month: decimal(&bytes[4..6])?, + day: decimal(&bytes[6..8])?, + hour: decimal(&bytes[9..11])?, + minute: decimal(&bytes[11..13])?, + second: decimal(&bytes[13..15])?, + }; + let valid: bool = (1..=LAST_MONTH).contains(&parsed.month) + && (1..=LAST_DAY).contains(&parsed.day) + && parsed.hour <= LAST_HOUR + && parsed.minute <= LAST_MINUTE + && parsed.second <= LAST_SECOND; + valid.then_some(parsed) +} diff --git a/crates/sqlbuild-analysis/src/review_command/main/plan_review.rs b/crates/sqlbuild-analysis/src/review_command/main/plan_review.rs new file mode 100644 index 0000000000..0974c5407e --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/main/plan_review.rs @@ -0,0 +1,39 @@ +//! What a review builds: the changed set, its downstream under the policy, and `--select`. + +use std::collections::BTreeSet; + +use crate::review_command::_helpers::scope::{ + ReviewScope, in_order, required_upstream, review_scope, selected_scope, split_affected, +}; +use crate::review_command::errors::ReviewPlanError; +use crate::review_command::models::{ + AddedNode, ChangedNode, DownstreamPolicy, ReviewGraph, ReviewPlan, SelectedToken, +}; + +/// Plan a review of `changed` over `graph`; `select` tokens intersect with the planned scope. +pub fn plan_review( + graph: &ReviewGraph, + changed: &[ChangedNode], + policy: DownstreamPolicy, + select: Option<&[SelectedToken]>, +) -> Result { + let scope: ReviewScope = review_scope(graph, changed, policy); + let mut validated_set: BTreeSet = selected_scope((graph, policy), &scope, select)?; + let added: Vec = required_upstream(graph, &validated_set, &scope.rebuildable); + validated_set.extend(added.iter().map(|node| node.name.clone())); + let (validated, not_validated) = split_affected((graph, policy), &scope, &validated_set); + let hooked: BTreeSet = validated_set + .iter() + .filter(|name| graph.nodes.get(*name).is_some_and(|node| node.has_hooks)) + .cloned() + .collect(); + Ok(ReviewPlan { + policy, + changed: changed.to_vec(), + validated, + not_validated, + critical: in_order(graph, &scope.critical), + added, + hooks: in_order(graph, &hooked), + }) +} diff --git a/crates/sqlbuild-analysis/src/review_command/main/render_review_json.rs b/crates/sqlbuild-analysis/src/review_command/main/render_review_json.rs new file mode 100644 index 0000000000..a07e6b9e3e --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/main/render_review_json.rs @@ -0,0 +1,97 @@ +//! The versioned review JSON document. + +use serde_json::{Value, json}; + +use crate::review_command::_helpers::json::{counts_json, finding_json, node_json, summary_json}; +use crate::review_command::_helpers::outcome::outcome; +use crate::review_command::_helpers::wording::policy_name; +use crate::review_command::constants::REVIEW_REPORT_FORMAT_VERSION; +use crate::review_command::models::ReviewReport; + +/// The review as one pretty-printed JSON document, ending in a newline. +#[must_use] +pub fn render_review_json(report: &ReviewReport) -> String { + let (status, exit_code) = outcome(report); + let baseline = &report.baseline; + let document: Value = json!({ + "format_version": REVIEW_REPORT_FORMAT_VERSION, + "command": "review", + "status": status, + "exit_code": exit_code, + "dry_run": report.dry_run, + "version": report.version, + "duration_seconds": (report.duration_seconds * 1000.0).round() / 1000.0, + "since": { + "ref": baseline.since_ref, + "merge_base": baseline.merge_base, + "head": baseline.head, + }, + "against": baseline.against, + "target": baseline.target, + "downstream": policy_name(report.plan.policy), + "summary": summary_json(report), + "findings": report.findings.iter().map(finding_json).collect::>(), + "changed": report.plan.changed.iter().map(|node| json!({ + "name": node.name, + "kind": node.kind, + "reasons": node.reasons, + })).chain(report.changed_declarations.iter().map(|(kind, name, reason)| json!({ + "name": name, + "kind": kind, + "reasons": [reason], + }))).collect::>(), + "changes": report.changes_json, + "critical": report.plan.critical, + "validated": report.plan.validated, + "not_validated": report.plan.not_validated.iter().map(|node| json!({ + "name": node.name, + "reason": node.reason, + })).collect::>(), + "added": report.plan.added.iter().map(|node| json!({ + "name": node.name, + "required_by": node.required_by, + })).collect::>(), + "hooks": report.plan.hooks, + "nodes": report.nodes.iter().map(node_json).collect::>(), + "impact_edges": report.impact_edges.iter().map(|(upstream, downstream)| json!({ + "upstream": upstream, + "downstream": downstream, + })).collect::>(), + "incomplete": report.incomplete.iter().map(|check| json!({ + "node": check.node, + "check": check.check, + "reason": check.reason, + })).collect::>(), + "baseline": { + "target": baseline.against, + "deferred": report.deferred, + "incremental_starts": report.incremental_starts.iter().map(|start| json!({ + "node": start.node, + "method": start.method, + "reason": start.reason, + })).collect::>(), + }, + "tests": counts_json(&report.tests), + "audits": counts_json(&report.audits), + "review_schema": report.schema.as_ref().map(|schema| json!({ + "database": schema.database, + "name": schema.name, + "created": schema.created, + "dropped": schema.dropped, + "error": schema.error, + })), + "recall": report.invocation_id.as_ref().map(|id| json!({ + "invocation_id": id, + "command": format!("sqb recall output {id}"), + })), + "error": report.error.as_ref().map(|error| json!({ + "code": error.code, + "message": error.message, + "help": error.help, + })), + }); + let mut text: String = + serde_json::to_string_pretty(&document).unwrap_or_else(|_| String::from("{}")); + text.push('\n'); + text +} diff --git a/crates/sqlbuild-analysis/src/review_command/main/render_review_markdown.rs b/crates/sqlbuild-analysis/src/review_command/main/render_review_markdown.rs new file mode 100644 index 0000000000..5c49f1304d --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/main/render_review_markdown.rs @@ -0,0 +1,53 @@ +//! The review as a Markdown document for a pull request. + +use crate::review_command::_helpers::markdown::{ + MARKDOWN, evidence, findings, impact, section, summary_table, +}; +use crate::review_command::_helpers::sections::{ + baseline_lines, changed_lines, context_line, has_evidence, headline, incomplete_lines, + run_lines, tests_lines, +}; +use crate::review_command::models::{NodeReport, ReviewReport}; + +/// The review as Markdown: outcome, summary, findings, changes, impact, coverage and run details. +#[must_use] +pub fn render_review_markdown(report: &ReviewReport) -> String { + let evidence_nodes: Vec<&NodeReport> = report + .nodes + .iter() + .filter(|node| has_evidence(node)) + .collect(); + [ + format!( + "# {}\n\n{}\n\n", + headline(report), + context_line(report, MARKDOWN) + ), + summary_table(report), + findings(report), + section( + "What changed", + &changed_lines(report, MARKDOWN), + "No definition changes.", + ), + impact(report), + section( + "Incomplete coverage", + &incomplete_lines(report, MARKDOWN), + "Every check completed.", + ), + section( + "Baseline", + &baseline_lines(report, MARKDOWN), + "No baseline.", + ), + section( + "Tests and audits", + &tests_lines(report), + "No tests or audits ran.", + ), + evidence(&evidence_nodes, &report.baseline.against), + section("Run details", &run_lines(report, MARKDOWN), ""), + ] + .concat() +} diff --git a/crates/sqlbuild-analysis/src/review_command/main/render_review_text.rs b/crates/sqlbuild-analysis/src/review_command/main/render_review_text.rs new file mode 100644 index 0000000000..88024f9250 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/main/render_review_text.rs @@ -0,0 +1,36 @@ +//! The review summary printed at the end of a terminal run. + +use crate::review_command::_helpers::sections::{ + baseline_lines, changed_lines, context_line, headline, impact_tree, incomplete_lines, + run_lines, tests_lines, +}; +use crate::review_command::_helpers::text::{PLAIN, block, count_pairs, finding_lines}; +use crate::review_command::models::ReviewReport; + +/// The plain-text review: findings and changes first, then impact, coverage, tests and run details. +#[must_use] +pub fn render_review_text(report: &ReviewReport) -> String { + let checks: [String; 2] = if report.dry_run { + [String::new(), String::new()] + } else { + [ + block("Baseline", &baseline_lines(report, PLAIN)), + block("Tests and audits", &tests_lines(report)), + ] + }; + [ + block("Findings", &finding_lines(report)), + block("What changed", &changed_lines(report, PLAIN)), + block("Downstream impact", &impact_tree(report, PLAIN)), + block("Incomplete coverage", &incomplete_lines(report, PLAIN)), + checks.concat(), + block("Run details", &run_lines(report, PLAIN)), + format!( + "\n{} {}\n{}\n", + headline(report), + count_pairs(report), + context_line(report, PLAIN) + ), + ] + .concat() +} diff --git a/crates/sqlbuild-analysis/src/review_command/main/review_outcome.rs b/crates/sqlbuild-analysis/src/review_command/main/review_outcome.rs new file mode 100644 index 0000000000..75b9f09d01 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/main/review_outcome.rs @@ -0,0 +1,10 @@ +//! The status and exit code of a review. + +use crate::review_command::_helpers::outcome::outcome; +use crate::review_command::models::ReviewReport; + +/// `(status, exit code)`: `failed` 3, `findings` 1, `incomplete` 2, `passed` or `planned` 0. +#[must_use] +pub fn review_outcome(report: &ReviewReport) -> (&'static str, i32) { + outcome(report) +} diff --git a/crates/sqlbuild-analysis/src/review_command/main/review_schema_name.rs b/crates/sqlbuild-analysis/src/review_command/main/review_schema_name.rs new file mode 100644 index 0000000000..ff52b2225d --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/main/review_schema_name.rs @@ -0,0 +1,25 @@ +//! The run-owned schema name of one review. + +use crate::review_command::constants::{SCHEMA_COMMIT_DIGITS, SCHEMA_RUN_DIGITS}; + +/// The first `digits` hex digits of `text`, lowercase and left-padded with zeros. +fn hex_part(text: &str, digits: usize) -> String { + let hex: String = text + .chars() + .filter(char::is_ascii_hexdigit) + .take(digits) + .collect::() + .to_ascii_lowercase(); + format!("{hex:0>digits$}") +} + +/// `___` in lowercase, as `parse_review_schema_name` reads. +#[must_use] +pub fn review_schema_name(prefix: &str, timestamp: &str, commit: &str, run_id: &str) -> String { + format!( + "{prefix}_{timestamp}_{}_{}", + hex_part(commit, SCHEMA_COMMIT_DIGITS), + hex_part(run_id, SCHEMA_RUN_DIGITS) + ) + .to_ascii_lowercase() +} diff --git a/crates/sqlbuild-analysis/src/review_command/mod.rs b/crates/sqlbuild-analysis/src/review_command/mod.rs new file mode 100644 index 0000000000..613e94a7b9 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/mod.rs @@ -0,0 +1,10 @@ +//! `sqb review`: plan what a branch changed, and render what building and comparing it found. + +mod _helpers; +pub mod constants; +pub mod errors; +pub mod main; +pub mod models; + +#[cfg(test)] +mod tests; diff --git a/crates/sqlbuild-analysis/src/review_command/models.rs b/crates/sqlbuild-analysis/src/review_command/models.rs new file mode 100644 index 0000000000..07af07a94b --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/models.rs @@ -0,0 +1,242 @@ +//! The review plan, the evidence a review collects, and the report it renders. + +use std::collections::BTreeMap; + +/// Which downstream nodes of the changed set a review builds and compares. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum DownstreamPolicy { + /// Every downstream node on a path to a critical model. + #[default] + Critical, + /// Every downstream node. + All, + /// No downstream node. + None, +} + +/// One project node: a model or seed a review can build, or a Python node it cannot. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ReviewNode { + pub name: String, + /// `model` or `seed`, or the Python node kind: `loader`, `task`, `asset` or `check`. + pub kind: String, + pub critical: bool, + pub materialized: String, + pub unique_key: Vec, + pub has_cursor: bool, + /// Whether the node is a Python node, which a review never runs. + pub python: bool, + /// Whether the node declares pre- or post-hooks. + pub has_hooks: bool, +} + +/// The project graph a review plans over: models, seeds and Python nodes. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ReviewGraph { + pub nodes: BTreeMap, + /// Direct downstream nodes by node name. + pub downstream: BTreeMap>, + /// Every node name in dependency order. + pub order: Vec, +} + +/// A changed buildable node and why it counts as changed. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ChangedNode { + pub name: String, + pub kind: String, + pub reasons: Vec, + /// Whether the node is new since the base revision. + pub added: bool, +} + +/// One `--select` token and the node names it resolved to. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SelectedToken { + pub token: String, + pub names: Vec, +} + +/// A changed or downstream node the review does not build, and why. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct NotValidated { + pub name: String, + pub reason: String, +} + +/// A node built only because a validated node reads it, and the first such node. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AddedNode { + pub name: String, + pub required_by: String, +} + +/// What a review builds and compares, and what it leaves out. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ReviewPlan { + pub policy: DownstreamPolicy, + pub changed: Vec, + /// Nodes to build, in dependency order. + pub validated: Vec, + /// Changed or downstream nodes left out, in dependency order. + pub not_validated: Vec, + /// Critical nodes the changed set reaches. + pub critical: Vec, + /// Nodes outside the selection or policy built because a validated node depends on them. + pub added: Vec, + /// Validated nodes whose hooks run against the review schema. + pub hooks: Vec, +} + +/// One named key or cell value shown as an example. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ReviewExample { + pub key: String, + pub target: Option, + pub branch: Option, +} + +/// A difference between the branch and the target, or a failed test or audit. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ReviewFinding { + pub node: String, + /// `values`, `schema`, `row_count`, `keys`, `test` or `audit`. + pub kind: String, + pub metric: String, + pub column: Option, + pub target_value: Option, + pub branch_value: Option, + pub limit: String, + pub examples: Vec, +} + +/// A check the review could not complete for one node. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct IncompleteCheck { + pub node: String, + pub check: String, + pub reason: String, +} + +/// One column-level difference in a node's comparison evidence. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ColumnEvidence { + pub column: String, + pub metric: String, + pub target: String, + pub branch: String, + pub limit: String, +} + +/// How one node compared with the target. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct NodeComparison { + /// `full`, `bounded`, `schema_only` or `none`. + pub mode: String, + pub target_relation: Option, + pub branch_relation: Option, + pub target_rows: Option, + pub branch_rows: Option, + pub compared_rows: Option, + pub unique_key: Vec, + pub added_columns: Vec, + pub removed_columns: Vec, + /// `(column, target type, branch type)`. + pub type_changes: Vec<(String, String, String)>, + pub branch_only_keys: i64, + pub target_only_keys: i64, + pub columns: Vec, + /// Columns compared without a difference. + pub unchanged_columns: usize, + /// Whether every check of this node ran to completion. + pub complete: bool, +} + +/// How a changed incremental model started in the review schema. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct IncrementalStart { + pub node: String, + /// `clone` or `full_refresh`. + pub method: String, + pub reason: String, +} + +/// Everything one review learned about one node. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct NodeReport { + pub name: String, + pub kind: String, + pub critical: bool, + pub changed: bool, + /// `built`, `ok`, `finding`, `failed` or `not_validated`. + pub status: String, + pub reason: Option, + pub build_status: Option, + pub comparison: Option, + pub affected_columns: Vec, +} + +/// Unit test and audit counts. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ValidationCounts { + pub selected: usize, + pub total: usize, + pub passed: usize, + pub failed: usize, + pub failures: Vec, +} + +/// The review schema and what happened to it. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ReviewSchemaReport { + pub database: Option, + pub name: String, + pub created: bool, + pub dropped: bool, + pub error: Option, +} + +/// The code, message and help of the error that ended a review early. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ReviewFailure { + pub code: String, + pub message: String, + pub help: Option, +} + +/// The git and target baselines of one review. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ReviewBaseline { + pub since_ref: String, + pub merge_base: String, + pub head: String, + pub against: String, + pub target: Option, +} + +/// Everything one `sqb review` run reports. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ReviewReport { + pub version: String, + pub baseline: ReviewBaseline, + pub dry_run: bool, + pub duration_seconds: f64, + pub plan: ReviewPlan, + /// The JSON of the definition changes since the base revision. + pub changes_json: Option, + /// Non-buildable changed nodes, such as macros and sources, as `(kind, name, reason)`. + pub changed_declarations: Vec<(String, String, String)>, + pub nodes: Vec, + /// `(upstream, downstream)` edges between the changed nodes and their downstream. + pub impact_edges: Vec<(String, String)>, + pub findings: Vec, + pub incomplete: Vec, + pub incremental_starts: Vec, + /// Upstream relations read from the target through deferral. + pub deferred: Vec, + pub tests: ValidationCounts, + pub audits: ValidationCounts, + pub schema: Option, + pub invocation_id: Option, + pub error: Option, +} diff --git a/crates/sqlbuild-analysis/src/review_command/tests.rs b/crates/sqlbuild-analysis/src/review_command/tests.rs new file mode 100644 index 0000000000..c5ad40874e --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/tests.rs @@ -0,0 +1,8 @@ +#[path = "tests/helpers.rs"] +mod helpers; +#[path = "tests/test_plan_review.rs"] +mod plan_review; +#[path = "tests/test_render_review.rs"] +mod render_review; +#[path = "tests/test_types.rs"] +mod test_types; diff --git a/crates/sqlbuild-analysis/src/review_command/tests/helpers.rs b/crates/sqlbuild-analysis/src/review_command/tests/helpers.rs new file mode 100644 index 0000000000..f6f2273e23 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/tests/helpers.rs @@ -0,0 +1,296 @@ +use std::collections::BTreeMap; + +use crate::review_command::models::{ + AddedNode, ChangedNode, ColumnEvidence, DownstreamPolicy, IncompleteCheck, IncrementalStart, + NodeComparison, NodeReport, ReviewBaseline, ReviewExample, ReviewFailure, ReviewFinding, + ReviewGraph, ReviewNode, ReviewPlan, ReviewReport, ReviewSchemaReport, SelectedToken, + ValidationCounts, +}; + +fn node(name: &str, kind: &str, critical: bool) -> (String, ReviewNode) { + ( + name.to_owned(), + ReviewNode { + name: name.to_owned(), + kind: kind.to_owned(), + critical, + materialized: "table".to_owned(), + unique_key: vec!["order_id".to_owned()], + has_hooks: name == "order_totals", + ..ReviewNode::default() + }, + ) +} + +fn python_node(name: &str, kind: &str) -> (String, ReviewNode) { + ( + name.to_owned(), + ReviewNode { + name: name.to_owned(), + kind: kind.to_owned(), + python: true, + ..ReviewNode::default() + }, + ) +} + +/// A waffle shop with a critical `daily_revenue`, a loader-fed `stg_toppings` and a Python task. +pub(super) fn waffle_shop() -> ReviewGraph { + let edges: [(&str, &str); 8] = [ + ("waffle_types", "stg_orders"), + ("stg_orders", "order_totals"), + ("order_totals", "daily_revenue"), + ("stg_orders", "order_archive"), + ("stg_customers", "customer_lifetime"), + ("daily_revenue", "task:export_revenue"), + ("loader:raw_toppings", "stg_toppings"), + ("stg_toppings", "topping_totals"), + ]; + let mut downstream: BTreeMap> = BTreeMap::new(); + for (upstream, child) in edges { + downstream + .entry(upstream.to_owned()) + .or_default() + .push(child.to_owned()); + } + ReviewGraph { + nodes: [ + node("waffle_types", "seed", false), + node("stg_orders", "model", false), + node("stg_customers", "model", false), + node("order_totals", "model", false), + node("order_archive", "model", false), + node("daily_revenue", "model", true), + node("customer_lifetime", "model", false), + python_node("task:export_revenue", "task"), + python_node("loader:raw_toppings", "loader"), + node("stg_toppings", "model", false), + node("topping_totals", "model", true), + ] + .into_iter() + .collect(), + downstream, + order: [ + "loader:raw_toppings", + "waffle_types", + "stg_customers", + "stg_orders", + "stg_toppings", + "customer_lifetime", + "order_archive", + "order_totals", + "topping_totals", + "daily_revenue", + "task:export_revenue", + ] + .iter() + .map(|name| (*name).to_owned()) + .collect(), + } +} + +pub(super) fn changed(name: &str, reason: &str) -> ChangedNode { + ChangedNode { + name: name.to_owned(), + kind: "model".to_owned(), + reasons: vec![reason.to_owned()], + added: false, + } +} + +pub(super) fn token(token: &str, names: &[&str]) -> SelectedToken { + SelectedToken { + token: token.to_owned(), + names: names.iter().map(|name| (*name).to_owned()).collect(), + } +} + +pub(super) fn names(values: &[&str]) -> Vec { + values.iter().map(|name| (*name).to_owned()).collect() +} + +fn reported(name: &str, status: &str, comparison: Option) -> NodeReport { + NodeReport { + name: name.to_owned(), + kind: "model".to_owned(), + critical: name == "daily_revenue", + changed: name == "stg_orders", + status: status.to_owned(), + reason: (status == "not_validated").then(|| "not selected (--select)".to_owned()), + build_status: (status != "not_validated").then(|| "success".to_owned()), + comparison, + affected_columns: Vec::new(), + } +} + +fn full(target_rows: i64, branch_rows: i64) -> NodeComparison { + NodeComparison { + mode: "full".to_owned(), + target_relation: Some("prod.order_totals".to_owned()), + branch_relation: Some("review_20261023t101500z_3f2a9c1_a1b2c3.order_totals".to_owned()), + target_rows: Some(target_rows), + branch_rows: Some(branch_rows), + compared_rows: Some(target_rows), + unique_key: vec!["order_id".to_owned()], + unchanged_columns: 2, + complete: true, + ..NodeComparison::default() + } +} + +/// A review of a `stg_orders` change whose `order_totals.amount` differs in one row. +pub(super) fn finding_report() -> ReviewReport { + let mut totals: NodeComparison = full(5, 5); + totals.columns.push(ColumnEvidence { + column: "amount".to_owned(), + metric: "rows differ".to_owned(), + target: "0".to_owned(), + branch: "1".to_owned(), + limit: "exact match".to_owned(), + }); + let mut revenue: NodeReport = reported("daily_revenue", "ok", Some(full(2, 2))); + revenue.affected_columns = vec!["total_revenue".to_owned()]; + ReviewReport { + version: "0.172.0".to_owned(), + baseline: ReviewBaseline { + since_ref: "origin/main".to_owned(), + merge_base: "3f2a9c1d00000000000000000000000000000000".to_owned(), + head: "a1b2c3d400000000000000000000000000000000".to_owned(), + against: "prod".to_owned(), + target: Some("dev".to_owned()), + }, + duration_seconds: 4.5, + plan: ReviewPlan { + policy: DownstreamPolicy::Critical, + changed: vec![changed("stg_orders", "model SQL changed")], + validated: names(&["stg_orders", "order_totals", "daily_revenue"]), + not_validated: Vec::new(), + critical: names(&["daily_revenue"]), + added: Vec::new(), + hooks: Vec::new(), + }, + changed_declarations: vec![( + "macro".to_owned(), + "cents_to_dollars".to_owned(), + "changed".to_owned(), + )], + nodes: vec![ + reported("stg_orders", "ok", Some(full(5, 5))), + reported("order_totals", "finding", Some(totals)), + revenue, + reported("order_archive", "not_validated", None), + ], + impact_edges: vec![ + ("stg_orders".to_owned(), "order_totals".to_owned()), + ("order_totals".to_owned(), "daily_revenue".to_owned()), + ("stg_orders".to_owned(), "order_archive".to_owned()), + ], + findings: vec![ReviewFinding { + node: "order_totals".to_owned(), + kind: "values".to_owned(), + metric: "values differ in 1 of 5 rows".to_owned(), + column: Some("amount".to_owned()), + target_value: None, + branch_value: None, + limit: "exact match".to_owned(), + examples: vec![ReviewExample { + key: "order_id=3".to_owned(), + target: Some("12.5".to_owned()), + branch: Some("13.0".to_owned()), + }], + }], + incremental_starts: vec![IncrementalStart { + node: "order_events".to_owned(), + method: "full_refresh".to_owned(), + reason: "the adapter has no zero-copy clone".to_owned(), + }], + deferred: names(&["waffle_types"]), + tests: ValidationCounts { + selected: 2, + total: 5, + passed: 2, + ..ValidationCounts::default() + }, + audits: ValidationCounts { + selected: 3, + total: 3, + passed: 3, + ..ValidationCounts::default() + }, + schema: Some(ReviewSchemaReport { + database: None, + name: "review_20261023t101500z_3f2a9c1_a1b2c3".to_owned(), + created: true, + dropped: true, + error: None, + }), + invocation_id: Some("a1b2c3d4e5f6".to_owned()), + ..ReviewReport::default() + } +} + +/// `finding_report` narrowed to `order_totals`, which added `stg_orders` and ran hooks. +pub(super) fn selected_report() -> ReviewReport { + let mut report: ReviewReport = finding_report(); + report.plan.added = vec![AddedNode { + name: "stg_orders".to_owned(), + required_by: "order_totals".to_owned(), + }]; + report.plan.hooks = names(&["order_totals"]); + report +} + +/// `finding_report` with no differences and one model left incomplete. +pub(super) fn incomplete_report() -> ReviewReport { + let mut report: ReviewReport = finding_report(); + report.findings.clear(); + report.nodes[1].status = "built".to_owned(); + report.nodes[1].comparison = None; + report.incomplete.push(IncompleteCheck { + node: "order_totals".to_owned(), + check: "keyed comparison".to_owned(), + reason: "the model has no unique_key".to_owned(), + }); + report +} + +/// The incomplete review whose schema could not be dropped. +pub(super) fn failed_report() -> ReviewReport { + let report: ReviewReport = incomplete_report(); + ReviewReport { + schema: report.schema.clone().map(|schema| ReviewSchemaReport { + dropped: false, + error: Some("permission denied".to_owned()), + ..schema + }), + ..report + } +} + +/// The incomplete review with every check completed. +pub(super) fn passed_report() -> ReviewReport { + ReviewReport { + incomplete: Vec::new(), + ..incomplete_report() + } +} + +/// The passing review as a dry run. +pub(super) fn dry_run_report() -> ReviewReport { + ReviewReport { + dry_run: true, + ..passed_report() + } +} + +/// The finding review ended by a `--select` error. +pub(super) fn errored_report() -> ReviewReport { + ReviewReport { + error: Some(ReviewFailure { + code: "C761".to_owned(), + message: "--select x matches no model or seed".to_owned(), + help: None, + }), + ..finding_report() + } +} diff --git a/crates/sqlbuild-analysis/src/review_command/tests/test_plan_review.rs b/crates/sqlbuild-analysis/src/review_command/tests/test_plan_review.rs new file mode 100644 index 0000000000..5ff3c3d7c7 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/tests/test_plan_review.rs @@ -0,0 +1,418 @@ +use crate::janitor_planning::models::ArchiveTimestamp; +use crate::review_command::main::parse_downstream_policy::parse_downstream_policy; +use crate::review_command::main::parse_review_schema_name::parse_review_schema_name; +use crate::review_command::main::plan_review::plan_review; +use crate::review_command::main::review_schema_name::review_schema_name; +use crate::review_command::models::{ChangedNode, DownstreamPolicy, ReviewPlan}; +use crate::review_command::tests::helpers::{changed, token, waffle_shop}; +use crate::review_command::tests::test_types::{ + HooksTestCase, PlanErrorTestCase, PlanTestCase, PolicyTestCase, SchemaNameBuildTestCase, + SchemaNameTestCase, +}; + +const EXPORT_TASK: (&str, &str) = ("task:export_revenue", "Python task, not run by review"); +const ARCHIVE_LEFT_OUT: (&str, &str) = ( + "order_archive", + "not on a path to a critical model (--downstream critical)", +); +const TOPPINGS_READER: &str = "reads Python node loader:raw_toppings, not rebuilt by review"; + +#[test] +fn given_changed_models_when_planning_then_validated_set_follows_policy_and_selection() { + let test_cases = [ + PlanTestCase { + description: "critical builds only the path to the critical model", + changed: vec!["stg_orders"], + policy: DownstreamPolicy::Critical, + select: None, + expected_validated: vec!["stg_orders", "order_totals", "daily_revenue"], + expected_not_validated: vec![ARCHIVE_LEFT_OUT, EXPORT_TASK], + expected_added: vec![], + }, + PlanTestCase { + description: "all builds every downstream SQL node and lists the Python node", + changed: vec!["stg_orders"], + policy: DownstreamPolicy::All, + select: None, + expected_validated: vec![ + "stg_orders", + "order_archive", + "order_totals", + "daily_revenue", + ], + expected_not_validated: vec![EXPORT_TASK], + expected_added: vec![], + }, + PlanTestCase { + description: "none builds only the changed model and lists its downstream", + changed: vec!["stg_orders"], + policy: DownstreamPolicy::None, + select: None, + expected_validated: vec!["stg_orders"], + expected_not_validated: vec![ + ( + "order_archive", + "downstream of a change (--downstream none)", + ), + ("order_totals", "downstream of a change (--downstream none)"), + ( + "daily_revenue", + "downstream of a change (--downstream none)", + ), + EXPORT_TASK, + ], + expected_added: vec![], + }, + PlanTestCase { + description: "a changed critical model needs no downstream path", + changed: vec!["daily_revenue"], + policy: DownstreamPolicy::Critical, + select: None, + expected_validated: vec!["daily_revenue"], + expected_not_validated: vec![EXPORT_TASK], + expected_added: vec![], + }, + PlanTestCase { + description: "a change with no critical downstream builds only itself", + changed: vec!["stg_customers"], + policy: DownstreamPolicy::Critical, + select: None, + expected_validated: vec!["stg_customers"], + expected_not_validated: vec![( + "customer_lifetime", + "not on a path to a critical model (--downstream critical)", + )], + expected_added: vec![], + }, + PlanTestCase { + description: "select intersects with the planned scope", + changed: vec!["stg_orders", "stg_customers"], + policy: DownstreamPolicy::Critical, + select: Some(vec![token("stg_orders", &["stg_orders"])]), + expected_validated: vec!["stg_orders"], + expected_not_validated: vec![ + ("stg_customers", "not selected (--select)"), + ( + "customer_lifetime", + "not on a path to a critical model (--downstream critical)", + ), + ARCHIVE_LEFT_OUT, + ("order_totals", "not selected (--select)"), + ("daily_revenue", "not selected (--select)"), + EXPORT_TASK, + ], + expected_added: vec![], + }, + PlanTestCase { + description: "a graph selector keeps only its nodes inside the scope", + changed: vec!["stg_orders"], + policy: DownstreamPolicy::Critical, + select: Some(vec![token( + "stg_orders+", + &[ + "stg_orders", + "order_totals", + "daily_revenue", + "order_archive", + ], + )]), + expected_validated: vec!["stg_orders", "order_totals", "daily_revenue"], + expected_not_validated: vec![ARCHIVE_LEFT_OUT, EXPORT_TASK], + expected_added: vec![], + }, + PlanTestCase { + description: "a selected downstream node adds its changed ancestor", + changed: vec!["stg_orders"], + policy: DownstreamPolicy::Critical, + select: Some(vec![token("order_totals", &["order_totals"])]), + expected_validated: vec!["stg_orders", "order_totals"], + expected_not_validated: vec![ + ARCHIVE_LEFT_OUT, + ("daily_revenue", "not selected (--select)"), + EXPORT_TASK, + ], + expected_added: vec![("stg_orders", "order_totals")], + }, + PlanTestCase { + description: "a selected critical node adds every node between it and the change", + changed: vec!["stg_orders"], + policy: DownstreamPolicy::Critical, + select: Some(vec![token("daily_revenue", &["daily_revenue"])]), + expected_validated: vec!["stg_orders", "order_totals", "daily_revenue"], + expected_not_validated: vec![ARCHIVE_LEFT_OUT, EXPORT_TASK], + expected_added: vec![ + ("stg_orders", "daily_revenue"), + ("order_totals", "daily_revenue"), + ], + }, + PlanTestCase { + description: "a changed loader leaves it and every SQL node reading it unvalidated", + changed: vec!["loader:raw_toppings"], + policy: DownstreamPolicy::Critical, + select: None, + expected_validated: vec![], + expected_not_validated: vec![ + ("loader:raw_toppings", "Python loader, not run by review"), + ("stg_toppings", TOPPINGS_READER), + ("topping_totals", TOPPINGS_READER), + ], + expected_added: vec![], + }, + PlanTestCase { + description: "an unchanged loader does not stop its readers being validated", + changed: vec!["stg_toppings"], + policy: DownstreamPolicy::Critical, + select: None, + expected_validated: vec!["stg_toppings", "topping_totals"], + expected_not_validated: vec![], + expected_added: vec![], + }, + ]; + for test_case in test_cases { + let changed_nodes: Vec = test_case + .changed + .iter() + .map(|name| changed(name, "model SQL changed")) + .collect(); + + let plan: ReviewPlan = plan_review( + &waffle_shop(), + &changed_nodes, + test_case.policy, + test_case.select.as_deref(), + ) + .expect(test_case.description); + + assert_eq!( + plan.validated, test_case.expected_validated, + "{}", + test_case.description + ); + let not_validated: Vec<(&str, &str)> = plan + .not_validated + .iter() + .map(|node| (node.name.as_str(), node.reason.as_str())) + .collect(); + assert_eq!( + not_validated, test_case.expected_not_validated, + "{}", + test_case.description + ); + let added: Vec<(&str, &str)> = plan + .added + .iter() + .map(|node| (node.name.as_str(), node.required_by.as_str())) + .collect(); + assert_eq!(added, test_case.expected_added, "{}", test_case.description); + } +} + +#[test] +fn given_validated_node_with_hooks_when_planning_then_plan_lists_it() { + let test_cases = [ + HooksTestCase { + description: "a validated model with hooks is listed", + changed: vec!["stg_orders"], + expected_hooks: vec!["order_totals"], + }, + HooksTestCase { + description: "a model with hooks the review leaves out is not listed", + changed: vec!["stg_customers"], + expected_hooks: vec![], + }, + ]; + for test_case in test_cases { + let changed_nodes: Vec = test_case + .changed + .iter() + .map(|name| changed(name, "model SQL changed")) + .collect(); + + let plan: ReviewPlan = plan_review( + &waffle_shop(), + &changed_nodes, + DownstreamPolicy::Critical, + None, + ) + .expect(test_case.description); + + assert_eq!( + plan.hooks, test_case.expected_hooks, + "{}", + test_case.description + ); + } +} + +#[test] +fn given_bad_selection_when_planning_then_fails_with_code() { + let test_cases = [ + PlanErrorTestCase { + description: "an unknown node", + policy: DownstreamPolicy::Critical, + select: vec![token("missing_model", &[])], + expected_code: "C761", + expected_fragment: "matches no model or seed", + }, + PlanErrorTestCase { + description: "an unchanged node", + policy: DownstreamPolicy::Critical, + select: vec![token("stg_customers", &["stg_customers"])], + expected_code: "C762", + expected_fragment: "did not change", + }, + PlanErrorTestCase { + description: "a downstream node outside the policy", + policy: DownstreamPolicy::Critical, + select: vec![token("order_archive", &["order_archive"])], + expected_code: "C763", + expected_fragment: "--downstream critical leaves out", + }, + PlanErrorTestCase { + description: "a node that reads a changed Python node", + policy: DownstreamPolicy::All, + select: vec![token("stg_toppings", &["stg_toppings"])], + expected_code: "C763", + expected_fragment: "read Python nodes", + }, + PlanErrorTestCase { + description: "any downstream node under none", + policy: DownstreamPolicy::None, + select: vec![token("order_totals", &["order_totals"])], + expected_code: "C763", + expected_fragment: "--downstream none leaves out", + }, + ]; + for test_case in test_cases { + let error = plan_review( + &waffle_shop(), + &[ + changed("stg_orders", "model SQL changed"), + changed("loader:raw_toppings", "changed"), + ], + test_case.policy, + Some(&test_case.select), + ) + .expect_err(test_case.description); + + assert_eq!( + error.code, test_case.expected_code, + "{}", + test_case.description + ); + assert!( + error.message.contains(test_case.expected_fragment), + "{}: {}", + test_case.description, + error.message + ); + } +} + +#[test] +fn given_policy_text_when_parsing_then_known_values_map() { + let test_cases = [ + PolicyTestCase { + description: "critical", + text: "critical", + expected_policy: Some(DownstreamPolicy::Critical), + }, + PolicyTestCase { + description: "all", + text: "all", + expected_policy: Some(DownstreamPolicy::All), + }, + PolicyTestCase { + description: "none", + text: "none", + expected_policy: Some(DownstreamPolicy::None), + }, + PolicyTestCase { + description: "an unknown policy", + text: "some", + expected_policy: None, + }, + ]; + for test_case in test_cases { + assert_eq!( + parse_downstream_policy(test_case.text), + test_case.expected_policy, + "{}", + test_case.description + ); + } +} + +#[test] +fn given_schema_name_parts_when_building_then_name_is_strict_and_lowercase_hex() { + let test_cases = [ + SchemaNameBuildTestCase { + description: "a long commit and run id are shortened, and the name is lowercase", + parts: ("REVIEW", "20261023T101500Z", "3F2A9C1D88", "a1b2c3d4e5f6"), + expected_name: "review_20261023t101500z_3f2a9c1_a1b2c3", + }, + SchemaNameBuildTestCase { + description: "a short commit and run id are zero-padded", + parts: ("Review", "20261023T101500Z", "", "1f"), + expected_name: "review_20261023t101500z_0000000_00001f", + }, + ]; + for test_case in test_cases { + let (prefix, timestamp, commit, run) = test_case.parts; + let name: String = review_schema_name(prefix, timestamp, commit, run); + + assert_eq!(name, test_case.expected_name, "{}", test_case.description); + assert!( + parse_review_schema_name(&name).is_some(), + "{}", + test_case.description + ); + } +} + +#[test] +fn given_schema_names_when_parsing_then_only_strict_names_parse() { + let created = ArchiveTimestamp { + year: 2026, + month: 10, + day: 23, + hour: 10, + minute: 15, + second: 0, + }; + let test_cases = [ + SchemaNameTestCase { + description: "a built name", + name: "REVIEW_20261023T101500Z_3f2a9c1_a1b2c3", + expected_timestamp: Some(created), + }, + SchemaNameTestCase { + description: "a folded name with an underscored prefix", + name: "ci_review_20261023t101500z_3f2a9c1_a1b2c3", + expected_timestamp: Some(created), + }, + SchemaNameTestCase { + description: "a short commit", + name: "REVIEW_20261023T101500Z_3f2a_a1b2c3", + expected_timestamp: None, + }, + SchemaNameTestCase { + description: "an invalid month", + name: "REVIEW_20261323T101500Z_3f2a9c1_a1b2c3", + expected_timestamp: None, + }, + SchemaNameTestCase { + description: "an ordinary schema", + name: "analytics_prod", + expected_timestamp: None, + }, + ]; + for test_case in test_cases { + assert_eq!( + parse_review_schema_name(test_case.name), + test_case.expected_timestamp, + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlbuild-analysis/src/review_command/tests/test_render_review.rs b/crates/sqlbuild-analysis/src/review_command/tests/test_render_review.rs new file mode 100644 index 0000000000..78ebe04dd4 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/tests/test_render_review.rs @@ -0,0 +1,246 @@ +use serde_json::Value; + +use crate::review_command::main::render_review_json::render_review_json; +use crate::review_command::main::render_review_markdown::render_review_markdown; +use crate::review_command::main::render_review_text::render_review_text; +use crate::review_command::main::review_outcome::review_outcome; +use crate::review_command::tests::helpers::{ + dry_run_report, errored_report, failed_report, finding_report, incomplete_report, + passed_report, selected_report, +}; +use crate::review_command::tests::test_types::{JsonTestCase, OutcomeTestCase, RenderTestCase}; + +#[test] +fn given_evidence_when_deciding_outcome_then_failure_beats_findings_beats_incomplete() { + let test_cases = [ + OutcomeTestCase { + description: "no findings and complete coverage passes", + report: passed_report(), + expected_status: "passed", + expected_exit_code: 0, + }, + OutcomeTestCase { + description: "a finding needs a fix", + report: finding_report(), + expected_status: "findings", + expected_exit_code: 1, + }, + OutcomeTestCase { + description: "an incomplete check is incomplete", + report: incomplete_report(), + expected_status: "incomplete", + expected_exit_code: 2, + }, + OutcomeTestCase { + description: "a review schema that was not dropped fails", + report: failed_report(), + expected_status: "failed", + expected_exit_code: 3, + }, + OutcomeTestCase { + description: "an error fails even with findings", + report: errored_report(), + expected_status: "failed", + expected_exit_code: 3, + }, + OutcomeTestCase { + description: "a dry run only plans", + report: dry_run_report(), + expected_status: "planned", + expected_exit_code: 0, + }, + ]; + for test_case in test_cases { + assert_eq!( + review_outcome(&test_case.report), + (test_case.expected_status, test_case.expected_exit_code), + "{}", + test_case.description + ); + } +} + +#[test] +fn given_reports_when_rendering_json_then_document_is_versioned_and_complete() { + let test_cases = [ + JsonTestCase { + description: "a finding", + report: finding_report(), + expected_values: vec![ + ("/format_version", Value::from(1)), + ("/status", Value::from("findings")), + ("/exit_code", Value::from(1)), + ("/summary/findings", Value::from(1)), + ("/summary/not_validated", Value::from(1)), + ("/findings/0/column", Value::from("amount")), + ("/findings/0/examples/0/key", Value::from("order_id=3")), + ("/tests/skipped", Value::from(3)), + ("/review_schema/dropped", Value::from(true)), + ( + "/recall/command", + Value::from("sqb recall output a1b2c3d4e5f6"), + ), + ("/changed/1/kind", Value::from("macro")), + ], + }, + JsonTestCase { + description: "a selection that added a changed ancestor and ran hooks", + report: selected_report(), + expected_values: vec![ + ("/added/0/name", Value::from("stg_orders")), + ("/added/0/required_by", Value::from("order_totals")), + ("/hooks/0", Value::from("order_totals")), + ], + }, + JsonTestCase { + description: "an error", + report: errored_report(), + expected_values: vec![ + ("/status", Value::from("failed")), + ("/exit_code", Value::from(3)), + ("/error/code", Value::from("C761")), + ], + }, + ]; + for test_case in test_cases { + let document: Value = + serde_json::from_str(&render_review_json(&test_case.report)).expect("valid JSON"); + + let actual: Vec<(&str, Value)> = test_case + .expected_values + .iter() + .map(|(pointer, _)| { + ( + *pointer, + document.pointer(pointer).cloned().unwrap_or(Value::Null), + ) + }) + .collect(); + assert_eq!( + actual, test_case.expected_values, + "{}", + test_case.description + ); + } +} + +#[test] +fn given_reports_when_rendering_markdown_then_sections_follow_the_agreed_layout() { + let test_cases = [ + RenderTestCase { + description: "a finding", + report: finding_report(), + expected_fragments: vec![ + "# Review needs a fix: 1 finding\n", + "since `origin/main` (3f2a9c1) · head a1b2c3d · against `prod` · exit 1 · 4.50s · sqlbuild 0.172.0", + "| Changed | Built | Compared | Tests | Findings | Not validated | Incomplete |\n|---:|---:|---:|---:|---:|---:|---:|\n| 2 | 3 | 3 | 2 | 1 | 1 | 0 |", + "## Findings\n\n1. `order_totals.amount` values differ in 1 of 5 rows (limit: exact match)\n - order_id=3: `12.5` → `13.0`", + "## What changed\n\n- `stg_orders` (model): model SQL changed\n- macro `cents_to_dollars`: changed", + "## Downstream impact\n\n```text\nstg_orders [ok]\n├── order_totals [finding]\n│ └── daily_revenue [ok, critical] columns: total_revenue\n└── order_archive [not validated: not selected (--select)]\n```", + "## Incomplete coverage\n\nEvery check completed.", + "## Baseline\n\n- Unchanged upstream read from `prod`: `waffle_types`\n- Incremental `order_events`: full refresh (the adapter has no zero-copy clone)", + "## Tests and audits\n\n- Unit tests: 2 of 5 ran (3 unaffected skipped): 2 passed, 0 failed\n- Audits: 3 ran: 3 passed, 0 failed", + "
order_totals: 1 difference\n\n| Column | Metric | prod | branch | Limit |\n|---|---|---|---|---|\n| amount | rows differ | 0 | 1 | exact match |", + "rows prod 5, branch 5; 2 other columns compared with no difference (full comparison).", + "## Run details\n\n- Review schema `review_20261023t101500z_3f2a9c1_a1b2c3`: dropped\n- Downstream policy: critical (critical: `daily_revenue`)\n- Recall: `sqb recall output a1b2c3d4e5f6`", + ], + unexpected_fragments: vec![], + }, + RenderTestCase { + description: "incomplete coverage", + report: incomplete_report(), + expected_fragments: vec![ + "# Review incomplete: 1 check\n", + "## Findings\n\nNo findings.", + "## Incomplete coverage\n\n- `order_totals` keyed comparison: the model has no unique_key", + ], + unexpected_fragments: vec![], + }, + RenderTestCase { + description: "a selection that added a changed ancestor and ran hooks", + report: selected_report(), + expected_fragments: vec![ + "- Also built: `stg_orders`, because `order_totals` depends on it", + "- Hooks run against the review schema: `order_totals`", + ], + unexpected_fragments: vec![], + }, + RenderTestCase { + description: "a cleanup failure", + report: failed_report(), + expected_fragments: vec![ + "# Review failed\n", + "- Review schema `review_20261023t101500z_3f2a9c1_a1b2c3`: not dropped: permission denied", + ], + unexpected_fragments: vec![], + }, + ]; + for test_case in test_cases { + let markdown: String = render_review_markdown(&test_case.report); + + let present: Vec<&str> = test_case + .expected_fragments + .iter() + .copied() + .filter(|fragment| markdown.contains(fragment)) + .collect(); + let absent: Vec<&str> = test_case + .unexpected_fragments + .iter() + .copied() + .filter(|fragment| !markdown.contains(fragment)) + .collect(); + assert_eq!( + present, test_case.expected_fragments, + "{}: {markdown}", + test_case.description + ); + assert_eq!( + absent, test_case.unexpected_fragments, + "{}: {markdown}", + test_case.description + ); + } +} + +#[test] +fn given_reports_when_rendering_text_then_summary_ends_with_outcome() { + let test_cases = [RenderTestCase { + description: "a finding", + report: finding_report(), + expected_fragments: vec![ + "Findings\n 1. order_totals.amount values differ in 1 of 5 rows", + "What changed\n stg_orders (model): model SQL changed", + "Downstream impact\n stg_orders [ok]\n ├── order_totals [finding]", + "Recall: sqb recall output a1b2c3d4e5f6", + "\nReview needs a fix: 1 finding changed=2 built=3 compared=3 tests=2 findings=1 not_validated=1 incomplete=0\n", + ], + unexpected_fragments: vec!["`"], + }]; + for test_case in test_cases { + let text: String = render_review_text(&test_case.report); + + let present: Vec<&str> = test_case + .expected_fragments + .iter() + .copied() + .filter(|fragment| text.contains(fragment)) + .collect(); + let absent: Vec<&str> = test_case + .unexpected_fragments + .iter() + .copied() + .filter(|fragment| !text.contains(fragment)) + .collect(); + assert_eq!( + present, test_case.expected_fragments, + "{}: {text}", + test_case.description + ); + assert_eq!( + absent, test_case.unexpected_fragments, + "{}: {text}", + test_case.description + ); + } +} diff --git a/crates/sqlbuild-analysis/src/review_command/tests/test_types.rs b/crates/sqlbuild-analysis/src/review_command/tests/test_types.rs new file mode 100644 index 0000000000..adbcdb2460 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/tests/test_types.rs @@ -0,0 +1,67 @@ +use serde_json::Value; + +use crate::janitor_planning::models::ArchiveTimestamp; +use crate::review_command::models::{DownstreamPolicy, ReviewReport, SelectedToken}; + +pub(super) struct PlanTestCase { + pub(super) description: &'static str, + pub(super) changed: Vec<&'static str>, + pub(super) policy: DownstreamPolicy, + pub(super) select: Option>, + pub(super) expected_validated: Vec<&'static str>, + pub(super) expected_not_validated: Vec<(&'static str, &'static str)>, + /// `(node, the validated node that depends on it)`. + pub(super) expected_added: Vec<(&'static str, &'static str)>, +} + +pub(super) struct PlanErrorTestCase { + pub(super) description: &'static str, + pub(super) policy: DownstreamPolicy, + pub(super) select: Vec, + pub(super) expected_code: &'static str, + pub(super) expected_fragment: &'static str, +} + +pub(super) struct OutcomeTestCase { + pub(super) description: &'static str, + pub(super) report: ReviewReport, + pub(super) expected_status: &'static str, + pub(super) expected_exit_code: i32, +} + +pub(super) struct JsonTestCase { + pub(super) description: &'static str, + pub(super) report: ReviewReport, + pub(super) expected_values: Vec<(&'static str, Value)>, +} + +pub(super) struct PolicyTestCase { + pub(super) description: &'static str, + pub(super) text: &'static str, + pub(super) expected_policy: Option, +} + +pub(super) struct SchemaNameBuildTestCase { + pub(super) description: &'static str, + pub(super) parts: (&'static str, &'static str, &'static str, &'static str), + pub(super) expected_name: &'static str, +} + +pub(super) struct RenderTestCase { + pub(super) description: &'static str, + pub(super) report: ReviewReport, + pub(super) expected_fragments: Vec<&'static str>, + pub(super) unexpected_fragments: Vec<&'static str>, +} + +pub(super) struct SchemaNameTestCase { + pub(super) description: &'static str, + pub(super) name: &'static str, + pub(super) expected_timestamp: Option, +} + +pub(super) struct HooksTestCase { + pub(super) description: &'static str, + pub(super) changed: Vec<&'static str>, + pub(super) expected_hooks: Vec<&'static str>, +} diff --git a/crates/sqlbuild-cli/src/argv/_helpers/parsing.rs b/crates/sqlbuild-cli/src/argv/_helpers/parsing.rs index 4ff0d45fc2..6516242c26 100644 --- a/crates/sqlbuild-cli/src/argv/_helpers/parsing.rs +++ b/crates/sqlbuild-cli/src/argv/_helpers/parsing.rs @@ -9,11 +9,11 @@ use crate::argv::_helpers::patterns::match_groups; use crate::argv::_helpers::values::{action_name, argument_error}; use crate::argv::constants::{ ERROR_PREFIX, ERROR_STRONG_STYLE, HELP_EXIT, OPTIONS_TERMINATOR, PARSER_ERROR_EXIT, - PREFIX_CHARS, STYLE_RESET, + PREFIX_CHARS, STYLE_RESET, USAGE_ERROR_EXITS, }; use crate::argv::models::{ ActionKind, ActionSpec, ArgvSettings, Default, HelpText, Namespace, Nargs, ParserSpec, Stream, - Value, + UsageFailure, Value, }; use crate::argv::types::ArgvHost; @@ -22,6 +22,9 @@ pub(crate) struct Run<'a> { pub(crate) settings: &'a ArgvSettings, pub(crate) host: &'a mut dyn ArgvHost, pub(crate) writes: Vec<(Stream, String)>, + /// The `prog` of the deepest sub-command parser entered so far. + pub(crate) command_prog: Option, + pub(crate) usage_failure: Option, } /// `SystemExit` with this status. @@ -60,7 +63,7 @@ impl Run<'_> { } } - /// `SqlbuildArgumentParser.error`: usage, then the coded message, to stderr; exit 2. + /// `SqlbuildArgumentParser.error`: usage, then the coded message, to stderr; the usage exit. pub(crate) fn error(&mut self, parser: &ParserSpec, message: &str) -> Exit { let usage: String = format_usage(self.settings, parser); self.write(Stream::Stderr, usage); @@ -73,7 +76,19 @@ impl Run<'_> { Stream::Stderr, format!("{}: {prefix} {message}\n\n", parser.prog), ); - Exit(PARSER_ERROR_EXIT) + let command: String = self + .command_prog + .clone() + .unwrap_or_else(|| parser.prog.clone()); + let status: i64 = USAGE_ERROR_EXITS + .iter() + .find(|(prog, _)| *prog == command) + .map_or(PARSER_ERROR_EXIT, |(_, status)| *status); + self.usage_failure = Some(UsageFailure { + prog: command, + message: message.to_owned(), + }); + Exit(status) } /// `parse_known_args(args, namespace)`: the namespace and unrecognised strings, or the exit. @@ -429,6 +444,7 @@ impl<'p, 'r, 'h> Level<'p, 'r, 'h> { let Some((_, subparser)) = action.parsers.iter().find(|(choice, _)| choice == name) else { return Ok(()); }; + self.run.command_prog = Some(subparser.prog.clone()); let (subnamespace, leftover) = self.run .parse_known_args(subparser, rest.to_vec(), Namespace::default())?; diff --git a/crates/sqlbuild-cli/src/argv/command_table.json b/crates/sqlbuild-cli/src/argv/command_table.json index 62e396c2bf..9947a4dcdf 100644 --- a/crates/sqlbuild-cli/src/argv/command_table.json +++ b/crates/sqlbuild-cli/src/argv/command_table.json @@ -3539,6 +3539,238 @@ ] } }, + { + "name": "review", + "parser": { + "prog": "sqb review", + "groups": [ + { + "title": "positional arguments" + }, + { + "title": "options" + } + ], + "actions": [ + { + "kind": "help", + "options": [ + "-h", + "--help" + ], + "dest": "help", + "nargs": 0, + "default": { + "suppress": true + }, + "help": "show this help message and exit", + "group": 1 + }, + { + "kind": "store_true", + "options": [ + "--no-sql-analysis", + "--no-sql-validation" + ], + "dest": "no_sql_validation", + "nargs": 0, + "const": true, + "default": false, + "help": "disable SQL syntax, binding, type inference, and semantic validation", + "group": 1 + }, + { + "kind": "store", + "options": [ + "--since" + ], + "dest": "since", + "metavar": "REF", + "help": "git ref whose merge base with HEAD is the code baseline; @base reads [git] base_ref (default: [git] base_ref)", + "group": 1 + }, + { + "kind": "store", + "options": [ + "--against" + ], + "dest": "review_against", + "metavar": "TARGET", + "help": "target whose tables the review compares with and reads unchanged upstream from (default: [review] against)", + "group": 1 + }, + { + "kind": "store", + "options": [ + "--downstream" + ], + "dest": "review_downstream", + "choices": [ + "critical", + "all", + "none" + ], + "help": "downstream nodes to build and compare: critical (paths to critical models), all, or none (default: [review] downstream, else critical)", + "group": 1 + }, + { + "kind": "store_true", + "options": [ + "--dry-run" + ], + "dest": "dry_run", + "nargs": 0, + "const": true, + "default": false, + "help": "print the review plan without touching the warehouse", + "group": 1 + }, + { + "kind": "store_true", + "options": [ + "--no-clone" + ], + "dest": "review_no_clone", + "nargs": 0, + "const": true, + "default": false, + "help": "build changed incremental models with a full refresh instead of cloning them from the target", + "group": 1 + }, + { + "kind": "store", + "options": [ + "--bounded" + ], + "dest": "bounded", + "metavar": "WINDOW", + "help": "compare models too large for an exact comparison within a recent cursor window, such as 14d", + "group": 1 + }, + { + "kind": "store", + "options": [ + "--markdown" + ], + "dest": "review_markdown", + "type": "Path", + "metavar": "PATH", + "help": "also write the review as Markdown to PATH", + "group": 1 + }, + { + "kind": "store_true", + "options": [ + "--json" + ], + "dest": "json", + "nargs": 0, + "const": true, + "default": false, + "group": 1 + }, + { + "kind": "store", + "options": [ + "--target" + ], + "dest": "target", + "group": 1 + }, + { + "kind": "store", + "options": [ + "--concurrency" + ], + "dest": "concurrency", + "type": "int", + "group": 1 + }, + { + "kind": "extend", + "options": [ + "--select", + "-s" + ], + "dest": "select", + "nargs": "+", + "default": [], + "group": 1 + }, + { + "kind": "store", + "dest": "positional_select", + "nargs": "*", + "default": [], + "help": "selectors to include, the same as --select; combined with any --select values", + "metavar": "SELECTOR", + "group": 0 + }, + { + "kind": "store", + "options": [ + "--vars" + ], + "dest": "vars", + "default": { + "dict": {} + }, + "type": "parse_cli_vars", + "group": 1 + }, + { + "kind": "store", + "options": [ + "--warehouse" + ], + "dest": "warehouse", + "help": "warehouse for this invocation; overrides the target's command-group warehouse and the connection warehouse", + "group": 1 + }, + { + "kind": "store", + "options": [ + "--project-dir", + "--sqb-project-dir" + ], + "dest": "project_dir", + "default": { + "suppress": true + }, + "help": "SQLBuild project directory (default: the current directory)", + "group": 1 + }, + { + "kind": "store_true", + "options": [ + "--no-color" + ], + "dest": "no_color", + "nargs": 0, + "const": true, + "default": { + "suppress": true + }, + "help": "disable colored output", + "group": 1 + }, + { + "kind": "store_true", + "options": [ + "--debug" + ], + "dest": "debug", + "nargs": 0, + "const": true, + "default": { + "suppress": true + }, + "help": "enable verbose output and mirror internal diagnostics to stderr", + "group": 1 + } + ] + } + }, { "name": "scope", "parser": { @@ -7357,6 +7589,11 @@ "dest": "recall", "metavar": "recall", "help": "query the recorded history of past invocations" + }, + { + "dest": "review", + "metavar": "review", + "help": "build what a branch changed in a temporary schema, test it, compare it with a target and report" } ] } diff --git a/crates/sqlbuild-cli/src/argv/constants.rs b/crates/sqlbuild-cli/src/argv/constants.rs index 2cd040d890..29d4726dc1 100644 --- a/crates/sqlbuild-cli/src/argv/constants.rs +++ b/crates/sqlbuild-cli/src/argv/constants.rs @@ -22,6 +22,11 @@ pub(crate) const ERROR_PREFIX: &str = "error[C900]:"; pub(crate) const ERROR_STRONG_STYLE: &str = "\u{1b}[38;5;167m\u{1b}[1m"; pub(crate) const STYLE_RESET: &str = "\u{1b}[0m"; pub(crate) const PARSER_ERROR_EXIT: i64 = 2; +/// The parser `prog` of `sqb review`, and the option that makes it print a JSON document. +pub(crate) const REVIEW_PROG: &str = "sqb review"; +pub(crate) const JSON_OPTION: &str = "--json"; +/// Commands whose usage errors exit with their own status, where 2 means something else. +pub(crate) const USAGE_ERROR_EXITS: [(&str, i64); 1] = [(REVIEW_PROG, 3)]; pub(crate) const HELP_EXIT: i64 = 0; pub(crate) const INDENT_INCREMENT: i64 = 2; pub(crate) const MAX_HELP_POSITION: i64 = 24; @@ -64,7 +69,7 @@ pub(crate) const INT_CONVERTER: &str = "int"; pub(crate) const PATH_CONVERTER: &str = "Path"; /// Every `CliNamespace` attribute with a default, in declaration order. -pub(crate) const CLI_NAMESPACE_DEFAULTS: [(&str, NamespaceDefault); 180] = [ +pub(crate) const CLI_NAMESPACE_DEFAULTS: [(&str, NamespaceDefault); 184] = [ ("command", NamespaceDefault::None), ("project_dir", NamespaceDefault::None), ("dbt_project_dir", NamespaceDefault::None), @@ -86,6 +91,10 @@ pub(crate) const CLI_NAMESPACE_DEFAULTS: [(&str, NamespaceDefault); 180] = [ ("inspect", NamespaceDefault::Bool(false)), ("since", NamespaceDefault::None), ("explain", NamespaceDefault::Bool(false)), + ("review_against", NamespaceDefault::None), + ("review_downstream", NamespaceDefault::None), + ("review_no_clone", NamespaceDefault::Bool(false)), + ("review_markdown", NamespaceDefault::None), ("target_range", NamespaceDefault::None), ("from_target", NamespaceDefault::None), ("to_target", NamespaceDefault::None), diff --git a/crates/sqlbuild-cli/src/argv/main/parse_cli_arguments.rs b/crates/sqlbuild-cli/src/argv/main/parse_cli_arguments.rs index 2f75531a90..c0fce019da 100644 --- a/crates/sqlbuild-cli/src/argv/main/parse_cli_arguments.rs +++ b/crates/sqlbuild-cli/src/argv/main/parse_cli_arguments.rs @@ -17,6 +17,8 @@ pub fn parse_cli_arguments( settings: &settings, host, writes: Vec::new(), + command_prog: None, + usage_failure: None, }; let namespace: Namespace = Namespace { entries: defaults, @@ -29,5 +31,6 @@ pub fn parse_cli_arguments( ArgvOutcome { writes: run.writes, parsed, + usage_failure: run.usage_failure, } } diff --git a/crates/sqlbuild-cli/src/argv/main/parse_dbt_execution_arguments.rs b/crates/sqlbuild-cli/src/argv/main/parse_dbt_execution_arguments.rs index 203bba8896..40a24ce62f 100644 --- a/crates/sqlbuild-cli/src/argv/main/parse_dbt_execution_arguments.rs +++ b/crates/sqlbuild-cli/src/argv/main/parse_dbt_execution_arguments.rs @@ -27,6 +27,8 @@ pub fn parse_dbt_execution_arguments( settings: &settings, host: &mut host, writes: Vec::new(), + command_prog: None, + usage_failure: None, }; let known: Result = run.known_args(&parser, head.to_vec(), Namespace::default()); diff --git a/crates/sqlbuild-cli/src/argv/models.rs b/crates/sqlbuild-cli/src/argv/models.rs index 72f814054e..037208ddb0 100644 --- a/crates/sqlbuild-cli/src/argv/models.rs +++ b/crates/sqlbuild-cli/src/argv/models.rs @@ -156,11 +156,20 @@ pub enum TypeFailure { Invalid, } +/// The `prog` of the parser that rejected the arguments, and its message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UsageFailure { + pub prog: String, + pub message: String, +} + /// The writes of the parse, then either the namespace or the exit status. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ArgvOutcome { pub writes: Vec<(Stream, String)>, pub parsed: Result, + /// The usage failure that ended the parse, if one did. + pub usage_failure: Option, } /// Why a `sqb dbt` execution parse stopped before producing flags. diff --git a/crates/sqlbuild-cli/src/argv/tests/test_parse_cli_arguments.rs b/crates/sqlbuild-cli/src/argv/tests/test_parse_cli_arguments.rs index 4fa7bed300..d47a6702c5 100644 --- a/crates/sqlbuild-cli/src/argv/tests/test_parse_cli_arguments.rs +++ b/crates/sqlbuild-cli/src/argv/tests/test_parse_cli_arguments.rs @@ -94,6 +94,18 @@ fn given_help_version_or_rejected_arguments_when_parsing_then_exits_after_writin expected_exit: 2, expected_last_write: "sqb rename: error[C900]: the following arguments are required: NEW_NAME\n\n", }, + ExitTestCase { + description: "an invalid review choice exits as a review setup failure", + argv: &["review", "--downstream", "some"], + expected_exit: 3, + expected_last_write: "sqb review: error[C900]: argument --downstream: invalid choice: 'some' (choose from 'critical', 'all', 'none')\n\n", + }, + ExitTestCase { + description: "an unknown review option exits as a review setup failure", + argv: &["review", "--no-such-option"], + expected_exit: 3, + expected_last_write: "sqb: error[C900]: unrecognized arguments: --no-such-option\n\n", + }, ExitTestCase { description: "a rename without a target or a file stays a usage error", argv: &["rename"], diff --git a/crates/sqlbuild-cli/src/dispatch/constants.rs b/crates/sqlbuild-cli/src/dispatch/constants.rs index 7410b3e21d..71b7c4de91 100644 --- a/crates/sqlbuild-cli/src/dispatch/constants.rs +++ b/crates/sqlbuild-cli/src/dispatch/constants.rs @@ -404,6 +404,33 @@ pub(crate) const DIFF_ROUTE: Route = routed( REQUEST_KEYWORD_SHAPE, ); +const REVIEW_FIELDS: [Field; 17] = [ + PROJECT_DIR, + NO_COLOR, + NO_SQL_VALIDATION, + SELECTED_TARGET, + SELECT, + CLI_VARS, + JSON_OUTPUT, + same("since"), + attr("against", "review_against"), + attr("downstream", "review_downstream"), + same("dry_run"), + attr("no_clone", "review_no_clone"), + same("bounded"), + attr("markdown_path", "review_markdown"), + same("concurrency"), + same("verbose"), + same("debug"), +]; +const REVIEW_ARGUMENTS: [Field; 1] = [request(class("ReviewCommandRequest"), &REVIEW_FIELDS)]; +pub(crate) const REVIEW_ROUTE: Route = routed( + "run_review", + "run_review_command", + &REVIEW_ARGUMENTS, + REQUEST_KEYWORD_SHAPE, +); + const QUERY_ARGUMENTS: [Field; 6] = [ PROJECT_DIR, attr("sql", "query_sql"), diff --git a/crates/sqlbuild-cli/src/dispatch/main/command_dispatch.rs b/crates/sqlbuild-cli/src/dispatch/main/command_dispatch.rs index dcad84311c..29de9f0374 100644 --- a/crates/sqlbuild-cli/src/dispatch/main/command_dispatch.rs +++ b/crates/sqlbuild-cli/src/dispatch/main/command_dispatch.rs @@ -7,7 +7,8 @@ use crate::dispatch::constants::{ AUDIT_ROUTE, BUILD_ROUTE, CHECK_ROUTE, CLONE_ROUTE, COMPILE_ROUTE, CONTRACT_ROUTE, COST_ROUTE, DAG_ROUTE, DEBUG_ROUTE, DIFF_ROUTE, FORMAT_ROUTE, FRESHNESS_ROUTE, INIT_ROUTE, JANITOR_ROUTE, LINEAGE_ROUTE, LOAD_ROUTE, PLAN_ROUTE, PLAYGROUND_ROUTE, QUERY_ROUTE, RECALL_COMMAND_NAME, - RECALL_ROUTE, REFACTOR_ROUTE, RULES_ROUTE, SCOPE_ROUTE, SEED_ROUTE, SKILLS_ROUTE, TEST_ROUTE, + RECALL_ROUTE, REFACTOR_ROUTE, REVIEW_ROUTE, RULES_ROUTE, SCOPE_ROUTE, SEED_ROUTE, SKILLS_ROUTE, + TEST_ROUTE, }; use crate::dispatch::models::{CommandFacts, Dispatch, DispatchFailure, Route}; @@ -49,6 +50,7 @@ pub fn command_dispatch(facts: &CommandFacts) -> Dispatch { (!facts.from_target).then(|| failure("clone requires --from", "C406", None)), ), "diff" => run(&DIFF_ROUTE), + "review" => run(&REVIEW_ROUTE), "query" => run(&QUERY_ROUTE), RECALL_COMMAND_NAME => run(&RECALL_ROUTE), "cost" => run(&COST_ROUTE), diff --git a/crates/sqlbuild-cli/src/native_commands/_helpers/decision.rs b/crates/sqlbuild-cli/src/native_commands/_helpers/decision.rs index 30ede63f68..ed076cde4b 100644 --- a/crates/sqlbuild-cli/src/native_commands/_helpers/decision.rs +++ b/crates/sqlbuild-cli/src/native_commands/_helpers/decision.rs @@ -2,7 +2,8 @@ use std::path::PathBuf; -use crate::argv::models::{Namespace, Value}; +use crate::argv::constants::{JSON_OPTION, REVIEW_PROG}; +use crate::argv::models::{ArgvOutcome, Namespace, Value}; use crate::dispatch::main::invocation_policy::invocation_policy; use crate::dispatch::models::{CommandFacts, InvocationPolicy}; use crate::native_commands::constants::{ @@ -14,6 +15,15 @@ use crate::native_commands::models::{ NativeCommand, NativeInvocation, RulesAction, RulesOptions, SkillsOptions, }; +/// Whether `sqb review --json` failed to parse, which Python reports as a JSON document. +pub(crate) fn review_json_failure(arguments: &[String], outcome: &ArgvOutcome) -> bool { + arguments.iter().any(|argument| argument == JSON_OPTION) + && outcome + .usage_failure + .as_ref() + .is_some_and(|failure| failure.prog == REVIEW_PROG) +} + fn text(namespace: &Namespace, name: &str) -> Option { match namespace.get(name) { Some(Value::Str(text) | Value::Path(text)) => Some(text.clone()), diff --git a/crates/sqlbuild-cli/src/native_commands/_helpers/terminal.rs b/crates/sqlbuild-cli/src/native_commands/_helpers/terminal.rs index 44d55e4570..cd354451ed 100644 --- a/crates/sqlbuild-cli/src/native_commands/_helpers/terminal.rs +++ b/crates/sqlbuild-cli/src/native_commands/_helpers/terminal.rs @@ -135,6 +135,16 @@ pub(crate) fn write_output(writes: &[(Stream, &str)]) -> Option { lost } +/// Print a rejected parse's writes; the exit status, as `finish_lost_stdout` settles it. +pub(crate) fn finish_rejected_parse(writes: &[(Stream, String)], code: i64) -> i32 { + let writes: Vec<(Stream, &str)> = writes + .iter() + .map(|(stream, text)| (*stream, text.as_str())) + .collect(); + let lost: Option = write_output(&writes); + finish_lost_stdout(lost.as_ref(), i32::try_from(code).unwrap_or(1)) +} + /// Report lost stdout as Python does and return the exit status that results. pub(crate) fn finish_lost_stdout(lost: Option<&LostStdout>, exit_code: i32) -> i32 { match lost { diff --git a/crates/sqlbuild-cli/src/native_commands/main/run_native_invocation.rs b/crates/sqlbuild-cli/src/native_commands/main/run_native_invocation.rs index 1233e99081..40360a7de8 100644 --- a/crates/sqlbuild-cli/src/native_commands/main/run_native_invocation.rs +++ b/crates/sqlbuild-cli/src/native_commands/main/run_native_invocation.rs @@ -4,16 +4,16 @@ use std::ffi::OsString; use std::path::Path; use crate::argv::main::parse_cli_arguments::parse_cli_arguments; -use crate::argv::models::{ArgvOutcome, ArgvSettings, Namespace, Stream}; +use crate::argv::models::{ArgvOutcome, ArgvSettings, Namespace}; use crate::native_commands::_helpers::argv_host::{NativeArgvHost, namespace_defaults}; -use crate::native_commands::_helpers::decision::native_invocation; +use crate::native_commands::_helpers::decision::{native_invocation, review_json_failure}; use crate::native_commands::_helpers::execution::run_command; use crate::native_commands::_helpers::terminal::{ - finish_lost_stdout, stdout_supports_color, terminal_columns, write_output, + finish_rejected_parse, stdout_supports_color, terminal_columns, }; use crate::native_commands::constants::NO_COLOR_OPTION; use crate::native_commands::errors::PythonNeeded; -use crate::native_commands::models::{LostStdout, NativeInvocation}; +use crate::native_commands::models::NativeInvocation; /// The exit status of an invocation `sqb` served, or why the same arguments go to Python. pub fn run_native_invocation( @@ -38,18 +38,10 @@ pub fn run_native_invocation( return Err(PythonNeeded::Arguments); } let namespace: Namespace = match outcome.parsed { - Err(code) => { - let writes: Vec<(Stream, &str)> = outcome - .writes - .iter() - .map(|(stream, text)| (*stream, text.as_str())) - .collect(); - let lost: Option = write_output(&writes); - return Ok(finish_lost_stdout( - lost.as_ref(), - i32::try_from(code).unwrap_or(1), - )); + Err(_) if review_json_failure(&arguments, &outcome) => { + return Err(PythonNeeded::Arguments); } + Err(code) => return Ok(finish_rejected_parse(&outcome.writes, code)), Ok(namespace) if outcome.writes.is_empty() => namespace, Ok(_) => return Err(PythonNeeded::Arguments), }; diff --git a/crates/sqlbuild-config/src/command_connection/constants.rs b/crates/sqlbuild-config/src/command_connection/constants.rs index f747e2218a..f5c5246f56 100644 --- a/crates/sqlbuild-config/src/command_connection/constants.rs +++ b/crates/sqlbuild-config/src/command_connection/constants.rs @@ -24,7 +24,7 @@ pub(crate) const SETTING_SNIPPET_INDENT: &str = " "; /// Two quotes around at least one character. pub(crate) const MIN_QUOTED_IDENTIFIER_LENGTH: usize = 3; /// The warehouse group each CLI command selects a target's default warehouse from. -pub const COMMAND_WAREHOUSE_GROUPS: [(&str, Option<&str>); 30] = [ +pub const COMMAND_WAREHOUSE_GROUPS: [(&str, Option<&str>); 31] = [ ("audit", Some("build")), ("build", Some("build")), ("check", Some("build")), @@ -32,6 +32,7 @@ pub const COMMAND_WAREHOUSE_GROUPS: [(&str, Option<&str>); 30] = [ ("dbt", Some("build")), ("load", Some("build")), ("plan", Some("build")), + ("review", Some("build")), ("seed", Some("build")), ("contract", Some("query")), ("cost", Some("query")), diff --git a/crates/sqlbuild-config/src/project/_helpers/documents.rs b/crates/sqlbuild-config/src/project/_helpers/documents.rs index c5c121bc45..5ae9cfb256 100644 --- a/crates/sqlbuild-config/src/project/_helpers/documents.rs +++ b/crates/sqlbuild-config/src/project/_helpers/documents.rs @@ -15,8 +15,8 @@ use crate::project::_helpers::help::{ use crate::project::_helpers::keys::Place; use crate::project::_helpers::sections::{ load_constants, load_cost, load_cursors, load_dbt, load_diff, load_git, load_janitor, - load_local_dbt, load_microbatches, load_migrations, load_policy_flag, load_scenario, - load_settings, load_snapshots, setting_overrides, + load_local_dbt, load_microbatches, load_migrations, load_policy_flag, load_review, + load_scenario, load_settings, load_snapshots, setting_overrides, }; use crate::project::_helpers::sinks::load_sinks; use crate::project::_helpers::targets::{ @@ -28,7 +28,7 @@ use crate::project::constants::{ REQUIRE_SQL_ANALYSIS_KEY, SQL_ANALYSIS_KEY, }; use crate::project::errors::{ConfigFailure, FailureClass, LoadFailure, MessagePiece}; -use crate::project::models::{Built, ConfigFile, ConfigPaths}; +use crate::project::models::{Built, ConfigFile, ConfigPaths, ReviewConfig}; use crate::project::types::{Entries, Fields, Loaded}; use crate::recall::constants::RECALL_SECTION; use crate::recall::main::recall_section::recall_section; @@ -277,6 +277,7 @@ fn project_fields(payload: &Entries) -> Loaded { .map_err(|message| ConfigFailure::at(PROJECT_FILE, message))?; } load_git(get(payload, "git"), PROJECT_FILE)?; + load_review(get(payload, "review"), PROJECT_FILE)?; let mut fields = identity(payload)?; fields.extend(sections(payload)?); fields.push(("targets", targets(payload)?)); @@ -432,3 +433,11 @@ pub(crate) fn git_base_ref(paths: &ConfigPaths) -> Result, LoadFa let payload = table.as_map().unwrap_or_default(); Ok(load_git(get(payload, "git"), PROJECT_FILE)?) } + +/// `[review]` of `sqlbuild_project.toml`, validated as project loading validates it. +pub(crate) fn review_config(paths: &ConfigPaths) -> Result { + let file = resolve(paths, false)?; + let table = load_toml_mapping(paths, file)?; + let payload = table.as_map().unwrap_or_default(); + Ok(load_review(get(payload, "review"), PROJECT_FILE)?) +} diff --git a/crates/sqlbuild-config/src/project/_helpers/sections.rs b/crates/sqlbuild-config/src/project/_helpers/sections.rs index 5842d42869..f787094cac 100644 --- a/crates/sqlbuild-config/src/project/_helpers/sections.rs +++ b/crates/sqlbuild-config/src/project/_helpers/sections.rs @@ -15,9 +15,10 @@ use crate::project::constants::{ DBT_DEFER_CLONE_KEY, DBT_KEYS, DBT_REMOVED_KEYS, DBT_REUSE_KEYS, DEFAULT_IN_USE_WINDOW, DEFAULT_QUERY_ARTIFACT_TTL, FUTURE_CURSOR_ACTIONS, LEGACY_CONCURRENCY_KEY, LEGACY_SQL_VALIDATION_KEY, LOCAL_DBT_KEYS, MAX_BATCHES_KEY, MICROBATCH_LIMIT_ACTIONS, - OLD_NAME_VIEWS_KEY, QUERY_CHANGE_TRACKING_KEY, REQUIRE_SQL_ANALYSIS_KEY, RUN_NAMESPACE_KEY, - RUN_NAMESPACE_MAX_LENGTH, SCENARIO_KEYS, SETTINGS_KEYS, SNAPSHOT_FULL_REFRESH_POLICIES, - SNAPSHOT_KEYS, SNAPSHOT_LIMIT_KEYS, SNAPSHOT_SCHEMA_CHANGE_POLICIES, SQL_ANALYSIS_KEY, + OLD_NAME_VIEWS_KEY, QUERY_CHANGE_TRACKING_KEY, REQUIRE_SQL_ANALYSIS_KEY, + REVIEW_DOWNSTREAM_POLICIES, REVIEW_KEYS, RUN_NAMESPACE_KEY, RUN_NAMESPACE_MAX_LENGTH, + SCENARIO_KEYS, SETTINGS_KEYS, SNAPSHOT_FULL_REFRESH_POLICIES, SNAPSHOT_KEYS, + SNAPSHOT_LIMIT_KEYS, SNAPSHOT_SCHEMA_CHANGE_POLICIES, SQL_ANALYSIS_KEY, UNACCOUNTED_PARTITION_POLICIES, USD_PER_CREDIT_KEY, ZERO_FIXED_DURATION, ZERO_FLOAT, }; use crate::project::constants::{ @@ -25,7 +26,7 @@ use crate::project::constants::{ SCENARIO_TIMEOUT_KEY, TEST_TIMEOUT_KEY, UNIT_TEST_MAX_REAL_MODELS_KEY, }; use crate::project::errors::ConfigFailure; -use crate::project::models::{Built, ConfigFile}; +use crate::project::models::{Built, ConfigFile, ReviewConfig}; use crate::project::types::{Entries, Fields, Loaded}; use sqlbuild_core::constants::ZERO_DAY_DURATION; @@ -548,6 +549,62 @@ pub(crate) fn load_git(payload: Option<&ConfigValue>, file: ConfigFile) -> Loade }) } +/// `[review]`: the defaults of `sqb review`, validated so a typo fails like any other section. +pub(crate) fn load_review(payload: Option<&ConfigValue>, file: ConfigFile) -> Loaded { + let mapping = coerce_mapping(payload, place(file, "review"))?; + validate_allowed_keys(mapping, &REVIEW_KEYS, place(file, "review"))?; + let against = optional_str(mapping, "against") + .map_err(|_| ConfigFailure::at(file, "review.against must be a non-empty target name"))?; + let downstream = match get(mapping, "downstream") { + None => None, + Some(ConfigValue::String(text)) if REVIEW_DOWNSTREAM_POLICIES.contains(&text.as_str()) => { + Some(text.clone()) + } + Some(_) => { + return Err(ConfigFailure::at( + file, + format!( + "review.downstream must be one of: {}", + REVIEW_DOWNSTREAM_POLICIES.join(", ") + ), + )); + } + }; + let valid_prefix = |prefix: &str| { + prefix + .chars() + .next() + .is_some_and(|first| first.is_ascii_alphabetic()) + && prefix + .chars() + .all(|item| item.is_ascii_alphanumeric() || item == '_') + }; + let schema_prefix: Option = match get(mapping, "schema_prefix") { + None => None, + Some(ConfigValue::String(text)) if valid_prefix(text) => Some(text.clone()), + Some(_) => { + return Err(ConfigFailure::at( + file, + "review.schema_prefix must start with a letter and contain only letters, digits \ + and underscores", + )); + } + }; + let critical_tags = match get(mapping, "critical_tags") { + None => None, + payload => Some(string_sequence( + payload, + place(file, "review.critical_tags"), + )?), + }; + Ok(ReviewConfig { + against, + downstream, + schema_prefix, + critical_tags, + }) +} + fn janitor_count(mapping: &Entries, key: &str, default: i64) -> Loaded { optional_int(mapping, key, default) } diff --git a/crates/sqlbuild-config/src/project/constants.rs b/crates/sqlbuild-config/src/project/constants.rs index 7cc378c799..7b56d857d7 100644 --- a/crates/sqlbuild-config/src/project/constants.rs +++ b/crates/sqlbuild-config/src/project/constants.rs @@ -194,6 +194,9 @@ pub(crate) const TARGET_KEYS: [&str; 20] = [ "recall", "concurrency", ]; +pub(crate) const REVIEW_KEYS: [&str; 4] = + ["against", "critical_tags", "downstream", "schema_prefix"]; +pub(crate) const REVIEW_DOWNSTREAM_POLICIES: [&str; 3] = ["critical", "all", "none"]; pub(crate) const CLONE_KEYS: [&str; 2] = ["allow_as_clone_origin", "allow_as_clone_destination"]; pub(crate) const EXECUTION_LIMIT_KEYS: [&str; 5] = [ "max_models", diff --git a/crates/sqlbuild-config/src/project/main/load_review_config.rs b/crates/sqlbuild-config/src/project/main/load_review_config.rs new file mode 100644 index 0000000000..d5039bd16a --- /dev/null +++ b/crates/sqlbuild-config/src/project/main/load_review_config.rs @@ -0,0 +1,9 @@ +//! Read `[review]` from `sqlbuild_project.toml`. + +use crate::project::errors::LoadFailure; +use crate::project::models::{ConfigPaths, ReviewConfig}; + +/// The configured `sqb review` defaults; every field is `None` when `[review]` sets none. +pub fn load_review_config(paths: &ConfigPaths) -> Result { + crate::project::_helpers::documents::review_config(paths) +} diff --git a/crates/sqlbuild-config/src/project/main/mod.rs b/crates/sqlbuild-config/src/project/main/mod.rs index 7198e4d7de..08ec0b599c 100644 --- a/crates/sqlbuild-config/src/project/main/mod.rs +++ b/crates/sqlbuild-config/src/project/main/mod.rs @@ -2,6 +2,7 @@ pub mod explicit_references_help; pub mod load_git_base_ref; pub mod load_local_config; pub mod load_project_config; +pub mod load_review_config; pub mod local_sql_analysis_failure; pub mod microbatch_guidance; pub mod resolve_config_file; diff --git a/crates/sqlbuild-config/src/project/models.rs b/crates/sqlbuild-config/src/project/models.rs index d583900e86..a36260035b 100644 --- a/crates/sqlbuild-config/src/project/models.rs +++ b/crates/sqlbuild-config/src/project/models.rs @@ -55,3 +55,12 @@ pub struct SettingSite<'a> { pub section: &'a str, pub key: &'a str, } + +/// `[review]` of `sqlbuild_project.toml`: the defaults `sqb review` uses when flags are omitted. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ReviewConfig { + pub against: Option, + pub downstream: Option, + pub schema_prefix: Option, + pub critical_tags: Option>, +} diff --git a/crates/sqlbuild-config/src/project/tests/test_load_config.rs b/crates/sqlbuild-config/src/project/tests/test_load_config.rs index 969b1b5ade..2479ee5592 100644 --- a/crates/sqlbuild-config/src/project/tests/test_load_config.rs +++ b/crates/sqlbuild-config/src/project/tests/test_load_config.rs @@ -1,7 +1,11 @@ use crate::project::main::load_git_base_ref::load_git_base_ref; +use crate::project::main::load_review_config::load_review_config; use crate::project::models::ConfigFile; +use crate::project::models::ReviewConfig; use crate::project::tests::helpers::{failure_at, first_failure, project_directory}; -use crate::project::tests::test_types::{GitBaseRefTestCase, ProjectFailureTestCase}; +use crate::project::tests::test_types::{ + GitBaseRefTestCase, ProjectFailureTestCase, ReviewConfigErrorTestCase, ReviewConfigTestCase, +}; const PROJECT: &str = "name = 'orders'\nadapter = 'duckdb'\n"; @@ -172,3 +176,71 @@ fn given_git_section_when_reading_base_ref_then_returns_configured_ref() { ); } } + +#[test] +fn given_review_section_when_reading_then_returns_its_values_or_defaults() { + let test_cases = [ + ReviewConfigTestCase { + description: "every review key", + project: "name = 'orders'\nadapter = 'duckdb'\n[review]\nagainst = 'prod'\n\ + downstream = 'all'\nschema_prefix = 'CI_REVIEW'\ncritical_tags = ['finance']\n", + expected_config: ReviewConfig { + against: Some("prod".to_owned()), + downstream: Some("all".to_owned()), + schema_prefix: Some("CI_REVIEW".to_owned()), + critical_tags: Some(vec!["finance".to_owned()]), + }, + }, + ReviewConfigTestCase { + description: "no review section", + project: PROJECT, + expected_config: ReviewConfig::default(), + }, + ]; + for test_case in test_cases { + let (_directory, paths) = project_directory(test_case.project, ""); + + let config: ReviewConfig = load_review_config(&paths).expect(test_case.description); + + assert_eq!( + config, test_case.expected_config, + "{}", + test_case.description + ); + } +} + +#[test] +fn given_invalid_review_section_when_reading_then_it_is_rejected() { + let test_cases = [ + ReviewConfigErrorTestCase { + description: "an unknown downstream policy", + project: "name = 'orders'\nadapter = 'duckdb'\n[review]\ndownstream = 'some'\n", + expected_fragment: "review.downstream must be one of: critical, all, none", + }, + ReviewConfigErrorTestCase { + description: "a schema prefix that is not an identifier", + project: "name = 'orders'\nadapter = 'duckdb'\n[review]\nschema_prefix = 'review-ci'\n", + expected_fragment: "review.schema_prefix must start with a letter", + }, + ReviewConfigErrorTestCase { + description: "a misspelt review key", + project: "name = 'orders'\nadapter = 'duckdb'\n[review]\nagaints = 'prod'\n", + expected_fragment: "review contains unknown key(s): againts", + }, + ]; + for test_case in test_cases { + let (_directory, paths) = project_directory(test_case.project, ""); + + let failure: String = format!( + "{:?}", + load_review_config(&paths).expect_err(test_case.description) + ); + + assert!( + failure.contains(test_case.expected_fragment), + "{}: {failure}", + test_case.description + ); + } +} diff --git a/crates/sqlbuild-config/src/project/tests/test_types.rs b/crates/sqlbuild-config/src/project/tests/test_types.rs index 09c6e090ef..e4dda5fb4e 100644 --- a/crates/sqlbuild-config/src/project/tests/test_types.rs +++ b/crates/sqlbuild-config/src/project/tests/test_types.rs @@ -1,4 +1,5 @@ use crate::project::errors::ConfigFailure; +use crate::project::models::ReviewConfig; pub(super) struct ProjectFailureTestCase { pub(super) description: &'static str, @@ -19,3 +20,15 @@ pub(crate) struct GitBaseRefTestCase { pub(crate) project: &'static str, pub(crate) expected_base_ref: Option<&'static str>, } + +pub(crate) struct ReviewConfigTestCase { + pub(crate) description: &'static str, + pub(crate) project: &'static str, + pub(crate) expected_config: ReviewConfig, +} + +pub(crate) struct ReviewConfigErrorTestCase { + pub(crate) description: &'static str, + pub(crate) project: &'static str, + pub(crate) expected_fragment: &'static str, +} diff --git a/crates/sqlbuild-core/src/git_revisions/main/head_commit.rs b/crates/sqlbuild-core/src/git_revisions/main/head_commit.rs new file mode 100644 index 0000000000..14314ddd97 --- /dev/null +++ b/crates/sqlbuild-core/src/git_revisions/main/head_commit.rs @@ -0,0 +1,19 @@ +//! The commit `HEAD` names in the repository containing a directory. + +use std::path::Path; + +use crate::git_revisions::_helpers::git_command::{GitFailure, git_output}; + +/// The full hash of `HEAD`, or `None` when `directory` is not in a git checkout with a commit. +#[must_use] +pub fn head_commit(directory: &Path) -> Option { + let found = git_output( + directory, + &["rev-parse", "--verify", "--quiet", "HEAD"], + &[], + ); + match found { + Ok(commit) if !commit.is_empty() => Some(commit), + Ok(_) | Err(GitFailure::Failed(_) | GitFailure::Unavailable(_)) => None, + } +} diff --git a/crates/sqlbuild-core/src/git_revisions/main/mod.rs b/crates/sqlbuild-core/src/git_revisions/main/mod.rs index c841f13156..6aa837e767 100644 --- a/crates/sqlbuild-core/src/git_revisions/main/mod.rs +++ b/crates/sqlbuild-core/src/git_revisions/main/mod.rs @@ -1,6 +1,7 @@ pub mod base_dependency_files; pub mod current_dependency_files; pub mod export_git_tree; +pub mod head_commit; pub mod resolve_git_base; pub mod resolve_since_reference; pub mod short_commit; diff --git a/crates/sqlbuild-core/src/janitor_text/_helpers/counts.rs b/crates/sqlbuild-core/src/janitor_text/_helpers/counts.rs index 5dff531a2c..767ffcf057 100644 --- a/crates/sqlbuild-core/src/janitor_text/_helpers/counts.rs +++ b/crates/sqlbuild-core/src/janitor_text/_helpers/counts.rs @@ -15,7 +15,7 @@ pub(crate) fn physical_deletion_count(plan: &JanitorPlanText) -> usize { } else { plan.candidates.len() }; - relations + plan.query_artifacts.len() + plan.old_name_drops.len() + relations + plan.query_artifacts.len() + plan.review_schemas.len() + plan.old_name_drops.len() } /// `archive N and ` when the plan archives relations. diff --git a/crates/sqlbuild-core/src/janitor_text/_helpers/phases.rs b/crates/sqlbuild-core/src/janitor_text/_helpers/phases.rs index 98a0026abd..776a840be9 100644 --- a/crates/sqlbuild-core/src/janitor_text/_helpers/phases.rs +++ b/crates/sqlbuild-core/src/janitor_text/_helpers/phases.rs @@ -12,6 +12,7 @@ pub(crate) fn phase_words(phase: JanitorPhase) -> (&'static str, &'static str, & JanitorPhase::DeleteArchives => ("Deleting", "Deleted", "expired archive"), JanitorPhase::DropOldNameViews => ("Dropping", "Dropped", "old-name view"), JanitorPhase::DropQueryArtifacts => ("Dropping", "Dropped", "query artifact"), + JanitorPhase::DropReviewSchemas => ("Dropping", "Dropped", "review schema"), JanitorPhase::PruneDirectState => ("Pruning", "Pruned", "direct state table"), } } diff --git a/crates/sqlbuild-core/src/janitor_text/_helpers/plan_json.rs b/crates/sqlbuild-core/src/janitor_text/_helpers/plan_json.rs index 255977ef9a..68b0f5fe12 100644 --- a/crates/sqlbuild-core/src/janitor_text/_helpers/plan_json.rs +++ b/crates/sqlbuild-core/src/janitor_text/_helpers/plan_json.rs @@ -217,6 +217,7 @@ pub(crate) fn plan_payload(plan: &JanitorPlanText) -> JsonValue { ), ("old_name_views", JsonValue::Array(views)), ("query_artifacts", texts(&plan.query_artifacts)), + ("review_schemas", texts(&plan.review_schemas)), ( "direct_state_prunes", JsonValue::Array( @@ -272,6 +273,10 @@ fn counts_payload(counts: &JanitorResultCounts) -> JsonValue { "deleted_query_artifacts", integer(counts.deleted_query_artifacts), ), + ( + "dropped_review_schemas", + integer(counts.dropped_review_schemas), + ), ( "dropped_old_name_views", integer(counts.dropped_old_name_views), diff --git a/crates/sqlbuild-core/src/janitor_text/_helpers/plan_sections.rs b/crates/sqlbuild-core/src/janitor_text/_helpers/plan_sections.rs index 70283b3565..6aebda3c01 100644 --- a/crates/sqlbuild-core/src/janitor_text/_helpers/plan_sections.rs +++ b/crates/sqlbuild-core/src/janitor_text/_helpers/plan_sections.rs @@ -123,6 +123,7 @@ pub(crate) fn summary(plan: &JanitorPlanText, style: CliStyle) -> String { text.push_str(&count_row(style, "eligible for deletion", &plan.candidates)); } text.push_str(&count_row(style, "query artifacts", &plan.query_artifacts)); + text.push_str(&count_row(style, "review schemas", &plan.review_schemas)); text.push_str(&count_row(style, "direct state pruned", &plan.state_prunes)); text.push_str(&row( "objects skipped", diff --git a/crates/sqlbuild-core/src/janitor_text/main/janitor_confirmation_prompt.rs b/crates/sqlbuild-core/src/janitor_text/main/janitor_confirmation_prompt.rs index e774f99687..b2a24deeb8 100644 --- a/crates/sqlbuild-core/src/janitor_text/main/janitor_confirmation_prompt.rs +++ b/crates/sqlbuild-core/src/janitor_text/main/janitor_confirmation_prompt.rs @@ -16,7 +16,7 @@ pub fn janitor_confirmation_prompt(plan: &JanitorPlanText) -> String { let objects: usize = if plan.direct_mode { physical_deletion_count(plan) } else { - plan.candidates.len() + plan.query_artifacts.len() + plan.candidates.len() + plan.query_artifacts.len() + plan.review_schemas.len() }; format!("Janitor will {prefix}delete {objects} objects from {environment}.\n") } else { diff --git a/crates/sqlbuild-core/src/janitor_text/main/janitor_item_counts.rs b/crates/sqlbuild-core/src/janitor_text/main/janitor_item_counts.rs index 4a754c360f..1abdce33f7 100644 --- a/crates/sqlbuild-core/src/janitor_text/main/janitor_item_counts.rs +++ b/crates/sqlbuild-core/src/janitor_text/main/janitor_item_counts.rs @@ -17,6 +17,7 @@ pub fn janitor_item_counts(items: &[JanitorItemText]) -> JanitorResultCounts { } JanitorPhase::DropOldNameViews => {} JanitorPhase::DropQueryArtifacts => counts.deleted_query_artifacts += 1, + JanitorPhase::DropReviewSchemas => counts.dropped_review_schemas += 1, JanitorPhase::PruneDirectState => counts.pruned_direct_state += 1, } } diff --git a/crates/sqlbuild-core/src/janitor_text/main/janitor_phase_flow_width.rs b/crates/sqlbuild-core/src/janitor_text/main/janitor_phase_flow_width.rs index 2920f2b22c..b33c1c5233 100644 --- a/crates/sqlbuild-core/src/janitor_text/main/janitor_phase_flow_width.rs +++ b/crates/sqlbuild-core/src/janitor_text/main/janitor_phase_flow_width.rs @@ -25,6 +25,7 @@ pub fn janitor_phase_flow_width(plan: &JanitorPlanText, phase: JanitorPhase) -> .map(|view| view.name.clone()) .collect(), JanitorPhase::DropQueryArtifacts => plan.query_artifacts.clone(), + JanitorPhase::DropReviewSchemas => plan.review_schemas.clone(), JanitorPhase::PruneDirectState => plan .state_prunes .iter() diff --git a/crates/sqlbuild-core/src/janitor_text/main/janitor_plan_has_work.rs b/crates/sqlbuild-core/src/janitor_text/main/janitor_plan_has_work.rs index bb4e554b5c..6e3ccc41ac 100644 --- a/crates/sqlbuild-core/src/janitor_text/main/janitor_plan_has_work.rs +++ b/crates/sqlbuild-core/src/janitor_text/main/janitor_plan_has_work.rs @@ -9,6 +9,7 @@ pub fn janitor_plan_has_work(plan: &JanitorPlanText) -> bool { || !plan.archive_candidates.is_empty() || !plan.archive_deletions.is_empty() || !plan.query_artifacts.is_empty() + || !plan.review_schemas.is_empty() || !plan.state_prunes.is_empty() || !plan.old_name_drops.is_empty() || !plan.old_name_missing.is_empty() diff --git a/crates/sqlbuild-core/src/janitor_text/main/render_janitor_completion.rs b/crates/sqlbuild-core/src/janitor_text/main/render_janitor_completion.rs index 8523335723..7e9a7c178b 100644 --- a/crates/sqlbuild-core/src/janitor_text/main/render_janitor_completion.rs +++ b/crates/sqlbuild-core/src/janitor_text/main/render_janitor_completion.rs @@ -16,6 +16,7 @@ pub fn render_janitor_completion( let deleted: usize = counts.deleted + counts.deleted_archives + counts.deleted_query_artifacts + + counts.dropped_review_schemas + counts.dropped_old_name_views; let mut summary: String = [ ("archived", counts.archived), diff --git a/crates/sqlbuild-core/src/janitor_text/main/render_janitor_plan.rs b/crates/sqlbuild-core/src/janitor_text/main/render_janitor_plan.rs index 4c3747f3be..16bc459cc5 100644 --- a/crates/sqlbuild-core/src/janitor_text/main/render_janitor_plan.rs +++ b/crates/sqlbuild-core/src/janitor_text/main/render_janitor_plan.rs @@ -57,6 +57,16 @@ pub fn render_janitor_plan(plan: &JanitorPlanText, verbose: bool, style: CliStyl "Eligible expired query artifacts", &artifacts, )); + let review_schemas: Vec<(String, Option)> = plan + .review_schemas + .iter() + .map(|name| (name.clone(), None)) + .collect(); + text.push_str(&listing( + (style, limit), + "Leftover review schemas", + &review_schemas, + )); let prunes: Vec<(String, Option)> = plan .state_prunes .iter() diff --git a/crates/sqlbuild-core/src/janitor_text/models.rs b/crates/sqlbuild-core/src/janitor_text/models.rs index 6011b794dd..00c60e75dc 100644 --- a/crates/sqlbuild-core/src/janitor_text/models.rs +++ b/crates/sqlbuild-core/src/janitor_text/models.rs @@ -95,6 +95,8 @@ pub struct JanitorPlanText { pub old_name_live: Vec, pub old_name_missing: Vec, pub query_artifacts: Vec, + /// Leftover review schemas to drop. + pub review_schemas: Vec, /// Direct state tables to prune and the versions each keeps. pub state_prunes: Vec<(String, i64)>, /// Skipped relations and why. @@ -114,6 +116,7 @@ pub struct JanitorResultCounts { pub deleted: usize, pub deleted_archives: usize, pub deleted_query_artifacts: usize, + pub dropped_review_schemas: usize, pub dropped_old_name_views: usize, pub pruned_direct_state: usize, } @@ -126,6 +129,7 @@ pub enum JanitorPhase { DeleteArchives, DropOldNameViews, DropQueryArtifacts, + DropReviewSchemas, PruneDirectState, } @@ -139,6 +143,7 @@ impl JanitorPhase { "delete_archives" => Some(Self::DeleteArchives), "drop_old_name_views" => Some(Self::DropOldNameViews), "drop_query_artifacts" => Some(Self::DropQueryArtifacts), + "drop_review_schemas" => Some(Self::DropReviewSchemas), "prune_direct_state" => Some(Self::PruneDirectState), _ => None, } @@ -153,6 +158,7 @@ impl JanitorPhase { Self::DeleteArchives => "delete_archives", Self::DropOldNameViews => "drop_old_name_views", Self::DropQueryArtifacts => "drop_query_artifacts", + Self::DropReviewSchemas => "drop_review_schemas", Self::PruneDirectState => "prune_direct_state", } } diff --git a/crates/sqlbuild-core/src/janitor_text/tests/test_janitor_progress_text.rs b/crates/sqlbuild-core/src/janitor_text/tests/test_janitor_progress_text.rs index ae0fa83a86..0d98b14598 100644 --- a/crates/sqlbuild-core/src/janitor_text/tests/test_janitor_progress_text.rs +++ b/crates/sqlbuild-core/src/janitor_text/tests/test_janitor_progress_text.rs @@ -233,6 +233,7 @@ fn given_finished_actions_when_counting_then_only_successes_count_by_kind() { deleted_query_artifacts: 1, dropped_old_name_views: 1, pruned_direct_state: 1, + dropped_review_schemas: 0, }, expected_archive_flow_width: 72, }]; diff --git a/crates/sqlbuild-discovery/src/constants.rs b/crates/sqlbuild-discovery/src/constants.rs index 19af2ecad6..096580ebab 100644 --- a/crates/sqlbuild-discovery/src/constants.rs +++ b/crates/sqlbuild-discovery/src/constants.rs @@ -52,7 +52,7 @@ pub const LOCAL_AUDIT_ROLE_DIRECTORY: &str = "_audits"; /// The singular audit directory below an audit role. pub const SINGULAR_AUDIT_DIRECTORY: &str = "singular"; /// Header keys a `MODEL` statement accepts, sorted by code point. -pub const SQL_MODEL_HEADER_KEYS: [&str; 73] = [ +pub const SQL_MODEL_HEADER_KEYS: [&str; 74] = [ "alias", "append_cursor_inclusive", "audit_factories", @@ -64,6 +64,7 @@ pub const SQL_MODEL_HEADER_KEYS: [&str; 73] = [ "config", "constants", "contract", + "critical", "cursor", "cursor_end", "cursor_future_action", diff --git a/crates/sqlbuild-host/src/entries/_helpers/executor.rs b/crates/sqlbuild-host/src/entries/_helpers/executor.rs index 06d7549249..637dbe5d5f 100644 --- a/crates/sqlbuild-host/src/entries/_helpers/executor.rs +++ b/crates/sqlbuild-host/src/entries/_helpers/executor.rs @@ -834,4 +834,19 @@ pub(crate) const ENTRIES: &[HostEntry] = &[ kind: HostKind::Exception, edge: HostEdge::Executor, }, + HostEntry { + name: "create_review_schema", + kind: HostKind::Function, + edge: HostEdge::Executor, + }, + HostEntry { + name: "drop_review_schema", + kind: HostKind::Function, + edge: HostEdge::Executor, + }, + HostEntry { + name: "leftover_review_schemas", + kind: HostKind::Function, + edge: HostEdge::Executor, + }, ]; diff --git a/crates/sqlbuild-host/src/entries/models.rs b/crates/sqlbuild-host/src/entries/models.rs index 531d06bd5c..44cb7aa246 100644 --- a/crates/sqlbuild-host/src/entries/models.rs +++ b/crates/sqlbuild-host/src/entries/models.rs @@ -367,6 +367,9 @@ pub enum ExecutorHost { CommandOutputStream, OutputRecordPriority, SqlValueRenderingError, + CreateReviewSchema, + DropReviewSchema, + LeftoverReviewSchemas, } impl ExecutorHost { diff --git a/crates/sqlbuild-model-config/src/model_validation/_helpers/materialization.rs b/crates/sqlbuild-model-config/src/model_validation/_helpers/materialization.rs index 46bc71a1ad..ddc75eeec5 100644 --- a/crates/sqlbuild-model-config/src/model_validation/_helpers/materialization.rs +++ b/crates/sqlbuild-model-config/src/model_validation/_helpers/materialization.rs @@ -33,6 +33,8 @@ use crate::model_validation::types::Check; use crate::types::{AuthoredNode, NodeKind}; const MIGRATE_FROM_KEY: &str = "migrate_from"; +/// The model key that marks a model whose downstream paths `sqb review` validates. +const CRITICAL_KEY: &str = "critical"; const MIGRATE_FORCE_KEY: &str = "migrate_force"; /// Check `validate_microbatch_project_capability`. @@ -96,6 +98,14 @@ pub(crate) fn check_execution_limit(config: &ConfigView<'_, N>) ))) } +/// Check that the `critical` review marker is a boolean. +pub(crate) fn check_review_marker(config: &ConfigView<'_, N>) -> Check { + match config.get(CRITICAL_KEY).map(AuthoredNode::kind) { + None | Some(NodeKind::Bool(_)) => Ok(()), + Some(_) => Err(config.error(format!("{CRITICAL_KEY} must be a boolean"))), + } +} + /// Check `validate_non_incremental_config`. pub(crate) fn check_non_incremental(config: &ConfigView<'_, N>) -> Check { if config.string("materialized")?.as_deref() == Some(INCREMENTAL_MATERIALIZATION) { diff --git a/crates/sqlbuild-model-config/src/model_validation/main/validate_model_config.rs b/crates/sqlbuild-model-config/src/model_validation/main/validate_model_config.rs index 5b299b390a..ea58dfe598 100644 --- a/crates/sqlbuild-model-config/src/model_validation/main/validate_model_config.rs +++ b/crates/sqlbuild-model-config/src/model_validation/main/validate_model_config.rs @@ -5,7 +5,7 @@ use crate::model_validation::_helpers::incremental::check_incremental; use crate::model_validation::_helpers::materialization::{ check_contract, check_custom_materialization, check_execution_limit, check_migration, check_non_incremental, check_physical_storage, check_placeholders, check_project_capability, - check_storage_policies, + check_review_marker, check_storage_policies, }; use crate::model_validation::_helpers::references::check_references; use crate::model_validation::_helpers::snapshot::check_snapshot; @@ -26,6 +26,7 @@ pub fn validate_model_config( check_project_capability(&config, project)?; check_contract(&config)?; check_execution_limit(&config)?; + check_review_marker(&config)?; check_non_incremental(&config)?; check_snapshot(&config, facts)?; check_custom_materialization(&config, project)?; diff --git a/crates/sqlbuild-model-config/src/model_validation/tests/test_validate_model_config.rs b/crates/sqlbuild-model-config/src/model_validation/tests/test_validate_model_config.rs index 9de1513ba1..6cdfdea1b5 100644 --- a/crates/sqlbuild-model-config/src/model_validation/tests/test_validate_model_config.rs +++ b/crates/sqlbuild-model-config/src/model_validation/tests/test_validate_model_config.rs @@ -33,6 +33,20 @@ fn given_effective_configs_when_validating_natively_then_python_outcomes_result( query_sql: "select 1", expected_outcome: "Model file models/marts/orders_daily.sql references table function 'order_lines' with __udf(); use __table_fn() in SQL contexts that support table-valued functions", }, + ValidateTestCase { + description: "a model marked critical for review", + config: vec![("critical", Value::Bool(true))], + references: &[], + query_sql: "select 1", + expected_outcome: "accepted", + }, + ValidateTestCase { + description: "a critical marker that is not a boolean", + config: vec![("critical", Value::Str("yes"))], + references: &[], + query_sql: "select 1", + expected_outcome: "model 'orders_daily': critical must be a boolean", + }, ValidateTestCase { description: "a dbt reference its resolver accepts", config: vec![], diff --git a/crates/sqlbuild-python/src/bindings/_helpers/build_command/invocation.rs b/crates/sqlbuild-python/src/bindings/_helpers/build_command/invocation.rs index a81e8e6660..1f1b394cb0 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/build_command/invocation.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/build_command/invocation.rs @@ -112,16 +112,19 @@ pub(crate) fn resolve_build_context<'py>( let project_dir: Bound<'py, PyAny> = or_none(native_field(request, "project_dir")?, || { imported(py, "pathlib", "Path")?.call_method0("cwd") })?; - let discovered: Bound<'py, PyAny> = discover_project_inputs( - py, - Some(keywords( - py, - &[ - ("project_dir", project_dir.clone()), - ("extract_output_column_locations", boolean(py, false)), - ], - )?), - )?; + let discovered: Bound<'py, PyAny> = + or_none(native_field(request, "discovered_inputs")?, || { + discover_project_inputs( + py, + Some(keywords( + py, + &[ + ("project_dir", project_dir.clone()), + ("extract_output_column_locations", boolean(py, false)), + ], + )?), + ) + })?; let selected_target: Bound<'py, PyAny> = native_field(request, "selected_target")?; let target_name: Bound<'py, PyAny> = resolve_target_name( py, diff --git a/crates/sqlbuild-python/src/bindings/_helpers/cli/argv.rs b/crates/sqlbuild-python/src/bindings/_helpers/cli/argv.rs index d1b06b4437..ad566ed3ed 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/cli/argv.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/cli/argv.rs @@ -6,13 +6,19 @@ use pyo3::types::{ PyBool, PyBoolMethods, PyDict, PyDictMethods, PyInt, PyList, PyListMethods, PyString, }; use pyo3::{IntoPyObjectExt, pyfunction, wrap_pyfunction}; +use sqlbuild_analysis::review_command::constants::{JSON_OPTION, REVIEW_PROG, USAGE_ERROR_CODE}; +use sqlbuild_analysis::review_command::main::render_review_json::render_review_json; +use sqlbuild_analysis::review_command::models::{ReviewFailure, ReviewReport}; use sqlbuild_cli::argv::main::parse_cli_arguments::parse_cli_arguments as parse_arguments; -use sqlbuild_cli::argv::models::{ArgvOutcome, ArgvSettings, Stream, TypeFailure, Value}; +use sqlbuild_cli::argv::models::{ + ArgvOutcome, ArgvSettings, Stream, TypeFailure, UsageFailure, Value, +}; use sqlbuild_cli::argv::types::ArgvHost; use sqlbuild_core::text::main::python_str_repr::python_str_repr; use crate::bindings::_helpers::cli::namespace::new_cli_namespace; use crate::bindings::_helpers::cli::surrogates::{python_text, rust_text, uses_placeholders}; +use crate::bindings::_helpers::recall::console::write_stdout_bytes; use crate::bindings::_helpers::records::construct::new_record_with; use crate::bindings::_helpers::records::fields::field as native_field; use crate::bindings::_helpers::records::ids::RecordId; @@ -176,6 +182,23 @@ fn object_of(py: Python<'_>, host: &PythonHost<'_>, value: Value) -> PyResult Option { + let UsageFailure { prog, message } = outcome.usage_failure.as_ref()?; + if prog != REVIEW_PROG || !arguments.iter().any(|arg| arg == JSON_OPTION) { + return None; + } + Some(render_review_json(&ReviewReport { + version: env!("CARGO_PKG_VERSION").to_owned(), + error: Some(ReviewFailure { + code: USAGE_ERROR_CODE.to_owned(), + message: message.clone(), + help: Some("run sqb review --help for the accepted options".to_owned()), + }), + ..ReviewReport::default() + })) +} + /// Parse `argv` onto `defaults`, printing help, version and errors; the attributes or exit status. fn parse_cli_arguments( argv: &[Bound<'_, PyString>], @@ -208,6 +231,7 @@ fn parse_cli_arguments( values.push((name, host.value_of(&object)?)); } let outcome: ArgvOutcome = parse_arguments(&arguments, values, settings, &mut host); + let review_usage: Option = review_usage_document(&arguments, &outcome); let sys: Bound<'_, PyModule> = py.import("sys")?; for (stream, text) in outcome.writes { let name: &str = match stream { @@ -225,6 +249,9 @@ fn parse_cli_arguments( return Err(error); } } + if let Some(document) = review_usage { + write_stdout_bytes(py, document.as_bytes())?; + } match outcome.parsed { Err(code) => Ok((None, Some(code))), Ok(namespace) => { diff --git a/crates/sqlbuild-python/src/bindings/_helpers/cli/dispatch.rs b/crates/sqlbuild-python/src/bindings/_helpers/cli/dispatch.rs index fe254c55e8..f255f63599 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/cli/dispatch.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/cli/dispatch.rs @@ -30,6 +30,7 @@ use crate::bindings::_helpers::query_command::query::run_query_command; use crate::bindings::_helpers::recall::command::run_recall_command; use crate::bindings::_helpers::records::construct::{new_record_with, record_class}; use crate::bindings::_helpers::refactor::refactor_command::run_refactor_command; +use crate::bindings::_helpers::review_command::command::run_review_command; use crate::bindings::_helpers::scenario_command::capture_command::run_scenario_capture_command; use crate::bindings::_helpers::scenario_command::test_command::run_scenario_test_command; use crate::bindings::_helpers::scopes::scope_command::run_scope_command; @@ -397,6 +398,7 @@ fn run_implementation<'py>( "run_query_command" => run_query_command(py, options())?, "run_recall_command" => run_recall_command(py, options())?, "run_refactor_command" => run_refactor_command(py, &request()?)?, + "run_review_command" => run_review_command(py, &request()?)?, "run_rules_command" => run_rules_command(&request()?)?, "run_scenario_capture_command" => run_scenario_capture_command(py, &request()?)?, "run_scenario_test_command" => run_scenario_test_command(py, &request()?)?, diff --git a/crates/sqlbuild-python/src/bindings/_helpers/cli/registration.rs b/crates/sqlbuild-python/src/bindings/_helpers/cli/registration.rs index f829d01f1b..90cea0cf56 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/cli/registration.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/cli/registration.rs @@ -9,8 +9,8 @@ use crate::bindings::_helpers::{ compute_logs, contract_command, cost_command, cost_report, cost_telemetry, dbt_command, dbt_events, dbt_interop, debug_command, diff_report, event_exporting, execution_history, format_command, freshness, init_command, janitor, load, output_capture, plan_command, - presentation_text, query_command, recall, result_output, scenario_command, seed_command, - sql_test_command, workspace_commands, + presentation_text, query_command, recall, result_output, review_command, scenario_command, + seed_command, sql_test_command, workspace_commands, }; pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { @@ -38,6 +38,7 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { dbt_events::registration::register(module)?; dbt_interop::registration::register(module)?; diff_report::registration::register(module)?; + review_command::registration::register(module)?; command_runtime::registration::register(module)?; presentation_text::registration::register(module)?; workspace_commands::registration::register(module)?; diff --git a/crates/sqlbuild-python/src/bindings/_helpers/clone/clone_command.rs b/crates/sqlbuild-python/src/bindings/_helpers/clone/clone_command.rs index 674c72b338..25ae035e11 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/clone/clone_command.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/clone/clone_command.rs @@ -142,10 +142,15 @@ fn invocation<'py>(py: Python<'py>, request: &Bound<'py, PyAny>) -> PyResult = py.import("sys")?.getattr("stdout")?; let progress: Bound<'py, PlanningProgressReporter> = progress_reporter(&stream, style)?; progress.get().start(py, "Discovering project...")?; - let discovered: Bound<'py, PyAny> = discover_project_inputs( - py, - Some(keywords(py, &[("project_dir", project_dir.clone())])?), - )?; + let supplied: Bound<'py, PyAny> = native_field(request, "discovered_inputs")?; + let discovered: Bound<'py, PyAny> = if supplied.is_none() { + discover_project_inputs( + py, + Some(keywords(py, &[("project_dir", project_dir.clone())])?), + )? + } else { + supplied + }; progress.get().complete(py, "Discovered project.")?; reject_unit_test_selectors( &native_field(request, "select")?, diff --git a/crates/sqlbuild-python/src/bindings/_helpers/diff_report/command.rs b/crates/sqlbuild-python/src/bindings/_helpers/diff_report/command.rs index 0c84319d67..65069a5c87 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/diff_report/command.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/diff_report/command.rs @@ -220,7 +220,7 @@ fn run_query_diff( } } -fn target_max_full_rows<'py>( +pub(crate) fn target_max_full_rows<'py>( discovered: &Bound<'py, PyAny>, target: &Bound<'py, PyAny>, ) -> PyResult> { diff --git a/crates/sqlbuild-python/src/bindings/_helpers/discovery/project_config.rs b/crates/sqlbuild-python/src/bindings/_helpers/discovery/project_config.rs index e695e2ed5e..3d594e56e0 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/discovery/project_config.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/discovery/project_config.rs @@ -12,9 +12,10 @@ use sqlbuild_config::project::errors::{ConfigFailure, FailureClass, LoadFailure} use sqlbuild_config::project::main::load_git_base_ref::load_git_base_ref; use sqlbuild_config::project::main::load_local_config::load_local_config; use sqlbuild_config::project::main::load_project_config::load_project_config; +use sqlbuild_config::project::main::load_review_config::load_review_config; use sqlbuild_config::project::main::local_sql_analysis_failure::local_sql_analysis_failure; use sqlbuild_config::project::main::resolve_config_file::resolve_config_file; -use sqlbuild_config::project::models::{Built, ConfigFile, ConfigPaths}; +use sqlbuild_config::project::models::{Built, ConfigFile, ConfigPaths, ReviewConfig}; use crate::bindings::_helpers::boundary::config_values::config_value_to_python; use crate::bindings::_helpers::records::construct::error_class; @@ -181,6 +182,13 @@ pub(crate) fn configured_git_base_ref(project_dir: &Bound<'_, PyAny>) -> PyResul .or_else(|failure| Err(load_error(project_dir.py(), &failure, &files)?)) } +/// `[review]` of the project at `project_dir`: the defaults `sqb review` falls back to. +pub(crate) fn configured_review(project_dir: &Bound<'_, PyAny>) -> PyResult { + let files = files(project_dir)?; + load_review_config(&files.native) + .or_else(|failure| Err(load_error(project_dir.py(), &failure, &files)?)) +} + /// `load_local_config`: the validated `LocalConfig` of `project_dir`, or the default. #[pyfunction] pub(crate) fn load_local_config_file( diff --git a/crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_phases.rs b/crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_phases.rs index 9337b93031..3686f37109 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_phases.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_phases.rs @@ -33,11 +33,12 @@ use crate::bindings::_helpers::records::ids::ErrorId; const UNKNOWN_VIEW_CODE: &str = "C503"; const UNKNOWN_VIEW_HELP: &str = "Run sqb janitor without --drop-old-name-view to list compatibility views."; -const CANDIDATE_FIELDS: [&str; 4] = [ +const CANDIDATE_FIELDS: [&str; 5] = [ "candidates", "archive_deletion_candidates", "query_diff_artifact_candidates", "direct_state_prune_candidates", + "review_schema_candidates", ]; /// The compiled project, its adapter and the active target connection settings. diff --git a/crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_plan.rs b/crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_plan.rs index 36f184cd43..1a61b7fb12 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_plan.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_plan.rs @@ -44,6 +44,20 @@ fn age_days(value: &Bound<'_, PyAny>, planned_at: &Bound<'_, PyAny>) -> PyResult )) } +/// `database.schema` of each leftover review schema, or the schema alone without a database. +fn review_schema_names(items: &Bound<'_, PyAny>) -> PyResult> { + let mut names: Vec = Vec::new(); + for item in items.try_iter()? { + let item: Bound<'_, PyAny> = item?; + let schema: String = native_field(&item, "schema")?.extract()?; + let database: Option = native_field(&item, "database")?.extract()?; + names.push( + database.map_or_else(|| schema.clone(), |database| format!("{database}.{schema}")), + ); + } + Ok(names) +} + fn names(items: &Bound<'_, PyAny>) -> PyResult> { items.try_iter()?.map(|item| key_name(&item?)).collect() } @@ -231,6 +245,7 @@ pub(crate) fn janitor_plan_text(plan: &Bound<'_, PyAny>) -> PyResult) -> PyResult(candidates: &[Candidate<'py>]) -> PyResult( + project: &Bound<'py, PyAny>, + warehouse: &Warehouse<'py>, + now: &Bound<'py, PyAny>, +) -> PyResult>> { + let py: Python<'py> = warehouse.py(); + let schema: Bound<'py, PyAny> = native_field(project, "effective_target_schema")?; + if schema.is_none() { + return Ok(Vec::new()); + } + let max_age: Bound<'py, PyAny> = days(py, LEFTOVER_REVIEW_SCHEMA_AGE_DAYS)?; + let leftovers: Bound<'py, PyAny> = call_with( + &host::entry(py, ExecutorHost::LeftoverReviewSchemas)?, + &[ + ("adapter", warehouse.adapter.clone()), + ("connection", warehouse.connection.clone()), + ( + "database", + native_field(project, "effective_target_database")?, + ), + ("schema", schema), + ("now", now.clone()), + ("max_age", max_age), + ], + )?; + leftovers.try_iter()?.collect() +} + /// The plan for a project whose target schemas list nothing beyond query-diff artifacts. fn artifact_only_plan<'py>( (project, warehouse): (&Bound<'py, PyAny>, &Warehouse<'py>), settings: &JanitorSettings, (artifacts, artifact_skips, scanned): (Vec>, Vec>, usize), - now: &Bound<'py, PyAny>, + (review_schemas, now): (Vec>, &Bound<'py, PyAny>), ) -> PyResult> { let py: Python<'py> = warehouse.py(); record( @@ -144,6 +174,7 @@ fn artifact_only_plan<'py>( ), ("query_diff_artifact_candidates", tuple(py, artifacts)?), ("direct_state_prune_candidates", tuple(py, [])?), + ("review_schema_candidates", tuple(py, review_schemas)?), ("skipped_relations", tuple(py, artifact_skips)?), ("scanned_schema_count", int_field(py, scanned)?), ( @@ -186,13 +217,15 @@ pub(crate) fn build_plan<'py>( let now: Bound<'py, PyAny> = now_utc(py)?; let (artifacts, artifact_skips) = query_diff_artifact_candidates(warehouse, &artifact_schemas, &now)?; + let review_schemas: Vec> = + review_schema_candidates(project, warehouse, &now)?; if target_schemas.is_empty() { let scanned: usize = artifact_schemas.len(); return artifact_only_plan( (project, warehouse), settings, (artifacts, artifact_skips, scanned), - &now, + (review_schemas, &now), ); } let lifecycle: Bound<'py, PyAny> = host::entry(py, LIFECYCLE)?; @@ -296,6 +329,7 @@ pub(crate) fn build_plan<'py>( ("retained_archives", tuple(py, archives.retained)?), ("query_diff_artifact_candidates", tuple(py, artifacts)?), ("direct_state_prune_candidates", tuple(py, state_prunes)?), + ("review_schema_candidates", tuple(py, review_schemas)?), ("skipped_relations", tuple(py, skipped)?), ("skipped_schemas", tuple(py, schemas.skipped_schemas)?), ("blocked_schemas", tuple(py, archives.blocked_schemas)?), diff --git a/crates/sqlbuild-python/src/bindings/_helpers/lineage/column_lineage_graph.rs b/crates/sqlbuild-python/src/bindings/_helpers/lineage/column_lineage_graph.rs index 75e3d8fc95..c47a4df49e 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/lineage/column_lineage_graph.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/lineage/column_lineage_graph.rs @@ -245,7 +245,7 @@ impl ProjectColumnLineage { /// Return direct downstream consumers of `resource_name.column_name`. #[pyo3(signature = (*, resource_name, column_name))] - fn column_consumers<'py>( + pub(crate) fn column_consumers<'py>( slf: &Bound<'py, Self>, resource_name: &str, column_name: &Bound<'py, PyAny>, diff --git a/crates/sqlbuild-python/src/bindings/_helpers/mod.rs b/crates/sqlbuild-python/src/bindings/_helpers/mod.rs index ae28284cad..457ca748d9 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/mod.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/mod.rs @@ -93,6 +93,7 @@ pub(crate) mod references; pub(crate) mod relation_naming; pub(crate) mod render; pub(crate) mod result_output; +pub(crate) mod review_command; pub(crate) mod rule_records; pub(crate) mod rules; pub(crate) mod scenario_command; diff --git a/crates/sqlbuild-python/src/bindings/_helpers/records/constants.rs b/crates/sqlbuild-python/src/bindings/_helpers/records/constants.rs index b56fb805f9..848655d07e 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/records/constants.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/records/constants.rs @@ -107,6 +107,18 @@ const CLI_ERRORS: &[ErrorSpec] = &[ ("exit_code", ClassValue::Int(3)), ], }, + ErrorSpec { + name: "ReviewFailedError", + parent: "CliUserError", + doc: "A review that could not set up, run or clean up; its report is already written.", + class_attributes: &[ + ("code", ClassValue::Text("C760")), + ("exit_code", ClassValue::Int(3)), + ], + required: &[], + optional: &[], + fixed: &[], + }, ErrorSpec { name: "DiffSizeGuardError", parent: "CliUserError", @@ -256,6 +268,7 @@ const BUILD_COMMAND_REQUEST: RecordSpec = RecordSpec { FieldSpec::new("selection_diagnostics", FieldDefault::False), FieldSpec::new("selector_files", FieldDefault::EmptyTuple), FieldSpec::new("test_timeout", FieldDefault::None), + FieldSpec::new("discovered_inputs", FieldDefault::None), ], }; const BUILD_INVOCATION: RecordSpec = RecordSpec { @@ -326,6 +339,7 @@ const CLONE_COMMAND_REQUEST: RecordSpec = RecordSpec { FieldSpec::new("cli_vars", FieldDefault::None), FieldSpec::new("json_output_path", FieldDefault::None), FieldSpec::new("event_output_path", FieldDefault::None), + FieldSpec::new("discovered_inputs", FieldDefault::None), ], }; const DBT_SQLBUILD_WORK_CONTEXT: RecordSpec = RecordSpec { @@ -414,6 +428,29 @@ const DIFF_COMMAND_REQUEST: RecordSpec = RecordSpec { FieldSpec::new("full_example_values", FieldDefault::False), ], }; +const REVIEW_COMMAND_REQUEST: RecordSpec = RecordSpec { + name: "ReviewCommandRequest", + doc: "CLI inputs for one `sqb review` invocation.", + fields: &[ + FieldSpec::new("project_dir", FieldDefault::None), + FieldSpec::new("no_color", FieldDefault::False), + FieldSpec::new("no_sql_validation", FieldDefault::False), + FieldSpec::new("selected_target", FieldDefault::None), + FieldSpec::new("select", FieldDefault::EmptyTuple), + FieldSpec::new("cli_vars", FieldDefault::None), + FieldSpec::new("json_output", FieldDefault::False), + FieldSpec::new("since", FieldDefault::None), + FieldSpec::new("against", FieldDefault::None), + FieldSpec::new("downstream", FieldDefault::None), + FieldSpec::new("dry_run", FieldDefault::False), + FieldSpec::new("no_clone", FieldDefault::False), + FieldSpec::new("bounded", FieldDefault::None), + FieldSpec::new("markdown_path", FieldDefault::None), + FieldSpec::new("concurrency", FieldDefault::None), + FieldSpec::new("verbose", FieldDefault::False), + FieldSpec::new("debug", FieldDefault::False), + ], +}; const QUERY_DIFF_PREPARATION: RecordSpec = RecordSpec { name: "QueryDiffPreparation", doc: "Resolved raw-query diff adapter, connection, SQL, and limits.", @@ -828,6 +865,7 @@ const COMMAND_RECORDS: &[RecordSpec] = &[ PLAN_SAFETY_GATES, DBT_INIT_COMMAND_REQUEST, DIFF_COMMAND_REQUEST, + REVIEW_COMMAND_REQUEST, QUERY_DIFF_PREPARATION, FRESHNESS_COMMAND_REQUEST, JANITOR_COMMAND_REQUEST, @@ -943,6 +981,7 @@ const CLI_ENTRYPOINT_HANDLERS: RecordSpec = RecordSpec { FieldSpec::new("run_contract", FieldDefault::None), FieldSpec::new("run_refactor", FieldDefault::None), FieldSpec::new("run_recall", FieldDefault::None), + FieldSpec::new("run_review", FieldDefault::None), ], }; const COMMAND_WAREHOUSE_SCOPE: RecordSpec = RecordSpec { @@ -1517,6 +1556,7 @@ const CLI_COMMAND: EnumSpec = EnumSpec { ("SEED", "seed"), ("CLONE", "clone"), ("DIFF", "diff"), + ("REVIEW", "review"), ("DEBUG", "debug"), ("LINEAGE", "lineage"), ("QUERY", "query"), diff --git a/crates/sqlbuild-python/src/bindings/_helpers/records/ids.rs b/crates/sqlbuild-python/src/bindings/_helpers/records/ids.rs index ac5e886fd0..86654230fb 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/records/ids.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/records/ids.rs @@ -412,6 +412,7 @@ native_ids! { QueryShape => "QueryShape", RefactorCommandRequest => "RefactorCommandRequest", RefactorProject => "RefactorProject", + ReviewCommandRequest => "ReviewCommandRequest", ReferenceRename => "ReferenceRename", ReferencesConfig => "ReferencesConfig", RelatedLocation => "RelatedLocation", @@ -632,6 +633,7 @@ native_ids! { RefactorIO => "RefactorIOError", RefactorValue => "RefactorValueError", ResourceIdentity => "ResourceIdentityError", + ReviewFailed => "ReviewFailedError", RuleAssertion => "RuleAssertionError", RuleUsage => "RuleUsageError", RulesConfig => "RulesConfigError", diff --git a/crates/sqlbuild-python/src/bindings/_helpers/review_command/command.rs b/crates/sqlbuild-python/src/bindings/_helpers/review_command/command.rs new file mode 100644 index 0000000000..14d8a121fc --- /dev/null +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/command.rs @@ -0,0 +1,753 @@ +//! `sqb review`: plan the changed set, build it in a run-owned schema, test, compare and report. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; +use std::time::Instant; + +use pyo3::exceptions::PyException; +use pyo3::prelude::{ + Bound, PyAny, PyAnyMethods, PyErr, PyModule, PyModuleMethods, PyResult, Python, +}; +use pyo3::{IntoPyObject, pyfunction, wrap_pyfunction}; +use sqlbuild_analysis::review_command::constants::{ + AUDIT_FINDING, BUILT_NODE, FAILED_NODE, FINDING_NODE, MARKDOWN_WRITE_CODE, MODEL_KIND, + NO_COMPARISON_MODE, NOT_VALIDATED_NODE, OK_NODE, PASSES_LIMIT, PLANNED_STATUS, SEED_KIND, + TEST_FINDING, UNIT_TEST_LABEL, +}; +use sqlbuild_analysis::review_command::main::downstream_policy_name::downstream_policy_name; +use sqlbuild_analysis::review_command::main::plan_review::plan_review; +use sqlbuild_analysis::review_command::main::render_review_json::render_review_json; +use sqlbuild_analysis::review_command::main::render_review_markdown::render_review_markdown; +use sqlbuild_analysis::review_command::main::render_review_text::render_review_text; +use sqlbuild_analysis::review_command::main::review_outcome::review_outcome; +use sqlbuild_analysis::review_command::models::{ + IncompleteCheck, NodeReport, ReviewFailure, ReviewFinding, ReviewNode, ReviewPlan, + ReviewReport, ReviewSchemaReport, ValidationCounts, +}; +use sqlbuild_core::git_revisions::main::head_commit::head_commit; + +use crate::bindings::_helpers::clone::clone_progress::write_line; +use crate::bindings::_helpers::recall::console::{write_stderr, write_stdout_bytes}; +use crate::bindings::_helpers::records::fields::field as native_field; +use crate::bindings::_helpers::review_command::comparison::{ + ComparisonContext, Comparisons, Sides, compare_models, prepare_sides, +}; +use crate::bindings::_helpers::review_command::execution::{ + BuildOutcome, RunInputs, build_validated, incremental_starts, run_changed_tests, +}; +use crate::bindings::_helpers::review_command::impact::affected_columns; +use crate::bindings::_helpers::review_command::planning::{ + Compiled, ReviewProject, ReviewSettings, discover, review_project, review_settings, + selected_tokens, +}; +use crate::bindings::_helpers::review_command::schema::{ + ReviewSchema, connection_config, create_review_schema, drop_review_schema, review_schema, + with_review_target, +}; +use crate::bindings::_helpers::review_command::support::{ + error_parts, failed_error, invocation_id, phase, plan_error, progress_stream, utc_now_compact, +}; + +const SUCCESS_STATUS: &str = "success"; +const SKIPPED_STATUS: &str = "skipped"; +const REVIEW_WORK_DIRECTORY: [&str; 2] = ["target", "review"]; +const BUILD_CHECK: &str = "build"; +const COMPARISON_CHECK: &str = "comparison"; +const SKIPPED_REASON: &str = "skipped after an upstream failure"; +const UNCOMPARED_SEED_REASON: &str = + "seeds are not compared: the diff pipeline compares models only"; +const NEW_NODE_REASON: &str = "new in this branch; nothing in --against to compare"; + +/// The nodes the changed set reaches, with edges between them. +fn impact_edges(project: &ReviewProject, plan: &ReviewPlan) -> Vec<(String, String)> { + let affected: BTreeSet<&str> = plan + .changed + .iter() + .map(|node| node.name.as_str()) + .chain(plan.not_validated.iter().map(|node| node.name.as_str())) + .chain(plan.validated.iter().map(String::as_str)) + .collect(); + let mut edges: Vec<(String, String)> = Vec::new(); + for (upstream, children) in &project.review_graph.downstream { + if !affected.contains(upstream.as_str()) { + continue; + } + for child in children { + if affected.contains(child.as_str()) { + edges.push((upstream.clone(), child.clone())); + } + } + } + edges +} + +/// Upstream nodes of the validated set that the build reads from `--against`. +fn deferred(project: &ReviewProject, validated: &[String]) -> Vec { + let built: BTreeSet<&str> = validated.iter().map(String::as_str).collect(); + let mut upstream: BTreeSet = BTreeSet::new(); + for (parent, children) in &project.review_graph.downstream { + if !built.contains(parent.as_str()) + && children.iter().any(|child| built.contains(child.as_str())) + { + upstream.insert(parent.clone()); + } + } + project + .review_graph + .order + .iter() + .filter(|name| upstream.contains(*name)) + .cloned() + .collect() +} + +/// The impact tree's nodes before anything runs: planned, or not validated. +fn planned_nodes(project: &ReviewProject, plan: &ReviewPlan) -> Vec { + let changed: BTreeSet<&str> = plan.changed.iter().map(|node| node.name.as_str()).collect(); + let validated: BTreeSet<&str> = plan.validated.iter().map(String::as_str).collect(); + let left_out: BTreeMap<&str, &str> = plan + .not_validated + .iter() + .map(|node| (node.name.as_str(), node.reason.as_str())) + .collect(); + project + .review_graph + .order + .iter() + .filter(|name| validated.contains(name.as_str()) || left_out.contains_key(name.as_str())) + .map(|name| { + let node: &ReviewNode = &project.review_graph.nodes[name]; + let reason: Option<&str> = left_out.get(name.as_str()).copied(); + NodeReport { + name: name.clone(), + kind: node.kind.clone(), + critical: node.critical, + changed: changed.contains(name.as_str()), + status: match reason { + Some(_) => NOT_VALIDATED_NODE.to_owned(), + None => PLANNED_STATUS.to_owned(), + }, + reason: reason.map(str::to_owned), + ..NodeReport::default() + } + }) + .collect() +} + +/// A report and how far the phases that filled it got. +struct Attempt { + report: ReviewReport, + outcome: PyResult<()>, +} + +/// The findings of failed audits and unit tests. +fn check_findings(build: &BuildOutcome, tests: &ValidationCounts) -> Vec { + let audits = build + .failed_audits + .iter() + .map(|(node, audit)| ReviewFinding { + node: node.clone(), + kind: AUDIT_FINDING.to_owned(), + metric: format!("audit {audit} failed"), + limit: PASSES_LIMIT.to_owned(), + ..ReviewFinding::default() + }); + let unit_tests = tests.failures.iter().map(|failure| ReviewFinding { + node: failure.trim_start_matches(UNIT_TEST_LABEL).to_owned(), + kind: TEST_FINDING.to_owned(), + metric: "unit test failed".to_owned(), + limit: PASSES_LIMIT.to_owned(), + ..ReviewFinding::default() + }); + audits.chain(unit_tests).collect() +} + +/// A built node's status from its build result, audits and comparison. +fn settled_status( + node: &NodeReport, + build: &BuildOutcome, + with_findings: &BTreeSet, +) -> (String, Option) { + let status: Option<&str> = node.build_status.as_deref(); + let blocked: bool = build.audit_blocked.contains(&node.name); + let compared: bool = node + .comparison + .as_ref() + .is_some_and(|comparison| comparison.complete && comparison.mode != NO_COMPARISON_MODE); + let reason: Option = node.reason.clone(); + match status { + _ if with_findings.contains(&node.name) => (FINDING_NODE.to_owned(), reason), + Some(SKIPPED_STATUS) => ( + NOT_VALIDATED_NODE.to_owned(), + Some(SKIPPED_REASON.to_owned()), + ), + Some(SUCCESS_STATUS) | None if blocked => (FINDING_NODE.to_owned(), reason), + Some(SUCCESS_STATUS) if compared => (OK_NODE.to_owned(), reason), + Some(SUCCESS_STATUS) => (BUILT_NODE.to_owned(), reason), + _ if blocked => (FINDING_NODE.to_owned(), reason), + _ => (FAILED_NODE.to_owned(), reason), + } +} + +/// Checks that never ran: skipped builds and built seeds, which no comparison covers. +fn uncovered_checks(nodes: &[NodeReport], build: &BuildOutcome) -> Vec { + nodes + .iter() + .filter_map(|node| { + let status: Option<&str> = build.statuses.get(&node.name).map(String::as_str); + let (check, reason) = match status { + Some(SKIPPED_STATUS) => (BUILD_CHECK, SKIPPED_REASON), + Some(SUCCESS_STATUS) if node.kind == SEED_KIND => { + (COMPARISON_CHECK, UNCOMPARED_SEED_REASON) + } + _ => return None, + }; + Some(IncompleteCheck { + node: node.name.clone(), + check: check.to_owned(), + reason: reason.to_owned(), + }) + }) + .collect() +} + +/// Settle each node's status from its build, audits, tests and comparison. +fn settle_nodes( + report: ReviewReport, + (build, comparisons): (&BuildOutcome, Comparisons), + (affected, new): (&BTreeMap>, &BTreeSet), +) -> ReviewReport { + let Comparisons { + mut findings, + mut by_node, + mut incomplete, + } = comparisons; + findings.extend(check_findings(build, &report.tests)); + let with_findings: BTreeSet = findings + .iter() + .map(|finding| finding.node.clone()) + .collect(); + let built: Vec = report + .nodes + .iter() + .filter(|node| node.status != NOT_VALIDATED_NODE) + .map(|node| node.name.clone()) + .collect(); + let nodes: Vec = report + .nodes + .into_iter() + .map(|node| { + let affected_columns: Vec = + affected.get(&node.name).cloned().unwrap_or_default(); + if !built.contains(&node.name) { + return NodeReport { + affected_columns, + ..node + }; + } + let comparison = by_node.remove(&node.name); + let new_relation: bool = new.contains(&node.name) + && comparison + .as_ref() + .is_some_and(|comparison| comparison.mode == NO_COMPARISON_MODE); + let built = NodeReport { + affected_columns, + build_status: build.statuses.get(&node.name).cloned(), + comparison, + reason: new_relation + .then(|| NEW_NODE_REASON.to_owned()) + .or(node.reason.clone()), + ..node + }; + let (status, reason) = settled_status(&built, build, &with_findings); + NodeReport { + status, + reason, + ..built + } + }) + .collect(); + let built_nodes: Vec = nodes + .iter() + .filter(|node| built.contains(&node.name)) + .cloned() + .collect(); + incomplete.extend(uncovered_checks(&built_nodes, build)); + ReviewReport { + nodes, + findings, + incomplete, + ..report + } +} + +/// The directory the review's sub-runs write their result documents to. +fn work_directory(project: &ReviewProject, schema: &ReviewSchema) -> PyResult { + let work_dir: PathBuf = REVIEW_WORK_DIRECTORY + .iter() + .fold(project.project_dir.clone(), |path, part| path.join(part)) + .join(&schema.name); + std::fs::create_dir_all(&work_dir) + .map_err(|error| pyo3::exceptions::PyOSError::new_err(error.to_string()))?; + Ok(work_dir) +} + +/// A run's settings, plan and compiled project, then its connection config and connection. +type RunContext<'a, 'py> = ( + (&'a ReviewSettings, &'a ReviewProject, &'a Compiled<'py>), + (&'a Bound<'py, PyAny>, &'a Bound<'py, PyAny>), +); + +/// The models a comparison covers: validated models that built. +fn comparable<'a>( + project: &'a ReviewProject, + validated: &[String], + build: &BuildOutcome, +) -> Vec<&'a ReviewNode> { + validated + .iter() + .filter(|name| build.statuses.get(*name).map(String::as_str) == Some(SUCCESS_STATUS)) + .filter_map(|name| project.review_graph.nodes.get(name)) + .filter(|node| node.kind == MODEL_KIND) + .collect() +} + +/// Validated nodes new since the base revision. +fn new_nodes(report: &ReviewReport) -> BTreeSet { + report + .plan + .changed + .iter() + .filter(|node| node.added) + .map(|node| node.name.clone()) + .collect() +} + +/// Everything that needs the review schema: clone, build, tests, comparison and impact. +fn validate( + (request, stream, machine): (&Bound<'_, PyAny>, &Bound<'_, PyAny>, bool), + (((settings, project, compiled), (connection_config, connection)), schema): ( + RunContext<'_, '_>, + &ReviewSchema, + ), + (review_discovered, sides): (&Bound<'_, PyAny>, Option<&Sides<'_>>), + report: ReviewReport, +) -> Attempt { + let mut report: ReviewReport = report; + let outcome: PyResult<()> = (|| { + let (_, adapter, graph) = compiled; + let work_dir: PathBuf = work_directory(project, schema)?; + let inputs: RunInputs<'_, '_> = RunInputs { + request, + project_dir: &project.project_dir, + review_discovered, + review_target: &schema.name, + work_dir: &work_dir, + output: (stream, machine), + }; + let validated: Vec = report.plan.validated.clone(); + let stateful: Vec = validated + .iter() + .filter(|name| project.stateful.contains(*name)) + .cloned() + .collect(); + report.incremental_starts = incremental_starts(&inputs, (settings, adapter), &stateful)?; + let build: BuildOutcome = phase( + stream, + ( + &format!( + "Building {} changed and downstream nodes into {} (unchanged upstream from {})", + validated.len(), + schema.name, + settings.against + ), + "Built the review nodes", + "Review build failed", + ), + || build_validated(&inputs, settings, (&project.review_graph, &validated)), + )?; + report.audits = build.audits.clone(); + report.tests = phase( + stream, + ( + &format!("Running unit tests affected since {}", project.reference), + "Ran affected unit tests", + "Unit tests failed to run", + ), + || run_changed_tests(&inputs, settings), + )?; + let models: Vec<&ReviewNode> = comparable(project, &validated, &build); + let new: BTreeSet = new_nodes(&report); + let context: ComparisonContext<'_, '_> = ComparisonContext { + request, + discovered: review_discovered, + adapter, + connection, + connection_config, + review_target: &schema.name, + }; + let comparisons: Comparisons = phase( + stream, + ( + &format!( + "Comparing {} models with {}", + models.len(), + settings.against + ), + "Compared the review models", + "Comparison failed", + ), + || compare_models(&context, sides, (&models, &new)), + )?; + let affected_set: BTreeSet = + report.nodes.iter().map(|node| node.name.clone()).collect(); + let changed: Vec = report + .plan + .changed + .iter() + .map(|node| node.name.clone()) + .collect(); + let affected: BTreeMap> = affected_columns( + &native_field(graph, "project")?, + (&changed, &affected_set), + &comparisons.by_node, + )?; + report = settle_nodes( + std::mem::take(&mut report), + (&build, comparisons), + (&affected, &new), + ); + Ok(()) + })(); + Attempt { report, outcome } +} + +/// What dropping the review schema left: dropped, or the reason it was not. +fn dropped_state(py: Python<'_>, dropped: PyResult) -> PyResult<(bool, Option)> { + match dropped { + Ok(true) => Ok((true, None)), + Ok(false) => Ok(( + false, + Some("the schema still exists after the drop".to_owned()), + )), + Err(error) => Ok((false, Some(error_parts(py, &error)?.message))), + } +} + +/// Name this run's review schema, refusing nodes that would build outside it, then connect. +fn open_review<'py>( + request: &Bound<'py, PyAny>, + (settings, project, compiled): (&ReviewSettings, &ReviewProject, &Compiled<'py>), + report: &ReviewReport, +) -> PyResult<(Bound<'py, PyAny>, Bound<'py, PyAny>, ReviewSchema)> { + let py: Python<'py> = request.py(); + let (discovered, adapter, _) = compiled; + let run_id: String = report + .invocation_id + .clone() + .unwrap_or_else(|| format!("{:x}", std::process::id())); + let schema: ReviewSchema = review_schema( + py, + (project, settings, &report.plan.validated), + (utc_now_compact(py)?, &report.baseline.head, &run_id), + )?; + let config: Bound<'py, PyAny> = connection_config( + request, + (&(&project.project_dir).into_pyobject(py)?, discovered), + settings, + )?; + let connection: Bound<'py, PyAny> = adapter.call_method1("connect", (&config,))?; + Ok((config, connection, schema)) +} + +/// The review target, and both sides of the comparison compiled before anything is created. +fn prepare<'py>( + request: &Bound<'py, PyAny>, + ((settings, project, compiled), (config, connection)): RunContext<'_, 'py>, + (schema, report): (&ReviewSchema, &ReviewReport), +) -> PyResult<(Bound<'py, PyAny>, Option>)> { + let (discovered, adapter, _) = compiled; + let review_discovered: Bound<'py, PyAny> = with_review_target(discovered, settings, schema)?; + let models: Vec = report + .plan + .validated + .iter() + .filter(|name| { + project + .review_graph + .nodes + .get(*name) + .is_some_and(|node| node.kind == MODEL_KIND) + }) + .cloned() + .collect(); + let context: ComparisonContext<'_, 'py> = ComparisonContext { + request, + discovered: &review_discovered, + adapter, + connection, + connection_config: config, + review_target: &schema.name, + }; + let sides: Option> = + prepare_sides(&context, settings, (&models, &new_nodes(report)))?; + Ok((review_discovered, sides)) +} + +/// Create the review schema, validate inside it, and always drop it. +fn run_in_schema( + (request, stream, machine): (&Bound<'_, PyAny>, &Bound<'_, PyAny>, bool), + (settings, project, compiled): (&ReviewSettings, &ReviewProject, &Compiled<'_>), + report: ReviewReport, +) -> Attempt { + let py: Python<'_> = request.py(); + let (config, connection, schema) = + match open_review(request, (settings, project, compiled), &report) { + Ok(opened) => opened, + Err(error) => { + return Attempt { + report, + outcome: Err(error), + }; + } + }; + let adapter: &Bound<'_, PyAny> = &compiled.1; + let run: RunContext<'_, '_> = ((settings, project, compiled), (&config, &connection)); + let mut state: ReviewSchemaReport = ReviewSchemaReport { + database: schema.database.clone(), + name: schema.name.clone(), + ..ReviewSchemaReport::default() + }; + let prepared = phase( + stream, + ( + &format!("Checking the relations to compare in {}", settings.against), + "Checked the relations to compare", + "Could not prepare the comparison", + ), + || prepare(request, run, (&schema, &report)), + ); + let created: PyResult<(Bound<'_, PyAny>, Option>)> = prepared.and_then(|ready| { + phase( + stream, + ( + &format!("Creating review schema {}", schema.name), + "Created the review schema", + "Could not create the review schema", + ), + || create_review_schema((adapter, &connection), &schema), + ) + .map(|()| ready) + }); + let attempt: Attempt = match created { + Ok((review_discovered, sides)) => { + state.created = true; + validate( + (request, stream, machine), + (run, &schema), + (&review_discovered, sides.as_ref()), + report, + ) + } + Err(error) => Attempt { + report, + outcome: Err(error), + }, + }; + let mut cleanup: PyResult<()> = Ok(()); + if state.created { + let dropped: PyResult = phase( + stream, + ( + &format!("Dropping review schema {}", schema.name), + "Dropped the review schema", + "Could not drop the review schema", + ), + || drop_review_schema((adapter, &connection), &schema), + ); + match dropped_state(py, dropped) { + Ok((dropped, error)) => (state.dropped, state.error) = (dropped, error), + Err(error) => cleanup = Err(error), + } + } + let _ = adapter.call_method1("close", (&connection,)); + Attempt { + report: ReviewReport { + schema: Some(state), + ..attempt.report + }, + outcome: attempt.outcome.and(cleanup), + } +} + +/// Settings, planning and, unless this is a dry run, the run in the review schema. +fn review( + (request, stream, machine): (&Bound<'_, PyAny>, &Bound<'_, PyAny>, bool), + report: ReviewReport, +) -> Attempt { + let mut report: ReviewReport = report; + let planned: PyResult)>> = (|| { + let py: Python<'_> = request.py(); + let (project_dir, discovered) = discover(request)?; + let settings: ReviewSettings = review_settings(request, (&project_dir, &discovered))?; + report.baseline.against.clone_from(&settings.against); + report.baseline.target = Some(settings.target.clone()); + report.baseline.since_ref.clone_from(&settings.since); + report.plan.policy = settings.policy; + report.baseline.head = head_commit(&project_dir.extract::()?).unwrap_or_default(); + let (project, compiled): (ReviewProject, Compiled<'_>) = phase( + stream, + ( + "Planning the review", + "Planned the review", + "Planning failed", + ), + || review_project(request, (project_dir, discovered), &settings, stream), + )?; + report.baseline.since_ref.clone_from(&project.reference); + report.baseline.merge_base.clone_from(&project.merge_base); + report.changes_json = project.changes_json.clone(); + report.changed_declarations = project.changed_declarations.clone(); + let tokens = selected_tokens(request, &compiled.2)?; + report.plan = plan_review( + &project.review_graph, + &project.changed, + settings.policy, + tokens.as_deref(), + ) + .map_err(|error| plan_error(py, &error))?; + report.impact_edges = impact_edges(&project, &report.plan); + report.nodes = planned_nodes(&project, &report.plan); + report.deferred = deferred(&project, &report.plan.validated); + write_line( + stream, + &format!( + "Review plan: {} changed, {} to build, {} left out (--downstream {})", + report.plan.changed.len(), + report.plan.validated.len(), + report.plan.not_validated.len(), + downstream_policy_name(settings.policy), + ), + )?; + if !report.plan.hooks.is_empty() { + write_line( + stream, + &format!( + "Hooks run against the review schema: {}", + report.plan.hooks.join(", ") + ), + )?; + } + let skip: bool = report.dry_run || report.plan.validated.is_empty(); + Ok((!skip).then_some((settings, project, compiled))) + })(); + match planned { + Ok(Some((settings, project, compiled))) => run_in_schema( + (request, stream, machine), + (&settings, &project, &compiled), + report, + ), + Ok(None) => Attempt { + report, + outcome: Ok(()), + }, + Err(error) => Attempt { + report, + outcome: Err(error), + }, + } +} + +/// Write the Markdown report to `--markdown`, when given. +fn write_markdown(request: &Bound<'_, PyAny>, report: &ReviewReport) -> PyResult> { + let Some(path) = native_field(request, "markdown_path")?.extract::>()? else { + return Ok(None); + }; + let written: std::io::Result<()> = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .map_or(Ok(()), std::fs::create_dir_all) + .and_then(|()| std::fs::write(&path, render_review_markdown(report))); + Ok(written.err().map(|error| { + format!( + "could not write the Markdown report to {}: {error}", + path.display() + ) + })) +} + +/// Print the report to stdout, as JSON or text. +fn print_report(py: Python<'_>, report: &ReviewReport, machine: bool) -> PyResult<()> { + let document: String = if machine { + render_review_json(report) + } else { + render_review_text(report) + }; + write_stdout_bytes(py, document.as_bytes()) +} + +/// `report` failed by the Markdown write failure, unless an earlier error already failed it. +fn with_markdown_failure(report: ReviewReport, failure: Option<&String>) -> ReviewReport { + match (&report.error, failure) { + (None, Some(message)) => ReviewReport { + error: Some(ReviewFailure { + code: MARKDOWN_WRITE_CODE.to_owned(), + message: message.clone(), + help: Some("pass a --markdown path in a writable directory".to_owned()), + }), + ..report + }, + _ => report, + } +} + +/// Run one `sqb review` request. +#[pyfunction] +#[pyo3(signature = (*, request))] +pub(crate) fn run_review_command(py: Python<'_>, request: &Bound<'_, PyAny>) -> PyResult { + let started: Instant = Instant::now(); + let machine: bool = native_field(request, "json_output")?.is_truthy()?; + let debug: bool = native_field(request, "debug")?.is_truthy()?; + let stream: Bound<'_, PyAny> = progress_stream(py, machine || debug)?; + let started_report: ReviewReport = ReviewReport { + version: env!("CARGO_PKG_VERSION").to_owned(), + dry_run: native_field(request, "dry_run")?.is_truthy()?, + invocation_id: invocation_id(py)?, + ..ReviewReport::default() + }; + let Attempt { report, outcome } = review((request, &stream, machine), started_report); + let cause: Option = outcome.err(); + let report: ReviewReport = ReviewReport { + duration_seconds: started.elapsed().as_secs_f64(), + error: cause + .as_ref() + .map(|error| error_parts(py, error)) + .transpose()?, + ..report + }; + let markdown_failure: Option = write_markdown(request, &report)?; + let report: ReviewReport = with_markdown_failure(report, markdown_failure.as_ref()); + print_report(py, &report, machine)?; + if let Some(cause) = cause { + if !cause.is_instance_of::(py) { + return Err(cause); + } + if let Some(message) = &markdown_failure { + write_stderr(py, &format!("{message}\n"))?; + } + let error = report.error.clone().unwrap_or_default(); + return Err(failed_error(py, &error, cause)); + } + if let Some(error) = &report.error { + return Err(failed_error( + py, + error, + pyo3::exceptions::PyOSError::new_err(error.message.clone()), + )); + } + Ok(review_outcome(&report).1) +} + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_function(wrap_pyfunction!(run_review_command, module)?) +} diff --git a/crates/sqlbuild-python/src/bindings/_helpers/review_command/comparison.rs b/crates/sqlbuild-python/src/bindings/_helpers/review_command/comparison.rs new file mode 100644 index 0000000000..3278d05ada --- /dev/null +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/comparison.rs @@ -0,0 +1,584 @@ +//! Compare each built model with `--against`: schema, row counts, keys and values. + +use std::collections::{BTreeMap, BTreeSet}; + +use pyo3::prelude::{Bound, PyAny, PyAnyMethods, PyResult, Python}; +use pyo3::types::{PyTuple, PyTupleMethods}; +use sqlbuild_analysis::review_command::constants::{ + BOUNDED_COMPARISON_MODE, EXACT_LIMIT, FULL_COMPARISON_MODE, MAX_FINDING_EXAMPLES, + MISSING_AGAINST_CODE, NO_COMPARISON_MODE, SCHEMA_ONLY_MODE, +}; +use sqlbuild_analysis::review_command::models::{ + ColumnEvidence, IncompleteCheck, NodeComparison, ReviewExample, ReviewFinding, ReviewNode, +}; +use sqlbuild_cli::diff_report::models::{ + DiffColumnFacts, DiffModelFacts, DiffRowFacts, DiffSampleRow, DiffValue, +}; +use sqlbuild_cli::diff_report::types::DiffNamedValue; +use sqlbuild_host::entries::models::ExecutorHost; + +use crate::bindings::_helpers::boundary::python_calls::{call_with, keywords}; +use crate::bindings::_helpers::build_command::support::{boolean, text}; +use crate::bindings::_helpers::compile_driver::diff::run_diff_pipeline; +use crate::bindings::_helpers::compiled_views::project_reads; +use crate::bindings::_helpers::dagster_invocation::access::string_tuple as texts; +use crate::bindings::_helpers::diff_report::command::target_max_full_rows; +use crate::bindings::_helpers::diff_report::facts::diff_facts; +use crate::bindings::_helpers::diff_report::support::{error_text, matches_class}; +use crate::bindings::_helpers::execution_history::conversion::optional_text; +use crate::bindings::_helpers::execution_history::statements::fetch_one; +use crate::bindings::_helpers::native_host::host; +use crate::bindings::_helpers::records::fields::field as native_field; +use crate::bindings::_helpers::review_command::planning::ReviewSettings; +use crate::bindings::_helpers::review_command::support::review_error; + +const KEYED_CHECK: &str = "keyed comparison"; +const MAX_EXAMPLE_LENGTH: usize = 80; + +/// The comparison of every built model, and the findings and incomplete checks it produced. +#[derive(Default)] +pub(crate) struct Comparisons { + pub(crate) by_node: BTreeMap, + pub(crate) findings: Vec, + pub(crate) incomplete: Vec, +} + +/// The two compiled projects and the connection a comparison runs on, borrowed for the run. +pub(crate) struct ComparisonContext<'a, 'py> { + pub(crate) request: &'a Bound<'py, PyAny>, + pub(crate) discovered: &'a Bound<'py, PyAny>, + pub(crate) adapter: &'a Bound<'py, PyAny>, + pub(crate) connection: &'a Bound<'py, PyAny>, + pub(crate) connection_config: &'a Bound<'py, PyAny>, + pub(crate) review_target: &'a str, +} + +fn short(value: &DiffValue) -> String { + let text: &str = &value.text; + if text.chars().count() <= MAX_EXAMPLE_LENGTH { + return text.to_owned(); + } + let kept: String = text.chars().take(MAX_EXAMPLE_LENGTH).collect(); + format!("{kept}…") +} + +fn key_text(values: &[(String, DiffValue)]) -> String { + values + .iter() + .map(|(name, value)| format!("{name}={}", short(value))) + .collect::>() + .join(", ") +} + +/// The names of `project`'s models among `names`, each with its compiled model. +fn models_named<'py>( + project: &Bound<'py, PyAny>, + names: &[String], +) -> PyResult)>> { + let mut models: Vec<(String, Bound<'py, PyAny>)> = Vec::new(); + for model in project_reads::models(project)?.try_iter()? { + let model: Bound<'py, PyAny> = model?; + let name: String = native_field(&model, "name")?.extract()?; + if names.contains(&name) { + models.push((name, model)); + } + } + Ok(models) +} + +/// Whether the target's relation of `model` exists. +fn target_exists(context: &ComparisonContext<'_, '_>, model: &Bound<'_, PyAny>) -> PyResult { + let destination: Bound<'_, PyAny> = native_field(model, "destination")?; + call_with( + &context.adapter.getattr("relation_exists")?, + &[ + ("connection", context.connection.clone()), + ("database", native_field(&destination, "database")?), + ("schema", native_field(&destination, "schema")?), + ("name", native_field(&destination, "name")?), + ], + )? + .is_truthy() +} + +fn row_count(context: &ComparisonContext<'_, '_>, relation: &str) -> PyResult> { + let cursor: Bound<'_, PyAny> = call_with( + &context.adapter.getattr("execute")?, + &[ + ("connection", context.connection.clone()), + ( + "sql", + text( + context.adapter.py(), + &format!("SELECT COUNT(*) FROM {relation}"), + ), + ), + ], + )?; + let Some(row) = fetch_one(&cursor)? else { + return Ok(None); + }; + row.get_item(0)?.extract() +} + +/// One `execute_diff` call for `name`. +fn run_one<'py>( + context: &ComparisonContext<'_, 'py>, + (left, right): (&Bound<'py, PyAny>, &Bound<'py, PyAny>), + name: &str, + options: &[(&str, Bound<'py, PyAny>)], +) -> PyResult> { + let py: Python<'py> = context.adapter.py(); + let mut pairs: Vec<(&str, Bound<'py, PyAny>)> = vec![ + ("collect_samples", boolean(py, true)), + ( + "max_column_examples", + pyo3::IntoPyObjectExt::into_bound_py_any(MAX_FINDING_EXAMPLES, py)?, + ), + ( + "max_row_only_examples", + pyo3::IntoPyObjectExt::into_bound_py_any(MAX_FINDING_EXAMPLES, py)?, + ), + ]; + pairs.extend(options.iter().cloned()); + let options: Bound<'py, PyAny> = call_with( + &host::entry(py, ExecutorHost::DiffExecutionOptions)?, + &pairs, + )?; + let result: Bound<'py, PyAny> = call_with( + &host::entry(py, ExecutorHost::ExecuteDiff)?, + &[ + ("adapter", context.adapter.clone()), + ("connection", context.connection.clone()), + ("left_project", left.clone()), + ("right_project", right.clone()), + ("selected_names", PyTuple::new(py, [name])?.into_any()), + ("options", options), + ], + )?; + Ok(diff_facts(&result)?.into_iter().next()) +} + +fn incomplete(node: &str, reason: String) -> IncompleteCheck { + IncompleteCheck { + node: node.to_owned(), + check: KEYED_CHECK.to_owned(), + reason, + } +} + +/// One model's comparison, with the findings and incomplete checks it produced. +#[derive(Default)] +struct ModelComparison { + comparison: Option, + findings: Vec, + incomplete: Vec, +} + +fn row_count_finding(node: &str, (target, branch): (i64, i64)) -> ReviewFinding { + ReviewFinding { + node: node.to_owned(), + kind: "row_count".to_owned(), + metric: "row count".to_owned(), + target_value: Some(target.to_string()), + branch_value: Some(branch.to_string()), + limit: EXACT_LIMIT.to_owned(), + ..ReviewFinding::default() + } +} + +fn schema_finding(facts: &DiffModelFacts) -> Option { + let mut parts: Vec = Vec::new(); + if !facts.added_columns.is_empty() { + parts.push(format!("added {}", facts.added_columns.join(", "))); + } + if !facts.removed_columns.is_empty() { + parts.push(format!("removed {}", facts.removed_columns.join(", "))); + } + for (column, from, to) in &facts.type_changes { + parts.push(format!("{column} {from} → {to}")); + } + (!parts.is_empty()).then(|| ReviewFinding { + node: facts.name.clone(), + kind: "schema".to_owned(), + metric: format!("schema changed: {}", parts.join("; ")), + limit: EXACT_LIMIT.to_owned(), + ..ReviewFinding::default() + }) +} + +/// The example of one sample row whose `column` changed, if it did. +fn cell_example(sample: &DiffSampleRow, column: &str) -> Option { + let cell = sample + .changed_cells + .iter() + .find(|cell| cell.name == column)?; + Some(ReviewExample { + key: key_text(&sample.key_values), + target: Some(short(&cell.left)), + branch: Some(short(&cell.right)), + }) +} + +fn key_examples(keys: &[Vec]) -> Vec { + keys.iter() + .take(MAX_FINDING_EXAMPLES) + .map(|key| ReviewExample { + key: key_text(key), + ..ReviewExample::default() + }) + .collect() +} + +fn key_findings(facts: &DiffModelFacts, rows: &DiffRowFacts) -> Vec { + [ + (rows.right_only_count, "branch-only", &facts.right_only_keys), + (rows.left_only_count, "target-only", &facts.left_only_keys), + ] + .into_iter() + .filter(|(count, _, _)| *count > 0) + .map(|(count, side, keys)| ReviewFinding { + node: facts.name.clone(), + kind: "keys".to_owned(), + metric: format!("{count} {side} keys"), + limit: EXACT_LIMIT.to_owned(), + examples: key_examples(keys), + ..ReviewFinding::default() + }) + .collect() +} + +/// The comparison evidence and findings of one model's diff facts. +fn evidence(facts: &DiffModelFacts, mode: &str) -> (NodeComparison, Vec) { + let mut comparison: NodeComparison = NodeComparison { + mode: mode.to_owned(), + target_relation: Some(facts.left_relation.clone()), + branch_relation: Some(facts.right_relation.clone()), + unique_key: facts.unique_key.clone(), + added_columns: facts.added_columns.clone(), + removed_columns: facts.removed_columns.clone(), + type_changes: facts.type_changes.clone(), + complete: mode == FULL_COMPARISON_MODE, + ..NodeComparison::default() + }; + let mut findings: Vec = schema_finding(facts).into_iter().collect(); + let Some(rows) = &facts.rows else { + return (comparison, findings); + }; + comparison.target_rows = Some(rows.left_count); + comparison.branch_rows = Some(rows.right_count); + comparison.compared_rows = Some(rows.compared_count); + comparison.branch_only_keys = rows.right_only_count; + comparison.target_only_keys = rows.left_only_count; + if rows.left_count != rows.right_count { + findings.push(row_count_finding( + &facts.name, + (rows.left_count, rows.right_count), + )); + } + findings.extend(key_findings(facts, rows)); + let changed: Vec<&DiffColumnFacts> = rows + .columns + .iter() + .filter(|column| column.mismatched_count > 0) + .collect(); + for column in &changed { + comparison.columns.push(ColumnEvidence { + column: column.name.clone(), + metric: "rows that differ".to_owned(), + target: "0".to_owned(), + branch: column.mismatched_count.to_string(), + limit: "0".to_owned(), + }); + let examples: Vec = facts + .unequal_samples + .iter() + .filter_map(|sample| cell_example(sample, &column.name)) + .take(MAX_FINDING_EXAMPLES) + .collect(); + findings.push(ReviewFinding { + node: facts.name.clone(), + kind: "values".to_owned(), + metric: format!( + "values differ in {} of {} matched rows", + column.mismatched_count, rows.joined_count + ), + column: Some(column.name.clone()), + limit: EXACT_LIMIT.to_owned(), + examples, + ..ReviewFinding::default() + }); + } + comparison.unchanged_columns = rows.columns.len().saturating_sub(changed.len()); + (comparison, findings) +} + +/// Both compiled sides, the exact-comparison size limits, and the models `--against` holds. +pub(crate) struct Sides<'py> { + left: Bound<'py, PyAny>, + right: Bound<'py, PyAny>, + limits: Bound<'py, PyAny>, + bounded: Option, + against_has: BTreeSet, +} + +/// A model without a unique key: its schema and row counts, with the keyed check incomplete. +fn compare_unkeyed( + context: &ComparisonContext<'_, '_>, + sides: &Sides<'_>, + name: &str, +) -> PyResult { + let py: Python<'_> = context.adapter.py(); + let Some(facts) = run_one( + context, + (&sides.left, &sides.right), + name, + &[("schema_only", boolean(py, true))], + )? + else { + return Ok(ModelComparison::default()); + }; + let (mut comparison, mut findings) = evidence(&facts, SCHEMA_ONLY_MODE); + comparison.target_rows = row_count(context, &facts.left_relation)?; + comparison.branch_rows = row_count(context, &facts.right_relation)?; + if let (Some(target), Some(branch)) = (comparison.target_rows, comparison.branch_rows) + && target != branch + { + findings.push(row_count_finding(name, (target, branch))); + } + Ok(ModelComparison { + comparison: Some(comparison), + findings, + incomplete: vec![incomplete( + name, + "the model has no unique_key, so only the schema and row counts were compared" + .to_owned(), + )], + }) +} + +/// A keyed model too large for an exact comparison: a bounded window, or the schema only. +fn compare_guarded<'py>( + context: &ComparisonContext<'_, 'py>, + sides: &Sides<'py>, + node: &ReviewNode, +) -> PyResult<(Option, &'static str, IncompleteCheck)> { + let py: Python<'py> = context.adapter.py(); + let name: &str = &node.name; + if let (Some(window), true) = (&sides.bounded, node.has_cursor) { + let facts = run_one( + context, + (&sides.left, &sides.right), + name, + &[ + ("schema_only", boolean(py, false)), + ("bounded", text(py, window)), + ], + )?; + let reason: String = + format!("too large for an exact comparison; compared the last {window} only"); + return Ok((facts, BOUNDED_COMPARISON_MODE, incomplete(name, reason))); + } + let hint: &str = if node.has_cursor { + "; pass --bounded to compare a recent window" + } else { + "" + }; + let facts = run_one( + context, + (&sides.left, &sides.right), + name, + &[("schema_only", boolean(py, true))], + )?; + let reason: String = format!("too large for an exact comparison, or its size is unknown{hint}"); + Ok((facts, SCHEMA_ONLY_MODE, incomplete(name, reason))) +} + +/// A keyed model: an exact comparison when the size guard allows it. +fn compare_keyed<'py>( + context: &ComparisonContext<'_, 'py>, + sides: &Sides<'py>, + node: &ReviewNode, +) -> PyResult { + let py: Python<'py> = context.adapter.py(); + let name: &str = &node.name; + let full = run_one( + context, + (&sides.left, &sides.right), + name, + &[ + ("schema_only", boolean(py, false)), + ("full_size_limits", sides.limits.clone()), + ], + ); + let (facts, mode, checks) = match full { + Ok(facts) => (facts, FULL_COMPARISON_MODE, Vec::new()), + Err(error) if matches_class(py, &error, ExecutorHost::FullDiffSizeGuardError)? => { + let (facts, mode, check) = compare_guarded(context, sides, node)?; + (facts, mode, vec![check]) + } + Err(error) if error.is_instance_of::(py) => { + return Ok(ModelComparison { + comparison: Some(NodeComparison { + mode: NO_COMPARISON_MODE.to_owned(), + target_relation: Some(String::new()), + ..NodeComparison::default() + }), + findings: Vec::new(), + incomplete: vec![incomplete(name, error_text(py, &error)?)], + }); + } + Err(error) => return Err(error), + }; + let Some(facts) = facts else { + return Ok(ModelComparison { + incomplete: checks, + ..ModelComparison::default() + }); + }; + let (comparison, findings) = evidence(&facts, mode); + Ok(ModelComparison { + comparison: Some(comparison), + findings, + incomplete: checks, + }) +} + +/// One model: keyed or unkeyed, or uncompared when `--against` lacks it, incomplete unless new. +fn compare_model( + context: &ComparisonContext<'_, '_>, + sides: &Sides<'_>, + (node, new): (&ReviewNode, bool), +) -> PyResult { + if !sides.against_has.contains(&node.name) { + return Ok(ModelComparison { + comparison: Some(NodeComparison { + mode: NO_COMPARISON_MODE.to_owned(), + complete: new, + ..NodeComparison::default() + }), + incomplete: if new { + Vec::new() + } else { + vec![incomplete( + &node.name, + "--against has no relation for this model, which existed at the base \ + revision" + .to_owned(), + )] + }, + ..ModelComparison::default() + }); + } + let compared: ModelComparison = if node.unique_key.is_empty() { + compare_unkeyed(context, sides, &node.name)? + } else { + compare_keyed(context, sides, node)? + }; + if compared.comparison.is_none() && compared.incomplete.is_empty() { + return Ok(ModelComparison { + incomplete: vec![incomplete( + &node.name, + "the comparison returned no result".to_owned(), + )], + ..compared + }); + } + Ok(compared) +} + +/// Compile both sides of `models`, failing when `--against` holds none that existed at the base. +pub(crate) fn prepare_sides<'py>( + context: &ComparisonContext<'_, 'py>, + settings: &ReviewSettings, + (models, new): (&[String], &BTreeSet), +) -> PyResult>> { + if models.is_empty() { + return Ok(None); + } + let py: Python<'py> = context.adapter.py(); + let pipeline: Bound<'py, PyTuple> = run_diff_pipeline( + py, + Some(keywords( + py, + &[ + ("discovered_inputs", context.discovered.clone()), + ("adapter", context.adapter.clone()), + ("from_target", text(py, &settings.against)), + ("to_target", text(py, context.review_target)), + ("resolved_connection", context.connection_config.clone()), + ("no_sql_validation", boolean(py, true)), + ("select", texts(py, models)?), + ("exclude", PyTuple::empty(py).into_any()), + ("cli_vars", native_field(context.request, "cli_vars")?), + ], + )?), + )?; + let left: Bound<'py, PyAny> = pipeline.get_item(0)?; + let mut against_has: BTreeSet = BTreeSet::new(); + for (name, model) in models_named(&left, models)? { + if target_exists(context, &model)? { + against_has.insert(name); + } + } + let existing: Vec<&String> = models.iter().filter(|name| !new.contains(*name)).collect(); + if !existing.is_empty() && !existing.iter().any(|name| against_has.contains(*name)) { + return Err(review_error( + py, + MISSING_AGAINST_CODE, + &format!( + "--against has none of these relations; check the target: {}", + existing + .iter() + .map(|name| name.as_str()) + .collect::>() + .join(", ") + ), + Some("pass the target that holds the deployed tables, such as --against prod"), + )); + } + let limits: Bound<'py, PyAny> = call_with( + &host::entry(py, ExecutorHost::FullDiffSizeLimits)?, + &[ + ("left_target", text(py, &settings.against)), + ("right_target", text(py, context.review_target)), + ( + "left_max_rows", + target_max_full_rows(context.discovered, &text(py, &settings.against))?, + ), + ( + "right_max_rows", + target_max_full_rows(context.discovered, &text(py, context.review_target))?, + ), + ], + )?; + Ok(Some(Sides { + right: pipeline.get_item(1)?, + left, + limits, + bounded: optional_text(&native_field(context.request, "bounded")?)?, + against_has, + })) +} + +/// Compare each of `models` (built, by name) between `--against` and the review schema. +pub(crate) fn compare_models( + context: &ComparisonContext<'_, '_>, + sides: Option<&Sides<'_>>, + (models, new): (&[&ReviewNode], &BTreeSet), +) -> PyResult { + let mut out: Comparisons = Comparisons::default(); + let Some(sides) = sides else { + return Ok(out); + }; + for node in models { + let compared: ModelComparison = + compare_model(context, sides, (node, new.contains(&node.name)))?; + out.findings.extend(compared.findings); + out.incomplete.extend(compared.incomplete); + if let Some(comparison) = compared.comparison { + out.by_node.insert(node.name.clone(), comparison); + } + } + Ok(out) +} diff --git a/crates/sqlbuild-python/src/bindings/_helpers/review_command/execution.rs b/crates/sqlbuild-python/src/bindings/_helpers/review_command/execution.rs new file mode 100644 index 0000000000..c590e518ed --- /dev/null +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/execution.rs @@ -0,0 +1,334 @@ +//! The clone, build and unit-test runs of a review, each read back from its JSON document. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use pyo3::IntoPyObject; +use pyo3::prelude::{Bound, PyAny, PyAnyMethods, PyResult, Python}; +use serde_json::Value; +use sqlbuild_analysis::review_command::constants::{ + CLONE_START, FULL_REFRESH_START, SEED_KIND, UNIT_TEST_LABEL, +}; +use sqlbuild_analysis::review_command::models::{IncrementalStart, ReviewGraph, ValidationCounts}; + +use crate::bindings::_helpers::build_command::command::run_build_command; +use crate::bindings::_helpers::build_command::support::{boolean, text}; +use crate::bindings::_helpers::clone::clone_command::run_clone_command; +use crate::bindings::_helpers::contracts::spec_resolution::resolve_target_config; +use crate::bindings::_helpers::dagster_invocation::access::string_tuple as texts; +use crate::bindings::_helpers::records::construct::new_record_with; +use crate::bindings::_helpers::records::fields::field as native_field; +use crate::bindings::_helpers::records::ids::RecordId; +use crate::bindings::_helpers::review_command::planning::ReviewSettings; +use crate::bindings::_helpers::review_command::support::{quietly, review_error}; +use crate::bindings::_helpers::sql_test_command::test_command::run_test_command; + +const FAILED_STATUS: &str = "failed"; +const AUDIT_KIND: &str = "audit"; +const SQL_TEST_KIND: &str = "sql_test"; +const ERROR_SEVERITY: &str = "error"; + +/// What the review build did to each node, and its audit results. +#[derive(Default)] +pub(crate) struct BuildOutcome { + /// Build status by node name. + pub(crate) statuses: BTreeMap, + /// Nodes whose failure came from a blocking audit rather than from running them. + pub(crate) audit_blocked: Vec, + pub(crate) audits: ValidationCounts, + /// `(node, audit)` of every failed audit. + pub(crate) failed_audits: Vec<(String, String)>, +} + +/// The run's shared inputs to the sub-runs, borrowed for the length of the run. +pub(crate) struct RunInputs<'a, 'py> { + pub(crate) request: &'a Bound<'py, PyAny>, + pub(crate) project_dir: &'a Path, + pub(crate) review_discovered: &'a Bound<'py, PyAny>, + pub(crate) review_target: &'a str, + pub(crate) work_dir: &'a Path, + /// The progress stream, and whether stdout carries `--json`. + pub(crate) output: (&'a Bound<'py, PyAny>, bool), +} + +/// The JSON result document a sub-run wrote, or `None` when it wrote none. +fn read_document(path: &Path) -> PyResult> { + let text: String = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(pyo3::exceptions::PyOSError::new_err(error.to_string())), + }; + serde_json::from_str(&text) + .map(Some) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string())) +} + +fn entries<'a>(document: &'a Value, key: &str) -> &'a [Value] { + document + .get(key) + .and_then(Value::as_array) + .map_or(&[], Vec::as_slice) +} + +fn field<'a>(item: &'a Value, key: &str) -> &'a str { + item.get(key).and_then(Value::as_str).unwrap_or_default() +} + +fn path_object<'py>(py: Python<'py>, path: &Path) -> PyResult> { + path.into_pyobject(py) +} + +/// How each changed stateful model starts: cloned from `--against` when that is free, else full. +pub(crate) fn incremental_starts( + inputs: &RunInputs<'_, '_>, + (settings, adapter): (&ReviewSettings, &Bound<'_, PyAny>), + stateful: &[String], +) -> PyResult> { + let py: Python<'_> = adapter.py(); + if stateful.is_empty() { + return Ok(Vec::new()); + } + let full = |reason: &str| -> Vec { + stateful + .iter() + .map(|node| IncrementalStart { + node: node.clone(), + method: FULL_REFRESH_START.to_owned(), + reason: reason.to_owned(), + }) + .collect() + }; + if native_field(inputs.request, "no_clone")?.is_truthy()? { + return Ok(full("cloning opted out with --no-clone")); + } + if !adapter + .call_method0("supports_zero_copy_clone")? + .is_truthy()? + { + return Ok(full("the adapter has no zero-copy clone")); + } + let against: Bound<'_, PyAny> = resolve_target_config( + py, + &native_field(inputs.review_discovered, "project_config")?, + &native_field(inputs.review_discovered, "local_config")?, + &text(py, &settings.against), + )?; + if !native_field(&native_field(&against, "clone")?, "allow_as_clone_origin")?.is_truthy()? { + return Ok(full(&format!( + "target '{}' does not allow cloning (allow_as_clone_origin)", + settings.against + ))); + } + let output: PathBuf = inputs.work_dir.join("clone.json"); + let request: Bound<'_, PyAny> = new_record_with( + py, + RecordId::CloneCommandRequest, + &[ + ("project_dir", path_object(py, inputs.project_dir)?), + ("no_color", native_field(inputs.request, "no_color")?), + ("no_sql_validation", boolean(py, true)), + ("origin_target_name", text(py, &settings.against)), + ("destination_target_name", text(py, inputs.review_target)), + ("hard_copy", boolean(py, false)), + ("select", texts(py, stateful)?), + ("cli_vars", native_field(inputs.request, "cli_vars")?), + ("json_output_path", path_object(py, &output)?), + ("discovered_inputs", inputs.review_discovered.clone()), + ], + )?; + let code: i32 = quietly(inputs.output, || run_clone_command(py, &request))?; + if code != 0 { + return Err(review_error( + py, + "C772", + &format!( + "cloning changed incremental models from '{}' failed", + settings.against + ), + Some("rerun with --no-clone to build them with a full refresh"), + )); + } + Ok(stateful + .iter() + .map(|node| IncrementalStart { + node: node.clone(), + method: CLONE_START.to_owned(), + reason: format!("zero-copy clone of {}", settings.against), + }) + .collect()) +} + +/// The selector of one planned node. +fn selector(graph: &ReviewGraph, name: &str) -> String { + match graph.nodes.get(name) { + Some(node) if node.kind == SEED_KIND => format!("seed:{name}"), + _ => name.to_owned(), + } +} + +/// Build the validated nodes into the review schema with deferral to `--against`. +pub(crate) fn build_validated( + inputs: &RunInputs<'_, '_>, + settings: &ReviewSettings, + (graph, validated): (&ReviewGraph, &[String]), +) -> PyResult { + let py: Python<'_> = inputs.request.py(); + let output: PathBuf = inputs.work_dir.join("build.json"); + let selectors: Vec = validated.iter().map(|name| selector(graph, name)).collect(); + let request: Bound<'_, PyAny> = new_record_with( + py, + RecordId::BuildCommandRequest, + &[ + ("project_dir", path_object(py, inputs.project_dir)?), + ( + "no_sql_validation", + native_field(inputs.request, "no_sql_validation")?, + ), + ("defer_to", text(py, &settings.against)), + ("selected_target", text(py, inputs.review_target)), + ("no_color", native_field(inputs.request, "no_color")?), + ("load_sources", boolean(py, false)), + ("include_python", boolean(py, false)), + ("allow_missing_migration_origin", boolean(py, true)), + ("concurrency", native_field(inputs.request, "concurrency")?), + ("select", texts(py, &selectors)?), + ("verbose", native_field(inputs.request, "verbose")?), + ("debug", native_field(inputs.request, "debug")?), + ("cli_vars", native_field(inputs.request, "cli_vars")?), + ("run_tests", boolean(py, false)), + ("run_audits", boolean(py, true)), + ("json_output_path", path_object(py, &output)?), + ("discovered_inputs", inputs.review_discovered.clone()), + ], + )?; + let ran: PyResult = quietly(inputs.output, || run_build_command(py, &request)); + let document: Option = read_document(&output)?; + let Some(document) = document else { + ran?; + return Err(review_error( + py, + "C773", + "the review build wrote no result document", + None, + )); + }; + if let Err(error) = ran { + let value: Bound<'_, PyAny> = error.value(py).clone().into_any(); + if value.getattr_opt("code")?.is_none() { + return Err(error); + } + } + Ok(build_outcome(&document)) +} + +fn build_outcome(document: &Value) -> BuildOutcome { + let mut outcome: BuildOutcome = BuildOutcome::default(); + for asset in entries(document, "assets") { + let name: &str = field(asset, "name"); + if name.is_empty() { + continue; + } + let status: &str = field(asset, "status"); + let phase: &str = asset + .get("failed_phase") + .and_then(Value::as_str) + .unwrap_or_default(); + if status == FAILED_STATUS && phase.contains(AUDIT_KIND) { + outcome.audit_blocked.push(name.to_owned()); + } + outcome.statuses.insert(name.to_owned(), status.to_owned()); + } + for check in entries(document, "checks") { + if field(check, "kind") != AUDIT_KIND { + continue; + } + outcome.audits.selected += 1; + outcome.audits.total += 1; + let passed: bool = check + .get("passed") + .and_then(Value::as_bool) + .unwrap_or(false); + if passed { + outcome.audits.passed += 1; + continue; + } + outcome.audits.failed += 1; + let node: String = field(check, "asset_name").to_owned(); + let audit: String = field(check, "name").to_owned(); + let severity: &str = field(check, "severity"); + let label: String = if severity.is_empty() || severity == ERROR_SEVERITY { + format!("audit {audit} on {node}") + } else { + format!("audit {audit} on {node} ({severity})") + }; + outcome.audits.failures.push(label); + outcome.failed_audits.push((node, audit)); + } + outcome +} + +/// Run the unit tests that execute something changed since `--since`, as `sqb test --since` does. +pub(crate) fn run_changed_tests( + inputs: &RunInputs<'_, '_>, + settings: &ReviewSettings, +) -> PyResult { + let py: Python<'_> = inputs.request.py(); + let output: PathBuf = inputs.work_dir.join("tests.json"); + let request: Bound<'_, PyAny> = new_record_with( + py, + RecordId::TestCommandRequest, + &[ + ("project_dir", path_object(py, inputs.project_dir)?), + ( + "no_sql_validation", + native_field(inputs.request, "no_sql_validation")?, + ), + ("no_color", native_field(inputs.request, "no_color")?), + ("selected_target", text(py, &settings.target)), + ("cli_vars", native_field(inputs.request, "cli_vars")?), + ("json_output_path", path_object(py, &output)?), + ("concurrency", native_field(inputs.request, "concurrency")?), + ("since", text(py, &settings.since)), + ], + )?; + let ran: PyResult = quietly(inputs.output, || run_test_command(py, &request)); + let Some(document) = read_document(&output)? else { + ran?; + return Ok(ValidationCounts::default()); + }; + ran.or_else(|error| { + let value: Bound<'_, PyAny> = error.value(py).clone().into_any(); + if value.getattr_opt("code")?.is_none() { + Err(error) + } else { + Ok(1) + } + })?; + let mut counts: ValidationCounts = ValidationCounts::default(); + for check in entries(&document, "checks") { + if field(check, "kind") != SQL_TEST_KIND { + continue; + } + counts.selected += 1; + if check + .get("passed") + .and_then(Value::as_bool) + .unwrap_or(false) + { + counts.passed += 1; + } else { + counts.failed += 1; + counts + .failures + .push(format!("{UNIT_TEST_LABEL}{}", field(check, "name"))); + } + } + counts.total = document + .get("since") + .and_then(|since| since.get("candidate_count")) + .and_then(Value::as_u64) + .map_or(counts.selected, |total| { + usize::try_from(total).unwrap_or(usize::MAX) + }); + Ok(counts) +} diff --git a/crates/sqlbuild-python/src/bindings/_helpers/review_command/impact.rs b/crates/sqlbuild-python/src/bindings/_helpers/review_command/impact.rs new file mode 100644 index 0000000000..45ec4977ad --- /dev/null +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/impact.rs @@ -0,0 +1,108 @@ +//! The downstream columns a change reaches, from column lineage. + +use sqlbuild_analysis::review_command::constants::NO_COMPARISON_MODE; +use std::collections::{BTreeMap, BTreeSet}; + +use pyo3::prelude::{Bound, PyAny, PyAnyMethods, PyResult, Python}; +use pyo3::types::{PyFrozenSet, PyString}; +use sqlbuild_analysis::review_command::models::NodeComparison; + +use crate::bindings::_helpers::compiled_views::{model_reads, project_reads}; +use crate::bindings::_helpers::lineage::column_lineage_graph::ProjectColumnLineage; +use crate::bindings::_helpers::lineage::project_lineage::build_project_column_lineage; +use crate::bindings::_helpers::records::construct::enum_member; +use crate::bindings::_helpers::records::fields::field as native_field; +use crate::bindings::_helpers::records::ids::RecordId; + +/// The columns of a changed model a comparison proved changed, or `None` when every column may. +fn changed_columns(comparison: Option<&NodeComparison>) -> Option> { + let comparison: &NodeComparison = comparison?; + if !comparison.complete || comparison.mode == NO_COMPARISON_MODE { + return None; + } + let mut columns: Vec = comparison.added_columns.clone(); + columns.extend( + comparison + .type_changes + .iter() + .map(|(column, _, _)| column.clone()), + ); + columns.extend( + comparison + .columns + .iter() + .map(|column| column.column.clone()), + ); + Some(columns) +} + +/// Affected columns of each downstream node of `changed`, within `affected`. +pub(crate) fn affected_columns( + project: &Bound<'_, PyAny>, + (changed, affected): (&[String], &BTreeSet), + comparisons: &BTreeMap, +) -> PyResult>> { + let py: Python<'_> = project.py(); + let mut models: BTreeMap> = BTreeMap::new(); + for model in project_reads::models(project)?.try_iter()? { + let model: Bound<'_, PyAny> = model?; + let name: String = model_reads::model_name(&model)?; + if affected.contains(&name) { + models.insert(name, model); + } + } + let names: Vec<&String> = models.keys().collect(); + let lineage = build_project_column_lineage( + project, + None, + &enum_member(py, RecordId::ColumnLineageMode, "FAST")?, + Some(PyFrozenSet::new(py, &names)?.as_any()), + ); + let Ok(Some(lineage)) = lineage else { + return Ok(BTreeMap::new()); + }; + let lineage: Bound<'_, PyAny> = lineage.into_bound(py); + let Ok(lineage) = lineage.cast::() else { + return Ok(BTreeMap::new()); + }; + let mut pending: Vec<(String, String)> = Vec::new(); + for name in changed { + let Some(model) = models.get(name) else { + continue; + }; + let columns: Vec = match changed_columns(comparisons.get(name)) { + Some(columns) => columns, + None => model_reads::inferred_names(model)?.unwrap_or_default(), + }; + pending.extend(columns.into_iter().map(|column| (name.clone(), column))); + } + let mut seen: BTreeSet<(String, String)> = BTreeSet::new(); + let mut reached: BTreeMap> = BTreeMap::new(); + while let Some((resource, column)) = pending.pop() { + if !seen.insert((resource.clone(), column.clone())) { + continue; + } + let column_name: Bound<'_, PyAny> = PyString::new(py, &column).into_any(); + let Ok(edges) = ProjectColumnLineage::column_consumers(lineage, &resource, &column_name) + else { + continue; + }; + for edge in edges.try_iter()? { + let target: Bound<'_, PyAny> = native_field(&edge?, "target")?; + let name: String = native_field(&target, "resource_name")?.extract()?; + let column_name: String = native_field(&target, "column_name")?.extract()?; + if !affected.contains(&name) || changed.contains(&name) { + continue; + } + reached + .entry(name.clone()) + .or_default() + .insert(column_name.clone()); + pending.push((name, column_name)); + } + } + Ok(reached + .into_iter() + .map(|(name, columns)| (name, columns.into_iter().collect())) + .collect()) +} diff --git a/crates/sqlbuild-python/src/bindings/_helpers/review_command/mod.rs b/crates/sqlbuild-python/src/bindings/_helpers/review_command/mod.rs new file mode 100644 index 0000000000..5387d9fd56 --- /dev/null +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/mod.rs @@ -0,0 +1,8 @@ +pub(crate) mod command; +pub(crate) mod comparison; +pub(crate) mod execution; +pub(crate) mod impact; +pub(crate) mod planning; +pub(crate) mod registration; +pub(crate) mod schema; +pub(crate) mod support; diff --git a/crates/sqlbuild-python/src/bindings/_helpers/review_command/planning.rs b/crates/sqlbuild-python/src/bindings/_helpers/review_command/planning.rs new file mode 100644 index 0000000000..de9e8aa36e --- /dev/null +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/planning.rs @@ -0,0 +1,585 @@ +//! Resolve the review's settings, compile the project, and plan what it builds. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +use pyo3::IntoPyObject; +use pyo3::prelude::{Bound, PyAny, PyAnyMethods, PyResult, Python}; +use pyo3::types::{PyDict, PyDictMethods, PyFrozenSet, PyString, PyTuple}; +use serde_json::Value; +use sqlbuild_analysis::definition_changes::main::node_key::node_key; +use sqlbuild_analysis::review_command::constants::{ + DEFAULT_CRITICAL_TAG, DEFAULT_SCHEMA_PREFIX, MODEL_KIND, SEED_KIND, +}; +use sqlbuild_analysis::review_command::main::changed_declarations::changed_declarations; +use sqlbuild_analysis::review_command::main::node_change_reasons::node_change_reasons; +use sqlbuild_analysis::review_command::main::parse_downstream_policy::parse_downstream_policy; +use sqlbuild_analysis::review_command::models::{ + ChangedNode, DownstreamPolicy, ReviewGraph, ReviewNode, SelectedToken, +}; +use sqlbuild_config::project::models::ReviewConfig; + +use crate::bindings::_helpers::boundary::python_calls::keywords; +use crate::bindings::_helpers::build_command::support::{boolean, text}; +use crate::bindings::_helpers::change_selection::base_snapshot::{base_comparison, base_variant}; +use crate::bindings::_helpers::change_selection::definition_snapshot::project_wide_keys; +use crate::bindings::_helpers::change_selection::selection::{ChangedProject, changed_project}; +use crate::bindings::_helpers::command_runtime::adapters::resolve_cli_adapter; +use crate::bindings::_helpers::compile_driver::discovery::discover_project_inputs; +use crate::bindings::_helpers::compile_driver::project::build_project_graph; +use crate::bindings::_helpers::compiled_views::model_reads; +use crate::bindings::_helpers::compiled_views::project_reads; +use crate::bindings::_helpers::compiled_views::seed_reads; +use crate::bindings::_helpers::contracts::spec_resolution::{ + resolve_effective_adapter_name, resolve_target_name, +}; +use crate::bindings::_helpers::discovery::project_config::{ + configured_git_base_ref, configured_review, +}; +use crate::bindings::_helpers::execution_history::conversion::optional_text; +use crate::bindings::_helpers::graph::python_node_graph::python_node_graph; +use crate::bindings::_helpers::planner_selection::selectors::select_project_graph; +use crate::bindings::_helpers::records::fields::{field as native_field, mapping_get}; +use crate::bindings::_helpers::review_command::support::review_error; + +const BASE_REF_ALIAS: &str = "@base"; +const UDF_KIND: &str = "udf"; +const TABLE_FUNCTION_KIND: &str = "table_fn"; +const SOURCE_KIND: &str = "source"; +const SNAPSHOT_MATERIALIZATION: &str = "snapshot"; +const INCREMENTAL_MATERIALIZATION: &str = "incremental"; +const LOADER_KIND: &str = "loader"; +const HOOK_KEYS: [&str; 2] = ["pre_hooks", "post_hooks"]; +/// A target database that keeps each node's own database. +const PRESERVE_DATABASE: &str = "preserve"; + +/// The flags and `[review]` defaults one review runs with. +pub(crate) struct ReviewSettings { + pub(crate) since: String, + pub(crate) against: String, + pub(crate) target: String, + pub(crate) policy: DownstreamPolicy, + pub(crate) schema_prefix: String, + critical_tags: Vec, +} + +/// The discovered project inputs, the adapter, and the compiled project graph, in that order. +pub(crate) type Compiled<'py> = (Bound<'py, PyAny>, Bound<'py, PyAny>, Bound<'py, PyAny>); + +/// The project's changes since the base revision, and the graph a review plans over. +pub(crate) struct ReviewProject { + pub(crate) project_dir: PathBuf, + pub(crate) review_graph: ReviewGraph, + pub(crate) changed: Vec, + pub(crate) changed_declarations: Vec<(String, String, String)>, + pub(crate) changes_json: Option, + pub(crate) reference: String, + pub(crate) merge_base: String, + /// Incremental and snapshot models by name. + pub(crate) stateful: BTreeSet, + /// Models and seeds whose compiled destination names a database, with that database. + pub(crate) databases: BTreeMap, + /// The active target's expanded database, unless it keeps each node's own database. + pub(crate) target_database: Option, + /// The active target's expanded schema. + pub(crate) target_schema: Option, +} + +fn strings(value: &Bound<'_, PyAny>) -> PyResult> { + if value.is_none() { + return Ok(Vec::new()); + } + value + .try_iter()? + .map(|item| item?.str()?.extract()) + .collect() +} + +fn setting_error(py: Python<'_>, code: &str, message: &str, help: &str) -> pyo3::PyErr { + review_error(py, code, message, Some(help)) +} + +/// Resolve `--since`, `--against`, the active target and the downstream policy. +pub(crate) fn review_settings( + request: &Bound<'_, PyAny>, + (project_dir, discovered): (&Bound<'_, PyAny>, &Bound<'_, PyAny>), +) -> PyResult { + let py: Python<'_> = request.py(); + let config: ReviewConfig = configured_review(project_dir)?; + let since: String = match optional_text(&native_field(request, "since")?)? { + Some(since) => since, + None if configured_git_base_ref(project_dir)?.is_some() => BASE_REF_ALIAS.to_owned(), + None => { + return Err(setting_error( + py, + "C766", + "sqb review needs a code baseline: pass --since ", + "or set [git] base_ref in sqlbuild_project.toml", + )); + } + }; + let against: String = + match optional_text(&native_field(request, "against")?)?.or(config.against.clone()) { + Some(against) => against, + None => { + return Err(setting_error( + py, + "C767", + "sqb review needs a data baseline: pass --against ", + "or set [review] against in sqlbuild_project.toml", + )); + } + }; + let project_config: Bound<'_, PyAny> = native_field(discovered, "project_config")?; + if !native_field(&project_config, "targets")?.contains(&against)? { + return Err(setting_error( + py, + "C768", + &format!("--against '{against}' is not a configured target"), + "name a target from [targets] in sqlbuild_project.toml", + )); + } + let target: Option = resolve_target_name( + py, + &project_config, + &native_field(discovered, "local_config")?, + &native_field(request, "selected_target")?, + )? + .map(|name| name.to_string()); + let Some(target) = target else { + return Err(setting_error( + py, + "C769", + "sqb review needs an active target whose connection builds the review", + "pass --target, or set default_target or a local target", + )); + }; + let downstream: Option = + optional_text(&native_field(request, "downstream")?)?.or(config.downstream.clone()); + let policy: DownstreamPolicy = match downstream.as_deref() { + None => DownstreamPolicy::Critical, + Some(value) => parse_downstream_policy(value).ok_or_else(|| { + setting_error( + py, + "C770", + &format!("unknown downstream policy '{value}'"), + "use critical, all or none", + ) + })?, + }; + Ok(ReviewSettings { + since, + against, + target, + policy, + schema_prefix: config + .schema_prefix + .unwrap_or_else(|| DEFAULT_SCHEMA_PREFIX.to_owned()), + critical_tags: config + .critical_tags + .unwrap_or_else(|| vec![DEFAULT_CRITICAL_TAG.to_owned()]), + }) +} + +/// The project directory and its discovered inputs. +pub(crate) fn discover<'py>( + request: &Bound<'py, PyAny>, +) -> PyResult<(Bound<'py, PyAny>, Bound<'py, PyAny>)> { + let py: Python<'py> = request.py(); + let project_dir: Bound<'py, PyAny> = match native_field(request, "project_dir")? { + directory if !directory.is_none() => directory, + _ => std::env::current_dir()?.into_pyobject(py)?, + }; + let discovered: Bound<'py, PyAny> = discover_project_inputs( + py, + Some(keywords(py, &[("project_dir", project_dir.clone())])?), + )?; + Ok((project_dir, discovered)) +} + +fn config_value<'py>(values: &Bound<'py, PyAny>, key: &str) -> PyResult> { + mapping_get(values, key) +} + +fn key_columns(value: &Bound<'_, PyAny>) -> PyResult> { + if value.is_none() { + return Ok(Vec::new()); + } + if value.is_instance_of::() { + return Ok(vec![value.extract()?]); + } + strings(value) +} + +/// One model as a review node, critical by its `critical` key or one of the critical tags. +fn model_node(model: &Bound<'_, PyAny>, critical_tags: &[String]) -> PyResult { + let values: Bound<'_, PyAny> = native_field(&model_reads::config(model)?, "values")?; + let tags: Vec = strings(&config_value(&values, "tags")?)?; + let marked: Bound<'_, PyAny> = config_value(&values, "critical")?; + let materialized: Bound<'_, PyAny> = config_value(&values, "materialized")?; + Ok(ReviewNode { + name: model_reads::model_name(model)?, + kind: MODEL_KIND.to_owned(), + critical: (!marked.is_none() && marked.is_truthy()?) + || tags.iter().any(|tag| critical_tags.contains(tag)), + materialized: if materialized.is_none() { + String::new() + } else { + materialized.str()?.to_string().to_ascii_lowercase() + }, + unique_key: key_columns(&config_value(&values, "unique_key")?)?, + has_cursor: !config_value(&values, "cursor")?.is_none(), + has_hooks: HOOK_KEYS + .iter() + .map(|key| config_value(&values, key)) + .collect::>>()? + .iter() + .any(|hooks| !hooks.is_none() && hooks.is_truthy().unwrap_or(false)), + ..ReviewNode::default() + }) +} + +/// The database a model's or seed's compiled destination names, if any. +fn destination_database(destination: &Bound<'_, PyAny>) -> PyResult> { + optional_text(&native_field(destination, "database")?) +} + +/// Dependency order over `nodes`, ties broken by name. +fn topological( + nodes: &BTreeSet, + downstream: &BTreeMap>, +) -> Vec { + let mut incoming: BTreeMap<&str, usize> = nodes.iter().map(|name| (name.as_str(), 0)).collect(); + for children in downstream.values() { + for child in children { + if let Some(count) = incoming.get_mut(child.as_str()) { + *count += 1; + } + } + } + let mut ready: BTreeSet<&str> = incoming + .iter() + .filter(|(_, count)| **count == 0) + .map(|(name, _)| *name) + .collect(); + let mut order: Vec = Vec::new(); + while let Some(name) = ready.pop_first() { + order.push(name.to_owned()); + for child in downstream.get(name).map_or(&[][..], Vec::as_slice) { + if let Some(count) = incoming.get_mut(child.as_str()) { + *count -= 1; + if *count == 0 { + ready.insert(child.as_str()); + } + } + } + } + order +} + +/// The review graph with each node's definition key and direct reads of sources and functions. +struct ProjectGraph { + graph: ReviewGraph, + keys: BTreeMap, + reads: BTreeMap>, + stateful: BTreeSet, + databases: BTreeMap, +} + +/// A Python node's name, kind, and `(kind, name)` of the SQL resources it depends on. +type PythonNode = (String, String, Vec<(String, String)>); + +/// `graph` with Python nodes as `kind:name`, each loader feeding the readers of its source. +fn with_python_nodes( + discovered: &Bound<'_, PyAny>, + project_graph: ProjectGraph, + sources: &BTreeMap>, +) -> PyResult { + let mut project_graph: ProjectGraph = project_graph; + let python_graph: Bound<'_, PyAny> = python_node_graph(discovered)?; + let mut names: BTreeMap = BTreeMap::new(); + let mut nodes: Vec = Vec::new(); + for node in native_field(&python_graph, "nodes")?.try_iter()? { + let node: Bound<'_, PyAny> = node?; + let name: String = native_field(&node, "name")?.extract()?; + let kind: String = native_field(&native_field(&node, "kind")?, "value")?.extract()?; + let mut sql_deps: Vec<(String, String)> = Vec::new(); + for dependency in native_field(&node, "sql_deps")?.try_iter()? { + let dependency: Bound<'_, PyAny> = dependency?; + sql_deps.push(( + native_field(&native_field(&dependency, "kind")?, "value")?.extract()?, + native_field(&dependency, "name")?.extract()?, + )); + } + names.insert(name.clone(), format!("{kind}:{name}")); + nodes.push((name, kind, sql_deps)); + } + let upstream: Bound<'_, PyAny> = native_field(&python_graph, "upstream_deps")?; + let upstream: &Bound<'_, PyDict> = upstream.cast()?; + let graph: &mut ReviewGraph = &mut project_graph.graph; + for (name, kind, sql_deps) in nodes { + let node_name: String = names[&name].clone(); + let mut parents: Vec = Vec::new(); + for (dependency_kind, dependency) in sql_deps { + if dependency_kind == SOURCE_KIND { + parents.extend(names.get(&dependency).cloned()); + } else if graph.nodes.contains_key(&dependency) { + parents.push(dependency); + } + } + if let Some(names_upstream) = upstream.get_item(&name)? { + for parent in names_upstream.try_iter()? { + let parent: String = parent?.extract()?; + parents.extend(names.get(&parent).cloned()); + } + } + for parent in parents { + graph + .downstream + .entry(parent) + .or_default() + .push(node_name.clone()); + } + if kind == LOADER_KIND { + for reader in sources.get(&name).into_iter().flatten() { + graph + .downstream + .entry(node_name.clone()) + .or_default() + .push(reader.clone()); + } + } + project_graph + .keys + .insert(node_name.clone(), node_key(&kind, &name)); + graph.nodes.insert( + node_name.clone(), + ReviewNode { + name: node_name, + kind, + python: true, + ..ReviewNode::default() + }, + ); + } + Ok(project_graph) +} + +/// The graph of models, seeds and Python nodes, with reads, stateful models and own databases. +fn review_graph( + (project, discovered): (&Bound<'_, PyAny>, &Bound<'_, PyAny>), + critical_tags: &[String], +) -> PyResult { + let mut project_graph = ProjectGraph { + graph: ReviewGraph::default(), + keys: BTreeMap::new(), + reads: BTreeMap::new(), + stateful: BTreeSet::new(), + databases: BTreeMap::new(), + }; + for seed in project_reads::seeds(project)?.try_iter()? { + let seed: Bound<'_, PyAny> = seed?; + let name: String = seed_reads::name(&seed)?.extract()?; + if let Some(database) = destination_database(&seed_reads::destination(&seed)?)? { + project_graph.databases.insert(name.clone(), database); + } + project_graph + .keys + .insert(name.clone(), node_key(SEED_KIND, &name)); + project_graph.graph.nodes.insert( + name.clone(), + ReviewNode { + name, + kind: SEED_KIND.to_owned(), + materialized: SEED_KIND.to_owned(), + ..ReviewNode::default() + }, + ); + } + let mut dependencies: Vec<(String, Vec<(String, String)>)> = Vec::new(); + for model in project_reads::models(project)?.try_iter()? { + let model: Bound<'_, PyAny> = model?; + let node: ReviewNode = model_node(&model, critical_tags)?; + if node.materialized == INCREMENTAL_MATERIALIZATION + || node.materialized == SNAPSHOT_MATERIALIZATION + { + project_graph.stateful.insert(node.name.clone()); + } + if let Some(database) = destination_database(&model_reads::destination(&model)?)? { + project_graph.databases.insert(node.name.clone(), database); + } + project_graph + .keys + .insert(node.name.clone(), node_key(MODEL_KIND, &node.name)); + dependencies.push((node.name.clone(), model_reads::dep_rows(&model)?)); + project_graph.graph.nodes.insert(node.name.clone(), node); + } + let mut sources: BTreeMap> = BTreeMap::new(); + for (name, rows) in dependencies { + let mut read: Vec = Vec::new(); + for (kind, upstream) in rows { + if (kind == MODEL_KIND || kind == SEED_KIND) + && project_graph.graph.nodes.contains_key(&upstream) + { + project_graph + .graph + .downstream + .entry(upstream) + .or_default() + .push(name.clone()); + } else if kind == SOURCE_KIND || kind == UDF_KIND || kind == TABLE_FUNCTION_KIND { + if kind == SOURCE_KIND { + sources + .entry(upstream.clone()) + .or_default() + .push(name.clone()); + } + read.push(node_key(&kind, &upstream)); + } + } + project_graph.reads.insert(name, read); + } + let mut project_graph: ProjectGraph = with_python_nodes(discovered, project_graph, &sources)?; + let graph: &mut ReviewGraph = &mut project_graph.graph; + for children in graph.downstream.values_mut() { + children.sort(); + children.dedup(); + } + let names: BTreeSet = graph.nodes.keys().cloned().collect(); + graph.order = topological(&names, &graph.downstream); + Ok(project_graph) +} + +/// Discover, compare with the base revision, compile, and find the changed models and seeds. +pub(crate) fn review_project<'py>( + request: &Bound<'py, PyAny>, + (project_dir, discovered): (Bound<'py, PyAny>, Bound<'py, PyAny>), + settings: &ReviewSettings, + stream: &Bound<'py, PyAny>, +) -> PyResult<(ReviewProject, Compiled<'py>)> { + let py: Python<'py> = request.py(); + let base = base_comparison( + (stream, &project_dir), + &settings.since, + &base_variant( + native_field(request, "selected_target")?.extract()?, + &native_field(request, "cli_vars")?, + )?, + )?; + let adapter_name: Bound<'py, PyAny> = resolve_effective_adapter_name( + &native_field(&discovered, "project_config")?, + &native_field(&discovered, "local_config")?, + )?; + let adapter: Bound<'py, PyAny> = resolve_cli_adapter(&adapter_name, &project_dir)?; + let graph: Bound<'py, PyAny> = build_project_graph( + py, + Some(keywords( + py, + &[ + ("discovered_inputs", discovered.clone()), + ("adapter", adapter.clone()), + ("selected_target", native_field(request, "selected_target")?), + ( + "no_sql_validation", + native_field(request, "no_sql_validation")?, + ), + ("cli_vars", native_field(request, "cli_vars")?), + ("skip_column_inference", boolean(py, false)), + ], + )?), + )?; + let project: Bound<'py, PyAny> = native_field(&graph, "project")?; + let directory: PathBuf = project_dir.extract()?; + let changed: ChangedProject = changed_project((&project, &discovered), &directory, base)?; + let ProjectGraph { + graph: review_graph, + keys, + reads, + stateful, + databases, + } = review_graph((&project, &discovered), &settings.critical_tags)?; + let project_wide: Vec = project_wide_keys(); + let mut changed_nodes: Vec = Vec::new(); + for name in &review_graph.order { + let node: &ReviewNode = &review_graph.nodes[name]; + let key: &str = &keys[name]; + let node_reads: &[String] = reads.get(name).map_or(&[], Vec::as_slice); + let reasons: Vec = node_change_reasons( + key, + (node_reads, &project_wide), + (&changed.current, &changed.changes), + ); + if !reasons.is_empty() { + changed_nodes.push(ChangedNode { + name: name.clone(), + kind: node.kind.clone(), + reasons, + added: changed + .changes + .changed + .get(key) + .is_some_and(|change| change.added), + }); + } + } + let changes_json: Value = serde_json::from_str(&changed.json((0, &[]))) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string()))?; + let target_database: Option = + optional_text(&native_field(&project, "effective_target_database")?)? + .filter(|database| !database.eq_ignore_ascii_case(PRESERVE_DATABASE)); + let review = ReviewProject { + changed_declarations: changed_declarations(&changed.changes), + reference: changed.reference.clone(), + merge_base: changes_json + .get("merge_base") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + changes_json: Some(changes_json), + project_dir: directory, + review_graph, + changed: changed_nodes, + stateful, + databases, + target_database, + target_schema: optional_text(&native_field(&project, "effective_target_schema")?)?, + }; + Ok((review, (discovered, adapter, graph))) +} + +/// Each `--select` token and the model and seed names it resolves to. +pub(crate) fn selected_tokens( + request: &Bound<'_, PyAny>, + graph: &Bound<'_, PyAny>, +) -> PyResult>> { + let py: Python<'_> = request.py(); + let tokens: Vec = strings(&native_field(request, "select")?)?; + if tokens.is_empty() { + return Ok(None); + } + let mut selected: Vec = Vec::new(); + for token in tokens { + let select: Bound<'_, PyAny> = PyTuple::new(py, [text(py, &token)])?.into_any(); + let exclude: Bound<'_, PyAny> = PyTuple::empty(py).into_any(); + let names: Vec = match select_project_graph(graph, &select, &exclude) { + Ok(keys) => key_names(&keys)?, + Err(error) if error.is_instance_of::(py) => Vec::new(), + Err(error) => return Err(error), + }; + selected.push(SelectedToken { token, names }); + } + Ok(Some(selected)) +} + +fn key_names(keys: &Bound<'_, PyFrozenSet>) -> PyResult> { + let mut names: Vec = Vec::new(); + for key in keys.try_iter()? { + let key: Bound<'_, PyAny> = key?; + let kind: String = native_field(&key, "resource_type")?.str()?.extract()?; + if kind == MODEL_KIND || kind == SEED_KIND { + names.push(native_field(&key, "name")?.extract()?); + } + } + names.sort(); + Ok(names) +} diff --git a/crates/sqlbuild-python/src/bindings/_helpers/review_command/registration.rs b/crates/sqlbuild-python/src/bindings/_helpers/review_command/registration.rs new file mode 100644 index 0000000000..f74d4efd27 --- /dev/null +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/registration.rs @@ -0,0 +1,25 @@ +//! Register `sqb review` and the review schema name parser the janitor uses. + +use pyo3::prelude::{Bound, PyAny, PyModule, PyModuleMethods, PyResult, Python}; +use pyo3::{pyfunction, wrap_pyfunction}; +use sqlbuild_analysis::review_command::main::parse_review_schema_name::parse_review_schema_name as parse_name; + +use crate::bindings::_helpers::janitor::janitor_keys::utc_datetime; +use crate::bindings::_helpers::review_command::command; + +/// The aware UTC creation time a strict review schema name encodes, or `None`. +#[pyfunction] +#[pyo3(signature = (name, /))] +pub(crate) fn parse_review_schema_name<'py>( + py: Python<'py>, + name: &str, +) -> PyResult>> { + parse_name(name) + .map(|timestamp| utc_datetime(py, timestamp)) + .transpose() +} + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_function(wrap_pyfunction!(parse_review_schema_name, module)?)?; + command::register(module) +} diff --git a/crates/sqlbuild-python/src/bindings/_helpers/review_command/schema.rs b/crates/sqlbuild-python/src/bindings/_helpers/review_command/schema.rs new file mode 100644 index 0000000000..83f2ce8070 --- /dev/null +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/schema.rs @@ -0,0 +1,204 @@ +//! The run-owned review schema: its name, its target, and its creation and cleanup. + +use pyo3::prelude::{Bound, PyAny, PyAnyMethods, PyResult, Python}; +use pyo3::types::{PyDict, PyDictMethods}; +use sqlbuild_analysis::janitor_planning::models::ArchiveTimestamp; +use sqlbuild_analysis::review_command::constants::UNKNOWN_REVIEW_DATABASE_CODE; +use sqlbuild_analysis::review_command::main::review_schema_name::review_schema_name; +use sqlbuild_host::entries::models::ExecutorHost; + +use crate::bindings::_helpers::boundary::python_calls::{call_with, keywords}; +use crate::bindings::_helpers::build_command::support::{boolean, none, text}; +use crate::bindings::_helpers::command_runtime::connection::resolve_target_connection_config; +use crate::bindings::_helpers::contracts::spec_resolution::resolve_target_config; +use crate::bindings::_helpers::janitor::janitor_keys::utc_datetime; +use crate::bindings::_helpers::native_host::host; +use crate::bindings::_helpers::records::construct::{enum_member, new_record_with}; +use crate::bindings::_helpers::records::fields::{field as native_field, replaced}; +use crate::bindings::_helpers::records::ids::RecordId; +use crate::bindings::_helpers::review_command::planning::{ReviewProject, ReviewSettings}; +use crate::bindings::_helpers::review_command::support::review_error; + +/// Where the review builds and where its ownership marker goes. +pub(crate) struct ReviewSchema { + pub(crate) name: String, + /// The database every node the review builds goes to: the active target's expanded one. + pub(crate) database: Option, + marker_schema: String, + run_id: String, + created_at: ArchiveTimestamp, +} + +/// This run's review schema, refusing nodes that set a database when the target sets none. +pub(crate) fn review_schema( + py: Python<'_>, + (project, settings, validated): (&ReviewProject, &ReviewSettings, &[String]), + ((created_at, compact), head, run_id): ((ArchiveTimestamp, String), &str, &str), +) -> PyResult { + let Some(marker_schema) = project.target_schema.clone() else { + return Err(review_error( + py, + "C771", + &format!( + "target '{}' has no schema to record the review schema in", + settings.target + ), + Some("set schema on the target in sqlbuild_project.toml"), + )); + }; + if project.target_database.is_none() { + let own: Vec = validated + .iter() + .filter_map(|name| own_database(project, name)) + .collect(); + if !own.is_empty() { + return Err(review_error( + py, + UNKNOWN_REVIEW_DATABASE_CODE, + &format!( + "target '{}' sets no database, so the review cannot keep these nodes in one \ + review database: {}", + settings.target, + own.join(", ") + ), + Some("set database on the target in sqlbuild_project.toml"), + )); + } + } + Ok(ReviewSchema { + name: review_schema_name(&settings.schema_prefix, &compact, head, run_id), + database: project.target_database.clone(), + marker_schema, + run_id: run_id.to_owned(), + created_at, + }) +} + +/// `name (database)` when the node sets its own database. +fn own_database(project: &ReviewProject, name: &str) -> Option { + let database: &String = project.databases.get(name)?; + Some(format!("{name} ({database})")) +} + +fn active_target_config<'py>( + discovered: &Bound<'py, PyAny>, + target: &str, +) -> PyResult> { + let py: Python<'py> = discovered.py(); + resolve_target_config( + py, + &native_field(discovered, "project_config")?, + &native_field(discovered, "local_config")?, + &text(py, target), + ) +} + +/// `discovered` plus the active target writing only to the review schema and database. +pub(crate) fn with_review_target<'py>( + discovered: &Bound<'py, PyAny>, + settings: &ReviewSettings, + schema: &ReviewSchema, +) -> PyResult> { + let py: Python<'py> = discovered.py(); + let active: Bound<'py, PyAny> = active_target_config(discovered, &settings.target)?; + let against: Bound<'py, PyAny> = active_target_config(discovered, &settings.against)?; + let sources_from: Bound<'py, PyAny> = match native_field(&against, "defer_sources_to")? { + value if !value.is_none() => value, + _ => text(py, &settings.against), + }; + let clone_policy: Bound<'py, PyAny> = new_record_with( + py, + RecordId::ClonePolicy, + &[ + ("allow_as_clone_origin", boolean(py, false)), + ("allow_as_clone_destination", boolean(py, true)), + ], + )?; + let mut fields: Vec<(&str, Bound<'py, PyAny>)> = vec![ + ("schema", text(py, &schema.name)), + ("loader_schema", text(py, &schema.name)), + ("defer_sources_to", sources_from), + ("defer_clone_from", none(py)), + ("clone", clone_policy), + ( + "missing_migration_origin", + enum_member(py, RecordId::MissingMigrationOriginPolicy, "ALLOW")?, + ), + ]; + if let Some(database) = &schema.database { + fields.push(("database", text(py, database))); + } + let review: Bound<'py, PyAny> = replaced(&active, &fields)?; + let project_config: Bound<'py, PyAny> = native_field(discovered, "project_config")?; + let targets: Bound<'py, PyDict> = PyDict::new(py); + targets.update(native_field(&project_config, "targets")?.cast::()?)?; + targets.set_item(&schema.name, review)?; + let project_config: Bound<'py, PyAny> = + replaced(&project_config, &[("targets", targets.into_any())])?; + replaced(discovered, &[("project_config", project_config)]) +} + +/// The active target's connection config. +pub(crate) fn connection_config<'py>( + request: &Bound<'py, PyAny>, + (project_dir, discovered): (&Bound<'py, PyAny>, &Bound<'py, PyAny>), + settings: &ReviewSettings, +) -> PyResult> { + let py: Python<'py> = request.py(); + resolve_target_connection_config( + py, + Some(keywords( + py, + &[ + ("discovered_inputs", discovered.clone()), + ("project_dir", project_dir.clone()), + ("target_name", text(py, &settings.target)), + ("cli_vars", native_field(request, "cli_vars")?), + ], + )?), + ) +} + +/// Record ownership in the active target's state table, then create the review schema. +pub(crate) fn create_review_schema( + (adapter, connection): (&Bound<'_, PyAny>, &Bound<'_, PyAny>), + schema: &ReviewSchema, +) -> PyResult<()> { + let py: Python<'_> = adapter.py(); + call_with( + &host::entry(py, ExecutorHost::CreateReviewSchema)?, + &[ + ("adapter", adapter.clone()), + ("connection", connection.clone()), + ("database", optional(py, schema.database.as_deref())), + ("schema", text(py, &schema.name)), + ("marker_database", optional(py, schema.database.as_deref())), + ("marker_schema", text(py, &schema.marker_schema)), + ("run_id", text(py, &schema.run_id)), + ("created_at", utc_datetime(py, schema.created_at)?), + ], + )?; + Ok(()) +} + +/// Drop the review schema; whether it is gone afterwards. +pub(crate) fn drop_review_schema( + (adapter, connection): (&Bound<'_, PyAny>, &Bound<'_, PyAny>), + schema: &ReviewSchema, +) -> PyResult { + let py: Python<'_> = adapter.py(); + call_with( + &host::entry(py, ExecutorHost::DropReviewSchema)?, + &[ + ("adapter", adapter.clone()), + ("connection", connection.clone()), + ("database", optional(py, schema.database.as_deref())), + ("schema", text(py, &schema.name)), + ], + )? + .is_truthy() +} + +fn optional<'py>(py: Python<'py>, value: Option<&str>) -> Bound<'py, PyAny> { + value.map_or_else(|| none(py), |value| text(py, value)) +} diff --git a/crates/sqlbuild-python/src/bindings/_helpers/review_command/support.rs b/crates/sqlbuild-python/src/bindings/_helpers/review_command/support.rs new file mode 100644 index 0000000000..d4216cd5c2 --- /dev/null +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/support.rs @@ -0,0 +1,153 @@ +//! Errors and progress lines shared by the `sqb review` phases. + +use std::time::Instant; + +use pyo3::prelude::{Bound, PyAny, PyAnyMethods, PyErr, PyResult, Python}; +use pyo3::types::{PyDict, PyDictMethods, PyTypeMethods}; +use sqlbuild_analysis::janitor_planning::models::ArchiveTimestamp; +use sqlbuild_analysis::review_command::errors::ReviewPlanError; +use sqlbuild_analysis::review_command::models::ReviewFailure; +use sqlbuild_host::entries::models::ExecutorHost; + +use crate::bindings::_helpers::boundary::python_calls::{call_with, imported, within}; +use crate::bindings::_helpers::clone::clone_progress::write_line; +use crate::bindings::_helpers::execution_history::conversion::optional_text; +use crate::bindings::_helpers::janitor::janitor_keys::{now_utc, timestamp_fields}; +use crate::bindings::_helpers::native_host::host; +use crate::bindings::_helpers::records::construct::error_class; +use crate::bindings::_helpers::records::fields::field as native_field; +use crate::bindings::_helpers::records::ids::ErrorId; + +/// The code of an unexpected failure inside a review. +const UNEXPECTED_CODE: &str = "C760"; +/// The message of a failure that carries none. +const UNEXPLAINED_FAILURE: &str = "review failed"; + +/// `class(message, code=code, help=help)`. +fn raised(py: Python<'_>, class: ErrorId, failure: &ReviewFailure) -> PyErr { + let instance = (|| -> PyResult> { + let keywords: Bound<'_, PyDict> = PyDict::new(py); + keywords.set_item("code", failure.code.as_str())?; + keywords.set_item("help", failure.help.as_deref())?; + error_class(py, class)?.call((failure.message.as_str(),), Some(&keywords)) + })(); + match instance { + Ok(error) => PyErr::from_value(error), + Err(error) => error, + } +} + +/// `CliUserError(message, code=code, help=help)`. +pub(crate) fn review_error(py: Python<'_>, code: &str, message: &str, help: Option<&str>) -> PyErr { + raised( + py, + ErrorId::CliUser, + &ReviewFailure { + code: code.to_owned(), + message: message.to_owned(), + help: help.map(str::to_owned), + }, + ) +} + +pub(crate) fn plan_error(py: Python<'_>, error: &ReviewPlanError) -> PyErr { + review_error(py, &error.code, &error.message, error.help.as_deref()) +} + +/// `name` of `value` as text, or `None` when it is absent or `None`. +fn attribute(value: &Bound<'_, PyAny>, name: &str) -> PyResult> { + match value.getattr_opt(name)? { + Some(item) if !item.is_none() => Ok(Some(item.str()?.extract()?)), + Some(_) | None => Ok(None), + } +} + +/// The code, message and help of `error`, as the report records it. +pub(crate) fn error_parts(py: Python<'_>, error: &PyErr) -> PyResult { + let value: Bound<'_, PyAny> = error.value(py).clone().into_any(); + let message: String = match attribute(&value, "message")? { + Some(message) => message, + None => value.str()?.extract()?, + }; + let message: String = if message.is_empty() { + value.get_type().name()?.extract()? + } else { + message + }; + Ok(ReviewFailure { + code: attribute(&value, "code")?.unwrap_or_else(|| UNEXPECTED_CODE.to_owned()), + message: if message.is_empty() { + UNEXPLAINED_FAILURE.to_owned() + } else { + message + }, + help: attribute(&value, "help")?, + }) +} + +/// `ReviewFailedError` carrying the reported error, chained to `cause`. +pub(crate) fn failed_error(py: Python<'_>, error: &ReviewFailure, cause: PyErr) -> PyErr { + let replacement: PyErr = raised(py, ErrorId::ReviewFailed, error); + replacement.set_cause(py, Some(cause)); + replacement +} + +/// One lifecycle phase: a start line, then a completion or failure line with its duration. +pub(crate) fn phase( + stream: &Bound<'_, PyAny>, + (start, done, failed): (&str, &str, &str), + body: impl FnOnce() -> PyResult, +) -> PyResult { + write_line(stream, &format!("{start}..."))?; + let started: Instant = Instant::now(); + let outcome: PyResult = body(); + let elapsed: f64 = started.elapsed().as_secs_f64(); + let label: &str = match &outcome { + Ok(_) => done, + Err(_) => failed, + }; + write_line(stream, &format!("{label} ({elapsed:.2}s)"))?; + outcome +} + +/// Where a review's progress goes: stderr when stdout carries `--json`, otherwise stdout. +pub(crate) fn progress_stream(py: Python<'_>, to_stderr: bool) -> PyResult> { + imported(py, "sys", if to_stderr { "stderr" } else { "stdout" }) +} + +/// Run `body` with `sys.stdout` sent to the `progress` stream when stdout carries JSON. +pub(crate) fn quietly( + (progress, machine): (&Bound<'_, PyAny>, bool), + body: impl FnOnce() -> PyResult, +) -> PyResult { + if !machine { + return body(); + } + let redirect: Bound<'_, PyAny> = imported(progress.py(), "contextlib", "redirect_stdout")?; + within( + &call_with(&redirect, &[("new_target", progress.clone())])?, + |_| body(), + ) +} + +/// The current UTC time and its `YYYYMMDDTHHMMSSZ` form. +pub(crate) fn utc_now_compact(py: Python<'_>) -> PyResult<(ArchiveTimestamp, String)> { + let fields: ArchiveTimestamp = timestamp_fields(&now_utc(py)?)?; + let compact: String = format!( + "{:04}{:02}{:02}T{:02}{:02}{:02}Z", + fields.year, fields.month, fields.day, fields.hour, fields.minute, fields.second + ); + Ok((fields, compact)) +} + +/// The current invocation id, when an invocation identity is active. +pub(crate) fn invocation_id(py: Python<'_>) -> PyResult> { + let identity: Bound<'_, PyAny> = call_with( + &host::entry(py, ExecutorHost::CurrentExecutionIdentity)?, + &[], + )?; + if identity.is_none() { + return Ok(None); + } + optional_text(&native_field(&identity, "invocation_id")?) +} diff --git a/fensu.toml b/fensu.toml index 26113efe6e..b554e3acbe 100644 --- a/fensu.toml +++ b/fensu.toml @@ -397,6 +397,21 @@ modules = ["sqlbuild.executor.diff.classes.query_artifact_lifecycle"] symbols = ["QueryDiffArtifactLifecycle"] reason = "Native code resolves these through the sqlbuild._native_host name table." +[[targets.python.dead_code.roots]] +modules = ["sqlbuild.executor.review.main.create_review_schema"] +symbols = ["create_review_schema"] +reason = "Native code resolves these through the sqlbuild._native_host name table." + +[[targets.python.dead_code.roots]] +modules = ["sqlbuild.executor.review.main.drop_review_schema"] +symbols = ["drop_review_schema"] +reason = "Native code resolves these through the sqlbuild._native_host name table." + +[[targets.python.dead_code.roots]] +modules = ["sqlbuild.executor.review.main.leftover_review_schemas"] +symbols = ["leftover_review_schemas"] +reason = "Native code resolves these through the sqlbuild._native_host name table." + [[targets.python.dead_code.roots]] modules = ["sqlbuild.executor.diff.exceptions"] symbols = ["FullDiffSizeGuardError"] @@ -2118,6 +2133,10 @@ pending_budgets = [ "crates/sqlbuild-python/src/bindings/_helpers/result_output/projection.rs=4", "crates/sqlbuild-python/src/bindings/_helpers/result_output/resource_ids.rs=1", "crates/sqlbuild-python/src/bindings/_helpers/result_output/terminal_index.rs=14", + "crates/sqlbuild-python/src/bindings/_helpers/review_command/command.rs=2", + "crates/sqlbuild-python/src/bindings/_helpers/review_command/comparison.rs=2", + "crates/sqlbuild-python/src/bindings/_helpers/review_command/execution.rs=1", + "crates/sqlbuild-python/src/bindings/_helpers/review_command/support.rs=3", "crates/sqlbuild-python/src/bindings/_helpers/rules/builtin_catalogue.rs=2", "crates/sqlbuild-python/src/bindings/_helpers/rules/catalogue.rs=7", "crates/sqlbuild-python/src/bindings/_helpers/rules/custom_evaluation.rs=5", @@ -2436,6 +2455,8 @@ pending_budgets = [ "crates/sqlbuild-python/src/bindings/_helpers/result_output/event_writer.rs=2", "crates/sqlbuild-python/src/bindings/_helpers/result_output/integration_envelope.rs=4", "crates/sqlbuild-python/src/bindings/_helpers/result_output/terminal_index.rs=4", + "crates/sqlbuild-python/src/bindings/_helpers/review_command/comparison.rs=8", + "crates/sqlbuild-python/src/bindings/_helpers/review_command/execution.rs=3", "crates/sqlbuild-python/src/bindings/_helpers/rules/custom_evaluation.rs=4", "crates/sqlbuild-python/src/bindings/_helpers/rules/custom_host_observation.rs=2", "crates/sqlbuild-python/src/bindings/_helpers/rules/custom_hosts.rs=2", @@ -2641,6 +2662,7 @@ module_layers = [ "render=front", "result_output=output", "result_output/integration_values=infra", + "review_command=commands", "rule_records=infra", "rules=compile", "scenario_command=commands", diff --git a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/CONTENTS.md b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/CONTENTS.md index 651fb4314a..35373fb57b 100644 --- a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/CONTENTS.md +++ b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/CONTENTS.md @@ -142,4 +142,5 @@ Bundled copies of every page on the SQLBuild documentation site, matching the in ## Other pages +- [sqb review](cli/review.md) (`cli/review`) - Build what a branch changed in a temporary schema, test it, compare it with a target and report. - [Enums and Constants Have Moved](concepts/enums-and-constants.md) (`concepts/enums-and-constants`) - Find the new focused guides for enums, constants, collections, contracts, and private values. diff --git a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/janitor.md b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/janitor.md index 02bf74b673..8e21ca3e63 100644 --- a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/janitor.md +++ b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/janitor.md @@ -15,6 +15,7 @@ Online: https://sqlbuild.com/docs/cli/janitor/ - Speed - Targets - Lifecycle +- Review schemas - Compatibility views - Pending migration origins - Relation age @@ -65,7 +66,7 @@ Deleted 1 expired archive. (0.12s) ✓ Janitor completed archived 3, deleted 1, pruned 0 (0.81s) ``` -The phases are archiving stale relations, deleting expired archives, dropping old-name views, dropping expired query artifacts and pruning direct state, in that order. If an action fails, its line shows `FAIL` and the error, no further action starts, and the run ends with `✗ Janitor failed` and the totals completed before the failure, exiting with `1`. +The phases are archiving stale relations, deleting expired archives, dropping old-name views, dropping expired query artifacts, dropping leftover review schemas and pruning direct state, in that order. If an action fails, its line shows `FAIL` and the error, no further action starts, and the run ends with `✗ Janitor failed` and the totals completed before the failure, exiting with `1`. Long preview sections show their first 10 entries and then `... and N more (use --verbose to show all)`. Skipped objects are counted by reason: @@ -185,6 +186,10 @@ Long names are shortened with a short hash so they fit the adapter's identifier Setting both `retention_days` and `archive_retention_days` to `0` archives and deletes stale relations in the same run. +## Review schemas + +[`sqb review`](review.md) builds into a schema it creates for one run and drops when the run ends. Before creating the schema it writes an ownership marker to the active target schema's `_sqlbuild_fingerprints` table. When a review could not drop its schema, the janitor drops it once the timestamp in its name is more than 24 hours old, so a review that is still running is never touched. Only schemas with a marker in the active target schema are dropped; a schema whose name merely looks like a review schema is left alone. + ## Compatibility views When a model is renamed, SQLBuild keeps a [compatibility view](../concepts/models/migrations.md#old-names) at its old name. The janitor lists each one with the model it reads and its expiry: diff --git a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/review.md b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/review.md new file mode 100644 index 0000000000..3762580046 --- /dev/null +++ b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/review.md @@ -0,0 +1,203 @@ + + +# sqb review + +> Build what a branch changed in a temporary schema, test it, compare it with a target and report. + +Online: https://sqlbuild.com/docs/cli/review/ + +## Contents + +- Usage +- Flags +- What gets built +- Selecting +- The review schema +- Hooks +- Checks +- Output +- Exit codes +- Configuration + +`sqb review` checks a branch before it merges. It finds the models and seeds the branch changed, +builds them and their affected downstream models in a schema it creates for this run, runs the +unit tests and audits those changes affect, compares every built model with the same model in +another target, and reports the result as text, JSON or Markdown with an exit code a CI job can +gate on. + +## Usage + +```bash +sqb review --since origin/main --against prod +sqb review --since origin/main --against prod --dry-run +sqb review --since origin/main --against prod --json --markdown review.md +``` + +Two baselines drive a review: + +- **`--since `** is the code baseline. The changed set is everything that differs between + the merge base of `` and `HEAD` (including uncommitted edits), worked out exactly as + [`sqb test --since`](test.md#run-only-affected-tests) does. +- **`--against `** is the data baseline: the target whose tables the review compares + with, reads unchanged upstream models from, and clones changed incremental models from. + +Both can come from `sqlbuild_project.toml` (see [Configuration](#configuration)), so a project +with `[git] base_ref` and `[review] against` runs a review with plain `sqb review`. + +## Flags + +| Flag | Description | +|------|-------------| +| `--since ` | Git ref whose merge base with `HEAD` is the code baseline; `@base` reads `[git] base_ref` (default: `[git] base_ref`) | +| `--against ` | Target to compare with and to read unchanged upstream from (default: `[review] against`) | +| `--downstream ` | Which unchanged downstream models to build and compare: `critical` (default), `all` or `none` | +| `--select`, `-s` | Narrow the review to these nodes of the changed set; see [Selecting](#selecting) | +| `--dry-run` | Print the plan, including the nodes whose hooks would run, without connecting to the warehouse | +| `--no-clone` | Start changed incremental models and snapshots from a full refresh instead of a clone | +| `--bounded ` | Compare models too large for an exact comparison within a recent cursor window, such as `14d` | +| `--markdown ` | Also write the report as Markdown to `` | +| `--json` | Print one JSON document on stdout; progress goes to stderr | +| `--target ` | Active target whose connection runs the review and whose schema holds the ownership marker | +| `--concurrency ` | Build concurrency | +| `--vars` | Project variables, as for `sqb build` | + +## What gets built + +The **changed set** is every model or seed whose definition changed, whose configuration changed, +or that reads a changed source, function or macro. Changes that build nothing, such as an edited +source declaration or unit test, are listed under "What changed" but are not built themselves. + +`--downstream` decides which unchanged models below the changed set are built too: + +- `critical` builds the models on every path from a changed node to a critical model. A model is + critical when its `MODEL` header sets [`critical true`](../concepts/models/configuration.md), or + when it carries one of the tags in `[review] critical_tags` (default `["critical"]`). +- `all` builds every downstream model. +- `none` builds only the changed set. + +Downstream models the policy leaves out are always listed as **not validated**, with the reason. +The impact tree is computed from the whole changed set, whatever the policy. + +A review never runs [Python nodes](../concepts/python-nodes/overview.md). A changed Python node, +and every task, asset or check downstream of a change, appears in the impact tree as not validated +with the reason. So does every model that reads a changed loader's source, directly or through +other models: the review would build it from the `--against` data rather than from the changed +loader, so it is listed as `reads Python node loader:, not rebuilt by review` instead of +being built. + +Upstream models outside the build are read from `--against`, as with +[`sqb build --defer-to`](build.md). A changed incremental model or snapshot starts from a +zero-copy clone of its `--against` table when the adapter supports zero-copy clones and the +`--against` target allows being a clone origin; otherwise, or with `--no-clone`, it is built with +a full refresh. The report says which start each one got and why. + +## Selecting + +`--select` intersects with the review scope: only the selected nodes are built and compared; the +rest of the scope is listed as not validated. Every changed node a selected node depends on is +built and compared too, so a selected model never reads `--against` data where the branch differs; +the report lists each one as also built because the selected node depends on it. Each selector must +name something the review can check, or the review fails before connecting: + +| Selection | Error | +|-----------|-------| +| A name that matches no node | `C761` | +| Only nodes the branch did not change and that nothing changed above | `C762` | +| Only affected nodes the downstream policy leaves out, or that read a changed Python node | `C763` | + +## The review schema + +A review builds into a schema named `___` in lowercase, for +example `review_20261023t101500z_3f9c2ab_4e1d0a`. Lowercase is how warehouses that fold unquoted +names to lowercase store it, and warehouses that fold to uppercase compare schema names without +case, so the review, the existence checks and the janitor all find the same schema. Before creating +it, the review writes an ownership marker to the active target schema's `_sqlbuild_fingerprints` +table, and it refuses a schema that already exists. + +Every model and seed the review builds goes to the review schema in the active target's database, +including nodes that set their own `database`. When the active target sets no database, or sets it +to `preserve`, and a node the review builds sets its own, the review refuses with `C776` before +creating anything and names those nodes; set a database on the target to review them. The schema is always dropped when the review ends, whether it passed or not; a failed drop +makes the review fail with exit code `3`. [`sqb janitor`](janitor.md#review-schemas) drops +marked review schemas left behind for more than 24 hours. + +The adapter must be able to drop a whole schema: SQLite and SQL Server targets cannot run a review. + +## Hooks + +Model [pre- and post-hooks](../concepts/models/hooks.md) run during a review as they run during a +build, with the review schema as the model's destination, so a hook that reaches its relation +through the hook context, such as `@@CTX:destination.qualified`, acts on the review copy. A hook +that hard-codes another relation's name acts on that relation, which may be outside the review +schema. Check those hooks before reviewing; `--dry-run` and the run details list the nodes whose +hooks will run. + +## Checks + +- **Unit tests** that execute a changed node, chosen as `sqb test --since` chooses them, run on the + active target. The report counts how many unaffected tests were skipped. +- **Audits** of the built models run during the build. +- **Comparison** of every built model with its `--against` table: column names and types, row + counts, and, when the model has a `unique_key` and the [size guard](diff.md#size-guard) + allows, an exact keyed diff with branch-only keys, target-only keys and changed values, each with + example keys. A model the size guard blocks is compared within `--bounded` when given and the + model has a cursor. Models without a `unique_key` get the schema and row-count checks only. + +Any difference is a finding: a schema change, a different row count, branch-only or target-only +keys, a changed value, a failed unit test or a failed audit. A check that never ran is never +reported as passing; it is **incomplete coverage**: + +- the keyed diff of a model without a `unique_key`, or of a model the size guard blocks; +- a changed seed, since the comparison covers models only; +- a node the build skipped; +- a model that existed at the base revision but has no table in `--against`. + +A model new in the branch is built but has nothing to compare, and the impact tree says so. When +`--against` has none of the tables of the models to compare that existed at the base revision, the +review fails with `C774` before creating anything, since that usually means the wrong target. + +## Output + +Text output starts with the outcome, then findings, what changed, the downstream impact tree with +each node's status (`built`, `ok`, `finding` or `not validated` and why), incomplete coverage, the +baseline, tests and audits, and run details: the review schema and its cleanup, the downstream +policy, and the [`sqb recall output`](recall.md) command that shows the whole run. + +`--markdown ` writes the same report as Markdown for a pull request comment: a title with the +outcome, a summary table, numbered findings, and per-model evidence in collapsed `
` blocks +that list only the columns that changed. + +`--json` prints one document with `format_version`, `status` (`passed`, `findings`, `incomplete`, +`failed` or `planned`), `exit_code`, `since`, `against`, `summary`, `findings`, `changed`, +`validated`, `not_validated`, `nodes`, `impact_edges`, `incomplete`, `baseline`, `tests`, `audits`, +`review_schema`, `recall` and `error`. The document is written on every path, including failures. + +## Exit codes + +| Code | Meaning | +|------|---------| +| `0` | Passed: every check ran and found no difference, or a dry run | +| `1` | Findings | +| `2` | Incomplete coverage, and no findings | +| `3` | The review could not set up, run or clean up, including invalid arguments and a `--markdown` file it could not write | + +A failure takes precedence over findings, and findings over incomplete coverage. Exit code `2` +means only incomplete coverage: invalid arguments, such as an unknown `--downstream` policy, exit +`3`, and with `--json` print the JSON document with the `C900` error. When the `--markdown` file +cannot be written, the review prints its result on stdout first, then reports the error (`C775`) +and exits `3`. + +## Configuration + +```toml +[git] +base_ref = "origin/main" + +[review] +against = "prod" +downstream = "critical" +schema_prefix = "review" +critical_tags = ["critical"] +``` + +See [Review](../concepts/project-configuration.md#review) in the project configuration reference. diff --git a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/adapters.md b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/adapters.md index 759e8deb0a..1496fb99b0 100644 --- a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/adapters.md +++ b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/adapters.md @@ -102,7 +102,7 @@ Set `sql_analysis_dialect_name` to the SQL dialect name that matches your engine For full control with no inherited defaults, subclass `StrictAdapter` instead. Every method is abstract and must be implemented explicitly. SQLBuild raises a clear error listing any unimplemented methods. -A few `StrictAdapter` methods are optional and have a documented default. `create_sql_test_output` and `drop_sql_test_output` let an adapter store a unit test's actual output once so every comparison reads it (see [Single-read comparison](testing.md#single-read-comparison)). By default `create_sql_test_output` returns `None`, and SQLBuild runs the test's inline comparison query instead, so adapters that do not implement them keep working unchanged. `render_create_temporary_function` returns the call target and DDL for a session-scoped copy of a project function, which `sqb test` calls instead of the built function (see [Functions in `sqb test`](testing.md#functions-in-sqb-test)). By default it returns `None`, and tests call the built function. +A few `StrictAdapter` methods are optional and have a documented default. `create_sql_test_output` and `drop_sql_test_output` let an adapter store a unit test's actual output once so every comparison reads it (see [Single-read comparison](testing.md#single-read-comparison)). By default `create_sql_test_output` returns `None`, and SQLBuild runs the test's inline comparison query instead, so adapters that do not implement them keep working unchanged. `render_create_temporary_function` returns the call target and DDL for a session-scoped copy of a project function, which `sqb test` calls instead of the built function (see [Functions in `sqb test`](testing.md#functions-in-sqb-test)). By default it returns `None`, and tests call the built function. `render_drop_schema` returns the statements that drop a schema and everything in it, which [`sqb review`](../cli/review.md) uses to remove the schema it built into. By default it raises an error, and `sqb review` is unavailable for that adapter. ### Discovery rules diff --git a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/models/configuration.md b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/models/configuration.md index 7a5fa287d8..22ed33a4aa 100644 --- a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/models/configuration.md +++ b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/models/configuration.md @@ -37,6 +37,7 @@ which accepts any keys. |-------|-------------| | `materialized` | `view`, `table`, `incremental`, `snapshot`, or a custom materialization name | | `tags` | Tags used by selectors | +| `critical` | `true` marks the model as critical, so [`sqb review`](../../cli/review.md) builds and compares it whenever something upstream of it changes (default `false`) | | `description` | Required human-readable model description; see [Descriptions](#descriptions) | | `columns` | Model-local column declarations, inherited-column audit augmentation, or column renames with `migrate_from` (incremental and snapshot models; see [Column migrations](column-migrations.md)). On a table or view model that declares its own `migrate_from`, a column `migrate_from` only names the old column in the [compatibility view](migrations.md#old-names) | | `model_schema` | Reusable column schema name | diff --git a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/project-configuration.md b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/project-configuration.md index b733f82084..17ab16b1a2 100644 --- a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/project-configuration.md +++ b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/project-configuration.md @@ -22,6 +22,7 @@ Online: https://sqlbuild.com/docs/concepts/project-configuration/ - Migrations - Scenario - Git +- Review - dbt - Skills - Project Python @@ -898,7 +899,26 @@ base_ref = "origin/main" | Field | Default | Description | |-------|---------|-------------| -| `base_ref` | unset | Git ref that [`sqb test --since @base`](../cli/test.md#run-only-affected-tests) and `sqb scenario test --since @base` read. Use a ref every checkout has, such as the remote default branch. | +| `base_ref` | unset | Git ref that [`sqb test --since @base`](../cli/test.md#run-only-affected-tests), `sqb scenario test --since @base` and [`sqb review`](../cli/review.md) read. Use a ref every checkout has, such as the remote default branch. | + +## Review + +Defaults for [`sqb review`](../cli/review.md): + +```toml +[review] +against = "prod" +downstream = "critical" +schema_prefix = "review" +critical_tags = ["critical"] +``` + +| Field | Default | Description | +|-------|---------|-------------| +| `against` | unset | Target to compare with and to read unchanged upstream models from. `--against` overrides it; one of them is required | +| `downstream` | `"critical"` | Which unchanged downstream models to build and compare: `critical`, `all` or `none`. `--downstream` overrides it | +| `schema_prefix` | `"review"` | Prefix of the schema each review creates and drops; the schema name is lowercase | +| `critical_tags` | `["critical"]` | Model tags that make a model critical, in addition to `critical true` in its `MODEL` header | ## dbt diff --git a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/index.md b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/index.md index 75eddbc8f4..86455e87ef 100644 --- a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/index.md +++ b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/index.md @@ -57,6 +57,7 @@ audit results and everything it printed. Agents and people query that history wi | Plan | [`sqb plan`](cli/plan.md) | Shows what will run, why, and how much it will rebuild | | Build | [`sqb build`](cli/build.md) | Runs tests, builds models in dependency order, and blocks bad data with audits | | Compare | [`sqb diff`](cli/diff.md) | Compares schemas and rows between two targets | +| Review | [`sqb review`](cli/review.md) | Builds a branch's changes in a temporary schema, tests them and compares them with a target | | Investigate | [`sqb recall`](cli/recall.md) | Queries the history of past runs, then live warehouse state | SQLBuild works with DuckDB, MotherDuck, Snowflake, BigQuery, Databricks, PostgreSQL, SQL Server, diff --git a/src/sqlbuild/_native.pyi b/src/sqlbuild/_native.pyi index bf81edc288..fa8e53d51f 100644 --- a/src/sqlbuild/_native.pyi +++ b/src/sqlbuild/_native.pyi @@ -3087,6 +3087,10 @@ class CliNamespace: warehouse: str | None case: str | None inspect: bool + review_against: str | None + review_downstream: str | None + review_no_clone: bool + review_markdown: Path | None target_range: str | None from_target: str | None to_target: str | None @@ -4779,6 +4783,7 @@ def write_check_results( def format_check_json(*, results: tuple[PythonCheckExecutionResult, ...]) -> str: ... def run_test_command(*, request: TestCommandRequest) -> int: ... def run_build_command(*, request: BuildCommandRequest) -> int: ... +def run_review_command(*, request: ReviewCommandRequest) -> int: ... def enforce_plan_safety_policies( *, plan: PlanOutput, @@ -5159,6 +5164,7 @@ def build_janitor_archive_name( ) -> str: ... def build_query_diff_artifact_name(*, run_id: str, side: str) -> str: ... def parse_query_diff_artifact_name(name: str, /) -> tuple[str, str] | None: ... +def parse_review_schema_name(name: str, /) -> datetime | None: ... def render_janitor_completion( *, result: JanitorExecutionResult, @@ -6407,6 +6413,7 @@ class BuildCommandRequest(NativeRecord): selection_diagnostics: bool selector_files: tuple[SelectorFileSummary, ...] test_timeout: str | None + discovered_inputs: DiscoveredProjectInputs | None def __init__( self, project_dir: Path | None = None, @@ -6444,6 +6451,7 @@ class BuildCommandRequest(NativeRecord): selection_diagnostics: bool = False, selector_files: tuple[SelectorFileSummary, ...] = (), test_timeout: str | None = None, + discovered_inputs: DiscoveredProjectInputs | None = None, ) -> None: ... class CloneCommandRequest(NativeRecord): @@ -6461,6 +6469,7 @@ class CloneCommandRequest(NativeRecord): cli_vars: dict[str, object] | None json_output_path: Path | None event_output_path: Path | None + discovered_inputs: DiscoveredProjectInputs | None def __init__( self, project_dir: Path | None, @@ -6475,6 +6484,7 @@ class CloneCommandRequest(NativeRecord): cli_vars: dict[str, object] | None = None, json_output_path: Path | None = None, event_output_path: Path | None = None, + discovered_inputs: DiscoveredProjectInputs | None = None, ) -> None: ... class PlanSafetyGates(NativeRecord): @@ -6604,6 +6614,47 @@ class DiffCommandRequest(NativeRecord): full_example_values: bool = False, ) -> None: ... +class ReviewCommandRequest(NativeRecord): + """CLI inputs for one `sqb review` invocation.""" + + project_dir: Path | None + no_color: bool + no_sql_validation: bool + selected_target: str | None + select: tuple[str, ...] + cli_vars: dict[str, object] | None + json_output: bool + since: str | None + against: str | None + downstream: str | None + dry_run: bool + no_clone: bool + bounded: str | None + markdown_path: Path | None + concurrency: int | None + verbose: bool + debug: bool + def __init__( + self, + project_dir: Path | None = None, + no_color: bool = False, + no_sql_validation: bool = False, + selected_target: str | None = None, + select: tuple[str, ...] = (), + cli_vars: dict[str, object] | None = None, + json_output: bool = False, + since: str | None = None, + against: str | None = None, + downstream: str | None = None, + dry_run: bool = False, + no_clone: bool = False, + bounded: str | None = None, + markdown_path: Path | None = None, + concurrency: int | None = None, + verbose: bool = False, + debug: bool = False, + ) -> None: ... + class QueryDiffPreparation(NativeRecord): """Resolved raw-query diff adapter, connection, SQL, and limits.""" @@ -7216,6 +7267,7 @@ class CliEntrypointHandlers(NativeRecord): run_contract: Callable[..., int] | None run_refactor: Callable[..., int] | None run_recall: Callable[..., int] | None + run_review: Callable[..., int] | None def __init__( self, run_compile: Callable[[CompileCommandRequest], int], @@ -7251,6 +7303,7 @@ class CliEntrypointHandlers(NativeRecord): run_contract: Callable[..., int] | None = None, run_refactor: Callable[..., int] | None = None, run_recall: Callable[..., int] | None = None, + run_review: Callable[..., int] | None = None, ) -> None: ... class CommandWarehouseScope(NativeRecord): @@ -7960,6 +8013,7 @@ class CliCommand(StrEnum): SEED = "seed" CLONE = "clone" DIFF = "diff" + REVIEW = "review" DEBUG = "debug" LINEAGE = "lineage" QUERY = "query" diff --git a/src/sqlbuild/_native_host.py b/src/sqlbuild/_native_host.py index f4d4ccbfc1..93ffba817f 100644 --- a/src/sqlbuild/_native_host.py +++ b/src/sqlbuild/_native_host.py @@ -360,6 +360,18 @@ "sqlbuild.executor.diff.classes.query_artifact_lifecycle", "QueryDiffArtifactLifecycle", ), + "create_review_schema": ( + "sqlbuild.executor.review.main.create_review_schema", + "create_review_schema", + ), + "drop_review_schema": ( + "sqlbuild.executor.review.main.drop_review_schema", + "drop_review_schema", + ), + "leftover_review_schemas": ( + "sqlbuild.executor.review.main.leftover_review_schemas", + "leftover_review_schemas", + ), "FullDiffSizeGuardError": ("sqlbuild.executor.diff.exceptions", "FullDiffSizeGuardError"), "execute_query_diff": ("sqlbuild.executor.diff.main._execute_query", "execute_query_diff"), "execute_diff": ("sqlbuild.executor.diff.main.execute", "execute_diff"), diff --git a/src/sqlbuild/_native_host.pyi b/src/sqlbuild/_native_host.pyi index cdd3aea788..7d3105f8d3 100644 --- a/src/sqlbuild/_native_host.pyi +++ b/src/sqlbuild/_native_host.pyi @@ -334,6 +334,15 @@ from sqlbuild.executor.python_nodes.models import ( ) from sqlbuild.executor.python_nodes.models import PythonNodeRunState as PythonNodeRunState from sqlbuild.executor.python_nodes.models import PythonNodeRuntime as PythonNodeRuntime +from sqlbuild.executor.review.main.create_review_schema import ( + create_review_schema as create_review_schema, +) +from sqlbuild.executor.review.main.drop_review_schema import ( + drop_review_schema as drop_review_schema, +) +from sqlbuild.executor.review.main.leftover_review_schemas import ( + leftover_review_schemas as leftover_review_schemas, +) from sqlbuild.executor.run.models import ModelExecutionResult as ModelExecutionResult from sqlbuild.executor.scenario.models import ScenarioCaptureSettings as ScenarioCaptureSettings from sqlbuild.executor.scenario.models import ScenarioLocalReplaySource as ScenarioLocalReplaySource diff --git a/src/sqlbuild/adapter/contract/classes/duckdb_backed_adapter.py b/src/sqlbuild/adapter/contract/classes/duckdb_backed_adapter.py index ec86c5acf3..b6907e80e1 100644 --- a/src/sqlbuild/adapter/contract/classes/duckdb_backed_adapter.py +++ b/src/sqlbuild/adapter/contract/classes/duckdb_backed_adapter.py @@ -251,6 +251,15 @@ def render_create_schema( target: str = f"{database}.{schema}" if database is not None else schema return (f"CREATE SCHEMA IF NOT EXISTS {target}",) + def render_drop_schema( + self, + *, + database: str | None, + schema: str, + ) -> tuple[str, ...]: + target: str = f"{database}.{schema}" if database is not None else schema + return (f"DROP SCHEMA IF EXISTS {target} CASCADE",) + def ensure_schema( self, *, diff --git a/src/sqlbuild/adapter/contract/classes/strict_adapter.py b/src/sqlbuild/adapter/contract/classes/strict_adapter.py index 7db76e1936..a9a37916aa 100644 --- a/src/sqlbuild/adapter/contract/classes/strict_adapter.py +++ b/src/sqlbuild/adapter/contract/classes/strict_adapter.py @@ -12,6 +12,7 @@ from sqlbuild.adapter.contract.classes.materialization import MaterializationMixin from sqlbuild.adapter.contract.classes.schema import SchemaMixin from sqlbuild.adapter.contract.classes.statement_recorder import StatementRecorder +from sqlbuild.adapter.contract.exceptions import AdapterUserError from sqlbuild.adapter.contract.models import ( ColumnInfo, CursorValue, @@ -274,6 +275,21 @@ def render_create_schema( """Render SQL statements that create a schema if missing.""" ... + def render_drop_schema( + self, + *, + database: str | None, + schema: str, + ) -> tuple[str, ...]: + """Optional: SQL that drops a schema and everything in it; unsupported by default.""" + + del database + raise AdapterUserError( + f"the {self.adapter_name} adapter cannot drop schema '{schema}' with its contents", + code="A714", + help="sqb review needs a warehouse that can drop a schema it created", + ) + @abstractmethod def render_create_table_as(self, *, destination: str, sql: str) -> tuple[str, ...]: """Render SQL statements that create or replace a table from a query.""" diff --git a/src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py b/src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py index fd35855629..1247bbc73c 100644 --- a/src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py +++ b/src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py @@ -1705,6 +1705,10 @@ def render_create_schema(self, *, database: str | None, schema: str) -> tuple[st sql += f" OPTIONS(location = '{self._location}')" return (sql,) + def render_drop_schema(self, *, database: str | None, schema: str) -> tuple[str, ...]: + target: str = f"{database}.{schema}" if database is not None else schema + return (f"DROP SCHEMA IF EXISTS {self._quote_identifier_path(target)} CASCADE",) + def render_create_table_as(self, *, destination: str, sql: str) -> tuple[str, ...]: return (f"CREATE OR REPLACE TABLE {self._quote_identifier_path(destination)} AS {sql}",) diff --git a/src/sqlbuild/adapters/clickhouse/classes/clickhouse_adapter.py b/src/sqlbuild/adapters/clickhouse/classes/clickhouse_adapter.py index 303ca95a40..745f207132 100644 --- a/src/sqlbuild/adapters/clickhouse/classes/clickhouse_adapter.py +++ b/src/sqlbuild/adapters/clickhouse/classes/clickhouse_adapter.py @@ -668,6 +668,10 @@ def render_create_schema(self, *, database: str | None, schema: str) -> tuple[st del database return (f"CREATE DATABASE IF NOT EXISTS {schema}",) + def render_drop_schema(self, *, database: str | None, schema: str) -> tuple[str, ...]: + del database + return (f"DROP DATABASE IF EXISTS {schema}",) + def ensure_schema( self, *, diff --git a/src/sqlbuild/adapters/databricks/classes/databricks_adapter.py b/src/sqlbuild/adapters/databricks/classes/databricks_adapter.py index 45890a0866..2165dabf5d 100644 --- a/src/sqlbuild/adapters/databricks/classes/databricks_adapter.py +++ b/src/sqlbuild/adapters/databricks/classes/databricks_adapter.py @@ -1626,6 +1626,10 @@ def render_create_schema(self, *, database: str | None, schema: str) -> tuple[st target: str = f"{database}.{schema}" if database is not None else schema return (f"CREATE SCHEMA IF NOT EXISTS {target}",) + def render_drop_schema(self, *, database: str | None, schema: str) -> tuple[str, ...]: + target: str = f"{database}.{schema}" if database is not None else schema + return (f"DROP SCHEMA IF EXISTS {target} CASCADE",) + def ensure_schema( self, *, diff --git a/src/sqlbuild/adapters/postgres/classes/postgres_adapter.py b/src/sqlbuild/adapters/postgres/classes/postgres_adapter.py index 1464805c67..a0b5c6ac3e 100644 --- a/src/sqlbuild/adapters/postgres/classes/postgres_adapter.py +++ b/src/sqlbuild/adapters/postgres/classes/postgres_adapter.py @@ -489,6 +489,15 @@ def render_create_schema( del database return (f"CREATE SCHEMA IF NOT EXISTS {schema}",) + def render_drop_schema( + self, + *, + database: str | None, + schema: str, + ) -> tuple[str, ...]: + del database + return (f"DROP SCHEMA IF EXISTS {schema} CASCADE",) + def ensure_schema( self, *, diff --git a/src/sqlbuild/adapters/snowflake/classes/snowflake_adapter.py b/src/sqlbuild/adapters/snowflake/classes/snowflake_adapter.py index 9e87820442..f15e059afd 100644 --- a/src/sqlbuild/adapters/snowflake/classes/snowflake_adapter.py +++ b/src/sqlbuild/adapters/snowflake/classes/snowflake_adapter.py @@ -994,6 +994,15 @@ def render_create_schema( target: str = f"{database}.{schema}" if database is not None else schema return (f"CREATE SCHEMA IF NOT EXISTS {target}",) + def render_drop_schema( + self, + *, + database: str | None, + schema: str, + ) -> tuple[str, ...]: + target: str = f"{database}.{schema}" if database is not None else schema + return (f"DROP SCHEMA IF EXISTS {target} CASCADE",) + def ensure_schema( self, *, diff --git a/src/sqlbuild/adapters/sqlite/classes/sqlite_adapter.py b/src/sqlbuild/adapters/sqlite/classes/sqlite_adapter.py index d42b28f5d9..235d9f9169 100644 --- a/src/sqlbuild/adapters/sqlite/classes/sqlite_adapter.py +++ b/src/sqlbuild/adapters/sqlite/classes/sqlite_adapter.py @@ -604,6 +604,14 @@ def render_create_schema( f"VALUES ({_quote_sql_string(schema)}, {self.render_current_timestamp()})", ) + def render_drop_schema(self, *, database: str | None, schema: str) -> tuple[str, ...]: + del database + raise AdapterUserError( + f"the {self.adapter_name} adapter cannot drop schema '{schema}' with its contents", + code="A714", + help="sqb review needs a warehouse that can drop a schema it created", + ) + def ensure_schema( self, *, diff --git a/src/sqlbuild/adapters/sqlserver/classes/sqlserver_adapter.py b/src/sqlbuild/adapters/sqlserver/classes/sqlserver_adapter.py index fcbd7a3c97..afab51a217 100644 --- a/src/sqlbuild/adapters/sqlserver/classes/sqlserver_adapter.py +++ b/src/sqlbuild/adapters/sqlserver/classes/sqlserver_adapter.py @@ -322,6 +322,14 @@ def render_create_schema(self, *, database: str | None, schema: str) -> tuple[st f"'{escaped_schema}') EXEC('CREATE SCHEMA {self.render_identifier(schema)}')", ) + def render_drop_schema(self, *, database: str | None, schema: str) -> tuple[str, ...]: + del database + raise AdapterUserError( + f"the {self.adapter_name} adapter cannot drop schema '{schema}' with its contents", + code="A714", + help="sqb review needs a warehouse that can drop a schema it created", + ) + def render_create_fingerprint_table_sql( self, *, diff --git a/src/sqlbuild/executor/janitor/_helpers/workers.py b/src/sqlbuild/executor/janitor/_helpers/workers.py index b4c316a625..4c662a1f9e 100644 --- a/src/sqlbuild/executor/janitor/_helpers/workers.py +++ b/src/sqlbuild/executor/janitor/_helpers/workers.py @@ -26,6 +26,7 @@ def janitor_worker_count(*, plan: JanitorPlan, adapter: BaseAdapter, concurrency len(plan.old_name_views.drops) + len(plan.old_name_views.missing), len(plan.query_diff_artifact_candidates), len(plan.direct_state_prune_candidates), + len(plan.review_schema_candidates), ) return max(1, min(concurrency, int(adapter.concurrent_ddl_limit), largest_phase)) diff --git a/src/sqlbuild/executor/janitor/main/execute.py b/src/sqlbuild/executor/janitor/main/execute.py index 95faf450c5..504d8d5174 100644 --- a/src/sqlbuild/executor/janitor/main/execute.py +++ b/src/sqlbuild/executor/janitor/main/execute.py @@ -39,6 +39,8 @@ from sqlbuild.executor.pipeline.main._close_worker_connection_pool import ( close_worker_connection_pool, ) +from sqlbuild.executor.review.classes.review_schema_lifecycle import ReviewSchemaLifecycle +from sqlbuild.executor.review.models import LeftoverReviewSchema from sqlbuild.runtime.observability.classes.operation_lifecycle import OperationLifecycle @@ -157,6 +159,23 @@ def drop_query_artifact(*, item: JanitorQueryDiffArtifactCandidate, connection: apply=drop_query_artifact, workers=workers, ) + + def drop_review_schema(*, item: LeftoverReviewSchema, connection: Any) -> None: + with OperationLifecycle(operation_kind="janitor", operation_name="janitor_cleanup_action"): + ReviewSchemaLifecycle.drop( + adapter=adapter, connection=connection, database=item.database, schema=item.schema + ) + + _ = run_janitor_phase( + progress=progress, + phase=JanitorPhase.DROP_REVIEW_SCHEMAS, + items=plan.review_schema_candidates, + describe=lambda candidate: JanitorProgressItem( + action=JanitorAction.DROPPED, name=candidate.display_name() + ), + apply=drop_review_schema, + workers=workers, + ) pruned_direct_state: tuple[JanitorDirectStatePruneCandidate, ...] = apply_janitor_deletions( candidates=plan.direct_state_prune_candidates, delete=lambda *, candidate, connection: adapter.execute( @@ -172,12 +191,13 @@ def drop_query_artifact(*, item: JanitorQueryDiffArtifactCandidate, connection: deleted_query_diff_artifacts=plan.query_diff_artifact_candidates, pruned_direct_state=pruned_direct_state, dropped_old_name_views=dropped_old_name_views, + dropped_review_schemas=plan.review_schema_candidates, ) def _janitor_action_count(plan: JanitorPlan) -> int: relation_count: int = 0 if plan.direct_mode else len(plan.candidates) - relation_count += len(plan.query_diff_artifact_candidates) + relation_count += len(plan.query_diff_artifact_candidates) + len(plan.review_schema_candidates) if plan.direct_mode: relation_count += len(plan.archive_candidates) + len(plan.archive_deletion_candidates) return ( diff --git a/src/sqlbuild/executor/janitor/models.py b/src/sqlbuild/executor/janitor/models.py index 6f4f890f30..7f3cecf6b8 100644 --- a/src/sqlbuild/executor/janitor/models.py +++ b/src/sqlbuild/executor/janitor/models.py @@ -9,6 +9,7 @@ from sqlbuild.adapter.contract.models import RelationInfo, RelationLookup, RelationUsage from sqlbuild.executor.janitor.types import JanitorAction from sqlbuild.executor.janitor_events.models import JanitorEventRecord +from sqlbuild.executor.review.models import LeftoverReviewSchema @dataclass(frozen=True, eq=False) @@ -224,6 +225,7 @@ class JanitorPlan: direct_state_prune_candidates: tuple[JanitorDirectStatePruneCandidate, ...] = field( default_factory=tuple ) + review_schema_candidates: tuple[LeftoverReviewSchema, ...] = field(default_factory=tuple) skipped_relations: tuple[JanitorSkippedRelation, ...] = field(default_factory=tuple) skipped_schemas: tuple[JanitorSkippedSchema, ...] = field(default_factory=tuple) blocked_schemas: tuple[JanitorBlockedSchema, ...] = field(default_factory=tuple) @@ -265,6 +267,7 @@ class JanitorExecutionResult: ) pruned_direct_state: tuple[JanitorDirectStatePruneCandidate, ...] = field(default_factory=tuple) dropped_old_name_views: tuple[JanitorOldNameView, ...] = field(default_factory=tuple) + dropped_review_schemas: tuple[LeftoverReviewSchema, ...] = field(default_factory=tuple) @dataclass(frozen=True) diff --git a/src/sqlbuild/executor/janitor/types.py b/src/sqlbuild/executor/janitor/types.py index 5549abfb21..4d591d00ee 100644 --- a/src/sqlbuild/executor/janitor/types.py +++ b/src/sqlbuild/executor/janitor/types.py @@ -15,6 +15,7 @@ class JanitorPhase(StrEnum): DELETE_ARCHIVES = "delete_archives" DROP_OLD_NAME_VIEWS = "drop_old_name_views" DROP_QUERY_ARTIFACTS = "drop_query_artifacts" + DROP_REVIEW_SCHEMAS = "drop_review_schemas" PRUNE_DIRECT_STATE = "prune_direct_state" diff --git a/src/sqlbuild/executor/review/__init__.py b/src/sqlbuild/executor/review/__init__.py new file mode 100644 index 0000000000..cd4d26c994 --- /dev/null +++ b/src/sqlbuild/executor/review/__init__.py @@ -0,0 +1 @@ +"""Run-owned review schemas: creation with an ownership marker, cleanup and leftovers.""" diff --git a/src/sqlbuild/executor/review/_helpers/__init__.py b/src/sqlbuild/executor/review/_helpers/__init__.py new file mode 100644 index 0000000000..9110ffd24a --- /dev/null +++ b/src/sqlbuild/executor/review/_helpers/__init__.py @@ -0,0 +1 @@ +"""Review schema marker helpers.""" diff --git a/src/sqlbuild/executor/review/_helpers/marker.py b/src/sqlbuild/executor/review/_helpers/marker.py new file mode 100644 index 0000000000..31e2b8b560 --- /dev/null +++ b/src/sqlbuild/executor/review/_helpers/marker.py @@ -0,0 +1,93 @@ +"""Write and read the immutable ownership markers of review schemas.""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from typing import Any + +from sqlbuild._native import ( + FINGERPRINT_TABLE_NAME, + Fingerprint, + FingerprintSet, + parse_review_schema_name, + read_latest_fingerprints, + write_fingerprint, +) +from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter +from sqlbuild.adapter.contract.models import RelationLookup +from sqlbuild.adapter.relations.main.relation_lookup import build_relation_lookup +from sqlbuild.executor.review.constants import REVIEW_MARKER_VERSION, REVIEW_SCHEMA_NODE_TYPE +from sqlbuild.executor.review.models import LeftoverReviewSchema, ReviewSchemaMarker + + +def write_review_marker( + *, adapter: BaseAdapter, connection: Any, marker: ReviewSchemaMarker, created_at: datetime +) -> None: + """Append the marker that proves SQLBuild created `marker.schema` for one run.""" + + metadata_json: str = json.dumps( + {"marker_version": REVIEW_MARKER_VERSION, "created_at": created_at.isoformat()}, + sort_keys=True, + separators=(",", ":"), + ) + write_fingerprint( + connection=connection, + execute=adapter.execute, + database=marker.marker_database, + schema=marker.marker_schema, + fingerprint=Fingerprint( + node_type=REVIEW_SCHEMA_NODE_TYPE, + node_name=marker.schema, + target_database=marker.database, + target_schema=marker.schema, + target_name=marker.schema, + run_id=marker.run_id, + definition_hash="", + version_hash="", + schema_fingerprint="", + definition="", + metadata_json=metadata_json, + ts=created_at, + ), + render_qualified_name=adapter.render_qualified_name, + render_framework_type=adapter.render_framework_type, + render_create_table_sql=adapter.render_create_fingerprint_table_sql, + render_create_index_sqls=adapter.render_create_fingerprint_index_sqls, + ) + + +def marked_review_schemas( + *, adapter: BaseAdapter, connection: Any, database: str | None, schema: str +) -> tuple[LeftoverReviewSchema, ...]: + """Review schemas whose markers `schema` holds, with the creation time each name encodes.""" + + lookup: RelationLookup = build_relation_lookup( + adapter=adapter, + connection=connection, + locations=((database, schema, FINGERPRINT_TABLE_NAME),), + ) + fingerprints: FingerprintSet = read_latest_fingerprints( + connection=connection, + execute=adapter.execute, + table_exists=lookup.exists(database=database, schema=schema, name=FINGERPRINT_TABLE_NAME), + database=database, + schema=schema, + render_qualified_name=adapter.render_qualified_name, + render_read_latest_sql=adapter.render_read_latest_fingerprints_sql, + ) + marked: list[LeftoverReviewSchema] = [] + for (node_type, _), fingerprint in (fingerprints.fingerprints_by_identity or {}).items(): + if node_type != REVIEW_SCHEMA_NODE_TYPE or fingerprint.target_schema is None: + continue + created_at: datetime | None = parse_review_schema_name(fingerprint.target_schema) + if created_at is None: + continue + marked.append( + LeftoverReviewSchema( + database=fingerprint.target_database, + schema=fingerprint.target_schema, + created_at=created_at.astimezone(UTC), + ) + ) + return tuple(sorted(marked, key=lambda item: (item.created_at, item.schema))) diff --git a/src/sqlbuild/executor/review/classes/__init__.py b/src/sqlbuild/executor/review/classes/__init__.py new file mode 100644 index 0000000000..7ad7e81db2 --- /dev/null +++ b/src/sqlbuild/executor/review/classes/__init__.py @@ -0,0 +1 @@ +"""Review schema lifecycle service.""" diff --git a/src/sqlbuild/executor/review/classes/review_schema_lifecycle.py b/src/sqlbuild/executor/review/classes/review_schema_lifecycle.py new file mode 100644 index 0000000000..82fd692a31 --- /dev/null +++ b/src/sqlbuild/executor/review/classes/review_schema_lifecycle.py @@ -0,0 +1,56 @@ +"""Public review schema lifecycle service.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter +from sqlbuild.adapter.contract.classes.statement_recorder import StatementRecorder +from sqlbuild.errors.contracts.exceptions import ExecutorInputError +from sqlbuild.executor.review._helpers.marker import write_review_marker +from sqlbuild.executor.review.models import ReviewSchemaMarker + + +class ReviewSchemaLifecycle: + """Create and drop the run-owned schemas `sqb review` builds into.""" + + @staticmethod + def create( + *, adapter: BaseAdapter, connection: Any, marker: ReviewSchemaMarker, created_at: datetime + ) -> None: + """Mark ownership first, then create the schema, refusing one that already exists.""" + + if adapter.schema_exists( + connection=connection, database=marker.database, schema=marker.schema + ): + raise ExecutorInputError( + f"review schema '{marker.schema}' already exists; another review may be using it", + code="C765", + ) + adapter.ensure_schema( + connection=connection, + database=marker.marker_database, + schema=marker.marker_schema, + statement_recorder=StatementRecorder(), + ) + write_review_marker( + adapter=adapter, connection=connection, marker=marker, created_at=created_at + ) + adapter.ensure_schema( + connection=connection, + database=marker.database, + schema=marker.schema, + statement_recorder=StatementRecorder(), + ) + + @staticmethod + def drop(*, adapter: BaseAdapter, connection: Any, database: str | None, schema: str) -> None: + """Drop the schema and everything in it; the marker stays as ownership evidence.""" + + for statement in adapter.render_drop_schema(database=database, schema=schema): + adapter.execute(connection=connection, sql=statement) + + @staticmethod + def exists(*, adapter: BaseAdapter, connection: Any, database: str | None, schema: str) -> bool: + return adapter.schema_exists(connection=connection, database=database, schema=schema) diff --git a/src/sqlbuild/executor/review/constants.py b/src/sqlbuild/executor/review/constants.py new file mode 100644 index 0000000000..d84f5bf5f6 --- /dev/null +++ b/src/sqlbuild/executor/review/constants.py @@ -0,0 +1,4 @@ +"""Constants for review schema ownership.""" + +REVIEW_SCHEMA_NODE_TYPE: str = "review_schema" +REVIEW_MARKER_VERSION: int = 1 diff --git a/src/sqlbuild/executor/review/main/__init__.py b/src/sqlbuild/executor/review/main/__init__.py new file mode 100644 index 0000000000..0522788ce4 --- /dev/null +++ b/src/sqlbuild/executor/review/main/__init__.py @@ -0,0 +1 @@ +"""Review schema executor entrypoint package.""" diff --git a/src/sqlbuild/executor/review/main/create_review_schema.py b/src/sqlbuild/executor/review/main/create_review_schema.py new file mode 100644 index 0000000000..1db119efa8 --- /dev/null +++ b/src/sqlbuild/executor/review/main/create_review_schema.py @@ -0,0 +1,37 @@ +"""Create the run-owned schema one `sqb review` builds into.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter +from sqlbuild.executor.review.classes.review_schema_lifecycle import ReviewSchemaLifecycle +from sqlbuild.executor.review.models import ReviewSchemaMarker + + +def create_review_schema( + *, + adapter: BaseAdapter, + connection: Any, + database: str | None, + schema: str, + marker_database: str | None, + marker_schema: str, + run_id: str, + created_at: datetime, +) -> None: + """Record ownership in the marker schema, then create the review schema.""" + + ReviewSchemaLifecycle.create( + adapter=adapter, + connection=connection, + marker=ReviewSchemaMarker( + database=database, + schema=schema, + marker_database=marker_database, + marker_schema=marker_schema, + run_id=run_id, + ), + created_at=created_at, + ) diff --git a/src/sqlbuild/executor/review/main/drop_review_schema.py b/src/sqlbuild/executor/review/main/drop_review_schema.py new file mode 100644 index 0000000000..81a937b86e --- /dev/null +++ b/src/sqlbuild/executor/review/main/drop_review_schema.py @@ -0,0 +1,21 @@ +"""Drop the run-owned schema one `sqb review` built into.""" + +from __future__ import annotations + +from typing import Any + +from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter +from sqlbuild.executor.review.classes.review_schema_lifecycle import ReviewSchemaLifecycle + + +def drop_review_schema( + *, adapter: BaseAdapter, connection: Any, database: str | None, schema: str +) -> bool: + """Drop the review schema and everything in it; whether it is gone afterwards.""" + + ReviewSchemaLifecycle.drop( + adapter=adapter, connection=connection, database=database, schema=schema + ) + return not ReviewSchemaLifecycle.exists( + adapter=adapter, connection=connection, database=database, schema=schema + ) diff --git a/src/sqlbuild/executor/review/main/leftover_review_schemas.py b/src/sqlbuild/executor/review/main/leftover_review_schemas.py new file mode 100644 index 0000000000..12a8839e26 --- /dev/null +++ b/src/sqlbuild/executor/review/main/leftover_review_schemas.py @@ -0,0 +1,33 @@ +"""Find review schemas an earlier run created and did not drop.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any + +from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter +from sqlbuild.executor.review._helpers.marker import marked_review_schemas +from sqlbuild.executor.review.models import LeftoverReviewSchema + + +def leftover_review_schemas( + *, + adapter: BaseAdapter, + connection: Any, + database: str | None, + schema: str, + now: datetime, + max_age: timedelta, +) -> tuple[LeftoverReviewSchema, ...]: + """Marked review schemas older than `max_age` that still exist.""" + + return tuple( + leftover + for leftover in marked_review_schemas( + adapter=adapter, connection=connection, database=database, schema=schema + ) + if now - leftover.created_at >= max_age + and adapter.schema_exists( + connection=connection, database=leftover.database, schema=leftover.schema + ) + ) diff --git a/src/sqlbuild/executor/review/models.py b/src/sqlbuild/executor/review/models.py new file mode 100644 index 0000000000..6ed0185953 --- /dev/null +++ b/src/sqlbuild/executor/review/models.py @@ -0,0 +1,31 @@ +"""Review schema domain models.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(frozen=True) +class ReviewSchemaMarker: + """Where one review schema lives and where its ownership marker is written.""" + + database: str | None + schema: str + marker_database: str | None + marker_schema: str + run_id: str + + +@dataclass(frozen=True) +class LeftoverReviewSchema: + """A review schema an earlier run created and did not drop.""" + + database: str | None + schema: str + created_at: datetime + + def display_name(self) -> str: + """Render a qualified schema display name.""" + + return self.schema if self.database is None else f"{self.database}.{self.schema}" diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/review/__init__.py b/tests/e2e/src/sqlbuild/cli/commands/main/review/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/review/_test_types.py b/tests/e2e/src/sqlbuild/cli/commands/main/review/_test_types.py new file mode 100644 index 0000000000..d83cfaae46 --- /dev/null +++ b/tests/e2e/src/sqlbuild/cli/commands/main/review/_test_types.py @@ -0,0 +1,142 @@ +"""Test types for `sqb review` E2E tests.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class ReviewOutcomeE2ETestCase: + """One branch edit reviewed with `--json`, and the outcome the report must show.""" + + description: str + edits: dict[str, str] + arguments: tuple[str, ...] + expected_exit_code: int + expected_status: str + expected_validated: tuple[str, ...] + expected_node_statuses: dict[str, str] + expected_not_validated: tuple[str, ...] = () + expected_finding_kinds: tuple[str, ...] = () + expected_incomplete_nodes: tuple[str, ...] = () + extra_files: dict[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True) +class ReviewSelectErrorE2ETestCase: + """A `--select` that cannot narrow the review, and the error it must raise.""" + + description: str + edits: dict[str, str] + select: str + expected_code: str + expected_message_fragment: str + + +@dataclass(frozen=True) +class ReviewTextOutputE2ETestCase: + """A human-readable review and the fragments its text and Markdown must contain.""" + + description: str + edits: dict[str, str] + expected_stdout_fragments: tuple[str, ...] + expected_markdown_fragments: tuple[str, ...] + expected_exit_code: int = 0 + + +@dataclass(frozen=True) +class ReviewIncrementalStartE2ETestCase: + """A review that builds an incremental model, and how the report says it started.""" + + description: str + arguments: tuple[str, ...] + expected_starts: tuple[tuple[str, str], ...] + + +@dataclass(frozen=True) +class ReviewSelectionE2ETestCase: + """A `--select` inside the changed set, and what the review validates.""" + + description: str + select: str + expected_validated: tuple[str, ...] + expected_not_validated_member: str + + +@dataclass(frozen=True) +class ReviewSelectedAncestorE2ETestCase: + """A `--select` below a changed model, and the findings the added ancestor must show.""" + + description: str + edits: dict[str, str] + select: str + expected_exit_code: int + expected_validated: tuple[str, ...] + expected_added: tuple[tuple[str, str], ...] + expected_finding_nodes: frozenset[str] + + +@dataclass(frozen=True) +class ReviewDatabaseE2ETestCase: + """A changed model with its own database, and the review database it must build in.""" + + description: str + edits: dict[str, str] + arguments: tuple[str, ...] + expected_exit_code: int + expected_database: str + + +@dataclass(frozen=True) +class ReviewMarkdownFailureE2ETestCase: + """A `--markdown` path the review cannot write, and the outcome it must still report.""" + + description: str + edits: dict[str, str] + expected_stdout_fragment: str + expected_code: str + expected_status: str + expected_finding_kinds: frozenset[str] + + +@dataclass(frozen=True) +class ReviewUsageErrorE2ETestCase: + """Arguments `sqb review` rejects, and the setup failure they must exit with.""" + + description: str + arguments: tuple[str, ...] + expected_message_fragment: str + + +@dataclass(frozen=True) +class ReviewSetupFailureE2ETestCase: + """A review that fails before creating its schema, and the error it reports.""" + + description: str + edits: dict[str, str] + arguments: tuple[str, ...] + expected_code: str + expected_message_fragment: str + expected_node_statuses: frozenset[str] + extra_files: dict[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True) +class ReviewDryRunE2ETestCase: + """A dry run, and the plan it reports without touching the warehouse.""" + + description: str + expected_status: str + expected_validated: tuple[str, ...] + expected_node_statuses: frozenset[str] + edits: dict[str, str] = field(default_factory=dict) + expected_hooks: tuple[str, ...] = () + + +@dataclass(frozen=True) +class ReviewCleanupFailureE2ETestCase: + """A review whose schema drop fails, and the leftover the janitor later removes.""" + + description: str + expired_schema: str + expected_exit_code: int diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/review/helpers.py b/tests/e2e/src/sqlbuild/cli/commands/main/review/helpers.py new file mode 100644 index 0000000000..9babb9f6f5 --- /dev/null +++ b/tests/e2e/src/sqlbuild/cli/commands/main/review/helpers.py @@ -0,0 +1,278 @@ +"""Helpers for `sqb review` E2E tests.""" + +from __future__ import annotations + +import json +import subprocess +from collections.abc import Mapping +from pathlib import Path +from textwrap import dedent +from typing import Any + +import duckdb + +from tests.e2e.src.sqlbuild.cli.commands.main.test.helpers import ( + commit_project_on_main, + write_project_files, +) +from tests.e2e.src.sqlbuild.cli.commands.shared.helpers import prepare_inline_project, run_sqb + +DATABASE_FILE: str = "review_orders.duckdb" +DATABASE_NAME: str = "review_orders" +REVIEW_SCHEMA_PREFIX: str = "review_" +STAGING: str = "models/stg_orders.sql" +ORDER_TOTALS: str = "models/order_totals.sql" +CUSTOMER_ORDERS: str = "models/customer_orders.sql" +DROP_FAILURE_PLUGIN: str = "sitecustomize.py" + + +def build_review_project_files() -> dict[str, str]: + """A DuckDB orders project with a production target, a dev target and one critical model.""" + + return { + "sqlbuild_project.toml": dedent( + f""" + name = "review_orders" + adapter = "duckdb" + default_target = "dev" + + [connection] + database = "{DATABASE_FILE}" + + [targets.prod] + schema = "prod" + + [targets.dev] + schema = "dev" + + [targets.empty] + schema = "empty" + + [targets.dev_database] + database = "{DATABASE_NAME}" + schema = "dev" + + [defaults] + materialized = "table" + + [git] + base_ref = "main" + + [review] + against = "prod" + + [janitor] + enabled = true + """ + ).lstrip(), + ".gitignore": "target/\nlogs/\n*.duckdb\n*.duckdb.wal\n", + "sources/raw.yml": dedent( + """ + sources: + - name: raw_orders + description: Raw orders. + expression: | + SELECT * FROM (VALUES + (1, 10, 'open', 250), + (2, 10, 'shipped', 1200), + (3, 20, 'open', 900) + ) AS raw_orders(order_id, customer_id, status, amount_cents) + """ + ).lstrip(), + STAGING: ( + "MODEL (description 'Cleaned orders.', unique_key order_id);\n\n" + 'SELECT order_id, customer_id, status, amount_cents FROM __source("raw_orders")\n' + ), + ORDER_TOTALS: ( + "MODEL (description 'Order totals in dollars.', unique_key order_id,\n" + " critical true);\n\n" + "SELECT order_id, amount_cents / 100.0 AS amount\n" + 'FROM __ref("stg_orders")\n' + ), + "models/open_orders.sql": ( + "MODEL (description 'Open orders.', unique_key order_id);\n\n" + "SELECT order_id FROM __ref(\"stg_orders\") WHERE status = 'open'\n" + ), + "models/order_ledger.sql": ( + "MODEL (\n" + " description 'Every order once.',\n" + " materialized incremental,\n" + " incremental_strategy merge,\n" + " unique_key order_id,\n" + ");\n\n" + 'SELECT order_id, status FROM __ref("stg_orders")\n' + ), + "tests/unit/test_order_totals.sql": ( + "TEST ();\n\nWITH\n" + "__ref__stg_orders AS (\n" + " SELECT 1 AS order_id, 10 AS customer_id, 'open' AS status, 250 AS amount_cents\n" + "),\n" + "__expected__order_totals AS (SELECT 1 AS order_id, 2.5 AS amount)\nSELECT 1\n" + ), + } + + +def own_database_model(*, amount: str) -> str: + """A model that sets its own database.""" + + return ( + "MODEL (description 'Orders with their own database.', unique_key order_id,\n" + f" database {DATABASE_NAME});\n\n" + f'SELECT order_id, {amount} AS amount_cents FROM __ref("stg_orders")\n' + ) + + +def export_task_module() -> str: + """A Python task that depends on the critical model.""" + + return dedent( + """ + from sqlbuild.refs import model + from sqlbuild.tasks import TaskContext, task + + + @task(depends_on=model("order_totals")) + def export_totals(ctx: TaskContext): + \"\"\"Export order totals.\"\"\" + return ctx.result(metadata={"rows": 0}) + """ + ).lstrip() + + +def order_statuses_seed(*, rows: str) -> dict[str, str]: + """A seed of order statuses and its column types.""" + + return { + "seeds/order_statuses.csv": "status,label\n" + rows, + "seeds/order_statuses.yml": dedent( + """ + seeds: + - name: order_statuses + description: Order status labels. + columns: + - name: status + type: VARCHAR + - name: label + type: VARCHAR + """ + ).lstrip(), + } + + +def customer_orders_model(*, tags: str) -> str: + """A model without a unique key, so its row-level check cannot run.""" + + return ( + f"MODEL (description 'Orders per customer.', tags [{tags}]);\n\n" + "SELECT customer_id, COUNT(*) AS order_count\n" + 'FROM __ref("stg_orders")\nGROUP BY customer_id\n' + ) + + +def prepare_review_repository(*, tmp_path: Path, extra_files: Mapping[str, str]) -> Path: + """Commit the review project on `main`, build production there, then branch to `feature`.""" + + project: Path = prepare_inline_project( + tmp_path=tmp_path, + project_name="review_orders", + repo_files={**build_review_project_files(), **extra_files}, + ) + commit_project_on_main(repository=project) + result: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "build", "--target", "prod"), project_dir=project + ) + assert result.returncode == 0, result.stdout + result.stderr + return project + + +def edit_project(*, project_dir: Path, files: Mapping[str, str]) -> None: + """Write uncommitted branch edits.""" + + write_project_files(project_dir=project_dir, files=files) + + +def edited_staging(*, before: str, after: str) -> str: + """The staging model with one SQL fragment replaced.""" + + staging: str = build_review_project_files()[STAGING] + assert before in staging + return staging.replace(before, after) + + +def edited_order_totals(*, before: str, after: str) -> str: + """The critical model with one SQL fragment replaced.""" + + totals: str = build_review_project_files()[ORDER_TOTALS] + assert before in totals + return totals.replace(before, after) + + +def write_drop_failure_plugin(*, directory: Path) -> dict[str, str]: + """A startup hook that makes every schema drop fail, and the environment that loads it.""" + + directory.mkdir(parents=True, exist_ok=True) + (directory / DROP_FAILURE_PLUGIN).write_text( + dedent( + """ + from sqlbuild.adapter.contract.classes.duckdb_backed_adapter import ( + DuckDbBackedAdapter, + ) + + + def _failing_drop(self, *, database, schema): + return (f"DROP SCHEMA __review_drop_is_broken__.{schema} CASCADE",) + + + DuckDbBackedAdapter.render_drop_schema = _failing_drop + """ + ).lstrip(), + encoding="utf-8", + ) + return {"PYTHONPATH": str(directory)} + + +def run_review( + *, project_dir: Path, arguments: tuple[str, ...], env: Mapping[str, str] | None = None +) -> subprocess.CompletedProcess[str]: + """Run `sqb review` with colour disabled.""" + + return run_sqb(command=("--no-color", "review", *arguments), project_dir=project_dir, env=env) + + +def review_document(result: subprocess.CompletedProcess[str]) -> dict[str, Any]: + """Parse the JSON document `sqb review --json` printed to stdout.""" + + document: object = json.loads(result.stdout) + assert isinstance(document, dict), result.stdout + return document + + +def review_schemas(*, project_dir: Path) -> tuple[str, ...]: + """Every review schema present in the project database.""" + + connection: duckdb.DuckDBPyConnection = duckdb.connect(str(project_dir / DATABASE_FILE)) + try: + rows: list[tuple[Any, ...]] = connection.execute( + "SELECT schema_name FROM information_schema.schemata " + "WHERE starts_with(schema_name, ?) ORDER BY schema_name", + [REVIEW_SCHEMA_PREFIX], + ).fetchall() + finally: + connection.close() + return tuple(str(row[0]) for row in rows) + + +def execute_review_database(*, project_dir: Path, sql: str) -> None: + """Run mutating SQL against the project database.""" + + connection: duckdb.DuckDBPyConnection = duckdb.connect(str(project_dir / DATABASE_FILE)) + try: + connection.execute(sql) + finally: + connection.close() + + +def node_statuses(document: Mapping[str, Any]) -> dict[str, str]: + """Each reported node's status.""" + + return {str(node["name"]): str(node["status"]) for node in document["nodes"]} diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/review/test_review.py b/tests/e2e/src/sqlbuild/cli/commands/main/review/test_review.py new file mode 100644 index 0000000000..3a7dd946e9 --- /dev/null +++ b/tests/e2e/src/sqlbuild/cli/commands/main/review/test_review.py @@ -0,0 +1,641 @@ +"""`sqb review` builds a branch's changes in a run-owned schema, compares and reports.""" + +import json +import subprocess +from pathlib import Path +from typing import Any + +import pytest + +from tests.e2e.src.sqlbuild.cli.commands.main.review._test_types import ( + ReviewCleanupFailureE2ETestCase, + ReviewDatabaseE2ETestCase, + ReviewDryRunE2ETestCase, + ReviewIncrementalStartE2ETestCase, + ReviewMarkdownFailureE2ETestCase, + ReviewOutcomeE2ETestCase, + ReviewSelectedAncestorE2ETestCase, + ReviewSelectErrorE2ETestCase, + ReviewSelectionE2ETestCase, + ReviewSetupFailureE2ETestCase, + ReviewTextOutputE2ETestCase, + ReviewUsageErrorE2ETestCase, +) +from tests.e2e.src.sqlbuild.cli.commands.main.review.helpers import ( + CUSTOMER_ORDERS, + DATABASE_NAME, + ORDER_TOTALS, + STAGING, + customer_orders_model, + edit_project, + edited_order_totals, + edited_staging, + execute_review_database, + export_task_module, + node_statuses, + order_statuses_seed, + own_database_model, + prepare_review_repository, + review_document, + review_schemas, + run_review, + write_drop_failure_plugin, +) +from tests.e2e.src.sqlbuild.cli.commands.shared.helpers import run_sqb + +_SAME_VALUES_STAGING: str = edited_staging( + before="SELECT order_id, customer_id, status,", + after="SELECT order_id, customer_id, LOWER(status) AS status,", +) +_CHANGED_VALUES_TOTALS: str = edited_order_totals(before="/ 100.0", after="/ 10.0") +_NEW_COLUMN_TOTALS: str = edited_order_totals( + before="AS amount\n", after="AS amount, amount_cents\n" +) +_DOUBLED_AMOUNTS_STAGING: str = edited_staging( + before="status, amount_cents FROM", after="status, amount_cents * 2 AS amount_cents FROM" +) +_HOOKED_TOTALS: str = edited_order_totals( + before="critical true);", after='critical true, post_hooks [inline_sql("SELECT 1")]);' +) +_EXPORT_TASK: str = "python/tasks/exports.py" +_OWN_DATABASE_ORDERS: str = "models/own_database_orders.sql" + + +@pytest.mark.parametrize( + "test_case", + ( + ReviewOutcomeE2ETestCase( + description="the default policy builds the change and its critical downstream only", + edits={STAGING: _SAME_VALUES_STAGING}, + arguments=(), + expected_exit_code=0, + expected_status="passed", + expected_validated=("stg_orders", "order_totals"), + expected_not_validated=("open_orders", "order_ledger"), + expected_node_statuses={ + "stg_orders": "ok", + "order_totals": "ok", + "open_orders": "not_validated", + "order_ledger": "not_validated", + }, + ), + ReviewOutcomeE2ETestCase( + description="--downstream none builds only the changed model", + edits={STAGING: _SAME_VALUES_STAGING}, + arguments=("--downstream", "none"), + expected_exit_code=0, + expected_status="passed", + expected_validated=("stg_orders",), + expected_not_validated=("order_totals", "open_orders", "order_ledger"), + expected_node_statuses={"stg_orders": "ok", "order_totals": "not_validated"}, + ), + ReviewOutcomeE2ETestCase( + description="--downstream all builds every downstream model, the incremental included", + edits={STAGING: _SAME_VALUES_STAGING}, + arguments=("--downstream", "all"), + expected_exit_code=0, + expected_status="passed", + expected_validated=("stg_orders", "open_orders", "order_ledger", "order_totals"), + expected_node_statuses={ + "stg_orders": "ok", + "order_totals": "ok", + "open_orders": "ok", + "order_ledger": "ok", + }, + ), + ReviewOutcomeE2ETestCase( + description="a changed value in a critical model is a finding", + edits={ORDER_TOTALS: _CHANGED_VALUES_TOTALS}, + arguments=(), + expected_exit_code=1, + expected_status="findings", + expected_validated=("order_totals",), + expected_node_statuses={"order_totals": "finding"}, + expected_finding_kinds=("values", "test"), + ), + ReviewOutcomeE2ETestCase( + description="a new column is reported as a schema change", + edits={ORDER_TOTALS: _NEW_COLUMN_TOTALS}, + arguments=(), + expected_exit_code=1, + expected_status="findings", + expected_validated=("order_totals",), + expected_node_statuses={"order_totals": "finding"}, + expected_finding_kinds=("schema",), + ), + ReviewOutcomeE2ETestCase( + description="a Python task downstream of the change is listed as not validated", + extra_files={_EXPORT_TASK: export_task_module()}, + edits={STAGING: _SAME_VALUES_STAGING}, + arguments=(), + expected_exit_code=0, + expected_status="passed", + expected_validated=("stg_orders", "order_totals"), + expected_not_validated=("open_orders", "order_ledger", "task:export_totals"), + expected_node_statuses={"task:export_totals": "not_validated"}, + ), + ReviewOutcomeE2ETestCase( + description="a changed seed is built but never passes uncompared", + extra_files=order_statuses_seed(rows="open,Open\n"), + edits=order_statuses_seed(rows="open,Open\nshipped,Shipped\n"), + arguments=(), + expected_exit_code=2, + expected_status="incomplete", + expected_validated=("order_statuses",), + expected_node_statuses={"order_statuses": "built"}, + expected_incomplete_nodes=("order_statuses",), + ), + ReviewOutcomeE2ETestCase( + description="a critical model without a unique key leaves the review incomplete", + extra_files={CUSTOMER_ORDERS: customer_orders_model(tags="critical")}, + edits={STAGING: _SAME_VALUES_STAGING}, + arguments=(), + expected_exit_code=2, + expected_status="incomplete", + expected_validated=("stg_orders", "customer_orders", "order_totals"), + expected_not_validated=("open_orders", "order_ledger"), + expected_node_statuses={"customer_orders": "built"}, + expected_incomplete_nodes=("customer_orders",), + ), + ), + ids=lambda case: case.description, +) +def test_given_branch_edit_when_reviewing_then_report_shows_outcome( + test_case: ReviewOutcomeE2ETestCase, tmp_path: Path +) -> None: + project_dir: Path = prepare_review_repository( + tmp_path=tmp_path, extra_files=test_case.extra_files + ) + edit_project(project_dir=project_dir, files=test_case.edits) + + result: subprocess.CompletedProcess[str] = run_review( + project_dir=project_dir, arguments=("--json", *test_case.arguments) + ) + + assert result.returncode == test_case.expected_exit_code, result.stdout + result.stderr + document: dict[str, Any] = review_document(result) + assert document["status"] == test_case.expected_status, result.stdout + assert document["exit_code"] == test_case.expected_exit_code + assert sorted(document["validated"]) == sorted(test_case.expected_validated) + assert sorted(item["name"] for item in document["not_validated"]) == sorted( + test_case.expected_not_validated + ) + statuses: dict[str, str] = node_statuses(document) + for name, status in test_case.expected_node_statuses.items(): + assert statuses.get(name) == status, (name, result.stdout) + finding_kinds: set[str] = {finding["kind"] for finding in document["findings"]} + assert finding_kinds == set(test_case.expected_finding_kinds), result.stdout + incomplete_nodes: set[str] = {check["node"] for check in document["incomplete"]} + assert set(test_case.expected_incomplete_nodes) <= incomplete_nodes, result.stdout + assert document["review_schema"]["created"] is True + assert document["review_schema"]["dropped"] is True + assert document["recall"]["command"].startswith("sqb recall output ") + assert review_schemas(project_dir=project_dir) == () + + +@pytest.mark.parametrize( + "test_case", + ( + ReviewIncrementalStartE2ETestCase( + description="DuckDB has no zero-copy clone, so the incremental is fully refreshed", + arguments=("--downstream", "all"), + expected_starts=(("order_ledger", "full_refresh"),), + ), + ReviewIncrementalStartE2ETestCase( + description="--no-clone fully refreshes the incremental", + arguments=("--downstream", "all", "--no-clone"), + expected_starts=(("order_ledger", "full_refresh"),), + ), + ), + ids=lambda case: case.description, +) +def test_given_incremental_downstream_when_reviewing_then_its_start_is_reported( + test_case: ReviewIncrementalStartE2ETestCase, tmp_path: Path +) -> None: + project_dir: Path = prepare_review_repository(tmp_path=tmp_path, extra_files={}) + edit_project(project_dir=project_dir, files={STAGING: _SAME_VALUES_STAGING}) + + result: subprocess.CompletedProcess[str] = run_review( + project_dir=project_dir, arguments=("--json", *test_case.arguments) + ) + + assert result.returncode == 0, result.stdout + result.stderr + starts: list[dict[str, Any]] = review_document(result)["baseline"]["incremental_starts"] + assert tuple((start["node"], start["method"]) for start in starts) == ( + test_case.expected_starts + ), result.stdout + assert all(start["reason"] for start in starts), result.stdout + + +@pytest.mark.parametrize( + "test_case", + ( + ReviewSelectErrorE2ETestCase( + description="an unknown name", + edits={ORDER_TOTALS: _CHANGED_VALUES_TOTALS}, + select="no_such_model", + expected_code="C761", + expected_message_fragment="no_such_model", + ), + ReviewSelectErrorE2ETestCase( + description="a model the branch did not change or affect", + edits={ORDER_TOTALS: _CHANGED_VALUES_TOTALS}, + select="open_orders", + expected_code="C762", + expected_message_fragment="open_orders", + ), + ReviewSelectErrorE2ETestCase( + description="an affected model the downstream policy leaves out", + edits={STAGING: _SAME_VALUES_STAGING}, + select="open_orders", + expected_code="C763", + expected_message_fragment="open_orders", + ), + ), + ids=lambda case: case.description, +) +def test_given_unusable_select_when_reviewing_then_it_fails_with_json_error( + test_case: ReviewSelectErrorE2ETestCase, tmp_path: Path +) -> None: + project_dir: Path = prepare_review_repository(tmp_path=tmp_path, extra_files={}) + edit_project(project_dir=project_dir, files=test_case.edits) + + result: subprocess.CompletedProcess[str] = run_review( + project_dir=project_dir, arguments=("--json", "--select", test_case.select) + ) + + assert result.returncode == 3, result.stdout + result.stderr + document: dict[str, Any] = review_document(result) + assert document["status"] == "failed" + assert document["error"]["code"] == test_case.expected_code, result.stdout + assert test_case.expected_message_fragment in document["error"]["message"] + assert review_schemas(project_dir=project_dir) == () + + +@pytest.mark.parametrize( + "test_case", + ( + ReviewSelectionE2ETestCase( + description="selecting the changed model leaves its critical downstream unvalidated", + select="stg_orders", + expected_validated=("stg_orders",), + expected_not_validated_member="order_totals", + ), + ), + ids=lambda case: case.description, +) +def test_given_select_inside_changed_set_when_reviewing_then_only_selection_is_validated( + test_case: ReviewSelectionE2ETestCase, tmp_path: Path +) -> None: + project_dir: Path = prepare_review_repository(tmp_path=tmp_path, extra_files={}) + edit_project(project_dir=project_dir, files={STAGING: _SAME_VALUES_STAGING}) + + result: subprocess.CompletedProcess[str] = run_review( + project_dir=project_dir, arguments=("--json", "--select", test_case.select) + ) + + assert result.returncode == 0, result.stdout + result.stderr + document: dict[str, Any] = review_document(result) + assert tuple(document["validated"]) == test_case.expected_validated + assert test_case.expected_not_validated_member in { + item["name"] for item in document["not_validated"] + } + + +@pytest.mark.parametrize( + "test_case", + ( + ReviewSelectedAncestorE2ETestCase( + description="selecting a model below a change builds and compares the change too", + edits={STAGING: _DOUBLED_AMOUNTS_STAGING}, + select="order_totals", + expected_exit_code=1, + expected_validated=("stg_orders", "order_totals"), + expected_added=(("stg_orders", "order_totals"),), + expected_finding_nodes=frozenset({"stg_orders", "order_totals"}), + ), + ), + ids=lambda case: case.description, +) +def test_given_select_below_change_when_reviewing_then_changed_ancestor_is_built_and_compared( + test_case: ReviewSelectedAncestorE2ETestCase, tmp_path: Path +) -> None: + project_dir: Path = prepare_review_repository(tmp_path=tmp_path, extra_files={}) + edit_project(project_dir=project_dir, files=test_case.edits) + + result: subprocess.CompletedProcess[str] = run_review( + project_dir=project_dir, arguments=("--json", "--select", test_case.select) + ) + + assert result.returncode == test_case.expected_exit_code, result.stdout + result.stderr + document: dict[str, Any] = review_document(result) + assert tuple(document["validated"]) == test_case.expected_validated + assert ( + tuple((item["name"], item["required_by"]) for item in document["added"]) + == test_case.expected_added + ) + assert {finding["node"] for finding in document["findings"]} == ( + test_case.expected_finding_nodes + ), result.stdout + + +@pytest.mark.parametrize( + "test_case", + ( + ReviewDatabaseE2ETestCase( + description="a target with a database builds the node in the review schema there", + edits={_OWN_DATABASE_ORDERS: own_database_model(amount="amount_cents + 0")}, + arguments=("--target", "dev_database"), + expected_exit_code=0, + expected_database=DATABASE_NAME, + ), + ), + ids=lambda case: case.description, +) +def test_given_model_with_own_database_when_reviewing_then_it_builds_in_review_database( + test_case: ReviewDatabaseE2ETestCase, tmp_path: Path +) -> None: + project_dir: Path = prepare_review_repository( + tmp_path=tmp_path, + extra_files={_OWN_DATABASE_ORDERS: own_database_model(amount="amount_cents")}, + ) + edit_project(project_dir=project_dir, files=test_case.edits) + + result: subprocess.CompletedProcess[str] = run_review( + project_dir=project_dir, arguments=("--json", *test_case.arguments) + ) + + assert result.returncode == test_case.expected_exit_code, result.stdout + result.stderr + document: dict[str, Any] = review_document(result) + assert document["review_schema"]["database"] == test_case.expected_database + assert document["review_schema"]["dropped"] is True + assert review_schemas(project_dir=project_dir) == () + + +@pytest.mark.parametrize( + "test_case", + ( + ReviewUsageErrorE2ETestCase( + description="an unknown downstream policy", + arguments=("--downstream", "some"), + expected_message_fragment="invalid choice: 'some'", + ), + ReviewUsageErrorE2ETestCase( + description="an unknown option", + arguments=("--no-such-option",), + expected_message_fragment="unrecognized arguments: --no-such-option", + ), + ), + ids=lambda case: case.description, +) +def test_given_invalid_arguments_when_reviewing_then_it_exits_3_with_json_error( + test_case: ReviewUsageErrorE2ETestCase, tmp_path: Path +) -> None: + project_dir: Path = prepare_review_repository(tmp_path=tmp_path, extra_files={}) + + machine: subprocess.CompletedProcess[str] = run_review( + project_dir=project_dir, arguments=("--json", *test_case.arguments) + ) + human: subprocess.CompletedProcess[str] = run_review( + project_dir=project_dir, arguments=test_case.arguments + ) + + assert machine.returncode == 3, machine.stdout + machine.stderr + document: dict[str, Any] = review_document(machine) + assert document["status"] == "failed" + assert document["exit_code"] == 3 + assert document["error"]["code"] == "C900" + assert test_case.expected_message_fragment in document["error"]["message"] + assert human.returncode == 3, human.stdout + human.stderr + assert test_case.expected_message_fragment in human.stderr + + +@pytest.mark.parametrize( + "test_case", + ( + ReviewSetupFailureE2ETestCase( + description="a target without a database refuses a node that sets its own", + extra_files={_OWN_DATABASE_ORDERS: own_database_model(amount="amount_cents")}, + edits={_OWN_DATABASE_ORDERS: own_database_model(amount="amount_cents + 0")}, + arguments=(), + expected_code="C776", + expected_message_fragment="own_database_orders", + expected_node_statuses=frozenset({"planned"}), + ), + ReviewSetupFailureE2ETestCase( + description="an --against target that holds none of the compared tables", + edits={STAGING: _SAME_VALUES_STAGING}, + arguments=("--against", "empty"), + expected_code="C774", + expected_node_statuses=frozenset({"planned", "not_validated"}), + expected_message_fragment="--against has none of these relations; check the target", + ), + ), + ids=lambda case: case.description, +) +def test_given_unusable_setup_when_reviewing_then_it_fails_before_creating_schema( + test_case: ReviewSetupFailureE2ETestCase, tmp_path: Path +) -> None: + project_dir: Path = prepare_review_repository( + tmp_path=tmp_path, extra_files=test_case.extra_files + ) + edit_project(project_dir=project_dir, files=test_case.edits) + + result: subprocess.CompletedProcess[str] = run_review( + project_dir=project_dir, arguments=("--json", *test_case.arguments) + ) + + assert result.returncode == 3, result.stdout + result.stderr + document: dict[str, Any] = review_document(result) + assert document["error"]["code"] == test_case.expected_code, result.stdout + assert test_case.expected_message_fragment in document["error"]["message"] + assert not (document["review_schema"] or {}).get("created") + assert set(node_statuses(document).values()) <= test_case.expected_node_statuses, result.stdout + assert all(node["build_status"] is None for node in document["nodes"]), result.stdout + assert review_schemas(project_dir=project_dir) == () + + +@pytest.mark.parametrize( + "test_case", + ( + ReviewMarkdownFailureE2ETestCase( + description="a directory as the Markdown path of a review with findings", + edits={ORDER_TOTALS: _CHANGED_VALUES_TOTALS}, + expected_stdout_fragment="Review failed", + expected_code="C775", + expected_status="failed", + expected_finding_kinds=frozenset({"values", "test"}), + ), + ), + ids=lambda case: case.description, +) +def test_given_unwritable_markdown_path_when_reviewing_then_result_prints_and_it_exits_3( + test_case: ReviewMarkdownFailureE2ETestCase, tmp_path: Path +) -> None: + project_dir: Path = prepare_review_repository(tmp_path=tmp_path, extra_files={}) + edit_project(project_dir=project_dir, files=test_case.edits) + + human: subprocess.CompletedProcess[str] = run_review( + project_dir=project_dir, arguments=("--markdown", str(tmp_path)) + ) + machine: subprocess.CompletedProcess[str] = run_review( + project_dir=project_dir, arguments=("--json", "--markdown", str(tmp_path)) + ) + + assert human.returncode == 3, human.stdout + human.stderr + assert test_case.expected_stdout_fragment in human.stdout + assert test_case.expected_code in human.stderr + assert "Traceback" not in human.stderr + assert machine.returncode == 3, machine.stdout + machine.stderr + document: dict[str, Any] = review_document(machine) + assert document["exit_code"] == machine.returncode + assert document["status"] == test_case.expected_status + assert document["error"]["code"] == test_case.expected_code + assert {finding["kind"] for finding in document["findings"]} == ( + test_case.expected_finding_kinds + ), machine.stdout + assert "Traceback" not in machine.stderr + assert review_schemas(project_dir=project_dir) == () + + +@pytest.mark.parametrize( + "test_case", + ( + ReviewDryRunE2ETestCase( + description="a dry run plans the review without creating a schema or building", + expected_status="planned", + expected_validated=("order_totals", "stg_orders"), + expected_node_statuses=frozenset({"planned", "not_validated"}), + ), + ReviewDryRunE2ETestCase( + description="a dry run lists the nodes whose hooks would run", + edits={STAGING: _SAME_VALUES_STAGING, ORDER_TOTALS: _HOOKED_TOTALS}, + expected_status="planned", + expected_validated=("order_totals", "stg_orders"), + expected_node_statuses=frozenset({"planned", "not_validated"}), + expected_hooks=("order_totals",), + ), + ), + ids=lambda case: case.description, +) +def test_given_dry_run_when_reviewing_then_nothing_is_built_or_created( + test_case: ReviewDryRunE2ETestCase, tmp_path: Path +) -> None: + project_dir: Path = prepare_review_repository(tmp_path=tmp_path, extra_files={}) + edit_project(project_dir=project_dir, files=test_case.edits or {STAGING: _SAME_VALUES_STAGING}) + + result: subprocess.CompletedProcess[str] = run_review( + project_dir=project_dir, arguments=("--json", "--dry-run") + ) + + assert result.returncode == 0, result.stdout + result.stderr + document: dict[str, Any] = review_document(result) + assert document["dry_run"] is True + assert document["status"] == test_case.expected_status + assert document["review_schema"] is None + assert tuple(sorted(document["validated"])) == test_case.expected_validated + assert set(node_statuses(document).values()) <= test_case.expected_node_statuses + assert tuple(document["hooks"]) == test_case.expected_hooks + assert review_schemas(project_dir=project_dir) == () + assert not (project_dir / "target" / "review").exists() + + +@pytest.mark.parametrize( + "test_case", + ( + ReviewTextOutputE2ETestCase( + description="a passing review lists what it left out and how to recall the run", + edits={STAGING: _SAME_VALUES_STAGING}, + expected_stdout_fragments=( + "Review passed", + "What changed\n stg_orders (model): model SQL changed", + "open_orders [not validated: not on a path to a critical model", + "sqb recall output ", + ), + expected_markdown_fragments=( + "# Review passed\n", + "## What changed\n\n- `stg_orders` (model): model SQL changed", + "open_orders [not validated: not on a path to a critical model", + "Recall: `sqb recall output ", + ), + ), + ReviewTextOutputE2ETestCase( + description="a review with findings names the changed column", + edits={ORDER_TOTALS: _CHANGED_VALUES_TOTALS}, + expected_exit_code=1, + expected_stdout_fragments=("Review needs a fix", "order_totals", "amount"), + expected_markdown_fragments=("## Findings", "order_totals", "amount"), + ), + ), + ids=lambda case: case.description, +) +def test_given_markdown_path_when_reviewing_then_text_and_markdown_are_written( + test_case: ReviewTextOutputE2ETestCase, tmp_path: Path +) -> None: + project_dir: Path = prepare_review_repository(tmp_path=tmp_path, extra_files={}) + edit_project(project_dir=project_dir, files=test_case.edits) + markdown_path: Path = tmp_path / "out" / "review.md" + + result: subprocess.CompletedProcess[str] = run_review( + project_dir=project_dir, arguments=("--markdown", str(markdown_path)) + ) + + assert result.returncode == test_case.expected_exit_code, result.stdout + result.stderr + for fragment in test_case.expected_stdout_fragments: + assert fragment in result.stdout, result.stdout + markdown: str = markdown_path.read_text(encoding="utf-8") + for fragment in test_case.expected_markdown_fragments: + assert fragment in markdown, markdown + + +@pytest.mark.parametrize( + "test_case", + ( + ReviewCleanupFailureE2ETestCase( + description="a failed drop exits 3 and the janitor removes only the expired leftover", + expired_schema="review_20200101t000000z_0000000_000000", + expected_exit_code=3, + ), + ), + ids=lambda case: case.description, +) +def test_given_failing_schema_drop_when_reviewing_then_it_exits_3_and_janitor_cleans_old_schema( + test_case: ReviewCleanupFailureE2ETestCase, tmp_path: Path +) -> None: + project_dir: Path = prepare_review_repository(tmp_path=tmp_path, extra_files={}) + edit_project(project_dir=project_dir, files={STAGING: _SAME_VALUES_STAGING}) + plugin_env: dict[str, str] = write_drop_failure_plugin(directory=tmp_path / "plugin") + + result: subprocess.CompletedProcess[str] = run_review( + project_dir=project_dir, arguments=("--json",), env=plugin_env + ) + + assert result.returncode == test_case.expected_exit_code, result.stdout + result.stderr + document: dict[str, Any] = review_document(result) + assert document["status"] == "failed" + schema: dict[str, Any] = document["review_schema"] + assert schema["created"] is True + assert schema["dropped"] is False + assert schema["error"] + leftover: str = schema["name"] + assert review_schemas(project_dir=project_dir) == (leftover,) + + expired: str = test_case.expired_schema + execute_review_database( + project_dir=project_dir, + sql=( + f"CREATE SCHEMA {expired};" + "INSERT INTO dev._sqlbuild_fingerprints " + f"SELECT * REPLACE ('{expired}' AS node_name, '{expired}' AS target_schema) " + "FROM dev._sqlbuild_fingerprints " + f"WHERE node_type = 'review_schema' AND node_name = '{leftover}'" + ), + ) + janitor: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "janitor", "--json", "--auto-approve"), project_dir=project_dir + ) + + assert janitor.returncode == 0, janitor.stdout + janitor.stderr + janitor_document: dict[str, Any] = json.loads(janitor.stdout) + assert janitor_document["plan"]["review_schemas"] == [expired], janitor.stdout + assert review_schemas(project_dir=project_dir) == (leftover,) diff --git a/tests/unit/src/sqlbuild/adapter/contract/classes/strict_adapter/test_strict_adapter.py b/tests/unit/src/sqlbuild/adapter/contract/classes/strict_adapter/test_strict_adapter.py index 3abd653077..faad1a7145 100644 --- a/tests/unit/src/sqlbuild/adapter/contract/classes/strict_adapter/test_strict_adapter.py +++ b/tests/unit/src/sqlbuild/adapter/contract/classes/strict_adapter/test_strict_adapter.py @@ -90,6 +90,7 @@ def test_given_first_class_adapters_when_checking_contract_then_no_method_comes_ "create_sql_test_output", "drop_sql_test_output", "render_create_temporary_function", + "render_drop_schema", } ), expected_output_relation=None, @@ -115,10 +116,14 @@ def test_given_strict_adapter_when_reading_optional_methods_then_defaults_keep_i ), ) + with pytest.raises(AdapterUserError) as drop_error: + _ = adapter.render_drop_schema(database=None, schema="review_orders") + assert test_case.expected_optional_methods.isdisjoint(abstract_methods) assert test_case.expected_optional_methods <= frozenset(StrictAdapter.__dict__) assert relation is test_case.expected_output_relation assert temporary is test_case.expected_temporary_function + assert drop_error.value.code == "A714" @pytest.mark.parametrize( diff --git a/tests/unit/src/sqlbuild/executor/review/__init__.py b/tests/unit/src/sqlbuild/executor/review/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/src/sqlbuild/executor/review/main/__init__.py b/tests/unit/src/sqlbuild/executor/review/main/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/src/sqlbuild/executor/review/main/_test_types.py b/tests/unit/src/sqlbuild/executor/review/main/_test_types.py new file mode 100644 index 0000000000..83d76f505e --- /dev/null +++ b/tests/unit/src/sqlbuild/executor/review/main/_test_types.py @@ -0,0 +1,19 @@ +"""Test types for the review schema lifecycle.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(frozen=True) +class ReviewSchemaFoldingTestCase: + """How a warehouse stores and compares schema names, and what the review must see.""" + + description: str + fold: Callable[[str], str] + compare: Callable[[str], str] + expected_dropped: bool + expected_code: str + expected_created_at: datetime diff --git a/tests/unit/src/sqlbuild/executor/review/main/helpers.py b/tests/unit/src/sqlbuild/executor/review/main/helpers.py new file mode 100644 index 0000000000..fd1d5364e2 --- /dev/null +++ b/tests/unit/src/sqlbuild/executor/review/main/helpers.py @@ -0,0 +1,41 @@ +"""Helpers for review schema lifecycle tests.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +DROP_PREFIX: str = "DROP SCHEMA IF EXISTS " +DROP_SUFFIX: str = " CASCADE" + + +class FoldingWarehouseAdapter: + """Stores unquoted schema names folded as a warehouse does, and checks them as its adapter does. + + Postgres and Databricks store unquoted names in lowercase and compare `schema_name` exactly; + Snowflake stores them in uppercase and compares `UPPER(schema_name)` with `UPPER(name)`. + """ + + def __init__( + self, + *, + fold: Callable[[str], str], + compare: Callable[[str], str], + schemas: tuple[str, ...] = (), + ) -> None: + self.fold: Callable[[str], str] = fold + self.compare: Callable[[str], str] = compare + self.schemas: set[str] = {fold(schema) for schema in schemas} + + def schema_exists(self, *, connection: Any, database: str | None, schema: str) -> bool: + del connection, database + return any(self.compare(stored) == self.compare(schema) for stored in self.schemas) + + def render_drop_schema(self, *, database: str | None, schema: str) -> tuple[str, ...]: + del database + return (f"{DROP_PREFIX}{schema}{DROP_SUFFIX}",) + + def execute(self, *, connection: Any, sql: str) -> None: + del connection + unquoted: str = sql.removeprefix(DROP_PREFIX).removesuffix(DROP_SUFFIX) + self.schemas.discard(self.fold(unquoted)) diff --git a/tests/unit/src/sqlbuild/executor/review/main/test_drop_review_schema.py b/tests/unit/src/sqlbuild/executor/review/main/test_drop_review_schema.py new file mode 100644 index 0000000000..ccc0475435 --- /dev/null +++ b/tests/unit/src/sqlbuild/executor/review/main/test_drop_review_schema.py @@ -0,0 +1,132 @@ +"""Review schema names survive the identifier folding of the warehouses a review runs on.""" + +from datetime import UTC, datetime +from typing import Any, cast + +import pytest + +from sqlbuild._native import parse_review_schema_name +from sqlbuild.errors.contracts.exceptions import ExecutorInputError +from sqlbuild.executor.review.main.create_review_schema import create_review_schema +from sqlbuild.executor.review.main.drop_review_schema import drop_review_schema +from tests.unit.src.sqlbuild.executor.review.main._test_types import ReviewSchemaFoldingTestCase +from tests.unit.src.sqlbuild.executor.review.main.helpers import FoldingWarehouseAdapter + +REVIEW_SCHEMA: str = "review_20261023t101500z_3f2a9c1_a1b2c3" +CREATED_AT: datetime = datetime(2026, 10, 23, 10, 15, tzinfo=UTC) + + +@pytest.mark.parametrize( + "test_case", + ( + ReviewSchemaFoldingTestCase( + description="postgres-style lowercase folding with an exact comparison", + fold=str.lower, + compare=str, + expected_dropped=True, + expected_code="C765", + expected_created_at=CREATED_AT, + ), + ReviewSchemaFoldingTestCase( + description="snowflake-style uppercase folding with a case-insensitive comparison", + fold=str.upper, + compare=str.upper, + expected_dropped=True, + expected_code="C765", + expected_created_at=CREATED_AT, + ), + ), + ids=lambda case: case.description, +) +def test_given_folded_review_schema_when_dropping_then_it_is_found_and_gone( + test_case: ReviewSchemaFoldingTestCase, +) -> None: + adapter: FoldingWarehouseAdapter = FoldingWarehouseAdapter( + fold=test_case.fold, compare=test_case.compare, schemas=(REVIEW_SCHEMA,) + ) + + dropped: bool = drop_review_schema( + adapter=cast(Any, adapter), connection=None, database=None, schema=REVIEW_SCHEMA + ) + + assert dropped is test_case.expected_dropped + assert adapter.schemas == set() + + +@pytest.mark.parametrize( + "test_case", + ( + ReviewSchemaFoldingTestCase( + description="postgres-style lowercase folding with an exact comparison", + fold=str.lower, + compare=str, + expected_dropped=True, + expected_code="C765", + expected_created_at=CREATED_AT, + ), + ReviewSchemaFoldingTestCase( + description="snowflake-style uppercase folding with a case-insensitive comparison", + fold=str.upper, + compare=str.upper, + expected_dropped=True, + expected_code="C765", + expected_created_at=CREATED_AT, + ), + ), + ids=lambda case: case.description, +) +def test_given_existing_folded_review_schema_when_creating_then_it_is_refused( + test_case: ReviewSchemaFoldingTestCase, +) -> None: + adapter: FoldingWarehouseAdapter = FoldingWarehouseAdapter( + fold=test_case.fold, compare=test_case.compare, schemas=(REVIEW_SCHEMA,) + ) + + with pytest.raises(ExecutorInputError) as raised: + create_review_schema( + adapter=cast(Any, adapter), + connection=None, + database=None, + schema=REVIEW_SCHEMA, + marker_database=None, + marker_schema="dev", + run_id="a1b2c3", + created_at=CREATED_AT, + ) + + assert raised.value.code == test_case.expected_code + + +@pytest.mark.parametrize( + "test_case", + ( + ReviewSchemaFoldingTestCase( + description="postgres-style lowercase folding with an exact comparison", + fold=str.lower, + compare=str, + expected_dropped=True, + expected_code="C765", + expected_created_at=CREATED_AT, + ), + ReviewSchemaFoldingTestCase( + description="snowflake-style uppercase folding with a case-insensitive comparison", + fold=str.upper, + compare=str.upper, + expected_dropped=True, + expected_code="C765", + expected_created_at=CREATED_AT, + ), + ), + ids=lambda case: case.description, +) +def test_given_folded_review_schema_name_when_parsing_then_creation_time_is_read( + test_case: ReviewSchemaFoldingTestCase, +) -> None: + parsed: datetime | None = parse_review_schema_name(test_case.fold(REVIEW_SCHEMA)) + + assert parsed is not None + assert parsed.astimezone(UTC) == test_case.expected_created_at + + +if __name__ == "__main__": + pytest.main([__file__, "-vv"]) diff --git a/website/src/content/docs/docs/cli/janitor.mdx b/website/src/content/docs/docs/cli/janitor.mdx index 4718fe60d5..246441459e 100644 --- a/website/src/content/docs/docs/cli/janitor.mdx +++ b/website/src/content/docs/docs/cli/janitor.mdx @@ -45,7 +45,7 @@ Deleted 1 expired archive. (0.12s) ✓ Janitor completed archived 3, deleted 1, pruned 0 (0.81s) ``` -The phases are archiving stale relations, deleting expired archives, dropping old-name views, dropping expired query artifacts and pruning direct state, in that order. If an action fails, its line shows `FAIL` and the error, no further action starts, and the run ends with `✗ Janitor failed` and the totals completed before the failure, exiting with `1`. +The phases are archiving stale relations, deleting expired archives, dropping old-name views, dropping expired query artifacts, dropping leftover review schemas and pruning direct state, in that order. If an action fails, its line shows `FAIL` and the error, no further action starts, and the run ends with `✗ Janitor failed` and the totals completed before the failure, exiting with `1`. Long preview sections show their first 10 entries and then `... and N more (use --verbose to show all)`. Skipped objects are counted by reason: @@ -165,6 +165,10 @@ Long names are shortened with a short hash so they fit the adapter's identifier Setting both `retention_days` and `archive_retention_days` to `0` archives and deletes stale relations in the same run. +## Review schemas + +[`sqb review`](/docs/cli/review) builds into a schema it creates for one run and drops when the run ends. Before creating the schema it writes an ownership marker to the active target schema's `_sqlbuild_fingerprints` table. When a review could not drop its schema, the janitor drops it once the timestamp in its name is more than 24 hours old, so a review that is still running is never touched. Only schemas with a marker in the active target schema are dropped; a schema whose name merely looks like a review schema is left alone. + ## Compatibility views When a model is renamed, SQLBuild keeps a [compatibility view](/docs/concepts/models/migrations#old-names) at its old name. The janitor lists each one with the model it reads and its expiry: diff --git a/website/src/content/docs/docs/cli/review.mdx b/website/src/content/docs/docs/cli/review.mdx new file mode 100644 index 0000000000..8ddf317f65 --- /dev/null +++ b/website/src/content/docs/docs/cli/review.mdx @@ -0,0 +1,189 @@ +--- +title: "sqb review" +description: "Build what a branch changed in a temporary schema, test it, compare it with a target and report." +sidebar: + label: "review" +--- + +`sqb review` checks a branch before it merges. It finds the models and seeds the branch changed, +builds them and their affected downstream models in a schema it creates for this run, runs the +unit tests and audits those changes affect, compares every built model with the same model in +another target, and reports the result as text, JSON or Markdown with an exit code a CI job can +gate on. + +## Usage + +```bash +sqb review --since origin/main --against prod +sqb review --since origin/main --against prod --dry-run +sqb review --since origin/main --against prod --json --markdown review.md +``` + +Two baselines drive a review: + +- **`--since `** is the code baseline. The changed set is everything that differs between + the merge base of `` and `HEAD` (including uncommitted edits), worked out exactly as + [`sqb test --since`](/docs/cli/test#run-only-affected-tests) does. +- **`--against `** is the data baseline: the target whose tables the review compares + with, reads unchanged upstream models from, and clones changed incremental models from. + +Both can come from `sqlbuild_project.toml` (see [Configuration](#configuration)), so a project +with `[git] base_ref` and `[review] against` runs a review with plain `sqb review`. + +## Flags + +| Flag | Description | +|------|-------------| +| `--since ` | Git ref whose merge base with `HEAD` is the code baseline; `@base` reads `[git] base_ref` (default: `[git] base_ref`) | +| `--against ` | Target to compare with and to read unchanged upstream from (default: `[review] against`) | +| `--downstream ` | Which unchanged downstream models to build and compare: `critical` (default), `all` or `none` | +| `--select`, `-s` | Narrow the review to these nodes of the changed set; see [Selecting](#selecting) | +| `--dry-run` | Print the plan, including the nodes whose hooks would run, without connecting to the warehouse | +| `--no-clone` | Start changed incremental models and snapshots from a full refresh instead of a clone | +| `--bounded ` | Compare models too large for an exact comparison within a recent cursor window, such as `14d` | +| `--markdown ` | Also write the report as Markdown to `` | +| `--json` | Print one JSON document on stdout; progress goes to stderr | +| `--target ` | Active target whose connection runs the review and whose schema holds the ownership marker | +| `--concurrency ` | Build concurrency | +| `--vars` | Project variables, as for `sqb build` | + +## What gets built + +The **changed set** is every model or seed whose definition changed, whose configuration changed, +or that reads a changed source, function or macro. Changes that build nothing, such as an edited +source declaration or unit test, are listed under "What changed" but are not built themselves. + +`--downstream` decides which unchanged models below the changed set are built too: + +- `critical` builds the models on every path from a changed node to a critical model. A model is + critical when its `MODEL` header sets [`critical true`](/docs/concepts/models/configuration), or + when it carries one of the tags in `[review] critical_tags` (default `["critical"]`). +- `all` builds every downstream model. +- `none` builds only the changed set. + +Downstream models the policy leaves out are always listed as **not validated**, with the reason. +The impact tree is computed from the whole changed set, whatever the policy. + +A review never runs [Python nodes](/docs/concepts/python-nodes/overview). A changed Python node, +and every task, asset or check downstream of a change, appears in the impact tree as not validated +with the reason. So does every model that reads a changed loader's source, directly or through +other models: the review would build it from the `--against` data rather than from the changed +loader, so it is listed as `reads Python node loader:, not rebuilt by review` instead of +being built. + +Upstream models outside the build are read from `--against`, as with +[`sqb build --defer-to`](/docs/cli/build). A changed incremental model or snapshot starts from a +zero-copy clone of its `--against` table when the adapter supports zero-copy clones and the +`--against` target allows being a clone origin; otherwise, or with `--no-clone`, it is built with +a full refresh. The report says which start each one got and why. + +## Selecting + +`--select` intersects with the review scope: only the selected nodes are built and compared; the +rest of the scope is listed as not validated. Every changed node a selected node depends on is +built and compared too, so a selected model never reads `--against` data where the branch differs; +the report lists each one as also built because the selected node depends on it. Each selector must +name something the review can check, or the review fails before connecting: + +| Selection | Error | +|-----------|-------| +| A name that matches no node | `C761` | +| Only nodes the branch did not change and that nothing changed above | `C762` | +| Only affected nodes the downstream policy leaves out, or that read a changed Python node | `C763` | + +## The review schema + +A review builds into a schema named `___` in lowercase, for +example `review_20261023t101500z_3f9c2ab_4e1d0a`. Lowercase is how warehouses that fold unquoted +names to lowercase store it, and warehouses that fold to uppercase compare schema names without +case, so the review, the existence checks and the janitor all find the same schema. Before creating +it, the review writes an ownership marker to the active target schema's `_sqlbuild_fingerprints` +table, and it refuses a schema that already exists. + +Every model and seed the review builds goes to the review schema in the active target's database, +including nodes that set their own `database`. When the active target sets no database, or sets it +to `preserve`, and a node the review builds sets its own, the review refuses with `C776` before +creating anything and names those nodes; set a database on the target to review them. The schema is always dropped when the review ends, whether it passed or not; a failed drop +makes the review fail with exit code `3`. [`sqb janitor`](/docs/cli/janitor#review-schemas) drops +marked review schemas left behind for more than 24 hours. + +The adapter must be able to drop a whole schema: SQLite and SQL Server targets cannot run a review. + +## Hooks + +Model [pre- and post-hooks](/docs/concepts/models/hooks) run during a review as they run during a +build, with the review schema as the model's destination, so a hook that reaches its relation +through the hook context, such as `@@CTX:destination.qualified`, acts on the review copy. A hook +that hard-codes another relation's name acts on that relation, which may be outside the review +schema. Check those hooks before reviewing; `--dry-run` and the run details list the nodes whose +hooks will run. + +## Checks + +- **Unit tests** that execute a changed node, chosen as `sqb test --since` chooses them, run on the + active target. The report counts how many unaffected tests were skipped. +- **Audits** of the built models run during the build. +- **Comparison** of every built model with its `--against` table: column names and types, row + counts, and, when the model has a `unique_key` and the [size guard](/docs/cli/diff#size-guard) + allows, an exact keyed diff with branch-only keys, target-only keys and changed values, each with + example keys. A model the size guard blocks is compared within `--bounded` when given and the + model has a cursor. Models without a `unique_key` get the schema and row-count checks only. + +Any difference is a finding: a schema change, a different row count, branch-only or target-only +keys, a changed value, a failed unit test or a failed audit. A check that never ran is never +reported as passing; it is **incomplete coverage**: + +- the keyed diff of a model without a `unique_key`, or of a model the size guard blocks; +- a changed seed, since the comparison covers models only; +- a node the build skipped; +- a model that existed at the base revision but has no table in `--against`. + +A model new in the branch is built but has nothing to compare, and the impact tree says so. When +`--against` has none of the tables of the models to compare that existed at the base revision, the +review fails with `C774` before creating anything, since that usually means the wrong target. + +## Output + +Text output starts with the outcome, then findings, what changed, the downstream impact tree with +each node's status (`built`, `ok`, `finding` or `not validated` and why), incomplete coverage, the +baseline, tests and audits, and run details: the review schema and its cleanup, the downstream +policy, and the [`sqb recall output`](/docs/cli/recall) command that shows the whole run. + +`--markdown ` writes the same report as Markdown for a pull request comment: a title with the +outcome, a summary table, numbered findings, and per-model evidence in collapsed `
` blocks +that list only the columns that changed. + +`--json` prints one document with `format_version`, `status` (`passed`, `findings`, `incomplete`, +`failed` or `planned`), `exit_code`, `since`, `against`, `summary`, `findings`, `changed`, +`validated`, `not_validated`, `nodes`, `impact_edges`, `incomplete`, `baseline`, `tests`, `audits`, +`review_schema`, `recall` and `error`. The document is written on every path, including failures. + +## Exit codes + +| Code | Meaning | +|------|---------| +| `0` | Passed: every check ran and found no difference, or a dry run | +| `1` | Findings | +| `2` | Incomplete coverage, and no findings | +| `3` | The review could not set up, run or clean up, including invalid arguments and a `--markdown` file it could not write | + +A failure takes precedence over findings, and findings over incomplete coverage. Exit code `2` +means only incomplete coverage: invalid arguments, such as an unknown `--downstream` policy, exit +`3`, and with `--json` print the JSON document with the `C900` error. When the `--markdown` file +cannot be written, the review prints its result on stdout first, then reports the error (`C775`) +and exits `3`. + +## Configuration + +```toml +[git] +base_ref = "origin/main" + +[review] +against = "prod" +downstream = "critical" +schema_prefix = "review" +critical_tags = ["critical"] +``` + +See [Review](/docs/concepts/project-configuration#review) in the project configuration reference. diff --git a/website/src/content/docs/docs/concepts/adapters.mdx b/website/src/content/docs/docs/concepts/adapters.mdx index 23ed8255a4..3d65adb756 100644 --- a/website/src/content/docs/docs/concepts/adapters.mdx +++ b/website/src/content/docs/docs/concepts/adapters.mdx @@ -95,7 +95,7 @@ Set `sql_analysis_dialect_name` to the SQL dialect name that matches your engine For full control with no inherited defaults, subclass `StrictAdapter` instead. Every method is abstract and must be implemented explicitly. SQLBuild raises a clear error listing any unimplemented methods. -A few `StrictAdapter` methods are optional and have a documented default. `create_sql_test_output` and `drop_sql_test_output` let an adapter store a unit test's actual output once so every comparison reads it (see [Single-read comparison](/docs/concepts/testing#single-read-comparison)). By default `create_sql_test_output` returns `None`, and SQLBuild runs the test's inline comparison query instead, so adapters that do not implement them keep working unchanged. `render_create_temporary_function` returns the call target and DDL for a session-scoped copy of a project function, which `sqb test` calls instead of the built function (see [Functions in `sqb test`](/docs/concepts/testing#functions-in-sqb-test)). By default it returns `None`, and tests call the built function. +A few `StrictAdapter` methods are optional and have a documented default. `create_sql_test_output` and `drop_sql_test_output` let an adapter store a unit test's actual output once so every comparison reads it (see [Single-read comparison](/docs/concepts/testing#single-read-comparison)). By default `create_sql_test_output` returns `None`, and SQLBuild runs the test's inline comparison query instead, so adapters that do not implement them keep working unchanged. `render_create_temporary_function` returns the call target and DDL for a session-scoped copy of a project function, which `sqb test` calls instead of the built function (see [Functions in `sqb test`](/docs/concepts/testing#functions-in-sqb-test)). By default it returns `None`, and tests call the built function. `render_drop_schema` returns the statements that drop a schema and everything in it, which [`sqb review`](/docs/cli/review) uses to remove the schema it built into. By default it raises an error, and `sqb review` is unavailable for that adapter. ### Discovery rules diff --git a/website/src/content/docs/docs/concepts/models/configuration.mdx b/website/src/content/docs/docs/concepts/models/configuration.mdx index c964396771..9a66339d57 100644 --- a/website/src/content/docs/docs/concepts/models/configuration.mdx +++ b/website/src/content/docs/docs/concepts/models/configuration.mdx @@ -22,6 +22,7 @@ which accepts any keys. |-------|-------------| | `materialized` | `view`, `table`, `incremental`, `snapshot`, or a custom materialization name | | `tags` | Tags used by selectors | +| `critical` | `true` marks the model as critical, so [`sqb review`](/docs/cli/review) builds and compares it whenever something upstream of it changes (default `false`) | | `description` | Required human-readable model description; see [Descriptions](#descriptions) | | `columns` | Model-local column declarations, inherited-column audit augmentation, or column renames with `migrate_from` (incremental and snapshot models; see [Column migrations](/docs/concepts/models/column-migrations)). On a table or view model that declares its own `migrate_from`, a column `migrate_from` only names the old column in the [compatibility view](/docs/concepts/models/migrations#old-names) | | `model_schema` | Reusable column schema name | diff --git a/website/src/content/docs/docs/concepts/project-configuration.mdx b/website/src/content/docs/docs/concepts/project-configuration.mdx index 349dd292a1..956cc6c2ed 100644 --- a/website/src/content/docs/docs/concepts/project-configuration.mdx +++ b/website/src/content/docs/docs/concepts/project-configuration.mdx @@ -874,7 +874,26 @@ base_ref = "origin/main" | Field | Default | Description | |-------|---------|-------------| -| `base_ref` | unset | Git ref that [`sqb test --since @base`](/docs/cli/test#run-only-affected-tests) and `sqb scenario test --since @base` read. Use a ref every checkout has, such as the remote default branch. | +| `base_ref` | unset | Git ref that [`sqb test --since @base`](/docs/cli/test#run-only-affected-tests), `sqb scenario test --since @base` and [`sqb review`](/docs/cli/review) read. Use a ref every checkout has, such as the remote default branch. | + +## Review + +Defaults for [`sqb review`](/docs/cli/review): + +```toml +[review] +against = "prod" +downstream = "critical" +schema_prefix = "review" +critical_tags = ["critical"] +``` + +| Field | Default | Description | +|-------|---------|-------------| +| `against` | unset | Target to compare with and to read unchanged upstream models from. `--against` overrides it; one of them is required | +| `downstream` | `"critical"` | Which unchanged downstream models to build and compare: `critical`, `all` or `none`. `--downstream` overrides it | +| `schema_prefix` | `"review"` | Prefix of the schema each review creates and drops; the schema name is lowercase | +| `critical_tags` | `["critical"]` | Model tags that make a model critical, in addition to `critical true` in its `MODEL` header | ## dbt diff --git a/website/src/content/docs/docs/index.mdx b/website/src/content/docs/docs/index.mdx index 8e68dbb6c4..d3e35fcc80 100644 --- a/website/src/content/docs/docs/index.mdx +++ b/website/src/content/docs/docs/index.mdx @@ -57,6 +57,7 @@ audit results and everything it printed. Agents and people query that history wi | Plan | [`sqb plan`](/docs/cli/plan) | Shows what will run, why, and how much it will rebuild | | Build | [`sqb build`](/docs/cli/build) | Runs tests, builds models in dependency order, and blocks bad data with audits | | Compare | [`sqb diff`](/docs/cli/diff) | Compares schemas and rows between two targets | +| Review | [`sqb review`](/docs/cli/review) | Builds a branch's changes in a temporary schema, tests them and compares them with a target | | Investigate | [`sqb recall`](/docs/cli/recall) | Queries the history of past runs, then live warehouse state | SQLBuild works with DuckDB, MotherDuck, Snowflake, BigQuery, Databricks, PostgreSQL, SQL Server,