1use 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
23const SEARCH: &str = "q";
25const RESERVED: [&str; 5] = [PAGE, SIZE, SORT, FILTER, SEARCH];
27const MAX_FILTERS: usize = 16;
29
30pub 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 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 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 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
147fn 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(¶meter_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
255fn parameter_name(key: &str) -> &str {
257 key.split_once('.').map_or(key, |(name, _)| name)
258}
259
260fn 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
356pub 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
368struct 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
483const 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¬e.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}