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