use brain_db_sdk::wire::types::{
RelationGetRequest, RelationListFromRequest, RelationListToRequest, RelationView,
};
use serde::{Deserialize, Serialize};
use super::{parse_uuid, uuid_string};
const DEFAULT_LIST_LIMIT: u32 = 100;
const MAX_LIST_LIMIT: u32 = 1000;
const RELATION_FLAG_SYMMETRIC: u32 = 1;
const fn default_follow_supersession() -> bool {
true
}
#[derive(Debug, Serialize)]
pub struct RelationDetailDto {
pub relation_id: String,
pub relation_type: String,
pub from_entity: String,
pub to_entity: String,
pub confidence: f32,
pub valid_from_unix_nanos: u64,
pub valid_to_unix_nanos: u64,
pub is_symmetric: bool,
pub tombstoned: bool,
}
impl From<RelationView> for RelationDetailDto {
fn from(v: RelationView) -> Self {
Self {
relation_id: uuid_string(&v.relation_id),
relation_type: v.relation_type,
from_entity: uuid_string(&v.from_entity),
to_entity: uuid_string(&v.to_entity),
confidence: v.confidence,
valid_from_unix_nanos: v.valid_from_unix_nanos,
valid_to_unix_nanos: v.valid_to_unix_nanos,
is_symmetric: v.flags & RELATION_FLAG_SYMMETRIC != 0,
tombstoned: v.tombstoned,
}
}
}
#[derive(Debug, Deserialize)]
pub struct GetRelationQuery {
#[serde(default = "default_follow_supersession")]
pub follow_supersession: bool,
}
pub fn get_request_from(id: &str, query: &GetRelationQuery) -> Result<RelationGetRequest, String> {
Ok(RelationGetRequest {
relation_id: parse_uuid(id)?,
follow_supersession: query.follow_supersession,
act_as: None,
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RelationSide {
From,
To,
}
#[derive(Debug, Default, Deserialize)]
pub struct ListRelationsQuery {
#[serde(default)]
pub direction: String,
#[serde(default, rename = "type")]
pub relation_type: String,
#[serde(default)]
pub include_superseded: bool,
#[serde(default)]
pub include_tombstoned: bool,
pub limit: Option<u32>,
}
fn parse_side(raw: &str) -> Result<RelationSide, String> {
match raw.trim().to_ascii_lowercase().as_str() {
"" | "from" | "outgoing" | "out" => Ok(RelationSide::From),
"to" | "incoming" | "in" => Ok(RelationSide::To),
_ => Err("direction must be one of: from (outgoing), to (incoming)".into()),
}
}
impl ListRelationsQuery {
pub fn side(&self) -> Result<RelationSide, String> {
parse_side(&self.direction)
}
fn limit(&self) -> u32 {
self.limit
.unwrap_or(DEFAULT_LIST_LIMIT)
.clamp(1, MAX_LIST_LIMIT)
}
pub fn to_from_request(&self, id: &str) -> Result<RelationListFromRequest, String> {
Ok(RelationListFromRequest {
from_entity: parse_uuid(id)?,
relation_type_filter: self.relation_type.clone(),
time_range_start_unix_nanos: 0,
time_range_end_unix_nanos: 0,
include_superseded: self.include_superseded,
include_tombstoned: self.include_tombstoned,
limit: self.limit(),
cursor: Vec::new(),
act_as: None,
})
}
pub fn to_to_request(&self, id: &str) -> Result<RelationListToRequest, String> {
Ok(RelationListToRequest {
to_entity: parse_uuid(id)?,
relation_type_filter: self.relation_type.clone(),
time_range_start_unix_nanos: 0,
time_range_end_unix_nanos: 0,
include_superseded: self.include_superseded,
include_tombstoned: self.include_tombstoned,
limit: self.limit(),
cursor: Vec::new(),
act_as: None,
})
}
}
#[derive(Debug, Serialize)]
pub struct ListRelationsResponseDto {
pub relations: Vec<RelationDetailDto>,
pub count: usize,
}
impl ListRelationsResponseDto {
pub fn from_views(views: Vec<RelationView>) -> Self {
let relations: Vec<RelationDetailDto> =
views.into_iter().map(RelationDetailDto::from).collect();
let count = relations.len();
Self { relations, count }
}
}
#[cfg(test)]
mod relation_tests {
use super::*;
use brain_db_sdk::wire::types::EvidenceRefWire;
fn sample_view(flags: u32) -> RelationView {
RelationView {
relation_id: [0x01u8; 16],
chain_root: [0u8; 16],
relation_type: "brain:works_at".into(),
from_entity: [0x02u8; 16],
to_entity: [0x03u8; 16],
properties_blob: Vec::new(),
evidence: EvidenceRefWire::Inline(Vec::new()),
extractor_id: 0,
extracted_at_unix_nanos: 0,
confidence: 0.9,
valid_from_unix_nanos: 10,
valid_to_unix_nanos: 0,
version: 1,
superseded_by: [0u8; 16],
supersedes: [0u8; 16],
tombstoned: false,
tombstoned_at_unix_nanos: 0,
flags,
}
}
#[test]
fn detail_reads_symmetric_from_flags_and_uuidifies() {
let dto = RelationDetailDto::from(sample_view(RELATION_FLAG_SYMMETRIC));
assert!(dto.is_symmetric);
assert!(dto.relation_id.contains('-'));
assert!(dto.from_entity.contains('-') && dto.to_entity.contains('-'));
let dto = RelationDetailDto::from(sample_view(0));
assert!(!dto.is_symmetric);
}
#[test]
fn direction_parses_both_sides_and_rejects_junk() {
assert_eq!(
ListRelationsQuery::default().side().unwrap(),
RelationSide::From
);
assert_eq!(
ListRelationsQuery {
direction: "to".into(),
..Default::default()
}
.side()
.unwrap(),
RelationSide::To
);
assert_eq!(
ListRelationsQuery {
direction: "incoming".into(),
..Default::default()
}
.side()
.unwrap(),
RelationSide::To
);
assert!(
ListRelationsQuery {
direction: "sideways".into(),
..Default::default()
}
.side()
.is_err()
);
}
#[test]
fn from_and_to_requests_target_correct_endpoint() {
let id = "00000000-0000-0000-0000-000000000005";
let q = ListRelationsQuery {
direction: "from".into(),
relation_type: "brain:knows".into(),
include_superseded: true,
include_tombstoned: false,
limit: Some(9999),
};
let from = q.to_from_request(id).unwrap();
assert_eq!(from.from_entity[15], 5);
assert_eq!(from.relation_type_filter, "brain:knows");
assert_eq!(from.limit, MAX_LIST_LIMIT);
assert!(from.include_superseded);
let to = q.to_to_request(id).unwrap();
assert_eq!(to.to_entity[15], 5);
assert!(q.to_from_request("bad").is_err());
}
#[test]
fn get_request_defaults_follow_supersession() {
let q = GetRelationQuery {
follow_supersession: true,
};
let req = get_request_from("00000000-0000-0000-0000-000000000001", &q).unwrap();
assert!(req.follow_supersession);
assert_eq!(req.relation_id[15], 1);
assert!(get_request_from("bad", &q).is_err());
}
}