Skip to main content

brain_edge/dto/
relation.rs

1//! Relation-graph request/response DTOs — the public JSON contract for the
2//! `/v1/relations/{id}` verb and `/v1/entities/{id}/relations` (list by
3//! direction).
4//!
5//! Deliberately identical to the Arc cloud gateway's contract. Ids are
6//! canonical UUID strings.
7
8use brain_db_sdk::wire::types::{
9    RelationGetRequest, RelationListFromRequest, RelationListToRequest, RelationView,
10};
11use serde::{Deserialize, Serialize};
12
13use super::{parse_uuid, uuid_string};
14
15/// Default page size for the list endpoint when the client omits `limit`.
16const DEFAULT_LIST_LIMIT: u32 = 100;
17/// Hard ceiling on the page size (mirrors Brain's `1..=1000`).
18const MAX_LIST_LIMIT: u32 = 1000;
19/// `RelationView.flags` bit 0 = `is_symmetric`.
20const RELATION_FLAG_SYMMETRIC: u32 = 1;
21
22/// Whether a relation read follows supersession by default (the dashboard wants
23/// the current head unless told otherwise).
24const fn default_follow_supersession() -> bool {
25    true
26}
27
28/// A read-side relation view in the public JSON contract. Ids are UUID strings.
29#[derive(Debug, Serialize)]
30pub struct RelationDetailDto {
31    /// The relation id (UUID).
32    pub relation_id: String,
33    /// The relation type (`namespace:name`).
34    pub relation_type: String,
35    /// The `from` endpoint entity (UUID).
36    pub from_entity: String,
37    /// The `to` endpoint entity (UUID).
38    pub to_entity: String,
39    /// Confidence in `[0,1]`.
40    pub confidence: f32,
41    /// Bi-temporal validity start (unix nanos).
42    pub valid_from_unix_nanos: u64,
43    /// Bi-temporal validity end (`0` = still valid).
44    pub valid_to_unix_nanos: u64,
45    /// Whether the relation type is symmetric.
46    pub is_symmetric: bool,
47    /// Whether the relation is tombstoned.
48    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/// `GET /v1/relations/{id}` query parameters.
68#[derive(Debug, Deserialize)]
69pub struct GetRelationQuery {
70    /// Follow supersession to the current chain head (default `true`).
71    #[serde(default = "default_follow_supersession")]
72    pub follow_supersession: bool,
73}
74
75/// Build a wire `RELATION_GET` request from the path id and query.
76pub 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/// Which side of a relation the anchor entity sits on.
85#[derive(Clone, Copy, Debug, PartialEq, Eq)]
86pub enum RelationSide {
87    /// Anchor is the `from` endpoint (outgoing).
88    From,
89    /// Anchor is the `to` endpoint (incoming).
90    To,
91}
92
93/// `GET /v1/entities/{id}/relations` query parameters. The anchor entity comes
94/// from the path id.
95#[derive(Debug, Default, Deserialize)]
96pub struct ListRelationsQuery {
97    /// `from`/outgoing (default) or `to`/incoming.
98    #[serde(default)]
99    pub direction: String,
100    /// Relation-type filter; omitted → any type.
101    #[serde(default, rename = "type")]
102    pub relation_type: String,
103    /// Include superseded relations (default `false`).
104    #[serde(default)]
105    pub include_superseded: bool,
106    /// Include tombstoned relations (default `false`).
107    #[serde(default)]
108    pub include_tombstoned: bool,
109    /// Page size; omitted → 100, clamped to `1..=1000`.
110    pub limit: Option<u32>,
111}
112
113/// The resolved side + limit for a relation-list query.
114fn 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    /// Resolve the requested side (outgoing vs incoming).
124    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    /// Build the outgoing (`RELATION_LIST_FROM`) wire request from the path id.
135    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    /// Build the incoming (`RELATION_LIST_TO`) wire request from the path id.
150    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/// `GET /v1/entities/{id}/relations` response: a page of relations plus its
166/// count.
167#[derive(Debug, Serialize)]
168pub struct ListRelationsResponseDto {
169    /// The relations in this page.
170    pub relations: Vec<RelationDetailDto>,
171    /// Number of relations returned.
172    pub count: usize,
173}
174
175impl ListRelationsResponseDto {
176    /// Assemble from the flattened relation-list views.
177    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}