Skip to main content

ahri_tre_app/
browser_relation_search.rs

1//! Browser-safe, datastore-scoped model-relation catalogue search.
2
3use 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}