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                }
650                break; // Only match one pattern per line
651            }
652        }
653        i += 1;
654    }
655}
656
657fn extract_string_arg(s: &str) -> Option<String> {
658    // Find "string" pattern
659    let s = s.trim();
660    if let Some(stripped) = s.strip_prefix('"') {
661        let end = stripped.find('"')?;
662        Some(stripped[..end].to_string())
663    } else {
664        None
665    }
666}
667
668fn extract_columns(line: &str) -> Vec<String> {
669    let mut columns = Vec::new();
670    let mut remaining = line;
671    
672    // .column("col")
673    while let Some(pos) = remaining.find(".column(") {
674        let after = &remaining[pos + 8..];
675        if let Some(col) = extract_string_arg(after) {
676            columns.push(col);
677        }
678        remaining = after;
679    }
680    
681    // Reset for next pattern
682    remaining = line;
683    
684    // .filter("col", ...)
685    while let Some(pos) = remaining.find(".filter(") {
686        let after = &remaining[pos + 8..];
687        if let Some(col) = extract_string_arg(after)
688            && !col.contains('.') {
689            columns.push(col);
690        }
691        remaining = after;
692    }
693    
694    // .eq("col", val), .ne("col", val), .gt, .lt, .gte, .lte
695    for method in [".eq(", ".ne(", ".gt(", ".lt(", ".gte(", ".lte(", ".like(", ".ilike("] {
696        let mut temp = line;
697        while let Some(pos) = temp.find(method) {
698            let after = &temp[pos + method.len()..];
699            if let Some(col) = extract_string_arg(after)
700                && !col.contains('.') {
701                columns.push(col);
702            }
703            temp = after;
704        }
705    }
706    
707    // .order_by("col", ...)
708    let mut remaining = line;
709    while let Some(pos) = remaining.find(".order_by(") {
710        let after = &remaining[pos + 10..];
711        if let Some(col) = extract_string_arg(after)
712            && !col.contains('.') {
713            columns.push(col);
714        }
715        remaining = after;
716    }
717    
718    columns
719}
720
721/// Validate QAIL usage against schema using the smart Validator
722/// Provides "Did you mean?" suggestions for typos, type validation, and RLS audit
723pub fn validate_against_schema(schema: &Schema, usages: &[QailUsage]) -> Vec<String> {
724    use crate::validator::Validator;
725    
726    // Build Validator from Schema with column types
727    let mut validator = Validator::new();
728    for (table_name, table_schema) in &schema.tables {
729        // Convert HashMap<String, String> to Vec<(&str, &str)>
730        let cols_with_types: Vec<(&str, &str)> = table_schema.columns
731            .iter()
732            .map(|(name, typ)| (name.as_str(), typ.as_str()))
733            .collect();
734        validator.add_table_with_types(table_name, &cols_with_types);
735    }
736    
737    let mut errors = Vec::new();
738    let mut rls_warnings = Vec::new();
739
740    for usage in usages {
741        // Skip CTE alias refs - these are defined in code, not in schema
742        if usage.is_cte_ref {
743            continue;
744        }
745        
746        // Use Validator for smart error messages with suggestions
747        match validator.validate_table(&usage.table) {
748            Ok(()) => {
749                // Table exists, check columns
750                for col in &usage.columns {
751                    // Skip qualified columns (CTE refs like cte.column)
752                    if col.contains('.') {
753                        continue;
754                    }
755                    
756                    if let Err(e) = validator.validate_column(&usage.table, col) {
757                        errors.push(format!("{}:{}: {}", usage.file, usage.line, e));
758                    }
759                }
760                
761                // RLS Audit: warn if query targets RLS-enabled table without .with_rls()
762                if schema.is_rls_table(&usage.table) && !usage.has_rls {
763                    rls_warnings.push(format!(
764                        "{}:{}: ⚠️ RLS AUDIT: Qail::{}(\"{}\") has no .with_rls() — table has RLS enabled, query may leak tenant data",
765                        usage.file, usage.line, usage.action.to_lowercase(), usage.table
766                    ));
767                }
768            }
769            Err(e) => {
770                errors.push(format!("{}:{}: {}", usage.file, usage.line, e));
771            }
772        }
773    }
774    
775    // Append RLS warnings (non-fatal, but visible)
776    errors.extend(rls_warnings);
777
778    errors
779}
780
781/// Main validation entry point for build.rs
782pub fn validate() {
783    let mode = std::env::var("QAIL").unwrap_or_else(|_| {
784        if Path::new("schema.qail").exists() {
785            "schema".to_string()
786        } else {
787            "false".to_string()
788        }
789    });
790
791    match mode.as_str() {
792        "schema" => {
793            println!("cargo:rerun-if-changed=schema.qail");
794            println!("cargo:rerun-if-changed=migrations");
795            println!("cargo:rerun-if-env-changed=QAIL");
796            
797            match Schema::parse_file("schema.qail") {
798                Ok(mut schema) => {
799                    // Merge pending migrations with pulled schema
800                    let merged = schema.merge_migrations("migrations").unwrap_or(0);
801                    if merged > 0 {
802                        println!("cargo:warning=QAIL: Merged {} schema changes from migrations", merged);
803                    }
804                    
805                    let usages = scan_source_files("src/");
806                    let errors = validate_against_schema(&schema, &usages);
807                    
808                    if errors.is_empty() {
809                        println!("cargo:warning=QAIL: Validated {} queries against schema.qail ✓", usages.len());
810                    } else {
811                        for error in &errors {
812                            println!("cargo:warning=QAIL ERROR: {}", error);
813                        }
814                        // Fail the build
815                        panic!("QAIL validation failed with {} errors", errors.len());
816                    }
817                }
818                Err(e) => {
819                    println!("cargo:warning=QAIL: {}", e);
820                }
821            }
822        }
823        "live" => {
824            println!("cargo:rerun-if-env-changed=QAIL");
825            println!("cargo:rerun-if-env-changed=DATABASE_URL");
826            
827            // Get DATABASE_URL for qail pull
828            let db_url = match std::env::var("DATABASE_URL") {
829                Ok(url) => url,
830                Err(_) => {
831                    panic!("QAIL=live requires DATABASE_URL environment variable");
832                }
833            };
834            
835            // Step 1: Run qail pull to update schema.qail
836            println!("cargo:warning=QAIL: Pulling schema from live database...");
837            
838            let pull_result = std::process::Command::new("qail")
839                .args(["pull", &db_url])
840                .output();
841            
842            match pull_result {
843                Ok(output) => {
844                    if !output.status.success() {
845                        let stderr = String::from_utf8_lossy(&output.stderr);
846                        panic!("QAIL: Failed to pull schema: {}", stderr);
847                    }
848                    println!("cargo:warning=QAIL: Schema pulled successfully ✓");
849                }
850                Err(e) => {
851                    // qail CLI not found, try using cargo run
852                    println!("cargo:warning=QAIL: qail CLI not in PATH, trying cargo...");
853                    
854                    let cargo_result = std::process::Command::new("cargo")
855                        .args(["run", "-p", "qail", "--", "pull", &db_url])
856                        .current_dir(std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()))
857                        .output();
858                    
859                    match cargo_result {
860                        Ok(output) if output.status.success() => {
861                            println!("cargo:warning=QAIL: Schema pulled via cargo ✓");
862                        }
863                        _ => {
864                            panic!("QAIL: Cannot run qail pull: {}. Install qail CLI or set QAIL=schema", e);
865                        }
866                    }
867                }
868            }
869            
870            // Step 2: Parse the updated schema and validate
871            match Schema::parse_file("schema.qail") {
872                Ok(mut schema) => {
873                    // Merge pending migrations (in case live DB doesn't have them yet)
874                    let merged = schema.merge_migrations("migrations").unwrap_or(0);
875                    if merged > 0 {
876                        println!("cargo:warning=QAIL: Merged {} schema changes from pending migrations", merged);
877                    }
878                    
879                    let usages = scan_source_files("src/");
880                    let errors = validate_against_schema(&schema, &usages);
881                    
882                    if errors.is_empty() {
883                        println!("cargo:warning=QAIL: Validated {} queries against live database ✓", usages.len());
884                    } else {
885                        for error in &errors {
886                            println!("cargo:warning=QAIL ERROR: {}", error);
887                        }
888                        panic!("QAIL validation failed with {} errors", errors.len());
889                    }
890                }
891                Err(e) => {
892                    panic!("QAIL: Failed to parse schema after pull: {}", e);
893                }
894            }
895        }
896        "false" | "off" | "0" => {
897            println!("cargo:rerun-if-env-changed=QAIL");
898            // Silently skip validation
899        }
900        _ => {
901            panic!("QAIL: Unknown mode '{}'. Use: schema, live, or false", mode);
902        }
903    }
904}
905
906#[cfg(test)]
907mod tests {
908    use super::*;
909
910    #[test]
911    fn test_parse_schema() {
912        // Format matches qail pull output (space-separated, not colon)
913        let content = r#"
914# Test schema
915
916table users {
917  id UUID primary_key
918  name TEXT not_null
919  email TEXT unique
920}
921
922table posts {
923  id UUID
924  user_id UUID
925  title TEXT
926}
927"#;
928        let schema = Schema::parse(content).unwrap();
929        assert!(schema.has_table("users"));
930        assert!(schema.has_table("posts"));
931        assert!(schema.table("users").unwrap().has_column("id"));
932        assert!(schema.table("users").unwrap().has_column("name"));
933        assert!(!schema.table("users").unwrap().has_column("foo"));
934    }
935
936    #[test]
937    fn test_extract_string_arg() {
938        assert_eq!(extract_string_arg(r#""users")"#), Some("users".to_string()));
939        assert_eq!(extract_string_arg(r#""table_name")"#), Some("table_name".to_string()));
940    }
941
942    #[test]
943    fn test_scan_file() {
944        // Test single-line pattern
945        let content = r#"
946let query = Qail::get("users").column("id").column("name").eq("active", true);
947"#;
948        let mut usages = Vec::new();
949        scan_file("test.rs", content, &mut usages);
950        
951        assert_eq!(usages.len(), 1);
952        assert_eq!(usages[0].table, "users");
953        assert_eq!(usages[0].action, "GET");
954        assert!(usages[0].columns.contains(&"id".to_string()));
955        assert!(usages[0].columns.contains(&"name".to_string()));
956    }
957
958    #[test]
959    fn test_scan_file_multiline() {
960        // Test multi-line chain pattern (common in real code)
961        let content = r#"
962let query = Qail::get("posts")
963    .column("id")
964    .column("title")
965    .column("author")
966    .eq("published", true)
967    .order_by("created_at", Desc);
968"#;
969        let mut usages = Vec::new();
970        scan_file("test.rs", content, &mut usages);
971        
972        assert_eq!(usages.len(), 1);
973        assert_eq!(usages[0].table, "posts");
974        assert_eq!(usages[0].action, "GET");
975        assert!(usages[0].columns.contains(&"id".to_string()));
976        assert!(usages[0].columns.contains(&"title".to_string()));
977        assert!(usages[0].columns.contains(&"author".to_string()));
978    }
979}
980
981// =============================================================================
982// Typed Schema Codegen
983// =============================================================================
984
985/// Map QAIL types to Rust types for TypedColumn<T>
986fn qail_type_to_rust(qail_type: &str) -> &'static str {
987    match qail_type.to_uppercase().as_str() {
988        "UUID" => "uuid::Uuid",
989        "TEXT" | "VARCHAR" | "CHAR" | "STRING" => "String",
990        "INT" | "INTEGER" | "INT4" | "SERIAL" => "i32",
991        "BIGINT" | "INT8" | "BIGSERIAL" => "i64",
992        "SMALLINT" | "INT2" => "i16",
993        "FLOAT" | "FLOAT4" | "REAL" => "f32",
994        "DOUBLE" | "FLOAT8" | "DOUBLE PRECISION" => "f64",
995        "DECIMAL" | "NUMERIC" => "rust_decimal::Decimal",
996        "BOOL" | "BOOLEAN" => "bool",
997        "TIMESTAMP" | "TIMESTAMPTZ" => "chrono::DateTime<chrono::Utc>",
998        "DATE" => "chrono::NaiveDate",
999        "TIME" | "TIMETZ" => "chrono::NaiveTime",
1000        "JSON" | "JSONB" => "serde_json::Value",
1001        "BYTEA" | "BLOB" => "Vec<u8>",
1002        _ => "String", // Default to String for unknown types
1003    }
1004}
1005
1006/// Convert table/column names to valid Rust identifiers
1007fn to_rust_ident(name: &str) -> String {
1008    // Handle Rust keywords
1009    let name = match name {
1010        "type" => "r#type",
1011        "match" => "r#match",
1012        "ref" => "r#ref",
1013        "self" => "r#self",
1014        "mod" => "r#mod",
1015        "use" => "r#use",
1016        _ => name,
1017    };
1018    name.to_string()
1019}
1020
1021/// Convert table name to PascalCase struct name
1022fn to_struct_name(name: &str) -> String {
1023    name.chars()
1024        .next()
1025        .map(|c| c.to_uppercase().collect::<String>() + &name[1..])
1026        .unwrap_or_default()
1027}
1028
1029/// Generate typed Rust module from schema.
1030/// 
1031/// # Usage in consumer's build.rs:
1032/// ```ignore
1033/// fn main() {
1034///     let out_dir = std::env::var("OUT_DIR").unwrap();
1035///     qail_core::build::generate_typed_schema("schema.qail", &format!("{}/schema.rs", out_dir)).unwrap();
1036///     println!("cargo:rerun-if-changed=schema.qail");
1037/// }
1038/// ```
1039/// 
1040/// Then in the consumer's lib.rs:
1041/// ```ignore
1042/// include!(concat!(env!("OUT_DIR"), "/schema.rs"));
1043/// ```
1044pub fn generate_typed_schema(schema_path: &str, output_path: &str) -> Result<(), String> {
1045    let schema = Schema::parse_file(schema_path)?;
1046    let code = generate_schema_code(&schema);
1047    
1048    fs::write(output_path, code)
1049        .map_err(|e| format!("Failed to write schema module to '{}': {}", output_path, e))?;
1050    
1051    Ok(())
1052}
1053
1054/// Generate typed Rust code from schema (does not write to file)
1055pub fn generate_schema_code(schema: &Schema) -> String {
1056    let mut code = String::new();
1057    
1058    // Header
1059    code.push_str("//! Auto-generated typed schema from schema.qail\n");
1060    code.push_str("//! Do not edit manually - regenerate with `cargo build`\n\n");
1061    code.push_str("#![allow(dead_code, non_upper_case_globals)]\n\n");
1062    code.push_str("use qail_core::typed::{Table, TypedColumn, RelatedTo, Public, Protected};\n\n");
1063    
1064    // Sort tables for deterministic output
1065    let mut tables: Vec<_> = schema.tables.values().collect();
1066    tables.sort_by(|a, b| a.name.cmp(&b.name));
1067    
1068    for table in &tables {
1069        let mod_name = to_rust_ident(&table.name);
1070        let struct_name = to_struct_name(&table.name);
1071        
1072        code.push_str(&format!("/// Typed schema for `{}` table\n", table.name));
1073        code.push_str(&format!("pub mod {} {{\n", mod_name));
1074        code.push_str("    use super::*;\n\n");
1075        
1076        // Table struct implementing Table trait
1077        code.push_str(&format!("    /// Table marker for `{}`\n", table.name));
1078        code.push_str("    #[derive(Debug, Clone, Copy)]\n");
1079        code.push_str(&format!("    pub struct {};\n\n", struct_name));
1080        
1081        code.push_str(&format!("    impl Table for {} {{\n", struct_name));
1082        code.push_str(&format!("        fn table_name() -> &'static str {{ \"{}\" }}\n", table.name));
1083        code.push_str("    }\n\n");
1084        
1085        code.push_str(&format!("    impl From<{}> for String {{\n", struct_name));
1086        code.push_str(&format!("        fn from(_: {}) -> String {{ \"{}\".to_string() }}\n", struct_name, table.name));
1087        code.push_str("    }\n\n");
1088
1089        code.push_str(&format!("    impl AsRef<str> for {} {{\n", struct_name));
1090        code.push_str(&format!("        fn as_ref(&self) -> &str {{ \"{}\" }}\n", table.name));
1091        code.push_str("    }\n\n");
1092        
1093        // Table constant for convenience
1094        code.push_str(&format!("    /// The `{}` table\n", table.name));
1095        code.push_str(&format!("    pub const table: {} = {};\n\n", struct_name, struct_name));
1096        
1097        // Sort columns for deterministic output
1098        let mut columns: Vec<_> = table.columns.iter().collect();
1099        columns.sort_by(|a, b| a.0.cmp(b.0));
1100        
1101        // Column constants
1102        for (col_name, col_type) in columns {
1103            let rust_type = qail_type_to_rust(col_type);
1104            let col_ident = to_rust_ident(col_name);
1105            let policy = table.policies.get(col_name).map(|s| s.as_str()).unwrap_or("Public");
1106            let rust_policy = if policy == "Protected" { "Protected" } else { "Public" };
1107            
1108            code.push_str(&format!("    /// Column `{}.{}` ({}) - {}\n", table.name, col_name, col_type, policy));
1109            code.push_str(&format!(
1110                "    pub const {}: TypedColumn<{}, {}> = TypedColumn::new(\"{}\", \"{}\");\n",
1111                col_ident, rust_type, rust_policy, table.name, col_name
1112            ));
1113        }
1114        
1115        code.push_str("}\n\n");
1116    }
1117    
1118    // ==========================================================================
1119    // Generate RelatedTo impls for compile-time relationship checking
1120    // ==========================================================================
1121    
1122    code.push_str("// =============================================================================\n");
1123    code.push_str("// Compile-Time Relationship Safety (RelatedTo impls)\n");
1124    code.push_str("// =============================================================================\n\n");
1125    
1126    for table in &tables {
1127        for fk in &table.foreign_keys {
1128            // table.column refs ref_table.ref_column
1129            // This means: table is related TO ref_table (forward)
1130            // AND: ref_table is related FROM table (reverse - parent has many children)
1131            
1132            let from_mod = to_rust_ident(&table.name);
1133            let from_struct = to_struct_name(&table.name);
1134            let to_mod = to_rust_ident(&fk.ref_table);
1135            let to_struct = to_struct_name(&fk.ref_table);
1136            
1137            // Forward: From table (child) -> Referenced table (parent)
1138            // Example: posts -> users (posts.user_id -> users.id)
1139            code.push_str(&format!(
1140                "/// {} has a foreign key to {} via {}.{}\n",
1141                table.name, fk.ref_table, table.name, fk.column
1142            ));
1143            code.push_str(&format!(
1144                "impl RelatedTo<{}::{}> for {}::{} {{\n",
1145                to_mod, to_struct, from_mod, from_struct
1146            ));
1147            code.push_str(&format!(
1148                "    fn join_columns() -> (&'static str, &'static str) {{ (\"{}\", \"{}\") }}\n",
1149                fk.column, fk.ref_column
1150            ));
1151            code.push_str("}\n\n");
1152            
1153            // Reverse: Referenced table (parent) -> From table (child)
1154            // Example: users -> posts (users.id -> posts.user_id)
1155            // This allows: Qail::get(users::table).join_related(posts::table)
1156            code.push_str(&format!(
1157                "/// {} is referenced by {} via {}.{}\n",
1158                fk.ref_table, table.name, table.name, fk.column
1159            ));
1160            code.push_str(&format!(
1161                "impl RelatedTo<{}::{}> for {}::{} {{\n",
1162                from_mod, from_struct, to_mod, to_struct
1163            ));
1164            code.push_str(&format!(
1165                "    fn join_columns() -> (&'static str, &'static str) {{ (\"{}\", \"{}\") }}\n",
1166                fk.ref_column, fk.column
1167            ));
1168            code.push_str("}\n\n");
1169        }
1170    }
1171    
1172    code
1173}
1174
1175#[cfg(test)]
1176mod codegen_tests {
1177    use super::*;
1178    
1179    #[test]
1180    fn test_generate_schema_code() {
1181        let schema_content = r#"
1182table users {
1183    id UUID primary_key
1184    email TEXT not_null
1185    age INT
1186}
1187
1188table posts {
1189    id UUID primary_key
1190    user_id UUID ref:users.id
1191    title TEXT
1192}
1193"#;
1194        
1195        let schema = Schema::parse(schema_content).unwrap();
1196        let code = generate_schema_code(&schema);
1197        
1198        // Verify module structure
1199        assert!(code.contains("pub mod users {"));
1200        assert!(code.contains("pub mod posts {"));
1201        
1202        // Verify table structs
1203        assert!(code.contains("pub struct Users;"));
1204        assert!(code.contains("pub struct Posts;"));
1205        
1206        // Verify columns
1207        assert!(code.contains("pub const id: TypedColumn<uuid::Uuid, Public>"));
1208        assert!(code.contains("pub const email: TypedColumn<String, Public>"));
1209        assert!(code.contains("pub const age: TypedColumn<i32, Public>"));
1210        
1211        // Verify RelatedTo impls for compile-time relationship checking
1212        assert!(code.contains("impl RelatedTo<users::Users> for posts::Posts"));
1213        assert!(code.contains("impl RelatedTo<posts::Posts> for users::Users"));
1214    }
1215
1216    #[test]
1217    fn test_generate_protected_column() {
1218        let schema_content = r#"
1219table secrets {
1220    id UUID primary_key
1221    token TEXT protected
1222}
1223"#;
1224        let schema = Schema::parse(schema_content).unwrap();
1225        let code = generate_schema_code(&schema);
1226        
1227        // Verify Protected policy
1228        assert!(code.contains("pub const token: TypedColumn<String, Protected>"));
1229    }
1230}
1231
1232
1233
1234#[cfg(test)]
1235mod migration_parser_tests {
1236    use super::*;
1237
1238    #[test]
1239    fn test_agent_contracts_migration_parses_all_columns() {
1240        let sql = r#"
1241CREATE TABLE agent_contracts (
1242    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
1243    agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
1244    operator_id UUID NOT NULL REFERENCES operators(id) ON DELETE CASCADE,
1245    pricing_model VARCHAR(20) NOT NULL CHECK (pricing_model IN ('commission', 'static_markup', 'net_rate')),
1246    commission_percent DECIMAL(5,2),
1247    static_markup DECIMAL(10,2),
1248    is_active BOOLEAN DEFAULT true,
1249    valid_from DATE,
1250    valid_until DATE,
1251    approved_by UUID REFERENCES users(id),
1252    created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
1253    updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
1254    UNIQUE(agent_id, operator_id)
1255);
1256"#;
1257
1258        let mut schema = Schema::default();
1259        schema.parse_sql_migration(sql);
1260        
1261        let table = schema.tables.get("agent_contracts")
1262            .expect("agent_contracts table should exist");
1263        
1264        for col in &["id", "agent_id", "operator_id", "pricing_model",
1265                      "commission_percent", "static_markup", "is_active",
1266                      "valid_from", "valid_until", "approved_by",
1267                      "created_at", "updated_at"] {
1268            assert!(
1269                table.columns.contains_key(*col),
1270                "Missing column: '{}'. Found: {:?}",
1271                col, table.columns.keys().collect::<Vec<_>>()
1272            );
1273        }
1274    }
1275
1276    /// Regression test: column names that START with SQL keywords must parse correctly.
1277    /// e.g., created_at starts with CREATE, primary_contact starts with PRIMARY, etc.
1278    #[test]
1279    fn test_keyword_prefixed_column_names_are_not_skipped() {
1280        let sql = r#"
1281CREATE TABLE edge_cases (
1282    id UUID PRIMARY KEY,
1283    created_at TIMESTAMPTZ NOT NULL,
1284    created_by UUID,
1285    primary_contact VARCHAR(255),
1286    check_status VARCHAR(20),
1287    unique_code VARCHAR(50),
1288    foreign_ref UUID,
1289    constraint_name VARCHAR(100),
1290    PRIMARY KEY (id),
1291    CHECK (check_status IN ('pending', 'active')),
1292    UNIQUE (unique_code),
1293    CONSTRAINT fk_ref FOREIGN KEY (foreign_ref) REFERENCES other(id)
1294);
1295"#;
1296
1297        let mut schema = Schema::default();
1298        schema.parse_sql_migration(sql);
1299        
1300        let table = schema.tables.get("edge_cases")
1301            .expect("edge_cases table should exist");
1302        
1303        // These column names start with SQL keywords — all must be found
1304        for col in &["created_at", "created_by", "primary_contact",
1305                      "check_status", "unique_code", "foreign_ref",
1306                      "constraint_name"] {
1307            assert!(
1308                table.columns.contains_key(*col),
1309                "Column '{}' should NOT be skipped just because it starts with a SQL keyword. Found: {:?}",
1310                col, table.columns.keys().collect::<Vec<_>>()
1311            );
1312        }
1313        
1314        // These are constraint keywords, not columns — must NOT appear
1315        // (PRIMARY KEY, CHECK, UNIQUE, CONSTRAINT lines should be skipped)
1316        assert!(!table.columns.contains_key("primary"),
1317            "Constraint keyword 'PRIMARY' should not be treated as a column");
1318    }
1319}