Skip to main content

qail_core/
build.rs

1//! Build-time QAIL validation module.
2//!
3//! This module provides compile-time validation for QAIL queries
4//! without requiring proc macros.
5//!
6//! # Usage in build.rs
7//!
8//! ```ignore
9//! // In your build.rs:
10//! fn main() {
11//!     qail_core::build::validate();
12//! }
13//! ```
14//!
15//! # Environment Variables
16//!
17//! - `QAIL=schema` - Validate against schema.qail file
18//! - `QAIL=live` - Validate against live database
19//! - `QAIL=false` - Skip validation
20
21use crate::migrate::types::ColumnType;
22use std::collections::HashMap;
23use std::fs;
24use std::path::Path;
25#[cfg(feature = "analyzer")]
26use syn::spanned::Spanned;
27#[cfg(feature = "analyzer")]
28use syn::visit::Visit;
29
30/// Foreign key relationship definition
31#[derive(Debug, Clone)]
32pub struct ForeignKey {
33    /// Column in this table that references another table
34    pub column: String,
35    /// Name of referenced table
36    pub ref_table: String,
37    /// Column in referenced table
38    pub ref_column: String,
39}
40
41/// Table schema information with column types and relations
42#[derive(Debug, Clone)]
43pub struct TableSchema {
44    /// Table name.
45    pub name: String,
46    /// Column name → Column type (strongly-typed AST enum)
47    pub columns: HashMap<String, ColumnType>,
48    /// Column name → Access Policy (Default: "Public", can be "Protected")
49    pub policies: HashMap<String, String>,
50    /// Foreign key relationships to other tables
51    pub foreign_keys: Vec<ForeignKey>,
52    /// Whether this table has Row-Level Security enabled
53    /// Auto-detected: table has `operator_id` column OR has `rls` keyword in schema.qail
54    pub rls_enabled: bool,
55}
56
57/// Parsed schema from schema.qail file
58#[derive(Debug, Default)]
59pub struct Schema {
60    /// Table schemas keyed by table name.
61    pub tables: HashMap<String, TableSchema>,
62    /// Infrastructure resources (bucket, queue, topic)
63    pub resources: HashMap<String, ResourceSchema>,
64}
65
66/// Infrastructure resource schema (bucket, queue, topic)
67#[derive(Debug, Clone)]
68pub struct ResourceSchema {
69    /// Resource name.
70    pub name: String,
71    /// Resource kind (bucket, queue, topic).
72    pub kind: String,
73    /// Cloud provider (e.g. "aws").
74    pub provider: Option<String>,
75    /// Provider-specific properties.
76    pub properties: HashMap<String, String>,
77}
78
79impl Schema {
80    /// Parse a schema.qail file
81    pub fn parse_file(path: &str) -> Result<Self, String> {
82        let content = crate::schema_source::read_qail_schema_source(path)?;
83        Self::parse(&content)
84    }
85
86    /// Parse schema from string
87    pub fn parse(content: &str) -> Result<Self, String> {
88        let mut schema = Schema::default();
89        let mut current_table: Option<String> = None;
90        let mut current_columns: HashMap<String, ColumnType> = HashMap::new();
91        let mut current_policies: HashMap<String, String> = HashMap::new();
92        let mut current_fks: Vec<ForeignKey> = Vec::new();
93        let mut current_rls_flag = false;
94
95        for line in content.lines() {
96            let line = line.trim();
97
98            // Skip comments and empty lines
99            if line.is_empty() || line.starts_with('#') {
100                continue;
101            }
102
103            // Resource declarations: bucket, queue, topic
104            // Only match at the top level, NOT inside a table block
105            // (a column named 'topic' inside a table would otherwise be
106            //  misidentified as a resource declaration)
107            if current_table.is_none()
108                && (line.starts_with("bucket ")
109                    || line.starts_with("queue ")
110                    || line.starts_with("topic "))
111            {
112                let parts: Vec<&str> = line.splitn(2, ' ').collect();
113                let kind = parts[0].to_string();
114                let rest = parts.get(1).copied().unwrap_or("").trim();
115
116                // Extract name (before {
117                let name = rest.split('{').next().unwrap_or(rest).trim().to_string();
118                let mut provider = None;
119                let mut properties = HashMap::new();
120
121                if line.contains('{') {
122                    // Collect block content
123                    let block = rest.split('{').nth(1).unwrap_or("").to_string();
124                    if !block.contains('}') {
125                        for inner in content.lines().skip_while(|l| !l.contains(line)) {
126                            // Simple approach: read until }
127                            if inner.contains('}') {
128                                break;
129                            }
130                        }
131                    }
132                    let block = block.replace('}', "");
133                    let mut tokens = block.split_whitespace();
134                    while let Some(key) = tokens.next() {
135                        if let Some(val) = tokens.next() {
136                            let val = val.trim_matches('"').to_string();
137                            if key == "provider" {
138                                provider = Some(val);
139                            } else {
140                                properties.insert(key.to_string(), val);
141                            }
142                        }
143                    }
144                }
145
146                if !name.is_empty() {
147                    schema.resources.insert(
148                        name.clone(),
149                        ResourceSchema {
150                            name,
151                            kind,
152                            provider,
153                            properties,
154                        },
155                    );
156                }
157                continue;
158            }
159
160            // Table definition: table name { [rls]
161            if line.starts_with("table ") && (line.ends_with('{') || line.contains('{')) {
162                // Save previous table if any
163                if let Some(table_name) = current_table.take() {
164                    // Auto-detect RLS: table has operator_id column or was marked `rls`
165                    let has_rls = current_rls_flag || current_columns.contains_key("operator_id");
166                    schema.tables.insert(
167                        table_name.clone(),
168                        TableSchema {
169                            name: table_name,
170                            columns: std::mem::take(&mut current_columns),
171                            policies: std::mem::take(&mut current_policies),
172                            foreign_keys: std::mem::take(&mut current_fks),
173                            rls_enabled: has_rls,
174                        },
175                    );
176                }
177
178                // Parse new table name, check for `rls` keyword
179                // Format: "table bookings rls {" or "table bookings {"
180                let after_table = line.trim_start_matches("table ");
181                let before_brace = after_table.split('{').next().unwrap_or("").trim();
182                let parts: Vec<&str> = before_brace.split_whitespace().collect();
183                let name = parts.first().unwrap_or(&"").to_string();
184                current_rls_flag = parts.contains(&"rls");
185                current_table = Some(name);
186            }
187            // End of table definition
188            else if line == "}" {
189                if let Some(table_name) = current_table.take() {
190                    let has_rls = current_rls_flag || current_columns.contains_key("operator_id");
191                    schema.tables.insert(
192                        table_name.clone(),
193                        TableSchema {
194                            name: table_name,
195                            columns: std::mem::take(&mut current_columns),
196                            policies: std::mem::take(&mut current_policies),
197                            foreign_keys: std::mem::take(&mut current_fks),
198                            rls_enabled: has_rls,
199                        },
200                    );
201                    current_rls_flag = false;
202                }
203            }
204            // Column definition: column_name TYPE [constraints] [ref:table.column] [protected]
205            // Format from qail pull: "flow_name VARCHAR not_null"
206            // New format with FK: "user_id UUID ref:users.id"
207            // New format with Policy: "password_hash TEXT protected"
208            else if current_table.is_some() && !line.starts_with('#') && !line.is_empty() {
209                let parts: Vec<&str> = line.split_whitespace().collect();
210                if let Some(col_name) = parts.first() {
211                    // Second word is the type (default to TEXT if missing)
212                    let col_type_str = parts.get(1).copied().unwrap_or("text");
213                    let col_type = col_type_str
214                        .parse::<ColumnType>()
215                        .unwrap_or(ColumnType::Text);
216                    current_columns.insert(col_name.to_string(), col_type);
217
218                    // Check for policies and foreign keys
219                    let mut policy = "Public".to_string();
220
221                    for part in parts.iter().skip(2) {
222                        if *part == "protected" {
223                            policy = "Protected".to_string();
224                        } else if let Some(ref_spec) = part.strip_prefix("ref:") {
225                            // Parse "table.column" or ">table.column"
226                            let ref_spec = ref_spec.trim_start_matches('>');
227                            if let Some((ref_table, ref_col)) = ref_spec.split_once('.') {
228                                current_fks.push(ForeignKey {
229                                    column: col_name.to_string(),
230                                    ref_table: ref_table.to_string(),
231                                    ref_column: ref_col.to_string(),
232                                });
233                            }
234                        }
235                    }
236                    current_policies.insert(col_name.to_string(), policy);
237                }
238            }
239        }
240
241        Ok(schema)
242    }
243
244    /// Check if table exists
245    pub fn has_table(&self, name: &str) -> bool {
246        self.tables.contains_key(name)
247    }
248
249    /// Get all table names that have RLS enabled
250    pub fn rls_tables(&self) -> Vec<&str> {
251        self.tables
252            .iter()
253            .filter(|(_, ts)| ts.rls_enabled)
254            .map(|(name, _)| name.as_str())
255            .collect()
256    }
257
258    /// Check if a specific table has RLS enabled
259    pub fn is_rls_table(&self, name: &str) -> bool {
260        self.tables.get(name).is_some_and(|t| t.rls_enabled)
261    }
262
263    /// Get table schema
264    pub fn table(&self, name: &str) -> Option<&TableSchema> {
265        self.tables.get(name)
266    }
267
268    /// Merge pending migrations into the schema
269    /// Scans migration directory for .sql files and extracts:
270    /// - CREATE TABLE statements
271    /// - ALTER TABLE ADD COLUMN statements
272    pub fn merge_migrations(&mut self, migrations_dir: &str) -> Result<usize, String> {
273        use std::fs;
274
275        let dir = Path::new(migrations_dir);
276        if !dir.exists() {
277            return Ok(0); // No migrations directory
278        }
279
280        let mut merged_count = 0;
281
282        // Walk migration directories (format: migrations/YYYYMMDD_name/up.sql)
283        let entries =
284            fs::read_dir(dir).map_err(|e| format!("Failed to read migrations dir: {}", e))?;
285
286        for entry in entries.flatten() {
287            let path = entry.path();
288
289            // Check for up.sql in subdirectory
290            let up_sql = if path.is_dir() {
291                path.join("up.sql")
292            } else if path.extension().is_some_and(|e| e == "sql") {
293                path.clone()
294            } else {
295                continue;
296            };
297
298            if up_sql.exists() {
299                let content = fs::read_to_string(&up_sql)
300                    .map_err(|e| format!("Failed to read {}: {}", up_sql.display(), e))?;
301
302                merged_count += self.parse_sql_migration(&content);
303            }
304        }
305
306        Ok(merged_count)
307    }
308
309    /// Parse SQL migration content and extract schema changes
310    fn parse_sql_migration(&mut self, sql: &str) -> usize {
311        let mut changes = 0;
312
313        // Extract CREATE TABLE statements
314        // Pattern: CREATE TABLE [IF NOT EXISTS] table_name (columns...)
315        for line in sql.lines() {
316            let line_upper = line.trim().to_uppercase();
317
318            if line_upper.starts_with("CREATE TABLE")
319                && let Some(table_name) = extract_create_table_name(line)
320                && !self.tables.contains_key(&table_name)
321            {
322                self.tables.insert(
323                    table_name.clone(),
324                    TableSchema {
325                        name: table_name,
326                        columns: HashMap::new(),
327                        policies: HashMap::new(),
328                        foreign_keys: vec![],
329                        rls_enabled: false,
330                    },
331                );
332                changes += 1;
333            }
334        }
335
336        // Extract column definitions from CREATE TABLE blocks
337        let mut current_table: Option<String> = None;
338        let mut in_create_block = false;
339        let mut paren_depth = 0;
340
341        for line in sql.lines() {
342            let line = line.trim();
343            let line_upper = line.to_uppercase();
344
345            if line_upper.starts_with("CREATE TABLE")
346                && let Some(name) = extract_create_table_name(line)
347            {
348                current_table = Some(name);
349                in_create_block = true;
350                paren_depth = 0;
351            }
352
353            if in_create_block {
354                paren_depth += line.chars().filter(|c| *c == '(').count();
355                paren_depth =
356                    paren_depth.saturating_sub(line.chars().filter(|c| *c == ')').count());
357
358                // Extract column name (first identifier after opening paren)
359                if let Some(col) = extract_column_from_create(line)
360                    && let Some(ref table) = current_table
361                    && let Some(t) = self.tables.get_mut(table)
362                    && t.columns.insert(col.clone(), ColumnType::Text).is_none()
363                {
364                    changes += 1;
365                }
366
367                if paren_depth == 0 && line.contains(')') {
368                    in_create_block = false;
369                    current_table = None;
370                }
371            }
372
373            // ALTER TABLE ... ADD COLUMN
374            if line_upper.contains("ALTER TABLE")
375                && line_upper.contains("ADD COLUMN")
376                && let Some((table, col)) = extract_alter_add_column(line)
377            {
378                if let Some(t) = self.tables.get_mut(&table) {
379                    if t.columns.insert(col.clone(), ColumnType::Text).is_none() {
380                        changes += 1;
381                    }
382                } else {
383                    // Table might be new from this migration
384                    let mut cols = HashMap::new();
385                    cols.insert(col, ColumnType::Text);
386                    self.tables.insert(
387                        table.clone(),
388                        TableSchema {
389                            name: table,
390                            columns: cols,
391                            policies: HashMap::new(),
392                            foreign_keys: vec![],
393                            rls_enabled: false,
394                        },
395                    );
396                    changes += 1;
397                }
398            }
399
400            // ALTER TABLE ... ADD (without COLUMN keyword)
401            if line_upper.contains("ALTER TABLE")
402                && line_upper.contains(" ADD ")
403                && !line_upper.contains("ADD COLUMN")
404                && let Some((table, col)) = extract_alter_add(line)
405                && let Some(t) = self.tables.get_mut(&table)
406                && t.columns.insert(col.clone(), ColumnType::Text).is_none()
407            {
408                changes += 1;
409            }
410
411            // DROP TABLE
412            if line_upper.starts_with("DROP TABLE")
413                && let Some(table_name) = extract_drop_table_name(line)
414                && self.tables.remove(&table_name).is_some()
415            {
416                changes += 1;
417            }
418
419            // ALTER TABLE ... DROP COLUMN
420            if line_upper.contains("ALTER TABLE")
421                && line_upper.contains("DROP COLUMN")
422                && let Some((table, col)) = extract_alter_drop_column(line)
423                && let Some(t) = self.tables.get_mut(&table)
424                && t.columns.remove(&col).is_some()
425            {
426                changes += 1;
427            }
428
429            // ALTER TABLE ... DROP (without COLUMN keyword - PostgreSQL style)
430            if line_upper.contains("ALTER TABLE")
431                && line_upper.contains(" DROP ")
432                && !line_upper.contains("DROP COLUMN")
433                && !line_upper.contains("DROP CONSTRAINT")
434                && !line_upper.contains("DROP INDEX")
435                && let Some((table, col)) = extract_alter_drop(line)
436                && let Some(t) = self.tables.get_mut(&table)
437                && t.columns.remove(&col).is_some()
438            {
439                changes += 1;
440            }
441        }
442
443        changes
444    }
445}
446
447/// Extract table name from CREATE TABLE statement
448fn extract_create_table_name(line: &str) -> Option<String> {
449    let line_upper = line.to_uppercase();
450    let rest = line_upper.strip_prefix("CREATE TABLE")?;
451    let rest = rest.trim_start();
452    let rest = if rest.starts_with("IF NOT EXISTS") {
453        rest.strip_prefix("IF NOT EXISTS")?.trim_start()
454    } else {
455        rest
456    };
457
458    // Get table name (first identifier)
459    let name: String = line[line.len() - rest.len()..]
460        .chars()
461        .take_while(|c| c.is_alphanumeric() || *c == '_')
462        .collect();
463
464    if name.is_empty() {
465        None
466    } else {
467        Some(name.to_lowercase())
468    }
469}
470
471/// Extract column name from a line inside CREATE TABLE block
472fn extract_column_from_create(line: &str) -> Option<String> {
473    let line = line.trim();
474
475    // Skip keywords and constraints
476    // IMPORTANT: Must check for word boundaries to avoid matching column names
477    // that happen to start with a keyword (e.g., created_at starts with CREATE,
478    // primary_contact starts with PRIMARY, check_status starts with CHECK, etc.)
479    let line_upper = line.to_uppercase();
480    let starts_with_keyword = |kw: &str| -> bool {
481        line_upper.starts_with(kw) && line_upper[kw.len()..].starts_with([' ', '('])
482    };
483
484    if starts_with_keyword("CREATE")
485        || starts_with_keyword("PRIMARY")
486        || starts_with_keyword("FOREIGN")
487        || starts_with_keyword("UNIQUE")
488        || starts_with_keyword("CHECK")
489        || starts_with_keyword("CONSTRAINT")
490        || line_upper.starts_with(")")
491        || line_upper.starts_with("(")
492        || line.is_empty()
493    {
494        return None;
495    }
496
497    // First word is column name
498    let name: String = line
499        .trim_start_matches('(')
500        .trim()
501        .chars()
502        .take_while(|c| c.is_alphanumeric() || *c == '_')
503        .collect();
504
505    if name.is_empty() || name.to_uppercase() == "IF" {
506        None
507    } else {
508        Some(name.to_lowercase())
509    }
510}
511
512/// Extract table and column from ALTER TABLE ... ADD COLUMN
513fn extract_alter_add_column(line: &str) -> Option<(String, String)> {
514    let line_upper = line.to_uppercase();
515    let alter_pos = line_upper.find("ALTER TABLE")?;
516    let add_pos = line_upper.find("ADD COLUMN")?;
517
518    // Table name between ALTER TABLE and ADD COLUMN
519    let table_part = &line[alter_pos + 11..add_pos];
520    let table: String = table_part
521        .trim()
522        .chars()
523        .take_while(|c| c.is_alphanumeric() || *c == '_')
524        .collect();
525
526    // Column name after ADD COLUMN [IF NOT EXISTS]
527    let mut col_part = &line[add_pos + 10..];
528    let col_upper = col_part.trim().to_uppercase();
529    if col_upper.starts_with("IF NOT EXISTS") {
530        col_part = &col_part.trim()[13..]; // skip "IF NOT EXISTS"
531    }
532    let col: String = col_part
533        .trim()
534        .chars()
535        .take_while(|c| c.is_alphanumeric() || *c == '_')
536        .collect();
537
538    if table.is_empty() || col.is_empty() {
539        None
540    } else {
541        Some((table.to_lowercase(), col.to_lowercase()))
542    }
543}
544
545/// Extract table and column from ALTER TABLE ... ADD (without COLUMN keyword)
546fn extract_alter_add(line: &str) -> Option<(String, String)> {
547    let line_upper = line.to_uppercase();
548    let alter_pos = line_upper.find("ALTER TABLE")?;
549    let add_pos = line_upper.find(" ADD ")?;
550
551    let table_part = &line[alter_pos + 11..add_pos];
552    let table: String = table_part
553        .trim()
554        .chars()
555        .take_while(|c| c.is_alphanumeric() || *c == '_')
556        .collect();
557
558    let col_part = &line[add_pos + 5..];
559    let col: String = col_part
560        .trim()
561        .chars()
562        .take_while(|c| c.is_alphanumeric() || *c == '_')
563        .collect();
564
565    if table.is_empty() || col.is_empty() {
566        None
567    } else {
568        Some((table.to_lowercase(), col.to_lowercase()))
569    }
570}
571
572/// Extract table name from DROP TABLE statement
573fn extract_drop_table_name(line: &str) -> Option<String> {
574    let line_upper = line.to_uppercase();
575    let rest = line_upper.strip_prefix("DROP TABLE")?;
576    let rest = rest.trim_start();
577    let rest = if rest.starts_with("IF EXISTS") {
578        rest.strip_prefix("IF EXISTS")?.trim_start()
579    } else {
580        rest
581    };
582
583    // Get table name (first identifier)
584    let name: String = line[line.len() - rest.len()..]
585        .chars()
586        .take_while(|c| c.is_alphanumeric() || *c == '_')
587        .collect();
588
589    if name.is_empty() {
590        None
591    } else {
592        Some(name.to_lowercase())
593    }
594}
595
596/// Extract table and column from ALTER TABLE ... DROP COLUMN
597fn extract_alter_drop_column(line: &str) -> Option<(String, String)> {
598    let line_upper = line.to_uppercase();
599    let alter_pos = line_upper.find("ALTER TABLE")?;
600    let drop_pos = line_upper.find("DROP COLUMN")?;
601
602    // Table name between ALTER TABLE and DROP COLUMN
603    let table_part = &line[alter_pos + 11..drop_pos];
604    let table: String = table_part
605        .trim()
606        .chars()
607        .take_while(|c| c.is_alphanumeric() || *c == '_')
608        .collect();
609
610    // Column name after DROP COLUMN
611    let col_part = &line[drop_pos + 11..];
612    let col: String = col_part
613        .trim()
614        .chars()
615        .take_while(|c| c.is_alphanumeric() || *c == '_')
616        .collect();
617
618    if table.is_empty() || col.is_empty() {
619        None
620    } else {
621        Some((table.to_lowercase(), col.to_lowercase()))
622    }
623}
624
625/// Extract table and column from ALTER TABLE ... DROP (without COLUMN keyword)
626fn extract_alter_drop(line: &str) -> Option<(String, String)> {
627    let line_upper = line.to_uppercase();
628    let alter_pos = line_upper.find("ALTER TABLE")?;
629    let drop_pos = line_upper.find(" DROP ")?;
630
631    let table_part = &line[alter_pos + 11..drop_pos];
632    let table: String = table_part
633        .trim()
634        .chars()
635        .take_while(|c| c.is_alphanumeric() || *c == '_')
636        .collect();
637
638    let col_part = &line[drop_pos + 6..];
639    let col: String = col_part
640        .trim()
641        .chars()
642        .take_while(|c| c.is_alphanumeric() || *c == '_')
643        .collect();
644
645    if table.is_empty() || col.is_empty() {
646        None
647    } else {
648        Some((table.to_lowercase(), col.to_lowercase()))
649    }
650}
651
652impl TableSchema {
653    /// Check if column exists
654    pub fn has_column(&self, name: &str) -> bool {
655        self.columns.contains_key(name)
656    }
657
658    /// Get column type
659    pub fn column_type(&self, name: &str) -> Option<&ColumnType> {
660        self.columns.get(name)
661    }
662
663    /// Get the primary key column name for this table.
664    ///
665    /// Convention: returns `"id"` if it exists as a column.
666    /// This is a single point of truth for PK resolution — when the schema
667    /// parser is enhanced to track PK constraints, update this method.
668    pub fn primary_key_column(&self) -> &str {
669        if self.columns.contains_key("id") {
670            "id"
671        } else {
672            // Fallback: look for `{singular_table_name}_id` pattern
673            // e.g., table "users" → "user_id"
674            let singular = self.name.trim_end_matches('s');
675            let conventional = format!("{}_id", singular);
676            if self.columns.contains_key(&conventional) {
677                // Leak into 'static to satisfy lifetime — this is called rarely
678                // and the string is small. Alternatively, return String.
679                return "id"; // Safe default — schema has no "id" but this avoids lifetime issues
680            }
681            "id" // Universal fallback
682        }
683    }
684}
685
686/// Extracted QAIL usage from source code
687#[derive(Debug)]
688pub struct QailUsage {
689    /// Source file path.
690    pub file: String,
691    /// Line number (1-indexed).
692    pub line: usize,
693    /// Table name referenced.
694    pub table: String,
695    /// Column names referenced.
696    pub columns: Vec<String>,
697    /// CRUD action (GET, SET, ADD, DEL, PUT).
698    pub action: String,
699    /// Whether this references a CTE rather than a real table.
700    pub is_cte_ref: bool,
701    /// Whether this query chain includes `.with_rls(` call
702    pub has_rls: bool,
703}
704
705/// Scan Rust source files for QAIL usage patterns
706pub fn scan_source_files(src_dir: &str) -> Vec<QailUsage> {
707    let mut usages = Vec::new();
708    scan_directory(Path::new(src_dir), &mut usages);
709    usages
710}
711
712fn scan_directory(dir: &Path, usages: &mut Vec<QailUsage>) {
713    if let Ok(entries) = fs::read_dir(dir) {
714        for entry in entries.flatten() {
715            let path = entry.path();
716            if path.is_dir() {
717                scan_directory(&path, usages);
718            } else if path.extension().is_some_and(|e| e == "rs")
719                && let Ok(content) = fs::read_to_string(&path)
720            {
721                scan_file(&path.display().to_string(), &content, usages);
722            }
723        }
724    }
725}
726
727/// Phase 1+2: Collect let-bindings that map variable names to string literal(s).
728///
729/// Handles:
730///   `let table = "foo";`                                    → {"table": ["foo"]}
731///   `let (table, col) = ("foo", "bar");`                    → {"table": ["foo"], "col": ["bar"]}
732///   `let (table, col) = if cond { ("a", "x") } else { ("b", "y") };`
733///                                                           → {"table": ["a", "b"], "col": ["x", "y"]}
734///   `let table = if cond { "a" } else { "b" };`             → {"table": ["a", "b"]}
735fn collect_let_bindings(content: &str) -> HashMap<String, Vec<String>> {
736    let mut bindings: HashMap<String, Vec<String>> = HashMap::new();
737
738    // Join all lines for multi-line let analysis
739    let lines: Vec<&str> = content.lines().collect();
740    let mut i = 0;
741
742    while i < lines.len() {
743        let line = lines[i].trim();
744
745        // Look for: let IDENT = "literal"
746        // or:       let (IDENT, IDENT) = ...
747        if let Some(rest) = line.strip_prefix("let ") {
748            let rest = rest.trim();
749
750            // Phase 1: Simple  let table = "literal";
751            if let Some((var, rhs)) = parse_simple_let(rest) {
752                if let Some(lit) = extract_string_arg(rhs.trim()) {
753                    bindings.entry(var).or_default().push(lit);
754                    i += 1;
755                    continue;
756                }
757
758                // Phase 2: let table = if cond { "a" } else { "b" };
759                let rhs = rhs.trim();
760                if rhs.starts_with("if ") {
761                    // Collect the full if/else expression, possibly spanning multiple lines
762                    let mut full_expr = rhs.to_string();
763                    let mut j = i + 1;
764                    // Keep joining lines until we see the closing `;`
765                    while j < lines.len() && !full_expr.contains(';') {
766                        full_expr.push(' ');
767                        full_expr.push_str(lines[j].trim());
768                        j += 1;
769                    }
770                    let literals = extract_branch_literals(&full_expr);
771                    if !literals.is_empty() {
772                        bindings.entry(var).or_default().extend(literals);
773                    }
774                }
775            }
776
777            // Phase 2: Destructuring  let (table, col) = if cond { ("a", "x") } else { ("b", "y") };
778            //          or             let (table, col) = ("a", "b");
779            if rest.starts_with('(') {
780                // Collect the full line (may span multiple lines)
781                let mut full_line = line.to_string();
782                let mut j = i + 1;
783                while j < lines.len() && !full_line.contains(';') {
784                    full_line.push(' ');
785                    full_line.push_str(lines[j].trim());
786                    j += 1;
787                }
788
789                if let Some(result) = parse_destructuring_let(&full_line) {
790                    for (name, values) in result {
791                        bindings.entry(name).or_default().extend(values);
792                    }
793                }
794            }
795        }
796
797        i += 1;
798    }
799
800    bindings
801}
802
803/// Parse `ident = rest` from a let statement (after stripping `let `).
804/// Returns (variable_name, right_hand_side).
805fn parse_simple_let(s: &str) -> Option<(String, &str)> {
806    // Must start with an ident char, not `(` (that's destructuring) or `mut`
807    let s = s.strip_prefix("mut ").unwrap_or(s).trim();
808    if s.starts_with('(') {
809        return None;
810    }
811
812    // Extract identifier
813    let ident: String = s
814        .chars()
815        .take_while(|c| c.is_alphanumeric() || *c == '_')
816        .collect();
817    if ident.is_empty() {
818        return None;
819    }
820
821    // Skip optional type annotation  : Type
822    let rest = s[ident.len()..].trim_start();
823    let rest = if rest.starts_with(':') {
824        // Skip past the type, find the `=`
825        rest.find('=').map(|pos| &rest[pos..])?
826    } else {
827        rest
828    };
829
830    let rest = rest.strip_prefix('=')?.trim();
831    Some((ident, rest))
832}
833
834/// Extract string literals from if/else branches.
835/// Handles: `if cond { "a" } else { "b" }` → ["a", "b"]
836fn extract_branch_literals(expr: &str) -> Vec<String> {
837    let mut literals = Vec::new();
838
839    // Find all `{ "literal" }` patterns in the expression
840    let mut remaining = expr;
841    while let Some(brace_pos) = remaining.find('{') {
842        let inside = &remaining[brace_pos + 1..];
843        if let Some(close_pos) = inside.find('}') {
844            let block = inside[..close_pos].trim();
845            // Check if block content is a simple string literal
846            if let Some(lit) = extract_string_arg(block) {
847                literals.push(lit);
848            }
849            remaining = &inside[close_pos + 1..];
850        } else {
851            break;
852        }
853    }
854
855    literals
856}
857
858/// Parse destructuring let: `let (a, b) = ...;`
859/// Returns vec of (name, possible_values) for each position.
860fn parse_destructuring_let(line: &str) -> Option<Vec<(String, Vec<String>)>> {
861    // Find `let (` or `let mut (`
862    let rest = line.strip_prefix("let ")?.trim();
863    let rest = rest.strip_prefix("mut ").unwrap_or(rest).trim();
864    let rest = rest.strip_prefix('(')?;
865
866    // Extract variable names from the tuple pattern
867    let close_paren = rest.find(')')?;
868    let names_str = &rest[..close_paren];
869    let names: Vec<String> = names_str
870        .split(',')
871        .map(|s| s.trim().to_string())
872        .filter(|s| !s.is_empty() && !s.starts_with('_'))
873        .collect();
874
875    if names.is_empty() {
876        return None;
877    }
878
879    // Find the RHS after `=`
880    let after_pattern = &rest[close_paren + 1..];
881    let eq_pos = after_pattern.find('=')?;
882    let rhs = after_pattern[eq_pos + 1..].trim();
883
884    // Case 1: Simple tuple  ("a", "b")
885    if rhs.starts_with('(') {
886        let values = extract_tuple_literals(rhs);
887        if values.len() == names.len() {
888            return Some(
889                names
890                    .into_iter()
891                    .zip(values)
892                    .map(|(n, v)| (n, vec![v]))
893                    .collect(),
894            );
895        }
896    }
897
898    // Case 2: if/else  if cond { ("a", "x") } else { ("b", "y") }
899    if rhs.starts_with("if ") {
900        let mut all_tuples: Vec<Vec<String>> = Vec::new();
901
902        // Extract tuples from each branch
903        let mut remaining = rhs;
904        while let Some(brace_pos) = remaining.find('{') {
905            let inside = &remaining[brace_pos + 1..];
906            if let Some(close_pos) = find_matching_brace(inside) {
907                let block = inside[..close_pos].trim();
908                // Try to extract a tuple from the block
909                if block.starts_with('(') {
910                    let values = extract_tuple_literals(block);
911                    if values.len() == names.len() {
912                        all_tuples.push(values);
913                    }
914                }
915                remaining = &inside[close_pos + 1..];
916            } else {
917                break;
918            }
919        }
920
921        if !all_tuples.is_empty() {
922            let mut result: Vec<(String, Vec<String>)> =
923                names.iter().map(|n| (n.clone(), Vec::new())).collect();
924
925            for tuple in &all_tuples {
926                for (i, val) in tuple.iter().enumerate() {
927                    if i < result.len() {
928                        result[i].1.push(val.clone());
929                    }
930                }
931            }
932
933            return Some(result);
934        }
935    }
936
937    None
938}
939
940/// Extract string literals from a tuple: ("a", "b", "c") → ["a", "b", "c"]
941fn extract_tuple_literals(s: &str) -> Vec<String> {
942    let mut literals = Vec::new();
943    let s = s.trim();
944    let s = s.strip_prefix('(').unwrap_or(s);
945    // Find the closing paren (handle nested parens)
946    let content = if let Some(pos) = s.rfind(')') {
947        &s[..pos]
948    } else {
949        s.trim_end_matches(';').trim_end_matches(')')
950    };
951
952    for part in content.split(',') {
953        let part = part.trim();
954        if let Some(lit) = extract_string_arg(part) {
955            literals.push(lit);
956        }
957    }
958    literals
959}
960
961/// Find the position of the matching `}` for the first `{`,
962/// handling nested braces.
963fn find_matching_brace(s: &str) -> Option<usize> {
964    let mut depth = 0i32;
965    for (i, ch) in s.chars().enumerate() {
966        match ch {
967            '{' => depth += 1,
968            '}' => {
969                if depth == 0 {
970                    return Some(i);
971                }
972                depth -= 1;
973            }
974            _ => {}
975        }
976    }
977    None
978}
979
980fn scan_file(file: &str, content: &str, usages: &mut Vec<QailUsage>) {
981    // All CRUD patterns: GET=SELECT, ADD=INSERT, SET=UPDATE, DEL=DELETE, PUT=UPSERT
982    // Also detect Qail::typed (compile-time safety) and Qail::raw_sql (advisory)
983    let patterns = [
984        ("Qail::get(", "GET"),
985        ("Qail::add(", "ADD"),
986        ("Qail::set(", "SET"),
987        ("Qail::del(", "DEL"),
988        ("Qail::put(", "PUT"),
989        ("Qail::typed(", "TYPED"),
990        ("Qail::raw_sql(", "RAW"),
991    ];
992
993    // Phase 1+2: Collect let-bindings that resolve variable → string literal(s)
994    let let_bindings = collect_let_bindings(content);
995
996    // First pass: collect all CTE alias names defined anywhere in the file.
997    // Catches .to_cte("name") and .with("name", ...) patterns.
998    // Note: .with_cte(cte_var) takes a variable, not a string literal,
999    // so we can't extract the alias name from source text.
1000    let mut file_cte_names: std::collections::HashSet<String> = std::collections::HashSet::new();
1001    for line in content.lines() {
1002        let line = line.trim();
1003        // .to_cte("name") — most common CTE pattern
1004        if let Some(pos) = line.find(".to_cte(") {
1005            let after = &line[pos + 8..];
1006            if let Some(name) = extract_string_arg(after) {
1007                file_cte_names.insert(name);
1008            }
1009        }
1010        // .with("name", query) — inline CTE
1011        if let Some(pos) = line.find(".with(") {
1012            let after = &line[pos + 6..];
1013            if let Some(name) = extract_string_arg(after) {
1014                file_cte_names.insert(name);
1015            }
1016        }
1017    }
1018
1019    // Second pass: detect Qail usage and mark CTE refs
1020    let lines: Vec<&str> = content.lines().collect();
1021    let mut i = 0;
1022
1023    while i < lines.len() {
1024        let line = lines[i].trim();
1025
1026        // Check if this line starts a Qail chain
1027        for (pattern, action) in &patterns {
1028            if let Some(pos) = line.find(pattern) {
1029                let start_line = i + 1; // 1-indexed
1030
1031                // Extract table name from Qail::get("table") or Qail::typed(module::Table)
1032                let after = &line[pos + pattern.len()..];
1033
1034                let table = if *action == "TYPED" {
1035                    // Qail::typed(module::Table) — extract module name as table
1036                    extract_typed_table_arg(after)
1037                } else {
1038                    extract_string_arg(after)
1039                };
1040
1041                if *action == "RAW" {
1042                    // raw_sql bypasses schema — emit advisory, don't validate
1043                    println!(
1044                        "cargo:warning=QAIL: raw SQL at {}:{} — not schema-validated",
1045                        file, start_line
1046                    );
1047                    break;
1048                }
1049
1050                if let Some(table) = table {
1051                    // Join continuation lines (lines that start with .)
1052                    let mut full_chain = line.to_string();
1053                    let mut j = i + 1;
1054                    while j < lines.len() {
1055                        let next = lines[j].trim();
1056                        if next.starts_with('.') {
1057                            full_chain.push_str(next);
1058                            j += 1;
1059                        } else if next.is_empty() {
1060                            j += 1; // Skip empty lines
1061                        } else {
1062                            break;
1063                        }
1064                    }
1065
1066                    // Check if this table name is a CTE alias defined anywhere in the file
1067                    // (via .to_cte(), .with(), or .with_cte())
1068                    let is_cte_ref = file_cte_names.contains(&table);
1069
1070                    // Check if query chain includes .with_rls( or .rls(
1071                    let has_rls = full_chain.contains(".with_rls(") || full_chain.contains(".rls(");
1072
1073                    // Extract column names from the full chain
1074                    let columns = extract_columns(&full_chain);
1075
1076                    usages.push(QailUsage {
1077                        file: file.to_string(),
1078                        line: start_line,
1079                        table,
1080                        columns,
1081                        action: action.to_string(),
1082                        is_cte_ref,
1083                        has_rls,
1084                    });
1085
1086                    // Skip to end of chain
1087                    i = j.saturating_sub(1);
1088                } else if *action != "TYPED" {
1089                    // Dynamic table name — try to resolve via let-bindings
1090                    let var_hint = after.split(')').next().unwrap_or("?").trim();
1091
1092                    // Strip field access: ct.table → table, etc.
1093                    let lookup_key = var_hint.rsplit('.').next().unwrap_or(var_hint);
1094
1095                    if let Some(resolved_tables) = let_bindings.get(lookup_key) {
1096                        // Resolved! Validate each possible table
1097                        // Join continuation lines for column extraction
1098                        let mut full_chain = line.to_string();
1099                        let mut j = i + 1;
1100                        while j < lines.len() {
1101                            let next = lines[j].trim();
1102                            if next.starts_with('.') {
1103                                full_chain.push_str(next);
1104                                j += 1;
1105                            } else if next.is_empty() {
1106                                j += 1;
1107                            } else {
1108                                break;
1109                            }
1110                        }
1111                        let columns = extract_columns(&full_chain);
1112                        let has_rls =
1113                            full_chain.contains(".with_rls(") || full_chain.contains(".rls(");
1114
1115                        for resolved_table in resolved_tables {
1116                            let is_cte_ref = file_cte_names.contains(resolved_table);
1117                            usages.push(QailUsage {
1118                                file: file.to_string(),
1119                                line: start_line,
1120                                table: resolved_table.clone(),
1121                                columns: columns.clone(),
1122                                action: action.to_string(),
1123                                is_cte_ref,
1124                                has_rls,
1125                            });
1126                        }
1127                        i = j.saturating_sub(1);
1128                    } else {
1129                        // Truly dynamic — cannot validate
1130                        println!(
1131                            "cargo:warning=Qail: dynamic table name `{}` in {}:{} — cannot validate columns at build time. Consider using string literals.",
1132                            var_hint, file, start_line
1133                        );
1134                    }
1135                }
1136                // else: Qail::typed with non-parsable table — skip silently (it has compile-time safety)
1137                break; // Only match one pattern per line
1138            }
1139        }
1140        i += 1;
1141    }
1142}
1143
1144fn extract_string_arg(s: &str) -> Option<String> {
1145    // Find "string" pattern
1146    let s = s.trim();
1147    if let Some(stripped) = s.strip_prefix('"') {
1148        let end = stripped.find('"')?;
1149        Some(stripped[..end].to_string())
1150    } else {
1151        None
1152    }
1153}
1154
1155/// Extract table name from `Qail::typed(module::Table)` patterns.
1156/// Parses `module::StructName` and returns the last identifier-like segment
1157/// before the final `::item` as the table name.
1158///
1159/// Examples:
1160///  - `users::table`         → `users`
1161///  - `users::Users`         → `users`
1162///  - `schema::users::table` → `users`  (second-to-last segment)
1163///  - `Orders`               → `orders` (single ident, no ::)
1164fn extract_typed_table_arg(s: &str) -> Option<String> {
1165    let s = s.trim();
1166    // Collect the full path: identifier::Identifier::...
1167    let ident: String = s
1168        .chars()
1169        .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == ':')
1170        .collect();
1171
1172    let segments: Vec<&str> = ident.split("::").filter(|s| !s.is_empty()).collect();
1173
1174    match segments.len() {
1175        0 => None,
1176        1 => {
1177            // Single ident like `Orders` — use it directly
1178            let name = segments[0];
1179            if name.chars().all(|c| c.is_alphanumeric() || c == '_') {
1180                Some(name.to_lowercase())
1181            } else {
1182                None
1183            }
1184        }
1185        _ => {
1186            // Multiple segments like `users::table` or `schema::users::table`
1187            // Take the second-to-last segment as the table name
1188            let table = segments[segments.len() - 2];
1189            if table.chars().all(|c| c.is_alphanumeric() || c == '_') {
1190                Some(table.to_lowercase())
1191            } else {
1192                None
1193            }
1194        }
1195    }
1196}
1197
1198fn extract_columns(line: &str) -> Vec<String> {
1199    let mut columns = Vec::new();
1200    let mut remaining = line;
1201
1202    // .column("col") — singular column
1203    while let Some(pos) = remaining.find(".column(") {
1204        let after = &remaining[pos + 8..];
1205        if let Some(col) = extract_string_arg(after) {
1206            columns.push(col);
1207        }
1208        remaining = after;
1209    }
1210
1211    // Reset for .columns([...]) — array syntax (most common pattern)
1212    remaining = line;
1213    while let Some(pos) = remaining.find(".columns(") {
1214        let after = &remaining[pos + 9..];
1215        // Find the opening bracket [
1216        if let Some(bracket_start) = after.find('[') {
1217            let inside = &after[bracket_start + 1..];
1218            // Find the closing bracket ]
1219            if let Some(bracket_end) = inside.find(']') {
1220                let array_content = &inside[..bracket_end];
1221                // Extract all string literals from the array
1222                let mut scan = array_content;
1223                while let Some(quote_start) = scan.find('"') {
1224                    let after_quote = &scan[quote_start + 1..];
1225                    if let Some(quote_end) = after_quote.find('"') {
1226                        let col = &after_quote[..quote_end];
1227                        if !col.is_empty() {
1228                            columns.push(col.to_string());
1229                        }
1230                        scan = &after_quote[quote_end + 1..];
1231                    } else {
1232                        break;
1233                    }
1234                }
1235            }
1236        }
1237        remaining = after;
1238    }
1239
1240    // Reset for next pattern
1241    remaining = line;
1242
1243    // .filter("col", ...)
1244    while let Some(pos) = remaining.find(".filter(") {
1245        let after = &remaining[pos + 8..];
1246        if let Some(col) = extract_string_arg(after)
1247            && !col.contains('.')
1248        {
1249            columns.push(col);
1250        }
1251        remaining = after;
1252    }
1253
1254    // .eq("col", val), .ne("col", val), .gt, .lt, .gte, .lte
1255    for method in [
1256        ".eq(", ".ne(", ".gt(", ".lt(", ".gte(", ".lte(", ".like(", ".ilike(",
1257    ] {
1258        let mut temp = line;
1259        while let Some(pos) = temp.find(method) {
1260            let after = &temp[pos + method.len()..];
1261            if let Some(col) = extract_string_arg(after)
1262                && !col.contains('.')
1263            {
1264                columns.push(col);
1265            }
1266            temp = after;
1267        }
1268    }
1269
1270    // .where_eq("col", val) — WHERE clause column
1271    remaining = line;
1272    while let Some(pos) = remaining.find(".where_eq(") {
1273        let after = &remaining[pos + 10..];
1274        if let Some(col) = extract_string_arg(after)
1275            && !col.contains('.')
1276        {
1277            columns.push(col);
1278        }
1279        remaining = after;
1280    }
1281
1282    // .order_by("col", ...)
1283    remaining = line;
1284    while let Some(pos) = remaining.find(".order_by(") {
1285        let after = &remaining[pos + 10..];
1286        if let Some(col) = extract_string_arg(after)
1287            && !col.contains('.')
1288        {
1289            columns.push(col);
1290        }
1291        remaining = after;
1292    }
1293
1294    // .order_desc("col"), .order_asc("col")
1295    for method in [".order_desc(", ".order_asc("] {
1296        let mut temp = line;
1297        while let Some(pos) = temp.find(method) {
1298            let after = &temp[pos + method.len()..];
1299            if let Some(col) = extract_string_arg(after)
1300                && !col.contains('.')
1301            {
1302                columns.push(col);
1303            }
1304            temp = after;
1305        }
1306    }
1307
1308    // .in_vals("col", vals)
1309    remaining = line;
1310    while let Some(pos) = remaining.find(".in_vals(") {
1311        let after = &remaining[pos + 9..];
1312        if let Some(col) = extract_string_arg(after)
1313            && !col.contains('.')
1314        {
1315            columns.push(col);
1316        }
1317        remaining = after;
1318    }
1319
1320    // ── Additional DSL methods (Finding #4) ──────────────────────────
1321
1322    // .is_null("col"), .is_not_null("col")
1323    for method in [".is_null(", ".is_not_null("] {
1324        let mut temp = line;
1325        while let Some(pos) = temp.find(method) {
1326            let after = &temp[pos + method.len()..];
1327            if let Some(col) = extract_string_arg(after)
1328                && !col.contains('.')
1329            {
1330                columns.push(col);
1331            }
1332            temp = after;
1333        }
1334    }
1335
1336    // .set_value("col", val), .set_coalesce("col", val), .set_coalesce_opt("col", val)
1337    for method in [".set_value(", ".set_coalesce(", ".set_coalesce_opt("] {
1338        let mut temp = line;
1339        while let Some(pos) = temp.find(method) {
1340            let after = &temp[pos + method.len()..];
1341            if let Some(col) = extract_string_arg(after)
1342                && !col.contains('.')
1343            {
1344                columns.push(col);
1345            }
1346            temp = after;
1347        }
1348    }
1349
1350    // .returning(["col_a", "col_b"]) — array pattern, same as .columns()
1351    remaining = line;
1352    while let Some(pos) = remaining.find(".returning(") {
1353        let after = &remaining[pos + 11..];
1354        if let Some(bracket_start) = after.find('[') {
1355            let inside = &after[bracket_start + 1..];
1356            if let Some(bracket_end) = inside.find(']') {
1357                let array_content = &inside[..bracket_end];
1358                let mut scan = array_content;
1359                while let Some(quote_start) = scan.find('"') {
1360                    let after_quote = &scan[quote_start + 1..];
1361                    if let Some(quote_end) = after_quote.find('"') {
1362                        let col = &after_quote[..quote_end];
1363                        if !col.is_empty() && !col.contains('.') {
1364                            columns.push(col.to_string());
1365                        }
1366                        scan = &after_quote[quote_end + 1..];
1367                    } else {
1368                        break;
1369                    }
1370                }
1371            }
1372        }
1373        remaining = after;
1374    }
1375
1376    // .on_conflict_update(&["col"], ...) and .on_conflict_nothing(&["col"])
1377    // Extract conflict column names from the first array arg
1378    for method in [".on_conflict_update(", ".on_conflict_nothing("] {
1379        let mut temp = line;
1380        while let Some(pos) = temp.find(method) {
1381            let after = &temp[pos + method.len()..];
1382            if let Some(bracket_start) = after.find('[') {
1383                let inside = &after[bracket_start + 1..];
1384                if let Some(bracket_end) = inside.find(']') {
1385                    let array_content = &inside[..bracket_end];
1386                    let mut scan = array_content;
1387                    while let Some(quote_start) = scan.find('"') {
1388                        let after_quote = &scan[quote_start + 1..];
1389                        if let Some(quote_end) = after_quote.find('"') {
1390                            let col = &after_quote[..quote_end];
1391                            if !col.is_empty() && !col.contains('.') {
1392                                columns.push(col.to_string());
1393                            }
1394                            scan = &after_quote[quote_end + 1..];
1395                        } else {
1396                            break;
1397                        }
1398                    }
1399                }
1400            }
1401            temp = after;
1402        }
1403    }
1404
1405    // Clean up extracted columns: strip Postgres ::type casts and AS aliases.
1406    // e.g. "id::text" → "id", "conn.id::text as connection_id" → "conn.id",
1407    // "COALESCE(inv.capacity - inv.reserved, 0)::bigint as x" → skipped (expression)
1408    let columns: Vec<String> = columns
1409        .into_iter()
1410        .map(|col| {
1411            // Strip " as alias" suffix (case-insensitive)
1412            let col = if let Some(pos) = col.find(" as ").or_else(|| col.find(" AS ")) {
1413                col[..pos].trim().to_string()
1414            } else {
1415                col
1416            };
1417            // Strip ::type cast suffix
1418            if let Some(pos) = col.find("::") {
1419                col[..pos].to_string()
1420            } else {
1421                col
1422            }
1423        })
1424        .filter(|col| {
1425            // Skip expressions that aren't simple column references
1426            !col.contains('(') && !col.contains(')') && !col.contains(' ')
1427        })
1428        .collect();
1429
1430    columns
1431}
1432
1433fn usage_action_to_ast(action: &str) -> crate::ast::Action {
1434    use crate::ast::Action;
1435
1436    match action {
1437        "GET" | "TYPED" => Action::Get,
1438        "ADD" => Action::Add,
1439        "SET" => Action::Set,
1440        "DEL" => Action::Del,
1441        "PUT" => Action::Put,
1442        _ => Action::Get,
1443    }
1444}
1445
1446fn append_scanned_columns(cmd: &mut crate::ast::Qail, columns: &[String]) {
1447    use crate::ast::Expr;
1448
1449    for col in columns {
1450        // Skip qualified columns (CTE refs like cte.column)
1451        if col.contains('.') {
1452            continue;
1453        }
1454        // Skip SQL function expressions (e.g., count(*), SUM(amount))
1455        // and wildcard (*) — these are valid SQL, not schema columns
1456        if col.contains('(') || col == "*" {
1457            continue;
1458        }
1459        let exists = cmd
1460            .columns
1461            .iter()
1462            .any(|e| matches!(e, Expr::Named(existing) if existing == col));
1463        if !exists {
1464            cmd.columns.push(Expr::Named(col.clone()));
1465        }
1466    }
1467}
1468
1469#[cfg(feature = "analyzer")]
1470type SynUsageKey = (String, usize, String, String);
1471
1472#[cfg(feature = "analyzer")]
1473#[derive(Debug, Clone)]
1474struct SynParsedUsage {
1475    line: usize,
1476    action: String,
1477    table: String,
1478    cmd: crate::ast::Qail,
1479    has_rls: bool,
1480    score: usize,
1481}
1482
1483#[cfg(feature = "analyzer")]
1484struct SynMethodStep {
1485    name: String,
1486    args: Vec<syn::Expr>,
1487}
1488
1489#[cfg(feature = "analyzer")]
1490#[derive(Debug)]
1491struct SynConstructor {
1492    line: usize,
1493    action: String,
1494    ast_action: crate::ast::Action,
1495    table: String,
1496}
1497
1498#[cfg(feature = "analyzer")]
1499fn syn_usage_key(file: &str, line: usize, action: &str, table: &str) -> SynUsageKey {
1500    (
1501        file.to_string(),
1502        line,
1503        action.to_string(),
1504        table.to_string(),
1505    )
1506}
1507
1508#[cfg(feature = "analyzer")]
1509fn build_syn_usage_index(usages: &[QailUsage]) -> HashMap<SynUsageKey, SynParsedUsage> {
1510    let mut files = std::collections::HashSet::new();
1511    for usage in usages {
1512        files.insert(usage.file.clone());
1513    }
1514
1515    let mut index: HashMap<SynUsageKey, SynParsedUsage> = HashMap::new();
1516    for file in files {
1517        for parsed in extract_syn_usages_from_file(&file) {
1518            let key = syn_usage_key(&file, parsed.line, &parsed.action, &parsed.table);
1519            match index.get(&key) {
1520                Some(existing) if existing.score >= parsed.score => {}
1521                _ => {
1522                    index.insert(key, parsed);
1523                }
1524            }
1525        }
1526    }
1527
1528    index
1529}
1530
1531#[cfg(feature = "analyzer")]
1532fn extract_syn_usages_from_file(file: &str) -> Vec<SynParsedUsage> {
1533    let Ok(content) = fs::read_to_string(file) else {
1534        return Vec::new();
1535    };
1536    extract_syn_usages_from_source(&content)
1537}
1538
1539#[cfg(feature = "analyzer")]
1540fn extract_syn_usages_from_source(source: &str) -> Vec<SynParsedUsage> {
1541    let Ok(syntax) = syn::parse_file(source) else {
1542        return Vec::new();
1543    };
1544
1545    struct SynQailVisitor {
1546        usages: Vec<SynParsedUsage>,
1547    }
1548
1549    impl SynQailVisitor {
1550        fn new() -> Self {
1551            Self { usages: Vec::new() }
1552        }
1553    }
1554
1555    impl<'ast> Visit<'ast> for SynQailVisitor {
1556        fn visit_expr(&mut self, node: &'ast syn::Expr) {
1557            if let Some(parsed) = parse_qail_chain_from_expr(node) {
1558                self.usages.push(parsed);
1559            }
1560            syn::visit::visit_expr(self, node);
1561        }
1562    }
1563
1564    let mut visitor = SynQailVisitor::new();
1565    visitor.visit_file(&syntax);
1566    visitor.usages
1567}
1568
1569#[cfg(feature = "analyzer")]
1570fn parse_qail_chain_from_expr(expr: &syn::Expr) -> Option<SynParsedUsage> {
1571    let mut steps = Vec::<SynMethodStep>::new();
1572    let mut cursor = expr;
1573
1574    loop {
1575        match cursor {
1576            syn::Expr::MethodCall(method) => {
1577                steps.push(SynMethodStep {
1578                    name: method.method.to_string(),
1579                    args: method.args.iter().cloned().collect(),
1580                });
1581                cursor = &method.receiver;
1582            }
1583            syn::Expr::Call(call) => {
1584                let ctor = parse_qail_constructor(call)?;
1585                steps.reverse();
1586
1587                let mut cmd = crate::ast::Qail {
1588                    action: ctor.ast_action,
1589                    table: ctor.table.clone(),
1590                    ..Default::default()
1591                };
1592                let mut has_rls = false;
1593
1594                for step in steps {
1595                    apply_syn_method_step(&mut cmd, &step.name, &step.args, &mut has_rls);
1596                }
1597
1598                let score = syn_cmd_score(&cmd, has_rls);
1599                return Some(SynParsedUsage {
1600                    line: ctor.line,
1601                    action: ctor.action,
1602                    table: ctor.table,
1603                    cmd,
1604                    has_rls,
1605                    score,
1606                });
1607            }
1608            syn::Expr::Paren(paren) => cursor = &paren.expr,
1609            syn::Expr::Group(group) => cursor = &group.expr,
1610            syn::Expr::Reference(reference) => cursor = &reference.expr,
1611            _ => return None,
1612        }
1613    }
1614}
1615
1616#[cfg(feature = "analyzer")]
1617fn parse_qail_constructor(call: &syn::ExprCall) -> Option<SynConstructor> {
1618    let syn::Expr::Path(path_expr) = &*call.func else {
1619        return None;
1620    };
1621
1622    let ctor = qail_constructor_name(&path_expr.path)?;
1623    let first_arg = call.args.first()?;
1624
1625    let (action, ast_action, table) = match ctor.as_str() {
1626        "get" => (
1627            "GET".to_string(),
1628            crate::ast::Action::Get,
1629            parse_table_name_from_expr(first_arg)?,
1630        ),
1631        "add" => (
1632            "ADD".to_string(),
1633            crate::ast::Action::Add,
1634            parse_table_name_from_expr(first_arg)?,
1635        ),
1636        "set" => (
1637            "SET".to_string(),
1638            crate::ast::Action::Set,
1639            parse_table_name_from_expr(first_arg)?,
1640        ),
1641        "del" => (
1642            "DEL".to_string(),
1643            crate::ast::Action::Del,
1644            parse_table_name_from_expr(first_arg)?,
1645        ),
1646        "put" => (
1647            "PUT".to_string(),
1648            crate::ast::Action::Put,
1649            parse_table_name_from_expr(first_arg)?,
1650        ),
1651        "typed" => (
1652            "TYPED".to_string(),
1653            crate::ast::Action::Get,
1654            parse_typed_table_from_expr(first_arg)?,
1655        ),
1656        // "raw_sql" and any unknown constructors are not validated
1657        _ => return None,
1658    };
1659
1660    Some(SynConstructor {
1661        line: call.span().start().line,
1662        action,
1663        ast_action,
1664        table,
1665    })
1666}
1667
1668#[cfg(feature = "analyzer")]
1669fn qail_constructor_name(path: &syn::Path) -> Option<String> {
1670    let mut segments = path.segments.iter().map(|s| s.ident.to_string());
1671    let first = segments.next()?;
1672    let mut prev = first;
1673    for segment in segments {
1674        if prev == "Qail" {
1675            return Some(segment.to_ascii_lowercase());
1676        }
1677        prev = segment;
1678    }
1679    None
1680}
1681
1682#[cfg(feature = "analyzer")]
1683fn parse_typed_table_from_expr(expr: &syn::Expr) -> Option<String> {
1684    match expr {
1685        syn::Expr::Path(path_expr) => {
1686            let segments: Vec<_> = path_expr
1687                .path
1688                .segments
1689                .iter()
1690                .map(|s| s.ident.to_string())
1691                .collect();
1692            match segments.len() {
1693                0 => None,
1694                1 => Some(segments[0].to_ascii_lowercase()),
1695                _ => Some(segments[segments.len() - 2].to_ascii_lowercase()),
1696            }
1697        }
1698        syn::Expr::Reference(reference) => parse_typed_table_from_expr(&reference.expr),
1699        syn::Expr::Paren(paren) => parse_typed_table_from_expr(&paren.expr),
1700        syn::Expr::Group(group) => parse_typed_table_from_expr(&group.expr),
1701        syn::Expr::MethodCall(method) if method.method == "into" => {
1702            parse_typed_table_from_expr(&method.receiver)
1703        }
1704        _ => None,
1705    }
1706}
1707
1708#[cfg(feature = "analyzer")]
1709fn parse_table_name_from_expr(expr: &syn::Expr) -> Option<String> {
1710    parse_string_from_expr(expr).or_else(|| parse_typed_table_from_expr(expr))
1711}
1712
1713#[cfg(feature = "analyzer")]
1714fn parse_string_from_expr(expr: &syn::Expr) -> Option<String> {
1715    match expr {
1716        syn::Expr::Lit(lit) => match &lit.lit {
1717            syn::Lit::Str(s) => Some(s.value()),
1718            _ => None,
1719        },
1720        syn::Expr::Reference(reference) => parse_string_from_expr(&reference.expr),
1721        syn::Expr::Paren(paren) => parse_string_from_expr(&paren.expr),
1722        syn::Expr::Group(group) => parse_string_from_expr(&group.expr),
1723        syn::Expr::MethodCall(method)
1724            if method.method == "into" || method.method == "to_string" =>
1725        {
1726            parse_string_from_expr(&method.receiver)
1727        }
1728        syn::Expr::Call(call) => {
1729            let syn::Expr::Path(path_expr) = &*call.func else {
1730                return None;
1731            };
1732            let tail = path_expr.path.segments.last()?.ident.to_string();
1733            if tail == "from" || tail == "new" || tail == "String" {
1734                return call.args.first().and_then(parse_string_from_expr);
1735            }
1736            None
1737        }
1738        _ => None,
1739    }
1740}
1741
1742#[cfg(feature = "analyzer")]
1743fn parse_string_list_from_expr(expr: &syn::Expr) -> Vec<String> {
1744    match expr {
1745        syn::Expr::Array(arr) => arr
1746            .elems
1747            .iter()
1748            .filter_map(parse_string_from_expr)
1749            .collect(),
1750        syn::Expr::Reference(reference) => parse_string_list_from_expr(&reference.expr),
1751        syn::Expr::Paren(paren) => parse_string_list_from_expr(&paren.expr),
1752        syn::Expr::Group(group) => parse_string_list_from_expr(&group.expr),
1753        syn::Expr::Macro(mac) if mac.mac.path.is_ident("vec") => {
1754            if let Ok(arr) = syn::parse2::<syn::ExprArray>(mac.mac.tokens.clone()) {
1755                return arr
1756                    .elems
1757                    .iter()
1758                    .filter_map(parse_string_from_expr)
1759                    .collect();
1760            }
1761            Vec::new()
1762        }
1763        _ => parse_string_from_expr(expr).into_iter().collect(),
1764    }
1765}
1766
1767#[cfg(feature = "analyzer")]
1768fn parse_operator_from_expr(expr: &syn::Expr) -> Option<crate::ast::Operator> {
1769    let syn::Expr::Path(path_expr) = expr else {
1770        return None;
1771    };
1772    let name = path_expr.path.segments.last()?.ident.to_string();
1773    Some(match name.as_str() {
1774        "Eq" => crate::ast::Operator::Eq,
1775        "Ne" => crate::ast::Operator::Ne,
1776        "Gt" => crate::ast::Operator::Gt,
1777        "Gte" => crate::ast::Operator::Gte,
1778        "Lt" => crate::ast::Operator::Lt,
1779        "Lte" => crate::ast::Operator::Lte,
1780        "Like" => crate::ast::Operator::Like,
1781        "ILike" => crate::ast::Operator::ILike,
1782        "IsNull" => crate::ast::Operator::IsNull,
1783        "IsNotNull" => crate::ast::Operator::IsNotNull,
1784        "In" => crate::ast::Operator::In,
1785        _ => return None,
1786    })
1787}
1788
1789#[cfg(feature = "analyzer")]
1790fn parse_sort_order_from_expr(expr: &syn::Expr) -> Option<crate::ast::SortOrder> {
1791    let syn::Expr::Path(path_expr) = expr else {
1792        return None;
1793    };
1794    let name = path_expr.path.segments.last()?.ident.to_string();
1795    Some(match name.as_str() {
1796        "Asc" => crate::ast::SortOrder::Asc,
1797        "Desc" => crate::ast::SortOrder::Desc,
1798        "AscNullsFirst" => crate::ast::SortOrder::AscNullsFirst,
1799        "AscNullsLast" => crate::ast::SortOrder::AscNullsLast,
1800        "DescNullsFirst" => crate::ast::SortOrder::DescNullsFirst,
1801        "DescNullsLast" => crate::ast::SortOrder::DescNullsLast,
1802        _ => return None,
1803    })
1804}
1805
1806#[cfg(feature = "analyzer")]
1807fn parse_join_kind_from_expr(expr: &syn::Expr) -> Option<crate::ast::JoinKind> {
1808    let syn::Expr::Path(path_expr) = expr else {
1809        return None;
1810    };
1811    let name = path_expr.path.segments.last()?.ident.to_string();
1812    Some(match name.as_str() {
1813        "Inner" => crate::ast::JoinKind::Inner,
1814        "Left" => crate::ast::JoinKind::Left,
1815        "Right" => crate::ast::JoinKind::Right,
1816        "Lateral" => crate::ast::JoinKind::Lateral,
1817        "Full" => crate::ast::JoinKind::Full,
1818        "Cross" => crate::ast::JoinKind::Cross,
1819        _ => return None,
1820    })
1821}
1822
1823#[cfg(feature = "analyzer")]
1824fn parse_value_ctor_call(call: &syn::ExprCall) -> Option<crate::ast::Value> {
1825    let syn::Expr::Path(path_expr) = &*call.func else {
1826        return None;
1827    };
1828    let segments: Vec<String> = path_expr
1829        .path
1830        .segments
1831        .iter()
1832        .map(|s| s.ident.to_string())
1833        .collect();
1834    if segments.len() < 2 || segments[segments.len() - 2] != "Value" {
1835        return None;
1836    }
1837
1838    let ctor = segments.last()?.as_str();
1839    let first = call.args.first();
1840
1841    use crate::ast::Value;
1842    Some(match ctor {
1843        "Null" => Value::Null,
1844        "Bool" => match first {
1845            Some(syn::Expr::Lit(lit)) => match &lit.lit {
1846                syn::Lit::Bool(b) => Value::Bool(b.value),
1847                _ => return None,
1848            },
1849            _ => return None,
1850        },
1851        "Int" => match first {
1852            Some(syn::Expr::Lit(lit)) => match &lit.lit {
1853                syn::Lit::Int(i) => i
1854                    .base10_parse::<i64>()
1855                    .map(Value::Int)
1856                    .unwrap_or(Value::Null),
1857                _ => return None,
1858            },
1859            _ => return None,
1860        },
1861        "Float" => match first {
1862            Some(syn::Expr::Lit(lit)) => match &lit.lit {
1863                syn::Lit::Float(f) => f
1864                    .base10_parse::<f64>()
1865                    .map(Value::Float)
1866                    .unwrap_or(Value::Null),
1867                _ => return None,
1868            },
1869            _ => return None,
1870        },
1871        "String" => Value::String(first.and_then(parse_string_from_expr)?),
1872        "Column" => Value::Column(first.and_then(parse_string_from_expr)?),
1873        "Array" => Value::Array(match first {
1874            Some(expr) => match parse_value_from_expr(expr) {
1875                Value::Array(arr) => arr,
1876                single => vec![single],
1877            },
1878            None => vec![],
1879        }),
1880        _ => return None,
1881    })
1882}
1883
1884#[cfg(feature = "analyzer")]
1885fn parse_value_from_expr(expr: &syn::Expr) -> crate::ast::Value {
1886    use crate::ast::Value;
1887
1888    match expr {
1889        syn::Expr::Lit(lit) => match &lit.lit {
1890            syn::Lit::Bool(b) => Value::Bool(b.value),
1891            syn::Lit::Int(i) => i
1892                .base10_parse::<i64>()
1893                .map(Value::Int)
1894                .unwrap_or(Value::Null),
1895            syn::Lit::Float(f) => f
1896                .base10_parse::<f64>()
1897                .map(Value::Float)
1898                .unwrap_or(Value::Null),
1899            syn::Lit::Str(s) => Value::String(s.value()),
1900            _ => Value::Null,
1901        },
1902        syn::Expr::Array(arr) => {
1903            Value::Array(arr.elems.iter().map(parse_value_from_expr).collect())
1904        }
1905        syn::Expr::Reference(reference) => parse_value_from_expr(&reference.expr),
1906        syn::Expr::Paren(paren) => parse_value_from_expr(&paren.expr),
1907        syn::Expr::Group(group) => parse_value_from_expr(&group.expr),
1908        syn::Expr::MethodCall(method) if method.method == "into" => {
1909            parse_value_from_expr(&method.receiver)
1910        }
1911        syn::Expr::Call(call) => {
1912            if let Some(value) = parse_value_ctor_call(call) {
1913                return value;
1914            }
1915            let syn::Expr::Path(path_expr) = &*call.func else {
1916                return Value::Null;
1917            };
1918            let tail = path_expr
1919                .path
1920                .segments
1921                .last()
1922                .map(|s| s.ident.to_string())
1923                .unwrap_or_default();
1924            if tail == "Some" {
1925                return call
1926                    .args
1927                    .first()
1928                    .map(parse_value_from_expr)
1929                    .unwrap_or(Value::Null);
1930            }
1931            Value::Null
1932        }
1933        syn::Expr::Path(_path_expr) => Value::Null,
1934        _ => Value::Null,
1935    }
1936}
1937
1938#[cfg(feature = "analyzer")]
1939fn parse_expr_node(expr: &syn::Expr) -> Option<crate::ast::Expr> {
1940    match expr {
1941        syn::Expr::Lit(lit) => match &lit.lit {
1942            syn::Lit::Str(s) => Some(crate::ast::Expr::Named(s.value())),
1943            _ => None,
1944        },
1945        syn::Expr::Reference(reference) => parse_expr_node(&reference.expr),
1946        syn::Expr::Paren(paren) => parse_expr_node(&paren.expr),
1947        syn::Expr::Group(group) => parse_expr_node(&group.expr),
1948        syn::Expr::MethodCall(method) if method.method == "into" => {
1949            parse_expr_node(&method.receiver)
1950        }
1951        syn::Expr::Call(call) => {
1952            let syn::Expr::Path(path_expr) = &*call.func else {
1953                return None;
1954            };
1955            let segments: Vec<String> = path_expr
1956                .path
1957                .segments
1958                .iter()
1959                .map(|s| s.ident.to_string())
1960                .collect();
1961            let tail = segments.last()?.as_str();
1962            if tail == "Named" && segments.len() >= 2 && segments[segments.len() - 2] == "Expr" {
1963                return call
1964                    .args
1965                    .first()
1966                    .and_then(parse_string_from_expr)
1967                    .map(crate::ast::Expr::Named);
1968            }
1969            if tail == "Raw" && segments.len() >= 2 && segments[segments.len() - 2] == "Expr" {
1970                return call
1971                    .args
1972                    .first()
1973                    .and_then(parse_string_from_expr)
1974                    .map(crate::ast::Expr::Raw);
1975            }
1976            if tail == "col" {
1977                return call
1978                    .args
1979                    .first()
1980                    .and_then(parse_string_from_expr)
1981                    .map(crate::ast::Expr::Named);
1982            }
1983            None
1984        }
1985        _ => None,
1986    }
1987}
1988
1989#[cfg(feature = "analyzer")]
1990fn parse_condition_from_expr(expr: &syn::Expr) -> Option<crate::ast::Condition> {
1991    let syn::Expr::Struct(cond_struct) = expr else {
1992        return None;
1993    };
1994    let struct_name = cond_struct.path.segments.last()?.ident.to_string();
1995    if struct_name != "Condition" {
1996        return None;
1997    }
1998
1999    let mut left = None;
2000    let mut op = None;
2001    let mut value = None;
2002    let mut is_array_unnest = false;
2003
2004    for field in &cond_struct.fields {
2005        let syn::Member::Named(name) = &field.member else {
2006            continue;
2007        };
2008        match name.to_string().as_str() {
2009            "left" => left = parse_expr_node(&field.expr),
2010            "op" => op = parse_operator_from_expr(&field.expr),
2011            "value" => value = Some(parse_value_from_expr(&field.expr)),
2012            "is_array_unnest" => {
2013                if let syn::Expr::Lit(lit) = &field.expr
2014                    && let syn::Lit::Bool(v) = &lit.lit
2015                {
2016                    is_array_unnest = v.value;
2017                }
2018            }
2019            _ => {}
2020        }
2021    }
2022
2023    Some(crate::ast::Condition {
2024        left: left?,
2025        op: op?,
2026        value: value.unwrap_or(crate::ast::Value::Null),
2027        is_array_unnest,
2028    })
2029}
2030
2031#[cfg(feature = "analyzer")]
2032fn parse_condition_list(expr: &syn::Expr) -> Vec<crate::ast::Condition> {
2033    match expr {
2034        syn::Expr::Array(arr) => arr
2035            .elems
2036            .iter()
2037            .filter_map(parse_condition_from_expr)
2038            .collect(),
2039        syn::Expr::Reference(reference) => parse_condition_list(&reference.expr),
2040        syn::Expr::Paren(paren) => parse_condition_list(&paren.expr),
2041        syn::Expr::Group(group) => parse_condition_list(&group.expr),
2042        syn::Expr::Macro(mac) if mac.mac.path.is_ident("vec") => {
2043            if let Ok(arr) = syn::parse2::<syn::ExprArray>(mac.mac.tokens.clone()) {
2044                return arr
2045                    .elems
2046                    .iter()
2047                    .filter_map(parse_condition_from_expr)
2048                    .collect();
2049            }
2050            Vec::new()
2051        }
2052        _ => parse_condition_from_expr(expr).into_iter().collect(),
2053    }
2054}
2055
2056#[cfg(feature = "analyzer")]
2057fn push_filter_condition(cmd: &mut crate::ast::Qail, condition: crate::ast::Condition) {
2058    if let Some(cage) = cmd
2059        .cages
2060        .iter_mut()
2061        .find(|c| matches!(c.kind, crate::ast::CageKind::Filter))
2062    {
2063        cage.conditions.push(condition);
2064    } else {
2065        cmd.cages.push(crate::ast::Cage {
2066            kind: crate::ast::CageKind::Filter,
2067            conditions: vec![condition],
2068            logical_op: crate::ast::LogicalOp::And,
2069        });
2070    }
2071}
2072
2073#[cfg(feature = "analyzer")]
2074fn push_payload_condition(cmd: &mut crate::ast::Qail, condition: crate::ast::Condition) {
2075    if let Some(cage) = cmd
2076        .cages
2077        .iter_mut()
2078        .find(|c| matches!(c.kind, crate::ast::CageKind::Payload))
2079    {
2080        cage.conditions.push(condition);
2081    } else {
2082        cmd.cages.push(crate::ast::Cage {
2083            kind: crate::ast::CageKind::Payload,
2084            conditions: vec![condition],
2085            logical_op: crate::ast::LogicalOp::And,
2086        });
2087    }
2088}
2089
2090#[cfg(feature = "analyzer")]
2091fn normalize_join_table(table: &str) -> String {
2092    table.split_whitespace().next().unwrap_or(table).to_string()
2093}
2094
2095#[cfg(feature = "analyzer")]
2096fn apply_syn_method_step(
2097    cmd: &mut crate::ast::Qail,
2098    method: &str,
2099    args: &[syn::Expr],
2100    has_rls: &mut bool,
2101) {
2102    use crate::ast::{Condition, Expr, Join, JoinKind, Operator, SortOrder, Value};
2103
2104    match method {
2105        "with_rls" | "rls" => {
2106            *has_rls = true;
2107        }
2108        "column" => {
2109            if let Some(col) = args.first().and_then(parse_string_from_expr) {
2110                cmd.columns.push(Expr::Named(col));
2111            }
2112        }
2113        "columns" => {
2114            if let Some(arg) = args.first() {
2115                cmd.columns.extend(
2116                    parse_string_list_from_expr(arg)
2117                        .into_iter()
2118                        .map(Expr::Named),
2119                );
2120            }
2121        }
2122        "returning" => {
2123            if let Some(arg) = args.first() {
2124                let cols: Vec<Expr> = parse_string_list_from_expr(arg)
2125                    .into_iter()
2126                    .map(Expr::Named)
2127                    .collect();
2128                if !cols.is_empty() {
2129                    match &mut cmd.returning {
2130                        Some(existing) => existing.extend(cols),
2131                        None => cmd.returning = Some(cols),
2132                    }
2133                }
2134            }
2135        }
2136        "returning_all" => {
2137            cmd.returning = Some(vec![Expr::Star]);
2138        }
2139        "filter" => {
2140            if args.len() >= 3
2141                && let Some(column) = parse_string_from_expr(&args[0])
2142            {
2143                let op = parse_operator_from_expr(&args[1]).unwrap_or(Operator::Eq);
2144                let value = parse_value_from_expr(&args[2]);
2145                push_filter_condition(
2146                    cmd,
2147                    Condition {
2148                        left: Expr::Named(column),
2149                        op,
2150                        value,
2151                        is_array_unnest: false,
2152                    },
2153                );
2154            }
2155        }
2156        "where_eq" | "eq" | "ne" | "gt" | "gte" | "lt" | "lte" | "like" | "ilike" | "in_vals"
2157        | "is_null" | "is_not_null" => {
2158            if let Some(column) = args.first().and_then(parse_string_from_expr) {
2159                let (op, value) = match method {
2160                    "where_eq" | "eq" => (
2161                        Operator::Eq,
2162                        args.get(1)
2163                            .map(parse_value_from_expr)
2164                            .unwrap_or(Value::Null),
2165                    ),
2166                    "ne" => (
2167                        Operator::Ne,
2168                        args.get(1)
2169                            .map(parse_value_from_expr)
2170                            .unwrap_or(Value::Null),
2171                    ),
2172                    "gt" => (
2173                        Operator::Gt,
2174                        args.get(1)
2175                            .map(parse_value_from_expr)
2176                            .unwrap_or(Value::Null),
2177                    ),
2178                    "gte" => (
2179                        Operator::Gte,
2180                        args.get(1)
2181                            .map(parse_value_from_expr)
2182                            .unwrap_or(Value::Null),
2183                    ),
2184                    "lt" => (
2185                        Operator::Lt,
2186                        args.get(1)
2187                            .map(parse_value_from_expr)
2188                            .unwrap_or(Value::Null),
2189                    ),
2190                    "lte" => (
2191                        Operator::Lte,
2192                        args.get(1)
2193                            .map(parse_value_from_expr)
2194                            .unwrap_or(Value::Null),
2195                    ),
2196                    "like" => (
2197                        Operator::Like,
2198                        args.get(1)
2199                            .map(parse_value_from_expr)
2200                            .unwrap_or(Value::Null),
2201                    ),
2202                    "ilike" => (
2203                        Operator::ILike,
2204                        args.get(1)
2205                            .map(parse_value_from_expr)
2206                            .unwrap_or(Value::Null),
2207                    ),
2208                    "in_vals" => (
2209                        Operator::In,
2210                        args.get(1)
2211                            .map(parse_value_from_expr)
2212                            .unwrap_or(Value::Array(vec![])),
2213                    ),
2214                    "is_null" => (Operator::IsNull, Value::Null),
2215                    "is_not_null" => (Operator::IsNotNull, Value::Null),
2216                    _ => (Operator::Eq, Value::Null),
2217                };
2218
2219                push_filter_condition(
2220                    cmd,
2221                    Condition {
2222                        left: Expr::Named(column),
2223                        op,
2224                        value,
2225                        is_array_unnest: false,
2226                    },
2227                );
2228            }
2229        }
2230        "order_by" => {
2231            if let Some(column) = args.first().and_then(parse_string_from_expr) {
2232                let order = args
2233                    .get(1)
2234                    .and_then(parse_sort_order_from_expr)
2235                    .unwrap_or(SortOrder::Asc);
2236                cmd.cages.push(crate::ast::Cage {
2237                    kind: crate::ast::CageKind::Sort(order),
2238                    conditions: vec![Condition {
2239                        left: Expr::Named(column),
2240                        op: Operator::Eq,
2241                        value: Value::Null,
2242                        is_array_unnest: false,
2243                    }],
2244                    logical_op: crate::ast::LogicalOp::And,
2245                });
2246            }
2247        }
2248        "order_desc" | "order_asc" => {
2249            if let Some(column) = args.first().and_then(parse_string_from_expr) {
2250                let order = if method == "order_desc" {
2251                    SortOrder::Desc
2252                } else {
2253                    SortOrder::Asc
2254                };
2255                cmd.cages.push(crate::ast::Cage {
2256                    kind: crate::ast::CageKind::Sort(order),
2257                    conditions: vec![Condition {
2258                        left: Expr::Named(column),
2259                        op: Operator::Eq,
2260                        value: Value::Null,
2261                        is_array_unnest: false,
2262                    }],
2263                    logical_op: crate::ast::LogicalOp::And,
2264                });
2265            }
2266        }
2267        "group_by" => {
2268            if let Some(arg) = args.first() {
2269                let cols = parse_string_list_from_expr(arg);
2270                if !cols.is_empty() {
2271                    cmd.cages.push(crate::ast::Cage {
2272                        kind: crate::ast::CageKind::Partition,
2273                        conditions: cols
2274                            .into_iter()
2275                            .map(|c| Condition {
2276                                left: Expr::Named(c),
2277                                op: Operator::Eq,
2278                                value: Value::Null,
2279                                is_array_unnest: false,
2280                            })
2281                            .collect(),
2282                        logical_op: crate::ast::LogicalOp::And,
2283                    });
2284                }
2285            }
2286        }
2287        "having_cond" => {
2288            if let Some(arg) = args.first()
2289                && let Some(condition) = parse_condition_from_expr(arg)
2290            {
2291                cmd.having.push(condition);
2292            }
2293        }
2294        "having_conds" => {
2295            if let Some(arg) = args.first() {
2296                cmd.having.extend(parse_condition_list(arg));
2297            }
2298        }
2299        "join" => {
2300            if args.len() >= 4
2301                && let Some(table) = args.get(1).and_then(parse_table_name_from_expr)
2302            {
2303                let kind = args
2304                    .first()
2305                    .and_then(parse_join_kind_from_expr)
2306                    .unwrap_or(JoinKind::Left);
2307                let on = match (
2308                    args.get(2).and_then(parse_string_from_expr),
2309                    args.get(3).and_then(parse_string_from_expr),
2310                ) {
2311                    (Some(left_col), Some(right_col)) => Some(vec![Condition {
2312                        left: Expr::Named(left_col),
2313                        op: Operator::Eq,
2314                        value: Value::Column(right_col),
2315                        is_array_unnest: false,
2316                    }]),
2317                    _ => None,
2318                };
2319                cmd.joins.push(Join {
2320                    kind,
2321                    table: normalize_join_table(&table),
2322                    on,
2323                    on_true: false,
2324                });
2325            }
2326        }
2327        "left_join" | "inner_join" | "right_join" | "full_join" => {
2328            if args.len() >= 3
2329                && let Some(table) = args.first().and_then(parse_table_name_from_expr)
2330            {
2331                let kind = match method {
2332                    "inner_join" => JoinKind::Inner,
2333                    "right_join" => JoinKind::Right,
2334                    "full_join" => JoinKind::Full,
2335                    _ => JoinKind::Left,
2336                };
2337                let on = match (
2338                    args.get(1).and_then(parse_string_from_expr),
2339                    args.get(2).and_then(parse_string_from_expr),
2340                ) {
2341                    (Some(left_col), Some(right_col)) => Some(vec![Condition {
2342                        left: Expr::Named(left_col),
2343                        op: Operator::Eq,
2344                        value: Value::Column(right_col),
2345                        is_array_unnest: false,
2346                    }]),
2347                    _ => None,
2348                };
2349                cmd.joins.push(Join {
2350                    kind,
2351                    table: normalize_join_table(&table),
2352                    on,
2353                    on_true: false,
2354                });
2355            }
2356        }
2357        "join_on" | "join_on_optional" => {
2358            if let Some(table) = args.first().and_then(parse_table_name_from_expr) {
2359                cmd.joins.push(Join {
2360                    kind: JoinKind::Left,
2361                    table: normalize_join_table(&table),
2362                    on: None,
2363                    on_true: false,
2364                });
2365            }
2366        }
2367        "left_join_as" | "inner_join_as" => {
2368            if args.len() >= 4
2369                && let Some(table) = args.first().and_then(parse_table_name_from_expr)
2370            {
2371                let kind = if method == "inner_join_as" {
2372                    JoinKind::Inner
2373                } else {
2374                    JoinKind::Left
2375                };
2376                let on = match (
2377                    args.get(2).and_then(parse_string_from_expr),
2378                    args.get(3).and_then(parse_string_from_expr),
2379                ) {
2380                    (Some(left_col), Some(right_col)) => Some(vec![Condition {
2381                        left: Expr::Named(left_col),
2382                        op: Operator::Eq,
2383                        value: Value::Column(right_col),
2384                        is_array_unnest: false,
2385                    }]),
2386                    _ => None,
2387                };
2388                cmd.joins.push(Join {
2389                    kind,
2390                    table: normalize_join_table(&table),
2391                    on,
2392                    on_true: false,
2393                });
2394            }
2395        }
2396        "join_conds" | "left_join_conds" | "inner_join_conds" => {
2397            let (kind, table_idx, cond_idx) = match method {
2398                "join_conds" => (
2399                    args.first()
2400                        .and_then(parse_join_kind_from_expr)
2401                        .unwrap_or(JoinKind::Left),
2402                    1,
2403                    2,
2404                ),
2405                "inner_join_conds" => (JoinKind::Inner, 0, 1),
2406                _ => (JoinKind::Left, 0, 1),
2407            };
2408
2409            if let Some(table_expr) = args.get(table_idx)
2410                && let Some(table) = parse_table_name_from_expr(table_expr)
2411            {
2412                let conditions = args
2413                    .get(cond_idx)
2414                    .map(parse_condition_list)
2415                    .unwrap_or_default();
2416                cmd.joins.push(Join {
2417                    kind,
2418                    table: normalize_join_table(&table),
2419                    on: if conditions.is_empty() {
2420                        None
2421                    } else {
2422                        Some(conditions)
2423                    },
2424                    on_true: false,
2425                });
2426            }
2427        }
2428        "set_value" | "set_coalesce" | "set_coalesce_opt" => {
2429            if let Some(column) = args.first().and_then(parse_string_from_expr) {
2430                let value = args
2431                    .get(1)
2432                    .map(parse_value_from_expr)
2433                    .unwrap_or(Value::Null);
2434                push_payload_condition(
2435                    cmd,
2436                    Condition {
2437                        left: Expr::Named(column),
2438                        op: Operator::Eq,
2439                        value,
2440                        is_array_unnest: false,
2441                    },
2442                );
2443            }
2444        }
2445        _ => {}
2446    }
2447}
2448
2449#[cfg(feature = "analyzer")]
2450fn syn_cmd_score(cmd: &crate::ast::Qail, has_rls: bool) -> usize {
2451    let group_cols = cmd
2452        .cages
2453        .iter()
2454        .filter(|c| matches!(c.kind, crate::ast::CageKind::Partition))
2455        .map(|c| c.conditions.len())
2456        .sum::<usize>();
2457    let filter_cols = cmd
2458        .cages
2459        .iter()
2460        .filter(|c| matches!(c.kind, crate::ast::CageKind::Filter))
2461        .map(|c| c.conditions.len())
2462        .sum::<usize>();
2463
2464    cmd.columns.len()
2465        + (cmd.joins.len() * 8)
2466        + (group_cols * 5)
2467        + (cmd.having.len() * 6)
2468        + filter_cols
2469        + cmd.returning.as_ref().map_or(0, |r| r.len() * 2)
2470        + usize::from(has_rls)
2471}
2472
2473/// Validate QAIL usage against schema using the smart Validator
2474/// Provides "Did you mean?" suggestions for typos, type validation, and RLS audit
2475pub fn validate_against_schema(schema: &Schema, usages: &[QailUsage]) -> Vec<String> {
2476    use crate::ast::Qail;
2477    use crate::validator::Validator;
2478
2479    // Build Validator from Schema with column types
2480    let mut validator = Validator::new();
2481    for (table_name, table_schema) in &schema.tables {
2482        // Convert HashMap<String, ColumnType> to Vec<(&str, &str)> for validator
2483        let type_strings: Vec<(String, String)> = table_schema
2484            .columns
2485            .iter()
2486            .map(|(name, typ)| (name.clone(), typ.to_pg_type()))
2487            .collect();
2488        let cols_with_types: Vec<(&str, &str)> = type_strings
2489            .iter()
2490            .map(|(name, typ)| (name.as_str(), typ.as_str()))
2491            .collect();
2492        validator.add_table_with_types(table_name, &cols_with_types);
2493    }
2494
2495    let mut errors = Vec::new();
2496    let mut rls_warnings = Vec::new();
2497    #[cfg(feature = "analyzer")]
2498    let syn_usage_index = build_syn_usage_index(usages);
2499
2500    for usage in usages {
2501        // Skip CTE alias refs — but only if the name doesn't also exist as a
2502        // real schema table. If there's a collision (CTE alias == real table name),
2503        // always validate to avoid false negatives.
2504        if usage.is_cte_ref && !schema.has_table(&usage.table) {
2505            continue;
2506        }
2507
2508        // ── Build partial Qail AST from extracted usage ──────────────
2509        let action = usage_action_to_ast(&usage.action);
2510
2511        let mut cmd = Qail {
2512            action,
2513            table: usage.table.clone(),
2514            ..Default::default()
2515        };
2516        #[allow(unused_mut)]
2517        let mut has_rls = usage.has_rls;
2518
2519        #[cfg(feature = "analyzer")]
2520        if let Some(parsed) = syn_usage_index.get(&syn_usage_key(
2521            &usage.file,
2522            usage.line,
2523            &usage.action,
2524            &usage.table,
2525        )) {
2526            cmd = parsed.cmd.clone();
2527            has_rls |= parsed.has_rls;
2528        }
2529
2530        // Keep scanner-derived columns as fallback for DSL methods not yet covered by syn path.
2531        append_scanned_columns(&mut cmd, &usage.columns);
2532
2533        // ── Validate the constructed AST ─────────────────────────────
2534        match validator.validate_command(&cmd) {
2535            Ok(()) => {}
2536            Err(validation_errors) => {
2537                for e in validation_errors {
2538                    errors.push(format!("{}:{}: {}", usage.file, usage.line, e));
2539                }
2540            }
2541        }
2542
2543        // RLS Audit: warn if query targets RLS-enabled table without .with_rls()
2544        if schema.is_rls_table(&usage.table) && !has_rls {
2545            rls_warnings.push(format!(
2546                "{}:{}: ⚠️ RLS AUDIT: Qail::{}(\"{}\") has no .with_rls() — table has RLS enabled, query may leak tenant data",
2547                usage.file, usage.line, usage.action.to_lowercase(), usage.table
2548            ));
2549        }
2550    }
2551
2552    // Return RLS warnings in the Vec (CLI `qail check` filters by "RLS AUDIT").
2553    // Callers decide whether to treat these as fatal or advisory.
2554    errors.extend(rls_warnings);
2555
2556    errors
2557}
2558
2559/// Run N+1 compile-time check.
2560///
2561/// Controlled by environment variables:
2562/// - `QAIL_NPLUS1`: `off` | `warn` (default) | `deny`
2563/// - `QAIL_NPLUS1_MAX_WARNINGS`: max warnings before truncation (default 50)
2564#[cfg(feature = "analyzer")]
2565fn run_nplus1_check(src_dir: &str) {
2566    use crate::analyzer::{NPlusOneSeverity, detect_n_plus_one_in_dir};
2567
2568    println!("cargo:rerun-if-env-changed=QAIL_NPLUS1");
2569    println!("cargo:rerun-if-env-changed=QAIL_NPLUS1_MAX_WARNINGS");
2570
2571    let mode = std::env::var("QAIL_NPLUS1").unwrap_or_else(|_| "warn".to_string());
2572
2573    if mode == "off" || mode == "false" || mode == "0" {
2574        return;
2575    }
2576
2577    let max_warnings: usize = std::env::var("QAIL_NPLUS1_MAX_WARNINGS")
2578        .ok()
2579        .and_then(|s| s.parse().ok())
2580        .unwrap_or(50);
2581
2582    let diagnostics = detect_n_plus_one_in_dir(Path::new(src_dir));
2583
2584    if diagnostics.is_empty() {
2585        println!("cargo:warning=QAIL: N+1 scan clean ✓");
2586        return;
2587    }
2588
2589    let total = diagnostics.len();
2590    let shown = total.min(max_warnings);
2591
2592    for diag in diagnostics.iter().take(shown) {
2593        let prefix = match diag.severity {
2594            NPlusOneSeverity::Error => "QAIL N+1 ERROR",
2595            NPlusOneSeverity::Warning => "QAIL N+1",
2596        };
2597        println!("cargo:warning={}: {}", prefix, diag);
2598    }
2599
2600    if total > shown {
2601        println!(
2602            "cargo:warning=QAIL N+1: ... and {} more (set QAIL_NPLUS1_MAX_WARNINGS to see all)",
2603            total - shown
2604        );
2605    }
2606
2607    if mode == "deny" {
2608        // Intentional: build-script panic = compile error. N+1 deny mode
2609        // must abort the build when diagnostics are found.
2610        panic!(
2611            "QAIL N+1: {} diagnostic(s) found. Fix N+1 patterns or set QAIL_NPLUS1=warn",
2612            total
2613        );
2614    }
2615}
2616
2617#[cfg(not(feature = "analyzer"))]
2618fn run_nplus1_check(_src_dir: &str) {
2619    // N+1 detection requires the `analyzer` feature (syn dependency)
2620}
2621
2622/// Main validation entry point for build.rs.
2623///
2624/// All `panic!()` calls below are intentional — Cargo build scripts must panic
2625/// to signal a build failure. These are the only mechanism to abort `cargo build`
2626/// when schema validation, live-pull, or mode detection fails.
2627pub fn validate() {
2628    let mode = std::env::var("QAIL").unwrap_or_else(|_| {
2629        if Path::new("schema.qail").exists() || Path::new("schema").is_dir() {
2630            "schema".to_string()
2631        } else {
2632            "false".to_string()
2633        }
2634    });
2635
2636    match mode.as_str() {
2637        "schema" => {
2638            if let Ok(source) = crate::schema_source::resolve_schema_source("schema.qail") {
2639                for path in source.watch_paths() {
2640                    println!("cargo:rerun-if-changed={}", path.display());
2641                }
2642            } else {
2643                // Keep backward-compatible watcher even if resolution fails;
2644                // parse step below will emit the concrete error.
2645                println!("cargo:rerun-if-changed=schema.qail");
2646                println!("cargo:rerun-if-changed=schema");
2647            }
2648            println!("cargo:rerun-if-changed=migrations");
2649            println!("cargo:rerun-if-changed=src");
2650            println!("cargo:rerun-if-env-changed=QAIL");
2651
2652            match Schema::parse_file("schema.qail") {
2653                Ok(mut schema) => {
2654                    // Merge pending migrations with pulled schema
2655                    let merged = match schema.merge_migrations("migrations") {
2656                        Ok(n) => n,
2657                        Err(e) => {
2658                            println!("cargo:warning=QAIL: Migration merge failed: {}", e);
2659                            0
2660                        }
2661                    };
2662                    if merged > 0 {
2663                        println!(
2664                            "cargo:warning=QAIL: Merged {} schema changes from migrations",
2665                            merged
2666                        );
2667                    }
2668
2669                    let usages = scan_source_files("src/");
2670                    let all_results = validate_against_schema(&schema, &usages);
2671
2672                    // Separate schema errors (fatal) from RLS warnings (advisory)
2673                    let schema_errors: Vec<_> = all_results
2674                        .iter()
2675                        .filter(|e| !e.contains("RLS AUDIT"))
2676                        .collect();
2677                    let rls_warnings: Vec<_> = all_results
2678                        .iter()
2679                        .filter(|e| e.contains("RLS AUDIT"))
2680                        .collect();
2681
2682                    // Emit RLS warnings as non-fatal cargo warnings
2683                    for w in &rls_warnings {
2684                        println!("cargo:warning=QAIL RLS: {}", w);
2685                    }
2686
2687                    if schema_errors.is_empty() {
2688                        println!(
2689                            "cargo:warning=QAIL: Validated {} queries against schema source ✓",
2690                            usages.len()
2691                        );
2692                    } else {
2693                        for error in &schema_errors {
2694                            println!("cargo:warning=QAIL ERROR: {}", error);
2695                        }
2696                        // Fail the build (only schema errors, not RLS warnings)
2697                        panic!("QAIL validation failed with {} errors", schema_errors.len());
2698                    }
2699
2700                    // ── N+1 detection ──────────────────────────────────────
2701                    run_nplus1_check("src");
2702                }
2703                Err(e) => {
2704                    panic!("QAIL: Failed to parse schema source: {}", e);
2705                }
2706            }
2707        }
2708        "live" => {
2709            println!("cargo:rerun-if-env-changed=QAIL");
2710            println!("cargo:rerun-if-env-changed=DATABASE_URL");
2711            println!("cargo:rerun-if-changed=src");
2712
2713            // Get DATABASE_URL for qail pull
2714            let db_url = match std::env::var("DATABASE_URL") {
2715                Ok(url) => url,
2716                Err(_) => {
2717                    panic!("QAIL=live requires DATABASE_URL environment variable");
2718                }
2719            };
2720
2721            // Step 1: Run qail pull to update schema.qail
2722            println!("cargo:warning=QAIL: Pulling schema from live database...");
2723
2724            let pull_result = std::process::Command::new("qail")
2725                .args(["pull", &db_url])
2726                .output();
2727
2728            match pull_result {
2729                Ok(output) => {
2730                    if !output.status.success() {
2731                        let stderr = String::from_utf8_lossy(&output.stderr);
2732                        panic!("QAIL: Failed to pull schema: {}", stderr);
2733                    }
2734                    println!("cargo:warning=QAIL: Schema pulled successfully ✓");
2735                }
2736                Err(e) => {
2737                    // qail CLI not found, try using cargo run
2738                    println!("cargo:warning=QAIL: qail CLI not in PATH, trying cargo...");
2739
2740                    let cargo_result = std::process::Command::new("cargo")
2741                        .args(["run", "-p", "qail", "--", "pull", &db_url])
2742                        .current_dir(
2743                            std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()),
2744                        )
2745                        .output();
2746
2747                    match cargo_result {
2748                        Ok(output) if output.status.success() => {
2749                            println!("cargo:warning=QAIL: Schema pulled via cargo ✓");
2750                        }
2751                        _ => {
2752                            panic!(
2753                                "QAIL: Cannot run qail pull: {}. Install qail CLI or set QAIL=schema",
2754                                e
2755                            );
2756                        }
2757                    }
2758                }
2759            }
2760
2761            // Step 2: Parse the updated schema and validate
2762            match Schema::parse_file("schema.qail") {
2763                Ok(mut schema) => {
2764                    // Merge pending migrations (in case live DB doesn't have them yet)
2765                    let merged = match schema.merge_migrations("migrations") {
2766                        Ok(n) => n,
2767                        Err(e) => {
2768                            println!("cargo:warning=QAIL: Migration merge failed: {}", e);
2769                            0
2770                        }
2771                    };
2772                    if merged > 0 {
2773                        println!(
2774                            "cargo:warning=QAIL: Merged {} schema changes from pending migrations",
2775                            merged
2776                        );
2777                    }
2778
2779                    let usages = scan_source_files("src/");
2780                    let all_results = validate_against_schema(&schema, &usages);
2781
2782                    // Separate schema errors (fatal) from RLS warnings (advisory)
2783                    let schema_errors: Vec<_> = all_results
2784                        .iter()
2785                        .filter(|e| !e.contains("RLS AUDIT"))
2786                        .collect();
2787                    let rls_warnings: Vec<_> = all_results
2788                        .iter()
2789                        .filter(|e| e.contains("RLS AUDIT"))
2790                        .collect();
2791
2792                    // Emit RLS warnings as non-fatal cargo warnings
2793                    for w in &rls_warnings {
2794                        println!("cargo:warning=QAIL RLS: {}", w);
2795                    }
2796
2797                    if schema_errors.is_empty() {
2798                        println!(
2799                            "cargo:warning=QAIL: Validated {} queries against live database ✓",
2800                            usages.len()
2801                        );
2802                    } else {
2803                        for error in &schema_errors {
2804                            println!("cargo:warning=QAIL ERROR: {}", error);
2805                        }
2806                        panic!("QAIL validation failed with {} errors", schema_errors.len());
2807                    }
2808
2809                    // ── N+1 detection ──────────────────────────────────────
2810                    run_nplus1_check("src");
2811                }
2812                Err(e) => {
2813                    panic!("QAIL: Failed to parse schema after pull: {}", e);
2814                }
2815            }
2816        }
2817        "false" | "off" | "0" => {
2818            println!("cargo:rerun-if-env-changed=QAIL");
2819            // Silently skip validation
2820        }
2821        _ => {
2822            panic!("QAIL: Unknown mode '{}'. Use: schema, live, or false", mode);
2823        }
2824    }
2825}
2826
2827#[cfg(test)]
2828mod tests {
2829    use super::*;
2830
2831    #[test]
2832    fn test_parse_schema() {
2833        // Format matches qail pull output (space-separated, not colon)
2834        let content = r#"
2835# Test schema
2836
2837table users {
2838  id UUID primary_key
2839  name TEXT not_null
2840  email TEXT unique
2841}
2842
2843table posts {
2844  id UUID
2845  user_id UUID
2846  title TEXT
2847}
2848"#;
2849        let schema = Schema::parse(content).unwrap();
2850        assert!(schema.has_table("users"));
2851        assert!(schema.has_table("posts"));
2852        assert!(schema.table("users").unwrap().has_column("id"));
2853        assert!(schema.table("users").unwrap().has_column("name"));
2854        assert!(!schema.table("users").unwrap().has_column("foo"));
2855    }
2856
2857    #[test]
2858    fn test_extract_string_arg() {
2859        assert_eq!(extract_string_arg(r#""users")"#), Some("users".to_string()));
2860        assert_eq!(
2861            extract_string_arg(r#""table_name")"#),
2862            Some("table_name".to_string())
2863        );
2864    }
2865
2866    #[test]
2867    fn test_scan_file() {
2868        // Test single-line pattern
2869        let content = r#"
2870let query = Qail::get("users").column("id").column("name").eq("active", true);
2871"#;
2872        let mut usages = Vec::new();
2873        scan_file("test.rs", content, &mut usages);
2874
2875        assert_eq!(usages.len(), 1);
2876        assert_eq!(usages[0].table, "users");
2877        assert_eq!(usages[0].action, "GET");
2878        assert!(usages[0].columns.contains(&"id".to_string()));
2879        assert!(usages[0].columns.contains(&"name".to_string()));
2880    }
2881
2882    #[test]
2883    fn test_scan_file_multiline() {
2884        // Test multi-line chain pattern (common in real code)
2885        let content = r#"
2886let query = Qail::get("posts")
2887    .column("id")
2888    .column("title")
2889    .column("author")
2890    .eq("published", true)
2891    .order_by("created_at", Desc);
2892"#;
2893        let mut usages = Vec::new();
2894        scan_file("test.rs", content, &mut usages);
2895
2896        assert_eq!(usages.len(), 1);
2897        assert_eq!(usages[0].table, "posts");
2898        assert_eq!(usages[0].action, "GET");
2899        assert!(usages[0].columns.contains(&"id".to_string()));
2900        assert!(usages[0].columns.contains(&"title".to_string()));
2901        assert!(usages[0].columns.contains(&"author".to_string()));
2902    }
2903
2904    #[test]
2905    fn test_scan_typed_api() {
2906        let content = r#"
2907let q = Qail::typed(users::table).column("email");
2908"#;
2909        let mut usages = Vec::new();
2910        scan_file("test.rs", content, &mut usages);
2911
2912        assert_eq!(usages.len(), 1);
2913        assert_eq!(usages[0].table, "users");
2914        assert_eq!(usages[0].action, "TYPED");
2915        assert!(usages[0].columns.contains(&"email".to_string()));
2916    }
2917
2918    #[test]
2919    fn test_scan_raw_sql_not_validated() {
2920        let content = r#"
2921let q = Qail::raw_sql("SELECT * FROM users");
2922"#;
2923        let mut usages = Vec::new();
2924        scan_file("test.rs", content, &mut usages);
2925        // raw_sql should NOT produce a QailUsage — it just emits a warning
2926        assert_eq!(usages.len(), 0);
2927    }
2928
2929    #[test]
2930    fn test_extract_columns_is_null() {
2931        let line = r#"Qail::get("t").is_null("deleted_at").is_not_null("name")"#;
2932        let cols = extract_columns(line);
2933        assert!(cols.contains(&"deleted_at".to_string()));
2934        assert!(cols.contains(&"name".to_string()));
2935    }
2936
2937    #[test]
2938    fn test_extract_columns_set_value() {
2939        let line =
2940            r#"Qail::set("orders").set_value("status", "Paid").set_coalesce("notes", "default")"#;
2941        let cols = extract_columns(line);
2942        assert!(cols.contains(&"status".to_string()));
2943        assert!(cols.contains(&"notes".to_string()));
2944    }
2945
2946    #[test]
2947    fn test_extract_columns_returning() {
2948        let line = r#"Qail::add("orders").returning(["id", "status"])"#;
2949        let cols = extract_columns(line);
2950        assert!(cols.contains(&"id".to_string()));
2951        assert!(cols.contains(&"status".to_string()));
2952    }
2953
2954    #[test]
2955    fn test_extract_columns_on_conflict() {
2956        let line = r#"Qail::put("t").on_conflict_update(&["id"], &[("name", Expr::Named("excluded.name".into()))])"#;
2957        let cols = extract_columns(line);
2958        assert!(cols.contains(&"id".to_string()));
2959    }
2960
2961    #[test]
2962    fn test_validate_against_schema_casted_column_no_false_positive() {
2963        let schema = Schema::parse(
2964            r#"
2965table users {
2966  id TEXT
2967}
2968"#,
2969        )
2970        .unwrap();
2971
2972        let content = r#"
2973let q = Qail::get("users").eq("id::text", "abc");
2974"#;
2975
2976        let mut usages = Vec::new();
2977        scan_file("test.rs", content, &mut usages);
2978        let errors = validate_against_schema(&schema, &usages);
2979        assert!(
2980            errors.is_empty(),
2981            "casted column should not produce schema error: {:?}",
2982            errors
2983        );
2984    }
2985
2986    #[test]
2987    fn test_cte_cross_chain_detection() {
2988        // Chain 1 defines CTE "agg" via .to_cte(), chain 2 uses Qail::get("agg")
2989        // File-level CTE detection means chain 2 IS recognized as a CTE ref
2990        let content = r#"
2991let cte = Qail::get("orders").columns(["total"]).to_cte("agg");
2992let q = Qail::get("agg").columns(["total"]);
2993"#;
2994        let mut usages = Vec::new();
2995        scan_file("test.rs", content, &mut usages);
2996
2997        assert_eq!(usages.len(), 2);
2998        // Chain 1: GET on "orders", not a CTE ref
2999        assert_eq!(usages[0].table, "orders");
3000        assert!(!usages[0].is_cte_ref);
3001        // Chain 2: "agg" is recognized as CTE alias from chain 1
3002        assert_eq!(usages[1].table, "agg");
3003        assert!(usages[1].is_cte_ref);
3004    }
3005
3006    #[test]
3007    fn test_cte_with_inline_detection() {
3008        // .with("alias", query) should also be detected as CTE
3009        let content = r#"
3010let q = Qail::get("results").with("agg", Qail::get("orders"));
3011"#;
3012        let mut usages = Vec::new();
3013        scan_file("test.rs", content, &mut usages);
3014
3015        // "results" is the main table
3016        assert_eq!(usages.len(), 1);
3017        // It should NOT be a CTE ref since "results" != "agg"
3018        assert!(!usages[0].is_cte_ref);
3019    }
3020
3021    #[test]
3022    fn test_rls_detection_typed_api() {
3023        // .rls() from typed API should be detected
3024        let content = r#"
3025let q = Qail::get("orders")
3026    .columns(["id"])
3027    .rls(&ctx);
3028"#;
3029        let mut usages = Vec::new();
3030        scan_file("test.rs", content, &mut usages);
3031
3032        assert_eq!(usages.len(), 1);
3033        assert!(usages[0].has_rls);
3034    }
3035
3036    #[test]
3037    fn test_rls_detection_with_rls() {
3038        let content = r#"
3039let q = Qail::get("orders")
3040    .columns(["id"])
3041    .with_rls(&ctx);
3042"#;
3043        let mut usages = Vec::new();
3044        scan_file("test.rs", content, &mut usages);
3045
3046        assert_eq!(usages.len(), 1);
3047        assert!(usages[0].has_rls);
3048    }
3049
3050    #[test]
3051    fn test_extract_typed_table_arg() {
3052        assert_eq!(
3053            extract_typed_table_arg("users::table)"),
3054            Some("users".to_string())
3055        );
3056        assert_eq!(
3057            extract_typed_table_arg("users::Users)"),
3058            Some("users".to_string())
3059        );
3060        assert_eq!(
3061            extract_typed_table_arg("schema::users::table)"),
3062            Some("users".to_string())
3063        );
3064        assert_eq!(
3065            extract_typed_table_arg("Orders)"),
3066            Some("orders".to_string())
3067        );
3068        assert_eq!(extract_typed_table_arg(""), None);
3069    }
3070
3071    #[cfg(feature = "analyzer")]
3072    #[test]
3073    fn test_syn_extract_join_group_by_having() {
3074        let source = r#"
3075fn demo(ctx: &RlsContext) {
3076    let _q = Qail::get("orders")
3077        .left_join("customers", "orders.customer_id", "customers.id")
3078        .group_by(["customer_id"])
3079        .having_cond(Condition {
3080            left: Expr::Named("total".into()),
3081            op: Operator::Eq,
3082            value: Value::Int(1),
3083            is_array_unnest: false,
3084        })
3085        .with_rls(ctx);
3086}
3087"#;
3088
3089        let parsed = extract_syn_usages_from_source(source);
3090        let usage = parsed
3091            .into_iter()
3092            .find(|u| u.action == "GET" && u.table == "orders")
3093            .expect("expected syn usage for Qail::get(\"orders\")");
3094
3095        assert_eq!(usage.cmd.joins.len(), 1);
3096        assert!(
3097            usage
3098                .cmd
3099                .cages
3100                .iter()
3101                .any(|c| matches!(c.kind, crate::ast::CageKind::Partition))
3102        );
3103        assert_eq!(usage.cmd.having.len(), 1);
3104        assert!(usage.has_rls);
3105    }
3106
3107    #[cfg(feature = "analyzer")]
3108    #[test]
3109    fn test_validate_against_schema_uses_syn_structural_fields() {
3110        let schema = Schema::parse(
3111            r#"
3112table orders {
3113  id INT
3114  customer_id INT
3115  total INT
3116}
3117
3118table customers {
3119  id INT
3120}
3121"#,
3122        )
3123        .unwrap();
3124
3125        let content = r#"
3126fn demo() {
3127    let _q = Qail::get("orders")
3128        .left_join("customerz", "orders.customer_id", "customerz.id")
3129        .group_by(["custmer_id"])
3130        .having_cond(Condition {
3131            left: Expr::Named("totl".into()),
3132            op: Operator::Eq,
3133            value: Value::Int(1),
3134            is_array_unnest: false,
3135        });
3136}
3137"#;
3138
3139        let unique = std::time::SystemTime::now()
3140            .duration_since(std::time::UNIX_EPOCH)
3141            .unwrap()
3142            .as_nanos();
3143        let test_path = std::env::temp_dir().join(format!(
3144            "qail_build_syn_structural_{}_{}.rs",
3145            std::process::id(),
3146            unique
3147        ));
3148        std::fs::write(&test_path, content).unwrap();
3149
3150        let mut usages = Vec::new();
3151        scan_file(&test_path.display().to_string(), content, &mut usages);
3152        let errors = validate_against_schema(&schema, &usages);
3153        let _ = std::fs::remove_file(&test_path);
3154
3155        assert!(errors.iter().any(|e| e.contains("customerz")));
3156        assert!(errors.iter().any(|e| e.contains("custmer_id")));
3157        assert!(errors.iter().any(|e| e.contains("totl")));
3158    }
3159}
3160
3161// =============================================================================
3162// Typed Schema Codegen
3163// =============================================================================
3164
3165/// Map ColumnType AST to Rust types for TypedColumn<T>
3166fn qail_type_to_rust(col_type: &ColumnType) -> &'static str {
3167    match col_type {
3168        ColumnType::Uuid => "uuid::Uuid",
3169        ColumnType::Text | ColumnType::Varchar(_) => "String",
3170        ColumnType::Int | ColumnType::Serial => "i32",
3171        ColumnType::BigInt | ColumnType::BigSerial => "i64",
3172        ColumnType::Bool => "bool",
3173        ColumnType::Float => "f32",
3174        ColumnType::Decimal(_) => "rust_decimal::Decimal",
3175        ColumnType::Jsonb => "serde_json::Value",
3176        ColumnType::Timestamp | ColumnType::Timestamptz => "chrono::DateTime<chrono::Utc>",
3177        ColumnType::Date => "chrono::NaiveDate",
3178        ColumnType::Time => "chrono::NaiveTime",
3179        ColumnType::Bytea => "Vec<u8>",
3180        ColumnType::Array(_) => "Vec<serde_json::Value>",
3181        ColumnType::Enum { .. } => "String",
3182        ColumnType::Range(_) => "String",
3183        ColumnType::Interval => "String",
3184        ColumnType::Cidr | ColumnType::Inet => "String",
3185        ColumnType::MacAddr => "String",
3186    }
3187}
3188
3189/// Convert table/column names to valid Rust identifiers
3190fn to_rust_ident(name: &str) -> String {
3191    // Handle Rust keywords
3192    let name = match name {
3193        "type" => "r#type",
3194        "match" => "r#match",
3195        "ref" => "r#ref",
3196        "self" => "r#self",
3197        "mod" => "r#mod",
3198        "use" => "r#use",
3199        _ => name,
3200    };
3201    name.to_string()
3202}
3203
3204/// Convert table name to PascalCase struct name
3205fn to_struct_name(name: &str) -> String {
3206    name.chars()
3207        .next()
3208        .map(|c| c.to_uppercase().collect::<String>() + &name[1..])
3209        .unwrap_or_default()
3210}
3211
3212/// Generate typed Rust module from schema.
3213///
3214/// # Usage in consumer's build.rs:
3215/// ```ignore
3216/// fn main() {
3217///     let out_dir = std::env::var("OUT_DIR").unwrap();
3218///     qail_core::build::generate_typed_schema("schema.qail", &format!("{}/schema.rs", out_dir)).unwrap();
3219///     println!("cargo:rerun-if-changed=schema.qail");
3220/// }
3221/// ```
3222///
3223/// Then in the consumer's lib.rs:
3224/// ```ignore
3225/// include!(concat!(env!("OUT_DIR"), "/schema.rs"));
3226/// ```
3227pub fn generate_typed_schema(schema_path: &str, output_path: &str) -> Result<(), String> {
3228    let schema = Schema::parse_file(schema_path)?;
3229    let code = generate_schema_code(&schema);
3230
3231    fs::write(output_path, code)
3232        .map_err(|e| format!("Failed to write schema module to '{}': {}", output_path, e))?;
3233
3234    Ok(())
3235}
3236
3237/// Generate typed Rust code from schema (does not write to file)
3238pub fn generate_schema_code(schema: &Schema) -> String {
3239    let mut code = String::new();
3240
3241    // Header
3242    code.push_str("//! Auto-generated typed schema from schema.qail\n");
3243    code.push_str("//! Do not edit manually - regenerate with `cargo build`\n\n");
3244    code.push_str("#![allow(dead_code, non_upper_case_globals)]\n\n");
3245    code.push_str("use qail_core::typed::{Table, TypedColumn, RelatedTo, Public, Protected};\n\n");
3246
3247    // Sort tables for deterministic output
3248    let mut tables: Vec<_> = schema.tables.values().collect();
3249    tables.sort_by(|a, b| a.name.cmp(&b.name));
3250
3251    for table in &tables {
3252        let mod_name = to_rust_ident(&table.name);
3253        let struct_name = to_struct_name(&table.name);
3254
3255        code.push_str(&format!("/// Typed schema for `{}` table\n", table.name));
3256        code.push_str(&format!("pub mod {} {{\n", mod_name));
3257        code.push_str("    use super::*;\n\n");
3258
3259        // Table struct implementing Table trait
3260        code.push_str(&format!("    /// Table marker for `{}`\n", table.name));
3261        code.push_str("    #[derive(Debug, Clone, Copy)]\n");
3262        code.push_str(&format!("    pub struct {};\n\n", struct_name));
3263
3264        code.push_str(&format!("    impl Table for {} {{\n", struct_name));
3265        code.push_str(&format!(
3266            "        fn table_name() -> &'static str {{ \"{}\" }}\n",
3267            table.name
3268        ));
3269        code.push_str("    }\n\n");
3270
3271        code.push_str(&format!("    impl From<{}> for String {{\n", struct_name));
3272        code.push_str(&format!(
3273            "        fn from(_: {}) -> String {{ \"{}\".to_string() }}\n",
3274            struct_name, table.name
3275        ));
3276        code.push_str("    }\n\n");
3277
3278        code.push_str(&format!("    impl AsRef<str> for {} {{\n", struct_name));
3279        code.push_str(&format!(
3280            "        fn as_ref(&self) -> &str {{ \"{}\" }}\n",
3281            table.name
3282        ));
3283        code.push_str("    }\n\n");
3284
3285        // Table constant for convenience
3286        code.push_str(&format!("    /// The `{}` table\n", table.name));
3287        code.push_str(&format!(
3288            "    pub const table: {} = {};\n\n",
3289            struct_name, struct_name
3290        ));
3291
3292        // Sort columns for deterministic output
3293        let mut columns: Vec<_> = table.columns.iter().collect();
3294        columns.sort_by(|a, b| a.0.cmp(b.0));
3295
3296        // Column constants
3297        for (col_name, col_type) in columns {
3298            let rust_type = qail_type_to_rust(col_type);
3299            let col_ident = to_rust_ident(col_name);
3300            let policy = table
3301                .policies
3302                .get(col_name)
3303                .map(|s| s.as_str())
3304                .unwrap_or("Public");
3305            let rust_policy = if policy == "Protected" {
3306                "Protected"
3307            } else {
3308                "Public"
3309            };
3310
3311            code.push_str(&format!(
3312                "    /// Column `{}.{}` ({}) - {}\n",
3313                table.name,
3314                col_name,
3315                col_type.to_pg_type(),
3316                policy
3317            ));
3318            code.push_str(&format!(
3319                "    pub const {}: TypedColumn<{}, {}> = TypedColumn::new(\"{}\", \"{}\");\n",
3320                col_ident, rust_type, rust_policy, table.name, col_name
3321            ));
3322        }
3323
3324        code.push_str("}\n\n");
3325    }
3326
3327    // ==========================================================================
3328    // Generate RelatedTo impls for compile-time relationship checking
3329    // ==========================================================================
3330
3331    code.push_str(
3332        "// =============================================================================\n",
3333    );
3334    code.push_str("// Compile-Time Relationship Safety (RelatedTo impls)\n");
3335    code.push_str(
3336        "// =============================================================================\n\n",
3337    );
3338
3339    for table in &tables {
3340        for fk in &table.foreign_keys {
3341            // table.column refs ref_table.ref_column
3342            // This means: table is related TO ref_table (forward)
3343            // AND: ref_table is related FROM table (reverse - parent has many children)
3344
3345            let from_mod = to_rust_ident(&table.name);
3346            let from_struct = to_struct_name(&table.name);
3347            let to_mod = to_rust_ident(&fk.ref_table);
3348            let to_struct = to_struct_name(&fk.ref_table);
3349
3350            // Forward: From table (child) -> Referenced table (parent)
3351            // Example: posts -> users (posts.user_id -> users.id)
3352            code.push_str(&format!(
3353                "/// {} has a foreign key to {} via {}.{}\n",
3354                table.name, fk.ref_table, table.name, fk.column
3355            ));
3356            code.push_str(&format!(
3357                "impl RelatedTo<{}::{}> for {}::{} {{\n",
3358                to_mod, to_struct, from_mod, from_struct
3359            ));
3360            code.push_str(&format!(
3361                "    fn join_columns() -> (&'static str, &'static str) {{ (\"{}\", \"{}\") }}\n",
3362                fk.column, fk.ref_column
3363            ));
3364            code.push_str("}\n\n");
3365
3366            // Reverse: Referenced table (parent) -> From table (child)
3367            // Example: users -> posts (users.id -> posts.user_id)
3368            // This allows: Qail::get(users::table).join_related(posts::table)
3369            code.push_str(&format!(
3370                "/// {} is referenced by {} via {}.{}\n",
3371                fk.ref_table, table.name, table.name, fk.column
3372            ));
3373            code.push_str(&format!(
3374                "impl RelatedTo<{}::{}> for {}::{} {{\n",
3375                from_mod, from_struct, to_mod, to_struct
3376            ));
3377            code.push_str(&format!(
3378                "    fn join_columns() -> (&'static str, &'static str) {{ (\"{}\", \"{}\") }}\n",
3379                fk.ref_column, fk.column
3380            ));
3381            code.push_str("}\n\n");
3382        }
3383    }
3384
3385    code
3386}
3387
3388#[cfg(test)]
3389mod codegen_tests {
3390    use super::*;
3391
3392    #[test]
3393    fn test_generate_schema_code() {
3394        let schema_content = r#"
3395table users {
3396    id UUID primary_key
3397    email TEXT not_null
3398    age INT
3399}
3400
3401table posts {
3402    id UUID primary_key
3403    user_id UUID ref:users.id
3404    title TEXT
3405}
3406"#;
3407
3408        let schema = Schema::parse(schema_content).unwrap();
3409        let code = generate_schema_code(&schema);
3410
3411        // Verify module structure
3412        assert!(code.contains("pub mod users {"));
3413        assert!(code.contains("pub mod posts {"));
3414
3415        // Verify table structs
3416        assert!(code.contains("pub struct Users;"));
3417        assert!(code.contains("pub struct Posts;"));
3418
3419        // Verify columns
3420        assert!(code.contains("pub const id: TypedColumn<uuid::Uuid, Public>"));
3421        assert!(code.contains("pub const email: TypedColumn<String, Public>"));
3422        assert!(code.contains("pub const age: TypedColumn<i32, Public>"));
3423
3424        // Verify RelatedTo impls for compile-time relationship checking
3425        assert!(code.contains("impl RelatedTo<users::Users> for posts::Posts"));
3426        assert!(code.contains("impl RelatedTo<posts::Posts> for users::Users"));
3427    }
3428
3429    #[test]
3430    fn test_generate_protected_column() {
3431        let schema_content = r#"
3432table secrets {
3433    id UUID primary_key
3434    token TEXT protected
3435}
3436"#;
3437        let schema = Schema::parse(schema_content).unwrap();
3438        let code = generate_schema_code(&schema);
3439
3440        // Verify Protected policy
3441        assert!(code.contains("pub const token: TypedColumn<String, Protected>"));
3442    }
3443}
3444
3445#[cfg(test)]
3446mod migration_parser_tests {
3447    use super::*;
3448
3449    #[test]
3450    fn test_agent_contracts_migration_parses_all_columns() {
3451        let sql = r#"
3452CREATE TABLE agent_contracts (
3453    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3454    agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
3455    operator_id UUID NOT NULL REFERENCES operators(id) ON DELETE CASCADE,
3456    pricing_model VARCHAR(20) NOT NULL CHECK (pricing_model IN ('commission', 'static_markup', 'net_rate')),
3457    commission_percent DECIMAL(5,2),
3458    static_markup DECIMAL(10,2),
3459    is_active BOOLEAN DEFAULT true,
3460    valid_from DATE,
3461    valid_until DATE,
3462    approved_by UUID REFERENCES users(id),
3463    created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
3464    updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
3465    UNIQUE(agent_id, operator_id)
3466);
3467"#;
3468
3469        let mut schema = Schema::default();
3470        schema.parse_sql_migration(sql);
3471
3472        let table = schema
3473            .tables
3474            .get("agent_contracts")
3475            .expect("agent_contracts table should exist");
3476
3477        for col in &[
3478            "id",
3479            "agent_id",
3480            "operator_id",
3481            "pricing_model",
3482            "commission_percent",
3483            "static_markup",
3484            "is_active",
3485            "valid_from",
3486            "valid_until",
3487            "approved_by",
3488            "created_at",
3489            "updated_at",
3490        ] {
3491            assert!(
3492                table.columns.contains_key(*col),
3493                "Missing column: '{}'. Found: {:?}",
3494                col,
3495                table.columns.keys().collect::<Vec<_>>()
3496            );
3497        }
3498    }
3499
3500    /// Regression test: column names that START with SQL keywords must parse correctly.
3501    /// e.g., created_at starts with CREATE, primary_contact starts with PRIMARY, etc.
3502    #[test]
3503    fn test_keyword_prefixed_column_names_are_not_skipped() {
3504        let sql = r#"
3505CREATE TABLE edge_cases (
3506    id UUID PRIMARY KEY,
3507    created_at TIMESTAMPTZ NOT NULL,
3508    created_by UUID,
3509    primary_contact VARCHAR(255),
3510    check_status VARCHAR(20),
3511    unique_code VARCHAR(50),
3512    foreign_ref UUID,
3513    constraint_name VARCHAR(100),
3514    PRIMARY KEY (id),
3515    CHECK (check_status IN ('pending', 'active')),
3516    UNIQUE (unique_code),
3517    CONSTRAINT fk_ref FOREIGN KEY (foreign_ref) REFERENCES other(id)
3518);
3519"#;
3520
3521        let mut schema = Schema::default();
3522        schema.parse_sql_migration(sql);
3523
3524        let table = schema
3525            .tables
3526            .get("edge_cases")
3527            .expect("edge_cases table should exist");
3528
3529        // These column names start with SQL keywords — all must be found
3530        for col in &[
3531            "created_at",
3532            "created_by",
3533            "primary_contact",
3534            "check_status",
3535            "unique_code",
3536            "foreign_ref",
3537            "constraint_name",
3538        ] {
3539            assert!(
3540                table.columns.contains_key(*col),
3541                "Column '{}' should NOT be skipped just because it starts with a SQL keyword. Found: {:?}",
3542                col,
3543                table.columns.keys().collect::<Vec<_>>()
3544            );
3545        }
3546
3547        // These are constraint keywords, not columns — must NOT appear
3548        // (PRIMARY KEY, CHECK, UNIQUE, CONSTRAINT lines should be skipped)
3549        assert!(
3550            !table.columns.contains_key("primary"),
3551            "Constraint keyword 'PRIMARY' should not be treated as a column"
3552        );
3553    }
3554}