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