rusticx-sql 1.0.0

Shared SQL dialect compiler (DDL + DML) for the Rusticx multi-database ORM
Documentation
use rusticx_core::{
    column::ColumnDef,
    model::TableSchema,
    query::{CondOp, Conjunction, Direction, QueryBuilder},
    value::Value,
};
use crate::dialect::SqlDialect;

/// Compiles a `QueryBuilder` or `TableSchema` into parameterized SQL.
pub struct SqlCompiler<'d, D: SqlDialect> {
    dialect: &'d D,
}

impl<'d, D: SqlDialect> SqlCompiler<'d, D> {
    pub fn new(dialect: &'d D) -> Self {
        Self { dialect }
    }

    // ── DDL ──────────────────────────────────────────────────────────────

    pub fn create_table(&self, schema: &TableSchema) -> String {
        let table = self.dialect.quote_ident(&schema.table);
        let col_defs: Vec<String> = schema.columns.iter().map(|c| self.col_def(c)).collect();

        // Primary keys
        let pks: Vec<String> = schema
            .columns
            .iter()
            .filter(|c| c.primary_key)
            .map(|c| self.dialect.quote_ident(&c.name))
            .collect();

        let mut parts = col_defs;
        if !pks.is_empty() {
            parts.push(format!("PRIMARY KEY ({})", pks.join(", ")));
        }

        // Unique constraints
        for col in schema.columns.iter().filter(|c| c.unique && !c.primary_key) {
            parts.push(format!(
                "UNIQUE ({})",
                self.dialect.quote_ident(&col.name)
            ));
        }

        let mut sql = format!(
            "CREATE TABLE IF NOT EXISTS {table} (\n  {}\n)",
            parts.join(",\n  ")
        );

        // Indexes
        let mut index_stmts = vec![];
        for idx in &schema.indexes {
            let cols: Vec<String> = idx.columns.iter().map(|c| self.dialect.quote_ident(c)).collect();
            let unique = if idx.unique { "UNIQUE " } else { "" };
            index_stmts.push(format!(
                "CREATE {unique}INDEX IF NOT EXISTS {} ON {table} ({})",
                self.dialect.quote_ident(&idx.name),
                cols.join(", ")
            ));
        }

        if !index_stmts.is_empty() {
            sql.push(';');
            sql.push('\n');
            sql.push_str(&index_stmts.join(";\n"));
        }

        sql
    }

    fn col_def(&self, col: &ColumnDef) -> String {
        let name = self.dialect.quote_ident(&col.name);
        let ty = self.dialect.sql_type(&col.col_type);
        let mut parts = vec![format!("{name} {ty}")];

        if !col.nullable {
            parts.push("NOT NULL".into());
        }
        if let Some(ref d) = col.default {
            parts.push(format!("DEFAULT {d}"));
        }

        parts.join(" ")
    }

    pub fn drop_table(&self, table: &str) -> String {
        format!("DROP TABLE IF EXISTS {}", self.dialect.quote_ident(table))
    }

    pub fn table_exists(&self, table: &str) -> (String, Vec<Value>) {
        // ANSI information_schema — works for both PG and MySQL
        let sql = "SELECT COUNT(*) FROM information_schema.tables WHERE table_name = $1".to_owned();
        (sql, vec![Value::Text(table.to_owned())])
    }

    // ── DML ──────────────────────────────────────────────────────────────

    /// Compile SELECT.
    pub fn select(&self, qb: &QueryBuilder) -> (String, Vec<Value>) {
        let table = self.dialect.quote_ident(&qb.table);
        let cols = if qb.columns.is_empty() {
            "*".to_owned()
        } else {
            qb.columns.iter().map(|c| self.dialect.quote_ident(c)).collect::<Vec<_>>().join(", ")
        };

        let mut sql = format!("SELECT {cols} FROM {table}");
        let mut bindings: Vec<Value> = vec![];
        let mut idx = 1usize;

        if !qb.conditions.is_empty() {
            let (where_clause, b, next_idx) = self.where_clause(&qb.conditions, idx);
            sql.push_str(&format!(" WHERE {where_clause}"));
            bindings.extend(b);
            idx = next_idx;
        }

        if !qb.order_by.is_empty() {
            let orders: Vec<String> = qb
                .order_by
                .iter()
                .map(|o| {
                    let dir = match o.direction {
                        Direction::Asc => "ASC",
                        Direction::Desc => "DESC",
                    };
                    format!("{} {dir}", self.dialect.quote_ident(&o.column))
                })
                .collect();
            sql.push_str(&format!(" ORDER BY {}", orders.join(", ")));
        }

        if let Some(limit) = qb.limit {
            sql.push_str(&format!(" LIMIT {limit}"));
        }
        if let Some(offset) = qb.offset {
            sql.push_str(&format!(" OFFSET {offset}"));
        }

        (sql, bindings)
    }

    /// Compile INSERT, returns (sql, bindings).
    pub fn insert(&self, table: &str, row: &[(String, Value)]) -> (String, Vec<Value>) {
        let t = self.dialect.quote_ident(table);
        let cols: Vec<String> = row.iter().map(|(c, _)| self.dialect.quote_ident(c)).collect();
        let mut bindings: Vec<Value> = vec![];
        let placeholders: Vec<String> = row
            .iter()
            .enumerate()
            .map(|(i, (_, v))| {
                bindings.push(v.clone());
                self.dialect.placeholder(i + 1)
            })
            .collect();

        let mut sql = format!(
            "INSERT INTO {t} ({}) VALUES ({})",
            cols.join(", "),
            placeholders.join(", ")
        );

        if self.dialect.supports_returning() {
            sql.push_str(" RETURNING *");
        }

        (sql, bindings)
    }

    /// Compile UPDATE.
    pub fn update(&self, qb: &QueryBuilder) -> (String, Vec<Value>) {
        let table = self.dialect.quote_ident(&qb.table);
        let mut bindings: Vec<Value> = vec![];
        let mut idx = 1usize;

        let sets: Vec<String> = qb
            .values
            .iter()
            .map(|(col, val)| {
                let ph = self.dialect.placeholder(idx);
                idx += 1;
                bindings.push(val.clone());
                format!("{} = {ph}", self.dialect.quote_ident(col))
            })
            .collect();

        let mut sql = format!("UPDATE {table} SET {}", sets.join(", "));

        if !qb.conditions.is_empty() {
            let (where_clause, b, _) = self.where_clause(&qb.conditions, idx);
            sql.push_str(&format!(" WHERE {where_clause}"));
            bindings.extend(b);
        }

        (sql, bindings)
    }

    /// Compile DELETE.
    pub fn delete(&self, qb: &QueryBuilder) -> (String, Vec<Value>) {
        let table = self.dialect.quote_ident(&qb.table);
        let mut bindings: Vec<Value> = vec![];

        let mut sql = format!("DELETE FROM {table}");

        if !qb.conditions.is_empty() {
            let (where_clause, b, _) = self.where_clause(&qb.conditions, 1);
            sql.push_str(&format!(" WHERE {where_clause}"));
            bindings.extend(b);
        }

        (sql, bindings)
    }

    /// Compile COUNT.
    pub fn count(&self, qb: &QueryBuilder) -> (String, Vec<Value>) {
        let table = self.dialect.quote_ident(&qb.table);
        let mut sql = format!("SELECT COUNT(*) AS count FROM {table}");
        let mut bindings: Vec<Value> = vec![];

        if !qb.conditions.is_empty() {
            let (where_clause, b, _) = self.where_clause(&qb.conditions, 1);
            sql.push_str(&format!(" WHERE {where_clause}"));
            bindings.extend(b);
        }

        (sql, bindings)
    }

    // ── WHERE builder ─────────────────────────────────────────────────────

    fn where_clause(
        &self,
        conditions: &[rusticx_core::query::Condition],
        start_idx: usize,
    ) -> (String, Vec<Value>, usize) {
        let mut parts: Vec<String> = vec![];
        let mut bindings: Vec<Value> = vec![];
        let mut idx = start_idx;

        for (i, cond) in conditions.iter().enumerate() {
            let col = self.dialect.quote_ident(&cond.column);
            let (op_str, needs_binding) = match &cond.op {
                CondOp::Eq => ("=", true),
                CondOp::Ne => ("!=", true),
                CondOp::Gt => (">", true),
                CondOp::Gte => (">=", true),
                CondOp::Lt => ("<", true),
                CondOp::Lte => ("<=", true),
                CondOp::Like => ("LIKE", true),
                CondOp::ILike => ("ILIKE", true),
                CondOp::In => ("IN", true),
                CondOp::NotIn => ("NOT IN", true),
                CondOp::IsNull => ("IS NULL", false),
                CondOp::IsNotNull => ("IS NOT NULL", false),
            };

            let part = if needs_binding {
                let ph = self.dialect.placeholder(idx);
                idx += 1;
                bindings.push(cond.value.clone());
                format!("{col} {op_str} {ph}")
            } else {
                format!("{col} {op_str}")
            };

            let conj = if i == 0 {
                String::new()
            } else {
                match cond.conjunction {
                    Conjunction::And => "AND ".to_owned(),
                    Conjunction::Or => "OR ".to_owned(),
                }
            };
            parts.push(format!("{conj}{part}"));
        }

        (parts.join(" "), bindings, idx)
    }
}