vibesql-executor 0.2.0

Query execution engine for vibesql SQL database
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
//! Integration tests for schema-aware catalog indexes and `sqlite_temp_master`.
//!
//! Issue #5513: A temp-table index lives in the session temp schema. It must be
//! listed in `sqlite_temp_master` (not `sqlite_master`), dropped with its temp
//! table, and able to coexist with a same-named main-schema index. This matches
//! SQLite 3.51.0:
//!
//! ```sql
//! CREATE TEMP TABLE t(a);
//! CREATE INDEX i ON t(a);
//! SELECT name,type FROM sqlite_temp_master;  -- lists t and i
//! SELECT name FROM sqlite_master;            -- lists neither
//! ```

use vibesql_executor::{
    CreateIndexExecutor, CreateTableExecutor, DropIndexExecutor, DropTableExecutor, SelectExecutor,
    TriggerExecutor, ViewExecutor,
};
use vibesql_parser::Parser;
use vibesql_storage::{Database, Row};
use vibesql_types::SqlValue;

fn exec_create_table(db: &mut Database, sql: &str) {
    let stmt = Parser::parse_sql(sql).expect("parse CREATE TABLE");
    match stmt {
        vibesql_ast::Statement::CreateTable(s) => {
            CreateTableExecutor::execute(&s, db).expect("CREATE TABLE")
        }
        other => panic!("expected CREATE TABLE, got {other:?}"),
    };
}

fn exec_create_index(db: &mut Database, sql: &str) {
    let stmt = Parser::parse_sql(sql).expect("parse CREATE INDEX");
    match stmt {
        vibesql_ast::Statement::CreateIndex(s) => {
            CreateIndexExecutor::execute(&s, db).expect("CREATE INDEX")
        }
        other => panic!("expected CREATE INDEX, got {other:?}"),
    };
}

fn exec_drop_index(db: &mut Database, sql: &str) {
    let stmt = Parser::parse_sql(sql).expect("parse DROP INDEX");
    match stmt {
        vibesql_ast::Statement::DropIndex(s) => {
            DropIndexExecutor::execute(&s, db).expect("DROP INDEX")
        }
        other => panic!("expected DROP INDEX, got {other:?}"),
    };
}

/// Execute an INSERT statement.
fn exec_insert(db: &mut Database, sql: &str) {
    let stmt = Parser::parse_sql(sql).expect("parse INSERT");
    match stmt {
        vibesql_ast::Statement::Insert(s) => {
            vibesql_executor::InsertExecutor::execute(db, &s).expect("INSERT");
        }
        other => panic!("expected INSERT, got {other:?}"),
    }
}

fn exec_drop_table(db: &mut Database, sql: &str) {
    let stmt = Parser::parse_sql(sql).expect("parse DROP TABLE");
    match stmt {
        vibesql_ast::Statement::DropTable(s) => {
            DropTableExecutor::execute(&s, db).expect("DROP TABLE")
        }
        other => panic!("expected DROP TABLE, got {other:?}"),
    };
}

fn exec_create_trigger(db: &mut Database, sql: &str) {
    let stmt = Parser::parse_sql(sql).expect("parse CREATE TRIGGER");
    match stmt {
        vibesql_ast::Statement::CreateTrigger(s) => {
            TriggerExecutor::create_trigger(db, &s).expect("CREATE TRIGGER");
        }
        other => panic!("expected CREATE TRIGGER, got {other:?}"),
    };
}

fn exec_create_view(db: &mut Database, sql: &str) {
    let stmt = Parser::parse_sql(sql).expect("parse CREATE VIEW");
    match stmt {
        vibesql_ast::Statement::CreateView(s) => {
            ViewExecutor::execute_create_view(&s, db).expect("CREATE VIEW");
        }
        other => panic!("expected CREATE VIEW, got {other:?}"),
    };
}

/// Run a SELECT and return whether it errored (used to assert "view errors on
/// use" parity after its underlying table is dropped).
fn select_is_err(db: &Database, sql: &str) -> bool {
    let stmt = match Parser::parse_sql(sql) {
        Ok(s) => s,
        Err(_) => return true,
    };
    match stmt {
        vibesql_ast::Statement::Select(s) => {
            SelectExecutor::new(db).execute_with_columns(&s).is_err()
        }
        other => panic!("expected SELECT, got {other:?}"),
    }
}

fn select(db: &Database, sql: &str) -> (Vec<String>, Vec<Row>) {
    let stmt = Parser::parse_sql(sql).expect("parse SELECT");
    match stmt {
        vibesql_ast::Statement::Select(s) => {
            let executor = SelectExecutor::new(db);
            let r = executor.execute_with_columns(&s).expect("SELECT");
            (r.columns, r.rows)
        }
        other => panic!("expected SELECT, got {other:?}"),
    }
}

/// Pull the `name` (column 0) string values out of a result set.
fn names(rows: &[Row]) -> Vec<String> {
    rows.iter()
        .map(|r| match &r.values[0] {
            SqlValue::Varchar(s) => s.to_string(),
            other => panic!("expected text name, got {other:?}"),
        })
        .collect()
}

/// sqlite3 parity: a temp table and its index appear in `sqlite_temp_master`.
#[test]
fn temp_table_and_index_listed_in_temp_master() {
    let mut db = Database::new();
    exec_create_table(&mut db, "CREATE TEMP TABLE t (a)");
    exec_create_index(&mut db, "CREATE INDEX i ON t(a)");

    let (_, rows) = select(&db, "SELECT name FROM sqlite_temp_master ORDER BY name");
    let listed = names(&rows);
    assert!(listed.contains(&"t".to_string()), "temp table t should be in sqlite_temp_master: {listed:?}");
    assert!(listed.contains(&"i".to_string()), "temp index i should be in sqlite_temp_master: {listed:?}");
}

/// sqlite3 parity: temp objects are absent from `sqlite_master`.
#[test]
fn temp_index_absent_from_sqlite_master() {
    let mut db = Database::new();
    exec_create_table(&mut db, "CREATE TEMP TABLE t (a)");
    exec_create_index(&mut db, "CREATE INDEX i ON t(a)");

    let (_, rows) = select(&db, "SELECT name FROM sqlite_master");
    let listed = names(&rows);
    assert!(!listed.contains(&"t".to_string()), "temp table must NOT be in sqlite_master: {listed:?}");
    assert!(!listed.contains(&"i".to_string()), "temp index must NOT be in sqlite_master: {listed:?}");
}

/// A main-table index stays in `sqlite_master` and is absent from temp_master.
#[test]
fn main_index_in_master_not_temp_master() {
    let mut db = Database::new();
    exec_create_table(&mut db, "CREATE TABLE base (a)");
    exec_create_index(&mut db, "CREATE INDEX mi ON base(a)");

    let (_, master) = select(&db, "SELECT name FROM sqlite_master");
    assert!(names(&master).contains(&"mi".to_string()), "main index should be in sqlite_master");

    let (_, temp) = select(&db, "SELECT name FROM sqlite_temp_master");
    assert!(!names(&temp).contains(&"mi".to_string()), "main index must NOT be in sqlite_temp_master");
}

/// Count the rows of a single-column COUNT(*) result.
fn scalar_i64(db: &Database, sql: &str) -> i64 {
    let (_, rows) = select(db, sql);
    assert_eq!(rows.len(), 1, "expected one result row for {sql}");
    match &rows[0].values[0] {
        SqlValue::Integer(n) => *n,
        SqlValue::Bigint(n) => *n,
        other => panic!("expected integer scalar, got {other:?}"),
    }
}

/// #5540 (was #5513's documented SQL-level limitation): a temp-schema index and
/// a main-schema index can share a bare name at the SQL level, because the
/// storage index manager is now schema-aware. Both must coexist, both must be
/// usable, and an unqualified DROP INDEX must resolve temp-shadows-main —
/// matching sqlite3 3.51.0:
///
/// ```sql
/// CREATE TABLE main_t(a); CREATE TEMP TABLE temp_t(a);
/// CREATE INDEX ix ON main_t(a);  -- main.ix
/// CREATE INDEX ix ON temp_t(a);  -- temp.ix  (succeeds in sqlite3)
/// ```
#[test]
fn sql_level_same_name_index_across_schemas_coexist() {
    let mut db = Database::new();
    exec_create_table(&mut db, "CREATE TABLE main_t (a)");
    exec_create_table(&mut db, "CREATE TEMP TABLE temp_t (a)");

    // Distinct data so a live SELECT through each index is observable.
    exec_insert(&mut db, "INSERT INTO main_t VALUES (1)");
    exec_insert(&mut db, "INSERT INTO main_t VALUES (2)");
    exec_insert(&mut db, "INSERT INTO temp.temp_t VALUES (10)");

    // main.ix and temp.ix share the bare name `ix` — both CREATE INDEX succeed.
    exec_create_index(&mut db, "CREATE INDEX ix ON main_t(a)");
    exec_create_index(&mut db, "CREATE INDEX ix ON temp_t(a)");

    // Both indexes are registered in their respective schemas' master tables.
    let (_, master) = select(&db, "SELECT name FROM sqlite_master WHERE type='index'");
    assert!(names(&master).contains(&"ix".to_string()), "main.ix must be in sqlite_master");
    let (_, temp_master) =
        select(&db, "SELECT name FROM sqlite_temp_master WHERE type='index'");
    assert!(
        names(&temp_master).contains(&"ix".to_string()),
        "temp.ix must be in sqlite_temp_master"
    );

    // Both indexes are usable: a live SELECT through each returns the right rows.
    assert_eq!(scalar_i64(&db, "SELECT count(*) FROM main_t WHERE a = 1"), 1);
    assert_eq!(scalar_i64(&db, "SELECT count(*) FROM main_t WHERE a = 2"), 1);
    assert_eq!(scalar_i64(&db, "SELECT count(*) FROM temp.temp_t WHERE a = 10"), 1);
    // The temp index does not leak the main table's values, and vice versa.
    assert_eq!(scalar_i64(&db, "SELECT count(*) FROM temp.temp_t WHERE a = 1"), 0);
    assert_eq!(scalar_i64(&db, "SELECT count(*) FROM main_t WHERE a = 10"), 0);

    // Unqualified DROP INDEX resolves temp-shadows-main: it drops temp.ix and
    // leaves main.ix intact (matching sqlite3's name resolution).
    exec_drop_index(&mut db, "DROP INDEX ix");

    let (_, temp_after) =
        select(&db, "SELECT name FROM sqlite_temp_master WHERE type='index'");
    assert!(
        !names(&temp_after).contains(&"ix".to_string()),
        "unqualified DROP INDEX must remove the temp index first"
    );
    let (_, master_after) = select(&db, "SELECT name FROM sqlite_master WHERE type='index'");
    assert!(
        names(&master_after).contains(&"ix".to_string()),
        "main.ix must survive an unqualified DROP INDEX that resolved to temp.ix"
    );

    // The surviving main index still works.
    assert_eq!(scalar_i64(&db, "SELECT count(*) FROM main_t WHERE a = 2"), 1);

    // A second unqualified DROP INDEX now removes main.ix.
    exec_drop_index(&mut db, "DROP INDEX ix");
    let (_, master_final) = select(&db, "SELECT name FROM sqlite_master WHERE type='index'");
    assert!(
        !names(&master_final).contains(&"ix".to_string()),
        "the second DROP INDEX removes the remaining main index"
    );
}

/// A temp index is dropped together with its temp table, and dropping the temp
/// table does not disturb a main index on another table.
#[test]
fn temp_index_dropped_with_temp_table() {
    let mut db = Database::new();
    exec_create_table(&mut db, "CREATE TABLE base (a)");
    exec_create_table(&mut db, "CREATE TEMP TABLE t (a)");
    exec_create_index(&mut db, "CREATE INDEX mi ON base(a)"); // main index
    exec_create_index(&mut db, "CREATE INDEX ti ON t(a)"); // temp index

    // Before: temp_master lists the temp index; master lists the main index.
    let (_, before) = select(&db, "SELECT name FROM sqlite_temp_master WHERE type='index'");
    assert!(names(&before).contains(&"ti".to_string()));

    // Drop the temp table. Use the schema-qualified name because an unqualified
    // DROP TABLE does not yet resolve into the temp schema (separate pre-existing
    // limitation, unrelated to #5513's index work).
    exec_drop_table(&mut db, "DROP TABLE temp.t");

    // After: temp index gone from temp_master.
    let (_, after_temp) = select(&db, "SELECT name FROM sqlite_temp_master WHERE type='index'");
    assert!(
        !names(&after_temp).contains(&"ti".to_string()),
        "temp index should be dropped with the temp table"
    );

    // Main index on the other table survives.
    let (_, after_main) = select(&db, "SELECT name FROM sqlite_master WHERE type='index'");
    assert!(
        names(&after_main).contains(&"mi".to_string()),
        "main index must survive dropping the temp table"
    );
}

/// Issue #5583 — temp lifecycle: a temp trigger defined ON a temp table is
/// dropped together with that table, while a temp VIEW referencing the table is
/// NOT auto-dropped (it remains in sqlite_temp_master and errors on use).
///
/// Verified against sqlite3 3.51.0:
///
/// ```text
/// CREATE TEMP TABLE t(a);
/// CREATE TEMP TRIGGER tr AFTER INSERT ON t BEGIN SELECT 1; END;
/// CREATE TEMP VIEW v AS SELECT * FROM t;
/// DROP TABLE t;                  -- tr gone, v remains
/// SELECT type,name FROM sqlite_temp_master;  -- only: view v
/// SELECT * FROM v;               -- Error: no such table: t
/// ```
#[test]
fn temp_trigger_dropped_with_temp_table_view_remains() {
    let mut db = Database::new();
    exec_create_table(&mut db, "CREATE TEMP TABLE t (a)");
    exec_create_trigger(&mut db, "CREATE TEMP TRIGGER tr AFTER INSERT ON t BEGIN SELECT 1; END");
    exec_create_view(&mut db, "CREATE TEMP VIEW v AS SELECT * FROM t");

    // Before the drop: both the trigger and the view are surfaced by temp_master.
    let (_, before) = select(&db, "SELECT name FROM sqlite_temp_master WHERE type='trigger'");
    assert!(names(&before).contains(&"tr".to_string()), "temp trigger should be listed before drop");
    let (_, before_v) = select(&db, "SELECT name FROM sqlite_temp_master WHERE type='view'");
    assert!(names(&before_v).contains(&"v".to_string()), "temp view should be listed before drop");

    // Drop the temp table (schema-qualified: unqualified DROP TABLE not resolving
    // into temp is a separate pre-existing limitation, same as the index test).
    exec_drop_table(&mut db, "DROP TABLE temp.t");

    // The temp trigger ON the table is gone (matches sqlite3).
    let (_, after_tr) = select(&db, "SELECT name FROM sqlite_temp_master WHERE type='trigger'");
    assert!(
        !names(&after_tr).contains(&"tr".to_string()),
        "temp trigger ON the dropped table must be cascade-dropped"
    );

    // The view referencing the table is NOT auto-dropped (matches sqlite3).
    let (_, after_v) = select(&db, "SELECT name FROM sqlite_temp_master WHERE type='view'");
    assert!(
        names(&after_v).contains(&"v".to_string()),
        "a view merely referencing the dropped table must NOT be auto-dropped"
    );

    // ...but the surviving view errors on use, since its base table is gone.
    assert!(
        select_is_err(&db, "SELECT * FROM v"),
        "the surviving view must error on use after its base table is dropped"
    );
}

/// Issue #5583 — main-schema parity: `DROP TABLE` drops a trigger defined ON the
/// table, but NOT a trigger defined ON a *different* table even if it references
/// the dropped one in its body.
///
/// Verified against sqlite3 3.51.0:
///
/// ```text
/// CREATE TABLE t(a);  CREATE TABLE other(b);
/// CREATE TRIGGER tr  AFTER INSERT ON t     BEGIN SELECT 1; END;
/// CREATE TRIGGER tr2 AFTER INSERT ON other BEGIN INSERT INTO t VALUES(1); END;
/// DROP TABLE t;       -- tr gone, tr2 remains (it is ON other)
/// ```
#[test]
fn main_drop_table_drops_only_triggers_on_that_table() {
    let mut db = Database::new();
    exec_create_table(&mut db, "CREATE TABLE t (a)");
    exec_create_table(&mut db, "CREATE TABLE other (b)");
    exec_create_trigger(&mut db, "CREATE TRIGGER tr AFTER INSERT ON t BEGIN SELECT 1; END");
    exec_create_trigger(
        &mut db,
        "CREATE TRIGGER tr2 AFTER INSERT ON other BEGIN INSERT INTO t VALUES (1); END",
    );

    exec_drop_table(&mut db, "DROP TABLE t");

    let (_, after) = select(&db, "SELECT name FROM sqlite_master WHERE type='trigger'");
    let listed = names(&after);
    assert!(
        !listed.contains(&"tr".to_string()),
        "trigger ON the dropped table must be removed: {listed:?}"
    );
    assert!(
        listed.contains(&"tr2".to_string()),
        "trigger ON a different table (even if it references the dropped one) must survive: {listed:?}"
    );
}

/// Issue #5583 — connection-close / schema isolation: each connection owns its
/// own `Catalog` (per `Catalog::new()` session), so temp triggers and views are
/// connection-local by construction and a fresh connection sees none of the
/// previous connection's temp objects. (Connection close is structural: the
/// `Catalog` — including the flat view/trigger maps and the session temp schema —
/// is dropped wholesale.)
#[test]
fn temp_trigger_and_view_are_connection_local() {
    // Connection A creates temp objects.
    let mut db_a = Database::new();
    exec_create_table(&mut db_a, "CREATE TEMP TABLE t (a)");
    exec_create_trigger(&mut db_a, "CREATE TEMP TRIGGER tr AFTER INSERT ON t BEGIN SELECT 1; END");
    exec_create_view(&mut db_a, "CREATE TEMP VIEW v AS SELECT * FROM t");

    let (_, a_objs) = select(&db_a, "SELECT name FROM sqlite_temp_master");
    let a_listed = names(&a_objs);
    assert!(a_listed.contains(&"tr".to_string()));
    assert!(a_listed.contains(&"v".to_string()));

    // A separate connection B sees none of A's temp objects.
    let db_b = Database::new();
    let (_, b_objs) = select(&db_b, "SELECT name FROM sqlite_temp_master");
    let b_listed = names(&b_objs);
    assert!(
        !b_listed.contains(&"tr".to_string()),
        "connection B must not see connection A's temp trigger: {b_listed:?}"
    );
    assert!(
        !b_listed.contains(&"v".to_string()),
        "connection B must not see connection A's temp view: {b_listed:?}"
    );

    // Closing connection A (drop) releases its temp objects with the Catalog.
    drop(db_a);
}