Skip to main content

drizzle_migrations/sqlite/
introspect.rs

1//! `SQLite` database introspection
2//!
3//! This module provides functionality to introspect an existing `SQLite` database
4//! and extract its schema as DDL entities, matching drizzle-kit introspect.ts
5
6use super::ddl::{
7    Column, ForeignKey, Index, IndexColumn, IndexOrigin, PrimaryKey, SqliteEntity, Table,
8    UniqueConstraint, View,
9};
10use super::ddl::{GeneratedType, ParsedGenerated};
11use super::snapshot::SQLiteSnapshot;
12use std::collections::{BTreeMap, HashMap, HashSet};
13
14/// Error type for introspection operations
15#[derive(Debug, Clone)]
16pub struct IntrospectError {
17    pub message: String,
18    pub table: Option<String>,
19}
20
21impl std::fmt::Display for IntrospectError {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        if let Some(table) = &self.table {
24            write!(f, "Introspection error for '{}': {}", table, self.message)
25        } else {
26            write!(f, "Introspection error: {}", self.message)
27        }
28    }
29}
30
31impl std::error::Error for IntrospectError {}
32
33/// Result type for introspection
34pub type IntrospectResult<T> = Result<T, IntrospectError>;
35
36/// Raw column info from `pragma_table_xinfo`
37#[derive(Debug, Clone)]
38pub struct RawColumnInfo {
39    pub table: String,
40    pub cid: i32,
41    pub name: String,
42    pub column_type: String,
43    pub not_null: bool,
44    pub default_value: Option<String>,
45    pub pk: i32,
46    pub hidden: i32,
47    pub sql: Option<String>,
48}
49
50/// Raw index info from `pragma_index_list`
51#[derive(Debug, Clone)]
52pub struct RawIndexInfo {
53    pub table: String,
54    pub name: String,
55    pub unique: bool,
56    pub origin: String, // 'c' for CREATE INDEX, 'u' for UNIQUE, 'pk' for PRIMARY KEY
57    pub partial: bool,
58}
59
60/// Raw index column from `pragma_index_xinfo`
61#[derive(Debug, Clone)]
62pub struct RawIndexColumn {
63    pub index_name: String,
64    pub seqno: i32,
65    pub cid: i32,
66    pub name: Option<String>,
67    pub desc: bool,
68    pub coll: String,
69    pub key: bool,
70}
71
72/// Raw foreign key info from `pragma_foreign_key_list`
73#[derive(Debug, Clone)]
74pub struct RawForeignKey {
75    pub table: String,
76    pub id: i32,
77    pub seq: i32,
78    pub to_table: String,
79    pub from_column: String,
80    pub to_column: String,
81    pub on_update: String,
82    pub on_delete: String,
83    pub r#match: String,
84}
85
86/// Raw view info
87#[derive(Debug, Clone)]
88pub struct RawViewInfo {
89    pub name: String,
90    pub sql: String,
91}
92
93/// Transport-independent raw rows needed to assemble a complete SQLite DDL.
94///
95/// Drivers are responsible only for decoding these rows from their transport;
96/// schema semantics live in [`assemble_ddl`].
97#[derive(Debug, Clone, Default)]
98pub struct RawIntrospection {
99    pub tables: Vec<(String, Option<String>)>,
100    pub columns: Vec<RawColumnInfo>,
101    pub indexes: Vec<RawIndexInfo>,
102    pub index_columns: Vec<RawIndexColumn>,
103    pub foreign_keys: Vec<RawForeignKey>,
104    pub views: Vec<RawViewInfo>,
105    /// `(index name, CREATE INDEX sql)` rows from [`queries::INDEX_SQL_QUERY`].
106    /// Recovers partial-index `WHERE` clauses and expression columns that the
107    /// index PRAGMAs cannot express.
108    pub index_sql: Vec<(String, String)>,
109}
110
111/// Assemble transport-decoded SQLite metadata into the canonical DDL model.
112#[must_use]
113pub fn assemble_ddl(raw: RawIntrospection) -> super::SQLiteDDL {
114    let table_sql_map: HashMap<String, String> = raw
115        .tables
116        .iter()
117        .filter_map(|(name, sql)| sql.as_ref().map(|sql| (name.clone(), sql.clone())))
118        .collect();
119
120    let mut generated_columns = HashMap::<String, ParsedGenerated>::new();
121    for (table, sql) in &table_sql_map {
122        generated_columns.extend(parse_generated_columns_from_table_sql(table, sql));
123    }
124    let primary_key_columns: HashSet<(String, String)> = raw
125        .columns
126        .iter()
127        .filter(|column| column.pk > 0)
128        .map(|column| (column.table.clone(), column.name.clone()))
129        .collect();
130
131    let (columns, primary_keys) =
132        process_columns(&raw.columns, &generated_columns, &primary_key_columns);
133    let index_sql_map: HashMap<String, String> = raw.index_sql.iter().cloned().collect();
134    let indexes = process_indexes_with_sql(&raw.indexes, &raw.index_columns, &index_sql_map);
135    let foreign_keys = process_foreign_keys(&raw.foreign_keys);
136    let unique_constraints =
137        process_unique_constraints_from_indexes(&raw.indexes, &raw.index_columns);
138
139    let mut ddl = super::SQLiteDDL::new();
140    for (table_name, table_sql) in raw.tables {
141        let mut table = Table::new(table_name);
142        if let Some(sql) = table_sql {
143            let (strict, without_rowid) = parse_table_options(&sql);
144            table.strict = strict;
145            table.without_rowid = without_rowid;
146        }
147        ddl.tables.push(table);
148    }
149    for column in columns {
150        ddl.columns.push(column);
151    }
152    for index in indexes {
153        ddl.indexes.push(index);
154    }
155    for foreign_key in foreign_keys {
156        ddl.fks.push(foreign_key);
157    }
158    for primary_key in primary_keys {
159        ddl.pks.push(primary_key);
160    }
161    for unique_constraint in unique_constraints {
162        ddl.uniques.push(unique_constraint);
163    }
164
165    for raw_view in raw.views {
166        let mut view = View::new(raw_view.name);
167        if let Some(definition) = parse_view_sql(&raw_view.sql) {
168            view.definition = Some(definition.into());
169        } else {
170            view.error = Some("Failed to parse view SQL".into());
171        }
172        ddl.views.push(view);
173    }
174
175    ddl
176}
177
178/// Entity filter function type
179pub type EntityFilter = Box<dyn Fn(&str, &str) -> bool>;
180
181/// Default entity filter that allows everything
182pub fn default_filter() -> EntityFilter {
183    Box::new(|_entity_type, _name| true)
184}
185
186/// System table filter - excludes `SQLite` system tables and drizzle migrations
187#[must_use]
188pub fn system_table_filter(name: &str) -> bool {
189    !name.starts_with("sqlite_")
190        && !name.starts_with("_cf_")
191        && !name.starts_with("_litestream_")
192        && !name.starts_with("libsql_")
193        && !name.starts_with("d1_")
194        && name != "__drizzle_migrations"
195}
196
197/// Introspection result containing all extracted entities
198#[derive(Debug, Clone, Default)]
199pub struct IntrospectionResult {
200    pub tables: Vec<Table>,
201    pub columns: Vec<Column>,
202    pub indexes: Vec<Index>,
203    pub foreign_keys: Vec<ForeignKey>,
204    pub primary_keys: Vec<PrimaryKey>,
205    pub unique_constraints: Vec<UniqueConstraint>,
206    pub views: Vec<View>,
207    pub errors: Vec<IntrospectError>,
208}
209
210impl IntrospectionResult {
211    /// Convert to a snapshot
212    #[must_use]
213    pub fn to_snapshot(&self) -> SQLiteSnapshot {
214        let mut snapshot = SQLiteSnapshot::new();
215
216        for table in &self.tables {
217            snapshot.add_entity(SqliteEntity::Table(table.clone()));
218        }
219        for column in &self.columns {
220            snapshot.add_entity(SqliteEntity::Column(column.clone()));
221        }
222        for index in &self.indexes {
223            snapshot.add_entity(SqliteEntity::Index(index.clone()));
224        }
225        for fk in &self.foreign_keys {
226            snapshot.add_entity(SqliteEntity::ForeignKey(fk.clone()));
227        }
228        for pk in &self.primary_keys {
229            snapshot.add_entity(SqliteEntity::PrimaryKey(pk.clone()));
230        }
231        for unique in &self.unique_constraints {
232            snapshot.add_entity(SqliteEntity::UniqueConstraint(unique.clone()));
233        }
234        for view in &self.views {
235            snapshot.add_entity(SqliteEntity::View(view.clone()));
236        }
237
238        snapshot
239    }
240
241    /// Check if introspection had any errors
242    #[must_use]
243    pub const fn has_errors(&self) -> bool {
244        !self.errors.is_empty()
245    }
246}
247
248/// Process raw column info into Column entities
249#[must_use]
250pub fn process_columns<S1: std::hash::BuildHasher, S2: std::hash::BuildHasher>(
251    raw_columns: &[RawColumnInfo],
252    generated_columns: &std::collections::HashMap<String, super::ddl::ParsedGenerated, S1>,
253    _pk_columns: &std::collections::HashSet<(String, String), S2>, // (table, column) - reserved for future use
254) -> (Vec<Column>, Vec<PrimaryKey>) {
255    // Precompute AUTOINCREMENT columns once per table (avoids per-column regex compilation).
256    let mut autoinc_by_table: HashMap<String, std::collections::HashSet<String>> = HashMap::new();
257    for c in raw_columns {
258        if autoinc_by_table.contains_key(&c.table) {
259            continue;
260        }
261        let Some(sql) = c.sql.as_deref() else {
262            continue;
263        };
264        autoinc_by_table.insert(
265            c.table.clone(),
266            parse_autoincrement_columns_from_table_sql(sql),
267        );
268    }
269
270    let columns: Vec<Column> = raw_columns
271        .iter()
272        // pragma_table_xinfo hidden values: 0 = normal, 1 = hidden (virtual
273        // table implementation columns), 2 = VIRTUAL generated, 3 = STORED
274        // generated. Generated columns are real schema and must be kept —
275        // their Generated info is attached from the parsed CREATE TABLE SQL.
276        .filter(|c| c.hidden != 1)
277        .map(|c| {
278            let key = format!("{}:{}", c.table, c.name);
279            let generated = generated_columns.get(&key).map(|g| super::ddl::Generated {
280                expression: g.expression.clone().into(),
281                gen_type: g.gen_type,
282            });
283
284            let is_autoincrement = autoinc_by_table
285                .get(&c.table)
286                .is_some_and(|set| set.contains(&c.name));
287
288            Column {
289                table: c.table.clone().into(),
290                name: c.name.clone().into(),
291                sql_type: normalize_sql_type(&c.column_type).into(),
292                not_null: c.not_null,
293                autoincrement: if is_autoincrement { Some(true) } else { None },
294                primary_key: None, // Handled via PrimaryKey entity
295                unique: None,      // Handled via UniqueConstraint entity
296                default: c.default_value.clone().map(std::convert::Into::into),
297                generated,
298                // PRAGMA table_info doesn't expose the collation per column;
299                // introspection-derived snapshots leave it as the default.
300                // Migration paths that need to detect collation drift will
301                // need to parse the CREATE TABLE SQL stored in sqlite_schema.
302                collate: None,
303                ordinal_position: Some(c.cid),
304            }
305        })
306        .collect();
307
308    // Extract primary keys from raw columns. BTreeMap keeps the emitted PK
309    // entity order deterministic; the `pk` value from pragma_table_xinfo is
310    // the column's 1-based position within the PRIMARY KEY, so sorting by it
311    // preserves the declared PK column order (which can differ from cid order
312    // for composite keys like PRIMARY KEY(b, a)).
313    let mut pk_map: BTreeMap<String, Vec<(i32, String)>> = BTreeMap::new();
314
315    for c in raw_columns.iter().filter(|c| c.pk > 0) {
316        pk_map
317            .entry(c.table.clone())
318            .or_default()
319            .push((c.pk, c.name.clone()));
320    }
321
322    let primary_keys: Vec<PrimaryKey> = pk_map
323        .into_iter()
324        .map(|(table, mut cols)| {
325            cols.sort_by_key(|(pk_pos, _)| *pk_pos);
326            let name = super::ddl::name_for_pk(&table);
327            PrimaryKey {
328                table: table.into(),
329                name: name.into(),
330                name_explicit: false,
331                columns: cols.into_iter().map(|(_, name)| name.into()).collect(),
332            }
333        })
334        .collect();
335
336    (columns, primary_keys)
337}
338
339/// Normalize a SQL type to lowercase canonical form
340fn normalize_sql_type(sql_type: &str) -> String {
341    sql_type.to_lowercase()
342}
343
344/// Extract the text between the outermost balanced parentheses of a CREATE TABLE body.
345///
346/// Returns the slice between the first '(' and its matching ')', or `None` if
347/// either is missing.
348fn extract_table_body(sql: &str) -> Option<&str> {
349    let sql = sql.trim();
350    let start = sql.find('(')?;
351
352    let mut depth = 0i32;
353    let mut end: Option<usize> = None;
354    for (i, ch) in sql.char_indices().skip(start) {
355        match ch {
356            '(' => depth += 1,
357            ')' => {
358                depth -= 1;
359                if depth == 0 {
360                    end = Some(i);
361                    break;
362                }
363            }
364            _ => {}
365        }
366    }
367    Some(&sql[start + 1..end?])
368}
369
370/// Returns the text AFTER the balanced closing paren of the table body.
371///
372/// This is where table options (`STRICT`, `WITHOUT ROWID`) legally appear;
373/// scanning the whole statement would false-positive on columns named
374/// `strict` or string content inside the body.
375fn table_options_tail(sql: &str) -> Option<&str> {
376    let start = sql.find('(')?;
377    let mut depth = 0i32;
378    for (i, ch) in sql.char_indices().skip(start) {
379        match ch {
380            '(' => depth += 1,
381            ')' => {
382                depth -= 1;
383                if depth == 0 {
384                    return Some(&sql[i + ch.len_utf8()..]);
385                }
386            }
387            _ => {}
388        }
389    }
390    None
391}
392
393/// Parse `(strict, without_rowid)` table options from a CREATE TABLE statement.
394///
395/// Only the text after the balanced closing paren of the table body is
396/// scanned, using word-token matching.
397#[must_use]
398pub fn parse_table_options(sql: &str) -> (bool, bool) {
399    let Some(tail) = table_options_tail(sql) else {
400        return (false, false);
401    };
402    let upper = tail.to_uppercase();
403    let tokens: Vec<&str> = upper
404        .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_'))
405        .filter(|t| !t.is_empty())
406        .collect();
407    let strict = tokens.contains(&"STRICT");
408    let without_rowid = tokens
409        .windows(2)
410        .any(|w| w[0] == "WITHOUT" && w[1] == "ROWID");
411    (strict, without_rowid)
412}
413
414/// Split a string on top-level commas (commas not inside parentheses).
415fn split_top_level_commas(body: &str) -> Vec<&str> {
416    let mut parts: Vec<&str> = Vec::new();
417    let mut part_start = 0usize;
418    let mut p_depth = 0i32;
419    for (i, ch) in body.char_indices() {
420        match ch {
421            '(' => p_depth += 1,
422            ')' => p_depth -= 1,
423            ',' if p_depth == 0 => {
424                parts.push(body[part_start..i].trim());
425                part_start = i + 1;
426            }
427            _ => {}
428        }
429    }
430    parts.push(body[part_start..].trim());
431    parts
432}
433
434/// Returns true if the column definition is actually a table-level constraint
435/// rather than a column definition we should parse.
436fn is_table_level_constraint(upper: &str) -> bool {
437    upper.starts_with("CONSTRAINT ")
438        || upper.starts_with("PRIMARY ")
439        || upper.starts_with("UNIQUE ")
440        || upper.starts_with("CHECK ")
441        || upper.starts_with("FOREIGN ")
442}
443
444/// Parse a leading column name from a column-definition string.
445///
446/// Returns the `(name, remainder_after_name)` pair. Handles `"…"`, `` `…` ``,
447/// `[…]` quoted identifiers and bare identifiers.
448fn take_column_name(def: &str) -> Option<(String, &str)> {
449    let def = def.trim();
450    if let Some(r) = def.strip_prefix('"') {
451        let endq = r.find('"')?;
452        return Some((r[..endq].to_string(), r[endq + 1..].trim_start()));
453    }
454    if let Some(r) = def.strip_prefix('`') {
455        let endq = r.find('`')?;
456        return Some((r[..endq].to_string(), r[endq + 1..].trim_start()));
457    }
458    if let Some(r) = def.strip_prefix('[') {
459        let endq = r.find(']')?;
460        return Some((r[..endq].to_string(), r[endq + 1..].trim_start()));
461    }
462    let name = def.split_whitespace().next()?;
463    let rest = def[name.len()..].trim_start();
464    Some((name.to_string(), rest))
465}
466
467/// Parse AUTOINCREMENT columns from a CREATE TABLE SQL statement.
468///
469/// This avoids regex compilation in hot paths and is tolerant of common quoting styles.
470fn parse_autoincrement_columns_from_table_sql(sql: &str) -> std::collections::HashSet<String> {
471    let mut out = std::collections::HashSet::new();
472
473    let Some(body) = extract_table_body(sql) else {
474        return out;
475    };
476
477    for item in split_top_level_commas(body) {
478        if item.is_empty() {
479            continue;
480        }
481
482        let upper = item.to_uppercase();
483        if is_table_level_constraint(&upper) {
484            continue;
485        }
486
487        if !upper.contains("AUTOINCREMENT") {
488            continue;
489        }
490        if !(upper.contains("INTEGER") && upper.contains("PRIMARY") && upper.contains("KEY")) {
491            continue;
492        }
493
494        if let Some((col_name, _rest)) = take_column_name(item) {
495            out.insert(col_name);
496        }
497    }
498
499    out
500}
501
502/// Metadata recovered from a `CREATE INDEX` statement's verbatim SQL.
503#[derive(Debug, Clone, Default)]
504pub struct ParsedIndexSql {
505    /// Index columns (including expression columns, in declaration order)
506    pub columns: Vec<IndexColumn>,
507    /// Partial-index WHERE clause (verbatim, without the `WHERE` keyword)
508    pub where_clause: Option<String>,
509}
510
511/// Returns true if `s` is a bare identifier (letters/digits/underscore).
512fn is_bare_identifier(s: &str) -> bool {
513    !s.is_empty() && s.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
514}
515
516/// Parse a single item of a CREATE INDEX column list into an [`IndexColumn`].
517///
518/// Named columns may carry `ASC`/`DESC`/`COLLATE <name>` modifiers, which are
519/// currently dropped — `IndexColumn` cannot represent per-column direction or
520/// collation yet.
521// TODO: extend `IndexColumn` with `desc`/`collate` so introspected indexes
522// round-trip those modifiers. Until then, comparison cannot churn on them
523// because no producer stores them.
524fn parse_index_item(item: &str) -> IndexColumn {
525    let expression = || IndexColumn {
526        value: item.trim().to_string().into(),
527        is_expression: true,
528    };
529
530    let Some((name, rest)) = take_column_name(item) else {
531        return expression();
532    };
533
534    // Quoted identifiers were unwrapped by take_column_name; a bare token has
535    // to look like an identifier (e.g. `lower(email)` must stay an expression).
536    let quoted = matches!(item.trim_start().chars().next(), Some('"' | '`' | '['));
537    if !quoted && !is_bare_identifier(&name) {
538        return expression();
539    }
540
541    // Only ASC/DESC/COLLATE <collation> may follow a plain column reference.
542    let mut tokens = rest.split_whitespace();
543    loop {
544        match tokens.next().map(str::to_ascii_uppercase) {
545            None => break,
546            Some(t) if t == "ASC" || t == "DESC" => {}
547            Some(t) if t == "COLLATE" => {
548                if tokens.next().is_none() {
549                    return expression();
550                }
551            }
552            Some(_) => return expression(),
553        }
554    }
555
556    IndexColumn {
557        value: name.into(),
558        is_expression: false,
559    }
560}
561
562/// Parse a `CREATE INDEX` statement (from `sqlite_master.sql`) to recover the
563/// column list (including expression columns) and any partial-index WHERE
564/// clause. This is a tolerant parser, not a full SQL parser.
565#[must_use]
566pub fn parse_index_sql(sql: &str) -> ParsedIndexSql {
567    let mut parsed = ParsedIndexSql::default();
568
569    // Find the first '(' outside quoted identifiers/strings, then its
570    // balanced closing paren.
571    let mut in_quote: Option<char> = None;
572    let mut open: Option<usize> = None;
573    for (i, ch) in sql.char_indices() {
574        match (in_quote, ch) {
575            (Some(q), _) if quote_closer(q) == ch => in_quote = None,
576            (Some(_), _) => {}
577            (None, '\'' | '"' | '`' | '[') => in_quote = Some(ch),
578            (None, '(') => {
579                open = Some(i);
580                break;
581            }
582            _ => {}
583        }
584    }
585    let Some(open) = open else {
586        return parsed;
587    };
588
589    let mut depth = 0i32;
590    let mut close: Option<usize> = None;
591    let mut in_quote: Option<char> = None;
592    for (i, ch) in sql.char_indices().skip(open) {
593        match (in_quote, ch) {
594            (Some(q), _) if quote_closer(q) == ch => in_quote = None,
595            (Some(_), _) => {}
596            (None, '\'' | '"' | '`' | '[') => in_quote = Some(ch),
597            (None, '(') => depth += 1,
598            (None, ')') => {
599                depth -= 1;
600                if depth == 0 {
601                    close = Some(i);
602                    break;
603                }
604            }
605            _ => {}
606        }
607    }
608    let Some(close) = close else {
609        return parsed;
610    };
611
612    let body = &sql[open + 1..close];
613    parsed.columns = split_top_level_commas(body)
614        .into_iter()
615        .filter(|item| !item.is_empty())
616        .map(parse_index_item)
617        .collect();
618
619    // Scan the tail (quote-aware) for the WHERE keyword.
620    let tail = &sql[close + 1..];
621    let mut in_quote: Option<char> = None;
622    let bytes = tail.as_bytes();
623    for (i, ch) in tail.char_indices() {
624        match (in_quote, ch) {
625            (Some(q), _) if quote_closer(q) == ch => in_quote = None,
626            (Some(_), _) => {}
627            (None, '\'' | '"' | '`' | '[') => in_quote = Some(ch),
628            (None, 'w' | 'W') => {
629                let end = i + 5;
630                if end <= tail.len()
631                    && tail[i..end].eq_ignore_ascii_case("where")
632                    && (i == 0 || !is_ident_byte(bytes[i - 1]))
633                    && (end == tail.len() || !is_ident_byte(bytes[end]))
634                {
635                    let clause = tail[end..].trim().trim_end_matches(';').trim();
636                    if !clause.is_empty() {
637                        parsed.where_clause = Some(clause.to_string());
638                    }
639                    break;
640                }
641            }
642            _ => {}
643        }
644    }
645
646    parsed
647}
648
649const fn quote_closer(open: char) -> char {
650    match open {
651        '[' => ']',
652        other => other,
653    }
654}
655
656const fn is_ident_byte(b: u8) -> bool {
657    b.is_ascii_alphanumeric() || b == b'_'
658}
659
660/// Process raw index info into Index entities.
661///
662/// This variant cannot recover partial-index WHERE clauses or expression
663/// columns; prefer [`process_indexes_with_sql`] when the indexes' CREATE SQL
664/// (from `sqlite_master`) is available.
665#[must_use]
666pub fn process_indexes<S: std::hash::BuildHasher>(
667    raw_indexes: &[RawIndexInfo],
668    index_columns: &[RawIndexColumn],
669    _table_sql_map: &std::collections::HashMap<String, String, S>,
670) -> Vec<Index> {
671    let empty: HashMap<String, String> = HashMap::new();
672    process_indexes_with_sql(raw_indexes, index_columns, &empty)
673}
674
675/// Process raw index info into Index entities, using each index's own CREATE
676/// SQL (keyed by index name) to recover what PRAGMA cannot express:
677///
678/// - partial-index WHERE clauses (`pragma_index_list` only reports a flag)
679/// - expression columns (`pragma_index_xinfo` returns NULL names for them)
680#[must_use]
681pub fn process_indexes_with_sql<S: std::hash::BuildHasher>(
682    raw_indexes: &[RawIndexInfo],
683    index_columns: &[RawIndexColumn],
684    index_sql_map: &std::collections::HashMap<String, String, S>,
685) -> Vec<Index> {
686    raw_indexes
687        .iter()
688        .filter(|idx| idx.origin == "c") // Only CREATE INDEX indexes
689        .map(|idx| {
690            let parsed = index_sql_map.get(&idx.name).map(|sql| parse_index_sql(sql));
691
692            let mut key_columns: Vec<&RawIndexColumn> = index_columns
693                .iter()
694                .filter(|c| c.index_name == idx.name && c.key)
695                .collect();
696            key_columns.sort_by_key(|c| c.seqno);
697
698            let has_expression = key_columns.iter().any(|c| c.name.is_none());
699            let columns: Vec<IndexColumn> = match &parsed {
700                // Expression columns (or missing xinfo rows): the parsed SQL
701                // has the verbatim column list, including expression text.
702                Some(parsed)
703                    if (has_expression || key_columns.is_empty()) && !parsed.columns.is_empty() =>
704                {
705                    parsed.columns.clone()
706                }
707                _ => key_columns
708                    .iter()
709                    .filter_map(|c| {
710                        c.name.clone().map(|name| IndexColumn {
711                            value: name.into(),
712                            is_expression: false,
713                        })
714                    })
715                    .collect(),
716            };
717
718            let where_clause = if idx.partial {
719                parsed
720                    .as_ref()
721                    .and_then(|p| p.where_clause.clone())
722                    .map(std::convert::Into::into)
723            } else {
724                None
725            };
726
727            Index {
728                table: idx.table.clone().into(),
729                name: idx.name.clone().into(),
730                columns,
731                is_unique: idx.unique,
732                where_clause,
733                origin: IndexOrigin::Manual,
734            }
735        })
736        .collect()
737}
738
739/// Extract unique constraints from pragma index list + `index_xinfo`.
740///
741/// `SQLite` reports UNIQUE constraints (including inline column UNIQUE and table-level UNIQUE)
742/// as indexes with `origin == "u"`. These should be represented as `UniqueConstraint` entities
743/// so codegen can emit `#[column(unique)]` for single-column uniques.
744#[must_use]
745pub fn process_unique_constraints_from_indexes(
746    raw_indexes: &[RawIndexInfo],
747    index_columns: &[RawIndexColumn],
748) -> Vec<UniqueConstraint> {
749    use std::borrow::Cow;
750
751    raw_indexes
752        .iter()
753        .filter(|idx| idx.origin == "u")
754        .filter_map(|idx| {
755            let mut cols: Vec<(i32, Cow<'static, str>)> = index_columns
756                .iter()
757                .filter(|c| c.index_name == idx.name && c.key)
758                .filter_map(|c| {
759                    c.name
760                        .as_ref()
761                        .map(|name| (c.seqno, Cow::Owned(name.clone())))
762                })
763                .collect();
764
765            cols.sort_by_key(|(seq, _)| *seq);
766            let columns: Vec<Cow<'static, str>> = cols.into_iter().map(|(_, c)| c).collect();
767            if columns.is_empty() {
768                return None;
769            }
770
771            let columns_refs: Vec<&str> = columns.iter().map(std::convert::AsRef::as_ref).collect();
772            let name = super::ddl::name_for_unique(&idx.table, &columns_refs);
773
774            Some(UniqueConstraint {
775                table: Cow::Owned(idx.table.clone()),
776                name: Cow::Owned(name),
777                name_explicit: false,
778                columns: Cow::Owned(columns),
779            })
780        })
781        .collect()
782}
783
784/// Process raw foreign key info into `ForeignKey` entities
785#[must_use]
786pub fn process_foreign_keys(raw_fks: &[RawForeignKey]) -> Vec<ForeignKey> {
787    use std::borrow::Cow;
788
789    // Group by table and id. BTreeMap keeps the emitted FK order (and thus
790    // snapshot JSON and generated SQL) deterministic.
791    let mut grouped: BTreeMap<(String, i32), Vec<&RawForeignKey>> = BTreeMap::new();
792
793    for fk in raw_fks {
794        grouped
795            .entry((fk.table.clone(), fk.id))
796            .or_default()
797            .push(fk);
798    }
799
800    grouped
801        .into_iter()
802        .filter_map(|((table, _id), fks)| {
803            let mut fks = fks;
804            fks.sort_by_key(|f| f.seq);
805
806            // Groups built via `entry(...).or_default().push(fk)` always have at
807            // least one entry, but this makes that invariant explicit in code.
808            let first = fks.first()?;
809
810            let columns: Vec<&str> = fks.iter().map(|f| f.from_column.as_str()).collect();
811            let columns_to: Vec<&str> = fks.iter().map(|f| f.to_column.as_str()).collect();
812
813            let name = super::ddl::name_for_fk(&table, &columns, &first.to_table, &columns_to);
814
815            // Convert columns to Cow
816            let columns_cow: Vec<Cow<'static, str>> = fks
817                .iter()
818                .map(|f| Cow::Owned(f.from_column.clone()))
819                .collect();
820            let columns_to_cow: Vec<Cow<'static, str>> = fks
821                .iter()
822                .map(|f| Cow::Owned(f.to_column.clone()))
823                .collect();
824
825            Some(ForeignKey {
826                table: table.into(),
827                name: name.into(),
828                name_explicit: false,
829                columns: Cow::Owned(columns_cow),
830                table_to: first.to_table.clone().into(),
831                columns_to: Cow::Owned(columns_to_cow),
832                on_update: Some(first.on_update.clone().into()),
833                on_delete: Some(first.on_delete.clone().into()),
834            })
835        })
836        .collect()
837}
838
839/// Create primary key constraint from column info
840///
841/// Note: Primary keys are now extracted directly in `process_columns()` along with columns
842/// since the raw column info contains pk field
843pub fn create_primary_key(table: &str, pk_columns: Vec<String>) -> PrimaryKey {
844    use std::borrow::Cow;
845
846    let name = super::ddl::name_for_pk(table);
847    let columns_cow: Vec<Cow<'static, str>> = pk_columns.into_iter().map(Cow::Owned).collect();
848
849    PrimaryKey {
850        table: table.to_string().into(),
851        name: name.into(),
852        name_explicit: false,
853        columns: Cow::Owned(columns_cow),
854    }
855}
856
857/// Create a unique constraint from parsed info
858pub fn create_unique_constraint(
859    table: &str,
860    name: &str,
861    columns: Vec<String>,
862    name_explicit: bool,
863) -> UniqueConstraint {
864    use std::borrow::Cow;
865
866    let columns_cow: Vec<Cow<'static, str>> = columns.into_iter().map(Cow::Owned).collect();
867
868    UniqueConstraint {
869        table: Cow::Owned(table.to_string()),
870        name: Cow::Owned(name.to_string()),
871        name_explicit,
872        columns: Cow::Owned(columns_cow),
873    }
874}
875
876/// Extract unique constraints from parsed table info
877#[must_use]
878pub fn process_unique_constraints_from_parsed(
879    table: &str,
880    parsed_uniques: &[super::ddl::ParsedUnique],
881) -> Vec<UniqueConstraint> {
882    use std::borrow::Cow;
883
884    parsed_uniques
885        .iter()
886        .map(|parsed| {
887            let columns_refs: Vec<&str> = parsed
888                .columns
889                .iter()
890                .map(std::string::String::as_str)
891                .collect();
892            let (name, name_explicit) = parsed.name.as_ref().map_or_else(
893                || (super::ddl::name_for_unique(table, &columns_refs), false),
894                |n| (n.clone(), true),
895            );
896            let columns_cow: Vec<Cow<'static, str>> = parsed
897                .columns
898                .iter()
899                .map(|c| Cow::Owned(c.clone()))
900                .collect();
901
902            UniqueConstraint {
903                table: table.to_string().into(),
904                name: name.into(),
905                name_explicit,
906                columns: Cow::Owned(columns_cow),
907            }
908        })
909        .collect()
910}
911
912/// SQL queries for `SQLite` introspection
913pub mod queries {
914    /// Query to get all tables
915    pub const TABLES_QUERY: &str = r"
916        SELECT name, sql
917        FROM sqlite_master
918        WHERE type = 'table'
919          AND name != '__drizzle_migrations'
920          AND name NOT LIKE '\_cf\_%' ESCAPE '\'
921          AND name NOT LIKE '\_litestream\_%' ESCAPE '\'
922          AND name NOT LIKE 'libsql\_%' ESCAPE '\'
923          AND name NOT LIKE 'sqlite\_%' ESCAPE '\'
924          AND name NOT LIKE 'd1\_%' ESCAPE '\'
925        ORDER BY name COLLATE NOCASE
926    ";
927
928    /// Query to get all columns for a table using `pragma_table_xinfo`
929    pub const COLUMNS_QUERY: &str = r#"
930        SELECT 
931            m.name as "table", 
932            p.cid as "cid",
933            p.name as "name", 
934            p.type as "columnType",
935            p."notnull" as "notNull", 
936            p.dflt_value as "defaultValue",
937            p.pk as pk,
938            p.hidden as hidden,
939            m.sql
940        FROM sqlite_master AS m 
941            JOIN pragma_table_xinfo(m.name) AS p
942        WHERE 
943            m.type = 'table'
944            AND m.tbl_name != '__drizzle_migrations' 
945            AND m.tbl_name NOT LIKE '\_cf\_%' ESCAPE '\'
946            AND m.tbl_name NOT LIKE '\_litestream\_%' ESCAPE '\'
947            AND m.tbl_name NOT LIKE 'libsql\_%' ESCAPE '\'
948            AND m.tbl_name NOT LIKE 'sqlite\_%' ESCAPE '\'
949            AND m.tbl_name NOT LIKE 'd1\_%' ESCAPE '\'
950        ORDER BY p.cid
951    "#;
952
953    /// Query to get all views
954    pub const VIEWS_QUERY: &str = r"
955        SELECT name, sql
956        FROM sqlite_master
957        WHERE type = 'view'
958          AND name != '__drizzle_migrations'
959          AND name NOT LIKE '\_cf\_%' ESCAPE '\'
960          AND name NOT LIKE '\_litestream\_%' ESCAPE '\'
961          AND name NOT LIKE 'libsql\_%' ESCAPE '\'
962          AND name NOT LIKE 'sqlite\_%' ESCAPE '\'
963          AND name NOT LIKE 'd1\_%' ESCAPE '\'
964        ORDER BY name COLLATE NOCASE
965    ";
966
967    /// Query to get all columns for views using `pragma_table_xinfo`
968    pub const VIEW_COLUMNS_QUERY: &str = r#"
969        SELECT
970            m.name as "table",
971            p.cid as "cid",
972            p.name as "name",
973            p.type as "columnType",
974            p."notnull" as "notNull",
975            p.dflt_value as "defaultValue",
976            p.pk as pk,
977            p.hidden as hidden,
978            m.sql
979        FROM sqlite_master AS m
980            JOIN pragma_table_xinfo(m.name) AS p
981        WHERE
982            m.type = 'view'
983            AND m.tbl_name != '__drizzle_migrations'
984            AND m.tbl_name NOT LIKE '\_cf\_%' ESCAPE '\'
985            AND m.tbl_name NOT LIKE '\_litestream\_%' ESCAPE '\'
986            AND m.tbl_name NOT LIKE 'libsql\_%' ESCAPE '\'
987            AND m.tbl_name NOT LIKE 'sqlite\_%' ESCAPE '\'
988            AND m.tbl_name NOT LIKE 'd1\_%' ESCAPE '\'
989        ORDER BY m.name, p.cid
990    "#;
991
992    /// Query all indexes in one round trip.
993    pub const INDEXES_QUERY: &str = r#"
994        SELECT
995            m.name AS "table",
996            p.name,
997            p."unique",
998            p.origin,
999            p.partial
1000        FROM sqlite_master AS m
1001            JOIN pragma_index_list(m.name) AS p
1002        WHERE m.type = 'table'
1003            AND m.tbl_name != '__drizzle_migrations'
1004            AND m.tbl_name NOT LIKE '\_cf\_%' ESCAPE '\'
1005            AND m.tbl_name NOT LIKE '\_litestream\_%' ESCAPE '\'
1006            AND m.tbl_name NOT LIKE 'libsql\_%' ESCAPE '\'
1007            AND m.tbl_name NOT LIKE 'sqlite\_%' ESCAPE '\'
1008            AND m.tbl_name NOT LIKE 'd1\_%' ESCAPE '\'
1009        ORDER BY m.name COLLATE NOCASE, p.seq
1010    "#;
1011
1012    /// Query the verbatim CREATE INDEX SQL for every user-created index.
1013    ///
1014    /// Feed the resulting `(name, sql)` rows into
1015    /// [`super::process_indexes_with_sql`] to recover partial-index WHERE
1016    /// clauses and expression columns, which PRAGMA cannot express. Auto
1017    /// indexes (`sqlite_autoindex_*`) have NULL sql and are excluded.
1018    pub const INDEX_SQL_QUERY: &str = r"
1019        SELECT name, sql
1020        FROM sqlite_master
1021        WHERE type = 'index'
1022          AND sql IS NOT NULL
1023          AND tbl_name != '__drizzle_migrations'
1024          AND tbl_name NOT LIKE '\_cf\_%' ESCAPE '\'
1025          AND tbl_name NOT LIKE '\_litestream\_%' ESCAPE '\'
1026          AND tbl_name NOT LIKE 'libsql\_%' ESCAPE '\'
1027          AND tbl_name NOT LIKE 'sqlite\_%' ESCAPE '\'
1028          AND tbl_name NOT LIKE 'd1\_%' ESCAPE '\'
1029        ORDER BY name COLLATE NOCASE
1030    ";
1031
1032    /// Query all indexed columns in one round trip.
1033    pub const INDEX_COLUMNS_QUERY: &str = r#"
1034        SELECT
1035            indexes.name AS index_name,
1036            columns.seqno,
1037            columns.cid,
1038            columns.name,
1039            columns."desc",
1040            columns.coll,
1041            columns."key"
1042        FROM sqlite_master AS m
1043            JOIN pragma_index_list(m.name) AS indexes
1044            JOIN pragma_index_xinfo(indexes.name) AS columns
1045        WHERE m.type = 'table'
1046            AND m.tbl_name != '__drizzle_migrations'
1047            AND m.tbl_name NOT LIKE '\_cf\_%' ESCAPE '\'
1048            AND m.tbl_name NOT LIKE '\_litestream\_%' ESCAPE '\'
1049            AND m.tbl_name NOT LIKE 'libsql\_%' ESCAPE '\'
1050            AND m.tbl_name NOT LIKE 'sqlite\_%' ESCAPE '\'
1051            AND m.tbl_name NOT LIKE 'd1\_%' ESCAPE '\'
1052        ORDER BY m.name COLLATE NOCASE, indexes.seq, columns.seqno
1053    "#;
1054
1055    /// Query all foreign keys in one round trip.
1056    pub const FOREIGN_KEYS_QUERY: &str = r#"
1057        SELECT
1058            m.name AS "table",
1059            p.id,
1060            p.seq,
1061            p."table" AS to_table,
1062            p."from" AS from_column,
1063            p."to" AS to_column,
1064            p.on_update,
1065            p.on_delete,
1066            p."match"
1067        FROM sqlite_master AS m
1068            JOIN pragma_foreign_key_list(m.name) AS p
1069        WHERE m.type = 'table'
1070            AND m.tbl_name != '__drizzle_migrations'
1071            AND m.tbl_name NOT LIKE '\_cf\_%' ESCAPE '\'
1072            AND m.tbl_name NOT LIKE '\_litestream\_%' ESCAPE '\'
1073            AND m.tbl_name NOT LIKE 'libsql\_%' ESCAPE '\'
1074            AND m.tbl_name NOT LIKE 'sqlite\_%' ESCAPE '\'
1075            AND m.tbl_name NOT LIKE 'd1\_%' ESCAPE '\'
1076        ORDER BY m.name COLLATE NOCASE, p.id, p.seq
1077    "#;
1078}
1079
1080/// Parse a view SQL to extract the definition.
1081///
1082/// Finds the top-level `AS` keyword — quote-aware (so a view named
1083/// `"my as view"` doesn't split early) and paren-aware (so a column-name list
1084/// `CREATE VIEW v(a, b) AS ...` is skipped over).
1085#[must_use]
1086pub fn parse_view_sql(sql: &str) -> Option<String> {
1087    let bytes = sql.as_bytes();
1088    let mut in_quote: Option<char> = None;
1089    let mut depth = 0i32;
1090
1091    for (i, ch) in sql.char_indices() {
1092        match (in_quote, ch) {
1093            (Some(q), _) if quote_closer(q) == ch => in_quote = None,
1094            (Some(_), _) => {}
1095            (None, '\'' | '"' | '`' | '[') => in_quote = Some(ch),
1096            (None, '(') => depth += 1,
1097            (None, ')') => depth -= 1,
1098            (None, 'a' | 'A') if depth == 0 => {
1099                let end = i + 2;
1100                if end <= sql.len()
1101                    && sql[i..end].eq_ignore_ascii_case("as")
1102                    && (i == 0 || !is_ident_byte(bytes[i - 1]))
1103                    && (end == sql.len() || !is_ident_byte(bytes[end]))
1104                {
1105                    let definition = sql[end..].trim().trim_end_matches(';').trim();
1106                    if definition.is_empty() {
1107                        return None;
1108                    }
1109                    return Some(definition.to_string());
1110                }
1111            }
1112            _ => {}
1113        }
1114    }
1115    None
1116}
1117
1118/// Parse the `AS (expr) [STORED|VIRTUAL]` tail of a column definition.
1119///
1120/// `rest` should point just past the column name. Returns `(expression, gen_type)`
1121/// or `None` if the column definition is not a generated column.
1122fn parse_generated_tail(rest: &str) -> Option<(String, GeneratedType)> {
1123    let upper_rest = rest.to_uppercase();
1124    let as_pos = upper_rest.find(" AS ")?;
1125    let after_as = &rest[as_pos + 4..];
1126    let expr_start_rel = after_as.find('(')?;
1127    let expr_start = as_pos + 4 + expr_start_rel;
1128
1129    let mut expr_depth = 0i32;
1130    let mut expr_end: Option<usize> = None;
1131    for (i, ch) in rest.char_indices().skip(expr_start) {
1132        match ch {
1133            '(' => expr_depth += 1,
1134            ')' => {
1135                expr_depth -= 1;
1136                if expr_depth == 0 {
1137                    expr_end = Some(i);
1138                    break;
1139                }
1140            }
1141            _ => {}
1142        }
1143    }
1144    let expr_end = expr_end?;
1145
1146    let expression = rest[expr_start + 1..expr_end].trim().to_string();
1147    let after_expr = rest[expr_end + 1..].to_uppercase();
1148    let gen_type = if after_expr.contains("STORED") {
1149        GeneratedType::Stored
1150    } else {
1151        GeneratedType::Virtual
1152    };
1153    Some((expression, gen_type))
1154}
1155
1156/// Parse generated columns from a CREATE TABLE SQL statement.
1157///
1158/// Returns a map keyed by `"table:column"` matching the key format used by `process_columns`.
1159///
1160/// This is intentionally a small, tolerant parser (not a full SQL parser). It handles common
1161/// `SQLite` syntax for generated columns:
1162/// - `col TYPE GENERATED ALWAYS AS (expr) STORED`
1163/// - `col TYPE GENERATED ALWAYS AS (expr) VIRTUAL`
1164#[must_use]
1165pub fn parse_generated_columns_from_table_sql(
1166    table: &str,
1167    sql: &str,
1168) -> HashMap<String, ParsedGenerated> {
1169    let mut out: HashMap<String, ParsedGenerated> = HashMap::new();
1170
1171    let Some(body) = extract_table_body(sql) else {
1172        return out;
1173    };
1174
1175    for item in split_top_level_commas(body) {
1176        if item.is_empty() {
1177            continue;
1178        }
1179        let upper = item.to_uppercase();
1180        if !upper.contains("GENERATED") || is_table_level_constraint(&upper) {
1181            continue;
1182        }
1183
1184        let Some((col_name, rest)) = take_column_name(item) else {
1185            continue;
1186        };
1187        let Some((expression, gen_type)) = parse_generated_tail(rest) else {
1188            continue;
1189        };
1190
1191        out.insert(
1192            format!("{table}:{col_name}"),
1193            ParsedGenerated {
1194                expression,
1195                gen_type,
1196            },
1197        );
1198    }
1199
1200    out
1201}
1202
1203#[cfg(test)]
1204mod tests {
1205    use super::*;
1206
1207    #[test]
1208    fn test_system_table_filter() {
1209        assert!(!system_table_filter("sqlite_master"));
1210        assert!(!system_table_filter("__drizzle_migrations"));
1211        assert!(!system_table_filter("_cf_something"));
1212        assert!(system_table_filter("users"));
1213        assert!(system_table_filter("posts"));
1214    }
1215
1216    #[test]
1217    fn test_parse_view_sql() {
1218        let sql = "CREATE VIEW active_users AS SELECT * FROM users WHERE active = 1";
1219        let definition = parse_view_sql(sql);
1220        assert_eq!(
1221            definition,
1222            Some("SELECT * FROM users WHERE active = 1".to_string())
1223        );
1224    }
1225
1226    #[test]
1227    fn test_parse_autoincrement_columns_from_table_sql() {
1228        let sql = "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)";
1229        let cols = parse_autoincrement_columns_from_table_sql(sql);
1230        assert!(cols.contains("id"));
1231        assert!(!cols.contains("name"));
1232    }
1233
1234    #[test]
1235    fn test_parse_generated_columns_from_table_sql() {
1236        let sql = r#"
1237CREATE TABLE users (
1238  id INTEGER PRIMARY KEY,
1239  first TEXT,
1240  last TEXT,
1241  full TEXT GENERATED ALWAYS AS (first || ' ' || last) VIRTUAL,
1242  total INT GENERATED ALWAYS AS ((id + 1) * 2) STORED
1243);
1244"#;
1245        let map = parse_generated_columns_from_table_sql("users", sql);
1246        let full = map.get("users:full").expect("full generated");
1247        assert_eq!(full.gen_type, GeneratedType::Virtual);
1248        assert!(full.expression.contains("first"));
1249
1250        let total = map.get("users:total").expect("total generated");
1251        assert_eq!(total.gen_type, GeneratedType::Stored);
1252        assert!(total.expression.contains("id"));
1253    }
1254
1255    #[test]
1256    fn test_parse_table_options_ignores_body_content() {
1257        // Column named `strict` must not trigger the STRICT option.
1258        let sql = "CREATE TABLE t (strict TEXT, without_rowid INTEGER)";
1259        assert_eq!(parse_table_options(sql), (false, false));
1260
1261        // String content in the body must not trigger options either.
1262        let sql = "CREATE TABLE t (note TEXT DEFAULT 'WITHOUT ROWID STRICT')";
1263        assert_eq!(parse_table_options(sql), (false, false));
1264
1265        let sql = "CREATE TABLE t (id INTEGER) STRICT";
1266        assert_eq!(parse_table_options(sql), (true, false));
1267
1268        let sql = "CREATE TABLE t (id INTEGER) WITHOUT ROWID";
1269        assert_eq!(parse_table_options(sql), (false, true));
1270
1271        let sql = "CREATE TABLE t (id INTEGER) STRICT, WITHOUT ROWID;";
1272        assert_eq!(parse_table_options(sql), (true, true));
1273
1274        let sql = "CREATE TABLE t (id INTEGER) WITHOUT ROWID, STRICT;";
1275        assert_eq!(parse_table_options(sql), (true, true));
1276    }
1277
1278    #[test]
1279    fn test_parse_index_sql_recovers_where_and_expressions() {
1280        let parsed =
1281            parse_index_sql("CREATE INDEX idx_c_positive ON multi_indexed(col_c) WHERE col_c > 0");
1282        assert_eq!(parsed.columns.len(), 1);
1283        assert_eq!(parsed.columns[0].value, "col_c");
1284        assert!(!parsed.columns[0].is_expression);
1285        assert_eq!(parsed.where_clause.as_deref(), Some("col_c > 0"));
1286
1287        let parsed = parse_index_sql("CREATE UNIQUE INDEX i ON t(lower(email), name)");
1288        assert_eq!(parsed.columns.len(), 2);
1289        assert_eq!(parsed.columns[0].value, "lower(email)");
1290        assert!(parsed.columns[0].is_expression);
1291        assert_eq!(parsed.columns[1].value, "name");
1292        assert!(!parsed.columns[1].is_expression);
1293        assert!(parsed.where_clause.is_none());
1294
1295        // Quoted identifiers and ASC/DESC/COLLATE modifiers stay named columns.
1296        let parsed =
1297            parse_index_sql("CREATE INDEX i ON t(`email` DESC, \"name\" COLLATE NOCASE ASC)");
1298        assert_eq!(parsed.columns.len(), 2);
1299        assert_eq!(parsed.columns[0].value, "email");
1300        assert!(!parsed.columns[0].is_expression);
1301        assert_eq!(parsed.columns[1].value, "name");
1302        assert!(!parsed.columns[1].is_expression);
1303
1304        // WHERE inside a string literal in an expression must not be picked up.
1305        let parsed = parse_index_sql("CREATE INDEX i ON t(coalesce(kind, 'WHERE x'))");
1306        assert!(parsed.where_clause.is_none());
1307        assert_eq!(parsed.columns.len(), 1);
1308        assert!(parsed.columns[0].is_expression);
1309    }
1310
1311    #[test]
1312    fn test_process_columns_keeps_generated_columns() {
1313        let table_sql = "CREATE TABLE g (id INTEGER PRIMARY KEY, v TEXT GENERATED ALWAYS AS (id + 1) VIRTUAL, s TEXT GENERATED ALWAYS AS (id + 2) STORED)";
1314        let raw = vec![
1315            RawColumnInfo {
1316                table: "g".to_string(),
1317                cid: 0,
1318                name: "id".to_string(),
1319                column_type: "INTEGER".to_string(),
1320                not_null: false,
1321                default_value: None,
1322                pk: 1,
1323                hidden: 0,
1324                sql: Some(table_sql.to_string()),
1325            },
1326            RawColumnInfo {
1327                table: "g".to_string(),
1328                cid: 1,
1329                name: "v".to_string(),
1330                column_type: "TEXT".to_string(),
1331                not_null: false,
1332                default_value: None,
1333                pk: 0,
1334                hidden: 2, // VIRTUAL generated
1335                sql: Some(table_sql.to_string()),
1336            },
1337            RawColumnInfo {
1338                table: "g".to_string(),
1339                cid: 2,
1340                name: "s".to_string(),
1341                column_type: "TEXT".to_string(),
1342                not_null: false,
1343                default_value: None,
1344                pk: 0,
1345                hidden: 3, // STORED generated
1346                sql: Some(table_sql.to_string()),
1347            },
1348        ];
1349
1350        let generated = parse_generated_columns_from_table_sql("g", table_sql);
1351        let pk_columns: HashSet<(String, String)> = HashSet::new();
1352        let (columns, _pks) = process_columns(&raw, &generated, &pk_columns);
1353
1354        assert_eq!(columns.len(), 3, "generated columns must be kept");
1355        let v = columns.iter().find(|c| c.name == "v").expect("v column");
1356        let v_generated = v.generated.as_ref().expect("v generated info");
1357        assert_eq!(v_generated.gen_type, GeneratedType::Virtual);
1358        assert_eq!(v_generated.expression, "id + 1");
1359        let s = columns.iter().find(|c| c.name == "s").expect("s column");
1360        let s_generated = s.generated.as_ref().expect("s generated info");
1361        assert_eq!(s_generated.gen_type, GeneratedType::Stored);
1362        assert_eq!(s_generated.expression, "id + 2");
1363    }
1364
1365    #[test]
1366    fn test_composite_pk_columns_ordered_by_pk_position() {
1367        // PRIMARY KEY (b, a): cid order is a, b but pk positions are b=1, a=2.
1368        let raw = vec![
1369            RawColumnInfo {
1370                table: "t".to_string(),
1371                cid: 0,
1372                name: "a".to_string(),
1373                column_type: "INTEGER".to_string(),
1374                not_null: true,
1375                default_value: None,
1376                pk: 2,
1377                hidden: 0,
1378                sql: None,
1379            },
1380            RawColumnInfo {
1381                table: "t".to_string(),
1382                cid: 1,
1383                name: "b".to_string(),
1384                column_type: "INTEGER".to_string(),
1385                not_null: true,
1386                default_value: None,
1387                pk: 1,
1388                hidden: 0,
1389                sql: None,
1390            },
1391        ];
1392        let generated = HashMap::new();
1393        let pk_columns: HashSet<(String, String)> = HashSet::new();
1394        let (_cols, pks) = process_columns(&raw, &generated, &pk_columns);
1395        assert_eq!(pks.len(), 1);
1396        let cols: Vec<&str> = pks[0].columns.iter().map(AsRef::as_ref).collect();
1397        assert_eq!(cols, vec!["b", "a"]);
1398    }
1399
1400    #[test]
1401    fn test_parse_view_sql_is_quote_aware() {
1402        let sql = r#"CREATE VIEW "my as view" AS SELECT * FROM users"#;
1403        assert_eq!(parse_view_sql(sql), Some("SELECT * FROM users".to_string()));
1404
1405        // Column-name list before AS is skipped over.
1406        let sql = "CREATE VIEW v(a, b) AS SELECT 1, 2";
1407        assert_eq!(parse_view_sql(sql), Some("SELECT 1, 2".to_string()));
1408
1409        // `AS` must be a word, not a substring.
1410        let sql = "CREATE VIEW basics AS SELECT 1";
1411        assert_eq!(parse_view_sql(sql), Some("SELECT 1".to_string()));
1412    }
1413
1414    #[test]
1415    fn set_based_metadata_queries_cover_all_tables_and_indexes() {
1416        let connection = rusqlite::Connection::open_in_memory().expect("open SQLite");
1417        connection
1418            .execute_batch(
1419                "PRAGMA foreign_keys = ON;
1420                 CREATE TABLE parents(id INTEGER PRIMARY KEY);
1421                 CREATE TABLE children(
1422                    id INTEGER PRIMARY KEY,
1423                    parent_id INTEGER NOT NULL,
1424                    email TEXT UNIQUE,
1425                    FOREIGN KEY(parent_id) REFERENCES parents(id)
1426                 );
1427                 CREATE INDEX children_parent_idx ON children(parent_id);",
1428            )
1429            .expect("create schema");
1430
1431        let index_count: i64 = connection
1432            .query_row(
1433                &format!("SELECT COUNT(*) FROM ({})", queries::INDEXES_QUERY),
1434                [],
1435                |row| row.get(0),
1436            )
1437            .expect("query all indexes");
1438        let indexed_column_count: i64 = connection
1439            .query_row(
1440                &format!("SELECT COUNT(*) FROM ({})", queries::INDEX_COLUMNS_QUERY),
1441                [],
1442                |row| row.get(0),
1443            )
1444            .expect("query all index columns");
1445        let foreign_key_count: i64 = connection
1446            .query_row(
1447                &format!("SELECT COUNT(*) FROM ({})", queries::FOREIGN_KEYS_QUERY),
1448                [],
1449                |row| row.get(0),
1450            )
1451            .expect("query all foreign keys");
1452
1453        assert!(index_count >= 2);
1454        assert!(indexed_column_count >= 2);
1455        assert_eq!(foreign_key_count, 1);
1456    }
1457}