Skip to main content

qail_core/
build.rs

1//! Build-time QAIL validation module.
2//!
3//! This module provides compile-time validation for QAIL queries
4//! without requiring proc macros.
5//!
6//! # Usage in build.rs
7//!
8//! ```ignore
9//! // In your build.rs:
10//! fn main() {
11//!     qail_core::build::validate();
12//! }
13//! ```
14//!
15//! # Environment Variables
16//!
17//! - `QAIL=schema` - Validate against schema.qail file
18//! - `QAIL=live` - Validate against live database
19//! - `QAIL=false` - Skip validation
20
21use crate::migrate::types::ColumnType;
22use std::collections::HashMap;
23use std::fs;
24use std::path::Path;
25
26/// Foreign key relationship definition
27#[derive(Debug, Clone)]
28pub struct ForeignKey {
29    /// Column in this table that references another table
30    pub column: String,
31    /// Name of referenced table
32    pub ref_table: String,
33    /// Column in referenced table
34    pub ref_column: String,
35}
36
37/// Table schema information with column types and relations
38#[derive(Debug, Clone)]
39pub struct TableSchema {
40    pub name: String,
41    /// Column name -> Column type (strongly-typed AST enum)
42    pub columns: HashMap<String, ColumnType>,
43    /// Column name -> Access Policy (Default: "Public", can be "Protected")
44    pub policies: HashMap<String, String>,
45    /// Foreign key relationships to other tables
46    pub foreign_keys: Vec<ForeignKey>,
47    /// Whether this table has Row-Level Security enabled
48    /// Auto-detected: table has `operator_id` column OR has `rls` keyword in schema.qail
49    pub rls_enabled: bool,
50}
51
52/// Parsed schema from schema.qail file
53#[derive(Debug, Default)]
54pub struct Schema {
55    pub tables: HashMap<String, TableSchema>,
56    /// Infrastructure resources (bucket, queue, topic)
57    pub resources: HashMap<String, ResourceSchema>,
58}
59
60/// Infrastructure resource schema (bucket, queue, topic)
61#[derive(Debug, Clone)]
62pub struct ResourceSchema {
63    pub name: String,
64    pub kind: String,
65    pub provider: Option<String>,
66    pub properties: HashMap<String, String>,
67}
68
69impl Schema {
70    /// Parse a schema.qail file
71    pub fn parse_file(path: &str) -> Result<Self, String> {
72        let content = fs::read_to_string(path)
73            .map_err(|e| format!("Failed to read schema file '{}': {}", path, e))?;
74        Self::parse(&content)
75    }
76
77    /// Parse schema from string
78    pub fn parse(content: &str) -> Result<Self, String> {
79        let mut schema = Schema::default();
80        let mut current_table: Option<String> = None;
81        let mut current_columns: HashMap<String, ColumnType> = HashMap::new();
82        let mut current_policies: HashMap<String, String> = HashMap::new();
83        let mut current_fks: Vec<ForeignKey> = Vec::new();
84        let mut current_rls_flag = false;
85
86        for line in content.lines() {
87            let line = line.trim();
88            
89            // Skip comments and empty lines
90            if line.is_empty() || line.starts_with('#') {
91                continue;
92            }
93
94            // Resource declarations: bucket, queue, topic
95            if line.starts_with("bucket ") || line.starts_with("queue ") || line.starts_with("topic ") {
96                let parts: Vec<&str> = line.splitn(2, ' ').collect();
97                let kind = parts[0].to_string();
98                let rest = parts.get(1).copied().unwrap_or("").trim();
99
100                // Extract name (before {
101                let name = rest.split('{').next().unwrap_or(rest).trim().to_string();
102                let mut provider = None;
103                let mut properties = HashMap::new();
104
105                if line.contains('{') {
106                    // Collect block content
107                    let block = rest.split('{').nth(1).unwrap_or("").to_string();
108                    if !block.contains('}') {
109                        for inner in content.lines().skip_while(|l| !l.contains(line)) {
110                            // Simple approach: read until }
111                            if inner.contains('}') { break; }
112                        }
113                    }
114                    let block = block.replace('}', "");
115                    let mut tokens = block.split_whitespace();
116                    while let Some(key) = tokens.next() {
117                        if let Some(val) = tokens.next() {
118                            let val = val.trim_matches('"').to_string();
119                            if key == "provider" {
120                                provider = Some(val);
121                            } else {
122                                properties.insert(key.to_string(), val);
123                            }
124                        }
125                    }
126                }
127
128                if !name.is_empty() {
129                    schema.resources.insert(name.clone(), ResourceSchema {
130                        name,
131                        kind,
132                        provider,
133                        properties,
134                    });
135                }
136                continue;
137            }
138
139            // Table definition: table name { [rls]
140            if line.starts_with("table ") && (line.ends_with('{') || line.contains('{')) {
141                // Save previous table if any
142                if let Some(table_name) = current_table.take() {
143                    // Auto-detect RLS: table has operator_id column or was marked `rls`
144                    let has_rls = current_rls_flag || current_columns.contains_key("operator_id");
145                    schema.tables.insert(table_name.clone(), TableSchema {
146                        name: table_name,
147                        columns: std::mem::take(&mut current_columns),
148                        policies: std::mem::take(&mut current_policies),
149                        foreign_keys: std::mem::take(&mut current_fks),
150                        rls_enabled: has_rls,
151                    });
152                }
153                
154                // Parse new table name, check for `rls` keyword
155                // Format: "table bookings rls {" or "table bookings {"
156                let after_table = line.trim_start_matches("table ");
157                let before_brace = after_table.split('{').next().unwrap_or("").trim();
158                let parts: Vec<&str> = before_brace.split_whitespace().collect();
159                let name = parts.first().unwrap_or(&"").to_string();
160                current_rls_flag = parts.contains(&"rls");
161                current_table = Some(name);
162            }
163            // End of table definition
164            else if line == "}" {
165                if let Some(table_name) = current_table.take() {
166                    let has_rls = current_rls_flag || current_columns.contains_key("operator_id");
167                    schema.tables.insert(table_name.clone(), TableSchema {
168                        name: table_name,
169                        columns: std::mem::take(&mut current_columns),
170                        policies: std::mem::take(&mut current_policies),
171                        foreign_keys: std::mem::take(&mut current_fks),
172                        rls_enabled: has_rls,
173                    });
174                    current_rls_flag = false;
175                }
176            }
177            // Column definition: column_name TYPE [constraints] [ref:table.column] [protected]
178            // Format from qail pull: "flow_name VARCHAR not_null"
179            // New format with FK: "user_id UUID ref:users.id"
180            // New format with Policy: "password_hash TEXT protected"
181            else if current_table.is_some() && !line.starts_with('#') && !line.is_empty() {
182                let parts: Vec<&str> = line.split_whitespace().collect();
183                if let Some(col_name) = parts.first() {
184                    // Second word is the type (default to TEXT if missing)
185                    let col_type_str = parts.get(1).copied().unwrap_or("text");
186                    let col_type = col_type_str.parse::<ColumnType>().unwrap_or(ColumnType::Text);
187                    current_columns.insert(col_name.to_string(), col_type);
188                    
189                    // Check for policies and foreign keys
190                    let mut policy = "Public".to_string();
191                    
192                    for part in parts.iter().skip(2) {
193                        if *part == "protected" {
194                            policy = "Protected".to_string();
195                        } else if let Some(ref_spec) = part.strip_prefix("ref:") {
196                            // Parse "table.column" or ">table.column"
197                            let ref_spec = ref_spec.trim_start_matches('>');
198                            if let Some((ref_table, ref_col)) = ref_spec.split_once('.') {
199                                current_fks.push(ForeignKey {
200                                    column: col_name.to_string(),
201                                    ref_table: ref_table.to_string(),
202                                    ref_column: ref_col.to_string(),
203                                });
204                            }
205                        }
206                    }
207                    current_policies.insert(col_name.to_string(), policy);
208                }
209            }
210        }
211
212        Ok(schema)
213    }
214
215    /// Check if table exists
216    pub fn has_table(&self, name: &str) -> bool {
217        self.tables.contains_key(name)
218    }
219
220    /// Get all table names that have RLS enabled
221    pub fn rls_tables(&self) -> Vec<&str> {
222        self.tables.iter()
223            .filter(|(_, ts)| ts.rls_enabled)
224            .map(|(name, _)| name.as_str())
225            .collect()
226    }
227
228    /// Check if a specific table has RLS enabled
229    pub fn is_rls_table(&self, name: &str) -> bool {
230        self.tables.get(name).is_some_and(|t| t.rls_enabled)
231    }
232
233    /// Get table schema
234    pub fn table(&self, name: &str) -> Option<&TableSchema> {
235        self.tables.get(name)
236    }
237    
238    /// Merge pending migrations into the schema
239    /// Scans migration directory for .sql files and extracts:
240    /// - CREATE TABLE statements
241    /// - ALTER TABLE ADD COLUMN statements
242    pub fn merge_migrations(&mut self, migrations_dir: &str) -> Result<usize, String> {
243        use std::fs;
244        
245        let dir = Path::new(migrations_dir);
246        if !dir.exists() {
247            return Ok(0); // No migrations directory
248        }
249        
250        let mut merged_count = 0;
251        
252        // Walk migration directories (format: migrations/YYYYMMDD_name/up.sql)
253        let entries = fs::read_dir(dir)
254            .map_err(|e| format!("Failed to read migrations dir: {}", e))?;
255        
256        for entry in entries.flatten() {
257            let path = entry.path();
258            
259            // Check for up.sql in subdirectory
260            let up_sql = if path.is_dir() {
261                path.join("up.sql")
262            } else if path.extension().is_some_and(|e| e == "sql") {
263                path.clone()
264            } else {
265                continue;
266            };
267            
268            if up_sql.exists() {
269                let content = fs::read_to_string(&up_sql)
270                    .map_err(|e| format!("Failed to read {}: {}", up_sql.display(), e))?;
271                
272                merged_count += self.parse_sql_migration(&content);
273            }
274        }
275        
276        Ok(merged_count)
277    }
278    
279    /// Parse SQL migration content and extract schema changes
280    fn parse_sql_migration(&mut self, sql: &str) -> usize {
281        let mut changes = 0;
282        
283        // Extract CREATE TABLE statements
284        // Pattern: CREATE TABLE [IF NOT EXISTS] table_name (columns...)
285        for line in sql.lines() {
286            let line_upper = line.trim().to_uppercase();
287            
288            if line_upper.starts_with("CREATE TABLE")
289                && let Some(table_name) = extract_create_table_name(line)
290                && !self.tables.contains_key(&table_name)
291            {
292                self.tables.insert(table_name.clone(), TableSchema {
293                    name: table_name,
294                    columns: HashMap::new(),
295                    policies: HashMap::new(),
296                    foreign_keys: vec![],
297                    rls_enabled: false,
298                });
299                changes += 1;
300            }
301        }
302        
303        // Extract column definitions from CREATE TABLE blocks
304        let mut current_table: Option<String> = None;
305        let mut in_create_block = false;
306        let mut paren_depth = 0;
307        
308        for line in sql.lines() {
309            let line = line.trim();
310            let line_upper = line.to_uppercase();
311            
312            if line_upper.starts_with("CREATE TABLE")
313                && let Some(name) = extract_create_table_name(line)
314            {
315                current_table = Some(name);
316                in_create_block = true;
317                paren_depth = 0;
318            }
319            
320            if in_create_block {
321                paren_depth += line.chars().filter(|c| *c == '(').count();
322                paren_depth = paren_depth.saturating_sub(line.chars().filter(|c| *c == ')').count());
323                
324                // Extract column name (first identifier after opening paren)
325                if let Some(col) = extract_column_from_create(line)
326                    && let Some(ref table) = current_table
327                    && let Some(t) = self.tables.get_mut(table)
328                    && t.columns.insert(col.clone(), ColumnType::Text).is_none()
329                {
330                    changes += 1;
331                }
332                
333                if paren_depth == 0 && line.contains(')') {
334                    in_create_block = false;
335                    current_table = None;
336                }
337            }
338            
339            // ALTER TABLE ... ADD COLUMN
340            if line_upper.contains("ALTER TABLE") && line_upper.contains("ADD COLUMN")
341                && let Some((table, col)) = extract_alter_add_column(line)
342            {
343                if let Some(t) = self.tables.get_mut(&table) {
344                    if t.columns.insert(col.clone(), ColumnType::Text).is_none() {
345                        changes += 1;
346                    }
347                } else {
348                    // Table might be new from this migration
349                    let mut cols = HashMap::new();
350                    cols.insert(col, ColumnType::Text);
351                    self.tables.insert(table.clone(), TableSchema {
352                        name: table,
353                        columns: cols,
354                        policies: HashMap::new(),
355                        foreign_keys: vec![],
356                        rls_enabled: false,
357                    });
358                    changes += 1;
359                }
360            }
361            
362            // ALTER TABLE ... ADD (without COLUMN keyword)
363            if line_upper.contains("ALTER TABLE") && line_upper.contains(" ADD ") && !line_upper.contains("ADD COLUMN")
364                && let Some((table, col)) = extract_alter_add(line)
365                && let Some(t) = self.tables.get_mut(&table)
366                && t.columns.insert(col.clone(), ColumnType::Text).is_none()
367            {
368                changes += 1;
369            }
370            
371            // DROP TABLE
372            if line_upper.starts_with("DROP TABLE")
373                && let Some(table_name) = extract_drop_table_name(line)
374                && self.tables.remove(&table_name).is_some()
375            {
376                changes += 1;
377            }
378            
379            // ALTER TABLE ... DROP COLUMN
380            if line_upper.contains("ALTER TABLE") && line_upper.contains("DROP COLUMN")
381                && let Some((table, col)) = extract_alter_drop_column(line)
382                && let Some(t) = self.tables.get_mut(&table)
383                && t.columns.remove(&col).is_some()
384            {
385                changes += 1;
386            }
387            
388            // ALTER TABLE ... DROP (without COLUMN keyword - PostgreSQL style)
389            if line_upper.contains("ALTER TABLE") && line_upper.contains(" DROP ") 
390                && !line_upper.contains("DROP COLUMN") 
391                && !line_upper.contains("DROP CONSTRAINT")
392                && !line_upper.contains("DROP INDEX")
393                && let Some((table, col)) = extract_alter_drop(line)
394                && let Some(t) = self.tables.get_mut(&table)
395                && t.columns.remove(&col).is_some()
396            {
397                changes += 1;
398            }
399        }
400        
401        changes
402    }
403}
404
405/// Extract table name from CREATE TABLE statement
406fn extract_create_table_name(line: &str) -> Option<String> {
407    let line_upper = line.to_uppercase();
408    let rest = line_upper.strip_prefix("CREATE TABLE")?;
409    let rest = rest.trim_start();
410    let rest = if rest.starts_with("IF NOT EXISTS") {
411        rest.strip_prefix("IF NOT EXISTS")?.trim_start()
412    } else {
413        rest
414    };
415    
416    // Get table name (first identifier)
417    let name: String = line[line.len() - rest.len()..]
418        .chars()
419        .take_while(|c| c.is_alphanumeric() || *c == '_')
420        .collect();
421    
422    if name.is_empty() { None } else { Some(name.to_lowercase()) }
423}
424
425/// Extract column name from a line inside CREATE TABLE block
426fn extract_column_from_create(line: &str) -> Option<String> {
427    let line = line.trim();
428    
429    // Skip keywords and constraints
430    // IMPORTANT: Must check for word boundaries to avoid matching column names
431    // that happen to start with a keyword (e.g., created_at starts with CREATE,
432    // primary_contact starts with PRIMARY, check_status starts with CHECK, etc.)
433    let line_upper = line.to_uppercase();
434    let starts_with_keyword = |kw: &str| -> bool {
435        line_upper.starts_with(kw)
436            && line_upper[kw.len()..].starts_with([' ', '('])
437    };
438    
439    if starts_with_keyword("CREATE") || 
440       starts_with_keyword("PRIMARY") ||
441       starts_with_keyword("FOREIGN") ||
442       starts_with_keyword("UNIQUE") ||
443       starts_with_keyword("CHECK") ||
444       starts_with_keyword("CONSTRAINT") ||
445       line_upper.starts_with(")") ||
446       line_upper.starts_with("(") ||
447       line.is_empty() {
448        return None;
449    }
450    
451    // First word is column name
452    let name: String = line
453        .trim_start_matches('(')
454        .trim()
455        .chars()
456        .take_while(|c| c.is_alphanumeric() || *c == '_')
457        .collect();
458    
459    if name.is_empty() || name.to_uppercase() == "IF" { None } else { Some(name.to_lowercase()) }
460}
461
462/// Extract table and column from ALTER TABLE ... ADD COLUMN
463fn extract_alter_add_column(line: &str) -> Option<(String, String)> {
464    let line_upper = line.to_uppercase();
465    let alter_pos = line_upper.find("ALTER TABLE")?;
466    let add_pos = line_upper.find("ADD COLUMN")?;
467    
468    // Table name between ALTER TABLE and ADD COLUMN
469    let table_part = &line[alter_pos + 11..add_pos];
470    let table: String = table_part.trim()
471        .chars()
472        .take_while(|c| c.is_alphanumeric() || *c == '_')
473        .collect();
474    
475    // Column name after ADD COLUMN
476    let col_part = &line[add_pos + 10..];
477    let col: String = col_part.trim()
478        .chars()
479        .take_while(|c| c.is_alphanumeric() || *c == '_')
480        .collect();
481    
482    if table.is_empty() || col.is_empty() {
483        None
484    } else {
485        Some((table.to_lowercase(), col.to_lowercase()))
486    }
487}
488
489/// Extract table and column from ALTER TABLE ... ADD (without COLUMN keyword)
490fn extract_alter_add(line: &str) -> Option<(String, String)> {
491    let line_upper = line.to_uppercase();
492    let alter_pos = line_upper.find("ALTER TABLE")?;
493    let add_pos = line_upper.find(" ADD ")?;
494    
495    let table_part = &line[alter_pos + 11..add_pos];
496    let table: String = table_part.trim()
497        .chars()
498        .take_while(|c| c.is_alphanumeric() || *c == '_')
499        .collect();
500    
501    let col_part = &line[add_pos + 5..];
502    let col: String = col_part.trim()
503        .chars()
504        .take_while(|c| c.is_alphanumeric() || *c == '_')
505        .collect();
506    
507    if table.is_empty() || col.is_empty() {
508        None
509    } else {
510        Some((table.to_lowercase(), col.to_lowercase()))
511    }
512}
513
514/// Extract table name from DROP TABLE statement
515fn extract_drop_table_name(line: &str) -> Option<String> {
516    let line_upper = line.to_uppercase();
517    let rest = line_upper.strip_prefix("DROP TABLE")?;
518    let rest = rest.trim_start();
519    let rest = if rest.starts_with("IF EXISTS") {
520        rest.strip_prefix("IF EXISTS")?.trim_start()
521    } else {
522        rest
523    };
524    
525    // Get table name (first identifier)
526    let name: String = line[line.len() - rest.len()..]
527        .chars()
528        .take_while(|c| c.is_alphanumeric() || *c == '_')
529        .collect();
530    
531    if name.is_empty() { None } else { Some(name.to_lowercase()) }
532}
533
534/// Extract table and column from ALTER TABLE ... DROP COLUMN
535fn extract_alter_drop_column(line: &str) -> Option<(String, String)> {
536    let line_upper = line.to_uppercase();
537    let alter_pos = line_upper.find("ALTER TABLE")?;
538    let drop_pos = line_upper.find("DROP COLUMN")?;
539    
540    // Table name between ALTER TABLE and DROP COLUMN
541    let table_part = &line[alter_pos + 11..drop_pos];
542    let table: String = table_part.trim()
543        .chars()
544        .take_while(|c| c.is_alphanumeric() || *c == '_')
545        .collect();
546    
547    // Column name after DROP COLUMN
548    let col_part = &line[drop_pos + 11..];
549    let col: String = col_part.trim()
550        .chars()
551        .take_while(|c| c.is_alphanumeric() || *c == '_')
552        .collect();
553    
554    if table.is_empty() || col.is_empty() {
555        None
556    } else {
557        Some((table.to_lowercase(), col.to_lowercase()))
558    }
559}
560
561/// Extract table and column from ALTER TABLE ... DROP (without COLUMN keyword)
562fn extract_alter_drop(line: &str) -> Option<(String, String)> {
563    let line_upper = line.to_uppercase();
564    let alter_pos = line_upper.find("ALTER TABLE")?;
565    let drop_pos = line_upper.find(" DROP ")?;
566    
567    let table_part = &line[alter_pos + 11..drop_pos];
568    let table: String = table_part.trim()
569        .chars()
570        .take_while(|c| c.is_alphanumeric() || *c == '_')
571        .collect();
572    
573    let col_part = &line[drop_pos + 6..];
574    let col: String = col_part.trim()
575        .chars()
576        .take_while(|c| c.is_alphanumeric() || *c == '_')
577        .collect();
578    
579    if table.is_empty() || col.is_empty() {
580        None
581    } else {
582        Some((table.to_lowercase(), col.to_lowercase()))
583    }
584}
585
586impl TableSchema {
587    /// Check if column exists
588    pub fn has_column(&self, name: &str) -> bool {
589        self.columns.contains_key(name)
590    }
591    
592    /// Get column type
593    pub fn column_type(&self, name: &str) -> Option<&ColumnType> {
594        self.columns.get(name)
595    }
596}
597
598/// Extracted QAIL usage from source code
599#[derive(Debug)]
600pub struct QailUsage {
601    pub file: String,
602    pub line: usize,
603    pub table: String,
604    pub columns: Vec<String>,
605    pub action: String,
606    pub is_cte_ref: bool,
607    /// Whether this query chain includes `.with_rls(` call
608    pub has_rls: bool,
609}
610
611/// Scan Rust source files for QAIL usage patterns
612pub fn scan_source_files(src_dir: &str) -> Vec<QailUsage> {
613    let mut usages = Vec::new();
614    scan_directory(Path::new(src_dir), &mut usages);
615    usages
616}
617
618fn scan_directory(dir: &Path, usages: &mut Vec<QailUsage>) {
619    if let Ok(entries) = fs::read_dir(dir) {
620        for entry in entries.flatten() {
621            let path = entry.path();
622            if path.is_dir() {
623                scan_directory(&path, usages);
624            } else if path.extension().is_some_and(|e| e == "rs")
625                && let Ok(content) = fs::read_to_string(&path)
626            {
627                scan_file(&path.display().to_string(), &content, usages);
628            }
629        }
630    }
631}
632
633fn scan_file(file: &str, content: &str, usages: &mut Vec<QailUsage>) {
634    // All CRUD patterns: GET=SELECT, ADD=INSERT, SET=UPDATE, DEL=DELETE, PUT=UPSERT
635    let patterns = [
636        ("Qail::get(", "GET"),
637        ("Qail::add(", "ADD"),
638        ("Qail::set(", "SET"),
639        ("Qail::del(", "DEL"),
640        ("Qail::put(", "PUT"),
641    ];
642
643    // First pass: extract all CTE names from .to_cte() patterns
644    // Pattern: .to_cte("cte_name")
645    let mut cte_names: std::collections::HashSet<String> = std::collections::HashSet::new();
646    for line in content.lines() {
647        let line = line.trim();
648        if let Some(pos) = line.find(".to_cte(") {
649            let after = &line[pos + 8..]; // ".to_cte(" is 8 chars
650            if let Some(name) = extract_string_arg(after) {
651                cte_names.insert(name);
652            }
653        }
654    }
655
656    // Second pass: detect Qail usage and mark CTE refs
657    let lines: Vec<&str> = content.lines().collect();
658    let mut i = 0;
659    
660    while i < lines.len() {
661        let line = lines[i].trim();
662        
663        // Check if this line starts a Qail chain
664        for (pattern, action) in &patterns {
665            if let Some(pos) = line.find(pattern) {
666                let start_line = i + 1; // 1-indexed
667                
668                // Extract table name from Qail::get("table")
669                let after = &line[pos + pattern.len()..];
670                if let Some(table) = extract_string_arg(after) {
671                    // Join continuation lines (lines that start with .)
672                    let mut full_chain = line.to_string();
673                    let mut j = i + 1;
674                    while j < lines.len() {
675                        let next = lines[j].trim();
676                        if next.starts_with('.') {
677                            full_chain.push_str(next);
678                            j += 1;
679                        } else if next.is_empty() {
680                            j += 1; // Skip empty lines
681                        } else {
682                            break;
683                        }
684                    }
685                    
686                    // Check if this is a CTE reference
687                    let is_cte_ref = cte_names.contains(&table);
688                    
689                    // Check if this query chain includes .with_rls(
690                    let has_rls = full_chain.contains(".with_rls(");
691                    
692                    // Extract column names from the full chain
693                    let columns = extract_columns(&full_chain);
694                    
695                    usages.push(QailUsage {
696                        file: file.to_string(),
697                        line: start_line,
698                        table,
699                        columns,
700                        action: action.to_string(),
701                        is_cte_ref,
702                        has_rls,
703                    });
704                    
705                    // Skip to end of chain
706                    i = j.saturating_sub(1);
707                } else {
708                    // Dynamic table name — cannot validate at build time.
709                    // Extract the variable name for a helpful warning.
710                    let var_hint = after.split(')').next().unwrap_or("?").trim();
711                    println!(
712                        "cargo:warning=Qail: dynamic table name `{}` in {}:{} — cannot validate columns at build time. Consider using string literals.",
713                        var_hint, file, start_line
714                    );
715                }
716                break; // Only match one pattern per line
717            }
718        }
719        i += 1;
720    }
721}
722
723fn extract_string_arg(s: &str) -> Option<String> {
724    // Find "string" pattern
725    let s = s.trim();
726    if let Some(stripped) = s.strip_prefix('"') {
727        let end = stripped.find('"')?;
728        Some(stripped[..end].to_string())
729    } else {
730        None
731    }
732}
733
734fn extract_columns(line: &str) -> Vec<String> {
735    let mut columns = Vec::new();
736    let mut remaining = line;
737    
738    // .column("col") — singular column
739    while let Some(pos) = remaining.find(".column(") {
740        let after = &remaining[pos + 8..];
741        if let Some(col) = extract_string_arg(after) {
742            columns.push(col);
743        }
744        remaining = after;
745    }
746    
747    // Reset for .columns([...]) — array syntax (most common pattern)
748    remaining = line;
749    while let Some(pos) = remaining.find(".columns(") {
750        let after = &remaining[pos + 9..];
751        // Find the opening bracket [
752        if let Some(bracket_start) = after.find('[') {
753            let inside = &after[bracket_start + 1..];
754            // Find the closing bracket ]
755            if let Some(bracket_end) = inside.find(']') {
756                let array_content = &inside[..bracket_end];
757                // Extract all string literals from the array
758                let mut scan = array_content;
759                while let Some(quote_start) = scan.find('"') {
760                    let after_quote = &scan[quote_start + 1..];
761                    if let Some(quote_end) = after_quote.find('"') {
762                        let col = &after_quote[..quote_end];
763                        if !col.is_empty() {
764                            columns.push(col.to_string());
765                        }
766                        scan = &after_quote[quote_end + 1..];
767                    } else {
768                        break;
769                    }
770                }
771            }
772        }
773        remaining = after;
774    }
775    
776    // Reset for next pattern
777    remaining = line;
778    
779    // .filter("col", ...)
780    while let Some(pos) = remaining.find(".filter(") {
781        let after = &remaining[pos + 8..];
782        if let Some(col) = extract_string_arg(after)
783            && !col.contains('.') {
784            columns.push(col);
785        }
786        remaining = after;
787    }
788    
789    // .eq("col", val), .ne("col", val), .gt, .lt, .gte, .lte
790    for method in [".eq(", ".ne(", ".gt(", ".lt(", ".gte(", ".lte(", ".like(", ".ilike("] {
791        let mut temp = line;
792        while let Some(pos) = temp.find(method) {
793            let after = &temp[pos + method.len()..];
794            if let Some(col) = extract_string_arg(after)
795                && !col.contains('.') {
796                columns.push(col);
797            }
798            temp = after;
799        }
800    }
801    
802    // .where_eq("col", val) — WHERE clause column
803    remaining = line;
804    while let Some(pos) = remaining.find(".where_eq(") {
805        let after = &remaining[pos + 10..];
806        if let Some(col) = extract_string_arg(after)
807            && !col.contains('.') {
808            columns.push(col);
809        }
810        remaining = after;
811    }
812    
813    // .order_by("col", ...)
814    remaining = line;
815    while let Some(pos) = remaining.find(".order_by(") {
816        let after = &remaining[pos + 10..];
817        if let Some(col) = extract_string_arg(after)
818            && !col.contains('.') {
819            columns.push(col);
820        }
821        remaining = after;
822    }
823    
824    // .order_desc("col"), .order_asc("col")
825    for method in [".order_desc(", ".order_asc("] {
826        let mut temp = line;
827        while let Some(pos) = temp.find(method) {
828            let after = &temp[pos + method.len()..];
829            if let Some(col) = extract_string_arg(after)
830                && !col.contains('.') {
831                columns.push(col);
832            }
833            temp = after;
834        }
835    }
836    
837    // .in_vals("col", vals)
838    remaining = line;
839    while let Some(pos) = remaining.find(".in_vals(") {
840        let after = &remaining[pos + 9..];
841        if let Some(col) = extract_string_arg(after)
842            && !col.contains('.') {
843            columns.push(col);
844        }
845        remaining = after;
846    }
847    
848    columns
849}
850
851/// Validate QAIL usage against schema using the smart Validator
852/// Provides "Did you mean?" suggestions for typos, type validation, and RLS audit
853pub fn validate_against_schema(schema: &Schema, usages: &[QailUsage]) -> Vec<String> {
854    use crate::validator::Validator;
855    
856    // Build Validator from Schema with column types
857    let mut validator = Validator::new();
858    for (table_name, table_schema) in &schema.tables {
859        // Convert HashMap<String, ColumnType> to Vec<(&str, &str)> for validator
860        let type_strings: Vec<(String, String)> = table_schema.columns
861            .iter()
862            .map(|(name, typ)| (name.clone(), typ.to_pg_type()))
863            .collect();
864        let cols_with_types: Vec<(&str, &str)> = type_strings
865            .iter()
866            .map(|(name, typ)| (name.as_str(), typ.as_str()))
867            .collect();
868        validator.add_table_with_types(table_name, &cols_with_types);
869    }
870    
871    let mut errors = Vec::new();
872    let mut rls_warnings = Vec::new();
873
874    for usage in usages {
875        // Skip CTE alias refs - these are defined in code, not in schema
876        if usage.is_cte_ref {
877            continue;
878        }
879        
880        // Use Validator for smart error messages with suggestions
881        match validator.validate_table(&usage.table) {
882            Ok(()) => {
883                // Table exists, check columns
884                for col in &usage.columns {
885                    // Skip qualified columns (CTE refs like cte.column)
886                    if col.contains('.') {
887                        continue;
888                    }
889                    // Skip SQL function expressions (e.g., count(*), SUM(amount))
890                    // and wildcard (*) — these are valid SQL, not schema columns
891                    if col.contains('(') || col == "*" {
892                        continue;
893                    }
894                    
895                    if let Err(e) = validator.validate_column(&usage.table, col) {
896                        errors.push(format!("{}:{}: {}", usage.file, usage.line, e));
897                    }
898                }
899                
900                // RLS Audit: warn if query targets RLS-enabled table without .with_rls()
901                if schema.is_rls_table(&usage.table) && !usage.has_rls {
902                    rls_warnings.push(format!(
903                        "{}:{}: ⚠️ RLS AUDIT: Qail::{}(\"{}\") has no .with_rls() — table has RLS enabled, query may leak tenant data",
904                        usage.file, usage.line, usage.action.to_lowercase(), usage.table
905                    ));
906                }
907            }
908            Err(e) => {
909                errors.push(format!("{}:{}: {}", usage.file, usage.line, e));
910            }
911        }
912    }
913    
914    // Append RLS warnings (non-fatal, but visible)
915    errors.extend(rls_warnings);
916
917    errors
918}
919
920/// Main validation entry point for build.rs
921pub fn validate() {
922    let mode = std::env::var("QAIL").unwrap_or_else(|_| {
923        if Path::new("schema.qail").exists() {
924            "schema".to_string()
925        } else {
926            "false".to_string()
927        }
928    });
929
930    match mode.as_str() {
931        "schema" => {
932            println!("cargo:rerun-if-changed=schema.qail");
933            println!("cargo:rerun-if-changed=migrations");
934            println!("cargo:rerun-if-env-changed=QAIL");
935            
936            match Schema::parse_file("schema.qail") {
937                Ok(mut schema) => {
938                    // Merge pending migrations with pulled schema
939                    let merged = schema.merge_migrations("migrations").unwrap_or(0);
940                    if merged > 0 {
941                        println!("cargo:warning=QAIL: Merged {} schema changes from migrations", merged);
942                    }
943                    
944                    let usages = scan_source_files("src/");
945                    let errors = validate_against_schema(&schema, &usages);
946                    
947                    if errors.is_empty() {
948                        println!("cargo:warning=QAIL: Validated {} queries against schema.qail ✓", usages.len());
949                    } else {
950                        for error in &errors {
951                            println!("cargo:warning=QAIL ERROR: {}", error);
952                        }
953                        // Fail the build
954                        panic!("QAIL validation failed with {} errors", errors.len());
955                    }
956                }
957                Err(e) => {
958                    println!("cargo:warning=QAIL: {}", e);
959                }
960            }
961        }
962        "live" => {
963            println!("cargo:rerun-if-env-changed=QAIL");
964            println!("cargo:rerun-if-env-changed=DATABASE_URL");
965            
966            // Get DATABASE_URL for qail pull
967            let db_url = match std::env::var("DATABASE_URL") {
968                Ok(url) => url,
969                Err(_) => {
970                    panic!("QAIL=live requires DATABASE_URL environment variable");
971                }
972            };
973            
974            // Step 1: Run qail pull to update schema.qail
975            println!("cargo:warning=QAIL: Pulling schema from live database...");
976            
977            let pull_result = std::process::Command::new("qail")
978                .args(["pull", &db_url])
979                .output();
980            
981            match pull_result {
982                Ok(output) => {
983                    if !output.status.success() {
984                        let stderr = String::from_utf8_lossy(&output.stderr);
985                        panic!("QAIL: Failed to pull schema: {}", stderr);
986                    }
987                    println!("cargo:warning=QAIL: Schema pulled successfully ✓");
988                }
989                Err(e) => {
990                    // qail CLI not found, try using cargo run
991                    println!("cargo:warning=QAIL: qail CLI not in PATH, trying cargo...");
992                    
993                    let cargo_result = std::process::Command::new("cargo")
994                        .args(["run", "-p", "qail", "--", "pull", &db_url])
995                        .current_dir(std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()))
996                        .output();
997                    
998                    match cargo_result {
999                        Ok(output) if output.status.success() => {
1000                            println!("cargo:warning=QAIL: Schema pulled via cargo ✓");
1001                        }
1002                        _ => {
1003                            panic!("QAIL: Cannot run qail pull: {}. Install qail CLI or set QAIL=schema", e);
1004                        }
1005                    }
1006                }
1007            }
1008            
1009            // Step 2: Parse the updated schema and validate
1010            match Schema::parse_file("schema.qail") {
1011                Ok(mut schema) => {
1012                    // Merge pending migrations (in case live DB doesn't have them yet)
1013                    let merged = schema.merge_migrations("migrations").unwrap_or(0);
1014                    if merged > 0 {
1015                        println!("cargo:warning=QAIL: Merged {} schema changes from pending migrations", merged);
1016                    }
1017                    
1018                    let usages = scan_source_files("src/");
1019                    let errors = validate_against_schema(&schema, &usages);
1020                    
1021                    if errors.is_empty() {
1022                        println!("cargo:warning=QAIL: Validated {} queries against live database ✓", usages.len());
1023                    } else {
1024                        for error in &errors {
1025                            println!("cargo:warning=QAIL ERROR: {}", error);
1026                        }
1027                        panic!("QAIL validation failed with {} errors", errors.len());
1028                    }
1029                }
1030                Err(e) => {
1031                    panic!("QAIL: Failed to parse schema after pull: {}", e);
1032                }
1033            }
1034        }
1035        "false" | "off" | "0" => {
1036            println!("cargo:rerun-if-env-changed=QAIL");
1037            // Silently skip validation
1038        }
1039        _ => {
1040            panic!("QAIL: Unknown mode '{}'. Use: schema, live, or false", mode);
1041        }
1042    }
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047    use super::*;
1048
1049    #[test]
1050    fn test_parse_schema() {
1051        // Format matches qail pull output (space-separated, not colon)
1052        let content = r#"
1053# Test schema
1054
1055table users {
1056  id UUID primary_key
1057  name TEXT not_null
1058  email TEXT unique
1059}
1060
1061table posts {
1062  id UUID
1063  user_id UUID
1064  title TEXT
1065}
1066"#;
1067        let schema = Schema::parse(content).unwrap();
1068        assert!(schema.has_table("users"));
1069        assert!(schema.has_table("posts"));
1070        assert!(schema.table("users").unwrap().has_column("id"));
1071        assert!(schema.table("users").unwrap().has_column("name"));
1072        assert!(!schema.table("users").unwrap().has_column("foo"));
1073    }
1074
1075    #[test]
1076    fn test_extract_string_arg() {
1077        assert_eq!(extract_string_arg(r#""users")"#), Some("users".to_string()));
1078        assert_eq!(extract_string_arg(r#""table_name")"#), Some("table_name".to_string()));
1079    }
1080
1081    #[test]
1082    fn test_scan_file() {
1083        // Test single-line pattern
1084        let content = r#"
1085let query = Qail::get("users").column("id").column("name").eq("active", true);
1086"#;
1087        let mut usages = Vec::new();
1088        scan_file("test.rs", content, &mut usages);
1089        
1090        assert_eq!(usages.len(), 1);
1091        assert_eq!(usages[0].table, "users");
1092        assert_eq!(usages[0].action, "GET");
1093        assert!(usages[0].columns.contains(&"id".to_string()));
1094        assert!(usages[0].columns.contains(&"name".to_string()));
1095    }
1096
1097    #[test]
1098    fn test_scan_file_multiline() {
1099        // Test multi-line chain pattern (common in real code)
1100        let content = r#"
1101let query = Qail::get("posts")
1102    .column("id")
1103    .column("title")
1104    .column("author")
1105    .eq("published", true)
1106    .order_by("created_at", Desc);
1107"#;
1108        let mut usages = Vec::new();
1109        scan_file("test.rs", content, &mut usages);
1110        
1111        assert_eq!(usages.len(), 1);
1112        assert_eq!(usages[0].table, "posts");
1113        assert_eq!(usages[0].action, "GET");
1114        assert!(usages[0].columns.contains(&"id".to_string()));
1115        assert!(usages[0].columns.contains(&"title".to_string()));
1116        assert!(usages[0].columns.contains(&"author".to_string()));
1117    }
1118}
1119
1120// =============================================================================
1121// Typed Schema Codegen
1122// =============================================================================
1123
1124/// Map ColumnType AST to Rust types for TypedColumn<T>
1125fn qail_type_to_rust(col_type: &ColumnType) -> &'static str {
1126    match col_type {
1127        ColumnType::Uuid => "uuid::Uuid",
1128        ColumnType::Text | ColumnType::Varchar(_) => "String",
1129        ColumnType::Int | ColumnType::Serial => "i32",
1130        ColumnType::BigInt | ColumnType::BigSerial => "i64",
1131        ColumnType::Bool => "bool",
1132        ColumnType::Float => "f32",
1133        ColumnType::Decimal(_) => "rust_decimal::Decimal",
1134        ColumnType::Jsonb => "serde_json::Value",
1135        ColumnType::Timestamp | ColumnType::Timestamptz => "chrono::DateTime<chrono::Utc>",
1136        ColumnType::Date => "chrono::NaiveDate",
1137        ColumnType::Time => "chrono::NaiveTime",
1138        ColumnType::Bytea => "Vec<u8>",
1139        ColumnType::Array(_) => "Vec<serde_json::Value>",
1140        ColumnType::Enum { .. } => "String",
1141        ColumnType::Range(_) => "String",
1142        ColumnType::Interval => "String",
1143        ColumnType::Cidr | ColumnType::Inet => "String",
1144        ColumnType::MacAddr => "String",
1145    }
1146}
1147
1148/// Convert table/column names to valid Rust identifiers
1149fn to_rust_ident(name: &str) -> String {
1150    // Handle Rust keywords
1151    let name = match name {
1152        "type" => "r#type",
1153        "match" => "r#match",
1154        "ref" => "r#ref",
1155        "self" => "r#self",
1156        "mod" => "r#mod",
1157        "use" => "r#use",
1158        _ => name,
1159    };
1160    name.to_string()
1161}
1162
1163/// Convert table name to PascalCase struct name
1164fn to_struct_name(name: &str) -> String {
1165    name.chars()
1166        .next()
1167        .map(|c| c.to_uppercase().collect::<String>() + &name[1..])
1168        .unwrap_or_default()
1169}
1170
1171/// Generate typed Rust module from schema.
1172/// 
1173/// # Usage in consumer's build.rs:
1174/// ```ignore
1175/// fn main() {
1176///     let out_dir = std::env::var("OUT_DIR").unwrap();
1177///     qail_core::build::generate_typed_schema("schema.qail", &format!("{}/schema.rs", out_dir)).unwrap();
1178///     println!("cargo:rerun-if-changed=schema.qail");
1179/// }
1180/// ```
1181/// 
1182/// Then in the consumer's lib.rs:
1183/// ```ignore
1184/// include!(concat!(env!("OUT_DIR"), "/schema.rs"));
1185/// ```
1186pub fn generate_typed_schema(schema_path: &str, output_path: &str) -> Result<(), String> {
1187    let schema = Schema::parse_file(schema_path)?;
1188    let code = generate_schema_code(&schema);
1189    
1190    fs::write(output_path, code)
1191        .map_err(|e| format!("Failed to write schema module to '{}': {}", output_path, e))?;
1192    
1193    Ok(())
1194}
1195
1196/// Generate typed Rust code from schema (does not write to file)
1197pub fn generate_schema_code(schema: &Schema) -> String {
1198    let mut code = String::new();
1199    
1200    // Header
1201    code.push_str("//! Auto-generated typed schema from schema.qail\n");
1202    code.push_str("//! Do not edit manually - regenerate with `cargo build`\n\n");
1203    code.push_str("#![allow(dead_code, non_upper_case_globals)]\n\n");
1204    code.push_str("use qail_core::typed::{Table, TypedColumn, RelatedTo, Public, Protected};\n\n");
1205    
1206    // Sort tables for deterministic output
1207    let mut tables: Vec<_> = schema.tables.values().collect();
1208    tables.sort_by(|a, b| a.name.cmp(&b.name));
1209    
1210    for table in &tables {
1211        let mod_name = to_rust_ident(&table.name);
1212        let struct_name = to_struct_name(&table.name);
1213        
1214        code.push_str(&format!("/// Typed schema for `{}` table\n", table.name));
1215        code.push_str(&format!("pub mod {} {{\n", mod_name));
1216        code.push_str("    use super::*;\n\n");
1217        
1218        // Table struct implementing Table trait
1219        code.push_str(&format!("    /// Table marker for `{}`\n", table.name));
1220        code.push_str("    #[derive(Debug, Clone, Copy)]\n");
1221        code.push_str(&format!("    pub struct {};\n\n", struct_name));
1222        
1223        code.push_str(&format!("    impl Table for {} {{\n", struct_name));
1224        code.push_str(&format!("        fn table_name() -> &'static str {{ \"{}\" }}\n", table.name));
1225        code.push_str("    }\n\n");
1226        
1227        code.push_str(&format!("    impl From<{}> for String {{\n", struct_name));
1228        code.push_str(&format!("        fn from(_: {}) -> String {{ \"{}\".to_string() }}\n", struct_name, table.name));
1229        code.push_str("    }\n\n");
1230
1231        code.push_str(&format!("    impl AsRef<str> for {} {{\n", struct_name));
1232        code.push_str(&format!("        fn as_ref(&self) -> &str {{ \"{}\" }}\n", table.name));
1233        code.push_str("    }\n\n");
1234        
1235        // Table constant for convenience
1236        code.push_str(&format!("    /// The `{}` table\n", table.name));
1237        code.push_str(&format!("    pub const table: {} = {};\n\n", struct_name, struct_name));
1238        
1239        // Sort columns for deterministic output
1240        let mut columns: Vec<_> = table.columns.iter().collect();
1241        columns.sort_by(|a, b| a.0.cmp(b.0));
1242        
1243        // Column constants
1244        for (col_name, col_type) in columns {
1245            let rust_type = qail_type_to_rust(col_type);
1246            let col_ident = to_rust_ident(col_name);
1247            let policy = table.policies.get(col_name).map(|s| s.as_str()).unwrap_or("Public");
1248            let rust_policy = if policy == "Protected" { "Protected" } else { "Public" };
1249            
1250            code.push_str(&format!("    /// Column `{}.{}` ({}) - {}\n", table.name, col_name, col_type.to_pg_type(), policy));
1251            code.push_str(&format!(
1252                "    pub const {}: TypedColumn<{}, {}> = TypedColumn::new(\"{}\", \"{}\");\n",
1253                col_ident, rust_type, rust_policy, table.name, col_name
1254            ));
1255        }
1256        
1257        code.push_str("}\n\n");
1258    }
1259    
1260    // ==========================================================================
1261    // Generate RelatedTo impls for compile-time relationship checking
1262    // ==========================================================================
1263    
1264    code.push_str("// =============================================================================\n");
1265    code.push_str("// Compile-Time Relationship Safety (RelatedTo impls)\n");
1266    code.push_str("// =============================================================================\n\n");
1267    
1268    for table in &tables {
1269        for fk in &table.foreign_keys {
1270            // table.column refs ref_table.ref_column
1271            // This means: table is related TO ref_table (forward)
1272            // AND: ref_table is related FROM table (reverse - parent has many children)
1273            
1274            let from_mod = to_rust_ident(&table.name);
1275            let from_struct = to_struct_name(&table.name);
1276            let to_mod = to_rust_ident(&fk.ref_table);
1277            let to_struct = to_struct_name(&fk.ref_table);
1278            
1279            // Forward: From table (child) -> Referenced table (parent)
1280            // Example: posts -> users (posts.user_id -> users.id)
1281            code.push_str(&format!(
1282                "/// {} has a foreign key to {} via {}.{}\n",
1283                table.name, fk.ref_table, table.name, fk.column
1284            ));
1285            code.push_str(&format!(
1286                "impl RelatedTo<{}::{}> for {}::{} {{\n",
1287                to_mod, to_struct, from_mod, from_struct
1288            ));
1289            code.push_str(&format!(
1290                "    fn join_columns() -> (&'static str, &'static str) {{ (\"{}\", \"{}\") }}\n",
1291                fk.column, fk.ref_column
1292            ));
1293            code.push_str("}\n\n");
1294            
1295            // Reverse: Referenced table (parent) -> From table (child)
1296            // Example: users -> posts (users.id -> posts.user_id)
1297            // This allows: Qail::get(users::table).join_related(posts::table)
1298            code.push_str(&format!(
1299                "/// {} is referenced by {} via {}.{}\n",
1300                fk.ref_table, table.name, table.name, fk.column
1301            ));
1302            code.push_str(&format!(
1303                "impl RelatedTo<{}::{}> for {}::{} {{\n",
1304                from_mod, from_struct, to_mod, to_struct
1305            ));
1306            code.push_str(&format!(
1307                "    fn join_columns() -> (&'static str, &'static str) {{ (\"{}\", \"{}\") }}\n",
1308                fk.ref_column, fk.column
1309            ));
1310            code.push_str("}\n\n");
1311        }
1312    }
1313    
1314    code
1315}
1316
1317#[cfg(test)]
1318mod codegen_tests {
1319    use super::*;
1320    
1321    #[test]
1322    fn test_generate_schema_code() {
1323        let schema_content = r#"
1324table users {
1325    id UUID primary_key
1326    email TEXT not_null
1327    age INT
1328}
1329
1330table posts {
1331    id UUID primary_key
1332    user_id UUID ref:users.id
1333    title TEXT
1334}
1335"#;
1336        
1337        let schema = Schema::parse(schema_content).unwrap();
1338        let code = generate_schema_code(&schema);
1339        
1340        // Verify module structure
1341        assert!(code.contains("pub mod users {"));
1342        assert!(code.contains("pub mod posts {"));
1343        
1344        // Verify table structs
1345        assert!(code.contains("pub struct Users;"));
1346        assert!(code.contains("pub struct Posts;"));
1347        
1348        // Verify columns
1349        assert!(code.contains("pub const id: TypedColumn<uuid::Uuid, Public>"));
1350        assert!(code.contains("pub const email: TypedColumn<String, Public>"));
1351        assert!(code.contains("pub const age: TypedColumn<i32, Public>"));
1352        
1353        // Verify RelatedTo impls for compile-time relationship checking
1354        assert!(code.contains("impl RelatedTo<users::Users> for posts::Posts"));
1355        assert!(code.contains("impl RelatedTo<posts::Posts> for users::Users"));
1356    }
1357
1358    #[test]
1359    fn test_generate_protected_column() {
1360        let schema_content = r#"
1361table secrets {
1362    id UUID primary_key
1363    token TEXT protected
1364}
1365"#;
1366        let schema = Schema::parse(schema_content).unwrap();
1367        let code = generate_schema_code(&schema);
1368        
1369        // Verify Protected policy
1370        assert!(code.contains("pub const token: TypedColumn<String, Protected>"));
1371    }
1372}
1373
1374
1375
1376#[cfg(test)]
1377mod migration_parser_tests {
1378    use super::*;
1379
1380    #[test]
1381    fn test_agent_contracts_migration_parses_all_columns() {
1382        let sql = r#"
1383CREATE TABLE agent_contracts (
1384    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
1385    agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
1386    operator_id UUID NOT NULL REFERENCES operators(id) ON DELETE CASCADE,
1387    pricing_model VARCHAR(20) NOT NULL CHECK (pricing_model IN ('commission', 'static_markup', 'net_rate')),
1388    commission_percent DECIMAL(5,2),
1389    static_markup DECIMAL(10,2),
1390    is_active BOOLEAN DEFAULT true,
1391    valid_from DATE,
1392    valid_until DATE,
1393    approved_by UUID REFERENCES users(id),
1394    created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
1395    updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
1396    UNIQUE(agent_id, operator_id)
1397);
1398"#;
1399
1400        let mut schema = Schema::default();
1401        schema.parse_sql_migration(sql);
1402        
1403        let table = schema.tables.get("agent_contracts")
1404            .expect("agent_contracts table should exist");
1405        
1406        for col in &["id", "agent_id", "operator_id", "pricing_model",
1407                      "commission_percent", "static_markup", "is_active",
1408                      "valid_from", "valid_until", "approved_by",
1409                      "created_at", "updated_at"] {
1410            assert!(
1411                table.columns.contains_key(*col),
1412                "Missing column: '{}'. Found: {:?}",
1413                col, table.columns.keys().collect::<Vec<_>>()
1414            );
1415        }
1416    }
1417
1418    /// Regression test: column names that START with SQL keywords must parse correctly.
1419    /// e.g., created_at starts with CREATE, primary_contact starts with PRIMARY, etc.
1420    #[test]
1421    fn test_keyword_prefixed_column_names_are_not_skipped() {
1422        let sql = r#"
1423CREATE TABLE edge_cases (
1424    id UUID PRIMARY KEY,
1425    created_at TIMESTAMPTZ NOT NULL,
1426    created_by UUID,
1427    primary_contact VARCHAR(255),
1428    check_status VARCHAR(20),
1429    unique_code VARCHAR(50),
1430    foreign_ref UUID,
1431    constraint_name VARCHAR(100),
1432    PRIMARY KEY (id),
1433    CHECK (check_status IN ('pending', 'active')),
1434    UNIQUE (unique_code),
1435    CONSTRAINT fk_ref FOREIGN KEY (foreign_ref) REFERENCES other(id)
1436);
1437"#;
1438
1439        let mut schema = Schema::default();
1440        schema.parse_sql_migration(sql);
1441        
1442        let table = schema.tables.get("edge_cases")
1443            .expect("edge_cases table should exist");
1444        
1445        // These column names start with SQL keywords — all must be found
1446        for col in &["created_at", "created_by", "primary_contact",
1447                      "check_status", "unique_code", "foreign_ref",
1448                      "constraint_name"] {
1449            assert!(
1450                table.columns.contains_key(*col),
1451                "Column '{}' should NOT be skipped just because it starts with a SQL keyword. Found: {:?}",
1452                col, table.columns.keys().collect::<Vec<_>>()
1453            );
1454        }
1455        
1456        // These are constraint keywords, not columns — must NOT appear
1457        // (PRIMARY KEY, CHECK, UNIQUE, CONSTRAINT lines should be skipped)
1458        assert!(!table.columns.contains_key("primary"),
1459            "Constraint keyword 'PRIMARY' should not be treated as a column");
1460    }
1461}