use std::collections::HashMap; use ed25519_dalek::SigningKey; use ontoref_types::{ operation::{OpBody, OpPayload, Operation}, AttrId, EntityId, Hlc, PublicKey, Value, }; use ontoref_triples::TripleStore; fn signing_key() -> SigningKey { SigningKey::from_bytes(&[9u8; 32]) } fn assert_op( entity: &str, attrs: Vec<(&str, Value)>, tx: Hlc, valid_from: Option, ) -> Operation { let key = signing_key(); let body = OpBody { actor: PublicKey::from_signing_key(&key), parents: vec![], payload: OpPayload::EntityAssert { attrs: attrs .into_iter() .map(|(name, value)| (AttrId::new(name), value)) .collect(), entity: EntityId::new(entity), valid_from, }, timestamp: tx, }; Operation::sign(body, &key) } #[test] fn forward_index_returns_attrs_for_entity() { let dir = tempfile::tempdir().unwrap(); let store = TripleStore::open(dir.path().join("triples.redb")).unwrap(); let tx = Hlc::new(1, 0); store .apply(&assert_op( "alice", vec![ ("name", Value::Str("Alice".into())), ("age", Value::Int(30)), ], tx, None, )) .unwrap(); let attrs = store .entity_attrs(&EntityId::new("alice"), tx, tx) .unwrap(); let map: HashMap = attrs .into_iter() .map(|(attr, value)| (attr.as_str().to_string(), value)) .collect(); assert_eq!(map.get("name"), Some(&Value::Str("Alice".into()))); assert_eq!(map.get("age"), Some(&Value::Int(30))); } #[test] fn forward_index_picks_latest_assertion_per_attr() { let dir = tempfile::tempdir().unwrap(); let store = TripleStore::open(dir.path().join("t.redb")).unwrap(); store .apply(&assert_op( "e", vec![("color", Value::Str("red".into()))], Hlc::new(1, 0), None, )) .unwrap(); store .apply(&assert_op( "e", vec![("color", Value::Str("blue".into()))], Hlc::new(2, 0), None, )) .unwrap(); let attrs = store .entity_attrs(&EntityId::new("e"), Hlc::new(10, 0), Hlc::new(10, 0)) .unwrap(); assert_eq!(attrs.len(), 1); assert_eq!(attrs[0].1, Value::Str("blue".into())); } #[test] fn reverse_index_finds_entities_by_value() { let dir = tempfile::tempdir().unwrap(); let store = TripleStore::open(dir.path().join("t.redb")).unwrap(); store .apply(&assert_op( "e1", vec![("color", Value::Str("red".into()))], Hlc::new(1, 0), None, )) .unwrap(); store .apply(&assert_op( "e2", vec![("color", Value::Str("red".into()))], Hlc::new(2, 0), None, )) .unwrap(); store .apply(&assert_op( "e3", vec![("color", Value::Str("blue".into()))], Hlc::new(3, 0), None, )) .unwrap(); let red = store .entities_by_value( &AttrId::new("color"), &Value::Str("red".into()), Hlc::new(10, 0), Hlc::new(10, 0), ) .unwrap(); assert_eq!(red, vec![EntityId::new("e1"), EntityId::new("e2")]); let blue = store .entities_by_value( &AttrId::new("color"), &Value::Str("blue".into()), Hlc::new(10, 0), Hlc::new(10, 0), ) .unwrap(); assert_eq!(blue, vec![EntityId::new("e3")]); } #[test] fn reverse_index_excludes_overwritten_entities() { let dir = tempfile::tempdir().unwrap(); let store = TripleStore::open(dir.path().join("t.redb")).unwrap(); store .apply(&assert_op( "e1", vec![("color", Value::Str("red".into()))], Hlc::new(1, 0), None, )) .unwrap(); store .apply(&assert_op( "e1", vec![("color", Value::Str("blue".into()))], Hlc::new(2, 0), None, )) .unwrap(); let red_now = store .entities_by_value( &AttrId::new("color"), &Value::Str("red".into()), Hlc::new(10, 0), Hlc::new(10, 0), ) .unwrap(); assert!( red_now.is_empty(), "e1 was overwritten to blue; reverse query for red must exclude it" ); let blue_now = store .entities_by_value( &AttrId::new("color"), &Value::Str("blue".into()), Hlc::new(10, 0), Hlc::new(10, 0), ) .unwrap(); assert_eq!(blue_now, vec![EntityId::new("e1")]); } #[test] fn as_of_filters_to_tx_time_horizon() { let dir = tempfile::tempdir().unwrap(); let store = TripleStore::open(dir.path().join("t.redb")).unwrap(); let tx1 = Hlc::new(10, 0); let tx2 = Hlc::new(20, 0); store .apply(&assert_op("e", vec![("v", Value::Int(1))], tx1, None)) .unwrap(); store .apply(&assert_op("e", vec![("v", Value::Int(2))], tx2, None)) .unwrap(); let at_tx1 = store .entity_attrs(&EntityId::new("e"), tx1, tx1) .unwrap(); assert_eq!(at_tx1[0].1, Value::Int(1)); let at_tx2 = store .entity_attrs(&EntityId::new("e"), tx2, tx2) .unwrap(); assert_eq!(at_tx2[0].1, Value::Int(2)); } #[test] fn as_of_with_old_horizon_excludes_future_assertions() { let dir = tempfile::tempdir().unwrap(); let store = TripleStore::open(dir.path().join("t.redb")).unwrap(); let tx1 = Hlc::new(10, 0); let tx2 = Hlc::new(20, 0); store .apply(&assert_op("e", vec![("v", Value::Int(1))], tx1, None)) .unwrap(); store .apply(&assert_op("e", vec![("v", Value::Int(2))], tx2, None)) .unwrap(); let view = store .entity_attrs(&EntityId::new("e"), Hlc::new(15, 0), Hlc::new(15, 0)) .unwrap(); assert_eq!(view[0].1, Value::Int(1)); } #[test] fn bitemporal_distinguishes_tx_time_from_valid_time() { let dir = tempfile::tempdir().unwrap(); let store = TripleStore::open(dir.path().join("t.redb")).unwrap(); let tx = Hlc::new(100, 0); let valid_from = Hlc::new(5, 0); store .apply(&assert_op( "alice", vec![("name", Value::Str("Alice".into()))], tx, Some(valid_from), )) .unwrap(); let after_valid = store .entity_attr( &EntityId::new("alice"), &AttrId::new("name"), tx, Hlc::new(10, 0), ) .unwrap(); assert_eq!(after_valid, Some(Value::Str("Alice".into()))); let before_valid = store .entity_attr( &EntityId::new("alice"), &AttrId::new("name"), tx, Hlc::new(1, 0), ) .unwrap(); assert_eq!(before_valid, None); } #[test] fn bitemporal_valid_time_picks_value_in_effect() { let dir = tempfile::tempdir().unwrap(); let store = TripleStore::open(dir.path().join("t.redb")).unwrap(); // Two retroactive corrections recorded at different tx_times but with // different valid_from points. store .apply(&assert_op( "e", vec![("v", Value::Str("A".into()))], Hlc::new(100, 0), Some(Hlc::new(10, 0)), )) .unwrap(); store .apply(&assert_op( "e", vec![("v", Value::Str("B".into()))], Hlc::new(101, 0), Some(Hlc::new(20, 0)), )) .unwrap(); let between = store .entity_attr( &EntityId::new("e"), &AttrId::new("v"), Hlc::new(200, 0), Hlc::new(15, 0), ) .unwrap(); assert_eq!(between, Some(Value::Str("A".into()))); let after = store .entity_attr( &EntityId::new("e"), &AttrId::new("v"), Hlc::new(200, 0), Hlc::new(25, 0), ) .unwrap(); assert_eq!(after, Some(Value::Str("B".into()))); } fn retract_op(entity: &str, attr_names: &[&str], tx: Hlc) -> Operation { let key = signing_key(); let body = OpBody { actor: PublicKey::from_signing_key(&key), parents: vec![], payload: OpPayload::Retract { attrs: attr_names.iter().map(|n| AttrId::new(*n)).collect(), entity: EntityId::new(entity), }, timestamp: tx, }; Operation::sign(body, &key) } #[test] fn retract_hides_attribute_in_query_view() { let dir = tempfile::tempdir().unwrap(); let store = TripleStore::open(dir.path().join("triples.redb")).unwrap(); // Assert e.v = 1 at tx=10, then retract e.v at tx=20. store .apply(&assert_op("e", vec![("v", Value::Int(1))], Hlc::new(10, 0), None)) .unwrap(); store .apply(&retract_op("e", &["v"], Hlc::new(20, 0))) .unwrap(); // As of tx=20 the attribute is gone. let after = store .entity_attrs(&EntityId::new("e"), Hlc::new(20, 0), Hlc::new(20, 0)) .unwrap(); assert!(after.is_empty(), "a winning retraction hides the attribute"); // Bitemporal: as of tx=10 (before the retract) it is still visible. let before = store .entity_attrs(&EntityId::new("e"), Hlc::new(10, 0), Hlc::new(10, 0)) .unwrap(); assert_eq!(before, vec![(AttrId::new("v"), Value::Int(1))]); } #[test] fn reassert_after_retract_restores_attribute() { let dir = tempfile::tempdir().unwrap(); let store = TripleStore::open(dir.path().join("triples.redb")).unwrap(); store .apply(&assert_op("e", vec![("v", Value::Int(1))], Hlc::new(10, 0), None)) .unwrap(); store.apply(&retract_op("e", &["v"], Hlc::new(20, 0))).unwrap(); store .apply(&assert_op("e", vec![("v", Value::Int(2))], Hlc::new(30, 0), None)) .unwrap(); let now = store .entity_attrs(&EntityId::new("e"), Hlc::new(30, 0), Hlc::new(30, 0)) .unwrap(); assert_eq!(now, vec![(AttrId::new("v"), Value::Int(2))], "re-assert after retract wins by tx_time"); }