Skip to main content

sofie_http/gate/optional/
listing.rs

1//! List queries such as `?page=2&sort=-total&status=PAID&total.gt=100`.
2
3use std::borrow::Cow;
4
5use serde_json::{Map, Value, json};
6use sofie_core::listing::{
7    CONTROLS, DEFAULT_SIZE, ERRORS, FILTER, HAS_NEXT, ITEMS, PAGE, QUERY, REFUSAL_ARGUMENTS,
8    REFUSAL_KEY, REFUSAL_MESSAGE, SIZE, SORT,
9};
10use sofie_core::show::Fields;
11use sofie_core::{Error, FieldError, Result, Show, Shown};
12use sofie_db::{
13    Aggregate, Condition, FieldNames, Input, Outcome, Page, Paginated, Query, Read, ReadOnly,
14    Reply, ReplyData, Select, Sort, Source, filterable, searched, selects,
15};
16use sofie_values::Operator;
17
18use crate::route_path::same_text;
19use crate::urlencoded::decode;
20use crate::{Area, RequestContext};
21use sofie_db::Resolve;
22
23/// A `Searched` aggregate's list matches the words of `?q=`.
24const SEARCH: &str = "q";
25/// Read by the listing itself, so no field of its query `Q` may take one.
26const RESERVED: [&str; 5] = [PAGE, SIZE, SORT, FILTER, SEARCH];
27/// Each set of filters is its own prepared statement, so a caller cannot make them without end.
28const MAX_FILTERS: usize = 16;
29
30/// A path parameter naming a field filters too, so a child is listed only under its own parent.
31/// `Q` reads the query's other parameters, as [`crate::Query`] does; `()` reads none.
32pub struct Listing<T: Source, Q = ()> {
33    narrowed: Query<T>,
34    page: Page,
35    sort: Option<String>,
36    filters: Vec<Filter>,
37    search: Option<String>,
38    parents: Vec<(String, String)>,
39    prefers_json: bool,
40    parameters: Option<Q>,
41    asked_parameters: Map<String, Value>,
42    refused_parameters: Vec<FieldError>,
43}
44
45struct Filter {
46    key: String,
47    field: String,
48    operator: Operator,
49    value: String,
50}
51
52impl<T: Source, Q> Listing<T, Q> {
53    /// None when a value was refused: the page then lists nothing and names the refusal.
54    pub fn parameters(&self) -> Option<&Q> {
55        self.parameters.as_ref()
56    }
57
58    #[must_use]
59    pub fn where_(mut self, condition: Condition<T::Base>) -> Self {
60        self.narrowed = self.narrowed.where_(condition);
61        self
62    }
63
64    pub fn query(&self) -> Result<Query<T>> {
65        let (query, refused) = self.filtered()?;
66        if refused.is_empty() {
67            return Ok(query);
68        }
69        Err(Error::Invalid(refused))
70    }
71
72    /// A value its field refuses is the asker's to correct, under the filter's key.
73    fn filtered(&self) -> Result<(Query<T>, Vec<FieldError>)> {
74        let (mut query, mut refused) = (self.narrowed.clone(), self.refused_parameters.clone());
75        for filter in &self.filters {
76            match Condition::on_field(&filter.field, filter.operator, &filter.value) {
77                Ok(condition) => query = query.where_(condition),
78                Err(Error::Invalid(errors)) => {
79                    refused.extend(errors.into_iter().map(|error| FieldError {
80                        field: filter.key.clone(),
81                        ..error
82                    }));
83                }
84                Err(error) => return Err(error),
85            }
86        }
87        if let Some(words) = &self.search {
88            let matching = Condition::searching(words).ok_or_else(|| {
89                Error::internal_message("searching a list whose aggregate was checked searched")
90            })?;
91            query = query.where_(matching);
92        }
93        for (field, id) in &self.parents {
94            let parent =
95                Condition::on_field(field, Operator::Equals, id).map_err(|error| match error {
96                    Error::Invalid(_) => Error::NotFound,
97                    other => other,
98                })?;
99            query = query.where_(parent);
100        }
101        if let Some(sort) = &self.sort {
102            let (field, descending) = sort_field(sort);
103            let sort = Sort::by_field(field, descending).ok_or_else(|| {
104                Error::internal_message(format!("sorting by `{field}`, which was checked"))
105            })?;
106            query = query.order_by(sort);
107        }
108        Ok((query, refused))
109    }
110
111    /// A parent beyond the read's reach is not found: an empty list would say it exists. A
112    /// filter in error lists nothing, so no one reads an unfiltered list as a filtered one; a
113    /// JSON caller or route gets the errors as a 422, as [`Self::query`] answers.
114    pub async fn page(self, read: &Read) -> Result<Listed<T>> {
115        #[cfg(feature = "groups")]
116        for (field, id) in &self.parents {
117            if let Ok(parent) = id.parse() {
118                sofie_db::refuse_parent_beyond_reach::<T::Base>(read, field, parent).await?;
119            }
120        }
121        let (query, refused) = self.filtered()?;
122        if self.prefers_json && !refused.is_empty() {
123            return Err(Error::Invalid(refused));
124        }
125        let page = if refused.is_empty() {
126            query.paginate(read, self.page).await?
127        } else {
128            Paginated::new(Vec::new(), self.page, false)
129        };
130        Ok(Listed {
131            page,
132            controls: controls::<T>(),
133            sort: self.sort,
134            filter: self
135                .filters
136                .into_iter()
137                .map(|filter| (filter.key, filter.value))
138                .chain(self.search.map(|words| (SEARCH.to_owned(), words)))
139                .map(|(key, value)| (key, Value::String(value)))
140                .collect(),
141            query: self.asked_parameters,
142            errors: Refused(refused.into_iter().map(RefusedFilter::from).collect()),
143        })
144    }
145}
146
147/// What a filter form offers for a field: an enum's variants, or a picker for an instant; and
148/// a search box for a `Searched` aggregate.
149fn controls<T: Source>() -> Map<String, Value> {
150    let search = searched::<T::Base>().then(|| (SEARCH.to_owned(), json!({ "type": "search" })));
151    T::Base::FIELDS
152        .iter()
153        .filter(|field| selects::<T>(field.name) && !filterable::<T::Base>(field.name).is_empty())
154        .filter_map(|field| {
155            let [column] = field.columns else {
156                return None;
157            };
158            let control = if !column.values.is_empty() {
159                json!({ "values": column.values })
160            } else if column.ty == sofie_core::SqlType::Timestamp {
161                json!({ "type": "datetime-local" })
162            } else {
163                return None;
164            };
165            Some((field.name.to_owned(), control))
166        })
167        .chain(search)
168        .collect()
169}
170
171fn sort_field(sort: &str) -> (&str, bool) {
172    match sort.strip_prefix('-') {
173        Some(field) => (field, true),
174        None => (sort, false),
175    }
176}
177
178impl<A: Area, M, T: Source, Q: Input + FieldNames + Send> Resolve<RequestContext<A, M>>
179    for Listing<T, Q>
180{
181    fn resolve(context: &mut RequestContext<A, M>) -> impl Future<Output = Result<Self>> + Send {
182        let parents = context
183            .matched
184            .parameters
185            .iter()
186            .filter(|(name, _)| name != T::Base::NAME && !filterable::<T::Base>(name).is_empty())
187            .cloned()
188            .collect();
189        let prefers_json = context.matched.prefers_json;
190        std::future::ready(
191            asked::<T, Q>(context.query().unwrap_or_default()).map(|mut listing| {
192                listing.parents = parents;
193                listing.prefers_json = prefers_json;
194                listing
195            }),
196        )
197    }
198}
199
200fn asked<T: Source, Q: Input + FieldNames>(query: &str) -> Result<Listing<T, Q>> {
201    const {
202        assert!(
203            !names_any(Q::FIELD_NAMES, &RESERVED),
204            "a listing's query struct names a field `page`, `size`, `sort`, `filter` or `q`, \
205             which the listing reads itself"
206        );
207        assert!(
208            !names_a_field::<T::Base>(Q::FIELD_NAMES),
209            "a listing's query struct names a field `id` or a field of the listed aggregate, \
210             which would hide the list's filter of that name; rename the query struct's field"
211        );
212    }
213    let (mut number, mut size, mut sort, mut filters) = (1, DEFAULT_SIZE, None, Vec::new());
214    let mut search = None;
215    let mut asked_parameters = Map::new();
216    for pair in query.split('&').filter(|pair| !pair.is_empty()) {
217        let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
218        let (key, value) = (decode(key.as_bytes())?, decode(value.as_bytes())?);
219        match key.as_str() {
220            PAGE => number = whole_number(&key, &value)?,
221            SIZE => size = whole_number(&key, &value)?,
222            SORT if sortable::<T>(sort_field(&value).0) => {
223                sort = Some(value);
224            }
225            SORT => {
226                return Err(Error::BadRequest(format!(
227                    "a list cannot be sorted by `{value}`"
228                )));
229            }
230            _ if value.is_empty() && key.strip_suffix(".empty").is_none() => {}
231            SEARCH if searched::<T::Base>() => search = Some(value),
232            _ if Q::FIELD_NAMES.contains(&parameter_name(&key)) => {
233                asked_parameters.insert(key, Value::String(value));
234            }
235            _ if crate::query::is_tracking_key(&key) && !selects::<T>(parameter_name(&key)) => {}
236            _ => add_filter(&mut filters, filter_of::<T>(key, value)?)?,
237        }
238    }
239    filters.sort_by(|first, second| first.key.cmp(&second.key));
240    let (parameters, refused_parameters) = read_parameters(&asked_parameters)?;
241    Ok(Listing {
242        narrowed: T::query(),
243        page: Page::new(number, size)?,
244        sort,
245        filters,
246        search,
247        parents: Vec::new(),
248        prefers_json: false,
249        parameters,
250        asked_parameters,
251        refused_parameters,
252    })
253}
254
255/// `budget.amount` is a part of the parameter `budget`, as `Query<Q>` reads it.
256fn parameter_name(key: &str) -> &str {
257    key.split_once('.').map_or(key, |(name, _)| name)
258}
259
260/// A refused value is the asker's to correct, as a refused filter is. The asked parameters stay
261/// flat, as links and the filter form write them back.
262fn read_parameters<Q: Input>(asked: &Map<String, Value>) -> Result<(Option<Q>, Vec<FieldError>)> {
263    let mut nested = Map::new();
264    for (key, value) in asked {
265        let text = value.as_str().unwrap_or_default().to_owned();
266        crate::dotted_name::insert(&mut nested, key, text)?;
267    }
268    match Q::from_json(Value::Object(nested)) {
269        Ok(parameters) => Ok((Some(parameters), Vec::new())),
270        Err(Error::Invalid(refused)) => Ok((None, refused)),
271        Err(error) => Err(error),
272    }
273}
274
275const fn names_any(names: &[&str], reserved: &[&str]) -> bool {
276    let mut index = 0;
277    while index < names.len() {
278        let mut other = 0;
279        while other < reserved.len() {
280            if same_text(names[index], reserved[other]) {
281                return true;
282            }
283            other += 1;
284        }
285        index += 1;
286    }
287    false
288}
289
290const fn names_a_field<A: Aggregate>(names: &[&str]) -> bool {
291    let mut index = 0;
292    while index < A::FIELDS.len() {
293        if names_any(names, &[A::FIELDS[index].name]) {
294            return true;
295        }
296        index += 1;
297    }
298    names_any(names, &["id"])
299}
300
301fn whole_number(key: &str, value: &str) -> Result<u64> {
302    value
303        .parse()
304        .map_err(|_| Error::BadRequest(format!("`{key}` is a whole number, not `{value}`")))
305}
306
307fn sortable<T: Source>(field: &str) -> bool {
308    selects::<T>(field) && Sort::<T::Base>::by_field(field, false).is_some()
309}
310
311fn filter_of<T: Source>(key: String, value: String) -> Result<Filter> {
312    let (field, operator) = match key.split_once('.') {
313        Some((field, operator)) => (field, operator_named(operator)?),
314        None => (key.as_str(), Operator::Equals),
315    };
316    if !selects::<T>(field) || !filterable::<T::Base>(field).contains(&operator) {
317        return Err(Error::BadRequest(format!(
318            "a list cannot be filtered by `{key}`"
319        )));
320    }
321    Ok(Filter {
322        field: field.to_owned(),
323        key,
324        operator,
325        value,
326    })
327}
328
329fn add_filter(filters: &mut Vec<Filter>, filter: Filter) -> Result<()> {
330    if filters
331        .iter()
332        .any(|held| held.field == filter.field && held.operator == filter.operator)
333    {
334        return Err(Error::BadRequest(format!(
335            "a list is filtered by `{}` once at most",
336            filter.key
337        )));
338    }
339    if filters.len() == MAX_FILTERS {
340        return Err(Error::BadRequest(format!(
341            "a list takes at most {MAX_FILTERS} filters"
342        )));
343    }
344    filters.push(filter);
345    Ok(())
346}
347
348fn operator_named(name: &str) -> Result<Operator> {
349    Operator::named(name).ok_or_else(|| {
350        Error::BadRequest(format!(
351            "`{name}` is no filter; filters are ne, gt, lt, gte, lte, between, contains and empty"
352        ))
353    })
354}
355
356/// Answered as `{items, page, size, has_next, sort, filter, query, controls, errors}`, what a `Table`
357/// links from; `errors` holds each filter whose value its field refuses, with its message's key
358/// and arguments, so a client area shows it in the viewer's language.
359pub struct Listed<T> {
360    page: Paginated<T>,
361    controls: Map<String, Value>,
362    sort: Option<String>,
363    filter: Map<String, Value>,
364    query: Map<String, Value>,
365    errors: Refused,
366}
367
368/// Shown with each message's key and arguments, so a page writes it in the viewer's language.
369struct Refused(Vec<RefusedFilter>);
370
371struct RefusedFilter {
372    key: String,
373    message_key: Cow<'static, str>,
374    arguments: RefusalArguments,
375    message: String,
376}
377
378struct RefusalArguments(Vec<(Cow<'static, str>, String)>);
379
380impl From<FieldError> for RefusedFilter {
381    fn from(error: FieldError) -> Self {
382        Self {
383            key: error.field,
384            message_key: error.key,
385            arguments: RefusalArguments(error.arguments),
386            message: error.message,
387        }
388    }
389}
390
391impl Refused {
392    fn records(&self) -> Map<String, Value> {
393        self.0
394            .iter()
395            .map(|refused| {
396                let arguments: Map<String, Value> = refused
397                    .arguments
398                    .0
399                    .iter()
400                    .map(|(name, value)| (name.to_string(), Value::from(value.as_str())))
401                    .collect();
402                let record = json!({
403                    REFUSAL_KEY: refused.message_key,
404                    REFUSAL_ARGUMENTS: arguments,
405                    REFUSAL_MESSAGE: refused.message,
406                });
407                (refused.key.clone(), record)
408            })
409            .collect()
410    }
411}
412
413impl Fields for Refused {
414    fn name(&self, index: usize) -> Option<&str> {
415        self.0.get(index).map(|refused| refused.key.as_str())
416    }
417
418    fn get(&self, name: &str) -> Option<Shown<'_>> {
419        let refused = self.0.iter().find(|refused| refused.key == name)?;
420        Some(Shown::Record(refused))
421    }
422}
423
424const REFUSAL_NAMES: [&str; 3] = [REFUSAL_KEY, REFUSAL_ARGUMENTS, REFUSAL_MESSAGE];
425
426impl Fields for RefusedFilter {
427    fn name(&self, index: usize) -> Option<&str> {
428        REFUSAL_NAMES.get(index).copied()
429    }
430
431    fn get(&self, name: &str) -> Option<Shown<'_>> {
432        match name {
433            REFUSAL_KEY => Some(Shown::Text(&self.message_key)),
434            REFUSAL_ARGUMENTS => Some(Shown::Record(&self.arguments)),
435            REFUSAL_MESSAGE => Some(Shown::Text(&self.message)),
436            _ => None,
437        }
438    }
439}
440
441impl Fields for RefusalArguments {
442    fn name(&self, index: usize) -> Option<&str> {
443        self.0.get(index).map(|(name, _)| name.as_ref())
444    }
445
446    fn get(&self, name: &str) -> Option<Shown<'_>> {
447        let (_, value) = self.0.iter().find(|(argument, _)| *argument == name)?;
448        Some(Shown::Text(value))
449    }
450}
451
452impl<T: ReplyData> Listed<T> {
453    #[must_use]
454    pub fn items(&self) -> &[T] {
455        self.page.items()
456    }
457
458    pub fn to_json(&self) -> Result<Value> {
459        self.json()
460            .map_err(|error| Error::internal("serializing a list's item", error))
461    }
462
463    fn json(&self) -> serde_json::Result<Value> {
464        let items = self
465            .items()
466            .iter()
467            .map(ReplyData::to_json)
468            .collect::<serde_json::Result<Vec<Value>>>()?;
469        Ok(json!({
470            ITEMS: items,
471            PAGE: self.page.page().number(),
472            SIZE: self.page.page().size(),
473            HAS_NEXT: self.page.has_next(),
474            SORT: self.sort,
475            FILTER: self.filter,
476            QUERY: self.query,
477            CONTROLS: self.controls,
478            ERRORS: self.errors.records(),
479        }))
480    }
481}
482
483/// Ordered as [`Listed::json`]'s object holds its keys.
484const LISTED_NAMES: [&str; 9] = [
485    CONTROLS, ERRORS, FILTER, HAS_NEXT, ITEMS, PAGE, QUERY, SIZE, SORT,
486];
487
488impl<T: Show> Fields for Listed<T> {
489    fn name(&self, index: usize) -> Option<&str> {
490        LISTED_NAMES.get(index).copied()
491    }
492
493    fn get(&self, name: &str) -> Option<Shown<'_>> {
494        match name {
495            CONTROLS => Some(Shown::Record(&self.controls)),
496            ERRORS => Some(Shown::Record(&self.errors)),
497            FILTER => Some(Shown::Record(&self.filter)),
498            QUERY => Some(Shown::Record(&self.query)),
499            SORT => Some(self.sort.show()),
500            _ => self.page.get(name),
501        }
502    }
503}
504
505impl<T: Show> Show for Listed<T> {
506    fn show(&self) -> Shown<'_> {
507        Shown::Record(self)
508    }
509}
510
511impl<T: Show + ReplyData> ReplyData for Listed<T> {
512    fn write_json(&self, out: &mut Vec<u8>) -> serde_json::Result<()> {
513        serde_json::to_writer(out, &self.json()?)
514    }
515
516    fn to_json(&self) -> serde_json::Result<Value> {
517        self.json()
518    }
519
520    fn shown(&self) -> Option<Shown<'_>> {
521        Some(self.show())
522    }
523}
524
525impl<T: Show + ReplyData + 'static> Outcome for Listed<T> {
526    fn reply(self) -> Result<Reply> {
527        Ok(Reply::Data(Box::new(self)))
528    }
529}
530
531impl<T: Show + ReplyData + 'static> ReadOnly for Listed<T> {}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536
537    #[test]
538    fn an_order_is_a_field_name_descending_with_a_minus() {
539        assert_eq!(sort_field("-total"), ("total", true));
540        assert_eq!(sort_field("total"), ("total", false));
541    }
542
543    #[test]
544    fn an_operator_is_named_after_the_field() {
545        assert_eq!(operator_named("gt").unwrap(), Operator::GreaterThan);
546        assert_eq!(operator_named("gte").unwrap(), Operator::AtLeast);
547        assert_eq!(operator_named("lte").unwrap(), Operator::AtMost);
548        assert!(matches!(operator_named("like"), Err(Error::BadRequest(_))));
549    }
550
551    const TEXT: &[sofie_core::ColumnSpec] =
552        &[sofie_core::ColumnSpec::new("", sofie_core::SqlType::Text)];
553
554    struct Note(sofie_db::Meta);
555
556    impl Aggregate for Note {
557        const NAME: &'static str = "note";
558        const MODULE: &'static str = "shop";
559        const FIELDS: &'static [sofie_db::Field] = &[
560            sofie_db::Field::new("title", TEXT, true),
561            sofie_db::Field::new("secret", TEXT, false),
562        ];
563
564        fn meta(&self) -> &sofie_db::Meta {
565            &self.0
566        }
567
568        fn meta_mut(&mut self) -> &mut sofie_db::Meta {
569            &mut self.0
570        }
571
572        fn encode(&self, _out: &mut Vec<sofie_core::SqlValue>) {}
573
574        fn decode(meta: sofie_db::Meta, _row: &mut dyn sofie_core::ColumnReader) -> Result<Self> {
575            Ok(Self(meta))
576        }
577    }
578
579    #[test]
580    fn an_emptiness_filter_needs_no_value_and_a_blank_filter_is_ignored() {
581        let listing = asked::<Note, ()>("title.empty&title=").unwrap();
582
583        let filters: Vec<_> = listing
584            .filters
585            .iter()
586            .map(|filter| (filter.key.as_str(), filter.operator))
587            .collect();
588        assert_eq!(filters, [("title.empty", Operator::IsEmpty)]);
589    }
590
591    struct Title;
592
593    impl Source for Title {
594        type Base = Note;
595
596        fn columns() -> Vec<sofie_db::Selected> {
597            sofie_db::read_columns::<Note>(&["title"])
598        }
599
600        fn column_types() -> Vec<Option<sofie_core::SqlType>> {
601            vec![Some(sofie_core::SqlType::Text)]
602        }
603
604        fn decode(_row: &mut dyn sofie_core::ColumnReader) -> Result<Self> {
605            Ok(Self)
606        }
607    }
608
609    const STATUS: &[sofie_core::ColumnSpec] =
610        &[sofie_core::ColumnSpec::new("", sofie_core::SqlType::Text)
611            .with_values(&["OPEN", "PAID"])];
612    const INSTANT: &[sofie_core::ColumnSpec] = &[sofie_core::ColumnSpec::new(
613        "",
614        sofie_core::SqlType::Timestamp,
615    )];
616
617    struct Booking(sofie_db::Meta);
618
619    impl Aggregate for Booking {
620        const NAME: &'static str = "booking";
621        const MODULE: &'static str = "shop";
622        const FIELDS: &'static [sofie_db::Field] = &[
623            sofie_db::Field::new("status", STATUS, false),
624            sofie_db::Field::new("starts_at", INSTANT, false),
625            sofie_db::Field::new("note", TEXT, false),
626        ];
627
628        fn meta(&self) -> &sofie_db::Meta {
629            &self.0
630        }
631
632        fn meta_mut(&mut self) -> &mut sofie_db::Meta {
633            &mut self.0
634        }
635
636        fn encode(&self, _out: &mut Vec<sofie_core::SqlValue>) {}
637
638        fn decode(meta: sofie_db::Meta, _row: &mut dyn sofie_core::ColumnReader) -> Result<Self> {
639            Ok(Self(meta))
640        }
641    }
642
643    #[test]
644    fn a_list_names_an_enum_s_variants_and_an_instant_for_its_filter_form() {
645        assert_eq!(
646            Value::Object(controls::<Booking>()),
647            json!({
648                "status": {"values": ["OPEN", "PAID"]},
649                "starts_at": {"type": "datetime-local"},
650            })
651        );
652    }
653
654    #[test]
655    fn a_list_takes_each_filter_once_in_one_order_and_a_bounded_number_of_them() {
656        let one_way = asked::<Booking, ()>("status=OPEN&note.contains=a").unwrap();
657        let other_way = asked::<Booking, ()>("note.contains=b&status=PAID").unwrap();
658        let keys = |listing: &Listing<Booking>| -> Vec<String> {
659            listing
660                .filters
661                .iter()
662                .map(|filter| filter.key.clone())
663                .collect()
664        };
665        assert_eq!(keys(&one_way), keys(&other_way));
666
667        let repeated = asked::<Booking, ()>("status.ne=OPEN&status.ne=PAID");
668        assert!(matches!(repeated, Err(Error::BadRequest(_))));
669        assert!(asked::<Booking, ()>("status.ne=OPEN&status=PAID").is_ok());
670
671        let mut filters = Vec::new();
672        let added: Vec<bool> = (0..=MAX_FILTERS)
673            .map(|index| {
674                let filter = Filter {
675                    key: format!("field_{index}"),
676                    field: format!("field_{index}"),
677                    operator: Operator::Equals,
678                    value: "a".to_owned(),
679                };
680                add_filter(&mut filters, filter).is_ok()
681            })
682            .collect();
683        assert_eq!(added.iter().filter(|added| **added).count(), MAX_FILTERS);
684        assert!(!added[MAX_FILTERS]);
685    }
686
687    #[test]
688    fn a_list_filters_and_sorts_only_by_what_its_read_model_selects() {
689        assert!(asked::<Title, ()>("title.contains=a&sort=-title").is_ok());
690        assert!(asked::<Note, ()>("secret.contains=a&sort=secret").is_ok());
691        for query in [
692            "secret.contains=a",
693            "secret=a",
694            "sort=secret",
695            "sort=-secret",
696        ] {
697            let refused = asked::<Title, ()>(query);
698            assert!(matches!(refused, Err(Error::BadRequest(_))), "{query}");
699        }
700    }
701
702    struct Booked {
703        at: sofie_core::Instant,
704    }
705
706    impl serde::Serialize for Booked {
707        fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
708            serializer.collect_map([("at", self.at)])
709        }
710    }
711
712    impl sofie_core::show::Fields for Booked {
713        fn name(&self, index: usize) -> Option<&str> {
714            ["at"].get(index).copied()
715        }
716
717        fn get(&self, name: &str) -> Option<sofie_core::Shown<'_>> {
718            (name == "at").then(|| sofie_core::Show::show(&self.at))
719        }
720    }
721
722    impl sofie_core::Show for Booked {
723        fn show(&self) -> sofie_core::Shown<'_> {
724            sofie_core::Shown::Record(self)
725        }
726    }
727
728    fn first_at(shown: sofie_core::Shown<'_>) -> Option<sofie_core::Shown<'_>> {
729        let sofie_core::Shown::Record(page) = shown else {
730            return None;
731        };
732        let sofie_core::Shown::List(items) = page.get("items")? else {
733            return None;
734        };
735        let sofie_core::Shown::Record(item) = items.item(0)? else {
736            return None;
737        };
738        item.get("at")
739    }
740
741    #[test]
742    fn a_listed_page_replies_its_items_typed_and_its_json_unchanged() {
743        let at = sofie_core::Instant::parse("2026-10-11T22:30:00Z").unwrap();
744        let listed = Listed {
745            page: Paginated::new(vec![Booked { at }], Page::new(1, 20).unwrap(), false),
746            controls: controls::<Booking>(),
747            sort: Some("-at".to_owned()),
748            filter: Map::from_iter([("note".to_owned(), Value::from("a"))]),
749            query: Map::new(),
750            errors: Refused(Vec::new()),
751        };
752
753        let reply = listed.reply().unwrap();
754
755        let first_at = reply.shown().and_then(first_at);
756        assert!(matches!(first_at, Some(sofie_core::Shown::Instant(shown)) if shown == at));
757        #[cfg(feature = "clients")]
758        assert_eq!(
759            sofie_engine::instant_fields(sofie_core::Show::show(&reply), &reply.to_json().unwrap()),
760            json!({"items": {"at": true}})
761        );
762        assert_eq!(
763            reply.to_json().unwrap(),
764            json!({
765                "items": [{"at": "2026-10-11T22:30:00Z"}],
766                "page": 1,
767                "size": 20,
768                "has_next": false,
769                "sort": "-at",
770                "filter": {"note": "a"},
771                "query": {},
772                "controls": controls::<Booking>(),
773                "errors": {},
774            })
775        );
776    }
777
778    #[tokio::test]
779    async fn a_filter_its_field_refuses_lists_nothing_and_is_named_beside_it() {
780        let db = sofie_db::Db::new(sofie_testing::FakeDriver::default());
781        let read = Read::within(&db, sofie_db::Reach::All);
782        let listing = asked::<Booking, ()>("starts_at.gt=yesterday&status=OPEN").unwrap();
783
784        let listed = listing.page(&read).await.unwrap();
785
786        assert!(listed.page.items().is_empty());
787        assert_eq!(listed.filter["starts_at.gt"], "yesterday");
788        let errors = listed.errors.records();
789        let error = errors["starts_at.gt"][REFUSAL_MESSAGE]
790            .as_str()
791            .unwrap_or_default();
792        assert!(error.contains("with an offset"), "{errors:?}");
793        assert!(matches!(
794            asked::<Booking, ()>("starts_at=yesterday").unwrap().query(),
795            Err(Error::Invalid(_))
796        ));
797    }
798
799    #[tokio::test]
800    async fn a_refused_filter_of_any_type_is_named_beside_it_on_a_page_and_a_422_for_json() {
801        let db = sofie_db::Db::new(sofie_testing::FakeDriver::default());
802        let read = Read::within(&db, sofie_db::Reach::All);
803        let query = "starts_at.gt=yesterday&status=CLOSED&id=7";
804
805        let listed = asked::<Booking, ()>(query)
806            .unwrap()
807            .page(&read)
808            .await
809            .unwrap();
810        let mut json = asked::<Booking, ()>(query).unwrap();
811        json.prefers_json = true;
812        let refused = json.page(&read).await.err();
813
814        let errors = listed.errors.records();
815        let named: Vec<&String> = errors.keys().collect();
816        assert_eq!(named, ["id", "starts_at.gt", "status"]);
817        let Some(Error::Invalid(errors)) = refused else {
818            panic!("{refused:?}");
819        };
820        let fields: Vec<&str> = errors.iter().map(|error| error.field.as_str()).collect();
821        assert_eq!(fields, ["id", "starts_at.gt", "status"]);
822    }
823
824    #[tokio::test]
825    async fn a_refused_filter_is_shown_with_its_message_s_key_and_arguments() {
826        let db = sofie_db::Db::new(sofie_testing::FakeDriver::default());
827        let read = Read::within(&db, sofie_db::Reach::All);
828
829        let listing = asked::<Booking, ()>("status=CLOSED").unwrap();
830        let listed = listing.page(&read).await.unwrap();
831
832        let Some(Shown::Record(refused)) = listed.errors.get("status") else {
833            panic!("{:?}", listed.errors.records());
834        };
835        assert!(matches!(
836            refused.get(REFUSAL_KEY),
837            Some(Shown::Text("validation.one_of"))
838        ));
839        let Some(Shown::Record(arguments)) = refused.get(REFUSAL_ARGUMENTS) else {
840            panic!("the refusal shows no arguments");
841        };
842        assert!(matches!(
843            arguments.get("values"),
844            Some(Shown::Text("OPEN, PAID"))
845        ));
846        assert_eq!(
847            Value::Object(listed.errors.records()),
848            json!({"status": {
849                "key": "validation.one_of",
850                "arguments": {"values": "OPEN, PAID"},
851                "message": "must be one of: OPEN, PAID",
852            }})
853        );
854    }
855
856    struct Around {
857        near: Option<String>,
858        radius: Option<u32>,
859    }
860
861    impl Input for Around {
862        fn from_json(value: Value) -> Result<Self> {
863            let mut fields = sofie_db::InputFields::new(value)?;
864            let near = fields.optional("near");
865            let radius = fields.optional::<u32>("radius");
866            let radius = fields.checked(radius, |radius| {
867                radius
868                    .is_some_and(|radius| radius > 50)
869                    .then(|| FieldError::new("radius", "validation.max", "must be at most 50"))
870            });
871            fields.finish()?;
872            Ok(Self {
873                near: near?,
874                radius: radius?,
875            })
876        }
877    }
878
879    impl FieldNames for Around {
880        const FIELD_NAMES: &'static [&'static str] = &["near", "radius"];
881    }
882
883    #[tokio::test]
884    async fn a_list_reads_its_query_parameters_typed_beside_its_filters_and_narrows_by_them() {
885        let driver = sofie_testing::FakeDriver::default();
886        let db = sofie_db::Db::new(driver.clone());
887        let read = Read::within(&db, sofie_db::Reach::All);
888        let listing = asked::<Booking, Around>("near=Ulm&radius=5&status=OPEN&page=2").unwrap();
889        let around = listing.parameters().unwrap();
890        assert_eq!(around.radius, Some(5));
891        let near = Condition::on_field("note", Operator::Equals, around.near.as_deref().unwrap());
892
893        let listed = listing.where_(near.unwrap()).page(&read).await.unwrap();
894
895        assert_eq!(
896            Value::Object(listed.query),
897            json!({"near": "Ulm", "radius": "5"})
898        );
899        assert_eq!(Value::Object(listed.filter), json!({"status": "OPEN"}));
900        let (_, arguments) = driver.queries().pop().unwrap();
901        assert!(
902            arguments.contains(&sofie_core::SqlValue::Text("Ulm".to_owned())),
903            "{arguments:?}"
904        );
905    }
906
907    #[test]
908    fn a_tracking_key_appended_to_a_shared_list_link_is_no_filter() {
909        let listing =
910            asked::<Booking, Around>("near=Ulm&utm_source=ad&fbclid=a&status=OPEN").unwrap();
911
912        assert_eq!(
913            listing.asked_parameters,
914            json!({"near": "Ulm"}).as_object().cloned().unwrap()
915        );
916        assert_eq!(listing.filters.len(), 1);
917        assert!(matches!(
918            asked::<Booking, Around>("utm=x"),
919            Err(Error::BadRequest(_))
920        ));
921    }
922
923    struct Visit(sofie_db::Meta);
924
925    impl Aggregate for Visit {
926        const NAME: &'static str = "visit";
927        const MODULE: &'static str = "shop";
928        const FIELDS: &'static [sofie_db::Field] =
929            &[sofie_db::Field::new("utm_source", TEXT, true)];
930
931        fn meta(&self) -> &sofie_db::Meta {
932            &self.0
933        }
934
935        fn meta_mut(&mut self) -> &mut sofie_db::Meta {
936            &mut self.0
937        }
938
939        fn encode(&self, _out: &mut Vec<sofie_core::SqlValue>) {}
940
941        fn decode(meta: sofie_db::Meta, _row: &mut dyn sofie_core::ColumnReader) -> Result<Self> {
942            Ok(Self(meta))
943        }
944    }
945
946    #[test]
947    fn a_tracking_key_naming_a_listed_field_stays_a_filter() {
948        let listing = asked::<Visit, ()>("utm_source=ad&utm_medium=mail&gclid=a").unwrap();
949
950        let filters: Vec<_> = listing
951            .filters
952            .iter()
953            .map(|filter| (filter.field.as_str(), filter.value.as_str()))
954            .collect();
955        assert_eq!(filters, [("utm_source", "ad")]);
956    }
957
958    #[tokio::test]
959    async fn a_refused_query_parameter_is_named_beside_the_list_and_a_422_for_json() {
960        let db = sofie_db::Db::new(sofie_testing::FakeDriver::default());
961        let read = Read::within(&db, sofie_db::Reach::All);
962
963        let listing = asked::<Booking, Around>("radius=80").unwrap();
964        assert!(listing.parameters().is_none());
965        let listed = listing.page(&read).await.unwrap();
966        let mut json = asked::<Booking, Around>("radius=80").unwrap();
967        json.prefers_json = true;
968        let refused = json.page(&read).await.err();
969
970        assert!(listed.page.items().is_empty());
971        assert_eq!(
972            Value::Object(listed.errors.records()),
973            json!({"radius": {
974                "key": "validation.max",
975                "arguments": {},
976                "message": "must be at most 50",
977            }})
978        );
979        assert_eq!(listed.query["radius"], "80");
980        assert!(matches!(refused, Some(Error::Invalid(errors)) if errors[0].field == "radius"));
981        assert!(matches!(
982            asked::<Booking, Around>("radius=80").unwrap().query(),
983            Err(Error::Invalid(_))
984        ));
985        assert!(matches!(
986            asked::<Booking, Around>("nearby=Ulm"),
987            Err(Error::BadRequest(_))
988        ));
989    }
990
991    struct Budgeted {
992        budget: Budget,
993    }
994
995    struct Budget {
996        amount: String,
997        currency: String,
998    }
999
1000    impl Input for Budgeted {
1001        fn from_json(value: Value) -> Result<Self> {
1002            let mut fields = sofie_db::InputFields::new(value)?;
1003            let budget = fields.take::<Value>("budget");
1004            fields.finish()?;
1005            let mut budget = sofie_db::InputFields::new(budget?)?;
1006            let (amount, currency) = (budget.take("amount"), budget.take("currency"));
1007            budget.finish()?;
1008            Ok(Self {
1009                budget: Budget {
1010                    amount: amount?,
1011                    currency: currency?,
1012                },
1013            })
1014        }
1015    }
1016
1017    impl FieldNames for Budgeted {
1018        const FIELD_NAMES: &'static [&'static str] = &["budget"];
1019    }
1020
1021    #[test]
1022    fn a_nested_query_parameter_is_read_from_its_dotted_keys() {
1023        let listing =
1024            asked::<Booking, Budgeted>("budget.amount=5&budget.currency=EUR&status=OPEN").unwrap();
1025
1026        let budgeted = listing.parameters().unwrap();
1027        assert_eq!(
1028            (
1029                budgeted.budget.amount.as_str(),
1030                budgeted.budget.currency.as_str()
1031            ),
1032            ("5", "EUR")
1033        );
1034        assert_eq!(
1035            Value::Object(listing.asked_parameters.clone()),
1036            json!({"budget.amount": "5", "budget.currency": "EUR"})
1037        );
1038    }
1039
1040    #[test]
1041    fn a_query_parameter_may_not_take_a_key_the_list_reads() {
1042        assert!(names_any(&["near", "page"], &RESERVED));
1043        assert!(names_any(&["filter"], &RESERVED));
1044        assert!(!names_any(&["near", "pages", "query"], &RESERVED));
1045    }
1046
1047    #[test]
1048    fn a_query_parameter_may_not_take_the_name_of_a_field_the_list_filters_by() {
1049        assert!(names_a_field::<Booking>(&["near", "status"]));
1050        assert!(names_a_field::<Booking>(&["id"]));
1051        assert!(!names_a_field::<Booking>(&["near", "radius", "notes"]));
1052    }
1053
1054    #[tokio::test]
1055    async fn a_list_under_a_parent_its_field_refuses_is_not_found() {
1056        let db = sofie_db::Db::new(sofie_testing::FakeDriver::default());
1057        let mut listing = asked::<Booking, ()>("").unwrap();
1058        listing.parents = vec![("status".to_owned(), "CLOSED".to_owned())];
1059
1060        let listed = listing.page(&Read::within(&db, sofie_db::Reach::All)).await;
1061
1062        assert!(matches!(listed, Err(Error::NotFound)), "{:?}", listed.err());
1063    }
1064
1065    #[test]
1066    fn a_listed_page_names_its_fields_as_its_json_holds_them() {
1067        let listed = Listed {
1068            page: Paginated::new(Vec::<Booked>::new(), Page::new(1, 20).unwrap(), false),
1069            controls: Map::new(),
1070            sort: None,
1071            filter: Map::new(),
1072            query: Map::new(),
1073            errors: Refused(Vec::new()),
1074        };
1075
1076        let names: Vec<&str> = (0..).map_while(|index| listed.name(index)).collect();
1077        let json = listed.to_json().unwrap();
1078        let keys: Vec<&str> = json
1079            .as_object()
1080            .unwrap()
1081            .keys()
1082            .map(String::as_str)
1083            .collect();
1084
1085        assert_eq!(names, keys);
1086    }
1087
1088    #[cfg(feature = "groups")]
1089    struct Folder(sofie_db::Meta);
1090
1091    #[cfg(feature = "groups")]
1092    impl Aggregate for Folder {
1093        const NAME: &'static str = "folder";
1094        const MODULE: &'static str = "shop";
1095        const FIELDS: &'static [sofie_db::Field] = &[sofie_db::Field::new(
1096            "account",
1097            <sofie_core::Uuid as sofie_core::Cast>::COLUMNS,
1098            false,
1099        )];
1100        const OWNER: Option<&'static str> = Some("account");
1101
1102        fn meta(&self) -> &sofie_db::Meta {
1103            &self.0
1104        }
1105
1106        fn meta_mut(&mut self) -> &mut sofie_db::Meta {
1107            &mut self.0
1108        }
1109
1110        fn encode(&self, _out: &mut Vec<sofie_core::SqlValue>) {}
1111
1112        fn decode(meta: sofie_db::Meta, _row: &mut dyn sofie_core::ColumnReader) -> Result<Self> {
1113            Ok(Self(meta))
1114        }
1115    }
1116
1117    #[cfg(feature = "groups")]
1118    struct Filed(sofie_db::Meta);
1119
1120    #[cfg(feature = "groups")]
1121    impl Aggregate for Filed {
1122        const NAME: &'static str = "filed";
1123        const MODULE: &'static str = "shop";
1124        const FIELDS: &'static [sofie_db::Field] = &[sofie_db::Field::new(
1125            "folder",
1126            <sofie_core::Uuid as sofie_core::Cast>::COLUMNS,
1127            false,
1128        )];
1129        const OWNER: Option<&'static str> = Some("folder");
1130
1131        fn meta(&self) -> &sofie_db::Meta {
1132            &self.0
1133        }
1134
1135        fn meta_mut(&mut self) -> &mut sofie_db::Meta {
1136            &mut self.0
1137        }
1138
1139        fn encode(&self, _out: &mut Vec<sofie_core::SqlValue>) {}
1140
1141        fn decode(meta: sofie_db::Meta, _row: &mut dyn sofie_core::ColumnReader) -> Result<Self> {
1142            Ok(Self(meta))
1143        }
1144
1145        fn owned_by(owner: sofie_core::Uuid) -> Option<Condition<Self>> {
1146            sofie_db::owned_through::<Self, Folder>(sofie_db::Column::new("folder"), owner)
1147        }
1148
1149        fn children(&self) -> Vec<&dyn sofie_db::ChildRows> {
1150            Vec::new()
1151        }
1152
1153        fn children_mut(&mut self) -> Vec<&mut dyn sofie_db::ChildRows> {
1154            Vec::new()
1155        }
1156    }
1157
1158    #[cfg(feature = "groups")]
1159    #[tokio::test]
1160    async fn a_list_under_a_parent_beyond_reach_is_not_found() {
1161        let db = sofie_db::Db::new(sofie_testing::FakeDriver::default());
1162        let owner = sofie_core::Uuid::now_v7().unwrap();
1163        let read = Read::within(&db, sofie_db::Reach::OwnedBy(owner));
1164        let mut listing = asked::<Filed, ()>("").unwrap();
1165        let folder = sofie_core::Uuid::now_v7().unwrap().to_string();
1166        listing.parents = vec![("folder".to_owned(), folder)];
1167
1168        let listed = listing.page(&read).await;
1169
1170        assert!(matches!(listed, Err(Error::NotFound)), "{:?}", listed.err());
1171    }
1172}