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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//! Aspect comparison and the words reasons use for aspects and kinds.

use crate::definition_changes::constants::{ASPECT_LABELS, KIND_LABELS};
use crate::definition_changes::models::NodeDefinition;

fn aspect_rank(aspect: &str) -> usize {
ASPECT_LABELS
.iter()
.position(|(name, _)| *name == aspect)
.unwrap_or(ASPECT_LABELS.len())
}

/// The aspects present on either side whose digests differ, in reason order.
pub(crate) fn changed_aspects(base: &NodeDefinition, current: &NodeDefinition) -> Vec<String> {
let mut aspects: Vec<String> = base
.aspects
.keys()
.chain(current.aspects.keys())
.filter(|aspect| base.aspects.get(*aspect) != current.aspects.get(*aspect))
.cloned()
.collect();
aspects.sort_by(|left, right| {
aspect_rank(left)
.cmp(&aspect_rank(right))
.then(left.cmp(right))
});
aspects.dedup();
aspects
}

/// How an aspect reads in a reason.
pub(crate) fn aspect_label(aspect: &str) -> &str {
ASPECT_LABELS
.iter()
.find(|(name, _)| *name == aspect)
.map_or(aspect, |(_, label)| label)
}

/// The singular and plural words of a node kind.
pub(crate) fn kind_words(kind: &str) -> (&str, String) {
KIND_LABELS
.iter()
.find(|(name, _, _)| *name == kind)
.map_or_else(
|| (kind, format!("{kind}s")),
|(_, singular, plural)| (*singular, (*plural).to_owned()),
)
}

/// `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(", ")),
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pub(crate) mod aspects;
pub(crate) mod reasons;
131 changes: 131 additions & 0 deletions crates/sqlbuild-analysis/src/definition_changes/_helpers/reasons.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
//! The reason texts of selected tests and the declarations an executed item reaches.

use std::collections::BTreeSet;

use crate::definition_changes::_helpers::aspects::{aspect_label, joined, kind_words};
use crate::definition_changes::constants::{
COLUMNS_ASPECT, CONNECTION_KIND, CONSTANT_KIND, DEPENDENCIES_KIND, ENUM_KIND, MODEL_KIND,
SEED_KIND, SETTINGS_KIND, SOURCE_KIND, TEST_FILE_KIND,
};

/// Project-wide kinds whose reasons name the kind, not the single node.
const PROJECT_WIDE_KINDS: [&str; 3] = [SETTINGS_KIND, CONNECTION_KIND, DEPENDENCIES_KIND];
use crate::definition_changes::models::{DefinitionSnapshot, ExecutedItem, NodeChange};

fn aspect_phrase(change: &NodeChange) -> String {
let labels: Vec<String> = change
.aspects
.iter()
.map(|aspect| aspect_label(aspect).to_owned())
.collect();
joined(&labels)
}

/// `test file changed`, `scenario file and snapshots changed`, `test file added`.
pub(crate) fn own_reason(change: &NodeChange) -> String {
let (singular, _) = kind_words(&change.kind);
if change.added {
return format!("{singular} added");
}
if change.kind == TEST_FILE_KIND {
return format!("{singular} changed");
}
format!("{singular} {} changed", aspect_phrase(change))
}

/// `added`, `model SQL changed`, `renamed from old_orders, config changed`, `changed`.
fn change_detail(change: &NodeChange) -> String {
if change.added {
return "added".to_owned();
}
let detail: String = if change.kind == MODEL_KIND {
format!("model {} changed", aspect_phrase(change))
} else {
"changed".to_owned()
};
match &change.renamed_from {
Some(old_name) => format!("renamed from {old_name}, {detail}"),
None => detail,
}
}

/// `executes stg_orders (model SQL changed)`, `reads seed countries (changed)`.
pub(crate) fn executed_reason(change: &NodeChange) -> String {
let subject: String = if change.kind == MODEL_KIND {
change.name.clone()
} else {
format!("{} {}", kind_words(&change.kind).0, change.name)
};
let verb: &str = if change.kind == SEED_KIND || change.kind == SOURCE_KIND {
"reads"
} else {
"executes"
};
format!("{verb} {subject} ({})", change_detail(change))
}

/// `executes macro to_utc (changed)`, `uses enum status (changed)`, `uses project settings (changed)`.
pub(crate) fn declaration_reason(change: &NodeChange) -> String {
if PROJECT_WIDE_KINDS.contains(&change.kind.as_str()) {
return format!(
"uses {} ({})",
kind_words(&change.kind).0,
change_detail(change)
);
}
if change.kind == SOURCE_KIND {
return executed_reason(change);
}
let verb: &str = if change.kind == ENUM_KIND || change.kind == CONSTANT_KIND {
"uses"
} else {
"executes"
};
format!(
"{verb} {} {} ({})",
kind_words(&change.kind).0,
change.name,
change_detail(change)
)
}

/// Declarations `item` reaches, transitively through its SQL and executed nodes, minus its mocks.
pub(crate) fn reached_declarations(
item: &ExecutedItem,
current: &DefinitionSnapshot,
) -> BTreeSet<String> {
let mut pending: Vec<String> = item.declarations.clone();
for key in &item.executed {
if let Some(node) = current.nodes.get(key) {
pending.extend(node.uses.iter().cloned());
}
}
let mut reached: BTreeSet<String> = BTreeSet::new();
while let Some(key) = pending.pop() {
if item.mocked.contains(&key) || !reached.insert(key.clone()) {
continue;
}
if let Some(node) = current.nodes.get(&key) {
pending.extend(node.uses.iter().cloned());
}
}
reached
}

/// `mocks stg_orders (model columns changed)` when a mocked relation's declared shape changed.
pub(crate) fn mocked_reason(change: &NodeChange) -> Option<String> {
if !change.added && !change.aspects.iter().any(|aspect| aspect == COLUMNS_ASPECT) {
return None;
}
let subject: String = if change.kind == MODEL_KIND {
change.name.clone()
} else {
format!("{} {}", kind_words(&change.kind).0, change.name)
};
let detail: String = if change.added {
"added".to_owned()
} else {
format!("{} columns changed", kind_words(&change.kind).0)
};
Some(format!("mocks {subject} ({detail})"))
}
89 changes: 89 additions & 0 deletions crates/sqlbuild-analysis/src/definition_changes/constants.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
//! Node kinds, definition aspects and the snapshot format version.

/// The version of the stored snapshot layout and fingerprint scheme; older snapshots are recomputed.
pub const DEFINITION_SNAPSHOT_FORMAT: u32 = 3;

pub const MODEL_KIND: &str = "model";
pub const SEED_KIND: &str = "seed";
pub const UDF_KIND: &str = "udf";
pub const TABLE_FUNCTION_KIND: &str = "table_fn";
pub const MACRO_KIND: &str = "macro";
pub const ENUM_KIND: &str = "enum";
pub const CONSTANT_KIND: &str = "constant";
pub const TEST_FILE_KIND: &str = "test_file";
pub const SCENARIO_KIND: &str = "scenario";
pub const SOURCE_KIND: &str = "source";
pub const MATERIALIZATION_KIND: &str = "materialization";
pub const SETTINGS_KIND: &str = "settings";
/// Adapter, connection and target configuration, adapter modules and referenced environment.
pub const CONNECTION_KIND: &str = "connection";
/// Python dependency manifests and lock files of the project and its repository.
pub const DEPENDENCIES_KIND: &str = "dependencies";
/// The one node of each project-wide kind.
pub const PROJECT_NODE_NAME: &str = "project";
/// Python nodes by decorator.
pub const PYTHON_NODE_KINDS: [&str; 4] = ["loader", "task", "asset", "check"];

/// Normalised SQL of a model, a function body or a scenario.
pub const SQL_ASPECT: &str = "sql";
/// Header configuration, without descriptions.
pub const CONFIG_ASPECT: &str = "config";
/// Declared columns and contract, without descriptions.
pub const COLUMNS_ASPECT: &str = "columns";
/// Attached audits, without descriptions.
pub const AUDITS_ASPECT: &str = "audits";
/// The whole definition of a seed, function, macro, enum or constant.
pub const DEFINITION_ASPECT: &str = "definition";
/// The authored file of a test or scenario.
pub const FILE_ASPECT: &str = "file";
/// Captured local snapshots of a scenario.
pub const SNAPSHOTS_ASPECT: &str = "snapshots";
/// Values of the project variables and environment variables a definition reads.
pub const VARS_ASPECT: &str = "vars";

/// How each aspect reads in a reason, in the order reasons list them.
pub(crate) const ASPECT_LABELS: [(&str, &str); 8] = [
(SQL_ASPECT, "SQL"),
(CONFIG_ASPECT, "config"),
(COLUMNS_ASPECT, "columns"),
(AUDITS_ASPECT, "audits"),
(DEFINITION_ASPECT, "definition"),
(FILE_ASPECT, "file"),
(SNAPSHOTS_ASPECT, "snapshots"),
(VARS_ASPECT, "vars"),
];

/// How each node kind reads in summaries: singular, plural.
pub(crate) const KIND_LABELS: [(&str, &str, &str); 23] = [
(SETTINGS_KIND, "project settings", "project settings"),
(
CONNECTION_KIND,
"connection and target configuration",
"connection and target configurations",
),
(
DEPENDENCIES_KIND,
"Python environment",
"Python environments",
),
(MODEL_KIND, "model", "models"),
(SOURCE_KIND, "source", "sources"),
(SEED_KIND, "seed", "seeds"),
(UDF_KIND, "function", "functions"),
(TABLE_FUNCTION_KIND, "table function", "table functions"),
(MACRO_KIND, "macro", "macros"),
(ENUM_KIND, "enum", "enums"),
(CONSTANT_KIND, "constant", "constants"),
("audit", "audit", "audits"),
("singular_audit", "singular audit", "singular audits"),
("schema", "schema", "schemas"),
("sql_hook", "SQL hook", "SQL hooks"),
("python_hook", "Python hook", "Python hooks"),
(MATERIALIZATION_KIND, "materialization", "materializations"),
("loader", "loader", "loaders"),
("task", "task", "tasks"),
("asset", "asset", "assets"),
("check", "check", "checks"),
(TEST_FILE_KIND, "test file", "test files"),
(SCENARIO_KIND, "scenario", "scenarios"),
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//! A one-line account of what changed since the base revision.

use std::collections::BTreeMap;

use crate::definition_changes::_helpers::aspects::kind_words;
use crate::definition_changes::constants::KIND_LABELS;
use crate::definition_changes::models::DefinitionChanges;

fn kind_rank(kind: &str) -> usize {
KIND_LABELS
.iter()
.position(|(name, _, _)| *name == kind)
.unwrap_or(KIND_LABELS.len())
}

fn counted<'a>(kinds: impl Iterator<Item = &'a str>) -> Option<String> {
let mut counts: BTreeMap<(usize, &str), usize> = BTreeMap::new();
for kind in kinds {
*counts.entry((kind_rank(kind), kind)).or_default() += 1;
}
if counts.is_empty() {
return None;
}
let parts: Vec<String> = counts
.into_iter()
.map(|((_, kind), count)| {
let (singular, plural) = kind_words(kind);
if count == 1 {
format!("1 {singular}")
} else {
format!("{count} {plural}")
}
})
.collect();
Some(parts.join(", "))
}

/// `2 models, 1 macro changed; 1 seed added; deleted: model old_orders`, or `no definition changes`.
pub fn changes_summary(changes: &DefinitionChanges) -> String {
let mut parts: Vec<String> = Vec::new();
let modified = changes.changed.values().filter(|change| !change.added);
if let Some(text) = counted(modified.map(|change| change.kind.as_str())) {
parts.push(format!("{text} changed"));
}
let added = changes.changed.values().filter(|change| change.added);
if let Some(text) = counted(added.map(|change| change.kind.as_str())) {
parts.push(format!("{text} added"));
}
let mut renamed: Vec<&str> = Vec::new();
for (_, key) in &changes.renamed {
if let Some((kind, _)) = key.split_once(':') {
renamed.push(kind);
}
}
if let Some(text) = counted(renamed.into_iter()) {
parts.push(format!("{text} renamed"));
}
if !changes.deleted.is_empty() {
let names: Vec<String> = changes
.deleted
.iter()
.map(|node| format!("{} {}", kind_words(&node.kind).0, node.name))
.collect();
parts.push(format!("deleted: {}", names.join(", ")));
}
if parts.is_empty() {
return "no definition changes".to_owned();
}
parts.join("; ")
}
Loading
Loading