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; a value holding a null matches 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 (holding_null, rows): (Vec<_>, Vec<_>) = values
61            .into_iter()
62            .map(encoded)
63            .partition(|row| row.contains(&SqlValue::Null));
64        let listed = Kind::In {
65            columns: columns.clone(),
66            types: T::COLUMNS.iter().map(|column| column.ty).collect(),
67            rows,
68        };
69        let kind = holding_null.into_iter().fold(listed, |kind, values| {
70            let equal = Kind::Compare {
71                columns: columns.clone(),
72                operator: "=",
73                values,
74            };
75            Kind::Or(Box::new(kind), Box::new(equal))
76        });
77        Condition::new(kind)
78    }
79
80    #[must_use]
81    pub fn like(self, pattern: &str) -> Condition<A> {
82        const { assert!(is_text(T::COLUMNS), "`like` applies to text columns only") };
83        Condition::new(Kind::Compare {
84            columns: vec![self.field.to_owned()],
85            operator: "LIKE",
86            values: vec![SqlValue::Text(pattern.to_owned())],
87        })
88    }
89
90    #[must_use]
91    pub fn asc(self) -> Sort<A> {
92        Sort::new(names::<T>(self.field).swap_remove(0), false, T::NULLABLE)
93    }
94
95    #[must_use]
96    pub fn desc(self) -> Sort<A> {
97        Sort::new(names::<T>(self.field).swap_remove(0), true, T::NULLABLE)
98    }
99}
100
101/// Held where `column` equals `selected` of a row of `B` matching `condition`, in the same query.
102#[must_use]
103pub fn in_select<A: Aggregate, B: Aggregate, T: Cast>(
104    column: Column<A, T>,
105    selected: Column<B, T>,
106    condition: Condition<B>,
107) -> Condition<A> {
108    const {
109        assert!(
110            T::COLUMNS.len() == 1,
111            "`in_select` applies to one-column values"
112        );
113    };
114    Condition::new(Kind::InSelect(Box::new(InSelect {
115        column: column.field.to_owned(),
116        selected: selected.field.to_owned(),
117        table: (B::MODULE, B::NAME),
118        condition: condition.kind,
119        hidden: crate::aggregate::table::<B>().hiding().collect(),
120    })))
121}
122
123fn names<T: Cast>(field: &str) -> Vec<String> {
124    T::COLUMNS
125        .iter()
126        .map(|column| crate::sql::column_name(field, column))
127        .collect()
128}
129
130fn compare<T: Cast>(field: &str, operator: &'static str, value: T) -> Kind {
131    Kind::Compare {
132        columns: names::<T>(field),
133        operator,
134        values: encoded(value),
135    }
136}
137
138const fn is_text(columns: &[ColumnSpec]) -> bool {
139    matches!(
140        columns,
141        [ColumnSpec {
142            ty: SqlType::Text,
143            ..
144        }]
145    )
146}
147
148#[expect(
149    clippy::needless_pass_by_value,
150    reason = "call sites read `eq(Status::Paid)`"
151)]
152fn encoded<T: Cast>(value: T) -> Vec<SqlValue> {
153    let mut values = Vec::with_capacity(T::COLUMNS.len());
154    value.encode(&mut values);
155    values
156}
157
158/// Of a nullable value, a null row never matches: give `is_null` its own condition to keep them.
159impl<A: Aggregate, T: Ordered> Column<A, T> {
160    #[must_use]
161    pub fn gt(self, value: impl Into<T>) -> Condition<A> {
162        Condition::new(compare(self.field, ">", value.into()))
163    }
164
165    #[must_use]
166    pub fn gte(self, value: impl Into<T>) -> Condition<A> {
167        Condition::new(compare(self.field, ">=", value.into()))
168    }
169
170    #[must_use]
171    pub fn lt(self, value: impl Into<T>) -> Condition<A> {
172        Condition::new(compare(self.field, "<", value.into()))
173    }
174
175    #[must_use]
176    pub fn lte(self, value: impl Into<T>) -> Condition<A> {
177        Condition::new(compare(self.field, "<=", value.into()))
178    }
179
180    /// Both ends included.
181    #[must_use]
182    pub fn between(self, low: impl Into<T>, high: impl Into<T>) -> Condition<A> {
183        Condition::new(Kind::Between {
184            column: self.field.to_owned(),
185            low: encoded(low.into()).swap_remove(0),
186            high: encoded(high.into()).swap_remove(0),
187        })
188    }
189}
190
191impl<A: Aggregate, T: Cast> Column<A, Option<T>> {
192    #[must_use]
193    pub fn is_null(self) -> Condition<A> {
194        Condition::new(Kind::Null(names::<T>(self.field)))
195    }
196
197    #[must_use]
198    pub fn is_not_null(self) -> Condition<A> {
199        Condition::new(Kind::Not(Box::new(Kind::Null(names::<T>(self.field)))))
200    }
201}
202
203pub struct Condition<A> {
204    pub(crate) kind: Kind,
205    marker: PhantomData<fn() -> A>,
206}
207
208impl<A> Condition<A> {
209    pub(crate) const fn new(kind: Kind) -> Self {
210        Self {
211            kind,
212            marker: PhantomData,
213        }
214    }
215
216    #[must_use]
217    pub fn or(self, other: Self) -> Self {
218        Self::new(Kind::Or(Box::new(self.kind), Box::new(other.kind)))
219    }
220}
221
222#[derive(Clone, Debug, PartialEq)]
223pub(crate) enum Kind {
224    Compare {
225        columns: Vec<String>,
226        operator: &'static str,
227        values: Vec<SqlValue>,
228    },
229    Between {
230        column: String,
231        low: SqlValue,
232        high: SqlValue,
233    },
234    In {
235        columns: Vec<String>,
236        types: Vec<SqlType>,
237        rows: Vec<Vec<SqlValue>>,
238    },
239    Null(Vec<String>),
240    #[cfg(feature = "listing")]
241    Contains {
242        column: String,
243        pattern: String,
244    },
245    InSelect(Box<InSelect>),
246    Not(Box<Kind>),
247    Or(Box<Kind>, Box<Kind>),
248    #[cfg(feature = "search")]
249    TextSearch {
250        column: String,
251        words: String,
252        #[cfg(feature = "search-engine")]
253        index: (&'static str, &'static str),
254    },
255}
256
257#[derive(Clone, Debug, PartialEq)]
258pub(crate) struct InSelect {
259    column: String,
260    selected: String,
261    table: (&'static str, &'static str),
262    condition: Kind,
263    hidden: Vec<&'static str>,
264}
265
266#[cfg(feature = "live")]
267impl Kind {
268    pub(crate) fn tables(&self, found: &mut Vec<(&'static str, &'static str)>) {
269        match self {
270            Self::InSelect(select) => {
271                found.push(select.table);
272                select.condition.tables(found);
273            }
274            Self::Not(kind) => kind.tables(found),
275            Self::Or(left, right) => {
276                left.tables(found);
277                right.tables(found);
278            }
279            Self::Compare { .. } | Self::Between { .. } | Self::In { .. } | Self::Null(_) => {}
280            #[cfg(feature = "listing")]
281            Self::Contains { .. } => {}
282            #[cfg(feature = "search")]
283            Self::TextSearch { .. } => {}
284        }
285    }
286}
287
288#[cfg(feature = "search-engine")]
289impl Kind {
290    /// `own_records` is false inside a subquery, which the read's owner does not narrow.
291    pub(crate) fn searches<'k>(
292        &'k mut self,
293        own_records: bool,
294        found: &mut Vec<(&'k mut Self, bool)>,
295    ) {
296        if matches!(self, Self::TextSearch { .. }) {
297            found.push((self, own_records));
298            return;
299        }
300        match self {
301            Self::InSelect(select) => select.condition.searches(false, found),
302            Self::Not(kind) => kind.searches(own_records, found),
303            Self::Or(left, right) => {
304                left.searches(own_records, found);
305                right.searches(own_records, found);
306            }
307            Self::Compare { .. }
308            | Self::Between { .. }
309            | Self::In { .. }
310            | Self::Null(_)
311            | Self::TextSearch { .. } => {}
312            #[cfg(feature = "listing")]
313            Self::Contains { .. } => {}
314        }
315    }
316}
317
318pub struct Sort<A> {
319    pub(crate) key: SortKey,
320    marker: PhantomData<fn() -> A>,
321}
322
323impl<A> Sort<A> {
324    pub(crate) fn new(column: String, descending: bool, nullable: bool) -> Self {
325        Self {
326            key: SortKey {
327                column,
328                descending,
329                nullable,
330            },
331            marker: PhantomData,
332        }
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use crate::aggregate::tests::{NUMBERED, Note};
340
341    const SUBTITLE: Column<Note, Option<String>> = Column::new("subtitle");
342
343    fn rendered(condition: &Condition<Note>) -> (String, Vec<SqlValue>) {
344        let (mut sql, mut params) = (String::new(), Vec::new());
345        condition.kind.render_into(&NUMBERED, &mut params, &mut sql);
346        (sql, params)
347    }
348
349    #[cfg(feature = "search")]
350    #[test]
351    fn no_words_match_nothing_on_every_database() {
352        let blank = Condition::<Note>::new(Kind::TextSearch {
353            column: "search".to_owned(),
354            words: " ".to_owned(),
355            #[cfg(feature = "search-engine")]
356            index: ("notes", "note"),
357        });
358
359        assert_eq!(rendered(&blank), ("(1 = 0)".to_owned(), Vec::new()));
360    }
361
362    #[test]
363    fn a_subquery_leaves_its_aggregate_s_hidden_rows_out() {
364        let (sql, _) = rendered(&in_select(Note::ID, Note::ID, SUBTITLE.eq(None)));
365
366        assert!(sql.ends_with(" AND (\"deleted_at\" IS NULL)))"), "{sql}");
367    }
368
369    #[test]
370    fn a_nullable_value_equal_to_none_is_null() {
371        assert_eq!(
372            rendered(&SUBTITLE.eq(None)),
373            ("(\"subtitle\" IS NULL)".to_owned(), Vec::new())
374        );
375    }
376
377    #[test]
378    fn a_nullable_value_unequal_to_a_value_keeps_the_null_rows() {
379        assert_eq!(
380            rendered(&SUBTITLE.ne(Some("draft".to_owned()))),
381            (
382                "NOT (\"subtitle\" IS NOT DISTINCT FROM $1)".to_owned(),
383                vec![SqlValue::Text("draft".to_owned())]
384            )
385        );
386        assert_eq!(rendered(&SUBTITLE.ne(None)).0, "NOT (\"subtitle\" IS NULL)");
387    }
388
389    struct Period {
390        from: Option<i64>,
391        until: i64,
392    }
393
394    impl Cast for Period {
395        const COLUMNS: &'static [ColumnSpec] = &[
396            ColumnSpec::new("_from", SqlType::Int),
397            ColumnSpec::new("_until", SqlType::Int),
398        ];
399
400        fn encode(&self, out: &mut Vec<SqlValue>) {
401            self.from.encode(out);
402            self.until.encode(out);
403        }
404
405        fn decode(row: &mut dyn sofie_core::ColumnReader) -> sofie_core::Result<Self> {
406            Ok(Self {
407                from: Cast::decode(row)?,
408                until: Cast::decode(row)?,
409            })
410        }
411    }
412
413    #[test]
414    fn a_value_of_several_columns_holding_a_null_matches_that_column_by_is_null() {
415        let period: Column<Note, Option<Period>> = Column::new("period");
416        let open = || {
417            Some(Period {
418                from: None,
419                until: 9,
420            })
421        };
422
423        assert_eq!(
424            rendered(&period.eq(open())),
425            (
426                "(\"period_from\" IS NULL AND \"period_until\" = $1)".to_owned(),
427                vec![SqlValue::Int(9)]
428            )
429        );
430        assert_eq!(
431            rendered(&period.ne(open())).0,
432            "NOT (\"period_from\" IS NULL AND \"period_until\" IS NOT DISTINCT FROM $1)"
433        );
434        assert_eq!(
435            rendered(&period.eq(None)).0,
436            "(\"period_from\" IS NULL AND \"period_until\" IS NULL)"
437        );
438    }
439
440    #[test]
441    fn a_listed_value_holding_a_null_matches_as_eq_does() {
442        let period: Column<Note, Option<Period>> = Column::new("period");
443        let closed = Some(Period {
444            from: Some(1),
445            until: 2,
446        });
447        let open = Some(Period {
448            from: None,
449            until: 9,
450        });
451
452        assert_eq!(
453            rendered(&period.in_([closed, open, None])),
454            (
455                "((((\"period_from\", \"period_until\") IN (($1, $2))) \
456                 OR (\"period_from\" IS NULL AND \"period_until\" = $3)) \
457                 OR (\"period_from\" IS NULL AND \"period_until\" IS NULL))"
458                    .to_owned(),
459                vec![SqlValue::Int(1), SqlValue::Int(2), SqlValue::Int(9)]
460            )
461        );
462    }
463
464    struct Arrays;
465
466    impl crate::Dialect for Arrays {
467        fn push_placeholder(&self, sql: &mut String, index: usize) {
468            NUMBERED.push_placeholder(sql, index);
469        }
470
471        fn binds_arrays(&self) -> bool {
472            true
473        }
474
475        fn migrations_setup(&self) -> &'static str {
476            unimplemented!()
477        }
478
479        fn migrations_lock(&self) -> &'static str {
480            unimplemented!()
481        }
482
483        fn enter_schema(&self, _schema: &str) -> String {
484            unimplemented!()
485        }
486
487        fn framework_migrations(&self) -> crate::Migrations {
488            unimplemented!()
489        }
490
491        fn messages(&self) -> &'static crate::MessageSql {
492            NUMBERED.messages()
493        }
494    }
495
496    #[test]
497    fn listed_values_of_several_columns_bind_one_array_per_column_where_arrays_bind() {
498        let period: Column<Note, Option<Period>> = Column::new("period");
499        let closed = |from| {
500            Some(Period {
501                from: Some(from),
502                until: 9,
503            })
504        };
505        let open = Some(Period {
506            from: None,
507            until: 9,
508        });
509        let render = |condition: Condition<Note>| {
510            let (mut sql, mut params) = (String::new(), Vec::new());
511            condition.kind.render_into(&Arrays, &mut params, &mut sql);
512            (sql, params)
513        };
514
515        let (one, _) = render(period.in_([closed(1)]));
516        let (three, params) = render(period.in_([closed(1), open, closed(2), closed(3)]));
517
518        assert_eq!(
519            one,
520            "((\"period_from\", \"period_until\") IN \
521             (SELECT * FROM unnest($1::bigint[], $2::bigint[])))"
522        );
523        assert_eq!(
524            three,
525            format!("({one} OR (\"period_from\" IS NULL AND \"period_until\" = $3))")
526        );
527        let ints = |values: [i64; 3]| SqlValue::Array(values.map(SqlValue::Int).to_vec());
528        assert_eq!(params, [ints([1, 2, 3]), ints([9, 9, 9]), SqlValue::Int(9)]);
529    }
530
531    #[test]
532    fn a_value_that_cannot_be_null_compares_with_equals() {
533        let title: Column<Note, String> = Column::new("title");
534
535        assert_eq!(
536            rendered(&title.ne("draft".to_owned())).0,
537            "NOT (\"title\" = $1)"
538        );
539    }
540
541    #[test]
542    fn a_nullable_value_compares_by_order_and_leaves_its_null_rows_out() {
543        assert_eq!(
544            rendered(&SUBTITLE.lt("m".to_owned())),
545            (
546                "(\"subtitle\" < $1)".to_owned(),
547                vec![SqlValue::Text("m".to_owned())]
548            )
549        );
550        assert_eq!(
551            rendered(&SUBTITLE.between("a".to_owned(), "m".to_owned())).1,
552            [
553                SqlValue::Text("a".to_owned()),
554                SqlValue::Text("m".to_owned())
555            ]
556        );
557        assert_eq!(rendered(&SUBTITLE.gte(None)).1, [SqlValue::Null]);
558    }
559
560    #[test]
561    fn like_names_its_escape_so_every_database_reads_a_backslash_alike() {
562        let title: Column<Note, String> = Column::new("title");
563
564        assert_eq!(
565            rendered(&title.like("50\\%")).0,
566            "(\"title\" LIKE $1 ESCAPE '\\')"
567        );
568    }
569}