• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

joaoh82 / rust_sqlite / 26737133294

01 Jun 2026 05:34AM UTC coverage: 68.985% (+0.2%) from 68.739%
26737133294

push

github

web-flow
SQLR-10 — honor CREATE TABLE IF NOT EXISTS + make sqlrite_master queryable (#151)

Two engine ergonomics gaps that every embedding SDK consumer hits when
writing idempotent "run my schema on startup" migrations:

1. `CREATE TABLE IF NOT EXISTS` was parsed but ignored — a second create
   of an existing table errored "table already exists", unlike
   `CREATE INDEX IF NOT EXISTS`. Now `CreateQuery` carries the
   `if_not_exists` flag and the executor treats a re-create as a no-op
   (by name only; no schema diff, matching SQLite).

2. The schema catalog wasn't reachable from SQL. Added:
   - `SELECT … FROM sqlrite_master` — synthesizes a read-only in-memory
     catalog table on demand (type/name/sql/rootpage/last_rowid), reusing
     the same SQL synthesis the persistence path uses. Works through the
     normal single-table SELECT path (WHERE/projection/ORDER BY/LIMIT);
     reflects live in-memory state. Writes remain rejected; joins against
     it are out of scope.
   - `PRAGMA table_list` — SQLite-canonical lightweight introspection
     (schema/name/type/ncol/wr/strict) listing tables + sqlrite_master.

Covers the REPL and every SDK (all route through execute_select_rows /
process_command). Docs (supported-sql.md, README) updated; the
go-collector example's now-stale workaround comments refreshed.

Tests: parser flag capture; IF NOT EXISTS idempotency + still-errors
without it + fresh-create; sqlrite_master listing/filtering/SELECT */
write-rejection/save-reopen; PRAGMA table_list listing + value-rejection.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

170 of 189 new or added lines in 5 files covered. (89.95%)

11357 of 16463 relevant lines covered (68.98%)

1.24 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.79
/src/sql/mod.rs
1
pub mod agg;
2
pub mod db;
3
pub mod dialect;
4
pub mod executor;
5
pub mod fts;
6
pub mod hnsw;
7
pub mod pager;
8
pub mod params;
9
pub mod parser;
10
pub mod pragma;
11
// pub mod tokenizer;
12

13
use parser::create::CreateQuery;
14
use parser::insert::InsertQuery;
15
use parser::select::SelectQuery;
16

17
use sqlparser::ast::{AlterTableOperation, ObjectType, Statement};
18
use sqlparser::parser::{Parser, ParserError};
19

20
use crate::sql::dialect::SqlriteDialect;
21

22
use crate::error::{Result, SQLRiteError};
23
use crate::sql::db::database::Database;
24
use crate::sql::db::table::Table;
25

26
#[derive(Debug, PartialEq)]
27
pub enum SQLCommand {
28
    Insert(String),
29
    Delete(String),
30
    Update(String),
31
    CreateTable(String),
32
    Select(String),
33
    Unknown(String),
34
}
35

36
impl SQLCommand {
37
    pub fn new(command: String) -> SQLCommand {
1✔
38
        let v = command.split(" ").collect::<Vec<&str>>();
2✔
39
        match v[0] {
1✔
40
            "insert" => SQLCommand::Insert(command),
1✔
41
            "update" => SQLCommand::Update(command),
2✔
42
            "delete" => SQLCommand::Delete(command),
2✔
43
            "create" => SQLCommand::CreateTable(command),
2✔
44
            "select" => SQLCommand::Select(command),
2✔
45
            _ => SQLCommand::Unknown(command),
1✔
46
        }
47
    }
48
}
49

50
/// Output of running one SQL statement through the engine.
51
///
52
/// Two fields:
53
///
54
/// - `status` is the short human-readable confirmation line every caller
55
///   wants ("INSERT Statement executed.", "3 rows updated.", "BEGIN", etc.).
56
/// - `rendered` is the pre-formatted prettytable rendering of a SELECT's
57
///   result rows. Populated only for `SELECT` statements; `None` for every
58
///   other statement type. The REPL prints this above the status line so
59
///   users see both the rows and the confirmation; SDK / FFI / MCP callers
60
///   ignore it and reach for the typed-row APIs (`Connection::prepare` →
61
///   `Statement::query` → `Rows`) when they want row data instead.
62
///
63
/// Splitting the two means [`process_command_with_render`] can return
64
/// everything the REPL needs without writing to stdout itself —
65
/// historically `process_command` would `print!()` the rendered table
66
/// directly, which corrupted any non-REPL stdout channel (the MCP server's
67
/// JSON-RPC wire, structured loggers piping engine output, …).
68
#[derive(Debug, Clone)]
69
pub struct CommandOutput {
70
    pub status: String,
71
    pub rendered: Option<String>,
72
}
73

74
/// Backwards-compatible wrapper around [`process_command_with_render`] that
75
/// returns just the status string. Every existing call site (the public
76
/// `Connection::execute`, the SDK FFI shims, the .ask meta-command's
77
/// inline runner, the engine's own tests) keeps working unchanged.
78
///
79
/// Callers that want the rendered SELECT table (the REPL, future
80
/// terminal-style consumers) should call [`process_command_with_render`]
81
/// directly and inspect [`CommandOutput::rendered`].
82
pub fn process_command(query: &str, db: &mut Database) -> Result<String> {
2✔
83
    process_command_with_render(query, db).map(|o| o.status)
6✔
84
}
85

86
/// Performs initial parsing of SQL Statement using sqlparser-rs.
87
///
88
/// Returns a [`CommandOutput`] carrying both the status string and (for
89
/// SELECT statements) the pre-rendered prettytable output. **Never writes
90
/// to stdout.** The REPL is responsible for printing whatever it wants
91
/// from the returned struct.
92
pub fn process_command_with_render(query: &str, db: &mut Database) -> Result<CommandOutput> {
2✔
93
    // SQLR-13 — intercept `PRAGMA` before sqlparser sees it. sqlparser's
94
    // pragma-value parser rejects bare `OFF` / `NONE` (and other classic
95
    // SQLite pragma idioms), so we tokenize and dispatch ourselves. Non-
96
    // PRAGMA input falls through to the regular dispatcher unchanged.
97
    if let Some(stmt) = pragma::try_parse_pragma(query)? {
2✔
98
        return pragma::execute_pragma(stmt, db);
1✔
99
    }
100

101
    let dialect = SqlriteDialect::new();
2✔
102
    let mut ast = Parser::parse_sql(&dialect, query).map_err(SQLRiteError::from)?;
2✔
103

104
    if ast.len() > 1 {
4✔
105
        return Err(SQLRiteError::SqlError(ParserError::ParserError(format!(
×
106
            "Expected a single query statement, but there are {}",
107
            ast.len()
×
108
        ))));
109
    }
110

111
    // Comment-only or whitespace-only input parses to an empty Vec<Statement>.
112
    // Return a benign status rather than panicking on `pop().unwrap()`. Callers
113
    // (REPL, Tauri app) treat this as a no-op with no disk write triggered.
114
    let Some(query) = ast.pop() else {
4✔
115
        return Ok(CommandOutput {
1✔
116
            status: "No statement to execute.".to_string(),
1✔
117
            rendered: None,
1✔
118
        });
119
    };
120
    process_ast_with_render(query, db)
2✔
121
}
122

123
/// Same as [`process_command_with_render`] but takes a pre-parsed
124
/// [`Statement`]. SQLR-23 — `Statement` / `Connection::prepare_cached`
125
/// dispatch through this entry point so they pay the sqlparser cost
126
/// once at prepare time, not per execute.
127
pub fn process_ast_with_render(query: Statement, db: &mut Database) -> Result<CommandOutput> {
2✔
128
    let message: String;
2✔
129
    let mut rendered: Option<String> = None;
2✔
130

131
    // Transaction boundary statements are routed to Database-level
132
    // handlers before we even inspect the rest of the AST. They don't
133
    // mutate table data directly, so they short-circuit the
134
    // is_write_statement / auto-save path.
135
    match &query {
2✔
136
        Statement::StartTransaction { .. } => {
137
            db.begin_transaction()?;
3✔
138
            return Ok(CommandOutput {
1✔
139
                status: String::from("BEGIN"),
1✔
140
                rendered: None,
1✔
141
            });
142
        }
143
        Statement::Commit { .. } => {
144
            if !db.in_transaction() {
2✔
145
                return Err(SQLRiteError::General(
1✔
146
                    "cannot COMMIT: no transaction is open".to_string(),
1✔
147
                ));
148
            }
149
            // Flush accumulated in-memory changes to disk. If the save
150
            // fails we auto-rollback the in-memory state to the
151
            // pre-BEGIN snapshot and surface a combined error. Leaving
152
            // the transaction open after a failed COMMIT would be
153
            // unsafe: auto-save on any subsequent non-transactional
154
            // statement would silently publish partial mid-transaction
155
            // work. Auto-rollback keeps the disk-plus-memory pair
156
            // coherent — the user loses their in-flight work on a disk
157
            // error, but that's the only safe outcome.
158
            if let Some(path) = db.source_path.clone() {
2✔
159
                if let Err(save_err) = pager::save_database(db, &path) {
2✔
160
                    let _ = db.rollback_transaction();
2✔
161
                    return Err(SQLRiteError::General(format!(
1✔
162
                        "COMMIT failed — transaction rolled back: {save_err}"
163
                    )));
164
                }
165
            }
166
            db.commit_transaction()?;
2✔
167
            return Ok(CommandOutput {
1✔
168
                status: String::from("COMMIT"),
1✔
169
                rendered: None,
1✔
170
            });
171
        }
172
        Statement::Rollback { .. } => {
173
            db.rollback_transaction()?;
3✔
174
            return Ok(CommandOutput {
1✔
175
                status: String::from("ROLLBACK"),
1✔
176
                rendered: None,
1✔
177
            });
178
        }
179
        _ => {}
180
    }
181

182
    // Statements that mutate state — trigger auto-save on success. Read-only
183
    // SELECTs skip the save entirely to avoid pointless file writes.
184
    // VACUUM is a write statement (rewrites the entire file) but it does
185
    // its own save internally, so it's also explicitly excluded from the
186
    // post-dispatch auto-save block at the bottom.
187
    let is_write_statement = matches!(
3✔
188
        &query,
2✔
189
        Statement::CreateTable(_)
190
            | Statement::CreateIndex(_)
191
            | Statement::Insert(_)
192
            | Statement::Update(_)
193
            | Statement::Delete(_)
194
            | Statement::Drop { .. }
195
            | Statement::AlterTable(_)
196
            | Statement::Vacuum(_)
197
    );
198
    let is_vacuum = matches!(&query, Statement::Vacuum(_));
2✔
199

200
    // SQLR-10: statements that release pages onto the freelist.
201
    // After the auto-save flushes them, we'll consult
202
    // `db.auto_vacuum_threshold` and possibly compact in place.
203
    // ALTER TABLE here matches only DROP COLUMN — RENAME / ADD COLUMN
204
    // don't grow the freelist, so they shouldn't pay the trigger cost.
205
    let releases_pages = match &query {
2✔
206
        Statement::Drop { object_type, .. } => {
1✔
207
            matches!(object_type, ObjectType::Table | ObjectType::Index)
1✔
208
        }
209
        Statement::AlterTable(alter) => alter
3✔
210
            .operations
211
            .iter()
212
            .any(|op| matches!(op, AlterTableOperation::DropColumn { .. })),
3✔
213
        _ => false,
2✔
214
    };
215

216
    // Early-reject mutations on a read-only database before they touch
217
    // in-memory state. Phase 4e: without this, a user running INSERT
218
    // on a `--readonly` REPL would see the row appear in the printed
219
    // table, and then the auto-save would fail — leaving the in-memory
220
    // Database visibly diverged from disk.
221
    if is_write_statement && db.is_read_only() {
4✔
222
        return Err(SQLRiteError::General(
1✔
223
            "cannot execute: database is opened read-only".to_string(),
1✔
224
        ));
225
    }
226

227
    // Initialy only implementing some basic SQL Statements
228
    match query {
2✔
229
        Statement::CreateTable(_) => {
230
            let create_query = CreateQuery::new(&query);
2✔
231
            match create_query {
2✔
232
                Ok(payload) => {
2✔
233
                    let table_name = payload.table_name.clone();
2✔
234
                    if table_name == pager::MASTER_TABLE_NAME {
4✔
235
                        return Err(SQLRiteError::General(format!(
×
236
                            "'{}' is a reserved name used by the internal schema catalog",
237
                            pager::MASTER_TABLE_NAME
238
                        )));
239
                    }
240
                    // Checking if table already exists, after parsing CREATE TABLE query
241
                    match db.contains_table(table_name.to_string()) {
4✔
242
                        true => {
243
                            // SQLR-10: `CREATE TABLE IF NOT EXISTS` is a no-op
244
                            // when the table already exists, so idempotent
245
                            // "run my schema on every startup" migrations work
246
                            // against a populated DB. Matches the existing
247
                            // `CREATE INDEX IF NOT EXISTS` behaviour and SQLite.
248
                            // The clause is honoured by name only — we do NOT
249
                            // diff the existing schema against the new column
250
                            // list (SQLite doesn't either).
251
                            if payload.if_not_exists {
1✔
252
                                message = format!(
2✔
253
                                    "CREATE TABLE Statement executed. (table '{table_name}' already exists, no-op)"
254
                                );
255
                            } else {
256
                                return Err(SQLRiteError::Internal(
1✔
257
                                    "Cannot create, table already exists.".to_string(),
1✔
258
                                ));
259
                            }
260
                        }
261
                        false => {
262
                            let table = Table::new(payload);
4✔
263
                            // Note: we used to call `table.print_table_schema()` here
264
                            // for REPL convenience. Removed because it wrote
265
                            // directly to stdout, which corrupted any non-REPL
266
                            // protocol channel (most painfully the MCP server's
267
                            // JSON-RPC wire). The status line below is enough for
268
                            // the REPL; users who want to inspect the schema can
269
                            // run a follow-up describe / `.tables`-style command.
270
                            db.tables.insert(table_name.to_string(), table);
4✔
271
                            message = String::from("CREATE TABLE Statement executed.");
2✔
272
                        }
273
                    }
274
                }
275
                Err(err) => return Err(err),
1✔
276
            }
277
        }
278
        Statement::Insert(_) => {
279
            let insert_query = InsertQuery::new(&query);
2✔
280
            match insert_query {
2✔
281
                Ok(payload) => {
2✔
282
                    let table_name = payload.table_name;
2✔
283
                    let columns = payload.columns;
2✔
284
                    let values = payload.rows;
2✔
285

286
                    // println!("table_name = {:?}\n cols = {:?}\n vals = {:?}", table_name, columns, values);
287
                    // Checking if Table exists in Database
288
                    match db.contains_table(table_name.to_string()) {
4✔
289
                        true => {
290
                            let db_table = db.get_table_mut(table_name.to_string()).unwrap();
4✔
291
                            // Checking if columns on INSERT query exist on Table
292
                            match columns
6✔
293
                                .iter()
2✔
294
                                .all(|column| db_table.contains_column(column.to_string()))
6✔
295
                            {
296
                                true => {
297
                                    for value in &values {
4✔
298
                                        // Checking if number of columns in query are the same as number of values
299
                                        if columns.len() != value.len() {
4✔
300
                                            return Err(SQLRiteError::Internal(format!(
×
301
                                                "{} values for {} columns",
302
                                                value.len(),
×
303
                                                columns.len()
×
304
                                            )));
305
                                        }
306
                                        db_table
2✔
307
                                            .validate_unique_constraint(&columns, value)
2✔
308
                                            .map_err(|err| {
2✔
309
                                                SQLRiteError::Internal(format!(
×
310
                                                    "Unique key constraint violation: {err}"
311
                                                ))
312
                                            })?;
313
                                        db_table.insert_row(&columns, value)?;
2✔
314
                                    }
315
                                }
316
                                false => {
317
                                    return Err(SQLRiteError::Internal(
×
318
                                        "Cannot insert, some of the columns do not exist"
319
                                            .to_string(),
×
320
                                    ));
321
                                }
322
                            }
323
                            // Note: we used to call `db_table.print_table_data()`
324
                            // here, which dumped the *entire* table to stdout
325
                            // after every INSERT. Beyond corrupting non-REPL
326
                            // stdout channels, that's actively bad UX on any
327
                            // table with more than a few rows. Removed in the
328
                            // engine-stdout-pollution cleanup.
329
                        }
330
                        false => {
331
                            return Err(SQLRiteError::Internal("Table doesn't exist".to_string()));
2✔
332
                        }
333
                    }
334
                }
335
                Err(err) => return Err(err),
×
336
            }
337

338
            message = String::from("INSERT Statement executed.")
2✔
339
        }
340
        Statement::Query(_) => {
341
            let select_query = SelectQuery::new(&query)?;
3✔
342
            let (rendered_table, rows) = executor::execute_select(select_query, db)?;
3✔
343
            // Stash the rendered prettytable in the output so the REPL
344
            // (or any terminal-style consumer) can print it above the
345
            // status line. SDK / FFI / MCP callers ignore this field.
346
            // The previous implementation `print!("{rendered}")`-ed
347
            // directly to stdout, which broke every non-REPL embedder.
348
            rendered = Some(rendered_table);
1✔
349
            message = format!(
2✔
350
                "SELECT Statement executed. {rows} row{s} returned.",
351
                s = if rows == 1 { "" } else { "s" }
1✔
352
            );
353
        }
354
        Statement::Delete(_) => {
355
            let rows = executor::execute_delete(&query, db)?;
2✔
356
            message = format!(
1✔
357
                "DELETE Statement executed. {rows} row{s} deleted.",
358
                s = if rows == 1 { "" } else { "s" }
1✔
359
            );
360
        }
361
        Statement::Update(_) => {
362
            let rows = executor::execute_update(&query, db)?;
3✔
363
            message = format!(
1✔
364
                "UPDATE Statement executed. {rows} row{s} updated.",
365
                s = if rows == 1 { "" } else { "s" }
1✔
366
            );
367
        }
368
        Statement::CreateIndex(_) => {
369
            let name = executor::execute_create_index(&query, db)?;
3✔
370
            message = format!("CREATE INDEX '{name}' executed.");
2✔
371
        }
372
        Statement::Drop {
373
            object_type,
1✔
374
            if_exists,
1✔
375
            names,
1✔
376
            ..
377
        } => match object_type {
1✔
378
            ObjectType::Table => {
379
                let count = executor::execute_drop_table(&names, if_exists, db)?;
3✔
380
                let plural = if count == 1 { "table" } else { "tables" };
1✔
381
                message = format!("DROP TABLE Statement executed. {count} {plural} dropped.");
1✔
382
            }
383
            ObjectType::Index => {
384
                let count = executor::execute_drop_index(&names, if_exists, db)?;
3✔
385
                let plural = if count == 1 { "index" } else { "indexes" };
1✔
386
                message = format!("DROP INDEX Statement executed. {count} {plural} dropped.");
1✔
387
            }
388
            other => {
×
389
                return Err(SQLRiteError::NotImplemented(format!(
×
390
                    "DROP {other:?} is not supported (only TABLE and INDEX)"
391
                )));
392
            }
393
        },
394
        Statement::AlterTable(alter) => {
1✔
395
            message = executor::execute_alter_table(alter, db)?;
3✔
396
        }
397
        Statement::Vacuum(vac) => {
1✔
398
            // SQLR-6 — only bare `VACUUM;` is supported. The crate-level
399
            // `VacuumStatement` carries Redshift-style modifiers we don't
400
            // implement; reject any non-default flag rather than silently
401
            // ignoring it.
402
            if vac.full
1✔
403
                || vac.sort_only
1✔
404
                || vac.delete_only
1✔
405
                || vac.reindex
1✔
406
                || vac.recluster
1✔
407
                || vac.boost
1✔
408
                || vac.table_name.is_some()
2✔
409
                || vac.threshold.is_some()
1✔
410
            {
411
                return Err(SQLRiteError::NotImplemented(
1✔
412
                    "VACUUM modifiers (FULL, REINDEX, table targets, etc.) are not supported; use bare VACUUM;"
413
                        .to_string(),
1✔
414
                ));
415
            }
416
            message = executor::execute_vacuum(db)?;
2✔
417
        }
418
        _ => {
419
            return Err(SQLRiteError::NotImplemented(
1✔
420
                "SQL Statement not supported yet.".to_string(),
1✔
421
            ));
422
        }
423
    };
424

425
    // Auto-save: if the database is backed by a file AND no explicit
426
    // transaction is open AND the statement changed state, flush to
427
    // disk before returning. Inside a `BEGIN … COMMIT` block the
428
    // mutations accumulate in memory (protected by the ROLLBACK
429
    // snapshot) and land on disk in one shot when COMMIT runs.
430
    //
431
    // A failed save surfaces as an error — the in-memory state already
432
    // mutated, so the caller should know disk is out of sync. The
433
    // Pager held on `db` diffs against its last-committed snapshot,
434
    // so only pages whose bytes actually changed are written.
435
    //
436
    // VACUUM is a write-shaped statement but already wrote the file
437
    // internally — skip the second save to avoid undoing the compact.
438
    if is_write_statement && !is_vacuum && db.source_path.is_some() && !db.in_transaction() {
4✔
439
        let path = db.source_path.clone().unwrap();
2✔
440
        pager::save_database(db, &path)?;
4✔
441
    }
442

443
    // SQLR-10 auto-VACUUM trigger. Runs *after* the auto-save above so
444
    // the orphaned pages from the just-executed DROP/ALTER have actually
445
    // landed on the freelist (the bottom-up rebuild populates it during
446
    // save). Skipped mid-transaction (no commit yet → no save → freelist
447
    // is stale), on in-memory DBs (nothing to compact), and when the
448
    // user has explicitly disabled the trigger via
449
    // `set_auto_vacuum_threshold(None)`. We deliberately bypass
450
    // `executor::execute_vacuum` and call `pager::vacuum_database`
451
    // directly: the executor wrapper builds a user-facing status string
452
    // and rejects in-transaction calls — both wrong for this silent
453
    // maintenance path.
454
    if releases_pages && !db.in_transaction() {
3✔
455
        if let (Some(threshold), Some(path)) = (db.auto_vacuum_threshold(), db.source_path.clone())
4✔
456
        {
457
            let should = match db.pager.as_ref() {
2✔
458
                Some(p) => pager::freelist::should_auto_vacuum(p, threshold)?,
2✔
459
                None => false,
×
460
            };
461
            if should {
1✔
462
                pager::vacuum_database(db, &path)?;
1✔
463
            }
464
        }
465
    }
466

467
    Ok(CommandOutput {
2✔
468
        status: message,
2✔
469
        rendered,
2✔
470
    })
471
}
472

473
#[cfg(test)]
474
mod tests {
475
    use super::*;
476
    use crate::sql::db::table::Value;
477

478
    /// Builds a `users(id INTEGER PK, name TEXT, age INTEGER)` table populated
479
    /// with three rows, for use in executor-level tests.
480
    fn seed_users_table() -> Database {
1✔
481
        let mut db = Database::new("tempdb".to_string());
1✔
482
        process_command(
483
            "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, age INTEGER);",
484
            &mut db,
485
        )
486
        .expect("create table");
487
        process_command(
488
            "INSERT INTO users (name, age) VALUES ('alice', 30);",
489
            &mut db,
490
        )
491
        .expect("insert alice");
492
        process_command("INSERT INTO users (name, age) VALUES ('bob', 25);", &mut db)
1✔
493
            .expect("insert bob");
494
        process_command(
495
            "INSERT INTO users (name, age) VALUES ('carol', 40);",
496
            &mut db,
497
        )
498
        .expect("insert carol");
499
        db
1✔
500
    }
501

502
    #[test]
503
    fn process_command_select_all_test() {
3✔
504
        let mut db = seed_users_table();
1✔
505
        let response = process_command("SELECT * FROM users;", &mut db).expect("select");
2✔
506
        assert!(response.contains("3 rows returned"));
2✔
507
    }
508

509
    #[test]
510
    fn process_command_select_where_test() {
3✔
511
        let mut db = seed_users_table();
1✔
512
        let response =
2✔
513
            process_command("SELECT name FROM users WHERE age > 25;", &mut db).expect("select");
514
        assert!(response.contains("2 rows returned"));
2✔
515
    }
516

517
    #[test]
518
    fn process_command_select_eq_string_test() {
3✔
519
        let mut db = seed_users_table();
1✔
520
        let response =
2✔
521
            process_command("SELECT name FROM users WHERE name = 'bob';", &mut db).expect("select");
522
        assert!(response.contains("1 row returned"));
2✔
523
    }
524

525
    #[test]
526
    fn process_command_select_limit_test() {
3✔
527
        let mut db = seed_users_table();
1✔
528
        let response = process_command("SELECT * FROM users ORDER BY age ASC LIMIT 2;", &mut db)
1✔
529
            .expect("select");
530
        assert!(response.contains("2 rows returned"));
2✔
531
    }
532

533
    #[test]
534
    fn process_command_select_unknown_table_test() {
3✔
535
        let mut db = Database::new("tempdb".to_string());
1✔
536
        let result = process_command("SELECT * FROM nope;", &mut db);
1✔
537
        assert!(result.is_err());
2✔
538
    }
539

540
    #[test]
541
    fn process_command_select_unknown_column_test() {
3✔
542
        let mut db = seed_users_table();
1✔
543
        let result = process_command("SELECT height FROM users;", &mut db);
1✔
544
        assert!(result.is_err());
2✔
545
    }
546

547
    #[test]
548
    fn process_command_insert_test() {
3✔
549
        // Creating temporary database
550
        let mut db = Database::new("tempdb".to_string());
1✔
551

552
        // Creating temporary table for testing purposes
553
        let query_statement = "CREATE TABLE users (
1✔
554
            id INTEGER PRIMARY KEY,
555
            name TEXT
556
        );";
557
        let dialect = SqlriteDialect::new();
1✔
558
        let mut ast = Parser::parse_sql(&dialect, query_statement).unwrap();
1✔
559
        if ast.len() > 1 {
2✔
560
            panic!("Expected a single query statement, but there are more then 1.")
×
561
        }
562
        let query = ast.pop().unwrap();
2✔
563
        let create_query = CreateQuery::new(&query).unwrap();
2✔
564

565
        // Inserting table into database
566
        db.tables.insert(
2✔
567
            create_query.table_name.to_string(),
2✔
568
            Table::new(create_query),
1✔
569
        );
570

571
        // Inserting data into table
572
        let insert_query = String::from("INSERT INTO users (name) Values ('josh');");
1✔
573
        match process_command(&insert_query, &mut db) {
2✔
574
            Ok(response) => assert_eq!(response, "INSERT Statement executed."),
1✔
575
            Err(err) => {
×
576
                eprintln!("Error: {}", err);
×
577
                assert!(false)
×
578
            }
579
        };
580
    }
581

582
    #[test]
583
    fn process_command_insert_no_pk_test() {
3✔
584
        // Creating temporary database
585
        let mut db = Database::new("tempdb".to_string());
1✔
586

587
        // Creating temporary table for testing purposes
588
        let query_statement = "CREATE TABLE users (
1✔
589
            name TEXT
590
        );";
591
        let dialect = SqlriteDialect::new();
1✔
592
        let mut ast = Parser::parse_sql(&dialect, query_statement).unwrap();
1✔
593
        if ast.len() > 1 {
2✔
594
            panic!("Expected a single query statement, but there are more then 1.")
×
595
        }
596
        let query = ast.pop().unwrap();
2✔
597
        let create_query = CreateQuery::new(&query).unwrap();
2✔
598

599
        // Inserting table into database
600
        db.tables.insert(
2✔
601
            create_query.table_name.to_string(),
2✔
602
            Table::new(create_query),
1✔
603
        );
604

605
        // Inserting data into table
606
        let insert_query = String::from("INSERT INTO users (name) Values ('josh');");
1✔
607
        match process_command(&insert_query, &mut db) {
2✔
608
            Ok(response) => assert_eq!(response, "INSERT Statement executed."),
1✔
609
            Err(err) => {
×
610
                eprintln!("Error: {}", err);
×
611
                assert!(false)
×
612
            }
613
        };
614
    }
615

616
    #[test]
617
    fn process_command_delete_where_test() {
4✔
618
        let mut db = seed_users_table();
1✔
619
        let response =
2✔
620
            process_command("DELETE FROM users WHERE name = 'bob';", &mut db).expect("delete");
621
        assert!(response.contains("1 row deleted"));
2✔
622

623
        let remaining = process_command("SELECT * FROM users;", &mut db).expect("select");
1✔
624
        assert!(remaining.contains("2 rows returned"));
2✔
625
    }
626

627
    #[test]
628
    fn process_command_delete_all_test() {
3✔
629
        let mut db = seed_users_table();
1✔
630
        let response = process_command("DELETE FROM users;", &mut db).expect("delete");
2✔
631
        assert!(response.contains("3 rows deleted"));
2✔
632
    }
633

634
    #[test]
635
    fn process_command_update_where_test() {
3✔
636
        use crate::sql::db::table::Value;
637

638
        let mut db = seed_users_table();
1✔
639
        let response = process_command("UPDATE users SET age = 99 WHERE name = 'bob';", &mut db)
1✔
640
            .expect("update");
641
        assert!(response.contains("1 row updated"));
2✔
642

643
        // Confirm the cell was actually rewritten.
644
        let users = db.get_table("users".to_string()).unwrap();
1✔
645
        let bob_rowid = users
1✔
646
            .rowids()
647
            .into_iter()
648
            .find(|r| users.get_value("name", *r) == Some(Value::Text("bob".to_string())))
3✔
649
            .expect("bob row must exist");
650
        assert_eq!(users.get_value("age", bob_rowid), Some(Value::Integer(99)));
1✔
651
    }
652

653
    #[test]
654
    fn process_command_update_unique_violation_test() {
3✔
655
        let mut db = seed_users_table();
1✔
656
        // `name` is not UNIQUE in the seed — reinforce with an explicit unique column.
657
        process_command(
658
            "CREATE TABLE tags (id INTEGER PRIMARY KEY, label TEXT UNIQUE);",
659
            &mut db,
660
        )
661
        .unwrap();
662
        process_command("INSERT INTO tags (label) VALUES ('a');", &mut db).unwrap();
1✔
663
        process_command("INSERT INTO tags (label) VALUES ('b');", &mut db).unwrap();
1✔
664

665
        let result = process_command("UPDATE tags SET label = 'a' WHERE label = 'b';", &mut db);
1✔
666
        assert!(result.is_err(), "expected UNIQUE violation, got {result:?}");
2✔
667
    }
668

669
    #[test]
670
    fn process_command_insert_type_mismatch_returns_error_test() {
3✔
671
        // Previously this panicked in parse::<i32>().unwrap(); now it should return an error cleanly.
672
        let mut db = Database::new("tempdb".to_string());
1✔
673
        process_command(
674
            "CREATE TABLE items (id INTEGER PRIMARY KEY, qty INTEGER);",
675
            &mut db,
676
        )
677
        .unwrap();
678
        let result = process_command("INSERT INTO items (qty) VALUES ('not a number');", &mut db);
1✔
679
        assert!(result.is_err(), "expected error, got {result:?}");
2✔
680
    }
681

682
    #[test]
683
    fn insert_omitted_integer_column_is_stored_as_null() {
3✔
684
        // SQLR-7 — pre-fix this errored because the omitted column was
685
        // padded with the literal `"Null"` and then re-parsed as i32. The
686
        // new INSERT pipeline carries `Option<Value>` from the parser
687
        // through to `insert_row`, so a missing non-PK column is just
688
        // SQL NULL (matches SQLite).
689
        use crate::sql::db::table::Value;
690

691
        let mut db = Database::new("tempdb".to_string());
1✔
692
        process_command(
693
            "CREATE TABLE items (id INTEGER PRIMARY KEY, qty INTEGER);",
694
            &mut db,
695
        )
696
        .unwrap();
697
        process_command("INSERT INTO items (id) VALUES (1);", &mut db)
1✔
698
            .expect("INSERT with omitted INTEGER column should succeed and store NULL");
699

700
        let table = db.get_table("items".to_string()).unwrap();
1✔
701
        let rowid = table.rowids().pop().expect("one row");
1✔
702
        // BTreeMap entry was never written → get_value returns None,
703
        // which the executor renders as Value::Null.
704
        assert_eq!(table.get_value("qty", rowid), None);
1✔
705
        // IS NULL via the executor sees the same NULL.
706
        let response = process_command("SELECT id FROM items WHERE qty IS NULL;", &mut db)
1✔
707
            .expect("select IS NULL");
708
        assert!(
×
709
            response.contains("1 row returned"),
2✔
710
            "qty IS NULL should match the omitted-column row, got: {response}"
711
        );
712
        // Sanity: explicit literal stays Integer.
713
        process_command("INSERT INTO items (id, qty) VALUES (2, 7);", &mut db).unwrap();
2✔
714
        let table = db.get_table("items".to_string()).unwrap();
1✔
715
        let row_two = table
1✔
716
            .rowids()
717
            .into_iter()
718
            .find(|r| table.get_value("id", *r) == Some(Value::Integer(2)))
3✔
719
            .unwrap();
720
        assert_eq!(table.get_value("qty", row_two), Some(Value::Integer(7)));
1✔
721
    }
722

723
    #[test]
724
    fn insert_explicit_null_into_integer_column() {
3✔
725
        let mut db = Database::new("tempdb".to_string());
1✔
726
        process_command(
727
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER);",
728
            &mut db,
729
        )
730
        .unwrap();
731
        process_command("INSERT INTO t (id, n) VALUES (1, NULL);", &mut db)
1✔
732
            .expect("INSERT explicit NULL into INTEGER must not panic on parse::<i32>()");
733
        let table = db.get_table("t".to_string()).unwrap();
1✔
734
        let rowid = table.rowids().pop().unwrap();
1✔
735
        assert_eq!(table.get_value("n", rowid), None);
1✔
736
    }
737

738
    #[test]
739
    fn insert_explicit_null_into_text_column() {
3✔
740
        // Pre-fix: the literal string "Null" was stored in the BTreeMap and
741
        // a read-side workaround (`if v == "Null"`) re-mapped it back to
742
        // Value::Null. Post-fix: nothing is stored at all, so a user-typed
743
        // string `'Null'` no longer collides with SQL NULL.
744
        use crate::sql::db::table::Value;
745

746
        let mut db = Database::new("tempdb".to_string());
1✔
747
        process_command("CREATE TABLE t (id INTEGER PRIMARY KEY, s TEXT);", &mut db).unwrap();
2✔
748
        process_command("INSERT INTO t (id, s) VALUES (1, NULL);", &mut db).unwrap();
1✔
749
        process_command("INSERT INTO t (id, s) VALUES (2, 'hi');", &mut db).unwrap();
1✔
750

751
        let table = db.get_table("t".to_string()).unwrap();
1✔
752
        let row_one = table
1✔
753
            .rowids()
754
            .into_iter()
755
            .find(|r| table.get_value("id", *r) == Some(Value::Integer(1)))
3✔
756
            .unwrap();
757
        let row_two = table
1✔
758
            .rowids()
759
            .into_iter()
760
            .find(|r| table.get_value("id", *r) == Some(Value::Integer(2)))
3✔
761
            .unwrap();
762
        assert_eq!(table.get_value("s", row_one), None);
1✔
763
        assert_eq!(
1✔
764
            table.get_value("s", row_two),
1✔
765
            Some(Value::Text("hi".to_string()))
2✔
766
        );
767
    }
768

769
    #[test]
770
    fn insert_explicit_null_into_real_column() {
3✔
771
        let mut db = Database::new("tempdb".to_string());
1✔
772
        process_command(
773
            "CREATE TABLE t (id INTEGER PRIMARY KEY, score REAL);",
774
            &mut db,
775
        )
776
        .unwrap();
777
        process_command("INSERT INTO t (id, score) VALUES (1, NULL);", &mut db)
1✔
778
            .expect("INSERT explicit NULL into REAL must not panic on parse::<f32>()");
779
        let table = db.get_table("t".to_string()).unwrap();
1✔
780
        let rowid = table.rowids().pop().unwrap();
1✔
781
        assert_eq!(table.get_value("score", rowid), None);
1✔
782
    }
783

784
    #[test]
785
    fn insert_explicit_null_into_bool_column() {
3✔
786
        let mut db = Database::new("tempdb".to_string());
1✔
787
        process_command(
788
            "CREATE TABLE t (id INTEGER PRIMARY KEY, flag BOOLEAN);",
789
            &mut db,
790
        )
791
        .unwrap();
792
        process_command("INSERT INTO t (id, flag) VALUES (1, NULL);", &mut db)
1✔
793
            .expect("INSERT explicit NULL into BOOL must not panic on parse::<bool>()");
794
        let table = db.get_table("t".to_string()).unwrap();
1✔
795
        let rowid = table.rowids().pop().unwrap();
1✔
796
        assert_eq!(table.get_value("flag", rowid), None);
1✔
797
    }
798

799
    #[test]
800
    fn insert_explicit_null_into_vector_column() {
3✔
801
        let mut db = Database::new("tempdb".to_string());
1✔
802
        process_command(
803
            "CREATE TABLE t (id INTEGER PRIMARY KEY, v VECTOR(3));",
804
            &mut db,
805
        )
806
        .unwrap();
807
        process_command("INSERT INTO t (id, v) VALUES (1, NULL);", &mut db)
1✔
808
            .expect("INSERT explicit NULL into VECTOR must not panic on parse_vector_literal");
809
        let table = db.get_table("t".to_string()).unwrap();
1✔
810
        let rowid = table.rowids().pop().unwrap();
1✔
811
        assert_eq!(table.get_value("v", rowid), None);
1✔
812
    }
813

814
    #[test]
815
    fn insert_explicit_null_into_json_column() {
3✔
816
        let mut db = Database::new("tempdb".to_string());
1✔
817
        process_command(
818
            "CREATE TABLE t (id INTEGER PRIMARY KEY, doc JSON);",
819
            &mut db,
820
        )
821
        .unwrap();
822
        process_command("INSERT INTO t (id, doc) VALUES (1, NULL);", &mut db)
1✔
823
            .expect("INSERT explicit NULL into JSON must skip serde_json validation");
824
        let table = db.get_table("t".to_string()).unwrap();
1✔
825
        let rowid = table.rowids().pop().unwrap();
1✔
826
        assert_eq!(table.get_value("doc", rowid), None);
1✔
827
    }
828

829
    #[test]
830
    fn default_does_not_override_explicit_null() {
3✔
831
        // Restored from SQLR-2 (was dropped because it collided with the
832
        // stringly-typed NULL handling SQLR-7 fixes). Column has DEFAULT 0;
833
        // an explicit NULL in the INSERT must override the default and
834
        // store NULL — the default only fires when the column is omitted.
835
        use crate::sql::db::table::Value;
836

837
        let mut db = Database::new("tempdb".to_string());
1✔
838
        process_command(
839
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER DEFAULT 0);",
840
            &mut db,
841
        )
842
        .unwrap();
843
        process_command("INSERT INTO t (id, n) VALUES (1, NULL);", &mut db).unwrap();
1✔
844
        process_command("INSERT INTO t (id) VALUES (2);", &mut db).unwrap();
1✔
845

846
        let table = db.get_table("t".to_string()).unwrap();
1✔
847
        let row_one = table
1✔
848
            .rowids()
849
            .into_iter()
850
            .find(|r| table.get_value("id", *r) == Some(Value::Integer(1)))
3✔
851
            .unwrap();
852
        let row_two = table
1✔
853
            .rowids()
854
            .into_iter()
855
            .find(|r| table.get_value("id", *r) == Some(Value::Integer(2)))
3✔
856
            .unwrap();
857
        // Explicit NULL: stored as NULL, not the default.
858
        assert_eq!(table.get_value("n", row_one), None);
1✔
859
        // Omitted: stored as the DEFAULT 0.
860
        assert_eq!(table.get_value("n", row_two), Some(Value::Integer(0)));
1✔
861
    }
862

863
    #[test]
864
    fn process_command_update_arith_test() {
3✔
865
        use crate::sql::db::table::Value;
866

867
        let mut db = seed_users_table();
1✔
868
        process_command("UPDATE users SET age = age + 1;", &mut db).expect("update +1");
2✔
869

870
        let users = db.get_table("users".to_string()).unwrap();
1✔
871
        let mut ages: Vec<i64> = users
872
            .rowids()
873
            .into_iter()
874
            .filter_map(|r| match users.get_value("age", r) {
3✔
875
                Some(Value::Integer(n)) => Some(n),
1✔
876
                _ => None,
×
877
            })
878
            .collect();
879
        ages.sort();
2✔
880
        assert_eq!(ages, vec![26, 31, 41]); // 25+1, 30+1, 40+1
1✔
881
    }
882

883
    #[test]
884
    fn process_command_select_arithmetic_where_test() {
3✔
885
        let mut db = seed_users_table();
1✔
886
        // age * 2 > 55  →  only ages > 27.5  →  alice(30) + carol(40)
887
        let response =
2✔
888
            process_command("SELECT name FROM users WHERE age * 2 > 55;", &mut db).expect("select");
889
        assert!(response.contains("2 rows returned"));
2✔
890
    }
891

892
    #[test]
893
    fn process_command_divide_by_zero_test() {
3✔
894
        let mut db = seed_users_table();
1✔
895
        let result = process_command("SELECT age / 0 FROM users;", &mut db);
1✔
896
        // Projection only supports bare columns, so this errors earlier; still shouldn't panic.
897
        assert!(result.is_err());
2✔
898
    }
899

900
    #[test]
901
    fn process_command_unsupported_statement_test() {
3✔
902
        let mut db = Database::new("tempdb".to_string());
1✔
903
        // CREATE VIEW is firmly in the "Not yet supported" list — used as
904
        // the canary for the dispatcher's NotImplemented arm. (DROP TABLE
905
        // moved out of unsupported in this branch.)
906
        let result = process_command("CREATE VIEW v AS SELECT * FROM users;", &mut db);
1✔
907
        assert!(result.is_err());
2✔
908
    }
909

910
    #[test]
911
    fn empty_input_is_a_noop_not_a_panic() {
3✔
912
        // Regression for: desktop app pre-fills the textarea with a
913
        // comment-only placeholder, and hitting Run used to panic because
914
        // sqlparser produced zero statements and pop().unwrap() exploded.
915
        let mut db = Database::new("t".to_string());
1✔
916
        for input in ["", "   ", "-- just a comment", "-- comment\n-- another"] {
4✔
917
            let result = process_command(input, &mut db);
2✔
918
            assert!(result.is_ok(), "input {input:?} should not error");
2✔
919
            let msg = result.unwrap();
1✔
920
            assert!(msg.contains("No statement"), "got: {msg:?}");
2✔
921
        }
922
    }
923

924
    #[test]
925
    fn create_index_adds_explicit_index() {
3✔
926
        let mut db = seed_users_table();
1✔
927
        let response = process_command("CREATE INDEX users_age_idx ON users (age);", &mut db)
1✔
928
            .expect("create index");
929
        assert!(response.contains("users_age_idx"));
2✔
930

931
        // The index should now be attached to the users table.
932
        let users = db.get_table("users".to_string()).unwrap();
1✔
933
        let idx = users
1✔
934
            .index_by_name("users_age_idx")
935
            .expect("index should exist after CREATE INDEX");
936
        assert_eq!(idx.column_name, "age");
1✔
937
        assert!(!idx.is_unique);
1✔
938
    }
939

940
    #[test]
941
    fn create_unique_index_rejects_duplicate_existing_values() {
3✔
942
        let mut db = seed_users_table();
1✔
943
        // `name` is already UNIQUE (auto-indexed); insert a duplicate-age row
944
        // first so CREATE UNIQUE INDEX on age catches the conflict.
945
        process_command("INSERT INTO users (name, age) VALUES ('dan', 30);", &mut db).unwrap();
2✔
946
        let result = process_command(
947
            "CREATE UNIQUE INDEX users_age_unique ON users (age);",
948
            &mut db,
949
        );
950
        assert!(
×
951
            result.is_err(),
2✔
952
            "expected unique-index failure, got {result:?}"
953
        );
954
    }
955

956
    #[test]
957
    fn where_eq_on_indexed_column_uses_index_probe() {
3✔
958
        // Build a table big enough that a full scan would be expensive,
959
        // then rely on the index-probe fast path. This test verifies
960
        // correctness (right rows returned); the perf win is implicit.
961
        let mut db = Database::new("t".to_string());
1✔
962
        process_command(
963
            "CREATE TABLE big (id INTEGER PRIMARY KEY, tag TEXT);",
964
            &mut db,
965
        )
966
        .unwrap();
967
        process_command("CREATE INDEX big_tag_idx ON big (tag);", &mut db).unwrap();
1✔
968
        for i in 1..=100 {
1✔
969
            let tag = if i % 3 == 0 { "hot" } else { "cold" };
2✔
970
            process_command(&format!("INSERT INTO big (tag) VALUES ('{tag}');"), &mut db).unwrap();
1✔
971
        }
972
        let response =
1✔
973
            process_command("SELECT id FROM big WHERE tag = 'hot';", &mut db).expect("select");
974
        // 1..=100 has 33 multiples of 3.
975
        assert!(
×
976
            response.contains("33 rows returned"),
2✔
977
            "response was {response:?}"
978
        );
979
    }
980

981
    #[test]
982
    fn where_eq_on_indexed_column_inside_parens_uses_index_probe() {
3✔
983
        let mut db = seed_users_table();
1✔
984
        let response = process_command("SELECT name FROM users WHERE (name = 'bob');", &mut db)
1✔
985
            .expect("select");
986
        assert!(response.contains("1 row returned"));
2✔
987
    }
988

989
    #[test]
990
    fn where_eq_literal_first_side_uses_index_probe() {
3✔
991
        let mut db = seed_users_table();
1✔
992
        // `'bob' = name` should hit the same path as `name = 'bob'`.
993
        let response =
2✔
994
            process_command("SELECT name FROM users WHERE 'bob' = name;", &mut db).expect("select");
995
        assert!(response.contains("1 row returned"));
2✔
996
    }
997

998
    #[test]
999
    fn non_equality_where_still_falls_back_to_full_scan() {
3✔
1000
        // Sanity: range predicates bypass the optimizer and the full-scan
1001
        // path still returns correct results.
1002
        let mut db = seed_users_table();
1✔
1003
        let response =
2✔
1004
            process_command("SELECT name FROM users WHERE age > 28;", &mut db).expect("select");
1005
        assert!(response.contains("2 rows returned"));
2✔
1006
    }
1007

1008
    // -------------------------------------------------------------------
1009
    // Phase 4f — Transactions (BEGIN / COMMIT / ROLLBACK)
1010
    // -------------------------------------------------------------------
1011

1012
    #[test]
1013
    fn rollback_restores_pre_begin_in_memory_state() {
3✔
1014
        // In-memory DB (no pager): BEGIN, insert a row, ROLLBACK.
1015
        // The row must disappear from the live tables HashMap.
1016
        let mut db = seed_users_table();
1✔
1017
        let before = db.get_table("users".to_string()).unwrap().rowids().len();
2✔
1018
        assert_eq!(before, 3);
1✔
1019

1020
        process_command("BEGIN;", &mut db).expect("BEGIN");
1✔
1021
        assert!(db.in_transaction());
1✔
1022
        process_command("INSERT INTO users (name, age) VALUES ('dan', 50);", &mut db)
1✔
1023
            .expect("INSERT inside txn");
1024
        // Mid-transaction read sees the new row.
1025
        let mid = db.get_table("users".to_string()).unwrap().rowids().len();
1✔
1026
        assert_eq!(mid, 4);
1✔
1027

1028
        process_command("ROLLBACK;", &mut db).expect("ROLLBACK");
1✔
1029
        assert!(!db.in_transaction());
1✔
1030
        let after = db.get_table("users".to_string()).unwrap().rowids().len();
2✔
1031
        assert_eq!(
1✔
1032
            after, 3,
1033
            "ROLLBACK should have restored the pre-BEGIN state"
1034
        );
1035
    }
1036

1037
    #[test]
1038
    fn commit_keeps_mutations_and_clears_txn_flag() {
3✔
1039
        let mut db = seed_users_table();
1✔
1040
        process_command("BEGIN;", &mut db).expect("BEGIN");
2✔
1041
        process_command("INSERT INTO users (name, age) VALUES ('dan', 50);", &mut db)
1✔
1042
            .expect("INSERT inside txn");
1043
        process_command("COMMIT;", &mut db).expect("COMMIT");
1✔
1044
        assert!(!db.in_transaction());
1✔
1045
        let after = db.get_table("users".to_string()).unwrap().rowids().len();
2✔
1046
        assert_eq!(after, 4);
1✔
1047
    }
1048

1049
    #[test]
1050
    fn rollback_undoes_update_and_delete_side_by_side() {
3✔
1051
        use crate::sql::db::table::Value;
1052

1053
        let mut db = seed_users_table();
1✔
1054
        process_command("BEGIN;", &mut db).unwrap();
2✔
1055
        process_command("UPDATE users SET age = 999;", &mut db).unwrap();
1✔
1056
        process_command("DELETE FROM users WHERE name = 'bob';", &mut db).unwrap();
1✔
1057
        // Mid-txn: one row gone, others have age=999.
1058
        let users = db.get_table("users".to_string()).unwrap();
1✔
1059
        assert_eq!(users.rowids().len(), 2);
1✔
1060
        for r in users.rowids() {
2✔
1061
            assert_eq!(users.get_value("age", r), Some(Value::Integer(999)));
2✔
1062
        }
1063

1064
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1065
        let users = db.get_table("users".to_string()).unwrap();
1✔
1066
        assert_eq!(users.rowids().len(), 3);
1✔
1067
        // Original ages {30, 25, 40} — none should be 999.
1068
        for r in users.rowids() {
2✔
1069
            assert_ne!(users.get_value("age", r), Some(Value::Integer(999)));
2✔
1070
        }
1071
    }
1072

1073
    #[test]
1074
    fn nested_begin_is_rejected() {
3✔
1075
        let mut db = seed_users_table();
1✔
1076
        process_command("BEGIN;", &mut db).unwrap();
2✔
1077
        let err = process_command("BEGIN;", &mut db).unwrap_err();
1✔
1078
        assert!(
×
1079
            format!("{err}").contains("already open"),
3✔
1080
            "nested BEGIN should error; got: {err}"
1081
        );
1082
        // Still in the original transaction; a ROLLBACK clears it.
1083
        assert!(db.in_transaction());
1✔
1084
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1085
    }
1086

1087
    #[test]
1088
    fn orphan_commit_and_rollback_are_rejected() {
3✔
1089
        let mut db = seed_users_table();
1✔
1090
        let commit_err = process_command("COMMIT;", &mut db).unwrap_err();
2✔
1091
        assert!(format!("{commit_err}").contains("no transaction"));
2✔
1092
        let rollback_err = process_command("ROLLBACK;", &mut db).unwrap_err();
1✔
1093
        assert!(format!("{rollback_err}").contains("no transaction"));
2✔
1094
    }
1095

1096
    #[test]
1097
    fn error_inside_transaction_keeps_txn_open() {
3✔
1098
        // A bad INSERT inside a txn doesn't commit or abort automatically —
1099
        // the user can still ROLLBACK. SQLite's implicit-rollback behavior
1100
        // isn't modeled here.
1101
        let mut db = seed_users_table();
1✔
1102
        process_command("BEGIN;", &mut db).unwrap();
2✔
1103
        let err = process_command("INSERT INTO nope (x) VALUES (1);", &mut db);
1✔
1104
        assert!(err.is_err());
2✔
1105
        assert!(db.in_transaction(), "txn should stay open after error");
1✔
1106
        process_command("ROLLBACK;", &mut db).unwrap();
2✔
1107
    }
1108

1109
    /// Builds a file-backed Database at a unique temp path, with the
1110
    /// schema seeded and `source_path` set so subsequent process_command
1111
    /// calls auto-save. Returns (path, db). Drop the db before deleting
1112
    /// the files.
1113
    fn seed_file_backed(name: &str, schema: &str) -> (std::path::PathBuf, Database) {
1✔
1114
        use crate::sql::pager::{open_database, save_database};
1115
        let mut p = std::env::temp_dir();
1✔
1116
        let pid = std::process::id();
2✔
1117
        let nanos = std::time::SystemTime::now()
2✔
1118
            .duration_since(std::time::UNIX_EPOCH)
1✔
1119
            .map(|d| d.as_nanos())
3✔
1120
            .unwrap_or(0);
1121
        p.push(format!("sqlrite-txn-{name}-{pid}-{nanos}.sqlrite"));
1✔
1122

1123
        // Seed the file, then reopen to get a source_path-attached db
1124
        // (save_database alone doesn't attach a fresh pager to a db
1125
        // whose source_path was None before the call).
1126
        {
1127
            let mut seed = Database::new("t".to_string());
1✔
1128
            process_command(schema, &mut seed).unwrap();
2✔
1129
            save_database(&mut seed, &p).unwrap();
1✔
1130
        }
1131
        let db = open_database(&p, "t".to_string()).unwrap();
1✔
1132
        (p, db)
1✔
1133
    }
1134

1135
    fn cleanup_file(path: &std::path::Path) {
1✔
1136
        let _ = std::fs::remove_file(path);
1✔
1137
        let mut wal = path.as_os_str().to_owned();
1✔
1138
        wal.push("-wal");
1✔
1139
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
1140
    }
1141

1142
    #[test]
1143
    fn null_values_round_trip_through_disk() {
3✔
1144
        // SQLR-7 — explicit NULLs and omitted columns must persist.
1145
        // Pre-fix, restore_row rejected NULL for INTEGER/REAL/BOOL/VECTOR
1146
        // columns ("Integer column 'n' cannot store NULL — corrupt
1147
        // cell?"), so any DB containing a NULL in those types failed to
1148
        // reopen.
1149
        use crate::sql::db::table::Value;
1150
        use crate::sql::pager::open_database;
1151

1152
        let (path, mut db) = seed_file_backed(
1153
            "nullrt",
1154
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER, s TEXT, score REAL, flag BOOLEAN);",
1155
        );
1156
        process_command(
1157
            "INSERT INTO t (id, n, s, score, flag) VALUES (1, 10, 'hi', 1.5, true);",
1158
            &mut db,
1159
        )
1160
        .unwrap();
1161
        process_command(
1162
            "INSERT INTO t (id, n, s, score, flag) VALUES (2, NULL, NULL, NULL, NULL);",
1163
            &mut db,
1164
        )
1165
        .unwrap();
1166
        // Row 3 omits every nullable column.
1167
        process_command("INSERT INTO t (id) VALUES (3);", &mut db).unwrap();
1✔
1168

1169
        drop(db); // release pager lock
1✔
1170

1171
        let reopened = open_database(&path, "t".to_string()).unwrap();
1✔
1172
        let t = reopened.get_table("t".to_string()).unwrap();
2✔
1173
        let by_id = |id: i64| {
2✔
1174
            t.rowids()
1✔
1175
                .into_iter()
1176
                .find(|r| t.get_value("id", *r) == Some(Value::Integer(id)))
3✔
1177
                .unwrap_or_else(|| panic!("row id={id} not found"))
×
1178
        };
1179

1180
        let r1 = by_id(1);
1✔
1181
        assert_eq!(t.get_value("n", r1), Some(Value::Integer(10)));
1✔
1182
        assert_eq!(t.get_value("s", r1), Some(Value::Text("hi".to_string())));
1✔
1183
        assert_eq!(t.get_value("score", r1), Some(Value::Real(1.5)));
1✔
1184
        assert_eq!(t.get_value("flag", r1), Some(Value::Bool(true)));
1✔
1185

1186
        for r in [by_id(2), by_id(3)] {
2✔
1187
            assert_eq!(t.get_value("n", r), None, "INTEGER NULL must round-trip");
2✔
1188
            assert_eq!(t.get_value("s", r), None, "TEXT NULL must round-trip");
1✔
1189
            assert_eq!(t.get_value("score", r), None, "REAL NULL must round-trip");
1✔
1190
            assert_eq!(t.get_value("flag", r), None, "BOOL NULL must round-trip");
1✔
1191
        }
1192

1193
        drop(reopened);
1✔
1194
        cleanup_file(&path);
1✔
1195
    }
1196

1197
    #[test]
1198
    fn begin_commit_rollback_round_trip_through_disk() {
3✔
1199
        // File-backed DB: commit inside a transaction must actually
1200
        // persist. ROLLBACK inside a *later* transaction must not
1201
        // un-do the previously-committed changes.
1202
        use crate::sql::pager::open_database;
1203

1204
        let (path, mut db) = seed_file_backed(
1205
            "roundtrip",
1206
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1207
        );
1208

1209
        // Transaction 1: insert two rows, commit.
1210
        process_command("BEGIN;", &mut db).unwrap();
2✔
1211
        process_command("INSERT INTO notes (body) VALUES ('a');", &mut db).unwrap();
1✔
1212
        process_command("INSERT INTO notes (body) VALUES ('b');", &mut db).unwrap();
1✔
1213
        process_command("COMMIT;", &mut db).unwrap();
1✔
1214

1215
        // Transaction 2: insert another, roll back.
1216
        process_command("BEGIN;", &mut db).unwrap();
1✔
1217
        process_command("INSERT INTO notes (body) VALUES ('c');", &mut db).unwrap();
1✔
1218
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1219

1220
        drop(db); // release pager lock
1✔
1221

1222
        let reopened = open_database(&path, "t".to_string()).unwrap();
1✔
1223
        let notes = reopened.get_table("notes".to_string()).unwrap();
2✔
1224
        assert_eq!(notes.rowids().len(), 2, "committed rows should survive");
1✔
1225

1226
        drop(reopened);
1✔
1227
        cleanup_file(&path);
1✔
1228
    }
1229

1230
    #[test]
1231
    fn write_inside_transaction_does_not_autosave() {
3✔
1232
        // File-backed DB: writes inside BEGIN/…/COMMIT must NOT hit
1233
        // the WAL until COMMIT. We prove it by checking the WAL file
1234
        // size before vs during the transaction.
1235
        let (path, mut db) =
1✔
1236
            seed_file_backed("noas", "CREATE TABLE t (id INTEGER PRIMARY KEY, x TEXT);");
1237

1238
        let mut wal_path = path.as_os_str().to_owned();
2✔
1239
        wal_path.push("-wal");
1✔
1240
        let wal_path = std::path::PathBuf::from(wal_path);
1✔
1241
        let frames_before = std::fs::metadata(&wal_path).unwrap().len();
2✔
1242

1243
        process_command("BEGIN;", &mut db).unwrap();
1✔
1244
        process_command("INSERT INTO t (x) VALUES ('a');", &mut db).unwrap();
1✔
1245
        process_command("INSERT INTO t (x) VALUES ('b');", &mut db).unwrap();
1✔
1246

1247
        // Mid-transaction: WAL must be unchanged — no auto-save fired.
1248
        let frames_mid = std::fs::metadata(&wal_path).unwrap().len();
1✔
1249
        assert_eq!(
1✔
1250
            frames_before, frames_mid,
1251
            "WAL should not grow during an open transaction"
1252
        );
1253

1254
        process_command("COMMIT;", &mut db).unwrap();
2✔
1255

1256
        drop(db); // release pager lock
1✔
1257
        let fresh = crate::sql::pager::open_database(&path, "t".to_string()).unwrap();
1✔
1258
        assert_eq!(
1✔
1259
            fresh.get_table("t".to_string()).unwrap().rowids().len(),
3✔
1260
            2,
1261
            "COMMIT should have persisted both inserted rows"
1262
        );
1263
        drop(fresh);
1✔
1264
        cleanup_file(&path);
1✔
1265
    }
1266

1267
    #[test]
1268
    fn rollback_undoes_create_table() {
3✔
1269
        // Schema DDL inside a txn: ROLLBACK must make the new table
1270
        // disappear. The txn snapshot captures db.tables as of BEGIN,
1271
        // and ROLLBACK reassigns tables from that snapshot, so a table
1272
        // created mid-transaction has no entry in the snapshot.
1273
        let mut db = seed_users_table();
1✔
1274
        assert_eq!(db.tables.len(), 1);
2✔
1275

1276
        process_command("BEGIN;", &mut db).unwrap();
1✔
1277
        process_command(
1278
            "CREATE TABLE dropme (id INTEGER PRIMARY KEY, x TEXT);",
1279
            &mut db,
1280
        )
1281
        .unwrap();
1282
        process_command("INSERT INTO dropme (x) VALUES ('stuff');", &mut db).unwrap();
1✔
1283
        assert_eq!(db.tables.len(), 2);
1✔
1284

1285
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1286
        assert_eq!(
1✔
1287
            db.tables.len(),
1✔
1288
            1,
1289
            "CREATE TABLE should have been rolled back"
1290
        );
1291
        assert!(db.get_table("dropme".to_string()).is_err());
2✔
1292
    }
1293

1294
    #[test]
1295
    fn rollback_restores_secondary_index_state() {
3✔
1296
        // Phase 4f edge case: rolling back an INSERT on a UNIQUE-indexed
1297
        // column must also clean up the index, otherwise a re-insert of
1298
        // the same value would spuriously collide.
1299
        let mut db = Database::new("t".to_string());
1✔
1300
        process_command(
1301
            "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
1302
            &mut db,
1303
        )
1304
        .unwrap();
1305
        process_command("INSERT INTO users (email) VALUES ('a@x');", &mut db).unwrap();
1✔
1306

1307
        process_command("BEGIN;", &mut db).unwrap();
1✔
1308
        process_command("INSERT INTO users (email) VALUES ('b@x');", &mut db).unwrap();
1✔
1309
        // Inside the txn: the index now contains both 'a@x' and 'b@x'.
1310
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1311

1312
        // Re-inserting 'b@x' after rollback must succeed — if the index
1313
        // wasn't properly restored, it would think 'b@x' is still a
1314
        // collision and fail with a UNIQUE violation.
1315
        let reinsert = process_command("INSERT INTO users (email) VALUES ('b@x');", &mut db);
1✔
1316
        assert!(
×
1317
            reinsert.is_ok(),
2✔
1318
            "re-insert after rollback should succeed, got {reinsert:?}"
1319
        );
1320
    }
1321

1322
    #[test]
1323
    fn rollback_restores_last_rowid_counter() {
3✔
1324
        // Rowids allocated inside a rolled-back transaction should be
1325
        // reusable. The snapshot restores Table::last_rowid, so the
1326
        // next insert picks up where the pre-BEGIN state left off.
1327
        use crate::sql::db::table::Value;
1328

1329
        let mut db = seed_users_table(); // 3 rows, last_rowid = 3
1✔
1330
        let pre = db.get_table("users".to_string()).unwrap().last_rowid;
2✔
1331

1332
        process_command("BEGIN;", &mut db).unwrap();
1✔
1333
        process_command("INSERT INTO users (name, age) VALUES ('d', 50);", &mut db).unwrap(); // would be rowid 4
1✔
1334
        process_command("INSERT INTO users (name, age) VALUES ('e', 60);", &mut db).unwrap(); // would be rowid 5
1✔
1335
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1336

1337
        let post = db.get_table("users".to_string()).unwrap().last_rowid;
1✔
1338
        assert_eq!(pre, post, "last_rowid must roll back with the snapshot");
1✔
1339

1340
        // Confirm: the next insert reuses rowid pre+1.
1341
        process_command("INSERT INTO users (name, age) VALUES ('d', 50);", &mut db).unwrap();
2✔
1342
        let users = db.get_table("users".to_string()).unwrap();
1✔
1343
        let d_rowid = users
1✔
1344
            .rowids()
1345
            .into_iter()
1346
            .find(|r| users.get_value("name", *r) == Some(Value::Text("d".into())))
3✔
1347
            .expect("d row must exist");
1348
        assert_eq!(d_rowid, pre + 1);
1✔
1349
    }
1350

1351
    #[test]
1352
    fn commit_on_in_memory_db_clears_txn_without_pager_call() {
3✔
1353
        // In-memory DB (no source_path): COMMIT must still work — just
1354
        // no disk flush. Covers the `if let Some(path) = …` branch
1355
        // where the guard falls through without calling save_database.
1356
        let mut db = seed_users_table(); // no source_path
1✔
1357
        assert!(db.source_path.is_none());
2✔
1358

1359
        process_command("BEGIN;", &mut db).unwrap();
1✔
1360
        process_command("INSERT INTO users (name, age) VALUES ('z', 99);", &mut db).unwrap();
1✔
1361
        process_command("COMMIT;", &mut db).unwrap();
1✔
1362

1363
        assert!(!db.in_transaction());
1✔
1364
        assert_eq!(db.get_table("users".to_string()).unwrap().rowids().len(), 4);
2✔
1365
    }
1366

1367
    #[test]
1368
    fn failed_commit_auto_rolls_back_in_memory_state() {
3✔
1369
        // Data-safety regression: on COMMIT save failure we must auto-
1370
        // rollback the in-memory state. Otherwise, any subsequent
1371
        // non-transactional statement would auto-save the partial
1372
        // mid-transaction work, silently publishing uncommitted
1373
        // changes to disk.
1374
        //
1375
        // We simulate a save failure by making the WAL sidecar path
1376
        // unavailable mid-transaction: after BEGIN, we take an
1377
        // exclusive OS lock on the WAL via a second File handle,
1378
        // forcing the next save to fail when it tries to append.
1379
        //
1380
        // Simpler repro: point source_path at a directory (not a file).
1381
        // `OpenOptions::open` will fail with EISDIR on save.
1382
        use crate::sql::pager::save_database;
1383

1384
        // Seed a file-backed db.
1385
        let (path, mut db) = seed_file_backed(
1386
            "failcommit",
1387
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1388
        );
1389

1390
        // Prime one committed row so we have a baseline.
1391
        process_command("INSERT INTO notes (body) VALUES ('before');", &mut db).unwrap();
2✔
1392

1393
        // Open a new txn and add a row.
1394
        process_command("BEGIN;", &mut db).unwrap();
1✔
1395
        process_command("INSERT INTO notes (body) VALUES ('inflight');", &mut db).unwrap();
1✔
1396
        assert_eq!(
1✔
1397
            db.get_table("notes".to_string()).unwrap().rowids().len(),
2✔
1398
            2,
1399
            "inflight row visible mid-txn"
1400
        );
1401

1402
        // Swap source_path to a path that will fail on open. A
1403
        // directory is a reliable failure mode — Pager::open on a
1404
        // directory errors with an I/O error.
1405
        let orig_source = db.source_path.clone();
1✔
1406
        let orig_pager = db.pager.take();
2✔
1407
        db.source_path = Some(std::env::temp_dir());
2✔
1408

1409
        let commit_result = process_command("COMMIT;", &mut db);
1✔
1410
        assert!(commit_result.is_err(), "commit must fail");
2✔
1411
        let err_str = format!("{}", commit_result.unwrap_err());
2✔
1412
        assert!(
×
1413
            err_str.contains("COMMIT failed") && err_str.contains("rolled back"),
3✔
1414
            "error must surface auto-rollback; got: {err_str}"
1415
        );
1416

1417
        // Auto-rollback fired: the inflight row is gone, the txn flag
1418
        // is cleared, and a follow-up non-txn statement won't leak
1419
        // stale state.
1420
        assert!(
×
1421
            !db.in_transaction(),
2✔
1422
            "txn must be cleared after auto-rollback"
1423
        );
1424
        assert_eq!(
1✔
1425
            db.get_table("notes".to_string()).unwrap().rowids().len(),
3✔
1426
            1,
1427
            "inflight row must be rolled back"
1428
        );
1429

1430
        // Restore the real source_path + pager and verify a clean
1431
        // subsequent write goes through.
1432
        db.source_path = orig_source;
1✔
1433
        db.pager = orig_pager;
1✔
1434
        process_command("INSERT INTO notes (body) VALUES ('after');", &mut db).unwrap();
1✔
1435
        drop(db);
1✔
1436

1437
        // Reopen and assert only 'before' + 'after' landed on disk.
1438
        let reopened = crate::sql::pager::open_database(&path, "t".to_string()).unwrap();
1✔
1439
        let notes = reopened.get_table("notes".to_string()).unwrap();
2✔
1440
        assert_eq!(notes.rowids().len(), 2);
1✔
1441
        // Ensure no leaked save_database partial happened.
1442
        let _ = save_database; // silence unused-import lint if any
1443
        drop(reopened);
1✔
1444
        cleanup_file(&path);
1✔
1445
    }
1446

1447
    #[test]
1448
    fn begin_on_read_only_is_rejected() {
3✔
1449
        use crate::sql::pager::{open_database_read_only, save_database};
1450

1451
        let path = {
1✔
1452
            let mut p = std::env::temp_dir();
1✔
1453
            let pid = std::process::id();
2✔
1454
            let nanos = std::time::SystemTime::now()
2✔
1455
                .duration_since(std::time::UNIX_EPOCH)
1✔
1456
                .map(|d| d.as_nanos())
3✔
1457
                .unwrap_or(0);
1458
            p.push(format!("sqlrite-txn-ro-{pid}-{nanos}.sqlrite"));
1✔
1459
            p
1✔
1460
        };
1461
        {
1462
            let mut seed = Database::new("t".to_string());
2✔
1463
            process_command("CREATE TABLE t (id INTEGER PRIMARY KEY);", &mut seed).unwrap();
2✔
1464
            save_database(&mut seed, &path).unwrap();
1✔
1465
        }
1466

1467
        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1✔
1468
        let err = process_command("BEGIN;", &mut ro).unwrap_err();
2✔
1469
        assert!(
×
1470
            format!("{err}").contains("read-only"),
3✔
1471
            "BEGIN on RO db should surface read-only; got: {err}"
1472
        );
1473
        assert!(!ro.in_transaction());
1✔
1474

1475
        let _ = std::fs::remove_file(&path);
2✔
1476
        let mut wal = path.as_os_str().to_owned();
1✔
1477
        wal.push("-wal");
1✔
1478
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
1479
    }
1480

1481
    #[test]
1482
    fn read_only_database_rejects_mutations_before_touching_state() {
3✔
1483
        // Phase 4e end-to-end: a `--readonly` caller that runs INSERT
1484
        // must error *before* the row is added to the in-memory table.
1485
        // Otherwise the user sees a rendered result table with the
1486
        // phantom row, followed by the auto-save error — UX rot and a
1487
        // state-drift risk.
1488
        use crate::sql::pager::open_database_read_only;
1489

1490
        let mut seed = Database::new("t".to_string());
1✔
1491
        process_command(
1492
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1493
            &mut seed,
1494
        )
1495
        .unwrap();
1496
        process_command("INSERT INTO notes (body) VALUES ('alpha');", &mut seed).unwrap();
1✔
1497

1498
        let path = {
1499
            let mut p = std::env::temp_dir();
1✔
1500
            let pid = std::process::id();
2✔
1501
            let nanos = std::time::SystemTime::now()
2✔
1502
                .duration_since(std::time::UNIX_EPOCH)
1✔
1503
                .map(|d| d.as_nanos())
3✔
1504
                .unwrap_or(0);
1505
            p.push(format!("sqlrite-ro-reject-{pid}-{nanos}.sqlrite"));
1✔
1506
            p
1✔
1507
        };
1508
        crate::sql::pager::save_database(&mut seed, &path).unwrap();
2✔
1509
        drop(seed);
1✔
1510

1511
        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1✔
1512
        let notes_before = ro.get_table("notes".to_string()).unwrap().rowids().len();
2✔
1513

1514
        for stmt in [
2✔
1515
            "INSERT INTO notes (body) VALUES ('beta');",
1516
            "UPDATE notes SET body = 'x';",
1517
            "DELETE FROM notes;",
1518
            "CREATE TABLE more (id INTEGER PRIMARY KEY);",
1519
            "CREATE INDEX notes_body ON notes (body);",
1520
        ] {
1521
            let err = process_command(stmt, &mut ro).unwrap_err();
2✔
1522
            assert!(
×
1523
                format!("{err}").contains("read-only"),
3✔
1524
                "stmt {stmt:?} should surface a read-only error; got: {err}"
1525
            );
1526
        }
1527

1528
        // Nothing mutated: same row count as before, and SELECTs still work.
1529
        let notes_after = ro.get_table("notes".to_string()).unwrap().rowids().len();
1✔
1530
        assert_eq!(notes_before, notes_after);
1✔
1531
        let sel = process_command("SELECT * FROM notes;", &mut ro).expect("select on RO must work");
1✔
1532
        assert!(sel.contains("1 row returned"));
2✔
1533

1534
        // Cleanup.
1535
        drop(ro);
1✔
1536
        let _ = std::fs::remove_file(&path);
1✔
1537
        let mut wal = path.as_os_str().to_owned();
1✔
1538
        wal.push("-wal");
1✔
1539
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
1540
    }
1541

1542
    // -----------------------------------------------------------------
1543
    // Phase 7a — VECTOR(N) end-to-end through process_command
1544
    // -----------------------------------------------------------------
1545

1546
    #[test]
1547
    fn vector_create_table_and_insert_basic() {
3✔
1548
        let mut db = Database::new("tempdb".to_string());
1✔
1549
        process_command(
1550
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(3));",
1551
            &mut db,
1552
        )
1553
        .expect("create table with VECTOR(3)");
1554
        process_command(
1555
            "INSERT INTO docs (embedding) VALUES ([0.1, 0.2, 0.3]);",
1556
            &mut db,
1557
        )
1558
        .expect("insert vector");
1559

1560
        // process_command returns a status string; the rendered table
1561
        // goes to stdout via print_table. Verify state by inspecting
1562
        // the database directly.
1563
        let sel = process_command("SELECT * FROM docs;", &mut db).expect("select");
1✔
1564
        assert!(sel.contains("1 row returned"));
2✔
1565

1566
        let docs = db.get_table("docs".to_string()).expect("docs table");
1✔
1567
        let rowids = docs.rowids();
1✔
1568
        assert_eq!(rowids.len(), 1);
2✔
1569
        match docs.get_value("embedding", rowids[0]) {
1✔
1570
            Some(Value::Vector(v)) => assert_eq!(v, vec![0.1f32, 0.2, 0.3]),
1✔
1571
            other => panic!("expected Value::Vector(...), got {other:?}"),
×
1572
        }
1573
    }
1574

1575
    #[test]
1576
    fn vector_dim_mismatch_at_insert_is_clean_error() {
3✔
1577
        let mut db = Database::new("tempdb".to_string());
1✔
1578
        process_command(
1579
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(3));",
1580
            &mut db,
1581
        )
1582
        .expect("create table");
1583

1584
        // Too few elements.
1585
        let err = process_command("INSERT INTO docs (embedding) VALUES ([0.1, 0.2]);", &mut db)
1✔
1586
            .unwrap_err();
1587
        let msg = format!("{err}");
2✔
1588
        assert!(
×
1589
            msg.to_lowercase().contains("dimension")
2✔
1590
                && msg.contains("declared 3")
1✔
1591
                && msg.contains("got 2"),
1✔
1592
            "expected clear dim-mismatch error, got: {msg}"
1593
        );
1594

1595
        // Too many elements.
1596
        let err = process_command(
1597
            "INSERT INTO docs (embedding) VALUES ([0.1, 0.2, 0.3, 0.4, 0.5]);",
1598
            &mut db,
1599
        )
1600
        .unwrap_err();
1601
        assert!(
×
1602
            format!("{err}").contains("got 5"),
3✔
1603
            "expected dim-mismatch error mentioning got 5, got: {err}"
1604
        );
1605
    }
1606

1607
    #[test]
1608
    fn vector_create_table_rejects_missing_dim() {
3✔
1609
        let mut db = Database::new("tempdb".to_string());
1✔
1610
        // `VECTOR` (no parens) currently parses as `DataType::Custom` with
1611
        // empty args from sqlparser, OR may not parse as Custom at all
1612
        // depending on dialect. Either way, the column shouldn't end up
1613
        // as a usable Vector type. Accept any error here — the precise
1614
        // message is parser-version-dependent.
1615
        let result = process_command(
1616
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR);",
1617
            &mut db,
1618
        );
1619
        assert!(
×
1620
            result.is_err(),
2✔
1621
            "expected CREATE TABLE with bare VECTOR to fail (no dim)"
1622
        );
1623
    }
1624

1625
    #[test]
1626
    fn vector_create_table_rejects_zero_dim() {
3✔
1627
        let mut db = Database::new("tempdb".to_string());
1✔
1628
        let err = process_command(
1629
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(0));",
1630
            &mut db,
1631
        )
1632
        .unwrap_err();
1633
        let msg = format!("{err}");
2✔
1634
        assert!(
×
1635
            msg.to_lowercase().contains("vector"),
3✔
1636
            "expected VECTOR-related error for VECTOR(0), got: {msg}"
1637
        );
1638
    }
1639

1640
    #[test]
1641
    fn vector_high_dim_works() {
3✔
1642
        // 384-dim vector (OpenAI text-embedding-3-small size). Mostly a
1643
        // smoke test — if cell encoding mishandles the size, this fails.
1644
        let mut db = Database::new("tempdb".to_string());
1✔
1645
        process_command(
1646
            "CREATE TABLE embeddings (id INTEGER PRIMARY KEY, e VECTOR(384));",
1647
            &mut db,
1648
        )
1649
        .expect("create table VECTOR(384)");
1650

1651
        let lit = format!(
1✔
1652
            "[{}]",
1653
            (0..384)
1✔
1654
                .map(|i| format!("{}", i as f32 * 0.001))
3✔
1655
                .collect::<Vec<_>>()
1✔
1656
                .join(",")
1✔
1657
        );
1658
        let sql = format!("INSERT INTO embeddings (e) VALUES ({lit});");
2✔
1659
        process_command(&sql, &mut db).expect("insert 384-dim vector");
2✔
1660

1661
        let sel = process_command("SELECT id FROM embeddings;", &mut db).expect("select id");
1✔
1662
        assert!(sel.contains("1 row returned"));
2✔
1663
    }
1664

1665
    #[test]
1666
    fn vector_multiple_rows() {
3✔
1667
        // Three rows with different vectors — exercises the Row::Vector
1668
        // BTreeMap path (not just single-row insertion).
1669
        let mut db = Database::new("tempdb".to_string());
1✔
1670
        process_command(
1671
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1672
            &mut db,
1673
        )
1674
        .expect("create");
1675
        for i in 0..3 {
1✔
1676
            let sql = format!("INSERT INTO docs (e) VALUES ([{i}.0, {}.0]);", i + 1);
2✔
1677
            process_command(&sql, &mut db).expect("insert");
2✔
1678
        }
1679
        let sel = process_command("SELECT * FROM docs;", &mut db).expect("select");
1✔
1680
        assert!(sel.contains("3 rows returned"));
2✔
1681

1682
        // Verify each vector round-tripped correctly via direct DB inspection.
1683
        let docs = db.get_table("docs".to_string()).expect("docs table");
1✔
1684
        let rowids = docs.rowids();
1✔
1685
        assert_eq!(rowids.len(), 3);
2✔
1686
        let mut vectors: Vec<Vec<f32>> = rowids
1✔
1687
            .iter()
1688
            .filter_map(|r| match docs.get_value("e", *r) {
3✔
1689
                Some(Value::Vector(v)) => Some(v),
1✔
1690
                _ => None,
×
1691
            })
1692
            .collect();
1693
        vectors.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap());
4✔
1694
        assert_eq!(vectors[0], vec![0.0f32, 1.0]);
1✔
1695
        assert_eq!(vectors[1], vec![1.0f32, 2.0]);
1✔
1696
        assert_eq!(vectors[2], vec![2.0f32, 3.0]);
1✔
1697
    }
1698

1699
    // -----------------------------------------------------------------
1700
    // Phase 7d.2 — CREATE INDEX … USING hnsw end-to-end
1701
    // -----------------------------------------------------------------
1702

1703
    /// Builds a 5-row docs(id, e VECTOR(2)) table with vectors arranged
1704
    /// at known positions for clear distance reasoning. Used by both
1705
    /// the 7d.2 KNN tests and the refuse-DELETE/UPDATE tests.
1706
    fn seed_hnsw_table() -> Database {
1✔
1707
        let mut db = Database::new("tempdb".to_string());
1✔
1708
        process_command(
1709
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1710
            &mut db,
1711
        )
1712
        .unwrap();
1713
        for v in &[
1✔
1714
            "[1.0, 0.0]",   // id=1
1715
            "[2.0, 0.0]",   // id=2
1716
            "[0.0, 3.0]",   // id=3
1717
            "[1.0, 4.0]",   // id=4
1718
            "[10.0, 10.0]", // id=5
1719
        ] {
1720
            process_command(&format!("INSERT INTO docs (e) VALUES ({v});"), &mut db).unwrap();
2✔
1721
        }
1722
        db
1✔
1723
    }
1724

1725
    #[test]
1726
    fn create_index_using_hnsw_succeeds() {
4✔
1727
        let mut db = seed_hnsw_table();
1✔
1728
        let resp = process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1729
        assert!(resp.to_lowercase().contains("create index"));
2✔
1730
        // Index attached.
1731
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1732
        assert_eq!(table.hnsw_indexes.len(), 1);
1✔
1733
        assert_eq!(table.hnsw_indexes[0].name, "ix_e");
1✔
1734
        assert_eq!(table.hnsw_indexes[0].column_name, "e");
1✔
1735
        // Existing rows landed in the graph.
1736
        assert_eq!(table.hnsw_indexes[0].index.len(), 5);
1✔
1737
    }
1738

1739
    #[test]
1740
    fn create_index_using_hnsw_rejects_non_vector_column() {
3✔
1741
        let mut db = Database::new("tempdb".to_string());
1✔
1742
        process_command(
1743
            "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);",
1744
            &mut db,
1745
        )
1746
        .unwrap();
1747
        let err =
1✔
1748
            process_command("CREATE INDEX ix_name ON t USING hnsw (name);", &mut db).unwrap_err();
1749
        let msg = format!("{err}");
2✔
1750
        assert!(
×
1751
            msg.to_lowercase().contains("vector"),
3✔
1752
            "expected error mentioning VECTOR; got: {msg}"
1753
        );
1754
    }
1755

1756
    #[test]
1757
    fn knn_query_uses_hnsw_after_create_index() {
3✔
1758
        // The KNN-shaped query route through try_hnsw_probe rather than
1759
        // the brute-force select_topk. The user-visible result should
1760
        // be the same (HNSW recall is high on small graphs); we
1761
        // primarily verify the index is being hit by checking that
1762
        // the right rowids come back in the right order.
1763
        let mut db = seed_hnsw_table();
1✔
1764
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1765

1766
        // Top-3 closest to [1.0, 0.0]:
1767
        //   id=1 [1.0, 0.0]   distance=0
1768
        //   id=2 [2.0, 0.0]   distance=1
1769
        //   id=3 [0.0, 3.0]   distance≈3.16
1770
        let resp = process_command(
1771
            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [1.0, 0.0]) ASC LIMIT 3;",
1772
            &mut db,
1773
        )
1774
        .unwrap();
1775
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
1776
    }
1777

1778
    #[test]
1779
    fn knn_query_works_after_subsequent_inserts() {
3✔
1780
        // Index built when 5 rows existed; insert 2 more after; the
1781
        // HNSW gets maintained incrementally by insert_row, so the
1782
        // KNN query should see the newly-inserted vectors.
1783
        let mut db = seed_hnsw_table();
1✔
1784
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1785
        process_command("INSERT INTO docs (e) VALUES ([0.5, 0.0]);", &mut db).unwrap(); // id=6
1✔
1786
        process_command("INSERT INTO docs (e) VALUES ([0.1, 0.1]);", &mut db).unwrap(); // id=7
1✔
1787

1788
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1789
        assert_eq!(
1✔
1790
            table.hnsw_indexes[0].index.len(),
1✔
1791
            7,
1792
            "incremental insert should grow HNSW alongside row storage"
1793
        );
1794

1795
        // Now query: id=7 [0.1, 0.1] is closer to [0.0, 0.0] than the
1796
        // original 5 rows.
1797
        let resp = process_command(
1798
            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [0.0, 0.0]) ASC LIMIT 1;",
1799
            &mut db,
1800
        )
1801
        .unwrap();
1802
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
1803
    }
1804

1805
    // Phase 7d.3 — DELETE / UPDATE on HNSW-indexed tables now works.
1806
    // The 7d.2 versions of these tests asserted a refusal; replaced
1807
    // with assertions that the operation succeeds + the index entry's
1808
    // needs_rebuild flag flipped so the next save will rebuild.
1809

1810
    #[test]
1811
    fn delete_on_hnsw_indexed_table_succeeds_and_marks_dirty() {
3✔
1812
        let mut db = seed_hnsw_table();
1✔
1813
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1814
        let resp = process_command("DELETE FROM docs WHERE id = 1;", &mut db).unwrap();
1✔
1815
        assert!(resp.contains("1 row"), "expected 1 row deleted: {resp}");
2✔
1816

1817
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
1818
        let entry = docs.hnsw_indexes.iter().find(|e| e.name == "ix_e").unwrap();
3✔
1819
        assert!(
×
1820
            entry.needs_rebuild,
1✔
1821
            "DELETE should have marked HNSW index dirty for rebuild on next save"
1822
        );
1823
    }
1824

1825
    #[test]
1826
    fn hnsw_delete_then_insert_rebuilds_in_same_connection() {
3✔
1827
        let mut db = Database::new(String::from("test_db"));
1✔
1828
        process_command(
1829
            "CREATE TABLE chunks (id INTEGER PRIMARY KEY, document_id INTEGER, embedding VECTOR(4));",
1830
            &mut db,
1831
        )
1832
        .unwrap();
1833
        process_command(
1834
            "CREATE INDEX idx_emb ON chunks USING hnsw (embedding);",
1835
            &mut db,
1836
        )
1837
        .unwrap();
1838
        process_command(
1839
            "INSERT INTO chunks (document_id, embedding) VALUES (1, [1, 0, 0, 0]);",
1840
            &mut db,
1841
        )
1842
        .unwrap();
1843
        process_command(
1844
            "INSERT INTO chunks (document_id, embedding) VALUES (1, [0, 1, 0, 0]);",
1845
            &mut db,
1846
        )
1847
        .unwrap();
1848
        process_command("DELETE FROM chunks WHERE document_id = 1;", &mut db).unwrap();
1✔
1849

1850
        process_command(
1851
            "INSERT INTO chunks (document_id, embedding) VALUES (2, [0, 0, 1, 0]);",
1852
            &mut db,
1853
        )
1854
        .unwrap();
1855
        let chunks = db.get_table("chunks".to_string()).unwrap();
1✔
1856
        let entry = chunks
3✔
1857
            .hnsw_indexes
1858
            .iter()
1859
            .find(|e| e.name == "idx_emb")
3✔
1860
            .unwrap();
1861
        assert!(
×
1862
            !entry.needs_rebuild,
1✔
1863
            "INSERT should rebuild the dirty index"
1864
        );
1865
        assert_eq!(entry.index.len(), 1);
2✔
1866

1867
        let out = process_command_with_render(
1868
            "SELECT document_id FROM chunks ORDER BY vec_distance_l2(embedding, [0, 0, 1, 0]) ASC LIMIT 1;",
1869
            &mut db,
1870
        )
1871
        .unwrap();
1872
        assert!(out.status.contains("1 row returned"), "got: {}", out.status);
2✔
1873
        let rendered = out.rendered.expect("SELECT should render rows");
1✔
1874
        assert!(
×
1875
            rendered.contains("| 2           |"),
2✔
1876
            "expected the post-delete inserted row: {rendered}"
1877
        );
1878
    }
1879

1880
    #[test]
1881
    fn update_on_hnsw_indexed_vector_col_succeeds_and_marks_dirty() {
3✔
1882
        let mut db = seed_hnsw_table();
1✔
1883
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1884
        let resp =
1✔
1885
            process_command("UPDATE docs SET e = [9.0, 9.0] WHERE id = 1;", &mut db).unwrap();
1886
        assert!(resp.contains("1 row"), "expected 1 row updated: {resp}");
2✔
1887

1888
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
1889
        let entry = docs.hnsw_indexes.iter().find(|e| e.name == "ix_e").unwrap();
3✔
1890
        assert!(
×
1891
            entry.needs_rebuild,
1✔
1892
            "UPDATE on the vector column should have marked HNSW index dirty"
1893
        );
1894
    }
1895

1896
    #[test]
1897
    fn duplicate_index_name_errors() {
3✔
1898
        let mut db = seed_hnsw_table();
1✔
1899
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1900
        let err =
1✔
1901
            process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap_err();
1902
        let msg = format!("{err}");
2✔
1903
        assert!(
×
1904
            msg.to_lowercase().contains("already exists"),
3✔
1905
            "expected duplicate-index error; got: {msg}"
1906
        );
1907
    }
1908

1909
    #[test]
1910
    fn index_if_not_exists_is_idempotent() {
3✔
1911
        let mut db = seed_hnsw_table();
1✔
1912
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1913
        // Second time with IF NOT EXISTS should succeed (no-op).
1914
        process_command(
1915
            "CREATE INDEX IF NOT EXISTS ix_e ON docs USING hnsw (e);",
1916
            &mut db,
1917
        )
1918
        .unwrap();
1919
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1920
        assert_eq!(table.hnsw_indexes.len(), 1);
1✔
1921
    }
1922

1923
    // -----------------------------------------------------------------
1924
    // SQLR-10 — CREATE TABLE IF NOT EXISTS + sqlrite_master introspection
1925
    // -----------------------------------------------------------------
1926

1927
    #[test]
1928
    fn create_table_if_not_exists_is_idempotent() {
3✔
1929
        let mut db = Database::new("tempdb".to_string());
1✔
1930
        process_command(
1931
            "CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER);",
1932
            &mut db,
1933
        )
1934
        .unwrap();
1935
        // Second CREATE with IF NOT EXISTS must succeed as a no-op
1936
        // (this is the bug: it used to error "table already exists").
1937
        let msg = process_command(
1938
            "CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, v INTEGER);",
1939
            &mut db,
1940
        )
1941
        .expect("CREATE TABLE IF NOT EXISTS should be a no-op on an existing table");
NEW
1942
        assert!(
×
1943
            msg.to_lowercase().contains("no-op"),
3✔
1944
            "expected a no-op status; got: {msg}"
1945
        );
1946
        // The original table is untouched — still exactly one table.
1947
        assert_eq!(db.tables.len(), 1);
1✔
1948
    }
1949

1950
    #[test]
1951
    fn create_table_without_if_not_exists_still_errors() {
3✔
1952
        let mut db = Database::new("tempdb".to_string());
1✔
1953
        process_command("CREATE TABLE t (id INTEGER PRIMARY KEY);", &mut db).unwrap();
2✔
1954
        let err = process_command("CREATE TABLE t (id INTEGER PRIMARY KEY);", &mut db).unwrap_err();
1✔
NEW
1955
        assert!(
×
1956
            format!("{err}").to_lowercase().contains("already exists"),
4✔
1957
            "plain CREATE TABLE on an existing table must still error; got: {err}"
1958
        );
1959
    }
1960

1961
    #[test]
1962
    fn create_table_if_not_exists_on_fresh_table_creates_it() {
3✔
1963
        let mut db = Database::new("tempdb".to_string());
1✔
1964
        // IF NOT EXISTS on a brand-new name still creates the table.
1965
        process_command(
1966
            "CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, v INTEGER);",
1967
            &mut db,
1968
        )
1969
        .unwrap();
1970
        assert!(db.contains_table("t".to_string()));
1✔
1971
    }
1972

1973
    #[test]
1974
    fn select_from_sqlrite_master_lists_tables_and_indexes() {
3✔
1975
        use crate::sql::executor::execute_select_rows;
1976
        use crate::sql::parser::select::SelectQuery;
1977

1978
        let mut db = Database::new("tempdb".to_string());
1✔
1979
        process_command(
1980
            "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
1981
            &mut db,
1982
        )
1983
        .unwrap();
1984
        process_command("CREATE TABLE posts (id INTEGER PRIMARY KEY);", &mut db).unwrap();
1✔
1985
        process_command("CREATE INDEX ix_email ON users (email);", &mut db).unwrap();
1✔
1986

1987
        // Helper: run a SELECT and return structured rows.
1988
        let run = |sql: &str, db: &Database| -> Vec<Vec<Value>> {
1✔
1989
            let dialect = SqlriteDialect::new();
1✔
1990
            let mut ast = Parser::parse_sql(&dialect, sql).unwrap();
1✔
1991
            let sq = SelectQuery::new(&ast.pop().unwrap()).unwrap();
2✔
1992
            execute_select_rows(sq, db).unwrap().rows
1✔
1993
        };
1994

1995
        // The exact bug from the issue: SELECT name FROM sqlrite_master.
1996
        let names: Vec<String> = run("SELECT name FROM sqlrite_master;", &db)
1✔
1997
            .into_iter()
1998
            .map(|r| match &r[0] {
4✔
1999
                Value::Text(s) => s.clone(),
1✔
NEW
2000
                other => panic!("expected Text name, got {other:?}"),
×
2001
            })
2002
            .collect();
2003
        assert!(names.contains(&"users".to_string()));
2✔
2004
        assert!(names.contains(&"posts".to_string()));
1✔
2005
        // The user's UNIQUE column produced an auto-index plus our explicit
2006
        // one — both should be visible by name.
2007
        assert!(names.contains(&"ix_email".to_string()));
1✔
2008

2009
        // type filtering works through the normal WHERE path.
2010
        let table_rows = run("SELECT name FROM sqlrite_master WHERE type = 'table';", &db);
1✔
2011
        assert_eq!(table_rows.len(), 2, "two user tables");
2✔
2012

2013
        let index_rows = run("SELECT name FROM sqlrite_master WHERE type = 'index';", &db);
1✔
NEW
2014
        assert!(
×
2015
            !index_rows.is_empty(),
2✔
2016
            "at least the explicit ix_email index"
2017
        );
2018

2019
        // SELECT * exposes the full catalog schema (type, name, sql, …).
2020
        let all = run("SELECT * FROM sqlrite_master WHERE name = 'users';", &db);
1✔
2021
        assert_eq!(all.len(), 1);
2✔
2022
        assert_eq!(all[0].len(), 5, "type, name, sql, rootpage, last_rowid");
1✔
2023
        match &all[0][2] {
2✔
2024
            Value::Text(sql) => assert!(
1✔
2025
                sql.to_uppercase().contains("CREATE TABLE"),
3✔
2026
                "sql column should carry the CREATE TABLE text; got: {sql}"
2027
            ),
NEW
2028
            other => panic!("expected Text sql, got {other:?}"),
×
2029
        }
2030
    }
2031

2032
    #[test]
2033
    fn writes_to_sqlrite_master_are_rejected() {
3✔
2034
        let mut db = Database::new("tempdb".to_string());
1✔
2035
        process_command("CREATE TABLE t (id INTEGER PRIMARY KEY);", &mut db).unwrap();
2✔
2036
        // sqlrite_master is read-only: it never lands in db.tables, so an
2037
        // INSERT can't find it. (The reserved-name guard also blocks
2038
        // CREATE/DROP/ALTER against it.)
NEW
2039
        assert!(
×
2040
            process_command(
2✔
2041
                "INSERT INTO sqlrite_master (type, name) VALUES ('table', 'x');",
2042
                &mut db
2043
            )
2044
            .is_err()
1✔
2045
        );
2046
    }
2047

2048
    #[test]
2049
    fn select_from_sqlrite_master_survives_save_and_reopen() {
3✔
2050
        use crate::sql::executor::execute_select_rows;
2051
        use crate::sql::pager::{open_database, save_database};
2052
        use crate::sql::parser::select::SelectQuery;
2053

2054
        let dir = std::env::temp_dir();
1✔
2055
        let path = dir.join(format!("sqlr10_master_{}.sqlrite", std::process::id()));
2✔
2056
        let _ = std::fs::remove_file(&path);
2✔
2057

2058
        let mut db = Database::new("tempdb".to_string());
1✔
2059
        process_command("CREATE TABLE alpha (id INTEGER PRIMARY KEY);", &mut db).unwrap();
2✔
2060
        process_command("CREATE TABLE beta (id INTEGER PRIMARY KEY);", &mut db).unwrap();
1✔
2061
        save_database(&mut db, &path).unwrap();
1✔
2062

2063
        let reopened = open_database(&path, "tempdb".to_string()).unwrap();
1✔
2064
        let dialect = SqlriteDialect::new();
1✔
2065
        let mut ast = Parser::parse_sql(
2066
            &dialect,
2067
            "SELECT name FROM sqlrite_master WHERE type = 'table';",
2068
        )
2069
        .unwrap();
2070
        let sq = SelectQuery::new(&ast.pop().unwrap()).unwrap();
2✔
2071
        let names: Vec<String> = execute_select_rows(sq, &reopened)
2✔
2072
            .unwrap()
1✔
2073
            .rows
2074
            .into_iter()
2075
            .map(|r| match &r[0] {
4✔
2076
                Value::Text(s) => s.clone(),
1✔
NEW
2077
                other => panic!("expected Text, got {other:?}"),
×
2078
            })
2079
            .collect();
2080
        assert!(names.contains(&"alpha".to_string()));
1✔
2081
        assert!(names.contains(&"beta".to_string()));
1✔
2082

2083
        let _ = std::fs::remove_file(&path);
1✔
2084
    }
2085

2086
    // -----------------------------------------------------------------
2087
    // Phase 8b — CREATE INDEX … USING fts end-to-end
2088
    // -----------------------------------------------------------------
2089

2090
    /// 5-row docs(id INTEGER PK, body TEXT) populated with overlapping
2091
    /// vocabulary so BM25 ranking has interesting structure.
2092
    fn seed_fts_table() -> Database {
1✔
2093
        let mut db = Database::new("tempdb".to_string());
1✔
2094
        process_command(
2095
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT);",
2096
            &mut db,
2097
        )
2098
        .unwrap();
2099
        for body in &[
1✔
2100
            "rust embedded database",        // id=1 — both 'rust' and 'embedded'
2101
            "rust web framework",            // id=2 — 'rust' only
2102
            "go embedded systems",           // id=3 — 'embedded' only
2103
            "python web framework",          // id=4 — neither
2104
            "rust rust rust embedded power", // id=5 — heavy on 'rust'
2105
        ] {
2106
            process_command(
2107
                &format!("INSERT INTO docs (body) VALUES ('{body}');"),
2✔
2108
                &mut db,
2109
            )
2110
            .unwrap();
2111
        }
2112
        db
1✔
2113
    }
2114

2115
    #[test]
2116
    fn create_index_using_fts_succeeds_and_indexes_existing_rows() {
3✔
2117
        let mut db = seed_fts_table();
1✔
2118
        let resp =
2✔
2119
            process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2120
        assert!(resp.to_lowercase().contains("create index"), "got {resp}");
2✔
2121
        let table = db.get_table("docs".to_string()).unwrap();
1✔
2122
        assert_eq!(table.fts_indexes.len(), 1);
1✔
2123
        assert_eq!(table.fts_indexes[0].name, "ix_body");
1✔
2124
        assert_eq!(table.fts_indexes[0].column_name, "body");
1✔
2125
        // All five rows should be in the in-memory PostingList.
2126
        assert_eq!(table.fts_indexes[0].index.len(), 5);
1✔
2127
    }
2128

2129
    #[test]
2130
    fn create_index_using_fts_rejects_non_text_column() {
3✔
2131
        let mut db = Database::new("tempdb".to_string());
1✔
2132
        process_command(
2133
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER);",
2134
            &mut db,
2135
        )
2136
        .unwrap();
2137
        let err = process_command("CREATE INDEX ix_n ON t USING fts (n);", &mut db).unwrap_err();
1✔
2138
        let msg = format!("{err}");
2✔
2139
        assert!(
×
2140
            msg.to_lowercase().contains("text"),
3✔
2141
            "expected error mentioning TEXT; got: {msg}"
2142
        );
2143
    }
2144

2145
    #[test]
2146
    fn fts_match_returns_expected_rows() {
3✔
2147
        let mut db = seed_fts_table();
1✔
2148
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2149
        // Rows that contain 'rust': ids 1, 2, 5.
2150
        let resp = process_command(
2151
            "SELECT id FROM docs WHERE fts_match(body, 'rust');",
2152
            &mut db,
2153
        )
2154
        .unwrap();
2155
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
2156
    }
2157

2158
    #[test]
2159
    fn fts_match_without_index_errors_clearly() {
3✔
2160
        let mut db = seed_fts_table();
1✔
2161
        // No CREATE INDEX — fts_match must surface a useful error.
2162
        let err = process_command(
2163
            "SELECT id FROM docs WHERE fts_match(body, 'rust');",
2164
            &mut db,
2165
        )
2166
        .unwrap_err();
2167
        let msg = format!("{err}");
2✔
2168
        assert!(
×
2169
            msg.contains("no FTS index"),
2✔
2170
            "expected no-index error; got: {msg}"
2171
        );
2172
    }
2173

2174
    #[test]
2175
    fn bm25_score_orders_descending_by_relevance() {
3✔
2176
        let mut db = seed_fts_table();
1✔
2177
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2178
        // ORDER BY bm25_score DESC LIMIT 1: id=5 has 'rust' three times in
2179
        // a 5-token doc — highest tf, modest length penalty → top score.
2180
        let out = process_command_with_render(
2181
            "SELECT id FROM docs WHERE fts_match(body, 'rust') \
2182
             ORDER BY bm25_score(body, 'rust') DESC LIMIT 1;",
2183
            &mut db,
2184
        )
2185
        .unwrap();
2186
        assert!(out.status.contains("1 row returned"), "got: {}", out.status);
2✔
2187
        let rendered = out.rendered.expect("SELECT should produce rendered output");
1✔
2188
        // The rendered prettytable contains the integer 5 in a cell.
2189
        assert!(
×
2190
            rendered.contains(" 5 "),
2✔
2191
            "expected id=5 to be top-ranked; rendered:\n{rendered}"
2192
        );
2193
    }
2194

2195
    #[test]
2196
    fn bm25_score_without_index_errors_clearly() {
3✔
2197
        let mut db = seed_fts_table();
1✔
2198
        let err = process_command(
2199
            "SELECT id FROM docs ORDER BY bm25_score(body, 'rust') DESC LIMIT 1;",
2200
            &mut db,
2201
        )
2202
        .unwrap_err();
2203
        let msg = format!("{err}");
2✔
2204
        assert!(
×
2205
            msg.contains("no FTS index"),
2✔
2206
            "expected no-index error; got: {msg}"
2207
        );
2208
    }
2209

2210
    #[test]
2211
    fn fts_post_create_inserts_are_indexed_incrementally() {
3✔
2212
        let mut db = seed_fts_table();
1✔
2213
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2214
        process_command(
2215
            "INSERT INTO docs (body) VALUES ('rust embedded analytics');",
2216
            &mut db,
2217
        )
2218
        .unwrap();
2219
        let table = db.get_table("docs".to_string()).unwrap();
1✔
2220
        // PostingList::len() reports doc count; should be 6 now.
2221
        assert_eq!(table.fts_indexes[0].index.len(), 6);
1✔
2222
        // 'analytics' appears only in the new row → query returns 1 hit.
2223
        let resp = process_command(
2224
            "SELECT id FROM docs WHERE fts_match(body, 'analytics');",
2225
            &mut db,
2226
        )
2227
        .unwrap();
2228
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2229
    }
2230

2231
    #[test]
2232
    fn delete_on_fts_indexed_table_marks_dirty() {
3✔
2233
        let mut db = seed_fts_table();
1✔
2234
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2235
        let resp = process_command("DELETE FROM docs WHERE id = 1;", &mut db).unwrap();
1✔
2236
        assert!(resp.contains("1 row"), "got: {resp}");
2✔
2237
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
2238
        let entry = docs
3✔
2239
            .fts_indexes
2240
            .iter()
2241
            .find(|e| e.name == "ix_body")
3✔
2242
            .unwrap();
2243
        assert!(
×
2244
            entry.needs_rebuild,
1✔
2245
            "DELETE should have flagged the FTS index dirty"
2246
        );
2247
    }
2248

2249
    #[test]
2250
    fn update_on_fts_indexed_text_col_marks_dirty() {
3✔
2251
        let mut db = seed_fts_table();
1✔
2252
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2253
        let resp = process_command(
2254
            "UPDATE docs SET body = 'java spring framework' WHERE id = 1;",
2255
            &mut db,
2256
        )
2257
        .unwrap();
2258
        assert!(resp.contains("1 row"), "got: {resp}");
2✔
2259
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
2260
        let entry = docs
3✔
2261
            .fts_indexes
2262
            .iter()
2263
            .find(|e| e.name == "ix_body")
3✔
2264
            .unwrap();
2265
        assert!(
×
2266
            entry.needs_rebuild,
1✔
2267
            "UPDATE on the indexed TEXT column should have flagged dirty"
2268
        );
2269
    }
2270

2271
    #[test]
2272
    fn fts_index_name_collides_with_btree_and_hnsw_namespaces() {
3✔
2273
        let mut db = seed_fts_table();
1✔
2274
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2275
        let err = process_command("CREATE INDEX ix_body ON docs (body);", &mut db).unwrap_err();
1✔
2276
        let msg = format!("{err}");
2✔
2277
        assert!(
×
2278
            msg.to_lowercase().contains("already exists"),
3✔
2279
            "expected duplicate-index error; got: {msg}"
2280
        );
2281
    }
2282

2283
    #[test]
2284
    fn fts_index_rejects_unique() {
3✔
2285
        let mut db = seed_fts_table();
1✔
2286
        let err = process_command(
2287
            "CREATE UNIQUE INDEX ix_body ON docs USING fts (body);",
2288
            &mut db,
2289
        )
2290
        .unwrap_err();
2291
        let msg = format!("{err}");
2✔
2292
        assert!(
×
2293
            msg.to_lowercase().contains("unique"),
3✔
2294
            "expected UNIQUE-rejection error; got: {msg}"
2295
        );
2296
    }
2297

2298
    #[test]
2299
    fn try_fts_probe_falls_through_on_ascending() {
3✔
2300
        // BM25 is "higher = better"; ASC is rejected so the slow path
2301
        // applies. We verify by running the query and checking the
2302
        // result is still correct (the slow path goes through scalar
2303
        // bm25_score on every row).
2304
        let mut db = seed_fts_table();
1✔
2305
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2306
        // Same query as bm25_score_orders_descending but ASC → should
2307
        // still succeed (slow path), and id=5 should now be LAST.
2308
        let resp = process_command(
2309
            "SELECT id FROM docs WHERE fts_match(body, 'rust') \
2310
             ORDER BY bm25_score(body, 'rust') ASC LIMIT 3;",
2311
            &mut db,
2312
        )
2313
        .unwrap();
2314
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
2315
    }
2316

2317
    // -----------------------------------------------------------------
2318
    // Phase 7b — vector distance functions through process_command
2319
    // -----------------------------------------------------------------
2320

2321
    /// Builds a 3-row docs table with 2-dim vectors aligned along the
2322
    /// axes so the expected distances are easy to reason about:
2323
    ///   id=1: [1, 0]
2324
    ///   id=2: [0, 1]
2325
    ///   id=3: [1, 1]
2326
    fn seed_vector_docs() -> Database {
1✔
2327
        let mut db = Database::new("tempdb".to_string());
1✔
2328
        process_command(
2329
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
2330
            &mut db,
2331
        )
2332
        .expect("create");
2333
        process_command("INSERT INTO docs (e) VALUES ([1.0, 0.0]);", &mut db).expect("insert 1");
1✔
2334
        process_command("INSERT INTO docs (e) VALUES ([0.0, 1.0]);", &mut db).expect("insert 2");
1✔
2335
        process_command("INSERT INTO docs (e) VALUES ([1.0, 1.0]);", &mut db).expect("insert 3");
1✔
2336
        db
1✔
2337
    }
2338

2339
    #[test]
2340
    fn vec_distance_l2_in_where_filters_correctly() {
3✔
2341
        // Distance from [1,0]:
2342
        //   id=1 [1,0]: 0
2343
        //   id=2 [0,1]: √2 ≈ 1.414
2344
        //   id=3 [1,1]: 1
2345
        // WHERE distance < 1.1 should match id=1 and id=3 (2 rows).
2346
        let mut db = seed_vector_docs();
1✔
2347
        let resp = process_command(
2348
            "SELECT * FROM docs WHERE vec_distance_l2(e, [1.0, 0.0]) < 1.1;",
2349
            &mut db,
2350
        )
2351
        .expect("select");
2352
        assert!(
×
2353
            resp.contains("2 rows returned"),
2✔
2354
            "expected 2 rows, got: {resp}"
2355
        );
2356
    }
2357

2358
    #[test]
2359
    fn vec_distance_cosine_in_where() {
3✔
2360
        // [1,0] vs [1,0]: cosine distance = 0
2361
        // [1,0] vs [0,1]: cosine distance = 1 (orthogonal)
2362
        // [1,0] vs [1,1]: cosine distance = 1 - 1/√2 ≈ 0.293
2363
        // WHERE distance < 0.5 → id=1 and id=3 (2 rows).
2364
        let mut db = seed_vector_docs();
1✔
2365
        let resp = process_command(
2366
            "SELECT * FROM docs WHERE vec_distance_cosine(e, [1.0, 0.0]) < 0.5;",
2367
            &mut db,
2368
        )
2369
        .expect("select");
2370
        assert!(
×
2371
            resp.contains("2 rows returned"),
2✔
2372
            "expected 2 rows, got: {resp}"
2373
        );
2374
    }
2375

2376
    #[test]
2377
    fn vec_distance_dot_negated() {
3✔
2378
        // [1,0]·[1,0] = 1 → -1
2379
        // [1,0]·[0,1] = 0 → 0
2380
        // [1,0]·[1,1] = 1 → -1
2381
        // WHERE -dot < 0 (i.e. dot > 0) → id=1 and id=3 (2 rows).
2382
        let mut db = seed_vector_docs();
1✔
2383
        let resp = process_command(
2384
            "SELECT * FROM docs WHERE vec_distance_dot(e, [1.0, 0.0]) < 0.0;",
2385
            &mut db,
2386
        )
2387
        .expect("select");
2388
        assert!(
×
2389
            resp.contains("2 rows returned"),
2✔
2390
            "expected 2 rows, got: {resp}"
2391
        );
2392
    }
2393

2394
    #[test]
2395
    fn knn_via_order_by_distance_limit() {
3✔
2396
        // Classic KNN shape: ORDER BY distance LIMIT k.
2397
        // Distances from [1,0]: id=1=0, id=3=1, id=2=√2.
2398
        // LIMIT 2 should return id=1 then id=3 in that order.
2399
        let mut db = seed_vector_docs();
1✔
2400
        let resp = process_command(
2401
            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [1.0, 0.0]) ASC LIMIT 2;",
2402
            &mut db,
2403
        )
2404
        .expect("select");
2405
        assert!(
×
2406
            resp.contains("2 rows returned"),
2✔
2407
            "expected 2 rows, got: {resp}"
2408
        );
2409
    }
2410

2411
    #[test]
2412
    fn distance_function_dim_mismatch_errors() {
3✔
2413
        // 2-dim column queried with a 3-dim probe → clean error.
2414
        let mut db = seed_vector_docs();
1✔
2415
        let err = process_command(
2416
            "SELECT * FROM docs WHERE vec_distance_l2(e, [1.0, 0.0, 0.0]) < 1.0;",
2417
            &mut db,
2418
        )
2419
        .unwrap_err();
2420
        let msg = format!("{err}");
2✔
2421
        assert!(
×
2422
            msg.to_lowercase().contains("dimension")
2✔
2423
                && msg.contains("lhs=2")
1✔
2424
                && msg.contains("rhs=3"),
1✔
2425
            "expected dim mismatch error, got: {msg}"
2426
        );
2427
    }
2428

2429
    #[test]
2430
    fn unknown_function_errors_with_name() {
3✔
2431
        // Use the function in WHERE, not projection — the projection
2432
        // parser still requires bare column references; function calls
2433
        // there are a future enhancement (with `AS alias` support).
2434
        let mut db = seed_vector_docs();
1✔
2435
        let err = process_command(
2436
            "SELECT * FROM docs WHERE vec_does_not_exist(e, [1.0, 0.0]) < 1.0;",
2437
            &mut db,
2438
        )
2439
        .unwrap_err();
2440
        let msg = format!("{err}");
2✔
2441
        assert!(
×
2442
            msg.contains("vec_does_not_exist"),
2✔
2443
            "expected error mentioning function name, got: {msg}"
2444
        );
2445
    }
2446

2447
    // -----------------------------------------------------------------
2448
    // Phase 7e — JSON column type + path-extraction functions
2449
    // -----------------------------------------------------------------
2450

2451
    fn seed_json_table() -> Database {
1✔
2452
        let mut db = Database::new("tempdb".to_string());
1✔
2453
        process_command(
2454
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, payload JSON);",
2455
            &mut db,
2456
        )
2457
        .expect("create json table");
2458
        db
1✔
2459
    }
2460

2461
    #[test]
2462
    fn json_column_round_trip_primitive_values() {
3✔
2463
        let mut db = seed_json_table();
1✔
2464
        process_command(
2465
            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice", "age": 30}');"#,
2466
            &mut db,
2467
        )
2468
        .expect("insert json");
2469
        let docs = db.get_table("docs".to_string()).unwrap();
1✔
2470
        let rowids = docs.rowids();
1✔
2471
        assert_eq!(rowids.len(), 1);
2✔
2472
        // Stored verbatim as Text underneath.
2473
        match docs.get_value("payload", rowids[0]) {
1✔
2474
            Some(Value::Text(s)) => {
1✔
2475
                assert!(s.contains("alice"), "expected JSON text to round-trip: {s}");
2✔
2476
            }
2477
            other => panic!("expected Value::Text holding JSON, got {other:?}"),
×
2478
        }
2479
    }
2480

2481
    #[test]
2482
    fn json_insert_rejects_invalid_json() {
3✔
2483
        let mut db = seed_json_table();
1✔
2484
        let err = process_command(
2485
            "INSERT INTO docs (payload) VALUES ('not-valid-json{');",
2486
            &mut db,
2487
        )
2488
        .unwrap_err();
2489
        let msg = format!("{err}").to_lowercase();
2✔
2490
        assert!(
×
2491
            msg.contains("json") && msg.contains("payload"),
2✔
2492
            "expected JSON validation error mentioning column, got: {msg}"
2493
        );
2494
    }
2495

2496
    #[test]
2497
    fn json_extract_object_field() {
3✔
2498
        let mut db = seed_json_table();
1✔
2499
        process_command(
2500
            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice", "age": 30}');"#,
2501
            &mut db,
2502
        )
2503
        .unwrap();
2504
        // We don't have function calls in projection (yet), so test
2505
        // the function via WHERE.
2506
        let resp = process_command(
2507
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.name') = 'alice';"#,
2508
            &mut db,
2509
        )
2510
        .expect("select via json_extract");
2511
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2512

2513
        let resp = process_command(
2514
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.age') = 30;"#,
2515
            &mut db,
2516
        )
2517
        .expect("select via numeric json_extract");
2518
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2519
    }
2520

2521
    #[test]
2522
    fn json_extract_array_index_and_nested() {
3✔
2523
        let mut db = seed_json_table();
1✔
2524
        process_command(
2525
            r#"INSERT INTO docs (payload) VALUES ('{"tags": ["rust", "sql", "vectors"], "meta": {"author": "joao"}}');"#,
2526
            &mut db,
2527
        )
2528
        .unwrap();
2529
        let resp = process_command(
2530
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.tags[0]') = 'rust';"#,
2531
            &mut db,
2532
        )
2533
        .expect("select via array index");
2534
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2535

2536
        let resp = process_command(
2537
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.meta.author') = 'joao';"#,
2538
            &mut db,
2539
        )
2540
        .expect("select via nested object");
2541
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2542
    }
2543

2544
    #[test]
2545
    fn json_extract_missing_path_returns_null() {
3✔
2546
        let mut db = seed_json_table();
1✔
2547
        process_command(
2548
            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice"}');"#,
2549
            &mut db,
2550
        )
2551
        .unwrap();
2552
        // Missing key under WHERE returns NULL → predicate is false →
2553
        // 0 rows returned. (Standard SQL three-valued logic.)
2554
        let resp = process_command(
2555
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.missing') = 'something';"#,
2556
            &mut db,
2557
        )
2558
        .expect("select with missing path");
2559
        assert!(resp.contains("0 rows returned"), "got: {resp}");
2✔
2560
    }
2561

2562
    #[test]
2563
    fn json_extract_malformed_path_errors() {
3✔
2564
        let mut db = seed_json_table();
1✔
2565
        process_command(
2566
            r#"INSERT INTO docs (payload) VALUES ('{"a": 1}');"#,
2567
            &mut db,
2568
        )
2569
        .unwrap();
2570
        // Path doesn't start with '$' — syntax error.
2571
        let err = process_command(
2572
            r#"SELECT id FROM docs WHERE json_extract(payload, 'a.b') = 1;"#,
2573
            &mut db,
2574
        )
2575
        .unwrap_err();
2576
        assert!(format!("{err}").contains("'$'"));
2✔
2577
    }
2578

2579
    #[test]
2580
    fn json_array_length_on_array() {
3✔
2581
        // Note: json_array_length used in WHERE clause where it can be
2582
        // compared; that exercises the function dispatch end-to-end.
2583
        let mut db = seed_json_table();
1✔
2584
        process_command(
2585
            r#"INSERT INTO docs (payload) VALUES ('{"tags": ["a", "b", "c"]}');"#,
2586
            &mut db,
2587
        )
2588
        .unwrap();
2589
        let resp = process_command(
2590
            r#"SELECT id FROM docs WHERE json_array_length(payload, '$.tags') = 3;"#,
2591
            &mut db,
2592
        )
2593
        .expect("select via array_length");
2594
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2595
    }
2596

2597
    #[test]
2598
    fn json_array_length_on_non_array_errors() {
3✔
2599
        let mut db = seed_json_table();
1✔
2600
        process_command(
2601
            r#"INSERT INTO docs (payload) VALUES ('{"tags": "not-an-array"}');"#,
2602
            &mut db,
2603
        )
2604
        .unwrap();
2605
        let err = process_command(
2606
            r#"SELECT id FROM docs WHERE json_array_length(payload, '$.tags') = 1;"#,
2607
            &mut db,
2608
        )
2609
        .unwrap_err();
2610
        let msg = format!("{err}").to_lowercase();
2✔
2611
        assert!(
×
2612
            msg.contains("non-array"),
1✔
2613
            "expected non-array error, got: {msg}"
2614
        );
2615
    }
2616

2617
    #[test]
2618
    fn json_type_recognizes_each_kind() {
3✔
2619
        let mut db = seed_json_table();
1✔
2620
        process_command(
2621
            r#"INSERT INTO docs (payload) VALUES ('{"o": {}, "a": [], "s": "x", "i": 1, "f": 1.5, "t": true, "n": null}');"#,
2622
            &mut db,
2623
        )
2624
        .unwrap();
2625
        let cases = &[
1✔
2626
            ("$.o", "object"),
2627
            ("$.a", "array"),
2628
            ("$.s", "text"),
2629
            ("$.i", "integer"),
2630
            ("$.f", "real"),
2631
            ("$.t", "true"),
2632
            ("$.n", "null"),
2633
        ];
2634
        for (path, expected_type) in cases {
1✔
2635
            let sql = format!(
2✔
2636
                "SELECT id FROM docs WHERE json_type(payload, '{path}') = '{expected_type}';"
2637
            );
2638
            let resp =
2✔
2639
                process_command(&sql, &mut db).unwrap_or_else(|e| panic!("path {path}: {e}"));
2640
            assert!(
×
2641
                resp.contains("1 row returned"),
2✔
2642
                "path {path} expected type {expected_type}; got response: {resp}"
2643
            );
2644
        }
2645
    }
2646

2647
    #[test]
2648
    fn update_on_json_column_revalidates() {
3✔
2649
        let mut db = seed_json_table();
1✔
2650
        process_command(
2651
            r#"INSERT INTO docs (payload) VALUES ('{"a": 1}');"#,
2652
            &mut db,
2653
        )
2654
        .unwrap();
2655
        // Valid JSON update succeeds.
2656
        process_command(
2657
            r#"UPDATE docs SET payload = '{"a": 2, "b": 3}' WHERE id = 1;"#,
2658
            &mut db,
2659
        )
2660
        .expect("valid JSON UPDATE");
2661
        // Invalid JSON in UPDATE is rejected with the same shape of
2662
        // error as INSERT.
2663
        let err = process_command(
2664
            r#"UPDATE docs SET payload = 'not-json{' WHERE id = 1;"#,
2665
            &mut db,
2666
        )
2667
        .unwrap_err();
2668
        let msg = format!("{err}").to_lowercase();
2✔
2669
        assert!(
×
2670
            msg.contains("json") && msg.contains("payload"),
2✔
2671
            "got: {msg}"
2672
        );
2673
    }
2674

2675
    // -------------------------------------------------------------------
2676
    // DEFAULT clause on CREATE TABLE columns
2677
    // -------------------------------------------------------------------
2678

2679
    #[test]
2680
    fn default_literal_int_applies_when_column_omitted() {
3✔
2681
        let mut db = Database::new("t".to_string());
1✔
2682
        process_command(
2683
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER DEFAULT 42);",
2684
            &mut db,
2685
        )
2686
        .unwrap();
2687
        process_command("INSERT INTO t (id) VALUES (1);", &mut db).unwrap();
1✔
2688

2689
        let table = db.get_table("t".to_string()).unwrap();
1✔
2690
        assert_eq!(table.get_value("n", 1), Some(Value::Integer(42)));
1✔
2691
    }
2692

2693
    #[test]
2694
    fn default_literal_text_applies_when_column_omitted() {
3✔
2695
        let mut db = Database::new("t".to_string());
1✔
2696
        process_command(
2697
            "CREATE TABLE users (id INTEGER PRIMARY KEY, status TEXT DEFAULT 'active');",
2698
            &mut db,
2699
        )
2700
        .unwrap();
2701
        process_command("INSERT INTO users (id) VALUES (1);", &mut db).unwrap();
1✔
2702

2703
        let table = db.get_table("users".to_string()).unwrap();
1✔
2704
        assert_eq!(
1✔
2705
            table.get_value("status", 1),
1✔
2706
            Some(Value::Text("active".to_string()))
2✔
2707
        );
2708
    }
2709

2710
    #[test]
2711
    fn default_literal_real_negative_applies_when_column_omitted() {
3✔
2712
        // `DEFAULT -1.5` arrives as a UnaryOp(Minus, Number) — exercise that path.
2713
        let mut db = Database::new("t".to_string());
1✔
2714
        process_command(
2715
            "CREATE TABLE t (id INTEGER PRIMARY KEY, score REAL DEFAULT -1.5);",
2716
            &mut db,
2717
        )
2718
        .unwrap();
2719
        process_command("INSERT INTO t (id) VALUES (1);", &mut db).unwrap();
1✔
2720

2721
        let table = db.get_table("t".to_string()).unwrap();
1✔
2722
        assert_eq!(table.get_value("score", 1), Some(Value::Real(-1.5)));
1✔
2723
    }
2724

2725
    #[test]
2726
    fn default_with_type_mismatch_errors_at_create_time() {
3✔
2727
        let mut db = Database::new("t".to_string());
1✔
2728
        let result = process_command(
2729
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER DEFAULT 'oops');",
2730
            &mut db,
2731
        );
2732
        let err = result.expect_err("text default on INTEGER column should be rejected");
1✔
2733
        let msg = format!("{err}").to_lowercase();
2✔
2734
        assert!(msg.contains("default"), "got: {msg}");
1✔
2735
    }
2736

2737
    #[test]
2738
    fn default_for_json_column_must_be_valid_json() {
3✔
2739
        // ADD COLUMN ... JSON DEFAULT 'not-json' would otherwise backfill
2740
        // every existing row with invalid JSON (insert_row's validation
2741
        // is bypassed for the backfill path).
2742
        let mut db = Database::new("t".to_string());
1✔
2743
        let err = process_command(
2744
            "CREATE TABLE t (id INTEGER PRIMARY KEY, doc JSON DEFAULT 'not-json{');",
2745
            &mut db,
2746
        )
2747
        .unwrap_err();
2748
        assert!(
×
2749
            format!("{err}").to_lowercase().contains("json"),
4✔
2750
            "got: {err}"
2751
        );
2752

2753
        // Valid JSON DEFAULT works.
2754
        process_command(
2755
            "CREATE TABLE t2 (id INTEGER PRIMARY KEY, doc JSON DEFAULT '{\"k\":1}');",
2756
            &mut db,
2757
        )
2758
        .expect("valid JSON DEFAULT should be accepted");
2759
    }
2760

2761
    #[test]
2762
    fn default_with_non_literal_expression_errors_at_create_time() {
3✔
2763
        let mut db = Database::new("t".to_string());
1✔
2764
        // Function-call DEFAULT (e.g. CURRENT_TIMESTAMP) → rejected; we only
2765
        // accept literal expressions for now.
2766
        let result = process_command(
2767
            "CREATE TABLE t (id INTEGER PRIMARY KEY, ts TEXT DEFAULT CURRENT_TIMESTAMP);",
2768
            &mut db,
2769
        );
2770
        let err = result.expect_err("non-literal DEFAULT should be rejected");
1✔
2771
        let msg = format!("{err}").to_lowercase();
2✔
2772
        assert!(
×
2773
            msg.contains("default") && msg.contains("literal"),
2✔
2774
            "got: {msg}"
2775
        );
2776
    }
2777

2778
    #[test]
2779
    fn default_null_is_accepted_at_create_time() {
3✔
2780
        // `DEFAULT NULL` is a no-op equivalent to no DEFAULT clause; the
2781
        // important thing is that CREATE TABLE accepts it without error
2782
        // (some DDL exporters emit `DEFAULT NULL` redundantly).
2783
        let mut db = Database::new("t".to_string());
1✔
2784
        process_command(
2785
            "CREATE TABLE t (id INTEGER PRIMARY KEY, note TEXT DEFAULT NULL);",
2786
            &mut db,
2787
        )
2788
        .expect("CREATE TABLE with DEFAULT NULL should be accepted");
2789
        let table = db.get_table("t".to_string()).unwrap();
1✔
2790
        let note = table
3✔
2791
            .columns
2792
            .iter()
2793
            .find(|c| c.column_name == "note")
3✔
2794
            .unwrap();
2795
        assert_eq!(note.default, Some(Value::Null));
1✔
2796
    }
2797

2798
    // -------------------------------------------------------------------
2799
    // DROP TABLE / DROP INDEX
2800
    // -------------------------------------------------------------------
2801

2802
    #[test]
2803
    fn drop_table_basic() {
3✔
2804
        let mut db = seed_users_table();
1✔
2805
        let response = process_command("DROP TABLE users;", &mut db).expect("drop table");
2✔
2806
        assert!(response.contains("1 table dropped"));
2✔
2807
        assert!(!db.contains_table("users".to_string()));
1✔
2808
    }
2809

2810
    #[test]
2811
    fn drop_table_if_exists_noop_on_missing() {
3✔
2812
        let mut db = Database::new("t".to_string());
1✔
2813
        let response =
2✔
2814
            process_command("DROP TABLE IF EXISTS missing;", &mut db).expect("drop if exists");
2815
        assert!(response.contains("0 tables dropped"));
2✔
2816
    }
2817

2818
    #[test]
2819
    fn drop_table_missing_errors_without_if_exists() {
3✔
2820
        let mut db = Database::new("t".to_string());
1✔
2821
        let err = process_command("DROP TABLE missing;", &mut db).unwrap_err();
2✔
2822
        assert!(format!("{err}").contains("does not exist"), "got: {err}");
2✔
2823
    }
2824

2825
    #[test]
2826
    fn drop_table_reserved_name_errors() {
3✔
2827
        let mut db = Database::new("t".to_string());
1✔
2828
        let err = process_command("DROP TABLE sqlrite_master;", &mut db).unwrap_err();
2✔
2829
        assert!(format!("{err}").contains("reserved"), "got: {err}");
2✔
2830
    }
2831

2832
    #[test]
2833
    fn drop_table_multi_target_rejected() {
3✔
2834
        let mut db = seed_users_table();
1✔
2835
        process_command("CREATE TABLE other (id INTEGER PRIMARY KEY);", &mut db).unwrap();
2✔
2836
        // sqlparser accepts `DROP TABLE a, b` as one statement; we reject
2837
        // to keep error semantics simple (no partial-failure rollback).
2838
        let err = process_command("DROP TABLE users, other;", &mut db).unwrap_err();
1✔
2839
        assert!(format!("{err}").contains("single table"), "got: {err}");
2✔
2840
    }
2841

2842
    #[test]
2843
    fn drop_table_cascades_indexes_in_memory() {
3✔
2844
        let mut db = seed_users_table();
1✔
2845
        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2✔
2846
        // PK auto-index + UNIQUE-on-name auto-index + the explicit one.
2847
        let users = db.get_table("users".to_string()).unwrap();
1✔
2848
        assert!(
×
2849
            users
3✔
2850
                .secondary_indexes
2851
                .iter()
1✔
2852
                .any(|i| i.name == "users_age_idx")
3✔
2853
        );
2854

2855
        process_command("DROP TABLE users;", &mut db).unwrap();
1✔
2856

2857
        // After DROP TABLE, no other table should claim the dropped indexes.
2858
        for table in db.tables.values() {
1✔
2859
            assert!(
×
2860
                !table
×
2861
                    .secondary_indexes
2862
                    .iter()
×
2863
                    .any(|i| i.name.contains("users")),
×
2864
                "dropped table's indexes should not survive on any other table"
2865
            );
2866
        }
2867
    }
2868

2869
    #[test]
2870
    fn drop_index_explicit_basic() {
3✔
2871
        let mut db = seed_users_table();
1✔
2872
        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2✔
2873
        let response = process_command("DROP INDEX users_age_idx;", &mut db).expect("drop index");
1✔
2874
        assert!(response.contains("1 index dropped"));
2✔
2875

2876
        let users = db.get_table("users".to_string()).unwrap();
1✔
2877
        assert!(users.index_by_name("users_age_idx").is_none());
1✔
2878
    }
2879

2880
    #[test]
2881
    fn drop_index_refuses_auto_index() {
3✔
2882
        let mut db = seed_users_table();
1✔
2883
        // `users` was created with `id INTEGER PRIMARY KEY` → auto-index
2884
        // named `sqlrite_autoindex_users_id`.
2885
        let err = process_command("DROP INDEX sqlrite_autoindex_users_id;", &mut db).unwrap_err();
2✔
2886
        assert!(format!("{err}").contains("auto-created"), "got: {err}");
2✔
2887
    }
2888

2889
    #[test]
2890
    fn drop_index_if_exists_noop_on_missing() {
3✔
2891
        let mut db = Database::new("t".to_string());
1✔
2892
        let response =
2✔
2893
            process_command("DROP INDEX IF EXISTS nope;", &mut db).expect("drop index if exists");
2894
        assert!(response.contains("0 indexes dropped"));
2✔
2895
    }
2896

2897
    #[test]
2898
    fn drop_index_missing_errors_without_if_exists() {
3✔
2899
        let mut db = Database::new("t".to_string());
1✔
2900
        let err = process_command("DROP INDEX nope;", &mut db).unwrap_err();
2✔
2901
        assert!(format!("{err}").contains("does not exist"), "got: {err}");
2✔
2902
    }
2903

2904
    #[test]
2905
    fn drop_statements_rejected_on_readonly_db() {
3✔
2906
        use crate::sql::pager::{open_database_read_only, save_database};
2907

2908
        let mut seed = Database::new("t".to_string());
1✔
2909
        process_command(
2910
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
2911
            &mut seed,
2912
        )
2913
        .unwrap();
2914
        process_command("CREATE INDEX notes_body ON notes (body);", &mut seed).unwrap();
1✔
2915
        let path = {
2916
            let mut p = std::env::temp_dir();
1✔
2917
            let pid = std::process::id();
2✔
2918
            let nanos = std::time::SystemTime::now()
2✔
2919
                .duration_since(std::time::UNIX_EPOCH)
1✔
2920
                .map(|d| d.as_nanos())
3✔
2921
                .unwrap_or(0);
2922
            p.push(format!("sqlrite-drop-ro-{pid}-{nanos}.sqlrite"));
1✔
2923
            p
1✔
2924
        };
2925
        save_database(&mut seed, &path).unwrap();
2✔
2926
        drop(seed);
1✔
2927

2928
        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1✔
2929
        for stmt in ["DROP TABLE notes;", "DROP INDEX notes_body;"] {
3✔
2930
            let err = process_command(stmt, &mut ro).unwrap_err();
2✔
2931
            assert!(
×
2932
                format!("{err}").contains("read-only"),
3✔
2933
                "{stmt:?} should surface read-only error, got: {err}"
2934
            );
2935
        }
2936

2937
        let _ = std::fs::remove_file(&path);
1✔
2938
        let mut wal = path.as_os_str().to_owned();
1✔
2939
        wal.push("-wal");
1✔
2940
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
2941
    }
2942

2943
    // -------------------------------------------------------------------
2944
    // ALTER TABLE — RENAME TO / RENAME COLUMN / ADD COLUMN / DROP COLUMN
2945
    // -------------------------------------------------------------------
2946

2947
    #[test]
2948
    fn alter_rename_table_basic() {
3✔
2949
        let mut db = seed_users_table();
1✔
2950
        process_command("ALTER TABLE users RENAME TO members;", &mut db).expect("rename table");
2✔
2951
        assert!(!db.contains_table("users".to_string()));
1✔
2952
        assert!(db.contains_table("members".to_string()));
2✔
2953
        // Data still queryable under the new name.
2954
        let response = process_command("SELECT * FROM members;", &mut db).expect("select");
1✔
2955
        assert!(response.contains("3 rows returned"));
2✔
2956
    }
2957

2958
    #[test]
2959
    fn alter_rename_table_renames_auto_indexes() {
3✔
2960
        // Use a fresh table with both PK and a UNIQUE column so we
2961
        // exercise both auto-index renames in one shot.
2962
        let mut db = Database::new("t".to_string());
1✔
2963
        process_command(
2964
            "CREATE TABLE accounts (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
2965
            &mut db,
2966
        )
2967
        .unwrap();
2968
        {
2969
            let accounts = db.get_table("accounts".to_string()).unwrap();
1✔
2970
            assert!(
×
2971
                accounts
2✔
2972
                    .index_by_name("sqlrite_autoindex_accounts_id")
1✔
2973
                    .is_some()
1✔
2974
            );
2975
            assert!(
×
2976
                accounts
2✔
2977
                    .index_by_name("sqlrite_autoindex_accounts_email")
1✔
2978
                    .is_some()
1✔
2979
            );
2980
        }
2981
        process_command("ALTER TABLE accounts RENAME TO members;", &mut db).expect("rename");
1✔
2982
        let members = db.get_table("members".to_string()).unwrap();
1✔
2983
        assert!(
×
2984
            members
2✔
2985
                .index_by_name("sqlrite_autoindex_members_id")
1✔
2986
                .is_some(),
1✔
2987
            "PK auto-index should be renamed to match new table"
2988
        );
2989
        assert!(
×
2990
            members
2✔
2991
                .index_by_name("sqlrite_autoindex_members_email")
1✔
2992
                .is_some()
1✔
2993
        );
2994
        // The old-named auto-indexes should be gone.
2995
        assert!(
×
2996
            members
2✔
2997
                .index_by_name("sqlrite_autoindex_accounts_id")
1✔
2998
                .is_none()
1✔
2999
        );
3000
        // table_name field on each index should also reflect the rename.
3001
        for idx in &members.secondary_indexes {
1✔
3002
            assert_eq!(idx.table_name, "members");
2✔
3003
        }
3004
    }
3005

3006
    #[test]
3007
    fn alter_rename_table_to_existing_errors() {
3✔
3008
        let mut db = seed_users_table();
1✔
3009
        process_command("CREATE TABLE other (id INTEGER PRIMARY KEY);", &mut db).unwrap();
2✔
3010
        let err = process_command("ALTER TABLE users RENAME TO other;", &mut db).unwrap_err();
1✔
3011
        assert!(format!("{err}").contains("already exists"), "got: {err}");
2✔
3012
        // Both tables still present.
3013
        assert!(db.contains_table("users".to_string()));
1✔
3014
        assert!(db.contains_table("other".to_string()));
1✔
3015
    }
3016

3017
    #[test]
3018
    fn alter_rename_table_to_reserved_name_errors() {
3✔
3019
        let mut db = seed_users_table();
1✔
3020
        let err =
2✔
3021
            process_command("ALTER TABLE users RENAME TO sqlrite_master;", &mut db).unwrap_err();
3022
        assert!(format!("{err}").contains("reserved"), "got: {err}");
2✔
3023
    }
3024

3025
    #[test]
3026
    fn alter_rename_column_basic() {
3✔
3027
        let mut db = seed_users_table();
1✔
3028
        process_command(
3029
            "ALTER TABLE users RENAME COLUMN name TO full_name;",
3030
            &mut db,
3031
        )
3032
        .expect("rename column");
3033

3034
        let users = db.get_table("users".to_string()).unwrap();
1✔
3035
        assert!(users.contains_column("full_name".to_string()));
1✔
3036
        assert!(!users.contains_column("name".to_string()));
1✔
3037

3038
        // Existing data is queryable under the new column name and value
3039
        // is preserved at the same rowid.
3040
        let bob_rowid = users
1✔
3041
            .rowids()
3042
            .into_iter()
3043
            .find(|r| users.get_value("full_name", *r) == Some(Value::Text("bob".to_string())))
3✔
3044
            .expect("bob row should be findable under the new column name");
3045
        assert_eq!(
1✔
3046
            users.get_value("full_name", bob_rowid),
1✔
3047
            Some(Value::Text("bob".to_string()))
2✔
3048
        );
3049
    }
3050

3051
    #[test]
3052
    fn alter_rename_column_collision_errors() {
3✔
3053
        let mut db = seed_users_table();
1✔
3054
        let err =
2✔
3055
            process_command("ALTER TABLE users RENAME COLUMN name TO age;", &mut db).unwrap_err();
3056
        assert!(format!("{err}").contains("already exists"), "got: {err}");
2✔
3057
    }
3058

3059
    #[test]
3060
    fn alter_rename_column_updates_indexes() {
3✔
3061
        // `accounts.email` is UNIQUE → has a renameable auto-index.
3062
        let mut db = Database::new("t".to_string());
1✔
3063
        process_command(
3064
            "CREATE TABLE accounts (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
3065
            &mut db,
3066
        )
3067
        .unwrap();
3068
        process_command(
3069
            "ALTER TABLE accounts RENAME COLUMN email TO contact;",
3070
            &mut db,
3071
        )
3072
        .unwrap();
3073
        let accounts = db.get_table("accounts".to_string()).unwrap();
1✔
3074
        assert!(
×
3075
            accounts
2✔
3076
                .index_by_name("sqlrite_autoindex_accounts_contact")
1✔
3077
                .is_some()
1✔
3078
        );
3079
        assert!(
×
3080
            accounts
2✔
3081
                .index_by_name("sqlrite_autoindex_accounts_email")
1✔
3082
                .is_none()
1✔
3083
        );
3084
    }
3085

3086
    #[test]
3087
    fn alter_add_column_basic() {
3✔
3088
        let mut db = seed_users_table();
1✔
3089
        process_command("ALTER TABLE users ADD COLUMN nickname TEXT;", &mut db)
1✔
3090
            .expect("add column");
3091
        let users = db.get_table("users".to_string()).unwrap();
1✔
3092
        assert!(users.contains_column("nickname".to_string()));
1✔
3093
        // Existing rows read NULL for the new column (no default given).
3094
        let any_rowid = *users.rowids().first().expect("seed has rows");
1✔
3095
        assert_eq!(users.get_value("nickname", any_rowid), None);
1✔
3096

3097
        // A new INSERT supplying the new column works.
3098
        process_command(
3099
            "INSERT INTO users (name, age, nickname) VALUES ('dan', 22, 'd');",
3100
            &mut db,
3101
        )
3102
        .expect("insert with new col");
3103
        let users = db.get_table("users".to_string()).unwrap();
1✔
3104
        let dan_rowid = users
1✔
3105
            .rowids()
3106
            .into_iter()
3107
            .find(|r| users.get_value("name", *r) == Some(Value::Text("dan".to_string())))
3✔
3108
            .unwrap();
3109
        assert_eq!(
1✔
3110
            users.get_value("nickname", dan_rowid),
1✔
3111
            Some(Value::Text("d".to_string()))
2✔
3112
        );
3113
    }
3114

3115
    #[test]
3116
    fn alter_add_column_with_default_backfills_existing_rows() {
3✔
3117
        let mut db = seed_users_table();
1✔
3118
        process_command(
3119
            "ALTER TABLE users ADD COLUMN status TEXT DEFAULT 'active';",
3120
            &mut db,
3121
        )
3122
        .expect("add column with default");
3123
        let users = db.get_table("users".to_string()).unwrap();
1✔
3124
        for rowid in users.rowids() {
2✔
3125
            assert_eq!(
1✔
3126
                users.get_value("status", rowid),
1✔
3127
                Some(Value::Text("active".to_string())),
2✔
3128
                "rowid {rowid} should have been backfilled with the default"
3129
            );
3130
        }
3131
    }
3132

3133
    #[test]
3134
    fn alter_add_column_not_null_with_default_works_on_nonempty_table() {
3✔
3135
        let mut db = seed_users_table();
1✔
3136
        process_command(
3137
            "ALTER TABLE users ADD COLUMN score INTEGER NOT NULL DEFAULT 0;",
3138
            &mut db,
3139
        )
3140
        .expect("NOT NULL ADD with DEFAULT should succeed even with existing rows");
3141
        let users = db.get_table("users".to_string()).unwrap();
1✔
3142
        for rowid in users.rowids() {
2✔
3143
            assert_eq!(users.get_value("score", rowid), Some(Value::Integer(0)));
2✔
3144
        }
3145
    }
3146

3147
    #[test]
3148
    fn alter_add_column_not_null_without_default_errors_on_nonempty_table() {
3✔
3149
        let mut db = seed_users_table();
1✔
3150
        let err = process_command(
3151
            "ALTER TABLE users ADD COLUMN score INTEGER NOT NULL;",
3152
            &mut db,
3153
        )
3154
        .unwrap_err();
3155
        let msg = format!("{err}").to_lowercase();
2✔
3156
        assert!(
×
3157
            msg.contains("not null") && msg.contains("default"),
2✔
3158
            "got: {msg}"
3159
        );
3160
    }
3161

3162
    #[test]
3163
    fn alter_add_column_pk_rejected() {
3✔
3164
        let mut db = seed_users_table();
1✔
3165
        let err = process_command(
3166
            "ALTER TABLE users ADD COLUMN extra INTEGER PRIMARY KEY;",
3167
            &mut db,
3168
        )
3169
        .unwrap_err();
3170
        assert!(
×
3171
            format!("{err}").to_lowercase().contains("primary key"),
4✔
3172
            "got: {err}"
3173
        );
3174
    }
3175

3176
    #[test]
3177
    fn alter_add_column_unique_rejected() {
3✔
3178
        let mut db = seed_users_table();
1✔
3179
        let err = process_command(
3180
            "ALTER TABLE users ADD COLUMN extra INTEGER UNIQUE;",
3181
            &mut db,
3182
        )
3183
        .unwrap_err();
3184
        assert!(
×
3185
            format!("{err}").to_lowercase().contains("unique"),
4✔
3186
            "got: {err}"
3187
        );
3188
    }
3189

3190
    #[test]
3191
    fn alter_add_column_existing_name_errors() {
3✔
3192
        let mut db = seed_users_table();
1✔
3193
        let err =
2✔
3194
            process_command("ALTER TABLE users ADD COLUMN age INTEGER;", &mut db).unwrap_err();
3195
        assert!(format!("{err}").contains("already exists"), "got: {err}");
2✔
3196
    }
3197

3198
    // Note: `ALTER TABLE ... ADD COLUMN IF NOT EXISTS ...` is not in the
3199
    // SQLite dialect (PG/MSSQL extension); the AST `if_not_exists` flag is
3200
    // still honoured by the executor if some other dialect ever produces
3201
    // it, but there's no way to feed it via SQL in our default dialect.
3202

3203
    #[test]
3204
    fn alter_drop_column_basic() {
3✔
3205
        let mut db = seed_users_table();
1✔
3206
        process_command("ALTER TABLE users DROP COLUMN age;", &mut db).expect("drop column");
2✔
3207
        let users = db.get_table("users".to_string()).unwrap();
1✔
3208
        assert!(!users.contains_column("age".to_string()));
1✔
3209
        // Other columns and rowids still intact.
3210
        assert!(users.contains_column("name".to_string()));
2✔
3211
        assert_eq!(users.rowids().len(), 3);
1✔
3212
    }
3213

3214
    #[test]
3215
    fn alter_drop_column_drops_dependent_indexes() {
3✔
3216
        let mut db = seed_users_table();
1✔
3217
        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2✔
3218
        process_command("ALTER TABLE users DROP COLUMN age;", &mut db).unwrap();
1✔
3219
        let users = db.get_table("users".to_string()).unwrap();
1✔
3220
        assert!(users.index_by_name("users_age_idx").is_none());
1✔
3221
    }
3222

3223
    #[test]
3224
    fn alter_drop_column_pk_errors() {
3✔
3225
        let mut db = seed_users_table();
1✔
3226
        let err = process_command("ALTER TABLE users DROP COLUMN id;", &mut db).unwrap_err();
2✔
3227
        assert!(
×
3228
            format!("{err}").to_lowercase().contains("primary key"),
4✔
3229
            "got: {err}"
3230
        );
3231
    }
3232

3233
    #[test]
3234
    fn alter_drop_column_only_column_errors() {
3✔
3235
        let mut db = Database::new("t".to_string());
1✔
3236
        process_command("CREATE TABLE solo (only_col TEXT);", &mut db).unwrap();
2✔
3237
        let err = process_command("ALTER TABLE solo DROP COLUMN only_col;", &mut db).unwrap_err();
1✔
3238
        assert!(
×
3239
            format!("{err}").to_lowercase().contains("only column"),
4✔
3240
            "got: {err}"
3241
        );
3242
    }
3243

3244
    #[test]
3245
    fn alter_unknown_table_errors_without_if_exists() {
3✔
3246
        let mut db = Database::new("t".to_string());
1✔
3247
        let err = process_command("ALTER TABLE missing RENAME TO other;", &mut db).unwrap_err();
2✔
3248
        assert!(format!("{err}").contains("does not exist"), "got: {err}");
2✔
3249
    }
3250

3251
    #[test]
3252
    fn alter_unknown_table_if_exists_noop() {
3✔
3253
        let mut db = Database::new("t".to_string());
1✔
3254
        let response = process_command("ALTER TABLE IF EXISTS missing RENAME TO other;", &mut db)
1✔
3255
            .expect("IF EXISTS makes missing-table ALTER a no-op");
3256
        assert!(response.contains("no-op"));
2✔
3257
    }
3258

3259
    #[test]
3260
    fn drop_table_inside_transaction_rolls_back() {
3✔
3261
        // Exercises Database::deep_clone snapshot path with DROP TABLE.
3262
        // A wholesale tables-HashMap restore on ROLLBACK should resurrect
3263
        // the dropped table — including its data and dependent indexes.
3264
        let mut db = seed_users_table();
1✔
3265
        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2✔
3266
        process_command("BEGIN;", &mut db).unwrap();
1✔
3267
        process_command("DROP TABLE users;", &mut db).unwrap();
1✔
3268
        assert!(!db.contains_table("users".to_string()));
1✔
3269
        process_command("ROLLBACK;", &mut db).unwrap();
2✔
3270
        assert!(db.contains_table("users".to_string()));
1✔
3271
        let users = db.get_table("users".to_string()).unwrap();
1✔
3272
        assert_eq!(users.rowids().len(), 3);
1✔
3273
        assert!(users.index_by_name("users_age_idx").is_some());
1✔
3274
    }
3275

3276
    #[test]
3277
    fn alter_inside_transaction_rolls_back() {
3✔
3278
        let mut db = seed_users_table();
1✔
3279
        process_command("BEGIN;", &mut db).unwrap();
2✔
3280
        process_command(
3281
            "ALTER TABLE users ADD COLUMN status TEXT DEFAULT 'active';",
3282
            &mut db,
3283
        )
3284
        .unwrap();
3285
        // Confirm in-flight visibility.
3286
        assert!(
×
3287
            db.get_table("users".to_string())
2✔
3288
                .unwrap()
1✔
3289
                .contains_column("status".to_string())
1✔
3290
        );
3291
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
3292
        // Snapshot restore should erase the ALTER.
3293
        assert!(
×
3294
            !db.get_table("users".to_string())
2✔
3295
                .unwrap()
1✔
3296
                .contains_column("status".to_string())
1✔
3297
        );
3298
    }
3299

3300
    #[test]
3301
    fn alter_rejected_on_readonly_db() {
3✔
3302
        use crate::sql::pager::{open_database_read_only, save_database};
3303

3304
        let mut seed = Database::new("t".to_string());
1✔
3305
        process_command(
3306
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
3307
            &mut seed,
3308
        )
3309
        .unwrap();
3310
        let path = {
3311
            let mut p = std::env::temp_dir();
1✔
3312
            let pid = std::process::id();
2✔
3313
            let nanos = std::time::SystemTime::now()
2✔
3314
                .duration_since(std::time::UNIX_EPOCH)
1✔
3315
                .map(|d| d.as_nanos())
3✔
3316
                .unwrap_or(0);
3317
            p.push(format!("sqlrite-alter-ro-{pid}-{nanos}.sqlrite"));
1✔
3318
            p
1✔
3319
        };
3320
        save_database(&mut seed, &path).unwrap();
2✔
3321
        drop(seed);
1✔
3322

3323
        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1✔
3324
        for stmt in [
3✔
3325
            "ALTER TABLE notes RENAME TO n2;",
3326
            "ALTER TABLE notes RENAME COLUMN body TO b;",
3327
            "ALTER TABLE notes ADD COLUMN extra TEXT;",
3328
            "ALTER TABLE notes DROP COLUMN body;",
3329
        ] {
3330
            let err = process_command(stmt, &mut ro).unwrap_err();
2✔
3331
            assert!(
×
3332
                format!("{err}").contains("read-only"),
3✔
3333
                "{stmt:?} should surface read-only error, got: {err}"
3334
            );
3335
        }
3336

3337
        let _ = std::fs::remove_file(&path);
1✔
3338
        let mut wal = path.as_os_str().to_owned();
1✔
3339
        wal.push("-wal");
1✔
3340
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
3341
    }
3342
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc