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