use std::collections::BTreeMap;
use crate::diff::change::{Change, TsConfigurationChange};
use crate::diff::changeset::ChangeSet;
use crate::diff::destructiveness::Destructiveness;
use crate::identifier::QualifiedName;
use crate::ir::catalog::Catalog;
use crate::ir::text_search::configuration::{TsConfiguration, TsMapping};
pub fn diff_ts_configurations(target: &Catalog, source: &Catalog, out: &mut ChangeSet) {
let target_map: BTreeMap<String, &TsConfiguration> = target
.ts_configurations
.iter()
.map(|c| (c.qname.render_sql(), c))
.collect();
let source_map: BTreeMap<String, &TsConfiguration> = source
.ts_configurations
.iter()
.map(|c| (c.qname.render_sql(), c))
.collect();
for (key, src) in &source_map {
if !target_map.contains_key(key) {
out.push(
Change::TsConfiguration(TsConfigurationChange::Create((*src).clone())),
Destructiveness::Safe,
);
}
}
for (key, tgt) in &target_map {
if !source_map.contains_key(key) {
out.push(
Change::TsConfiguration(TsConfigurationChange::Drop {
qname: tgt.qname.clone(),
}),
Destructiveness::Safe,
);
}
}
for (key, src) in &source_map {
let Some(tgt) = target_map.get(key) else {
continue;
};
emit_modify(tgt, src, out);
}
}
fn emit_modify(t: &TsConfiguration, s: &TsConfiguration, out: &mut ChangeSet) {
if t.parser != s.parser {
out.push(
Change::TsConfiguration(TsConfigurationChange::Replace {
from: t.clone(),
to: s.clone(),
}),
Destructiveness::Safe,
);
return;
}
diff_mappings(&s.qname, &t.mappings, &s.mappings, out);
if let Some(src_owner) = &s.owner
&& t.owner.as_ref() != Some(src_owner)
{
out.push(
Change::TsConfiguration(TsConfigurationChange::AlterOwner {
qname: s.qname.clone(),
owner: src_owner.clone(),
}),
Destructiveness::Safe,
);
}
if t.comment != s.comment {
out.push(
Change::TsConfiguration(TsConfigurationChange::CommentOn {
qname: s.qname.clone(),
comment: s.comment.clone(),
}),
Destructiveness::Safe,
);
}
}
fn diff_mappings(
qname: &QualifiedName,
target_mappings: &[TsMapping],
source_mappings: &[TsMapping],
out: &mut ChangeSet,
) {
let t_map: BTreeMap<&str, &Vec<QualifiedName>> = target_mappings
.iter()
.map(|m| (m.token_type.as_str(), &m.dictionaries))
.collect();
let s_map: BTreeMap<&str, &Vec<QualifiedName>> = source_mappings
.iter()
.map(|m| (m.token_type.as_str(), &m.dictionaries))
.collect();
for (token_type, s_dicts) in &s_map {
match t_map.get(token_type) {
None => {
out.push(
Change::TsConfiguration(TsConfigurationChange::AddMapping {
qname: qname.clone(),
token_type: (*token_type).to_owned(),
dictionaries: (*s_dicts).clone(),
}),
Destructiveness::Safe,
);
}
Some(t_dicts) if t_dicts != s_dicts => {
out.push(
Change::TsConfiguration(TsConfigurationChange::AlterMapping {
qname: qname.clone(),
token_type: (*token_type).to_owned(),
dictionaries: (*s_dicts).clone(),
}),
Destructiveness::Safe,
);
}
Some(_) => {
}
}
}
for token_type in t_map.keys() {
if !s_map.contains_key(token_type) {
out.push(
Change::TsConfiguration(TsConfigurationChange::DropMapping {
qname: qname.clone(),
token_type: (*token_type).to_owned(),
}),
Destructiveness::Safe,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diff::change::{Change, TsConfigurationChange};
use crate::identifier::{Identifier, QualifiedName};
use crate::ir::catalog::Catalog;
use crate::ir::text_search::configuration::{TsConfiguration, TsMapping};
fn id(s: &str) -> Identifier {
Identifier::from_unquoted(s).unwrap()
}
fn qn(schema: &str, name: &str) -> QualifiedName {
QualifiedName::new(id(schema), id(name))
}
fn basic_config(name: &str) -> TsConfiguration {
TsConfiguration {
qname: qn("app", name),
parser: qn("pg_catalog", "default"),
mappings: vec![],
owner: None,
comment: None,
}
}
fn mapping(token_type: &str, dicts: Vec<QualifiedName>) -> TsMapping {
TsMapping {
token_type: token_type.to_owned(),
dictionaries: dicts,
}
}
fn cat(configs: Vec<TsConfiguration>) -> Catalog {
let mut c = Catalog::empty();
c.ts_configurations = configs;
c
}
fn run(target: &Catalog, source: &Catalog) -> ChangeSet {
let mut out = ChangeSet::new();
diff_ts_configurations(target, source, &mut out);
out
}
#[test]
fn source_only_creates() {
let changes = run(&cat(vec![]), &cat(vec![basic_config("english")]));
assert_eq!(changes.len(), 1);
assert!(matches!(
changes.iter().next().unwrap().change,
Change::TsConfiguration(TsConfigurationChange::Create(_))
));
}
#[test]
fn target_only_drops() {
let changes = run(&cat(vec![basic_config("english")]), &cat(vec![]));
assert_eq!(
changes.len(),
1,
"managed configuration must emit Drop when absent from source"
);
assert!(
matches!(
changes.iter().next().unwrap().change,
Change::TsConfiguration(TsConfigurationChange::Drop { .. })
),
"expected Drop, got {:?}",
changes.iter().next().unwrap().change
);
}
#[test]
fn different_parser_replaces() {
let t = basic_config("english");
let mut s = basic_config("english");
s.parser = qn("app", "custom_parser");
let changes = run(&cat(vec![t.clone()]), &cat(vec![s.clone()]));
assert_eq!(changes.len(), 1);
let entry = changes.iter().next().unwrap();
match &entry.change {
Change::TsConfiguration(TsConfigurationChange::Replace { from, to }) => {
assert_eq!(from, &t, "from must be the target (live) configuration");
assert_eq!(to, &s, "to must be the source (desired) configuration");
}
other => panic!("expected Replace, got {other:?}"),
}
}
#[test]
fn replace_subsumes_mappings_owner_and_comment() {
let t = basic_config("english");
let mut s = basic_config("english");
s.parser = qn("app", "custom_parser"); s.mappings = vec![mapping("word", vec![qn("app", "english_stem")])];
s.owner = Some(id("alice"));
s.comment = Some("custom config".into());
let changes = run(&cat(vec![t]), &cat(vec![s]));
assert_eq!(
changes.len(),
1,
"Replace must subsume mappings + owner + comment"
);
assert!(matches!(
changes.iter().next().unwrap().change,
Change::TsConfiguration(TsConfigurationChange::Replace { .. })
));
}
#[test]
fn add_mapping_when_token_in_source_only() {
let t = basic_config("english");
let mut s = basic_config("english");
s.mappings = vec![mapping("word", vec![qn("app", "english_stem")])];
let changes = run(&cat(vec![t]), &cat(vec![s]));
assert_eq!(changes.len(), 1);
let entry = changes.iter().next().unwrap();
match &entry.change {
Change::TsConfiguration(TsConfigurationChange::AddMapping {
token_type,
dictionaries,
..
}) => {
assert_eq!(token_type, "word");
assert_eq!(dictionaries, &vec![qn("app", "english_stem")]);
}
other => panic!("expected AddMapping, got {other:?}"),
}
}
#[test]
fn alter_mapping_when_dictionaries_differ() {
let mut t = basic_config("english");
t.mappings = vec![mapping("word", vec![qn("app", "english_stem")])];
let mut s = basic_config("english");
s.mappings = vec![mapping(
"word",
vec![qn("app", "english_stem"), qn("pg_catalog", "simple")],
)];
let changes = run(&cat(vec![t]), &cat(vec![s]));
assert_eq!(changes.len(), 1);
let entry = changes.iter().next().unwrap();
match &entry.change {
Change::TsConfiguration(TsConfigurationChange::AlterMapping {
token_type,
dictionaries,
..
}) => {
assert_eq!(token_type, "word");
assert_eq!(
dictionaries,
&vec![qn("app", "english_stem"), qn("pg_catalog", "simple")]
);
}
other => panic!("expected AlterMapping, got {other:?}"),
}
}
#[test]
fn drop_mapping_when_token_in_target_only() {
let mut t = basic_config("english");
t.mappings = vec![mapping("word", vec![qn("app", "english_stem")])];
let s = basic_config("english"); let changes = run(&cat(vec![t]), &cat(vec![s]));
assert_eq!(changes.len(), 1);
let entry = changes.iter().next().unwrap();
match &entry.change {
Change::TsConfiguration(TsConfigurationChange::DropMapping { token_type, .. }) => {
assert_eq!(token_type, "word");
}
other => panic!("expected DropMapping, got {other:?}"),
}
}
#[test]
fn mixed_add_alter_drop_mapping() {
let mut t = basic_config("english");
t.mappings = vec![
mapping("asciiword", vec![qn("pg_catalog", "simple")]),
mapping("word", vec![qn("app", "english_stem")]),
];
let mut s = basic_config("english");
s.mappings = vec![
mapping(
"asciiword",
vec![qn("app", "english_stem"), qn("pg_catalog", "simple")],
),
mapping("numword", vec![qn("pg_catalog", "simple")]),
];
let changes = run(&cat(vec![t]), &cat(vec![s]));
assert_eq!(
changes.len(),
3,
"expected 3 changes (add+alter+drop), got: {changes:?}"
);
let kinds: Vec<_> = changes
.iter()
.map(|e| match &e.change {
Change::TsConfiguration(c) => c.clone(),
other => panic!("unexpected change kind: {other:?}"),
})
.collect();
assert!(
kinds.iter().any(|c| matches!(
c,
TsConfigurationChange::AddMapping { token_type, .. } if token_type == "numword"
)),
"expected AddMapping for numword"
);
assert!(
kinds.iter().any(|c| matches!(
c,
TsConfigurationChange::AlterMapping { token_type, .. } if token_type == "asciiword"
)),
"expected AlterMapping for asciiword"
);
assert!(
kinds.iter().any(|c| matches!(
c,
TsConfigurationChange::DropMapping { token_type, .. } if token_type == "word"
)),
"expected DropMapping for word"
);
}
#[test]
fn owner_change_emits_alter_owner() {
let mut t = basic_config("english");
t.owner = Some(id("alice"));
let mut s = basic_config("english");
s.owner = Some(id("bob"));
let changes = run(&cat(vec![t]), &cat(vec![s]));
assert_eq!(changes.len(), 1);
assert!(matches!(
changes.iter().next().unwrap().change,
Change::TsConfiguration(TsConfigurationChange::AlterOwner { .. })
));
}
#[test]
fn source_owner_none_no_alter_owner() {
let mut t = basic_config("english");
t.owner = Some(id("alice"));
let s = basic_config("english"); let changes = run(&cat(vec![t]), &cat(vec![s]));
assert!(
changes.is_empty(),
"source owner None = unmanaged, no change expected"
);
}
#[test]
fn comment_change_emits_comment_on() {
let t = basic_config("english");
let mut s = basic_config("english");
s.comment = Some("English text search configuration".into());
let changes = run(&cat(vec![t]), &cat(vec![s]));
assert_eq!(changes.len(), 1);
assert!(matches!(
changes.iter().next().unwrap().change,
Change::TsConfiguration(TsConfigurationChange::CommentOn { .. })
));
}
#[test]
fn identical_configurations_produce_no_changes() {
let mut cfg = basic_config("english");
cfg.mappings = vec![
mapping("asciiword", vec![qn("app", "english_stem")]),
mapping("word", vec![qn("app", "english_stem")]),
];
cfg.owner = Some(id("alice"));
cfg.comment = Some("English config".into());
let c = cat(vec![cfg]);
let changes = run(&c, &c);
assert!(changes.is_empty());
}
}