use super::edit::append_statements;
use super::{StatementBatch, StatementResult, StatementResultStatus};
use crate::mutation::FileEdit;
use crate::{Error, resource};
use knowledge_base_models::{Entity, EntityId, Statement, StatementId};
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::str::FromStr;
#[derive(Debug)]
pub(super) struct StatementPlan {
pub(super) results: Vec<StatementResult>,
pub(super) edits: Vec<FileEdit>,
}
impl StatementPlan {
pub(super) fn all_new(&self) -> bool {
self.results.iter().all(|result| result.status == StatementResultStatus::WouldAdd)
}
}
pub(super) struct StatementPlanner<'a> {
root: &'a Path,
batch: &'a StatementBatch,
}
struct EntityState {
path: PathBuf,
original: Vec<u8>,
next_id: Option<u64>,
known: Vec<Statement>,
additions: Vec<Statement>,
}
impl<'a> StatementPlanner<'a> {
pub(super) fn new(root: &'a Path, batch: &'a StatementBatch) -> Self {
Self { root, batch }
}
pub(super) fn plan(self) -> Result<StatementPlan, Error> {
let mut entities = self.load_entities()?;
let mut results = Vec::with_capacity(self.batch.statements.len());
for (offset, input) in self.batch.statements.iter().enumerate() {
let state = entities.get_mut(&input.entity).expect("all statement entities were loaded");
let existing = state
.known
.iter()
.find(|statement| statement.property == input.property && statement.value == input.value && qualifiers_match(&statement.qualifiers, &input.qualifiers))
.map(|statement| statement.id.clone());
let (statement, status) = if let Some(statement) = existing {
(statement, StatementResultStatus::AlreadyPresent)
} else {
let statement = allocate_statement_id(&input.entity, &mut state.next_id)?;
let addition = Statement {
id: statement.clone(),
property: input.property.clone(),
value: input.value.clone(),
qualifiers: input.qualifiers.clone(),
references: input.references.clone(),
};
state.known.push(addition.clone());
state.additions.push(addition);
(statement, StatementResultStatus::WouldAdd)
};
results.push(StatementResult {
index: offset + 1,
entity: input.entity.clone(),
property: input.property.clone(),
statement,
status,
});
}
let mut edits = entities
.into_values()
.filter(|state| !state.additions.is_empty())
.map(|state| {
let source = std::str::from_utf8(&state.original).map_err(|error| Error::Edit {
path: state.path.clone(),
message: format!("file is not UTF-8: {error}"),
})?;
let replacement = append_statements(source, &state.additions, &state.path)?;
Ok(FileEdit {
path: state.path,
original: Some(state.original),
replacement: replacement.into_bytes(),
})
})
.collect::<Result<Vec<_>, Error>>()?;
edits.sort_by(|left, right| left.path.cmp(&right.path));
Ok(StatementPlan { results, edits })
}
fn load_entities(&self) -> Result<BTreeMap<EntityId, EntityState>, Error> {
self.batch
.statements
.iter()
.map(|input| input.entity.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.map(|id| self.load_entity(id))
.collect()
}
fn load_entity(&self, id: EntityId) -> Result<(EntityId, EntityState), Error> {
let path = resource::path(self.root, "entities", id.as_str(), "yaml");
let original = fs::read(&path).map_err(|source| Error::Read { path: path.clone(), source })?;
let entity = serde_yaml::from_slice::<Entity>(&original).map_err(|source| Error::ParseEntity { path: path.clone(), source })?;
let maximum = entity.statements.iter().map(|statement| statement.id.number()).max().unwrap_or(0);
let known = entity.statements;
Ok((
id,
EntityState {
path,
original,
next_id: maximum.checked_add(1),
known,
additions: Vec::new(),
},
))
}
}
fn qualifiers_match(left: &[knowledge_base_models::Qualifier], right: &[knowledge_base_models::Qualifier]) -> bool {
left.len() == right.len() && left.iter().all(|qualifier| right.contains(qualifier))
}
fn allocate_statement_id(entity: &EntityId, next_id: &mut Option<u64>) -> Result<StatementId, Error> {
let next = next_id
.take()
.ok_or_else(|| Error::InvalidRequest(format!("cannot allocate another statement identifier for {entity}")))?;
let statement = StatementId::from_str(&format!("S{next}")).expect("positive u64 values form valid statement identifiers");
*next_id = next.checked_add(1);
Ok(statement)
}
#[cfg(test)]
mod tests {
use super::StatementPlanner;
use crate::{StatementBatch, StatementResultStatus};
use std::fs;
fn repository(entity_source: &str) -> tempfile::TempDir {
let root = tempfile::tempdir().unwrap();
fs::create_dir(root.path().join("entities")).unwrap();
fs::write(root.path().join("entities/Q1.yaml"), entity_source).unwrap();
root
}
fn batch(source: &str) -> StatementBatch {
serde_yaml::from_str(source).unwrap()
}
#[test]
fn duplicate_rows_share_the_planned_identifier() {
let root = repository("id: Q1\nlabels: {}\nentity_types: []\nstatements: []\n");
let batch = batch(
"statements:\n - entity: Q1\n property: P1\n value: { type: integer, value: 7 }\n references: [R1]\n - entity: Q1\n property: P1\n value: { type: integer, value: 7 }\n references: [R2]\n",
);
let plan = StatementPlanner::new(root.path(), &batch).plan().unwrap();
assert_eq!(plan.results[0].statement, plan.results[1].statement);
assert_eq!(plan.results[0].status, StatementResultStatus::WouldAdd);
assert_eq!(plan.results[1].status, StatementResultStatus::AlreadyPresent);
assert_eq!(plan.edits.len(), 1);
}
#[test]
fn existing_statements_are_detected_without_an_edit() {
let root = repository("id: Q1\nlabels: {}\nentity_types: []\nstatements:\n - id: S3\n property: P1\n value: { type: integer, value: 7 }\n references: [R1]\n");
let batch = batch("statements:\n - entity: Q1\n property: P1\n value: { type: integer, value: 7 }\n references: [R2]\n");
let plan = StatementPlanner::new(root.path(), &batch).plan().unwrap();
assert_eq!(plan.results[0].status, StatementResultStatus::AlreadyPresent);
assert_eq!(plan.results[0].statement.as_str(), "S3");
assert!(plan.edits.is_empty());
}
#[test]
fn identifiers_are_allocated_independently_for_each_entity() {
let root = repository("id: Q1\nlabels: {}\nentity_types: []\nstatements:\n - id: S2\n property: P1\n value: { type: integer, value: 1 }\n references: [R1]\n");
fs::write(root.path().join("entities/Q2.yaml"), "id: Q2\nlabels: {}\nentity_types: []\nstatements: []\n").unwrap();
let batch = batch(
"statements:\n - entity: Q1\n property: P1\n value: { type: integer, value: 2 }\n references: [R1]\n - entity: Q2\n property: P1\n value: { type: integer, value: 3 }\n references: [R1]\n",
);
let plan = StatementPlanner::new(root.path(), &batch).plan().unwrap();
assert_eq!(plan.results[0].statement.as_str(), "S3");
assert_eq!(plan.results[1].statement.as_str(), "S1");
assert_eq!(plan.edits.len(), 2);
assert!(plan.edits[0].path.ends_with("Q1.yaml"));
assert!(plan.edits[1].path.ends_with("Q2.yaml"));
}
#[test]
fn qualifiers_are_order_independent_for_duplicate_detection() {
let root = repository(
"id: Q1\nlabels: {}\nentity_types: []\nstatements:\n - id: S3\n property: P1\n value: { type: integer, value: 7 }\n qualifiers:\n - property: P2\n value: { type: date, value: 2024-01-01 }\n - property: P3\n value: { type: string, value: source }\n references: [R1]\n",
);
let batch = batch(
"statements:\n - entity: Q1\n property: P1\n value: { type: integer, value: 7 }\n qualifiers:\n - property: P3\n value: { type: string, value: source }\n - property: P2\n value: { type: date, value: 2024-01-01 }\n references: [R2]\n",
);
let plan = StatementPlanner::new(root.path(), &batch).plan().unwrap();
assert_eq!(plan.results[0].status, StatementResultStatus::AlreadyPresent);
assert_eq!(plan.results[0].statement.as_str(), "S3");
assert!(plan.edits.is_empty());
}
#[test]
fn changed_qualifiers_create_a_distinct_statement() {
let root = repository(
"id: Q1\nlabels: {}\nentity_types: []\nstatements:\n - id: S3\n property: P1\n value: { type: integer, value: 7 }\n qualifiers:\n - property: P2\n value: { type: date, value: 2024-01-01 }\n references: [R1]\n",
);
let batch = batch(
"statements:\n - entity: Q1\n property: P1\n value: { type: integer, value: 7 }\n qualifiers:\n - property: P2\n value: { type: date, value: 2024-01-02 }\n references: [R1]\n",
);
let plan = StatementPlanner::new(root.path(), &batch).plan().unwrap();
assert_eq!(plan.results[0].status, StatementResultStatus::WouldAdd);
assert_eq!(plan.results[0].statement.as_str(), "S4");
}
#[test]
fn identifier_exhaustion_is_reported() {
let root = repository(
"id: Q1\nlabels: {}\nentity_types: []\nstatements:\n - id: S18446744073709551615\n property: P1\n value: { type: integer, value: 1 }\n references: [R1]\n",
);
let batch = batch("statements:\n - entity: Q1\n property: P1\n value: { type: integer, value: 2 }\n references: [R1]\n");
let error = StatementPlanner::new(root.path(), &batch).plan().unwrap_err();
assert!(error.to_string().contains("cannot allocate another statement identifier"));
}
}