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