378 lines
10 KiB
Rust
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");
|
|
}
|