Skip to main content

nautilus_dialect/
mysql.rs

1//! MySQL SQL dialect renderer.
2
3use crate::{Dialect, Sql};
4use nautilus_core::{BinaryOp, Delete, Expr, Insert, Result, Select, Update, Value};
5
6/// MySQL SQL dialect renderer.
7#[derive(Debug, Clone, Copy)]
8pub struct MysqlDialect;
9
10/// Renders query ASTs into MySQL-compatible SQL with `?` placeholders
11/// and backtick-quoted identifiers.
12impl Dialect for MysqlDialect {
13    fn supports_returning(&self) -> bool {
14        false
15    }
16
17    fn render_select(&self, select: &Select) -> Result<Sql> {
18        let mut ctx = RenderContext::new();
19        // No DISTINCT ON (MySQL); use the MySQL large-LIMIT hack for offset-only queries.
20        render_select_body_core!(&mut ctx, select, quote_identifier, render_expr, false, true);
21        Ok(Sql {
22            text: ctx.sql,
23            params: ctx.params,
24        })
25    }
26
27    fn render_insert(&self, insert: &Insert) -> Result<Sql> {
28        let mut ctx = RenderContext::new();
29        render_insert_body!(&mut ctx, insert, quote_identifier, false, false);
30        Ok(Sql {
31            text: ctx.sql,
32            params: ctx.params,
33        })
34    }
35
36    fn render_update(&self, update: &Update) -> Result<Sql> {
37        let mut ctx = RenderContext::new();
38        render_update_body!(
39            &mut ctx,
40            update,
41            quote_identifier,
42            render_expr,
43            false,
44            false
45        );
46        Ok(Sql {
47            text: ctx.sql,
48            params: ctx.params,
49        })
50    }
51
52    fn render_delete(&self, delete: &Delete) -> Result<Sql> {
53        let mut ctx = RenderContext::new();
54        render_delete_body!(&mut ctx, delete, quote_identifier, render_expr, false);
55        Ok(Sql {
56            text: ctx.sql,
57            params: ctx.params,
58        })
59    }
60}
61
62/// Quote a SQL identifier with backticks (MySQL style).
63fn quote_identifier(name: &str) -> String {
64    crate::backtick_quote_identifier(name)
65}
66
67/// Context for rendering SQL with parameter tracking.
68struct RenderContext {
69    sql: String,
70    params: Vec<Value>,
71}
72
73impl RenderContext {
74    fn new() -> Self {
75        Self {
76            sql: String::new(),
77            params: Vec::new(),
78        }
79    }
80
81    /// Push a parameter and return the `?` placeholder.
82    fn push_param(&mut self, value: Value) -> String {
83        self.params.push(value);
84        "?".to_string()
85    }
86}
87
88/// Render a SELECT query body into an existing context.
89///
90/// Used for both top-level queries and inline subqueries inside EXISTS / NOT EXISTS.
91fn render_select_body(ctx: &mut RenderContext, select: &crate::Select) {
92    // No DISTINCT ON support in MySQL; use large-LIMIT hack when only OFFSET is set.
93    render_select_body_core!(ctx, select, quote_identifier, render_expr, false, true);
94}
95
96/// Render an expression into SQL.
97fn render_expr(ctx: &mut RenderContext, expr: &Expr) {
98    render_expr_common!(ctx, expr, quote_identifier, render_expr, render_select_body, {
99        Expr::Param(value) => {
100            // Render NULL directly in SQL instead of as a bound parameter.
101            if matches!(value, Value::Null) {
102                ctx.sql.push_str("NULL");
103            } else {
104                let placeholder = ctx.push_param(value.clone());
105                ctx.sql.push_str(&placeholder);
106            }
107        }
108        Expr::Binary { left, op, right } => {
109            if matches!(op, BinaryOp::In | BinaryOp::NotIn) {
110                ctx.sql.push('(');
111                render_expr(ctx, left);
112                ctx.sql.push(' ');
113                ctx.sql.push_str(if matches!(op, BinaryOp::In) { "IN" } else { "NOT IN" });
114                ctx.sql.push_str(" (");
115                if let Expr::List(exprs) = right.as_ref() {
116                    for (i, e) in exprs.iter().enumerate() {
117                        if i > 0 { ctx.sql.push_str(", "); }
118                        render_expr(ctx, e);
119                    }
120                } else {
121                    render_expr(ctx, right);
122                }
123                ctx.sql.push(')');
124                ctx.sql.push(')');
125            } else if matches!(op, BinaryOp::ArrayContains | BinaryOp::ArrayContainedBy | BinaryOp::ArrayOverlaps) {
126                // Array operators emulated via MySQL JSON functions.
127                // Arrays are bound as JSON strings by the connector layer.
128                match op {
129                    BinaryOp::ArrayContains => {
130                        // col @> rhs: col contains every element of rhs.
131                        // JSON_CONTAINS(target, candidate) returns 1 when candidate ⊆ target.
132                        ctx.sql.push_str("JSON_CONTAINS(");
133                        render_expr(ctx, left);
134                        ctx.sql.push_str(", ");
135                        render_expr(ctx, right);
136                        ctx.sql.push(')');
137                    }
138                    BinaryOp::ArrayContainedBy => {
139                        // col <@ rhs: rhs contains every element of col.
140                        ctx.sql.push_str("JSON_CONTAINS(");
141                        render_expr(ctx, right);
142                        ctx.sql.push_str(", ");
143                        render_expr(ctx, left);
144                        ctx.sql.push(')');
145                    }
146                    BinaryOp::ArrayOverlaps => {
147                        // col && rhs: at least one element in common (requires MySQL 8.0.17+).
148                        ctx.sql.push_str("JSON_OVERLAPS(");
149                        render_expr(ctx, left);
150                        ctx.sql.push_str(", ");
151                        render_expr(ctx, right);
152                        ctx.sql.push(')');
153                    }
154                    _ => unreachable!(),
155                }
156            } else {
157                ctx.sql.push('(');
158                render_expr(ctx, left);
159                ctx.sql.push(' ');
160                ctx.sql.push_str(crate::binary_op_sql(op));
161                ctx.sql.push(' ');
162                render_expr(ctx, right);
163                ctx.sql.push(')');
164            }
165        }
166        Expr::FunctionCall { name, args } => {
167            // Map PostgreSQL function names to MySQL equivalents.
168            let mysql_name = match name.as_str() {
169                "json_agg" => "JSON_ARRAYAGG",
170                "json_build_object" => "JSON_OBJECT",
171                _ => name,
172            };
173            ctx.sql.push_str(mysql_name);
174            ctx.sql.push('(');
175            for (i, arg) in args.iter().enumerate() {
176                if i > 0 { ctx.sql.push_str(", "); }
177                render_expr(ctx, arg);
178            }
179            ctx.sql.push(')');
180        }
181        Expr::Filter { expr, predicate } => {
182            // MySQL has no native FILTER clause; emulate with CASE WHEN inside the aggregate.
183            if let Expr::FunctionCall { name, args } = expr.as_ref() {
184                let mysql_name = match name.as_str() {
185                    "json_agg" => "JSON_ARRAYAGG",
186                    "json_build_object" => "JSON_OBJECT",
187                    _ => name,
188                };
189                ctx.sql.push_str(mysql_name);
190                ctx.sql.push_str("(CASE WHEN ");
191                render_expr(ctx, predicate);
192                ctx.sql.push_str(" THEN ");
193                if !args.is_empty() {
194                    render_expr(ctx, &args[0]);
195                } else {
196                    ctx.sql.push_str("NULL");
197                }
198                ctx.sql.push_str(" ELSE NULL END)");
199            } else {
200                // Fallback: render the expression without a filter.
201                render_expr(ctx, expr);
202            }
203        }
204    });
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn test_quote_identifier() {
213        assert_eq!(quote_identifier("users"), "`users`");
214        assert_eq!(quote_identifier("email"), "`email`");
215        assert_eq!(quote_identifier("foo`bar"), "`foo``bar`");
216        assert_eq!(quote_identifier("a`b`c"), "`a``b``c`");
217    }
218
219    // ——— MySQL-specific: skip without take emits synthetic LIMIT ————————————————
220
221    #[test]
222    fn test_skip_without_take() {
223        let dialect = MysqlDialect;
224        let select = Select::from_table("users").skip(20).build().unwrap();
225        let sql = dialect.render_select(&select).unwrap();
226
227        // MySQL requires LIMIT when OFFSET is used.
228        assert_eq!(
229            sql.text,
230            "SELECT * FROM `users` LIMIT 18446744073709551615 OFFSET 20"
231        );
232        assert!(sql.params.is_empty());
233    }
234
235    // ——— MySQL-specific: RETURNING clause is silently suppressed ———————————————
236
237    #[test]
238    fn test_insert_returning_is_omitted() {
239        let dialect = MysqlDialect;
240        let insert = Insert::into_table("users")
241            .column(nautilus_core::ColumnMarker::new("users", "email"))
242            .values(vec![Value::String("alice@example.com".to_string())])
243            .returning(vec![
244                nautilus_core::ColumnMarker::new("users", "id"),
245                nautilus_core::ColumnMarker::new("users", "email"),
246            ])
247            .build()
248            .unwrap();
249        let sql = dialect.render_insert(&insert).unwrap();
250
251        assert_eq!(sql.text, "INSERT INTO `users` (`email`) VALUES (?)");
252        assert!(!sql.text.contains("RETURNING"));
253    }
254
255    #[test]
256    fn test_update_returning_is_omitted() {
257        let dialect = MysqlDialect;
258        let update = Update::table("users")
259            .set(
260                nautilus_core::ColumnMarker::new("users", "email"),
261                Value::String("new@example.com".to_string()),
262            )
263            .filter(Expr::column("id").eq(Expr::param(Value::I64(1))))
264            .returning(vec![
265                nautilus_core::ColumnMarker::new("users", "id"),
266                nautilus_core::ColumnMarker::new("users", "email"),
267            ])
268            .build()
269            .unwrap();
270        let sql = dialect.render_update(&update).unwrap();
271
272        assert_eq!(sql.text, "UPDATE `users` SET `email` = ? WHERE (`id` = ?)");
273        assert!(!sql.text.contains("RETURNING"));
274    }
275
276    #[test]
277    fn test_delete_returning_is_omitted() {
278        let dialect = MysqlDialect;
279        let delete = Delete::from_table("users")
280            .filter(Expr::column("id").eq(Expr::param(Value::I64(1))))
281            .returning(vec![
282                nautilus_core::ColumnMarker::new("users", "id"),
283                nautilus_core::ColumnMarker::new("users", "email"),
284            ])
285            .build()
286            .unwrap();
287        let sql = dialect.render_delete(&delete).unwrap();
288
289        assert_eq!(sql.text, "DELETE FROM `users` WHERE (`id` = ?)");
290        assert!(!sql.text.contains("RETURNING"));
291    }
292}