Skip to main content

ahri_tre_app/
browser_study_discovery.rs

1//! Browser-safe Study discovery and search workflow projection.
2
3use crate::browser_resource_discovery::{
4    BrowserResourceDiscoveryCapability, BrowserResourceDiscoveryError,
5    BrowserResourceDiscoverySource,
6};
7
8use ahri_tre_protocol::{
9    ProtocolError, ProtocolVersion, PublicUuid,
10    domain::DomainSummary,
11    refs::{StudyRef, TagRef, TagScope, TagSummary, VersionRef},
12    search::PredicateSet,
13    study::{
14        BrowserContentSummaryPolicy, BrowserSearchOrdering, BrowserStudyAccessCondition,
15        BrowserStudyAccessRequestReference, BrowserStudyDiscoveryEmptyState,
16        BrowserStudyDiscoveryRecord, BrowserStudyDiscoveryResponse, BrowserStudyFacts,
17        BrowserStudyMatchEvidence, BrowserStudyMatchedField, BrowserStudySearchEmptyState,
18        BrowserStudySearchInput, BrowserStudySearchRecord, BrowserStudySearchResponse,
19        MAX_BROWSER_STUDY_MATCH_EVIDENCE, StudyPredicate, StudySearchCursorAnchor, StudyTextField,
20    },
21};
22use std::fmt;
23use thiserror::Error;
24use uuid::Uuid;
25
26pub const BROWSER_STUDY_DISCOVERY_AUTHORITY: &str = "tre_browser.study_discovery";
27
28#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
29pub enum BrowserStudyDiscoveryError {
30    #[error("selected datastore identity is invalid")]
31    InvalidDatastoreIdentity,
32    #[error("selected datastore is not available for browser discovery")]
33    DatastoreUnavailable,
34    #[error("browser study discovery authority is unavailable")]
35    AuthorityUnavailable,
36    #[error("study was not found in the selected datastore")]
37    StudyNotFound,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct BrowserStudyDiscoverySource {
42    pub datastore_id: PublicUuid,
43    pub studies: Vec<BrowserStudyDiscoverySourceRecord>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct BrowserStudyDiscoverySourceRecord {
48    pub study: ahri_tre_pgmeta::PgStudyReadModel,
49    pub domains: Vec<ahri_tre_pgmeta::PgDomainReadModel>,
50    pub tags: Vec<ahri_tre_pgmeta::PgTagReadModel>,
51    pub access_conditions: Vec<ahri_tre_pgmeta::PgDuoRestrictionReadModel>,
52}
53
54/// Resolved adapter capability used by the application-owned Study workflow.
55pub trait BrowserStudyDiscoveryCapability: fmt::Debug + Send + Sync {
56    fn discover_studies(
57        &self,
58        datastore_id: PublicUuid,
59    ) -> Result<BrowserStudyDiscoverySource, BrowserStudyDiscoveryError>;
60}
61
62pub fn discover_browser_studies(
63    capability: &dyn BrowserStudyDiscoveryCapability,
64    datastore_id: PublicUuid,
65) -> Result<BrowserStudyDiscoveryResponse, BrowserStudyDiscoveryError> {
66    capability
67        .discover_studies(datastore_id)
68        .map(browser_study_discovery_response)
69}
70
71pub fn discover_browser_study(
72    capability: &dyn BrowserStudyDiscoveryCapability,
73    datastore_id: PublicUuid,
74    study_id: PublicUuid,
75) -> Result<BrowserStudyDiscoveryResponse, BrowserStudyDiscoveryError> {
76    browser_study_get_response(capability.discover_studies(datastore_id)?, study_id)
77}
78
79#[allow(clippy::result_large_err)]
80pub fn search_browser_studies(
81    capability: &dyn BrowserResourceDiscoveryCapability,
82    datastore_id: PublicUuid,
83    request: &BrowserStudySearchInput,
84    protocol_version: &ProtocolVersion,
85) -> Result<BrowserStudySearchResponse, BrowserStudyWorkflowError> {
86    validate_search_references(datastore_id, request, protocol_version)
87        .map_err(BrowserStudyWorkflowError::InvalidRequest)?;
88    let sources = capability.discover_catalogue_search_resources(datastore_id)?;
89    browser_study_search_response(datastore_id, sources, request, protocol_version)
90        .map_err(BrowserStudyWorkflowError::InvalidRequest)
91}
92
93#[allow(clippy::result_large_err)]
94fn validate_search_references(
95    datastore_id: PublicUuid,
96    request: &BrowserStudySearchInput,
97    protocol_version: &ProtocolVersion,
98) -> Result<(), ProtocolError> {
99    use crate::browser_catalogue_search::{validate_domain, validate_tag};
100    if let Some(predicates) = &request.predicates {
101        for predicate in predicates.predicates() {
102            match predicate {
103                ahri_tre_protocol::study::StudyPredicate::Domain { domain } => {
104                    validate_domain(datastore_id, domain)?
105                }
106                ahri_tre_protocol::study::StudyPredicate::Tag(tag) => {
107                    validate_tag(datastore_id, &tag.selector)?
108                }
109                _ => {}
110            }
111        }
112    }
113    if let Some(query) = &request.query {
114        for domain in &query.facets.any_domains {
115            validate_domain(datastore_id, domain)?;
116        }
117        for tag in &query.facets.any_tags {
118            validate_tag(datastore_id, tag)?;
119        }
120    }
121    if let Some(cursor) = request.decoded_cursor(datastore_id, protocol_version)? {
122        cursor
123            .study
124            .validate_context(datastore_id, ahri_tre_protocol::refs::ObjectKind::Study)?;
125    }
126    Ok(())
127}
128
129#[derive(Debug, Error, Clone, PartialEq, Eq)]
130pub enum BrowserStudyWorkflowError {
131    #[error(transparent)]
132    Discovery(#[from] BrowserResourceDiscoveryError),
133    #[error("browser Study search request is invalid")]
134    InvalidRequest(ProtocolError),
135}
136
137pub fn browser_study_discovery_response(
138    source: BrowserStudyDiscoverySource,
139) -> BrowserStudyDiscoveryResponse {
140    let datastore_id = source.datastore_id;
141    let studies = source
142        .studies
143        .into_iter()
144        .map(|record| browser_study_discovery_record(datastore_id, record))
145        .collect::<Vec<_>>();
146    let empty_state = studies.is_empty().then(|| BrowserStudyDiscoveryEmptyState {
147        code: "no_browser_visible_studies".to_string(),
148        message: "No browser-visible studies are available in this datastore.".to_string(),
149    });
150
151    BrowserStudyDiscoveryResponse {
152        authority: BROWSER_STUDY_DISCOVERY_AUTHORITY.to_string(),
153        datastore_id,
154        studies,
155        empty_state,
156        content_derived_summaries: BrowserContentSummaryPolicy {
157            included: false,
158            reason: "Content-derived summaries are excluded from broad Discovery metadata."
159                .to_string(),
160        },
161    }
162}
163
164pub fn browser_study_get_response(
165    source: BrowserStudyDiscoverySource,
166    study_id: PublicUuid,
167) -> Result<BrowserStudyDiscoveryResponse, BrowserStudyDiscoveryError> {
168    let filtered = BrowserStudyDiscoverySource {
169        datastore_id: source.datastore_id,
170        studies: source
171            .studies
172            .into_iter()
173            .filter(|record| PublicUuid::from_uuid(record.study.study_id.0) == study_id)
174            .collect(),
175    };
176
177    if filtered.studies.is_empty() {
178        Err(BrowserStudyDiscoveryError::StudyNotFound)
179    } else {
180        Ok(browser_study_discovery_response(filtered))
181    }
182}
183
184#[allow(clippy::result_large_err)]
185pub fn browser_study_search_response(
186    datastore_id: PublicUuid,
187    sources: Vec<BrowserResourceDiscoverySource>,
188    request: &BrowserStudySearchInput,
189    protocol_version: &ProtocolVersion,
190) -> Result<BrowserStudySearchResponse, ProtocolError> {
191    validate_search_references(datastore_id, request, protocol_version)?;
192    let after = request.decoded_cursor(datastore_id, protocol_version)?;
193    let mut studies = sources
194        .into_iter()
195        .filter_map(|source| study_candidate(source, request))
196        .collect::<Vec<_>>();
197    studies.sort_by_key(study_sort_key);
198    if let Some(after) = after {
199        studies.retain(|record| study_sort_key(record) > cursor_sort_key(&after));
200    }
201
202    let limit = usize::from(request.page.limit);
203    let has_more = studies.len() > limit;
204    let page = studies.into_iter().take(limit).collect::<Vec<_>>();
205    let next_cursor = if has_more {
206        match page.last() {
207            Some(record) => Some(request.encode_cursor(
208                datastore_id,
209                protocol_version,
210                study_cursor_anchor(datastore_id, record),
211            )?),
212            None => None,
213        }
214    } else {
215        None
216    };
217    let returned_count = u16::try_from(page.len()).expect("page length fits u16");
218    let empty_state = page.is_empty().then(|| BrowserStudySearchEmptyState {
219        code: "no_matching_browser_visible_studies".to_string(),
220        message: "No browser-visible studies match the active search filters.".to_string(),
221    });
222
223    Ok(BrowserStudySearchResponse {
224        protocol_version: protocol_version.clone(),
225        datastore_id,
226        family: "studies".to_string(),
227        predicates: request.predicates.clone(),
228        query: request.query.clone(),
229        studies: page
230            .into_iter()
231            .map(BrowserStudyCandidate::record)
232            .collect(),
233        returned_count,
234        requested_limit: request.page.limit,
235        ordering: BrowserSearchOrdering {
236            id: "study_name_then_identity".to_string(),
237            description: "Studies are ordered by canonical name, then stable identity.".to_string(),
238        },
239        has_more,
240        next_cursor,
241        empty_state,
242    })
243}
244
245#[derive(Debug)]
246struct BrowserStudyCandidate {
247    source: BrowserResourceDiscoverySource,
248    evidence: Vec<BrowserStudyMatchEvidence>,
249    evidence_truncated: bool,
250}
251
252impl BrowserStudyCandidate {
253    fn record(self) -> BrowserStudySearchRecord {
254        let datastore_id = self.source.datastore_id;
255        let record = BrowserStudyDiscoverySourceRecord {
256            study: self.source.study,
257            domains: self.source.domains,
258            tags: self.source.study_tags,
259            access_conditions: self.source.study_access_conditions,
260        };
261        BrowserStudySearchRecord {
262            discovery: browser_study_discovery_record(datastore_id, record),
263            match_evidence: self.evidence,
264            match_evidence_truncated: self.evidence_truncated,
265        }
266    }
267}
268
269fn browser_study_discovery_record(
270    datastore_id: PublicUuid,
271    record: BrowserStudyDiscoverySourceRecord,
272) -> BrowserStudyDiscoveryRecord {
273    let study = StudyRef {
274        datastore_id,
275        kind: ahri_tre_protocol::refs::ObjectKind::Study,
276        id: PublicUuid::from_uuid(record.study.study_id.0),
277    };
278    BrowserStudyDiscoveryRecord {
279        facts: browser_study_facts(datastore_id, &record),
280        domains: record
281            .domains
282            .into_iter()
283            .map(|value| domain_summary(datastore_id, value))
284            .collect(),
285        access_request: BrowserStudyAccessRequestReference {
286            datastore_id,
287            study,
288        },
289    }
290}
291
292fn study_candidate(
293    mut source: BrowserResourceDiscoverySource,
294    request: &BrowserStudySearchInput,
295) -> Option<BrowserStudyCandidate> {
296    let datastore_id = source.datastore_id;
297    if request.query.is_none() {
298        return legacy_study_candidate(source, request);
299    }
300    let query = request.query.as_ref().expect("query branch checked");
301    source
302        .datasets
303        .sort_by_key(|record| asset_evidence_sort_key(&record.asset));
304    source
305        .datafiles
306        .sort_by_key(|record| asset_evidence_sort_key(&record.asset));
307
308    let domain_matches = query.facets.any_domains.is_empty()
309        || query.facets.any_domains.iter().any(|selector| {
310            source
311                .domains
312                .iter()
313                .any(|domain| domain_matches(domain, selector))
314        });
315    if !domain_matches {
316        return None;
317    }
318
319    let tag_matches = query.facets.any_tags.is_empty()
320        || source_tags(&source).any(|tag| {
321            query
322                .facets
323                .any_tags
324                .iter()
325                .any(|selector| tag_matches_selector(tag, selector))
326        });
327    if !tag_matches {
328        return None;
329    }
330
331    let mut evidence = Vec::new();
332    let study_fields = study_matched_fields(&source, request);
333    if !study_fields.is_empty() {
334        evidence.push(BrowserStudyMatchEvidence::Study {
335            study: StudyRef {
336                datastore_id,
337                kind: ahri_tre_protocol::refs::ObjectKind::Study,
338                id: PublicUuid::from_uuid(source.study.study_id.0),
339            },
340            matched_fields: study_fields,
341        });
342    }
343    for dataset in &source.datasets {
344        for matched in
345            asset_version_matches(&dataset.asset, &dataset.tags, &dataset.versions, request)
346        {
347            let version = &dataset.versions[matched.version_index];
348            evidence.push(BrowserStudyMatchEvidence::Dataset {
349                asset: ahri_tre_protocol::refs::AssetRef {
350                    datastore_id,
351                    kind: ahri_tre_protocol::refs::ObjectKind::Asset,
352                    id: PublicUuid::from_uuid(dataset.asset.asset_id.0),
353                },
354                dataset: ahri_tre_protocol::refs::DatasetRef {
355                    datastore_id,
356                    kind: ahri_tre_protocol::refs::ObjectKind::Asset,
357                    id: PublicUuid::from_uuid(dataset.asset.asset_id.0),
358                },
359                version: VersionRef {
360                    datastore_id,
361                    kind: ahri_tre_protocol::refs::ObjectKind::AssetVersion,
362                    id: PublicUuid::from_uuid(version.version.version_id.0),
363                },
364                name: dataset.asset.name.clone(),
365                matched_fields: matched.fields,
366            });
367        }
368    }
369    for datafile in &source.datafiles {
370        for matched in
371            asset_version_matches(&datafile.asset, &datafile.tags, &datafile.versions, request)
372        {
373            let version = &datafile.versions[matched.version_index];
374            evidence.push(BrowserStudyMatchEvidence::Datafile {
375                asset: ahri_tre_protocol::refs::AssetRef {
376                    datastore_id,
377                    kind: ahri_tre_protocol::refs::ObjectKind::Asset,
378                    id: PublicUuid::from_uuid(datafile.asset.asset_id.0),
379                },
380                datafile: ahri_tre_protocol::refs::DataFileRef {
381                    datastore_id,
382                    kind: ahri_tre_protocol::refs::ObjectKind::Asset,
383                    id: PublicUuid::from_uuid(datafile.asset.asset_id.0),
384                },
385                version: VersionRef {
386                    datastore_id,
387                    kind: ahri_tre_protocol::refs::ObjectKind::AssetVersion,
388                    id: PublicUuid::from_uuid(version.version.version_id.0),
389                },
390                name: datafile.asset.name.clone(),
391                matched_fields: matched.fields,
392            });
393        }
394    }
395
396    let text_matches = query.text.is_none()
397        || evidence.iter().any(|item| {
398            matched_fields(item).iter().any(|field| {
399                *field != BrowserStudyMatchedField::Tag
400                    && *field != BrowserStudyMatchedField::Domain
401            })
402        });
403    if !text_matches {
404        return None;
405    }
406    if evidence.is_empty() {
407        evidence.push(BrowserStudyMatchEvidence::Study {
408            study: StudyRef {
409                datastore_id,
410                kind: ahri_tre_protocol::refs::ObjectKind::Study,
411                id: PublicUuid::from_uuid(source.study.study_id.0),
412            },
413            matched_fields: Vec::new(),
414        });
415    }
416    let evidence_truncated = evidence.len() > MAX_BROWSER_STUDY_MATCH_EVIDENCE;
417    evidence.truncate(MAX_BROWSER_STUDY_MATCH_EVIDENCE);
418    Some(BrowserStudyCandidate {
419        source,
420        evidence,
421        evidence_truncated,
422    })
423}
424
425fn legacy_study_candidate(
426    source: BrowserResourceDiscoverySource,
427    request: &BrowserStudySearchInput,
428) -> Option<BrowserStudyCandidate> {
429    let datastore_id = source.datastore_id;
430    let predicates = request
431        .predicates
432        .as_ref()
433        .expect("validated browser search has predicates or a query");
434    let evaluated = predicates
435        .predicates()
436        .iter()
437        .map(|predicate| legacy_predicate_match(&source, predicate))
438        .collect::<Vec<_>>();
439    let matches = match predicates {
440        PredicateSet::AllOf(_) => evaluated.iter().all(|(matches, _)| *matches),
441        PredicateSet::AnyOf(_) => evaluated.iter().any(|(matches, _)| *matches),
442    };
443    if !matches {
444        return None;
445    }
446
447    let mut matched_fields = evaluated
448        .into_iter()
449        .filter_map(|(matches, field)| matches.then_some(field))
450        .collect::<Vec<_>>();
451    matched_fields.sort_by_key(|field| format!("{field:?}"));
452    matched_fields.dedup();
453    Some(BrowserStudyCandidate {
454        evidence: vec![BrowserStudyMatchEvidence::Study {
455            study: StudyRef {
456                datastore_id,
457                kind: ahri_tre_protocol::refs::ObjectKind::Study,
458                id: PublicUuid::from_uuid(source.study.study_id.0),
459            },
460            matched_fields,
461        }],
462        source,
463        evidence_truncated: false,
464    })
465}
466
467fn legacy_predicate_match(
468    source: &BrowserResourceDiscoverySource,
469    predicate: &StudyPredicate,
470) -> (bool, BrowserStudyMatchedField) {
471    match predicate {
472        StudyPredicate::Text(text) => {
473            let (candidate, field) = match text.field {
474                StudyTextField::Name => (
475                    Some(source.study.name.as_str()),
476                    BrowserStudyMatchedField::Name,
477                ),
478                StudyTextField::Title => (
479                    source.study.documentation.as_deref(),
480                    BrowserStudyMatchedField::Documentation,
481                ),
482                StudyTextField::Description => (
483                    source.study.description.as_deref(),
484                    BrowserStudyMatchedField::Description,
485                ),
486            };
487            (
488                candidate.is_some_and(|candidate| {
489                    ahri_tre_core::search_text_matches(
490                        candidate,
491                        match text.mode {
492                            ahri_tre_protocol::search::TextMode::Exact => {
493                                ahri_tre_core::SearchTextMode::Exact
494                            }
495                            ahri_tre_protocol::search::TextMode::Prefix => {
496                                ahri_tre_core::SearchTextMode::Prefix
497                            }
498                            ahri_tre_protocol::search::TextMode::Contains => {
499                                ahri_tre_core::SearchTextMode::Contains
500                            }
501                        },
502                        &text.value,
503                    )
504                }),
505                field,
506            )
507        }
508        StudyPredicate::Domain { domain } => (
509            source
510                .domains
511                .iter()
512                .any(|candidate| domain_matches(candidate, domain)),
513            BrowserStudyMatchedField::Domain,
514        ),
515        StudyPredicate::Tag(tag) => (
516            source
517                .study_tags
518                .iter()
519                .any(|candidate| tag_matches_selector(candidate, &tag.selector)),
520            BrowserStudyMatchedField::Tag,
521        ),
522    }
523}
524
525fn study_matched_fields(
526    source: &BrowserResourceDiscoverySource,
527    request: &BrowserStudySearchInput,
528) -> Vec<BrowserStudyMatchedField> {
529    let query = request
530        .query
531        .as_ref()
532        .expect("asset-aware query is present");
533    let mut fields = Vec::new();
534    if let Some(text) = query.text.as_ref() {
535        push_text_match(
536            &mut fields,
537            BrowserStudyMatchedField::Name,
538            &source.study.name,
539            text,
540        );
541        push_optional_text_match(
542            &mut fields,
543            BrowserStudyMatchedField::ExternalId,
544            source.study.external_id.as_deref(),
545            text,
546        );
547        push_optional_text_match(
548            &mut fields,
549            BrowserStudyMatchedField::Description,
550            source.study.description.as_deref(),
551            text,
552        );
553        push_optional_text_match(
554            &mut fields,
555            BrowserStudyMatchedField::Documentation,
556            source.study.documentation.as_deref(),
557            text,
558        );
559    }
560    if !query.facets.any_domains.is_empty()
561        && source.domains.iter().any(|domain| {
562            query
563                .facets
564                .any_domains
565                .iter()
566                .any(|selector| domain_matches(domain, selector))
567        })
568    {
569        fields.push(BrowserStudyMatchedField::Domain);
570    }
571    if tags_match_selected(&source.study_tags, request) {
572        fields.push(BrowserStudyMatchedField::Tag);
573    }
574    fields
575}
576
577struct AssetVersionMatch {
578    version_index: usize,
579    fields: Vec<BrowserStudyMatchedField>,
580}
581
582trait AssetVersionEvidenceSource {
583    fn version(&self) -> &ahri_tre_pgmeta::PgAssetVersionReadModel;
584    fn tags(&self) -> &[ahri_tre_pgmeta::PgTagReadModel];
585
586    fn available_for_latest_selection(&self) -> bool {
587        true
588    }
589
590    fn format(&self) -> Option<&str> {
591        None
592    }
593}
594
595impl AssetVersionEvidenceSource
596    for crate::browser_resource_discovery::BrowserDatasetVersionDiscoverySourceRecord
597{
598    fn version(&self) -> &ahri_tre_pgmeta::PgAssetVersionReadModel {
599        &self.version
600    }
601
602    fn tags(&self) -> &[ahri_tre_pgmeta::PgTagReadModel] {
603        &self.tags
604    }
605
606    fn available_for_latest_selection(&self) -> bool {
607        self.withdrawn_at.is_none()
608    }
609}
610
611impl AssetVersionEvidenceSource
612    for crate::browser_resource_discovery::BrowserDataFileVersionDiscoverySourceRecord
613{
614    fn version(&self) -> &ahri_tre_pgmeta::PgAssetVersionReadModel {
615        &self.version
616    }
617
618    fn tags(&self) -> &[ahri_tre_pgmeta::PgTagReadModel] {
619        &self.tags
620    }
621
622    fn format(&self) -> Option<&str> {
623        Some(&self.datafile.format)
624    }
625}
626
627fn asset_version_matches<V: AssetVersionEvidenceSource>(
628    asset: &ahri_tre_pgmeta::PgAssetReadModel,
629    asset_tags: &[ahri_tre_pgmeta::PgTagReadModel],
630    versions: &[V],
631    request: &BrowserStudySearchInput,
632) -> Vec<AssetVersionMatch> {
633    let query = request
634        .query
635        .as_ref()
636        .expect("asset-aware query is present");
637    let latest_index = versions
638        .iter()
639        .position(|source| {
640            source.available_for_latest_selection() && source.version().is_latest.unwrap_or(false)
641        })
642        .or_else(|| {
643            versions
644                .iter()
645                .rposition(AssetVersionEvidenceSource::available_for_latest_selection)
646        });
647    let mut asset_fields = Vec::new();
648    if let Some(text) = query.text.as_ref() {
649        push_text_match(
650            &mut asset_fields,
651            BrowserStudyMatchedField::Name,
652            &asset.name,
653            text,
654        );
655        push_optional_text_match(
656            &mut asset_fields,
657            BrowserStudyMatchedField::Description,
658            asset.description.as_deref(),
659            text,
660        );
661    }
662    if tags_match_selected(asset_tags, request) {
663        asset_fields.push(BrowserStudyMatchedField::Tag);
664    }
665
666    let mut matches = versions
667        .iter()
668        .enumerate()
669        .filter_map(|(version_index, source)| {
670            let version = source.version();
671            let mut fields = Vec::new();
672            if let Some(text) = query.text.as_ref() {
673                push_optional_text_match(
674                    &mut fields,
675                    BrowserStudyMatchedField::VersionLabel,
676                    version.version_label.as_deref(),
677                    text,
678                );
679                push_optional_text_match(
680                    &mut fields,
681                    BrowserStudyMatchedField::VersionNote,
682                    version.version_note.as_deref(),
683                    text,
684                );
685                push_optional_text_match(
686                    &mut fields,
687                    BrowserStudyMatchedField::Doi,
688                    version.doi.as_deref(),
689                    text,
690                );
691                push_optional_text_match(
692                    &mut fields,
693                    BrowserStudyMatchedField::Format,
694                    source.format(),
695                    text,
696                );
697            }
698            if tags_match_selected(source.tags(), request) {
699                fields.push(BrowserStudyMatchedField::Tag);
700            }
701            (!fields.is_empty()).then_some(AssetVersionMatch {
702                version_index,
703                fields,
704            })
705        })
706        .collect::<Vec<_>>();
707
708    if !asset_fields.is_empty()
709        && let Some(latest_index) = latest_index
710    {
711        if let Some(latest) = matches
712            .iter_mut()
713            .find(|matched| matched.version_index == latest_index)
714        {
715            for field in asset_fields {
716                if !latest.fields.contains(&field) {
717                    latest.fields.push(field);
718                }
719            }
720        } else {
721            matches.insert(
722                0,
723                AssetVersionMatch {
724                    version_index: latest_index,
725                    fields: asset_fields,
726                },
727            );
728        }
729    }
730    matches
731}
732
733fn push_optional_text_match(
734    fields: &mut Vec<BrowserStudyMatchedField>,
735    field: BrowserStudyMatchedField,
736    candidate: Option<&str>,
737    text: &ahri_tre_protocol::study::BrowserStudyTextQuery,
738) {
739    if let Some(candidate) = candidate {
740        push_text_match(fields, field, candidate, text);
741    }
742}
743
744fn push_text_match(
745    fields: &mut Vec<BrowserStudyMatchedField>,
746    field: BrowserStudyMatchedField,
747    candidate: &str,
748    text: &ahri_tre_protocol::study::BrowserStudyTextQuery,
749) {
750    if text_matches(candidate, text) && !fields.contains(&field) {
751        fields.push(field);
752    }
753}
754
755fn text_matches(candidate: &str, text: &ahri_tre_protocol::study::BrowserStudyTextQuery) -> bool {
756    ahri_tre_core::search_text_matches(
757        candidate,
758        match text.mode {
759            ahri_tre_protocol::search::TextMode::Exact => ahri_tre_core::SearchTextMode::Exact,
760            ahri_tre_protocol::search::TextMode::Prefix => ahri_tre_core::SearchTextMode::Prefix,
761            ahri_tre_protocol::search::TextMode::Contains => {
762                ahri_tre_core::SearchTextMode::Contains
763            }
764        },
765        &text.value,
766    )
767}
768
769fn tags_match_selected(
770    tags: &[ahri_tre_pgmeta::PgTagReadModel],
771    request: &BrowserStudySearchInput,
772) -> bool {
773    let query = request
774        .query
775        .as_ref()
776        .expect("asset-aware query is present");
777    !query.facets.any_tags.is_empty()
778        && tags.iter().any(|tag| {
779            query
780                .facets
781                .any_tags
782                .iter()
783                .any(|selector| tag_matches_selector(tag, selector))
784        })
785}
786
787fn source_tags(
788    source: &BrowserResourceDiscoverySource,
789) -> impl Iterator<Item = &ahri_tre_pgmeta::PgTagReadModel> {
790    source
791        .study_tags
792        .iter()
793        .chain(source.datasets.iter().flat_map(|record| {
794            record.tags.iter().chain(
795                record
796                    .versions
797                    .iter()
798                    .flat_map(|version| version.tags.iter()),
799            )
800        }))
801        .chain(source.datafiles.iter().flat_map(|record| {
802            record.tags.iter().chain(
803                record
804                    .versions
805                    .iter()
806                    .flat_map(|version| version.tags.iter()),
807            )
808        }))
809}
810
811fn tag_matches_selector(
812    tag: &ahri_tre_pgmeta::PgTagReadModel,
813    selector: &ahri_tre_protocol::refs::TagSelector,
814) -> bool {
815    match selector {
816        ahri_tre_protocol::refs::TagSelector::Id { tag: expected } => {
817            ahri_tre_protocol::refs::encode_scoped_integer_ref("tag", tag.tag_id) == expected.id
818        }
819        ahri_tre_protocol::refs::TagSelector::Label { scope, label } => {
820            *scope == TagScope::Global && tag.name == label.as_str()
821        }
822    }
823}
824
825fn domain_matches(
826    domain: &ahri_tre_pgmeta::PgDomainReadModel,
827    selector: &ahri_tre_protocol::domain::DomainSelector,
828) -> bool {
829    match selector {
830        ahri_tre_protocol::domain::DomainSelector::Id { domain: expected } => {
831            ahri_tre_protocol::refs::encode_scoped_integer_ref("domain", domain.domain_id.0)
832                == expected.id
833        }
834        ahri_tre_protocol::domain::DomainSelector::Name { name } => domain.name == name.as_str(),
835    }
836}
837
838fn matched_fields(evidence: &BrowserStudyMatchEvidence) -> &[BrowserStudyMatchedField] {
839    match evidence {
840        BrowserStudyMatchEvidence::Study { matched_fields, .. }
841        | BrowserStudyMatchEvidence::Dataset { matched_fields, .. }
842        | BrowserStudyMatchEvidence::Datafile { matched_fields, .. } => matched_fields,
843    }
844}
845
846fn asset_evidence_sort_key(record: &ahri_tre_pgmeta::PgAssetReadModel) -> (String, String, Uuid) {
847    (
848        record.name.to_ascii_lowercase(),
849        record.name.clone(),
850        record.asset_id.0,
851    )
852}
853
854fn study_sort_key(record: &BrowserStudyCandidate) -> (String, String, Uuid) {
855    (
856        record.source.study.name.as_str().to_ascii_lowercase(),
857        record.source.study.name.as_str().to_string(),
858        record.source.study.study_id.0,
859    )
860}
861
862fn cursor_sort_key(anchor: &StudySearchCursorAnchor) -> (String, String, Uuid) {
863    (
864        anchor.name.to_ascii_lowercase(),
865        anchor.name.clone(),
866        anchor.study.id.as_uuid(),
867    )
868}
869
870fn study_cursor_anchor(
871    datastore_id: PublicUuid,
872    record: &BrowserStudyCandidate,
873) -> StudySearchCursorAnchor {
874    StudySearchCursorAnchor {
875        study: StudyRef {
876            datastore_id,
877            kind: ahri_tre_protocol::refs::ObjectKind::Study,
878            id: PublicUuid::from_uuid(record.source.study.study_id.0),
879        },
880        name: record.source.study.name.as_str().to_string(),
881    }
882}
883
884pub fn browser_study_facts(
885    datastore_id: PublicUuid,
886    record: &BrowserStudyDiscoverySourceRecord,
887) -> BrowserStudyFacts {
888    BrowserStudyFacts {
889        study: StudyRef {
890            datastore_id,
891            kind: ahri_tre_protocol::refs::ObjectKind::Study,
892            id: PublicUuid::from_uuid(record.study.study_id.0),
893        },
894        name: record.study.name.clone(),
895        external_id: record.study.external_id.clone(),
896        description: record.study.description.clone(),
897        documentation: record.study.documentation.clone(),
898        tags: record
899            .tags
900            .iter()
901            .cloned()
902            .map(|tag| tag_summary(datastore_id, tag))
903            .collect(),
904        access_conditions: record
905            .access_conditions
906            .iter()
907            .cloned()
908            .map(access_condition)
909            .collect(),
910    }
911}
912
913fn domain_summary(
914    datastore_id: PublicUuid,
915    domain: ahri_tre_pgmeta::PgDomainReadModel,
916) -> DomainSummary {
917    DomainSummary {
918        domain: crate::projections::domain_ref(datastore_id, domain.domain_id),
919        name: domain.name,
920        description: domain.description,
921        uri: domain.uri,
922    }
923}
924
925fn tag_summary(datastore_id: PublicUuid, tag: ahri_tre_pgmeta::PgTagReadModel) -> TagSummary {
926    TagSummary {
927        tag: TagRef {
928            datastore_id,
929            kind: ahri_tre_protocol::refs::ObjectKind::Tag,
930            id: ahri_tre_protocol::refs::encode_scoped_integer_ref("tag", tag.tag_id),
931        },
932        scope: TagScope::Global,
933        label: tag.name,
934    }
935}
936
937fn access_condition(
938    condition: ahri_tre_pgmeta::PgDuoRestrictionReadModel,
939) -> BrowserStudyAccessCondition {
940    BrowserStudyAccessCondition {
941        duo_id: condition.duo_id,
942        code: condition.duo_code,
943        label: condition.label,
944        qualifier_ontology_namespace: condition.qualifier_ontology_namespace,
945        qualifier_ontology_id: condition.qualifier_ontology_id,
946        qualifier_text: condition.qualifier_text,
947        qualifier_date: condition.qualifier_date,
948        qualifier_integer: condition.qualifier_integer,
949    }
950}
951
952#[cfg(test)]
953mod asset_aware_search_tests {
954    use super::*;
955    use crate::browser_resource_discovery::{
956        BrowserDataFileDiscoverySourceRecord, BrowserDataFileMetadataSource,
957        BrowserDataFileVersionDiscoverySourceRecord, BrowserDatasetDiscoverySourceRecord,
958        BrowserDatasetVersionDiscoverySourceRecord, BrowserResourceDiscoverySource,
959    };
960    use ahri_tre_protocol::{
961        pagination::PageRequest,
962        study::{
963            BrowserStudyFacetGroups, BrowserStudySearchInput, BrowserStudySearchQuery,
964            BrowserStudyTextQuery,
965        },
966    };
967    use ahri_tre_types::{AssetId, AssetType, DomainId, StudyId, VersionId};
968
969    #[test]
970    fn returned_study_domain_uses_the_analyst_reference_and_can_filter_again() {
971        let datastore_id = PublicUuid::from_uuid(Uuid::from_u128(42));
972        let domain = source(datastore_id).domains.remove(0);
973        let returned = domain_summary(datastore_id, domain.clone()).domain;
974        assert_eq!(
975            returned,
976            crate::projections::domain_ref(datastore_id, domain.domain_id)
977        );
978        assert_eq!(
979            ahri_tre_protocol::refs::decode_scoped_integer_ref("domain", returned.id),
980            Some(domain.domain_id.0)
981        );
982        assert!(domain_matches(
983            &domain,
984            &ahri_tre_protocol::domain::DomainSelector::Id { domain: returned }
985        ));
986    }
987
988    #[test]
989    fn aggregate_search_matches_study_dataset_and_datafile_once_with_nested_facets() {
990        let datastore_id = PublicUuid::from_uuid(Uuid::from_u128(42));
991        let mut request = BrowserStudySearchInput {
992            session: None,
993            predicates: None,
994            query: Some(BrowserStudySearchQuery {
995                text: Some(BrowserStudyTextQuery {
996                    mode: ahri_tre_protocol::search::TextMode::Contains,
997                    value: "sentinel".to_string(),
998                }),
999                facets: BrowserStudyFacetGroups {
1000                    any_tags: vec![
1001                        ahri_tre_protocol::refs::TagSelector::Label {
1002                            scope: TagScope::Global,
1003                            label: ahri_tre_protocol::PublicName::new("missing-tag").unwrap(),
1004                        },
1005                        ahri_tre_protocol::refs::TagSelector::Label {
1006                            scope: TagScope::Global,
1007                            label: ahri_tre_protocol::PublicName::new("curated").unwrap(),
1008                        },
1009                    ],
1010                    any_domains: vec![
1011                        ahri_tre_protocol::domain::DomainSelector::Name {
1012                            name: ahri_tre_protocol::PublicName::new("missing-domain").unwrap(),
1013                        },
1014                        ahri_tre_protocol::domain::DomainSelector::Name {
1015                            name: ahri_tre_protocol::PublicName::new("demography").unwrap(),
1016                        },
1017                    ],
1018                },
1019            }),
1020            page: PageRequest {
1021                cursor: None,
1022                limit: 10,
1023            },
1024        };
1025        request
1026            .validate(datastore_id, &ProtocolVersion::current())
1027            .unwrap();
1028
1029        let response = browser_study_search_response(
1030            datastore_id,
1031            vec![source(datastore_id)],
1032            &request,
1033            &ProtocolVersion::current(),
1034        )
1035        .unwrap();
1036
1037        assert_eq!(response.studies.len(), 1);
1038        assert_eq!(response.studies[0].match_evidence.len(), 3);
1039        assert!(matches!(
1040            response.studies[0].match_evidence[0],
1041            ahri_tre_protocol::study::BrowserStudyMatchEvidence::Study { .. }
1042        ));
1043        assert!(matches!(
1044            response.studies[0].match_evidence[1],
1045            ahri_tre_protocol::study::BrowserStudyMatchEvidence::Dataset { .. }
1046        ));
1047        assert!(matches!(
1048            response.studies[0].match_evidence[2],
1049            ahri_tre_protocol::study::BrowserStudyMatchEvidence::Datafile { .. }
1050        ));
1051    }
1052
1053    #[test]
1054    fn aggregate_search_bounds_evidence_and_excludes_private_source_fields() {
1055        let datastore_id = PublicUuid::from_uuid(Uuid::from_u128(42));
1056        let mut source = source(datastore_id);
1057        let template = source.datasets[0].clone();
1058        for offset in 1_u128..7 {
1059            let mut dataset = template.clone();
1060            dataset.asset.asset_id = AssetId(Uuid::from_u128(200 + offset));
1061            dataset.asset.name = format!("Sentinel dataset {offset}");
1062            dataset.versions[0].version.asset_id = dataset.asset.asset_id;
1063            dataset.versions[0].version.version_id = VersionId(Uuid::from_u128(220 + offset));
1064            dataset.versions[0].dataset.dataset_id = dataset.versions[0].version.version_id;
1065            source.datasets.push(dataset);
1066        }
1067        let mut request = BrowserStudySearchInput {
1068            session: None,
1069            predicates: None,
1070            query: Some(BrowserStudySearchQuery {
1071                text: Some(BrowserStudyTextQuery {
1072                    mode: ahri_tre_protocol::search::TextMode::Contains,
1073                    value: "sentinel".to_string(),
1074                }),
1075                facets: BrowserStudyFacetGroups::default(),
1076            }),
1077            page: PageRequest {
1078                cursor: None,
1079                limit: 10,
1080            },
1081        };
1082        request
1083            .validate(datastore_id, &ProtocolVersion::current())
1084            .unwrap();
1085
1086        let response = browser_study_search_response(
1087            datastore_id,
1088            vec![source],
1089            &request,
1090            &ProtocolVersion::current(),
1091        )
1092        .unwrap();
1093        let result = &response.studies[0];
1094        assert_eq!(
1095            result.match_evidence.len(),
1096            ahri_tre_protocol::study::MAX_BROWSER_STUDY_MATCH_EVIDENCE
1097        );
1098        assert!(result.match_evidence_truncated);
1099        let public_json = serde_json::to_string(&response).unwrap();
1100        assert!(!public_json.contains("never disclose me"));
1101        assert!(!public_json.contains("agent_instructions"));
1102    }
1103
1104    #[test]
1105    fn aggregate_search_keeps_dataset_and_datafile_version_evidence_separate() {
1106        let datastore_id = PublicUuid::from_uuid(Uuid::from_u128(42));
1107        let mut source = source(datastore_id);
1108        source.study.name = "Cohort".to_string();
1109        source.study.description = None;
1110        source.datasets[0].asset.name = "Dataset".to_string();
1111        let mut historical = source.datasets[0].versions[0].clone();
1112        historical.version.version_id = VersionId(Uuid::from_u128(199));
1113        historical.version.version_label = Some("historical needle".to_string());
1114        historical.version.is_latest = Some(false);
1115        historical.dataset.dataset_id = historical.version.version_id;
1116        source.datasets[0].versions[0].version.version_label = Some("current".to_string());
1117        source.datasets[0].versions[0].version.doi = Some("needle dataset DOI".to_string());
1118        source.datasets[0].versions.insert(0, historical);
1119        source.datafiles[0].asset.name = "Datafile".to_string();
1120        source.datafiles[0].versions[0].version.version_label = Some("current".to_string());
1121        source.datafiles[0].versions[0].datafile.format = "needle/format".to_string();
1122        let mut historical = source.datafiles[0].versions[0].clone();
1123        historical.version.version_id = VersionId(Uuid::from_u128(299));
1124        historical.version.version_note = Some("historical needle note".to_string());
1125        historical.version.doi = None;
1126        historical.version.is_latest = Some(false);
1127        historical.datafile.datafile_id = historical.version.version_id;
1128        historical.datafile.format = "text/csv".to_string();
1129        source.datafiles[0].versions.insert(0, historical);
1130        let mut request = search_request("needle");
1131        request
1132            .validate(datastore_id, &ProtocolVersion::current())
1133            .unwrap();
1134
1135        let response = browser_study_search_response(
1136            datastore_id,
1137            vec![source],
1138            &request,
1139            &ProtocolVersion::current(),
1140        )
1141        .unwrap();
1142
1143        let evidence = &response.studies[0].match_evidence;
1144        assert_eq!(evidence.len(), 4);
1145        assert!(evidence.iter().any(|item| matches!(
1146            item,
1147            BrowserStudyMatchEvidence::Dataset { version, matched_fields, .. }
1148                if version.id == PublicUuid::from_uuid(Uuid::from_u128(199))
1149                    && matched_fields == &[BrowserStudyMatchedField::VersionLabel]
1150        )));
1151        assert!(evidence.iter().any(|item| matches!(
1152            item,
1153            BrowserStudyMatchEvidence::Dataset { version, matched_fields, .. }
1154                if version.id == PublicUuid::from_uuid(Uuid::from_u128(201))
1155                    && matched_fields == &[BrowserStudyMatchedField::Doi]
1156        )));
1157        assert!(evidence.iter().any(|item| matches!(
1158            item,
1159            BrowserStudyMatchEvidence::Datafile { version, matched_fields, .. }
1160                if version.id == PublicUuid::from_uuid(Uuid::from_u128(299))
1161                    && matched_fields == &[BrowserStudyMatchedField::VersionNote]
1162        )));
1163        assert!(evidence.iter().any(|item| matches!(
1164            item,
1165            BrowserStudyMatchEvidence::Datafile { version, matched_fields, .. }
1166                if version.id == PublicUuid::from_uuid(Uuid::from_u128(301))
1167                    && matched_fields == &[BrowserStudyMatchedField::Format]
1168        )));
1169    }
1170
1171    #[test]
1172    fn aggregate_search_attributes_asset_evidence_to_latest_available_dataset_version() {
1173        let datastore_id = PublicUuid::from_uuid(Uuid::from_u128(42));
1174        let mut source = source(datastore_id);
1175        source.study.name = "Cohort".to_string();
1176        source.study.description = None;
1177        source.datafiles.clear();
1178
1179        let mut available = source.datasets[0].versions[0].clone();
1180        available.version.version_id = VersionId(Uuid::from_u128(199));
1181        available.version.is_latest = Some(false);
1182        available.dataset.dataset_id = available.version.version_id;
1183        source.datasets[0].versions[0].withdrawn_at = Some(chrono::Utc::now());
1184        source.datasets[0].versions.insert(0, available);
1185
1186        let request = search_request("sentinel dataset");
1187        let response = browser_study_search_response(
1188            datastore_id,
1189            vec![source],
1190            &request,
1191            &ProtocolVersion::current(),
1192        )
1193        .unwrap();
1194
1195        assert!(matches!(
1196            response.studies[0].match_evidence.as_slice(),
1197            [BrowserStudyMatchEvidence::Dataset {
1198                version,
1199                matched_fields,
1200                ..
1201            }] if version.id == PublicUuid::from_uuid(Uuid::from_u128(199))
1202                && matched_fields == &[BrowserStudyMatchedField::Name]
1203        ));
1204    }
1205
1206    #[test]
1207    fn predicate_search_keeps_existing_study_only_behavior() {
1208        let datastore_id = PublicUuid::from_uuid(Uuid::from_u128(42));
1209        let mut request: BrowserStudySearchInput = serde_json::from_value(serde_json::json!({
1210            "predicates": {
1211                "kind": "all_of",
1212                "predicates": [{
1213                    "kind": "text",
1214                    "field": "name",
1215                    "mode": "contains",
1216                    "value": "cohort"
1217                }]
1218            },
1219            "page": {"limit": 10}
1220        }))
1221        .unwrap();
1222        request
1223            .validate(datastore_id, &ProtocolVersion::current())
1224            .unwrap();
1225
1226        let response = browser_study_search_response(
1227            datastore_id,
1228            vec![source(datastore_id)],
1229            &request,
1230            &ProtocolVersion::current(),
1231        )
1232        .unwrap();
1233
1234        assert_eq!(response.returned_count, 1);
1235        assert_eq!(response.predicates, request.predicates);
1236        assert!(response.query.is_none());
1237    }
1238
1239    fn search_request(value: &str) -> BrowserStudySearchInput {
1240        BrowserStudySearchInput {
1241            session: None,
1242            predicates: None,
1243            query: Some(BrowserStudySearchQuery {
1244                text: Some(BrowserStudyTextQuery {
1245                    mode: ahri_tre_protocol::search::TextMode::Contains,
1246                    value: value.to_string(),
1247                }),
1248                facets: BrowserStudyFacetGroups::default(),
1249            }),
1250            page: PageRequest {
1251                cursor: None,
1252                limit: 10,
1253            },
1254        }
1255    }
1256
1257    fn source(datastore_id: PublicUuid) -> BrowserResourceDiscoverySource {
1258        let study_id = StudyId(Uuid::from_u128(100));
1259        let dataset_asset_id = AssetId(Uuid::from_u128(200));
1260        let dataset_version_id = VersionId(Uuid::from_u128(201));
1261        let datafile_asset_id = AssetId(Uuid::from_u128(300));
1262        let datafile_version_id = VersionId(Uuid::from_u128(301));
1263        BrowserResourceDiscoverySource {
1264            datastore_id,
1265            study: ahri_tre_pgmeta::PgStudyReadModel {
1266                study_id,
1267                name: "Sentinel cohort".to_string(),
1268                description: Some("A sentinel Study".to_string()),
1269                documentation: None,
1270                agent_instructions: Some("never disclose me".to_string()),
1271                external_id: None,
1272                study_type_id: None,
1273                date_created: None,
1274                created_by: None,
1275            },
1276            study_tags: vec![tag(1, "priority")],
1277            study_access_conditions: Vec::new(),
1278            domains: vec![ahri_tre_pgmeta::PgDomainReadModel {
1279                domain_id: DomainId(7),
1280                name: "demography".to_string(),
1281                uri: None,
1282                description: None,
1283            }],
1284            datasets: vec![BrowserDatasetDiscoverySourceRecord {
1285                asset: asset(
1286                    dataset_asset_id,
1287                    study_id,
1288                    "Sentinel dataset",
1289                    AssetType::Dataset,
1290                ),
1291                tags: vec![tag(2, "curated")],
1292                versions: vec![BrowserDatasetVersionDiscoverySourceRecord {
1293                    version: version(dataset_version_id, dataset_asset_id),
1294                    dataset: ahri_tre_pgmeta::PgDatasetReadModel {
1295                        dataset_id: dataset_version_id,
1296                    },
1297                    withdrawn_at: None,
1298                    tags: Vec::new(),
1299                    access_conditions: Vec::new(),
1300                }],
1301            }],
1302            datafiles: vec![BrowserDataFileDiscoverySourceRecord {
1303                asset: asset(datafile_asset_id, study_id, "sentinel.csv", AssetType::File),
1304                tags: Vec::new(),
1305                versions: vec![BrowserDataFileVersionDiscoverySourceRecord {
1306                    version: version(datafile_version_id, datafile_asset_id),
1307                    datafile: BrowserDataFileMetadataSource {
1308                        datafile_id: datafile_version_id,
1309                        format: "text/csv".to_string(),
1310                        size_bytes: None,
1311                        compressed: None,
1312                        encrypted: None,
1313                    },
1314                    tags: vec![tag(2, "curated")],
1315                    access_conditions: Vec::new(),
1316                }],
1317            }],
1318        }
1319    }
1320
1321    fn asset(
1322        asset_id: AssetId,
1323        study_id: StudyId,
1324        name: &str,
1325        asset_type: AssetType,
1326    ) -> ahri_tre_pgmeta::PgAssetReadModel {
1327        ahri_tre_pgmeta::PgAssetReadModel {
1328            asset_id,
1329            study_id,
1330            name: name.to_string(),
1331            description: None,
1332            agent_instructions: Some("never disclose me".to_string()),
1333            asset_type,
1334            date_created: None,
1335            created_by: None,
1336        }
1337    }
1338
1339    fn version(
1340        version_id: VersionId,
1341        asset_id: AssetId,
1342    ) -> ahri_tre_pgmeta::PgAssetVersionReadModel {
1343        ahri_tre_pgmeta::PgAssetVersionReadModel {
1344            version_id,
1345            asset_id,
1346            major: 1,
1347            minor: 0,
1348            patch: 0,
1349            version_label: Some("sentinel release".to_string()),
1350            version_note: None,
1351            is_latest: Some(true),
1352            doi: None,
1353            created_at: None,
1354            created_by: None,
1355        }
1356    }
1357
1358    fn tag(tag_id: i64, name: &str) -> ahri_tre_pgmeta::PgTagReadModel {
1359        ahri_tre_pgmeta::PgTagReadModel {
1360            tag_id,
1361            name: name.to_string(),
1362        }
1363    }
1364}