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