use std::sync::Arc;
use armour_core::Fuid;
use armour_core::{CollectionKind, GetType, OnMissing};
use rapira::Rapira;
use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout};
use crate::armour::db::Db;
use crate::key::Key;
use crate::schema::*;
use crate::{CollectionMeta, Config, NoHook};
use crate::{RapiraCodec, typed_map};
armour_core::const_hasher_or!(
s_user,
"SUSR",
"K_zxJmNfxOS4R5DfaA2Dl42iHC4LjNaPTfLha6VKQoU1xtk91gdYVSNyl_o5iw1jqxx2zTqYqrQ"
);
armour_core::const_hasher_or!(
s_group,
"SGRP",
"au2voAmcasJn_OEcNTf9OZVhP0FEECk9l59i3On0yirc7FyIHMWeEQWqEui4bjEQtfqZkMRpejE"
);
pub type SUserId = Fuid<s_user::Hasher>;
pub type SGroupId = Fuid<s_group::Hasher>;
fn fuid_from_native<H>(v: u64) -> Fuid<H> {
Fuid::from_be_bytes(v.to_be_bytes())
}
fn suser(name: &str, mentor: SUserId) -> SUser {
SUser {
name: name.into(),
mentor,
}
}
#[derive(Clone, Debug, PartialEq, GetType, Rapira)]
pub struct SUser {
pub name: String,
pub mentor: SUserId,
}
impl CollectionMeta for SUser {
type SelfId = SUserId;
const NAME: &'static str = "s_users";
}
#[derive(Clone, Debug, PartialEq, GetType, Rapira)]
pub struct SGroup {
pub title: String,
pub owner: SUserId,
pub member_count: u32,
}
impl CollectionMeta for SGroup {
type SelfId = SGroupId;
const NAME: &'static str = "s_groups";
}
pub fn open_fixture(
dir: &std::path::Path,
) -> (
Arc<Db>,
Arc<typed_map!(SUser, RapiraCodec)>,
Arc<typed_map!(SGroup, RapiraCodec)>,
) {
let db = Arc::new(Db::open_test(dir).expect("open db"));
let users = db
.open_typed_map::<SUser, RapiraCodec, _>(Config::default(), NoHook, &[])
.expect("open users");
let groups = db
.open_typed_map::<SGroup, RapiraCodec, _>(Config::default(), NoHook, &[])
.expect("open groups");
(db, users, groups)
}
#[test]
fn schema_mermaid_golden() {
let dir = tempfile::tempdir().expect("tmp");
let (_db, users, groups) = open_fixture(dir.path());
let mut reg = SchemaRegistry::new();
reg.register_entity(&users);
reg.register_entity(&groups);
let built = reg.finish().expect("finish");
let out = MermaidEr.format(&built.graph());
let expected = "\
erDiagram
s_users {
Str name
Fuid mentor
}
s_groups {
Str title
Fuid owner
U32 member_count
}
s_users }o--|| s_users : \"fk mentor (SUSR)\"
s_groups }o--|| s_users : \"fk owner (SUSR)\"
";
assert_eq!(out, expected);
}
#[test]
fn schema_register_and_finish() {
let dir = tempfile::tempdir().expect("tmp");
let (_db, users, groups) = open_fixture(dir.path());
let mut reg = SchemaRegistry::new();
reg.register_entity(&users);
reg.register_entity(&groups);
let built = reg.finish().expect("finish ok");
let graph = built.graph();
assert_eq!(graph.collections.len(), 2);
let users_node = graph
.collections
.iter()
.find(|c| c.name == "s_users")
.expect("users node");
assert_eq!(users_node.kind, CollectionKind::Entity);
assert_eq!(users_node.self_brand.as_deref(), Some("SUSR"));
assert!(
graph
.relations
.iter()
.any(|r| r.from == "s_groups" && r.to == "s_users")
);
}
#[test]
fn schema_finish_rejects_duplicate_entity_brand() {
let dir = tempfile::tempdir().expect("tmp");
let (_db, users, _groups) = open_fixture(dir.path());
#[derive(Clone, Debug, PartialEq, GetType, Rapira)]
struct SUserDup {
pub bio: String,
}
impl CollectionMeta for SUserDup {
type SelfId = SUserId;
const NAME: &'static str = "s_users_dup";
}
let db2 = Db::open_test(dir.path().join("dup"))
.map(Arc::new)
.expect("db2");
let dups = db2
.open_typed_map::<SUserDup, RapiraCodec, _>(Config::default(), NoHook, &[])
.expect("open dup");
let mut reg = SchemaRegistry::new();
reg.register_entity(&users);
reg.register_entity(&dups);
assert!(
reg.finish().is_err(),
"duplicate entity brand must be rejected"
);
}
#[test]
fn schema_validate_same_brand_value_fk_dangling() {
let dir = tempfile::tempdir().expect("tmp");
let (db, users, groups) = open_fixture(dir.path());
let u1 = fuid_from_native::<s_user::Hasher>(1);
users.insert(&u1, suser("a", u1)).expect("ins");
users
.insert(
&fuid_from_native::<s_user::Hasher>(2),
suser("b", fuid_from_native::<s_user::Hasher>(999)),
)
.expect("ins");
let mut reg = SchemaRegistry::new();
reg.register_entity(&users);
reg.register_entity(&groups);
let built = reg.finish().expect("finish");
let report = validate(&db, &built);
assert!(
report.findings.iter().any(|f| matches!(
f,
Finding::DanglingFk {
collection,
target,
id,
..
} if collection == "s_users" && target == "s_users" && *id == 999
)),
"{report}"
);
}
#[test]
fn schema_validate_dangling_fk() {
let dir = tempfile::tempdir().expect("tmp");
let (db, users, groups) = open_fixture(dir.path());
let alive = fuid_from_native::<s_user::Hasher>(101);
users.insert(&alive, suser("a", alive)).expect("ins");
groups
.insert(
&fuid_from_native::<s_group::Hasher>(1),
SGroup {
title: "ok".into(),
owner: alive,
member_count: 0,
},
)
.expect("ins");
groups
.insert(
&fuid_from_native::<s_group::Hasher>(2),
SGroup {
title: "bad".into(),
owner: fuid_from_native::<s_user::Hasher>(999),
member_count: 0,
},
)
.expect("ins");
let mut reg = SchemaRegistry::new();
reg.register_entity(&users);
reg.register_entity(&groups);
let built = reg.finish().expect("finish");
let report = validate(&db, &built);
let dangling: Vec<_> = report
.findings
.iter()
.filter(|f| matches!(f, Finding::DanglingFk { .. }))
.collect();
assert_eq!(dangling.len(), 1, "report: {report}");
match dangling[0] {
Finding::DanglingFk {
collection,
target,
id,
..
} => {
assert_eq!(*collection, "s_groups".to_string());
assert_eq!(*target, "s_users".to_string());
assert_eq!(*id, 999);
}
_ => unreachable!(),
}
}
#[test]
fn schema_validate_unclassified_collection() {
let dir = tempfile::tempdir().expect("tmp");
let (db, users, _groups) = open_fixture(dir.path());
let mut reg = SchemaRegistry::new();
reg.register_entity(&users);
let built = reg.finish().expect("finish");
let report = validate(&db, &built);
assert!(
report
.findings
.iter()
.any(|f| matches!(f, Finding::UnclassifiedCollection { name } if name == "s_groups"))
);
}
#[derive(
Clone,
Copy,
Debug,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
FromBytes,
IntoBytes,
Immutable,
KnownLayout,
armour_core::GetRefs,
)]
#[repr(C)]
struct OwnerGroupKey {
owner: SUserId,
group: SGroupId,
}
crate::impl_key_zerocopy!(
OwnerGroupKey,
armour_core::KeyScheme::Typed(&[armour_core::KeyType::Fuid, armour_core::KeyType::Fuid])
);
#[derive(Clone, Copy, Debug, PartialEq, GetType, FromBytes, IntoBytes, Immutable, KnownLayout)]
#[repr(C)]
struct OwnerGroupMarker(pub u8);
impl CollectionMeta for OwnerGroupMarker {
type SelfId = OwnerGroupKey;
const NAME: &'static str = "s_owner_groups";
}
#[test]
fn schema_index_hook_mutation_matrix() {
let dir = tempfile::tempdir().expect("tmp");
let db = Arc::new(Db::open_test(dir.path()).expect("db"));
let idx = db
.open_zero_tree::<OwnerGroupMarker, { std::mem::size_of::<OwnerGroupMarker>() }, _>(
Config::default(),
NoHook,
&[],
)
.expect("open idx");
let mut reg = SchemaRegistry::new();
reg.register(&idx, CollectionKind::SecondaryIndex);
let hook = reg.secondary_index::<SGroup, _, _>(idx.clone(), |k: &SGroupId, v: &SGroup| {
(
OwnerGroupKey {
owner: v.owner,
group: *k,
},
OwnerGroupMarker(1),
)
});
let groups = db
.open_typed_map::<SGroup, RapiraCodec, _>(Config::default(), hook, &[])
.expect("open groups");
reg.register_entity(&groups);
let g = fuid_from_native::<s_group::Hasher>(1);
let u1 = fuid_from_native::<s_user::Hasher>(10);
let u2 = fuid_from_native::<s_user::Hasher>(20);
groups
.insert(
&g,
SGroup {
title: "t".into(),
owner: u1,
member_count: 0,
},
)
.expect("ins");
assert!(idx.contains(&OwnerGroupKey {
owner: u1,
group: g
}));
groups
.update(&g, |v| SGroup {
title: "t2".into(),
..v.clone()
})
.expect("upd");
assert!(idx.contains(&OwnerGroupKey {
owner: u1,
group: g
}));
groups
.update(&g, |v| SGroup {
owner: u2,
..v.clone()
})
.expect("upd2");
assert!(
!idx.contains(&OwnerGroupKey {
owner: u1,
group: g
}),
"old index row must be removed"
);
assert!(idx.contains(&OwnerGroupKey {
owner: u2,
group: g
}));
groups.delete(&g).expect("del");
assert!(!idx.contains(&OwnerGroupKey {
owner: u2,
group: g
}));
}
#[test]
fn schema_validate_index_sync() {
let dir = tempfile::tempdir().expect("tmp");
let db = Arc::new(Db::open_test(dir.path()).expect("db"));
let idx = db
.open_zero_tree::<OwnerGroupMarker, { std::mem::size_of::<OwnerGroupMarker>() }, _>(
Config::default(),
NoHook,
&[],
)
.expect("idx");
let mut reg = SchemaRegistry::new();
reg.register(&idx, CollectionKind::SecondaryIndex);
let _unused_hook = reg.secondary_index::<SGroup, _, _>(idx.clone(), |k, v: &SGroup| {
(
OwnerGroupKey {
owner: v.owner,
group: *k,
},
OwnerGroupMarker(1),
)
});
let users = db
.open_typed_map::<SUser, RapiraCodec, _>(Config::default(), NoHook, &[])
.expect("users");
let groups = db
.open_typed_map::<SGroup, RapiraCodec, _>(Config::default(), NoHook, &[])
.expect("groups");
reg.register_entity(&users);
reg.register_entity(&groups);
let u = fuid_from_native::<s_user::Hasher>(1);
users.insert(&u, suser("a", u)).expect("i");
groups
.insert(
&fuid_from_native::<s_group::Hasher>(1),
SGroup {
title: "a".into(),
owner: u,
member_count: 0,
},
)
.expect("i");
idx.insert(
&OwnerGroupKey {
owner: u,
group: fuid_from_native::<s_group::Hasher>(777),
},
&OwnerGroupMarker(1),
)
.expect("i");
let built = reg.finish().expect("finish");
let report = validate(&db, &built);
assert!(
report
.findings
.iter()
.any(|f| matches!(f, Finding::IndexMissing { .. })),
"{report}"
);
assert!(
report
.findings
.iter()
.any(|f| matches!(f, Finding::IndexOrphan { .. })),
"{report}"
);
}
#[test]
fn schema_validate_unvalidated_edge_for_unknown_brand() {
let dir = tempfile::tempdir().expect("tmp");
let (db, _users, groups) = open_fixture(dir.path());
let mut reg = SchemaRegistry::new();
reg.register_entity(&groups);
let built = reg.finish().expect("finish");
let report = validate(&db, &built);
assert!(
report
.findings
.iter()
.any(|f| matches!(f, Finding::UnvalidatedEdge { .. }))
);
}
#[derive(Clone, Debug, PartialEq, GetType, Rapira)]
pub struct SDevice {
pub raw_owner: u64,
}
impl CollectionMeta for SDevice {
type SelfId = [u8; 8];
const NAME: &'static str = "s_devices";
}
#[test]
fn schema_validate_explicit_fk_unbranded() {
let dir = tempfile::tempdir().expect("tmp");
let (db, users, _groups) = open_fixture(dir.path());
let devices = db
.open_typed_map::<SDevice, RapiraCodec, _>(Config::default(), NoHook, &[])
.expect("devices");
let alive = fuid_from_native::<s_user::Hasher>(5);
users.insert(&alive, suser("x", alive)).expect("i");
devices
.insert(&[1u8; 8], SDevice { raw_owner: 5 })
.expect("i");
devices
.insert(&[2u8; 8], SDevice { raw_owner: 6 })
.expect("i");
let mut reg = SchemaRegistry::new();
reg.register_entity(&users);
reg.register(&devices, CollectionKind::Standalone);
reg.fk::<SDevice, SUser, _>(
OnMissing::Error,
FkLabel::field("raw_owner"),
|_k: &[u8; 8], v: &SDevice| Some(fuid_from_native::<s_user::Hasher>(v.raw_owner)),
);
let built = reg.finish().expect("finish");
let report = validate(&db, &built);
let dangling: Vec<_> = report
.findings
.iter()
.filter(|f| {
matches!(
f,
Finding::DanglingFk {
collection,
..
} if collection == "s_devices"
)
})
.collect();
assert_eq!(dangling.len(), 1, "{report}");
}
#[test]
fn schema_validate_denormalized() {
let dir = tempfile::tempdir().expect("tmp");
let (db, users, _groups) = open_fixture(dir.path());
#[derive(Clone, Debug, PartialEq, GetType, Rapira)]
struct SUserCard {
pub name: String,
}
impl CollectionMeta for SUserCard {
type SelfId = SUserId;
const NAME: &'static str = "s_user_cards";
}
let cards = db
.open_typed_map::<SUserCard, RapiraCodec, _>(Config::default(), NoHook, &[])
.expect("cards");
let u1 = fuid_from_native::<s_user::Hasher>(1);
let u2 = fuid_from_native::<s_user::Hasher>(2);
users.insert(&u1, suser("a", u1)).expect("i");
users.insert(&u2, suser("b", u2)).expect("i");
cards
.insert(&u1, SUserCard { name: "a".into() })
.expect("i"); let u3 = fuid_from_native::<s_user::Hasher>(3);
users.insert(&u3, suser("c", u3)).expect("i");
cards
.insert(
&u3,
SUserCard {
name: "STALE".into(),
},
)
.expect("i");
let mut reg = SchemaRegistry::new();
reg.register_entity(&users);
reg.register(&cards, CollectionKind::Denormalized);
reg.denormalized::<SUser, SUserCard, _, _>(
|k: &SUserId, _v: &SUser| *k,
|s: &SUser, d: &SUserCard| s.name == d.name,
);
let built = reg.finish().expect("finish");
let report = validate(&db, &built);
assert!(
report
.findings
.iter()
.any(|f| matches!(f, Finding::DenormMissing { .. })),
"{report}"
);
assert!(
report
.findings
.iter()
.any(|f| matches!(f, Finding::DenormMismatch { .. })),
"{report}"
);
}
#[test]
fn schema_validate_counter() {
let dir = tempfile::tempdir().expect("tmp");
let (db, users, groups) = open_fixture(dir.path());
#[derive(
Clone,
Copy,
Debug,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
FromBytes,
IntoBytes,
Immutable,
KnownLayout,
armour_core::GetRefs,
)]
#[repr(C)]
struct SMemberKey {
group: SGroupId,
user: SUserId,
}
crate::impl_key_zerocopy!(
SMemberKey,
armour_core::KeyScheme::Typed(&[armour_core::KeyType::Fuid, armour_core::KeyType::Fuid])
);
#[derive(
Clone, Copy, Debug, PartialEq, GetType, FromBytes, IntoBytes, Immutable, KnownLayout,
)]
#[repr(C)]
struct SMember(pub u8);
impl CollectionMeta for SMember {
type SelfId = SMemberKey;
const NAME: &'static str = "s_members";
}
let members = db
.open_zero_tree::<SMember, { std::mem::size_of::<SMember>() }, _>(
Config::default(),
NoHook,
&[],
)
.expect("members");
let g = fuid_from_native::<s_group::Hasher>(1);
let u1 = fuid_from_native::<s_user::Hasher>(1);
users.insert(&u1, suser("a", u1)).expect("i");
groups
.insert(
&g,
SGroup {
title: "g".into(),
owner: u1,
member_count: 2,
},
)
.expect("i"); members
.insert(&SMemberKey { group: g, user: u1 }, &SMember(1))
.expect("i");
let mut reg = SchemaRegistry::new();
reg.register_entity(&users);
reg.register_entity(&groups);
reg.register(&members, CollectionKind::SecondaryIndex);
reg.counter::<SGroup, _, _>(
"member_count",
|v: &SGroup| v.member_count as u64,
"s_members",
|k: &SGroupId| Key::as_bytes(k).to_vec(),
);
let built = reg.finish().expect("finish");
let report = validate(&db, &built);
match report
.findings
.iter()
.find(|f| matches!(f, Finding::CounterMismatch { .. }))
{
Some(Finding::CounterMismatch {
stored, expected, ..
}) => {
assert_eq!((*stored, *expected), (2, 1));
}
other => panic!("expected CounterMismatch, got {other:?}; report: {report}"),
}
}
#[derive(Clone, Debug, PartialEq, GetType, Rapira)]
struct SGroupArchive {
pub owner: SUserId,
}
impl CollectionMeta for SGroupArchive {
type SelfId = SGroupId;
const NAME: &'static str = "s_groups_archive";
}
#[test]
fn schema_validate_index_sync_multi_source() {
let dir = tempfile::tempdir().expect("tmp");
let db = Arc::new(Db::open_test(dir.path()).expect("db"));
let idx = db
.open_zero_tree::<OwnerGroupMarker, { std::mem::size_of::<OwnerGroupMarker>() }, _>(
Config::default(),
NoHook,
&[],
)
.expect("idx");
let mut reg = SchemaRegistry::new();
reg.register(&idx, CollectionKind::SecondaryIndex);
let _h1 = reg.secondary_index::<SGroup, _, _>(idx.clone(), |k, v: &SGroup| {
(
OwnerGroupKey {
owner: v.owner,
group: *k,
},
OwnerGroupMarker(1),
)
});
let _h2 = reg.secondary_index::<SGroupArchive, _, _>(idx.clone(), |k, v: &SGroupArchive| {
(
OwnerGroupKey {
owner: v.owner,
group: *k,
},
OwnerGroupMarker(1),
)
});
let users = db
.open_typed_map::<SUser, RapiraCodec, _>(Config::default(), NoHook, &[])
.expect("users");
let groups = db
.open_typed_map::<SGroup, RapiraCodec, _>(Config::default(), NoHook, &[])
.expect("groups");
let archive = db
.open_typed_map::<SGroupArchive, RapiraCodec, _>(Config::default(), NoHook, &[])
.expect("archive");
reg.register_entity(&users);
reg.register_entity(&groups);
reg.register(&archive, CollectionKind::Denormalized);
let u = fuid_from_native::<s_user::Hasher>(1);
users.insert(&u, suser("a", u)).expect("i");
let g1 = fuid_from_native::<s_group::Hasher>(1);
let g2 = fuid_from_native::<s_group::Hasher>(2);
groups
.insert(
&g1,
SGroup {
title: "live".into(),
owner: u,
member_count: 0,
},
)
.expect("i");
archive.insert(&g2, SGroupArchive { owner: u }).expect("i");
idx.insert(
&OwnerGroupKey {
owner: u,
group: g1,
},
&OwnerGroupMarker(1),
)
.expect("i");
idx.insert(
&OwnerGroupKey {
owner: u,
group: g2,
},
&OwnerGroupMarker(1),
)
.expect("i");
let g_orphan = fuid_from_native::<s_group::Hasher>(99);
idx.insert(
&OwnerGroupKey {
owner: u,
group: g_orphan,
},
&OwnerGroupMarker(1),
)
.expect("i");
let built = reg.finish().expect("finish");
let report = validate(&db, &built);
let orphans: Vec<_> = report
.findings
.iter()
.filter(|f| matches!(f, Finding::IndexOrphan { .. }))
.collect();
assert_eq!(
orphans.len(),
1,
"only the true orphan is flagged: {report}"
);
assert!(
!report
.findings
.iter()
.any(|f| matches!(f, Finding::IndexMissing { .. })),
"{report}"
);
}