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/// Get the schema for sqlite_master/sqlite_schema
33pub fn get_sqlite_schema_table_schema() -> TableSchema {
34    TableSchema::new(
35        "sqlite_master".to_string(),
36        vec![
37            ColumnSchema::new("type".to_string(), DataType::Varchar { max_length: None }, false),
38            ColumnSchema::new("name".to_string(), DataType::Varchar { max_length: None }, false),
39            ColumnSchema::new(
40                "tbl_name".to_string(),
41                DataType::Varchar { max_length: None },
42                false,
43            ),
44            ColumnSchema::new("rootpage".to_string(), DataType::Integer, false),
45            ColumnSchema::new("sql".to_string(), DataType::Varchar { max_length: None }, true),
46        ],
47    )
48}
49
50/// Execute a sqlite_master/sqlite_schema query
51pub fn execute_sqlite_schema_query(
52    catalog: &vibesql_catalog::Catalog,
53) -> Result<SelectResult, ExecutorError> {
54    let schema = get_sqlite_schema_table_schema();
55    let column_names: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
56    let mut rows = Vec::new();
57
58    // Add tables
59    for table_name in catalog.list_tables() {
60        if let Some(table) = catalog.get_table(&table_name) {
61            let sql = generate_create_table_sql(table);
62            rows.push(Row::new(vec![
63                SqlValue::Varchar(arcstr::ArcStr::from("table")),
64                SqlValue::Varchar(arcstr::ArcStr::from(table_name.clone())),
65                SqlValue::Varchar(arcstr::ArcStr::from(table_name)),
66                SqlValue::Integer(0), // rootpage - always 0 for VibeSQL
67                SqlValue::Varchar(arcstr::ArcStr::from(sql)),
68            ]));
69        }
70    }
71
72    // Add indexes
73    for index in catalog.list_all_indexes() {
74        let sql = generate_create_index_sql(index);
75        rows.push(Row::new(vec![
76            SqlValue::Varchar(arcstr::ArcStr::from("index")),
77            SqlValue::Varchar(arcstr::ArcStr::from(index.name.clone())),
78            SqlValue::Varchar(arcstr::ArcStr::from(index.table_name.clone())),
79            SqlValue::Integer(0), // rootpage - always 0 for VibeSQL
80            SqlValue::Varchar(arcstr::ArcStr::from(sql)),
81        ]));
82    }
83
84    // Add views
85    for view_name in catalog.list_views() {
86        if let Some(view) = catalog.get_view(&view_name) {
87            let sql = generate_create_view_sql(view);
88            rows.push(Row::new(vec![
89                SqlValue::Varchar(arcstr::ArcStr::from("view")),
90                SqlValue::Varchar(arcstr::ArcStr::from(view.name.clone())),
91                SqlValue::Varchar(arcstr::ArcStr::from(view.name.clone())), // tbl_name is same as name for views
92                SqlValue::Integer(0), // rootpage - always 0 for VibeSQL
93                SqlValue::Varchar(arcstr::ArcStr::from(sql)),
94            ]));
95        }
96    }
97
98    // Add triggers
99    for trigger_name in catalog.list_triggers() {
100        if let Some(trigger) = catalog.get_trigger(&trigger_name) {
101            let sql = generate_create_trigger_sql(trigger);
102            rows.push(Row::new(vec![
103                SqlValue::Varchar(arcstr::ArcStr::from("trigger")),
104                SqlValue::Varchar(arcstr::ArcStr::from(trigger.name.clone())),
105                SqlValue::Varchar(arcstr::ArcStr::from(trigger.table_name.clone())),
106                SqlValue::Integer(0), // rootpage - always 0 for VibeSQL
107                SqlValue::Varchar(arcstr::ArcStr::from(sql)),
108            ]));
109        }
110    }
111
112    Ok(SelectResult { columns: column_names, rows })
113}
114
115/// Generate CREATE TABLE SQL statement for a table
116fn generate_create_table_sql(table: &TableSchema) -> String {
117    let mut sql = format!("CREATE TABLE {} (\n", table.name);
118    let mut definitions: Vec<String> = Vec::new();
119
120    // Add column definitions
121    for col in &table.columns {
122        let mut col_def = format!("  {} {}", col.name, format_data_type(&col.data_type));
123
124        // Handle generated columns (AS expression syntax)
125        if let Some(ref generated_expr) = col.generated_expr {
126            col_def.push_str(&format!(" AS ({})", format_expression(generated_expr)));
127        } else {
128            // Only non-generated columns can have NOT NULL and DEFAULT
129            if !col.nullable {
130                col_def.push_str(" NOT NULL");
131            }
132
133            if let Some(ref default) = col.default_value {
134                col_def.push_str(&format!(" DEFAULT {}", format_expression(default)));
135            }
136        }
137
138        definitions.push(col_def);
139    }
140
141    // Add PRIMARY KEY constraint
142    if let Some(ref pk_cols) = table.primary_key {
143        definitions.push(format!("  PRIMARY KEY ({})", pk_cols.join(", ")));
144    }
145
146    // Add UNIQUE constraints
147    for unique_cols in &table.unique_constraints {
148        definitions.push(format!("  UNIQUE ({})", unique_cols.join(", ")));
149    }
150
151    // Add FOREIGN KEY constraints
152    for fk in &table.foreign_keys {
153        let mut fk_def = format!(
154            "  FOREIGN KEY ({}) REFERENCES {} ({})",
155            fk.column_names.join(", "),
156            fk.parent_table,
157            fk.parent_column_names.join(", ")
158        );
159
160        // Add ON DELETE action if not NO ACTION
161        if fk.on_delete != ReferentialAction::NoAction {
162            fk_def.push_str(&format!(" ON DELETE {}", format_referential_action(&fk.on_delete)));
163        }
164
165        // Add ON UPDATE action if not NO ACTION
166        if fk.on_update != ReferentialAction::NoAction {
167            fk_def.push_str(&format!(" ON UPDATE {}", format_referential_action(&fk.on_update)));
168        }
169
170        definitions.push(fk_def);
171    }
172
173    // Add CHECK constraints
174    for (name, expr) in &table.check_constraints {
175        definitions.push(format!("  CONSTRAINT {} CHECK ({})", name, format_expression(expr)));
176    }
177
178    sql.push_str(&definitions.join(",\n"));
179    sql.push_str("\n)");
180
181    sql
182}
183
184/// Generate CREATE INDEX SQL statement for an index
185fn generate_create_index_sql(index: &vibesql_catalog::IndexMetadata) -> String {
186    let unique_str = if index.is_unique { "UNIQUE " } else { "" };
187
188    let columns: Vec<String> = index
189        .columns
190        .iter()
191        .map(|col| {
192            let order_str = match col.order() {
193                SortOrder::Ascending => "",
194                SortOrder::Descending => " DESC",
195            };
196            match col {
197                vibesql_catalog::IndexedColumn::Column { column_name, prefix_length, .. } => {
198                    let prefix_str = prefix_length.map(|l| format!("({})", l)).unwrap_or_default();
199                    format!("{}{}{}", column_name, prefix_str, order_str)
200                }
201                vibesql_catalog::IndexedColumn::Expression { expr, .. } => {
202                    // For expression indexes, wrap the expression in parentheses
203                    format!("({}){}", expr.to_sql(), order_str)
204                }
205            }
206        })
207        .collect();
208
209    format!(
210        "CREATE {}INDEX {} ON {}({})",
211        unique_str,
212        index.name,
213        index.table_name,
214        columns.join(", ")
215    )
216}
217
218/// Generate CREATE VIEW SQL statement for a view
219fn generate_create_view_sql(view: &vibesql_catalog::ViewDefinition) -> String {
220    // Use stored SQL definition if available
221    if let Some(ref sql) = view.sql_definition {
222        return sql.clone();
223    }
224
225    // Otherwise generate from the view definition
226    let columns_str =
227        view.columns.as_ref().map(|cols| format!(" ({})", cols.join(", "))).unwrap_or_default();
228
229    // Use the ToSql trait to generate valid SQL from the AST
230    use vibesql_ast::pretty_print::ToSql;
231    format!("CREATE VIEW {}{} AS {}", view.name, columns_str, view.query.to_sql())
232}
233
234/// Generate CREATE TRIGGER SQL statement for a trigger
235fn generate_create_trigger_sql(trigger: &vibesql_catalog::TriggerDefinition) -> String {
236    use vibesql_ast::{TriggerAction, TriggerEvent, TriggerGranularity, TriggerTiming};
237
238    let timing = match trigger.timing {
239        TriggerTiming::Before => "BEFORE",
240        TriggerTiming::After => "AFTER",
241        TriggerTiming::InsteadOf => "INSTEAD OF",
242    };
243
244    let granularity = match trigger.granularity {
245        TriggerGranularity::Row => "ROW",
246        TriggerGranularity::Statement => "STATEMENT",
247    };
248
249    let event = match &trigger.event {
250        TriggerEvent::Insert => "INSERT".to_string(),
251        TriggerEvent::Update(None) => "UPDATE".to_string(),
252        TriggerEvent::Update(Some(cols)) => format!("UPDATE OF {}", cols.join(", ")),
253        TriggerEvent::Delete => "DELETE".to_string(),
254    };
255
256    // Include WHEN clause if present
257    let when_clause = trigger
258        .when_condition
259        .as_ref()
260        .map(|expr| format!(" WHEN ({})", format_expression(expr)))
261        .unwrap_or_default();
262
263    // Include trigger body from TriggerAction
264    let body = match &trigger.triggered_action {
265        TriggerAction::RawSql(sql) => sql.clone(),
266    };
267
268    format!(
269        "CREATE TRIGGER {} {} {} ON {} FOR EACH {}{}{}",
270        trigger.name, timing, event, trigger.table_name, granularity, when_clause, body
271    )
272}
273
274/// Format a DataType as a SQL string
275fn format_data_type(dt: &DataType) -> String {
276    match dt {
277        DataType::Integer => "INTEGER".to_string(),
278        DataType::Smallint => "SMALLINT".to_string(),
279        DataType::Bigint => "BIGINT".to_string(),
280        DataType::Unsigned => "UNSIGNED BIGINT".to_string(),
281        DataType::Real => "REAL".to_string(),
282        DataType::Float { precision } => format!("FLOAT({})", precision),
283        DataType::DoublePrecision => "DOUBLE PRECISION".to_string(),
284        DataType::Numeric { precision, scale } => format!("NUMERIC({}, {})", precision, scale),
285        DataType::Decimal { precision, scale } => format!("DECIMAL({}, {})", precision, scale),
286        DataType::Varchar { max_length } => {
287            if let Some(len) = max_length {
288                format!("VARCHAR({})", len)
289            } else {
290                "TEXT".to_string()
291            }
292        }
293        DataType::Character { length } => format!("CHAR({})", length),
294        DataType::CharacterLargeObject => "TEXT".to_string(),
295        DataType::Name => "TEXT".to_string(),
296        DataType::Boolean => "BOOLEAN".to_string(),
297        DataType::Date => "DATE".to_string(),
298        DataType::Time { with_timezone } => {
299            if *with_timezone {
300                "TIME WITH TIME ZONE".to_string()
301            } else {
302                "TIME".to_string()
303            }
304        }
305        DataType::Timestamp { with_timezone } => {
306            if *with_timezone {
307                "TIMESTAMP WITH TIME ZONE".to_string()
308            } else {
309                "TIMESTAMP".to_string()
310            }
311        }
312        DataType::Interval { start_field, end_field } => {
313            if let Some(end) = end_field {
314                format!("INTERVAL {:?} TO {:?}", start_field, end)
315            } else {
316                format!("INTERVAL {:?}", start_field)
317            }
318        }
319        DataType::BinaryLargeObject => "BLOB".to_string(),
320        DataType::Bit { length } => {
321            if let Some(len) = length {
322                format!("BIT({})", len)
323            } else {
324                "BIT".to_string()
325            }
326        }
327        DataType::UserDefined { type_name } => type_name.clone(),
328        DataType::Vector { dimensions } => format!("VECTOR({})", dimensions),
329        DataType::Null => "NULL".to_string(),
330    }
331}
332
333/// Format a referential action as a SQL string
334fn format_referential_action(action: &ReferentialAction) -> &'static str {
335    match action {
336        ReferentialAction::NoAction => "NO ACTION",
337        ReferentialAction::Restrict => "RESTRICT",
338        ReferentialAction::Cascade => "CASCADE",
339        ReferentialAction::SetNull => "SET NULL",
340        ReferentialAction::SetDefault => "SET DEFAULT",
341    }
342}
343
344/// Format an expression for SQL output
345fn format_expression(expr: &vibesql_ast::Expression) -> String {
346    use vibesql_ast::pretty_print::ToSql;
347    expr.to_sql()
348}
349
350#[cfg(test)]
351mod tests {
352    use vibesql_catalog::{Catalog, IndexMetadata, IndexType, IndexedColumn};
353
354    use super::*;
355
356    #[test]
357    fn test_is_sqlite_schema_table() {
358        assert!(is_sqlite_schema_table("sqlite_master"));
359        assert!(is_sqlite_schema_table("sqlite_schema"));
360        assert!(is_sqlite_schema_table("SQLITE_MASTER"));
361        assert!(is_sqlite_schema_table("SQLITE_SCHEMA"));
362        assert!(is_sqlite_schema_table("Sqlite_Master"));
363        assert!(!is_sqlite_schema_table("users"));
364        assert!(!is_sqlite_schema_table("sqlite_stat1"));
365        assert!(!is_sqlite_schema_table("information_schema.tables"));
366    }
367
368    #[test]
369    fn test_sqlite_schema_empty_db() {
370        let catalog = Catalog::new();
371        let result = execute_sqlite_schema_query(&catalog).unwrap();
372
373        assert_eq!(result.columns, vec!["type", "name", "tbl_name", "rootpage", "sql"]);
374        assert!(result.rows.is_empty());
375    }
376
377    #[test]
378    fn test_get_sqlite_schema_table_schema() {
379        let schema = get_sqlite_schema_table_schema();
380
381        assert_eq!(schema.name, "sqlite_master");
382        assert_eq!(schema.columns.len(), 5);
383        assert_eq!(schema.columns[0].name, "type");
384        assert_eq!(schema.columns[1].name, "name");
385        assert_eq!(schema.columns[2].name, "tbl_name");
386        assert_eq!(schema.columns[3].name, "rootpage");
387        assert_eq!(schema.columns[4].name, "sql");
388    }
389
390    #[test]
391    fn test_format_data_type() {
392        assert_eq!(format_data_type(&DataType::Integer), "INTEGER");
393        assert_eq!(format_data_type(&DataType::Varchar { max_length: Some(255) }), "VARCHAR(255)");
394        assert_eq!(format_data_type(&DataType::Varchar { max_length: None }), "TEXT");
395        assert_eq!(format_data_type(&DataType::Boolean), "BOOLEAN");
396        assert_eq!(
397            format_data_type(&DataType::Numeric { precision: 10, scale: 2 }),
398            "NUMERIC(10, 2)"
399        );
400    }
401
402    #[test]
403    fn test_sqlite_schema_with_table() {
404        let mut catalog = Catalog::new();
405
406        // Create a simple table
407        let columns = vec![
408            ColumnSchema::new("id".to_string(), DataType::Integer, false),
409            ColumnSchema::new(
410                "name".to_string(),
411                DataType::Varchar { max_length: Some(100) },
412                true,
413            ),
414        ];
415        let table =
416            TableSchema::with_primary_key("users".to_string(), columns, vec!["id".to_string()]);
417        catalog.create_table(table).unwrap();
418
419        let result = execute_sqlite_schema_query(&catalog).unwrap();
420
421        // Should have one row for the table
422        assert_eq!(result.rows.len(), 1);
423
424        // Check the type column
425        let type_val = &result.rows[0].values[0];
426        assert_eq!(type_val, &SqlValue::Varchar(arcstr::ArcStr::from("table")));
427
428        // Check the name column
429        let name_val = &result.rows[0].values[1];
430        assert_eq!(name_val, &SqlValue::Varchar(arcstr::ArcStr::from("users")));
431
432        // Check the tbl_name column (same as name for tables)
433        let tbl_name_val = &result.rows[0].values[2];
434        assert_eq!(tbl_name_val, &SqlValue::Varchar(arcstr::ArcStr::from("users")));
435
436        // Check the rootpage column (always 0)
437        let rootpage_val = &result.rows[0].values[3];
438        assert_eq!(rootpage_val, &SqlValue::Integer(0));
439
440        // Check the sql column contains CREATE TABLE
441        let sql_val = &result.rows[0].values[4];
442        if let SqlValue::Varchar(sql) = sql_val {
443            assert!(sql.contains("CREATE TABLE users"));
444            assert!(sql.contains("id INTEGER"));
445            assert!(sql.contains("name VARCHAR(100)"));
446            assert!(sql.contains("PRIMARY KEY"));
447        } else {
448            panic!("Expected VARCHAR for sql column");
449        }
450    }
451
452    #[test]
453    fn test_sqlite_schema_with_index() {
454        let mut catalog = Catalog::new();
455
456        // Create a table first
457        let columns = vec![
458            ColumnSchema::new("id".to_string(), DataType::Integer, false),
459            ColumnSchema::new(
460                "email".to_string(),
461                DataType::Varchar { max_length: Some(255) },
462                false,
463            ),
464        ];
465        let table =
466            TableSchema::with_primary_key("users".to_string(), columns, vec!["id".to_string()]);
467        catalog.create_table(table).unwrap();
468
469        // Create an index
470        let index = IndexMetadata::new(
471            "idx_email".to_string(),
472            "users".to_string(),
473            IndexType::BTree,
474            vec![IndexedColumn::new_column("email".to_string(), SortOrder::Ascending)],
475            true, // unique
476        );
477        catalog.add_index(index).unwrap();
478
479        let result = execute_sqlite_schema_query(&catalog).unwrap();
480
481        // Should have two rows: one for table, one for index
482        assert_eq!(result.rows.len(), 2);
483
484        // Find the index row
485        let index_row = result
486            .rows
487            .iter()
488            .find(|r| matches!(&r.values[0], SqlValue::Varchar(s) if s.as_str() == "index"))
489            .expect("Should have an index row");
490
491        // Check index name
492        assert_eq!(index_row.values[1], SqlValue::Varchar(arcstr::ArcStr::from("idx_email")));
493
494        // Check tbl_name for index
495        assert_eq!(index_row.values[2], SqlValue::Varchar(arcstr::ArcStr::from("users")));
496
497        // Check sql contains CREATE INDEX
498        if let SqlValue::Varchar(sql) = &index_row.values[4] {
499            assert!(sql.contains("CREATE UNIQUE INDEX idx_email ON users"));
500        } else {
501            panic!("Expected VARCHAR for sql column");
502        }
503    }
504
505    #[test]
506    fn test_generate_create_index_sql() {
507        let index = IndexMetadata::new(
508            "idx_name".to_string(),
509            "users".to_string(),
510            IndexType::BTree,
511            vec![
512                IndexedColumn::new_column("last_name".to_string(), SortOrder::Ascending),
513                IndexedColumn::Column {
514                    column_name: "first_name".to_string(),
515                    order: SortOrder::Descending,
516                    prefix_length: None,
517                },
518            ],
519            false, // not unique
520        );
521
522        let sql = generate_create_index_sql(&index);
523        assert_eq!(sql, "CREATE INDEX idx_name ON users(last_name, first_name DESC)");
524    }
525
526    #[test]
527    fn test_generate_create_index_sql_with_prefix() {
528        let index = IndexMetadata::new(
529            "idx_email".to_string(),
530            "users".to_string(),
531            IndexType::BTree,
532            vec![IndexedColumn::new_column_with_prefix(
533                "email".to_string(),
534                SortOrder::Ascending,
535                50,
536            )],
537            true, // unique
538        );
539
540        let sql = generate_create_index_sql(&index);
541        assert_eq!(sql, "CREATE UNIQUE INDEX idx_email ON users(email(50))");
542    }
543
544    #[test]
545    fn test_generate_create_trigger_sql_basic() {
546        use vibesql_ast::{TriggerAction, TriggerEvent, TriggerGranularity, TriggerTiming};
547        use vibesql_catalog::TriggerDefinition;
548
549        let trigger = TriggerDefinition {
550            name: "audit_insert".to_string(),
551            table_name: "users".to_string(),
552            timing: TriggerTiming::After,
553            event: TriggerEvent::Insert,
554            granularity: TriggerGranularity::Row,
555            when_condition: None,
556            triggered_action: TriggerAction::RawSql(
557                " BEGIN INSERT INTO audit VALUES (NEW.id); END".to_string(),
558            ),
559            enabled: true,
560        };
561
562        let sql = generate_create_trigger_sql(&trigger);
563        assert_eq!(
564            sql,
565            "CREATE TRIGGER audit_insert AFTER INSERT ON users FOR EACH ROW BEGIN INSERT INTO audit VALUES (NEW.id); END"
566        );
567    }
568
569    #[test]
570    fn test_generate_create_trigger_sql_with_update_of() {
571        use vibesql_ast::{TriggerAction, TriggerEvent, TriggerGranularity, TriggerTiming};
572        use vibesql_catalog::TriggerDefinition;
573
574        let trigger = TriggerDefinition {
575            name: "track_status_change".to_string(),
576            table_name: "orders".to_string(),
577            timing: TriggerTiming::Before,
578            event: TriggerEvent::Update(Some(vec!["status".to_string(), "updated_at".to_string()])),
579            granularity: TriggerGranularity::Row,
580            when_condition: None,
581            triggered_action: TriggerAction::RawSql(" BEGIN SELECT 1; END".to_string()),
582            enabled: true,
583        };
584
585        let sql = generate_create_trigger_sql(&trigger);
586        assert!(sql.contains("UPDATE OF status, updated_at"));
587        assert!(sql.contains("CREATE TRIGGER track_status_change BEFORE UPDATE OF"));
588    }
589
590    #[test]
591    fn test_generate_create_trigger_sql_instead_of() {
592        use vibesql_ast::{TriggerAction, TriggerEvent, TriggerGranularity, TriggerTiming};
593        use vibesql_catalog::TriggerDefinition;
594
595        let trigger = TriggerDefinition {
596            name: "instead_delete".to_string(),
597            table_name: "my_view".to_string(),
598            timing: TriggerTiming::InsteadOf,
599            event: TriggerEvent::Delete,
600            granularity: TriggerGranularity::Row,
601            when_condition: None,
602            triggered_action: TriggerAction::RawSql(
603                " BEGIN DELETE FROM base_table WHERE id = OLD.id; END".to_string(),
604            ),
605            enabled: true,
606        };
607
608        let sql = generate_create_trigger_sql(&trigger);
609        assert!(sql.contains("INSTEAD OF DELETE"));
610        assert!(sql.contains("FOR EACH ROW"));
611    }
612
613    /// Create a minimal SelectStmt for testing purposes
614    fn create_minimal_select_stmt() -> vibesql_ast::SelectStmt {
615        vibesql_ast::SelectStmt {
616            with_clause: None,
617            distinct: false,
618            select_list: vec![vibesql_ast::SelectItem::Wildcard { alias: None }],
619            into_table: None,
620            into_variables: None,
621            from: None,
622            where_clause: None,
623            group_by: None,
624            having: None,
625            order_by: None,
626            limit: None,
627            offset: None,
628            set_operation: None,
629            values: None,
630        }
631    }
632
633    #[test]
634    fn test_generate_create_view_sql_with_definition() {
635        use vibesql_catalog::ViewDefinition;
636
637        // Create a view with sql_definition set
638        let view = ViewDefinition::new_with_sql(
639            "active_users".to_string(),
640            Some(vec!["id".to_string(), "name".to_string()]),
641            create_minimal_select_stmt(),
642            false,
643            "CREATE VIEW active_users (id, name) AS SELECT id, name FROM users WHERE active = 1"
644                .to_string(),
645        );
646
647        let sql = generate_create_view_sql(&view);
648        assert_eq!(
649            sql,
650            "CREATE VIEW active_users (id, name) AS SELECT id, name FROM users WHERE active = 1"
651        );
652    }
653
654    #[test]
655    fn test_generate_create_view_sql_fallback() {
656        use vibesql_catalog::ViewDefinition;
657
658        // Create a view without sql_definition (fallback path)
659        let view = ViewDefinition::new(
660            "test_view".to_string(),
661            Some(vec!["col1".to_string()]),
662            create_minimal_select_stmt(),
663            false,
664        );
665
666        let sql = generate_create_view_sql(&view);
667        // Should contain CREATE VIEW and the view name
668        assert!(sql.contains("CREATE VIEW test_view"));
669        // With column list specified
670        assert!(sql.contains("(col1)"));
671    }
672}