1use 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
34fn to_rust_ident(name: &str) -> String {
36 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
49fn 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
57pub 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
82pub fn generate_schema_code(schema: &Schema) -> String {
84 let mut code = String::new();
85
86 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 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 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 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 let mut columns: Vec<_> = table.columns.iter().collect();
139 columns.sort_by(|a, b| a.0.cmp(b.0));
140
141 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 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 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 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 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 assert!(code.contains("pub mod users {"));
258 assert!(code.contains("pub mod posts {"));
259
260 assert!(code.contains("pub struct Users;"));
262 assert!(code.contains("pub struct Posts;"));
263
264 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 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 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 #[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 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 assert!(
395 !table.columns.contains_key("primary"),
396 "Constraint keyword 'PRIMARY' should not be treated as a column"
397 );
398 }
399}