1use brain_db_sdk::wire::types::{
9 RelationGetRequest, RelationListFromRequest, RelationListToRequest, RelationView,
10};
11use serde::{Deserialize, Serialize};
12
13use super::{parse_uuid, uuid_string};
14
15const DEFAULT_LIST_LIMIT: u32 = 100;
17const MAX_LIST_LIMIT: u32 = 1000;
19const RELATION_FLAG_SYMMETRIC: u32 = 1;
21
22const fn default_follow_supersession() -> bool {
25 true
26}
27
28#[derive(Debug, Serialize)]
30pub struct RelationDetailDto {
31 pub relation_id: String,
33 pub relation_type: String,
35 pub from_entity: String,
37 pub to_entity: String,
39 pub confidence: f32,
41 pub valid_from_unix_nanos: u64,
43 pub valid_to_unix_nanos: u64,
45 pub is_symmetric: bool,
47 pub tombstoned: bool,
49}
50
51impl From<RelationView> for RelationDetailDto {
52 fn from(v: RelationView) -> Self {
53 Self {
54 relation_id: uuid_string(&v.relation_id),
55 relation_type: v.relation_type,
56 from_entity: uuid_string(&v.from_entity),
57 to_entity: uuid_string(&v.to_entity),
58 confidence: v.confidence,
59 valid_from_unix_nanos: v.valid_from_unix_nanos,
60 valid_to_unix_nanos: v.valid_to_unix_nanos,
61 is_symmetric: v.flags & RELATION_FLAG_SYMMETRIC != 0,
62 tombstoned: v.tombstoned,
63 }
64 }
65}
66
67#[derive(Debug, Deserialize)]
69pub struct GetRelationQuery {
70 #[serde(default = "default_follow_supersession")]
72 pub follow_supersession: bool,
73}
74
75pub fn get_request_from(id: &str, query: &GetRelationQuery) -> Result<RelationGetRequest, String> {
77 Ok(RelationGetRequest {
78 relation_id: parse_uuid(id)?,
79 follow_supersession: query.follow_supersession,
80 act_as: None,
81 })
82}
83
84#[derive(Clone, Copy, Debug, PartialEq, Eq)]
86pub enum RelationSide {
87 From,
89 To,
91}
92
93#[derive(Debug, Default, Deserialize)]
96pub struct ListRelationsQuery {
97 #[serde(default)]
99 pub direction: String,
100 #[serde(default, rename = "type")]
102 pub relation_type: String,
103 #[serde(default)]
105 pub include_superseded: bool,
106 #[serde(default)]
108 pub include_tombstoned: bool,
109 pub limit: Option<u32>,
111}
112
113fn parse_side(raw: &str) -> Result<RelationSide, String> {
115 match raw.trim().to_ascii_lowercase().as_str() {
116 "" | "from" | "outgoing" | "out" => Ok(RelationSide::From),
117 "to" | "incoming" | "in" => Ok(RelationSide::To),
118 _ => Err("direction must be one of: from (outgoing), to (incoming)".into()),
119 }
120}
121
122impl ListRelationsQuery {
123 pub fn side(&self) -> Result<RelationSide, String> {
125 parse_side(&self.direction)
126 }
127
128 fn limit(&self) -> u32 {
129 self.limit
130 .unwrap_or(DEFAULT_LIST_LIMIT)
131 .clamp(1, MAX_LIST_LIMIT)
132 }
133
134 pub fn to_from_request(&self, id: &str) -> Result<RelationListFromRequest, String> {
136 Ok(RelationListFromRequest {
137 from_entity: parse_uuid(id)?,
138 relation_type_filter: self.relation_type.clone(),
139 time_range_start_unix_nanos: 0,
140 time_range_end_unix_nanos: 0,
141 include_superseded: self.include_superseded,
142 include_tombstoned: self.include_tombstoned,
143 limit: self.limit(),
144 cursor: Vec::new(),
145 act_as: None,
146 })
147 }
148
149 pub fn to_to_request(&self, id: &str) -> Result<RelationListToRequest, String> {
151 Ok(RelationListToRequest {
152 to_entity: parse_uuid(id)?,
153 relation_type_filter: self.relation_type.clone(),
154 time_range_start_unix_nanos: 0,
155 time_range_end_unix_nanos: 0,
156 include_superseded: self.include_superseded,
157 include_tombstoned: self.include_tombstoned,
158 limit: self.limit(),
159 cursor: Vec::new(),
160 act_as: None,
161 })
162 }
163}
164
165#[derive(Debug, Serialize)]
168pub struct ListRelationsResponseDto {
169 pub relations: Vec<RelationDetailDto>,
171 pub count: usize,
173}
174
175impl ListRelationsResponseDto {
176 pub fn from_views(views: Vec<RelationView>) -> Self {
178 let relations: Vec<RelationDetailDto> =
179 views.into_iter().map(RelationDetailDto::from).collect();
180 let count = relations.len();
181 Self { relations, count }
182 }
183}
184
185#[cfg(test)]
186mod relation_tests {
187 use super::*;
188 use brain_db_sdk::wire::types::EvidenceRefWire;
189
190 fn sample_view(flags: u32) -> RelationView {
191 RelationView {
192 relation_id: [0x01u8; 16],
193 chain_root: [0u8; 16],
194 relation_type: "brain:works_at".into(),
195 from_entity: [0x02u8; 16],
196 to_entity: [0x03u8; 16],
197 properties_blob: Vec::new(),
198 evidence: EvidenceRefWire::Inline(Vec::new()),
199 extractor_id: 0,
200 extracted_at_unix_nanos: 0,
201 confidence: 0.9,
202 valid_from_unix_nanos: 10,
203 valid_to_unix_nanos: 0,
204 version: 1,
205 superseded_by: [0u8; 16],
206 supersedes: [0u8; 16],
207 tombstoned: false,
208 tombstoned_at_unix_nanos: 0,
209 flags,
210 }
211 }
212
213 #[test]
214 fn detail_reads_symmetric_from_flags_and_uuidifies() {
215 let dto = RelationDetailDto::from(sample_view(RELATION_FLAG_SYMMETRIC));
216 assert!(dto.is_symmetric);
217 assert!(dto.relation_id.contains('-'));
218 assert!(dto.from_entity.contains('-') && dto.to_entity.contains('-'));
219
220 let dto = RelationDetailDto::from(sample_view(0));
221 assert!(!dto.is_symmetric);
222 }
223
224 #[test]
225 fn direction_parses_both_sides_and_rejects_junk() {
226 assert_eq!(
227 ListRelationsQuery::default().side().unwrap(),
228 RelationSide::From
229 );
230 assert_eq!(
231 ListRelationsQuery {
232 direction: "to".into(),
233 ..Default::default()
234 }
235 .side()
236 .unwrap(),
237 RelationSide::To
238 );
239 assert_eq!(
240 ListRelationsQuery {
241 direction: "incoming".into(),
242 ..Default::default()
243 }
244 .side()
245 .unwrap(),
246 RelationSide::To
247 );
248 assert!(
249 ListRelationsQuery {
250 direction: "sideways".into(),
251 ..Default::default()
252 }
253 .side()
254 .is_err()
255 );
256 }
257
258 #[test]
259 fn from_and_to_requests_target_correct_endpoint() {
260 let id = "00000000-0000-0000-0000-000000000005";
261 let q = ListRelationsQuery {
262 direction: "from".into(),
263 relation_type: "brain:knows".into(),
264 include_superseded: true,
265 include_tombstoned: false,
266 limit: Some(9999),
267 };
268 let from = q.to_from_request(id).unwrap();
269 assert_eq!(from.from_entity[15], 5);
270 assert_eq!(from.relation_type_filter, "brain:knows");
271 assert_eq!(from.limit, MAX_LIST_LIMIT);
272 assert!(from.include_superseded);
273
274 let to = q.to_to_request(id).unwrap();
275 assert_eq!(to.to_entity[15], 5);
276 assert!(q.to_from_request("bad").is_err());
277 }
278
279 #[test]
280 fn get_request_defaults_follow_supersession() {
281 let q = GetRelationQuery {
282 follow_supersession: true,
283 };
284 let req = get_request_from("00000000-0000-0000-0000-000000000001", &q).unwrap();
285 assert!(req.follow_supersession);
286 assert_eq!(req.relation_id[15], 1);
287 assert!(get_request_from("bad", &q).is_err());
288 }
289}