1use crate::{Entity, Relationship, Store, StoreError};
16use rusqlite::params;
17use serde::{Deserialize, Serialize};
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct GraphRevision {
23 pub rev: i64,
25 pub base_rev: i64,
27 pub created_at: String,
29 pub source_hash: String,
31 pub extractor_version: String,
33 pub semantic_config_hash: String,
36 pub graph_content_hash: String,
40 pub entity_count: u64,
42 pub rel_count: u64,
44 pub file_count: u64,
46}
47
48#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50pub struct SemanticDelta {
52 pub added_entities: Vec<String>,
54 pub removed_entities: Vec<String>,
56 pub modified_entities: Vec<String>,
59 pub added_relationships: Vec<String>,
61 pub removed_relationships: Vec<String>,
63 pub modified_relationships: Vec<String>,
65 pub modified_kinds: std::collections::BTreeMap<String, usize>,
70}
71
72impl SemanticDelta {
74 pub fn is_empty(&self) -> bool {
77 self.added_entities.is_empty()
78 && self.removed_entities.is_empty()
79 && self.modified_entities.is_empty()
80 && self.added_relationships.is_empty()
81 && self.removed_relationships.is_empty()
82 && self.modified_relationships.is_empty()
83 }
84}
85
86impl Store {
89 pub fn record_revision(
94 &self,
95 source_hash: &str,
96 extractor_version: &str,
97 file_count: u64,
98 semantic_config_hash: &str,
99 graph_content_hash: &str,
100 ) -> Result<GraphRevision, StoreError> {
101 let entities = self.all_entities()?;
102 let rels = self.all_relationships()?;
103 let base: i64 = self
104 .conn
105 .query_row(
106 "SELECT COALESCE(MAX(rev), 0) FROM graph_revisions",
107 [],
108 |r| r.get(0),
109 )
110 .unwrap_or(0);
111 let tx = self.conn.unchecked_transaction()?;
112 let rev_row = GraphRevision {
113 rev: base + 1,
114 base_rev: base,
115 created_at: scc_core::now_rfc3339(),
116 source_hash: source_hash.to_string(),
117 extractor_version: extractor_version.to_string(),
118 semantic_config_hash: semantic_config_hash.to_string(),
119 graph_content_hash: graph_content_hash.to_string(),
120 entity_count: entities.len() as u64,
121 rel_count: rels.len() as u64,
122 file_count,
123 };
124 {
125 let mut ins_rev = tx.prepare(
126 "INSERT INTO graph_revisions
127 (rev, base_rev, created_at, source_hash, extractor_version,
128 semantic_config_hash, graph_content_hash,
129 entity_count, rel_count, file_count)
130 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
131 )?;
132 ins_rev.execute(params![
133 rev_row.rev,
134 rev_row.base_rev,
135 rev_row.created_at,
136 rev_row.source_hash,
137 rev_row.extractor_version,
138 rev_row.semantic_config_hash,
139 rev_row.graph_content_hash,
140 rev_row.entity_count as i64,
141 rev_row.rel_count as i64,
142 rev_row.file_count as i64,
143 ])?;
144 let mut ins_mem = tx.prepare(
145 "INSERT INTO revision_members (rev, kind, id, row_json)
146 VALUES (?1, ?2, ?3, ?4)",
147 )?;
148 for e in &entities {
149 ins_mem.execute(params![
150 rev_row.rev,
151 "entity",
152 e.id,
153 serde_json::to_string(e).unwrap_or_default(),
154 ])?;
155 }
156 for r in &rels {
157 ins_mem.execute(params![
158 rev_row.rev,
159 "relationship",
160 r.id,
161 serde_json::to_string(r).unwrap_or_default(),
162 ])?;
163 }
164 }
165 tx.commit()?;
166 Ok(rev_row)
167 }
168
169 pub fn revisions(&self) -> Result<Vec<GraphRevision>, StoreError> {
172 let mut stmt = self.conn.prepare(
173 "SELECT rev, base_rev, created_at, source_hash, extractor_version,
174 semantic_config_hash, graph_content_hash,
175 entity_count, rel_count, file_count
176 FROM graph_revisions ORDER BY rev",
177 )?;
178 let rows = stmt.query_map([], |r| {
179 Ok(GraphRevision {
180 rev: r.get(0)?,
181 base_rev: r.get(1)?,
182 created_at: r.get(2)?,
183 source_hash: r.get(3)?,
184 extractor_version: r.get(4)?,
185 semantic_config_hash: r.get(5)?,
186 graph_content_hash: r.get(6)?,
187 entity_count: r.get::<_, i64>(7)? as u64,
188 rel_count: r.get::<_, i64>(8)? as u64,
189 file_count: r.get::<_, i64>(9)? as u64,
190 })
191 })?;
192 rows.collect::<Result<Vec<_>, _>>().map_err(StoreError::from)
193 }
194
195 pub fn revision_members(
200 &self,
201 rev: i64,
202 ) -> Result<(Vec<Entity>, Vec<Relationship>), StoreError> {
203 let mut stmt = self.conn.prepare(
204 "SELECT kind, row_json FROM revision_members WHERE rev = ?1",
205 )?;
206 let mut entities = Vec::new();
207 let mut rels = Vec::new();
208 let rows = stmt.query_map(params![rev], |r| {
209 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
210 })?;
211 for row in rows {
212 let (kind, js) = row?;
213 if kind == "entity" {
214 if let Ok(e) = serde_json::from_str::<Entity>(&js) {
215 entities.push(e);
216 }
217 } else if let Ok(r) = serde_json::from_str::<Relationship>(&js) {
218 rels.push(r);
219 }
220 }
221 Ok((entities, rels))
222 }
223
224 pub fn revision_rows(
230 &self,
231 rev: i64,
232 ) -> Result<
233 (
234 std::collections::BTreeMap<String, String>,
235 std::collections::BTreeMap<String, String>,
236 ),
237 StoreError,
238 > {
239 let mut stmt = self
240 .conn
241 .prepare("SELECT kind, id, row_json FROM revision_members WHERE rev = ?1")?;
242 let mut entities = std::collections::BTreeMap::new();
243 let mut rels = std::collections::BTreeMap::new();
244 let rows = stmt.query_map(params![rev], |r| {
245 Ok((
246 r.get::<_, String>(0)?,
247 r.get::<_, String>(1)?,
248 r.get::<_, String>(2)?,
249 ))
250 })?;
251 for row in rows {
252 let (kind, id, js) = row?;
253 if kind == "entity" {
254 entities.insert(id, js);
255 } else {
256 rels.insert(id, js);
257 }
258 }
259 Ok((entities, rels))
260 }
261
262 pub fn semantic_diff(&self, from: i64, to: i64) -> Result<SemanticDelta, StoreError> {
268 let (fe, fr) = self.revision_rows(from)?;
269 let (te, tr) = self.revision_rows(to)?;
270 let ids = |v: Vec<String>| {
271 let mut v = v;
272 v.sort();
273 v
274 };
275 let fset: std::collections::BTreeSet<String> = fe.keys().cloned().collect();
276 let tset: std::collections::BTreeSet<String> = te.keys().cloned().collect();
277 let frset: std::collections::BTreeSet<String> = fr.keys().cloned().collect();
278 let trset: std::collections::BTreeSet<String> = tr.keys().cloned().collect();
279 let mut modified_entities: Vec<String> = tset
280 .intersection(&fset)
281 .filter(|id| te.get(*id) != fe.get(*id))
282 .cloned()
283 .collect();
284 modified_entities.sort();
285 let mut modified_relationships: Vec<String> = trset
286 .intersection(&frset)
287 .filter(|id| tr.get(*id) != fr.get(*id))
288 .cloned()
289 .collect();
290 modified_relationships.sort();
291 let mut modified_kinds: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
294 for id in &modified_entities {
295 if let Some(js) = te.get(id) {
296 if let Ok(v) = serde_json::from_str::<serde_json::Value>(js) {
297 if let Some(k) = v.get("kind").and_then(|k| k.as_str()) {
298 *modified_kinds.entry(k.to_string()).or_default() += 1;
299 }
300 }
301 }
302 }
303 Ok(SemanticDelta {
304 added_entities: ids(tset.difference(&fset).cloned().collect()),
305 removed_entities: ids(fset.difference(&tset).cloned().collect()),
306 modified_entities,
307 added_relationships: ids(trset.difference(&frset).cloned().collect()),
308 removed_relationships: ids(frset.difference(&trset).cloned().collect()),
309 modified_relationships,
310 modified_kinds,
311 })
312 }
313}
314
315impl Store {
317 pub fn graph_content_hash(&self) -> Result<String, StoreError> {
329 const OFFSET: u64 = 0xcbf29ce484222325;
330 const PRIME: u64 = 0x100000001b3;
331 let mut h = OFFSET;
332 let mut feed = |b: &[u8]| {
333 for byte in b {
334 h ^= u64::from(*byte);
335 h = h.wrapping_mul(PRIME);
336 }
337 h ^= 0xff;
338 h = h.wrapping_mul(PRIME);
339 };
340 let mut stmt = self.conn.prepare(
341 "SELECT id, kind, name, attributes, evidence FROM entities ORDER BY kind, name",
342 )?;
343 let rows = stmt.query_map([], |r| {
344 Ok((
345 r.get::<_, String>(0)?,
346 r.get::<_, String>(1)?,
347 r.get::<_, String>(2)?,
348 r.get::<_, String>(3)?,
349 r.get::<_, String>(4)?,
350 ))
351 })?;
352 for r in rows {
353 let (id, kind, name, attributes, evidence) = r?;
354 feed(id.as_bytes());
355 feed(kind.as_bytes());
356 feed(name.as_bytes());
357 feed(attributes.as_bytes());
358 feed(evidence.as_bytes());
359 }
360 let mut stmt = self.conn.prepare(
361 "SELECT id, subject, predicate, object, provenance, confidence, evidence, verified_at FROM relationships ORDER BY id",
362 )?;
363 let rows = stmt.query_map([], |r| {
364 Ok((
365 r.get::<_, String>(0)?,
366 r.get::<_, String>(1)?,
367 r.get::<_, String>(2)?,
368 r.get::<_, String>(3)?,
369 r.get::<_, String>(4)?,
370 r.get::<_, f64>(5)?,
371 r.get::<_, String>(6)?,
372 r.get::<_, String>(7)?,
373 ))
374 })?;
375 for r in rows {
376 let (id, subject, predicate, object, provenance, confidence, evidence, verified_at) =
377 r?;
378 feed(id.as_bytes());
379 feed(subject.as_bytes());
380 feed(predicate.as_bytes());
381 feed(object.as_bytes());
382 feed(provenance.as_bytes());
383 feed(&confidence.to_le_bytes());
384 feed(evidence.as_bytes());
385 feed(verified_at.as_bytes());
386 }
387 Ok(format!("{h:016x}"))
388 }
389
390 pub fn record_current_revision(&self) -> Result<GraphRevision, StoreError> {
396 self.record_current_revision_with_config("")
397 }
398
399 pub fn record_current_revision_with_config(
404 &self,
405 semantic_config_hash: &str,
406 ) -> Result<GraphRevision, StoreError> {
407 let files = self.all_files()?;
408 let mut buf = String::new();
409 for (path, hash, _, _, _) in files.iter() {
410 buf.push_str(path);
411 buf.push('\0');
412 buf.push_str(hash);
413 buf.push('\n');
414 }
415 let source_hash = scc_core::fnv1a64_hex(buf.as_bytes());
416 let content_hash = self.graph_content_hash()?;
417 let extractor = format!(
418 "store:{};core:{}",
419 crate::SCHEMA_VERSION,
420 scc_core::SCHEMA_VERSION
421 );
422 let head: Option<GraphRevision> = self.revisions()?.into_iter().last();
423 if let Some(h) = head {
424 if h.source_hash == source_hash
425 && h.extractor_version == extractor
426 && h.semantic_config_hash == semantic_config_hash
427 && h.graph_content_hash == content_hash
428 {
429 return Ok(h);
430 }
431 }
432 self.record_revision(
433 &source_hash,
434 &extractor,
435 files.len() as u64,
436 semantic_config_hash,
437 &content_hash,
438 )
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445 use crate::tests::tmp_store;
446
447 #[test]
448 fn revisions_record_intro_removal_and_diff() {
450 let (s, _d) = tmp_store();
451 let r1 = s.record_current_revision().unwrap();
452 assert_eq!(r1.rev, 1);
453 assert_eq!(r1.base_rev, 0);
454 assert!(!r1.source_hash.is_empty());
455 assert!(!r1.extractor_version.is_empty());
456 let r1b = s.record_current_revision().unwrap();
458 assert_eq!(r1b.rev, 1);
459 s.upsert_file("a.py", "h1", "python", "source", 10).unwrap();
461 s.insert_entity(
462 &Entity::new("repo://t/symbol/a.py/f", "symbol", "f"),
463 &["a.py".into()],
464 )
465 .unwrap();
466 let r2 = s.record_current_revision().unwrap();
467 assert_eq!((r2.rev, r2.base_rev), (2, 1));
468 let d = s.semantic_diff(1, 2).unwrap();
469 assert_eq!(d.added_entities, vec!["repo://t/symbol/a.py/f".to_string()]);
470 assert!(d.removed_entities.is_empty());
471 assert!(!d.is_empty());
472 let (e1, _) = s.revision_members(1).unwrap();
474 assert!(e1.is_empty());
475 let (e2, _) = s.revision_members(2).unwrap();
476 assert_eq!(e2.len(), 1);
477 s.delete_entity("repo://t/symbol/a.py/f").unwrap();
479 s.delete_file("a.py").unwrap();
480 let r3 = s.record_current_revision().unwrap();
481 assert_eq!(r3.rev, 3);
482 let d2 = s.semantic_diff(2, 3).unwrap();
483 assert_eq!(d2.removed_entities, vec!["repo://t/symbol/a.py/f".to_string()]);
484 assert!(s.semantic_diff(3, 3).unwrap().is_empty());
485 let revs = s.revisions().unwrap();
487 assert_eq!(revs.len(), 3);
488 assert_eq!(revs[0].base_rev, 0);
489 s.upsert_file("a.py", "h1", "python", "source", 10).unwrap();
492 s.insert_entity(
493 &Entity::new("repo://t/symbol/a.py/g", "symbol", "g"),
494 &["a.py".into()],
495 )
496 .unwrap();
497 let r4 = s.record_current_revision().unwrap();
498 assert_eq!(r4.rev, 4);
499 let mut g = Entity::new("repo://t/symbol/a.py/g", "symbol", "g");
500 g.attr("confidence", serde_json::json!(0.77));
501 s.insert_entity(&g, &["a.py".into()]).unwrap();
502 let r5 = s.record_current_revision().unwrap();
503 assert_eq!(r5.rev, 5, "content change without file change must advance");
504 assert_ne!(r5.graph_content_hash, r4.graph_content_hash);
505 let d4 = s.semantic_diff(4, 5).unwrap();
506 assert!(d4.added_entities.is_empty() && d4.removed_entities.is_empty());
507 assert_eq!(d4.modified_entities, vec!["repo://t/symbol/a.py/g".to_string()]);
508 assert_eq!(d4.modified_kinds.get("symbol"), Some(&1));
509 assert!(s.semantic_diff(5, 5).unwrap().is_empty());
510 let r6 = s.record_current_revision_with_config("cfg2").unwrap();
512 assert_eq!(r6.rev, 6);
513 assert_eq!(r6.semantic_config_hash, "cfg2");
514 let r6b = s.record_current_revision_with_config("cfg2").unwrap();
515 assert_eq!(r6b.rev, 6, "fully-identical rerun dedups");
516 }
517}