Skip to main content

ahri_tre_app/
browser_dataset_search.rs

1//! Browser-safe, datastore-scoped Dataset catalogue search.
2//!
3//! This module reuses the resource Discovery source and projection. It never
4//! reads dataset content and keeps the browser response distinct from daemon
5//! Dataset search envelopes.
6
7use crate::{
8    browser_catalogue_search::ordered_page,
9    browser_resource_discovery::{
10        BrowserDatasetDiscoverySourceRecord, BrowserResourceDiscoveryCapability,
11        BrowserResourceDiscoveryError, BrowserResourceDiscoverySource, dataset_record,
12        latest_available_dataset_version,
13    },
14};
15use thiserror::Error;
16
17#[derive(Debug, Error, Clone, PartialEq, Eq)]
18pub enum BrowserDatasetWorkflowError {
19    #[error(transparent)]
20    Discovery(#[from] BrowserResourceDiscoveryError),
21    #[error("browser Dataset search request is invalid")]
22    InvalidRequest(ahri_tre_protocol::ProtocolError),
23}
24
25#[allow(clippy::result_large_err)]
26pub fn search_browser_datasets(
27    capability: &dyn BrowserResourceDiscoveryCapability,
28    datastore_id: PublicUuid,
29    request: &SearchDatasetsRequest,
30    protocol_version: &ProtocolVersion,
31) -> Result<BrowserDatasetSearchResponse, BrowserDatasetWorkflowError> {
32    validate_references(datastore_id, request, protocol_version)
33        .map_err(BrowserDatasetWorkflowError::InvalidRequest)?;
34    let sources = capability.discover_catalogue_search_resources(datastore_id)?;
35    browser_dataset_search_response(datastore_id, sources, request, protocol_version)
36        .map_err(BrowserDatasetWorkflowError::InvalidRequest)
37}
38use ahri_tre_protocol::{
39    ProtocolVersion, PublicUuid,
40    dataset::{
41        BrowserDatasetSearchCursorAnchor, BrowserDatasetSearchEmptyState,
42        BrowserDatasetSearchResponse, DatasetPredicate, DatasetTextField, SearchDatasetsRequest,
43    },
44    refs::{StudyRef, TagScope},
45    search::{PredicateSet, TextMode},
46    study::{BrowserSearchOrdering, StudySelector},
47};
48
49#[allow(clippy::result_large_err)]
50fn validate_references(
51    datastore_id: PublicUuid,
52    request: &SearchDatasetsRequest,
53    protocol_version: &ProtocolVersion,
54) -> Result<(), ahri_tre_protocol::ProtocolError> {
55    for predicate in request.predicates.predicates() {
56        match predicate {
57            DatasetPredicate::Study { study } => {
58                crate::browser_catalogue_search::validate_study(datastore_id, study)?
59            }
60            DatasetPredicate::Tag(tag) => {
61                if let ahri_tre_protocol::refs::TagSelector::Id { tag } = tag.selector {
62                    tag.validate_context(datastore_id, ahri_tre_protocol::refs::ObjectKind::Tag)?;
63                }
64            }
65            DatasetPredicate::Domain { domain } => {
66                crate::browser_catalogue_search::validate_domain(datastore_id, domain)?
67            }
68            _ => {}
69        }
70    }
71    if let Some(cursor) = request.browser_decoded_cursor(datastore_id, protocol_version)? {
72        use ahri_tre_protocol::refs::ObjectKind;
73        cursor
74            .study
75            .validate_context(datastore_id, ObjectKind::Study)?;
76        cursor
77            .dataset
78            .validate_context(datastore_id, ObjectKind::Asset)?;
79        cursor
80            .asset
81            .validate_context(datastore_id, ObjectKind::Asset)?;
82    }
83    Ok(())
84}
85
86#[derive(Debug, Clone)]
87struct DatasetCandidate {
88    study: StudyRef,
89    domains: Vec<ahri_tre_pgmeta::PgDomainReadModel>,
90    record: BrowserDatasetDiscoverySourceRecord,
91}
92
93#[allow(clippy::result_large_err)]
94pub fn browser_dataset_search_response(
95    datastore_id: PublicUuid,
96    sources: Vec<BrowserResourceDiscoverySource>,
97    request: &SearchDatasetsRequest,
98    protocol_version: &ProtocolVersion,
99) -> Result<BrowserDatasetSearchResponse, ahri_tre_protocol::ProtocolError> {
100    validate_references(datastore_id, request, protocol_version)?;
101    let after = request.browser_decoded_cursor(datastore_id, protocol_version)?;
102    let datasets = sources
103        .into_iter()
104        .flat_map(|source| {
105            let study = StudyRef {
106                datastore_id,
107                kind: ahri_tre_protocol::refs::ObjectKind::Study,
108                id: PublicUuid::from_uuid(source.study.study_id.0),
109            };
110            let domains = source.domains.clone();
111            source.datasets.into_iter().filter_map(move |record| {
112                dataset_matches(&source.study, &domains, &record, request).then(|| {
113                    DatasetCandidate {
114                        study,
115                        domains: domains.clone(),
116                        record,
117                    }
118                })
119            })
120        })
121        .collect::<Vec<_>>();
122    let limit = usize::from(request.page.limit);
123    let page = ordered_page(
124        datasets,
125        after.as_ref().map(cursor_sort_key),
126        limit,
127        dataset_sort_key,
128    );
129    let next_cursor = if page.has_more {
130        page.items
131            .last()
132            .map(|candidate| {
133                request.encode_browser_cursor(
134                    datastore_id,
135                    protocol_version,
136                    dataset_cursor_anchor(datastore_id, candidate),
137                )
138            })
139            .transpose()?
140    } else {
141        None
142    };
143    let returned_count = u16::try_from(page.items.len()).expect("page length fits u16");
144    let empty_state = page
145        .items
146        .is_empty()
147        .then(|| BrowserDatasetSearchEmptyState {
148            code: "no_matching_browser_visible_datasets".to_string(),
149            message: "No browser-visible datasets match the active search filters.".to_string(),
150        });
151    Ok(BrowserDatasetSearchResponse {
152        protocol_version: protocol_version.clone(),
153        datastore_id,
154        family: "datasets".to_string(),
155        predicates: request.predicates.clone(),
156        datasets: page
157            .items
158            .into_iter()
159            .map(|candidate| {
160                dataset_record(
161                    datastore_id,
162                    candidate.study,
163                    &candidate
164                        .domains
165                        .into_iter()
166                        .map(|value| domain_summary(datastore_id, value))
167                        .collect::<Vec<_>>(),
168                    candidate.record,
169                )
170            })
171            .collect(),
172        returned_count,
173        requested_limit: request.page.limit,
174        ordering: BrowserSearchOrdering {
175            id: "dataset_name_then_study_identity_then_asset_identity".to_string(),
176            description:
177                "Datasets are ordered by canonical name, Study identity, then asset identity."
178                    .to_string(),
179        },
180        has_more: page.has_more,
181        next_cursor,
182        empty_state,
183    })
184}
185
186fn dataset_matches(
187    study: &ahri_tre_pgmeta::PgStudyReadModel,
188    domains: &[ahri_tre_pgmeta::PgDomainReadModel],
189    record: &BrowserDatasetDiscoverySourceRecord,
190    request: &SearchDatasetsRequest,
191) -> bool {
192    let matches = |predicate: &DatasetPredicate| match predicate {
193        DatasetPredicate::Text(text) => match text.field {
194            DatasetTextField::Name => text_matches(&record.asset.name, text.mode, &text.value),
195            DatasetTextField::Description => record
196                .asset
197                .description
198                .as_deref()
199                .is_some_and(|value| text_matches(value, text.mode, &text.value)),
200            DatasetTextField::VersionLabel => latest_version(record)
201                .and_then(|version| version.version.version_label.as_deref())
202                .is_some_and(|value| text_matches(value, text.mode, &text.value)),
203        },
204        DatasetPredicate::Study { study: selector } => match selector {
205            StudySelector::Id { study: expected } => {
206                expected.id == PublicUuid::from_uuid(study.study_id.0)
207            }
208            StudySelector::Name { domain, name } => {
209                study.name == name.as_str()
210                    && domain.as_ref().is_none_or(|domain| {
211                        domains
212                            .iter()
213                            .any(|candidate| domain_matches(candidate, domain))
214                    })
215            }
216        },
217        DatasetPredicate::Domain { domain } => domains
218            .iter()
219            .any(|candidate| domain_matches(candidate, domain)),
220        DatasetPredicate::VersionLabel { label } => {
221            latest_version(record).and_then(|version| version.version.version_label.as_deref())
222                == Some(label.0.as_str())
223        }
224        DatasetPredicate::Tag(tag) => latest_version(record).is_some_and(|version| {
225            version.tags.iter().any(|candidate| match &tag.selector {
226                ahri_tre_protocol::refs::TagSelector::Id { tag } => {
227                    ahri_tre_protocol::refs::encode_scoped_integer_ref("tag", candidate.tag_id)
228                        == tag.id
229                }
230                ahri_tre_protocol::refs::TagSelector::Label { scope, label } => {
231                    *scope == TagScope::Global && candidate.name == label.as_str()
232                }
233            })
234        }),
235    };
236    match &request.predicates {
237        PredicateSet::AllOf(predicates) => predicates.iter().all(matches),
238        PredicateSet::AnyOf(predicates) => predicates.iter().any(matches),
239    }
240}
241
242fn text_matches(value: &str, mode: TextMode, expected: &str) -> bool {
243    ahri_tre_core::search_text_matches(
244        value,
245        match mode {
246            TextMode::Exact => ahri_tre_core::SearchTextMode::Exact,
247            TextMode::Prefix => ahri_tre_core::SearchTextMode::Prefix,
248            TextMode::Contains => ahri_tre_core::SearchTextMode::Contains,
249        },
250        expected,
251    )
252}
253
254fn domain_matches(
255    candidate: &ahri_tre_pgmeta::PgDomainReadModel,
256    selector: &ahri_tre_protocol::domain::DomainSelector,
257) -> bool {
258    match selector {
259        ahri_tre_protocol::domain::DomainSelector::Id { domain } => {
260            ahri_tre_protocol::refs::encode_scoped_integer_ref("domain", candidate.domain_id.0)
261                == domain.id
262        }
263        ahri_tre_protocol::domain::DomainSelector::Name { name } => candidate.name == name.as_str(),
264    }
265}
266
267fn dataset_sort_key(candidate: &DatasetCandidate) -> (String, String, uuid::Uuid, uuid::Uuid) {
268    let record = &candidate.record;
269    (
270        record.asset.name.to_ascii_lowercase(),
271        record.asset.name.clone(),
272        candidate.study.id.as_uuid(),
273        record.asset.asset_id.0,
274    )
275}
276
277fn latest_version(
278    record: &BrowserDatasetDiscoverySourceRecord,
279) -> Option<&crate::browser_resource_discovery::BrowserDatasetVersionDiscoverySourceRecord> {
280    latest_available_dataset_version(record)
281}
282
283fn cursor_sort_key(
284    anchor: &BrowserDatasetSearchCursorAnchor,
285) -> (String, String, uuid::Uuid, uuid::Uuid) {
286    (
287        anchor.name.to_ascii_lowercase(),
288        anchor.name.clone(),
289        anchor.study.id.as_uuid(),
290        anchor.asset.id.as_uuid(),
291    )
292}
293
294fn dataset_cursor_anchor(
295    datastore_id: PublicUuid,
296    candidate: &DatasetCandidate,
297) -> BrowserDatasetSearchCursorAnchor {
298    let record = &candidate.record;
299    let _dataset = latest_available_dataset_version(record)
300        .unwrap_or_else(|| record.versions.last().expect("dataset has versions"));
301    BrowserDatasetSearchCursorAnchor {
302        study: candidate.study,
303        dataset: ahri_tre_protocol::refs::DatasetRef {
304            datastore_id,
305            kind: ahri_tre_protocol::refs::ObjectKind::Asset,
306            id: PublicUuid::from_uuid(record.asset.asset_id.0),
307        },
308        asset: ahri_tre_protocol::refs::AssetRef {
309            datastore_id,
310            kind: ahri_tre_protocol::refs::ObjectKind::Asset,
311            id: PublicUuid::from_uuid(record.asset.asset_id.0),
312        },
313        name: record.asset.name.clone(),
314    }
315}
316
317fn domain_summary(
318    datastore_id: PublicUuid,
319    domain: ahri_tre_pgmeta::PgDomainReadModel,
320) -> ahri_tre_protocol::domain::DomainSummary {
321    ahri_tre_protocol::domain::DomainSummary {
322        domain: ahri_tre_protocol::refs::DomainRef {
323            datastore_id,
324            kind: ahri_tre_protocol::refs::ObjectKind::Domain,
325            id: ahri_tre_protocol::refs::encode_scoped_integer_ref("domain", domain.domain_id.0),
326        },
327        name: domain.name,
328        description: domain.description,
329        uri: domain.uri,
330    }
331}