Skip to main content

sofie_db/
condition.rs

1use std::marker::PhantomData;
2
3mod render;
4
5use sofie_core::{Cast, ColumnSpec, Ordered, SqlType, SqlValue};
6
7use crate::Aggregate;
8use crate::statement::SortKey;
9
10pub struct Column<A, T> {
11    field: &'static str,
12    marker: PhantomData<fn() -> (A, T)>,
13}
14
15impl<A, T> Clone for Column<A, T> {
16    fn clone(&self) -> Self {
17        *self
18    }
19}
20
21impl<A, T> Copy for Column<A, T> {}
22
23impl<A, T> Column<A, T> {
24    #[must_use]
25    pub const fn new(field: &'static str) -> Self {
26        Self {
27            field,
28            marker: PhantomData,
29        }
30    }
31
32    #[must_use]
33    pub const fn name(self) -> &'static str {
34        self.field
35    }
36}
37
38impl<A: Aggregate, T: Cast> Column<A, T> {
39    /// `None` matches the rows where the value is null.
40    #[must_use]
41    pub fn eq(self, value: T) -> Condition<A> {
42        Condition::new(compare(self.field, "=", value))
43    }
44
45    /// Also matches the rows holding a null where `value` does not, as a nullable value's.
46    #[must_use]
47    pub fn ne(self, value: T) -> Condition<A> {
48        let operator = if T::NULLABLE || T::COLUMNS.len() > 1 {
49            "IS NOT DISTINCT FROM"
50        } else {
51            "="
52        };
53        Condition::new(Kind::Not(Box::new(compare(self.field, operator, value))))
54    }
55
56    /// An empty list matches nothing; `None` matches the null rows, as in `eq`.
57    #[must_use]
58    pub fn in_(self, values: impl IntoIterator<Item = T>) -> Condition<A> {
59        let columns = names::<T>(self.field);
60        let (nulls, rows): (Vec<_>, Vec<_>) = values
61            .into_iter()
62            .map(encoded)
63            .partition(|row| T::NULLABLE && row.iter().all(|value| *value == SqlValue::Null));
64        let listed = Kind::In {
65            columns: columns.clone(),
66            rows,
67        };
68        if nulls.is_empty() {
69            return Condition::new(listed);
70        }
71        Condition::new(Kind::Or(Box::new(listed), Box::new(Kind::Null(columns))))
72    }
73
74    #[must_use]
75    pub fn like(self, pattern: &str) -> Condition<A> {
76        const { assert!(is_text(T::COLUMNS), "`like` applies to text columns only") };
77        Condition::new(Kind::Compare {
78            columns: vec![self.field.to_owned()],
79            operator: "LIKE",
80            values: vec![SqlValue::Text(pattern.to_owned())],
81        })
82    }
83
84    #[must_use]
85    pub fn asc(self) -> Sort<A> {
86        Sort::new(names::<T>(self.field).swap_remove(0), false, T::NULLABLE)
87    }
88
89    #[must_use]
90    pub fn desc(self) -> Sort<A> {
91        Sort::new(names::<T>(self.field).swap_remove(0), true, T::NULLABLE)
92    }
93}
94
95/// Held where `column` equals `selected` of a row of `B` matching `condition`, in the same query.
96#[must_use]
97pub fn in_select<A: Aggregate, B: Aggregate, T: Cast>(
98    column: Column<A, T>,
99    selected: Column<B, T>,
100    condition: Condition<B>,
101) -> Condition<A> {
102    const {
103        assert!(
104            T::COLUMNS.len() == 1,
105            "`in_select` applies to one-column values"
106        );
107    };
108    Condition::new(Kind::InSelect(Box::new(InSelect {
109        column: column.field.to_owned(),
110        selected: selected.field.to_owned(),
111        table: (B::MODULE, B::NAME),
112        condition: condition.kind,
113        hidden: crate::aggregate::table::<B>().hiding().collect(),
114    })))
115}
116
117fn names<T: Cast>(field: &str) -> Vec<String> {
118    T::COLUMNS
119        .iter()
120        .map(|column| crate::sql::column_name(field, column))
121        .collect()
122}
123
124fn compare<T: Cast>(field: &str, operator: &'static str, value: T) -> Kind {
125    Kind::Compare {
126        columns: names::<T>(field),
127        operator,
128        values: encoded(value),
129    }
130}
131
132const fn is_text(columns: &[ColumnSpec]) -> bool {
133    matches!(
134        columns,
135        [ColumnSpec {
136            ty: SqlType::Text,
137            ..
138        }]
139    )
140}
141
142#[expect(
143    clippy::needless_pass_by_value,
144    reason = "call sites read `eq(Status::Paid)`"
145)]
146fn encoded<T: Cast>(value: T) -> Vec<SqlValue> {
147    let mut values = Vec::with_capacity(T::COLUMNS.len());
148    value.encode(&mut values);
149    values
150}
151
152/// Of a nullable value, a null row never matches: give `is_null` its own condition to keep them.
153impl<A: Aggregate, T: Ordered> Column<A, T> {
154    #[must_use]
155    pub fn gt(self, value: impl Into<T>) -> Condition<A> {
156        Condition::new(compare(self.field, ">", value.into()))
157    }
158
159    #[must_use]
160    pub fn gte(self, value: impl Into<T>) -> Condition<A> {
161        Condition::new(compare(self.field, ">=", value.into()))
162    }
163
164    #[must_use]
165    pub fn lt(self, value: impl Into<T>) -> Condition<A> {
166        Condition::new(compare(self.field, "<", value.into()))
167    }
168
169    #[must_use]
170    pub fn lte(self, value: impl Into<T>) -> Condition<A> {
171        Condition::new(compare(self.field, "<=", value.into()))
172    }
173
174    /// Both ends included.
175    #[must_use]
176    pub fn between(self, low: impl Into<T>, high: impl Into<T>) -> Condition<A> {
177        Condition::new(Kind::Between {
178            column: self.field.to_owned(),
179            low: encoded(low.into()).swap_remove(0),
180            high: encoded(high.into()).swap_remove(0),
181        })
182    }
183}
184
185impl<A: Aggregate, T: Cast> Column<A, Option<T>> {
186    #[must_use]
187    pub fn is_null(self) -> Condition<A> {
188        Condition::new(Kind::Null(names::<T>(self.field)))
189    }
190
191    #[must_use]
192    pub fn is_not_null(self) -> Condition<A> {
193        Condition::new(Kind::Not(Box::new(Kind::Null(names::<T>(self.field)))))
194    }
195}
196
197pub struct Condition<A> {
198    pub(crate) kind: Kind,
199    marker: PhantomData<fn() -> A>,
200}
201
202impl<A> Condition<A> {
203    pub(crate) const fn new(kind: Kind) -> Self {
204        Self {
205            kind,
206            marker: PhantomData,
207        }
208    }
209
210    #[must_use]
211    pub fn or(self, other: Self) -> Self {
212        Self::new(Kind::Or(Box::new(self.kind), Box::new(other.kind)))
213    }
214}
215
216#[derive(Clone, Debug, PartialEq)]
217pub(crate) enum Kind {
218    Compare {
219        columns: Vec<String>,
220        operator: &'static str,
221        values: Vec<SqlValue>,
222    },
223    Between {
224        column: String,
225        low: SqlValue,
226        high: SqlValue,
227    },
228    In {
229        columns: Vec<String>,
230        rows: Vec<Vec<SqlValue>>,
231    },
232    Null(Vec<String>),
233    #[cfg(feature = "listing")]
234    Contains {
235        column: String,
236        pattern: String,
237    },
238    InSelect(Box<InSelect>),
239    Not(Box<Kind>),
240    Or(Box<Kind>, Box<Kind>),
241    #[cfg(feature = "search")]
242    TextSearch {
243        column: String,
244        words: String,
245        #[cfg(feature = "search-engine")]
246        index: (&'static str, &'static str),
247    },
248}
249
250#[derive(Clone, Debug, PartialEq)]
251pub(crate) struct InSelect {
252    column: String,
253    selected: String,
254    table: (&'static str, &'static str),
255    condition: Kind,
256    hidden: Vec<&'static str>,
257}
258
259#[cfg(feature = "live")]
260impl Kind {
261    pub(crate) fn tables(&self, found: &mut Vec<(&'static str, &'static str)>) {
262        match self {
263            Self::InSelect(select) => {
264                found.push(select.table);
265                select.condition.tables(found);
266            }
267            Self::Not(kind) => kind.tables(found),
268            Self::Or(left, right) => {
269                left.tables(found);
270                right.tables(found);
271            }
272            Self::Compare { .. } | Self::Between { .. } | Self::In { .. } | Self::Null(_) => {}
273            #[cfg(feature = "listing")]
274            Self::Contains { .. } => {}
275            #[cfg(feature = "search")]
276            Self::TextSearch { .. } => {}
277        }
278    }
279}
280
281#[cfg(feature = "search-engine")]
282impl Kind {
283    /// `own_records` is false inside a subquery, which the read's owner does not narrow.
284    pub(crate) fn searches<'k>(
285        &'k mut self,
286        own_records: bool,
287        found: &mut Vec<(&'k mut Self, bool)>,
288    ) {
289        if matches!(self, Self::TextSearch { .. }) {
290            found.push((self, own_records));
291            return;
292        }
293        match self {
294            Self::InSelect(select) => select.condition.searches(false, found),
295            Self::Not(kind) => kind.searches(own_records, found),
296            Self::Or(left, right) => {
297                left.searches(own_records, found);
298                right.searches(own_records, found);
299            }
300            Self::Compare { .. }
301            | Self::Between { .. }
302            | Self::In { .. }
303            | Self::Null(_)
304            | Self::TextSearch { .. } => {}
305            #[cfg(feature = "listing")]
306            Self::Contains { .. } => {}
307        }
308    }
309}
310
311pub struct Sort<A> {
312    pub(crate) key: SortKey,
313    marker: PhantomData<fn() -> A>,
314}
315
316impl<A> Sort<A> {
317    pub(crate) fn new(column: String, descending: bool, nullable: bool) -> Self {
318        Self {
319            key: SortKey {
320                column,
321                descending,
322                nullable,
323            },
324            marker: PhantomData,
325        }
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::aggregate::tests::{NUMBERED, Note};
333
334    const SUBTITLE: Column<Note, Option<String>> = Column::new("subtitle");
335
336    fn rendered(condition: &Condition<Note>) -> (String, Vec<SqlValue>) {
337        let (mut sql, mut params) = (String::new(), Vec::new());
338        condition.kind.render_into(&NUMBERED, &mut params, &mut sql);
339        (sql, params)
340    }
341
342    #[cfg(feature = "search")]
343    #[test]
344    fn no_words_match_nothing_on_every_database() {
345        let blank = Condition::<Note>::new(Kind::TextSearch {
346            column: "search".to_owned(),
347            words: " ".to_owned(),
348            #[cfg(feature = "search-engine")]
349            index: ("notes", "note"),
350        });
351
352        assert_eq!(rendered(&blank), ("(1 = 0)".to_owned(), Vec::new()));
353    }
354
355    #[test]
356    fn a_subquery_leaves_its_aggregate_s_hidden_rows_out() {
357        let (sql, _) = rendered(&in_select(Note::ID, Note::ID, SUBTITLE.eq(None)));
358
359        assert!(sql.ends_with(" AND (\"deleted_at\" IS NULL)))"), "{sql}");
360    }
361
362    #[test]
363    fn a_nullable_value_equal_to_none_is_null() {
364        assert_eq!(
365            rendered(&SUBTITLE.eq(None)),
366            ("(\"subtitle\" IS NULL)".to_owned(), Vec::new())
367        );
368    }
369
370    #[test]
371    fn a_nullable_value_unequal_to_a_value_keeps_the_null_rows() {
372        assert_eq!(
373            rendered(&SUBTITLE.ne(Some("draft".to_owned()))),
374            (
375                "NOT (\"subtitle\" IS NOT DISTINCT FROM $1)".to_owned(),
376                vec![SqlValue::Text("draft".to_owned())]
377            )
378        );
379        assert_eq!(rendered(&SUBTITLE.ne(None)).0, "NOT (\"subtitle\" IS NULL)");
380    }
381
382    struct Period {
383        from: Option<i64>,
384        until: i64,
385    }
386
387    impl Cast for Period {
388        const COLUMNS: &'static [ColumnSpec] = &[
389            ColumnSpec::new("_from", SqlType::Int),
390            ColumnSpec::new("_until", SqlType::Int),
391        ];
392
393        fn encode(&self, out: &mut Vec<SqlValue>) {
394            self.from.encode(out);
395            self.until.encode(out);
396        }
397
398        fn decode(row: &mut dyn sofie_core::ColumnReader) -> sofie_core::Result<Self> {
399            Ok(Self {
400                from: Cast::decode(row)?,
401                until: Cast::decode(row)?,
402            })
403        }
404    }
405
406    #[test]
407    fn a_value_of_several_columns_holding_a_null_matches_that_column_by_is_null() {
408        let period: Column<Note, Option<Period>> = Column::new("period");
409        let open = || {
410            Some(Period {
411                from: None,
412                until: 9,
413            })
414        };
415
416        assert_eq!(
417            rendered(&period.eq(open())),
418            (
419                "(\"period_from\" IS NULL AND \"period_until\" = $1)".to_owned(),
420                vec![SqlValue::Int(9)]
421            )
422        );
423        assert_eq!(
424            rendered(&period.ne(open())).0,
425            "NOT (\"period_from\" IS NULL AND \"period_until\" IS NOT DISTINCT FROM $1)"
426        );
427        assert_eq!(
428            rendered(&period.eq(None)).0,
429            "(\"period_from\" IS NULL AND \"period_until\" IS NULL)"
430        );
431    }
432
433    #[test]
434    fn a_value_that_cannot_be_null_compares_with_equals() {
435        let title: Column<Note, String> = Column::new("title");
436
437        assert_eq!(
438            rendered(&title.ne("draft".to_owned())).0,
439            "NOT (\"title\" = $1)"
440        );
441    }
442
443    #[test]
444    fn a_nullable_value_compares_by_order_and_leaves_its_null_rows_out() {
445        assert_eq!(
446            rendered(&SUBTITLE.lt("m".to_owned())),
447            (
448                "(\"subtitle\" < $1)".to_owned(),
449                vec![SqlValue::Text("m".to_owned())]
450            )
451        );
452        assert_eq!(
453            rendered(&SUBTITLE.between("a".to_owned(), "m".to_owned())).1,
454            [
455                SqlValue::Text("a".to_owned()),
456                SqlValue::Text("m".to_owned())
457            ]
458        );
459        assert_eq!(rendered(&SUBTITLE.gte(None)).1, [SqlValue::Null]);
460    }
461
462    #[test]
463    fn like_names_its_escape_so_every_database_reads_a_backslash_alike() {
464        let title: Column<Note, String> = Column::new("title");
465
466        assert_eq!(
467            rendered(&title.like("50\\%")).0,
468            "(\"title\" LIKE $1 ESCAPE '\\')"
469        );
470    }
471}