1use crate::browser_semantic_discovery::{
4 BrowserSemanticCatalogDiscoveryCapability, BrowserSemanticCatalogDiscoveryError,
5 BrowserSemanticEntityCatalogueRecord, BrowserSemanticRelationCatalogueRecord, relation_record,
6};
7use ahri_tre_protocol::{
8 ProtocolVersion, PublicUuid,
9 model::{
10 BrowserRelationSearchEmptyState, BrowserRelationSearchResponse, EntitySelector,
11 RelationPredicate, RelationSearchCursorAnchor, RelationTextField, SearchRelationsRequest,
12 },
13 refs::{RelationRef, TagScope},
14 search::{PredicateSet, TextMode},
15 study::BrowserSearchOrdering,
16};
17
18#[allow(clippy::result_large_err)]
19pub fn browser_model_relation_search_response(
20 datastore_id: PublicUuid,
21 entities: Vec<BrowserSemanticEntityCatalogueRecord>,
22 mut relations: Vec<BrowserSemanticRelationCatalogueRecord>,
23 request: &SearchRelationsRequest,
24 protocol_version: &ProtocolVersion,
25) -> Result<BrowserRelationSearchResponse, ahri_tre_protocol::ProtocolError> {
26 use ahri_tre_protocol::refs::{ObjectKind, TagSelector};
27 for predicate in request.predicates.predicates() {
28 match predicate {
29 RelationPredicate::Domain { domain } => {
30 crate::browser_catalogue_search::validate_domain(datastore_id, domain)?
31 }
32 RelationPredicate::Tag(tag) => {
33 if let TagSelector::Id { tag } = tag.selector {
34 tag.validate_context(datastore_id, ObjectKind::Tag)?;
35 }
36 }
37 RelationPredicate::SourceEntity { entity }
38 | RelationPredicate::TargetEntity { entity } => match entity {
39 EntitySelector::Id { entity, domain } => {
40 entity.validate_context(datastore_id, ObjectKind::Entity)?;
41 if let Some(domain) = domain {
42 crate::browser_catalogue_search::validate_domain(datastore_id, domain)?;
43 for candidate in &entities {
44 let endpoint = &candidate.record.entity;
45 if ahri_tre_protocol::refs::encode_scoped_integer_ref(
46 "entity",
47 endpoint.entity_id.0,
48 ) == entity.id
49 && !domain_matches(
50 domain,
51 endpoint.domain_id.0,
52 &candidate.domain_name,
53 )
54 {
55 return Err(ahri_tre_protocol::ProtocolError::new(
56 ahri_tre_protocol::ProtocolErrorCode::Conflict,
57 "Entity reference and Domain constraint disagree",
58 ));
59 }
60 }
61 }
62 }
63 EntitySelector::Name { domain, .. } => {
64 crate::browser_catalogue_search::validate_domain(datastore_id, domain)?
65 }
66 },
67 _ => {}
68 }
69 }
70 let after = request.browser_decoded_cursor(datastore_id, protocol_version)?;
71 if let Some(cursor) = after.as_ref() {
72 cursor
73 .domain
74 .validate_context(datastore_id, ObjectKind::Domain)?;
75 cursor
76 .relation
77 .validate_context(datastore_id, ObjectKind::Relation)?;
78 }
79 relations.retain(|record| relation_matches(record, request));
80 relations.sort_by_key(relation_sort_key);
81 if let Some(after) = after {
82 relations.retain(|record| relation_sort_key(record) > cursor_sort_key(&after));
83 }
84 let limit = usize::from(request.page.limit);
85 let has_more = relations.len() > limit;
86 let page = relations.into_iter().take(limit).collect::<Vec<_>>();
87 let next_cursor = if has_more {
88 page.last()
89 .map(|record| {
90 request.encode_browser_cursor(
91 datastore_id,
92 protocol_version,
93 cursor_anchor(datastore_id, record),
94 )
95 })
96 .transpose()?
97 } else {
98 None
99 };
100 let returned_count = u16::try_from(page.len()).expect("page length fits u16");
101 let empty_state = page.is_empty().then(|| BrowserRelationSearchEmptyState {
102 code: "no_matching_browser_visible_model_relations".to_string(),
103 message: "No browser-visible model relations match the active search filters.".to_string(),
104 });
105 Ok(BrowserRelationSearchResponse {
106 protocol_version: protocol_version.clone(), datastore_id, family: "model_relations".to_string(), predicates: request.predicates.clone(),
107 relations: page.into_iter().map(|candidate| relation_record(datastore_id, candidate.record)).collect(), returned_count, requested_limit: request.page.limit,
108 ordering: BrowserSearchOrdering { id: "model_relation_name_then_domain_identity_then_relation_identity".to_string(), description: "Model relations are ordered by canonical name, domain identity, then relation identity.".to_string() },
109 has_more, next_cursor, empty_state,
110 })
111}
112
113pub fn search_browser_model_relations(
114 capability: &dyn BrowserSemanticCatalogDiscoveryCapability,
115 datastore_id: PublicUuid,
116 request: &SearchRelationsRequest,
117 protocol_version: &ProtocolVersion,
118) -> Result<BrowserRelationSearchResponse, BrowserSemanticCatalogDiscoveryError> {
119 browser_model_relation_search_response(
120 datastore_id,
121 capability.discover_catalogue_entities(datastore_id)?,
122 capability.discover_catalogue_relations(datastore_id)?,
123 request,
124 protocol_version,
125 )
126 .map_err(|error| {
127 if error.code == ahri_tre_protocol::ProtocolErrorCode::Conflict {
128 BrowserSemanticCatalogDiscoveryError::ReferenceConflict
129 } else {
130 BrowserSemanticCatalogDiscoveryError::InvalidRequest
131 }
132 })
133}
134
135fn relation_matches(
136 candidate: &BrowserSemanticRelationCatalogueRecord,
137 request: &SearchRelationsRequest,
138) -> bool {
139 let relation = &candidate.record.relation;
140 let matches =
141 |predicate: &RelationPredicate| match predicate {
142 RelationPredicate::Text(text) => match text.field {
143 RelationTextField::Name => text_matches(&relation.name, text.mode, &text.value),
144 RelationTextField::Description => relation
145 .description
146 .as_deref()
147 .is_some_and(|value| text_matches(value, text.mode, &text.value)),
148 RelationTextField::SourceEntityName => {
149 text_matches(&candidate.source_entity_name, text.mode, &text.value)
150 }
151 RelationTextField::TargetEntityName => {
152 text_matches(&candidate.target_entity_name, text.mode, &text.value)
153 }
154 RelationTextField::OntologyClass => relation
155 .ontology_class
156 .as_deref()
157 .is_some_and(|value| text_matches(value, text.mode, &text.value)),
158 },
159 RelationPredicate::Domain { domain } => {
160 domain_matches(domain, relation.domain_id.0, &candidate.domain_name)
161 }
162 RelationPredicate::SourceEntity { entity } => entity_matches(
163 entity,
164 relation.subject_entity_id.0,
165 &candidate.source_entity_name,
166 relation.domain_id.0,
167 &candidate.domain_name,
168 ),
169 RelationPredicate::TargetEntity { entity } => entity_matches(
170 entity,
171 relation.object_entity_id.0,
172 &candidate.target_entity_name,
173 relation.domain_id.0,
174 &candidate.domain_name,
175 ),
176 RelationPredicate::Tag(tag) => candidate.record.tags.iter().any(|candidate| match &tag
177 .selector
178 {
179 ahri_tre_protocol::refs::TagSelector::Id { tag } => {
180 ahri_tre_protocol::refs::encode_scoped_integer_ref("tag", candidate.tag_id)
181 == tag.id
182 }
183 ahri_tre_protocol::refs::TagSelector::Label { scope, label } => {
184 *scope == TagScope::Global
185 && ahri_tre_core::normalize_tags([label.as_str()])
186 .first()
187 .is_some_and(|normalized| candidate.name == *normalized)
188 }
189 }),
190 };
191 match &request.predicates {
192 PredicateSet::AllOf(predicates) => predicates.iter().all(matches),
193 PredicateSet::AnyOf(predicates) => predicates.iter().any(matches),
194 }
195}
196
197fn domain_matches(
198 domain: &ahri_tre_protocol::domain::DomainSelector,
199 domain_id: i64,
200 domain_name: &str,
201) -> bool {
202 match domain {
203 ahri_tre_protocol::domain::DomainSelector::Id { domain } => {
204 ahri_tre_protocol::refs::encode_scoped_integer_ref("domain", domain_id) == domain.id
205 }
206 ahri_tre_protocol::domain::DomainSelector::Name { name } => domain_name == name.as_str(),
207 }
208}
209
210fn entity_matches(
211 selector: &EntitySelector,
212 entity_id: i64,
213 entity_name: &str,
214 domain_id: i64,
215 domain_name: &str,
216) -> bool {
217 match selector {
218 EntitySelector::Id { entity, domain } => {
219 ahri_tre_protocol::refs::encode_scoped_integer_ref("entity", entity_id) == entity.id
220 && domain
221 .as_ref()
222 .is_none_or(|domain| domain_matches(domain, domain_id, domain_name))
223 }
224 EntitySelector::Name { domain, name } => {
225 domain_matches(domain, domain_id, domain_name) && entity_name == name.as_str()
226 }
227 }
228}
229
230fn text_matches(value: &str, mode: TextMode, expected: &str) -> bool {
231 ahri_tre_core::search_text_matches(
232 value,
233 match mode {
234 TextMode::Exact => ahri_tre_core::SearchTextMode::Exact,
235 TextMode::Prefix => ahri_tre_core::SearchTextMode::Prefix,
236 TextMode::Contains => ahri_tre_core::SearchTextMode::Contains,
237 },
238 expected,
239 )
240}
241
242fn relation_sort_key(
243 record: &BrowserSemanticRelationCatalogueRecord,
244) -> (String, String, i64, i64) {
245 let relation = &record.record.relation;
246 (
247 relation.name.to_ascii_lowercase(),
248 relation.name.clone(),
249 relation.domain_id.0,
250 relation.entity_relation_id.0,
251 )
252}
253
254fn cursor_sort_key(anchor: &RelationSearchCursorAnchor) -> (String, String, i64, i64) {
255 (
256 anchor.name.to_ascii_lowercase(),
257 anchor.name.clone(),
258 anchor.domain.id.as_uuid().as_u128() as i64,
259 anchor.relation.id.as_uuid().as_u128() as i64,
260 )
261}
262
263fn cursor_anchor(
264 datastore_id: PublicUuid,
265 record: &BrowserSemanticRelationCatalogueRecord,
266) -> RelationSearchCursorAnchor {
267 let relation = &record.record.relation;
268 RelationSearchCursorAnchor {
269 domain: ahri_tre_protocol::refs::DomainRef {
270 datastore_id,
271 kind: ahri_tre_protocol::refs::ObjectKind::Domain,
272 id: ahri_tre_protocol::refs::encode_scoped_integer_ref("domain", relation.domain_id.0),
273 },
274 relation: RelationRef {
275 datastore_id,
276 kind: ahri_tre_protocol::refs::ObjectKind::Relation,
277 id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
278 "relation",
279 relation.entity_relation_id.0,
280 ),
281 },
282 name: relation.name.clone(),
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use ahri_tre_types::{DomainId, EntityId, EntityRelationId};
290 use uuid::Uuid;
291
292 fn relation(
293 id: i64,
294 name: &str,
295 source: &str,
296 target: &str,
297 ) -> BrowserSemanticRelationCatalogueRecord {
298 BrowserSemanticRelationCatalogueRecord {
299 domain_name: "demography".to_string(),
300 source_entity_name: source.to_string(),
301 target_entity_name: target.to_string(),
302 record: crate::browser_semantic_discovery::BrowserSemanticRelationSourceRecord {
303 relation: ahri_tre_pgmeta::PgEntityRelationReadModel {
304 entity_relation_id: EntityRelationId(id),
305 subject_entity_id: EntityId(4),
306 object_entity_id: EntityId(5),
307 domain_id: DomainId(3),
308 uuid: None,
309 name: name.to_string(),
310 description: Some("A browser-visible semantic relation.".to_string()),
311 agent_instructions: Some("agent-secret".to_string()),
312 ontology_namespace: None,
313 ontology_class: Some("association".to_string()),
314 },
315 tags: vec![ahri_tre_pgmeta::PgTagReadModel {
316 tag_id: 6,
317 name: "core-link".to_string(),
318 }],
319 },
320 }
321 }
322
323 #[test]
324 fn browser_relation_search_filters_pages_binds_cursor_and_redacts() {
325 let datastore_id = PublicUuid::from_uuid(Uuid::from_u128(1));
326 let version = ProtocolVersion::current();
327 let mut request: SearchRelationsRequest = serde_json::from_str(r#"{"predicates":{"kind":"all_of","predicates":[{"kind":"source_entity","entity":{"kind":"name","domain":{"kind":"name","name":"demography"},"name":"Patient"}},{"kind":"tag","selector":{"kind":"label","scope":"global","label":"CORE-LINK"}}]},"page":{"limit":1}}"#).unwrap();
328 request
329 .validate_for_browser(datastore_id, &version)
330 .unwrap();
331 let records = vec![
332 relation(2, "has guardian", "Patient", "Guardian"),
333 relation(1, "has address", "Patient", "Address"),
334 ];
335 let first = browser_model_relation_search_response(
336 datastore_id,
337 vec![],
338 records.clone(),
339 &request,
340 &version,
341 )
342 .unwrap();
343 assert_eq!(first.relations[0].name, "has address");
344 assert!(first.has_more);
345 assert!(
346 !serde_json::to_string(&first)
347 .unwrap()
348 .contains("agent-secret")
349 );
350 request.page.cursor = first.next_cursor;
351 let second = browser_model_relation_search_response(
352 datastore_id,
353 vec![],
354 records,
355 &request,
356 &version,
357 )
358 .unwrap();
359 assert_eq!(second.relations[0].name, "has guardian");
360 assert!(
361 request
362 .validate_for_browser(PublicUuid::from_uuid(Uuid::from_u128(9)), &version)
363 .is_err()
364 );
365 }
366 #[test]
367 fn browser_relation_search_rejects_conflicting_domain_for_unlinked_entity() {
368 let datastore_id = PublicUuid::from_uuid(Uuid::from_u128(1));
369 let entity = ahri_tre_protocol::refs::ObjectRef {
370 datastore_id,
371 kind: ahri_tre_protocol::refs::ObjectKind::Entity,
372 id: ahri_tre_protocol::refs::encode_scoped_integer_ref("entity", 4),
373 };
374 let request = serde_json::from_value(serde_json::json!({
375 "predicates": {"kind": "all_of", "predicates": [{
376 "kind": "source_entity", "entity": {
377 "kind": "id", "entity": entity,
378 "domain": {"kind": "name", "name": "other_domain"}
379 }
380 }]}, "page": {}
381 }))
382 .unwrap();
383 let error = browser_model_relation_search_response(
384 datastore_id,
385 vec![BrowserSemanticEntityCatalogueRecord {
386 domain_name: "demography".into(),
387 record: crate::BrowserSemanticEntitySourceRecord {
388 entity: ahri_tre_pgmeta::PgEntityReadModel {
389 entity_id: EntityId(4),
390 domain_id: DomainId(3),
391 uuid: None,
392 name: "Patient".into(),
393 description: None,
394 agent_instructions: None,
395 ontology_namespace: None,
396 ontology_class: None,
397 },
398 tags: vec![],
399 },
400 }],
401 vec![],
402 &request,
403 &ProtocolVersion::current(),
404 )
405 .unwrap_err();
406 assert_eq!(error.code, ahri_tre_protocol::ProtocolErrorCode::Conflict);
407 }
408}