Skip to main content

qail_core/build/
codegen.rs

1//! Typed schema code generation.
2//!
3//! Generates Rust modules from `schema.qail` for compile-time type safety.
4
5use std::fs;
6
7use crate::migrate::types::ColumnType;
8
9use super::schema::Schema;
10
11fn qail_type_to_rust(col_type: &ColumnType) -> &'static str {
12    match col_type {
13        ColumnType::Uuid => "uuid::Uuid",
14        ColumnType::Text | ColumnType::Varchar(_) => "String",
15        ColumnType::Int | ColumnType::Serial => "i32",
16        ColumnType::BigInt | ColumnType::BigSerial => "i64",
17        ColumnType::Bool => "bool",
18        ColumnType::Float => "f32",
19        ColumnType::Decimal(_) => "rust_decimal::Decimal",
20        ColumnType::Jsonb => "serde_json::Value",
21        ColumnType::Timestamp | ColumnType::Timestamptz => "chrono::DateTime<chrono::Utc>",
22        ColumnType::Date => "chrono::NaiveDate",
23        ColumnType::Time => "chrono::NaiveTime",
24        ColumnType::Bytea => "Vec<u8>",
25        ColumnType::Array(_) => "Vec<serde_json::Value>",
26        ColumnType::Enum { .. } => "String",
27        ColumnType::Range(_) => "String",
28        ColumnType::Interval => "String",
29        ColumnType::Cidr | ColumnType::Inet => "String",
30        ColumnType::MacAddr => "String",
31    }
32}
33
34/// Convert table/column names to valid Rust identifiers
35fn to_rust_ident(name: &str) -> String {
36    // Handle Rust keywords
37    let name = match name {
38        "type" => "r#type",
39        "match" => "r#match",
40        "ref" => "r#ref",
41        "self" => "r#self",
42        "mod" => "r#mod",
43        "use" => "r#use",
44        _ => name,
45    };
46    name.to_string()
47}
48
49/// Convert table name to PascalCase struct name
50fn to_struct_name(name: &str) -> String {
51    name.chars()
52        .next()
53        .map(|c| c.to_uppercase().collect::<String>() + &name[1..])
54        .unwrap_or_default()
55}
56
57/// Generate typed Rust module from schema.
58///
59/// # Usage in consumer's build.rs:
60/// ```ignore
61/// fn main() {
62///     let out_dir = std::env::var("OUT_DIR").unwrap();
63///     qail_core::build::generate_typed_schema("schema.qail", &format!("{}/schema.rs", out_dir)).unwrap();
64///     println!("cargo:rerun-if-changed=schema.qail");
65/// }
66/// ```
67///
68/// Then in the consumer's lib.rs:
69/// ```ignore
70/// include!(concat!(env!("OUT_DIR"), "/schema.rs"));
71/// ```
72pub fn generate_typed_schema(schema_path: &str, output_path: &str) -> Result<(), String> {
73    let schema = Schema::parse_file(schema_path)?;
74    let code = generate_schema_code(&schema);
75
76    fs::write(output_path, code)
77        .map_err(|e| format!("Failed to write schema module to '{}': {}", output_path, e))?;
78
79    Ok(())
80}
81
82/// Generate typed Rust code from schema (does not write to file)
83pub fn generate_schema_code(schema: &Schema) -> String {
84    let mut code = String::new();
85
86    // Header
87    code.push_str("//! Auto-generated typed schema from schema.qail\n");
88    code.push_str("//! Do not edit manually - regenerate with `cargo build`\n\n");
89    code.push_str("#![allow(dead_code, non_upper_case_globals)]\n\n");
90    code.push_str("use qail_core::typed::{Table, TypedColumn, RelatedTo, Public, Protected};\n\n");
91
92    // Sort tables for deterministic output
93    let mut tables: Vec<_> = schema.tables.values().collect();
94    tables.sort_by(|a, b| a.name.cmp(&b.name));
95
96    for table in &tables {
97        let mod_name = to_rust_ident(&table.name);
98        let struct_name = to_struct_name(&table.name);
99
100        code.push_str(&format!("/// Typed schema for `{}` table\n", table.name));
101        code.push_str(&format!("pub mod {} {{\n", mod_name));
102        code.push_str("    use super::*;\n\n");
103
104        // Table struct implementing Table trait
105        code.push_str(&format!("    /// Table marker for `{}`\n", table.name));
106        code.push_str("    #[derive(Debug, Clone, Copy)]\n");
107        code.push_str(&format!("    pub struct {};\n\n", struct_name));
108
109        code.push_str(&format!("    impl Table for {} {{\n", struct_name));
110        code.push_str(&format!(
111            "        fn table_name() -> &'static str {{ \"{}\" }}\n",
112            table.name
113        ));
114        code.push_str("    }\n\n");
115
116        code.push_str(&format!("    impl From<{}> for String {{\n", struct_name));
117        code.push_str(&format!(
118            "        fn from(_: {}) -> String {{ \"{}\".to_string() }}\n",
119            struct_name, table.name
120        ));
121        code.push_str("    }\n\n");
122
123        code.push_str(&format!("    impl AsRef<str> for {} {{\n", struct_name));
124        code.push_str(&format!(
125            "        fn as_ref(&self) -> &str {{ \"{}\" }}\n",
126            table.name
127        ));
128        code.push_str("    }\n\n");
129
130        // Table constant for convenience
131        code.push_str(&format!("    /// The `{}` table\n", table.name));
132        code.push_str(&format!(
133            "    pub const table: {} = {};\n\n",
134            struct_name, struct_name
135        ));
136
137        // Sort columns for deterministic output
138        let mut columns: Vec<_> = table.columns.iter().collect();
139        columns.sort_by(|a, b| a.0.cmp(b.0));
140
141        // Column constants
142        for (col_name, col_type) in columns {
143            let rust_type = qail_type_to_rust(col_type);
144            let col_ident = to_rust_ident(col_name);
145            let policy = table
146                .policies
147                .get(col_name)
148                .map(|s| s.as_str())
149                .unwrap_or("Public");
150            let rust_policy = if policy == "Protected" {
151                "Protected"
152            } else {
153                "Public"
154            };
155
156            code.push_str(&format!(
157                "    /// Column `{}.{}` ({}) - {}\n",
158                table.name,
159                col_name,
160                col_type.to_pg_type(),
161                policy
162            ));
163            code.push_str(&format!(
164                "    pub const {}: TypedColumn<{}, {}> = TypedColumn::new(\"{}\", \"{}\");\n",
165                col_ident, rust_type, rust_policy, table.name, col_name
166            ));
167        }
168
169        code.push_str("}\n\n");
170    }
171
172    // ==========================================================================
173    // Generate RelatedTo impls for compile-time relationship checking
174    // ==========================================================================
175
176    code.push_str(
177        "// =============================================================================\n",
178    );
179    code.push_str("// Compile-Time Relationship Safety (RelatedTo impls)\n");
180    code.push_str(
181        "// =============================================================================\n\n",
182    );
183
184    for table in &tables {
185        for fk in &table.foreign_keys {
186            // table.column refs ref_table.ref_column
187            // This means: table is related TO ref_table (forward)
188            // AND: ref_table is related FROM table (reverse - parent has many children)
189
190            let from_mod = to_rust_ident(&table.name);
191            let from_struct = to_struct_name(&table.name);
192            let to_mod = to_rust_ident(&fk.ref_table);
193            let to_struct = to_struct_name(&fk.ref_table);
194
195            // Forward: From table (child) -> Referenced table (parent)
196            // Example: posts -> users (posts.user_id -> users.id)
197            code.push_str(&format!(
198                "/// {} has a foreign key to {} via {}.{}\n",
199                table.name, fk.ref_table, table.name, fk.column
200            ));
201            code.push_str(&format!(
202                "impl RelatedTo<{}::{}> for {}::{} {{\n",
203                to_mod, to_struct, from_mod, from_struct
204            ));
205            code.push_str(&format!(
206                "    fn join_columns() -> (&'static str, &'static str) {{ (\"{}\", \"{}\") }}\n",
207                fk.column, fk.ref_column
208            ));
209            code.push_str("}\n\n");
210
211            // Reverse: Referenced table (parent) -> From table (child)
212            // Example: users -> posts (users.id -> posts.user_id)
213            // This allows: Qail::get(users::table).join_related(posts::table)
214            code.push_str(&format!(
215                "/// {} is referenced by {} via {}.{}\n",
216                fk.ref_table, table.name, table.name, fk.column
217            ));
218            code.push_str(&format!(
219                "impl RelatedTo<{}::{}> for {}::{} {{\n",
220                from_mod, from_struct, to_mod, to_struct
221            ));
222            code.push_str(&format!(
223                "    fn join_columns() -> (&'static str, &'static str) {{ (\"{}\", \"{}\") }}\n",
224                fk.ref_column, fk.column
225            ));
226            code.push_str("}\n\n");
227        }
228    }
229
230    code
231}
232
233#[cfg(test)]
234mod codegen_tests {
235    use super::*;
236
237    #[test]
238    fn test_generate_schema_code() {
239        let schema_content = r#"
240table users {
241    id UUID primary_key
242    email TEXT not_null
243    age INT
244}
245
246table posts {
247    id UUID primary_key
248    user_id UUID ref:users.id
249    title TEXT
250}
251"#;
252
253        let schema = Schema::parse(schema_content).unwrap();
254        let code = generate_schema_code(&schema);
255
256        // Verify module structure
257        assert!(code.contains("pub mod users {"));
258        assert!(code.contains("pub mod posts {"));
259
260        // Verify table structs
261        assert!(code.contains("pub struct Users;"));
262        assert!(code.contains("pub struct Posts;"));
263
264        // Verify columns
265        assert!(code.contains("pub const id: TypedColumn<uuid::Uuid, Public>"));
266        assert!(code.contains("pub const email: TypedColumn<String, Public>"));
267        assert!(code.contains("pub const age: TypedColumn<i32, Public>"));
268
269        // Verify RelatedTo impls for compile-time relationship checking
270        assert!(code.contains("impl RelatedTo<users::Users> for posts::Posts"));
271        assert!(code.contains("impl RelatedTo<posts::Posts> for users::Users"));
272    }
273
274    #[test]
275    fn test_generate_protected_column() {
276        let schema_content = r#"
277table secrets {
278    id UUID primary_key
279    token TEXT protected
280}
281"#;
282        let schema = Schema::parse(schema_content).unwrap();
283        let code = generate_schema_code(&schema);
284
285        // Verify Protected policy
286        assert!(code.contains("pub const token: TypedColumn<String, Protected>"));
287    }
288}
289
290#[cfg(test)]
291mod migration_parser_tests {
292    use super::*;
293
294    #[test]
295    fn test_agent_contracts_migration_parses_all_columns() {
296        let sql = r#"
297CREATE TABLE agent_contracts (
298    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
299    agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
300    operator_id UUID NOT NULL REFERENCES operators(id) ON DELETE CASCADE,
301    pricing_model VARCHAR(20) NOT NULL CHECK (pricing_model IN ('commission', 'static_markup', 'net_rate')),
302    commission_percent DECIMAL(5,2),
303    static_markup DECIMAL(10,2),
304    is_active BOOLEAN DEFAULT true,
305    valid_from DATE,
306    valid_until DATE,
307    approved_by UUID REFERENCES users(id),
308    created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
309    updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
310    UNIQUE(agent_id, operator_id)
311);
312"#;
313
314        let mut schema = Schema::default();
315        schema.parse_sql_migration(sql);
316
317        let table = schema
318            .tables
319            .get("agent_contracts")
320            .expect("agent_contracts table should exist");
321
322        for col in &[
323            "id",
324            "agent_id",
325            "operator_id",
326            "pricing_model",
327            "commission_percent",
328            "static_markup",
329            "is_active",
330            "valid_from",
331            "valid_until",
332            "approved_by",
333            "created_at",
334            "updated_at",
335        ] {
336            assert!(
337                table.columns.contains_key(*col),
338                "Missing column: '{}'. Found: {:?}",
339                col,
340                table.columns.keys().collect::<Vec<_>>()
341            );
342        }
343    }
344
345    /// Regression test: column names that START with SQL keywords must parse correctly.
346    /// e.g., created_at starts with CREATE, primary_contact starts with PRIMARY, etc.
347    #[test]
348    fn test_keyword_prefixed_column_names_are_not_skipped() {
349        let sql = r#"
350CREATE TABLE edge_cases (
351    id UUID PRIMARY KEY,
352    created_at TIMESTAMPTZ NOT NULL,
353    created_by UUID,
354    primary_contact VARCHAR(255),
355    check_status VARCHAR(20),
356    unique_code VARCHAR(50),
357    foreign_ref UUID,
358    constraint_name VARCHAR(100),
359    PRIMARY KEY (id),
360    CHECK (check_status IN ('pending', 'active')),
361    UNIQUE (unique_code),
362    CONSTRAINT fk_ref FOREIGN KEY (foreign_ref) REFERENCES other(id)
363);
364"#;
365
366        let mut schema = Schema::default();
367        schema.parse_sql_migration(sql);
368
369        let table = schema
370            .tables
371            .get("edge_cases")
372            .expect("edge_cases table should exist");
373
374        // These column names start with SQL keywords — all must be found
375        for col in &[
376            "created_at",
377            "created_by",
378            "primary_contact",
379            "check_status",
380            "unique_code",
381            "foreign_ref",
382            "constraint_name",
383        ] {
384            assert!(
385                table.columns.contains_key(*col),
386                "Column '{}' should NOT be skipped just because it starts with a SQL keyword. Found: {:?}",
387                col,
388                table.columns.keys().collect::<Vec<_>>()
389            );
390        }
391
392        // These are constraint keywords, not columns — must NOT appear
393        // (PRIMARY KEY, CHECK, UNIQUE, CONSTRAINT lines should be skipped)
394        assert!(
395            !table.columns.contains_key("primary"),
396            "Constraint keyword 'PRIMARY' should not be treated as a column"
397        );
398    }
399}