ontoref-code/crates/ontoref-triples/tests/basic.rs
2026-07-10 01:44:59 +01:00

378 lines
10 KiB
Rust

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<Hlc>,
) -> 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<String, Value> = 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");
}