Skip to main content

ahri_tre_app/
browser_model_search.rs

1//! Browser-safe, datastore-scoped model-entity catalogue search.
2
3use crate::browser_semantic_discovery::{
4    BrowserSemanticCatalogDiscoveryCapability, BrowserSemanticCatalogDiscoveryError,
5    BrowserSemanticEntityCatalogueRecord, entity_record,
6};
7use ahri_tre_protocol::{
8    ProtocolVersion, PublicUuid,
9    model::{
10        BrowserEntitySearchEmptyState, BrowserEntitySearchResponse, EntityPredicate,
11        EntitySearchCursorAnchor, EntityTextField, SearchEntitiesRequest,
12    },
13    refs::{EntityRef, TagScope},
14    search::{PredicateSet, TextMode},
15    study::BrowserSearchOrdering,
16};
17
18#[allow(clippy::result_large_err)]
19pub fn browser_model_entity_search_response(
20    datastore_id: PublicUuid,
21    mut entities: Vec<BrowserSemanticEntityCatalogueRecord>,
22    request: &SearchEntitiesRequest,
23    protocol_version: &ProtocolVersion,
24) -> Result<BrowserEntitySearchResponse, ahri_tre_protocol::ProtocolError> {
25    use ahri_tre_protocol::refs::{ObjectKind, TagSelector};
26    for predicate in request.predicates.predicates() {
27        match predicate {
28            EntityPredicate::Domain { domain } => {
29                crate::browser_catalogue_search::validate_domain(datastore_id, domain)?
30            }
31            EntityPredicate::Tag(tag) => {
32                if let TagSelector::Id { tag } = tag.selector {
33                    tag.validate_context(datastore_id, ObjectKind::Tag)?;
34                }
35            }
36
37            _ => {}
38        }
39    }
40    let after = request.browser_decoded_cursor(datastore_id, protocol_version)?;
41    if let Some(cursor) = after.as_ref() {
42        cursor
43            .domain
44            .validate_context(datastore_id, ObjectKind::Domain)?;
45        cursor
46            .entity
47            .validate_context(datastore_id, ObjectKind::Entity)?;
48    }
49    entities.retain(|record| entity_matches(record, request));
50    entities.sort_by_key(entity_sort_key);
51    if let Some(after) = after {
52        entities.retain(|record| entity_sort_key(record) > cursor_sort_key(&after));
53    }
54    let limit = usize::from(request.page.limit);
55    let has_more = entities.len() > limit;
56    let page = entities.into_iter().take(limit).collect::<Vec<_>>();
57    let next_cursor = if has_more {
58        page.last()
59            .map(|record| {
60                request.encode_browser_cursor(
61                    datastore_id,
62                    protocol_version,
63                    cursor_anchor(datastore_id, record),
64                )
65            })
66            .transpose()?
67    } else {
68        None
69    };
70    let returned_count = u16::try_from(page.len()).expect("page length fits u16");
71    let empty_state = page.is_empty().then(|| BrowserEntitySearchEmptyState {
72        code: "no_matching_browser_visible_model_entities".to_string(),
73        message: "No browser-visible model entities match the active search filters.".to_string(),
74    });
75    Ok(BrowserEntitySearchResponse {
76        protocol_version: protocol_version.clone(), datastore_id, family: "model_entities".to_string(), predicates: request.predicates.clone(),
77        entities: page.into_iter().map(|candidate| entity_record(datastore_id, candidate.record)).collect(), returned_count, requested_limit: request.page.limit,
78        ordering: BrowserSearchOrdering { id: "model_entity_name_then_domain_identity_then_entity_identity".to_string(), description: "Model entities are ordered by canonical name, domain identity, then entity identity.".to_string() },
79        has_more, next_cursor, empty_state,
80    })
81}
82
83pub fn search_browser_model_entities(
84    capability: &dyn BrowserSemanticCatalogDiscoveryCapability,
85    datastore_id: PublicUuid,
86    request: &SearchEntitiesRequest,
87    protocol_version: &ProtocolVersion,
88) -> Result<BrowserEntitySearchResponse, BrowserSemanticCatalogDiscoveryError> {
89    browser_model_entity_search_response(
90        datastore_id,
91        capability.discover_catalogue_entities(datastore_id)?,
92        request,
93        protocol_version,
94    )
95    .map_err(|error| {
96        if error.code == ahri_tre_protocol::ProtocolErrorCode::Conflict {
97            BrowserSemanticCatalogDiscoveryError::ReferenceConflict
98        } else {
99            BrowserSemanticCatalogDiscoveryError::InvalidRequest
100        }
101    })
102}
103
104fn entity_matches(
105    candidate: &BrowserSemanticEntityCatalogueRecord,
106    request: &SearchEntitiesRequest,
107) -> bool {
108    let record = &candidate.record;
109    let matches = |predicate: &EntityPredicate| match predicate {
110        EntityPredicate::Text(text) => match text.field {
111            EntityTextField::Name => text_matches(&record.entity.name, text.mode, &text.value),
112            EntityTextField::Description => record
113                .entity
114                .description
115                .as_deref()
116                .is_some_and(|value| text_matches(value, text.mode, &text.value)),
117            EntityTextField::OntologyClass => record
118                .entity
119                .ontology_class
120                .as_deref()
121                .is_some_and(|value| text_matches(value, text.mode, &text.value)),
122        },
123        EntityPredicate::Domain { domain } => match domain {
124            ahri_tre_protocol::domain::DomainSelector::Id { domain } => {
125                ahri_tre_protocol::refs::encode_scoped_integer_ref(
126                    "domain",
127                    record.entity.domain_id.0,
128                ) == domain.id
129            }
130            ahri_tre_protocol::domain::DomainSelector::Name { name } => {
131                candidate.domain_name == name.as_str()
132            }
133        },
134        EntityPredicate::Tag(tag) => record.tags.iter().any(|candidate| match &tag.selector {
135            ahri_tre_protocol::refs::TagSelector::Id { tag } => {
136                ahri_tre_protocol::refs::encode_scoped_integer_ref("tag", candidate.tag_id)
137                    == tag.id
138            }
139            ahri_tre_protocol::refs::TagSelector::Label { scope, label } => {
140                *scope == TagScope::Global
141                    && ahri_tre_core::normalize_tags([label.as_str()])
142                        .first()
143                        .is_some_and(|normalized| candidate.name == *normalized)
144            }
145        }),
146    };
147    match &request.predicates {
148        PredicateSet::AllOf(predicates) => predicates.iter().all(matches),
149        PredicateSet::AnyOf(predicates) => predicates.iter().any(matches),
150    }
151}
152
153fn text_matches(value: &str, mode: TextMode, expected: &str) -> bool {
154    ahri_tre_core::search_text_matches(
155        value,
156        match mode {
157            TextMode::Exact => ahri_tre_core::SearchTextMode::Exact,
158            TextMode::Prefix => ahri_tre_core::SearchTextMode::Prefix,
159            TextMode::Contains => ahri_tre_core::SearchTextMode::Contains,
160        },
161        expected,
162    )
163}
164fn entity_sort_key(record: &BrowserSemanticEntityCatalogueRecord) -> (String, String, i64, i64) {
165    let record = &record.record;
166    (
167        record.entity.name.to_ascii_lowercase(),
168        record.entity.name.clone(),
169        record.entity.domain_id.0,
170        record.entity.entity_id.0,
171    )
172}
173fn cursor_sort_key(anchor: &EntitySearchCursorAnchor) -> (String, String, i64, i64) {
174    (
175        anchor.name.to_ascii_lowercase(),
176        anchor.name.clone(),
177        anchor.domain.id.as_uuid().as_u128() as i64,
178        anchor.entity.id.as_uuid().as_u128() as i64,
179    )
180}
181fn cursor_anchor(
182    datastore_id: PublicUuid,
183    record: &BrowserSemanticEntityCatalogueRecord,
184) -> EntitySearchCursorAnchor {
185    let record = &record.record;
186    EntitySearchCursorAnchor {
187        domain: ahri_tre_protocol::refs::DomainRef {
188            datastore_id,
189            kind: ahri_tre_protocol::refs::ObjectKind::Domain,
190            id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
191                "domain",
192                record.entity.domain_id.0,
193            ),
194        },
195        entity: EntityRef {
196            datastore_id,
197            kind: ahri_tre_protocol::refs::ObjectKind::Entity,
198            id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
199                "entity",
200                record.entity.entity_id.0,
201            ),
202        },
203        name: record.entity.name.clone(),
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use ahri_tre_types::{DomainId, EntityId};
211    use uuid::Uuid;
212
213    fn entity(id: i64, name: &str, ontology_class: &str) -> BrowserSemanticEntityCatalogueRecord {
214        BrowserSemanticEntityCatalogueRecord {
215            domain_name: "demography".to_string(),
216            record: crate::browser_semantic_discovery::BrowserSemanticEntitySourceRecord {
217                entity: ahri_tre_pgmeta::PgEntityReadModel {
218                    entity_id: EntityId(id),
219                    domain_id: DomainId(3),
220                    uuid: None,
221                    name: name.to_string(),
222                    description: Some("A browser-visible semantic entity.".to_string()),
223                    agent_instructions: Some("agent-secret".to_string()),
224                    ontology_namespace: None,
225                    ontology_class: Some(ontology_class.to_string()),
226                },
227                tags: vec![ahri_tre_pgmeta::PgTagReadModel {
228                    tag_id: 4,
229                    name: "person-key".to_string(),
230                }],
231            },
232        }
233    }
234
235    #[test]
236    fn browser_entity_search_filters_pages_binds_cursor_and_redacts() {
237        let datastore_id = PublicUuid::from_uuid(Uuid::from_u128(1));
238        let version = ProtocolVersion::current();
239        let mut request: SearchEntitiesRequest = serde_json::from_str(r#"{"predicates":{"kind":"all_of","predicates":[{"kind":"text","field":"ontology_class","mode":"contains","value":"person"},{"kind":"tag","selector":{"kind":"label","scope":"global","label":"PERSON-KEY"}}]},"page":{"limit":1}}"#).unwrap();
240        request
241            .validate_for_browser(datastore_id, &version)
242            .unwrap();
243        let records = vec![
244            entity(2, "Patient", "person"),
245            entity(1, "Participant", "person"),
246        ];
247        let first =
248            browser_model_entity_search_response(datastore_id, records.clone(), &request, &version)
249                .unwrap();
250        assert_eq!(first.entities[0].name, "Participant");
251        assert!(first.has_more);
252        assert!(
253            !serde_json::to_string(&first)
254                .unwrap()
255                .contains("agent-secret")
256        );
257        request.page.cursor = first.next_cursor;
258        let second =
259            browser_model_entity_search_response(datastore_id, records, &request, &version)
260                .unwrap();
261        assert_eq!(second.entities[0].name, "Patient");
262        let wrong_datastore = PublicUuid::from_uuid(Uuid::from_u128(9));
263        assert!(
264            request
265                .validate_for_browser(wrong_datastore, &version)
266                .is_err()
267        );
268    }
269}