use super::partition::UNMODULED_DISPLAY;
use anyhow::Result;
use std::collections::{BTreeMap, BTreeSet};
pub fn remap_source_held(
section: &crate::migration::section_parser::MigrationSection,
established: &BTreeSet<String>,
) -> bool {
match section.remaps.as_deref() {
None => false,
Some(source) if source == UNMODULED_DISPLAY => true,
Some(source) => established.contains(source),
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct RemapSection {
pub name: String,
pub module: Option<String>,
pub source: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct ReAnchor {
pub version: u64,
pub path: std::path::PathBuf,
pub remap_sections: Vec<RemapSection>,
pub plain_modules: BTreeSet<String>,
pub surviving_modules: BTreeSet<String>,
}
pub(crate) fn discover_re_anchors(baselines_dir: &std::path::Path) -> Result<Vec<ReAnchor>> {
let to_module = |r: &str| {
if r == UNMODULED_DISPLAY {
None
} else {
Some(r.to_string())
}
};
let mut re_anchors = Vec::new();
for baseline in crate::migration::discover_baselines(baselines_dir)? {
let sql = std::fs::read_to_string(&baseline.path)?;
let sections = crate::migration::parse_migration_sections(&baseline.path, &sql)?;
let remap_sections: Vec<RemapSection> = sections
.iter()
.filter_map(|s| {
s.remaps.as_deref().map(|source| RemapSection {
name: s.name.clone(),
module: s.module.clone(),
source: to_module(source),
})
})
.collect();
if remap_sections.is_empty() {
continue;
}
let plain_modules = sections
.iter()
.filter(|s| s.remaps.is_none())
.filter_map(|s| s.module.clone())
.collect();
let surviving_modules = sections.iter().filter_map(|s| s.module.clone()).collect();
re_anchors.push(ReAnchor {
version: baseline.version,
path: baseline.path.clone(),
remap_sections,
plain_modules,
surviving_modules,
});
}
re_anchors.sort_by_key(|r| r.version);
Ok(re_anchors)
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum CrossingCheck {
Whole { rewritten: BTreeSet<String> },
Blocked {
needs_adoption: BTreeSet<String>,
unsatisfiable: BTreeSet<String>,
},
}
pub(crate) fn run_eligible(
module: &Option<String>,
subscription: &BTreeSet<String>,
plain_modules: &BTreeSet<String>,
) -> bool {
match module {
None => true,
Some(m) => subscription.contains(m) || !plain_modules.contains(m),
}
}
pub(crate) fn acquisition_pairs(
sections: &[crate::migration::section_parser::MigrationSection],
) -> BTreeSet<(Option<String>, Option<String>)> {
sections
.iter()
.filter_map(|s| {
let remap = s.remaps.as_deref()?;
let source = if remap == UNMODULED_DISPLAY {
None
} else {
Some(remap.to_string())
};
Some((s.module.clone(), source))
})
.collect()
}
pub(crate) fn evaluate_crossing(
re_anchor: &ReAnchor,
subscription: &BTreeSet<String>,
applied_sections: &BTreeSet<String>,
will_run: &BTreeSet<(Option<String>, Option<String>)>,
) -> CrossingCheck {
let source_established =
|s: &Option<String>| s.as_ref().is_none_or(|m| subscription.contains(m));
let satisfied_now =
|rs: &RemapSection| source_established(&rs.source) || applied_sections.contains(&rs.name);
let mut by_module: BTreeMap<Option<String>, Vec<&RemapSection>> = BTreeMap::new();
for rs in &re_anchor.remap_sections {
by_module.entry(rs.module.clone()).or_default().push(rs);
}
let mut rewritten = subscription.clone();
let mut needs_adoption: BTreeSet<String> = BTreeSet::new();
let mut unsatisfiable: BTreeSet<String> = BTreeSet::new();
for (module, sections) in &by_module {
let is_base = module.is_none();
let subscribed = module.as_ref().is_some_and(|m| subscription.contains(m));
let any_satisfied = sections.iter().any(|rs| satisfied_now(rs));
if !(is_base || subscribed || any_satisfied) {
continue;
}
if let Some(m) = module
&& !subscribed
&& re_anchor.plain_modules.contains(m)
&& sections.iter().any(|rs| source_established(&rs.source))
{
needs_adoption.insert(m.clone());
continue;
}
let satisfiable = |rs: &RemapSection| {
satisfied_now(rs) || will_run.contains(&(rs.module.clone(), rs.source.clone()))
};
if !sections.iter().all(|rs| satisfiable(rs)) {
unsatisfiable.extend(
sections
.iter()
.filter(|rs| !satisfiable(rs))
.map(|rs| rs.name.clone()),
);
continue;
}
let auto_subscribe =
module.is_some() && run_eligible(module, subscription, &re_anchor.plain_modules);
if let Some(m) = module
&& auto_subscribe
{
rewritten.insert(m.clone());
}
if is_base || auto_subscribe {
for rs in sections {
if let Some(src) = &rs.source
&& !re_anchor.surviving_modules.contains(src)
{
rewritten.remove(src);
}
}
}
}
if needs_adoption.is_empty() && unsatisfiable.is_empty() {
CrossingCheck::Whole { rewritten }
} else {
CrossingCheck::Blocked {
needs_adoption,
unsatisfiable,
}
}
}
#[derive(Debug, Clone)]
pub struct PendingCrossing {
pub(crate) version: u64,
pub(crate) rewritten: BTreeSet<String>,
}
impl PendingCrossing {
pub fn rewritten(&self) -> &BTreeSet<String> {
&self.rewritten
}
}
#[cfg(test)]
mod tests {
use super::*;
fn re_anchor(
version: u64,
remap_sections: &[(&str, Option<&str>, Option<&str>)],
plain: &[&str],
surviving: &[&str],
) -> ReAnchor {
ReAnchor {
version,
path: std::path::PathBuf::from(format!("baseline_{version}.sql")),
remap_sections: remap_sections
.iter()
.map(|(name, module, source)| RemapSection {
name: name.to_string(),
module: module.map(str::to_string),
source: source.map(str::to_string),
})
.collect(),
plain_modules: plain.iter().map(|s| s.to_string()).collect(),
surviving_modules: surviving.iter().map(|s| s.to_string()).collect(),
}
}
fn subs(names: &[&str]) -> BTreeSet<String> {
names.iter().map(|s| s.to_string()).collect()
}
fn blocked(needs_adoption: &[&str], unsatisfiable: &[&str]) -> CrossingCheck {
CrossingCheck::Blocked {
needs_adoption: subs(needs_adoption),
unsatisfiable: unsatisfiable.iter().map(|s| s.to_string()).collect(),
}
}
fn will_run(
pairs: &[(Option<&str>, Option<&str>)],
) -> BTreeSet<(Option<String>, Option<String>)> {
pairs
.iter()
.map(|(m, s)| (m.map(str::to_string), s.map(str::to_string)))
.collect()
}
fn no_will_run() -> BTreeSet<(Option<String>, Option<String>)> {
BTreeSet::new()
}
#[test]
fn test_crossing_modularization_from_base_auto_subscribes() {
let ra = re_anchor(
1200,
&[
("app", Some("app"), None),
("analytics", Some("analytics"), None),
],
&[],
&["app", "analytics"],
);
assert_eq!(
evaluate_crossing(&ra, &BTreeSet::new(), &BTreeSet::new(), &no_will_run()),
CrossingCheck::Whole {
rewritten: subs(&["analytics", "app"])
}
);
}
#[test]
fn test_crossing_split_rewrites_subscription() {
let ra = re_anchor(
1300,
&[("analytics", Some("analytics"), Some("app"))],
&["app"],
&["app", "analytics"],
);
assert_eq!(
evaluate_crossing(&ra, &subs(&["app"]), &BTreeSet::new(), &no_will_run()),
CrossingCheck::Whole {
rewritten: subs(&["analytics", "app"])
},
"app survives (plain section, still subscribed); analytics auto-subscribes"
);
}
#[test]
fn test_crossing_merge_with_both_sources_collapses() {
let ra = re_anchor(
1600,
&[("c", Some("c"), Some("a")), ("c_2", Some("c"), Some("b"))],
&[],
&["c"],
);
assert_eq!(
evaluate_crossing(&ra, &subs(&["a", "b"]), &BTreeSet::new(), &no_will_run()),
CrossingCheck::Whole {
rewritten: subs(&["c"])
}
);
}
#[test]
fn test_crossing_merge_some_but_not_all_acquires_via_migration() {
let ra = re_anchor(
1600,
&[("c", Some("c"), Some("a")), ("c_2", Some("c"), Some("b"))],
&[],
&["c"],
);
assert_eq!(
evaluate_crossing(
&ra,
&subs(&["a"]),
&BTreeSet::new(),
&will_run(&[(Some("c"), Some("a")), (Some("c"), Some("b"))]),
),
CrossingCheck::Whole {
rewritten: subs(&["c"])
}
);
assert_eq!(
evaluate_crossing(&ra, &subs(&["a"]), &BTreeSet::new(), &no_will_run()),
blocked(&[], &["c_2"])
);
}
#[test]
fn test_crossing_merge_applied_row_satisfies() {
let ra = re_anchor(
1600,
&[("c", Some("c"), Some("a")), ("c_2", Some("c"), Some("b"))],
&[],
&["c"],
);
assert_eq!(
evaluate_crossing(&ra, &subs(&["a"]), &subs(&["c_2"]), &no_will_run()),
CrossingCheck::Whole {
rewritten: subs(&["c"])
}
);
}
#[test]
fn test_crossing_move_into_pre_existing_module() {
let ra = re_anchor(
1700,
&[("b_2", Some("b"), Some("a"))],
&["b"],
&["a", "b"], );
assert_eq!(
evaluate_crossing(&ra, &subs(&["a"]), &BTreeSet::new(), &no_will_run()),
blocked(&["b"], &[]),
"needed + pre-existing + not subscribed blocks the crossing"
);
assert_eq!(
evaluate_crossing(
&ra,
&subs(&["a"]),
&BTreeSet::new(),
&will_run(&[(Some("b"), Some("a"))]),
),
blocked(&["b"], &[]),
);
assert_eq!(
evaluate_crossing(&ra, &subs(&["a", "b"]), &BTreeSet::new(), &no_will_run()),
CrossingCheck::Whole {
rewritten: subs(&["a", "b"])
}
);
assert_eq!(
evaluate_crossing(&ra, &subs(&["other"]), &BTreeSet::new(), &no_will_run()),
CrossingCheck::Whole {
rewritten: subs(&["other"])
}
);
}
#[test]
fn test_crossing_move_into_brand_new_module() {
let ra = re_anchor(1700, &[("b", Some("b"), Some("a"))], &[], &["b"]);
assert_eq!(
evaluate_crossing(&ra, &subs(&["a"]), &BTreeSet::new(), &no_will_run()),
CrossingCheck::Whole {
rewritten: subs(&["b"])
}
);
}
#[test]
fn test_crossing_demotion_with_source_subscribed() {
let ra = re_anchor(1800, &[("default", None, Some("a"))], &[], &[]);
assert_eq!(
evaluate_crossing(
&ra,
&subs(&["a", "other"]),
&BTreeSet::new(),
&no_will_run()
),
CrossingCheck::Whole {
rewritten: subs(&["other"])
}
);
}
#[test]
fn test_crossing_demotion_without_source_acquires_via_migration() {
let ra = re_anchor(1800, &[("default", None, Some("a"))], &[], &[]);
assert_eq!(
evaluate_crossing(
&ra,
&subs(&["other"]),
&BTreeSet::new(),
&will_run(&[(None, Some("a"))]),
),
CrossingCheck::Whole {
rewritten: subs(&["other"])
}
);
assert_eq!(
evaluate_crossing(&ra, &subs(&["other"]), &BTreeSet::new(), &no_will_run()),
blocked(&[], &["default"])
);
}
#[test]
fn test_crossing_irrelevant_re_anchor_is_inert_but_crossed() {
let ra = re_anchor(
1900,
&[("reports", Some("reports"), Some("analytics"))],
&["analytics"],
&["analytics", "reports"],
);
assert_eq!(
evaluate_crossing(&ra, &subs(&["core"]), &BTreeSet::new(), &no_will_run()),
CrossingCheck::Whole {
rewritten: subs(&["core"])
}
);
}
}