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