Skip to main content

vibesql_executor/
sqlite_schema.rs

1//! SQLite Schema Virtual Table
2//!
3//! Implements `sqlite_master` (and its alias `sqlite_schema`) for SQLite compatibility.
4//! These virtual tables return metadata about database objects in the standard SQLite format.
5//!
6//! Schema:
7//! ```sql
8//! CREATE TABLE sqlite_master (
9//!   type TEXT,      -- 'table', 'index', 'view', 'trigger'
10//!   name TEXT,      -- name of the object
11//!   tbl_name TEXT,  -- table the object is associated with
12//!   rootpage INT,   -- internal (always 0 for VibeSQL)
13//!   sql TEXT        -- CREATE statement that created the object
14//! );
15//! ```
16//!
17//! Reference: https://www.sqlite.org/schematab.html
18
19use vibesql_ast::pretty_print::ToSql;
20use vibesql_catalog::{ColumnSchema, ReferentialAction, SortOrder, TableSchema};
21use vibesql_storage::Row;
22use vibesql_types::{DataType, SqlValue};
23
24use crate::{errors::ExecutorError, select::SelectResult};
25
26/// Check if a table reference is sqlite_master or sqlite_schema
27pub fn is_sqlite_schema_table(table_name: &str) -> bool {
28    let normalized = table_name.to_lowercase();
29    matches!(normalized.as_str(), "sqlite_master" | "sqlite_schema")
30}
31
32/// Check whether an object name is reserved for internal use.
33///
34/// SQLite reserves the `sqlite_` prefix for its own schema objects
35/// (`sqlite_master`, `sqlite_sequence`, `sqlite_autoindex_*`, …) and rejects any
36/// *user* `CREATE TABLE`/`CREATE INDEX` whose name begins with that prefix:
37///
38/// ```text
39/// sqlite> CREATE TABLE sqlite_foo(x);
40/// Error: object name reserved for internal use: sqlite_foo
41/// ```
42///
43/// The match is case-insensitive (issue #5553 identifier folding); `SQLITE_foo`
44/// is rejected just like `sqlite_foo`. This guard applies ONLY to user-issued
45/// DDL — VibeSQL's own internal schema objects (sqlite_master, the
46/// `sqlite_autoindex_*` auto-indexes, etc.) are created through dedicated
47/// catalog APIs that never route through the user-facing executors, so they are
48/// unaffected.
49pub fn is_reserved_object_name(name: &str) -> bool {
50    name.len() >= "sqlite_".len()
51        && name.as_bytes()[.."sqlite_".len()].eq_ignore_ascii_case(b"sqlite_")
52}
53
54/// Check if a table reference is sqlite_temp_master or sqlite_temp_schema.
55///
56/// These are the temp-schema introspection views: they list objects that live
57/// in the session temp schema (temp tables, and indexes on temp tables), and
58/// are the temp-schema counterpart to `sqlite_master`. See issue #5513.
59pub fn is_sqlite_temp_schema_table(table_name: &str) -> bool {
60    let normalized = table_name.to_lowercase();
61    matches!(normalized.as_str(), "sqlite_temp_master" | "sqlite_temp_schema")
62}
63
64/// Get the schema for sqlite_master/sqlite_schema
65pub fn get_sqlite_schema_table_schema() -> TableSchema {
66    TableSchema::new(
67        "sqlite_master".to_string(),
68        vec![
69            ColumnSchema::new("type".to_string(), DataType::Varchar { max_length: None }, false),
70            ColumnSchema::new("name".to_string(), DataType::Varchar { max_length: None }, false),
71            ColumnSchema::new(
72                "tbl_name".to_string(),
73                DataType::Varchar { max_length: None },
74                false,
75            ),
76            ColumnSchema::new("rootpage".to_string(), DataType::Integer, false),
77            ColumnSchema::new("sql".to_string(), DataType::Varchar { max_length: None }, true),
78        ],
79    )
80}
81
82/// Build a single sqlite_master-format row.
83fn schema_row(obj_type: &str, name: &str, tbl_name: &str, sql: String) -> Row {
84    Row::new(vec![
85        SqlValue::Varchar(arcstr::ArcStr::from(obj_type)),
86        SqlValue::Varchar(arcstr::ArcStr::from(name)),
87        SqlValue::Varchar(arcstr::ArcStr::from(tbl_name)),
88        SqlValue::Integer(0), // rootpage - always 0 for VibeSQL
89        SqlValue::Varchar(arcstr::ArcStr::from(sql)),
90    ])
91}
92
93/// Execute a `sqlite_master` / `sqlite_schema` query.
94///
95/// Lists **main-schema** objects only. Temp-schema objects (temp tables and
96/// indexes on temp tables) are reported by `sqlite_temp_master`, matching
97/// SQLite 3.51.0 where a temp-table index appears in `sqlite_temp_master` and
98/// never in `sqlite_master`. See issue #5513.
99pub fn execute_sqlite_schema_query(
100    catalog: &vibesql_catalog::Catalog,
101) -> Result<SelectResult, ExecutorError> {
102    let schema = get_sqlite_schema_table_schema();
103    let column_names: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
104    let mut rows = Vec::new();
105
106    // Add tables. `list_tables()` returns the current (main) schema only, so
107    // temp tables are already excluded here.
108    for table_name in catalog.list_tables() {
109        if let Some(table) = catalog.get_table(&table_name) {
110            let sql = generate_create_table_sql(table);
111            // SQLite echoes the *original* declared case in sqlite_master, even
112            // though lookups are case-folded (issue #5553). `table.name` retains
113            // the original spelling; `table_name` is the lowercase catalog key.
114            rows.push(schema_row("table", &table.name, &table.name, sql));
115        }
116    }
117
118    // Add indexes owned by the main schema. Temp-table indexes are tagged with
119    // their temp schema (#5513) and surface only via sqlite_temp_master.
120    for index in catalog.get_schema_indexes(vibesql_catalog::DEFAULT_SCHEMA) {
121        let sql = generate_create_index_sql(index);
122        rows.push(schema_row("index", &index.name, &index.table_name, sql));
123    }
124
125    // Add views. Temp views are tagged with the `temp` schema (#5541) and
126    // surface only via sqlite_temp_master, so skip them here.
127    for view_name in catalog.list_views() {
128        if let Some(view) = catalog.get_view(&view_name) {
129            if view.is_temp() {
130                continue;
131            }
132            let sql = generate_create_view_sql(view);
133            // tbl_name is same as name for views
134            rows.push(schema_row("view", &view.name, &view.name, sql));
135        }
136    }
137
138    // Add triggers. Temp triggers are tagged with the `temp` schema (#5532)
139    // and surface only via sqlite_temp_master, so skip them here.
140    for trigger_name in catalog.list_triggers() {
141        if let Some(trigger) = catalog.get_trigger(&trigger_name) {
142            if trigger.is_temp() {
143                continue;
144            }
145            let sql = generate_create_trigger_sql(trigger);
146            rows.push(schema_row("trigger", &trigger.name, &trigger.table_name, sql));
147        }
148    }
149
150    Ok(SelectResult { columns: column_names, rows })
151}
152
153/// Execute a `sqlite_temp_master` / `sqlite_temp_schema` query.
154///
155/// Lists the session's **temp-schema** objects: temp tables, indexes on temp
156/// tables, temp views, and temp triggers. Mirrors SQLite 3.51.0, where:
157///   CREATE TEMP TABLE t(a); CREATE INDEX i ON t(a);
158///   CREATE TEMP VIEW v AS SELECT 1; CREATE TEMP TRIGGER tr AFTER INSERT ON t ...;
159///   SELECT name,type FROM sqlite_temp_master;  -- lists t, i, v and tr
160/// while `sqlite_master` lists none of them. Temp tables/indexes were added in
161/// #5513 (PR #5539); temp views/triggers in #5541.
162pub fn execute_sqlite_temp_schema_query(
163    catalog: &vibesql_catalog::Catalog,
164) -> Result<SelectResult, ExecutorError> {
165    let schema = get_sqlite_schema_table_schema();
166    let column_names: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
167    let mut rows = Vec::new();
168
169    let temp_schema = catalog.temp_schema_name();
170
171    // Add temp tables (objects living in the session temp schema).
172    for table_name in catalog.list_tables_in_schema(temp_schema) {
173        // Look up via the temp-qualified name so we read the temp copy even when
174        // a same-named main table shadows it for unqualified lookups.
175        let qualified = format!("{}.{}", temp_schema, table_name);
176        if let Some(table) = catalog.get_table(&qualified) {
177            let sql = generate_create_table_sql(table);
178            // Echo the original declared case (issue #5553); `table_name` is the
179            // lowercase catalog key.
180            rows.push(schema_row("table", &table.name, &table.name, sql));
181        }
182    }
183
184    // Add indexes owned by the temp schema.
185    for index in catalog.get_schema_indexes(temp_schema) {
186        let sql = generate_create_index_sql(index);
187        rows.push(schema_row("index", &index.name, &index.table_name, sql));
188    }
189
190    // Add temp views. Views are stored flat in the catalog (not partitioned by
191    // schema), so filter on the per-view `temp` tag set at CREATE TEMP VIEW
192    // time (#5541).
193    for view_name in catalog.list_views() {
194        if let Some(view) = catalog.get_view(&view_name) {
195            if !view.is_temp() {
196                continue;
197            }
198            let sql = generate_create_view_sql(view);
199            rows.push(schema_row("view", &view.name, &view.name, sql));
200        }
201    }
202
203    // Add temp triggers. Triggers are also stored flat; filter on the `temp`
204    // schema tag from CREATE TEMP TRIGGER (#5532).
205    for trigger_name in catalog.list_triggers() {
206        if let Some(trigger) = catalog.get_trigger(&trigger_name) {
207            if !trigger.is_temp() {
208                continue;
209            }
210            let sql = generate_create_trigger_sql(trigger);
211            rows.push(schema_row("trigger", &trigger.name, &trigger.table_name, sql));
212        }
213    }
214
215    Ok(SelectResult { columns: column_names, rows })
216}
217
218/// Generate CREATE TABLE SQL statement for a table
219fn generate_create_table_sql(table: &TableSchema) -> String {
220    // SQLite stores the original CREATE TABLE statement byte-verbatim in
221    // sqlite_master.sql (whitespace and formatting preserved). When we captured
222    // the original source text at CREATE time (issue #5619), return it as-is
223    // rather than reconstructing a normalized form from the parsed schema. We
224    // only reconstruct when the verbatim text is unavailable (e.g. schemas built
225    // programmatically, or after ALTER TABLE invalidated the stale source).
226    if let Some(ref src) = table.sql_source {
227        return src.clone();
228    }
229
230    let mut sql = format!("CREATE TABLE {} (\n", table.name);
231    let mut definitions: Vec<String> = Vec::new();
232
233    // Add column definitions
234    for col in &table.columns {
235        let mut col_def = format!("  {} {}", col.name, format_data_type(&col.data_type));
236
237        // Handle generated columns (AS expression syntax)
238        if let Some(ref generated_expr) = col.generated_expr {
239            col_def.push_str(&format!(" AS ({})", format_expression(generated_expr)));
240        } else {
241            // Only non-generated columns can have NOT NULL and DEFAULT
242            if !col.nullable {
243                col_def.push_str(" NOT NULL");
244            }
245
246            if let Some(ref default) = col.default_value {
247                col_def.push_str(&format!(" DEFAULT {}", format_expression(default)));
248            }
249        }
250
251        definitions.push(col_def);
252    }
253
254    // Add PRIMARY KEY constraint
255    if let Some(ref pk_cols) = table.primary_key {
256        definitions.push(format!("  PRIMARY KEY ({})", pk_cols.join(", ")));
257    }
258
259    // Add UNIQUE constraints
260    for unique_cols in &table.unique_constraints {
261        definitions.push(format!("  UNIQUE ({})", unique_cols.join(", ")));
262    }
263
264    // Add FOREIGN KEY constraints
265    for fk in &table.foreign_keys {
266        let mut fk_def = format!(
267            "  FOREIGN KEY ({}) REFERENCES {} ({})",
268            fk.column_names.join(", "),
269            fk.parent_table,
270            fk.parent_column_names.join(", ")
271        );
272
273        // Add ON DELETE action if not NO ACTION
274        if fk.on_delete != ReferentialAction::NoAction {
275            fk_def.push_str(&format!(" ON DELETE {}", format_referential_action(&fk.on_delete)));
276        }
277
278        // Add ON UPDATE action if not NO ACTION
279        if fk.on_update != ReferentialAction::NoAction {
280            fk_def.push_str(&format!(" ON UPDATE {}", format_referential_action(&fk.on_update)));
281        }
282
283        // Emit DEFERRABLE clause when non-default. Matches SQLite's
284        // sqlite_master format and the `introspection.rs` SHOW CREATE
285        // TABLE wiring landed in Phase C3 of #5085.
286        if fk.is_deferrable {
287            if fk.initially_deferred {
288                fk_def.push_str(" DEFERRABLE INITIALLY DEFERRED");
289            } else {
290                fk_def.push_str(" DEFERRABLE INITIALLY IMMEDIATE");
291            }
292        }
293
294        definitions.push(fk_def);
295    }
296
297    // Add CHECK constraints
298    for (name, expr) in &table.check_constraints {
299        definitions.push(format!("  CONSTRAINT {} CHECK ({})", name, format_expression(expr)));
300    }
301
302    sql.push_str(&definitions.join(",\n"));
303    sql.push_str("\n)");
304
305    sql
306}
307
308/// Generate CREATE INDEX SQL statement for an index
309fn generate_create_index_sql(index: &vibesql_catalog::IndexMetadata) -> String {
310    let unique_str = if index.is_unique { "UNIQUE " } else { "" };
311
312    let columns: Vec<String> = index
313        .columns
314        .iter()
315        .map(|col| {
316            let order_str = match col.order() {
317                SortOrder::Ascending => "",
318                SortOrder::Descending => " DESC",
319            };
320            match col {
321                vibesql_catalog::IndexedColumn::Column { column_name, prefix_length, .. } => {
322                    let prefix_str = prefix_length.map(|l| format!("({})", l)).unwrap_or_default();
323                    format!("{}{}{}", column_name, prefix_str, order_str)
324                }
325                vibesql_catalog::IndexedColumn::Expression { expr, .. } => {
326                    // For expression indexes, wrap the expression in parentheses
327                    format!("({}){}", expr.to_sql(), order_str)
328                }
329            }
330        })
331        .collect();
332
333    format!(
334        "CREATE {}INDEX {} ON {}({})",
335        unique_str,
336        index.name,
337        index.table_name,
338        columns.join(", ")
339    )
340}
341
342/// Generate CREATE VIEW SQL statement for a view
343fn generate_create_view_sql(view: &vibesql_catalog::ViewDefinition) -> String {
344    // Use stored SQL definition if available
345    if let Some(ref sql) = view.sql_definition {
346        return sql.clone();
347    }
348
349    // Otherwise generate from the view definition
350    let columns_str =
351        view.columns.as_ref().map(|cols| format!(" ({})", cols.join(", "))).unwrap_or_default();
352
353    // Use the ToSql trait to generate valid SQL from the AST
354    use vibesql_ast::pretty_print::ToSql;
355    format!("CREATE VIEW {}{} AS {}", view.name, columns_str, view.query.to_sql())
356}
357
358/// Generate CREATE TRIGGER SQL statement for a trigger
359fn generate_create_trigger_sql(trigger: &vibesql_catalog::TriggerDefinition) -> String {
360    use vibesql_ast::{TriggerAction, TriggerEvent, TriggerGranularity, TriggerTiming};
361
362    // Prefer the original SQL text when available so `sqlite_master.sql` matches
363    // SQLite's verbatim form (no injected BEFORE/FOR EACH ROW, original spacing
364    // and quoting preserved). This mirrors `generate_create_view_sql` and is the
365    // form kept consistent by `crate::trigger_rename` across ALTER TABLE RENAME.
366    // SQLite stores the statement text without the trailing `;`, so strip one if
367    // present.
368    if let Some(ref sql) = trigger.sql_definition {
369        let trimmed = sql.trim_end();
370        let trimmed = trimmed.strip_suffix(';').unwrap_or(trimmed);
371        return trimmed.trim_end().to_string();
372    }
373
374    let timing = match trigger.timing {
375        TriggerTiming::Before => "BEFORE",
376        TriggerTiming::After => "AFTER",
377        TriggerTiming::InsteadOf => "INSTEAD OF",
378    };
379
380    let granularity = match trigger.granularity {
381        TriggerGranularity::Row => "ROW",
382        TriggerGranularity::Statement => "STATEMENT",
383    };
384
385    let event = match &trigger.event {
386        TriggerEvent::Insert => "INSERT".to_string(),
387        TriggerEvent::Update(None) => "UPDATE".to_string(),
388        TriggerEvent::Update(Some(cols)) => format!("UPDATE OF {}", cols.join(", ")),
389        TriggerEvent::Delete => "DELETE".to_string(),
390    };
391
392    // Include WHEN clause if present
393    let when_clause = trigger
394        .when_condition
395        .as_ref()
396        .map(|expr| format!(" WHEN ({})", format_expression(expr)))
397        .unwrap_or_default();
398
399    // Include trigger body from TriggerAction
400    let body = match &trigger.triggered_action {
401        TriggerAction::RawSql(sql) => sql.clone(),
402    };
403
404    format!(
405        "CREATE TRIGGER {} {} {} ON {} FOR EACH {}{}{}",
406        trigger.name, timing, event, trigger.table_name, granularity, when_clause, body
407    )
408}
409
410/// Format a DataType as a SQL string
411fn format_data_type(dt: &DataType) -> String {
412    match dt {
413        DataType::Integer => "INTEGER".to_string(),
414        DataType::Smallint => "SMALLINT".to_string(),
415        DataType::Bigint => "BIGINT".to_string(),
416        DataType::Unsigned => "UNSIGNED BIGINT".to_string(),
417        DataType::Real => "REAL".to_string(),
418        DataType::Float { precision } => format!("FLOAT({})", precision),
419        DataType::DoublePrecision => "DOUBLE PRECISION".to_string(),
420        DataType::Numeric { precision, scale } => format!("NUMERIC({}, {})", precision, scale),
421        DataType::Decimal { precision, scale } => format!("DECIMAL({}, {})", precision, scale),
422        DataType::Varchar { max_length } => {
423            if let Some(len) = max_length {
424                format!("VARCHAR({})", len)
425            } else {
426                "TEXT".to_string()
427            }
428        }
429        DataType::Character { length } => format!("CHAR({})", length),
430        DataType::CharacterLargeObject => "TEXT".to_string(),
431        DataType::Name => "TEXT".to_string(),
432        DataType::Boolean => "BOOLEAN".to_string(),
433        DataType::Date => "DATE".to_string(),
434        DataType::Time { with_timezone } => {
435            if *with_timezone {
436                "TIME WITH TIME ZONE".to_string()
437            } else {
438                "TIME".to_string()
439            }
440        }
441        DataType::Timestamp { with_timezone } => {
442            if *with_timezone {
443                "TIMESTAMP WITH TIME ZONE".to_string()
444            } else {
445                "TIMESTAMP".to_string()
446            }
447        }
448        DataType::Interval { start_field, end_field } => {
449            if let Some(end) = end_field {
450                format!("INTERVAL {:?} TO {:?}", start_field, end)
451            } else {
452                format!("INTERVAL {:?}", start_field)
453            }
454        }
455        DataType::BinaryLargeObject => "BLOB".to_string(),
456        DataType::Bit { length } => {
457            if let Some(len) = length {
458                format!("BIT({})", len)
459            } else {
460                "BIT".to_string()
461            }
462        }
463        DataType::UserDefined { type_name } => type_name.clone(),
464        DataType::Vector { dimensions } => format!("VECTOR({})", dimensions),
465        DataType::Null => "NULL".to_string(),
466    }
467}
468
469/// Format a referential action as a SQL string
470fn format_referential_action(action: &ReferentialAction) -> &'static str {
471    match action {
472        ReferentialAction::NoAction => "NO ACTION",
473        ReferentialAction::Restrict => "RESTRICT",
474        ReferentialAction::Cascade => "CASCADE",
475        ReferentialAction::SetNull => "SET NULL",
476        ReferentialAction::SetDefault => "SET DEFAULT",
477    }
478}
479
480/// Format an expression for SQL output
481fn format_expression(expr: &vibesql_ast::Expression) -> String {
482    use vibesql_ast::pretty_print::ToSql;
483    expr.to_sql()
484}
485
486#[cfg(test)]
487mod tests {
488    use vibesql_catalog::{Catalog, IndexMetadata, IndexType, IndexedColumn};
489
490    use super::*;
491
492    #[test]
493    fn test_is_sqlite_schema_table() {
494        assert!(is_sqlite_schema_table("sqlite_master"));
495        assert!(is_sqlite_schema_table("sqlite_schema"));
496        assert!(is_sqlite_schema_table("SQLITE_MASTER"));
497        assert!(is_sqlite_schema_table("SQLITE_SCHEMA"));
498        assert!(is_sqlite_schema_table("Sqlite_Master"));
499        assert!(!is_sqlite_schema_table("users"));
500        assert!(!is_sqlite_schema_table("sqlite_stat1"));
501        assert!(!is_sqlite_schema_table("information_schema.tables"));
502        // sqlite_temp_master is a distinct view, not sqlite_master.
503        assert!(!is_sqlite_schema_table("sqlite_temp_master"));
504    }
505
506    #[test]
507    fn test_is_sqlite_temp_schema_table() {
508        assert!(is_sqlite_temp_schema_table("sqlite_temp_master"));
509        assert!(is_sqlite_temp_schema_table("sqlite_temp_schema"));
510        assert!(is_sqlite_temp_schema_table("SQLITE_TEMP_MASTER"));
511        assert!(is_sqlite_temp_schema_table("Sqlite_Temp_Schema"));
512        assert!(!is_sqlite_temp_schema_table("sqlite_master"));
513        assert!(!is_sqlite_temp_schema_table("users"));
514    }
515
516    #[test]
517    fn test_temp_index_separated_from_main_in_schema_views() {
518        // #5513: a temp-schema index appears in sqlite_temp_master and is
519        // excluded from sqlite_master; a main index is the reverse.
520        let mut catalog = Catalog::new();
521
522        // main.t with a main index.
523        let main_table = TableSchema::new(
524            "t".to_string(),
525            vec![ColumnSchema::new("a".to_string(), DataType::Integer, true)],
526        );
527        catalog.create_table(main_table).unwrap();
528        catalog
529            .add_index(IndexMetadata::new(
530                "main_i".to_string(),
531                "t".to_string(),
532                IndexType::BTree,
533                vec![IndexedColumn::new_column("a".to_string(), SortOrder::Ascending)],
534                false,
535            ))
536            .unwrap();
537
538        // temp.tt with a temp index.
539        let temp_schema = catalog.temp_schema_name().to_string();
540        let temp_table = TableSchema::new(
541            "tt".to_string(),
542            vec![ColumnSchema::new("a".to_string(), DataType::Integer, true)],
543        );
544        catalog.create_table_in_schema(&temp_schema, temp_table).unwrap();
545        catalog
546            .add_index(
547                IndexMetadata::new(
548                    "temp_i".to_string(),
549                    "tt".to_string(),
550                    IndexType::BTree,
551                    vec![IndexedColumn::new_column("a".to_string(), SortOrder::Ascending)],
552                    false,
553                )
554                .with_schema(temp_schema.clone()),
555            )
556            .unwrap();
557
558        let master = execute_sqlite_schema_query(&catalog).unwrap();
559        let master_names: Vec<String> = master
560            .rows
561            .iter()
562            .map(|r| match &r.values[1] {
563                SqlValue::Varchar(s) => s.to_string(),
564                _ => String::new(),
565            })
566            .collect();
567        assert!(master_names.contains(&"main_i".to_string()), "master should list main index");
568        assert!(!master_names.contains(&"temp_i".to_string()), "master must exclude temp index");
569
570        let temp_master = execute_sqlite_temp_schema_query(&catalog).unwrap();
571        let temp_names: Vec<String> = temp_master
572            .rows
573            .iter()
574            .map(|r| match &r.values[1] {
575                SqlValue::Varchar(s) => s.to_string(),
576                _ => String::new(),
577            })
578            .collect();
579        assert!(temp_names.contains(&"temp_i".to_string()), "temp_master should list temp index");
580        assert!(temp_names.contains(&"tt".to_string()), "temp_master should list temp table");
581        assert!(!temp_names.contains(&"main_i".to_string()), "temp_master must exclude main index");
582    }
583
584    #[test]
585    fn test_temp_view_and_trigger_separated_from_main_in_schema_views() {
586        // #5541: temp views/triggers appear in sqlite_temp_master and are
587        // excluded from sqlite_master; main views/triggers are the reverse.
588        use vibesql_ast::{TriggerAction, TriggerEvent, TriggerGranularity, TriggerTiming};
589        use vibesql_catalog::{TriggerDefinition, ViewDefinition};
590
591        let mut catalog = Catalog::new();
592
593        // A base table for the triggers to target.
594        let base = TableSchema::new(
595            "t".to_string(),
596            vec![ColumnSchema::new("a".to_string(), DataType::Integer, true)],
597        );
598        catalog.create_table(base).unwrap();
599
600        // main view + temp view.
601        catalog
602            .create_view(ViewDefinition::new(
603                "main_v".to_string(),
604                None,
605                create_minimal_select_stmt(),
606                false,
607            ))
608            .unwrap();
609        catalog
610            .create_view(
611                ViewDefinition::new("temp_v".to_string(), None, create_minimal_select_stmt(), false)
612                    .with_schema(Some("temp".to_string())),
613            )
614            .unwrap();
615
616        // main trigger + temp trigger.
617        let mk_trigger = |name: &str| {
618            TriggerDefinition::new(
619                name.to_string(),
620                TriggerTiming::After,
621                TriggerEvent::Insert,
622                "t".to_string(),
623                TriggerGranularity::Row,
624                None,
625                TriggerAction::RawSql("SELECT 1".to_string()),
626            )
627        };
628        catalog.create_trigger(mk_trigger("main_tr")).unwrap();
629        catalog
630            .create_trigger(mk_trigger("temp_tr").with_schema(Some("temp".to_string())))
631            .unwrap();
632
633        let names = |result: &SelectResult| -> Vec<String> {
634            result
635                .rows
636                .iter()
637                .map(|r| match &r.values[1] {
638                    SqlValue::Varchar(s) => s.to_string(),
639                    _ => String::new(),
640                })
641                .collect()
642        };
643
644        let master = execute_sqlite_schema_query(&catalog).unwrap();
645        let master_names = names(&master);
646        assert!(master_names.contains(&"main_v".to_string()), "master should list main view");
647        assert!(master_names.contains(&"main_tr".to_string()), "master should list main trigger");
648        assert!(!master_names.contains(&"temp_v".to_string()), "master must exclude temp view");
649        assert!(
650            !master_names.contains(&"temp_tr".to_string()),
651            "master must exclude temp trigger"
652        );
653
654        let temp_master = execute_sqlite_temp_schema_query(&catalog).unwrap();
655        let temp_names = names(&temp_master);
656        assert!(temp_names.contains(&"temp_v".to_string()), "temp_master should list temp view");
657        assert!(
658            temp_names.contains(&"temp_tr".to_string()),
659            "temp_master should list temp trigger"
660        );
661        assert!(!temp_names.contains(&"main_v".to_string()), "temp_master must exclude main view");
662        assert!(
663            !temp_names.contains(&"main_tr".to_string()),
664            "temp_master must exclude main trigger"
665        );
666    }
667
668    #[test]
669    fn test_sqlite_schema_empty_db() {
670        let catalog = Catalog::new();
671        let result = execute_sqlite_schema_query(&catalog).unwrap();
672
673        assert_eq!(result.columns, vec!["type", "name", "tbl_name", "rootpage", "sql"]);
674        assert!(result.rows.is_empty());
675    }
676
677    #[test]
678    fn test_get_sqlite_schema_table_schema() {
679        let schema = get_sqlite_schema_table_schema();
680
681        assert_eq!(schema.name, "sqlite_master");
682        assert_eq!(schema.columns.len(), 5);
683        assert_eq!(schema.columns[0].name, "type");
684        assert_eq!(schema.columns[1].name, "name");
685        assert_eq!(schema.columns[2].name, "tbl_name");
686        assert_eq!(schema.columns[3].name, "rootpage");
687        assert_eq!(schema.columns[4].name, "sql");
688    }
689
690    #[test]
691    fn test_format_data_type() {
692        assert_eq!(format_data_type(&DataType::Integer), "INTEGER");
693        assert_eq!(format_data_type(&DataType::Varchar { max_length: Some(255) }), "VARCHAR(255)");
694        assert_eq!(format_data_type(&DataType::Varchar { max_length: None }), "TEXT");
695        assert_eq!(format_data_type(&DataType::Boolean), "BOOLEAN");
696        assert_eq!(
697            format_data_type(&DataType::Numeric { precision: 10, scale: 2 }),
698            "NUMERIC(10, 2)"
699        );
700    }
701
702    #[test]
703    fn test_sqlite_schema_with_table() {
704        let mut catalog = Catalog::new();
705
706        // Create a simple table
707        let columns = vec![
708            ColumnSchema::new("id".to_string(), DataType::Integer, false),
709            ColumnSchema::new(
710                "name".to_string(),
711                DataType::Varchar { max_length: Some(100) },
712                true,
713            ),
714        ];
715        let table =
716            TableSchema::with_primary_key("users".to_string(), columns, vec!["id".to_string()]);
717        catalog.create_table(table).unwrap();
718
719        let result = execute_sqlite_schema_query(&catalog).unwrap();
720
721        // Should have one row for the table
722        assert_eq!(result.rows.len(), 1);
723
724        // Check the type column
725        let type_val = &result.rows[0].values[0];
726        assert_eq!(type_val, &SqlValue::Varchar(arcstr::ArcStr::from("table")));
727
728        // Check the name column
729        let name_val = &result.rows[0].values[1];
730        assert_eq!(name_val, &SqlValue::Varchar(arcstr::ArcStr::from("users")));
731
732        // Check the tbl_name column (same as name for tables)
733        let tbl_name_val = &result.rows[0].values[2];
734        assert_eq!(tbl_name_val, &SqlValue::Varchar(arcstr::ArcStr::from("users")));
735
736        // Check the rootpage column (always 0)
737        let rootpage_val = &result.rows[0].values[3];
738        assert_eq!(rootpage_val, &SqlValue::Integer(0));
739
740        // Check the sql column contains CREATE TABLE
741        let sql_val = &result.rows[0].values[4];
742        if let SqlValue::Varchar(sql) = sql_val {
743            assert!(sql.contains("CREATE TABLE users"));
744            assert!(sql.contains("id INTEGER"));
745            assert!(sql.contains("name VARCHAR(100)"));
746            assert!(sql.contains("PRIMARY KEY"));
747        } else {
748            panic!("Expected VARCHAR for sql column");
749        }
750    }
751
752    #[test]
753    fn test_sqlite_schema_with_index() {
754        let mut catalog = Catalog::new();
755
756        // Create a table first
757        let columns = vec![
758            ColumnSchema::new("id".to_string(), DataType::Integer, false),
759            ColumnSchema::new(
760                "email".to_string(),
761                DataType::Varchar { max_length: Some(255) },
762                false,
763            ),
764        ];
765        let table =
766            TableSchema::with_primary_key("users".to_string(), columns, vec!["id".to_string()]);
767        catalog.create_table(table).unwrap();
768
769        // Create an index
770        let index = IndexMetadata::new(
771            "idx_email".to_string(),
772            "users".to_string(),
773            IndexType::BTree,
774            vec![IndexedColumn::new_column("email".to_string(), SortOrder::Ascending)],
775            true, // unique
776        );
777        catalog.add_index(index).unwrap();
778
779        let result = execute_sqlite_schema_query(&catalog).unwrap();
780
781        // Should have two rows: one for table, one for index
782        assert_eq!(result.rows.len(), 2);
783
784        // Find the index row
785        let index_row = result
786            .rows
787            .iter()
788            .find(|r| matches!(&r.values[0], SqlValue::Varchar(s) if s.as_str() == "index"))
789            .expect("Should have an index row");
790
791        // Check index name
792        assert_eq!(index_row.values[1], SqlValue::Varchar(arcstr::ArcStr::from("idx_email")));
793
794        // Check tbl_name for index
795        assert_eq!(index_row.values[2], SqlValue::Varchar(arcstr::ArcStr::from("users")));
796
797        // Check sql contains CREATE INDEX
798        if let SqlValue::Varchar(sql) = &index_row.values[4] {
799            assert!(sql.contains("CREATE UNIQUE INDEX idx_email ON users"));
800        } else {
801            panic!("Expected VARCHAR for sql column");
802        }
803    }
804
805    #[test]
806    fn test_generate_create_index_sql() {
807        let index = IndexMetadata::new(
808            "idx_name".to_string(),
809            "users".to_string(),
810            IndexType::BTree,
811            vec![
812                IndexedColumn::new_column("last_name".to_string(), SortOrder::Ascending),
813                IndexedColumn::Column {
814                    column_name: "first_name".to_string(),
815                    order: SortOrder::Descending,
816                    prefix_length: None,
817                },
818            ],
819            false, // not unique
820        );
821
822        let sql = generate_create_index_sql(&index);
823        assert_eq!(sql, "CREATE INDEX idx_name ON users(last_name, first_name DESC)");
824    }
825
826    #[test]
827    fn test_generate_create_index_sql_with_prefix() {
828        let index = IndexMetadata::new(
829            "idx_email".to_string(),
830            "users".to_string(),
831            IndexType::BTree,
832            vec![IndexedColumn::new_column_with_prefix(
833                "email".to_string(),
834                SortOrder::Ascending,
835                50,
836            )],
837            true, // unique
838        );
839
840        let sql = generate_create_index_sql(&index);
841        assert_eq!(sql, "CREATE UNIQUE INDEX idx_email ON users(email(50))");
842    }
843
844    /// Regression test for #5579: `sqlite_master` must report the index name and
845    /// SQL with their original case (the case the user typed in `CREATE INDEX`),
846    /// matching sqlite3 3.51.0. The catalog stores the original-case name, so
847    /// both the `name` column and the generated `sql` column preserve it.
848    #[test]
849    fn test_sqlite_schema_index_preserves_name_case() {
850        let mut catalog = Catalog::new();
851        catalog.set_case_sensitive_identifiers(false);
852
853        let columns = vec![ColumnSchema::new("a".to_string(), DataType::Integer, false)];
854        let table = TableSchema::new("t".to_string(), columns);
855        catalog.create_table(table).unwrap();
856
857        let index = IndexMetadata::new(
858            "MyIdx".to_string(),
859            "t".to_string(),
860            IndexType::BTree,
861            vec![IndexedColumn::new_column("a".to_string(), SortOrder::Ascending)],
862            false,
863        );
864        catalog.add_index(index).unwrap();
865
866        let result = execute_sqlite_schema_query(&catalog).unwrap();
867        let index_row = result
868            .rows
869            .iter()
870            .find(|r| matches!(&r.values[0], SqlValue::Varchar(s) if s.as_str() == "index"))
871            .expect("Should have an index row");
872
873        // `name` column preserves original case (not "myidx").
874        assert_eq!(index_row.values[1], SqlValue::Varchar(arcstr::ArcStr::from("MyIdx")));
875
876        // `sql` column preserves original case.
877        if let SqlValue::Varchar(sql) = &index_row.values[4] {
878            assert!(
879                sql.contains("CREATE INDEX MyIdx ON t"),
880                "sqlite_master.sql must preserve original index-name case, got: {sql}"
881            );
882            assert!(!sql.contains("myidx"), "index name must not be lowercased, got: {sql}");
883        } else {
884            panic!("Expected VARCHAR for sql column");
885        }
886    }
887
888    #[test]
889    fn test_generate_create_trigger_sql_basic() {
890        use vibesql_ast::{TriggerAction, TriggerEvent, TriggerGranularity, TriggerTiming};
891        use vibesql_catalog::TriggerDefinition;
892
893        let trigger = TriggerDefinition {
894            name: "audit_insert".to_string(),
895            schema: None,
896            table_name: "users".to_string(),
897            timing: TriggerTiming::After,
898            event: TriggerEvent::Insert,
899            granularity: TriggerGranularity::Row,
900            when_condition: None,
901            triggered_action: TriggerAction::RawSql(
902                " BEGIN INSERT INTO audit VALUES (NEW.id); END".to_string(),
903            ),
904            enabled: true,
905            sql_definition: None,
906        };
907
908        let sql = generate_create_trigger_sql(&trigger);
909        assert_eq!(
910            sql,
911            "CREATE TRIGGER audit_insert AFTER INSERT ON users FOR EACH ROW BEGIN INSERT INTO audit VALUES (NEW.id); END"
912        );
913    }
914
915    #[test]
916    fn test_generate_create_trigger_sql_with_update_of() {
917        use vibesql_ast::{TriggerAction, TriggerEvent, TriggerGranularity, TriggerTiming};
918        use vibesql_catalog::TriggerDefinition;
919
920        let trigger = TriggerDefinition {
921            name: "track_status_change".to_string(),
922            schema: None,
923            table_name: "orders".to_string(),
924            timing: TriggerTiming::Before,
925            event: TriggerEvent::Update(Some(vec!["status".to_string(), "updated_at".to_string()])),
926            granularity: TriggerGranularity::Row,
927            when_condition: None,
928            triggered_action: TriggerAction::RawSql(" BEGIN SELECT 1; END".to_string()),
929            enabled: true,
930            sql_definition: None,
931        };
932
933        let sql = generate_create_trigger_sql(&trigger);
934        assert!(sql.contains("UPDATE OF status, updated_at"));
935        assert!(sql.contains("CREATE TRIGGER track_status_change BEFORE UPDATE OF"));
936    }
937
938    #[test]
939    fn test_generate_create_trigger_sql_instead_of() {
940        use vibesql_ast::{TriggerAction, TriggerEvent, TriggerGranularity, TriggerTiming};
941        use vibesql_catalog::TriggerDefinition;
942
943        let trigger = TriggerDefinition {
944            name: "instead_delete".to_string(),
945            schema: None,
946            table_name: "my_view".to_string(),
947            timing: TriggerTiming::InsteadOf,
948            event: TriggerEvent::Delete,
949            granularity: TriggerGranularity::Row,
950            when_condition: None,
951            triggered_action: TriggerAction::RawSql(
952                " BEGIN DELETE FROM base_table WHERE id = OLD.id; END".to_string(),
953            ),
954            enabled: true,
955            sql_definition: None,
956        };
957
958        let sql = generate_create_trigger_sql(&trigger);
959        assert!(sql.contains("INSTEAD OF DELETE"));
960        assert!(sql.contains("FOR EACH ROW"));
961    }
962
963    /// Create a minimal SelectStmt for testing purposes
964    fn create_minimal_select_stmt() -> vibesql_ast::SelectStmt {
965        vibesql_ast::SelectStmt {
966            with_clause: None,
967            distinct: false,
968            select_list: vec![vibesql_ast::SelectItem::Wildcard { alias: None }],
969            into_table: None,
970            into_variables: None,
971            from: None,
972            where_clause: None,
973            group_by: None,
974            having: None,
975            window_definitions: None,
976            order_by: None,
977            limit: None,
978            offset: None,
979            set_operation: None,
980            values: None,
981        }
982    }
983
984    #[test]
985    fn test_generate_create_view_sql_with_definition() {
986        use vibesql_catalog::ViewDefinition;
987
988        // Create a view with sql_definition set
989        let view = ViewDefinition::new_with_sql(
990            "active_users".to_string(),
991            Some(vec!["id".to_string(), "name".to_string()]),
992            create_minimal_select_stmt(),
993            false,
994            "CREATE VIEW active_users (id, name) AS SELECT id, name FROM users WHERE active = 1"
995                .to_string(),
996        );
997
998        let sql = generate_create_view_sql(&view);
999        assert_eq!(
1000            sql,
1001            "CREATE VIEW active_users (id, name) AS SELECT id, name FROM users WHERE active = 1"
1002        );
1003    }
1004
1005    #[test]
1006    fn test_generate_create_view_sql_fallback() {
1007        use vibesql_catalog::ViewDefinition;
1008
1009        // Create a view without sql_definition (fallback path)
1010        let view = ViewDefinition::new(
1011            "test_view".to_string(),
1012            Some(vec!["col1".to_string()]),
1013            create_minimal_select_stmt(),
1014            false,
1015        );
1016
1017        let sql = generate_create_view_sql(&view);
1018        // Should contain CREATE VIEW and the view name
1019        assert!(sql.contains("CREATE VIEW test_view"));
1020        // With column list specified
1021        assert!(sql.contains("(col1)"));
1022    }
1023}