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 #[must_use]
41 pub fn eq(self, value: T) -> Condition<A> {
42 Condition::new(compare(self.field, "=", value))
43 }
44
45 #[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 #[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#[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
152impl<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 #[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 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}