1use crate::browser_catalogue_search::ordered_page;
4use ahri_tre_protocol::{
5 ProtocolVersion, PublicUuid,
6 dictionary::{
7 BrowserVariableSearchEmptyState, BrowserVariableSearchResponse, SearchVariablesRequest,
8 VariablePredicate, VariableSearchCursorAnchor, VariableTextField,
9 },
10 dictionary_discovery::{
11 BrowserDictionaryVariableDiscoveryRecord, BrowserVocabularyDiscoveryRecord,
12 BrowserVocabularyItemDiscoveryRecord, BrowserVocabularyMappingDiscoveryRecord,
13 },
14 refs::{DomainRef, TagRef, TagScope, TagSummary, VariableRef, VocabularyRef},
15 search::{PredicateSet, TextMode},
16 study::BrowserSearchOrdering,
17};
18use ahri_tre_types::KeyRole;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct BrowserDictionaryVariableDiscoverySourceRecord {
23 pub dataset_variable: ahri_tre_pgmeta::PgDatasetVariableReadModel,
25 pub variable: ahri_tre_pgmeta::PgVariableReadModel,
27 pub vocabulary: Option<BrowserVocabularyDiscoverySourceRecord>,
29 pub vocabulary_mappings: Vec<BrowserVocabularyMappingDiscoverySourceRecord>,
31 pub tags: Vec<ahri_tre_pgmeta::PgTagReadModel>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct BrowserDictionaryVariableCatalogueRecord {
38 pub domain_name: String,
40 pub record: BrowserDictionaryVariableDiscoverySourceRecord,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct BrowserVocabularyDiscoverySourceRecord {
47 pub vocabulary: ahri_tre_pgmeta::PgVocabularyReadModel,
49 pub items: Vec<ahri_tre_pgmeta::PgVocabularyItemReadModel>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct BrowserVocabularyMappingDiscoverySourceRecord {
56 pub mapping: ahri_tre_pgmeta::PgVocabularyMappingReadModel,
58 pub from_item: ahri_tre_pgmeta::PgVocabularyItemReadModel,
60 pub to_item: ahri_tre_pgmeta::PgVocabularyItemReadModel,
62}
63
64#[allow(clippy::result_large_err)]
65pub fn browser_dictionary_variable_search_response(
71 datastore_id: PublicUuid,
72 mut variables: Vec<BrowserDictionaryVariableCatalogueRecord>,
73 request: &SearchVariablesRequest,
74 protocol_version: &ProtocolVersion,
75) -> Result<BrowserVariableSearchResponse, ahri_tre_protocol::ProtocolError> {
76 use ahri_tre_protocol::refs::{ObjectKind, TagSelector};
77 for predicate in request.predicates.predicates() {
78 match predicate {
79 VariablePredicate::Domain { domain } => {
80 crate::browser_catalogue_search::validate_domain(datastore_id, domain)?
81 }
82 VariablePredicate::Tag(tag) => {
83 if let TagSelector::Id { tag } = tag.selector {
84 tag.validate_context(datastore_id, ObjectKind::Tag)?;
85 }
86 }
87 VariablePredicate::Vocabulary { vocabulary } => {
88 vocabulary.validate_context(datastore_id, ObjectKind::Vocabulary)?
89 }
90 _ => {}
91 }
92 }
93 let after = request.browser_decoded_cursor(datastore_id, protocol_version)?;
94 if let Some(cursor) = after.as_ref() {
95 cursor
96 .domain
97 .validate_context(datastore_id, ObjectKind::Domain)?;
98 cursor
99 .variable
100 .validate_context(datastore_id, ObjectKind::Variable)?;
101 }
102 variables.retain(|record| variable_matches(record, request));
103 let limit = usize::from(request.page.limit);
104 let page = ordered_page(
105 variables,
106 after.as_ref().map(cursor_sort_key),
107 limit,
108 variable_sort_key,
109 );
110 let next_cursor = if page.has_more {
111 page.items
112 .last()
113 .map(|record| {
114 request.encode_browser_cursor(
115 datastore_id,
116 protocol_version,
117 cursor_anchor(datastore_id, record),
118 )
119 })
120 .transpose()?
121 } else {
122 None
123 };
124 let returned_count = u16::try_from(page.items.len()).expect("page length fits u16");
125 let empty_state = page
126 .items
127 .is_empty()
128 .then(|| BrowserVariableSearchEmptyState {
129 code: "no_matching_browser_visible_dictionary_variables".to_string(),
130 message: "No browser-visible dictionary variables match the active search filters."
131 .to_string(),
132 });
133 Ok(BrowserVariableSearchResponse {
134 protocol_version: protocol_version.clone(),
135 datastore_id,
136 family: "dictionary_variables".to_string(),
137 predicates: request.predicates.clone(),
138 variables: page
139 .items
140 .into_iter()
141 .map(|candidate| variable_record(datastore_id, candidate.record))
142 .collect(),
143 returned_count,
144 requested_limit: request.page.limit,
145 ordering: BrowserSearchOrdering {
146 id: "dictionary_variable_name_then_domain_identity_then_variable_identity".to_string(),
147 description: "Dictionary variables are ordered by canonical name, domain identity, then variable identity.".to_string(),
148 },
149 has_more: page.has_more,
150 next_cursor,
151 empty_state,
152 })
153}
154
155fn variable_matches(
156 candidate: &BrowserDictionaryVariableCatalogueRecord,
157 request: &SearchVariablesRequest,
158) -> bool {
159 let record = &candidate.record;
160 let matches = |predicate: &VariablePredicate| match predicate {
161 VariablePredicate::Text(text) => match text.field {
162 VariableTextField::Name => text_matches(&record.variable.name, text.mode, &text.value),
163 VariableTextField::Description => record
164 .variable
165 .description
166 .as_deref()
167 .is_some_and(|value| text_matches(value, text.mode, &text.value)),
168 VariableTextField::ValueType => {
169 text_matches(&record.variable.value_type, text.mode, &text.value)
170 }
171 VariableTextField::VocabularyName => {
172 record.vocabulary.as_ref().is_some_and(|vocabulary| {
173 text_matches(&vocabulary.vocabulary.name, text.mode, &text.value)
174 })
175 }
176 VariableTextField::OntologyClass => record
177 .variable
178 .ontology_class
179 .as_deref()
180 .is_some_and(|value| text_matches(value, text.mode, &text.value)),
181 },
182 VariablePredicate::Domain { domain } => match domain {
183 ahri_tre_protocol::domain::DomainSelector::Id { domain } => {
184 ahri_tre_protocol::refs::encode_scoped_integer_ref(
185 "domain",
186 record.variable.domain_id.0,
187 ) == domain.id
188 }
189 ahri_tre_protocol::domain::DomainSelector::Name { name } => {
190 candidate.domain_name == name.as_str()
191 }
192 },
193 VariablePredicate::ValueType { value_type } => {
194 record.variable.value_type.eq_ignore_ascii_case(value_type)
195 }
196 VariablePredicate::Vocabulary { vocabulary } => {
197 record.vocabulary.as_ref().is_some_and(|candidate| {
198 ahri_tre_protocol::refs::encode_scoped_integer_ref(
199 "vocabulary",
200 candidate.vocabulary.vocabulary_id.0,
201 ) == vocabulary.id
202 })
203 }
204 VariablePredicate::KeyRole { key_role } => {
205 key_role_label(record.variable.key_role).eq_ignore_ascii_case(key_role)
206 }
207 VariablePredicate::Tag(tag) => record.tags.iter().any(|candidate| match &tag.selector {
208 ahri_tre_protocol::refs::TagSelector::Id { tag } => {
209 ahri_tre_protocol::refs::encode_scoped_integer_ref("tag", candidate.tag_id)
210 == tag.id
211 }
212 ahri_tre_protocol::refs::TagSelector::Label { scope, label } => {
213 *scope == TagScope::Global
214 && ahri_tre_core::normalize_tags([label.as_str()])
215 .first()
216 .is_some_and(|normalized| candidate.name == *normalized)
217 }
218 }),
219 };
220 match &request.predicates {
221 PredicateSet::AllOf(predicates) => predicates.iter().all(matches),
222 PredicateSet::AnyOf(predicates) => predicates.iter().any(matches),
223 }
224}
225
226fn text_matches(value: &str, mode: TextMode, expected: &str) -> bool {
227 ahri_tre_core::search_text_matches(
228 value,
229 match mode {
230 TextMode::Exact => ahri_tre_core::SearchTextMode::Exact,
231 TextMode::Prefix => ahri_tre_core::SearchTextMode::Prefix,
232 TextMode::Contains => ahri_tre_core::SearchTextMode::Contains,
233 },
234 expected,
235 )
236}
237
238fn variable_sort_key(
239 record: &BrowserDictionaryVariableCatalogueRecord,
240) -> (String, String, i64, i64) {
241 let record = &record.record;
242 (
243 record.variable.name.to_ascii_lowercase(),
244 record.variable.name.clone(),
245 record.variable.domain_id.0,
246 record.variable.variable_id.0,
247 )
248}
249
250fn cursor_sort_key(anchor: &VariableSearchCursorAnchor) -> (String, String, i64, i64) {
251 (
252 anchor.name.to_ascii_lowercase(),
253 anchor.name.clone(),
254 anchor.domain.id.as_uuid().as_u128() as i64,
255 anchor.variable.id.as_uuid().as_u128() as i64,
256 )
257}
258
259fn cursor_anchor(
260 datastore_id: PublicUuid,
261 record: &BrowserDictionaryVariableCatalogueRecord,
262) -> VariableSearchCursorAnchor {
263 let record = &record.record;
264 VariableSearchCursorAnchor {
265 domain: ahri_tre_protocol::refs::DomainRef {
266 datastore_id,
267 kind: ahri_tre_protocol::refs::ObjectKind::Domain,
268 id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
269 "domain",
270 record.variable.domain_id.0,
271 ),
272 },
273 variable: VariableRef {
274 datastore_id,
275 kind: ahri_tre_protocol::refs::ObjectKind::Variable,
276 id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
277 "variable",
278 record.variable.variable_id.0,
279 ),
280 },
281 name: record.variable.name.clone(),
282 }
283}
284
285pub fn variable_record(
289 datastore_id: PublicUuid,
290 record: BrowserDictionaryVariableDiscoverySourceRecord,
291) -> BrowserDictionaryVariableDiscoveryRecord {
292 BrowserDictionaryVariableDiscoveryRecord {
293 variable: VariableRef {
294 datastore_id,
295 kind: ahri_tre_protocol::refs::ObjectKind::Variable,
296 id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
297 "variable",
298 record.variable.variable_id.0,
299 ),
300 },
301 domain: DomainRef {
302 datastore_id,
303 kind: ahri_tre_protocol::refs::ObjectKind::Domain,
304 id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
305 "domain",
306 record.variable.domain_id.0,
307 ),
308 },
309 name: record.variable.name,
310 value_type: record.variable.value_type,
311 value_type_id: ahri_tre_protocol::refs::ObjectRef {
312 datastore_id,
313 kind: ahri_tre_protocol::refs::ObjectKind::ValueType,
314 id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
315 "value_type",
316 record.variable.value_type_id.0,
317 ),
318 },
319 value_format: record.variable.value_format,
320 row_role: key_role_label(record.dataset_variable.row_role).to_string(),
321 key_role: key_role_label(record.variable.key_role).to_string(),
322 description: record.variable.description,
323 note: record.variable.note,
324 ontology_namespace: record.variable.ontology_namespace,
325 ontology_class: record.variable.ontology_class,
326 vocabulary: record
327 .vocabulary
328 .map(|value| vocabulary_record(datastore_id, value)),
329 vocabulary_mappings: record
330 .vocabulary_mappings
331 .into_iter()
332 .map(|source| vocabulary_mapping_record(datastore_id, source))
333 .collect(),
334 tags: record
335 .tags
336 .into_iter()
337 .map(|tag| tag_summary(datastore_id, tag))
338 .collect(),
339 }
340}
341
342fn vocabulary_record(
343 datastore_id: PublicUuid,
344 source: BrowserVocabularyDiscoverySourceRecord,
345) -> BrowserVocabularyDiscoveryRecord {
346 let vocabulary = VocabularyRef {
347 datastore_id,
348 kind: ahri_tre_protocol::refs::ObjectKind::Vocabulary,
349 id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
350 "vocabulary",
351 source.vocabulary.vocabulary_id.0,
352 ),
353 };
354 BrowserVocabularyDiscoveryRecord {
355 vocabulary,
356 domain: DomainRef {
357 datastore_id,
358 kind: ahri_tre_protocol::refs::ObjectKind::Domain,
359 id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
360 "domain",
361 source.vocabulary.domain_id.0,
362 ),
363 },
364 name: source.vocabulary.name,
365 description: source.vocabulary.description,
366 items: source
367 .items
368 .into_iter()
369 .map(|item| vocabulary_item_record(vocabulary, item))
370 .collect(),
371 }
372}
373
374fn vocabulary_item_record(
375 vocabulary: VocabularyRef,
376 item: ahri_tre_pgmeta::PgVocabularyItemReadModel,
377) -> BrowserVocabularyItemDiscoveryRecord {
378 BrowserVocabularyItemDiscoveryRecord {
379 item_id: ahri_tre_protocol::refs::ObjectRef {
380 datastore_id: vocabulary.datastore_id,
381 kind: ahri_tre_protocol::refs::ObjectKind::VocabularyItem,
382 id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
383 "vocabulary_item",
384 item.vocabulary_item_id.0,
385 ),
386 },
387 vocabulary,
388 value: item.value,
389 code: item.code,
390 description: item.description,
391 }
392}
393
394fn vocabulary_mapping_record(
395 datastore_id: PublicUuid,
396 source: BrowserVocabularyMappingDiscoverySourceRecord,
397) -> BrowserVocabularyMappingDiscoveryRecord {
398 BrowserVocabularyMappingDiscoveryRecord {
399 mapping_id: ahri_tre_protocol::refs::ObjectRef {
400 datastore_id,
401 kind: ahri_tre_protocol::refs::ObjectKind::VocabularyMapping,
402 id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
403 "vocabulary_mapping",
404 source.mapping.vocabulary_mapping_id,
405 ),
406 },
407 from_vocabulary: VocabularyRef {
408 datastore_id,
409 kind: ahri_tre_protocol::refs::ObjectKind::Vocabulary,
410 id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
411 "vocabulary",
412 source.from_item.vocabulary_id.0,
413 ),
414 },
415 from_item_id: ahri_tre_protocol::refs::ObjectRef {
416 datastore_id,
417 kind: ahri_tre_protocol::refs::ObjectKind::VocabularyItem,
418 id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
419 "vocabulary_item",
420 source.mapping.from_vocabulary_item.0,
421 ),
422 },
423 from_code: source.from_item.code,
424 from_value: source.from_item.value,
425 to_vocabulary: VocabularyRef {
426 datastore_id,
427 kind: ahri_tre_protocol::refs::ObjectKind::Vocabulary,
428 id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
429 "vocabulary",
430 source.to_item.vocabulary_id.0,
431 ),
432 },
433 to_item_id: ahri_tre_protocol::refs::ObjectRef {
434 datastore_id,
435 kind: ahri_tre_protocol::refs::ObjectKind::VocabularyItem,
436 id: ahri_tre_protocol::refs::encode_scoped_integer_ref(
437 "vocabulary_item",
438 source.mapping.to_vocabulary_item.0,
439 ),
440 },
441 to_code: source.to_item.code,
442 to_value: source.to_item.value,
443 }
444}
445
446fn tag_summary(datastore_id: PublicUuid, tag: ahri_tre_pgmeta::PgTagReadModel) -> TagSummary {
447 TagSummary {
448 tag: TagRef {
449 datastore_id,
450 kind: ahri_tre_protocol::refs::ObjectKind::Tag,
451 id: ahri_tre_protocol::refs::encode_scoped_integer_ref("tag", tag.tag_id),
452 },
453 scope: TagScope::Global,
454 label: tag.name,
455 }
456}
457
458pub fn key_role_label(role: KeyRole) -> &'static str {
460 match role {
461 KeyRole::None => "none",
462 KeyRole::Record => "record",
463 KeyRole::External => "external",
464 }
465}