Skip to main content

ahri_tre_protocol/
dataset.rs

1use crate::{
2    ProtocolVersion, PublicName,
3    asset::{AssetCatalog, SafeAssetCatalog},
4    domain::DomainSelector,
5    pagination::PageRequest,
6    public_error::{ProtocolError, ValidationIssue, ValidationIssueCode},
7    refs::{DatasetRef, SessionSelector, StudyRef, TagSummary, VersionRef},
8    search::{PredicateSet, SearchCursorBinding, TagPredicate, TextPredicate},
9    session::SessionStatusPayload,
10    study::StudySelector,
11    tabular::TabularDataRef,
12};
13use ahri_tre_types::DatasetRecord;
14use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
18pub enum DatasetSelector {
19    Id {
20        dataset: DatasetRef,
21        #[serde(default, skip_serializing_if = "Option::is_none")]
22        study: Option<StudySelector>,
23    },
24    Name {
25        study: StudySelector,
26        name: PublicName,
27    },
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct DatasetVersionRef {
32    pub dataset: DatasetRef,
33    pub version: VersionRef,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
37pub struct VersionLabel(pub String);
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct DatasetSummary {
41    pub dataset: DatasetRef,
42    pub study: StudyRef,
43    pub name: String,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub latest_version: Option<DatasetVersionRef>,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub version_label: Option<VersionLabel>,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub description: Option<String>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub row_count: Option<u64>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub variable_count: Option<u64>,
54    #[serde(default)]
55    pub tags: Vec<TagSummary>,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct DatasetDetail {
60    pub summary: DatasetSummary,
61    #[serde(default, skip_serializing_if = "Vec::is_empty")]
62    pub tabular_refs: Vec<TabularDataRef>,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct ListDatasetsRequest {
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub session: Option<SessionSelector>,
69    pub study: StudySelector,
70    pub page: PageRequest,
71    #[serde(default)]
72    pub include_versions: bool,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct GetDatasetMetadataRequest {
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub session: Option<SessionSelector>,
79    pub dataset: DatasetSelector,
80    #[serde(default)]
81    pub with_variables: bool,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct PreviewDatasetRequest {
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub session: Option<SessionSelector>,
88    pub dataset: DatasetSelector,
89    pub limit: usize,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub width: Option<usize>,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "snake_case")]
96pub enum DatasetDataFormat {
97    Arrow,
98    Parquet,
99    Csv,
100    Json,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub struct DatasetDataRequest {
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub session: Option<SessionSelector>,
107    pub dataset: DatasetSelector,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub limit: Option<usize>,
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub to: Option<String>,
112    pub format: DatasetDataFormat,
113    #[serde(default)]
114    pub compress: bool,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct ExportDatasetRequest {
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub session: Option<SessionSelector>,
121    pub dataset: DatasetSelector,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub limit: Option<usize>,
124    pub target: DatasetExportTarget,
125    pub format: DatasetDataFormat,
126    #[serde(default)]
127    pub compress: bool,
128    #[serde(default)]
129    pub overwrite: bool,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct DatasetExportTarget {
134    pub path: String,
135    #[serde(default)]
136    pub request_only: bool,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct SearchDatasetsRequest {
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub session: Option<SessionSelector>,
143    pub predicates: PredicateSet<DatasetPredicate>,
144    pub page: PageRequest,
145}
146
147/// Service-authorized browser Dataset catalogue search scoped to one public
148/// Datastore identity. The nested search retains the established browser
149/// validation and cursor contract without carrying a user Session.
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct BrowserDatasetSearchRequest {
152    pub datastore_id: crate::PublicUuid,
153    pub search: SearchDatasetsRequest,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157pub struct DatasetSearchCursorAnchor {
158    pub study: StudyRef,
159    pub dataset: DatasetRef,
160    pub name: String,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164pub struct DatasetSearchCursorQuery {
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub session: Option<SessionSelector>,
167    pub predicates: PredicateSet<DatasetPredicate>,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct DatasetSearchCursor {
172    pub binding: SearchCursorBinding<DatasetSearchCursorQuery>,
173    pub after: DatasetSearchCursorAnchor,
174}
175
176#[allow(clippy::result_large_err)]
177impl SearchDatasetsRequest {
178    pub fn validate(&mut self, protocol_version: &ProtocolVersion) -> Result<(), ProtocolError> {
179        let mut issues = Vec::new();
180        collect_predicate_set_issues("body.predicates", &self.predicates, &mut issues);
181        collect_dataset_predicate_issues(&mut self.predicates, &mut issues);
182        collect_page_issues("body.page.limit", self.page.limit, &mut issues);
183        collect_cursor_issues(self, protocol_version, &mut issues);
184
185        if issues.is_empty() {
186            Ok(())
187        } else {
188            Err(ProtocolError::validation(
189                "dataset search request is invalid",
190                issues,
191            ))
192        }
193    }
194
195    pub fn decoded_cursor(
196        &self,
197        protocol_version: &ProtocolVersion,
198    ) -> Result<Option<DatasetSearchCursorAnchor>, ProtocolError> {
199        match self.page.cursor.as_deref() {
200            None => Ok(None),
201            Some(raw_cursor) => {
202                let cursor: DatasetSearchCursor =
203                    serde_json::from_str(raw_cursor).map_err(|error| {
204                        ProtocolError::validation(
205                            "dataset search request is invalid",
206                            vec![ValidationIssue::new(
207                                "body.page.cursor",
208                                ValidationIssueCode::InvalidFormat,
209                                format!("dataset search cursor is invalid: {error}"),
210                            )],
211                        )
212                    })?;
213                let expected = self.cursor_binding(protocol_version);
214                if cursor.binding != expected {
215                    return Err(ProtocolError::validation(
216                        "dataset search request is invalid",
217                        vec![ValidationIssue::new(
218                            "body.page.cursor",
219                            ValidationIssueCode::Conflict,
220                            "dataset search cursor does not match the request predicates, page limit, or protocol version",
221                        )],
222                    ));
223                }
224                Ok(Some(cursor.after))
225            }
226        }
227    }
228
229    pub fn encode_cursor(
230        &self,
231        protocol_version: &ProtocolVersion,
232        after: DatasetSearchCursorAnchor,
233    ) -> Result<String, ProtocolError> {
234        serde_json::to_string(&DatasetSearchCursor {
235            binding: self.cursor_binding(protocol_version),
236            after,
237        })
238        .map_err(|error| {
239            ProtocolError::new(
240                crate::public_error::ProtocolErrorCode::Internal,
241                format!("failed to encode dataset search cursor: {error}"),
242            )
243        })
244    }
245
246    fn cursor_binding(
247        &self,
248        protocol_version: &ProtocolVersion,
249    ) -> SearchCursorBinding<DatasetSearchCursorQuery> {
250        SearchCursorBinding {
251            query: DatasetSearchCursorQuery {
252                session: self.session.clone(),
253                predicates: self.predicates.clone(),
254            },
255            page_limit: self.page.limit,
256            protocol_version: protocol_version.to_string(),
257        }
258    }
259
260    /// Validates the browser route variant of Dataset search. Browser callers
261    /// never hold daemon session capabilities and browser cursors are scoped
262    /// to the selected datastore and search family.
263    #[allow(clippy::result_large_err)]
264    pub fn validate_for_browser(
265        &mut self,
266        datastore_id: crate::PublicUuid,
267        protocol_version: &ProtocolVersion,
268    ) -> Result<(), ProtocolError> {
269        let mut issues = Vec::new();
270        if self.session.is_some() {
271            issues.push(ValidationIssue::new(
272                "body.session",
273                ValidationIssueCode::Unsupported,
274                "daemon session is not accepted for browser dataset search",
275            ));
276        }
277        collect_predicate_set_issues("body.predicates", &self.predicates, &mut issues);
278        collect_dataset_predicate_issues(&mut self.predicates, &mut issues);
279        collect_page_issues("body.page.limit", self.page.limit, &mut issues);
280        if let Some(raw_cursor) = self.page.cursor.as_deref() {
281            match serde_json::from_str::<BrowserDatasetSearchCursor>(raw_cursor) {
282                Ok(cursor)
283                    if cursor.binding
284                        == self.browser_cursor_binding(datastore_id, protocol_version) => {}
285                Ok(_) => issues.push(ValidationIssue::new(
286                    "body.page.cursor",
287                    ValidationIssueCode::Conflict,
288                    "browser dataset search cursor does not match the datastore, search family, predicates, page limit, or protocol version",
289                )),
290                Err(_) => issues.push(ValidationIssue::new(
291                    "body.page.cursor",
292                    ValidationIssueCode::InvalidFormat,
293                    "browser dataset search cursor is invalid",
294                )),
295            }
296        }
297        if issues.is_empty() {
298            Ok(())
299        } else {
300            Err(ProtocolError::validation(
301                "browser dataset search request is invalid",
302                issues,
303            ))
304        }
305    }
306
307    #[allow(clippy::result_large_err)]
308    pub fn browser_decoded_cursor(
309        &self,
310        datastore_id: crate::PublicUuid,
311        protocol_version: &ProtocolVersion,
312    ) -> Result<Option<BrowserDatasetSearchCursorAnchor>, ProtocolError> {
313        let Some(raw_cursor) = self.page.cursor.as_deref() else {
314            return Ok(None);
315        };
316        let cursor: BrowserDatasetSearchCursor =
317            serde_json::from_str(raw_cursor).map_err(|_| {
318                ProtocolError::validation(
319                    "browser dataset search request is invalid",
320                    vec![ValidationIssue::new(
321                        "body.page.cursor",
322                        ValidationIssueCode::InvalidFormat,
323                        "browser dataset search cursor is invalid",
324                    )],
325                )
326            })?;
327        if cursor.binding != self.browser_cursor_binding(datastore_id, protocol_version) {
328            return Err(ProtocolError::validation(
329                "browser dataset search request is invalid",
330                vec![ValidationIssue::new(
331                    "body.page.cursor",
332                    ValidationIssueCode::Conflict,
333                    "browser dataset search cursor does not match the datastore, search family, predicates, page limit, or protocol version",
334                )],
335            ));
336        }
337        Ok(Some(cursor.after))
338    }
339
340    #[allow(clippy::result_large_err)]
341    pub fn encode_browser_cursor(
342        &self,
343        datastore_id: crate::PublicUuid,
344        protocol_version: &ProtocolVersion,
345        after: BrowserDatasetSearchCursorAnchor,
346    ) -> Result<String, ProtocolError> {
347        serde_json::to_string(&BrowserDatasetSearchCursor {
348            binding: self.browser_cursor_binding(datastore_id, protocol_version),
349            after,
350        })
351        .map_err(|_| {
352            ProtocolError::new(
353                crate::public_error::ProtocolErrorCode::Internal,
354                "failed to encode browser dataset search cursor",
355            )
356        })
357    }
358
359    fn browser_cursor_binding(
360        &self,
361        datastore_id: crate::PublicUuid,
362        protocol_version: &ProtocolVersion,
363    ) -> SearchCursorBinding<BrowserDatasetSearchCursorQuery> {
364        SearchCursorBinding {
365            query: BrowserDatasetSearchCursorQuery {
366                datastore_id,
367                family: "datasets".to_string(),
368                predicates: self.predicates.clone(),
369            },
370            page_limit: self.page.limit,
371            protocol_version: protocol_version.to_string(),
372        }
373    }
374}
375
376/// A browser-safe Dataset search page. Results use the existing Discovery
377/// record shape so rows, counts, paths, credentials, and daemon state remain
378/// outside the Web contract.
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
380pub struct BrowserDatasetSearchResponse {
381    pub protocol_version: ProtocolVersion,
382    pub datastore_id: crate::PublicUuid,
383    pub family: String,
384    pub predicates: PredicateSet<DatasetPredicate>,
385    #[serde(default)]
386    pub datasets: Vec<crate::resource_discovery::BrowserDatasetDiscoveryRecord>,
387    pub returned_count: u16,
388    pub requested_limit: u16,
389    pub ordering: crate::study::BrowserSearchOrdering,
390    pub has_more: bool,
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub next_cursor: Option<String>,
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub empty_state: Option<BrowserDatasetSearchEmptyState>,
395}
396
397#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
398pub struct BrowserDatasetSearchEmptyState {
399    pub code: String,
400    pub message: String,
401}
402
403#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
404pub struct BrowserDatasetSearchCursorQuery {
405    pub datastore_id: crate::PublicUuid,
406    pub family: String,
407    pub predicates: PredicateSet<DatasetPredicate>,
408}
409
410#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
411pub struct BrowserDatasetSearchCursor {
412    pub binding: SearchCursorBinding<BrowserDatasetSearchCursorQuery>,
413    pub after: BrowserDatasetSearchCursorAnchor,
414}
415
416#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
417pub struct BrowserDatasetSearchCursorAnchor {
418    pub study: StudyRef,
419    pub dataset: DatasetRef,
420    pub asset: crate::refs::AssetRef,
421    pub name: String,
422}
423
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
425#[serde(tag = "kind", rename_all = "snake_case")]
426pub enum DatasetPredicate {
427    Text(TextPredicate<DatasetTextField>),
428    Study { study: StudySelector },
429    Domain { domain: DomainSelector },
430    VersionLabel { label: VersionLabel },
431    Tag(TagPredicate),
432}
433
434#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
435#[serde(rename_all = "snake_case")]
436pub enum DatasetTextField {
437    Name,
438    Description,
439    VersionLabel,
440}
441
442#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
443pub struct RegisteredDatasetVariablePayload {
444    pub variable: crate::catalogue::VariableSummary,
445    pub vocabulary: Option<crate::catalogue::VocabularySummary>,
446    pub vocabulary_items: Vec<crate::catalogue::VocabularyItemSummary>,
447    /// True means that values were withheld; the empty vector is not a zero count.
448    #[serde(default = "withheld_by_default")]
449    pub vocabulary_items_withheld: bool,
450    pub link: crate::catalogue::DatasetVariableLinkSummary,
451}
452
453#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
454pub struct DatasetVariableMetadataPayload {
455    pub link: crate::catalogue::DatasetVariableLinkSummary,
456    pub variable: crate::catalogue::VariableSummary,
457    pub value_type: crate::catalogue::ValueTypeSummary,
458    pub vocabulary: Option<crate::catalogue::VocabularySummary>,
459    pub vocabulary_items: Vec<crate::catalogue::VocabularyItemSummary>,
460    /// True means that values were withheld; the empty vector is not a zero count.
461    #[serde(default = "withheld_by_default")]
462    pub vocabulary_items_withheld: bool,
463}
464
465#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
466pub struct DatasetListEntryPayload {
467    pub catalog: SafeAssetCatalog,
468    pub latest_dataset: Option<crate::catalogue::DatasetSummary>,
469}
470
471#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
472pub struct DatasetListResponse {
473    pub session: SessionStatusPayload,
474    pub study: String,
475    pub study_id: StudyRef,
476    pub datasets: Vec<DatasetListEntryPayload>,
477}
478
479#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
480pub struct DatasetSearchResponse {
481    pub session: SessionStatusPayload,
482    pub datasets: Vec<DatasetSummary>,
483    #[serde(default, skip_serializing_if = "Option::is_none")]
484    pub next_cursor: Option<String>,
485}
486
487#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
488pub struct DatasetMetadataPayload {
489    pub catalog: SafeAssetCatalog,
490    pub dataset: crate::catalogue::DatasetSummary,
491    #[serde(default)]
492    pub variables_included: bool,
493    #[serde(default)]
494    pub variable_count: usize,
495    pub variables: Vec<DatasetVariableMetadataPayload>,
496}
497
498#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
499pub struct DatasetMetadataResponse {
500    pub session: SessionStatusPayload,
501    pub study: String,
502    pub metadata: DatasetMetadataPayload,
503}
504
505#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
506pub struct DatasetPreviewColumnPayload {
507    pub name: String,
508    pub value_type: String,
509}
510
511#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
512pub struct DatasetPreviewPayload {
513    pub catalog: AssetCatalog,
514    pub dataset: DatasetRecord,
515    pub columns: Vec<DatasetPreviewColumnPayload>,
516    pub rows: Vec<Vec<Option<String>>>,
517    pub displayed_row_count: usize,
518    pub total_row_count: usize,
519    pub truncated: bool,
520    pub limit: usize,
521}
522
523#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
524pub struct DatasetPreviewResponse {
525    pub session: SessionStatusPayload,
526    pub study: String,
527    pub preview: DatasetPreviewPayload,
528}
529
530#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
531pub struct DatasetDataResponse {
532    pub session: SessionStatusPayload,
533    pub study: String,
534    pub catalog: AssetCatalog,
535    pub dataset: DatasetRecord,
536    pub lineage: crate::transformation::TransformationSummary,
537    pub format: String,
538    pub target_path: String,
539    pub compressed: bool,
540    pub row_count: usize,
541    pub column_count: usize,
542    pub exported_size_bytes: u64,
543    pub warnings: Vec<String>,
544}
545
546#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
547pub struct DatasetExportResponse {
548    pub session: SessionStatusPayload,
549    pub study: String,
550    pub catalog: AssetCatalog,
551    pub dataset: DatasetRecord,
552    pub lineage: crate::transformation::TransformationSummary,
553    pub artifact: DatasetExportArtifactSummary,
554    pub row_count: usize,
555    pub column_count: usize,
556    #[serde(default, skip_serializing_if = "Vec::is_empty")]
557    pub warnings: Vec<String>,
558}
559
560#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
561pub struct DatasetExportArtifactSummary {
562    pub kind: DatasetExportArtifactKind,
563    pub format: DatasetDataFormat,
564    pub compressed: bool,
565    pub exported_size_bytes: u64,
566    #[serde(default)]
567    pub target_request_only: bool,
568}
569
570#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
571#[serde(rename_all = "snake_case")]
572pub enum DatasetExportArtifactKind {
573    Dataset,
574}
575
576fn collect_predicate_set_issues(
577    path: &str,
578    predicates: &PredicateSet<DatasetPredicate>,
579    issues: &mut Vec<ValidationIssue>,
580) {
581    if let Err(message) = predicates.validate() {
582        let code = if predicates.predicates().is_empty() {
583            ValidationIssueCode::TooShort
584        } else {
585            ValidationIssueCode::TooLong
586        };
587        issues.push(ValidationIssue::new(path, code, message));
588    }
589}
590
591fn collect_dataset_predicate_issues(
592    predicates: &mut PredicateSet<DatasetPredicate>,
593    issues: &mut Vec<ValidationIssue>,
594) {
595    for (index, predicate) in predicates.predicates_mut().iter_mut().enumerate() {
596        match predicate {
597            DatasetPredicate::Text(text) => {
598                if let Err(message) = text.validate() {
599                    let code = if text.value.trim().is_empty() {
600                        ValidationIssueCode::TooShort
601                    } else {
602                        ValidationIssueCode::TooLong
603                    };
604                    issues.push(ValidationIssue::new(
605                        format!("body.predicates.predicates[{index}].value"),
606                        code,
607                        message,
608                    ));
609                }
610            }
611            DatasetPredicate::VersionLabel { label } => {
612                label.0 = label.0.trim().to_string();
613                if label.0.is_empty() {
614                    issues.push(ValidationIssue::new(
615                        format!("body.predicates.predicates[{index}].label"),
616                        ValidationIssueCode::TooShort,
617                        "dataset version label must not be empty",
618                    ));
619                } else if label.0.len() > crate::search::MAX_TEXT_VALUE_LENGTH {
620                    issues.push(ValidationIssue::new(
621                        format!("body.predicates.predicates[{index}].label"),
622                        ValidationIssueCode::TooLong,
623                        format!(
624                            "dataset version label must be at most {} characters",
625                            crate::search::MAX_TEXT_VALUE_LENGTH
626                        ),
627                    ));
628                }
629            }
630            DatasetPredicate::Study { .. }
631            | DatasetPredicate::Domain { .. }
632            | DatasetPredicate::Tag(_) => {}
633        }
634    }
635}
636
637fn collect_page_issues(path: &str, limit: u16, issues: &mut Vec<ValidationIssue>) {
638    if limit == 0 || limit > crate::search::MAX_PAGE_LIMIT {
639        issues.push(ValidationIssue::new(
640            path,
641            ValidationIssueCode::OutOfRange,
642            if limit == 0 {
643                "page limit must be greater than zero".to_string()
644            } else {
645                format!(
646                    "page limit must be at most {}",
647                    crate::search::MAX_PAGE_LIMIT
648                )
649            },
650        ));
651    }
652}
653
654fn collect_cursor_issues(
655    request: &SearchDatasetsRequest,
656    protocol_version: &ProtocolVersion,
657    issues: &mut Vec<ValidationIssue>,
658) {
659    if let Some(raw_cursor) = request.page.cursor.as_deref() {
660        let cursor: DatasetSearchCursor = match serde_json::from_str(raw_cursor) {
661            Ok(cursor) => cursor,
662            Err(error) => {
663                issues.push(ValidationIssue::new(
664                    "body.page.cursor",
665                    ValidationIssueCode::InvalidFormat,
666                    format!("dataset search cursor is invalid: {error}"),
667                ));
668                return;
669            }
670        };
671
672        if cursor.binding != request.cursor_binding(protocol_version) {
673            issues.push(ValidationIssue::new(
674                "body.page.cursor",
675                ValidationIssueCode::Conflict,
676                "dataset search cursor does not match the request predicates, page limit, or protocol version",
677            ));
678        }
679    }
680}
681
682fn withheld_by_default() -> bool {
683    true
684}
685
686/// Arbitrary SQL over governed immutable inputs; the retained output is High.
687#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
688#[serde(deny_unknown_fields)]
689pub struct DatasetTransformRequest {
690    pub session: Option<crate::refs::SessionSelector>,
691    pub study: StudySelector,
692    pub dataset: crate::PublicName,
693    pub inputs: Vec<crate::content::QueryInput>,
694    #[serde(default)]
695    pub views: std::collections::BTreeMap<String, String>,
696    pub sql: String,
697    pub description: String,
698    pub version_note: Option<String>,
699    #[serde(default)]
700    pub budgets: ahri_tre_types::DisclosureBudgetOverrides,
701}
702
703/// Only non-content identities and provenance can leave a High transform.
704#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
705pub struct DatasetTransformResponse {
706    pub session: SessionStatusPayload,
707    pub catalog: SafeAssetCatalog,
708    pub lineage: crate::transformation::TransformationSummary,
709}