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