Skip to main content

fraiseql_wire/operators/
sql_gen.rs

1//! SQL generation from operators
2//!
3//! Converts operator enums to PostgreSQL WHERE clause SQL strings.
4//! Handles parameter binding, type casting, and operator-specific SQL generation
5//! for both JSONB and direct column sources.
6//!
7//! # Type Casting Strategy
8//!
9//! JSONB fields extracted with `->>` are always text. When comparing with non-string values,
10//! we apply explicit type casting:
11//!
12//! - String comparisons: No cast needed (text = text)
13//! - Numeric comparisons: Cast to integer or float (text::integer > $1)
14//! - Boolean comparisons: Cast to boolean (text::boolean = true)
15//! - Array comparisons: No special handling (uses array operators)
16//!
17//! Direct columns use native types from the database schema.
18
19use super::{Field, Value, WhereOperator};
20use crate::Result;
21use std::collections::HashMap;
22
23/// Infers the PostgreSQL type cast needed for a value
24///
25/// Returns the type cast suffix (e.g., "::integer", "::text") if needed
26fn infer_type_cast(value: &Value) -> &'static str {
27    match value {
28        Value::String(_) => "::text",
29        Value::Number(_) => "::numeric", // numeric handles both int and float
30        Value::Bool(_) => "::boolean",
31        Value::Null => "",          // no cast for NULL
32        Value::Array(_) => "",      // arrays handled by operators
33        Value::FloatArray(_) => "", // vector operators handle their own casting
34        Value::RawSql(_) => "",     // raw SQL is assumed correct
35    }
36}
37
38/// Generates SQL from a WHERE operator with parameter binding support
39///
40/// # Parameters
41///
42/// - `operator`: The WHERE operator to generate SQL for
43/// - `param_index`: Mutable reference to parameter counter (for $1, $2, etc.)
44/// - `params`: Mutable map to accumulate parameter values (for later binding)
45///
46/// # Returns
47///
48/// SQL string with parameter placeholders ($1, $2, etc.)
49///
50/// # Examples
51///
52/// ```ignore
53/// let mut param_index = 0;
54/// let mut params = HashMap::new();
55/// let op = WhereOperator::Eq(Field::JsonbField("name".to_string()), Value::String("John".to_string()));
56/// let sql = generate_where_operator_sql(&op, &mut param_index, &mut params)?;
57/// assert_eq!(sql, "(data->'name') = $1");
58/// assert_eq!(params[&1], Value::String("John".to_string()));
59/// ```
60pub fn generate_where_operator_sql(
61    operator: &WhereOperator,
62    param_index: &mut usize,
63    params: &mut HashMap<usize, Value>,
64) -> Result<String> {
65    operator.validate().map_err(crate::Error::InvalidSchema)?;
66
67    match operator {
68        // ============ Comparison Operators ============
69        // These operators work on both JSONB and direct columns.
70        // For JSONB text extraction, we apply type casting for proper comparison.
71        WhereOperator::Eq(field, value) => {
72            let field_sql = field.to_sql();
73            if value.is_null() {
74                Ok(format!("{} IS NULL", field_sql))
75            } else {
76                let param_num = *param_index + 1;
77                *param_index += 1;
78                params.insert(param_num, value.clone());
79                // JSONB fields need type cast for non-string comparisons
80                let cast = match field {
81                    Field::JsonbField(_) | Field::JsonbPath(_) => infer_type_cast(value),
82                    Field::DirectColumn(_) => "", // direct columns use native types
83                };
84                Ok(format!("{}{} = ${}", field_sql, cast, param_num))
85            }
86        }
87
88        WhereOperator::Neq(field, value) => {
89            let field_sql = field.to_sql();
90            if value.is_null() {
91                Ok(format!("{} IS NOT NULL", field_sql))
92            } else {
93                let param_num = *param_index + 1;
94                *param_index += 1;
95                params.insert(param_num, value.clone());
96                let cast = match field {
97                    Field::JsonbField(_) | Field::JsonbPath(_) => infer_type_cast(value),
98                    Field::DirectColumn(_) => "",
99                };
100                Ok(format!("{}{} != ${}", field_sql, cast, param_num))
101            }
102        }
103
104        WhereOperator::Gt(field, value) => {
105            let field_sql = field.to_sql();
106            let param_num = *param_index + 1;
107            *param_index += 1;
108            params.insert(param_num, value.clone());
109            let cast = match field {
110                Field::JsonbField(_) | Field::JsonbPath(_) => infer_type_cast(value),
111                Field::DirectColumn(_) => "",
112            };
113            Ok(format!("{}{} > ${}", field_sql, cast, param_num))
114        }
115
116        WhereOperator::Gte(field, value) => {
117            let field_sql = field.to_sql();
118            let param_num = *param_index + 1;
119            *param_index += 1;
120            params.insert(param_num, value.clone());
121            let cast = match field {
122                Field::JsonbField(_) | Field::JsonbPath(_) => infer_type_cast(value),
123                Field::DirectColumn(_) => "",
124            };
125            Ok(format!("{}{} >= ${}", field_sql, cast, param_num))
126        }
127
128        WhereOperator::Lt(field, value) => {
129            let field_sql = field.to_sql();
130            let param_num = *param_index + 1;
131            *param_index += 1;
132            params.insert(param_num, value.clone());
133            let cast = match field {
134                Field::JsonbField(_) | Field::JsonbPath(_) => infer_type_cast(value),
135                Field::DirectColumn(_) => "",
136            };
137            Ok(format!("{}{} < ${}", field_sql, cast, param_num))
138        }
139
140        WhereOperator::Lte(field, value) => {
141            let field_sql = field.to_sql();
142            let param_num = *param_index + 1;
143            *param_index += 1;
144            params.insert(param_num, value.clone());
145            let cast = match field {
146                Field::JsonbField(_) | Field::JsonbPath(_) => infer_type_cast(value),
147                Field::DirectColumn(_) => "",
148            };
149            Ok(format!("{}{} <= ${}", field_sql, cast, param_num))
150        }
151
152        // ============ Array Operators ============
153        WhereOperator::In(field, values) => {
154            let field_sql = field.to_sql();
155            let placeholders: Vec<String> = values
156                .iter()
157                .map(|v| {
158                    let param_num = *param_index + 1;
159                    *param_index += 1;
160                    params.insert(param_num, v.clone());
161                    format!("${}", param_num)
162                })
163                .collect();
164            Ok(format!("{} IN ({})", field_sql, placeholders.join(", ")))
165        }
166
167        WhereOperator::Nin(field, values) => {
168            let field_sql = field.to_sql();
169            let placeholders: Vec<String> = values
170                .iter()
171                .map(|v| {
172                    let param_num = *param_index + 1;
173                    *param_index += 1;
174                    params.insert(param_num, v.clone());
175                    format!("${}", param_num)
176                })
177                .collect();
178            Ok(format!(
179                "{} NOT IN ({})",
180                field_sql,
181                placeholders.join(", ")
182            ))
183        }
184
185        WhereOperator::Contains(field, substring) => {
186            let field_sql = field.to_sql();
187            let param_num = *param_index + 1;
188            *param_index += 1;
189            params.insert(param_num, Value::String(substring.clone()));
190            Ok(format!(
191                "{} LIKE '%' || ${}::text || '%'",
192                field_sql, param_num
193            ))
194        }
195
196        WhereOperator::ArrayContains(field, value) => {
197            let field_sql = field.to_sql();
198            let param_num = *param_index + 1;
199            *param_index += 1;
200            params.insert(param_num, value.clone());
201            Ok(format!("{} @> ARRAY[${}]", field_sql, param_num))
202        }
203
204        WhereOperator::ArrayContainedBy(field, value) => {
205            let field_sql = field.to_sql();
206            let param_num = *param_index + 1;
207            *param_index += 1;
208            params.insert(param_num, value.clone());
209            Ok(format!("{} <@ ARRAY[${}]", field_sql, param_num))
210        }
211
212        WhereOperator::ArrayOverlaps(field, values) => {
213            let field_sql = field.to_sql();
214            let placeholders: Vec<String> = values
215                .iter()
216                .map(|v| {
217                    let param_num = *param_index + 1;
218                    *param_index += 1;
219                    params.insert(param_num, v.clone());
220                    format!("${}", param_num)
221                })
222                .collect();
223            Ok(format!(
224                "{} && ARRAY[{}]",
225                field_sql,
226                placeholders.join(", ")
227            ))
228        }
229
230        // ============ Array Length Operators ============
231        WhereOperator::LenEq(field, len) => {
232            let field_sql = field.to_sql();
233            Ok(format!("array_length({}, 1) = {}", field_sql, len))
234        }
235
236        WhereOperator::LenGt(field, len) => {
237            let field_sql = field.to_sql();
238            Ok(format!("array_length({}, 1) > {}", field_sql, len))
239        }
240
241        WhereOperator::LenGte(field, len) => {
242            let field_sql = field.to_sql();
243            Ok(format!("array_length({}, 1) >= {}", field_sql, len))
244        }
245
246        WhereOperator::LenLt(field, len) => {
247            let field_sql = field.to_sql();
248            Ok(format!("array_length({}, 1) < {}", field_sql, len))
249        }
250
251        WhereOperator::LenLte(field, len) => {
252            let field_sql = field.to_sql();
253            Ok(format!("array_length({}, 1) <= {}", field_sql, len))
254        }
255
256        // ============ String Operators ============
257        WhereOperator::Icontains(field, substring) => {
258            let field_sql = field.to_sql();
259            let param_num = *param_index + 1;
260            *param_index += 1;
261            params.insert(param_num, Value::String(substring.clone()));
262            Ok(format!(
263                "{} ILIKE '%' || ${}::text || '%'",
264                field_sql, param_num
265            ))
266        }
267
268        WhereOperator::Startswith(field, prefix) => {
269            let field_sql = field.to_sql();
270            let param_num = *param_index + 1;
271            *param_index += 1;
272            params.insert(param_num, Value::String(format!("{}%", prefix)));
273            Ok(format!("{} LIKE ${}", field_sql, param_num))
274        }
275
276        WhereOperator::Istartswith(field, prefix) => {
277            let field_sql = field.to_sql();
278            let param_num = *param_index + 1;
279            *param_index += 1;
280            params.insert(param_num, Value::String(format!("{}%", prefix)));
281            Ok(format!("{} ILIKE ${}", field_sql, param_num))
282        }
283
284        WhereOperator::Endswith(field, suffix) => {
285            let field_sql = field.to_sql();
286            let param_num = *param_index + 1;
287            *param_index += 1;
288            params.insert(param_num, Value::String(format!("%{}", suffix)));
289            Ok(format!("{} LIKE ${}", field_sql, param_num))
290        }
291
292        WhereOperator::Iendswith(field, suffix) => {
293            let field_sql = field.to_sql();
294            let param_num = *param_index + 1;
295            *param_index += 1;
296            params.insert(param_num, Value::String(format!("%{}", suffix)));
297            Ok(format!("{} ILIKE ${}", field_sql, param_num))
298        }
299
300        WhereOperator::Like(field, pattern) => {
301            let field_sql = field.to_sql();
302            let param_num = *param_index + 1;
303            *param_index += 1;
304            params.insert(param_num, Value::String(pattern.clone()));
305            Ok(format!("{} LIKE ${}", field_sql, param_num))
306        }
307
308        WhereOperator::Ilike(field, pattern) => {
309            let field_sql = field.to_sql();
310            let param_num = *param_index + 1;
311            *param_index += 1;
312            params.insert(param_num, Value::String(pattern.clone()));
313            Ok(format!("{} ILIKE ${}", field_sql, param_num))
314        }
315
316        // ============ Null Operator ============
317        WhereOperator::IsNull(field, is_null) => {
318            let field_sql = field.to_sql();
319            if *is_null {
320                Ok(format!("{} IS NULL", field_sql))
321            } else {
322                Ok(format!("{} IS NOT NULL", field_sql))
323            }
324        }
325
326        // ============ Vector Distance Operators (pgvector) ============
327        WhereOperator::L2Distance {
328            field,
329            vector,
330            threshold,
331        } => {
332            let field_sql = field.to_sql();
333            let param_num = *param_index + 1;
334            *param_index += 1;
335            params.insert(param_num, Value::FloatArray(vector.clone()));
336            Ok(format!(
337                "l2_distance({}::vector, ${}::vector) < {}",
338                field_sql, param_num, threshold
339            ))
340        }
341
342        WhereOperator::CosineDistance {
343            field,
344            vector,
345            threshold,
346        } => {
347            let field_sql = field.to_sql();
348            let param_num = *param_index + 1;
349            *param_index += 1;
350            params.insert(param_num, Value::FloatArray(vector.clone()));
351            Ok(format!(
352                "cosine_distance({}::vector, ${}::vector) < {}",
353                field_sql, param_num, threshold
354            ))
355        }
356
357        WhereOperator::InnerProduct {
358            field,
359            vector,
360            threshold,
361        } => {
362            let field_sql = field.to_sql();
363            let param_num = *param_index + 1;
364            *param_index += 1;
365            params.insert(param_num, Value::FloatArray(vector.clone()));
366            Ok(format!(
367                "inner_product({}::vector, ${}::vector) > {}",
368                field_sql, param_num, threshold
369            ))
370        }
371
372        WhereOperator::L1Distance {
373            field,
374            vector,
375            threshold,
376        } => {
377            let field_sql = field.to_sql();
378            let param_num = *param_index + 1;
379            *param_index += 1;
380            params.insert(param_num, Value::FloatArray(vector.clone()));
381            Ok(format!(
382                "l1_distance({}::vector, ${}::vector) < {}",
383                field_sql, param_num, threshold
384            ))
385        }
386
387        WhereOperator::HammingDistance {
388            field,
389            vector,
390            threshold,
391        } => {
392            let field_sql = field.to_sql();
393            let param_num = *param_index + 1;
394            *param_index += 1;
395            params.insert(param_num, Value::FloatArray(vector.clone()));
396            Ok(format!(
397                "hamming_distance({}::bit, ${}::bit) < {}",
398                field_sql, param_num, threshold
399            ))
400        }
401
402        WhereOperator::JaccardDistance {
403            field,
404            set,
405            threshold,
406        } => {
407            let field_sql = field.to_sql();
408            let param_num = *param_index + 1;
409            *param_index += 1;
410            let value_array: Vec<Value> = set.iter().map(|s| Value::String(s.clone())).collect();
411            params.insert(param_num, Value::Array(value_array));
412            Ok(format!(
413                "jaccard_distance({}::text[], ${}::text[]) < {}",
414                field_sql, param_num, threshold
415            ))
416        }
417
418        // ============ Full-Text Search Operators ============
419        WhereOperator::Matches {
420            field,
421            query,
422            language,
423        } => {
424            let field_sql = field.to_sql();
425            let param_num = *param_index + 1;
426            *param_index += 1;
427            params.insert(param_num, Value::String(query.clone()));
428            let lang = language.as_deref().unwrap_or("english");
429            Ok(format!(
430                "{} @@ plainto_tsquery('{}', ${})",
431                field_sql, lang, param_num
432            ))
433        }
434
435        WhereOperator::PlainQuery { field, query } => {
436            let field_sql = field.to_sql();
437            let param_num = *param_index + 1;
438            *param_index += 1;
439            params.insert(param_num, Value::String(query.clone()));
440            Ok(format!(
441                "{} @@ plainto_tsquery(${})::tsvector",
442                field_sql, param_num
443            ))
444        }
445
446        WhereOperator::PhraseQuery {
447            field,
448            query,
449            language,
450        } => {
451            let field_sql = field.to_sql();
452            let param_num = *param_index + 1;
453            *param_index += 1;
454            params.insert(param_num, Value::String(query.clone()));
455            let lang = language.as_deref().unwrap_or("english");
456            Ok(format!(
457                "{} @@ phraseto_tsquery('{}', ${})",
458                field_sql, lang, param_num
459            ))
460        }
461
462        WhereOperator::WebsearchQuery {
463            field,
464            query,
465            language,
466        } => {
467            let field_sql = field.to_sql();
468            let param_num = *param_index + 1;
469            *param_index += 1;
470            params.insert(param_num, Value::String(query.clone()));
471            let lang = language.as_deref().unwrap_or("english");
472            Ok(format!(
473                "{} @@ websearch_to_tsquery('{}', ${})",
474                field_sql, lang, param_num
475            ))
476        }
477
478        // ============ Network/INET Operators ============
479        WhereOperator::IsIPv4(field) => {
480            let field_sql = field.to_sql();
481            Ok(format!("family({}::inet) = 4", field_sql))
482        }
483
484        WhereOperator::IsIPv6(field) => {
485            let field_sql = field.to_sql();
486            Ok(format!("family({}::inet) = 6", field_sql))
487        }
488
489        WhereOperator::IsPrivate(field) => {
490            let field_sql = field.to_sql();
491            // RFC1918 private ranges + link-local
492            Ok(format!(
493                "({}::inet << '10.0.0.0/8'::inet OR {}::inet << '172.16.0.0/12'::inet OR {}::inet << '192.168.0.0/16'::inet OR {}::inet << '169.254.0.0/16'::inet)",
494                field_sql, field_sql, field_sql, field_sql
495            ))
496        }
497
498        WhereOperator::IsPublic(field) => {
499            let field_sql = field.to_sql();
500            // NOT private (opposite of IsPrivate)
501            Ok(format!(
502                "NOT ({}::inet << '10.0.0.0/8'::inet OR {}::inet << '172.16.0.0/12'::inet OR {}::inet << '192.168.0.0/16'::inet OR {}::inet << '169.254.0.0/16'::inet)",
503                field_sql, field_sql, field_sql, field_sql
504            ))
505        }
506
507        WhereOperator::IsLoopback(field) => {
508            let field_sql = field.to_sql();
509            Ok(format!(
510                "(family({}::inet) = 4 AND {}::inet << '127.0.0.0/8'::inet) OR (family({}::inet) = 6 AND {}::inet << '::1/128'::inet)",
511                field_sql, field_sql, field_sql, field_sql
512            ))
513        }
514
515        WhereOperator::InSubnet { field, subnet } => {
516            let field_sql = field.to_sql();
517            let param_num = *param_index + 1;
518            *param_index += 1;
519            params.insert(param_num, Value::String(subnet.clone()));
520            Ok(format!("{}::inet << ${}::inet", field_sql, param_num))
521        }
522
523        WhereOperator::ContainsSubnet { field, subnet } => {
524            let field_sql = field.to_sql();
525            let param_num = *param_index + 1;
526            *param_index += 1;
527            params.insert(param_num, Value::String(subnet.clone()));
528            Ok(format!("{}::inet >> ${}::inet", field_sql, param_num))
529        }
530
531        WhereOperator::ContainsIP { field, ip } => {
532            let field_sql = field.to_sql();
533            let param_num = *param_index + 1;
534            *param_index += 1;
535            params.insert(param_num, Value::String(ip.clone()));
536            Ok(format!("{}::inet >> ${}::inet", field_sql, param_num))
537        }
538
539        WhereOperator::IPRangeOverlap { field, range } => {
540            let field_sql = field.to_sql();
541            let param_num = *param_index + 1;
542            *param_index += 1;
543            params.insert(param_num, Value::String(range.clone()));
544            Ok(format!("{}::inet && ${}::inet", field_sql, param_num))
545        }
546
547        // ============ JSONB Operators ============
548        WhereOperator::StrictlyContains(field, value) => {
549            let field_sql = field.to_sql();
550            let param_num = *param_index + 1;
551            *param_index += 1;
552            params.insert(param_num, value.clone());
553            Ok(format!("{}::jsonb @> ${}::jsonb", field_sql, param_num))
554        }
555
556        // ============ LTree Operators ============
557        WhereOperator::AncestorOf { field, path } => {
558            let field_sql = field.to_sql();
559            let param_num = *param_index + 1;
560            *param_index += 1;
561            params.insert(param_num, Value::String(path.clone()));
562            Ok(format!("{}::ltree @> ${}::ltree", field_sql, param_num))
563        }
564
565        WhereOperator::DescendantOf { field, path } => {
566            let field_sql = field.to_sql();
567            let param_num = *param_index + 1;
568            *param_index += 1;
569            params.insert(param_num, Value::String(path.clone()));
570            Ok(format!("{}::ltree <@ ${}::ltree", field_sql, param_num))
571        }
572
573        WhereOperator::MatchesLquery { field, pattern } => {
574            let field_sql = field.to_sql();
575            let param_num = *param_index + 1;
576            *param_index += 1;
577            params.insert(param_num, Value::String(pattern.clone()));
578            Ok(format!("{}::ltree ~ ${}::lquery", field_sql, param_num))
579        }
580
581        WhereOperator::MatchesLtxtquery { field, query } => {
582            let field_sql = field.to_sql();
583            let param_num = *param_index + 1;
584            *param_index += 1;
585            params.insert(param_num, Value::String(query.clone()));
586            Ok(format!("{}::ltree @ ${}::ltxtquery", field_sql, param_num))
587        }
588
589        WhereOperator::MatchesAnyLquery { field, patterns } => {
590            let field_sql = field.to_sql();
591            let placeholders: Vec<String> = patterns
592                .iter()
593                .map(|p| {
594                    let param_num = *param_index + 1;
595                    *param_index += 1;
596                    params.insert(param_num, Value::String(p.clone()));
597                    format!("${}::lquery", param_num)
598                })
599                .collect();
600            Ok(format!(
601                "{}::ltree ? ARRAY[{}]",
602                field_sql,
603                placeholders.join(", ")
604            ))
605        }
606
607        // ============ LTree Depth Operators ============
608        WhereOperator::DepthEq { field, depth } => {
609            let field_sql = field.to_sql();
610            Ok(format!("nlevel({}::ltree) = {}", field_sql, depth))
611        }
612
613        WhereOperator::DepthNeq { field, depth } => {
614            let field_sql = field.to_sql();
615            Ok(format!("nlevel({}::ltree) != {}", field_sql, depth))
616        }
617
618        WhereOperator::DepthGt { field, depth } => {
619            let field_sql = field.to_sql();
620            Ok(format!("nlevel({}::ltree) > {}", field_sql, depth))
621        }
622
623        WhereOperator::DepthGte { field, depth } => {
624            let field_sql = field.to_sql();
625            Ok(format!("nlevel({}::ltree) >= {}", field_sql, depth))
626        }
627
628        WhereOperator::DepthLt { field, depth } => {
629            let field_sql = field.to_sql();
630            Ok(format!("nlevel({}::ltree) < {}", field_sql, depth))
631        }
632
633        WhereOperator::DepthLte { field, depth } => {
634            let field_sql = field.to_sql();
635            Ok(format!("nlevel({}::ltree) <= {}", field_sql, depth))
636        }
637
638        // ============ LTree LCA Operator ============
639        WhereOperator::Lca { field, paths } => {
640            let field_sql = field.to_sql();
641            let placeholders: Vec<String> = paths
642                .iter()
643                .map(|p| {
644                    let param_num = *param_index + 1;
645                    *param_index += 1;
646                    params.insert(param_num, Value::String(p.clone()));
647                    format!("${}::ltree", param_num)
648                })
649                .collect();
650            Ok(format!(
651                "{}::ltree = lca(ARRAY[{}])",
652                field_sql,
653                placeholders.join(", ")
654            ))
655        }
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    #[test]
664    fn test_eq_operator_jsonb_string() {
665        let mut param_index = 0;
666        let mut params = HashMap::new();
667        let op = WhereOperator::Eq(
668            Field::JsonbField("name".to_string()),
669            Value::String("John".to_string()),
670        );
671        let sql = generate_where_operator_sql(&op, &mut param_index, &mut params).unwrap();
672        // JSONB string fields get ::text cast for proper text comparison
673        assert_eq!(sql, "(data->'name')::text = $1");
674        assert_eq!(param_index, 1);
675    }
676
677    #[test]
678    fn test_eq_operator_direct_column() {
679        let mut param_index = 0;
680        let mut params = HashMap::new();
681        let op = WhereOperator::Eq(
682            Field::DirectColumn("status".to_string()),
683            Value::String("active".to_string()),
684        );
685        let sql = generate_where_operator_sql(&op, &mut param_index, &mut params).unwrap();
686        // Direct columns don't need casting (use native types)
687        assert_eq!(sql, "status = $1");
688        assert_eq!(param_index, 1);
689    }
690
691    #[test]
692    fn test_len_eq_operator() {
693        let mut param_index = 0;
694        let mut params = HashMap::new();
695        let op = WhereOperator::LenEq(Field::JsonbField("tags".to_string()), 5);
696        let sql = generate_where_operator_sql(&op, &mut param_index, &mut params).unwrap();
697        assert_eq!(sql, "array_length((data->'tags'), 1) = 5");
698        assert_eq!(param_index, 0); // No parameters for length operators
699    }
700
701    #[test]
702    fn test_is_ipv4_operator() {
703        let mut param_index = 0;
704        let mut params = HashMap::new();
705        let op = WhereOperator::IsIPv4(Field::JsonbField("ip".to_string()));
706        let sql = generate_where_operator_sql(&op, &mut param_index, &mut params).unwrap();
707        assert_eq!(sql, "family((data->'ip')::inet) = 4");
708    }
709
710    #[test]
711    fn test_l2_distance_operator() {
712        let mut param_index = 0;
713        let mut params = HashMap::new();
714        let op = WhereOperator::L2Distance {
715            field: Field::JsonbField("embedding".to_string()),
716            vector: vec![0.1, 0.2, 0.3],
717            threshold: 0.5,
718        };
719        let sql = generate_where_operator_sql(&op, &mut param_index, &mut params).unwrap();
720        assert_eq!(
721            sql,
722            "l2_distance((data->'embedding')::vector, $1::vector) < 0.5"
723        );
724        assert_eq!(param_index, 1);
725    }
726
727    #[test]
728    fn test_in_operator() {
729        let mut param_index = 0;
730        let mut params = HashMap::new();
731        let op = WhereOperator::In(
732            Field::JsonbField("status".to_string()),
733            vec![
734                Value::String("active".to_string()),
735                Value::String("pending".to_string()),
736            ],
737        );
738        let sql = generate_where_operator_sql(&op, &mut param_index, &mut params).unwrap();
739        assert_eq!(sql, "(data->'status') IN ($1, $2)");
740        assert_eq!(param_index, 2);
741    }
742
743    // ============ LTree Operator Tests ============
744
745    #[test]
746    fn test_ltree_ancestor_of() {
747        let mut param_index = 0;
748        let mut params = HashMap::new();
749        let op = WhereOperator::AncestorOf {
750            field: Field::DirectColumn("path".to_string()),
751            path: "Top.Sciences.Astronomy".to_string(),
752        };
753        let sql = generate_where_operator_sql(&op, &mut param_index, &mut params).unwrap();
754        assert_eq!(sql, "path::ltree @> $1::ltree");
755        assert_eq!(param_index, 1);
756    }
757
758    #[test]
759    fn test_ltree_descendant_of() {
760        let mut param_index = 0;
761        let mut params = HashMap::new();
762        let op = WhereOperator::DescendantOf {
763            field: Field::DirectColumn("path".to_string()),
764            path: "Top.Sciences".to_string(),
765        };
766        let sql = generate_where_operator_sql(&op, &mut param_index, &mut params).unwrap();
767        assert_eq!(sql, "path::ltree <@ $1::ltree");
768        assert_eq!(param_index, 1);
769    }
770
771    #[test]
772    fn test_ltree_matches_lquery() {
773        let mut param_index = 0;
774        let mut params = HashMap::new();
775        let op = WhereOperator::MatchesLquery {
776            field: Field::DirectColumn("path".to_string()),
777            pattern: "Top.*.Ast*".to_string(),
778        };
779        let sql = generate_where_operator_sql(&op, &mut param_index, &mut params).unwrap();
780        assert_eq!(sql, "path::ltree ~ $1::lquery");
781        assert_eq!(param_index, 1);
782    }
783
784    #[test]
785    fn test_ltree_matches_ltxtquery() {
786        let mut param_index = 0;
787        let mut params = HashMap::new();
788        let op = WhereOperator::MatchesLtxtquery {
789            field: Field::DirectColumn("path".to_string()),
790            query: "Science & !Deprecated".to_string(),
791        };
792        let sql = generate_where_operator_sql(&op, &mut param_index, &mut params).unwrap();
793        assert_eq!(sql, "path::ltree @ $1::ltxtquery");
794        assert_eq!(param_index, 1);
795    }
796
797    #[test]
798    fn test_ltree_matches_any_lquery() {
799        let mut param_index = 0;
800        let mut params = HashMap::new();
801        let op = WhereOperator::MatchesAnyLquery {
802            field: Field::DirectColumn("path".to_string()),
803            patterns: vec!["Top.*".to_string(), "Other.*".to_string()],
804        };
805        let sql = generate_where_operator_sql(&op, &mut param_index, &mut params).unwrap();
806        assert_eq!(sql, "path::ltree ? ARRAY[$1::lquery, $2::lquery]");
807        assert_eq!(param_index, 2);
808    }
809
810    #[test]
811    fn test_ltree_depth_eq() {
812        let mut param_index = 0;
813        let mut params = HashMap::new();
814        let op = WhereOperator::DepthEq {
815            field: Field::DirectColumn("path".to_string()),
816            depth: 3,
817        };
818        let sql = generate_where_operator_sql(&op, &mut param_index, &mut params).unwrap();
819        assert_eq!(sql, "nlevel(path::ltree) = 3");
820        assert_eq!(param_index, 0); // Depth is inlined, not parameterized
821    }
822
823    #[test]
824    fn test_ltree_depth_gt() {
825        let mut param_index = 0;
826        let mut params = HashMap::new();
827        let op = WhereOperator::DepthGt {
828            field: Field::DirectColumn("path".to_string()),
829            depth: 2,
830        };
831        let sql = generate_where_operator_sql(&op, &mut param_index, &mut params).unwrap();
832        assert_eq!(sql, "nlevel(path::ltree) > 2");
833        assert_eq!(param_index, 0);
834    }
835
836    #[test]
837    fn test_ltree_depth_lte() {
838        let mut param_index = 0;
839        let mut params = HashMap::new();
840        let op = WhereOperator::DepthLte {
841            field: Field::DirectColumn("path".to_string()),
842            depth: 5,
843        };
844        let sql = generate_where_operator_sql(&op, &mut param_index, &mut params).unwrap();
845        assert_eq!(sql, "nlevel(path::ltree) <= 5");
846        assert_eq!(param_index, 0);
847    }
848
849    #[test]
850    fn test_ltree_lca() {
851        let mut param_index = 0;
852        let mut params = HashMap::new();
853        let op = WhereOperator::Lca {
854            field: Field::DirectColumn("path".to_string()),
855            paths: vec![
856                "Org.Engineering.Backend".to_string(),
857                "Org.Engineering.Frontend".to_string(),
858            ],
859        };
860        let sql = generate_where_operator_sql(&op, &mut param_index, &mut params).unwrap();
861        assert_eq!(sql, "path::ltree = lca(ARRAY[$1::ltree, $2::ltree])");
862        assert_eq!(param_index, 2);
863    }
864}