Skip to main content

cynos_database/
expr.rs

1//! Expression builders for query predicates.
2//!
3//! This module provides the `Column` and `Expr` types for building
4//! query predicates in a fluent API style.
5
6use crate::convert::js_to_value;
7use alloc::boxed::Box;
8use alloc::string::{String, ToString};
9use alloc::vec::Vec;
10use cynos_core::{DataType, Value};
11use cynos_query::ast::Expr as AstExpr;
12use wasm_bindgen::prelude::*;
13
14/// A column reference for building expressions.
15#[wasm_bindgen]
16#[derive(Clone, Debug)]
17pub struct Column {
18    table: Option<String>,
19    name: String,
20    index: Option<usize>,
21}
22
23#[wasm_bindgen]
24impl Column {
25    /// Creates a new column reference with table name.
26    #[wasm_bindgen(constructor)]
27    pub fn new(table: &str, name: &str) -> Self {
28        Self {
29            table: Some(table.to_string()),
30            name: name.to_string(),
31            index: None,
32        }
33    }
34
35    /// Creates a simple column reference without table name.
36    /// If the name contains a dot (e.g., "orders.year"), it will be parsed
37    /// as table.column.
38    pub fn new_simple(name: &str) -> Self {
39        if let Some(dot_pos) = name.find('.') {
40            let table = &name[..dot_pos];
41            let col = &name[dot_pos + 1..];
42            Self {
43                table: Some(table.to_string()),
44                name: col.to_string(),
45                index: None,
46            }
47        } else {
48            Self {
49                table: None,
50                name: name.to_string(),
51                index: None,
52            }
53        }
54    }
55
56    /// Sets the column index.
57    pub fn with_index(mut self, index: usize) -> Self {
58        self.index = Some(index);
59        self
60    }
61
62    /// Returns the column name.
63    #[wasm_bindgen(getter)]
64    pub fn name(&self) -> String {
65        self.name.clone()
66    }
67
68    /// Returns the table name if set.
69    #[wasm_bindgen(getter, js_name = tableName)]
70    pub fn table_name(&self) -> Option<String> {
71        self.table.clone()
72    }
73
74    /// Creates an equality expression: column = value
75    pub fn eq(&self, value: &JsValue) -> Expr {
76        Expr::comparison(self.clone(), ComparisonOp::Eq, value.clone())
77    }
78
79    /// Creates a not-equal expression: column != value
80    pub fn ne(&self, value: &JsValue) -> Expr {
81        Expr::comparison(self.clone(), ComparisonOp::Ne, value.clone())
82    }
83
84    /// Creates a greater-than expression: column > value
85    pub fn gt(&self, value: &JsValue) -> Expr {
86        Expr::comparison(self.clone(), ComparisonOp::Gt, value.clone())
87    }
88
89    /// Creates a greater-than-or-equal expression: column >= value
90    pub fn gte(&self, value: &JsValue) -> Expr {
91        Expr::comparison(self.clone(), ComparisonOp::Gte, value.clone())
92    }
93
94    /// Creates a less-than expression: column < value
95    pub fn lt(&self, value: &JsValue) -> Expr {
96        Expr::comparison(self.clone(), ComparisonOp::Lt, value.clone())
97    }
98
99    /// Creates a less-than-or-equal expression: column <= value
100    pub fn lte(&self, value: &JsValue) -> Expr {
101        Expr::comparison(self.clone(), ComparisonOp::Lte, value.clone())
102    }
103
104    /// Creates a BETWEEN expression: column BETWEEN low AND high
105    pub fn between(&self, low: &JsValue, high: &JsValue) -> Expr {
106        Expr::between(self.clone(), low.clone(), high.clone())
107    }
108
109    /// Creates a NOT BETWEEN expression: column NOT BETWEEN low AND high
110    #[wasm_bindgen(js_name = notBetween)]
111    pub fn not_between(&self, low: &JsValue, high: &JsValue) -> Expr {
112        Expr::not_between(self.clone(), low.clone(), high.clone())
113    }
114
115    /// Creates an IN expression: column IN (values)
116    #[wasm_bindgen(js_name = "in")]
117    pub fn in_(&self, values: &JsValue) -> Expr {
118        Expr::in_list(self.clone(), values.clone())
119    }
120
121    /// Creates a NOT IN expression: column NOT IN (values)
122    #[wasm_bindgen(js_name = notIn)]
123    pub fn not_in(&self, values: &JsValue) -> Expr {
124        Expr::not_in_list(self.clone(), values.clone())
125    }
126
127    /// Creates a LIKE expression: column LIKE pattern
128    pub fn like(&self, pattern: &str) -> Expr {
129        Expr::like(self.clone(), pattern)
130    }
131
132    /// Creates a NOT LIKE expression: column NOT LIKE pattern
133    #[wasm_bindgen(js_name = notLike)]
134    pub fn not_like(&self, pattern: &str) -> Expr {
135        Expr::not_like(self.clone(), pattern)
136    }
137
138    /// Creates a MATCH (regex) expression: column MATCH pattern
139    #[wasm_bindgen(js_name = "match")]
140    pub fn regex_match(&self, pattern: &str) -> Expr {
141        Expr::regex_match(self.clone(), pattern)
142    }
143
144    /// Creates a NOT MATCH (regex) expression: column NOT MATCH pattern
145    #[wasm_bindgen(js_name = notMatch)]
146    pub fn not_regex_match(&self, pattern: &str) -> Expr {
147        Expr::not_regex_match(self.clone(), pattern)
148    }
149
150    /// Creates an IS NULL expression
151    #[wasm_bindgen(js_name = isNull)]
152    pub fn is_null(&self) -> Expr {
153        Expr::is_null(self.clone())
154    }
155
156    /// Creates an IS NOT NULL expression
157    #[wasm_bindgen(js_name = isNotNull)]
158    pub fn is_not_null(&self) -> Expr {
159        Expr::is_not_null(self.clone())
160    }
161
162    /// Creates a JSONB path access expression
163    pub fn get(&self, path: &str) -> JsonbColumn {
164        JsonbColumn {
165            column: self.clone(),
166            path: path.to_string(),
167        }
168    }
169
170    /// Converts to AST expression.
171    pub(crate) fn to_ast(&self) -> AstExpr {
172        AstExpr::column(
173            self.table.as_deref().unwrap_or(""),
174            &self.name,
175            self.index.unwrap_or(0),
176        )
177    }
178}
179
180/// A JSONB column with path access.
181#[wasm_bindgen]
182#[derive(Clone, Debug)]
183pub struct JsonbColumn {
184    column: Column,
185    path: String,
186}
187
188#[wasm_bindgen]
189impl JsonbColumn {
190    /// Creates an equality expression for the JSONB path value.
191    pub fn eq(&self, value: &JsValue) -> Expr {
192        Expr::jsonb_eq(self.column.clone(), &self.path, value.clone())
193    }
194
195    /// Creates a contains expression for the JSONB path.
196    pub fn contains(&self, value: &JsValue) -> Expr {
197        Expr::jsonb_contains(self.column.clone(), &self.path, value.clone())
198    }
199
200    /// Creates an exists expression for the JSONB path.
201    pub fn exists(&self) -> Expr {
202        Expr::jsonb_exists(self.column.clone(), &self.path)
203    }
204}
205
206/// Comparison operators.
207#[derive(Clone, Copy, Debug, PartialEq, Eq)]
208pub enum ComparisonOp {
209    Eq,
210    Ne,
211    Gt,
212    Gte,
213    Lt,
214    Lte,
215}
216
217/// Expression type for query predicates.
218#[wasm_bindgen]
219#[derive(Clone, Debug)]
220pub struct Expr {
221    inner: ExprInner,
222}
223
224#[derive(Clone, Debug)]
225#[allow(dead_code)]
226pub(crate) enum ExprInner {
227    Comparison {
228        column: Column,
229        op: ComparisonOp,
230        value: JsValue,
231    },
232    Between {
233        column: Column,
234        low: JsValue,
235        high: JsValue,
236    },
237    NotBetween {
238        column: Column,
239        low: JsValue,
240        high: JsValue,
241    },
242    InList {
243        column: Column,
244        values: JsValue,
245    },
246    NotInList {
247        column: Column,
248        values: JsValue,
249    },
250    Like {
251        column: Column,
252        pattern: String,
253    },
254    NotLike {
255        column: Column,
256        pattern: String,
257    },
258    Match {
259        column: Column,
260        pattern: String,
261    },
262    NotMatch {
263        column: Column,
264        pattern: String,
265    },
266    IsNull {
267        column: Column,
268    },
269    IsNotNull {
270        column: Column,
271    },
272    JsonbEq {
273        column: Column,
274        path: String,
275        value: JsValue,
276    },
277    JsonbContains {
278        column: Column,
279        path: String,
280        value: JsValue,
281    },
282    JsonbExists {
283        column: Column,
284        path: String,
285    },
286    And {
287        left: Box<Expr>,
288        right: Box<Expr>,
289    },
290    Or {
291        left: Box<Expr>,
292        right: Box<Expr>,
293    },
294    Not {
295        inner: Box<Expr>,
296    },
297    ColumnRef {
298        column: Column,
299    },
300    Literal {
301        value: JsValue,
302    },
303    True,
304}
305
306impl Expr {
307    pub(crate) fn comparison(column: Column, op: ComparisonOp, value: JsValue) -> Self {
308        Self {
309            inner: ExprInner::Comparison { column, op, value },
310        }
311    }
312
313    pub(crate) fn between(column: Column, low: JsValue, high: JsValue) -> Self {
314        Self {
315            inner: ExprInner::Between { column, low, high },
316        }
317    }
318
319    pub(crate) fn not_between(column: Column, low: JsValue, high: JsValue) -> Self {
320        Self {
321            inner: ExprInner::NotBetween { column, low, high },
322        }
323    }
324
325    pub(crate) fn in_list(column: Column, values: JsValue) -> Self {
326        Self {
327            inner: ExprInner::InList { column, values },
328        }
329    }
330
331    pub(crate) fn not_in_list(column: Column, values: JsValue) -> Self {
332        Self {
333            inner: ExprInner::NotInList { column, values },
334        }
335    }
336
337    pub(crate) fn like(column: Column, pattern: &str) -> Self {
338        Self {
339            inner: ExprInner::Like {
340                column,
341                pattern: pattern.to_string(),
342            },
343        }
344    }
345
346    pub(crate) fn not_like(column: Column, pattern: &str) -> Self {
347        Self {
348            inner: ExprInner::NotLike {
349                column,
350                pattern: pattern.to_string(),
351            },
352        }
353    }
354
355    pub(crate) fn regex_match(column: Column, pattern: &str) -> Self {
356        Self {
357            inner: ExprInner::Match {
358                column,
359                pattern: pattern.to_string(),
360            },
361        }
362    }
363
364    pub(crate) fn not_regex_match(column: Column, pattern: &str) -> Self {
365        Self {
366            inner: ExprInner::NotMatch {
367                column,
368                pattern: pattern.to_string(),
369            },
370        }
371    }
372
373    pub(crate) fn is_null(column: Column) -> Self {
374        Self {
375            inner: ExprInner::IsNull { column },
376        }
377    }
378
379    pub(crate) fn is_not_null(column: Column) -> Self {
380        Self {
381            inner: ExprInner::IsNotNull { column },
382        }
383    }
384
385    pub(crate) fn jsonb_eq(column: Column, path: &str, value: JsValue) -> Self {
386        Self {
387            inner: ExprInner::JsonbEq {
388                column,
389                path: path.to_string(),
390                value,
391            },
392        }
393    }
394
395    pub(crate) fn jsonb_contains(column: Column, path: &str, value: JsValue) -> Self {
396        Self {
397            inner: ExprInner::JsonbContains {
398                column,
399                path: path.to_string(),
400                value,
401            },
402        }
403    }
404
405    pub(crate) fn jsonb_exists(column: Column, path: &str) -> Self {
406        Self {
407            inner: ExprInner::JsonbExists {
408                column,
409                path: path.to_string(),
410            },
411        }
412    }
413
414    #[allow(dead_code)]
415    pub(crate) fn column_ref(column: Column) -> Self {
416        Self {
417            inner: ExprInner::ColumnRef { column },
418        }
419    }
420
421    #[allow(dead_code)]
422    pub(crate) fn literal(value: JsValue) -> Self {
423        Self {
424            inner: ExprInner::Literal { value },
425        }
426    }
427
428    #[allow(dead_code)]
429    pub(crate) fn true_expr() -> Self {
430        Self {
431            inner: ExprInner::True,
432        }
433    }
434
435    /// Returns the inner expression type.
436    pub(crate) fn inner(&self) -> &ExprInner {
437        &self.inner
438    }
439}
440
441#[wasm_bindgen]
442impl Expr {
443    /// Creates an AND expression: self AND other
444    pub fn and(&self, other: &Expr) -> Expr {
445        Expr {
446            inner: ExprInner::And {
447                left: Box::new(self.clone()),
448                right: Box::new(other.clone()),
449            },
450        }
451    }
452
453    /// Creates an OR expression: self OR other
454    pub fn or(&self, other: &Expr) -> Expr {
455        Expr {
456            inner: ExprInner::Or {
457                left: Box::new(self.clone()),
458                right: Box::new(other.clone()),
459            },
460        }
461    }
462
463    /// Creates a NOT expression: NOT self
464    pub fn not(&self) -> Expr {
465        Expr {
466            inner: ExprInner::Not {
467                inner: Box::new(self.clone()),
468            },
469        }
470    }
471}
472
473impl Expr {
474    /// Converts to AST expression for JOIN conditions where table names are needed.
475    pub(crate) fn to_ast_with_table(&self, get_column_info: &impl Fn(&str) -> Option<(String, usize, DataType)>) -> AstExpr {
476        match &self.inner {
477            ExprInner::Comparison { column, op, value } => {
478                // Build the lookup key: if table is set, use "table.column", otherwise just "column"
479                let lookup_key = if let Some(ref table) = column.table {
480                    alloc::format!("{}.{}", table, column.name)
481                } else {
482                    column.name.clone()
483                };
484
485                let col_expr = if let Some((table, idx, _dt)) = get_column_info(&lookup_key) {
486                    let table_name = if table.is_empty() {
487                        column.table.as_deref().unwrap_or("")
488                    } else {
489                        &table
490                    };
491                    AstExpr::column(table_name, &column.name, idx)
492                } else {
493                    column.to_ast()
494                };
495
496                // Check if value is a column reference (string that matches a column name)
497                // or a Column object (check by looking for 'name' property)
498                let right_expr = if let Some(s) = value.as_string() {
499                    if let Some((table, idx, _dt)) = get_column_info(&s) {
500                        // Value is a column reference - extract just the column name if qualified
501                        let col_name = if let Some(dot_pos) = s.find('.') {
502                            &s[dot_pos + 1..]
503                        } else {
504                            &s
505                        };
506                        AstExpr::column(&table, col_name, idx)
507                    } else {
508                        // Value is a string literal
509                        let val = if let Some((_, _, dt)) = get_column_info(&lookup_key) {
510                            js_to_value(value, dt).unwrap_or(Value::String(s))
511                        } else {
512                            Value::String(s)
513                        };
514                        AstExpr::literal(val)
515                    }
516                } else if value.is_object() {
517                    // Check if it's a Column object by looking for 'name' property
518                    if let Ok(name_val) = js_sys::Reflect::get(value, &JsValue::from_str("name")) {
519                        if let Some(col_name) = name_val.as_string() {
520                            // Get table name if present
521                            let table_name = js_sys::Reflect::get(value, &JsValue::from_str("tableName"))
522                                .ok()
523                                .and_then(|v| v.as_string());
524
525                            // Build lookup key with table prefix if present
526                            let col_lookup = if let Some(ref tbl) = table_name {
527                                alloc::format!("{}.{}", tbl, col_name)
528                            } else {
529                                col_name.clone()
530                            };
531
532                            if let Some((table, idx, _dt)) = get_column_info(&col_lookup) {
533                                AstExpr::column(&table, &col_name, idx)
534                            } else {
535                                // Fallback: use the column's own info
536                                AstExpr::column(table_name.as_deref().unwrap_or(""), &col_name, 0)
537                            }
538                        } else {
539                            // Not a Column object, treat as literal
540                            AstExpr::literal(Value::Null)
541                        }
542                    } else {
543                        // Not a Column object, treat as literal
544                        AstExpr::literal(Value::Null)
545                    }
546                } else {
547                    let val = if let Some((_, _, dt)) = get_column_info(&lookup_key) {
548                        js_to_value(value, dt).unwrap_or(Value::Null)
549                    } else {
550                        // Try to infer type
551                        if let Some(n) = value.as_f64() {
552                            if n.fract() == 0.0 {
553                                Value::Int64(n as i64)
554                            } else {
555                                Value::Float64(n)
556                            }
557                        } else if let Some(b) = value.as_bool() {
558                            Value::Boolean(b)
559                        } else {
560                            Value::Null
561                        }
562                    };
563                    AstExpr::literal(val)
564                };
565
566                match op {
567                    ComparisonOp::Eq => AstExpr::eq(col_expr, right_expr),
568                    ComparisonOp::Ne => AstExpr::ne(col_expr, right_expr),
569                    ComparisonOp::Gt => AstExpr::gt(col_expr, right_expr),
570                    ComparisonOp::Gte => AstExpr::gte(col_expr, right_expr),
571                    ComparisonOp::Lt => AstExpr::lt(col_expr, right_expr),
572                    ComparisonOp::Lte => AstExpr::lte(col_expr, right_expr),
573                }
574            }
575            ExprInner::Between { column, low, high } => {
576                let (table, idx, dt) = get_column_info(&column.name).unwrap_or((String::new(), 0, DataType::Float64));
577                let table_name = if table.is_empty() {
578                    column.table.as_deref().unwrap_or("")
579                } else {
580                    &table
581                };
582                let col_expr = AstExpr::column(table_name, &column.name, idx);
583                let low_val = js_to_value(low, dt).unwrap_or(Value::Null);
584                let high_val = js_to_value(high, dt).unwrap_or(Value::Null);
585                AstExpr::between(col_expr, AstExpr::literal(low_val), AstExpr::literal(high_val))
586            }
587            ExprInner::NotBetween { column, low, high } => {
588                let (_, idx, dt) = get_column_info(&column.name).unwrap_or((String::new(), 0, DataType::Float64));
589                let col_expr = AstExpr::column(column.table.as_deref().unwrap_or(""), &column.name, idx);
590                let low_val = js_to_value(low, dt).unwrap_or(Value::Null);
591                let high_val = js_to_value(high, dt).unwrap_or(Value::Null);
592                AstExpr::not_between(col_expr, AstExpr::literal(low_val), AstExpr::literal(high_val))
593            }
594            ExprInner::InList { column, values } => {
595                let (_, idx, dt) = get_column_info(&column.name).unwrap_or((String::new(), 0, DataType::String));
596                let col_expr = AstExpr::column(column.table.as_deref().unwrap_or(""), &column.name, idx);
597
598                let arr = js_sys::Array::from(values);
599                let vals: Vec<Value> = arr
600                    .iter()
601                    .filter_map(|v| js_to_value(&v, dt).ok())
602                    .collect();
603
604                AstExpr::in_list(col_expr, vals)
605            }
606            ExprInner::NotInList { column, values } => {
607                let (_, idx, dt) = get_column_info(&column.name).unwrap_or((String::new(), 0, DataType::String));
608                let col_expr = AstExpr::column(column.table.as_deref().unwrap_or(""), &column.name, idx);
609
610                let arr = js_sys::Array::from(values);
611                let vals: Vec<Value> = arr
612                    .iter()
613                    .filter_map(|v| js_to_value(&v, dt).ok())
614                    .collect();
615
616                AstExpr::not_in_list(col_expr, vals)
617            }
618            ExprInner::Like { column, pattern } => {
619                let idx = get_column_info(&column.name).map(|(_, i, _)| i).unwrap_or(0);
620                let col_expr = AstExpr::column(column.table.as_deref().unwrap_or(""), &column.name, idx);
621                AstExpr::like(col_expr, pattern)
622            }
623            ExprInner::NotLike { column, pattern } => {
624                let idx = get_column_info(&column.name).map(|(_, i, _)| i).unwrap_or(0);
625                let col_expr = AstExpr::column(column.table.as_deref().unwrap_or(""), &column.name, idx);
626                AstExpr::not_like(col_expr, pattern)
627            }
628            ExprInner::Match { column, pattern } => {
629                let idx = get_column_info(&column.name).map(|(_, i, _)| i).unwrap_or(0);
630                let col_expr = AstExpr::column(column.table.as_deref().unwrap_or(""), &column.name, idx);
631                AstExpr::regex_match(col_expr, pattern)
632            }
633            ExprInner::NotMatch { column, pattern } => {
634                let idx = get_column_info(&column.name).map(|(_, i, _)| i).unwrap_or(0);
635                let col_expr = AstExpr::column(column.table.as_deref().unwrap_or(""), &column.name, idx);
636                AstExpr::not_regex_match(col_expr, pattern)
637            }
638            ExprInner::IsNull { column } => {
639                let idx = get_column_info(&column.name).map(|(_, i, _)| i).unwrap_or(0);
640                let col_expr = AstExpr::column(column.table.as_deref().unwrap_or(""), &column.name, idx);
641                AstExpr::is_null(col_expr)
642            }
643            ExprInner::IsNotNull { column } => {
644                let idx = get_column_info(&column.name).map(|(_, i, _)| i).unwrap_or(0);
645                let col_expr = AstExpr::column(column.table.as_deref().unwrap_or(""), &column.name, idx);
646                AstExpr::is_not_null(col_expr)
647            }
648            ExprInner::JsonbEq { column, path, value } => {
649                // JSONB path equality - use get_column_info to get correct index
650                let col_expr = if let Some((_, idx, _)) = get_column_info(&column.name) {
651                    AstExpr::column(column.table.as_deref().unwrap_or(""), &column.name, idx)
652                } else {
653                    column.to_ast()
654                };
655                let val = if let Some(s) = value.as_string() {
656                    Value::String(s)
657                } else if let Some(n) = value.as_f64() {
658                    Value::Float64(n)
659                } else {
660                    Value::Null
661                };
662                AstExpr::jsonb_path_eq(col_expr, path, val)
663            }
664            ExprInner::JsonbContains { column, path, value: _ } => {
665                let col_expr = if let Some((_, idx, _)) = get_column_info(&column.name) {
666                    AstExpr::column(column.table.as_deref().unwrap_or(""), &column.name, idx)
667                } else {
668                    column.to_ast()
669                };
670                AstExpr::jsonb_contains(col_expr, path)
671            }
672            ExprInner::JsonbExists { column, path } => {
673                let col_expr = if let Some((_, idx, _)) = get_column_info(&column.name) {
674                    AstExpr::column(column.table.as_deref().unwrap_or(""), &column.name, idx)
675                } else {
676                    column.to_ast()
677                };
678                AstExpr::jsonb_exists(col_expr, path)
679            }
680            ExprInner::And { left, right } => {
681                let left_ast = left.to_ast_with_table(get_column_info);
682                let right_ast = right.to_ast_with_table(get_column_info);
683                AstExpr::and(left_ast, right_ast)
684            }
685            ExprInner::Or { left, right } => {
686                let left_ast = left.to_ast_with_table(get_column_info);
687                let right_ast = right.to_ast_with_table(get_column_info);
688                AstExpr::or(left_ast, right_ast)
689            }
690            ExprInner::Not { inner } => {
691                let inner_ast = inner.to_ast_with_table(get_column_info);
692                AstExpr::not(inner_ast)
693            }
694            ExprInner::ColumnRef { column } => column.to_ast(),
695            ExprInner::Literal { value } => {
696                let val = if let Some(n) = value.as_f64() {
697                    if n.fract() == 0.0 {
698                        Value::Int64(n as i64)
699                    } else {
700                        Value::Float64(n)
701                    }
702                } else if let Some(s) = value.as_string() {
703                    Value::String(s)
704                } else if let Some(b) = value.as_bool() {
705                    Value::Boolean(b)
706                } else {
707                    Value::Null
708                };
709                AstExpr::literal(val)
710            }
711            ExprInner::True => AstExpr::literal(Value::Boolean(true)),
712        }
713    }
714}
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719    use wasm_bindgen_test::*;
720
721    wasm_bindgen_test_configure!(run_in_browser);
722
723    #[wasm_bindgen_test]
724    fn test_column_eq() {
725        let col = Column::new_simple("age");
726        let expr = col.eq(&JsValue::from_f64(25.0));
727
728        match &expr.inner {
729            ExprInner::Comparison { column, op, .. } => {
730                assert_eq!(column.name, "age");
731                assert_eq!(*op, ComparisonOp::Eq);
732            }
733            _ => panic!("Expected Comparison"),
734        }
735    }
736
737    #[wasm_bindgen_test]
738    fn test_column_gt() {
739        let col = Column::new_simple("age");
740        let expr = col.gt(&JsValue::from_f64(18.0));
741
742        match &expr.inner {
743            ExprInner::Comparison { op, .. } => {
744                assert_eq!(*op, ComparisonOp::Gt);
745            }
746            _ => panic!("Expected Comparison"),
747        }
748    }
749
750    #[wasm_bindgen_test]
751    fn test_expr_and() {
752        let col = Column::new_simple("age");
753        let expr1 = col.gt(&JsValue::from_f64(18.0));
754        let expr2 = col.lt(&JsValue::from_f64(65.0));
755        let combined = expr1.and(&expr2);
756
757        match &combined.inner {
758            ExprInner::And { .. } => {}
759            _ => panic!("Expected And"),
760        }
761    }
762
763    #[wasm_bindgen_test]
764    fn test_expr_or() {
765        let col = Column::new_simple("status");
766        let expr1 = col.eq(&JsValue::from_str("active"));
767        let expr2 = col.eq(&JsValue::from_str("pending"));
768        let combined = expr1.or(&expr2);
769
770        match &combined.inner {
771            ExprInner::Or { .. } => {}
772            _ => panic!("Expected Or"),
773        }
774    }
775
776    #[wasm_bindgen_test]
777    fn test_expr_not() {
778        let col = Column::new_simple("deleted");
779        let expr = col.eq(&JsValue::from_bool(true)).not();
780
781        match &expr.inner {
782            ExprInner::Not { .. } => {}
783            _ => panic!("Expected Not"),
784        }
785    }
786
787    #[wasm_bindgen_test]
788    fn test_column_is_null() {
789        let col = Column::new_simple("email");
790        let expr = col.is_null();
791
792        match &expr.inner {
793            ExprInner::IsNull { column } => {
794                assert_eq!(column.name, "email");
795            }
796            _ => panic!("Expected IsNull"),
797        }
798    }
799
800    #[wasm_bindgen_test]
801    fn test_column_between() {
802        let col = Column::new_simple("age");
803        let expr = col.between(&JsValue::from_f64(18.0), &JsValue::from_f64(65.0));
804
805        match &expr.inner {
806            ExprInner::Between { column, .. } => {
807                assert_eq!(column.name, "age");
808            }
809            _ => panic!("Expected Between"),
810        }
811    }
812
813    #[wasm_bindgen_test]
814    fn test_column_like() {
815        let col = Column::new_simple("name");
816        let expr = col.like("Alice%");
817
818        match &expr.inner {
819            ExprInner::Like { column, pattern } => {
820                assert_eq!(column.name, "name");
821                assert_eq!(pattern, "Alice%");
822            }
823            _ => panic!("Expected Like"),
824        }
825    }
826}