Skip to main content

ahri_tre_app/
browser_variable_search.rs

1//! Browser-safe, datastore-scoped dictionary-variable catalogue search.
2
3use 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/// Adapter-owned source metadata for one browser-visible dictionary variable.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct BrowserDictionaryVariableDiscoverySourceRecord {
23    /// Dataset-specific role assigned to the variable.
24    pub dataset_variable: ahri_tre_pgmeta::PgDatasetVariableReadModel,
25    /// Canonical variable metadata.
26    pub variable: ahri_tre_pgmeta::PgVariableReadModel,
27    /// Optional vocabulary and its visible items.
28    pub vocabulary: Option<BrowserVocabularyDiscoverySourceRecord>,
29    /// Browser-visible mappings involving the vocabulary.
30    pub vocabulary_mappings: Vec<BrowserVocabularyMappingDiscoverySourceRecord>,
31    /// Normalized browser-visible tags attached to the variable.
32    pub tags: Vec<ahri_tre_pgmeta::PgTagReadModel>,
33}
34
35/// Catalogue search record carrying the domain name used by name predicates.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct BrowserDictionaryVariableCatalogueRecord {
38    /// Canonical domain name for predicate matching and ordering.
39    pub domain_name: String,
40    /// Variable metadata projected when the record matches.
41    pub record: BrowserDictionaryVariableDiscoverySourceRecord,
42}
43
44/// Adapter-owned vocabulary metadata and visible items.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct BrowserVocabularyDiscoverySourceRecord {
47    /// Canonical vocabulary metadata.
48    pub vocabulary: ahri_tre_pgmeta::PgVocabularyReadModel,
49    /// Browser-visible items belonging to the vocabulary.
50    pub items: Vec<ahri_tre_pgmeta::PgVocabularyItemReadModel>,
51}
52
53/// Adapter-owned source values for one vocabulary mapping.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct BrowserVocabularyMappingDiscoverySourceRecord {
56    /// Canonical mapping metadata.
57    pub mapping: ahri_tre_pgmeta::PgVocabularyMappingReadModel,
58    /// Resolved source item.
59    pub from_item: ahri_tre_pgmeta::PgVocabularyItemReadModel,
60    /// Resolved destination item.
61    pub to_item: ahri_tre_pgmeta::PgVocabularyItemReadModel,
62}
63
64#[allow(clippy::result_large_err)]
65/// Filters, orders, pages, and projects browser-visible variable metadata.
66///
67/// `request` is validated while its cursor is decoded against `datastore_id`
68/// and `protocol_version`. Invalid or mismatched cursors return a stable
69/// [`ahri_tre_protocol::ProtocolError`].
70pub 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
285/// Projects one adapter source record into its browser-safe variable DTO.
286///
287/// The projection includes metadata and public identifiers only.
288pub 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
458/// Returns the stable protocol label for a variable key role.
459pub 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}