nautilus-orm-dialect 0.1.3

SQL dialect renderers for Nautilus ORM
Documentation
//! MySQL SQL dialect renderer.

use crate::{Dialect, Sql};
use nautilus_core::{BinaryOp, Delete, Expr, Insert, Result, Select, Update, Value};

/// MySQL SQL dialect renderer.
#[derive(Debug, Clone, Copy)]
pub struct MysqlDialect;

/// Renders query ASTs into MySQL-compatible SQL with `?` placeholders
/// and backtick-quoted identifiers.
impl Dialect for MysqlDialect {
    fn supports_returning(&self) -> bool {
        false
    }

    fn render_select(&self, select: &Select) -> Result<Sql> {
        let mut ctx = RenderContext::new();
        // No DISTINCT ON (MySQL); use the MySQL large-LIMIT hack for offset-only queries.
        render_select_body_core!(&mut ctx, select, quote_identifier, render_expr, false, true);
        Ok(Sql {
            text: ctx.sql,
            params: ctx.params,
        })
    }

    fn render_insert(&self, insert: &Insert) -> Result<Sql> {
        let mut ctx = RenderContext::new();
        render_insert_body!(&mut ctx, insert, quote_identifier, false, false);
        Ok(Sql {
            text: ctx.sql,
            params: ctx.params,
        })
    }

    fn render_update(&self, update: &Update) -> Result<Sql> {
        let mut ctx = RenderContext::new();
        render_update_body!(
            &mut ctx,
            update,
            quote_identifier,
            render_expr,
            false,
            false
        );
        Ok(Sql {
            text: ctx.sql,
            params: ctx.params,
        })
    }

    fn render_delete(&self, delete: &Delete) -> Result<Sql> {
        let mut ctx = RenderContext::new();
        render_delete_body!(&mut ctx, delete, quote_identifier, render_expr, false);
        Ok(Sql {
            text: ctx.sql,
            params: ctx.params,
        })
    }
}

/// Quote a SQL identifier with backticks (MySQL style).
fn quote_identifier(name: &str) -> String {
    crate::backtick_quote_identifier(name)
}

/// Context for rendering SQL with parameter tracking.
struct RenderContext {
    sql: String,
    params: Vec<Value>,
}

impl RenderContext {
    fn new() -> Self {
        Self {
            sql: String::new(),
            params: Vec::new(),
        }
    }

    /// Push a parameter and return the `?` placeholder.
    fn push_param(&mut self, value: Value) -> String {
        self.params.push(value);
        "?".to_string()
    }
}

/// Render a SELECT query body into an existing context.
///
/// Used for both top-level queries and inline subqueries inside EXISTS / NOT EXISTS.
fn render_select_body(ctx: &mut RenderContext, select: &crate::Select) {
    // No DISTINCT ON support in MySQL; use large-LIMIT hack when only OFFSET is set.
    render_select_body_core!(ctx, select, quote_identifier, render_expr, false, true);
}

/// Render an expression into SQL.
fn render_expr(ctx: &mut RenderContext, expr: &Expr) {
    render_expr_common!(ctx, expr, quote_identifier, render_expr, render_select_body, {
        Expr::Param(value) => {
            // Render NULL directly in SQL instead of as a bound parameter.
            if matches!(value, Value::Null) {
                ctx.sql.push_str("NULL");
            } else {
                let placeholder = ctx.push_param(value.clone());
                ctx.sql.push_str(&placeholder);
            }
        }
        Expr::Binary { left, op, right } => {
            if matches!(op, BinaryOp::In | BinaryOp::NotIn) {
                ctx.sql.push('(');
                render_expr(ctx, left);
                ctx.sql.push(' ');
                ctx.sql.push_str(if matches!(op, BinaryOp::In) { "IN" } else { "NOT IN" });
                ctx.sql.push_str(" (");
                if let Expr::List(exprs) = right.as_ref() {
                    for (i, e) in exprs.iter().enumerate() {
                        if i > 0 { ctx.sql.push_str(", "); }
                        render_expr(ctx, e);
                    }
                } else {
                    render_expr(ctx, right);
                }
                ctx.sql.push(')');
                ctx.sql.push(')');
            } else if matches!(op, BinaryOp::ArrayContains | BinaryOp::ArrayContainedBy | BinaryOp::ArrayOverlaps) {
                // Array operators emulated via MySQL JSON functions.
                // Arrays are bound as JSON strings by the connector layer.
                match op {
                    BinaryOp::ArrayContains => {
                        // col @> rhs: col contains every element of rhs.
                        // JSON_CONTAINS(target, candidate) returns 1 when candidate ⊆ target.
                        ctx.sql.push_str("JSON_CONTAINS(");
                        render_expr(ctx, left);
                        ctx.sql.push_str(", ");
                        render_expr(ctx, right);
                        ctx.sql.push(')');
                    }
                    BinaryOp::ArrayContainedBy => {
                        // col <@ rhs: rhs contains every element of col.
                        ctx.sql.push_str("JSON_CONTAINS(");
                        render_expr(ctx, right);
                        ctx.sql.push_str(", ");
                        render_expr(ctx, left);
                        ctx.sql.push(')');
                    }
                    BinaryOp::ArrayOverlaps => {
                        // col && rhs: at least one element in common (requires MySQL 8.0.17+).
                        ctx.sql.push_str("JSON_OVERLAPS(");
                        render_expr(ctx, left);
                        ctx.sql.push_str(", ");
                        render_expr(ctx, right);
                        ctx.sql.push(')');
                    }
                    _ => unreachable!(),
                }
            } else {
                ctx.sql.push('(');
                render_expr(ctx, left);
                ctx.sql.push(' ');
                ctx.sql.push_str(crate::binary_op_sql(op));
                ctx.sql.push(' ');
                render_expr(ctx, right);
                ctx.sql.push(')');
            }
        }
        Expr::FunctionCall { name, args } => {
            // Map PostgreSQL function names to MySQL equivalents.
            let mysql_name = match name.as_str() {
                "json_agg" => "JSON_ARRAYAGG",
                "json_build_object" => "JSON_OBJECT",
                _ => name,
            };
            ctx.sql.push_str(mysql_name);
            ctx.sql.push('(');
            for (i, arg) in args.iter().enumerate() {
                if i > 0 { ctx.sql.push_str(", "); }
                render_expr(ctx, arg);
            }
            ctx.sql.push(')');
        }
        Expr::Filter { expr, predicate } => {
            // MySQL has no native FILTER clause; emulate with CASE WHEN inside the aggregate.
            if let Expr::FunctionCall { name, args } = expr.as_ref() {
                let mysql_name = match name.as_str() {
                    "json_agg" => "JSON_ARRAYAGG",
                    "json_build_object" => "JSON_OBJECT",
                    _ => name,
                };
                ctx.sql.push_str(mysql_name);
                ctx.sql.push_str("(CASE WHEN ");
                render_expr(ctx, predicate);
                ctx.sql.push_str(" THEN ");
                if !args.is_empty() {
                    render_expr(ctx, &args[0]);
                } else {
                    ctx.sql.push_str("NULL");
                }
                ctx.sql.push_str(" ELSE NULL END)");
            } else {
                // Fallback: render the expression without a filter.
                render_expr(ctx, expr);
            }
        }
    });
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_quote_identifier() {
        assert_eq!(quote_identifier("users"), "`users`");
        assert_eq!(quote_identifier("email"), "`email`");
        assert_eq!(quote_identifier("foo`bar"), "`foo``bar`");
        assert_eq!(quote_identifier("a`b`c"), "`a``b``c`");
    }

    // ——— MySQL-specific: skip without take emits synthetic LIMIT ————————————————

    #[test]
    fn test_skip_without_take() {
        let dialect = MysqlDialect;
        let select = Select::from_table("users").skip(20).build().unwrap();
        let sql = dialect.render_select(&select).unwrap();

        // MySQL requires LIMIT when OFFSET is used.
        assert_eq!(
            sql.text,
            "SELECT * FROM `users` LIMIT 18446744073709551615 OFFSET 20"
        );
        assert!(sql.params.is_empty());
    }

    // ——— MySQL-specific: RETURNING clause is silently suppressed ———————————————

    #[test]
    fn test_insert_returning_is_omitted() {
        let dialect = MysqlDialect;
        let insert = Insert::into_table("users")
            .column(nautilus_core::ColumnMarker::new("users", "email"))
            .values(vec![Value::String("alice@example.com".to_string())])
            .returning(vec![
                nautilus_core::ColumnMarker::new("users", "id"),
                nautilus_core::ColumnMarker::new("users", "email"),
            ])
            .build()
            .unwrap();
        let sql = dialect.render_insert(&insert).unwrap();

        assert_eq!(sql.text, "INSERT INTO `users` (`email`) VALUES (?)");
        assert!(!sql.text.contains("RETURNING"));
    }

    #[test]
    fn test_update_returning_is_omitted() {
        let dialect = MysqlDialect;
        let update = Update::table("users")
            .set(
                nautilus_core::ColumnMarker::new("users", "email"),
                Value::String("new@example.com".to_string()),
            )
            .filter(Expr::column("id").eq(Expr::param(Value::I64(1))))
            .returning(vec![
                nautilus_core::ColumnMarker::new("users", "id"),
                nautilus_core::ColumnMarker::new("users", "email"),
            ])
            .build()
            .unwrap();
        let sql = dialect.render_update(&update).unwrap();

        assert_eq!(sql.text, "UPDATE `users` SET `email` = ? WHERE (`id` = ?)");
        assert!(!sql.text.contains("RETURNING"));
    }

    #[test]
    fn test_delete_returning_is_omitted() {
        let dialect = MysqlDialect;
        let delete = Delete::from_table("users")
            .filter(Expr::column("id").eq(Expr::param(Value::I64(1))))
            .returning(vec![
                nautilus_core::ColumnMarker::new("users", "id"),
                nautilus_core::ColumnMarker::new("users", "email"),
            ])
            .build()
            .unwrap();
        let sql = dialect.render_delete(&delete).unwrap();

        assert_eq!(sql.text, "DELETE FROM `users` WHERE (`id` = ?)");
        assert!(!sql.text.contains("RETURNING"));
    }
}