Skip to main content

sofie_db/page/listing/
mod.rs

1//! A request cannot reach past the aggregate's fields.
2
3#[cfg(feature = "resources")]
4mod resources;
5
6use sofie_core::{Error, FieldError, Instant, Result, SqlType, SqlValue};
7use sofie_values::{EXACT, ORDERED, Operator, TEXT};
8
9#[cfg(feature = "resources")]
10pub use self::resources::delete_softly;
11use crate::aggregate::field_columns;
12use crate::condition::Kind;
13use crate::{Aggregate, Condition, Field, Sort, Source};
14
15#[must_use]
16pub fn filterable<A: Aggregate>(name: &str) -> Vec<Operator> {
17    let Some(column) = column_of::<A>(name) else {
18        return Vec::new();
19    };
20    let mut offered = operators(column.ty).to_vec();
21    if column.nullable && !offered.is_empty() {
22        offered.push(Operator::IsEmpty);
23    }
24    offered
25}
26
27#[must_use]
28pub fn searched<A: Aggregate>() -> bool {
29    cfg!(feature = "search") && A::BEHAVIORS.iter().any(|behavior| behavior.searched())
30}
31
32/// A list reaches only what its read model shows, so a hidden column cannot be probed by filters.
33#[must_use]
34pub fn selects<S: Source>(name: &str) -> bool {
35    let wanted = if name == "id" {
36        vec!["id".to_owned()]
37    } else {
38        match listed_field::<S::Base>(name) {
39            Some(field) => field_columns(std::slice::from_ref(field)),
40            None => return false,
41        }
42    };
43    let selected = S::columns();
44    wanted.iter().all(|column| {
45        selected
46            .iter()
47            .any(|selection| selection.column_name() == Some(column))
48    })
49}
50
51fn listed_field<A: Aggregate>(name: &str) -> Option<&'static Field> {
52    if A::SENSITIVE.contains(&name) {
53        return None;
54    }
55    A::FIELDS.iter().find(|field| field.name == name)
56}
57
58struct FieldColumn {
59    name: String,
60    ty: SqlType,
61    nullable: bool,
62    values: &'static [&'static str],
63    parse_text: fn(&str) -> Option<Result<SqlValue, FieldError>>,
64}
65
66fn operators(ty: SqlType) -> &'static [Operator] {
67    match ty {
68        SqlType::Text => TEXT,
69        SqlType::Int | SqlType::Float | SqlType::Timestamp | SqlType::Date | SqlType::DateTime => {
70            ORDERED
71        }
72        SqlType::Bool | SqlType::Uuid => EXACT,
73        SqlType::Bytes => &[],
74    }
75}
76
77fn column_of<A: Aggregate>(name: &str) -> Option<FieldColumn> {
78    if name == "id" {
79        return Some(FieldColumn {
80            name: "id".to_owned(),
81            ty: SqlType::Uuid,
82            nullable: false,
83            values: &[],
84            parse_text: |_| None,
85        });
86    }
87    let field = listed_field::<A>(name)?;
88    match field.columns {
89        [column] => Some(FieldColumn {
90            name: crate::sql::column_name(name, column),
91            ty: column.ty,
92            nullable: field.nullable,
93            values: column.values,
94            parse_text: field.parse_text,
95        }),
96        _ => None,
97    }
98}
99
100impl<A: Aggregate> Sort<A> {
101    #[must_use]
102    pub fn by_field(name: &str, descending: bool) -> Option<Self> {
103        if let Some(column) = column_of::<A>(name) {
104            return Some(Self::new(column.name, descending, column.nullable));
105        }
106        let field = listed_field::<A>(name)?;
107        let mut ordering = field.columns.iter().filter(|column| column.orders);
108        match (ordering.next(), ordering.next()) {
109            (Some(column), None) => Some(Self::new(
110                crate::sql::column_name(name, column),
111                descending,
112                field.nullable,
113            )),
114            _ => None,
115        }
116    }
117}
118
119impl<A: Aggregate> Condition<A> {
120    #[cfg_attr(not(feature = "search"), expect(unused_variables))]
121    #[must_use]
122    pub fn searching(words: &str) -> Option<Self> {
123        #[cfg(feature = "search")]
124        if searched::<A>() {
125            return Some(crate::search::text_search(words));
126        }
127        None
128    }
129
130    /// `Between` takes `low..high`; a value the field refuses is its own error, shown beside it.
131    pub fn on_field(name: &str, operator: Operator, text: &str) -> Result<Self> {
132        let refused = |why: String| {
133            Error::BadRequest(format!("a list cannot be filtered by `{name}`: {why}"))
134        };
135        if !filterable::<A>(name).contains(&operator) {
136            return Err(refused(format!("it offers no `{operator:?}`")));
137        }
138        let column = column_of::<A>(name).ok_or_else(|| refused("no such field".to_owned()))?;
139        let exact = matches!(operator, Operator::Equals | Operator::NotEquals);
140        if exact && !column.values.is_empty() && !column.values.contains(&text) {
141            let accepted = column.values.join(", ");
142            let refusal = FieldError::new(
143                name,
144                "validation.one_of",
145                format!("must be one of: {accepted}"),
146            );
147            return Err(Error::Invalid(vec![refusal.with("values", accepted)]));
148        }
149        let value = |text: &str| column.value(name, text);
150        let compare = |operator: &'static str, value: SqlValue| Kind::Compare {
151            columns: vec![column.name.clone()],
152            operator,
153            values: vec![value],
154        };
155        let kind = match operator {
156            Operator::Equals => compare("=", value(text)?),
157            Operator::NotEquals if column.nullable => {
158                Kind::Not(Box::new(compare("IS NOT DISTINCT FROM", value(text)?)))
159            }
160            Operator::NotEquals => Kind::Not(Box::new(compare("=", value(text)?))),
161            Operator::GreaterThan => compare(">", value(text)?),
162            Operator::LessThan => compare("<", value(text)?),
163            Operator::AtLeast => compare(">=", value(text)?),
164            Operator::AtMost => compare("<=", value(text)?),
165            Operator::Between => {
166                let (low, high) = text.split_once("..").ok_or_else(|| {
167                    let refusal = FieldError::new(
168                        name,
169                        "validation.range",
170                        format!("`{text}` is not a range `low..high`"),
171                    );
172                    Error::Invalid(vec![refusal.with("value", text)])
173                })?;
174                Kind::Between {
175                    column: column.name.clone(),
176                    low: value(low)?,
177                    high: value(high)?,
178                }
179            }
180            Operator::Contains => Kind::Contains {
181                column: column.name.clone(),
182                pattern: contains_pattern(text),
183            },
184            Operator::IsEmpty => Kind::Null(vec![column.name.clone()]),
185        };
186        Ok(Self::new(kind))
187    }
188}
189
190impl FieldColumn {
191    fn value(&self, name: &str, text: &str) -> Result<SqlValue> {
192        match (self.parse_text)(text) {
193            Some(read) => read.map_err(|refusal| refused(name, vec![refusal])),
194            None if self.ty == SqlType::Timestamp => instant_value(name, text),
195            None => parse(self.ty, text)
196                .ok_or_else(|| refused(name, vec![crate::aggregate::expected(self.ty)])),
197        }
198    }
199}
200
201fn refused(name: &str, refusals: Vec<FieldError>) -> Error {
202    let named = refusals.into_iter().map(|refusal| FieldError {
203        field: name.to_owned(),
204        ..refusal
205    });
206    Error::Invalid(named.collect())
207}
208
209fn parse(ty: SqlType, text: &str) -> Option<SqlValue> {
210    Some(match ty {
211        SqlType::Text => SqlValue::Text(text.to_owned()),
212        SqlType::Int => SqlValue::Int(text.parse().ok()?),
213        SqlType::Float => SqlValue::Float(text.parse().ok()?),
214        SqlType::Bool => SqlValue::Bool(text.parse().ok()?),
215        SqlType::Uuid => SqlValue::Uuid(text.parse().ok()?),
216        SqlType::Date => SqlValue::Date(text.parse().ok()?),
217        SqlType::DateTime => SqlValue::DateTime(text.parse().ok()?),
218        SqlType::Timestamp | SqlType::Bytes => return None,
219    })
220}
221
222/// A wall clock a backward shift repeats is refused naming both offsets, which a text box can hold.
223fn instant_value(name: &str, text: &str) -> Result<SqlValue> {
224    let instant = Instant::parse(text).map_err(|error| match error {
225        Error::Invalid(refusals) => refused(name, refusals),
226        other => other,
227    })?;
228    Ok(SqlValue::Timestamp(instant.timestamp()))
229}
230
231/// The text matched literally anywhere: `%`, `_` and the escape itself are escaped.
232fn contains_pattern(text: &str) -> String {
233    let mut pattern = String::with_capacity(text.len() + 4);
234    pattern.push('%');
235    for character in text.chars() {
236        if matches!(character, '\\' | '%' | '_') {
237            pattern.push('\\');
238        }
239        pattern.push(character);
240    }
241    pattern.push('%');
242    pattern
243}
244
245#[cfg(test)]
246mod tests {
247    use std::marker::PhantomData;
248
249    use sofie_core::{Cast as _, ColumnSpec};
250
251    use super::*;
252    use crate::{ReadsNoText as _, ReadsText as _};
253
254    #[test]
255    fn a_filter_reads_its_value_as_the_column_s_type() {
256        assert_eq!(parse(SqlType::Int, "12"), Some(SqlValue::Int(12)));
257        assert_eq!(parse(SqlType::Int, "twelve"), None);
258        assert_eq!(parse(SqlType::Bool, "true"), Some(SqlValue::Bool(true)));
259        assert_eq!(contains_pattern("50%_\\"), "%50\\%\\_\\\\%");
260    }
261
262    #[test]
263    fn an_instant_filter_reads_a_datetime_local_value_as_an_instant_does() {
264        let instant = sofie_core::Instant::parse("2026-10-16T10:00").unwrap();
265        assert_eq!(
266            instant_value("due", "2026-10-16T10:00").unwrap(),
267            SqlValue::Timestamp(instant.timestamp())
268        );
269    }
270
271    struct Priced(crate::Meta);
272
273    const AMOUNT: &[ColumnSpec] = &[
274        ColumnSpec::new("", SqlType::Int),
275        ColumnSpec::new("_currency", SqlType::Text).unordered(),
276    ];
277
278    impl Aggregate for Priced {
279        const NAME: &'static str = "priced";
280        const MODULE: &'static str = "shop";
281        const FIELDS: &'static [crate::Field] = &[
282            crate::Field::new("total", AMOUNT, false),
283            crate::Field::new("token", &[ColumnSpec::new("", SqlType::Text)], false),
284            crate::Field::new("note", &[ColumnSpec::new("", SqlType::Text)], true),
285            crate::Field::new("due", &[ColumnSpec::new("", SqlType::Timestamp)], false),
286        ];
287        const SENSITIVE: &'static [&'static str] = &["token"];
288
289        fn meta(&self) -> &crate::Meta {
290            &self.0
291        }
292
293        fn meta_mut(&mut self) -> &mut crate::Meta {
294            &mut self.0
295        }
296
297        fn encode(&self, _out: &mut Vec<SqlValue>) {}
298
299        fn decode(meta: crate::Meta, _row: &mut dyn sofie_core::ColumnReader) -> Result<Self> {
300            Ok(Self(meta))
301        }
302    }
303
304    #[test]
305    fn a_value_of_several_columns_sorts_by_the_one_that_orders_it_and_filters_by_none() {
306        assert!(Sort::<Priced>::by_field("total", true).is_some());
307        assert_eq!(filterable::<Priced>("total"), []);
308    }
309
310    #[test]
311    fn a_sensitive_field_is_neither_filtered_nor_sorted_by() {
312        assert_eq!(filterable::<Priced>("token"), []);
313        assert!(Sort::<Priced>::by_field("token", false).is_none());
314        assert!(!selects::<Priced>("token"));
315        assert!(Condition::<Priced>::on_field("token", Operator::Equals, "a").is_err());
316    }
317
318    #[test]
319    fn a_nullable_field_unequal_to_a_value_keeps_its_empty_rows() {
320        let condition = Condition::<Priced>::on_field("note", Operator::NotEquals, "gift").unwrap();
321        let (mut sql, mut params) = (String::new(), Vec::new());
322        condition
323            .kind
324            .render_into(&crate::aggregate::tests::NUMBERED, &mut params, &mut sql);
325
326        assert_eq!(sql, "NOT (\"note\" IS NOT DISTINCT FROM $1)");
327    }
328
329    #[test]
330    fn an_instant_filter_refused_is_an_error_of_its_field_saying_why() {
331        let refused = Condition::<Priced>::on_field("due", Operator::AtLeast, "yesterday").err();
332
333        let Some(Error::Invalid(errors)) = &refused else {
334            panic!("{refused:?}");
335        };
336        assert_eq!(errors[0].field, "due");
337        assert!(errors[0].message.contains("with an offset"), "{refused:?}");
338    }
339
340    struct Tenths(i64);
341
342    struct Count(i64);
343
344    macro_rules! int_column {
345        ($ty:ident) => {
346            impl sofie_core::Cast for $ty {
347                const COLUMNS: &'static [ColumnSpec] = &[ColumnSpec::new("", SqlType::Int)];
348
349                fn encode(&self, out: &mut Vec<SqlValue>) {
350                    out.push(SqlValue::Int(self.0));
351                }
352
353                fn decode(_row: &mut dyn sofie_core::ColumnReader) -> Result<Self> {
354                    Ok(Self(0))
355                }
356            }
357        };
358    }
359
360    int_column!(Tenths);
361    int_column!(Count);
362    int_column!(Hundredths);
363
364    struct Hundredths(i64);
365
366    impl<'de> serde::Deserialize<'de> for Hundredths {
367        fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
368            let whole: i64 = String::deserialize(deserializer)?
369                .parse()
370                .map_err(serde::de::Error::custom)?;
371            if !(0..=100).contains(&whole) {
372                return Err(serde::de::Error::custom("must be at most 100"));
373            }
374            Ok(Self(whole * 100))
375        }
376    }
377
378    impl<'de> serde::Deserialize<'de> for Tenths {
379        fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
380            let text = String::deserialize(deserializer)?;
381            let tenths = text
382                .replace('.', "")
383                .parse()
384                .map_err(serde::de::Error::custom)?;
385            Ok(Self(tenths))
386        }
387    }
388
389    struct Rated(crate::Meta);
390
391    impl Aggregate for Rated {
392        const NAME: &'static str = "rated";
393        const MODULE: &'static str = "shop";
394        #[expect(
395            clippy::needless_borrow,
396            reason = "as the derive writes it, for every type"
397        )]
398        const FIELDS: &'static [crate::Field] = &[
399            crate::Field::new("rate", Tenths::COLUMNS, false)
400                .parsed_by(|text| (&&crate::TextOf::<Tenths>(PhantomData)).read(text)),
401            crate::Field::new("count", Count::COLUMNS, false)
402                .parsed_by(|text| (&&crate::TextOf::<Count>(PhantomData)).read(text)),
403            crate::Field::new("total", i64::COLUMNS, false)
404                .parsed_by(|text| (&&crate::TextOf::<i64>(PhantomData)).read(text)),
405            crate::Field::new("share", Hundredths::COLUMNS, false)
406                .parsed_by(|text| (&&crate::TextOf::<Hundredths>(PhantomData)).read(text)),
407            crate::Field::new("open", bool::COLUMNS, false)
408                .parsed_by(|text| (&&crate::TextOf::<bool>(PhantomData)).read(text)),
409            crate::Field::new("due", Instant::COLUMNS, false)
410                .parsed_by(|text| (&&crate::TextOf::<Instant>(PhantomData)).read(text)),
411            crate::Field::new("owner", sofie_core::Uuid::COLUMNS, false)
412                .parsed_by(|text| (&&crate::TextOf::<sofie_core::Uuid>(PhantomData)).read(text)),
413        ];
414
415        fn meta(&self) -> &crate::Meta {
416            &self.0
417        }
418
419        fn meta_mut(&mut self) -> &mut crate::Meta {
420            &mut self.0
421        }
422
423        fn encode(&self, _out: &mut Vec<SqlValue>) {}
424
425        fn decode(meta: crate::Meta, _row: &mut dyn sofie_core::ColumnReader) -> Result<Self> {
426            Ok(Self(meta))
427        }
428    }
429
430    fn filtered_values(name: &str, text: &str) -> Vec<SqlValue> {
431        let condition = Condition::<Rated>::on_field(name, Operator::AtLeast, text).unwrap();
432        let (mut sql, mut params) = (String::new(), Vec::new());
433        condition
434            .kind
435            .render_into(&crate::aggregate::tests::NUMBERED, &mut params, &mut sql);
436        params
437    }
438
439    #[test]
440    fn a_filter_reads_its_value_as_the_field_s_type_reads_text() {
441        assert_eq!(filtered_values("rate", "1.9"), [SqlValue::Int(19)]);
442        assert_eq!(filtered_values("count", "12"), [SqlValue::Int(12)]);
443        assert_eq!(filtered_values("total", "12"), [SqlValue::Int(12)]);
444        assert_eq!(filtered_values("share", "19"), [SqlValue::Int(1900)]);
445    }
446
447    fn refusal_of<A: Aggregate>(name: &str, operator: Operator, text: &str) -> String {
448        refused_by::<A>(name, operator, text).message
449    }
450
451    fn refused_by<A: Aggregate>(name: &str, operator: Operator, text: &str) -> FieldError {
452        match Condition::<A>::on_field(name, operator, text).err() {
453            Some(Error::Invalid(mut errors)) if errors.len() == 1 && errors[0].field == name => {
454                errors.remove(0)
455            }
456            other => panic!("`{name}` refusing `{text}` gave {other:?}"),
457        }
458    }
459
460    #[test]
461    fn a_value_the_field_s_type_refuses_is_not_read_in_the_column_s_units() {
462        let refused = refusal_of::<Rated>("share", Operator::AtLeast, "150");
463        assert!(refused.contains("at most 100"), "{refused}");
464        refusal_of::<Rated>("total", Operator::AtLeast, "twelve");
465        let open = Condition::<Rated>::on_field("open", Operator::Equals, "true").unwrap();
466        let (mut sql, mut params) = (String::new(), Vec::new());
467        open.kind
468            .render_into(&crate::aggregate::tests::NUMBERED, &mut params, &mut sql);
469        assert_eq!(params, [SqlValue::Bool(true)]);
470    }
471
472    #[test]
473    fn every_filter_value_refused_is_an_error_of_its_field() {
474        refusal_of::<Rated>("due", Operator::Between, "2026-10-16T10:00");
475        refusal_of::<Rated>("open", Operator::Equals, "maybe");
476        refusal_of::<Priced>("due", Operator::Between, "a..b");
477        refusal_of::<Priced>("id", Operator::Equals, "not-a-uuid");
478    }
479
480    #[test]
481    fn a_value_not_of_its_column_s_kind_is_refused_in_neutral_words() {
482        let rated = |name, text| refusal_of::<Rated>(name, Operator::Equals, text);
483
484        assert_eq!(rated("total", "twelve"), "must be a whole number");
485        assert_eq!(rated("total", "1.5"), "must be a whole number");
486        assert_eq!(rated("open", "maybe"), "must be true or false");
487        assert_eq!(rated("owner", "not-a-uuid"), "must be an id");
488        assert_eq!(rated("owner", "7"), "must be an id");
489        assert_eq!(
490            refusal_of::<Priced>("id", Operator::Equals, "7"),
491            "must be an id"
492        );
493    }
494
495    #[test]
496    fn a_refusal_carries_the_key_and_arguments_a_page_translates_it_by() {
497        let rated = |name, operator, text| refused_by::<Rated>(name, operator, text);
498
499        assert_eq!(
500            rated("total", Operator::Equals, "twelve").key,
501            "validation.kind.whole_number"
502        );
503        assert_eq!(
504            rated("open", Operator::Equals, "maybe").key,
505            "validation.kind.true_or_false"
506        );
507        assert_eq!(
508            rated("due", Operator::AtLeast, "yesterday").key,
509            "validation.instant.invalid"
510        );
511        let range = rated("total", Operator::Between, "12");
512        assert_eq!(range.key, "validation.range");
513        assert_eq!(range.arguments, [("value".into(), "12".to_owned())]);
514        let undated = refused_by::<Priced>("due", Operator::AtLeast, "yesterday");
515        assert_eq!(undated.key, "validation.instant.invalid");
516    }
517
518    #[test]
519    fn a_derived_instant_filter_refused_says_what_reading_the_instant_says() {
520        let refused = refusal_of::<Rated>("due", Operator::AtLeast, "yesterday");
521        let read = Instant::parse("yesterday").unwrap_err();
522        assert_eq!(refused, format!("{read:#}"));
523    }
524}