Skip to main content

fraiseql_wire/operators/sql_gen/
mod.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/// Escapes LIKE metacharacters in a literal string.
24///
25/// Escapes `\`, `%`, and `_` so that user-supplied substrings, prefixes, and
26/// suffixes are always treated as literals inside a `LIKE` or `ILIKE` pattern.
27/// PostgreSQL uses `\` as the default LIKE escape character.
28pub(crate) fn escape_like_literal(s: &str) -> String {
29    // Order matters: escape `\` first to avoid double-escaping.
30    s.replace('\\', "\\\\")
31        .replace('%', "\\%")
32        .replace('_', "\\_")
33}
34
35/// Infers the PostgreSQL type cast needed for a value
36///
37/// Returns the type cast suffix (e.g., "`::integer`", "`::text`") if needed
38const fn infer_type_cast(value: &Value) -> &'static str {
39    match value {
40        Value::String(_) => "::text",
41        Value::Number(_) => "::numeric", // numeric handles both int and float
42        Value::Bool(_) => "::boolean",
43        Value::Null => "",          // no cast for NULL
44        Value::Array(_) => "",      // arrays handled by operators
45        Value::FloatArray(_) => "", // vector operators handle their own casting
46        Value::RawSql(_) => "",     // raw SQL is assumed correct
47    }
48}
49
50/// Generates a CIDR containment check for network classification operators.
51///
52/// Produces SQL that tests whether a field (cast to `inet`) is strictly contained
53/// within one or more CIDR ranges using the `<<` operator.
54///
55/// When `negate` is true, the entire expression is wrapped in `NOT (...)`.
56///
57/// # Examples
58///
59/// ```text
60/// // negate=false, single range:
61/// (field::inet << '100.64.0.0/10'::inet)
62///
63/// // negate=false, multiple ranges:
64/// (field::inet << '224.0.0.0/4'::inet OR field::inet << 'ff00::/8'::inet)
65///
66/// // negate=true:
67/// NOT (field::inet << '224.0.0.0/4'::inet OR field::inet << 'ff00::/8'::inet)
68/// ```
69pub(crate) fn cidr_containment_check(field_sql: &str, ranges: &[&str], negate: bool) -> String {
70    let conditions: Vec<String> = ranges
71        .iter()
72        .map(|r| format!("{field_sql}::inet << '{r}'::inet"))
73        .collect();
74    let inner = format!("({})", conditions.join(" OR "));
75    if negate {
76        format!("NOT {inner}")
77    } else {
78        inner
79    }
80}
81
82/// CIDR ranges for RFC1918 private addresses plus IPv6 unique-local.
83const PRIVATE_RANGES: &[&str] = &["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7"];
84
85/// CIDR ranges for loopback addresses.
86const LOOPBACK_RANGES: &[&str] = &["127.0.0.0/8", "::1/128"];
87
88/// CIDR ranges for multicast addresses (RFC 3171, RFC 4291).
89const MULTICAST_RANGES: &[&str] = &["224.0.0.0/4", "ff00::/8"];
90
91/// CIDR ranges for link-local addresses (RFC 3927, RFC 4291).
92const LINK_LOCAL_RANGES: &[&str] = &["169.254.0.0/16", "fe80::/10"];
93
94/// CIDR ranges for documentation addresses (RFC 5737, RFC 3849).
95const DOCUMENTATION_RANGES: &[&str] = &[
96    "192.0.2.0/24",
97    "198.51.100.0/24",
98    "203.0.113.0/24",
99    "2001:db8::/32",
100];
101
102/// CIDR ranges for carrier-grade NAT (RFC 6598, IPv4 only).
103const CARRIER_GRADE_RANGES: &[&str] = &["100.64.0.0/10"];
104
105/// Generates SQL from a WHERE operator with parameter binding support
106///
107/// # Parameters
108///
109/// - `operator`: The WHERE operator to generate SQL for
110/// - `param_index`: Mutable reference to parameter counter (for $1, $2, etc.)
111/// - `params`: Mutable map to accumulate parameter values (for later binding)
112///
113/// # Returns
114///
115/// SQL string with parameter placeholders ($1, $2, etc.)
116///
117/// # Errors
118///
119/// Returns `WireError::InvalidSchema` if the operator fails validation (e.g., invalid
120/// field names or unsupported value types for the given operator).
121///
122/// # Examples
123///
124/// ```no_run
125/// // Requires: fraiseql_wire::operators re-exports; Value has no PartialEq so assert_eq on params omitted.
126/// use std::collections::HashMap;
127/// use fraiseql_wire::operators::{Field, Value, WhereOperator, generate_where_operator_sql};
128/// let mut param_index = 0;
129/// let mut params = HashMap::new();
130/// let op = WhereOperator::Eq(Field::JsonbField("name".to_string()), Value::String("John".to_string()));
131/// let sql = generate_where_operator_sql(&op, &mut param_index, &mut params).unwrap();
132/// assert_eq!(sql, "(data->'name')::text = $1");
133/// ```
134pub fn generate_where_operator_sql(
135    operator: &WhereOperator,
136    param_index: &mut usize,
137    params: &mut HashMap<usize, Value>,
138) -> Result<String> {
139    operator
140        .validate()
141        .map_err(crate::WireError::InvalidSchema)?;
142
143    match operator {
144        // ============ Comparison Operators ============
145        // These operators work on both JSONB and direct columns.
146        // For JSONB text extraction, we apply type casting for proper comparison.
147        WhereOperator::Eq(field, value) => {
148            let field_sql = field.to_sql();
149            if value.is_null() {
150                Ok(format!("{} IS NULL", field_sql))
151            } else {
152                let param_num = *param_index + 1;
153                *param_index += 1;
154                params.insert(param_num, value.clone());
155                // JSONB fields need type cast for non-string comparisons
156                let cast = match field {
157                    Field::JsonbField(_) | Field::JsonbPath(_) => infer_type_cast(value),
158                    Field::DirectColumn(_) => "", // direct columns use native types
159                };
160                Ok(format!("{}{} = ${}", field_sql, cast, param_num))
161            }
162        }
163
164        WhereOperator::Neq(field, value) => {
165            let field_sql = field.to_sql();
166            if value.is_null() {
167                Ok(format!("{} IS NOT NULL", field_sql))
168            } else {
169                let param_num = *param_index + 1;
170                *param_index += 1;
171                params.insert(param_num, value.clone());
172                let cast = match field {
173                    Field::JsonbField(_) | Field::JsonbPath(_) => infer_type_cast(value),
174                    Field::DirectColumn(_) => "",
175                };
176                Ok(format!("{}{} != ${}", field_sql, cast, param_num))
177            }
178        }
179
180        WhereOperator::Gt(field, value) => {
181            let field_sql = field.to_sql();
182            let param_num = *param_index + 1;
183            *param_index += 1;
184            params.insert(param_num, value.clone());
185            let cast = match field {
186                Field::JsonbField(_) | Field::JsonbPath(_) => infer_type_cast(value),
187                Field::DirectColumn(_) => "",
188            };
189            Ok(format!("{}{} > ${}", field_sql, cast, param_num))
190        }
191
192        WhereOperator::Gte(field, value) => {
193            let field_sql = field.to_sql();
194            let param_num = *param_index + 1;
195            *param_index += 1;
196            params.insert(param_num, value.clone());
197            let cast = match field {
198                Field::JsonbField(_) | Field::JsonbPath(_) => infer_type_cast(value),
199                Field::DirectColumn(_) => "",
200            };
201            Ok(format!("{}{} >= ${}", field_sql, cast, param_num))
202        }
203
204        WhereOperator::Lt(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            let cast = match field {
210                Field::JsonbField(_) | Field::JsonbPath(_) => infer_type_cast(value),
211                Field::DirectColumn(_) => "",
212            };
213            Ok(format!("{}{} < ${}", field_sql, cast, param_num))
214        }
215
216        WhereOperator::Lte(field, value) => {
217            let field_sql = field.to_sql();
218            let param_num = *param_index + 1;
219            *param_index += 1;
220            params.insert(param_num, value.clone());
221            let cast = match field {
222                Field::JsonbField(_) | Field::JsonbPath(_) => infer_type_cast(value),
223                Field::DirectColumn(_) => "",
224            };
225            Ok(format!("{}{} <= ${}", field_sql, cast, param_num))
226        }
227
228        // ============ Array Operators ============
229        WhereOperator::In(field, values) => {
230            // Empty IN () is a syntax error in all databases; semantically equivalent to FALSE.
231            if values.is_empty() {
232                return Ok("FALSE".to_string());
233            }
234            let field_sql = field.to_sql();
235            let placeholders: Vec<String> = values
236                .iter()
237                .map(|v| {
238                    let param_num = *param_index + 1;
239                    *param_index += 1;
240                    params.insert(param_num, v.clone());
241                    format!("${}", param_num)
242                })
243                .collect();
244            Ok(format!("{} IN ({})", field_sql, placeholders.join(", ")))
245        }
246
247        WhereOperator::Nin(field, values) => {
248            // Empty NOT IN () is a syntax error in all databases; semantically equivalent to TRUE.
249            if values.is_empty() {
250                return Ok("TRUE".to_string());
251            }
252            let field_sql = field.to_sql();
253            let placeholders: Vec<String> = values
254                .iter()
255                .map(|v| {
256                    let param_num = *param_index + 1;
257                    *param_index += 1;
258                    params.insert(param_num, v.clone());
259                    format!("${}", param_num)
260                })
261                .collect();
262            Ok(format!(
263                "{} NOT IN ({})",
264                field_sql,
265                placeholders.join(", ")
266            ))
267        }
268
269        WhereOperator::Contains(field, substring) => {
270            let field_sql = field.to_sql();
271            let param_num = *param_index + 1;
272            *param_index += 1;
273            params.insert(param_num, Value::String(escape_like_literal(substring)));
274            Ok(format!(
275                "{} LIKE '%' || ${}::text || '%'",
276                field_sql, param_num
277            ))
278        }
279
280        WhereOperator::ArrayContains(field, value) => {
281            let field_sql = field.to_sql();
282            let param_num = *param_index + 1;
283            *param_index += 1;
284            params.insert(param_num, value.clone());
285            Ok(format!("{} @> ARRAY[${}]", field_sql, param_num))
286        }
287
288        WhereOperator::ArrayContainedBy(field, value) => {
289            let field_sql = field.to_sql();
290            let param_num = *param_index + 1;
291            *param_index += 1;
292            params.insert(param_num, value.clone());
293            Ok(format!("{} <@ ARRAY[${}]", field_sql, param_num))
294        }
295
296        WhereOperator::ArrayOverlaps(field, values) => {
297            let field_sql = field.to_sql();
298            let placeholders: Vec<String> = values
299                .iter()
300                .map(|v| {
301                    let param_num = *param_index + 1;
302                    *param_index += 1;
303                    params.insert(param_num, v.clone());
304                    format!("${}", param_num)
305                })
306                .collect();
307            Ok(format!(
308                "{} && ARRAY[{}]",
309                field_sql,
310                placeholders.join(", ")
311            ))
312        }
313
314        // ============ Array Length Operators ============
315        WhereOperator::LenEq(field, len) => {
316            let field_sql = field.to_sql();
317            Ok(format!("array_length({}, 1) = {}", field_sql, len))
318        }
319
320        WhereOperator::LenGt(field, len) => {
321            let field_sql = field.to_sql();
322            Ok(format!("array_length({}, 1) > {}", field_sql, len))
323        }
324
325        WhereOperator::LenGte(field, len) => {
326            let field_sql = field.to_sql();
327            Ok(format!("array_length({}, 1) >= {}", field_sql, len))
328        }
329
330        WhereOperator::LenLt(field, len) => {
331            let field_sql = field.to_sql();
332            Ok(format!("array_length({}, 1) < {}", field_sql, len))
333        }
334
335        WhereOperator::LenLte(field, len) => {
336            let field_sql = field.to_sql();
337            Ok(format!("array_length({}, 1) <= {}", field_sql, len))
338        }
339
340        // ============ String Operators ============
341        WhereOperator::Icontains(field, substring) => {
342            let field_sql = field.to_sql();
343            let param_num = *param_index + 1;
344            *param_index += 1;
345            params.insert(param_num, Value::String(escape_like_literal(substring)));
346            Ok(format!(
347                "{} ILIKE '%' || ${}::text || '%'",
348                field_sql, param_num
349            ))
350        }
351
352        WhereOperator::Startswith(field, prefix) => {
353            let field_sql = field.to_sql();
354            let param_num = *param_index + 1;
355            *param_index += 1;
356            params.insert(
357                param_num,
358                Value::String(format!("{}%", escape_like_literal(prefix))),
359            );
360            Ok(format!("{} LIKE ${}", field_sql, param_num))
361        }
362
363        WhereOperator::Istartswith(field, prefix) => {
364            let field_sql = field.to_sql();
365            let param_num = *param_index + 1;
366            *param_index += 1;
367            params.insert(
368                param_num,
369                Value::String(format!("{}%", escape_like_literal(prefix))),
370            );
371            Ok(format!("{} ILIKE ${}", field_sql, param_num))
372        }
373
374        WhereOperator::Endswith(field, suffix) => {
375            let field_sql = field.to_sql();
376            let param_num = *param_index + 1;
377            *param_index += 1;
378            params.insert(
379                param_num,
380                Value::String(format!("%{}", escape_like_literal(suffix))),
381            );
382            Ok(format!("{} LIKE ${}", field_sql, param_num))
383        }
384
385        WhereOperator::Iendswith(field, suffix) => {
386            let field_sql = field.to_sql();
387            let param_num = *param_index + 1;
388            *param_index += 1;
389            params.insert(
390                param_num,
391                Value::String(format!("%{}", escape_like_literal(suffix))),
392            );
393            Ok(format!("{} ILIKE ${}", field_sql, param_num))
394        }
395
396        WhereOperator::Like(field, pattern) => {
397            let field_sql = field.to_sql();
398            let param_num = *param_index + 1;
399            *param_index += 1;
400            params.insert(param_num, Value::String(pattern.clone()));
401            Ok(format!("{} LIKE ${}", field_sql, param_num))
402        }
403
404        WhereOperator::Ilike(field, pattern) => {
405            let field_sql = field.to_sql();
406            let param_num = *param_index + 1;
407            *param_index += 1;
408            params.insert(param_num, Value::String(pattern.clone()));
409            Ok(format!("{} ILIKE ${}", field_sql, param_num))
410        }
411
412        // ============ Null Operator ============
413        WhereOperator::IsNull(field, is_null) => {
414            let field_sql = field.to_sql();
415            if *is_null {
416                Ok(format!("{} IS NULL", field_sql))
417            } else {
418                Ok(format!("{} IS NOT NULL", field_sql))
419            }
420        }
421
422        // ============ Vector Distance Operators (pgvector) ============
423        WhereOperator::L2Distance {
424            field,
425            vector,
426            threshold,
427        } => {
428            let field_sql = field.to_sql();
429            let param_num = *param_index + 1;
430            *param_index += 1;
431            params.insert(param_num, Value::FloatArray(vector.clone()));
432            Ok(format!(
433                "l2_distance({}::vector, ${}::vector) < {}",
434                field_sql, param_num, threshold
435            ))
436        }
437
438        WhereOperator::CosineDistance {
439            field,
440            vector,
441            threshold,
442        } => {
443            let field_sql = field.to_sql();
444            let param_num = *param_index + 1;
445            *param_index += 1;
446            params.insert(param_num, Value::FloatArray(vector.clone()));
447            Ok(format!(
448                "cosine_distance({}::vector, ${}::vector) < {}",
449                field_sql, param_num, threshold
450            ))
451        }
452
453        WhereOperator::InnerProduct {
454            field,
455            vector,
456            threshold,
457        } => {
458            let field_sql = field.to_sql();
459            let param_num = *param_index + 1;
460            *param_index += 1;
461            params.insert(param_num, Value::FloatArray(vector.clone()));
462            Ok(format!(
463                "inner_product({}::vector, ${}::vector) > {}",
464                field_sql, param_num, threshold
465            ))
466        }
467
468        WhereOperator::L1Distance {
469            field,
470            vector,
471            threshold,
472        } => {
473            let field_sql = field.to_sql();
474            let param_num = *param_index + 1;
475            *param_index += 1;
476            params.insert(param_num, Value::FloatArray(vector.clone()));
477            Ok(format!(
478                "l1_distance({}::vector, ${}::vector) < {}",
479                field_sql, param_num, threshold
480            ))
481        }
482
483        WhereOperator::HammingDistance {
484            field,
485            vector,
486            threshold,
487        } => {
488            let field_sql = field.to_sql();
489            let param_num = *param_index + 1;
490            *param_index += 1;
491            params.insert(param_num, Value::FloatArray(vector.clone()));
492            Ok(format!(
493                "hamming_distance({}::bit, ${}::bit) < {}",
494                field_sql, param_num, threshold
495            ))
496        }
497
498        WhereOperator::JaccardDistance {
499            field,
500            set,
501            threshold,
502        } => {
503            let field_sql = field.to_sql();
504            let param_num = *param_index + 1;
505            *param_index += 1;
506            let value_array: Vec<Value> = set.iter().map(|s| Value::String(s.clone())).collect();
507            params.insert(param_num, Value::Array(value_array));
508            Ok(format!(
509                "jaccard_distance({}::text[], ${}::text[]) < {}",
510                field_sql, param_num, threshold
511            ))
512        }
513
514        // ============ Full-Text Search Operators ============
515        WhereOperator::Matches {
516            field,
517            query,
518            language,
519        } => {
520            let field_sql = field.to_sql();
521            let param_num = *param_index + 1;
522            *param_index += 1;
523            params.insert(param_num, Value::String(query.clone()));
524            let lang = language.as_deref().unwrap_or("english");
525            Ok(format!(
526                "{} @@ plainto_tsquery('{}', ${})",
527                field_sql, lang, param_num
528            ))
529        }
530
531        WhereOperator::PlainQuery { field, query } => {
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(query.clone()));
536            Ok(format!(
537                "{} @@ plainto_tsquery(${})::tsvector",
538                field_sql, param_num
539            ))
540        }
541
542        WhereOperator::PhraseQuery {
543            field,
544            query,
545            language,
546        } => {
547            let field_sql = field.to_sql();
548            let param_num = *param_index + 1;
549            *param_index += 1;
550            params.insert(param_num, Value::String(query.clone()));
551            let lang = language.as_deref().unwrap_or("english");
552            Ok(format!(
553                "{} @@ phraseto_tsquery('{}', ${})",
554                field_sql, lang, param_num
555            ))
556        }
557
558        WhereOperator::WebsearchQuery {
559            field,
560            query,
561            language,
562        } => {
563            let field_sql = field.to_sql();
564            let param_num = *param_index + 1;
565            *param_index += 1;
566            params.insert(param_num, Value::String(query.clone()));
567            let lang = language.as_deref().unwrap_or("english");
568            Ok(format!(
569                "{} @@ websearch_to_tsquery('{}', ${})",
570                field_sql, lang, param_num
571            ))
572        }
573
574        // ============ Network/INET Operators ============
575        WhereOperator::IsIPv4(field) => {
576            let field_sql = field.to_sql();
577            Ok(format!("family({}::inet) = 4", field_sql))
578        }
579
580        WhereOperator::IsIPv6(field) => {
581            let field_sql = field.to_sql();
582            Ok(format!("family({}::inet) = 6", field_sql))
583        }
584
585        WhereOperator::IsPrivate { field, value } => {
586            let field_sql = field.to_sql();
587            Ok(cidr_containment_check(&field_sql, PRIVATE_RANGES, !value))
588        }
589
590        WhereOperator::IsLoopback { field, value } => {
591            let field_sql = field.to_sql();
592            Ok(cidr_containment_check(&field_sql, LOOPBACK_RANGES, !value))
593        }
594
595        WhereOperator::IsMulticast { field, value } => {
596            let field_sql = field.to_sql();
597            Ok(cidr_containment_check(&field_sql, MULTICAST_RANGES, !value))
598        }
599
600        WhereOperator::IsLinkLocal { field, value } => {
601            let field_sql = field.to_sql();
602            Ok(cidr_containment_check(
603                &field_sql,
604                LINK_LOCAL_RANGES,
605                !value,
606            ))
607        }
608
609        WhereOperator::IsDocumentation { field, value } => {
610            let field_sql = field.to_sql();
611            Ok(cidr_containment_check(
612                &field_sql,
613                DOCUMENTATION_RANGES,
614                !value,
615            ))
616        }
617
618        WhereOperator::IsCarrierGrade { field, value } => {
619            let field_sql = field.to_sql();
620            Ok(cidr_containment_check(
621                &field_sql,
622                CARRIER_GRADE_RANGES,
623                !value,
624            ))
625        }
626
627        WhereOperator::InSubnet { field, subnet } => {
628            let field_sql = field.to_sql();
629            let param_num = *param_index + 1;
630            *param_index += 1;
631            params.insert(param_num, Value::String(subnet.clone()));
632            Ok(format!("{}::inet << ${}::inet", field_sql, param_num))
633        }
634
635        WhereOperator::ContainsSubnet { field, subnet } => {
636            let field_sql = field.to_sql();
637            let param_num = *param_index + 1;
638            *param_index += 1;
639            params.insert(param_num, Value::String(subnet.clone()));
640            Ok(format!("{}::inet >> ${}::inet", field_sql, param_num))
641        }
642
643        WhereOperator::ContainsIP { field, ip } => {
644            let field_sql = field.to_sql();
645            let param_num = *param_index + 1;
646            *param_index += 1;
647            params.insert(param_num, Value::String(ip.clone()));
648            Ok(format!("{}::inet >> ${}::inet", field_sql, param_num))
649        }
650
651        WhereOperator::IPRangeOverlap { field, range } => {
652            let field_sql = field.to_sql();
653            let param_num = *param_index + 1;
654            *param_index += 1;
655            params.insert(param_num, Value::String(range.clone()));
656            Ok(format!("{}::inet && ${}::inet", field_sql, param_num))
657        }
658
659        // ============ JSONB Operators ============
660        WhereOperator::StrictlyContains(field, value) => {
661            let field_sql = field.to_sql();
662            let param_num = *param_index + 1;
663            *param_index += 1;
664            params.insert(param_num, value.clone());
665            Ok(format!("{}::jsonb @> ${}::jsonb", field_sql, param_num))
666        }
667
668        // ============ LTree Operators ============
669        WhereOperator::AncestorOf { field, path } => {
670            let field_sql = field.to_sql();
671            let param_num = *param_index + 1;
672            *param_index += 1;
673            params.insert(param_num, Value::String(path.clone()));
674            Ok(format!("{}::ltree @> ${}::ltree", field_sql, param_num))
675        }
676
677        WhereOperator::DescendantOf { field, path } => {
678            let field_sql = field.to_sql();
679            let param_num = *param_index + 1;
680            *param_index += 1;
681            params.insert(param_num, Value::String(path.clone()));
682            Ok(format!("{}::ltree <@ ${}::ltree", field_sql, param_num))
683        }
684
685        WhereOperator::MatchesLquery { field, pattern } => {
686            let field_sql = field.to_sql();
687            let param_num = *param_index + 1;
688            *param_index += 1;
689            params.insert(param_num, Value::String(pattern.clone()));
690            Ok(format!("{}::ltree ~ ${}::lquery", field_sql, param_num))
691        }
692
693        WhereOperator::MatchesLtxtquery { field, query } => {
694            let field_sql = field.to_sql();
695            let param_num = *param_index + 1;
696            *param_index += 1;
697            params.insert(param_num, Value::String(query.clone()));
698            Ok(format!("{}::ltree @ ${}::ltxtquery", field_sql, param_num))
699        }
700
701        WhereOperator::MatchesAnyLquery { field, patterns } => {
702            let field_sql = field.to_sql();
703            let placeholders: Vec<String> = patterns
704                .iter()
705                .map(|p| {
706                    let param_num = *param_index + 1;
707                    *param_index += 1;
708                    params.insert(param_num, Value::String(p.clone()));
709                    format!("${}::lquery", param_num)
710                })
711                .collect();
712            Ok(format!(
713                "{}::ltree ? ARRAY[{}]",
714                field_sql,
715                placeholders.join(", ")
716            ))
717        }
718
719        // ============ LTree Depth Operators ============
720        WhereOperator::DepthEq { field, depth } => {
721            let field_sql = field.to_sql();
722            Ok(format!("nlevel({}::ltree) = {}", field_sql, depth))
723        }
724
725        WhereOperator::DepthNeq { field, depth } => {
726            let field_sql = field.to_sql();
727            Ok(format!("nlevel({}::ltree) != {}", field_sql, depth))
728        }
729
730        WhereOperator::DepthGt { field, depth } => {
731            let field_sql = field.to_sql();
732            Ok(format!("nlevel({}::ltree) > {}", field_sql, depth))
733        }
734
735        WhereOperator::DepthGte { field, depth } => {
736            let field_sql = field.to_sql();
737            Ok(format!("nlevel({}::ltree) >= {}", field_sql, depth))
738        }
739
740        WhereOperator::DepthLt { field, depth } => {
741            let field_sql = field.to_sql();
742            Ok(format!("nlevel({}::ltree) < {}", field_sql, depth))
743        }
744
745        WhereOperator::DepthLte { field, depth } => {
746            let field_sql = field.to_sql();
747            Ok(format!("nlevel({}::ltree) <= {}", field_sql, depth))
748        }
749
750        // ============ LTree LCA Operator ============
751        WhereOperator::Lca { field, paths } => {
752            let field_sql = field.to_sql();
753            let placeholders: Vec<String> = paths
754                .iter()
755                .map(|p| {
756                    let param_num = *param_index + 1;
757                    *param_index += 1;
758                    params.insert(param_num, Value::String(p.clone()));
759                    format!("${}::ltree", param_num)
760                })
761                .collect();
762            Ok(format!(
763                "{}::ltree = lca(ARRAY[{}])",
764                field_sql,
765                placeholders.join(", ")
766            ))
767        }
768
769        // ============ LTree ID-Based Operators ============
770        // SQL generation requires HierarchyContext (table, path_column).
771        // These operators are handled by the GenericWhereGenerator in fraiseql-db,
772        // not the wire-level SQL generator. Return an error if reached here.
773        WhereOperator::DescendantOfId { .. } | WhereOperator::AncestorOfId { .. } => {
774            Err(crate::WireError::InvalidSchema(
775                "ID-based ltree operators require HierarchyContext; use GenericWhereGenerator"
776                    .to_string(),
777            ))
778        }
779    }
780}
781
782#[cfg(test)]
783mod tests;