toasty-sql 0.8.0

SQL serialization layer for Toasty database drivers
Documentation
use toasty_core::stmt::ResolvedRef;

use super::{ColumnAlias, Comma, Delimited, Ident, ToSql};

use crate::{serializer::Flavor, stmt};

impl ToSql for &stmt::Expr {
    fn to_sql(self, f: &mut super::Formatter<'_>) {
        match self {
            stmt::Expr::And(expr) => {
                fmt!(f, Delimited(expr.operands.iter().map(AndOperand), " AND "));
            }
            stmt::Expr::Between(expr) => {
                fmt!(f, expr.expr " BETWEEN " expr.low " AND " expr.high);
            }
            stmt::Expr::BinaryOp(expr) => {
                assert!(!expr.lhs.is_value_null());
                assert!(!expr.rhs.is_value_null());

                fmt!(f, expr.lhs " " expr.op " " expr.rhs);
            }
            stmt::Expr::Exists(expr) => {
                f.depth += 1;
                fmt!(f, "EXISTS (" expr.subquery ")");
                f.depth -= 1;
            }
            stmt::Expr::Func(stmt::ExprFunc::Count(func)) => match (&func.arg, &func.filter) {
                (None, None) => fmt!(f, "COUNT(*)"),
                // Mysql does not support filters, so translate it to an expression
                (None, Some(expr)) if f.serializer.is_mysql() => {
                    fmt!(f, "COUNT(CASE WHEN " expr " THEN 1 END)")
                }
                (None, Some(expr)) => fmt!(f, "COUNT(*) FILTER (WHERE " expr ")"),
                _ => todo!("func={func:#?}"),
            },
            stmt::Expr::Func(stmt::ExprFunc::LastInsertId(_)) => {
                fmt!(f, "LAST_INSERT_ID()")
            }
            stmt::Expr::IsSuperset(e) => match f.serializer.flavor {
                Flavor::Postgresql => fmt!(f, e.lhs.as_ref() " @> " e.rhs.as_ref()),
                // The rhs Value::List is bound as one JSON string. MySQL's
                // `JSON_CONTAINS(target, candidate)` matches when every
                // element of `candidate` appears in `target`.
                Flavor::Mysql => {
                    fmt!(f, "JSON_CONTAINS(" e.lhs.as_ref() ", " e.rhs.as_ref() ")")
                }
                // SQLite has no direct superset operator; emulate via
                // `NOT EXISTS (rhs element with no match in lhs)`.
                Flavor::Sqlite => fmt!(
                    f,
                    "NOT EXISTS (SELECT 1 FROM json_each(" e.rhs.as_ref()
                    ") AS r WHERE r.value NOT IN (SELECT l.value FROM json_each("
                    e.lhs.as_ref() ") AS l))"
                ),
            },
            stmt::Expr::Intersects(e) => match f.serializer.flavor {
                Flavor::Postgresql => fmt!(f, e.lhs.as_ref() " && " e.rhs.as_ref()),
                Flavor::Mysql => {
                    fmt!(f, "JSON_OVERLAPS(" e.lhs.as_ref() ", " e.rhs.as_ref() ")")
                }
                Flavor::Sqlite => fmt!(
                    f,
                    "EXISTS (SELECT 1 FROM json_each(" e.rhs.as_ref()
                    ") AS r WHERE r.value IN (SELECT l.value FROM json_each("
                    e.lhs.as_ref() ") AS l))"
                ),
            },
            stmt::Expr::Length(e) => match f.serializer.flavor {
                Flavor::Postgresql => fmt!(f, "cardinality(" e.expr.as_ref() ")"),
                Flavor::Mysql => fmt!(f, "JSON_LENGTH(" e.expr.as_ref() ")"),
                Flavor::Sqlite => fmt!(f, "json_array_length(" e.expr.as_ref() ")"),
            },
            stmt::Expr::Ident(name) => {
                fmt!(f, Ident(name));
            }
            stmt::Expr::InList(expr) => {
                fmt!(f, expr.expr " IN " expr.list);
            }
            stmt::Expr::AnyOp(expr) => match f.serializer.flavor {
                // `value = ANY(col)` — PostgreSQL's array membership operator.
                // Drives `Path::contains` for native-array columns and the
                // IN-list rewrite.
                Flavor::Postgresql => {
                    fmt!(f, expr.lhs " " expr.op " ANY(" expr.rhs ")");
                }
                // MySQL's `value MEMBER OF (json_array)` (8.0.17+). Only the
                // equality form makes sense; `Path::contains` is the only
                // current emitter and the lowering pass never produces
                // ANY on MySQL since `predicate_match_any` is false.
                Flavor::Mysql if matches!(expr.op, stmt::BinaryOp::Eq) => {
                    fmt!(f, expr.lhs " MEMBER OF (" expr.rhs ")");
                }
                Flavor::Mysql => unreachable!("AnyOp with non-Eq operator on MySQL: {expr:?}"),
                // SQLite renders `value = ANY(col)` (i.e. `Path::contains`)
                // as `value IN (SELECT value FROM json_each(col))`.
                Flavor::Sqlite if matches!(expr.op, stmt::BinaryOp::Eq) => {
                    fmt!(
                        f,
                        expr.lhs " IN (SELECT value FROM json_each(" expr.rhs "))"
                    );
                }
                Flavor::Sqlite => unreachable!("AnyOp with non-Eq operator on SQLite: {expr:?}"),
            },
            stmt::Expr::AllOp(expr) => {
                fmt!(f, expr.lhs " " expr.op " ALL(" expr.rhs ")");
            }
            stmt::Expr::InSubquery(expr) => {
                fmt!(f, expr.expr " IN (" expr.query ")");
            }
            stmt::Expr::IsNull(expr) => {
                fmt!(f, expr.expr " IS NULL");
            }
            stmt::Expr::Like(expr) => {
                let op =
                    if expr.case_insensitive && matches!(f.serializer.flavor, Flavor::Postgresql) {
                        " ILIKE "
                    } else {
                        " LIKE "
                    };
                fmt!(f, expr.expr op expr.pattern);
                if let Some(escape) = expr.escape {
                    let escape = if f.serializer.is_mysql() && escape == '\\' {
                        stmt::Value::String("\\\\".to_string())
                    } else {
                        stmt::Value::String(escape.to_string())
                    };
                    let escape = &escape;
                    fmt!(f, " ESCAPE " escape);
                }
            }
            stmt::Expr::StartsWith(expr) => {
                match f.serializer.flavor {
                    // PostgreSQL's `^@` prefix-match operator; prefix is bound
                    // as a plain string parameter.
                    Flavor::Postgresql => {
                        fmt!(f, expr.expr " ^@ " expr.prefix);
                    }
                    // SQLite GLOB is case-sensitive.  extract_params has already
                    // escaped GLOB metacharacters and appended `*` to the prefix
                    // parameter, so we only need to emit the right operator.
                    Flavor::Sqlite => {
                        fmt!(f, expr.expr " GLOB " expr.prefix);
                    }
                    // MySQL LIKE is case-insensitive by default; casting the
                    // column side to BINARY forces a case-sensitive byte
                    // comparison.  extract_params has escaped `%`/`_`/`!` and
                    // appended `%` to the prefix parameter.
                    Flavor::Mysql => {
                        fmt!(f, "BINARY " expr.expr " LIKE " expr.prefix " ESCAPE '!'");
                    }
                }
            }
            stmt::Expr::Not(expr) => {
                fmt!(f, "NOT (" expr.expr ")");
            }
            stmt::Expr::Or(expr) => {
                fmt!(f, Delimited(&expr.operands, " OR "));
            }
            stmt::Expr::Record(expr) => {
                let fields = Comma(expr.fields.iter());
                fmt!(f, "(" fields ")");
            }
            stmt::Expr::Reference(expr_reference @ stmt::ExprReference::Column(expr_column)) => {
                if f.alias {
                    let depth = f.depth - expr_column.nesting;

                    match f.cx.resolve_expr_reference(expr_reference) {
                        ResolvedRef::Column(column) => {
                            let name = Ident(&column.name);
                            fmt!(f, "tbl_" depth "_" expr_column.table "." name)
                        }
                        ResolvedRef::Cte { .. } | ResolvedRef::Derived(_) => {
                            fmt!(f, "tbl_" depth "_" expr_column.table "." ColumnAlias(expr_column.column))
                        }
                        ResolvedRef::Model(model) => {
                            panic!("Model references cannot be serialized to SQL; model={model:?}")
                        }
                        ResolvedRef::Field(field) => {
                            panic!("Field references cannot be serialized to SQL; field={field:?}")
                        }
                    }
                } else {
                    let column =
                        f.cx.resolve_expr_reference(expr_reference)
                            .as_column_unwrap();
                    fmt!(f, Ident(&column.name))
                }
            }
            stmt::Expr::Stmt(expr) => {
                let stmt = &*expr.stmt;
                fmt!(f, "(" stmt ")");
            }
            stmt::Expr::List(expr) => {
                let items = Comma(expr.items.iter());
                fmt!(f, "(" items ")");
            }
            stmt::Expr::Value(expr) => expr.to_sql(f),
            stmt::Expr::Arg(arg) => {
                // Pre-extracted bind parameter placeholder — render as a
                // positional parameter. The arg position is 0-based; the
                // placeholder is 1-based.
                f.arg_positions.push(arg.position);
                let placeholder = super::Placeholder(arg.position + 1);
                fmt!(f, placeholder);
            }
            stmt::Expr::Default => match f.serializer.flavor {
                Flavor::Postgresql | Flavor::Mysql => fmt!(f, "DEFAULT"),
                // SQLite does not support the DEFAULT keyword but NULL acts similarly.
                Flavor::Sqlite => fmt!(f, "NULL"),
            },
            _ => todo!("expr={:#?}", self),
        }
    }
}

/// A single operand of an `AND` chain.
///
/// `OR` binds looser than `AND` in SQL, so an `Or` operand must be
/// parenthesized: `a AND (b OR c)` would otherwise serialize as
/// `a AND b OR c`, which parses as `(a AND b) OR c` and silently changes the
/// query's meaning. Operands of other kinds bind at least as tightly as `AND`
/// (comparisons, `IS NULL`, `NOT (..)`, nested `AND`), so they need no parens.
struct AndOperand<'a>(&'a stmt::Expr);

impl ToSql for AndOperand<'_> {
    fn to_sql(self, f: &mut super::Formatter<'_>) {
        if matches!(self.0, stmt::Expr::Or(_)) {
            fmt!(f, "(" self.0 ")");
        } else {
            fmt!(f, self.0);
        }
    }
}

impl ToSql for &stmt::BinaryOp {
    fn to_sql(self, f: &mut super::Formatter<'_>) {
        f.dst.push_str(match self {
            stmt::BinaryOp::Eq => "=",
            stmt::BinaryOp::Gt => ">",
            stmt::BinaryOp::Ge => ">=",
            stmt::BinaryOp::Lt => "<",
            stmt::BinaryOp::Le => "<=",
            stmt::BinaryOp::Ne => "<>",
            stmt::BinaryOp::Add => "+",
            stmt::BinaryOp::Sub => "-",
        })
    }
}