Skip to main content

nautilus_dialect/
lib.rs

1//! SQL dialect renderers for Nautilus ORM.
2
3#![warn(missing_docs)]
4#![forbid(unsafe_code)]
5
6// Shared SQL rendering macros
7//
8// These macros accept identifier parameters (`$quote`, `$render_expr`) so that
9// each dialect module supplies only the logic that differs between dialects.
10// Free identifiers in macro bodies (types, constants) are resolved at the
11// *definition site* (here in lib.rs), so the required types must be imported
12// below.  Identifier parameters (`$quote:ident`, `$render_expr:ident`) are
13// substituted textually at the call site, which is the intended behaviour.
14
15/// Append `RETURNING col1 AS alias1, ...` when `$returning` is non-empty.
16macro_rules! render_returning {
17    ($ctx:expr, $returning:expr, $quote:ident) => {{
18        if !$returning.is_empty() {
19            $ctx.sql.push_str(" RETURNING ");
20            for (i, col) in $returning.iter().enumerate() {
21                if i > 0 {
22                    $ctx.sql.push_str(", ");
23                }
24                $ctx.sql.push_str(&$quote(&col.table));
25                $ctx.sql.push('.');
26                $ctx.sql.push_str(&$quote(&col.name));
27                $ctx.sql.push_str(" AS ");
28                $ctx.sql.push_str(&$quote(&col.alias()));
29            }
30        }
31    }};
32}
33
34/// Render the full body of an INSERT statement into `$ctx`.
35///
36/// `$supports_returning`: when `false` the RETURNING clause is omitted (MySQL).
37macro_rules! render_insert_body {
38    ($ctx:expr, $insert:expr, $quote:ident, $supports_returning:expr, $supports_enum_cast:expr) => {{
39        $ctx.sql.push_str("INSERT INTO ");
40        $ctx.sql.push_str(&$quote(&$insert.table));
41
42        $ctx.sql.push_str(" (");
43        for (i, col) in $insert.columns.iter().enumerate() {
44            if i > 0 {
45                $ctx.sql.push_str(", ");
46            }
47            $ctx.sql.push_str(&$quote(&col.name));
48        }
49        $ctx.sql.push(')');
50
51        $ctx.sql.push_str(" VALUES ");
52        for (row_idx, row) in $insert.values.iter().enumerate() {
53            if row_idx > 0 {
54                $ctx.sql.push_str(", ");
55            }
56            $ctx.sql.push('(');
57            for (val_idx, value) in row.iter().enumerate() {
58                if val_idx > 0 {
59                    $ctx.sql.push_str(", ");
60                }
61                if matches!(value, nautilus_core::Value::Null) {
62                    $ctx.sql.push_str("NULL");
63                } else {
64                    let placeholder = $ctx.push_param(value.clone());
65                    $ctx.sql.push_str(&placeholder);
66                    if $supports_enum_cast {
67                        if let nautilus_core::Value::Enum { type_name, .. } = value {
68                            $ctx.sql.push_str("::");
69                            $ctx.sql.push_str(type_name);
70                        }
71                    }
72                }
73            }
74            $ctx.sql.push(')');
75        }
76
77        if $supports_returning {
78            render_returning!($ctx, $insert.returning, $quote);
79        }
80    }};
81}
82
83/// Render the full body of an UPDATE statement into `$ctx`.
84///
85/// `$render_expr`: the dialect-local expression renderer.
86/// `$supports_returning`: when `false` the RETURNING clause is omitted (MySQL).
87macro_rules! render_update_body {
88    ($ctx:expr, $update:expr, $quote:ident, $render_expr:ident, $supports_returning:expr, $supports_enum_cast:expr) => {{
89        $ctx.sql.push_str("UPDATE ");
90        $ctx.sql.push_str(&$quote(&$update.table));
91
92        $ctx.sql.push_str(" SET ");
93        for (i, (col, value)) in $update.assignments.iter().enumerate() {
94            if i > 0 {
95                $ctx.sql.push_str(", ");
96            }
97            $ctx.sql.push_str(&$quote(&col.name));
98            $ctx.sql.push_str(" = ");
99            if matches!(value, nautilus_core::Value::Null) {
100                $ctx.sql.push_str("NULL");
101            } else {
102                let placeholder = $ctx.push_param(value.clone());
103                $ctx.sql.push_str(&placeholder);
104                if $supports_enum_cast {
105                    if let nautilus_core::Value::Enum { type_name, .. } = value {
106                        $ctx.sql.push_str("::");
107                        $ctx.sql.push_str(type_name);
108                    }
109                }
110            }
111        }
112
113        if let Some(ref filter) = $update.filter {
114            $ctx.sql.push_str(" WHERE ");
115            $render_expr($ctx, filter);
116        }
117
118        if $supports_returning {
119            render_returning!($ctx, $update.returning, $quote);
120        }
121    }};
122}
123
124/// Render the full body of a DELETE statement into `$ctx`.
125///
126/// `$render_expr`: the dialect-local expression renderer.
127/// `$supports_returning`: when `false` the RETURNING clause is omitted (MySQL).
128macro_rules! render_delete_body {
129    ($ctx:expr, $delete:expr, $quote:ident, $render_expr:ident, $supports_returning:expr) => {{
130        $ctx.sql.push_str("DELETE FROM ");
131        $ctx.sql.push_str(&$quote(&$delete.table));
132
133        if let Some(ref filter) = $delete.filter {
134            $ctx.sql.push_str(" WHERE ");
135            $render_expr($ctx, filter);
136        }
137
138        if $supports_returning {
139            render_returning!($ctx, $delete.returning, $quote);
140        }
141    }};
142}
143
144/// Render the full body of a SELECT statement into `$ctx`.
145///
146/// - `$distinct_on`: `true` for PostgreSQL-style `DISTINCT ON (cols)`;
147///   `false` emits plain `SELECT DISTINCT`.
148/// - `$mysql_limit_hack`: `true` inserts a synthetic `LIMIT 18446744073709551615`
149///   when only OFFSET is present (required by MySQL).
150/// - `$render_expr`: the dialect-local expression renderer.
151macro_rules! render_select_body_core {
152    (
153        $ctx:expr, $select:expr,
154        $quote:ident, $render_expr:ident,
155        $distinct_on:expr, $mysql_limit_hack:expr
156    ) => {{
157        $ctx.sql.push_str("SELECT ");
158
159        // DISTINCT handling: Postgres supports DISTINCT ON (cols);
160        // other dialects support only full-row SELECT DISTINCT.
161        if !$select.distinct.is_empty() {
162            if $distinct_on {
163                $ctx.sql.push_str("DISTINCT ON (");
164                for (i, col) in $select.distinct.iter().enumerate() {
165                    if i > 0 {
166                        $ctx.sql.push_str(", ");
167                    }
168                    $ctx.sql.push_str(&$quote(col));
169                }
170                $ctx.sql.push_str(") ");
171            } else {
172                $ctx.sql.push_str("DISTINCT ");
173            }
174        }
175
176        let join_items: Vec<&nautilus_core::SelectItem> =
177            $select.joins.iter().flat_map(|j| j.items.iter()).collect();
178        let has_items = !$select.items.is_empty() || !join_items.is_empty();
179
180        if !has_items {
181            $ctx.sql.push('*');
182        } else {
183            let mut first = true;
184            for item in $select.items.iter().chain(join_items.iter().copied()) {
185                if !first {
186                    $ctx.sql.push_str(", ");
187                }
188                first = false;
189                // Inline render_select_item using the dialect's quote and render_expr.
190                match item {
191                    nautilus_core::SelectItem::Column(col) => {
192                        $ctx.sql.push_str(&$quote(&col.table));
193                        $ctx.sql.push('.');
194                        $ctx.sql.push_str(&$quote(&col.name));
195                        $ctx.sql.push_str(" AS ");
196                        $ctx.sql.push_str(&$quote(&col.alias()));
197                    }
198                    nautilus_core::SelectItem::Computed { expr, alias } => {
199                        $ctx.sql.push('(');
200                        $render_expr($ctx, expr);
201                        $ctx.sql.push(')');
202                        $ctx.sql.push_str(" AS ");
203                        $ctx.sql.push_str(&$quote(alias));
204                    }
205                }
206            }
207        }
208
209        $ctx.sql.push_str(" FROM ");
210        $ctx.sql.push_str(&$quote(&$select.table));
211
212        for join in &$select.joins {
213            match join.join_type {
214                nautilus_core::JoinType::Inner => $ctx.sql.push_str(" INNER JOIN "),
215                nautilus_core::JoinType::Left => $ctx.sql.push_str(" LEFT JOIN "),
216            }
217            $ctx.sql.push_str(&$quote(&join.table));
218            $ctx.sql.push_str(" ON ");
219            $render_expr($ctx, &join.on);
220        }
221
222        if let Some(ref filter) = $select.filter {
223            $ctx.sql.push_str(" WHERE ");
224            $render_expr($ctx, filter);
225        }
226
227        if !$select.group_by.is_empty() {
228            $ctx.sql.push_str(" GROUP BY ");
229            for (i, col) in $select.group_by.iter().enumerate() {
230                if i > 0 {
231                    $ctx.sql.push_str(", ");
232                }
233                $ctx.sql.push_str(&$quote(&col.table));
234                $ctx.sql.push('.');
235                $ctx.sql.push_str(&$quote(&col.name));
236            }
237        }
238
239        if let Some(ref having) = $select.having {
240            $ctx.sql.push_str(" HAVING ");
241            $render_expr($ctx, having);
242        }
243
244        let has_col_order = !$select.order_by.is_empty();
245        let has_expr_order = !$select.order_by_exprs.is_empty();
246        if has_col_order || has_expr_order {
247            $ctx.sql.push_str(" ORDER BY ");
248            let mut first = true;
249            for order in &$select.order_by {
250                if !first {
251                    $ctx.sql.push_str(", ");
252                }
253                first = false;
254                $ctx.sql.push_str(&$quote(&order.column));
255                match order.direction {
256                    nautilus_core::OrderDir::Asc => $ctx.sql.push_str(" ASC"),
257                    nautilus_core::OrderDir::Desc => $ctx.sql.push_str(" DESC"),
258                }
259            }
260            for (expr, dir) in &$select.order_by_exprs {
261                if !first {
262                    $ctx.sql.push_str(", ");
263                }
264                first = false;
265                $render_expr($ctx, expr);
266                match dir {
267                    nautilus_core::OrderDir::Asc => $ctx.sql.push_str(" ASC"),
268                    nautilus_core::OrderDir::Desc => $ctx.sql.push_str(" DESC"),
269                }
270            }
271        }
272
273        // MySQL requires LIMIT whenever OFFSET is present; emit a synthetic max value.
274        if let Some(take) = $select.take {
275            $ctx.sql.push_str(" LIMIT ");
276            $ctx.sql.push_str(&take.unsigned_abs().to_string());
277        } else if $mysql_limit_hack && $select.skip.is_some() {
278            $ctx.sql.push_str(" LIMIT 18446744073709551615");
279        }
280
281        if let Some(skip) = $select.skip {
282            $ctx.sql.push_str(" OFFSET ");
283            $ctx.sql.push_str(&skip.to_string());
284        }
285    }};
286}
287
288/// Render the `Expr` variants that are **identical** across all SQL dialect renderers.
289///
290/// Eight variants (`Column`, `Not`, `Exists`, `NotExists`, `ScalarSubquery`,
291/// `IsNull`, `IsNotNull`, `Literal`) have the same rendering logic in every
292/// dialect — the only structural difference is which function is called to
293/// quote identifiers and which function recurses for sub-expressions.
294///
295/// The four dialect-specific variants (`Param`, `Binary`, `FunctionCall`,
296/// `Filter`) are provided by the caller as a block of match arms in
297/// `{ $($specific:tt)* }` and are appended after the shared arms.
298///
299/// Parameters:
300/// - `$ctx`: `&mut RenderContext` — mutable render context
301/// - `$expr`: `&Expr` — the expression to render
302/// - `$quote`: local identifier-quoting function
303/// - `$render_expr`: dialect-local recursive expression renderer
304/// - `$render_select_body`: dialect-local subquery renderer
305/// - `{ $($specific:tt)* }`: match arms for dialect-specific variants
306macro_rules! render_expr_common {
307    (
308        $ctx:expr, $expr:expr,
309        $quote:ident, $render_expr:ident, $render_select_body:ident,
310        { $($specific:tt)* }
311    ) => {
312        match $expr {
313            // Split "table__column" into a qualified identifier pair; otherwise
314            // render as a single unqualified identifier.
315            nautilus_core::Expr::Column(name) => {
316                if let Some((table, column)) = name.split_once("__") {
317                    $ctx.sql.push_str(&$quote(table));
318                    $ctx.sql.push('.');
319                    $ctx.sql.push_str(&$quote(column));
320                } else {
321                    $ctx.sql.push_str(&$quote(name));
322                }
323            }
324            nautilus_core::Expr::Not(inner) => {
325                $ctx.sql.push_str("NOT (");
326                $render_expr($ctx, inner);
327                $ctx.sql.push(')');
328            }
329            nautilus_core::Expr::Exists(subquery) => {
330                $ctx.sql.push_str("EXISTS (");
331                $render_select_body($ctx, subquery);
332                $ctx.sql.push(')');
333            }
334            nautilus_core::Expr::NotExists(subquery) => {
335                $ctx.sql.push_str("NOT EXISTS (");
336                $render_select_body($ctx, subquery);
337                $ctx.sql.push(')');
338            }
339            nautilus_core::Expr::ScalarSubquery(subquery) => {
340                $ctx.sql.push('(');
341                $render_select_body($ctx, subquery);
342                $ctx.sql.push(')');
343            }
344            nautilus_core::Expr::IsNull(inner) => {
345                $ctx.sql.push('(');
346                $render_expr($ctx, inner);
347                $ctx.sql.push_str(" IS NULL)");
348            }
349            nautilus_core::Expr::IsNotNull(inner) => {
350                $ctx.sql.push('(');
351                $render_expr($ctx, inner);
352                $ctx.sql.push_str(" IS NOT NULL)");
353            }
354            // Emit as a single-quoted SQL string literal with internal
355            // single-quotes escaped by doubling.
356            // Must only be called with trusted, static strings.
357            nautilus_core::Expr::Literal(s) => {
358                $ctx.sql.push('\'');
359                $ctx.sql.push_str(&s.replace('\'', "''"));
360                $ctx.sql.push('\'');
361            }
362            nautilus_core::Expr::List(exprs) => {
363                for (i, e) in exprs.iter().enumerate() {
364                    if i > 0 { $ctx.sql.push_str(", "); }
365                    $render_expr($ctx, e);
366                }
367            }
368            nautilus_core::Expr::CaseWhen { condition, then } => {
369                $ctx.sql.push_str("CASE WHEN ");
370                $render_expr($ctx, condition);
371                $ctx.sql.push_str(" THEN ");
372                $render_expr($ctx, then);
373                $ctx.sql.push_str(" ELSE NULL END");
374            }
375            nautilus_core::Expr::Star => {
376                $ctx.sql.push('*');
377            }
378            $($specific)*
379        }
380    };
381}
382
383mod mysql;
384mod postgres;
385mod sqlite;
386
387pub use mysql::MysqlDialect;
388pub use postgres::PostgresDialect;
389pub use sqlite::SqliteDialect;
390
391use nautilus_core::{Delete, Insert, Result, Select, Update, Value};
392
393/// SQL query with bound parameters.
394///
395/// Separates the SQL text from parameter values for use with prepared statements.
396#[derive(Debug, Clone, PartialEq)]
397#[must_use]
398pub struct Sql {
399    /// The SQL query text with parameter placeholders.
400    pub text: String,
401    /// The parameter values to bind to the query.
402    pub params: Vec<Value>,
403}
404
405/// Trait for SQL dialect renderers.
406///
407/// Allows rendering AST queries into dialect-specific SQL strings.
408pub trait Dialect {
409    /// Whether this dialect natively supports the RETURNING clause
410    /// on INSERT, UPDATE, and DELETE statements.
411    ///
412    /// Dialects that return `false` (e.g. MySQL) will have RETURNING
413    /// emulated at the connector layer via separate queries.
414    fn supports_returning(&self) -> bool {
415        true
416    }
417
418    /// Render a SELECT query into SQL.
419    fn render_select(&self, select: &Select) -> Result<Sql>;
420
421    /// Render an INSERT query into SQL.
422    fn render_insert(&self, insert: &Insert) -> Result<Sql>;
423
424    /// Render an UPDATE query into SQL.
425    fn render_update(&self, update: &Update) -> Result<Sql>;
426
427    /// Render a DELETE query into SQL.
428    fn render_delete(&self, delete: &Delete) -> Result<Sql>;
429}
430
431/// Quote a SQL identifier with double quotes (ANSI standard; used by PostgreSQL and SQLite).
432///
433/// Internal double quotes are escaped by doubling them.
434pub(crate) fn double_quote_identifier(name: &str) -> String {
435    format!("\"{}\"", name.replace('"', "\"\""))
436}
437
438/// Quote a SQL identifier with backticks (MySQL style).
439///
440/// Internal backticks are escaped by doubling them.
441pub(crate) fn backtick_quote_identifier(name: &str) -> String {
442    format!("`{}`", name.replace('`', "``"))
443}
444
445/// Return the SQL operator keyword for a standard scalar binary operation.
446///
447/// Call only for the nine scalar operators (Eq through Like).  Composite cases
448/// (IN/NOT IN, array operators) must be handled separately by each dialect before
449/// delegating to this helper.
450#[inline]
451pub(crate) fn binary_op_sql(op: &nautilus_core::BinaryOp) -> &'static str {
452    match op {
453        nautilus_core::BinaryOp::Eq => "=",
454        nautilus_core::BinaryOp::Ne => "!=",
455        nautilus_core::BinaryOp::Lt => "<",
456        nautilus_core::BinaryOp::Le => "<=",
457        nautilus_core::BinaryOp::Gt => ">",
458        nautilus_core::BinaryOp::Ge => ">=",
459        nautilus_core::BinaryOp::And => "AND",
460        nautilus_core::BinaryOp::Or => "OR",
461        nautilus_core::BinaryOp::Like => "LIKE",
462        nautilus_core::BinaryOp::ArrayContains
463        | nautilus_core::BinaryOp::ArrayContainedBy
464        | nautilus_core::BinaryOp::ArrayOverlaps
465        | nautilus_core::BinaryOp::In
466        | nautilus_core::BinaryOp::NotIn => {
467            unreachable!(
468                "binary_op_sql: operator {:?} must be handled by dialect-specific code",
469                op
470            )
471        }
472    }
473}