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:expr`, `$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.
14///
15/// All render paths consume the AST by value (moving bound values out instead of
16/// cloning them), so the rendering macros take `&mut` and are the single source of
17/// truth for SQL generation. The borrowed `Dialect::render_*` entry points simply
18/// clone the AST once and delegate here.
19macro_rules! render_returning_mut {
20    ($ctx:expr, $returning:expr, $quote:expr) => {{
21        if !$returning.is_empty() {
22            $ctx.sql.push_str(" RETURNING ");
23            for (i, col) in $returning.iter().enumerate() {
24                if i > 0 {
25                    $ctx.sql.push_str(", ");
26                }
27                crate::push_qualified_identifier(&mut $ctx.sql, &col.table, &col.name, $quote);
28                $ctx.sql.push_str(" AS ");
29                crate::push_column_alias(&mut $ctx.sql, col, $quote);
30            }
31        }
32    }};
33}
34
35/// Mutable/owned variant of [`render_insert_body!`] used by `render_*_owned`.
36macro_rules! render_insert_body_mut {
37    ($ctx:expr, $insert:expr, $quote:expr, $supports_returning:expr, $supports_enum_cast:expr) => {{
38        $ctx.sql.push_str("INSERT INTO ");
39        crate::push_quoted_identifier(&mut $ctx.sql, &$insert.table, $quote);
40
41        $ctx.sql.push_str(" (");
42        for (i, col) in $insert.columns.iter().enumerate() {
43            if i > 0 {
44                $ctx.sql.push_str(", ");
45            }
46            crate::push_quoted_identifier(&mut $ctx.sql, &col.name, $quote);
47        }
48        $ctx.sql.push(')');
49
50        $ctx.sql.push_str(" VALUES ");
51        for (row_idx, row) in $insert.values.iter_mut().enumerate() {
52            if row_idx > 0 {
53                $ctx.sql.push_str(", ");
54            }
55            $ctx.sql.push('(');
56            for (val_idx, value) in row.iter_mut().enumerate() {
57                if val_idx > 0 {
58                    $ctx.sql.push_str(", ");
59                }
60                if matches!(value, nautilus_core::Value::Null) {
61                    $ctx.sql.push_str("NULL");
62                } else {
63                    let cast_type_name = if $supports_enum_cast {
64                        if let nautilus_core::Value::Enum { type_name, .. }
65                        | nautilus_core::Value::Composite { type_name, .. } = value
66                        {
67                            Some(type_name.clone())
68                        } else {
69                            None
70                        }
71                    } else {
72                        None
73                    };
74                    $ctx.take_param(value);
75                    if let Some(type_name) = cast_type_name.as_deref() {
76                        $ctx.sql.push_str("::");
77                        crate::push_quoted_identifier(&mut $ctx.sql, type_name, $quote);
78                    }
79                }
80            }
81            $ctx.sql.push(')');
82        }
83
84        if $supports_returning {
85            render_returning_mut!($ctx, $insert.returning, $quote);
86        }
87    }};
88}
89
90/// Mutable/owned variant of [`render_update_body!`] used by `render_*_owned`.
91macro_rules! render_update_body_mut {
92    ($ctx:expr, $update:expr, $quote:expr, $render_expr:ident, $supports_returning:expr, $supports_enum_cast:expr) => {{
93        $ctx.sql.push_str("UPDATE ");
94        crate::push_quoted_identifier(&mut $ctx.sql, &$update.table, $quote);
95
96        $ctx.sql.push_str(" SET ");
97        for (i, (col, value)) in $update.assignments.iter_mut().enumerate() {
98            if i > 0 {
99                $ctx.sql.push_str(", ");
100            }
101            crate::push_quoted_identifier(&mut $ctx.sql, &col.name, $quote);
102            $ctx.sql.push_str(" = ");
103            if matches!(value, nautilus_core::Value::Null) {
104                $ctx.sql.push_str("NULL");
105            } else {
106                let cast_type_name = if $supports_enum_cast {
107                    if let nautilus_core::Value::Enum { type_name, .. }
108                    | nautilus_core::Value::Composite { type_name, .. } = value
109                    {
110                        Some(type_name.clone())
111                    } else {
112                        None
113                    }
114                } else {
115                    None
116                };
117                $ctx.take_param(value);
118                if let Some(type_name) = cast_type_name.as_deref() {
119                    $ctx.sql.push_str("::");
120                    crate::push_quoted_identifier(&mut $ctx.sql, type_name, $quote);
121                }
122            }
123        }
124
125        if let Some(filter) = $update.filter.as_mut() {
126            $ctx.sql.push_str(" WHERE ");
127            $render_expr($ctx, filter);
128        }
129
130        if $supports_returning {
131            render_returning_mut!($ctx, $update.returning, $quote);
132        }
133    }};
134}
135
136/// Mutable/owned variant of [`render_delete_body!`] used by `render_*_owned`.
137macro_rules! render_delete_body_mut {
138    ($ctx:expr, $delete:expr, $quote:expr, $render_expr:ident, $supports_returning:expr) => {{
139        $ctx.sql.push_str("DELETE FROM ");
140        crate::push_quoted_identifier(&mut $ctx.sql, &$delete.table, $quote);
141
142        if let Some(filter) = $delete.filter.as_mut() {
143            $ctx.sql.push_str(" WHERE ");
144            $render_expr($ctx, filter);
145        }
146
147        if $supports_returning {
148            render_returning_mut!($ctx, $delete.returning, $quote);
149        }
150    }};
151}
152
153/// Mutable/owned variant of [`render_select_body_core!`] used by `render_*_owned`.
154macro_rules! render_select_body_core_mut {
155    (
156        $ctx:expr, $select:expr,
157        $quote:expr, $render_expr:ident,
158        $distinct_on:expr, $mysql_limit_hack:expr
159    ) => {{
160        $ctx.sql.push_str("SELECT ");
161
162        if !$select.distinct.is_empty() {
163            if $distinct_on {
164                $ctx.sql.push_str("DISTINCT ON (");
165                for (i, col) in $select.distinct.iter().enumerate() {
166                    if i > 0 {
167                        $ctx.sql.push_str(", ");
168                    }
169                    crate::push_identifier_reference(&mut $ctx.sql, col, $quote);
170                }
171                $ctx.sql.push_str(") ");
172            } else {
173                $ctx.sql.push_str("DISTINCT ");
174            }
175        }
176
177        let has_items =
178            !$select.items.is_empty() || $select.joins.iter().any(|join| !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_mut() {
185                if !first {
186                    $ctx.sql.push_str(", ");
187                }
188                first = false;
189                match item {
190                    nautilus_core::SelectItem::Column(col) => {
191                        crate::push_qualified_identifier(
192                            &mut $ctx.sql,
193                            &col.table,
194                            &col.name,
195                            $quote,
196                        );
197                        $ctx.sql.push_str(" AS ");
198                        crate::push_column_alias(&mut $ctx.sql, col, $quote);
199                    }
200                    nautilus_core::SelectItem::Computed { expr, alias } => {
201                        $ctx.sql.push('(');
202                        $render_expr($ctx, expr);
203                        $ctx.sql.push(')');
204                        $ctx.sql.push_str(" AS ");
205                        crate::push_quoted_identifier(&mut $ctx.sql, alias, $quote);
206                    }
207                }
208            }
209            for join in $select.joins.iter_mut() {
210                for item in join.items.iter_mut() {
211                    if !first {
212                        $ctx.sql.push_str(", ");
213                    }
214                    first = false;
215                    match item {
216                        nautilus_core::SelectItem::Column(col) => {
217                            crate::push_qualified_identifier(
218                                &mut $ctx.sql,
219                                &col.table,
220                                &col.name,
221                                $quote,
222                            );
223                            $ctx.sql.push_str(" AS ");
224                            crate::push_column_alias(&mut $ctx.sql, col, $quote);
225                        }
226                        nautilus_core::SelectItem::Computed { expr, alias } => {
227                            $ctx.sql.push('(');
228                            $render_expr($ctx, expr);
229                            $ctx.sql.push(')');
230                            $ctx.sql.push_str(" AS ");
231                            crate::push_quoted_identifier(&mut $ctx.sql, alias, $quote);
232                        }
233                    }
234                }
235            }
236        }
237
238        $ctx.sql.push_str(" FROM ");
239        crate::push_quoted_identifier(&mut $ctx.sql, &$select.table, $quote);
240
241        for join in $select.joins.iter_mut() {
242            match join.join_type {
243                nautilus_core::JoinType::Inner => $ctx.sql.push_str(" INNER JOIN "),
244                nautilus_core::JoinType::Left => $ctx.sql.push_str(" LEFT JOIN "),
245            }
246            crate::push_quoted_identifier(&mut $ctx.sql, &join.table, $quote);
247            $ctx.sql.push_str(" ON ");
248            $render_expr($ctx, &mut join.on);
249        }
250
251        if let Some(filter) = $select.filter.as_mut() {
252            $ctx.sql.push_str(" WHERE ");
253            $render_expr($ctx, filter);
254        }
255
256        if !$select.group_by.is_empty() {
257            $ctx.sql.push_str(" GROUP BY ");
258            for (i, col) in $select.group_by.iter().enumerate() {
259                if i > 0 {
260                    $ctx.sql.push_str(", ");
261                }
262                crate::push_qualified_identifier(&mut $ctx.sql, &col.table, &col.name, $quote);
263            }
264        }
265
266        if let Some(having) = $select.having.as_mut() {
267            $ctx.sql.push_str(" HAVING ");
268            $render_expr($ctx, having);
269        }
270
271        let has_order_items = !$select.order_by_items.is_empty();
272        let has_col_order = !$select.order_by.is_empty();
273        let has_expr_order = !$select.order_by_exprs.is_empty();
274        if has_order_items || has_col_order || has_expr_order {
275            $ctx.sql.push_str(" ORDER BY ");
276            let mut first = true;
277            if has_order_items {
278                for item in $select.order_by_items.iter_mut() {
279                    if !first {
280                        $ctx.sql.push_str(", ");
281                    }
282                    first = false;
283                    match item {
284                        nautilus_core::OrderByItem::Column(order) => {
285                            crate::push_identifier_reference(&mut $ctx.sql, &order.column, $quote);
286                            match order.direction {
287                                nautilus_core::OrderDir::Asc => $ctx.sql.push_str(" ASC"),
288                                nautilus_core::OrderDir::Desc => $ctx.sql.push_str(" DESC"),
289                            }
290                        }
291                        nautilus_core::OrderByItem::Expr(expr, dir) => {
292                            $render_expr($ctx, expr);
293                            match *dir {
294                                nautilus_core::OrderDir::Asc => $ctx.sql.push_str(" ASC"),
295                                nautilus_core::OrderDir::Desc => $ctx.sql.push_str(" DESC"),
296                            }
297                        }
298                    }
299                }
300            } else {
301                for order in $select.order_by.iter() {
302                    if !first {
303                        $ctx.sql.push_str(", ");
304                    }
305                    first = false;
306                    crate::push_identifier_reference(&mut $ctx.sql, &order.column, $quote);
307                    match order.direction {
308                        nautilus_core::OrderDir::Asc => $ctx.sql.push_str(" ASC"),
309                        nautilus_core::OrderDir::Desc => $ctx.sql.push_str(" DESC"),
310                    }
311                }
312                for (expr, dir) in $select.order_by_exprs.iter_mut() {
313                    if !first {
314                        $ctx.sql.push_str(", ");
315                    }
316                    first = false;
317                    $render_expr($ctx, expr);
318                    match *dir {
319                        nautilus_core::OrderDir::Asc => $ctx.sql.push_str(" ASC"),
320                        nautilus_core::OrderDir::Desc => $ctx.sql.push_str(" DESC"),
321                    }
322                }
323            }
324        }
325
326        if let Some(take) = $select.take {
327            $ctx.sql.push_str(" LIMIT ");
328            crate::push_u32(&mut $ctx.sql, take.unsigned_abs());
329        } else if $mysql_limit_hack && $select.skip.is_some() {
330            $ctx.sql.push_str(" LIMIT 18446744073709551615");
331        }
332
333        if let Some(skip) = $select.skip {
334            $ctx.sql.push_str(" OFFSET ");
335            crate::push_u32(&mut $ctx.sql, skip);
336        }
337    }};
338}
339
340/// Mutable/owned variant of [`render_expr_common!`] used by `render_*_owned`.
341macro_rules! render_expr_common_mut {
342    (
343        $ctx:expr, $expr:expr,
344        $quote:expr, $render_expr:ident, $render_select_body:ident,
345        { $($specific:tt)* }
346    ) => {
347        match $expr {
348            nautilus_core::Expr::Column(name) => {
349                crate::push_identifier_reference(&mut $ctx.sql, name, $quote);
350            }
351            nautilus_core::Expr::Not(inner) => {
352                $ctx.sql.push_str("NOT (");
353                $render_expr($ctx, inner.as_mut());
354                $ctx.sql.push(')');
355            }
356            nautilus_core::Expr::Exists(subquery) => {
357                $ctx.sql.push_str("EXISTS (");
358                $render_select_body($ctx, subquery.as_mut());
359                $ctx.sql.push(')');
360            }
361            nautilus_core::Expr::NotExists(subquery) => {
362                $ctx.sql.push_str("NOT EXISTS (");
363                $render_select_body($ctx, subquery.as_mut());
364                $ctx.sql.push(')');
365            }
366            nautilus_core::Expr::Relation { op, relation } => {
367                let is_exists = matches!(*op, nautilus_core::expr::RelationFilterOp::Some);
368                if is_exists {
369                    $ctx.sql.push_str("EXISTS (SELECT * FROM ");
370                } else {
371                    $ctx.sql.push_str("NOT EXISTS (SELECT * FROM ");
372                }
373                crate::push_quoted_identifier(&mut $ctx.sql, &relation.target_table, $quote);
374                $ctx.sql.push_str(" WHERE ");
375                crate::push_qualified_identifier(
376                    &mut $ctx.sql,
377                    &relation.target_table,
378                    &relation.fk_db,
379                    $quote,
380                );
381                $ctx.sql.push_str(" = ");
382                crate::push_qualified_identifier(
383                    &mut $ctx.sql,
384                    &relation.parent_table,
385                    &relation.pk_db,
386                    $quote,
387                );
388                $ctx.sql.push_str(" AND ");
389                if matches!(*op, nautilus_core::expr::RelationFilterOp::Every) {
390                    $ctx.sql.push_str("NOT (");
391                    $render_expr($ctx, relation.filter.as_mut());
392                    $ctx.sql.push(')');
393                } else {
394                    $render_expr($ctx, relation.filter.as_mut());
395                }
396                $ctx.sql.push(')');
397            }
398            nautilus_core::Expr::ScalarSubquery(subquery) => {
399                $ctx.sql.push('(');
400                $render_select_body($ctx, subquery.as_mut());
401                $ctx.sql.push(')');
402            }
403            nautilus_core::Expr::IsNull(inner) => {
404                $ctx.sql.push('(');
405                $render_expr($ctx, inner.as_mut());
406                $ctx.sql.push_str(" IS NULL)");
407            }
408            nautilus_core::Expr::IsNotNull(inner) => {
409                $ctx.sql.push('(');
410                $render_expr($ctx, inner.as_mut());
411                $ctx.sql.push_str(" IS NOT NULL)");
412            }
413            nautilus_core::Expr::Literal(s) => {
414                crate::push_sql_string_literal(&mut $ctx.sql, s.as_str());
415            }
416            nautilus_core::Expr::List(exprs) => {
417                for (i, e) in exprs.iter_mut().enumerate() {
418                    if i > 0 {
419                        $ctx.sql.push_str(", ");
420                    }
421                    $render_expr($ctx, e);
422                }
423            }
424            nautilus_core::Expr::CaseWhen { condition, then } => {
425                $ctx.sql.push_str("CASE WHEN ");
426                $render_expr($ctx, condition.as_mut());
427                $ctx.sql.push_str(" THEN ");
428                $render_expr($ctx, then.as_mut());
429                $ctx.sql.push_str(" ELSE NULL END");
430            }
431            nautilus_core::Expr::Star => {
432                $ctx.sql.push('*');
433            }
434            $($specific)*
435        }
436    };
437}
438
439mod mysql;
440mod postgres;
441mod render_estimate;
442mod sqlite;
443
444pub use mysql::MysqlDialect;
445pub use postgres::PostgresDialect;
446pub use sqlite::SqliteDialect;
447
448use nautilus_core::{Delete, Insert, Result, Select, Update, Value};
449pub(crate) use render_estimate::{
450    estimate_delete_render, estimate_insert_render, estimate_select_render, estimate_update_render,
451    RenderEstimate,
452};
453
454/// SQL query with bound parameters.
455///
456/// Separates the SQL text from parameter values for use with prepared statements.
457#[derive(Debug, Clone, PartialEq)]
458#[must_use]
459pub struct Sql {
460    /// The SQL query text with parameter placeholders.
461    pub text: String,
462    /// The parameter values to bind to the query.
463    pub params: Vec<Value>,
464}
465
466/// Trait for SQL dialect renderers.
467///
468/// Allows rendering AST queries into dialect-specific SQL strings.
469pub trait Dialect {
470    /// Whether this dialect natively supports the RETURNING clause
471    /// on INSERT, UPDATE, and DELETE statements.
472    ///
473    /// Dialects that return `false` (e.g. MySQL) will have RETURNING
474    /// emulated at the connector layer via separate queries.
475    fn supports_returning(&self) -> bool {
476        true
477    }
478
479    /// Render an owned SELECT query into SQL, moving bound values out of the AST
480    /// instead of cloning them. This is the primary rendering entry point used by
481    /// the engine's hot paths; dialects implement this.
482    fn render_select_owned(&self, select: Select) -> Result<Sql>;
483
484    /// Render a borrowed SELECT query into SQL.
485    ///
486    /// Clones the AST once and delegates to [`Self::render_select_owned`]. Used by
487    /// previews, tests, and other non-hot paths that only hold a `&Select`.
488    fn render_select(&self, select: &Select) -> Result<Sql> {
489        self.render_select_owned(select.clone())
490    }
491
492    /// Render an owned INSERT query into SQL, moving bound values out of the AST
493    /// instead of cloning them.
494    fn render_insert_owned(&self, insert: Insert) -> Result<Sql>;
495
496    /// Render a borrowed INSERT query into SQL by cloning and delegating to
497    /// [`Self::render_insert_owned`].
498    fn render_insert(&self, insert: &Insert) -> Result<Sql> {
499        self.render_insert_owned(insert.clone())
500    }
501
502    /// Render an owned UPDATE query into SQL, moving bound values out of the AST
503    /// instead of cloning them.
504    fn render_update_owned(&self, update: Update) -> Result<Sql>;
505
506    /// Render a borrowed UPDATE query into SQL by cloning and delegating to
507    /// [`Self::render_update_owned`].
508    fn render_update(&self, update: &Update) -> Result<Sql> {
509        self.render_update_owned(update.clone())
510    }
511
512    /// Render an owned DELETE query into SQL, moving bound values out of the AST
513    /// instead of cloning them.
514    fn render_delete_owned(&self, delete: Delete) -> Result<Sql>;
515
516    /// Render a borrowed DELETE query into SQL by cloning and delegating to
517    /// [`Self::render_delete_owned`].
518    fn render_delete(&self, delete: &Delete) -> Result<Sql> {
519        self.render_delete_owned(delete.clone())
520    }
521}
522
523fn push_escaped_identifier(sql: &mut String, name: &str, quote: char) {
524    for ch in name.chars() {
525        if ch == quote {
526            sql.push(quote);
527        }
528        sql.push(ch);
529    }
530}
531
532/// Quote a SQL identifier directly into the SQL buffer.
533pub(crate) fn push_quoted_identifier(sql: &mut String, name: &str, quote: char) {
534    sql.push(quote);
535    push_escaped_identifier(sql, name, quote);
536    sql.push(quote);
537}
538
539/// Quote multiple identifier segments as a single identifier directly into the SQL buffer.
540pub(crate) fn push_quoted_identifier_segments(sql: &mut String, segments: &[&str], quote: char) {
541    sql.push(quote);
542    for segment in segments {
543        push_escaped_identifier(sql, segment, quote);
544    }
545    sql.push(quote);
546}
547
548/// Render `table.column` directly into the SQL buffer.
549pub(crate) fn push_qualified_identifier(sql: &mut String, table: &str, column: &str, quote: char) {
550    push_quoted_identifier(sql, table, quote);
551    sql.push('.');
552    push_quoted_identifier(sql, column, quote);
553}
554
555/// Render a join-safe `table__column` alias directly into the SQL buffer.
556pub(crate) fn push_column_alias(
557    sql: &mut String,
558    column: &nautilus_core::ColumnMarker,
559    quote: char,
560) {
561    push_quoted_identifier_segments(
562        sql,
563        &[column.table.as_ref(), "__", column.name.as_ref()],
564        quote,
565    );
566}
567
568/// Render an identifier reference that may use the `table__column` shorthand.
569///
570/// The split happens only on the first `__`, so mapped column names like
571/// `users__profile__slug` still render as `users.profile__slug`.
572pub(crate) fn push_identifier_reference(sql: &mut String, name: &str, quote: char) {
573    if let Some((table, column)) = name.split_once("__") {
574        push_qualified_identifier(sql, table, column, quote);
575    } else {
576        push_quoted_identifier(sql, name, quote);
577    }
578}
579
580/// Render a single-quoted SQL string literal directly into the SQL buffer.
581pub(crate) fn push_sql_string_literal(sql: &mut String, value: &str) {
582    sql.push('\'');
583    for ch in value.chars() {
584        if ch == '\'' {
585            sql.push('\'');
586        }
587        sql.push(ch);
588    }
589    sql.push('\'');
590}
591
592fn push_u64(sql: &mut String, mut value: u64) {
593    let mut digits = [0_u8; 20];
594    let mut idx = digits.len();
595
596    loop {
597        idx -= 1;
598        digits[idx] = b'0' + (value % 10) as u8;
599        value /= 10;
600        if value == 0 {
601            break;
602        }
603    }
604
605    for digit in &digits[idx..] {
606        sql.push(char::from(*digit));
607    }
608}
609
610/// Append a `u32` value directly into the SQL buffer.
611pub(crate) fn push_u32(sql: &mut String, value: u32) {
612    push_u64(sql, u64::from(value));
613}
614
615/// Append a `usize` value directly into the SQL buffer.
616pub(crate) fn push_usize(sql: &mut String, value: usize) {
617    push_u64(sql, value as u64);
618}
619
620/// Return the SQL operator keyword for a standard scalar binary operation.
621///
622/// Call only for the nine scalar operators (Eq through Like).  Composite cases
623/// (IN/NOT IN, array operators) must be handled separately by each dialect before
624/// delegating to this helper.
625#[inline]
626pub(crate) fn binary_op_sql(op: &nautilus_core::BinaryOp) -> &'static str {
627    match op {
628        nautilus_core::BinaryOp::Eq => "=",
629        nautilus_core::BinaryOp::Ne => "!=",
630        nautilus_core::BinaryOp::Lt => "<",
631        nautilus_core::BinaryOp::Le => "<=",
632        nautilus_core::BinaryOp::Gt => ">",
633        nautilus_core::BinaryOp::Ge => ">=",
634        nautilus_core::BinaryOp::And => "AND",
635        nautilus_core::BinaryOp::Or => "OR",
636        nautilus_core::BinaryOp::Like => "LIKE",
637        nautilus_core::BinaryOp::ArrayContains
638        | nautilus_core::BinaryOp::ArrayContainedBy
639        | nautilus_core::BinaryOp::ArrayOverlaps
640        | nautilus_core::BinaryOp::In
641        | nautilus_core::BinaryOp::NotIn => {
642            unreachable!(
643                "binary_op_sql: operator {:?} must be handled by dialect-specific code",
644                op
645            )
646        }
647    }
648}