Skip to main content

qail_core/build/
schema.rs

1//! Schema types and parsing for build-time validation.
2
3use crate::migrate::types::ColumnType;
4use std::collections::{HashMap, HashSet};
5use std::path::Path;
6
7/// Foreign key relationship definition
8#[derive(Debug, Clone)]
9pub struct ForeignKey {
10    /// Column in this table that references another table
11    pub column: String,
12    /// Name of referenced table
13    pub ref_table: String,
14    /// Column in referenced table
15    pub ref_column: String,
16}
17
18/// Table schema information with column types and relations
19#[derive(Debug, Clone)]
20pub struct TableSchema {
21    /// Table name.
22    pub name: String,
23    /// Column name → Column type (strongly-typed AST enum)
24    pub columns: HashMap<String, ColumnType>,
25    /// Column name → Access Policy (Default: "Public", can be "Protected")
26    pub policies: HashMap<String, String>,
27    /// Foreign key relationships to other tables
28    pub foreign_keys: Vec<ForeignKey>,
29    /// Whether this table has Row-Level Security enabled
30    /// Auto-detected: table has `tenant_id` column or explicit `rls` keyword.
31    pub rls_enabled: bool,
32}
33
34/// Parsed schema from schema.qail file
35#[derive(Debug, Default)]
36pub struct Schema {
37    /// Table schemas keyed by table name.
38    pub tables: HashMap<String, TableSchema>,
39    /// SQL view names (column-level typing is not available in build schema parser).
40    pub views: HashSet<String>,
41    /// Infrastructure resources (bucket, queue, topic)
42    pub resources: HashMap<String, ResourceSchema>,
43}
44
45/// Infrastructure resource schema (bucket, queue, topic)
46#[derive(Debug, Clone)]
47pub struct ResourceSchema {
48    /// Resource name.
49    pub name: String,
50    /// Resource kind (bucket, queue, topic).
51    pub kind: String,
52    /// Cloud provider (e.g. "aws").
53    pub provider: Option<String>,
54    /// Provider-specific properties.
55    pub properties: HashMap<String, String>,
56}
57
58fn strip_schema_comments(line: &str) -> &str {
59    let line = line.split_once("--").map_or(line, |(left, _)| left);
60    line.split_once('#').map_or(line, |(left, _)| left).trim()
61}
62
63fn strip_sql_line_comments(line: &str) -> &str {
64    line.split_once("--").map_or(line, |(left, _)| left).trim()
65}
66
67impl Schema {
68    /// Parse a schema.qail file
69    pub fn parse_file(path: &str) -> Result<Self, String> {
70        let content = crate::schema_source::read_qail_schema_source(path)?;
71        Self::parse(&content)
72    }
73
74    /// Parse schema from string
75    pub fn parse(content: &str) -> Result<Self, String> {
76        let mut schema = Schema::default();
77        let mut current_table: Option<String> = None;
78        let mut current_columns: HashMap<String, ColumnType> = HashMap::new();
79        let mut current_policies: HashMap<String, String> = HashMap::new();
80        let mut current_fks: Vec<ForeignKey> = Vec::new();
81        let mut current_rls_flag = false;
82
83        for raw_line in content.lines() {
84            let line = strip_schema_comments(raw_line);
85
86            // Skip comments and empty lines
87            if line.is_empty() {
88                continue;
89            }
90
91            // Resource declarations: bucket, queue, topic
92            // Only match at the top level, NOT inside a table block
93            // (a column named 'topic' inside a table would otherwise be
94            //  misidentified as a resource declaration)
95            if current_table.is_none()
96                && (line.starts_with("bucket ")
97                    || line.starts_with("queue ")
98                    || line.starts_with("topic "))
99            {
100                let parts: Vec<&str> = line.splitn(2, ' ').collect();
101                let kind = parts[0].to_string();
102                let rest = parts.get(1).copied().unwrap_or("").trim();
103
104                // Extract name (before {
105                let name = rest.split('{').next().unwrap_or(rest).trim().to_string();
106                let mut provider = None;
107                let mut properties = HashMap::new();
108
109                if line.contains('{') {
110                    // Collect block content
111                    let block = rest.split('{').nth(1).unwrap_or("").to_string();
112                    if !block.contains('}') {
113                        for inner in content.lines().skip_while(|l| !l.contains(line)) {
114                            // Simple approach: read until }
115                            if inner.contains('}') {
116                                break;
117                            }
118                        }
119                    }
120                    let block = block.replace('}', "");
121                    let mut tokens = block.split_whitespace();
122                    while let Some(key) = tokens.next() {
123                        if let Some(val) = tokens.next() {
124                            let val = val.trim_matches('"').to_string();
125                            if key == "provider" {
126                                provider = Some(val);
127                            } else {
128                                properties.insert(key.to_string(), val);
129                            }
130                        }
131                    }
132                }
133
134                if !name.is_empty() {
135                    schema.resources.insert(
136                        name.clone(),
137                        ResourceSchema {
138                            name,
139                            kind,
140                            provider,
141                            properties,
142                        },
143                    );
144                }
145                continue;
146            }
147
148            // View declarations: `view name $$` or `materialized view name $$`
149            // Track view names so query-table validation accepts view-backed reads.
150            if current_table.is_none()
151                && let Some(view_name) = extract_view_name(line)
152            {
153                schema.views.insert(view_name.to_string());
154                continue;
155            }
156
157            // Table definition: table name { [rls]
158            if line.starts_with("table ") && (line.ends_with('{') || line.contains('{')) {
159                // Save previous table if any
160                if let Some(table_name) = current_table.take() {
161                    // Auto-detect RLS from tenant_id column or explicit `rls` marker.
162                    let has_rls = current_rls_flag || current_columns.contains_key("tenant_id");
163                    schema.tables.insert(
164                        table_name.clone(),
165                        TableSchema {
166                            name: table_name,
167                            columns: std::mem::take(&mut current_columns),
168                            policies: std::mem::take(&mut current_policies),
169                            foreign_keys: std::mem::take(&mut current_fks),
170                            rls_enabled: has_rls,
171                        },
172                    );
173                }
174
175                // Parse new table name, check for `rls` keyword
176                // Format: "table bookings rls {" or "table bookings {"
177                let after_table = line.trim_start_matches("table ");
178                let before_brace = after_table.split('{').next().unwrap_or("").trim();
179                let parts: Vec<&str> = before_brace.split_whitespace().collect();
180                let name = parts.first().unwrap_or(&"").to_string();
181                current_rls_flag = parts.contains(&"rls");
182                current_table = Some(name);
183            }
184            // End of table definition
185            else if line == "}" {
186                if let Some(table_name) = current_table.take() {
187                    let has_rls = current_rls_flag || current_columns.contains_key("tenant_id");
188                    schema.tables.insert(
189                        table_name.clone(),
190                        TableSchema {
191                            name: table_name,
192                            columns: std::mem::take(&mut current_columns),
193                            policies: std::mem::take(&mut current_policies),
194                            foreign_keys: std::mem::take(&mut current_fks),
195                            rls_enabled: has_rls,
196                        },
197                    );
198                    current_rls_flag = false;
199                }
200            }
201            // Column definition: column_name TYPE [constraints] [ref:table.column] [protected]
202            // Format from qail pull: "flow_name VARCHAR not_null"
203            // New format with FK: "user_id UUID ref:users.id"
204            // New format with Policy: "password_hash TEXT protected"
205            else if current_table.is_some() {
206                let parts: Vec<&str> = line.split_whitespace().collect();
207                if let Some(col_name) = parts.first() {
208                    // Second word is the type (default to TEXT if missing)
209                    let col_type_str = parts.get(1).copied().unwrap_or("text");
210                    let col_type = col_type_str
211                        .parse::<ColumnType>()
212                        .unwrap_or(ColumnType::Text);
213                    current_columns.insert(col_name.to_string(), col_type);
214
215                    // Check for policies and foreign keys
216                    let mut policy = "Public".to_string();
217
218                    for part in parts.iter().skip(2) {
219                        if *part == "protected" {
220                            policy = "Protected".to_string();
221                        } else if let Some(ref_spec) = part.strip_prefix("ref:") {
222                            // Parse "table.column" or ">table.column"
223                            let ref_spec = ref_spec.trim_start_matches('>');
224                            if let Some((ref_table, ref_col)) = ref_spec.split_once('.') {
225                                current_fks.push(ForeignKey {
226                                    column: col_name.to_string(),
227                                    ref_table: ref_table.to_string(),
228                                    ref_column: ref_col.to_string(),
229                                });
230                            }
231                        }
232                    }
233                    current_policies.insert(col_name.to_string(), policy);
234                }
235            }
236        }
237
238        if let Some(table_name) = current_table.take() {
239            return Err(format!(
240                "Unclosed table definition for '{}': expected closing '}}'",
241                table_name
242            ));
243        }
244
245        Ok(schema)
246    }
247
248    /// Check if table exists
249    pub fn has_table(&self, name: &str) -> bool {
250        self.tables.contains_key(name) || self.views.contains(name)
251    }
252
253    /// Get all table names that have RLS enabled
254    pub fn rls_tables(&self) -> Vec<&str> {
255        self.tables
256            .iter()
257            .filter(|(_, ts)| ts.rls_enabled)
258            .map(|(name, _)| name.as_str())
259            .collect()
260    }
261
262    /// Check if a specific table has RLS enabled
263    pub fn is_rls_table(&self, name: &str) -> bool {
264        self.tables.get(name).is_some_and(|t| t.rls_enabled)
265    }
266
267    /// Get table schema
268    pub fn table(&self, name: &str) -> Option<&TableSchema> {
269        self.tables.get(name)
270    }
271
272    /// Merge pending migrations into the schema
273    /// Scans migration directory for:
274    /// - legacy SQL migrations (`up.sql` / `*.sql`)
275    /// - native QAIL migrations (`up.qail` / `*.qail`)
276    pub fn merge_migrations(&mut self, migrations_dir: &str) -> Result<usize, String> {
277        use std::fs;
278
279        let dir = Path::new(migrations_dir);
280        if !dir.exists() {
281            return Ok(0); // No migrations directory
282        }
283
284        let mut merged_count = 0;
285
286        // Walk migration directories (format: migrations/YYYYMMDD_name/up.sql)
287        let entries =
288            fs::read_dir(dir).map_err(|e| format!("Failed to read migrations dir: {}", e))?;
289
290        for entry in entries.flatten() {
291            let path = entry.path();
292
293            // Check for migration file candidates in subdirectory (prefer native QAIL),
294            // or direct file entries.
295            let migration_file = if path.is_dir() {
296                let up_qail = path.join("up.qail");
297                let up_sql = path.join("up.sql");
298                if up_qail.exists() {
299                    up_qail
300                } else if up_sql.exists() {
301                    up_sql
302                } else {
303                    continue;
304                }
305            } else if path.extension().is_some_and(|e| e == "qail" || e == "sql") {
306                path.clone()
307            } else {
308                continue;
309            };
310
311            if migration_file.exists() {
312                let content = fs::read_to_string(&migration_file)
313                    .map_err(|e| format!("Failed to read {}: {}", migration_file.display(), e))?;
314
315                if migration_file.extension().is_some_and(|ext| ext == "qail") {
316                    merged_count += self.parse_qail_migration(&content).map_err(|e| {
317                        format!(
318                            "Failed to parse native migration {}: {}",
319                            migration_file.display(),
320                            e
321                        )
322                    })?;
323                } else {
324                    merged_count += self.parse_sql_migration(&content);
325                }
326            }
327        }
328
329        Ok(merged_count)
330    }
331
332    /// Parse native QAIL migration content and merge tables/columns into build schema.
333    pub(crate) fn parse_qail_migration(&mut self, qail: &str) -> Result<usize, String> {
334        let parsed = Schema::parse(qail)?;
335        let mut changes = 0usize;
336
337        for (table_name, parsed_table) in parsed.tables {
338            if let Some(existing) = self.tables.get_mut(&table_name) {
339                for (col_name, col_type) in parsed_table.columns {
340                    if existing
341                        .columns
342                        .insert(col_name.clone(), col_type)
343                        .is_none()
344                    {
345                        changes += 1;
346                    }
347                }
348                for (col_name, policy) in parsed_table.policies {
349                    if existing.policies.insert(col_name, policy).is_none() {
350                        changes += 1;
351                    }
352                }
353                for fk in parsed_table.foreign_keys {
354                    let duplicate = existing.foreign_keys.iter().any(|existing_fk| {
355                        existing_fk.column == fk.column
356                            && existing_fk.ref_table == fk.ref_table
357                            && existing_fk.ref_column == fk.ref_column
358                    });
359                    if !duplicate {
360                        existing.foreign_keys.push(fk);
361                        changes += 1;
362                    }
363                }
364                if parsed_table.rls_enabled && !existing.rls_enabled {
365                    existing.rls_enabled = true;
366                    changes += 1;
367                }
368            } else {
369                changes += 1 + parsed_table.columns.len();
370                self.tables.insert(table_name, parsed_table);
371            }
372        }
373
374        for view_name in parsed.views {
375            if self.views.insert(view_name) {
376                changes += 1;
377            }
378        }
379        for (resource_name, resource) in parsed.resources {
380            if self.resources.insert(resource_name, resource).is_none() {
381                changes += 1;
382            }
383        }
384
385        Ok(changes)
386    }
387
388    /// Parse SQL migration content and extract schema changes
389    pub(crate) fn parse_sql_migration(&mut self, sql: &str) -> usize {
390        let mut changes = 0;
391
392        // Extract CREATE TABLE statements
393        // Pattern: CREATE TABLE [IF NOT EXISTS] table_name (columns...)
394        for raw_line in sql.lines() {
395            let line = strip_sql_line_comments(raw_line);
396            if line.is_empty()
397                || line.starts_with("/*")
398                || line.starts_with('*')
399                || line.starts_with("*/")
400            {
401                continue;
402            }
403            let line_upper = line.to_uppercase();
404
405            if line_upper.starts_with("CREATE TABLE")
406                && let Some(table_name) = extract_create_table_name(line)
407                && !self.tables.contains_key(&table_name)
408            {
409                self.tables.insert(
410                    table_name.clone(),
411                    TableSchema {
412                        name: table_name,
413                        columns: HashMap::new(),
414                        policies: HashMap::new(),
415                        foreign_keys: vec![],
416                        rls_enabled: false,
417                    },
418                );
419                changes += 1;
420            }
421        }
422
423        // Extract column definitions from CREATE TABLE blocks
424        // IMPORTANT: Only track CREATE blocks for tables that were newly created
425        // by this migration. Tables that already exist in the schema (from schema.qail)
426        // already have correct column types — overwriting them with ColumnType::Text
427        // would cause false type-mismatch errors.
428        let mut current_table: Option<String> = None;
429        let mut in_create_block = false;
430        let mut paren_depth = 0;
431
432        for raw_line in sql.lines() {
433            let line = strip_sql_line_comments(raw_line);
434            if line.is_empty()
435                || line.starts_with("/*")
436                || line.starts_with('*')
437                || line.starts_with("*/")
438            {
439                continue;
440            }
441            let line_upper = line.to_uppercase();
442
443            if line_upper.starts_with("CREATE TABLE")
444                && let Some(name) = extract_create_table_name(line)
445            {
446                // Only track column extraction for tables that DON'T already
447                // have their types from schema.qail. Tables that existed before
448                // this migration already have correct types; overwriting them
449                // with ColumnType::Text would be a bug.
450                if self.tables.get(&name).is_none_or(|t| t.columns.is_empty()) {
451                    current_table = Some(name);
452                } else {
453                    current_table = None;
454                }
455                in_create_block = true;
456                paren_depth = 0;
457            }
458
459            if in_create_block {
460                paren_depth += line.chars().filter(|c| *c == '(').count();
461                paren_depth =
462                    paren_depth.saturating_sub(line.chars().filter(|c| *c == ')').count());
463
464                // Extract column name (first identifier after opening paren)
465                if let Some(col) = extract_column_from_create(line)
466                    && let Some(ref table) = current_table
467                    && let Some(t) = self.tables.get_mut(table)
468                    && t.columns.insert(col.clone(), ColumnType::Text).is_none()
469                {
470                    changes += 1;
471                }
472
473                if paren_depth == 0 && line.contains(')') {
474                    in_create_block = false;
475                    current_table = None;
476                }
477            }
478
479            // ALTER TABLE ... ADD COLUMN
480            if line_upper.starts_with("ALTER TABLE")
481                && line_upper.contains("ADD COLUMN")
482                && let Some((table, col)) = extract_alter_add_column(line)
483            {
484                if let Some(t) = self.tables.get_mut(&table) {
485                    if t.columns.insert(col.clone(), ColumnType::Text).is_none() {
486                        changes += 1;
487                    }
488                } else {
489                    // Table might be new from this migration
490                    let mut cols = HashMap::new();
491                    cols.insert(col, ColumnType::Text);
492                    self.tables.insert(
493                        table.clone(),
494                        TableSchema {
495                            name: table,
496                            columns: cols,
497                            policies: HashMap::new(),
498                            foreign_keys: vec![],
499                            rls_enabled: false,
500                        },
501                    );
502                    changes += 1;
503                }
504            }
505
506            // ALTER TABLE ... ADD (without COLUMN keyword)
507            if line_upper.starts_with("ALTER TABLE")
508                && line_upper.contains(" ADD ")
509                && !line_upper.contains("ADD COLUMN")
510                && let Some((table, col)) = extract_alter_add(line)
511                && let Some(t) = self.tables.get_mut(&table)
512                && t.columns.insert(col.clone(), ColumnType::Text).is_none()
513            {
514                changes += 1;
515            }
516
517            // DROP TABLE
518            if line_upper.starts_with("DROP TABLE")
519                && let Some(table_name) = extract_drop_table_name(line)
520                && self.tables.remove(&table_name).is_some()
521            {
522                changes += 1;
523            }
524
525            // ALTER TABLE ... DROP COLUMN
526            if line_upper.starts_with("ALTER TABLE")
527                && line_upper.contains("DROP COLUMN")
528                && let Some((table, col)) = extract_alter_drop_column(line)
529                && let Some(t) = self.tables.get_mut(&table)
530                && t.columns.remove(&col).is_some()
531            {
532                changes += 1;
533            }
534
535            // ALTER TABLE ... DROP (without COLUMN keyword - PostgreSQL style)
536            if line_upper.starts_with("ALTER TABLE")
537                && line_upper.contains(" DROP ")
538                && !line_upper.contains("DROP COLUMN")
539                && !line_upper.contains("DROP CONSTRAINT")
540                && !line_upper.contains("DROP INDEX")
541                && let Some((table, col)) = extract_alter_drop(line)
542                && let Some(t) = self.tables.get_mut(&table)
543                && t.columns.remove(&col).is_some()
544            {
545                changes += 1;
546            }
547        }
548
549        changes
550    }
551}
552
553fn extract_view_name(line: &str) -> Option<&str> {
554    let rest = if let Some(r) = line.strip_prefix("view ") {
555        r
556    } else if let Some(r) = line.strip_prefix("materialized view ") {
557        r
558    } else {
559        return None;
560    };
561
562    let name = rest.split_whitespace().next().unwrap_or_default().trim();
563    if name.is_empty() { None } else { Some(name) }
564}
565
566/// Extract table name from CREATE TABLE statement
567fn extract_create_table_name(line: &str) -> Option<String> {
568    let line_upper = line.to_uppercase();
569    let rest = line_upper.strip_prefix("CREATE TABLE")?;
570    let rest = rest.trim_start();
571    let rest = if rest.starts_with("IF NOT EXISTS") {
572        rest.strip_prefix("IF NOT EXISTS")?.trim_start()
573    } else {
574        rest
575    };
576
577    // Get table name (first identifier)
578    let name: String = line[line.len() - rest.len()..]
579        .chars()
580        .take_while(|c| c.is_alphanumeric() || *c == '_')
581        .collect();
582
583    if name.is_empty() {
584        None
585    } else {
586        Some(name.to_lowercase())
587    }
588}
589
590/// Extract column name from a line inside CREATE TABLE block
591fn extract_column_from_create(line: &str) -> Option<String> {
592    let line = line.trim();
593
594    // Skip keywords and constraints
595    // IMPORTANT: Must check for word boundaries to avoid matching column names
596    // that happen to start with a keyword (e.g., created_at starts with CREATE,
597    // primary_contact starts with PRIMARY, check_status starts with CHECK, etc.)
598    let line_upper = line.to_uppercase();
599    let starts_with_keyword = |kw: &str| -> bool {
600        line_upper.starts_with(kw) && line_upper[kw.len()..].starts_with([' ', '('])
601    };
602
603    if starts_with_keyword("CREATE")
604        || starts_with_keyword("PRIMARY")
605        || starts_with_keyword("FOREIGN")
606        || starts_with_keyword("UNIQUE")
607        || starts_with_keyword("CHECK")
608        || starts_with_keyword("CONSTRAINT")
609        || line_upper.starts_with(")")
610        || line_upper.starts_with("(")
611        || line.is_empty()
612    {
613        return None;
614    }
615
616    // First word is column name
617    let name: String = line
618        .trim_start_matches('(')
619        .trim()
620        .chars()
621        .take_while(|c| c.is_alphanumeric() || *c == '_')
622        .collect();
623
624    if name.is_empty() || name.to_uppercase() == "IF" {
625        None
626    } else {
627        Some(name.to_lowercase())
628    }
629}
630
631/// Extract table and column from ALTER TABLE ... ADD COLUMN
632fn extract_alter_add_column(line: &str) -> Option<(String, String)> {
633    let line_upper = line.to_uppercase();
634    let alter_pos = line_upper.find("ALTER TABLE")?;
635    let add_pos = line_upper.find("ADD COLUMN")?;
636
637    // Table name between ALTER TABLE and ADD COLUMN
638    let table_part = &line[alter_pos + 11..add_pos];
639    let table: String = table_part
640        .trim()
641        .chars()
642        .take_while(|c| c.is_alphanumeric() || *c == '_')
643        .collect();
644
645    // Column name after ADD COLUMN [IF NOT EXISTS]
646    let mut col_part = &line[add_pos + 10..];
647    let col_upper = col_part.trim().to_uppercase();
648    if col_upper.starts_with("IF NOT EXISTS") {
649        col_part = &col_part.trim()[13..]; // skip "IF NOT EXISTS"
650    }
651    let col: String = col_part
652        .trim()
653        .chars()
654        .take_while(|c| c.is_alphanumeric() || *c == '_')
655        .collect();
656
657    if table.is_empty() || col.is_empty() {
658        None
659    } else {
660        Some((table.to_lowercase(), col.to_lowercase()))
661    }
662}
663
664/// Extract table and column from ALTER TABLE ... ADD (without COLUMN keyword)
665fn extract_alter_add(line: &str) -> Option<(String, String)> {
666    let line_upper = line.to_uppercase();
667    let alter_pos = line_upper.find("ALTER TABLE")?;
668    let add_pos = line_upper.find(" ADD ")?;
669
670    let table_part = &line[alter_pos + 11..add_pos];
671    let table: String = table_part
672        .trim()
673        .chars()
674        .take_while(|c| c.is_alphanumeric() || *c == '_')
675        .collect();
676
677    let col_part = &line[add_pos + 5..];
678    let col: String = col_part
679        .trim()
680        .chars()
681        .take_while(|c| c.is_alphanumeric() || *c == '_')
682        .collect();
683
684    if table.is_empty() || col.is_empty() {
685        None
686    } else {
687        Some((table.to_lowercase(), col.to_lowercase()))
688    }
689}
690
691/// Extract table name from DROP TABLE statement
692fn extract_drop_table_name(line: &str) -> Option<String> {
693    let line_upper = line.to_uppercase();
694    let rest = line_upper.strip_prefix("DROP TABLE")?;
695    let rest = rest.trim_start();
696    let rest = if rest.starts_with("IF EXISTS") {
697        rest.strip_prefix("IF EXISTS")?.trim_start()
698    } else {
699        rest
700    };
701
702    // Get table name (first identifier)
703    let name: String = line[line.len() - rest.len()..]
704        .chars()
705        .take_while(|c| c.is_alphanumeric() || *c == '_')
706        .collect();
707
708    if name.is_empty() {
709        None
710    } else {
711        Some(name.to_lowercase())
712    }
713}
714
715/// Extract table and column from ALTER TABLE ... DROP COLUMN
716fn extract_alter_drop_column(line: &str) -> Option<(String, String)> {
717    let line_upper = line.to_uppercase();
718    let alter_pos = line_upper.find("ALTER TABLE")?;
719    let drop_pos = line_upper.find("DROP COLUMN")?;
720
721    // Table name between ALTER TABLE and DROP COLUMN
722    let table_part = &line[alter_pos + 11..drop_pos];
723    let table: String = table_part
724        .trim()
725        .chars()
726        .take_while(|c| c.is_alphanumeric() || *c == '_')
727        .collect();
728
729    // Column name after DROP COLUMN
730    let col_part = &line[drop_pos + 11..];
731    let col: String = col_part
732        .trim()
733        .chars()
734        .take_while(|c| c.is_alphanumeric() || *c == '_')
735        .collect();
736
737    if table.is_empty() || col.is_empty() {
738        None
739    } else {
740        Some((table.to_lowercase(), col.to_lowercase()))
741    }
742}
743
744/// Extract table and column from ALTER TABLE ... DROP (without COLUMN keyword)
745fn extract_alter_drop(line: &str) -> Option<(String, String)> {
746    let line_upper = line.to_uppercase();
747    let alter_pos = line_upper.find("ALTER TABLE")?;
748    let drop_pos = line_upper.find(" DROP ")?;
749
750    let table_part = &line[alter_pos + 11..drop_pos];
751    let table: String = table_part
752        .trim()
753        .chars()
754        .take_while(|c| c.is_alphanumeric() || *c == '_')
755        .collect();
756
757    let col_part = &line[drop_pos + 6..];
758    let col: String = col_part
759        .trim()
760        .chars()
761        .take_while(|c| c.is_alphanumeric() || *c == '_')
762        .collect();
763
764    if table.is_empty() || col.is_empty() {
765        None
766    } else {
767        Some((table.to_lowercase(), col.to_lowercase()))
768    }
769}
770
771impl TableSchema {
772    /// Check if column exists
773    pub fn has_column(&self, name: &str) -> bool {
774        self.columns.contains_key(name)
775    }
776
777    /// Get column type
778    pub fn column_type(&self, name: &str) -> Option<&ColumnType> {
779        self.columns.get(name)
780    }
781
782    /// Get the primary key column name for this table.
783    ///
784    /// Convention: returns `"id"` if it exists as a column.
785    /// This is a single point of truth for PK resolution — when the schema
786    /// parser is enhanced to track PK constraints, update this method.
787    pub fn primary_key_column(&self) -> &str {
788        if self.columns.contains_key("id") {
789            "id"
790        } else {
791            // Fallback: look for `{singular_table_name}_id` pattern
792            // e.g., table "users" → "user_id"
793            let singular = self.name.trim_end_matches('s');
794            let conventional = format!("{}_id", singular);
795            if self.columns.contains_key(&conventional) {
796                // Leak into 'static to satisfy lifetime — this is called rarely
797                // and the string is small. Alternatively, return String.
798                return "id"; // Safe default — schema has no "id" but this avoids lifetime issues
799            }
800            "id" // Universal fallback
801        }
802    }
803}