1use 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}