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