From 874fdf6dba8dd9f0d9fe54bceaa55a627fc1ed1a Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:54:25 +0100 Subject: [PATCH 1/7] feat: add sqb review --- .../src/definition_changes/mod.rs | 2 +- crates/sqlbuild-analysis/src/lib.rs | 1 + .../src/review_command/_helpers/graph.rs | 48 ++ .../src/review_command/_helpers/mod.rs | 4 + .../src/review_command/_helpers/outcome.rs | 34 ++ .../src/review_command/_helpers/sections.rs | 406 +++++++++++++++ .../src/review_command/_helpers/wording.rs | 39 ++ .../src/review_command/constants.rs | 55 ++ .../main/changed_declarations.rs | 27 + .../src/review_command/main/mod.rs | 10 + .../main/node_change_reasons.rs | 25 + .../main/parse_downstream_policy.rs | 15 + .../main/parse_review_schema_name.rs | 46 ++ .../src/review_command/main/plan_review.rs | 167 ++++++ .../review_command/main/render_review_json.rs | 193 +++++++ .../main/render_review_markdown.rs | 186 +++++++ .../review_command/main/render_review_text.rs | 62 +++ .../src/review_command/main/review_outcome.rs | 10 + .../review_command/main/review_schema_name.rs | 21 + .../src/review_command/mod.rs | 9 + .../src/review_command/models.rs | 233 +++++++++ .../src/review_command/tests.rs | 8 + .../src/review_command/tests/helpers.rs | 217 ++++++++ .../review_command/tests/test_plan_review.rs | 257 +++++++++ .../tests/test_render_review.rs | 162 ++++++ .../src/review_command/tests/test_types.rs | 36 ++ .../sqlbuild-cli/src/argv/command_table.json | 237 +++++++++ crates/sqlbuild-cli/src/argv/constants.rs | 6 +- crates/sqlbuild-cli/src/dispatch/constants.rs | 27 + .../src/dispatch/main/command_dispatch.rs | 4 +- .../src/command_connection/constants.rs | 3 +- .../src/project/_helpers/documents.rs | 15 +- .../src/project/_helpers/sections.rs | 59 ++- .../sqlbuild-config/src/project/constants.rs | 3 + .../src/project/main/load_review_config.rs | 9 + .../sqlbuild-config/src/project/main/mod.rs | 1 + crates/sqlbuild-config/src/project/models.rs | 9 + .../src/project/tests/test_load_config.rs | 60 ++- .../src/project/tests/test_types.rs | 7 + .../src/git_revisions/main/head_commit.rs | 17 + .../src/git_revisions/main/mod.rs | 1 + .../src/janitor_text/_helpers/counts.rs | 2 +- .../src/janitor_text/_helpers/phases.rs | 1 + .../src/janitor_text/_helpers/plan_json.rs | 5 + .../janitor_text/_helpers/plan_sections.rs | 1 + .../main/janitor_confirmation_prompt.rs | 2 +- .../janitor_text/main/janitor_item_counts.rs | 1 + .../main/janitor_phase_flow_width.rs | 1 + .../main/janitor_plan_has_work.rs | 1 + .../main/render_janitor_completion.rs | 1 + .../janitor_text/main/render_janitor_plan.rs | 10 + .../sqlbuild-core/src/janitor_text/models.rs | 6 + .../tests/test_janitor_progress_text.rs | 1 + crates/sqlbuild-discovery/src/constants.rs | 3 +- .../src/entries/_helpers/executor.rs | 10 + crates/sqlbuild-host/src/entries/models.rs | 2 + .../_helpers/materialization.rs | 10 + .../main/validate_model_config.rs | 3 +- .../tests/test_validate_model_config.rs | 14 + .../_helpers/build_command/invocation.rs | 23 +- .../src/bindings/_helpers/cli/dispatch.rs | 2 + .../src/bindings/_helpers/cli/registration.rs | 5 +- .../bindings/_helpers/clone/clone_command.rs | 13 +- .../bindings/_helpers/diff_report/command.rs | 2 +- .../_helpers/discovery/project_config.rs | 10 +- .../_helpers/janitor/janitor_phases.rs | 3 +- .../bindings/_helpers/janitor/janitor_plan.rs | 5 + .../_helpers/janitor/janitor_planning.rs | 48 +- .../src/bindings/_helpers/mod.rs | 1 + .../bindings/_helpers/records/constants.rs | 40 ++ .../src/bindings/_helpers/records/ids.rs | 2 + .../_helpers/review_command/command.rs | 486 ++++++++++++++++++ .../_helpers/review_command/comparison.rs | 451 ++++++++++++++++ .../_helpers/review_command/execution.rs | 324 ++++++++++++ .../_helpers/review_command/impact.rs | 107 ++++ .../bindings/_helpers/review_command/mod.rs | 8 + .../_helpers/review_command/planning.rs | 426 +++++++++++++++ .../_helpers/review_command/registration.rs | 29 ++ .../_helpers/review_command/schema.rs | 200 +++++++ .../_helpers/review_command/support.rs | 179 +++++++ fensu.toml | 10 + src/sqlbuild/_native.pyi | 54 ++ src/sqlbuild/_native_host.py | 5 + src/sqlbuild/_native_host.pyi | 4 + .../adapter/contract/classes/base_adapter.py | 9 + .../contract/classes/duckdb_backed_adapter.py | 9 + .../contract/classes/strict_adapter.py | 16 + .../bigquery/classes/bigquery_adapter.py | 4 + .../clickhouse/classes/clickhouse_adapter.py | 4 + .../databricks/classes/databricks_adapter.py | 4 + .../postgres/classes/postgres_adapter.py | 9 + .../snowflake/classes/snowflake_adapter.py | 9 + .../adapters/sqlite/classes/sqlite_adapter.py | 8 + .../sqlserver/classes/sqlserver_adapter.py | 8 + .../executor/janitor/_helpers/workers.py | 1 + src/sqlbuild/executor/janitor/main/execute.py | 22 +- src/sqlbuild/executor/janitor/models.py | 3 + src/sqlbuild/executor/janitor/types.py | 1 + src/sqlbuild/executor/review/__init__.py | 1 + .../executor/review/_helpers/__init__.py | 1 + .../executor/review/_helpers/marker.py | 93 ++++ .../executor/review/classes/__init__.py | 1 + .../review/classes/review_schema_lifecycle.py | 79 +++ src/sqlbuild/executor/review/constants.py | 4 + src/sqlbuild/executor/review/models.py | 31 ++ .../cli/commands/main/review/__init__.py | 0 .../cli/commands/main/review/_test_types.py | 44 ++ .../cli/commands/main/review/helpers.py | 223 ++++++++ .../cli/commands/main/review/test_review.py | 338 ++++++++++++ website/src/content/docs/docs/cli/janitor.mdx | 6 +- website/src/content/docs/docs/cli/review.mdx | 152 ++++++ .../content/docs/docs/concepts/adapters.mdx | 2 +- .../docs/concepts/models/configuration.mdx | 1 + .../docs/concepts/project-configuration.mdx | 21 +- 114 files changed, 6306 insertions(+), 40 deletions(-) create mode 100644 crates/sqlbuild-analysis/src/review_command/_helpers/graph.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/_helpers/mod.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/_helpers/outcome.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/_helpers/sections.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/_helpers/wording.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/constants.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/main/changed_declarations.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/main/mod.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/main/node_change_reasons.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/main/parse_downstream_policy.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/main/parse_review_schema_name.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/main/plan_review.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/main/render_review_json.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/main/render_review_markdown.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/main/render_review_text.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/main/review_outcome.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/main/review_schema_name.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/mod.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/models.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/tests.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/tests/helpers.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/tests/test_plan_review.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/tests/test_render_review.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/tests/test_types.rs create mode 100644 crates/sqlbuild-config/src/project/main/load_review_config.rs create mode 100644 crates/sqlbuild-core/src/git_revisions/main/head_commit.rs create mode 100644 crates/sqlbuild-python/src/bindings/_helpers/review_command/command.rs create mode 100644 crates/sqlbuild-python/src/bindings/_helpers/review_command/comparison.rs create mode 100644 crates/sqlbuild-python/src/bindings/_helpers/review_command/execution.rs create mode 100644 crates/sqlbuild-python/src/bindings/_helpers/review_command/impact.rs create mode 100644 crates/sqlbuild-python/src/bindings/_helpers/review_command/mod.rs create mode 100644 crates/sqlbuild-python/src/bindings/_helpers/review_command/planning.rs create mode 100644 crates/sqlbuild-python/src/bindings/_helpers/review_command/registration.rs create mode 100644 crates/sqlbuild-python/src/bindings/_helpers/review_command/schema.rs create mode 100644 crates/sqlbuild-python/src/bindings/_helpers/review_command/support.rs create mode 100644 src/sqlbuild/executor/review/__init__.py create mode 100644 src/sqlbuild/executor/review/_helpers/__init__.py create mode 100644 src/sqlbuild/executor/review/_helpers/marker.py create mode 100644 src/sqlbuild/executor/review/classes/__init__.py create mode 100644 src/sqlbuild/executor/review/classes/review_schema_lifecycle.py create mode 100644 src/sqlbuild/executor/review/constants.py create mode 100644 src/sqlbuild/executor/review/models.py create mode 100644 tests/e2e/src/sqlbuild/cli/commands/main/review/__init__.py create mode 100644 tests/e2e/src/sqlbuild/cli/commands/main/review/_test_types.py create mode 100644 tests/e2e/src/sqlbuild/cli/commands/main/review/helpers.py create mode 100644 tests/e2e/src/sqlbuild/cli/commands/main/review/test_review.py create mode 100644 website/src/content/docs/docs/cli/review.mdx diff --git a/crates/sqlbuild-analysis/src/definition_changes/mod.rs b/crates/sqlbuild-analysis/src/definition_changes/mod.rs index b4c56eaa3e..153271c423 100644 --- a/crates/sqlbuild-analysis/src/definition_changes/mod.rs +++ b/crates/sqlbuild-analysis/src/definition_changes/mod.rs @@ -1,6 +1,6 @@ //! Definition fingerprints of project nodes, their changes, and why tests that run them are selected. -mod _helpers; +pub(crate) mod _helpers; pub mod constants; pub mod main; pub mod models; 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..43fd843a3a --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/_helpers/graph.rs @@ -0,0 +1,48 @@ +//! 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 +} + +/// 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 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()); + } + } + 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/mod.rs b/crates/sqlbuild-analysis/src/review_command/_helpers/mod.rs new file mode 100644 index 0000000000..ca5a26070a --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/_helpers/mod.rs @@ -0,0 +1,4 @@ +pub(crate) mod graph; +pub(crate) mod outcome; +pub(crate) mod sections; +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/sections.rs b/crates/sqlbuild-analysis/src/review_command/_helpers/sections.rs new file mode 100644 index 0000000000..31d4c82219 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/_helpers/sections.rs @@ -0,0 +1,406 @@ +//! The report sections the Markdown and terminal renderers share. + +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 != "none") + }) + .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 => 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 +} + +/// 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<(&str, String, bool, bool)> = + vec![(&root.name, String::new(), true, true)]; + while let Some((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((child, child_prefix.clone(), index + 1 == next.len(), 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" { + "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 == "none" && 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); + 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/wording.rs b/crates/sqlbuild-analysis/src/review_command/_helpers/wording.rs new file mode 100644 index 0000000000..c4cd47d25a --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/_helpers/wording.rs @@ -0,0 +1,39 @@ +//! Words the review renders for policies, statuses and counts. + +use crate::review_command::constants::{ALL_POLICY, CRITICAL_POLICY, NONE_POLICY}; +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 { + if seconds < 60.0 { + return format!("{seconds:.2}s"); + } + let whole: u64 = seconds.round() as u64; + format!("{}m {:02}s", whole / 60, whole % 60) +} + +/// `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..4c9ccea13f --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/constants.rs @@ -0,0 +1,55 @@ +//! 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; + +/// 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"; +/// Nothing changed since the base revision. +pub const NOTHING_CHANGED_CODE: &str = "C764"; + +/// 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; +/// How old a marked review schema must be before the janitor drops it, in seconds. +pub const LEFTOVER_REVIEW_SCHEMA_AGE_SECONDS: i64 = 24 * 60 * 60; + +/// 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/main/changed_declarations.rs b/crates/sqlbuild-analysis/src/review_command/main/changed_declarations.rs new file mode 100644 index 0000000000..8aab2f6bf6 --- /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::_helpers::aspects::kind_words; +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_words(&change.kind).0.to_owned(), + change.name.clone(), + detail, + ) + }) + .collect() +} 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..0bf5fa97bf --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/main/mod.rs @@ -0,0 +1,10 @@ +pub mod changed_declarations; +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..c9a616dafe --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/main/node_change_reasons.rs @@ -0,0 +1,25 @@ +//! 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}; + +/// The reasons `key` changed: its own definition, a declaration it uses, or a source or function +/// it reads; empty when nothing it depends on directly changed. +#[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..321769305b --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/main/parse_review_schema_name.rs @@ -0,0 +1,46 @@ +//! The strict review schema name format. + +use crate::review_command::constants::{ + 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 `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<(i32, u32, u32, u32, u32, u32)> { + 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 digits = |range: std::ops::Range| -> Option { + let text: &str = upper.get(range)?; + if !text.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + text.parse().ok() + }; + let year: i32 = i32::try_from(digits(0..4)?).ok()?; + let (month, day) = (digits(4..6)?, digits(6..8)?); + let (hour, minute, second) = (digits(9..11)?, digits(11..13)?, digits(13..15)?); + if !(1..=12).contains(&month) + || !(1..=31).contains(&day) + || hour > 23 + || minute > 59 + || second > 59 + { + return None; + } + Some((year, month, day, hour, minute, second)) +} 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..81f4b7ef21 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/main/plan_review.rs @@ -0,0 +1,167 @@ +//! What a review builds: the changed set, its downstream under the policy, and `--select`. + +use std::collections::BTreeSet; + +use crate::review_command::_helpers::graph::{ancestors_within, descendants}; +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::models::{ + ChangedNode, DownstreamPolicy, NotValidated, ReviewGraph, ReviewPlan, ReviewPlanError, + SelectedToken, +}; + +fn selection_error(code: &str, message: String, help: &str) -> ReviewPlanError { + ReviewPlanError { + code: code.to_owned(), + message, + help: Some(help.to_owned()), + } +} + +/// The downstream nodes the policy validates. +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() + } + } +} + +/// Reject a token that matches nothing, only unchanged nodes, or only nodes outside the policy. +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", + token.token, + policy_name(policy) + ), + "pass --downstream all to review every downstream 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", + )) +} + +fn not_validated_reason( + name: &str, + (changed, scope): (&BTreeSet, &BTreeSet), + policy: DownstreamPolicy, +) -> String { + 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() + } + } +} + +/// 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 changed_names: BTreeSet = changed + .iter() + .filter(|node| graph.nodes.contains_key(&node.name)) + .map(|node| node.name.clone()) + .collect(); + let downstream: BTreeSet = descendants(graph, &changed_names) + .into_iter() + .filter(|name| !changed_names.contains(name)) + .collect(); + let affected: BTreeSet = changed_names.union(&downstream).cloned().collect(); + let critical: BTreeSet = affected + .iter() + .filter(|name| graph.nodes.get(*name).is_some_and(|node| node.critical)) + .cloned() + .collect(); + let mut scope: BTreeSet = changed_names.clone(); + scope.extend(policy_downstream( + graph, + policy, + (&changed_names, &downstream), + &critical, + )); + let validated_set: BTreeSet = match select { + None => scope.clone(), + Some(tokens) => { + let mut selected: BTreeSet = BTreeSet::new(); + for token in tokens { + check_token(token, (graph, policy), (&affected, &scope))?; + selected.extend(token.names.iter().cloned()); + } + scope.intersection(&selected).cloned().collect() + } + }; + let mut validated: Vec = Vec::new(); + let mut not_validated: Vec = Vec::new(); + for name in &graph.order { + if validated_set.contains(name) { + validated.push(name.clone()); + } else if affected.contains(name) { + not_validated.push(NotValidated { + name: name.clone(), + reason: not_validated_reason(name, (&changed_names, &scope), policy), + }); + } + } + Ok(ReviewPlan { + policy, + changed: changed.to_vec(), + validated, + not_validated, + critical: graph + .order + .iter() + .filter(|name| critical.contains(*name)) + .cloned() + .collect(), + }) +} 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..d3ad839ef0 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/main/render_review_json.rs @@ -0,0 +1,193 @@ +//! The versioned review JSON document. + +use serde_json::{Value, json}; + +use crate::review_command::_helpers::outcome::outcome; +use crate::review_command::_helpers::wording::policy_name; +use crate::review_command::constants::{ + FINDING_NODE, NOT_VALIDATED_NODE, REVIEW_REPORT_FORMAT_VERSION, +}; +use crate::review_command::models::{ + NodeComparison, NodeReport, ReviewFinding, ReviewReport, ValidationCounts, +}; + +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, + }) +} + +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, + }) +} + +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::>(), + }) +} + +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, + }) +} + +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 != "none") + }) + .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(), + }) +} + +/// 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::>(), + "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..988eca6e92 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/main/render_review_markdown.rs @@ -0,0 +1,186 @@ +//! The review as a Markdown document for a pull request. + +use crate::review_command::_helpers::sections::{ + Style, baseline_lines, changed_lines, context_line, example_lines, finding_text, has_evidence, + headline, impact_tree, incomplete_lines, run_lines, summary_counts, tests_lines, +}; +use crate::review_command::_helpers::wording::counted; +use crate::review_command::models::{NodeComparison, NodeReport, ReviewReport}; + +const STYLE: Style = Style { markdown: true }; + +fn cell(text: &str) -> String { + text.replace('|', "\\|").replace('\n', " ") +} + +fn bullets(out: &mut String, lines: &[String]) { + for line in lines { + out.push_str(&format!("- {line}\n")); + } +} + +fn section(out: &mut String, title: &str, lines: &[String], empty: &str) { + out.push_str(&format!("\n## {title}\n\n")); + if lines.is_empty() { + out.push_str(&format!("{empty}\n")); + } else { + bullets(out, lines); + } +} + +fn evidence_rows(comparison: &NodeComparison) -> Vec<[String; 5]> { + let mut rows: Vec<[String; 5]> = Vec::new(); + for column in &comparison.added_columns { + rows.push([ + column.clone(), + "column".into(), + "absent".into(), + "added".into(), + "exact match".into(), + ]); + } + for column in &comparison.removed_columns { + rows.push([ + column.clone(), + "column".into(), + "present".into(), + "removed".into(), + "exact match".into(), + ]); + } + for (column, target, branch) in &comparison.type_changes { + rows.push([ + column.clone(), + "type".into(), + target.clone(), + branch.clone(), + "exact match".into(), + ]); + } + for column in &comparison.columns { + rows.push([ + column.column.clone(), + column.metric.clone(), + column.target.clone(), + column.branch.clone(), + column.limit.clone(), + ]); + } + rows +} + +fn evidence(out: &mut String, node: &NodeReport, against: &str) { + let Some(comparison) = &node.comparison else { + return; + }; + let rows: Vec<[String; 5]> = evidence_rows(comparison); + out.push_str(&format!( + "\n
{}: {}\n\n", + cell(&node.name), + counted(rows.len(), "difference", "differences") + )); + if !rows.is_empty() { + out.push_str(&format!( + "| Column | Metric | {} | branch | Limit |\n", + cell(against) + )); + out.push_str("|---|---|---|---|---|\n"); + for row in &rows { + let cells: Vec = row.iter().map(|text| cell(text)).collect(); + out.push_str(&format!("| {} |\n", cells.join(" | "))); + } + out.push('\n'); + } + let rows_text: String = match (comparison.target_rows, comparison.branch_rows) { + (Some(target), Some(branch)) => format!("rows {against} {target}, branch {branch}; "), + _ => String::new(), + }; + out.push_str(&format!( + "{rows_text}{} compared with no difference ({} comparison).\n\n
\n", + counted( + comparison.unchanged_columns, + "other column", + "other columns" + ), + comparison.mode.replace('_', " ") + )); +} + +/// The review as Markdown: outcome, summary, findings, changes, impact, coverage and run details. +#[must_use] +pub fn render_review_markdown(report: &ReviewReport) -> String { + let against: &str = &report.baseline.against; + let mut out: String = format!( + "# {}\n\n{}\n\n", + headline(report), + context_line(report, STYLE) + ); + let counts: Vec<(&str, usize)> = summary_counts(report); + let labels: Vec<&str> = counts.iter().map(|(label, _)| *label).collect(); + out.push_str(&format!("| {} |\n", labels.join(" | "))); + out.push_str(&format!("|{}\n", "---:|".repeat(counts.len()))); + let values: Vec = counts.iter().map(|(_, count)| count.to_string()).collect(); + out.push_str(&format!("| {} |\n", values.join(" | "))); + out.push_str("\n## Findings\n\n"); + if report.findings.is_empty() { + out.push_str("No findings.\n"); + } + for (index, finding) in report.findings.iter().enumerate() { + out.push_str(&format!( + "{}. {}\n", + index + 1, + finding_text(finding, against, STYLE) + )); + for example in example_lines(finding, STYLE) { + out.push_str(&format!(" - {example}\n")); + } + } + section( + &mut out, + "What changed", + &changed_lines(report, STYLE), + "No definition changes.", + ); + out.push_str("\n## Downstream impact\n\n"); + let tree: Vec = impact_tree(report, Style { markdown: false }); + if tree.is_empty() { + out.push_str("No changed models or seeds.\n"); + } else { + out.push_str("```text\n"); + for line in tree { + out.push_str(&format!("{line}\n")); + } + out.push_str("```\n"); + } + section( + &mut out, + "Incomplete coverage", + &incomplete_lines(report, STYLE), + "Every check completed.", + ); + section( + &mut out, + "Baseline", + &baseline_lines(report, STYLE), + "No baseline.", + ); + section( + &mut out, + "Tests and audits", + &tests_lines(report), + "No tests or audits ran.", + ); + let evidence_nodes: Vec<&NodeReport> = report + .nodes + .iter() + .filter(|node| has_evidence(node)) + .collect(); + if !evidence_nodes.is_empty() { + out.push_str("\n## Per-model evidence\n"); + for node in evidence_nodes { + evidence(&mut out, node, against); + } + } + section(&mut out, "Run details", &run_lines(report, STYLE), ""); + out +} 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..0cebe5370c --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/main/render_review_text.rs @@ -0,0 +1,62 @@ +//! The review summary printed at the end of a terminal run. + +use crate::review_command::_helpers::sections::{ + Style, baseline_lines, changed_lines, context_line, example_lines, finding_text, headline, + impact_tree, incomplete_lines, run_lines, summary_counts, tests_lines, +}; + +use crate::review_command::models::ReviewReport; + +const STYLE: Style = Style { markdown: false }; + +fn block(out: &mut String, title: &str, lines: &[String]) { + if lines.is_empty() { + return; + } + out.push_str(&format!("\n{title}\n")); + for line in lines { + out.push_str(&format!(" {line}\n")); + } +} + +/// 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 against: &str = &report.baseline.against; + let mut out: String = String::new(); + let counts: Vec = summary_counts(report) + .iter() + .map(|(label, count)| format!("{}={count}", label.to_ascii_lowercase().replace(' ', "_"))) + .collect(); + let mut findings: Vec = Vec::new(); + for (index, finding) in report.findings.iter().enumerate() { + findings.push(format!( + "{}. {}", + index + 1, + finding_text(finding, against, STYLE) + )); + for example in example_lines(finding, STYLE) { + findings.push(format!(" {example}")); + } + } + block(&mut out, "Findings", &findings); + block(&mut out, "What changed", &changed_lines(report, STYLE)); + block(&mut out, "Downstream impact", &impact_tree(report, STYLE)); + block( + &mut out, + "Incomplete coverage", + &incomplete_lines(report, STYLE), + ); + if !report.dry_run { + block(&mut out, "Baseline", &baseline_lines(report, STYLE)); + block(&mut out, "Tests and audits", &tests_lines(report)); + } + block(&mut out, "Run details", &run_lines(report, STYLE)); + out.push_str(&format!( + "\n{} {}\n{}\n", + headline(report), + counts.join(" "), + context_line(report, STYLE) + )); + out +} 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..5982143246 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/main/review_schema_name.rs @@ -0,0 +1,21 @@ +//! The run-owned schema name of one review. + +use crate::review_command::constants::{SCHEMA_COMMIT_DIGITS, SCHEMA_RUN_DIGITS}; + +/// `___`, which `parse_review_schema_name` reads back. +#[must_use] +pub fn review_schema_name(prefix: &str, timestamp: &str, commit: &str, run_id: &str) -> String { + let commit: String = commit + .chars() + .filter(char::is_ascii_hexdigit) + .take(SCHEMA_COMMIT_DIGITS) + .collect::() + .to_ascii_lowercase(); + let run: String = run_id + .chars() + .filter(char::is_ascii_hexdigit) + .take(SCHEMA_RUN_DIGITS) + .collect::() + .to_ascii_lowercase(); + format!("{prefix}_{timestamp}_{commit}_{run}") +} 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..624434d3f6 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/mod.rs @@ -0,0 +1,9 @@ +//! `sqb review`: plan what a branch changed, and render what building and comparing it found. + +mod _helpers; +pub mod constants; +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..edbddfd315 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/models.rs @@ -0,0 +1,233 @@ +//! 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 buildable project node: a model or a seed. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ReviewNode { + pub name: String, + /// `model` or `seed`. + pub kind: String, + pub critical: bool, + pub materialized: String, + pub unique_key: Vec, + pub has_cursor: bool, +} + +/// The buildable project graph a review plans over. +#[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, +} + +/// 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, +} + +/// 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, +} + +/// A user error that stops planning. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ReviewPlanError { + pub code: String, + pub message: String, + pub help: Option, +} + +/// 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 error that ended a review early. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ReviewError { + 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..216119cb39 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/tests/helpers.rs @@ -0,0 +1,217 @@ +use std::collections::BTreeMap; + +use crate::review_command::models::{ + ChangedNode, ColumnEvidence, DownstreamPolicy, IncompleteCheck, IncrementalStart, + NodeComparison, NodeReport, ReviewBaseline, ReviewExample, 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_cursor: false, + }, + ) +} + +/// A waffle shop: `stg_orders` feeds a critical `daily_revenue` through `order_totals`, +/// and an uncritical `order_archive`; `stg_customers` feeds `customer_lifetime`. +pub(super) fn waffle_shop() -> ReviewGraph { + let edges: [(&str, &str); 5] = [ + ("waffle_types", "stg_orders"), + ("stg_orders", "order_totals"), + ("order_totals", "daily_revenue"), + ("stg_orders", "order_archive"), + ("stg_customers", "customer_lifetime"), + ]; + 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), + ] + .into_iter() + .collect(), + downstream, + order: [ + "waffle_types", + "stg_customers", + "stg_orders", + "customer_lifetime", + "order_archive", + "order_totals", + "daily_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()], + } +} + +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"]), + }, + 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` 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 +} 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..17da018aec --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/tests/test_plan_review.rs @@ -0,0 +1,257 @@ +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::{ + PlanErrorTestCase, PlanTestCase, SchemaNameTestCase, +}; + +#[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![( + "order_archive", + "not on a path to a critical model (--downstream critical)", + )], + }, + PlanTestCase { + description: "all builds every downstream 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![], + }, + 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)", + ), + ], + }, + 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![], + }, + 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)", + )], + }, + 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)", + ), + ( + "order_archive", + "not on a path to a critical model (--downstream critical)", + ), + ("order_totals", "not selected (--select)"), + ("daily_revenue", "not selected (--select)"), + ], + }, + 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![( + "order_archive", + "not on a path to a critical model (--downstream critical)", + )], + }, + ]; + 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 + ); + } +} + +#[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: "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")], + 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() { + assert_eq!( + parse_downstream_policy("critical"), + Some(DownstreamPolicy::Critical) + ); + assert_eq!(parse_downstream_policy("all"), Some(DownstreamPolicy::All)); + assert_eq!( + parse_downstream_policy("none"), + Some(DownstreamPolicy::None) + ); + assert_eq!(parse_downstream_policy("some"), None); +} + +#[test] +fn given_schema_names_when_parsing_then_only_strict_names_parse() { + let built: String = + review_schema_name("REVIEW", "20261023T101500Z", "3F2A9C1D88", "a1b2c3d4e5f6"); + assert_eq!(built, "REVIEW_20261023T101500Z_3f2a9c1_a1b2c3"); + let test_cases = [ + SchemaNameTestCase { + description: "a built name", + name: "REVIEW_20261023T101500Z_3f2a9c1_a1b2c3", + expected: Some((2026, 10, 23, 10, 15, 0)), + }, + SchemaNameTestCase { + description: "a folded name with an underscored prefix", + name: "ci_review_20261023t101500z_3f2a9c1_a1b2c3", + expected: Some((2026, 10, 23, 10, 15, 0)), + }, + SchemaNameTestCase { + description: "a short commit", + name: "REVIEW_20261023T101500Z_3f2a_a1b2c3", + expected: None, + }, + SchemaNameTestCase { + description: "an invalid month", + name: "REVIEW_20261323T101500Z_3f2a9c1_a1b2c3", + expected: None, + }, + SchemaNameTestCase { + description: "an ordinary schema", + name: "analytics_prod", + expected: None, + }, + ]; + for test_case in test_cases { + assert_eq!( + parse_review_schema_name(test_case.name), + test_case.expected, + "{}", + 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..b732832c9b --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/tests/test_render_review.rs @@ -0,0 +1,162 @@ +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::models::{ReviewError, ReviewReport}; +use crate::review_command::tests::helpers::{finding_report, incomplete_report}; +use crate::review_command::tests::test_types::{OutcomeTestCase, RenderTestCase}; + +fn failed_report() -> ReviewReport { + let mut report: ReviewReport = incomplete_report(); + if let Some(schema) = report.schema.as_mut() { + schema.dropped = false; + schema.error = Some("permission denied".to_owned()); + } + report +} + +fn passed_report() -> ReviewReport { + let mut report: ReviewReport = incomplete_report(); + report.incomplete.clear(); + report +} + +#[test] +fn given_evidence_when_deciding_outcome_then_failure_beats_findings_beats_incomplete() { + let mut errored: ReviewReport = finding_report(); + errored.error = Some(ReviewError { + code: "C761".to_owned(), + message: "--select x matches no model or seed".to_owned(), + help: None, + }); + let mut dry_run: ReviewReport = passed_report(); + dry_run.dry_run = true; + let test_cases = [ + OutcomeTestCase { + description: "no findings and complete coverage passes", + report: passed_report(), + expected: ("passed", 0), + }, + OutcomeTestCase { + description: "a finding needs a fix", + report: finding_report(), + expected: ("findings", 1), + }, + OutcomeTestCase { + description: "an incomplete check is incomplete", + report: incomplete_report(), + expected: ("incomplete", 2), + }, + OutcomeTestCase { + description: "a review schema that was not dropped fails", + report: failed_report(), + expected: ("failed", 3), + }, + OutcomeTestCase { + description: "an error fails even with findings", + report: errored, + expected: ("failed", 3), + }, + OutcomeTestCase { + description: "a dry run only plans", + report: dry_run, + expected: ("planned", 0), + }, + ]; + for test_case in test_cases { + assert_eq!( + review_outcome(&test_case.report), + test_case.expected, + "{}", + test_case.description + ); + } +} + +#[test] +fn given_finding_report_when_rendering_json_then_document_is_versioned_and_complete() { + let document: Value = + serde_json::from_str(&render_review_json(&finding_report())).expect("valid JSON"); + + assert_eq!(document["format_version"], 1); + assert_eq!(document["status"], "findings"); + assert_eq!(document["exit_code"], 1); + assert_eq!(document["summary"]["findings"], 1); + assert_eq!(document["summary"]["not_validated"], 1); + assert_eq!(document["findings"][0]["column"], "amount"); + assert_eq!(document["findings"][0]["examples"][0]["key"], "order_id=3"); + assert_eq!(document["tests"]["skipped"], 3); + assert_eq!(document["review_schema"]["dropped"], true); + assert_eq!( + document["recall"]["command"], + "sqb recall output a1b2c3d4e5f6" + ); + assert_eq!(document["changed"][1]["kind"], "macro"); +} + +#[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`", + ], + }, + 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", + ], + }, + RenderTestCase { + description: "a cleanup failure", + report: failed_report(), + expected_fragments: vec![ + "# Review failed\n", + "- Review schema `REVIEW_20261023T101500Z_3f2a9c1_a1b2c3`: not dropped: permission denied", + ], + }, + ]; + for test_case in test_cases { + let markdown: String = render_review_markdown(&test_case.report); + + for fragment in &test_case.expected_fragments { + assert!( + markdown.contains(fragment), + "{}: missing {fragment:?} in\n{markdown}", + test_case.description + ); + } + } +} + +#[test] +fn given_finding_report_when_rendering_text_then_summary_ends_with_outcome() { + let text: String = render_review_text(&finding_report()); + + assert!(text.contains("Findings\n 1. order_totals.amount values differ in 1 of 5 rows")); + assert!(text.contains("Downstream impact\n stg_orders [ok]\n ├── order_totals [finding]")); + assert!(text.contains("Recall: sqb recall output a1b2c3d4e5f6")); + assert!(text.contains( + "\nReview needs a fix: 1 finding changed=2 built=3 compared=3 tests=2 findings=1 not_validated=1 incomplete=0\n" + )); + assert!(!text.contains('`')); +} 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..93afc463a4 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/tests/test_types.rs @@ -0,0 +1,36 @@ +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)>, +} + +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: (&'static str, i32), +} + +pub(super) struct RenderTestCase { + pub(super) description: &'static str, + pub(super) report: ReviewReport, + pub(super) expected_fragments: Vec<&'static str>, +} + +pub(super) struct SchemaNameTestCase { + pub(super) description: &'static str, + pub(super) name: &'static str, + pub(super) expected: Option<(i32, u32, u32, u32, u32, u32)>, +} 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..84522d84bb 100644 --- a/crates/sqlbuild-cli/src/argv/constants.rs +++ b/crates/sqlbuild-cli/src/argv/constants.rs @@ -64,7 +64,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 +86,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/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-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..85f54be00a 100644 --- a/crates/sqlbuild-config/src/project/_helpers/sections.rs +++ b/crates/sqlbuild-config/src/project/_helpers/sections.rs @@ -15,7 +15,7 @@ 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, + 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, @@ -25,7 +25,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 +548,61 @@ 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 schema_prefix = optional_str(mapping, "schema_prefix").ok().flatten(); + 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 == '_') + }; + if get(mapping, "schema_prefix").is_some() + && !schema_prefix.as_deref().is_some_and(valid_prefix) + { + 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..7b48ade480 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,12 @@ +use crate::project::errors::LoadFailure; 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, ReviewConfigTestCase, +}; const PROJECT: &str = "name = 'orders'\nadapter = 'duckdb'\n"; @@ -172,3 +177,56 @@ fn given_git_section_when_reading_base_ref_then_returns_configured_ref() { ); } } + +#[test] +fn given_review_section_when_reading_then_returns_defaults_or_rejects() { + 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: Ok(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: Ok(ReviewConfig::default()), + }, + ReviewConfigTestCase { + description: "an unknown downstream policy", + project: "name = 'orders'\nadapter = 'duckdb'\n[review]\ndownstream = 'some'\n", + expected: Err("review.downstream must be one of: critical, all, none"), + }, + ReviewConfigTestCase { + description: "a schema prefix that is not an identifier", + project: "name = 'orders'\nadapter = 'duckdb'\n[review]\nschema_prefix = 'review-ci'\n", + expected: Err("review.schema_prefix must start with a letter"), + }, + ReviewConfigTestCase { + description: "a misspelt review key", + project: "name = 'orders'\nadapter = 'duckdb'\n[review]\nagaints = 'prod'\n", + expected: Err("review contains unknown key(s): againts"), + }, + ]; + for test_case in test_cases { + let (_directory, paths) = project_directory(test_case.project, ""); + + let loaded = load_review_config(&paths); + + match (loaded, test_case.expected) { + (Ok(config), Ok(expected)) => { + assert_eq!(config, expected, "{}", test_case.description); + } + (Err(LoadFailure::Config(failure)), Err(fragment)) => { + let text: String = format!("{failure:?}"); + assert!(text.contains(fragment), "{}: {text}", test_case.description); + } + (other, _) => panic!("{}: unexpected {other:?}", 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..12b0fc3ced 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,9 @@ 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: Result, +} 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..825541094c --- /dev/null +++ b/crates/sqlbuild-core/src/git_revisions/main/head_commit.rs @@ -0,0 +1,17 @@ +//! The commit `HEAD` names in the repository containing a directory. + +use std::path::Path; + +use crate::git_revisions::_helpers::git_command::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 { + git_output( + directory, + &["rev-parse", "--verify", "--quiet", "HEAD"], + &[], + ) + .ok() + .filter(|commit| !commit.is_empty()) +} 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..eef382afce 100644 --- a/crates/sqlbuild-host/src/entries/_helpers/executor.rs +++ b/crates/sqlbuild-host/src/entries/_helpers/executor.rs @@ -834,4 +834,14 @@ pub(crate) const ENTRIES: &[HostEntry] = &[ kind: HostKind::Exception, edge: HostEdge::Executor, }, + HostEntry { + name: "ReviewSchemaLifecycle", + kind: HostKind::Class, + edge: HostEdge::Executor, + }, + HostEntry { + name: "ReviewSchemaMarker", + kind: HostKind::Class, + edge: HostEdge::Executor, + }, ]; diff --git a/crates/sqlbuild-host/src/entries/models.rs b/crates/sqlbuild-host/src/entries/models.rs index 531d06bd5c..7d3a984eee 100644 --- a/crates/sqlbuild-host/src/entries/models.rs +++ b/crates/sqlbuild-host/src/entries/models.rs @@ -367,6 +367,8 @@ pub enum ExecutorHost { CommandOutputStream, OutputRecordPriority, SqlValueRenderingError, + ReviewSchemaLifecycle, + ReviewSchemaMarker, } 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/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..1314a08ace 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_plan.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_plan.rs @@ -231,6 +231,10 @@ pub(crate) fn janitor_plan_text(plan: &Bound<'_, PyAny>) -> PyResult>>()?, state_prunes, skipped_relations, skipped_schemas: source_schemas(&native_field(plan, "skipped_schemas")?, false)?, @@ -249,6 +253,7 @@ pub(crate) fn janitor_result_counts(result: &Bound<'_, PyAny>) -> 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> = call_with( + &imported(py, "datetime", "timedelta")?, + &[("seconds", integer(py, LEFTOVER_REVIEW_SCHEMA_AGE_SECONDS)?)], + )?; + let leftovers: Bound<'py, PyAny> = call_with( + &native_field( + &host::entry(py, ExecutorHost::ReviewSchemaLifecycle)?, + "leftovers", + )?, + &[ + ("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 +182,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 +225,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 +337,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/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..f353d628ad --- /dev/null +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/command.rs @@ -0,0 +1,486 @@ +//! `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::{pyfunction, wrap_pyfunction}; +use sqlbuild_analysis::review_command::constants::{ + BUILT_NODE, FAILED_NODE, FINDING_NODE, MODEL_KIND, NOT_VALIDATED_NODE, OK_NODE, PLANNED_STATUS, +}; +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::{ + DownstreamPolicy, NodeReport, ReviewFinding, ReviewNode, ReviewPlan, ReviewReport, + ReviewSchemaReport, +}; +use sqlbuild_core::git_revisions::main::head_commit::head_commit; + +use crate::bindings::_helpers::boundary::python_calls::imported; +use crate::bindings::_helpers::records::fields::field as native_field; +use crate::bindings::_helpers::review_command::comparison::{ + ComparisonContext, Comparisons, compare_models, +}; +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::{ + 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::{ + emit, error_parts, failed_error, invocation_id, optional_text, phase, plan_error, utc_now, +}; + +const SUCCESS_STATUS: &str = "success"; +const SKIPPED_STATUS: &str = "skipped"; +const REVIEW_WORK_DIRECTORY: [&str; 2] = ["target", "review"]; + +/// 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. +fn planned_nodes(project: &ReviewProject<'_>, plan: &ReviewPlan, dry_run: bool) -> 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, dry_run) { + (Some(_), _) => NOT_VALIDATED_NODE.to_owned(), + (None, true) => PLANNED_STATUS.to_owned(), + (None, false) => BUILT_NODE.to_owned(), + }, + reason: reason.map(str::to_owned), + ..NodeReport::default() + } + }) + .collect() +} + +/// Settle each node's status from its build, audits, tests and comparison. +fn settle_nodes( + report: &mut ReviewReport, + build: &BuildOutcome, + comparisons: Comparisons, + affected: &BTreeMap>, +) { + let mut findings: Vec = comparisons.findings; + for (node, audit) in &build.failed_audits { + findings.push(ReviewFinding { + node: node.clone(), + kind: "audit".to_owned(), + metric: format!("audit {audit} failed"), + limit: "passes".to_owned(), + ..ReviewFinding::default() + }); + } + let with_findings: BTreeSet = findings + .iter() + .map(|finding| finding.node.clone()) + .collect(); + let mut by_node: BTreeMap = + comparisons.by_node; + for node in &mut report.nodes { + node.affected_columns = affected.get(&node.name).cloned().unwrap_or_default(); + if node.status == NOT_VALIDATED_NODE { + continue; + } + let status: Option = build.statuses.get(&node.name).cloned(); + node.build_status.clone_from(&status); + node.comparison = by_node.remove(&node.name); + let blocked: bool = build.audit_blocked.contains(&node.name); + node.status = match status.as_deref() { + _ if with_findings.contains(&node.name) => FINDING_NODE.to_owned(), + Some(SKIPPED_STATUS) => { + node.reason = Some("skipped after an upstream failure".to_owned()); + NOT_VALIDATED_NODE.to_owned() + } + Some(SUCCESS_STATUS) | None if blocked => FINDING_NODE.to_owned(), + Some(SUCCESS_STATUS) => match &node.comparison { + Some(comparison) if comparison.complete && comparison.mode != "none" => { + OK_NODE.to_owned() + } + _ => BUILT_NODE.to_owned(), + }, + _ if blocked => FINDING_NODE.to_owned(), + _ => FAILED_NODE.to_owned(), + }; + } + for failure in &report.tests.failures { + findings.push(ReviewFinding { + node: failure.trim_start_matches("unit test ").to_owned(), + kind: "test".to_owned(), + metric: "unit test failed".to_owned(), + limit: "passes".to_owned(), + ..ReviewFinding::default() + }); + } + report.findings = findings; + report.incomplete = comparisons.incomplete; +} + +/// Everything that needs the review schema: clone, build, tests, comparison and impact. +fn validate( + (request, stream, machine): (&Bound<'_, PyAny>, &Bound<'_, PyAny>, bool), + (settings, project, schema): (&ReviewSettings, &ReviewProject<'_>, &ReviewSchema<'_>), + (connection_config, connection): (&Bound<'_, PyAny>, &Bound<'_, PyAny>), + report: &mut ReviewReport, +) -> PyResult<()> { + let review_discovered: Bound<'_, PyAny> = + with_review_target(&project.discovered, settings, schema)?; + let work_dir: PathBuf = REVIEW_WORK_DIRECTORY + .iter() + .fold(project.project_dir.extract::()?, |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()))?; + let inputs: RunInputs<'_, '_> = RunInputs { + request, + project_dir: &project.project_dir, + review_discovered: &review_discovered, + review_target: &schema.name, + work_dir: &work_dir, + machine, + }; + let stateful: Vec = report + .plan + .validated + .iter() + .filter(|name| project.stateful.contains(*name)) + .cloned() + .collect(); + report.incremental_starts = + incremental_starts(&inputs, (settings, &project.adapter), &stateful)?; + let validated: Vec = report.plan.validated.clone(); + let count: usize = validated.len(); + let build: BuildOutcome = phase( + stream, + ( + &format!( + "Building {count} changed and downstream nodes into {} (unchanged upstream from {})", + 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 comparable: Vec<&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(); + let context: ComparisonContext<'_, '_> = ComparisonContext { + request, + discovered: &review_discovered, + adapter: &project.adapter, + connection, + connection_config, + review_target: &schema.name, + }; + let comparisons: Comparisons = phase( + stream, + ( + &format!( + "Comparing {} models with {}", + comparable.len(), + settings.against + ), + "Compared the review models", + "Comparison failed", + ), + || compare_models(&context, settings, &comparable), + )?; + 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( + &project.project, + (&changed, &affected_set), + &comparisons.by_node, + )?; + settle_nodes(report, &build, comparisons, &affected); + Ok(()) +} + +/// 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): (&ReviewSettings, &ReviewProject<'_>), + report: &mut ReviewReport, +) -> PyResult<()> { + let py: Python<'_> = request.py(); + let config: Bound<'_, PyAny> = connection_config( + request, + (&project.project_dir, &project.discovered), + settings, + )?; + let adapter: &Bound<'_, PyAny> = &project.adapter; + let connection: Bound<'_, PyAny> = adapter.call_method1("connect", (&config,))?; + let run_id: String = report + .invocation_id + .clone() + .unwrap_or_else(|| format!("{:x}", std::process::id())); + let schema: ReviewSchema<'_> = review_schema( + (&project.project, &project.discovered), + settings, + (utc_now(py)?, &report.baseline.head, &run_id), + )?; + report.schema = Some(ReviewSchemaReport { + database: schema.database.clone(), + name: schema.name.clone(), + ..ReviewSchemaReport::default() + }); + let created: PyResult<()> = phase( + stream, + ( + &format!("Creating review schema {}", schema.name), + "Created the review schema", + "Could not create the review schema", + ), + || create_review_schema((adapter, &connection), &schema), + ); + let worked: PyResult<()> = match created { + Ok(()) => { + if let Some(state) = report.schema.as_mut() { + state.created = true; + } + validate( + (request, stream, machine), + (settings, project, &schema), + (&config, &connection), + report, + ) + } + Err(error) => Err(error), + }; + let created: bool = report.schema.as_ref().is_some_and(|state| state.created); + if 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), + ); + if let Some(state) = report.schema.as_mut() { + match dropped { + Ok(true) => state.dropped = true, + Ok(false) => { + state.error = Some("the schema still exists after the drop".to_owned()) + } + Err(error) => state.error = Some(error_parts(py, &error).message), + } + } + } + let _ = adapter.call_method1("close", (&connection,)); + worked +} + +fn review( + (request, stream, machine): (&Bound<'_, PyAny>, &Bound<'_, PyAny>, bool), + report: &mut ReviewReport, +) -> 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; + let directory: PathBuf = project_dir.extract()?; + report.baseline.head = head_commit(&directory).unwrap_or_default(); + let project: ReviewProject<'_> = 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, &project.graph)?; + 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.dry_run); + report.deferred = deferred(&project, &report.plan.validated); + emit( + stream, + &format!( + "Review plan: {} changed, {} to build, {} left out (--downstream {})", + report.plan.changed.len(), + report.plan.validated.len(), + report.plan.not_validated.len(), + match settings.policy { + DownstreamPolicy::Critical => "critical", + DownstreamPolicy::All => "all", + DownstreamPolicy::None => "none", + }, + ), + )?; + if report.dry_run || report.plan.validated.is_empty() { + return Ok(()); + } + run_in_schema((request, stream, machine), (&settings, &project), report) +} + +fn publish(request: &Bound<'_, PyAny>, report: &ReviewReport, machine: bool) -> PyResult<()> { + let py: Python<'_> = request.py(); + if let Some(path) = optional_text(&native_field(request, "markdown_path")?)? { + let path: PathBuf = PathBuf::from(path); + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent) + .map_err(|error| pyo3::exceptions::PyOSError::new_err(error.to_string()))?; + } + std::fs::write(&path, render_review_markdown(report)) + .map_err(|error| pyo3::exceptions::PyOSError::new_err(error.to_string()))?; + } + let stdout: Bound<'_, PyAny> = imported(py, "sys", "stdout")?; + let document: String = if machine { + render_review_json(report) + } else { + render_review_text(report) + }; + stdout.call_method1("write", (document,))?; + stdout.call_method0("flush")?; + Ok(()) +} + +/// 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> = imported( + py, + "sys", + if machine || debug { "stderr" } else { "stdout" }, + )?; + let mut 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 outcome: PyResult<()> = review((request, &stream, machine), &mut report); + report.duration_seconds = started.elapsed().as_secs_f64(); + let cause: Option = outcome.err(); + if let Some(error) = &cause { + report.error = Some(error_parts(py, error)); + } + publish(request, &report, machine)?; + if let Some(cause) = cause { + if !cause.is_instance_of::(py) { + return Err(cause); + } + let error = report.error.clone().unwrap_or_default(); + return Err(failed_error(py, &error, Some(cause))); + } + 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..15923561eb --- /dev/null +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/comparison.rs @@ -0,0 +1,451 @@ +//! Compare each built model with `--against`: schema, row counts, keys and values. + +use std::collections::BTreeMap; + +use pyo3::prelude::{Bound, PyAny, PyAnyMethods, PyResult, Python}; +use pyo3::types::{PyTuple, PyTupleMethods}; +use sqlbuild_analysis::review_command::constants::{EXACT_LIMIT, MAX_FINDING_EXAMPLES}; +use sqlbuild_analysis::review_command::models::{ + ColumnEvidence, IncompleteCheck, NodeComparison, ReviewExample, ReviewFinding, ReviewNode, +}; +use sqlbuild_cli::diff_report::models::{DiffModelFacts, DiffValue}; +use sqlbuild_host::entries::models::ExecutorHost; + +use crate::bindings::_helpers::boundary::python_calls::{call_with, keywords}; +use crate::bindings::_helpers::compile_driver::diff::run_diff_pipeline; +use crate::bindings::_helpers::compiled_views::project_reads; +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::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::{boolean, optional_text, text, texts}; + +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. +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(", ") +} + +fn models_by_name<'py>( + project: &Bound<'py, PyAny>, +) -> PyResult>> { + let mut models: BTreeMap> = BTreeMap::new(); + for model in project_reads::models(project)?.try_iter()? { + let model: Bound<'py, PyAny> = model?; + models.insert(native_field(&model, "name")?.extract()?, 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")?; + let exists = 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")?), + ], + ); + Ok(exists.is_ok_and(|exists| exists.is_truthy().unwrap_or(false))) +} + +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 row: Bound<'_, PyAny> = cursor.call_method0("fetchone")?; + if row.is_none() { + 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, + } +} + +/// The comparison evidence and findings of one model's diff facts. +fn evidence(facts: &DiffModelFacts, mode: &str, out: &mut Comparisons) -> NodeComparison { + let name: &str = &facts.name; + 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", + ..NodeComparison::default() + }; + let schema_changes: usize = + facts.added_columns.len() + facts.removed_columns.len() + facts.type_changes.len(); + if schema_changes > 0 { + 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}")); + } + out.findings.push(ReviewFinding { + node: name.to_owned(), + kind: "schema".to_owned(), + metric: format!("schema changed: {}", parts.join("; ")), + limit: EXACT_LIMIT.to_owned(), + ..ReviewFinding::default() + }); + } + let Some(rows) = &facts.rows else { + return comparison; + }; + 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 { + out.findings.push(ReviewFinding { + node: name.to_owned(), + kind: "row_count".to_owned(), + metric: "row count".to_owned(), + target_value: Some(rows.left_count.to_string()), + branch_value: Some(rows.right_count.to_string()), + limit: EXACT_LIMIT.to_owned(), + ..ReviewFinding::default() + }); + } + for (count, side, keys) in [ + (rows.right_only_count, "branch-only", &facts.right_only_keys), + (rows.left_only_count, "target-only", &facts.left_only_keys), + ] { + if count == 0 { + continue; + } + out.findings.push(ReviewFinding { + node: name.to_owned(), + kind: "keys".to_owned(), + metric: format!("{count} {side} keys"), + limit: EXACT_LIMIT.to_owned(), + examples: keys + .iter() + .take(MAX_FINDING_EXAMPLES) + .map(|key| ReviewExample { + key: key_text(key), + ..ReviewExample::default() + }) + .collect(), + ..ReviewFinding::default() + }); + } + let mut changed: usize = 0; + for column in &rows.columns { + if column.mismatched_count == 0 { + continue; + } + changed += 1; + 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| { + let cell = sample + .changed_cells + .iter() + .find(|cell| cell.name == column.name)?; + Some(ReviewExample { + key: key_text(&sample.key_values), + target: Some(short(&cell.left)), + branch: Some(short(&cell.right)), + }) + }) + .take(MAX_FINDING_EXAMPLES) + .collect(); + out.findings.push(ReviewFinding { + node: name.to_owned(), + 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); + comparison +} + +/// Compare each of `models` (built, by name) between `--against` and the review schema. +pub(crate) fn compare_models( + context: &ComparisonContext<'_, '_>, + settings: &ReviewSettings, + models: &[&ReviewNode], +) -> PyResult { + let py: Python<'_> = context.adapter.py(); + let mut out: Comparisons = Comparisons::default(); + if models.is_empty() { + return Ok(out); + } + let names: Vec = models.iter().map(|node| node.name.clone()).collect(); + let pipeline: Bound<'_, 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, &names)?), + ("exclude", PyTuple::empty(py).into_any()), + ("cli_vars", native_field(context.request, "cli_vars")?), + ], + )?), + )?; + let left: Bound<'_, PyAny> = pipeline.get_item(0)?; + let right: Bound<'_, PyAny> = pipeline.get_item(1)?; + let left_models = models_by_name(&left)?; + let bounded: Option = optional_text(&native_field(context.request, "bounded")?)?; + let limits: Bound<'_, 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))?, + ), + ], + )?; + for node in models { + let name: &str = &node.name; + let exists: bool = match left_models.get(name) { + Some(model) => target_exists(context, model)?, + None => false, + }; + if !exists { + out.by_node.insert( + name.to_owned(), + NodeComparison { + mode: "none".to_owned(), + complete: true, + ..NodeComparison::default() + }, + ); + continue; + } + if node.unique_key.is_empty() { + let facts: Option = run_one( + context, + (&left, &right), + name, + &[("schema_only", boolean(py, true))], + )?; + let Some(facts) = facts else { continue }; + let mut comparison: NodeComparison = evidence(&facts, "schema_only", &mut out); + 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 + { + out.findings.push(ReviewFinding { + node: name.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() + }); + } + out.incomplete.push(incomplete( + name, + "the model has no unique_key, so only the schema and row counts were compared" + .to_owned(), + )); + out.by_node.insert(name.to_owned(), comparison); + continue; + } + let full = run_one( + context, + (&left, &right), + name, + &[ + ("schema_only", boolean(py, false)), + ("full_size_limits", limits.clone()), + ], + ); + let (facts, mode) = match full { + Ok(facts) => (facts, "full"), + Err(error) if matches_class(py, &error, ExecutorHost::FullDiffSizeGuardError)? => { + match (&bounded, node.has_cursor) { + (Some(window), true) => { + let facts = run_one( + context, + (&left, &right), + name, + &[ + ("schema_only", boolean(py, false)), + ("bounded", text(py, window)), + ], + )?; + out.incomplete.push(IncompleteCheck { + node: name.to_owned(), + check: KEYED_CHECK.to_owned(), + reason: format!( + "too large for an exact comparison; compared the last {window} only" + ), + }); + (facts, "bounded") + } + _ => { + let hint: &str = if node.has_cursor { + "; pass --bounded to compare a recent window" + } else { + "" + }; + out.incomplete.push(incomplete( + name, + format!( + "too large for an exact comparison, or its size is unknown{hint}" + ), + )); + let facts = run_one( + context, + (&left, &right), + name, + &[("schema_only", boolean(py, true))], + )?; + (facts, "schema_only") + } + } + } + Err(error) if error.is_instance_of::(py) => { + out.incomplete + .push(incomplete(name, error_text(py, &error)?)); + out.by_node.insert( + name.to_owned(), + NodeComparison { + mode: "none".to_owned(), + target_relation: Some(String::new()), + ..NodeComparison::default() + }, + ); + continue; + } + Err(error) => return Err(error), + }; + if let Some(facts) = facts { + let comparison: NodeComparison = evidence(&facts, mode, &mut out); + out.by_node.insert(name.to_owned(), 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..24dfa2e2cc --- /dev/null +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/execution.rs @@ -0,0 +1,324 @@ +//! 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::prelude::{Bound, PyAny, PyAnyMethods, PyResult, Python}; +use serde_json::Value; +use sqlbuild_analysis::review_command::constants::SEED_KIND; +use sqlbuild_analysis::review_command::models::{IncrementalStart, ReviewGraph, ValidationCounts}; + +use crate::bindings::_helpers::build_command::command::run_build_command; +use crate::bindings::_helpers::clone::clone_command::run_clone_command; +use crate::bindings::_helpers::contracts::spec_resolution::resolve_target_config; +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::{ + boolean, quietly, review_error, text, texts, +}; +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. +pub(crate) struct RunInputs<'a, 'py> { + pub(crate) request: &'a Bound<'py, PyAny>, + pub(crate) project_dir: &'a Bound<'py, PyAny>, + pub(crate) review_discovered: &'a Bound<'py, PyAny>, + pub(crate) review_target: &'a str, + pub(crate) work_dir: &'a Path, + pub(crate) machine: bool, +} + +fn read_document(path: &Path) -> Option { + serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok() +} + +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> { + py.import("pathlib")? + .getattr("Path")? + .call1((path.to_path_buf(),)) +} + +/// 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".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", inputs.project_dir.clone()), + ("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(py, inputs.machine, || 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".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", inputs.project_dir.clone()), + ( + "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(py, inputs.machine, || 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", inputs.project_dir.clone()), + ( + "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(py, inputs.machine, || 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 {}", 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..8742c88947 --- /dev/null +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/impact.rs @@ -0,0 +1,107 @@ +//! The downstream columns a change reaches, from column lineage. + +use std::collections::{BTreeMap, BTreeSet}; + +use pyo3::prelude::{Bound, PyAny, PyAnyMethods, PyResult, Python}; +use pyo3::types::{PyDict, PyDictMethods, PyFrozenSet}; +use sqlbuild_analysis::review_command::models::NodeComparison; + +use crate::bindings::_helpers::compiled_views::{model_reads, project_reads}; +use crate::bindings::_helpers::records::construct::enum_member; +use crate::bindings::_helpers::records::fields::field as native_field; +use crate::bindings::_helpers::records::ids::{NativeAttr, RecordId}; +use crate::bindings::_helpers::records::registry; + +/// 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 == "none" { + 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: PyResult> = + registry::native(py, NativeAttr::BuildProjectColumnLineage)?.call1(( + project, + py.None(), + enum_member(py, RecordId::ColumnLineageMode, "FAST")?, + PyFrozenSet::new(py, &names)?, + )); + let Ok(lineage) = lineage else { + return Ok(BTreeMap::new()); + }; + if lineage.is_none() { + 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 options: Bound<'_, PyDict> = PyDict::new(py); + options.set_item("resource_name", &resource)?; + options.set_item("column_name", &column)?; + let Ok(edges) = lineage.call_method("column_consumers", (), Some(&options)) 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..68d42b85fd --- /dev/null +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/planning.rs @@ -0,0 +1,426 @@ +//! Resolve the review's settings, compile the project, and plan what it builds. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +use pyo3::prelude::{Bound, PyAny, PyAnyMethods, PyResult, Python}; +use pyo3::types::{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::{imported, keywords}; +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::planner_selection::selectors::select_project_graph; +use crate::bindings::_helpers::records::fields::field as native_field; +use crate::bindings::_helpers::review_command::support::{ + boolean, optional_text, review_error, strings, text, +}; + +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"; + +/// 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 compiled project, its changes since the base revision, and the graph a review plans over. +pub(crate) struct ReviewProject<'py> { + pub(crate) project_dir: Bound<'py, PyAny>, + pub(crate) discovered: Bound<'py, PyAny>, + pub(crate) adapter: Bound<'py, PyAny>, + pub(crate) graph: Bound<'py, PyAny>, + pub(crate) project: Bound<'py, PyAny>, + 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, +} + +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, + _ => imported(py, "pathlib", "Path")?.call_method0("cwd")?, + }; + 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> { + values.call_method1("get", (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(), + }) +} + +/// 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 buildable graph, each node's direct reads of sources and functions, and stateful models. +fn review_graph( + project: &Bound<'_, PyAny>, + critical_tags: &[String], +) -> PyResult<(ReviewGraph, BTreeMap>, BTreeSet)> { + let mut nodes: BTreeMap = BTreeMap::new(); + let mut downstream: BTreeMap> = BTreeMap::new(); + let mut reads: BTreeMap> = BTreeMap::new(); + let mut stateful: BTreeSet = BTreeSet::new(); + for seed in project_reads::seeds(project)?.try_iter()? { + let name: String = seed_reads::name(&seed?)?.extract()?; + 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 + { + stateful.insert(node.name.clone()); + } + dependencies.push((node.name.clone(), model_reads::dep_rows(&model)?)); + nodes.insert(node.name.clone(), node); + } + for (name, rows) in dependencies { + let mut read: Vec = Vec::new(); + for (kind, upstream) in rows { + if (kind == MODEL_KIND || kind == SEED_KIND) && nodes.contains_key(&upstream) { + downstream.entry(upstream).or_default().push(name.clone()); + } else if kind == SOURCE_KIND || kind == UDF_KIND || kind == TABLE_FUNCTION_KIND { + read.push(node_key(&kind, &upstream)); + } + } + reads.insert(name, read); + } + for children in downstream.values_mut() { + children.sort(); + children.dedup(); + } + let names: BTreeSet = nodes.keys().cloned().collect(); + let order: Vec = topological(&names, &downstream); + Ok(( + ReviewGraph { + nodes, + downstream, + order, + }, + reads, + stateful, + )) +} + +/// 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> { + 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 (review_graph, reads, stateful) = review_graph(&project, &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: String = node_key(&node.kind, 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, + }); + } + } + let changes_json: Option = serde_json::from_str(&changed.json((0, &[]))).ok(); + Ok(ReviewProject { + changed_declarations: changed_declarations(&changed.changes), + reference: changed.reference.clone(), + merge_base: changes_json + .as_ref() + .and_then(|json| json.get("merge_base")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + changes_json, + project_dir, + discovered, + adapter, + graph, + project, + review_graph, + changed: changed_nodes, + stateful, + }) +} + +/// 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..8cea0b2c4d --- /dev/null +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/registration.rs @@ -0,0 +1,29 @@ +//! Register `sqb review` and the review schema name parser the janitor uses. + +use pyo3::prelude::{Bound, PyAny, PyAnyMethods, 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::boundary::python_calls::imported; +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>> { + let Some((year, month, day, hour, minute, second)) = parse_name(name) else { + return Ok(None); + }; + let utc: Bound<'py, PyAny> = imported(py, "datetime", "timezone")?.getattr("utc")?; + let created = imported(py, "datetime", "datetime")? + .call1((year, month, day, hour, minute, second, 0, utc)); + Ok(created.ok()) +} + +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..b5478de2f6 --- /dev/null +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/schema.rs @@ -0,0 +1,200 @@ +//! 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::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::command_runtime::connection::resolve_target_connection_config; +use crate::bindings::_helpers::contracts::spec_resolution::resolve_target_config; +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::ReviewSettings; +use crate::bindings::_helpers::review_command::support::{ + boolean, compact_timestamp, none, optional_text, text, +}; + +/// Where the review builds and where its ownership marker goes. +pub(crate) struct ReviewSchema<'py> { + pub(crate) name: String, + pub(crate) database: Option, + marker_database: Option, + marker_schema: String, + run_id: String, + created_at: Bound<'py, PyAny>, +} + +/// The review schema of this run: `___` in the active target's database. +pub(crate) fn review_schema<'py>( + (project, discovered): (&Bound<'py, PyAny>, &Bound<'py, PyAny>), + settings: &ReviewSettings, + (created_at, head, run_id): (Bound<'py, PyAny>, &str, &str), +) -> PyResult> { + let py: Python<'py> = project.py(); + let target: Bound<'py, PyAny> = active_target_config(discovered, &settings.target)?; + let marker_schema: Option = + optional_text(&native_field(project, "effective_target_schema")?)?; + let Some(marker_schema) = marker_schema else { + return Err( + crate::bindings::_helpers::review_command::support::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"), + ), + ); + }; + Ok(ReviewSchema { + name: review_schema_name( + &settings.schema_prefix, + &compact_timestamp(&created_at)?, + head, + run_id, + ), + database: optional_text(&native_field(&target, "database")?)?, + marker_database: optional_text(&native_field(project, "effective_target_database")?)?, + marker_schema, + run_id: run_id.to_owned(), + created_at, + }) +} + +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` with a target named after the review schema: the active target's connection and +/// policies, writing only to the review schema and reading managed sources like `--against`. +pub(crate) fn with_review_target<'py>( + discovered: &Bound<'py, PyAny>, + settings: &ReviewSettings, + schema: &ReviewSchema<'py>, +) -> 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 review: Bound<'py, PyAny> = replaced( + &active, + &[ + ("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")?, + ), + ], + )?; + 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")?), + ], + )?), + ) +} + +fn lifecycle(py: Python<'_>) -> PyResult> { + host::entry(py, ExecutorHost::ReviewSchemaLifecycle) +} + +/// Record ownership in the active target's state table, then create the review schema. +pub(crate) fn create_review_schema<'py>( + (adapter, connection): (&Bound<'py, PyAny>, &Bound<'py, PyAny>), + schema: &ReviewSchema<'py>, +) -> PyResult<()> { + let py: Python<'py> = adapter.py(); + let marker: Bound<'py, PyAny> = call_with( + &host::entry(py, ExecutorHost::ReviewSchemaMarker)?, + &[ + ("database", optional(py, schema.database.as_deref())), + ("schema", text(py, &schema.name)), + ( + "marker_database", + optional(py, schema.marker_database.as_deref()), + ), + ("marker_schema", text(py, &schema.marker_schema)), + ("run_id", text(py, &schema.run_id)), + ], + )?; + call_with( + &lifecycle(py)?.getattr("create")?, + &[ + ("adapter", adapter.clone()), + ("connection", connection.clone()), + ("marker", marker), + ("created_at", schema.created_at.clone()), + ], + )?; + Ok(()) +} + +/// Drop the review schema and confirm it is gone. +pub(crate) fn drop_review_schema( + (adapter, connection): (&Bound<'_, PyAny>, &Bound<'_, PyAny>), + schema: &ReviewSchema<'_>, +) -> PyResult { + let py: Python<'_> = adapter.py(); + let location = [ + ("adapter", adapter.clone()), + ("connection", connection.clone()), + ("database", optional(py, schema.database.as_deref())), + ("schema", text(py, &schema.name)), + ]; + call_with(&lifecycle(py)?.getattr("drop")?, &location)?; + Ok(!call_with(&lifecycle(py)?.getattr("exists")?, &location)?.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..5f9e213bd1 --- /dev/null +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/support.rs @@ -0,0 +1,179 @@ +//! Python values, 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::{PyBool, PyDict, PyDictMethods, PyString, PyTuple, PyTypeMethods}; +use sqlbuild_analysis::review_command::models::{ReviewError, ReviewPlanError}; + +use crate::bindings::_helpers::boundary::python_calls::imported; +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"; + +pub(crate) fn text<'py>(py: Python<'py>, value: &str) -> Bound<'py, PyAny> { + PyString::new(py, value).into_any() +} + +pub(crate) fn boolean(py: Python<'_>, value: bool) -> Bound<'_, PyAny> { + PyBool::new(py, value).to_owned().into_any() +} + +pub(crate) fn none(py: Python<'_>) -> Bound<'_, PyAny> { + py.None().into_bound(py) +} + +pub(crate) fn texts<'py>(py: Python<'py>, values: &[String]) -> PyResult> { + Ok(PyTuple::new(py, values)?.into_any()) +} + +pub(crate) fn strings(value: &Bound<'_, PyAny>) -> PyResult> { + if value.is_none() { + return Ok(Vec::new()); + } + value + .try_iter()? + .map(|item| item?.str()?.extract()) + .collect() +} + +pub(crate) fn optional_text(value: &Bound<'_, PyAny>) -> PyResult> { + if value.is_none() { + return Ok(None); + } + Ok(Some(value.str()?.extract()?)) +} + +/// `CliUserError(message, code=code, help=help)`. +pub(crate) fn review_error(py: Python<'_>, code: &str, message: &str, help: Option<&str>) -> PyErr { + let raised = (|| -> PyResult> { + let keywords: Bound<'_, PyDict> = PyDict::new(py); + keywords.set_item("code", code)?; + keywords.set_item("help", help)?; + error_class(py, ErrorId::CliUser)?.call((message,), Some(&keywords)) + })(); + match raised { + Ok(error) => PyErr::from_value(error), + Err(error) => error, + } +} + +pub(crate) fn plan_error(py: Python<'_>, error: &ReviewPlanError) -> PyErr { + review_error(py, &error.code, &error.message, error.help.as_deref()) +} + +/// The code, message and help of `error`, as the report records it. +pub(crate) fn error_parts(py: Python<'_>, error: &PyErr) -> ReviewError { + let value: Bound<'_, PyAny> = error.value(py).clone().into_any(); + let attribute = |name: &str| -> Option { + value + .getattr_opt(name) + .ok() + .flatten() + .filter(|item| !item.is_none()) + .and_then(|item| item.str().ok()) + .map(|item| item.to_string()) + }; + let message: String = attribute("message").unwrap_or_else(|| { + value + .str() + .map_or_else(|_| "review failed".to_owned(), |item| item.to_string()) + }); + let message: String = if message.is_empty() { + value + .get_type() + .name() + .map_or_else(|_| "review failed".to_owned(), |name| name.to_string()) + } else { + message + }; + ReviewError { + code: attribute("code").unwrap_or_else(|| UNEXPECTED_CODE.to_owned()), + message, + help: attribute("help"), + } +} + +/// `ReviewFailedError` carrying the reported error, chained to `cause`. +pub(crate) fn failed_error(py: Python<'_>, error: &ReviewError, cause: Option) -> PyErr { + let raised = (|| -> PyResult> { + let keywords: Bound<'_, PyDict> = PyDict::new(py); + keywords.set_item("code", error.code.as_str())?; + keywords.set_item("help", error.help.as_deref())?; + error_class(py, ErrorId::ReviewFailed)?.call((error.message.as_str(),), Some(&keywords)) + })(); + let replacement: PyErr = match raised { + Ok(instance) => PyErr::from_value(instance), + Err(failure) => failure, + }; + replacement.set_cause(py, cause); + replacement +} + +/// Write `line` and a newline to `stream`, then flush. +pub(crate) fn emit(stream: &Bound<'_, PyAny>, line: &str) -> PyResult<()> { + stream.call_method1("write", (format!("{line}\n"),))?; + stream.call_method0("flush")?; + Ok(()) +} + +/// 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 { + emit(stream, &format!("{start}..."))?; + let started: Instant = Instant::now(); + let outcome: PyResult = body(); + let elapsed: f64 = started.elapsed().as_secs_f64(); + match &outcome { + Ok(_) => emit(stream, &format!("{done} ({elapsed:.2}s)"))?, + Err(_) => emit(stream, &format!("{failed} ({elapsed:.2}s)"))?, + } + outcome +} + +/// Run `body` with `sys.stdout` sent to `sys.stderr` when stdout must stay machine-readable. +pub(crate) fn quietly( + py: Python<'_>, + machine: bool, + body: impl FnOnce() -> PyResult, +) -> PyResult { + if !machine { + return body(); + } + let stderr: Bound<'_, PyAny> = imported(py, "sys", "stderr")?; + let manager: Bound<'_, PyAny> = + imported(py, "contextlib", "redirect_stdout")?.call1((stderr,))?; + crate::bindings::_helpers::boundary::python_calls::within(&manager, |_| body()) +} + +/// `datetime.now(timezone.utc)`. +pub(crate) fn utc_now(py: Python<'_>) -> PyResult> { + let utc: Bound<'_, PyAny> = imported(py, "datetime", "timezone")?.getattr("utc")?; + imported(py, "datetime", "datetime")?.call_method1("now", (utc,)) +} + +/// `YYYYMMDDTHHMMSSZ` of an aware UTC datetime. +pub(crate) fn compact_timestamp(moment: &Bound<'_, PyAny>) -> PyResult { + moment + .call_method1("strftime", ("%Y%m%dT%H%M%SZ",))? + .extract() +} + +/// The current invocation id, when an invocation identity is active. +pub(crate) fn invocation_id(py: Python<'_>) -> PyResult> { + let identity: Bound<'_, PyAny> = crate::bindings::_helpers::native_host::host::entry( + py, + sqlbuild_host::entries::models::ExecutorHost::CurrentExecutionIdentity, + )? + .call0()?; + if identity.is_none() { + return Ok(None); + } + optional_text(&native_field(&identity, "invocation_id")?) +} diff --git a/fensu.toml b/fensu.toml index 26113efe6e..83a1e8f851 100644 --- a/fensu.toml +++ b/fensu.toml @@ -397,6 +397,16 @@ 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.classes.review_schema_lifecycle"] +symbols = ["ReviewSchemaLifecycle"] +reason = "Native code resolves these through the sqlbuild._native_host name table." + +[[targets.python.dead_code.roots]] +modules = ["sqlbuild.executor.review.models"] +symbols = ["ReviewSchemaMarker"] +reason = "Native code resolves these through the sqlbuild._native_host name table." + [[targets.python.dead_code.roots]] modules = ["sqlbuild.executor.diff.exceptions"] symbols = ["FullDiffSizeGuardError"] 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..5ea24bd25c 100644 --- a/src/sqlbuild/_native_host.py +++ b/src/sqlbuild/_native_host.py @@ -360,6 +360,11 @@ "sqlbuild.executor.diff.classes.query_artifact_lifecycle", "QueryDiffArtifactLifecycle", ), + "ReviewSchemaLifecycle": ( + "sqlbuild.executor.review.classes.review_schema_lifecycle", + "ReviewSchemaLifecycle", + ), + "ReviewSchemaMarker": ("sqlbuild.executor.review.models", "ReviewSchemaMarker"), "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..583f7325d4 100644 --- a/src/sqlbuild/_native_host.pyi +++ b/src/sqlbuild/_native_host.pyi @@ -334,6 +334,10 @@ 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.classes.review_schema_lifecycle import ( + ReviewSchemaLifecycle as ReviewSchemaLifecycle, +) +from sqlbuild.executor.review.models import ReviewSchemaMarker as ReviewSchemaMarker 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/base_adapter.py b/src/sqlbuild/adapter/contract/classes/base_adapter.py index 97d8f9b5b4..e27427503f 100644 --- a/src/sqlbuild/adapter/contract/classes/base_adapter.py +++ b/src/sqlbuild/adapter/contract/classes/base_adapter.py @@ -608,6 +608,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/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..83740b0798 --- /dev/null +++ b/src/sqlbuild/executor/review/classes/review_schema_lifecycle.py @@ -0,0 +1,79 @@ +"""Public review schema lifecycle service.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +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 marked_review_schemas, write_review_marker +from sqlbuild.executor.review.models import LeftoverReviewSchema, ReviewSchemaMarker + + +class ReviewSchemaLifecycle: + """Create, drop and find 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) + + @staticmethod + def leftovers( + *, + 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/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/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..96ccdf8e71 --- /dev/null +++ b/tests/e2e/src/sqlbuild/cli/commands/main/review/_test_types.py @@ -0,0 +1,44 @@ +"""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 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..5efd232d1e --- /dev/null +++ b/tests/e2e/src/sqlbuild/cli/commands/main/review/helpers.py @@ -0,0 +1,223 @@ +"""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" +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" + + [defaults] + materialized = "table" + + [git] + base_ref = "main" + + [review] + against = "prod" + + [janitor] + enabled = true + """ + ).lstrip(), + ".gitignore": "target/\n*.duckdb\n*.duckdb.wal\nreview.md\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 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" + ).fetchall() + finally: + connection.close() + return tuple( + sorted(str(row[0]) for row in rows if str(row[0]).startswith(REVIEW_SCHEMA_PREFIX)) + ) + + +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..1bb98b8bc7 --- /dev/null +++ b/tests/e2e/src/sqlbuild/cli/commands/main/review/test_review.py @@ -0,0 +1,338 @@ +"""`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 ( + ReviewOutcomeE2ETestCase, + ReviewSelectErrorE2ETestCase, + ReviewTextOutputE2ETestCase, +) +from tests.e2e.src.sqlbuild.cli.commands.main.review.helpers import ( + CUSTOMER_ORDERS, + ORDER_TOTALS, + STAGING, + customer_orders_model, + edit_project, + edited_order_totals, + edited_staging, + execute_review_database, + node_statuses, + 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" +) + + +@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 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 test_case: test_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) == () + + +def test_given_incremental_downstream_when_reviewing_then_its_start_is_reported( + 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", "--downstream", "all") + ) + + assert result.returncode == 0, result.stdout + result.stderr + starts: list[dict[str, Any]] = review_document(result)["baseline"]["incremental_starts"] + assert [(start["node"], start["method"]) for start in starts] == [ + ("order_ledger", "full_refresh") + ], result.stdout + assert starts[0]["reason"], 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 test_case: test_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) == () + + +def test_given_select_inside_changed_set_when_reviewing_then_only_selection_is_validated( + 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", "stg_orders") + ) + + assert result.returncode == 0, result.stdout + result.stderr + document: dict[str, Any] = review_document(result) + assert document["validated"] == ["stg_orders"] + assert "order_totals" in {item["name"] for item in document["not_validated"]} + + +def test_given_dry_run_when_reviewing_then_nothing_is_built_or_created(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", "--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"] == "planned" + assert document["review_schema"] is None + assert sorted(document["validated"]) == ["order_totals", "stg_orders"] + assert set(node_statuses(document).values()) <= {"planned", "not_validated"} + 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 test_case: test_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 + + +def test_given_failing_schema_drop_when_reviewing_then_it_exits_3_and_janitor_cleans_old_schema( + 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 == 3, 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 = "REVIEW_20200101T000000Z_0000000_000000" + 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/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..c8a955dc4b --- /dev/null +++ b/website/src/content/docs/docs/cli/review.mdx @@ -0,0 +1,152 @@ +--- +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 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. + +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. 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 | `C763` | + +## The review schema + +A review builds into a schema named `___`, for example +`REVIEW_20261023T101500Z_3f9c2ab_4e1d0a`. 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. 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. + +## 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 could not run, such as +the keyed diff of a model without a `unique_key`, is reported as **incomplete coverage**. A model +that does not exist in `--against` yet is built but not compared, and the Baseline section says so. + +## 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 | + +A failure takes precedence over findings, and findings over incomplete coverage. + +## 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..f32abf27bc 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; `BaseAdapter` renders `DROP SCHEMA IF EXISTS ... CASCADE`. ### 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..3a89609dd4 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 | +| `critical_tags` | `["critical"]` | Model tags that make a model critical, in addition to `critical true` in its `MODEL` header | ## dbt From 401cad089d8cf64553867b8e4848033da34a7c9f Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:03:32 +0100 Subject: [PATCH 2/7] docs: list sqb review on the docs home page --- .../src/project/_helpers/sections.rs | 7 +- .../sqlbuild/references/docs/CONTENTS.md | 1 + .../sqlbuild/references/docs/cli/janitor.md | 7 +- .../sqlbuild/references/docs/cli/review.md | 165 ++++++++++++++++++ .../references/docs/concepts/adapters.md | 2 +- .../docs/concepts/models/configuration.md | 1 + .../docs/concepts/project-configuration.md | 22 ++- .../skills/sqlbuild/references/docs/index.md | 1 + website/src/content/docs/docs/index.mdx | 1 + 9 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/review.md diff --git a/crates/sqlbuild-config/src/project/_helpers/sections.rs b/crates/sqlbuild-config/src/project/_helpers/sections.rs index 85f54be00a..9ae283bb04 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, 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, + 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::{ 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..9aa3844d19 --- /dev/null +++ b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/review.md @@ -0,0 +1,165 @@ + + +# 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 +- 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 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. + +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. 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 | `C763` | + +## The review schema + +A review builds into a schema named `___`, for example +`REVIEW_20261023T101500Z_3f9c2ab_4e1d0a`. 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. 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. + +## 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 could not run, such as +the keyed diff of a model without a `unique_key`, is reported as **incomplete coverage**. A model +that does not exist in `--against` yet is built but not compared, and the Baseline section says so. + +## 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 | + +A failure takes precedence over findings, and findings over incomplete coverage. + +## 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..38c56b95f6 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; `BaseAdapter` renders `DROP SCHEMA IF EXISTS ... CASCADE`. ### 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..632fe555dc 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 | +| `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/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, From efb7bebbb12a93e6e1ecca288179df259e3a4473 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:45:35 +0100 Subject: [PATCH 3/7] fix: keep render_drop_schema optional on StrictAdapter only --- .../skills/sqlbuild/references/docs/concepts/adapters.md | 2 +- src/sqlbuild/adapter/contract/classes/base_adapter.py | 9 --------- .../e2e/src/sqlbuild/cli/commands/main/review/helpers.py | 2 +- .../classes/strict_adapter/test_strict_adapter.py | 5 +++++ website/src/content/docs/docs/concepts/adapters.mdx | 2 +- 5 files changed, 8 insertions(+), 12 deletions(-) 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 38c56b95f6..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. `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; `BaseAdapter` renders `DROP SCHEMA IF EXISTS ... CASCADE`. +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/adapter/contract/classes/base_adapter.py b/src/sqlbuild/adapter/contract/classes/base_adapter.py index e27427503f..97d8f9b5b4 100644 --- a/src/sqlbuild/adapter/contract/classes/base_adapter.py +++ b/src/sqlbuild/adapter/contract/classes/base_adapter.py @@ -608,15 +608,6 @@ 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/tests/e2e/src/sqlbuild/cli/commands/main/review/helpers.py b/tests/e2e/src/sqlbuild/cli/commands/main/review/helpers.py index 5efd232d1e..5e91f53b2d 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/review/helpers.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/review/helpers.py @@ -57,7 +57,7 @@ def build_review_project_files() -> dict[str, str]: enabled = true """ ).lstrip(), - ".gitignore": "target/\n*.duckdb\n*.duckdb.wal\nreview.md\n", + ".gitignore": "target/\nlogs/\n*.duckdb\n*.duckdb.wal\n", "sources/raw.yml": dedent( """ sources: 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/website/src/content/docs/docs/concepts/adapters.mdx b/website/src/content/docs/docs/concepts/adapters.mdx index f32abf27bc..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. `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; `BaseAdapter` renders `DROP SCHEMA IF EXISTS ... CASCADE`. +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 From 9124b64b44e821c7c8094f5921c89efb81af2549 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:13:57 +0100 Subject: [PATCH 4/7] refactor: meet the repository's layout, boundary and test-style rules in sqb review --- Makefile | 3 +- .../src/definition_changes/main/kind_label.rs | 9 + .../src/definition_changes/main/mod.rs | 1 + .../src/definition_changes/mod.rs | 2 +- .../src/review_command/_helpers/json.rs | 106 ++++ .../src/review_command/_helpers/markdown.rs | 177 ++++++ .../src/review_command/_helpers/mod.rs | 4 + .../src/review_command/_helpers/scope.rs | 100 +++ .../src/review_command/_helpers/sections.rs | 38 +- .../src/review_command/_helpers/text.rs | 45 ++ .../src/review_command/_helpers/wording.rs | 14 +- .../src/review_command/constants.rs | 26 + .../src/review_command/errors.rs | 9 + .../main/changed_declarations.rs | 4 +- .../main/downstream_policy_name.rs | 10 + .../src/review_command/main/mod.rs | 1 + .../main/node_change_reasons.rs | 3 +- .../main/parse_review_schema_name.rs | 49 +- .../src/review_command/main/plan_review.rs | 99 +-- .../review_command/main/render_review_json.rs | 107 +--- .../main/render_review_markdown.rs | 211 ++----- .../review_command/main/render_review_text.rs | 78 +-- .../src/review_command/mod.rs | 1 + .../src/review_command/models.rs | 14 +- .../src/review_command/tests/helpers.rs | 49 +- .../review_command/tests/test_plan_review.rs | 84 ++- .../tests/test_render_review.rs | 201 +++--- .../src/review_command/tests/test_types.rs | 27 +- .../src/project/_helpers/sections.rs | 21 +- .../src/project/tests/test_load_config.rs | 60 +- .../src/project/tests/test_types.rs | 8 +- .../src/git_revisions/main/head_commit.rs | 12 +- .../src/entries/_helpers/executor.rs | 5 + crates/sqlbuild-host/src/entries/models.rs | 1 + .../bindings/_helpers/janitor/janitor_plan.rs | 19 +- .../_helpers/janitor/janitor_planning.rs | 5 +- .../_helpers/review_command/command.rs | 570 +++++++++++------- .../_helpers/review_command/comparison.rs | 526 +++++++++------- .../_helpers/review_command/execution.rs | 32 +- .../_helpers/review_command/impact.rs | 3 +- .../_helpers/review_command/planning.rs | 48 +- .../_helpers/review_command/registration.rs | 14 +- .../_helpers/review_command/schema.rs | 17 +- .../_helpers/review_command/support.rs | 191 +++--- fensu.toml | 19 +- src/sqlbuild/_native_host.py | 4 + src/sqlbuild/_native_host.pyi | 3 + .../review/classes/review_schema_lifecycle.py | 31 +- src/sqlbuild/executor/review/main/__init__.py | 1 + .../review/main/leftover_review_schemas.py | 33 + .../cli/commands/main/review/_test_types.py | 38 ++ .../cli/commands/main/review/helpers.py | 8 +- .../cli/commands/main/review/test_review.py | 99 ++- 53 files changed, 1963 insertions(+), 1277 deletions(-) create mode 100644 crates/sqlbuild-analysis/src/definition_changes/main/kind_label.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/_helpers/json.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/_helpers/markdown.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/_helpers/scope.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/_helpers/text.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/errors.rs create mode 100644 crates/sqlbuild-analysis/src/review_command/main/downstream_policy_name.rs create mode 100644 src/sqlbuild/executor/review/main/__init__.py create mode 100644 src/sqlbuild/executor/review/main/leftover_review_schemas.py 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/definition_changes/mod.rs b/crates/sqlbuild-analysis/src/definition_changes/mod.rs index 153271c423..b4c56eaa3e 100644 --- a/crates/sqlbuild-analysis/src/definition_changes/mod.rs +++ b/crates/sqlbuild-analysis/src/definition_changes/mod.rs @@ -1,6 +1,6 @@ //! Definition fingerprints of project nodes, their changes, and why tests that run them are selected. -pub(crate) mod _helpers; +mod _helpers; pub mod constants; pub mod main; pub mod models; 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 index ca5a26070a..24689f1b13 100644 --- a/crates/sqlbuild-analysis/src/review_command/_helpers/mod.rs +++ b/crates/sqlbuild-analysis/src/review_command/_helpers/mod.rs @@ -1,4 +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/scope.rs b/crates/sqlbuild-analysis/src/review_command/_helpers/scope.rs new file mode 100644 index 0000000000..f7aaca68d1 --- /dev/null +++ b/crates/sqlbuild-analysis/src/review_command/_helpers/scope.rs @@ -0,0 +1,100 @@ +//! The scope a downstream policy plans, and the checks on `--select` tokens. + +use std::collections::BTreeSet; + +use crate::review_command::_helpers::graph::ancestors_within; +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::{DownstreamPolicy, ReviewGraph, SelectedToken}; + +fn selection_error(code: &str, message: String, help: &str) -> ReviewPlanError { + ReviewPlanError { + code: code.to_owned(), + message, + help: Some(help.to_owned()), + } +} + +/// The downstream nodes the policy validates. +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() + } + } +} + +/// Reject a token that matches nothing, only unchanged nodes, or only nodes outside the policy. +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", + token.token, + policy_name(policy) + ), + "pass --downstream all to review every downstream 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( + name: &str, + (changed, scope): (&BTreeSet, &BTreeSet), + policy: DownstreamPolicy, +) -> String { + 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 index 31d4c82219..0624978bf4 100644 --- a/crates/sqlbuild-analysis/src/review_command/_helpers/sections.rs +++ b/crates/sqlbuild-analysis/src/review_command/_helpers/sections.rs @@ -1,5 +1,6 @@ //! 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; @@ -80,7 +81,7 @@ pub(crate) fn compared_count(report: &ReviewReport) -> usize { .filter(|node| { node.comparison .as_ref() - .is_some_and(|comparison| comparison.mode != "none") + .is_some_and(|comparison| comparison.mode != NO_COMPARISON_MODE) }) .count() } @@ -173,6 +174,14 @@ fn node_line(node: &NodeReport, style: Style) -> String { 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 @@ -209,9 +218,19 @@ pub(crate) fn impact_tree(report: &ReviewReport, style: Style) -> Vec { if shown.contains(root.name.as_str()) { continue; } - let mut stack: Vec<(&str, String, bool, bool)> = - vec![(&root.name, String::new(), true, true)]; - while let Some((name, prefix, last, is_root)) = stack.pop() { + 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; }; @@ -239,7 +258,12 @@ pub(crate) fn impact_tree(report: &ReviewReport, style: Style) -> Vec { }; let next: &[&str] = children.get(name).map_or(&[], Vec::as_slice); for (index, child) in next.iter().enumerate().rev() { - stack.push((child, child_prefix.clone(), index + 1 == next.len(), false)); + stack.push(TreeEntry { + name: child, + prefix: child_prefix.clone(), + last: index + 1 == next.len(), + is_root: false, + }); } } } @@ -299,7 +323,7 @@ pub(crate) fn baseline_lines(report: &ReviewReport, style: Style) -> Vec )); } for start in &report.incremental_starts { - let method: &str = if start.method == "clone" { + let method: &str = if start.method == CLONE_START { "cloned from the target" } else { "full refresh" @@ -315,7 +339,7 @@ pub(crate) fn baseline_lines(report: &ReviewReport, style: Style) -> Vec .iter() .filter(|node| { node.comparison.as_ref().is_some_and(|comparison| { - comparison.mode == "none" && comparison.target_relation.is_none() + comparison.mode == NO_COMPARISON_MODE && comparison.target_relation.is_none() }) }) .map(|node| style.code(&node.name)) 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 index c4cd47d25a..05f67bed49 100644 --- a/crates/sqlbuild-analysis/src/review_command/_helpers/wording.rs +++ b/crates/sqlbuild-analysis/src/review_command/_helpers/wording.rs @@ -1,6 +1,8 @@ //! Words the review renders for policies, statuses and counts. -use crate::review_command::constants::{ALL_POLICY, CRITICAL_POLICY, NONE_POLICY}; +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 { @@ -22,11 +24,15 @@ pub(crate) fn counted(count: usize, singular: &str, plural: &str) -> String { /// `3.25s` or `2m 05s`. pub(crate) fn duration_text(seconds: f64) -> String { - if seconds < 60.0 { + let whole: u64 = seconds.round() as u64; + if whole < SECONDS_PER_MINUTE { return format!("{seconds:.2}s"); } - let whole: u64 = seconds.round() as u64; - format!("{}m {:02}s", whole / 60, whole % 60) + format!( + "{}m {:02}s", + whole / SECONDS_PER_MINUTE, + whole % SECONDS_PER_MINUTE + ) } /// `a`, `a and b`, `a, b and c`. diff --git a/crates/sqlbuild-analysis/src/review_command/constants.rs b/crates/sqlbuild-analysis/src/review_command/constants.rs index 4c9ccea13f..dadd1472d3 100644 --- a/crates/sqlbuild-analysis/src/review_command/constants.rs +++ b/crates/sqlbuild-analysis/src/review_command/constants.rs @@ -21,6 +21,19 @@ 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"; @@ -46,9 +59,22 @@ 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 seconds. pub const LEFTOVER_REVIEW_SCHEMA_AGE_SECONDS: i64 = 24 * 60 * 60; +/// 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. 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 index 8aab2f6bf6..a1487d4132 100644 --- a/crates/sqlbuild-analysis/src/review_command/main/changed_declarations.rs +++ b/crates/sqlbuild-analysis/src/review_command/main/changed_declarations.rs @@ -1,6 +1,6 @@ //! The changed nodes a review does not build, such as macros, sources and settings. -use crate::definition_changes::_helpers::aspects::kind_words; +use crate::definition_changes::main::kind_label::kind_label; use crate::definition_changes::models::DefinitionChanges; use crate::review_command::constants::{MODEL_KIND, SEED_KIND}; @@ -18,7 +18,7 @@ pub fn changed_declarations(changes: &DefinitionChanges) -> Vec<(String, String, "changed".to_owned() }; ( - kind_words(&change.kind).0.to_owned(), + kind_label(&change.kind).to_owned(), change.name.clone(), detail, ) 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 index 0bf5fa97bf..25f16188a8 100644 --- a/crates/sqlbuild-analysis/src/review_command/main/mod.rs +++ b/crates/sqlbuild-analysis/src/review_command/main/mod.rs @@ -1,4 +1,5 @@ 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; 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 index c9a616dafe..371afb342d 100644 --- a/crates/sqlbuild-analysis/src/review_command/main/node_change_reasons.rs +++ b/crates/sqlbuild-analysis/src/review_command/main/node_change_reasons.rs @@ -3,8 +3,7 @@ use crate::definition_changes::main::selection_reasons::selection_reasons; use crate::definition_changes::models::{DefinitionChanges, DefinitionSnapshot, ExecutedItem}; -/// The reasons `key` changed: its own definition, a declaration it uses, or a source or function -/// it reads; empty when nothing it depends on directly changed. +/// 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, 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 index 321769305b..c6b3655b68 100644 --- 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 @@ -1,16 +1,30 @@ //! The strict review schema name format. +use crate::janitor_planning::models::ArchiveTimestamp; use crate::review_command::constants::{ - SCHEMA_COMMIT_DIGITS, SCHEMA_RUN_DIGITS, SCHEMA_TIMESTAMP_LENGTH, + 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<(i32, u32, u32, u32, u32, u32)> { +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()?; @@ -24,23 +38,18 @@ pub fn parse_review_schema_name(name: &str) -> Option<(i32, u32, u32, u32, u32, if bytes.len() != SCHEMA_TIMESTAMP_LENGTH || bytes[8] != b'T' || bytes[15] != b'Z' { return None; } - let digits = |range: std::ops::Range| -> Option { - let text: &str = upper.get(range)?; - if !text.bytes().all(|byte| byte.is_ascii_digit()) { - return None; - } - text.parse().ok() + 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 year: i32 = i32::try_from(digits(0..4)?).ok()?; - let (month, day) = (digits(4..6)?, digits(6..8)?); - let (hour, minute, second) = (digits(9..11)?, digits(11..13)?, digits(13..15)?); - if !(1..=12).contains(&month) - || !(1..=31).contains(&day) - || hour > 23 - || minute > 59 - || second > 59 - { - return None; - } - Some((year, month, day, hour, minute, second)) + 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 index 81f4b7ef21..45118f8244 100644 --- a/crates/sqlbuild-analysis/src/review_command/main/plan_review.rs +++ b/crates/sqlbuild-analysis/src/review_command/main/plan_review.rs @@ -2,104 +2,15 @@ use std::collections::BTreeSet; -use crate::review_command::_helpers::graph::{ancestors_within, descendants}; -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::_helpers::graph::descendants; +use crate::review_command::_helpers::scope::{ + check_token, not_validated_reason, policy_downstream, }; +use crate::review_command::errors::ReviewPlanError; use crate::review_command::models::{ - ChangedNode, DownstreamPolicy, NotValidated, ReviewGraph, ReviewPlan, ReviewPlanError, - SelectedToken, + ChangedNode, DownstreamPolicy, NotValidated, ReviewGraph, ReviewPlan, SelectedToken, }; -fn selection_error(code: &str, message: String, help: &str) -> ReviewPlanError { - ReviewPlanError { - code: code.to_owned(), - message, - help: Some(help.to_owned()), - } -} - -/// The downstream nodes the policy validates. -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() - } - } -} - -/// Reject a token that matches nothing, only unchanged nodes, or only nodes outside the policy. -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", - token.token, - policy_name(policy) - ), - "pass --downstream all to review every downstream 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", - )) -} - -fn not_validated_reason( - name: &str, - (changed, scope): (&BTreeSet, &BTreeSet), - policy: DownstreamPolicy, -) -> String { - 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() - } - } -} - /// Plan a review of `changed` over `graph`; `select` tokens intersect with the planned scope. pub fn plan_review( graph: &ReviewGraph, 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 index d3ad839ef0..5307baf70f 100644 --- a/crates/sqlbuild-analysis/src/review_command/main/render_review_json.rs +++ b/crates/sqlbuild-analysis/src/review_command/main/render_review_json.rs @@ -2,112 +2,11 @@ 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::{ - FINDING_NODE, NOT_VALIDATED_NODE, REVIEW_REPORT_FORMAT_VERSION, -}; -use crate::review_command::models::{ - NodeComparison, NodeReport, ReviewFinding, ReviewReport, ValidationCounts, -}; - -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, - }) -} - -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, - }) -} - -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::>(), - }) -} - -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, - }) -} - -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 != "none") - }) - .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(), - }) -} +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] 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 index 988eca6e92..5c49f1304d 100644 --- a/crates/sqlbuild-analysis/src/review_command/main/render_review_markdown.rs +++ b/crates/sqlbuild-analysis/src/review_command/main/render_review_markdown.rs @@ -1,186 +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::{ - Style, baseline_lines, changed_lines, context_line, example_lines, finding_text, has_evidence, - headline, impact_tree, incomplete_lines, run_lines, summary_counts, tests_lines, + baseline_lines, changed_lines, context_line, has_evidence, headline, incomplete_lines, + run_lines, tests_lines, }; -use crate::review_command::_helpers::wording::counted; -use crate::review_command::models::{NodeComparison, NodeReport, ReviewReport}; - -const STYLE: Style = Style { markdown: true }; - -fn cell(text: &str) -> String { - text.replace('|', "\\|").replace('\n', " ") -} - -fn bullets(out: &mut String, lines: &[String]) { - for line in lines { - out.push_str(&format!("- {line}\n")); - } -} - -fn section(out: &mut String, title: &str, lines: &[String], empty: &str) { - out.push_str(&format!("\n## {title}\n\n")); - if lines.is_empty() { - out.push_str(&format!("{empty}\n")); - } else { - bullets(out, lines); - } -} - -fn evidence_rows(comparison: &NodeComparison) -> Vec<[String; 5]> { - let mut rows: Vec<[String; 5]> = Vec::new(); - for column in &comparison.added_columns { - rows.push([ - column.clone(), - "column".into(), - "absent".into(), - "added".into(), - "exact match".into(), - ]); - } - for column in &comparison.removed_columns { - rows.push([ - column.clone(), - "column".into(), - "present".into(), - "removed".into(), - "exact match".into(), - ]); - } - for (column, target, branch) in &comparison.type_changes { - rows.push([ - column.clone(), - "type".into(), - target.clone(), - branch.clone(), - "exact match".into(), - ]); - } - for column in &comparison.columns { - rows.push([ - column.column.clone(), - column.metric.clone(), - column.target.clone(), - column.branch.clone(), - column.limit.clone(), - ]); - } - rows -} - -fn evidence(out: &mut String, node: &NodeReport, against: &str) { - let Some(comparison) = &node.comparison else { - return; - }; - let rows: Vec<[String; 5]> = evidence_rows(comparison); - out.push_str(&format!( - "\n
{}: {}\n\n", - cell(&node.name), - counted(rows.len(), "difference", "differences") - )); - if !rows.is_empty() { - out.push_str(&format!( - "| Column | Metric | {} | branch | Limit |\n", - cell(against) - )); - out.push_str("|---|---|---|---|---|\n"); - for row in &rows { - let cells: Vec = row.iter().map(|text| cell(text)).collect(); - out.push_str(&format!("| {} |\n", cells.join(" | "))); - } - out.push('\n'); - } - let rows_text: String = match (comparison.target_rows, comparison.branch_rows) { - (Some(target), Some(branch)) => format!("rows {against} {target}, branch {branch}; "), - _ => String::new(), - }; - out.push_str(&format!( - "{rows_text}{} compared with no difference ({} comparison).\n\n
\n", - counted( - comparison.unchanged_columns, - "other column", - "other columns" - ), - comparison.mode.replace('_', " ") - )); -} +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 against: &str = &report.baseline.against; - let mut out: String = format!( - "# {}\n\n{}\n\n", - headline(report), - context_line(report, STYLE) - ); - let counts: Vec<(&str, usize)> = summary_counts(report); - let labels: Vec<&str> = counts.iter().map(|(label, _)| *label).collect(); - out.push_str(&format!("| {} |\n", labels.join(" | "))); - out.push_str(&format!("|{}\n", "---:|".repeat(counts.len()))); - let values: Vec = counts.iter().map(|(_, count)| count.to_string()).collect(); - out.push_str(&format!("| {} |\n", values.join(" | "))); - out.push_str("\n## Findings\n\n"); - if report.findings.is_empty() { - out.push_str("No findings.\n"); - } - for (index, finding) in report.findings.iter().enumerate() { - out.push_str(&format!( - "{}. {}\n", - index + 1, - finding_text(finding, against, STYLE) - )); - for example in example_lines(finding, STYLE) { - out.push_str(&format!(" - {example}\n")); - } - } - section( - &mut out, - "What changed", - &changed_lines(report, STYLE), - "No definition changes.", - ); - out.push_str("\n## Downstream impact\n\n"); - let tree: Vec = impact_tree(report, Style { markdown: false }); - if tree.is_empty() { - out.push_str("No changed models or seeds.\n"); - } else { - out.push_str("```text\n"); - for line in tree { - out.push_str(&format!("{line}\n")); - } - out.push_str("```\n"); - } - section( - &mut out, - "Incomplete coverage", - &incomplete_lines(report, STYLE), - "Every check completed.", - ); - section( - &mut out, - "Baseline", - &baseline_lines(report, STYLE), - "No baseline.", - ); - section( - &mut out, - "Tests and audits", - &tests_lines(report), - "No tests or audits ran.", - ); let evidence_nodes: Vec<&NodeReport> = report .nodes .iter() .filter(|node| has_evidence(node)) .collect(); - if !evidence_nodes.is_empty() { - out.push_str("\n## Per-model evidence\n"); - for node in evidence_nodes { - evidence(&mut out, node, against); - } - } - section(&mut out, "Run details", &run_lines(report, STYLE), ""); - out + [ + 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 index 0cebe5370c..88024f9250 100644 --- a/crates/sqlbuild-analysis/src/review_command/main/render_review_text.rs +++ b/crates/sqlbuild-analysis/src/review_command/main/render_review_text.rs @@ -1,62 +1,36 @@ //! The review summary printed at the end of a terminal run. use crate::review_command::_helpers::sections::{ - Style, baseline_lines, changed_lines, context_line, example_lines, finding_text, headline, - impact_tree, incomplete_lines, run_lines, summary_counts, tests_lines, + 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; -const STYLE: Style = Style { markdown: false }; - -fn block(out: &mut String, title: &str, lines: &[String]) { - if lines.is_empty() { - return; - } - out.push_str(&format!("\n{title}\n")); - for line in lines { - out.push_str(&format!(" {line}\n")); - } -} - /// 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 against: &str = &report.baseline.against; - let mut out: String = String::new(); - let counts: Vec = summary_counts(report) - .iter() - .map(|(label, count)| format!("{}={count}", label.to_ascii_lowercase().replace(' ', "_"))) - .collect(); - let mut findings: Vec = Vec::new(); - for (index, finding) in report.findings.iter().enumerate() { - findings.push(format!( - "{}. {}", - index + 1, - finding_text(finding, against, STYLE) - )); - for example in example_lines(finding, STYLE) { - findings.push(format!(" {example}")); - } - } - block(&mut out, "Findings", &findings); - block(&mut out, "What changed", &changed_lines(report, STYLE)); - block(&mut out, "Downstream impact", &impact_tree(report, STYLE)); - block( - &mut out, - "Incomplete coverage", - &incomplete_lines(report, STYLE), - ); - if !report.dry_run { - block(&mut out, "Baseline", &baseline_lines(report, STYLE)); - block(&mut out, "Tests and audits", &tests_lines(report)); - } - block(&mut out, "Run details", &run_lines(report, STYLE)); - out.push_str(&format!( - "\n{} {}\n{}\n", - headline(report), - counts.join(" "), - context_line(report, STYLE) - )); - out + 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/mod.rs b/crates/sqlbuild-analysis/src/review_command/mod.rs index 624434d3f6..613e94a7b9 100644 --- a/crates/sqlbuild-analysis/src/review_command/mod.rs +++ b/crates/sqlbuild-analysis/src/review_command/mod.rs @@ -2,6 +2,7 @@ mod _helpers; pub mod constants; +pub mod errors; pub mod main; pub mod models; diff --git a/crates/sqlbuild-analysis/src/review_command/models.rs b/crates/sqlbuild-analysis/src/review_command/models.rs index edbddfd315..ee708efb31 100644 --- a/crates/sqlbuild-analysis/src/review_command/models.rs +++ b/crates/sqlbuild-analysis/src/review_command/models.rs @@ -71,14 +71,6 @@ pub struct ReviewPlan { pub critical: Vec, } -/// A user error that stops planning. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct ReviewPlanError { - pub code: String, - pub message: String, - pub help: Option, -} - /// One named key or cell value shown as an example. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ReviewExample { @@ -187,9 +179,9 @@ pub struct ReviewSchemaReport { pub error: Option, } -/// The error that ended a review early. +/// The code, message and help of the error that ended a review early. #[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct ReviewError { +pub struct ReviewFailure { pub code: String, pub message: String, pub help: Option, @@ -229,5 +221,5 @@ pub struct ReviewReport { pub audits: ValidationCounts, pub schema: Option, pub invocation_id: Option, - pub error: Option, + pub error: Option, } diff --git a/crates/sqlbuild-analysis/src/review_command/tests/helpers.rs b/crates/sqlbuild-analysis/src/review_command/tests/helpers.rs index 216119cb39..04cbee07d3 100644 --- a/crates/sqlbuild-analysis/src/review_command/tests/helpers.rs +++ b/crates/sqlbuild-analysis/src/review_command/tests/helpers.rs @@ -2,8 +2,9 @@ use std::collections::BTreeMap; use crate::review_command::models::{ ChangedNode, ColumnEvidence, DownstreamPolicy, IncompleteCheck, IncrementalStart, - NodeComparison, NodeReport, ReviewBaseline, ReviewExample, ReviewFinding, ReviewGraph, - ReviewNode, ReviewPlan, ReviewReport, ReviewSchemaReport, SelectedToken, ValidationCounts, + NodeComparison, NodeReport, ReviewBaseline, ReviewExample, ReviewFailure, ReviewFinding, + ReviewGraph, ReviewNode, ReviewPlan, ReviewReport, ReviewSchemaReport, SelectedToken, + ValidationCounts, }; fn node(name: &str, kind: &str, critical: bool) -> (String, ReviewNode) { @@ -20,8 +21,7 @@ fn node(name: &str, kind: &str, critical: bool) -> (String, ReviewNode) { ) } -/// A waffle shop: `stg_orders` feeds a critical `daily_revenue` through `order_totals`, -/// and an uncritical `order_archive`; `stg_customers` feeds `customer_lifetime`. +/// A waffle shop whose `stg_orders` reaches a critical `daily_revenue` and an `order_archive`. pub(super) fn waffle_shop() -> ReviewGraph { let edges: [(&str, &str); 5] = [ ("waffle_types", "stg_orders"), @@ -215,3 +215,44 @@ pub(super) fn incomplete_report() -> ReviewReport { }); 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 index 17da018aec..d185189747 100644 --- a/crates/sqlbuild-analysis/src/review_command/tests/test_plan_review.rs +++ b/crates/sqlbuild-analysis/src/review_command/tests/test_plan_review.rs @@ -1,3 +1,4 @@ +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; @@ -5,7 +6,7 @@ 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::{ - PlanErrorTestCase, PlanTestCase, SchemaNameTestCase, + PlanErrorTestCase, PlanTestCase, PolicyTestCase, SchemaNameBuildTestCase, SchemaNameTestCase, }; #[test] @@ -202,54 +203,97 @@ fn given_bad_selection_when_planning_then_fails_with_code() { #[test] fn given_policy_text_when_parsing_then_known_values_map() { - assert_eq!( - parse_downstream_policy("critical"), - Some(DownstreamPolicy::Critical) - ); - assert_eq!(parse_downstream_policy("all"), Some(DownstreamPolicy::All)); - assert_eq!( - parse_downstream_policy("none"), - Some(DownstreamPolicy::None) - ); - assert_eq!(parse_downstream_policy("some"), None); + 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 folded", + parts: ("REVIEW", "20261023T101500Z", "3F2A9C1D88", "a1b2c3d4e5f6"), + expected_name: "REVIEW_20261023T101500Z_3f2a9c1_a1b2c3", + }]; + for test_case in test_cases { + let (prefix, timestamp, commit, run) = test_case.parts; + assert_eq!( + review_schema_name(prefix, timestamp, commit, run), + test_case.expected_name, + "{}", + test_case.description + ); + } } #[test] fn given_schema_names_when_parsing_then_only_strict_names_parse() { - let built: String = - review_schema_name("REVIEW", "20261023T101500Z", "3F2A9C1D88", "a1b2c3d4e5f6"); - assert_eq!(built, "REVIEW_20261023T101500Z_3f2a9c1_a1b2c3"); + 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: Some((2026, 10, 23, 10, 15, 0)), + expected_timestamp: Some(created), }, SchemaNameTestCase { description: "a folded name with an underscored prefix", name: "ci_review_20261023t101500z_3f2a9c1_a1b2c3", - expected: Some((2026, 10, 23, 10, 15, 0)), + expected_timestamp: Some(created), }, SchemaNameTestCase { description: "a short commit", name: "REVIEW_20261023T101500Z_3f2a_a1b2c3", - expected: None, + expected_timestamp: None, }, SchemaNameTestCase { description: "an invalid month", name: "REVIEW_20261323T101500Z_3f2a9c1_a1b2c3", - expected: None, + expected_timestamp: None, }, SchemaNameTestCase { description: "an ordinary schema", name: "analytics_prod", - expected: None, + expected_timestamp: None, }, ]; for test_case in test_cases { assert_eq!( parse_review_schema_name(test_case.name), - test_case.expected, + 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 index b732832c9b..d4c5fdfff2 100644 --- a/crates/sqlbuild-analysis/src/review_command/tests/test_render_review.rs +++ b/crates/sqlbuild-analysis/src/review_command/tests/test_render_review.rs @@ -4,71 +4,55 @@ 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::models::{ReviewError, ReviewReport}; -use crate::review_command::tests::helpers::{finding_report, incomplete_report}; -use crate::review_command::tests::test_types::{OutcomeTestCase, RenderTestCase}; - -fn failed_report() -> ReviewReport { - let mut report: ReviewReport = incomplete_report(); - if let Some(schema) = report.schema.as_mut() { - schema.dropped = false; - schema.error = Some("permission denied".to_owned()); - } - report -} - -fn passed_report() -> ReviewReport { - let mut report: ReviewReport = incomplete_report(); - report.incomplete.clear(); - report -} +use crate::review_command::tests::helpers::{ + dry_run_report, errored_report, failed_report, finding_report, incomplete_report, passed_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 mut errored: ReviewReport = finding_report(); - errored.error = Some(ReviewError { - code: "C761".to_owned(), - message: "--select x matches no model or seed".to_owned(), - help: None, - }); - let mut dry_run: ReviewReport = passed_report(); - dry_run.dry_run = true; let test_cases = [ OutcomeTestCase { description: "no findings and complete coverage passes", report: passed_report(), - expected: ("passed", 0), + expected_status: "passed", + expected_exit_code: 0, }, OutcomeTestCase { description: "a finding needs a fix", report: finding_report(), - expected: ("findings", 1), + expected_status: "findings", + expected_exit_code: 1, }, OutcomeTestCase { description: "an incomplete check is incomplete", report: incomplete_report(), - expected: ("incomplete", 2), + expected_status: "incomplete", + expected_exit_code: 2, }, OutcomeTestCase { description: "a review schema that was not dropped fails", report: failed_report(), - expected: ("failed", 3), + expected_status: "failed", + expected_exit_code: 3, }, OutcomeTestCase { description: "an error fails even with findings", - report: errored, - expected: ("failed", 3), + report: errored_report(), + expected_status: "failed", + expected_exit_code: 3, }, OutcomeTestCase { description: "a dry run only plans", - report: dry_run, - expected: ("planned", 0), + 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, + (test_case.expected_status, test_case.expected_exit_code), "{}", test_case.description ); @@ -76,24 +60,58 @@ fn given_evidence_when_deciding_outcome_then_failure_beats_findings_beats_incomp } #[test] -fn given_finding_report_when_rendering_json_then_document_is_versioned_and_complete() { - let document: Value = - serde_json::from_str(&render_review_json(&finding_report())).expect("valid JSON"); +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: "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"); - assert_eq!(document["format_version"], 1); - assert_eq!(document["status"], "findings"); - assert_eq!(document["exit_code"], 1); - assert_eq!(document["summary"]["findings"], 1); - assert_eq!(document["summary"]["not_validated"], 1); - assert_eq!(document["findings"][0]["column"], "amount"); - assert_eq!(document["findings"][0]["examples"][0]["key"], "order_id=3"); - assert_eq!(document["tests"]["skipped"], 3); - assert_eq!(document["review_schema"]["dropped"], true); - assert_eq!( - document["recall"]["command"], - "sqb recall output a1b2c3d4e5f6" - ); - assert_eq!(document["changed"][1]["kind"], "macro"); + 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] @@ -116,6 +134,7 @@ fn given_reports_when_rendering_markdown_then_sections_follow_the_agreed_layout( "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", @@ -125,6 +144,7 @@ fn given_reports_when_rendering_markdown_then_sections_follow_the_agreed_layout( "## Findings\n\nNo findings.", "## Incomplete coverage\n\n- `order_totals` keyed comparison: the model has no unique_key", ], + unexpected_fragments: vec![], }, RenderTestCase { description: "a cleanup failure", @@ -133,30 +153,75 @@ fn given_reports_when_rendering_markdown_then_sections_follow_the_agreed_layout( "# 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); - for fragment in &test_case.expected_fragments { - assert!( - markdown.contains(fragment), - "{}: missing {fragment:?} in\n{markdown}", - test_case.description - ); - } + 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_finding_report_when_rendering_text_then_summary_ends_with_outcome() { - let text: String = render_review_text(&finding_report()); +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); - assert!(text.contains("Findings\n 1. order_totals.amount values differ in 1 of 5 rows")); - assert!(text.contains("Downstream impact\n stg_orders [ok]\n ├── order_totals [finding]")); - assert!(text.contains("Recall: sqb recall output a1b2c3d4e5f6")); - assert!(text.contains( - "\nReview needs a fix: 1 finding changed=2 built=3 compared=3 tests=2 findings=1 not_validated=1 incomplete=0\n" - )); - assert!(!text.contains('`')); + 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 index 93afc463a4..4ac36b820c 100644 --- a/crates/sqlbuild-analysis/src/review_command/tests/test_types.rs +++ b/crates/sqlbuild-analysis/src/review_command/tests/test_types.rs @@ -1,3 +1,6 @@ +use serde_json::Value; + +use crate::janitor_planning::models::ArchiveTimestamp; use crate::review_command::models::{DownstreamPolicy, ReviewReport, SelectedToken}; pub(super) struct PlanTestCase { @@ -20,17 +23,37 @@ pub(super) struct PlanErrorTestCase { pub(super) struct OutcomeTestCase { pub(super) description: &'static str, pub(super) report: ReviewReport, - pub(super) expected: (&'static str, i32), + 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: Option<(i32, u32, u32, u32, u32, u32)>, + pub(super) expected_timestamp: Option, } diff --git a/crates/sqlbuild-config/src/project/_helpers/sections.rs b/crates/sqlbuild-config/src/project/_helpers/sections.rs index 9ae283bb04..f787094cac 100644 --- a/crates/sqlbuild-config/src/project/_helpers/sections.rs +++ b/crates/sqlbuild-config/src/project/_helpers/sections.rs @@ -570,7 +570,6 @@ pub(crate) fn load_review(payload: Option<&ConfigValue>, file: ConfigFile) -> Lo )); } }; - let schema_prefix = optional_str(mapping, "schema_prefix").ok().flatten(); let valid_prefix = |prefix: &str| { prefix .chars() @@ -580,15 +579,17 @@ pub(crate) fn load_review(payload: Option<&ConfigValue>, file: ConfigFile) -> Lo .chars() .all(|item| item.is_ascii_alphanumeric() || item == '_') }; - if get(mapping, "schema_prefix").is_some() - && !schema_prefix.as_deref().is_some_and(valid_prefix) - { - return Err(ConfigFailure::at( - file, - "review.schema_prefix must start with a letter and contain only letters, digits and \ - underscores", - )); - } + 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( 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 7b48ade480..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,11 +1,10 @@ -use crate::project::errors::LoadFailure; 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, ReviewConfigTestCase, + GitBaseRefTestCase, ProjectFailureTestCase, ReviewConfigErrorTestCase, ReviewConfigTestCase, }; const PROJECT: &str = "name = 'orders'\nadapter = 'duckdb'\n"; @@ -179,54 +178,69 @@ fn given_git_section_when_reading_base_ref_then_returns_configured_ref() { } #[test] -fn given_review_section_when_reading_then_returns_defaults_or_rejects() { +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: Ok(ReviewConfig { + 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: Ok(ReviewConfig::default()), + expected_config: ReviewConfig::default(), }, - ReviewConfigTestCase { + ]; + 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: Err("review.downstream must be one of: critical, all, none"), + expected_fragment: "review.downstream must be one of: critical, all, none", }, - ReviewConfigTestCase { + ReviewConfigErrorTestCase { description: "a schema prefix that is not an identifier", project: "name = 'orders'\nadapter = 'duckdb'\n[review]\nschema_prefix = 'review-ci'\n", - expected: Err("review.schema_prefix must start with a letter"), + expected_fragment: "review.schema_prefix must start with a letter", }, - ReviewConfigTestCase { + ReviewConfigErrorTestCase { description: "a misspelt review key", project: "name = 'orders'\nadapter = 'duckdb'\n[review]\nagaints = 'prod'\n", - expected: Err("review contains unknown key(s): againts"), + expected_fragment: "review contains unknown key(s): againts", }, ]; for test_case in test_cases { let (_directory, paths) = project_directory(test_case.project, ""); - let loaded = load_review_config(&paths); + let failure: String = format!( + "{:?}", + load_review_config(&paths).expect_err(test_case.description) + ); - match (loaded, test_case.expected) { - (Ok(config), Ok(expected)) => { - assert_eq!(config, expected, "{}", test_case.description); - } - (Err(LoadFailure::Config(failure)), Err(fragment)) => { - let text: String = format!("{failure:?}"); - assert!(text.contains(fragment), "{}: {text}", test_case.description); - } - (other, _) => panic!("{}: unexpected {other:?}", 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 12b0fc3ced..e4dda5fb4e 100644 --- a/crates/sqlbuild-config/src/project/tests/test_types.rs +++ b/crates/sqlbuild-config/src/project/tests/test_types.rs @@ -24,5 +24,11 @@ pub(crate) struct GitBaseRefTestCase { pub(crate) struct ReviewConfigTestCase { pub(crate) description: &'static str, pub(crate) project: &'static str, - pub(crate) expected: Result, + 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 index 825541094c..14314ddd97 100644 --- a/crates/sqlbuild-core/src/git_revisions/main/head_commit.rs +++ b/crates/sqlbuild-core/src/git_revisions/main/head_commit.rs @@ -2,16 +2,18 @@ use std::path::Path; -use crate::git_revisions::_helpers::git_command::git_output; +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 { - git_output( + let found = git_output( directory, &["rev-parse", "--verify", "--quiet", "HEAD"], &[], - ) - .ok() - .filter(|commit| !commit.is_empty()) + ); + match found { + Ok(commit) if !commit.is_empty() => Some(commit), + Ok(_) | Err(GitFailure::Failed(_) | GitFailure::Unavailable(_)) => None, + } } diff --git a/crates/sqlbuild-host/src/entries/_helpers/executor.rs b/crates/sqlbuild-host/src/entries/_helpers/executor.rs index eef382afce..6db67c7d22 100644 --- a/crates/sqlbuild-host/src/entries/_helpers/executor.rs +++ b/crates/sqlbuild-host/src/entries/_helpers/executor.rs @@ -844,4 +844,9 @@ pub(crate) const ENTRIES: &[HostEntry] = &[ kind: HostKind::Class, 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 7d3a984eee..b227618caf 100644 --- a/crates/sqlbuild-host/src/entries/models.rs +++ b/crates/sqlbuild-host/src/entries/models.rs @@ -369,6 +369,7 @@ pub enum ExecutorHost { SqlValueRenderingError, ReviewSchemaLifecycle, ReviewSchemaMarker, + LeftoverReviewSchemas, } impl ExecutorHost { 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 1314a08ace..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,10 +245,7 @@ pub(crate) fn janitor_plan_text(plan: &Bound<'_, PyAny>) -> PyResult>>()?, + review_schemas: review_schema_names(&native_field(plan, "review_schema_candidates")?)?, state_prunes, skipped_relations, skipped_schemas: source_schemas(&native_field(plan, "skipped_schemas")?, false)?, diff --git a/crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_planning.rs b/crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_planning.rs index c3870ee068..30a35b4814 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_planning.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_planning.rs @@ -139,10 +139,7 @@ fn review_schema_candidates<'py>( &[("seconds", integer(py, LEFTOVER_REVIEW_SCHEMA_AGE_SECONDS)?)], )?; let leftovers: Bound<'py, PyAny> = call_with( - &native_field( - &host::entry(py, ExecutorHost::ReviewSchemaLifecycle)?, - "leftovers", - )?, + &host::entry(py, ExecutorHost::LeftoverReviewSchemas)?, &[ ("adapter", warehouse.adapter.clone()), ("connection", warehouse.connection.clone()), diff --git a/crates/sqlbuild-python/src/bindings/_helpers/review_command/command.rs b/crates/sqlbuild-python/src/bindings/_helpers/review_command/command.rs index f353d628ad..df90a11954 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/review_command/command.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/command.rs @@ -10,20 +10,23 @@ use pyo3::prelude::{ }; use pyo3::{pyfunction, wrap_pyfunction}; use sqlbuild_analysis::review_command::constants::{ - BUILT_NODE, FAILED_NODE, FINDING_NODE, MODEL_KIND, NOT_VALIDATED_NODE, OK_NODE, PLANNED_STATUS, + AUDIT_FINDING, BUILT_NODE, FAILED_NODE, FINDING_NODE, MODEL_KIND, NO_COMPARISON_MODE, + NOT_VALIDATED_NODE, OK_NODE, PASSES_LIMIT, PLANNED_STATUS, 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::{ - DownstreamPolicy, NodeReport, ReviewFinding, ReviewNode, ReviewPlan, ReviewReport, - ReviewSchemaReport, + NodeReport, ReviewFinding, ReviewNode, ReviewPlan, ReviewReport, ReviewSchemaReport, + ValidationCounts, }; use sqlbuild_core::git_revisions::main::head_commit::head_commit; use crate::bindings::_helpers::boundary::python_calls::imported; +use crate::bindings::_helpers::clone::clone_progress::write_line; use crate::bindings::_helpers::records::fields::field as native_field; use crate::bindings::_helpers::review_command::comparison::{ ComparisonContext, Comparisons, compare_models, @@ -40,7 +43,7 @@ use crate::bindings::_helpers::review_command::schema::{ with_review_target, }; use crate::bindings::_helpers::review_command::support::{ - emit, error_parts, failed_error, invocation_id, optional_text, phase, plan_error, utc_now, + error_parts, failed_error, invocation_id, phase, plan_error, utc_now_compact, }; const SUCCESS_STATUS: &str = "success"; @@ -124,77 +127,114 @@ fn planned_nodes(project: &ReviewProject<'_>, plan: &ReviewPlan, dry_run: bool) .collect() } -/// Settle each node's status from its build, audits, tests and comparison. -fn settle_nodes( - report: &mut ReviewReport, - build: &BuildOutcome, - comparisons: Comparisons, - affected: &BTreeMap>, -) { - let mut findings: Vec = comparisons.findings; - for (node, audit) in &build.failed_audits { - findings.push(ReviewFinding { +/// 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".to_owned(), + kind: AUDIT_FINDING.to_owned(), metric: format!("audit {audit} failed"), - limit: "passes".to_owned(), + 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 after an upstream failure".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), } +} + +/// Settle each node's status from its build, audits, tests and comparison. +fn settle_nodes( + report: ReviewReport, + build: &BuildOutcome, + comparisons: Comparisons, + affected: &BTreeMap>, +) -> ReviewReport { + let Comparisons { + mut findings, + mut by_node, + incomplete, + } = comparisons; + findings.extend(check_findings(build, &report.tests)); let with_findings: BTreeSet = findings .iter() .map(|finding| finding.node.clone()) .collect(); - let mut by_node: BTreeMap = - comparisons.by_node; - for node in &mut report.nodes { - node.affected_columns = affected.get(&node.name).cloned().unwrap_or_default(); - if node.status == NOT_VALIDATED_NODE { - continue; - } - let status: Option = build.statuses.get(&node.name).cloned(); - node.build_status.clone_from(&status); - node.comparison = by_node.remove(&node.name); - let blocked: bool = build.audit_blocked.contains(&node.name); - node.status = match status.as_deref() { - _ if with_findings.contains(&node.name) => FINDING_NODE.to_owned(), - Some(SKIPPED_STATUS) => { - node.reason = Some("skipped after an upstream failure".to_owned()); - NOT_VALIDATED_NODE.to_owned() + let nodes: Vec = report + .nodes + .into_iter() + .map(|node| { + let affected_columns: Vec = + affected.get(&node.name).cloned().unwrap_or_default(); + if node.status == NOT_VALIDATED_NODE { + return NodeReport { + affected_columns, + ..node + }; } - Some(SUCCESS_STATUS) | None if blocked => FINDING_NODE.to_owned(), - Some(SUCCESS_STATUS) => match &node.comparison { - Some(comparison) if comparison.complete && comparison.mode != "none" => { - OK_NODE.to_owned() - } - _ => BUILT_NODE.to_owned(), - }, - _ if blocked => FINDING_NODE.to_owned(), - _ => FAILED_NODE.to_owned(), - }; - } - for failure in &report.tests.failures { - findings.push(ReviewFinding { - node: failure.trim_start_matches("unit test ").to_owned(), - kind: "test".to_owned(), - metric: "unit test failed".to_owned(), - limit: "passes".to_owned(), - ..ReviewFinding::default() - }); + let built = NodeReport { + affected_columns, + build_status: build.statuses.get(&node.name).cloned(), + comparison: by_node.remove(&node.name), + ..node + }; + let (status, reason) = settled_status(&built, build, &with_findings); + NodeReport { + status, + reason, + ..built + } + }) + .collect(); + ReviewReport { + nodes, + findings, + incomplete, + ..report } - report.findings = findings; - report.incomplete = comparisons.incomplete; } -/// Everything that needs the review schema: clone, build, tests, comparison and impact. -fn validate( - (request, stream, machine): (&Bound<'_, PyAny>, &Bound<'_, PyAny>, bool), - (settings, project, schema): (&ReviewSettings, &ReviewProject<'_>, &ReviewSchema<'_>), - (connection_config, connection): (&Bound<'_, PyAny>, &Bound<'_, PyAny>), - report: &mut ReviewReport, -) -> PyResult<()> { - let review_discovered: Bound<'_, PyAny> = - with_review_target(&project.discovered, settings, schema)?; +/// 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.extract::()?, |path, part| { @@ -203,119 +243,166 @@ fn validate( .join(&schema.name); std::fs::create_dir_all(&work_dir) .map_err(|error| pyo3::exceptions::PyOSError::new_err(error.to_string()))?; - let inputs: RunInputs<'_, '_> = RunInputs { - request, - project_dir: &project.project_dir, - review_discovered: &review_discovered, - review_target: &schema.name, - work_dir: &work_dir, - machine, - }; - let stateful: Vec = report - .plan - .validated - .iter() - .filter(|name| project.stateful.contains(*name)) - .cloned() - .collect(); - report.incremental_starts = - incremental_starts(&inputs, (settings, &project.adapter), &stateful)?; - let validated: Vec = report.plan.validated.clone(); - let count: usize = validated.len(); - let build: BuildOutcome = phase( - stream, - ( - &format!( - "Building {count} changed and downstream nodes into {} (unchanged upstream from {})", - schema.name, settings.against + Ok(work_dir) +} + +/// Everything that needs the review schema: clone, build, tests, comparison and impact. +fn validate( + (request, stream, machine): (&Bound<'_, PyAny>, &Bound<'_, PyAny>, bool), + (settings, project, schema): (&ReviewSettings, &ReviewProject<'_>, &ReviewSchema<'_>), + (connection_config, connection): (&Bound<'_, PyAny>, &Bound<'_, PyAny>), + report: ReviewReport, +) -> Attempt { + let mut report: ReviewReport = report; + let outcome: PyResult<()> = (|| { + let review_discovered: Bound<'_, PyAny> = + with_review_target(&project.discovered, settings, schema)?; + let work_dir: PathBuf = work_directory(project, schema)?; + let inputs: RunInputs<'_, '_> = RunInputs { + request, + project_dir: &project.project_dir, + review_discovered: &review_discovered, + review_target: &schema.name, + work_dir: &work_dir, + 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, &project.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", ), - "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 comparable: Vec<&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(); - let context: ComparisonContext<'_, '_> = ComparisonContext { - request, - discovered: &review_discovered, - adapter: &project.adapter, - connection, - connection_config, - review_target: &schema.name, - }; - let comparisons: Comparisons = phase( - stream, - ( - &format!( - "Comparing {} models with {}", - comparable.len(), - settings.against + || 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", ), - "Compared the review models", - "Comparison failed", - ), - || compare_models(&context, settings, &comparable), - )?; - 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( - &project.project, - (&changed, &affected_set), - &comparisons.by_node, - )?; - settle_nodes(report, &build, comparisons, &affected); - Ok(()) + || run_changed_tests(&inputs, settings), + )?; + let comparable: Vec<&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(); + let context: ComparisonContext<'_, '_> = ComparisonContext { + request, + discovered: &review_discovered, + adapter: &project.adapter, + connection, + connection_config, + review_target: &schema.name, + }; + let comparisons: Comparisons = phase( + stream, + ( + &format!( + "Comparing {} models with {}", + comparable.len(), + settings.against + ), + "Compared the review models", + "Comparison failed", + ), + || compare_models(&context, settings, &comparable), + )?; + 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( + &project.project, + (&changed, &affected_set), + &comparisons.by_node, + )?; + report = settle_nodes(std::mem::take(&mut report), &build, comparisons, &affected); + Ok(()) + })(); + Attempt { report, outcome } } -/// 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): (&ReviewSettings, &ReviewProject<'_>), - report: &mut ReviewReport, -) -> PyResult<()> { - let py: Python<'_> = request.py(); - let config: Bound<'_, PyAny> = connection_config( +/// 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))), + } +} + +/// Connect and name this run's review schema. +fn open_review<'py>( + request: &Bound<'py, PyAny>, + (settings, project): (&ReviewSettings, &ReviewProject<'py>), + report: &ReviewReport, +) -> PyResult<(Bound<'py, PyAny>, Bound<'py, PyAny>, ReviewSchema<'py>)> { + let py: Python<'py> = request.py(); + let config: Bound<'py, PyAny> = connection_config( request, (&project.project_dir, &project.discovered), settings, )?; - let adapter: &Bound<'_, PyAny> = &project.adapter; - let connection: Bound<'_, PyAny> = adapter.call_method1("connect", (&config,))?; let run_id: String = report .invocation_id .clone() .unwrap_or_else(|| format!("{:x}", std::process::id())); - let schema: ReviewSchema<'_> = review_schema( + let schema: ReviewSchema<'py> = review_schema( (&project.project, &project.discovered), settings, - (utc_now(py)?, &report.baseline.head, &run_id), + (utc_now_compact(py)?, &report.baseline.head, &run_id), )?; - report.schema = Some(ReviewSchemaReport { + let connection: Bound<'py, PyAny> = project.adapter.call_method1("connect", (&config,))?; + Ok((config, connection, schema)) +} + +/// 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): (&ReviewSettings, &ReviewProject<'_>), + report: ReviewReport, +) -> Attempt { + let py: Python<'_> = request.py(); + let (config, connection, schema) = match open_review(request, (settings, project), &report) { + Ok(opened) => opened, + Err(error) => { + return Attempt { + report, + outcome: Err(error), + }; + } + }; + let adapter: &Bound<'_, PyAny> = &project.adapter; + let mut state: ReviewSchemaReport = ReviewSchemaReport { database: schema.database.clone(), name: schema.name.clone(), ..ReviewSchemaReport::default() - }); + }; let created: PyResult<()> = phase( stream, ( @@ -325,11 +412,9 @@ fn run_in_schema( ), || create_review_schema((adapter, &connection), &schema), ); - let worked: PyResult<()> = match created { + let attempt: Attempt = match created { Ok(()) => { - if let Some(state) = report.schema.as_mut() { - state.created = true; - } + state.created = true; validate( (request, stream, machine), (settings, project, &schema), @@ -337,10 +422,13 @@ fn run_in_schema( report, ) } - Err(error) => Err(error), + Err(error) => Attempt { + report, + outcome: Err(error), + }, }; - let created: bool = report.schema.as_ref().is_some_and(|state| state.created); - if created { + let mut cleanup: PyResult<()> = Ok(()); + if state.created { let dropped: PyResult = phase( stream, ( @@ -350,81 +438,91 @@ fn run_in_schema( ), || drop_review_schema((adapter, &connection), &schema), ); - if let Some(state) = report.schema.as_mut() { - match dropped { - Ok(true) => state.dropped = true, - Ok(false) => { - state.error = Some("the schema still exists after the drop".to_owned()) - } - Err(error) => state.error = Some(error_parts(py, &error).message), - } + 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,)); - worked + 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: &mut ReviewReport, -) -> 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; - let directory: PathBuf = project_dir.extract()?; - report.baseline.head = head_commit(&directory).unwrap_or_default(); - let project: ReviewProject<'_> = 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, &project.graph)?; - 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.dry_run); - report.deferred = deferred(&project, &report.plan.validated); - emit( - stream, - &format!( - "Review plan: {} changed, {} to build, {} left out (--downstream {})", - report.plan.changed.len(), - report.plan.validated.len(), - report.plan.not_validated.len(), - match settings.policy { - DownstreamPolicy::Critical => "critical", - DownstreamPolicy::All => "all", - DownstreamPolicy::None => "none", - }, - ), - )?; - if report.dry_run || report.plan.validated.is_empty() { - return Ok(()); + 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: ReviewProject<'_> = 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, &project.graph)?; + 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.dry_run); + 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), + ), + )?; + let skip: bool = report.dry_run || report.plan.validated.is_empty(); + Ok((!skip).then_some((settings, project))) + })(); + match planned { + Ok(Some((settings, project))) => { + run_in_schema((request, stream, machine), (&settings, &project), report) + } + Ok(None) => Attempt { + report, + outcome: Ok(()), + }, + Err(error) => Attempt { + report, + outcome: Err(error), + }, } - run_in_schema((request, stream, machine), (&settings, &project), report) } fn publish(request: &Bound<'_, PyAny>, report: &ReviewReport, machine: bool) -> PyResult<()> { let py: Python<'_> = request.py(); - if let Some(path) = optional_text(&native_field(request, "markdown_path")?)? { - let path: PathBuf = PathBuf::from(path); + if let Some(path) = native_field(request, "markdown_path")?.extract::>()? { if let Some(parent) = path .parent() .filter(|parent| !parent.as_os_str().is_empty()) @@ -458,25 +556,29 @@ pub(crate) fn run_review_command(py: Python<'_>, request: &Bound<'_, PyAny>) -> "sys", if machine || debug { "stderr" } else { "stdout" }, )?; - let mut report: ReviewReport = ReviewReport { + 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 outcome: PyResult<()> = review((request, &stream, machine), &mut report); - report.duration_seconds = started.elapsed().as_secs_f64(); + let Attempt { report, outcome } = review((request, &stream, machine), started_report); let cause: Option = outcome.err(); - if let Some(error) = &cause { - report.error = Some(error_parts(py, error)); - } + let report: ReviewReport = ReviewReport { + duration_seconds: started.elapsed().as_secs_f64(), + error: cause + .as_ref() + .map(|error| error_parts(py, error)) + .transpose()?, + ..report + }; publish(request, &report, machine)?; if let Some(cause) = cause { if !cause.is_instance_of::(py) { return Err(cause); } let error = report.error.clone().unwrap_or_default(); - return Err(failed_error(py, &error, Some(cause))); + return Err(failed_error(py, &error, cause)); } Ok(review_outcome(&report).1) } diff --git a/crates/sqlbuild-python/src/bindings/_helpers/review_command/comparison.rs b/crates/sqlbuild-python/src/bindings/_helpers/review_command/comparison.rs index 15923561eb..72839ed94c 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/review_command/comparison.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/comparison.rs @@ -4,23 +4,31 @@ use std::collections::BTreeMap; use pyo3::prelude::{Bound, PyAny, PyAnyMethods, PyResult, Python}; use pyo3::types::{PyTuple, PyTupleMethods}; -use sqlbuild_analysis::review_command::constants::{EXACT_LIMIT, MAX_FINDING_EXAMPLES}; +use sqlbuild_analysis::review_command::constants::{ + BOUNDED_COMPARISON_MODE, EXACT_LIMIT, FULL_COMPARISON_MODE, MAX_FINDING_EXAMPLES, + NO_COMPARISON_MODE, SCHEMA_ONLY_MODE, +}; use sqlbuild_analysis::review_command::models::{ ColumnEvidence, IncompleteCheck, NodeComparison, ReviewExample, ReviewFinding, ReviewNode, }; -use sqlbuild_cli::diff_report::models::{DiffModelFacts, DiffValue}; +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::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::{boolean, optional_text, text, texts}; const KEYED_CHECK: &str = "keyed comparison"; const MAX_EXAMPLE_LENGTH: usize = 80; @@ -74,7 +82,7 @@ fn models_by_name<'py>( /// 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")?; - let exists = call_with( + call_with( &context.adapter.getattr("relation_exists")?, &[ ("connection", context.connection.clone()), @@ -82,8 +90,8 @@ fn target_exists(context: &ComparisonContext<'_, '_>, model: &Bound<'_, PyAny>) ("schema", native_field(&destination, "schema")?), ("name", native_field(&destination, "name")?), ], - ); - Ok(exists.is_ok_and(|exists| exists.is_truthy().unwrap_or(false))) + )? + .is_truthy() } fn row_count(context: &ComparisonContext<'_, '_>, relation: &str) -> PyResult> { @@ -153,9 +161,89 @@ fn incomplete(node: &str, reason: String) -> IncompleteCheck { } } +/// 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, out: &mut Comparisons) -> NodeComparison { - let name: &str = &facts.name; +fn evidence(facts: &DiffModelFacts, mode: &str) -> (NodeComparison, Vec) { let mut comparison: NodeComparison = NodeComparison { mode: mode.to_owned(), target_relation: Some(facts.left_relation.clone()), @@ -164,32 +252,12 @@ fn evidence(facts: &DiffModelFacts, mode: &str, out: &mut Comparisons) -> NodeCo added_columns: facts.added_columns.clone(), removed_columns: facts.removed_columns.clone(), type_changes: facts.type_changes.clone(), - complete: mode == "full", + complete: mode == FULL_COMPARISON_MODE, ..NodeComparison::default() }; - let schema_changes: usize = - facts.added_columns.len() + facts.removed_columns.len() + facts.type_changes.len(); - if schema_changes > 0 { - 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}")); - } - out.findings.push(ReviewFinding { - node: name.to_owned(), - kind: "schema".to_owned(), - metric: format!("schema changed: {}", parts.join("; ")), - limit: EXACT_LIMIT.to_owned(), - ..ReviewFinding::default() - }); - } + let mut findings: Vec = schema_finding(facts).into_iter().collect(); let Some(rows) = &facts.rows else { - return comparison; + return (comparison, findings); }; comparison.target_rows = Some(rows.left_count); comparison.branch_rows = Some(rows.right_count); @@ -197,45 +265,18 @@ fn evidence(facts: &DiffModelFacts, mode: &str, out: &mut Comparisons) -> NodeCo comparison.branch_only_keys = rows.right_only_count; comparison.target_only_keys = rows.left_only_count; if rows.left_count != rows.right_count { - out.findings.push(ReviewFinding { - node: name.to_owned(), - kind: "row_count".to_owned(), - metric: "row count".to_owned(), - target_value: Some(rows.left_count.to_string()), - branch_value: Some(rows.right_count.to_string()), - limit: EXACT_LIMIT.to_owned(), - ..ReviewFinding::default() - }); - } - for (count, side, keys) in [ - (rows.right_only_count, "branch-only", &facts.right_only_keys), - (rows.left_only_count, "target-only", &facts.left_only_keys), - ] { - if count == 0 { - continue; - } - out.findings.push(ReviewFinding { - node: name.to_owned(), - kind: "keys".to_owned(), - metric: format!("{count} {side} keys"), - limit: EXACT_LIMIT.to_owned(), - examples: keys - .iter() - .take(MAX_FINDING_EXAMPLES) - .map(|key| ReviewExample { - key: key_text(key), - ..ReviewExample::default() - }) - .collect(), - ..ReviewFinding::default() - }); + findings.push(row_count_finding( + &facts.name, + (rows.left_count, rows.right_count), + )); } - let mut changed: usize = 0; - for column in &rows.columns { - if column.mismatched_count == 0 { - continue; - } - changed += 1; + 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(), @@ -246,21 +287,11 @@ fn evidence(facts: &DiffModelFacts, mode: &str, out: &mut Comparisons) -> NodeCo let examples: Vec = facts .unequal_samples .iter() - .filter_map(|sample| { - let cell = sample - .changed_cells - .iter() - .find(|cell| cell.name == column.name)?; - Some(ReviewExample { - key: key_text(&sample.key_values), - target: Some(short(&cell.left)), - branch: Some(short(&cell.right)), - }) - }) + .filter_map(|sample| cell_example(sample, &column.name)) .take(MAX_FINDING_EXAMPLES) .collect(); - out.findings.push(ReviewFinding { - node: name.to_owned(), + findings.push(ReviewFinding { + node: facts.name.clone(), kind: "values".to_owned(), metric: format!( "values differ in {} of {} matched rows", @@ -272,23 +303,175 @@ fn evidence(facts: &DiffModelFacts, mode: &str, out: &mut Comparisons) -> NodeCo ..ReviewFinding::default() }); } - comparison.unchanged_columns = rows.columns.len().saturating_sub(changed); - comparison + comparison.unchanged_columns = rows.columns.len().saturating_sub(changed.len()); + (comparison, findings) } -/// Compare each of `models` (built, by name) between `--against` and the review schema. -pub(crate) fn compare_models( +/// The compiled projects of both sides, and the size limits of an exact comparison. +struct Sides<'py> { + left: Bound<'py, PyAny>, + right: Bound<'py, PyAny>, + left_models: BTreeMap>, + limits: Bound<'py, PyAny>, + bounded: Option, +} + +/// A model without a unique key: its schema and row counts, with the keyed check incomplete. +fn compare_unkeyed( context: &ComparisonContext<'_, '_>, - settings: &ReviewSettings, - models: &[&ReviewNode], -) -> PyResult { + sides: &Sides<'_>, + name: &str, +) -> PyResult { let py: Python<'_> = context.adapter.py(); - let mut out: Comparisons = Comparisons::default(); - if models.is_empty() { - return Ok(out); + 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))); } - let names: Vec = models.iter().map(|node| node.name.clone()).collect(); - let pipeline: Bound<'_, PyTuple> = run_diff_pipeline( + 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: not compared when `--against` lacks it, else keyed or unkeyed. +fn compare_model( + context: &ComparisonContext<'_, '_>, + sides: &Sides<'_>, + node: &ReviewNode, +) -> PyResult { + let exists: bool = match sides.left_models.get(&node.name) { + Some(model) => target_exists(context, model)?, + None => false, + }; + if !exists { + return Ok(ModelComparison { + comparison: Some(NodeComparison { + mode: NO_COMPARISON_MODE.to_owned(), + complete: true, + ..NodeComparison::default() + }), + ..ModelComparison::default() + }); + } + if node.unique_key.is_empty() { + return compare_unkeyed(context, sides, &node.name); + } + compare_keyed(context, sides, node) +} + +/// Compile both sides and read the size limits of an exact comparison. +fn prepare_sides<'py>( + context: &ComparisonContext<'_, 'py>, + settings: &ReviewSettings, + names: &[String], +) -> PyResult> { + let py: Python<'py> = context.adapter.py(); + let pipeline: Bound<'py, PyTuple> = run_diff_pipeline( py, Some(keywords( py, @@ -299,17 +482,14 @@ pub(crate) fn compare_models( ("to_target", text(py, context.review_target)), ("resolved_connection", context.connection_config.clone()), ("no_sql_validation", boolean(py, true)), - ("select", texts(py, &names)?), + ("select", texts(py, names)?), ("exclude", PyTuple::empty(py).into_any()), ("cli_vars", native_field(context.request, "cli_vars")?), ], )?), )?; - let left: Bound<'_, PyAny> = pipeline.get_item(0)?; - let right: Bound<'_, PyAny> = pipeline.get_item(1)?; - let left_models = models_by_name(&left)?; - let bounded: Option = optional_text(&native_field(context.request, "bounded")?)?; - let limits: Bound<'_, PyAny> = call_with( + let left: Bound<'py, PyAny> = pipeline.get_item(0)?; + let limits: Bound<'py, PyAny> = call_with( &host::entry(py, ExecutorHost::FullDiffSizeLimits)?, &[ ("left_target", text(py, &settings.against)), @@ -324,127 +504,33 @@ pub(crate) fn compare_models( ), ], )?; + Ok(Sides { + left_models: models_by_name(&left)?, + right: pipeline.get_item(1)?, + left, + limits, + bounded: optional_text(&native_field(context.request, "bounded")?)?, + }) +} + +/// Compare each of `models` (built, by name) between `--against` and the review schema. +pub(crate) fn compare_models( + context: &ComparisonContext<'_, '_>, + settings: &ReviewSettings, + models: &[&ReviewNode], +) -> PyResult { + if models.is_empty() { + return Ok(Comparisons::default()); + } + let names: Vec = models.iter().map(|node| node.name.clone()).collect(); + let sides: Sides<'_> = prepare_sides(context, settings, &names)?; + let mut out: Comparisons = Comparisons::default(); for node in models { - let name: &str = &node.name; - let exists: bool = match left_models.get(name) { - Some(model) => target_exists(context, model)?, - None => false, - }; - if !exists { - out.by_node.insert( - name.to_owned(), - NodeComparison { - mode: "none".to_owned(), - complete: true, - ..NodeComparison::default() - }, - ); - continue; - } - if node.unique_key.is_empty() { - let facts: Option = run_one( - context, - (&left, &right), - name, - &[("schema_only", boolean(py, true))], - )?; - let Some(facts) = facts else { continue }; - let mut comparison: NodeComparison = evidence(&facts, "schema_only", &mut out); - 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 - { - out.findings.push(ReviewFinding { - node: name.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() - }); - } - out.incomplete.push(incomplete( - name, - "the model has no unique_key, so only the schema and row counts were compared" - .to_owned(), - )); - out.by_node.insert(name.to_owned(), comparison); - continue; - } - let full = run_one( - context, - (&left, &right), - name, - &[ - ("schema_only", boolean(py, false)), - ("full_size_limits", limits.clone()), - ], - ); - let (facts, mode) = match full { - Ok(facts) => (facts, "full"), - Err(error) if matches_class(py, &error, ExecutorHost::FullDiffSizeGuardError)? => { - match (&bounded, node.has_cursor) { - (Some(window), true) => { - let facts = run_one( - context, - (&left, &right), - name, - &[ - ("schema_only", boolean(py, false)), - ("bounded", text(py, window)), - ], - )?; - out.incomplete.push(IncompleteCheck { - node: name.to_owned(), - check: KEYED_CHECK.to_owned(), - reason: format!( - "too large for an exact comparison; compared the last {window} only" - ), - }); - (facts, "bounded") - } - _ => { - let hint: &str = if node.has_cursor { - "; pass --bounded to compare a recent window" - } else { - "" - }; - out.incomplete.push(incomplete( - name, - format!( - "too large for an exact comparison, or its size is unknown{hint}" - ), - )); - let facts = run_one( - context, - (&left, &right), - name, - &[("schema_only", boolean(py, true))], - )?; - (facts, "schema_only") - } - } - } - Err(error) if error.is_instance_of::(py) => { - out.incomplete - .push(incomplete(name, error_text(py, &error)?)); - out.by_node.insert( - name.to_owned(), - NodeComparison { - mode: "none".to_owned(), - target_relation: Some(String::new()), - ..NodeComparison::default() - }, - ); - continue; - } - Err(error) => return Err(error), - }; - if let Some(facts) = facts { - let comparison: NodeComparison = evidence(&facts, mode, &mut out); - out.by_node.insert(name.to_owned(), comparison); + let compared: ModelComparison = compare_model(context, &sides, node)?; + 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 index 24dfa2e2cc..58e16e8b64 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/review_command/execution.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/execution.rs @@ -5,19 +5,21 @@ use std::path::{Path, PathBuf}; use pyo3::prelude::{Bound, PyAny, PyAnyMethods, PyResult, Python}; use serde_json::Value; -use sqlbuild_analysis::review_command::constants::SEED_KIND; +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::{ - boolean, quietly, review_error, text, texts, -}; +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"; @@ -47,8 +49,16 @@ pub(crate) struct RunInputs<'a, 'py> { pub(crate) machine: bool, } -fn read_document(path: &Path) -> Option { - serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok() +/// 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] { @@ -83,7 +93,7 @@ pub(crate) fn incremental_starts( .iter() .map(|node| IncrementalStart { node: node.clone(), - method: "full_refresh".to_owned(), + method: FULL_REFRESH_START.to_owned(), reason: reason.to_owned(), }) .collect() @@ -142,7 +152,7 @@ pub(crate) fn incremental_starts( .iter() .map(|node| IncrementalStart { node: node.clone(), - method: "clone".to_owned(), + method: CLONE_START.to_owned(), reason: format!("zero-copy clone of {}", settings.against), }) .collect()) @@ -192,7 +202,7 @@ pub(crate) fn build_validated( ], )?; let ran: PyResult = quietly(py, inputs.machine, || run_build_command(py, &request)); - let document: Option = read_document(&output); + let document: Option = read_document(&output)?; let Some(document) = document else { ran?; return Err(review_error( @@ -282,7 +292,7 @@ pub(crate) fn run_changed_tests( ], )?; let ran: PyResult = quietly(py, inputs.machine, || run_test_command(py, &request)); - let Some(document) = read_document(&output) else { + let Some(document) = read_document(&output)? else { ran?; return Ok(ValidationCounts::default()); }; @@ -310,7 +320,7 @@ pub(crate) fn run_changed_tests( counts.failed += 1; counts .failures - .push(format!("unit test {}", field(check, "name"))); + .push(format!("{UNIT_TEST_LABEL}{}", field(check, "name"))); } } counts.total = document diff --git a/crates/sqlbuild-python/src/bindings/_helpers/review_command/impact.rs b/crates/sqlbuild-python/src/bindings/_helpers/review_command/impact.rs index 8742c88947..aef6f9339e 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/review_command/impact.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/impact.rs @@ -1,5 +1,6 @@ //! 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}; @@ -15,7 +16,7 @@ use crate::bindings::_helpers::records::registry; /// 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 == "none" { + if !comparison.complete || comparison.mode == NO_COMPARISON_MODE { return None; } let mut columns: Vec = comparison.added_columns.clone(); diff --git a/crates/sqlbuild-python/src/bindings/_helpers/review_command/planning.rs b/crates/sqlbuild-python/src/bindings/_helpers/review_command/planning.rs index 68d42b85fd..ffcb1f571c 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/review_command/planning.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/planning.rs @@ -19,6 +19,7 @@ use sqlbuild_analysis::review_command::models::{ use sqlbuild_config::project::models::ReviewConfig; use crate::bindings::_helpers::boundary::python_calls::{imported, 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}; @@ -34,11 +35,10 @@ use crate::bindings::_helpers::contracts::spec_resolution::{ 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::planner_selection::selectors::select_project_graph; use crate::bindings::_helpers::records::fields::field as native_field; -use crate::bindings::_helpers::review_command::support::{ - boolean, optional_text, review_error, strings, text, -}; +use crate::bindings::_helpers::review_command::support::review_error; const BASE_REF_ALIAS: &str = "@base"; const UDF_KIND: &str = "udf"; @@ -74,6 +74,16 @@ pub(crate) struct ReviewProject<'py> { pub(crate) stateful: BTreeSet, } +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)) } @@ -244,11 +254,15 @@ fn topological( order } +/// The buildable graph with each node's direct reads of sources and functions. +struct ProjectGraph { + graph: ReviewGraph, + reads: BTreeMap>, + stateful: BTreeSet, +} + /// The buildable graph, each node's direct reads of sources and functions, and stateful models. -fn review_graph( - project: &Bound<'_, PyAny>, - critical_tags: &[String], -) -> PyResult<(ReviewGraph, BTreeMap>, BTreeSet)> { +fn review_graph(project: &Bound<'_, PyAny>, critical_tags: &[String]) -> PyResult { let mut nodes: BTreeMap = BTreeMap::new(); let mut downstream: BTreeMap> = BTreeMap::new(); let mut reads: BTreeMap> = BTreeMap::new(); @@ -294,15 +308,15 @@ fn review_graph( } let names: BTreeSet = nodes.keys().cloned().collect(); let order: Vec = topological(&names, &downstream); - Ok(( - ReviewGraph { + Ok(ProjectGraph { + graph: ReviewGraph { nodes, downstream, order, }, reads, stateful, - )) + }) } /// Discover, compare with the base revision, compile, and find the changed models and seeds. @@ -346,7 +360,11 @@ pub(crate) fn review_project<'py>( 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 (review_graph, reads, stateful) = review_graph(&project, &settings.critical_tags)?; + let ProjectGraph { + graph: review_graph, + reads, + stateful, + } = review_graph(&project, &settings.critical_tags)?; let project_wide: Vec = project_wide_keys(); let mut changed_nodes: Vec = Vec::new(); for name in &review_graph.order { @@ -366,17 +384,17 @@ pub(crate) fn review_project<'py>( }); } } - let changes_json: Option = serde_json::from_str(&changed.json((0, &[]))).ok(); + let changes_json: Value = serde_json::from_str(&changed.json((0, &[]))) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string()))?; Ok(ReviewProject { changed_declarations: changed_declarations(&changed.changes), reference: changed.reference.clone(), merge_base: changes_json - .as_ref() - .and_then(|json| json.get("merge_base")) + .get("merge_base") .and_then(Value::as_str) .unwrap_or_default() .to_owned(), - changes_json, + changes_json: Some(changes_json), project_dir, discovered, adapter, diff --git a/crates/sqlbuild-python/src/bindings/_helpers/review_command/registration.rs b/crates/sqlbuild-python/src/bindings/_helpers/review_command/registration.rs index 8cea0b2c4d..f74d4efd27 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/review_command/registration.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/registration.rs @@ -1,10 +1,10 @@ //! Register `sqb review` and the review schema name parser the janitor uses. -use pyo3::prelude::{Bound, PyAny, PyAnyMethods, PyModule, PyModuleMethods, PyResult, Python}; +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::boundary::python_calls::imported; +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`. @@ -14,13 +14,9 @@ pub(crate) fn parse_review_schema_name<'py>( py: Python<'py>, name: &str, ) -> PyResult>> { - let Some((year, month, day, hour, minute, second)) = parse_name(name) else { - return Ok(None); - }; - let utc: Bound<'py, PyAny> = imported(py, "datetime", "timezone")?.getattr("utc")?; - let created = imported(py, "datetime", "datetime")? - .call1((year, month, day, hour, minute, second, 0, utc)); - Ok(created.ok()) + parse_name(name) + .map(|timestamp| utc_datetime(py, timestamp)) + .transpose() } pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { diff --git a/crates/sqlbuild-python/src/bindings/_helpers/review_command/schema.rs b/crates/sqlbuild-python/src/bindings/_helpers/review_command/schema.rs index b5478de2f6..173df74b58 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/review_command/schema.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/schema.rs @@ -6,16 +6,15 @@ use sqlbuild_analysis::review_command::main::review_schema_name::review_schema_n 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::execution_history::conversion::optional_text; 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::ReviewSettings; -use crate::bindings::_helpers::review_command::support::{ - boolean, compact_timestamp, none, optional_text, text, -}; /// Where the review builds and where its ownership marker goes. pub(crate) struct ReviewSchema<'py> { @@ -31,7 +30,7 @@ pub(crate) struct ReviewSchema<'py> { pub(crate) fn review_schema<'py>( (project, discovered): (&Bound<'py, PyAny>, &Bound<'py, PyAny>), settings: &ReviewSettings, - (created_at, head, run_id): (Bound<'py, PyAny>, &str, &str), + ((created_at, compact), head, run_id): ((Bound<'py, PyAny>, String), &str, &str), ) -> PyResult> { let py: Python<'py> = project.py(); let target: Bound<'py, PyAny> = active_target_config(discovered, &settings.target)?; @@ -51,12 +50,7 @@ pub(crate) fn review_schema<'py>( ); }; Ok(ReviewSchema { - name: review_schema_name( - &settings.schema_prefix, - &compact_timestamp(&created_at)?, - head, - run_id, - ), + name: review_schema_name(&settings.schema_prefix, &compact, head, run_id), database: optional_text(&native_field(&target, "database")?)?, marker_database: optional_text(&native_field(project, "effective_target_database")?)?, marker_schema, @@ -78,8 +72,7 @@ fn active_target_config<'py>( ) } -/// `discovered` with a target named after the review schema: the active target's connection and -/// policies, writing only to the review schema and reading managed sources like `--against`. +/// `discovered` plus a target that writes only to the review schema, otherwise like the active one. pub(crate) fn with_review_target<'py>( discovered: &Bound<'py, PyAny>, settings: &ReviewSettings, diff --git a/crates/sqlbuild-python/src/bindings/_helpers/review_command/support.rs b/crates/sqlbuild-python/src/bindings/_helpers/review_command/support.rs index 5f9e213bd1..dcb2665a0f 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/review_command/support.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/support.rs @@ -1,139 +1,111 @@ -//! Python values, errors and progress lines shared by the `sqb review` phases. +//! 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::{PyBool, PyDict, PyDictMethods, PyString, PyTuple, PyTypeMethods}; -use sqlbuild_analysis::review_command::models::{ReviewError, ReviewPlanError}; - -use crate::bindings::_helpers::boundary::python_calls::imported; +use pyo3::types::{PyDict, PyDictMethods, PyTypeMethods}; +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::{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"; -pub(crate) fn text<'py>(py: Python<'py>, value: &str) -> Bound<'py, PyAny> { - PyString::new(py, value).into_any() -} - -pub(crate) fn boolean(py: Python<'_>, value: bool) -> Bound<'_, PyAny> { - PyBool::new(py, value).to_owned().into_any() -} - -pub(crate) fn none(py: Python<'_>) -> Bound<'_, PyAny> { - py.None().into_bound(py) -} - -pub(crate) fn texts<'py>(py: Python<'py>, values: &[String]) -> PyResult> { - Ok(PyTuple::new(py, values)?.into_any()) -} - -pub(crate) fn strings(value: &Bound<'_, PyAny>) -> PyResult> { - if value.is_none() { - return Ok(Vec::new()); - } - value - .try_iter()? - .map(|item| item?.str()?.extract()) - .collect() -} - -pub(crate) fn optional_text(value: &Bound<'_, PyAny>) -> PyResult> { - if value.is_none() { - return Ok(None); - } - Ok(Some(value.str()?.extract()?)) -} - -/// `CliUserError(message, code=code, help=help)`. -pub(crate) fn review_error(py: Python<'_>, code: &str, message: &str, help: Option<&str>) -> PyErr { - let raised = (|| -> PyResult> { +/// `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", code)?; - keywords.set_item("help", help)?; - error_class(py, ErrorId::CliUser)?.call((message,), Some(&keywords)) + 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 raised { + 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) -> ReviewError { +pub(crate) fn error_parts(py: Python<'_>, error: &PyErr) -> PyResult { let value: Bound<'_, PyAny> = error.value(py).clone().into_any(); - let attribute = |name: &str| -> Option { - value - .getattr_opt(name) - .ok() - .flatten() - .filter(|item| !item.is_none()) - .and_then(|item| item.str().ok()) - .map(|item| item.to_string()) + let message: String = match attribute(&value, "message")? { + Some(message) => message, + None => value.str()?.extract()?, }; - let message: String = attribute("message").unwrap_or_else(|| { - value - .str() - .map_or_else(|_| "review failed".to_owned(), |item| item.to_string()) - }); let message: String = if message.is_empty() { - value - .get_type() - .name() - .map_or_else(|_| "review failed".to_owned(), |name| name.to_string()) + value.get_type().name()?.extract()? } else { message }; - ReviewError { - code: attribute("code").unwrap_or_else(|| UNEXPECTED_CODE.to_owned()), - message, - help: attribute("help"), - } + 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: &ReviewError, cause: Option) -> PyErr { - let raised = (|| -> PyResult> { - let keywords: Bound<'_, PyDict> = PyDict::new(py); - keywords.set_item("code", error.code.as_str())?; - keywords.set_item("help", error.help.as_deref())?; - error_class(py, ErrorId::ReviewFailed)?.call((error.message.as_str(),), Some(&keywords)) - })(); - let replacement: PyErr = match raised { - Ok(instance) => PyErr::from_value(instance), - Err(failure) => failure, - }; - replacement.set_cause(py, 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 } -/// Write `line` and a newline to `stream`, then flush. -pub(crate) fn emit(stream: &Bound<'_, PyAny>, line: &str) -> PyResult<()> { - stream.call_method1("write", (format!("{line}\n"),))?; - stream.call_method0("flush")?; - Ok(()) -} - /// 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 { - emit(stream, &format!("{start}..."))?; + write_line(stream, &format!("{start}..."))?; let started: Instant = Instant::now(); let outcome: PyResult = body(); let elapsed: f64 = started.elapsed().as_secs_f64(); - match &outcome { - Ok(_) => emit(stream, &format!("{done} ({elapsed:.2}s)"))?, - Err(_) => emit(stream, &format!("{failed} ({elapsed:.2}s)"))?, - } + let label: &str = match &outcome { + Ok(_) => done, + Err(_) => failed, + }; + write_line(stream, &format!("{label} ({elapsed:.2}s)"))?; outcome } @@ -146,32 +118,27 @@ pub(crate) fn quietly( if !machine { return body(); } - let stderr: Bound<'_, PyAny> = imported(py, "sys", "stderr")?; - let manager: Bound<'_, PyAny> = - imported(py, "contextlib", "redirect_stdout")?.call1((stderr,))?; - crate::bindings::_helpers::boundary::python_calls::within(&manager, |_| body()) -} - -/// `datetime.now(timezone.utc)`. -pub(crate) fn utc_now(py: Python<'_>) -> PyResult> { - let utc: Bound<'_, PyAny> = imported(py, "datetime", "timezone")?.getattr("utc")?; - imported(py, "datetime", "datetime")?.call_method1("now", (utc,)) + let redirect: Bound<'_, PyAny> = imported(py, "contextlib", "redirect_stdout")?; + within(&redirect.call1((imported(py, "sys", "stderr")?,))?, |_| { + body() + }) } -/// `YYYYMMDDTHHMMSSZ` of an aware UTC datetime. -pub(crate) fn compact_timestamp(moment: &Bound<'_, PyAny>) -> PyResult { - moment - .call_method1("strftime", ("%Y%m%dT%H%M%SZ",))? - .extract() +/// The current UTC time and its `YYYYMMDDTHHMMSSZ` form. +pub(crate) fn utc_now_compact(py: Python<'_>) -> PyResult<(Bound<'_, PyAny>, String)> { + let now: Bound<'_, PyAny> = now_utc(py)?; + let fields = timestamp_fields(&now)?; + 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((now, compact)) } /// The current invocation id, when an invocation identity is active. pub(crate) fn invocation_id(py: Python<'_>) -> PyResult> { - let identity: Bound<'_, PyAny> = crate::bindings::_helpers::native_host::host::entry( - py, - sqlbuild_host::entries::models::ExecutorHost::CurrentExecutionIdentity, - )? - .call0()?; + let identity: Bound<'_, PyAny> = + host::entry(py, ExecutorHost::CurrentExecutionIdentity)?.call0()?; if identity.is_none() { return Ok(None); } diff --git a/fensu.toml b/fensu.toml index 83a1e8f851..3f7b8c6a95 100644 --- a/fensu.toml +++ b/fensu.toml @@ -407,6 +407,11 @@ modules = ["sqlbuild.executor.review.models"] symbols = ["ReviewSchemaMarker"] 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"] @@ -1920,7 +1925,7 @@ pending_budgets = [ "crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_old_names.rs=3", "crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_phases.rs=17", "crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_plan.rs=4", - "crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_planning.rs=2", + "crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_planning.rs=3", "crates/sqlbuild-python/src/bindings/_helpers/lifecycle_events/envelopes.rs=4", "crates/sqlbuild-python/src/bindings/_helpers/lifecycle_events/payloads.rs=5", "crates/sqlbuild-python/src/bindings/_helpers/lifecycle_events/validation.rs=4", @@ -2128,6 +2133,13 @@ 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=6", + "crates/sqlbuild-python/src/bindings/_helpers/review_command/comparison.rs=3", + "crates/sqlbuild-python/src/bindings/_helpers/review_command/execution.rs=4", + "crates/sqlbuild-python/src/bindings/_helpers/review_command/impact.rs=3", + "crates/sqlbuild-python/src/bindings/_helpers/review_command/planning.rs=3", + "crates/sqlbuild-python/src/bindings/_helpers/review_command/schema.rs=3", + "crates/sqlbuild-python/src/bindings/_helpers/review_command/support.rs=5", "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", @@ -2446,6 +2458,10 @@ 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=9", + "crates/sqlbuild-python/src/bindings/_helpers/review_command/execution.rs=3", + "crates/sqlbuild-python/src/bindings/_helpers/review_command/planning.rs=5", + "crates/sqlbuild-python/src/bindings/_helpers/review_command/schema.rs=1", "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", @@ -2651,6 +2667,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/_native_host.py b/src/sqlbuild/_native_host.py index 5ea24bd25c..095315f12d 100644 --- a/src/sqlbuild/_native_host.py +++ b/src/sqlbuild/_native_host.py @@ -365,6 +365,10 @@ "ReviewSchemaLifecycle", ), "ReviewSchemaMarker": ("sqlbuild.executor.review.models", "ReviewSchemaMarker"), + "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 583f7325d4..ac70329e05 100644 --- a/src/sqlbuild/_native_host.pyi +++ b/src/sqlbuild/_native_host.pyi @@ -337,6 +337,9 @@ from sqlbuild.executor.python_nodes.models import PythonNodeRuntime as PythonNod from sqlbuild.executor.review.classes.review_schema_lifecycle import ( ReviewSchemaLifecycle as ReviewSchemaLifecycle, ) +from sqlbuild.executor.review.main.leftover_review_schemas import ( + leftover_review_schemas as leftover_review_schemas, +) from sqlbuild.executor.review.models import ReviewSchemaMarker as ReviewSchemaMarker from sqlbuild.executor.run.models import ModelExecutionResult as ModelExecutionResult from sqlbuild.executor.scenario.models import ScenarioCaptureSettings as ScenarioCaptureSettings diff --git a/src/sqlbuild/executor/review/classes/review_schema_lifecycle.py b/src/sqlbuild/executor/review/classes/review_schema_lifecycle.py index 83740b0798..82fd692a31 100644 --- a/src/sqlbuild/executor/review/classes/review_schema_lifecycle.py +++ b/src/sqlbuild/executor/review/classes/review_schema_lifecycle.py @@ -2,18 +2,18 @@ from __future__ import annotations -from datetime import datetime, timedelta +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 marked_review_schemas, write_review_marker -from sqlbuild.executor.review.models import LeftoverReviewSchema, ReviewSchemaMarker +from sqlbuild.executor.review._helpers.marker import write_review_marker +from sqlbuild.executor.review.models import ReviewSchemaMarker class ReviewSchemaLifecycle: - """Create, drop and find the run-owned schemas `sqb review` builds into.""" + """Create and drop the run-owned schemas `sqb review` builds into.""" @staticmethod def create( @@ -54,26 +54,3 @@ def drop(*, adapter: BaseAdapter, connection: Any, database: str | None, schema: @staticmethod def exists(*, adapter: BaseAdapter, connection: Any, database: str | None, schema: str) -> bool: return adapter.schema_exists(connection=connection, database=database, schema=schema) - - @staticmethod - def leftovers( - *, - 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/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/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/tests/e2e/src/sqlbuild/cli/commands/main/review/_test_types.py b/tests/e2e/src/sqlbuild/cli/commands/main/review/_test_types.py index 96ccdf8e71..b6beefea70 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/review/_test_types.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/review/_test_types.py @@ -42,3 +42,41 @@ class ReviewTextOutputE2ETestCase: 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 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] + + +@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 index 5e91f53b2d..b26397a963 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/review/helpers.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/review/helpers.py @@ -198,13 +198,13 @@ def review_schemas(*, project_dir: Path) -> tuple[str, ...]: connection: duckdb.DuckDBPyConnection = duckdb.connect(str(project_dir / DATABASE_FILE)) try: rows: list[tuple[Any, ...]] = connection.execute( - "SELECT schema_name FROM information_schema.schemata" + "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( - sorted(str(row[0]) for row in rows if str(row[0]).startswith(REVIEW_SCHEMA_PREFIX)) - ) + return tuple(str(row[0]) for row in rows) def execute_review_database(*, project_dir: Path, sql: str) -> None: 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 index 1bb98b8bc7..3a8cedc935 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/review/test_review.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/review/test_review.py @@ -8,8 +8,12 @@ import pytest from tests.e2e.src.sqlbuild.cli.commands.main.review._test_types import ( + ReviewCleanupFailureE2ETestCase, + ReviewDryRunE2ETestCase, + ReviewIncrementalStartE2ETestCase, ReviewOutcomeE2ETestCase, ReviewSelectErrorE2ETestCase, + ReviewSelectionE2ETestCase, ReviewTextOutputE2ETestCase, ) from tests.e2e.src.sqlbuild.cli.commands.main.review.helpers import ( @@ -115,7 +119,7 @@ expected_incomplete_nodes=("customer_orders",), ), ), - ids=lambda test_case: test_case.description, + ids=lambda case: case.description, ) def test_given_branch_edit_when_reviewing_then_report_shows_outcome( test_case: ReviewOutcomeE2ETestCase, tmp_path: Path @@ -150,22 +154,38 @@ def test_given_branch_edit_when_reviewing_then_report_shows_outcome( 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( - tmp_path: Path, + 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", "--downstream", "all") + 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 [(start["node"], start["method"]) for start in starts] == [ - ("order_ledger", "full_refresh") - ], result.stdout - assert starts[0]["reason"], result.stdout + 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( @@ -193,7 +213,7 @@ def test_given_incremental_downstream_when_reviewing_then_its_start_is_reported( expected_message_fragment="open_orders", ), ), - ids=lambda test_case: test_case.description, + ids=lambda case: case.description, ) def test_given_unusable_select_when_reviewing_then_it_fails_with_json_error( test_case: ReviewSelectErrorE2ETestCase, tmp_path: Path @@ -213,23 +233,51 @@ def test_given_unusable_select_when_reviewing_then_it_fails_with_json_error( 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( - tmp_path: Path, + 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", "stg_orders") + 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 document["validated"] == ["stg_orders"] - assert "order_totals" in {item["name"] for item in document["not_validated"]} + assert tuple(document["validated"]) == test_case.expected_validated + assert test_case.expected_not_validated_member in { + item["name"] for item in document["not_validated"] + } -def test_given_dry_run_when_reviewing_then_nothing_is_built_or_created(tmp_path: Path) -> None: +@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"}), + ), + ), + 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={STAGING: _SAME_VALUES_STAGING}) @@ -240,10 +288,10 @@ def test_given_dry_run_when_reviewing_then_nothing_is_built_or_created(tmp_path: assert result.returncode == 0, result.stdout + result.stderr document: dict[str, Any] = review_document(result) assert document["dry_run"] is True - assert document["status"] == "planned" + assert document["status"] == test_case.expected_status assert document["review_schema"] is None - assert sorted(document["validated"]) == ["order_totals", "stg_orders"] - assert set(node_statuses(document).values()) <= {"planned", "not_validated"} + assert tuple(sorted(document["validated"])) == test_case.expected_validated + assert set(node_statuses(document).values()) <= test_case.expected_node_statuses assert review_schemas(project_dir=project_dir) == () assert not (project_dir / "target" / "review").exists() @@ -275,7 +323,7 @@ def test_given_dry_run_when_reviewing_then_nothing_is_built_or_created(tmp_path: expected_markdown_fragments=("## Findings", "order_totals", "amount"), ), ), - ids=lambda test_case: test_case.description, + ids=lambda case: case.description, ) def test_given_markdown_path_when_reviewing_then_text_and_markdown_are_written( test_case: ReviewTextOutputE2ETestCase, tmp_path: Path @@ -296,8 +344,19 @@ def test_given_markdown_path_when_reviewing_then_text_and_markdown_are_written( 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( - tmp_path: Path, + 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}) @@ -307,7 +366,7 @@ def test_given_failing_schema_drop_when_reviewing_then_it_exits_3_and_janitor_cl project_dir=project_dir, arguments=("--json",), env=plugin_env ) - assert result.returncode == 3, result.stdout + result.stderr + 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"] @@ -317,7 +376,7 @@ def test_given_failing_schema_drop_when_reviewing_then_it_exits_3_and_janitor_cl leftover: str = schema["name"] assert review_schemas(project_dir=project_dir) == (leftover,) - expired: str = "REVIEW_20200101T000000Z_0000000_000000" + expired: str = test_case.expired_schema execute_review_database( project_dir=project_dir, sql=( From a49feb0ff625f24536d005e12fb5598f4ee29c65 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:23:40 +0100 Subject: [PATCH 5/7] fix: keep sqb review inside its schema and honest about what it checked Build every changed ancestor of a selected node, send every built node to the review schema in one review database, name review schemas in lowercase, list Python nodes and their SQL readers as not validated, report checks that never ran as incomplete, exit 3 on usage errors, and document hooks. --- .../src/review_command/_helpers/graph.rs | 18 +- .../src/review_command/_helpers/scope.rs | 202 +++++++++- .../src/review_command/_helpers/sections.rs | 24 +- .../src/review_command/constants.rs | 19 +- .../src/review_command/main/plan_review.rs | 63 +-- .../review_command/main/render_review_json.rs | 5 + .../review_command/main/review_schema_name.rs | 28 +- .../src/review_command/models.rs | 23 +- .../src/review_command/tests/helpers.rs | 50 ++- .../review_command/tests/test_plan_review.rs | 167 ++++++-- .../tests/test_render_review.rs | 25 +- .../src/review_command/tests/test_types.rs | 8 + .../sqlbuild-cli/src/argv/_helpers/parsing.rs | 24 +- crates/sqlbuild-cli/src/argv/constants.rs | 5 + .../src/argv/main/parse_cli_arguments.rs | 3 + .../main/parse_dbt_execution_arguments.rs | 2 + crates/sqlbuild-cli/src/argv/models.rs | 9 + .../argv/tests/test_parse_cli_arguments.rs | 12 + .../src/native_commands/_helpers/decision.rs | 12 +- .../src/native_commands/_helpers/terminal.rs | 10 + .../main/run_native_invocation.rs | 22 +- .../src/entries/_helpers/executor.rs | 8 +- crates/sqlbuild-host/src/entries/models.rs | 4 +- .../src/bindings/_helpers/cli/argv.rs | 29 +- .../_helpers/janitor/janitor_planning.rs | 13 +- .../_helpers/lineage/column_lineage_graph.rs | 2 +- .../_helpers/review_command/command.rs | 367 +++++++++++++----- .../_helpers/review_command/comparison.rs | 127 ++++-- .../_helpers/review_command/execution.rs | 24 +- .../_helpers/review_command/impact.rs | 34 +- .../_helpers/review_command/planning.rs | 239 +++++++++--- .../_helpers/review_command/schema.rs | 163 ++++---- .../_helpers/review_command/support.rs | 35 +- fensu.toml | 25 +- .../sqlbuild/references/docs/cli/review.md | 66 +++- .../docs/concepts/project-configuration.md | 4 +- src/sqlbuild/_native_host.py | 11 +- src/sqlbuild/_native_host.pyi | 8 +- .../review/main/create_review_schema.py | 37 ++ .../review/main/drop_review_schema.py | 21 + .../cli/commands/main/review/_test_types.py | 57 +++ .../cli/commands/main/review/helpers.py | 57 ++- .../cli/commands/main/review/test_review.py | 230 ++++++++++- .../src/sqlbuild/executor/review/__init__.py | 0 .../sqlbuild/executor/review/main/__init__.py | 0 .../executor/review/main/_test_types.py | 19 + .../sqlbuild/executor/review/main/helpers.py | 41 ++ .../review/main/test_drop_review_schema.py | 132 +++++++ website/src/content/docs/docs/cli/review.mdx | 65 +++- .../docs/concepts/project-configuration.mdx | 4 +- 50 files changed, 2026 insertions(+), 527 deletions(-) create mode 100644 src/sqlbuild/executor/review/main/create_review_schema.py create mode 100644 src/sqlbuild/executor/review/main/drop_review_schema.py create mode 100644 tests/unit/src/sqlbuild/executor/review/__init__.py create mode 100644 tests/unit/src/sqlbuild/executor/review/main/__init__.py create mode 100644 tests/unit/src/sqlbuild/executor/review/main/_test_types.py create mode 100644 tests/unit/src/sqlbuild/executor/review/main/helpers.py create mode 100644 tests/unit/src/sqlbuild/executor/review/main/test_drop_review_schema.py diff --git a/crates/sqlbuild-analysis/src/review_command/_helpers/graph.rs b/crates/sqlbuild-analysis/src/review_command/_helpers/graph.rs index 43fd843a3a..d8a068a185 100644 --- a/crates/sqlbuild-analysis/src/review_command/_helpers/graph.rs +++ b/crates/sqlbuild-analysis/src/review_command/_helpers/graph.rs @@ -19,12 +19,8 @@ pub(crate) fn descendants(graph: &ReviewGraph, roots: &BTreeSet) -> BTre reached } -/// 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 { +/// 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 { @@ -34,6 +30,16 @@ pub(crate) fn ancestors_within( .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() { diff --git a/crates/sqlbuild-analysis/src/review_command/_helpers/scope.rs b/crates/sqlbuild-analysis/src/review_command/_helpers/scope.rs index f7aaca68d1..e9c8ae7c3f 100644 --- a/crates/sqlbuild-analysis/src/review_command/_helpers/scope.rs +++ b/crates/sqlbuild-analysis/src/review_command/_helpers/scope.rs @@ -1,14 +1,128 @@ -//! The scope a downstream policy plans, and the checks on `--select` tokens. +//! The scope a downstream policy plans, the nodes a review cannot rebuild, and `--select` checks. -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; -use crate::review_command::_helpers::graph::ancestors_within; +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::{DownstreamPolicy, ReviewGraph, SelectedToken}; +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 { @@ -18,7 +132,37 @@ fn selection_error(code: &str, message: String, help: &str) -> ReviewPlanError { } } -/// The downstream nodes the policy validates. +/// 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, @@ -38,7 +182,35 @@ pub(crate) fn policy_downstream( } } -/// Reject a token that matches nothing, only unchanged nodes, or only nodes outside the policy. +/// 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), @@ -63,11 +235,12 @@ pub(crate) fn check_token( return Err(selection_error( OUT_OF_SCOPE_SELECTION_CODE, format!( - "--select {} matches only downstream nodes that --downstream {} leaves out", + "--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 node", + "pass --downstream all to review every downstream SQL node", )); } Err(selection_error( @@ -82,10 +255,21 @@ pub(crate) fn check_token( /// Why an affected node is left out of the review. pub(crate) fn not_validated_reason( + graph: &ReviewGraph, name: &str, - (changed, scope): (&BTreeSet, &BTreeSet), + (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(); } diff --git a/crates/sqlbuild-analysis/src/review_command/_helpers/sections.rs b/crates/sqlbuild-analysis/src/review_command/_helpers/sections.rs index 0624978bf4..ffe0f1b1e6 100644 --- a/crates/sqlbuild-analysis/src/review_command/_helpers/sections.rs +++ b/crates/sqlbuild-analysis/src/review_command/_helpers/sections.rs @@ -153,7 +153,10 @@ fn node_status(node: &NodeReport) -> String { "not validated: {}", node.reason.as_deref().unwrap_or("left out") ), - other => other.to_owned(), + other => match &node.reason { + Some(reason) => format!("{other}: {reason}"), + None => other.to_owned(), + }, }; if node.critical { status.push_str(", critical"); @@ -402,6 +405,25 @@ pub(crate) fn run_lines(report: &ReviewReport, style: Style) -> Vec { 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: {}", diff --git a/crates/sqlbuild-analysis/src/review_command/constants.rs b/crates/sqlbuild-analysis/src/review_command/constants.rs index dadd1472d3..688eb2d1de 100644 --- a/crates/sqlbuild-analysis/src/review_command/constants.rs +++ b/crates/sqlbuild-analysis/src/review_command/constants.rs @@ -47,11 +47,20 @@ pub const UNKNOWN_SELECTION_CODE: &str = "C761"; pub const UNCHANGED_SELECTION_CODE: &str = "C762"; /// A `--select` token outside the downstream policy. pub const OUT_OF_SCOPE_SELECTION_CODE: &str = "C763"; -/// Nothing changed since the base revision. -pub const NOTHING_CHANGED_CODE: &str = "C764"; +/// `--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"; +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. @@ -65,8 +74,8 @@ 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 seconds. -pub const LEFTOVER_REVIEW_SCHEMA_AGE_SECONDS: i64 = 24 * 60 * 60; +/// 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"; diff --git a/crates/sqlbuild-analysis/src/review_command/main/plan_review.rs b/crates/sqlbuild-analysis/src/review_command/main/plan_review.rs index 45118f8244..0974c5407e 100644 --- a/crates/sqlbuild-analysis/src/review_command/main/plan_review.rs +++ b/crates/sqlbuild-analysis/src/review_command/main/plan_review.rs @@ -2,13 +2,12 @@ use std::collections::BTreeSet; -use crate::review_command::_helpers::graph::descendants; use crate::review_command::_helpers::scope::{ - check_token, not_validated_reason, policy_downstream, + ReviewScope, in_order, required_upstream, review_scope, selected_scope, split_affected, }; use crate::review_command::errors::ReviewPlanError; use crate::review_command::models::{ - ChangedNode, DownstreamPolicy, NotValidated, ReviewGraph, ReviewPlan, SelectedToken, + AddedNode, ChangedNode, DownstreamPolicy, ReviewGraph, ReviewPlan, SelectedToken, }; /// Plan a review of `changed` over `graph`; `select` tokens intersect with the planned scope. @@ -18,61 +17,23 @@ pub fn plan_review( policy: DownstreamPolicy, select: Option<&[SelectedToken]>, ) -> Result { - let changed_names: BTreeSet = changed + 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(|node| graph.nodes.contains_key(&node.name)) - .map(|node| node.name.clone()) - .collect(); - let downstream: BTreeSet = descendants(graph, &changed_names) - .into_iter() - .filter(|name| !changed_names.contains(name)) - .collect(); - let affected: BTreeSet = changed_names.union(&downstream).cloned().collect(); - let critical: BTreeSet = affected - .iter() - .filter(|name| graph.nodes.get(*name).is_some_and(|node| node.critical)) + .filter(|name| graph.nodes.get(*name).is_some_and(|node| node.has_hooks)) .cloned() .collect(); - let mut scope: BTreeSet = changed_names.clone(); - scope.extend(policy_downstream( - graph, - policy, - (&changed_names, &downstream), - &critical, - )); - let validated_set: BTreeSet = match select { - None => scope.clone(), - Some(tokens) => { - let mut selected: BTreeSet = BTreeSet::new(); - for token in tokens { - check_token(token, (graph, policy), (&affected, &scope))?; - selected.extend(token.names.iter().cloned()); - } - scope.intersection(&selected).cloned().collect() - } - }; - let mut validated: Vec = Vec::new(); - let mut not_validated: Vec = Vec::new(); - for name in &graph.order { - if validated_set.contains(name) { - validated.push(name.clone()); - } else if affected.contains(name) { - not_validated.push(NotValidated { - name: name.clone(), - reason: not_validated_reason(name, (&changed_names, &scope), policy), - }); - } - } Ok(ReviewPlan { policy, changed: changed.to_vec(), validated, not_validated, - critical: graph - .order - .iter() - .filter(|name| critical.contains(*name)) - .cloned() - .collect(), + 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 index 5307baf70f..a07e6b9e3e 100644 --- a/crates/sqlbuild-analysis/src/review_command/main/render_review_json.rs +++ b/crates/sqlbuild-analysis/src/review_command/main/render_review_json.rs @@ -47,6 +47,11 @@ pub fn render_review_json(report: &ReviewReport) -> String { "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, 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 index 5982143246..ff52b2225d 100644 --- a/crates/sqlbuild-analysis/src/review_command/main/review_schema_name.rs +++ b/crates/sqlbuild-analysis/src/review_command/main/review_schema_name.rs @@ -2,20 +2,24 @@ use crate::review_command::constants::{SCHEMA_COMMIT_DIGITS, SCHEMA_RUN_DIGITS}; -/// `___`, which `parse_review_schema_name` reads back. -#[must_use] -pub fn review_schema_name(prefix: &str, timestamp: &str, commit: &str, run_id: &str) -> String { - let commit: String = commit - .chars() - .filter(char::is_ascii_hexdigit) - .take(SCHEMA_COMMIT_DIGITS) - .collect::() - .to_ascii_lowercase(); - let run: String = run_id +/// 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(SCHEMA_RUN_DIGITS) + .take(digits) .collect::() .to_ascii_lowercase(); - format!("{prefix}_{timestamp}_{commit}_{run}") + 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/models.rs b/crates/sqlbuild-analysis/src/review_command/models.rs index ee708efb31..07af07a94b 100644 --- a/crates/sqlbuild-analysis/src/review_command/models.rs +++ b/crates/sqlbuild-analysis/src/review_command/models.rs @@ -14,19 +14,23 @@ pub enum DownstreamPolicy { None, } -/// One buildable project node: a model or a seed. +/// 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`. + /// `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 buildable project graph a review plans over. +/// The project graph a review plans over: models, seeds and Python nodes. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ReviewGraph { pub nodes: BTreeMap, @@ -42,6 +46,8 @@ 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. @@ -58,6 +64,13 @@ pub struct NotValidated { 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 { @@ -69,6 +82,10 @@ pub struct ReviewPlan { 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. diff --git a/crates/sqlbuild-analysis/src/review_command/tests/helpers.rs b/crates/sqlbuild-analysis/src/review_command/tests/helpers.rs index 04cbee07d3..f6f2273e23 100644 --- a/crates/sqlbuild-analysis/src/review_command/tests/helpers.rs +++ b/crates/sqlbuild-analysis/src/review_command/tests/helpers.rs @@ -1,7 +1,7 @@ use std::collections::BTreeMap; use crate::review_command::models::{ - ChangedNode, ColumnEvidence, DownstreamPolicy, IncompleteCheck, IncrementalStart, + AddedNode, ChangedNode, ColumnEvidence, DownstreamPolicy, IncompleteCheck, IncrementalStart, NodeComparison, NodeReport, ReviewBaseline, ReviewExample, ReviewFailure, ReviewFinding, ReviewGraph, ReviewNode, ReviewPlan, ReviewReport, ReviewSchemaReport, SelectedToken, ValidationCounts, @@ -16,19 +16,35 @@ fn node(name: &str, kind: &str, critical: bool) -> (String, ReviewNode) { critical, materialized: "table".to_owned(), unique_key: vec!["order_id".to_owned()], - has_cursor: false, + has_hooks: name == "order_totals", + ..ReviewNode::default() }, ) } -/// A waffle shop whose `stg_orders` reaches a critical `daily_revenue` and an `order_archive`. +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); 5] = [ + 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 { @@ -46,18 +62,26 @@ pub(super) fn waffle_shop() -> ReviewGraph { 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()) @@ -70,6 +94,7 @@ pub(super) fn changed(name: &str, reason: &str) -> ChangedNode { name: name.to_owned(), kind: "model".to_owned(), reasons: vec![reason.to_owned()], + added: false, } } @@ -102,7 +127,7 @@ 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()), + 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), @@ -141,6 +166,8 @@ pub(super) fn finding_report() -> ReviewReport { 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(), @@ -192,7 +219,7 @@ pub(super) fn finding_report() -> ReviewReport { }, schema: Some(ReviewSchemaReport { database: None, - name: "REVIEW_20261023T101500Z_3f2a9c1_a1b2c3".to_owned(), + name: "review_20261023t101500z_3f2a9c1_a1b2c3".to_owned(), created: true, dropped: true, error: None, @@ -202,6 +229,17 @@ pub(super) fn finding_report() -> ReviewReport { } } +/// `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(); 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 index d185189747..5ff3c3d7c7 100644 --- a/crates/sqlbuild-analysis/src/review_command/tests/test_plan_review.rs +++ b/crates/sqlbuild-analysis/src/review_command/tests/test_plan_review.rs @@ -6,9 +6,17 @@ 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::{ - PlanErrorTestCase, PlanTestCase, PolicyTestCase, SchemaNameBuildTestCase, SchemaNameTestCase, + 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 = [ @@ -18,13 +26,11 @@ fn given_changed_models_when_planning_then_validated_set_follows_policy_and_sele policy: DownstreamPolicy::Critical, select: None, expected_validated: vec!["stg_orders", "order_totals", "daily_revenue"], - expected_not_validated: vec![( - "order_archive", - "not on a path to a critical model (--downstream critical)", - )], + expected_not_validated: vec![ARCHIVE_LEFT_OUT, EXPORT_TASK], + expected_added: vec![], }, PlanTestCase { - description: "all builds every downstream node", + description: "all builds every downstream SQL node and lists the Python node", changed: vec!["stg_orders"], policy: DownstreamPolicy::All, select: None, @@ -34,7 +40,8 @@ fn given_changed_models_when_planning_then_validated_set_follows_policy_and_sele "order_totals", "daily_revenue", ], - expected_not_validated: vec![], + expected_not_validated: vec![EXPORT_TASK], + expected_added: vec![], }, PlanTestCase { description: "none builds only the changed model and lists its downstream", @@ -52,7 +59,9 @@ fn given_changed_models_when_planning_then_validated_set_follows_policy_and_sele "daily_revenue", "downstream of a change (--downstream none)", ), + EXPORT_TASK, ], + expected_added: vec![], }, PlanTestCase { description: "a changed critical model needs no downstream path", @@ -60,7 +69,8 @@ fn given_changed_models_when_planning_then_validated_set_follows_policy_and_sele policy: DownstreamPolicy::Critical, select: None, expected_validated: vec!["daily_revenue"], - expected_not_validated: vec![], + expected_not_validated: vec![EXPORT_TASK], + expected_added: vec![], }, PlanTestCase { description: "a change with no critical downstream builds only itself", @@ -72,6 +82,7 @@ fn given_changed_models_when_planning_then_validated_set_follows_policy_and_sele "customer_lifetime", "not on a path to a critical model (--downstream critical)", )], + expected_added: vec![], }, PlanTestCase { description: "select intersects with the planned scope", @@ -85,13 +96,12 @@ fn given_changed_models_when_planning_then_validated_set_follows_policy_and_sele "customer_lifetime", "not on a path to a critical model (--downstream critical)", ), - ( - "order_archive", - "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", @@ -107,10 +117,55 @@ fn given_changed_models_when_planning_then_validated_set_follows_policy_and_sele ], )]), expected_validated: vec!["stg_orders", "order_totals", "daily_revenue"], - expected_not_validated: vec![( - "order_archive", - "not on a path to a critical model (--downstream critical)", - )], + 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 { @@ -143,6 +198,49 @@ fn given_changed_models_when_planning_then_validated_set_follows_policy_and_sele "{}", 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 + ); } } @@ -170,6 +268,13 @@ fn given_bad_selection_when_planning_then_fails_with_code() { 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, @@ -181,7 +286,10 @@ fn given_bad_selection_when_planning_then_fails_with_code() { for test_case in test_cases { let error = plan_review( &waffle_shop(), - &[changed("stg_orders", "model SQL changed")], + &[ + changed("stg_orders", "model SQL changed"), + changed("loader:raw_toppings", "changed"), + ], test_case.policy, Some(&test_case.select), ) @@ -237,16 +345,25 @@ fn given_policy_text_when_parsing_then_known_values_map() { #[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 folded", - parts: ("REVIEW", "20261023T101500Z", "3F2A9C1D88", "a1b2c3d4e5f6"), - expected_name: "REVIEW_20261023T101500Z_3f2a9c1_a1b2c3", - }]; + 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; - assert_eq!( - review_schema_name(prefix, timestamp, commit, run), - test_case.expected_name, + 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 ); 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 index d4c5fdfff2..78ebe04dd4 100644 --- a/crates/sqlbuild-analysis/src/review_command/tests/test_render_review.rs +++ b/crates/sqlbuild-analysis/src/review_command/tests/test_render_review.rs @@ -5,7 +5,8 @@ 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, + 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}; @@ -82,6 +83,15 @@ fn given_reports_when_rendering_json_then_document_is_versioned_and_complete() { ("/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(), @@ -132,7 +142,7 @@ fn given_reports_when_rendering_markdown_then_sections_follow_the_agreed_layout( "## 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`", + "## 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![], }, @@ -146,12 +156,21 @@ fn given_reports_when_rendering_markdown_then_sections_follow_the_agreed_layout( ], 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", + "- Review schema `review_20261023t101500z_3f2a9c1_a1b2c3`: not dropped: permission denied", ], unexpected_fragments: vec![], }, diff --git a/crates/sqlbuild-analysis/src/review_command/tests/test_types.rs b/crates/sqlbuild-analysis/src/review_command/tests/test_types.rs index 4ac36b820c..adbcdb2460 100644 --- a/crates/sqlbuild-analysis/src/review_command/tests/test_types.rs +++ b/crates/sqlbuild-analysis/src/review_command/tests/test_types.rs @@ -10,6 +10,8 @@ pub(super) struct PlanTestCase { 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 { @@ -57,3 +59,9 @@ pub(super) struct SchemaNameTestCase { 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/constants.rs b/crates/sqlbuild-cli/src/argv/constants.rs index 84522d84bb..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; 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/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-host/src/entries/_helpers/executor.rs b/crates/sqlbuild-host/src/entries/_helpers/executor.rs index 6db67c7d22..637dbe5d5f 100644 --- a/crates/sqlbuild-host/src/entries/_helpers/executor.rs +++ b/crates/sqlbuild-host/src/entries/_helpers/executor.rs @@ -835,13 +835,13 @@ pub(crate) const ENTRIES: &[HostEntry] = &[ edge: HostEdge::Executor, }, HostEntry { - name: "ReviewSchemaLifecycle", - kind: HostKind::Class, + name: "create_review_schema", + kind: HostKind::Function, edge: HostEdge::Executor, }, HostEntry { - name: "ReviewSchemaMarker", - kind: HostKind::Class, + name: "drop_review_schema", + kind: HostKind::Function, edge: HostEdge::Executor, }, HostEntry { diff --git a/crates/sqlbuild-host/src/entries/models.rs b/crates/sqlbuild-host/src/entries/models.rs index b227618caf..44cb7aa246 100644 --- a/crates/sqlbuild-host/src/entries/models.rs +++ b/crates/sqlbuild-host/src/entries/models.rs @@ -367,8 +367,8 @@ pub enum ExecutorHost { CommandOutputStream, OutputRecordPriority, SqlValueRenderingError, - ReviewSchemaLifecycle, - ReviewSchemaMarker, + CreateReviewSchema, + DropReviewSchema, LeftoverReviewSchemas, } 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/janitor/janitor_planning.rs b/crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_planning.rs index 30a35b4814..47f0bc38e6 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_planning.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_planning.rs @@ -9,12 +9,10 @@ use sqlbuild_analysis::janitor_planning::constants::OLD_NAME_VIEW_REASON; use sqlbuild_analysis::janitor_planning::main::build_archive_name::build_archive_name; use sqlbuild_analysis::janitor_planning::main::query_diff_artifact_name::query_diff_artifact_name; use sqlbuild_analysis::janitor_planning::main::query_diff_artifact_parts::query_diff_artifact_parts; -use sqlbuild_analysis::review_command::constants::LEFTOVER_REVIEW_SCHEMA_AGE_SECONDS; +use sqlbuild_analysis::review_command::constants::LEFTOVER_REVIEW_SCHEMA_AGE_DAYS; use sqlbuild_host::entries::models::ExecutorHost; -use crate::bindings::_helpers::boundary::python_calls::{ - KeywordArguments, call_with, imported, within, -}; +use crate::bindings::_helpers::boundary::python_calls::{KeywordArguments, call_with, within}; use crate::bindings::_helpers::janitor::janitor_classification::{ Candidate, Policy, Protection, SchemaClassification, classify_target_schemas, plan_archives, }; @@ -23,7 +21,7 @@ use crate::bindings::_helpers::janitor::janitor_facts::{ gather_warehouse_facts, query_diff_artifact_candidates, }; use crate::bindings::_helpers::janitor::janitor_keys::{ - Folded, Key, SchemaKey, archive_timestamp, dedupe_schema_keys, integer, now_utc, record, + Folded, Key, SchemaKey, archive_timestamp, days, dedupe_schema_keys, integer, now_utc, record, sorted_schemas, timestamp_fields, tuple, union_exact, }; use crate::bindings::_helpers::janitor::janitor_old_names::{ @@ -134,10 +132,7 @@ fn review_schema_candidates<'py>( if schema.is_none() { return Ok(Vec::new()); } - let max_age: Bound<'py, PyAny> = call_with( - &imported(py, "datetime", "timedelta")?, - &[("seconds", integer(py, LEFTOVER_REVIEW_SCHEMA_AGE_SECONDS)?)], - )?; + 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)?, &[ 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/review_command/command.rs b/crates/sqlbuild-python/src/bindings/_helpers/review_command/command.rs index df90a11954..e8528f0234 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/review_command/command.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/command.rs @@ -8,10 +8,11 @@ use pyo3::exceptions::PyException; use pyo3::prelude::{ Bound, PyAny, PyAnyMethods, PyErr, PyModule, PyModuleMethods, PyResult, Python, }; -use pyo3::{pyfunction, wrap_pyfunction}; +use pyo3::{IntoPyObject, pyfunction, wrap_pyfunction}; use sqlbuild_analysis::review_command::constants::{ - AUDIT_FINDING, BUILT_NODE, FAILED_NODE, FINDING_NODE, MODEL_KIND, NO_COMPARISON_MODE, - NOT_VALIDATED_NODE, OK_NODE, PASSES_LIMIT, PLANNED_STATUS, TEST_FINDING, UNIT_TEST_LABEL, + 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; @@ -20,38 +21,45 @@ use sqlbuild_analysis::review_command::main::render_review_markdown::render_revi 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::{ - NodeReport, ReviewFinding, ReviewNode, ReviewPlan, ReviewReport, ReviewSchemaReport, - ValidationCounts, + IncompleteCheck, NodeReport, ReviewFailure, ReviewFinding, ReviewNode, ReviewPlan, + ReviewReport, ReviewSchemaReport, ValidationCounts, }; use sqlbuild_core::git_revisions::main::head_commit::head_commit; -use crate::bindings::_helpers::boundary::python_calls::imported; 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, compare_models, + 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::{ - ReviewProject, ReviewSettings, discover, review_project, review_settings, selected_tokens, + 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, utc_now_compact, + 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)> { +fn impact_edges(project: &ReviewProject, plan: &ReviewPlan) -> Vec<(String, String)> { let affected: BTreeSet<&str> = plan .changed .iter() @@ -74,7 +82,7 @@ fn impact_edges(project: &ReviewProject<'_>, plan: &ReviewPlan) -> Vec<(String, } /// Upstream nodes of the validated set that the build reads from `--against`. -fn deferred(project: &ReviewProject<'_>, validated: &[String]) -> Vec { +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 { @@ -94,7 +102,7 @@ fn deferred(project: &ReviewProject<'_>, validated: &[String]) -> Vec { } /// The impact tree's nodes before anything runs. -fn planned_nodes(project: &ReviewProject<'_>, plan: &ReviewPlan, dry_run: bool) -> Vec { +fn planned_nodes(project: &ReviewProject, plan: &ReviewPlan, dry_run: bool) -> 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 @@ -172,7 +180,7 @@ fn settled_status( _ if with_findings.contains(&node.name) => (FINDING_NODE.to_owned(), reason), Some(SKIPPED_STATUS) => ( NOT_VALIDATED_NODE.to_owned(), - Some("skipped after an upstream failure".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), @@ -182,39 +190,74 @@ fn settled_status( } } +/// 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: &BuildOutcome, - comparisons: Comparisons, - affected: &BTreeMap>, + (build, comparisons): (&BuildOutcome, Comparisons), + (affected, new): (&BTreeMap>, &BTreeSet), ) -> ReviewReport { let Comparisons { mut findings, mut by_node, - incomplete, + 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 node.status == NOT_VALIDATED_NODE { + 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: by_node.remove(&node.name), + comparison, + reason: new_relation + .then(|| NEW_NODE_REASON.to_owned()) + .or(node.reason.clone()), ..node }; let (status, reason) = settled_status(&built, build, &with_findings); @@ -225,6 +268,12 @@ fn settle_nodes( } }) .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, @@ -234,37 +283,68 @@ fn settle_nodes( } /// The directory the review's sub-runs write their result documents to. -fn work_directory(project: &ReviewProject<'_>, schema: &ReviewSchema<'_>) -> PyResult { +fn work_directory(project: &ReviewProject, schema: &ReviewSchema) -> PyResult { let work_dir: PathBuf = REVIEW_WORK_DIRECTORY .iter() - .fold(project.project_dir.extract::()?, |path, part| { - path.join(part) - }) + .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, schema): (&ReviewSettings, &ReviewProject<'_>, &ReviewSchema<'_>), - (connection_config, connection): (&Bound<'_, PyAny>, &Bound<'_, PyAny>), + (((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 review_discovered: Bound<'_, PyAny> = - with_review_target(&project.discovered, settings, schema)?; + 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_discovered, + review_discovered, review_target: &schema.name, work_dir: &work_dir, - machine, + output: (stream, machine), }; let validated: Vec = report.plan.validated.clone(); let stateful: Vec = validated @@ -272,8 +352,7 @@ fn validate( .filter(|name| project.stateful.contains(*name)) .cloned() .collect(); - report.incremental_starts = - incremental_starts(&inputs, (settings, &project.adapter), &stateful)?; + report.incremental_starts = incremental_starts(&inputs, (settings, adapter), &stateful)?; let build: BuildOutcome = phase( stream, ( @@ -298,16 +377,12 @@ fn validate( ), || run_changed_tests(&inputs, settings), )?; - let comparable: Vec<&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(); + let models: Vec<&ReviewNode> = comparable(project, &validated, &build); + let new: BTreeSet = new_nodes(&report); let context: ComparisonContext<'_, '_> = ComparisonContext { request, - discovered: &review_discovered, - adapter: &project.adapter, + discovered: review_discovered, + adapter, connection, connection_config, review_target: &schema.name, @@ -317,13 +392,13 @@ fn validate( ( &format!( "Comparing {} models with {}", - comparable.len(), + models.len(), settings.against ), "Compared the review models", "Comparison failed", ), - || compare_models(&context, settings, &comparable), + || compare_models(&context, sides, (&models, &new)), )?; let affected_set: BTreeSet = report.nodes.iter().map(|node| node.name.clone()).collect(); @@ -334,11 +409,15 @@ fn validate( .map(|node| node.name.clone()) .collect(); let affected: BTreeMap> = affected_columns( - &project.project, + &native_field(graph, "project")?, (&changed, &affected_set), &comparisons.by_node, )?; - report = settle_nodes(std::mem::take(&mut report), &build, comparisons, &affected); + report = settle_nodes( + std::mem::take(&mut report), + (&build, comparisons), + (&affected, &new), + ); Ok(()) })(); Attempt { report, outcome } @@ -356,69 +435,118 @@ fn dropped_state(py: Python<'_>, dropped: PyResult) -> PyResult<(bool, Opt } } -/// Connect and name this run's review schema. +/// 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): (&ReviewSettings, &ReviewProject<'py>), + (settings, project, compiled): (&ReviewSettings, &ReviewProject, &Compiled<'py>), report: &ReviewReport, -) -> PyResult<(Bound<'py, PyAny>, Bound<'py, PyAny>, ReviewSchema<'py>)> { +) -> PyResult<(Bound<'py, PyAny>, Bound<'py, PyAny>, ReviewSchema)> { let py: Python<'py> = request.py(); - let config: Bound<'py, PyAny> = connection_config( - request, - (&project.project_dir, &project.discovered), - settings, - )?; + let (discovered, adapter, _) = compiled; let run_id: String = report .invocation_id .clone() .unwrap_or_else(|| format!("{:x}", std::process::id())); - let schema: ReviewSchema<'py> = review_schema( - (&project.project, &project.discovered), - settings, + let schema: ReviewSchema = review_schema( + py, + (project, settings, &report.plan.validated), (utc_now_compact(py)?, &report.baseline.head, &run_id), )?; - let connection: Bound<'py, PyAny> = project.adapter.call_method1("connect", (&config,))?; + 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): (&ReviewSettings, &ReviewProject<'_>), + (settings, project, compiled): (&ReviewSettings, &ReviewProject, &Compiled<'_>), report: ReviewReport, ) -> Attempt { let py: Python<'_> = request.py(); - let (config, connection, schema) = match open_review(request, (settings, project), &report) { - Ok(opened) => opened, - Err(error) => { - return Attempt { - report, - outcome: Err(error), - }; - } - }; - let adapter: &Bound<'_, PyAny> = &project.adapter; + 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 created: PyResult<()> = phase( + let prepared = phase( stream, ( - &format!("Creating review schema {}", schema.name), - "Created the review schema", - "Could not create the review schema", + &format!("Checking the relations to compare in {}", settings.against), + "Checked the relations to compare", + "Could not prepare the comparison", ), - || create_review_schema((adapter, &connection), &schema), + || 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(()) => { + Ok((review_discovered, sides)) => { state.created = true; validate( (request, stream, machine), - (settings, project, &schema), - (&config, &connection), + (run, &schema), + (&review_discovered, sides.as_ref()), report, ) } @@ -459,7 +587,7 @@ fn review( report: ReviewReport, ) -> Attempt { let mut report: ReviewReport = report; - let planned: PyResult)>> = (|| { + let planned: PyResult)>> = (|| { let py: Python<'_> = request.py(); let (project_dir, discovered) = discover(request)?; let settings: ReviewSettings = review_settings(request, (&project_dir, &discovered))?; @@ -468,7 +596,7 @@ fn review( 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: ReviewProject<'_> = phase( + let (project, compiled): (ReviewProject, Compiled<'_>) = phase( stream, ( "Planning the review", @@ -481,7 +609,7 @@ fn review( 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, &project.graph)?; + let tokens = selected_tokens(request, &compiled.2)?; report.plan = plan_review( &project.review_graph, &project.changed, @@ -502,13 +630,24 @@ fn review( 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))) + Ok((!skip).then_some((settings, project, compiled))) })(); match planned { - Ok(Some((settings, project))) => { - run_in_schema((request, stream, machine), (&settings, &project), report) - } + Ok(Some((settings, project, compiled))) => run_in_schema( + (request, stream, machine), + (&settings, &project, &compiled), + report, + ), Ok(None) => Attempt { report, outcome: Ok(()), @@ -520,28 +659,37 @@ fn review( } } -fn publish(request: &Bound<'_, PyAny>, report: &ReviewReport, machine: bool) -> PyResult<()> { - let py: Python<'_> = request.py(); - if let Some(path) = native_field(request, "markdown_path")?.extract::>()? { - if let Some(parent) = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { - std::fs::create_dir_all(parent) - .map_err(|error| pyo3::exceptions::PyOSError::new_err(error.to_string()))?; - } - std::fs::write(&path, render_review_markdown(report)) - .map_err(|error| pyo3::exceptions::PyOSError::new_err(error.to_string()))?; - } - let stdout: Bound<'_, PyAny> = imported(py, "sys", "stdout")?; +/// 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, then write `--markdown`; the Markdown failure, if any. +fn publish( + request: &Bound<'_, PyAny>, + report: &ReviewReport, + machine: bool, +) -> PyResult> { let document: String = if machine { render_review_json(report) } else { render_review_text(report) }; - stdout.call_method1("write", (document,))?; - stdout.call_method0("flush")?; - Ok(()) + write_stdout_bytes(request.py(), document.as_bytes())?; + write_markdown(request, report) } /// Run one `sqb review` request. @@ -551,11 +699,7 @@ pub(crate) fn run_review_command(py: Python<'_>, request: &Bound<'_, PyAny>) -> 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> = imported( - py, - "sys", - if machine || debug { "stderr" } else { "stdout" }, - )?; + 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()?, @@ -572,14 +716,29 @@ pub(crate) fn run_review_command(py: Python<'_>, request: &Bound<'_, PyAny>) -> .transpose()?, ..report }; - publish(request, &report, machine)?; + let markdown_failure: Option = publish(request, &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(message) = markdown_failure { + let failure = ReviewFailure { + code: MARKDOWN_WRITE_CODE.to_owned(), + message, + help: Some("pass a --markdown path in a writable directory".to_owned()), + }; + return Err(failed_error( + py, + &failure, + pyo3::exceptions::PyOSError::new_err(failure.message.clone()), + )); + } Ok(review_outcome(&report).1) } diff --git a/crates/sqlbuild-python/src/bindings/_helpers/review_command/comparison.rs b/crates/sqlbuild-python/src/bindings/_helpers/review_command/comparison.rs index 72839ed94c..3278d05ada 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/review_command/comparison.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/comparison.rs @@ -1,12 +1,12 @@ //! Compare each built model with `--against`: schema, row counts, keys and values. -use std::collections::BTreeMap; +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, - NO_COMPARISON_MODE, SCHEMA_ONLY_MODE, + MISSING_AGAINST_CODE, NO_COMPARISON_MODE, SCHEMA_ONLY_MODE, }; use sqlbuild_analysis::review_command::models::{ ColumnEvidence, IncompleteCheck, NodeComparison, ReviewExample, ReviewFinding, ReviewNode, @@ -26,9 +26,11 @@ 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; @@ -41,7 +43,7 @@ pub(crate) struct Comparisons { pub(crate) incomplete: Vec, } -/// The two compiled projects and the connection a comparison runs on. +/// 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>, @@ -68,13 +70,18 @@ fn key_text(values: &[(String, DiffValue)]) -> String { .join(", ") } -fn models_by_name<'py>( +/// The names of `project`'s models among `names`, each with its compiled model. +fn models_named<'py>( project: &Bound<'py, PyAny>, -) -> PyResult>> { - let mut models: BTreeMap> = BTreeMap::new(); + 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?; - models.insert(native_field(&model, "name")?.extract()?, model); + let name: String = native_field(&model, "name")?.extract()?; + if names.contains(&name) { + models.push((name, model)); + } } Ok(models) } @@ -108,10 +115,9 @@ fn row_count(context: &ComparisonContext<'_, '_>, relation: &str) -> PyResult = cursor.call_method0("fetchone")?; - if row.is_none() { + let Some(row) = fetch_one(&cursor)? else { return Ok(None); - } + }; row.get_item(0)?.extract() } @@ -307,13 +313,13 @@ fn evidence(facts: &DiffModelFacts, mode: &str) -> (NodeComparison, Vec { +/// 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>, - left_models: BTreeMap>, 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. @@ -438,38 +444,58 @@ fn compare_keyed<'py>( }) } -/// One model: not compared when `--against` lacks it, else keyed or unkeyed. +/// One model: keyed or unkeyed, or uncompared when `--against` lacks it, incomplete unless new. fn compare_model( context: &ComparisonContext<'_, '_>, sides: &Sides<'_>, - node: &ReviewNode, + (node, new): (&ReviewNode, bool), ) -> PyResult { - let exists: bool = match sides.left_models.get(&node.name) { - Some(model) => target_exists(context, model)?, - None => false, - }; - if !exists { + if !sides.against_has.contains(&node.name) { return Ok(ModelComparison { comparison: Some(NodeComparison { mode: NO_COMPARISON_MODE.to_owned(), - complete: true, + 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() }); } - if node.unique_key.is_empty() { - return compare_unkeyed(context, sides, &node.name); + 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 + }); } - compare_keyed(context, sides, node) + Ok(compared) } -/// Compile both sides and read the size limits of an exact comparison. -fn prepare_sides<'py>( +/// 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, - names: &[String], -) -> PyResult> { + (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, @@ -482,13 +508,35 @@ fn prepare_sides<'py>( ("to_target", text(py, context.review_target)), ("resolved_connection", context.connection_config.clone()), ("no_sql_validation", boolean(py, true)), - ("select", texts(py, names)?), + ("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)?, &[ @@ -504,29 +552,28 @@ fn prepare_sides<'py>( ), ], )?; - Ok(Sides { - left_models: models_by_name(&left)?, + 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<'_, '_>, - settings: &ReviewSettings, - models: &[&ReviewNode], + sides: Option<&Sides<'_>>, + (models, new): (&[&ReviewNode], &BTreeSet), ) -> PyResult { - if models.is_empty() { - return Ok(Comparisons::default()); - } - let names: Vec = models.iter().map(|node| node.name.clone()).collect(); - let sides: Sides<'_> = prepare_sides(context, settings, &names)?; 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)?; + 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 { diff --git a/crates/sqlbuild-python/src/bindings/_helpers/review_command/execution.rs b/crates/sqlbuild-python/src/bindings/_helpers/review_command/execution.rs index 58e16e8b64..c590e518ed 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/review_command/execution.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/execution.rs @@ -3,6 +3,7 @@ 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::{ @@ -39,14 +40,15 @@ pub(crate) struct BuildOutcome { pub(crate) failed_audits: Vec<(String, String)>, } -/// The run's shared inputs to the sub-runs. +/// 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 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, - pub(crate) machine: bool, + /// 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. @@ -73,9 +75,7 @@ fn field<'a>(item: &'a Value, key: &str) -> &'a str { } fn path_object<'py>(py: Python<'py>, path: &Path) -> PyResult> { - py.import("pathlib")? - .getattr("Path")? - .call1((path.to_path_buf(),)) + path.into_pyobject(py) } /// How each changed stateful model starts: cloned from `--against` when that is free, else full. @@ -124,7 +124,7 @@ pub(crate) fn incremental_starts( py, RecordId::CloneCommandRequest, &[ - ("project_dir", inputs.project_dir.clone()), + ("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)), @@ -136,7 +136,7 @@ pub(crate) fn incremental_starts( ("discovered_inputs", inputs.review_discovered.clone()), ], )?; - let code: i32 = quietly(py, inputs.machine, || run_clone_command(py, &request))?; + let code: i32 = quietly(inputs.output, || run_clone_command(py, &request))?; if code != 0 { return Err(review_error( py, @@ -179,7 +179,7 @@ pub(crate) fn build_validated( py, RecordId::BuildCommandRequest, &[ - ("project_dir", inputs.project_dir.clone()), + ("project_dir", path_object(py, inputs.project_dir)?), ( "no_sql_validation", native_field(inputs.request, "no_sql_validation")?, @@ -201,7 +201,7 @@ pub(crate) fn build_validated( ("discovered_inputs", inputs.review_discovered.clone()), ], )?; - let ran: PyResult = quietly(py, inputs.machine, || run_build_command(py, &request)); + let ran: PyResult = quietly(inputs.output, || run_build_command(py, &request)); let document: Option = read_document(&output)?; let Some(document) = document else { ran?; @@ -278,7 +278,7 @@ pub(crate) fn run_changed_tests( py, RecordId::TestCommandRequest, &[ - ("project_dir", inputs.project_dir.clone()), + ("project_dir", path_object(py, inputs.project_dir)?), ( "no_sql_validation", native_field(inputs.request, "no_sql_validation")?, @@ -291,7 +291,7 @@ pub(crate) fn run_changed_tests( ("since", text(py, &settings.since)), ], )?; - let ran: PyResult = quietly(py, inputs.machine, || run_test_command(py, &request)); + let ran: PyResult = quietly(inputs.output, || run_test_command(py, &request)); let Some(document) = read_document(&output)? else { ran?; return Ok(ValidationCounts::default()); diff --git a/crates/sqlbuild-python/src/bindings/_helpers/review_command/impact.rs b/crates/sqlbuild-python/src/bindings/_helpers/review_command/impact.rs index aef6f9339e..45ec4977ad 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/review_command/impact.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/impact.rs @@ -4,14 +4,15 @@ 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::{PyDict, PyDictMethods, PyFrozenSet}; +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::{NativeAttr, RecordId}; -use crate::bindings::_helpers::records::registry; +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> { @@ -51,19 +52,19 @@ pub(crate) fn affected_columns( } } let names: Vec<&String> = models.keys().collect(); - let lineage: PyResult> = - registry::native(py, NativeAttr::BuildProjectColumnLineage)?.call1(( - project, - py.None(), - enum_member(py, RecordId::ColumnLineageMode, "FAST")?, - PyFrozenSet::new(py, &names)?, - )); - let Ok(lineage) = lineage else { + 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()); }; - if lineage.is_none() { + 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 { @@ -81,10 +82,9 @@ pub(crate) fn affected_columns( if !seen.insert((resource.clone(), column.clone())) { continue; } - let options: Bound<'_, PyDict> = PyDict::new(py); - options.set_item("resource_name", &resource)?; - options.set_item("column_name", &column)?; - let Ok(edges) = lineage.call_method("column_consumers", (), Some(&options)) else { + 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()? { diff --git a/crates/sqlbuild-python/src/bindings/_helpers/review_command/planning.rs b/crates/sqlbuild-python/src/bindings/_helpers/review_command/planning.rs index ffcb1f571c..de9e8aa36e 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/review_command/planning.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/planning.rs @@ -3,8 +3,9 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::PathBuf; +use pyo3::IntoPyObject; use pyo3::prelude::{Bound, PyAny, PyAnyMethods, PyResult, Python}; -use pyo3::types::{PyFrozenSet, PyString, PyTuple}; +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::{ @@ -18,7 +19,7 @@ use sqlbuild_analysis::review_command::models::{ }; use sqlbuild_config::project::models::ReviewConfig; -use crate::bindings::_helpers::boundary::python_calls::{imported, keywords}; +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; @@ -36,8 +37,9 @@ 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; +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"; @@ -46,6 +48,10 @@ 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 { @@ -57,13 +63,12 @@ pub(crate) struct ReviewSettings { critical_tags: Vec, } -/// The compiled project, its changes since the base revision, and the graph a review plans over. -pub(crate) struct ReviewProject<'py> { - pub(crate) project_dir: Bound<'py, PyAny>, - pub(crate) discovered: Bound<'py, PyAny>, - pub(crate) adapter: Bound<'py, PyAny>, - pub(crate) graph: Bound<'py, PyAny>, - pub(crate) project: Bound<'py, PyAny>, +/// 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)>, @@ -72,6 +77,12 @@ pub(crate) struct ReviewProject<'py> { 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> { @@ -177,7 +188,7 @@ pub(crate) fn discover<'py>( let py: Python<'py> = request.py(); let project_dir: Bound<'py, PyAny> = match native_field(request, "project_dir")? { directory if !directory.is_none() => directory, - _ => imported(py, "pathlib", "Path")?.call_method0("cwd")?, + _ => std::env::current_dir()?.into_pyobject(py)?, }; let discovered: Bound<'py, PyAny> = discover_project_inputs( py, @@ -187,7 +198,7 @@ pub(crate) fn discover<'py>( } fn config_value<'py>(values: &Bound<'py, PyAny>, key: &str) -> PyResult> { - values.call_method1("get", (key,)) + mapping_get(values, key) } fn key_columns(value: &Bound<'_, PyAny>) -> PyResult> { @@ -218,9 +229,21 @@ fn model_node(model: &Bound<'_, PyAny>, critical_tags: &[String]) -> PyResult>>()? + .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, @@ -254,22 +277,116 @@ fn topological( order } -/// The buildable graph with each node's direct reads of sources and functions. +/// 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, } -/// The buildable graph, each node's direct reads of sources and functions, and stateful models. -fn review_graph(project: &Bound<'_, PyAny>, critical_tags: &[String]) -> PyResult { - let mut nodes: BTreeMap = BTreeMap::new(); - let mut downstream: BTreeMap> = BTreeMap::new(); - let mut reads: BTreeMap> = BTreeMap::new(); - let mut stateful: BTreeSet = BTreeSet::new(); +/// 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 name: String = seed_reads::name(&seed?)?.extract()?; - nodes.insert( + 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, @@ -286,37 +403,51 @@ fn review_graph(project: &Bound<'_, PyAny>, critical_tags: &[String]) -> PyResul if node.materialized == INCREMENTAL_MATERIALIZATION || node.materialized == SNAPSHOT_MATERIALIZATION { - stateful.insert(node.name.clone()); + 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)?)); - nodes.insert(node.name.clone(), node); + 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) && nodes.contains_key(&upstream) { - downstream.entry(upstream).or_default().push(name.clone()); + 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)); } } - reads.insert(name, read); + project_graph.reads.insert(name, read); } - for children in downstream.values_mut() { + 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 = nodes.keys().cloned().collect(); - let order: Vec = topological(&names, &downstream); - Ok(ProjectGraph { - graph: ReviewGraph { - nodes, - downstream, - order, - }, - reads, - stateful, - }) + 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. @@ -325,7 +456,7 @@ pub(crate) fn review_project<'py>( (project_dir, discovered): (Bound<'py, PyAny>, Bound<'py, PyAny>), settings: &ReviewSettings, stream: &Bound<'py, PyAny>, -) -> PyResult> { +) -> PyResult<(ReviewProject, Compiled<'py>)> { let py: Python<'py> = request.py(); let base = base_comparison( (stream, &project_dir), @@ -362,17 +493,19 @@ pub(crate) fn review_project<'py>( let changed: ChangedProject = changed_project((&project, &discovered), &directory, base)?; let ProjectGraph { graph: review_graph, + keys, reads, stateful, - } = review_graph(&project, &settings.critical_tags)?; + 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: String = node_key(&node.kind, 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, + key, (node_reads, &project_wide), (&changed.current, &changed.changes), ); @@ -381,12 +514,20 @@ pub(crate) fn review_project<'py>( 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()))?; - Ok(ReviewProject { + 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 @@ -395,15 +536,15 @@ pub(crate) fn review_project<'py>( .unwrap_or_default() .to_owned(), changes_json: Some(changes_json), - project_dir, - discovered, - adapter, - graph, - project, + 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. diff --git a/crates/sqlbuild-python/src/bindings/_helpers/review_command/schema.rs b/crates/sqlbuild-python/src/bindings/_helpers/review_command/schema.rs index 173df74b58..83f2ce8070 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/review_command/schema.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/schema.rs @@ -2,6 +2,8 @@ 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; @@ -9,56 +11,75 @@ 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::execution_history::conversion::optional_text; +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::ReviewSettings; +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<'py> { +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_database: Option, marker_schema: String, run_id: String, - created_at: Bound<'py, PyAny>, + created_at: ArchiveTimestamp, } -/// The review schema of this run: `___` in the active target's database. -pub(crate) fn review_schema<'py>( - (project, discovered): (&Bound<'py, PyAny>, &Bound<'py, PyAny>), - settings: &ReviewSettings, - ((created_at, compact), head, run_id): ((Bound<'py, PyAny>, String), &str, &str), -) -> PyResult> { - let py: Python<'py> = project.py(); - let target: Bound<'py, PyAny> = active_target_config(discovered, &settings.target)?; - let marker_schema: Option = - optional_text(&native_field(project, "effective_target_schema")?)?; - let Some(marker_schema) = marker_schema else { - return Err( - crate::bindings::_helpers::review_command::support::review_error( +/// 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, - "C771", + UNKNOWN_REVIEW_DATABASE_CODE, &format!( - "target '{}' has no schema to record the review schema in", - settings.target + "target '{}' sets no database, so the review cannot keep these nodes in one \ + review database: {}", + settings.target, + own.join(", ") ), - Some("set schema on the target in sqlbuild_project.toml"), - ), - ); - }; + Some("set database on the target in sqlbuild_project.toml"), + )); + } + } Ok(ReviewSchema { name: review_schema_name(&settings.schema_prefix, &compact, head, run_id), - database: optional_text(&native_field(&target, "database")?)?, - marker_database: optional_text(&native_field(project, "effective_target_database")?)?, + 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, @@ -72,11 +93,11 @@ fn active_target_config<'py>( ) } -/// `discovered` plus a target that writes only to the review schema, otherwise like the active one. +/// `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<'py>, + schema: &ReviewSchema, ) -> PyResult> { let py: Python<'py> = discovered.py(); let active: Bound<'py, PyAny> = active_target_config(discovered, &settings.target)?; @@ -93,20 +114,21 @@ pub(crate) fn with_review_target<'py>( ("allow_as_clone_destination", boolean(py, true)), ], )?; - let review: Bound<'py, PyAny> = replaced( - &active, - &[ - ("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")?, - ), - ], - )?; + 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::()?)?; @@ -137,55 +159,44 @@ pub(crate) fn connection_config<'py>( ) } -fn lifecycle(py: Python<'_>) -> PyResult> { - host::entry(py, ExecutorHost::ReviewSchemaLifecycle) -} - /// Record ownership in the active target's state table, then create the review schema. -pub(crate) fn create_review_schema<'py>( - (adapter, connection): (&Bound<'py, PyAny>, &Bound<'py, PyAny>), - schema: &ReviewSchema<'py>, +pub(crate) fn create_review_schema( + (adapter, connection): (&Bound<'_, PyAny>, &Bound<'_, PyAny>), + schema: &ReviewSchema, ) -> PyResult<()> { - let py: Python<'py> = adapter.py(); - let marker: Bound<'py, PyAny> = call_with( - &host::entry(py, ExecutorHost::ReviewSchemaMarker)?, + 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.marker_database.as_deref()), - ), + ("marker_database", optional(py, schema.database.as_deref())), ("marker_schema", text(py, &schema.marker_schema)), ("run_id", text(py, &schema.run_id)), - ], - )?; - call_with( - &lifecycle(py)?.getattr("create")?, - &[ - ("adapter", adapter.clone()), - ("connection", connection.clone()), - ("marker", marker), - ("created_at", schema.created_at.clone()), + ("created_at", utc_datetime(py, schema.created_at)?), ], )?; Ok(()) } -/// Drop the review schema and confirm it is gone. +/// Drop the review schema; whether it is gone afterwards. pub(crate) fn drop_review_schema( (adapter, connection): (&Bound<'_, PyAny>, &Bound<'_, PyAny>), - schema: &ReviewSchema<'_>, + schema: &ReviewSchema, ) -> PyResult { let py: Python<'_> = adapter.py(); - let location = [ - ("adapter", adapter.clone()), - ("connection", connection.clone()), - ("database", optional(py, schema.database.as_deref())), - ("schema", text(py, &schema.name)), - ]; - call_with(&lifecycle(py)?.getattr("drop")?, &location)?; - Ok(!call_with(&lifecycle(py)?.getattr("exists")?, &location)?.is_truthy()?) + 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> { diff --git a/crates/sqlbuild-python/src/bindings/_helpers/review_command/support.rs b/crates/sqlbuild-python/src/bindings/_helpers/review_command/support.rs index dcb2665a0f..d4216cd5c2 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/review_command/support.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/support.rs @@ -4,11 +4,12 @@ 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::{imported, within}; +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}; @@ -109,36 +110,42 @@ pub(crate) fn phase( outcome } -/// Run `body` with `sys.stdout` sent to `sys.stderr` when stdout must stay machine-readable. +/// 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( - py: Python<'_>, - machine: bool, + (progress, machine): (&Bound<'_, PyAny>, bool), body: impl FnOnce() -> PyResult, ) -> PyResult { if !machine { return body(); } - let redirect: Bound<'_, PyAny> = imported(py, "contextlib", "redirect_stdout")?; - within(&redirect.call1((imported(py, "sys", "stderr")?,))?, |_| { - 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<(Bound<'_, PyAny>, String)> { - let now: Bound<'_, PyAny> = now_utc(py)?; - let fields = timestamp_fields(&now)?; +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((now, compact)) + 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> = - host::entry(py, ExecutorHost::CurrentExecutionIdentity)?.call0()?; + let identity: Bound<'_, PyAny> = call_with( + &host::entry(py, ExecutorHost::CurrentExecutionIdentity)?, + &[], + )?; if identity.is_none() { return Ok(None); } diff --git a/fensu.toml b/fensu.toml index 3f7b8c6a95..b554e3acbe 100644 --- a/fensu.toml +++ b/fensu.toml @@ -398,13 +398,13 @@ symbols = ["QueryDiffArtifactLifecycle"] reason = "Native code resolves these through the sqlbuild._native_host name table." [[targets.python.dead_code.roots]] -modules = ["sqlbuild.executor.review.classes.review_schema_lifecycle"] -symbols = ["ReviewSchemaLifecycle"] +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.models"] -symbols = ["ReviewSchemaMarker"] +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]] @@ -1925,7 +1925,7 @@ pending_budgets = [ "crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_old_names.rs=3", "crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_phases.rs=17", "crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_plan.rs=4", - "crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_planning.rs=3", + "crates/sqlbuild-python/src/bindings/_helpers/janitor/janitor_planning.rs=2", "crates/sqlbuild-python/src/bindings/_helpers/lifecycle_events/envelopes.rs=4", "crates/sqlbuild-python/src/bindings/_helpers/lifecycle_events/payloads.rs=5", "crates/sqlbuild-python/src/bindings/_helpers/lifecycle_events/validation.rs=4", @@ -2133,13 +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=6", - "crates/sqlbuild-python/src/bindings/_helpers/review_command/comparison.rs=3", - "crates/sqlbuild-python/src/bindings/_helpers/review_command/execution.rs=4", - "crates/sqlbuild-python/src/bindings/_helpers/review_command/impact.rs=3", - "crates/sqlbuild-python/src/bindings/_helpers/review_command/planning.rs=3", - "crates/sqlbuild-python/src/bindings/_helpers/review_command/schema.rs=3", - "crates/sqlbuild-python/src/bindings/_helpers/review_command/support.rs=5", + "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", @@ -2458,10 +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=9", + "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/review_command/planning.rs=5", - "crates/sqlbuild-python/src/bindings/_helpers/review_command/schema.rs=1", "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", diff --git a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/review.md b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/review.md index 9aa3844d19..3762580046 100644 --- a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/review.md +++ b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/review.md @@ -13,6 +13,7 @@ Online: https://sqlbuild.com/docs/cli/review/ - What gets built - Selecting - The review schema +- Hooks - Checks - Output - Exit codes @@ -51,7 +52,7 @@ with `[git] base_ref` and `[review] against` runs a review with plain `sqb revie | `--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 without connecting to the warehouse | +| `--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 `` | @@ -77,6 +78,13 @@ source declaration or unit test, are listed under "What changed" but are not bui 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 @@ -86,26 +94,44 @@ 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. Each selector must name something the review can -check, or the review fails before connecting: +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 | `C763` | +| 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 `___`, for example -`REVIEW_20261023T101500Z_3f9c2ab_4e1d0a`. 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. The schema is always dropped when the review ends, whether it passed or not; a failed drop +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 @@ -118,9 +144,17 @@ The adapter must be able to drop a whole schema: SQLite and SQL Server targets c 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 could not run, such as -the keyed diff of a model without a `unique_key`, is reported as **incomplete coverage**. A model -that does not exist in `--against` yet is built but not compared, and the Baseline section says so. +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 @@ -145,9 +179,13 @@ that list only the columns that changed. | `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 | +| `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. +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 @@ -158,7 +196,7 @@ base_ref = "origin/main" [review] against = "prod" downstream = "critical" -schema_prefix = "REVIEW" +schema_prefix = "review" critical_tags = ["critical"] ``` 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 632fe555dc..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 @@ -909,7 +909,7 @@ Defaults for [`sqb review`](../cli/review.md): [review] against = "prod" downstream = "critical" -schema_prefix = "REVIEW" +schema_prefix = "review" critical_tags = ["critical"] ``` @@ -917,7 +917,7 @@ critical_tags = ["critical"] |-------|---------|-------------| | `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 | +| `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/_native_host.py b/src/sqlbuild/_native_host.py index 095315f12d..93ffba817f 100644 --- a/src/sqlbuild/_native_host.py +++ b/src/sqlbuild/_native_host.py @@ -360,11 +360,14 @@ "sqlbuild.executor.diff.classes.query_artifact_lifecycle", "QueryDiffArtifactLifecycle", ), - "ReviewSchemaLifecycle": ( - "sqlbuild.executor.review.classes.review_schema_lifecycle", - "ReviewSchemaLifecycle", + "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", ), - "ReviewSchemaMarker": ("sqlbuild.executor.review.models", "ReviewSchemaMarker"), "leftover_review_schemas": ( "sqlbuild.executor.review.main.leftover_review_schemas", "leftover_review_schemas", diff --git a/src/sqlbuild/_native_host.pyi b/src/sqlbuild/_native_host.pyi index ac70329e05..7d3105f8d3 100644 --- a/src/sqlbuild/_native_host.pyi +++ b/src/sqlbuild/_native_host.pyi @@ -334,13 +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.classes.review_schema_lifecycle import ( - ReviewSchemaLifecycle as ReviewSchemaLifecycle, +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.review.models import ReviewSchemaMarker as ReviewSchemaMarker 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/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/tests/e2e/src/sqlbuild/cli/commands/main/review/_test_types.py b/tests/e2e/src/sqlbuild/cli/commands/main/review/_test_types.py index b6beefea70..7df2a584fb 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/review/_test_types.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/review/_test_types.py @@ -63,6 +63,61 @@ class ReviewSelectionE2ETestCase: 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 what it must still print.""" + + description: str + edits: dict[str, str] + expected_stdout_fragment: str + expected_code: 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 + 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.""" @@ -71,6 +126,8 @@ class ReviewDryRunE2ETestCase: 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) diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/review/helpers.py b/tests/e2e/src/sqlbuild/cli/commands/main/review/helpers.py index b26397a963..9babb9f6f5 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/review/helpers.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/review/helpers.py @@ -18,7 +18,8 @@ from tests.e2e.src.sqlbuild.cli.commands.shared.helpers import prepare_inline_project, run_sqb DATABASE_FILE: str = "review_orders.duckdb" -REVIEW_SCHEMA_PREFIX: str = "REVIEW_" +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" @@ -44,6 +45,13 @@ def build_review_project_files() -> dict[str, str]: [targets.dev] schema = "dev" + [targets.empty] + schema = "empty" + + [targets.dev_database] + database = "{DATABASE_NAME}" + schema = "dev" + [defaults] materialized = "table" @@ -104,6 +112,53 @@ def build_review_project_files() -> dict[str, str]: } +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.""" 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 index 3a8cedc935..f8cd3e80a5 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/review/test_review.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/review/test_review.py @@ -9,15 +9,21 @@ 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, @@ -25,7 +31,10 @@ 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, @@ -42,6 +51,14 @@ _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( @@ -106,6 +123,28 @@ 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")}, @@ -263,6 +302,184 @@ def test_given_select_inside_changed_set_when_reviewing_then_only_selection_is_v } +@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", + ), + ReviewSetupFailureE2ETestCase( + description="an --against target that holds none of the compared tables", + edits={STAGING: _SAME_VALUES_STAGING}, + arguments=("--against", "empty"), + expected_code="C774", + 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 review_schemas(project_dir=project_dir) == () + + +@pytest.mark.parametrize( + "test_case", + ( + ReviewMarkdownFailureE2ETestCase( + description="a directory as the Markdown path", + edits={STAGING: _SAME_VALUES_STAGING}, + expected_stdout_fragment="Review passed", + expected_code="C775", + ), + ), + 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) + + result: subprocess.CompletedProcess[str] = run_review( + project_dir=project_dir, arguments=("--markdown", str(tmp_path)) + ) + + assert result.returncode == 3, result.stdout + result.stderr + assert test_case.expected_stdout_fragment in result.stdout + assert test_case.expected_code in result.stderr + assert "Traceback" not in result.stderr + assert review_schemas(project_dir=project_dir) == () + + @pytest.mark.parametrize( "test_case", ( @@ -272,6 +489,14 @@ def test_given_select_inside_changed_set_when_reviewing_then_only_selection_is_v 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, ) @@ -279,7 +504,7 @@ 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={STAGING: _SAME_VALUES_STAGING}) + 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") @@ -292,6 +517,7 @@ def test_given_dry_run_when_reviewing_then_nothing_is_built_or_created( 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() @@ -349,7 +575,7 @@ def test_given_markdown_path_when_reviewing_then_text_and_markdown_are_written( ( ReviewCleanupFailureE2ETestCase( description="a failed drop exits 3 and the janitor removes only the expired leftover", - expired_schema="REVIEW_20200101T000000Z_0000000_000000", + expired_schema="review_20200101t000000z_0000000_000000", expected_exit_code=3, ), ), 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/review.mdx b/website/src/content/docs/docs/cli/review.mdx index c8a955dc4b..8ddf317f65 100644 --- a/website/src/content/docs/docs/cli/review.mdx +++ b/website/src/content/docs/docs/cli/review.mdx @@ -38,7 +38,7 @@ with `[git] base_ref` and `[review] against` runs a review with plain `sqb revie | `--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 without connecting to the warehouse | +| `--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 `` | @@ -64,6 +64,13 @@ source declaration or unit test, are listed under "What changed" but are not bui 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 @@ -73,26 +80,44 @@ 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. Each selector must name something the review can -check, or the review fails before connecting: +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 | `C763` | +| 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 `___`, for example -`REVIEW_20261023T101500Z_3f9c2ab_4e1d0a`. 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. The schema is always dropped when the review ends, whether it passed or not; a failed drop +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 @@ -105,9 +130,17 @@ The adapter must be able to drop a whole schema: SQLite and SQL Server targets c 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 could not run, such as -the keyed diff of a model without a `unique_key`, is reported as **incomplete coverage**. A model -that does not exist in `--against` yet is built but not compared, and the Baseline section says so. +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 @@ -132,9 +165,13 @@ that list only the columns that changed. | `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 | +| `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. +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 @@ -145,7 +182,7 @@ base_ref = "origin/main" [review] against = "prod" downstream = "critical" -schema_prefix = "REVIEW" +schema_prefix = "review" critical_tags = ["critical"] ``` diff --git a/website/src/content/docs/docs/concepts/project-configuration.mdx b/website/src/content/docs/docs/concepts/project-configuration.mdx index 3a89609dd4..956cc6c2ed 100644 --- a/website/src/content/docs/docs/concepts/project-configuration.mdx +++ b/website/src/content/docs/docs/concepts/project-configuration.mdx @@ -884,7 +884,7 @@ Defaults for [`sqb review`](/docs/cli/review): [review] against = "prod" downstream = "critical" -schema_prefix = "REVIEW" +schema_prefix = "review" critical_tags = ["critical"] ``` @@ -892,7 +892,7 @@ critical_tags = ["critical"] |-------|---------|-------------| | `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 | +| `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 From d894ca7f51cdb8124537c8dac6d3fd64dc25f0a9 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:34:11 +0100 Subject: [PATCH 6/7] fix: report a failed Markdown write in the sqb review document --- .../_helpers/review_command/command.rs | 41 +++++++++++-------- .../cli/commands/main/review/_test_types.py | 4 +- .../cli/commands/main/review/test_review.py | 30 ++++++++++---- 3 files changed, 49 insertions(+), 26 deletions(-) diff --git a/crates/sqlbuild-python/src/bindings/_helpers/review_command/command.rs b/crates/sqlbuild-python/src/bindings/_helpers/review_command/command.rs index e8528f0234..3fd7aa1247 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/review_command/command.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/command.rs @@ -677,19 +677,29 @@ fn write_markdown(request: &Bound<'_, PyAny>, report: &ReviewReport) -> PyResult })) } -/// Print the report to stdout, then write `--markdown`; the Markdown failure, if any. -fn publish( - request: &Bound<'_, PyAny>, - report: &ReviewReport, - machine: bool, -) -> PyResult> { +/// 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(request.py(), document.as_bytes())?; - write_markdown(request, 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. @@ -716,7 +726,9 @@ pub(crate) fn run_review_command(py: Python<'_>, request: &Bound<'_, PyAny>) -> .transpose()?, ..report }; - let markdown_failure: Option = publish(request, &report, machine)?; + 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); @@ -727,16 +739,11 @@ pub(crate) fn run_review_command(py: Python<'_>, request: &Bound<'_, PyAny>) -> let error = report.error.clone().unwrap_or_default(); return Err(failed_error(py, &error, cause)); } - if let Some(message) = markdown_failure { - let failure = ReviewFailure { - code: MARKDOWN_WRITE_CODE.to_owned(), - message, - help: Some("pass a --markdown path in a writable directory".to_owned()), - }; + if let Some(error) = &report.error { return Err(failed_error( py, - &failure, - pyo3::exceptions::PyOSError::new_err(failure.message.clone()), + error, + pyo3::exceptions::PyOSError::new_err(error.message.clone()), )); } Ok(review_outcome(&report).1) 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 index 7df2a584fb..f3aec9d006 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/review/_test_types.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/review/_test_types.py @@ -89,12 +89,14 @@ class ReviewDatabaseE2ETestCase: @dataclass(frozen=True) class ReviewMarkdownFailureE2ETestCase: - """A `--markdown` path the review cannot write, and what it must still print.""" + """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) 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 index f8cd3e80a5..b5b6e7cfa0 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/review/test_review.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/review/test_review.py @@ -455,10 +455,12 @@ def test_given_unusable_setup_when_reviewing_then_it_fails_before_creating_schem "test_case", ( ReviewMarkdownFailureE2ETestCase( - description="a directory as the Markdown path", - edits={STAGING: _SAME_VALUES_STAGING}, - expected_stdout_fragment="Review passed", + 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, @@ -469,14 +471,26 @@ def test_given_unwritable_markdown_path_when_reviewing_then_result_prints_and_it 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( + 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 result.returncode == 3, result.stdout + result.stderr - assert test_case.expected_stdout_fragment in result.stdout - assert test_case.expected_code in result.stderr - assert "Traceback" not in result.stderr + 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) == () From 964650e374b0ad115c90531f1474941e0650da74 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:39:28 +0100 Subject: [PATCH 7/7] fix: report review nodes as planned until the review builds them --- .../src/bindings/_helpers/review_command/command.rs | 13 ++++++------- .../cli/commands/main/review/_test_types.py | 1 + .../cli/commands/main/review/test_review.py | 4 ++++ 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/sqlbuild-python/src/bindings/_helpers/review_command/command.rs b/crates/sqlbuild-python/src/bindings/_helpers/review_command/command.rs index 3fd7aa1247..14d8a121fc 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/review_command/command.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/review_command/command.rs @@ -101,8 +101,8 @@ fn deferred(project: &ReviewProject, validated: &[String]) -> Vec { .collect() } -/// The impact tree's nodes before anything runs. -fn planned_nodes(project: &ReviewProject, plan: &ReviewPlan, dry_run: bool) -> Vec { +/// 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 @@ -123,10 +123,9 @@ fn planned_nodes(project: &ReviewProject, plan: &ReviewPlan, dry_run: bool) -> V kind: node.kind.clone(), critical: node.critical, changed: changed.contains(name.as_str()), - status: match (reason, dry_run) { - (Some(_), _) => NOT_VALIDATED_NODE.to_owned(), - (None, true) => PLANNED_STATUS.to_owned(), - (None, false) => BUILT_NODE.to_owned(), + status: match reason { + Some(_) => NOT_VALIDATED_NODE.to_owned(), + None => PLANNED_STATUS.to_owned(), }, reason: reason.map(str::to_owned), ..NodeReport::default() @@ -618,7 +617,7 @@ fn review( ) .map_err(|error| plan_error(py, &error))?; report.impact_edges = impact_edges(&project, &report.plan); - report.nodes = planned_nodes(&project, &report.plan, report.dry_run); + report.nodes = planned_nodes(&project, &report.plan); report.deferred = deferred(&project, &report.plan.validated); write_line( stream, 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 index f3aec9d006..d83cfaae46 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/review/_test_types.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/review/_test_types.py @@ -117,6 +117,7 @@ class ReviewSetupFailureE2ETestCase: arguments: tuple[str, ...] expected_code: str expected_message_fragment: str + expected_node_statuses: frozenset[str] extra_files: dict[str, str] = field(default_factory=dict) 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 index b5b6e7cfa0..3a7dd946e9 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/review/test_review.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/review/test_review.py @@ -420,12 +420,14 @@ def test_given_invalid_arguments_when_reviewing_then_it_exits_3_with_json_error( 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", ), ), @@ -448,6 +450,8 @@ def test_given_unusable_setup_when_reviewing_then_it_fails_before_creating_schem 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) == ()