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 (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#[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
158impl<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 #[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 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}