use crate::utils::error::Result;
use ahash::AHasher;
use oxigraph::model::Quad;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::hash::{Hash, Hasher};
use crate::graph::Graph;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DeltaType {
Addition {
subject: String,
predicate: String,
object: String,
},
Deletion {
subject: String,
predicate: String,
object: String,
},
Modification {
subject: String,
predicate: String,
old_object: String,
new_object: String,
},
}
impl DeltaType {
pub fn from_quads(old: Option<&Quad>, new: Option<&Quad>) -> Option<Self> {
match (old, new) {
(None, Some(new_quad)) => Some(DeltaType::Addition {
subject: new_quad.subject.to_string(),
predicate: new_quad.predicate.to_string(),
object: new_quad.object.to_string(),
}),
(Some(old_quad), None) => Some(DeltaType::Deletion {
subject: old_quad.subject.to_string(),
predicate: old_quad.predicate.to_string(),
object: old_quad.object.to_string(),
}),
(Some(old_quad), Some(new_quad)) => {
if old_quad.subject == new_quad.subject
&& old_quad.predicate == new_quad.predicate
&& old_quad.object != new_quad.object
{
Some(DeltaType::Modification {
subject: old_quad.subject.to_string(),
predicate: old_quad.predicate.to_string(),
old_object: old_quad.object.to_string(),
new_object: new_quad.object.to_string(),
})
} else {
None
}
}
(None, None) => None,
}
}
pub fn subjects(&self) -> Vec<&str> {
match self {
DeltaType::Addition { subject, .. }
| DeltaType::Deletion { subject, .. }
| DeltaType::Modification { subject, .. } => vec![subject],
}
}
pub fn predicates(&self) -> Vec<&str> {
match self {
DeltaType::Addition { predicate, .. }
| DeltaType::Deletion { predicate, .. }
| DeltaType::Modification { predicate, .. } => vec![predicate],
}
}
pub fn affects_iri(&self, iri: &str) -> bool {
self.subjects().contains(&iri)
|| self.predicates().contains(&iri)
|| match self {
DeltaType::Addition { object, .. } | DeltaType::Deletion { object, .. } => {
object == iri
}
DeltaType::Modification {
old_object,
new_object,
..
} => old_object == iri || new_object == iri,
}
}
}
impl fmt::Display for DeltaType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DeltaType::Addition {
subject,
predicate,
object,
} => write!(f, "+ {} {} {}", subject, predicate, object),
DeltaType::Deletion {
subject,
predicate,
object,
} => write!(f, "- {} {} {}", subject, predicate, object),
DeltaType::Modification {
subject,
predicate,
old_object,
new_object,
} => write!(
f,
"~ {} {} {} -> {}",
subject, predicate, old_object, new_object
),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GraphDelta {
pub deltas: Vec<DeltaType>,
pub baseline_hash: Option<String>,
pub current_hash: Option<String>,
pub computed_at: chrono::DateTime<chrono::Utc>,
}
impl GraphDelta {
pub fn new(baseline: &Graph, current: &Graph) -> Result<Self> {
let mut deltas = Vec::new();
let baseline_quads = baseline.get_all_quads()?;
let current_quads = current.get_all_quads()?;
let baseline_map: BTreeMap<(String, String, String), Quad> = baseline_quads
.iter()
.map(|q| {
(
(
q.subject.to_string(),
q.predicate.to_string(),
q.object.to_string(),
),
q.clone(),
)
})
.collect();
let current_map: BTreeMap<(String, String, String), Quad> = current_quads
.iter()
.map(|q| {
(
(
q.subject.to_string(),
q.predicate.to_string(),
q.object.to_string(),
),
q.clone(),
)
})
.collect();
for ((s, p, o), current_quad) in ¤t_map {
match baseline_map.get(&(s.clone(), p.clone(), o.clone())) {
Some(baseline_quad) => {
if baseline_quad.object != current_quad.object {
deltas.push(DeltaType::Modification {
subject: s.clone(),
predicate: p.clone(),
old_object: baseline_quad.object.to_string(),
new_object: current_quad.object.to_string(),
});
}
}
None => {
deltas.push(DeltaType::Addition {
subject: s.clone(),
predicate: p.clone(),
object: o.clone(),
});
}
}
}
for (s, p, o) in baseline_map.keys() {
if !current_map.contains_key(&(s.to_string(), p.clone(), o.clone())) {
deltas.push(DeltaType::Deletion {
subject: s.clone(),
predicate: p.clone(),
object: o.clone(),
});
}
}
Ok(Self {
deltas,
baseline_hash: baseline.compute_hash().ok(),
current_hash: current.compute_hash().ok(),
computed_at: chrono::Utc::now(),
})
}
pub fn affected_iris(&self) -> BTreeSet<String> {
let mut iris = BTreeSet::new();
for delta in &self.deltas {
iris.extend(delta.subjects().iter().map(|s| s.to_string()));
iris.extend(delta.predicates().iter().map(|p| p.to_string()));
match delta {
DeltaType::Addition { object, .. } | DeltaType::Deletion { object, .. } => {
iris.insert(object.clone());
}
DeltaType::Modification {
old_object,
new_object,
..
} => {
iris.insert(old_object.clone());
iris.insert(new_object.clone());
}
}
}
iris
}
pub fn affects_iri(&self, iri: &str) -> bool {
self.deltas.iter().any(|d| d.affects_iri(iri))
}
pub fn is_empty(&self) -> bool {
self.deltas.is_empty()
}
pub fn counts(&self) -> BTreeMap<&str, usize> {
let mut counts = BTreeMap::new();
for delta in &self.deltas {
let key = match delta {
DeltaType::Addition { .. } => "additions",
DeltaType::Deletion { .. } => "deletions",
DeltaType::Modification { .. } => "modifications",
};
*counts.entry(key).or_insert(0) += 1;
}
counts
}
pub fn filter_by_iris(&self, iris: &[String]) -> Self {
let filtered_deltas: Vec<_> = self
.deltas
.iter()
.filter(|d| iris.iter().any(|iri| d.affects_iri(iri)))
.cloned()
.collect();
Self {
deltas: filtered_deltas,
baseline_hash: self.baseline_hash.clone(),
current_hash: self.current_hash.clone(),
computed_at: self.computed_at,
}
}
pub fn merge(&mut self, other: GraphDelta) {
self.deltas.extend(other.deltas);
if self.baseline_hash.is_none() {
self.baseline_hash = other.baseline_hash;
}
if self.current_hash.is_none() {
self.current_hash = other.current_hash;
}
}
}
impl fmt::Display for GraphDelta {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "GraphDelta ({} changes):", self.deltas.len())?;
let counts = self.counts();
for (delta_type, count) in counts {
writeln!(f, " {}: {}", delta_type, count)?;
}
if !self.deltas.is_empty() {
const MAX_DELTAS_DISPLAY: usize = 10;
writeln!(f)?;
for delta in self.deltas.iter().take(MAX_DELTAS_DISPLAY) {
writeln!(f, " {}", delta)?;
}
if self.deltas.len() > MAX_DELTAS_DISPLAY {
writeln!(
f,
" ... and {} more",
self.deltas.len() - MAX_DELTAS_DISPLAY
)?;
}
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateImpact {
pub template_path: String,
pub affected_iris: Vec<String>,
pub confidence: f64,
pub reason: String,
}
impl TemplateImpact {
pub fn new(
template_path: String, affected_iris: Vec<String>, confidence: f64, reason: String,
) -> Self {
Self {
template_path,
affected_iris,
confidence,
reason,
}
}
pub fn is_confident(&self, threshold: f64) -> bool {
self.confidence >= threshold
}
}
pub struct ImpactAnalyzer {
#[allow(dead_code)]
template_queries: BTreeMap<String, Vec<String>>,
}
impl Default for ImpactAnalyzer {
fn default() -> Self {
Self::new()
}
}
impl ImpactAnalyzer {
pub fn new() -> Self {
Self {
template_queries: BTreeMap::new(),
}
}
pub fn analyze_impacts(
&mut self, delta: &GraphDelta, template_paths: &[String], graph: &Graph,
) -> Result<Vec<TemplateImpact>> {
let mut impacts = Vec::new();
for template_path in template_paths {
let queries = self.get_template_queries(template_path, graph)?;
let (confidence, reason) = self.assess_impact(delta, &queries);
if confidence > 0.0 {
impacts.push(TemplateImpact::new(
template_path.clone(),
delta.affected_iris().into_iter().collect(),
confidence,
reason,
));
}
}
impacts.sort_by(|a, b| {
b.confidence
.partial_cmp(&a.confidence)
.unwrap_or(std::cmp::Ordering::Equal)
});
Ok(impacts)
}
fn get_template_queries(&self, template_path: &str, _graph: &Graph) -> Result<Vec<String>> {
if let Some(queries) = self.template_queries.get(template_path) {
return Ok(queries.clone());
}
Ok(vec![
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }".to_string(),
"SELECT ?class WHERE { ?class a rdfs:Class }".to_string(),
])
}
fn assess_impact(&self, delta: &GraphDelta, queries: &[String]) -> (f64, String) {
let affected_iris = delta.affected_iris();
let mut max_relevance = 0.0;
let mut reasons = Vec::new();
for query in queries {
let query_lower = query.to_lowercase();
for iri in &affected_iris {
if query_lower.contains(&iri.to_lowercase()) {
max_relevance = 1.0;
reasons.push(format!("Query directly references IRI: {}", iri));
break;
}
}
}
if max_relevance == 0.0 {
for iri in &affected_iris {
if iri.contains("rdfs:Class") || iri.contains("rdf:Property") {
max_relevance = 0.8;
reasons.push("Schema element changed".to_string());
}
}
}
let reason = if reasons.is_empty() {
"No direct impact detected".to_string()
} else {
reasons.join("; ")
};
(max_relevance, reason)
}
}
impl Graph {
fn get_all_quads(&self) -> Result<Vec<Quad>> {
let pattern = self.quads_for_pattern(None, None, None, None)?;
Ok(pattern)
}
pub fn compute_hash(&self) -> Result<String> {
let quads = self.get_all_quads()?;
let mut hasher = AHasher::default();
let mut sorted_quads: Vec<String> = quads
.iter()
.map(|q| format!("{} {} {}", q.subject, q.predicate, q.object))
.collect();
sorted_quads.sort();
for quad_str in sorted_quads {
quad_str.hash(&mut hasher);
}
Ok(format!("{:x}", hasher.finish()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::Graph;
fn create_test_graph() -> Result<(Graph, Graph)> {
let baseline = Graph::new()?;
baseline.insert_turtle(
r#"
@prefix : <http://example.org/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
:User a rdfs:Class .
:name a rdf:Property ;
rdfs:domain :User .
"#,
)?;
let current = Graph::new()?;
current.insert_turtle(
r#"
@prefix : <http://example.org/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
:User a rdfs:Class .
:name a rdf:Property ;
rdfs:domain :User .
:email a rdf:Property ;
rdfs:domain :User .
"#,
)?;
Ok((baseline, current))
}
#[test]
fn test_delta_creation() {
let (baseline, current) = create_test_graph().unwrap();
let delta = GraphDelta::new(&baseline, ¤t).unwrap();
assert!(!delta.is_empty());
assert!(delta.affects_iri("<http://example.org/email>"));
let counts = delta.counts();
assert_eq!(counts.get("additions"), Some(&2));
assert_eq!(counts.get("deletions"), None); assert_eq!(counts.get("modifications"), None); }
#[test]
fn test_delta_affected_iris() {
let (baseline, current) = create_test_graph().unwrap();
let delta = GraphDelta::new(&baseline, ¤t).unwrap();
let affected = delta.affected_iris();
assert!(affected.contains("<http://example.org/email>"));
assert!(affected.contains("<http://example.org/User>"));
assert!(affected.contains("<http://www.w3.org/2000/01/rdf-schema#domain>"));
}
#[test]
fn test_delta_filtering() -> std::result::Result<(), Box<dyn std::error::Error>> {
let (baseline, current) = create_test_graph()?;
let delta = GraphDelta::new(&baseline, ¤t)?;
let filtered = delta.filter_by_iris(&["<http://example.org/User>".to_string()]);
assert!(!filtered.is_empty());
Ok(())
}
#[test]
fn test_impact_analyzer() -> std::result::Result<(), Box<dyn std::error::Error>> {
let (baseline, current) = create_test_graph().unwrap();
let delta = GraphDelta::new(&baseline, ¤t).unwrap();
let mut analyzer = ImpactAnalyzer::new();
analyzer.template_queries.insert(
"template1.tmpl".to_string(),
vec!["SELECT * WHERE { ?s <http://example.org/email> ?o }".to_string()],
);
let template_paths = vec!["template1.tmpl".to_string()];
let impacts = analyzer
.analyze_impacts(&delta, &template_paths, &baseline)
.unwrap();
assert!(!impacts.is_empty());
Ok(())
}
#[test]
fn test_graph_hash() -> std::result::Result<(), Box<dyn std::error::Error>> {
let (baseline, current) = create_test_graph().unwrap();
let hash1 = baseline.compute_hash().unwrap();
let hash2 = current.compute_hash().unwrap();
assert_ne!(hash1, hash2);
let hash3 = baseline.compute_hash().unwrap();
assert_eq!(hash1, hash3);
Ok(())
}
#[test]
fn test_delta_display() -> std::result::Result<(), Box<dyn std::error::Error>> {
let (baseline, current) = create_test_graph().unwrap();
let delta = GraphDelta::new(&baseline, ¤t).unwrap();
let display = format!("{}", delta);
assert!(display.contains("GraphDelta"));
assert!(display.contains("additions"));
assert!(display.contains("http://example.org/email"));
Ok(())
}
}