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