Skip to main content

floz_orm/
expr.rs

1//! Expression tree for building WHERE clauses and filter conditions.
2//!
3//! Expressions are created via methods on `Column<T>` and composed
4//! with `.and()`, `.or()`, `.not()`.
5//!
6//! ```ignore
7//! use floz::prelude::*;
8//!
9//! let filter = col_age.gt(25).and(col_name.eq("Alice").or(col_name.eq("Bob")));
10//! // Renders: (age > $1 AND (name = $2 OR name = $3))
11//! ```
12
13use std::fmt::Write;
14
15use crate::column::AnyColumn;
16use crate::value::Value;
17
18/// A composable SQL expression node.
19///
20/// Built via `Column<T>` methods, composed via `.and()`, `.or()`, `.not()`.
21/// Rendered to SQL + params via `to_sql()`.
22#[derive(Debug, Clone)]
23pub enum Expr {
24    // ── Comparison ──
25    /// `column = $N`
26    Eq(AnyColumn, Value),
27    /// `column != $N`
28    Ne(AnyColumn, Value),
29    /// `column > $N`
30    Gt(AnyColumn, Value),
31    /// `column >= $N`
32    Gte(AnyColumn, Value),
33    /// `column < $N`
34    Lt(AnyColumn, Value),
35    /// `column <= $N`
36    Lte(AnyColumn, Value),
37
38    // ── Range ──
39    /// `column BETWEEN $N AND $M`
40    Between(AnyColumn, Value, Value),
41    /// `column IN ($N, $M, ...)` — empty list renders as `1=0`
42    InList(AnyColumn, Vec<Value>),
43    /// `column NOT IN ($N, $M, ...)` — empty list renders as `1=1`
44    NotIn(AnyColumn, Vec<Value>),
45
46    // ── String ──
47    /// `column LIKE $N`
48    Like(AnyColumn, Value),
49    /// `column ILIKE $N` (case-insensitive, PostgreSQL)
50    ILike(AnyColumn, Value),
51
52    // ── Null ──
53    /// `column IS NULL`
54    IsNull(AnyColumn),
55    /// `column IS NOT NULL`
56    IsNotNull(AnyColumn),
57
58    // ── Logical ──
59    /// `(left AND right)`
60    And(Box<Expr>, Box<Expr>),
61    /// `(left OR right)`
62    Or(Box<Expr>, Box<Expr>),
63    /// `NOT (expr)`
64    Not(Box<Expr>),
65}
66
67impl Expr {
68    /// Combine with AND: `self AND other`
69    pub fn and(self, other: Expr) -> Expr {
70        Expr::And(Box::new(self), Box::new(other))
71    }
72
73    /// Combine with OR: `self OR other`
74    pub fn or(self, other: Expr) -> Expr {
75        Expr::Or(Box::new(self), Box::new(other))
76    }
77
78    /// Negate: `NOT self`
79    pub fn not(self) -> Expr {
80        Expr::Not(Box::new(self))
81    }
82
83    /// Render this expression to a SQL string fragment, pushing parameters into `params`
84    /// and incrementing `idx` for each `$N` placeholder.
85    ///
86    /// The `idx` counter is shared across the entire query so parameter numbers
87    /// are globally unique (e.g., a WHERE clause with 3 params followed by a
88    /// HAVING clause continues from `$4`).
89    pub fn to_sql(&self, sql: &mut String, params: &mut Vec<Value>, idx: &mut usize) {
90        match self {
91            // ── Comparison ──
92            Expr::Eq(col, val) => {
93                *idx += 1;
94                write!(sql, "{} = ${}", col.name(), idx).unwrap();
95                params.push(val.clone());
96            }
97            Expr::Ne(col, val) => {
98                *idx += 1;
99                write!(sql, "{} != ${}", col.name(), idx).unwrap();
100                params.push(val.clone());
101            }
102            Expr::Gt(col, val) => {
103                *idx += 1;
104                write!(sql, "{} > ${}", col.name(), idx).unwrap();
105                params.push(val.clone());
106            }
107            Expr::Gte(col, val) => {
108                *idx += 1;
109                write!(sql, "{} >= ${}", col.name(), idx).unwrap();
110                params.push(val.clone());
111            }
112            Expr::Lt(col, val) => {
113                *idx += 1;
114                write!(sql, "{} < ${}", col.name(), idx).unwrap();
115                params.push(val.clone());
116            }
117            Expr::Lte(col, val) => {
118                *idx += 1;
119                write!(sql, "{} <= ${}", col.name(), idx).unwrap();
120                params.push(val.clone());
121            }
122
123            // ── Range ──
124            Expr::Between(col, low, high) => {
125                *idx += 1;
126                let low_idx = *idx;
127                *idx += 1;
128                let high_idx = *idx;
129                write!(sql, "{} BETWEEN ${} AND ${}", col.name(), low_idx, high_idx).unwrap();
130                params.push(low.clone());
131                params.push(high.clone());
132            }
133            Expr::InList(col, values) => {
134                if values.is_empty() {
135                    // Empty IN is logically impossible — short-circuit to FALSE
136                    sql.push_str("1=0");
137                } else {
138                    write!(sql, "{} IN (", col.name()).unwrap();
139                    for (i, val) in values.iter().enumerate() {
140                        if i > 0 {
141                            sql.push_str(", ");
142                        }
143                        *idx += 1;
144                        write!(sql, "${}", idx).unwrap();
145                        params.push(val.clone());
146                    }
147                    sql.push(')');
148                }
149            }
150            Expr::NotIn(col, values) => {
151                if values.is_empty() {
152                    // Empty NOT IN excludes nothing — short-circuit to TRUE
153                    sql.push_str("1=1");
154                } else {
155                    write!(sql, "{} NOT IN (", col.name()).unwrap();
156                    for (i, val) in values.iter().enumerate() {
157                        if i > 0 {
158                            sql.push_str(", ");
159                        }
160                        *idx += 1;
161                        write!(sql, "${}", idx).unwrap();
162                        params.push(val.clone());
163                    }
164                    sql.push(')');
165                }
166            }
167
168            // ── String ──
169            Expr::Like(col, val) => {
170                *idx += 1;
171                write!(sql, "{} LIKE ${}", col.name(), idx).unwrap();
172                params.push(val.clone());
173            }
174            Expr::ILike(col, val) => {
175                *idx += 1;
176                write!(sql, "{} ILIKE ${}", col.name(), idx).unwrap();
177                params.push(val.clone());
178            }
179
180            // ── Null (no parameters) ──
181            Expr::IsNull(col) => {
182                write!(sql, "{} IS NULL", col.name()).unwrap();
183            }
184            Expr::IsNotNull(col) => {
185                write!(sql, "{} IS NOT NULL", col.name()).unwrap();
186            }
187
188            // ── Logical (recursive with parentheses) ──
189            Expr::And(left, right) => {
190                sql.push('(');
191                left.to_sql(sql, params, idx);
192                sql.push_str(" AND ");
193                right.to_sql(sql, params, idx);
194                sql.push(')');
195            }
196            Expr::Or(left, right) => {
197                sql.push('(');
198                left.to_sql(sql, params, idx);
199                sql.push_str(" OR ");
200                right.to_sql(sql, params, idx);
201                sql.push(')');
202            }
203            Expr::Not(inner) => {
204                sql.push_str("NOT (");
205                inner.to_sql(sql, params, idx);
206                sql.push(')');
207            }
208        }
209    }
210
211    /// Convenience: render to a standalone (sql, params) tuple.
212    /// Starts parameter numbering from $1.
213    pub fn render(&self) -> (String, Vec<Value>) {
214        let mut sql = String::new();
215        let mut params = Vec::new();
216        let mut idx = 0usize;
217        self.to_sql(&mut sql, &mut params, &mut idx);
218        (sql, params)
219    }
220}
221
222/// An ordering expression for ORDER BY clauses.
223#[derive(Debug, Clone)]
224pub struct OrderExpr {
225    pub(crate) column: AnyColumn,
226    pub(crate) direction: OrderDirection,
227}
228
229/// The direction of an ORDER BY clause.
230#[derive(Debug, Clone, Copy, PartialEq)]
231pub enum OrderDirection {
232    Asc,
233    Desc,
234    AscNullsLast,
235    DescNullsFirst,
236}
237
238impl OrderExpr {
239    pub fn new(column: AnyColumn, direction: OrderDirection) -> Self {
240        Self { column, direction }
241    }
242
243    /// Render this ordering clause to SQL.
244    pub fn to_sql(&self, sql: &mut String) {
245        sql.push_str(self.column.name());
246        match self.direction {
247            OrderDirection::Asc => sql.push_str(" ASC"),
248            OrderDirection::Desc => sql.push_str(" DESC"),
249            OrderDirection::AscNullsLast => sql.push_str(" ASC NULLS LAST"),
250            OrderDirection::DescNullsFirst => sql.push_str(" DESC NULLS FIRST"),
251        }
252    }
253
254    /// Convenience: render to a standalone SQL string.
255    pub fn render(&self) -> String {
256        let mut sql = String::new();
257        self.to_sql(&mut sql);
258        sql
259    }
260}
261
262// ═══════════════════════════════════════════════════════════════
263// Tests
264// ═══════════════════════════════════════════════════════════════
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::column::Column;
270
271    fn col_id() -> Column<i32> { Column::new("id", "users") }
272    fn col_age() -> Column<i32> { Column::new("age", "users") }
273    fn col_name() -> Column<String> { Column::new("name", "users") }
274    fn col_email() -> Column<String> { Column::new("email", "users") }
275
276    // ── Comparison rendering ──
277
278    #[test]
279    fn render_eq() {
280        let (sql, params) = col_age().eq(25).render();
281        assert_eq!(sql, "age = $1");
282        assert_eq!(params, vec![Value::Int(25)]);
283    }
284
285    #[test]
286    fn render_ne() {
287        let (sql, params) = col_age().ne(0).render();
288        assert_eq!(sql, "age != $1");
289        assert_eq!(params, vec![Value::Int(0)]);
290    }
291
292    #[test]
293    fn render_gt() {
294        let (sql, params) = col_age().gt(25).render();
295        assert_eq!(sql, "age > $1");
296        assert_eq!(params, vec![Value::Int(25)]);
297    }
298
299    #[test]
300    fn render_gte() {
301        let (sql, params) = col_age().gte(18).render();
302        assert_eq!(sql, "age >= $1");
303        assert_eq!(params, vec![Value::Int(18)]);
304    }
305
306    #[test]
307    fn render_lt() {
308        let (sql, params) = col_age().lt(65).render();
309        assert_eq!(sql, "age < $1");
310        assert_eq!(params, vec![Value::Int(65)]);
311    }
312
313    #[test]
314    fn render_lte() {
315        let (sql, params) = col_age().lte(100).render();
316        assert_eq!(sql, "age <= $1");
317        assert_eq!(params, vec![Value::Int(100)]);
318    }
319
320    // ── Range rendering ──
321
322    #[test]
323    fn render_between() {
324        let (sql, params) = col_age().between(18, 65).render();
325        assert_eq!(sql, "age BETWEEN $1 AND $2");
326        assert_eq!(params, vec![Value::Int(18), Value::Int(65)]);
327    }
328
329    #[test]
330    fn render_in_list() {
331        let (sql, params) = col_id().in_list(vec![1, 2, 3]).render();
332        assert_eq!(sql, "id IN ($1, $2, $3)");
333        assert_eq!(params, vec![Value::Int(1), Value::Int(2), Value::Int(3)]);
334    }
335
336    #[test]
337    fn render_in_list_single() {
338        let (sql, params) = col_id().in_list(vec![42]).render();
339        assert_eq!(sql, "id IN ($1)");
340        assert_eq!(params, vec![Value::Int(42)]);
341    }
342
343    #[test]
344    fn render_in_list_empty() {
345        let (sql, params) = col_id().in_list(Vec::<i32>::new()).render();
346        assert_eq!(sql, "1=0");
347        assert!(params.is_empty());
348    }
349
350    #[test]
351    fn render_not_in() {
352        let (sql, params) = col_id().not_in(vec![4, 5]).render();
353        assert_eq!(sql, "id NOT IN ($1, $2)");
354        assert_eq!(params, vec![Value::Int(4), Value::Int(5)]);
355    }
356
357    #[test]
358    fn render_not_in_empty() {
359        let (sql, params) = col_id().not_in(Vec::<i32>::new()).render();
360        assert_eq!(sql, "1=1");
361        assert!(params.is_empty());
362    }
363
364    // ── String rendering ──
365
366    #[test]
367    fn render_like() {
368        let (sql, params) = col_name().like("A%").render();
369        assert_eq!(sql, "name LIKE $1");
370        assert_eq!(params, vec![Value::String("A%".into())]);
371    }
372
373    #[test]
374    fn render_ilike() {
375        let (sql, params) = col_name().ilike("alice%").render();
376        assert_eq!(sql, "name ILIKE $1");
377        assert_eq!(params, vec![Value::String("alice%".into())]);
378    }
379
380    #[test]
381    fn render_contains() {
382        let (sql, params) = col_name().contains("foo").render();
383        assert_eq!(sql, "name LIKE $1");
384        assert_eq!(params, vec![Value::String("%foo%".into())]);
385    }
386
387    #[test]
388    fn render_starts_with() {
389        let (sql, params) = col_name().starts_with("A").render();
390        assert_eq!(sql, "name LIKE $1");
391        assert_eq!(params, vec![Value::String("A%".into())]);
392    }
393
394    #[test]
395    fn render_ends_with() {
396        let (sql, params) = col_name().ends_with("z").render();
397        assert_eq!(sql, "name LIKE $1");
398        assert_eq!(params, vec![Value::String("%z".into())]);
399    }
400
401    // ── Null rendering ──
402
403    #[test]
404    fn render_is_null() {
405        let (sql, params) = col_email().is_null().render();
406        assert_eq!(sql, "email IS NULL");
407        assert!(params.is_empty());
408    }
409
410    #[test]
411    fn render_is_not_null() {
412        let (sql, params) = col_email().is_not_null().render();
413        assert_eq!(sql, "email IS NOT NULL");
414        assert!(params.is_empty());
415    }
416
417    // ── Logical rendering ──
418
419    #[test]
420    fn render_and() {
421        let (sql, params) = col_age().gt(25).and(col_name().eq("Alice")).render();
422        assert_eq!(sql, "(age > $1 AND name = $2)");
423        assert_eq!(params, vec![Value::Int(25), Value::String("Alice".into())]);
424    }
425
426    #[test]
427    fn render_or() {
428        let (sql, params) = col_age().lt(18).or(col_age().gt(65)).render();
429        assert_eq!(sql, "(age < $1 OR age > $2)");
430        assert_eq!(params, vec![Value::Int(18), Value::Int(65)]);
431    }
432
433    #[test]
434    fn render_not() {
435        let (sql, params) = col_email().is_null().not().render();
436        assert_eq!(sql, "NOT (email IS NULL)");
437        assert!(params.is_empty());
438    }
439
440    #[test]
441    fn render_nested_and_or() {
442        // age > 25 AND (name = 'Alice' OR name = 'Bob')
443        let expr = col_age().gt(25).and(
444            col_name().eq("Alice").or(col_name().eq("Bob")),
445        );
446        let (sql, params) = expr.render();
447        assert_eq!(sql, "(age > $1 AND (name = $2 OR name = $3))");
448        assert_eq!(params, vec![
449            Value::Int(25),
450            Value::String("Alice".into()),
451            Value::String("Bob".into()),
452        ]);
453    }
454
455    #[test]
456    fn render_deep_nesting() {
457        // NOT (age < 18 OR age > 65)
458        let expr = col_age().lt(18).or(col_age().gt(65)).not();
459        let (sql, params) = expr.render();
460        assert_eq!(sql, "NOT ((age < $1 OR age > $2))");
461        assert_eq!(params, vec![Value::Int(18), Value::Int(65)]);
462    }
463
464    #[test]
465    fn render_complex_filter() {
466        // (age BETWEEN 18 AND 65) AND (email IS NOT NULL) OR (name = 'admin')
467        let expr = col_age()
468            .between(18, 65)
469            .and(col_email().is_not_null())
470            .or(col_name().eq("admin"));
471        let (sql, params) = expr.render();
472        assert_eq!(
473            sql,
474            "((age BETWEEN $1 AND $2 AND email IS NOT NULL) OR name = $3)"
475        );
476        assert_eq!(params, vec![
477            Value::Int(18),
478            Value::Int(65),
479            Value::String("admin".into()),
480        ]);
481    }
482
483    // ── Parameter counter continuity ──
484
485    #[test]
486    fn render_shared_counter() {
487        // Simulate two expressions sharing a counter (as in WHERE + HAVING)
488        let mut sql = String::new();
489        let mut params = Vec::new();
490        let mut idx = 0usize;
491
492        // First expression: age > $1
493        col_age().gt(25).to_sql(&mut sql, &mut params, &mut idx);
494        sql.push_str(" AND ");
495        // Second expression: name = $2 (continues from $1)
496        col_name().eq("Alice").to_sql(&mut sql, &mut params, &mut idx);
497
498        assert_eq!(sql, "age > $1 AND name = $2");
499        assert_eq!(idx, 2);
500        assert_eq!(params.len(), 2);
501    }
502
503    #[test]
504    fn render_many_params() {
505        // 5 IN list values → $1..$5, then one more → $6
506        let mut sql = String::new();
507        let mut params = Vec::new();
508        let mut idx = 0usize;
509
510        col_id().in_list(vec![1, 2, 3, 4, 5]).to_sql(&mut sql, &mut params, &mut idx);
511        sql.push_str(" AND ");
512        col_name().eq("test").to_sql(&mut sql, &mut params, &mut idx);
513
514        assert_eq!(sql, "id IN ($1, $2, $3, $4, $5) AND name = $6");
515        assert_eq!(params.len(), 6);
516        assert_eq!(idx, 6);
517    }
518
519    // ── ORDER BY rendering ──
520
521    #[test]
522    fn render_order_asc() {
523        assert_eq!(col_name().asc().render(), "name ASC");
524    }
525
526    #[test]
527    fn render_order_desc() {
528        assert_eq!(col_age().desc().render(), "age DESC");
529    }
530
531    #[test]
532    fn render_order_asc_nulls_last() {
533        assert_eq!(col_age().asc_nulls_last().render(), "age ASC NULLS LAST");
534    }
535
536    #[test]
537    fn render_order_desc_nulls_first() {
538        assert_eq!(col_age().desc_nulls_first().render(), "age DESC NULLS FIRST");
539    }
540
541    // ── Edge cases ──
542
543    #[test]
544    fn render_is_null_no_params() {
545        let (_, params) = col_email().is_null().render();
546        assert!(params.is_empty(), "IS NULL should not produce parameters");
547    }
548
549    #[test]
550    fn render_and_with_null() {
551        // age > 25 AND email IS NULL
552        let (sql, params) = col_age().gt(25).and(col_email().is_null()).render();
553        assert_eq!(sql, "(age > $1 AND email IS NULL)");
554        assert_eq!(params.len(), 1); // only one param from gt
555    }
556}