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];
25
26pub 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 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 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 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
134fn 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(¶meter_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
235fn parameter_name(key: &str) -> &str {
237 key.split_once('.').map_or(key, |(name, _)| name)
238}
239
240fn 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
317pub 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
329struct 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
444const 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}