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, 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 RESERVED: [&str; 4] = [PAGE, SIZE, SORT, FILTER];
25const MAX_FILTERS: usize = 16;
27
28pub 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 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 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 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
136fn 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(¶meter_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
238fn parameter_name(key: &str) -> &str {
240 key.split_once('.').map_or(key, |(name, _)| name)
241}
242
243fn 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
339pub 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
351struct 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
466const 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¬e.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}