use std::collections::BTreeSet;
use std::path::Path;
use kglite::api::session::Session;
use kglite::api::{ontology_from_json, OntologyStore};
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Applied {
Declared { warnings: Vec<String> },
Unchanged,
Replaced { warnings: Vec<String> },
}
pub(crate) fn apply(session: &Session, file: &Path, replace: bool) -> Result<Applied, String> {
let text =
std::fs::read_to_string(file).map_err(|e| format!("--ontology {}: {e}", file.display()))?;
let wanted =
ontology_from_json(&text).map_err(|e| format!("--ontology {}: {e}", file.display()))?;
let snapshot = session.snapshot();
let stored: &OntologyStore = &snapshot.ontology;
let outcome = if *stored == wanted {
Applied::Unchanged
} else {
let replacing = !stored.is_empty();
if replacing && !replace {
return Err(format!(
"the stored ontology differs from --ontology {}:\n {}\nRestart with \
--ontology-replace to replace the stored declaration with the file's, or \
point --ontology at the file that matches it",
file.display(),
diff(stored, &wanted).join("\n ")
));
}
let mut tx = session.begin();
let warnings = tx
.working_mut()
.map_err(|e| format!("--ontology {}: {e}", file.display()))?
.define_ontology(wanted)
.map_err(|e| format!("--ontology {}: {e}", file.display()))?;
match session.commit(tx, true) {
kglite::api::session::CommitOutcome::Committed { .. } => {}
other => {
return Err(format!(
"--ontology {}: the declaration could not be committed ({other:?})",
file.display()
))
}
}
if replacing {
Applied::Replaced { warnings }
} else {
Applied::Declared { warnings }
}
};
drop(snapshot);
session.lock_ontology();
Ok(outcome)
}
pub(crate) fn apply_cli(session: &Session, cli: &crate::Cli) -> anyhow::Result<()> {
let Some(file) = cli.ontology.as_deref() else {
return Ok(());
};
let (outcome, warnings) =
match apply(session, file, cli.ontology_replace).map_err(anyhow::Error::msg)? {
Applied::Declared { warnings } => ("declared", warnings),
Applied::Replaced { warnings } => ("replaced", warnings),
Applied::Unchanged => ("unchanged", Vec::new()),
};
for warning in &warnings {
tracing::warn!("{warning}");
}
tracing::info!(file = %file.display(), outcome, "ontology locked");
Ok(())
}
fn diff(stored: &OntologyStore, wanted: &OntologyStore) -> Vec<String> {
let mut lines = Vec::new();
if stored.closed_labels != wanted.closed_labels {
lines.push(format!(
"closed_labels: stored {}, file {}",
stored.closed_labels, wanted.closed_labels
));
}
if stored.enforcement != wanted.enforcement {
lines.push(format!(
"enforcement: stored {}, file {}",
stored.enforcement.as_str(),
wanted.enforcement.as_str()
));
}
section(&mut lines, "class", &stored.classes, &wanted.classes);
section(
&mut lines,
"relationship",
&stored.relationships,
&wanted.relationships,
);
if lines.is_empty() {
lines.push("version or other settings differ".to_string());
}
lines
}
fn section<T: PartialEq>(
lines: &mut Vec<String>,
kind: &str,
stored: &std::collections::BTreeMap<String, T>,
wanted: &std::collections::BTreeMap<String, T>,
) {
let names: BTreeSet<&String> = stored.keys().chain(wanted.keys()).collect();
for name in names {
match (stored.get(name), wanted.get(name)) {
(Some(_), None) => lines.push(format!("{kind} '{name}': only in the stored ontology")),
(None, Some(_)) => lines.push(format!("{kind} '{name}': only in the file")),
(Some(a), Some(b)) if a != b => {
lines.push(format!("{kind} '{name}': declaration differs"))
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use kglite::api::DirGraph;
fn file(tag: &str, name: &str, json: &str) -> std::path::PathBuf {
let dir =
std::env::temp_dir().join(format!("kglite-bolt-ontology-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(name);
std::fs::write(&path, json).unwrap();
path
}
const A: &str =
r#"{"classes": {"Person": {"required_properties": ["id"], "enforcement": "error"}}}"#;
const B: &str = r#"{"classes": {"Person": {"required_properties": ["name"], "enforcement": "error"}, "Org": {}}}"#;
#[test]
fn declare_lock_unchanged_differs_and_replace() {
let (a, b) = (file("ab", "a.json", A), file("ab", "b.json", B));
let session = Session::new(DirGraph::new());
assert!(matches!(
apply(&session, &a, false),
Ok(Applied::Declared { .. })
));
assert!(session.snapshot().ontology_locked());
let mut tx = session.begin();
let refused = tx
.working_mut()
.unwrap()
.define_ontology(ontology_from_json(B).unwrap());
assert!(refused.unwrap_err().to_string().contains("--ontology"));
let mut graph = DirGraph::new();
graph
.define_ontology(ontology_from_json(A).unwrap())
.unwrap();
let stored = Session::new(graph);
assert_eq!(apply(&stored, &a, false).unwrap(), Applied::Unchanged);
let err = apply(&stored, &b, false).unwrap_err();
assert!(
err.contains("differs") && err.contains("class 'Org': only in the file"),
"{err}"
);
assert!(err.contains("class 'Person': declaration differs"), "{err}");
let mut graph = DirGraph::new();
graph
.define_ontology(ontology_from_json(A).unwrap())
.unwrap();
let restarted = Session::new(graph);
assert!(matches!(
apply(&restarted, &b, true),
Ok(Applied::Replaced { .. })
));
assert!(restarted.snapshot().ontology.classes.contains_key("Org"));
}
#[test]
fn violating_data_refuses_the_declaration_and_leaves_the_graph_alone() {
let a = file(
"violating",
"a.json",
r#"{"classes": {"Person": {"required_properties": ["email"], "enforcement": "error"}}}"#,
);
let mut graph = DirGraph::new();
let params = std::collections::HashMap::new();
kglite::api::session::execute_mut(
&mut graph,
"CREATE (:Person {name: 'x'})",
&kglite::api::session::ExecuteOptions::eager(¶ms),
)
.unwrap();
let session = Session::new(graph);
let err = apply(&session, &a, false).unwrap_err();
assert!(err.contains("Person.required_properties"), "{err}");
assert!(session.snapshot().ontology.is_empty());
assert!(!session.snapshot().ontology_locked());
}
}