qail-core 0.27.8

AST-native query builder - type-safe expressions, zero SQL strings
Documentation
//! Type-safe schema code generation.
//!
//! Generates Rust code from schema.qail for compile-time type safety.
//!
//! # Usage from build.rs
//! ```ignore
//! qail_core::codegen::generate_to_file("schema.qail", "src/generated/schema.rs")?;
//! ```
//!
//! # Generated code example
//! ```ignore
//! pub mod users {
//!     use qail_core::typed::{Table, TypedColumn};
//!     
//!     pub struct Users;
//!     impl Table for Users { fn table_name() -> &'static str { "users" } }
//!     
//!     pub fn id() -> TypedColumn<uuid::Uuid> { TypedColumn::new("users", "id") }
//!     pub fn age() -> TypedColumn<i64> { TypedColumn::new("users", "age") }
//! }
//! ```

use crate::build::Schema;
use crate::migrate::types::ColumnType;
use std::fs;

/// Generate typed Rust code from a schema.qail file and write to output
pub fn generate_to_file(schema_path: &str, output_path: &str) -> Result<(), String> {
    let schema = Schema::parse_file(schema_path)?;
    let code = generate_schema_code(&schema);
    fs::write(output_path, &code).map_err(|e| format!("Failed to write output: {}", e))?;
    Ok(())
}

/// Generate typed Rust code from a schema.qail file
pub fn generate_from_file(schema_path: &str) -> Result<String, String> {
    let schema = Schema::parse_file(schema_path)?;
    Ok(generate_schema_code(&schema))
}

/// Generate Rust code for the schema
pub fn generate_schema_code(schema: &Schema) -> String {
    let mut code = String::new();

    // Header
    code.push_str("//! Auto-generated by `qail types`\n");
    code.push_str("//! Do not edit manually.\n\n");
    code.push_str("#![allow(dead_code)]\n\n");
    code.push_str("use qail_core::typed::{Table, TypedColumn, RequiresRls, DirectBuild, Bucket, Queue, Topic};\n\n");

    // Generate table modules
    let mut table_names: Vec<_> = schema.tables.keys().collect();
    table_names.sort();

    for table_name in &table_names {
        if let Some(table) = schema.tables.get(*table_name) {
            code.push_str(&generate_table_module(table_name, table));
            code.push('\n');
        }
    }

    // Generate tables re-export
    code.push_str("/// Re-export all table types\n");
    code.push_str("pub mod tables {\n");

    for table_name in &table_names {
        let struct_name = to_pascal_case(table_name);
        code.push_str(&format!(
            "    pub use super::{}::{};\n",
            table_name, struct_name
        ));
    }
    code.push_str("}\n\n");

    // Generate resource modules
    let mut resource_names: Vec<_> = schema.resources.keys().collect();
    resource_names.sort();

    for res_name in &resource_names {
        if let Some(resource) = schema.resources.get(*res_name) {
            code.push_str(&generate_resource_module(res_name, resource));
            code.push('\n');
        }
    }

    // Generate resources re-export
    if !resource_names.is_empty() {
        code.push_str("/// Re-export all resource types\n");
        code.push_str("pub mod resources {\n");
        for res_name in &resource_names {
            let struct_name = to_pascal_case(res_name);
            code.push_str(&format!(
                "    pub use super::{}::{};\n",
                res_name, struct_name
            ));
        }
        code.push_str("}\n");
    }

    code
}

/// Generate a module for an infrastructure resource
fn generate_resource_module(
    resource_name: &str,
    resource: &crate::build::ResourceSchema,
) -> String {
    let mut code = String::new();
    let struct_name = to_pascal_case(resource_name);
    let kind = &resource.kind;

    code.push_str(&format!("/// {} resource: {}\n", kind, resource_name));
    code.push_str(&format!("pub mod {} {{\n", resource_name));
    code.push_str("    use super::*;\n\n");

    // Struct
    code.push_str(&format!(
        "    /// Type-safe reference to {} `{}`\n",
        kind, resource_name
    ));
    code.push_str("    #[derive(Debug, Clone, Copy, Default)]\n");
    code.push_str(&format!("    pub struct {};\n\n", struct_name));

    // Implement the appropriate trait
    let (trait_name, method_name) = match kind.as_str() {
        "bucket" => ("Bucket", "bucket_name"),
        "queue" => ("Queue", "queue_name"),
        "topic" => ("Topic", "topic_name"),
        _ => ("Bucket", "bucket_name"), // fallback
    };

    code.push_str(&format!("    impl {} for {} {{\n", trait_name, struct_name));
    code.push_str(&format!(
        "        fn {}() -> &'static str {{ \"{}\" }}\n",
        method_name, resource_name
    ));
    code.push_str("    }\n");

    // Add provider constant if specified
    if let Some(ref provider) = resource.provider {
        code.push_str(&format!(
            "\n    pub const PROVIDER: &str = \"{}\";\n",
            provider
        ));
    }

    // Add property constants
    for (key, value) in &resource.properties {
        let const_name = key.to_uppercase();
        code.push_str(&format!(
            "    pub const {}: &str = \"{}\";\n",
            const_name, value
        ));
    }

    code.push_str("}\n");
    code
}

fn generate_table_module(table_name: &str, table: &crate::build::TableSchema) -> String {
    let mut code = String::new();
    let struct_name = to_pascal_case(table_name);

    code.push_str(&format!("/// Table: {}\n", table_name));
    code.push_str(&format!("pub mod {} {{\n", table_name));
    code.push_str("    use super::*;\n\n");

    // Table struct with Table trait
    code.push_str(&format!(
        "    /// Type-safe reference to `{}`\n",
        table_name
    ));
    code.push_str("    #[derive(Debug, Clone, Copy, Default)]\n");
    code.push_str(&format!("    pub struct {};\n\n", struct_name));

    code.push_str(&format!("    impl Table for {} {{\n", struct_name));
    code.push_str(&format!(
        "        fn table_name() -> &'static str {{ \"{}\" }}\n",
        table_name
    ));
    code.push_str("    }\n\n");

    // Implement From<Table> for String to work with Qail::get()
    code.push_str(&format!("    impl From<{}> for String {{\n", struct_name));
    code.push_str(&format!(
        "        fn from(_: {}) -> String {{ \"{}\".to_string() }}\n",
        struct_name, table_name
    ));
    code.push_str("    }\n\n");

    // AsRef<str> for TypedQail compatibility
    code.push_str(&format!("    impl AsRef<str> for {} {{\n", struct_name));
    code.push_str(&format!(
        "        fn as_ref(&self) -> &str {{ \"{}\" }}\n",
        table_name
    ));
    code.push_str("    }\n\n");

    // RLS trait: RequiresRls for tables with tenant_id, DirectBuild for others
    if table.rls_enabled {
        code.push_str("    /// This table has `tenant_id` — queries require `.with_rls()` proof\n");
        code.push_str(&format!(
            "    impl RequiresRls for {} {{}}\n\n",
            struct_name
        ));
    } else {
        code.push_str(&format!(
            "    impl DirectBuild for {} {{}}\n\n",
            struct_name
        ));
    }

    // Typed column functions
    let mut col_names: Vec<_> = table.columns.keys().collect();
    col_names.sort();

    for col_name in &col_names {
        if let Some(col_type) = table.columns.get(*col_name) {
            let rust_type = column_type_to_rust(col_type);
            let fn_name = escape_keyword(col_name);
            code.push_str(&format!(
                "    /// Column `{}` ({})\n",
                col_name,
                col_type.to_pg_type()
            ));
            code.push_str(&format!(
                "    pub fn {}() -> TypedColumn<{}> {{ TypedColumn::new(\"{}\", \"{}\") }}\n\n",
                fn_name, rust_type, table_name, col_name
            ));
        }
    }

    code.push_str("}\n");

    code
}

/// Map ColumnType AST enum to Rust types (for codegen).
/// This is the ONLY place where we map SQL types to Rust types.
fn column_type_to_rust(col_type: &ColumnType) -> &'static str {
    match col_type {
        ColumnType::Uuid => "uuid::Uuid",
        ColumnType::Text | ColumnType::Varchar(_) => "String",
        ColumnType::Int | ColumnType::BigInt | ColumnType::Serial | ColumnType::BigSerial => "i64",
        ColumnType::Bool => "bool",
        ColumnType::Float | ColumnType::Decimal(_) => "f64",
        ColumnType::Jsonb => "serde_json::Value",
        ColumnType::Timestamp | ColumnType::Timestamptz | ColumnType::Date | ColumnType::Time => {
            "chrono::DateTime<chrono::Utc>"
        }
        ColumnType::Bytea => "Vec<u8>",
        ColumnType::Array(_) => "Vec<serde_json::Value>",
        ColumnType::Enum { .. } => "String",
        ColumnType::Range(_) => "String",
        ColumnType::Interval => "String",
        ColumnType::Cidr | ColumnType::Inet => "String",
        ColumnType::MacAddr => "String",
    }
}

/// Convert snake_case to PascalCase
fn to_pascal_case(s: &str) -> String {
    s.split('_')
        .map(|word| {
            let mut chars = word.chars();
            match chars.next() {
                None => String::new(),
                Some(c) => c.to_uppercase().chain(chars).collect(),
            }
        })
        .collect()
}

/// Escape Rust reserved keywords with r# prefix
fn escape_keyword(name: &str) -> String {
    const KEYWORDS: &[&str] = &[
        "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn",
        "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
        "return", "self", "Self", "static", "struct", "super", "trait", "true", "type", "unsafe",
        "use", "where", "while", "async", "await", "dyn", "abstract", "become", "box", "do",
        "final", "macro", "override", "priv", "try", "typeof", "unsized", "virtual", "yield",
    ];

    if KEYWORDS.contains(&name) {
        format!("r#{}", name)
    } else {
        name.to_string()
    }
}

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

    #[test]
    fn test_pascal_case() {
        assert_eq!(to_pascal_case("users"), "Users");
        assert_eq!(to_pascal_case("user_profiles"), "UserProfiles");
    }

    #[test]
    fn test_column_type_mapping() {
        assert_eq!(column_type_to_rust(&ColumnType::Int), "i64");
        assert_eq!(column_type_to_rust(&ColumnType::Text), "String");
        assert_eq!(column_type_to_rust(&ColumnType::Uuid), "uuid::Uuid");
        assert_eq!(column_type_to_rust(&ColumnType::Bool), "bool");
        assert_eq!(column_type_to_rust(&ColumnType::Jsonb), "serde_json::Value");
        assert_eq!(column_type_to_rust(&ColumnType::BigInt), "i64");
        assert_eq!(column_type_to_rust(&ColumnType::Float), "f64");
        assert_eq!(
            column_type_to_rust(&ColumnType::Timestamp),
            "chrono::DateTime<chrono::Utc>"
        );
        assert_eq!(column_type_to_rust(&ColumnType::Bytea), "Vec<u8>");
    }
}