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