Skip to main content

chain_builder/
compile.rs

1//! SQL compilation: turn a [`QueryBuilder`] into `(sql, binds)`.
2//!
3//! Values are never inlined — each value pushes onto a running `binds` vector
4//! and emits a placeholder via [`Dialect::write_placeholder`] with a 1-based
5//! counter, so Postgres yields `$1..$n` in first-appearance order (including
6//! across nested groups) while MySQL/SQLite yield `?`.
7
8use crate::builder::{
9    ConflictAction, Cte, Having, Join, JoinCond, JoinKind, Method, Order, QueryBuilder,
10};
11use crate::dialect::{Dialect, UpsertStyle};
12use crate::ident::escape_identifier;
13use crate::value::Value;
14use crate::where_::{Conj, Predicate};
15
16/// Accumulates the generated SQL and the ordered bind values.
17///
18/// A single `Ctx` is threaded through the whole compilation (see
19/// [`compile_into`]): SQL, binds, and the placeholder counter all continue
20/// across every clause and across nested builders, which is what guarantees
21/// Postgres placeholder continuity (e.g. WHERE `$1` → LIMIT `$2` → OFFSET `$3`).
22struct Ctx {
23    sql: String,
24    binds: Vec<Value>,
25    quote: char,
26}
27
28impl Ctx {
29    /// Push a value and emit its placeholder (1-based = len after push).
30    fn placeholder<D: Dialect>(&mut self, val: Value) {
31        self.binds.push(val);
32        D::write_placeholder(&mut self.sql, self.binds.len());
33    }
34
35    /// Escape a single identifier for SQL output.
36    ///
37    /// This is the ONLY place identifiers are turned into SQL in v2. Every
38    /// identifier→SQL site in this module routes through `ctx.esc`, so
39    /// `grep 'esc(' src/v2/compile.rs` is the complete inventory of identifier
40    /// writes. (The sole exception is [`Predicate::Raw`], which is the
41    /// documented verbatim escape hatch and is emitted unescaped.)
42    fn esc(&self, ident: &str) -> String {
43        escape_identifier(ident, self.quote)
44    }
45
46    /// Escape a table, optionally prefixed by a database qualifier:
47    /// `Some("db")`, `"t"` → `"db"."t"`; `None` → `"t"`.
48    fn qualify(&self, db: Option<&str>, table: &str) -> String {
49        match db {
50            Some(d) => format!("{}.{}", self.esc(d), self.esc(table)),
51            None => self.esc(table),
52        }
53    }
54}
55
56/// Compile a [`QueryBuilder`] into `(sql, binds)`.
57pub fn compile<D: Dialect>(qb: &QueryBuilder<D>) -> (String, Vec<Value>) {
58    let mut ctx = Ctx {
59        sql: String::new(),
60        binds: Vec::new(),
61        quote: D::quote_char(),
62    };
63    compile_into::<D>(&mut ctx, qb);
64    (ctx.sql, ctx.binds)
65}
66
67/// Write `qb`'s SQL into the existing `ctx`, continuing its binds and
68/// placeholder counter. This is the single-pass core used by [`compile`] (and,
69/// in later M2 tasks, by nested builders such as CTEs/UNION arms).
70fn compile_into<D: Dialect>(ctx: &mut Ctx, qb: &QueryBuilder<D>) {
71    let table = ctx.qualify(qb.db.as_deref(), &qb.table);
72
73    match qb.method {
74        Method::Select => {
75            // CTEs are emitted first so their binds (and pg `$N`) come first.
76            write_ctes::<D>(ctx, &qb.ctes);
77            if !qb.distinct_on.is_empty() {
78                if !D::supports_distinct_on() {
79                    panic!("DISTINCT ON requires PostgreSQL");
80                }
81                ctx.sql.push_str("SELECT DISTINCT ON (");
82                let cols: Vec<String> = qb.distinct_on.iter().map(|c| ctx.esc(c)).collect();
83                ctx.sql.push_str(&cols.join(", "));
84                ctx.sql.push_str(") ");
85            } else if qb.distinct {
86                ctx.sql.push_str("SELECT DISTINCT ");
87            } else {
88                ctx.sql.push_str("SELECT ");
89            }
90            write_select_list::<D>(ctx, qb);
91            ctx.sql.push_str(" FROM ");
92            ctx.sql.push_str(&table);
93            write_joins::<D>(ctx, &qb.joins, qb.db.as_deref());
94            write_wheres::<D>(ctx, &qb.wheres);
95            write_group_by(ctx, &qb.groups, qb.group_by_raw.as_ref());
96            write_having::<D>(ctx, &qb.havings);
97            write_order_by(ctx, &qb.orders, qb.order_by_raw.as_ref());
98            write_limit_offset::<D>(ctx, qb.limit, qb.offset);
99            write_unions::<D>(ctx, &qb.unions);
100        }
101        Method::Insert => {
102            if qb.set.is_empty() && qb.insert_rows.is_empty() {
103                panic!("insert() requires at least one column");
104            }
105
106            // Single-row sorted pairs (preserves the M-prev path byte-for-byte,
107            // including duplicate keys). For multi-row, columns come from the
108            // FIRST row's sorted keys.
109            let mut single_rows: Vec<&(String, Value)> = qb.set.iter().collect();
110            single_rows.sort_by(|a, b| a.0.cmp(&b.0));
111            let sorted_cols: Vec<&str> = if !qb.insert_rows.is_empty() {
112                let mut cols: Vec<&str> =
113                    qb.insert_rows[0].iter().map(|(k, _)| k.as_str()).collect();
114                cols.sort_unstable();
115                cols
116            } else {
117                single_rows.iter().map(|(k, _)| k.as_str()).collect()
118            };
119
120            // Decide the INSERT keyword up front: MySQL `DO NOTHING` becomes
121            // `INSERT IGNORE INTO …` with no trailing conflict clause.
122            let mysql_ignore = D::upsert_style() == UpsertStyle::OnDuplicateKey
123                && matches!(
124                    qb.on_conflict.as_ref().map(|c| c.action),
125                    Some(ConflictAction::DoNothing)
126                );
127            if mysql_ignore {
128                ctx.sql.push_str("INSERT IGNORE INTO ");
129            } else {
130                ctx.sql.push_str("INSERT INTO ");
131            }
132            ctx.sql.push_str(&table);
133            ctx.sql.push_str(" (");
134            let cols: Vec<String> = sorted_cols.iter().map(|k| ctx.esc(k)).collect();
135            ctx.sql.push_str(&cols.join(", "));
136            ctx.sql.push_str(") VALUES ");
137
138            if !qb.insert_rows.is_empty() {
139                // Multi-row: one `(…)` tuple per row. A key missing in a row binds
140                // `Value::Null` (ragged rows are NULL-padded, never a panic).
141                for (ri, row) in qb.insert_rows.iter().enumerate() {
142                    if ri > 0 {
143                        ctx.sql.push_str(", ");
144                    }
145                    ctx.sql.push('(');
146                    for (ci, col) in sorted_cols.iter().enumerate() {
147                        if ci > 0 {
148                            ctx.sql.push_str(", ");
149                        }
150                        let v = row
151                            .iter()
152                            .find(|(k, _)| k == col)
153                            .map(|(_, v)| v.clone())
154                            .unwrap_or(Value::Null);
155                        ctx.placeholder::<D>(v);
156                    }
157                    ctx.sql.push(')');
158                }
159            } else {
160                // Single-row: byte-identical to the M-prev path (iterate the
161                // sorted (key, value) pairs directly, duplicates and all).
162                ctx.sql.push('(');
163                for (i, (_, v)) in single_rows.iter().enumerate() {
164                    if i > 0 {
165                        ctx.sql.push_str(", ");
166                    }
167                    ctx.placeholder::<D>(v.clone());
168                }
169                ctx.sql.push(')');
170            }
171
172            if !mysql_ignore {
173                if let Some(oc) = &qb.on_conflict {
174                    write_on_conflict::<D>(ctx, oc, &sorted_cols);
175                }
176            }
177            write_returning::<D>(ctx, &qb.returning);
178        }
179        Method::Update => {
180            if qb.set.is_empty() {
181                panic!("update() requires at least one column");
182            }
183            let mut rows: Vec<&(String, Value)> = qb.set.iter().collect();
184            rows.sort_by(|a, b| a.0.cmp(&b.0));
185            ctx.sql.push_str("UPDATE ");
186            ctx.sql.push_str(&table);
187            ctx.sql.push_str(" SET ");
188            for (i, (k, v)) in rows.iter().enumerate() {
189                if i > 0 {
190                    ctx.sql.push_str(", ");
191                }
192                let col = ctx.esc(k);
193                ctx.sql.push_str(&col);
194                ctx.sql.push_str(" = ");
195                ctx.placeholder::<D>(v.clone());
196            }
197            write_wheres::<D>(ctx, &qb.wheres);
198            write_returning::<D>(ctx, &qb.returning);
199        }
200        Method::Delete => {
201            ctx.sql.push_str("DELETE FROM ");
202            ctx.sql.push_str(&table);
203            write_wheres::<D>(ctx, &qb.wheres);
204            write_returning::<D>(ctx, &qb.returning);
205        }
206    }
207}
208
209/// Render the upsert conflict clause for an `INSERT` (never called for the
210/// MySQL `INSERT IGNORE` path, which is handled at the keyword). `inserted` is
211/// the sorted-key list of inserted columns.
212fn write_on_conflict<D: Dialect>(
213    ctx: &mut Ctx,
214    oc: &crate::builder::OnConflict,
215    inserted: &[&str],
216) {
217    match D::upsert_style() {
218        UpsertStyle::OnDuplicateKey => {
219            // MySQL merge: `ON DUPLICATE KEY UPDATE c = VALUES(c), …` for ALL
220            // inserted columns (explicit targets are ignored).
221            ctx.sql.push_str(" ON DUPLICATE KEY UPDATE ");
222            let sets: Vec<String> = inserted
223                .iter()
224                .map(|c| {
225                    let e = ctx.esc(c);
226                    format!("{e} = VALUES({e})")
227                })
228                .collect();
229            ctx.sql.push_str(&sets.join(", "));
230        }
231        UpsertStyle::OnConflict => {
232            let targets = &oc.targets;
233            // SET list = inserted columns minus the conflict targets.
234            let set_cols: Vec<&&str> = inserted
235                .iter()
236                .filter(|c| !targets.iter().any(|t| t == **c))
237                .collect();
238            let do_update = matches!(oc.action, ConflictAction::Merge)
239                && !targets.is_empty()
240                && !set_cols.is_empty();
241
242            ctx.sql.push_str(" ON CONFLICT");
243            if !targets.is_empty() {
244                ctx.sql.push_str(" (");
245                let cols: Vec<String> = targets.iter().map(|t| ctx.esc(t)).collect();
246                ctx.sql.push_str(&cols.join(", "));
247                ctx.sql.push(')');
248            }
249            if do_update {
250                ctx.sql.push_str(" DO UPDATE SET ");
251                let sets: Vec<String> = set_cols
252                    .iter()
253                    .map(|c| {
254                        let e = ctx.esc(c);
255                        // `EXCLUDED` is an unquoted, case-insensitive identifier
256                        // accepted by both pg and sqlite — emitted literally.
257                        format!("{e} = EXCLUDED.{e}")
258                    })
259                    .collect();
260                ctx.sql.push_str(&sets.join(", "));
261            } else {
262                ctx.sql.push_str(" DO NOTHING");
263            }
264        }
265    }
266}
267
268/// Render ` RETURNING col, …` when the dialect supports it and the list is
269/// non-empty. A `"*"` column is emitted unescaped. No-op otherwise (e.g. MySQL).
270fn write_returning<D: Dialect>(ctx: &mut Ctx, cols: &[String]) {
271    if !D::supports_returning() || cols.is_empty() {
272        return;
273    }
274    ctx.sql.push_str(" RETURNING ");
275    let parts: Vec<String> = cols
276        .iter()
277        .map(|c| if c == "*" { "*".to_owned() } else { ctx.esc(c) })
278        .collect();
279    ctx.sql.push_str(&parts.join(", "));
280}
281
282/// Render `GROUP BY a, b, …` (SELECT only). No-op when there are no columns and
283/// no raw fragment. A raw fragment is appended (comma-joined) after the
284/// structured columns; if only raw is present it becomes the whole clause.
285fn write_group_by(ctx: &mut Ctx, groups: &[String], raw: Option<&(String, Vec<Value>)>) {
286    if groups.is_empty() && raw.is_none() {
287        return;
288    }
289    ctx.sql.push_str(" GROUP BY ");
290    let cols: Vec<String> = groups.iter().map(|c| ctx.esc(c)).collect();
291    ctx.sql.push_str(&cols.join(", "));
292    if let Some((sql, binds)) = raw {
293        if !groups.is_empty() {
294            ctx.sql.push_str(", ");
295        }
296        // Verbatim escape hatch (see `group_by_raw` docs).
297        ctx.sql.push_str(sql);
298        ctx.binds.extend(binds.iter().cloned());
299    }
300}
301
302/// Render the SELECT column list: escaped `select_cols`, then verbatim
303/// `select_raw` expressions, then `(<subquery>) AS {alias}` columns — in that
304/// order. An empty list (no cols, no raw, no subqueries) yields `*`.
305///
306/// Written directly into `ctx` (not pre-joined) so subquery binds continue the
307/// placeholder counter in emission order.
308fn write_select_list<D: Dialect>(ctx: &mut Ctx, qb: &QueryBuilder<D>) {
309    if qb.select_cols.is_empty() && qb.select_raw.is_empty() && qb.select_subqueries.is_empty() {
310        ctx.sql.push('*');
311        return;
312    }
313    let mut wrote_any = false;
314    for c in &qb.select_cols {
315        if wrote_any {
316            ctx.sql.push_str(", ");
317        }
318        let e = ctx.esc(c);
319        ctx.sql.push_str(&e);
320        wrote_any = true;
321    }
322    for (sql, binds) in &qb.select_raw {
323        if wrote_any {
324            ctx.sql.push_str(", ");
325        }
326        // Verbatim escape hatch (see `select_raw` docs).
327        ctx.sql.push_str(sql);
328        ctx.binds.extend(binds.iter().cloned());
329        wrote_any = true;
330    }
331    for (alias, sub) in &qb.select_subqueries {
332        if wrote_any {
333            ctx.sql.push_str(", ");
334        }
335        ctx.sql.push('(');
336        compile_into::<D>(ctx, sub);
337        ctx.sql.push_str(") AS ");
338        let a = ctx.esc(alias);
339        ctx.sql.push_str(&a);
340        wrote_any = true;
341    }
342}
343
344/// Render each `JOIN` (SELECT only): ` {KIND} {esc table}[ ON cond AND …]`.
345/// `CROSS JOIN` emits no `ON`. Placeholders from `OnVal`/`OnRaw` continue the
346/// running counter.
347fn write_joins<D: Dialect>(ctx: &mut Ctx, joins: &[Join], db: Option<&str>) {
348    for j in joins {
349        let kw = match j.kind {
350            JoinKind::Inner => " INNER JOIN ",
351            JoinKind::Left => " LEFT JOIN ",
352            JoinKind::Right => " RIGHT JOIN ",
353            JoinKind::FullOuter => " FULL OUTER JOIN ",
354            JoinKind::Cross => " CROSS JOIN ",
355        };
356        ctx.sql.push_str(kw);
357        let table = ctx.qualify(db, &j.table);
358        ctx.sql.push_str(&table);
359        if j.on.is_empty() {
360            continue;
361        }
362        ctx.sql.push_str(" ON ");
363        for (i, cond) in j.on.iter().enumerate() {
364            if i > 0 {
365                ctx.sql.push_str(" AND ");
366            }
367            match cond {
368                JoinCond::On(c, op, c2) => {
369                    let l = ctx.esc(c);
370                    let r = ctx.esc(c2);
371                    ctx.sql.push_str(&l);
372                    ctx.sql.push(' ');
373                    ctx.sql.push_str(op);
374                    ctx.sql.push(' ');
375                    ctx.sql.push_str(&r);
376                }
377                JoinCond::OnVal(c, op, v) => {
378                    let l = ctx.esc(c);
379                    ctx.sql.push_str(&l);
380                    ctx.sql.push(' ');
381                    ctx.sql.push_str(op);
382                    ctx.sql.push(' ');
383                    ctx.placeholder::<D>(v.clone());
384                }
385                JoinCond::OnRaw(sql, binds) => {
386                    // Verbatim escape hatch (see `JoinClause::on_raw` docs).
387                    ctx.sql.push_str(sql);
388                    ctx.binds.extend(binds.iter().cloned());
389                }
390            }
391        }
392    }
393}
394
395/// Render ` HAVING cond AND …` (SELECT only) after GROUP BY. No-op when empty.
396fn write_having<D: Dialect>(ctx: &mut Ctx, havings: &[Having]) {
397    if havings.is_empty() {
398        return;
399    }
400    ctx.sql.push_str(" HAVING ");
401    for (i, h) in havings.iter().enumerate() {
402        if i > 0 {
403            ctx.sql.push_str(" AND ");
404        }
405        match h {
406            Having::Col { col, op, val } => {
407                let c = ctx.esc(col);
408                ctx.sql.push_str(&c);
409                ctx.sql.push(' ');
410                ctx.sql.push_str(op);
411                ctx.sql.push(' ');
412                ctx.placeholder::<D>(val.clone());
413            }
414            Having::Raw { sql, binds } => {
415                // Verbatim escape hatch (see `having_raw` docs).
416                ctx.sql.push_str(sql);
417                ctx.binds.extend(binds.iter().cloned());
418            }
419        }
420    }
421}
422
423/// Render `WITH [RECURSIVE] name AS (body), … ` BEFORE the main SELECT.
424///
425/// Single-pass per CTE: the name header is written and the body compiled in one
426/// go, so SQL text order equals bind-push order (placeholder/bind never desync).
427fn write_ctes<D: Dialect>(ctx: &mut Ctx, ctes: &[Cte<D>]) {
428    if ctes.is_empty() {
429        return;
430    }
431    ctx.sql.push_str("WITH ");
432    if ctes.iter().any(|c| c.recursive) {
433        ctx.sql.push_str("RECURSIVE ");
434    }
435    for (i, cte) in ctes.iter().enumerate() {
436        if i > 0 {
437            ctx.sql.push_str(", ");
438        }
439        let name = ctx.esc(&cte.name);
440        ctx.sql.push_str(&name);
441        ctx.sql.push_str(" AS (");
442        compile_into::<D>(ctx, &cte.query);
443        ctx.sql.push(')');
444    }
445    ctx.sql.push(' ');
446}
447
448/// Render ` UNION [ALL] body` per arm, AFTER the main query (SELECT only).
449fn write_unions<D: Dialect>(ctx: &mut Ctx, unions: &[(bool, QueryBuilder<D>)]) {
450    for (all, arm) in unions {
451        ctx.sql
452            .push_str(if *all { " UNION ALL " } else { " UNION " });
453        compile_into::<D>(ctx, arm);
454    }
455}
456
457/// Render `ORDER BY a ASC, b DESC, …` (SELECT only). No-op when empty and no raw
458/// fragment. A raw fragment is appended (comma-joined) after the structured
459/// terms; if only raw is present it becomes the whole clause.
460fn write_order_by(ctx: &mut Ctx, orders: &[(String, Order)], raw: Option<&(String, Vec<Value>)>) {
461    if orders.is_empty() && raw.is_none() {
462        return;
463    }
464    ctx.sql.push_str(" ORDER BY ");
465    let cols: Vec<String> = orders
466        .iter()
467        .map(|(c, o)| {
468            let dir = match o {
469                Order::Asc => "ASC",
470                Order::Desc => "DESC",
471            };
472            format!("{} {}", ctx.esc(c), dir)
473        })
474        .collect();
475    ctx.sql.push_str(&cols.join(", "));
476    if let Some((sql, binds)) = raw {
477        if !orders.is_empty() {
478            ctx.sql.push_str(", ");
479        }
480        // Verbatim escape hatch (see `order_by_raw` docs).
481        ctx.sql.push_str(sql);
482        ctx.binds.extend(binds.iter().cloned());
483    }
484}
485
486/// Render `LIMIT $n [OFFSET $m]` (SELECT only), binding both values.
487///
488/// Panics if `offset` is set without `limit` (uniform across dialects; MySQL
489/// rejects bare `OFFSET`).
490fn write_limit_offset<D: Dialect>(ctx: &mut Ctx, limit: Option<i64>, offset: Option<i64>) {
491    if offset.is_some() && limit.is_none() {
492        panic!("offset(...) requires limit(...)");
493    }
494    if let Some(n) = limit {
495        ctx.sql.push_str(" LIMIT ");
496        ctx.placeholder::<D>(Value::I64(n));
497    }
498    if let Some(n) = offset {
499        ctx.sql.push_str(" OFFSET ");
500        ctx.placeholder::<D>(Value::I64(n));
501    }
502}
503
504/// A predicate produces no SQL if it is an empty group (F4): an empty group
505/// would emit invalid `()`, so it is skipped entirely (and must not leave a
506/// dangling `AND`/`OR` separator behind it).
507fn is_omitted<D: Dialect>(p: &Predicate<D>) -> bool {
508    matches!(p, Predicate::Group { preds, .. } if preds.is_empty())
509}
510
511fn write_wheres<D: Dialect>(ctx: &mut Ctx, wheres: &[Predicate<D>]) {
512    // Skip empty groups so they neither emit `()` nor force a `WHERE`.
513    if wheres.iter().all(is_omitted) {
514        return;
515    }
516    ctx.sql.push_str(" WHERE ");
517    write_clause_list::<D>(ctx, wheres);
518}
519
520/// Render a top-level clause list. Predicates are joined by `AND` by default,
521/// but a [`Predicate::Group`] attaches to the preceding clause using its own
522/// outer conjunction (so `or_where` emits `... OR (...)`). Empty groups are
523/// omitted and never contribute a separator.
524fn write_clause_list<D: Dialect>(ctx: &mut Ctx, preds: &[Predicate<D>]) {
525    let mut wrote_any = false;
526    for p in preds.iter() {
527        if is_omitted(p) {
528            continue;
529        }
530        if wrote_any {
531            let sep = match p {
532                Predicate::Group {
533                    outer_conj: Conj::Or,
534                    ..
535                } => " OR ",
536                _ => " AND ",
537            };
538            ctx.sql.push_str(sep);
539        }
540        write_pred::<D>(ctx, p);
541        wrote_any = true;
542    }
543}
544
545fn write_pred<D: Dialect>(ctx: &mut Ctx, pred: &Predicate<D>) {
546    match pred {
547        Predicate::Binary { col, op, val } => {
548            let col = ctx.esc(col);
549            ctx.sql.push_str(&col);
550            ctx.sql.push(' ');
551            ctx.sql.push_str(op);
552            ctx.sql.push(' ');
553            ctx.placeholder::<D>(val.clone());
554        }
555        Predicate::In { col, neg, vals } => {
556            if vals.is_empty() {
557                // Empty IN is always false; empty NOT IN is always true.
558                ctx.sql.push_str(if *neg { "1 = 1" } else { "1 = 0" });
559                return;
560            }
561            let col = ctx.esc(col);
562            ctx.sql.push_str(&col);
563            ctx.sql.push_str(if *neg { " NOT IN (" } else { " IN (" });
564            for (i, v) in vals.iter().enumerate() {
565                if i > 0 {
566                    ctx.sql.push_str(", ");
567                }
568                ctx.placeholder::<D>(v.clone());
569            }
570            ctx.sql.push(')');
571        }
572        Predicate::Null { col, neg } => {
573            let col = ctx.esc(col);
574            ctx.sql.push_str(&col);
575            ctx.sql
576                .push_str(if *neg { " IS NOT NULL" } else { " IS NULL" });
577        }
578        Predicate::Between { col, lo, hi } => {
579            let col = ctx.esc(col);
580            ctx.sql.push_str(&col);
581            ctx.sql.push_str(" BETWEEN ");
582            ctx.placeholder::<D>(lo.clone());
583            ctx.sql.push_str(" AND ");
584            ctx.placeholder::<D>(hi.clone());
585        }
586        Predicate::ILike { col, val } => {
587            let col = ctx.esc(col);
588            if D::ilike_is_native() {
589                // Postgres: native `col ILIKE $n`.
590                ctx.sql.push_str(&col);
591                ctx.sql.push_str(" ILIKE ");
592                ctx.placeholder::<D>(val.clone());
593            } else {
594                // MySQL/SQLite: `LOWER(col) LIKE LOWER(?)`.
595                ctx.sql.push_str("LOWER(");
596                ctx.sql.push_str(&col);
597                ctx.sql.push_str(") LIKE LOWER(");
598                ctx.placeholder::<D>(val.clone());
599                ctx.sql.push(')');
600            }
601        }
602        Predicate::JsonContains { col, val } => {
603            // Postgres-oriented `@>` (jsonb contains); emitted verbatim.
604            let col = ctx.esc(col);
605            ctx.sql.push_str(&col);
606            ctx.sql.push_str(" @> ");
607            ctx.placeholder::<D>(val.clone());
608        }
609        Predicate::Raw { sql, binds } => {
610            // Verbatim escape hatch: SQL is NOT escaped (see `where_raw` docs).
611            ctx.sql.push_str(sql);
612            ctx.binds.extend(binds.iter().cloned());
613        }
614        Predicate::Group {
615            outer_conj: _,
616            preds,
617        } => {
618            // `outer_conj` controls how this group attaches to the *preceding*
619            // clause (handled in `write_clause_list`). The inner predicates are
620            // rendered with the SAME attach-conj logic as the top level
621            // (`write_clause_list`): each inner pred is joined with ` AND `
622            // unless it is itself a `Group` with `outer_conj == Conj::Or`, in
623            // which case it is joined with ` OR `. This enables M11 nested
624            // groups and inner-OR while staying byte-identical for the pre-M11
625            // case (a group whose preds are all non-`Group` predicates joins
626            // them all with ` AND `, exactly as the old hardcoded `Conj::And`).
627            //
628            // Empty groups never reach here: `write_clause_list` /
629            // `write_wheres` filter them via `is_omitted` (F4), so we never
630            // emit invalid `()`.
631            ctx.sql.push('(');
632            write_clause_list::<D>(ctx, preds);
633            ctx.sql.push(')');
634        }
635        Predicate::Column { lhs, op, rhs } => {
636            let l = ctx.esc(lhs);
637            let r = ctx.esc(rhs);
638            ctx.sql.push_str(&l);
639            ctx.sql.push(' ');
640            ctx.sql.push_str(op);
641            ctx.sql.push(' ');
642            ctx.sql.push_str(&r);
643        }
644        Predicate::Exists { neg, sub } => {
645            ctx.sql
646                .push_str(if *neg { "NOT EXISTS (" } else { "EXISTS (" });
647            compile_into::<D>(ctx, sub);
648            ctx.sql.push(')');
649        }
650        Predicate::InSubquery { col, neg, sub } => {
651            let col = ctx.esc(col);
652            ctx.sql.push_str(&col);
653            ctx.sql.push_str(if *neg { " NOT IN (" } else { " IN (" });
654            compile_into::<D>(ctx, sub);
655            ctx.sql.push(')');
656        }
657    }
658}