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

joaoh82 / rust_sqlite / 25404925331

05 May 2026 10:06PM UTC coverage: 63.607% (+0.4%) from 63.201%
25404925331

push

github

web-flow
feat(sql): IS NULL / IS NOT NULL + typed Option<Value> INSERT pipeline (SQLR-7) (#95)

Two related fixes from SQLR-2 fallout:

1) `WHERE col IS NULL` / `IS NOT NULL` now work — two new arms in
   `eval_expr`. NULLs aren't stored in secondary / HNSW / FTS indexes,
   so these predicates correctly fall through to a full scan via the
   existing `select_rowids` logic.

2) INSERT no longer carries values as `Vec<Vec<String>>` with the
   string sentinel `"Null"` standing in for SQL NULL. `InsertQuery.rows`
   is now `Vec<Vec<Option<Value>>>`; the parser emits `None` for NULL
   and typed `Value::*` for everything else. `Table::insert_row` and
   `Table::validate_unique_constraint` dispatch on `Option<Value>`,
   leaving the per-column BTreeMap entry absent for NULL (reads come
   back as `Value::Null` via the existing missing-rowid path).

   Fixes:
   - `INSERT INTO t (n) VALUES (NULL)` for INTEGER / REAL / BOOLEAN /
     VECTOR columns no longer errors via `"Null".parse::<T>()`
   - `INSERT INTO t (s) VALUES (NULL)` for TEXT no longer stores the
     literal string `"Null"`
   - `Table::restore_row` accepts NULL for non-text column types so
     persisted NULLs reopen cleanly (without this, a saved DB
     containing any NULL outside TEXT failed to reload)
   - SQL UNIQUE allows multiple NULLs (standard SQL three-valued
     logic)
   - `DEFAULT NULL` collapses to "no default" — explicit NULL in
     INSERT is preserved over a column DEFAULT, matching SQLite

Tests: 8 new INSERT-NULL tests across all column types; 5 IS NULL /
IS NOT NULL executor tests (non-indexed, indexed, omitted-column,
combined-with-AND); restored the SQLR-2 `default_does_not_override_
explicit_null` test that had to be dropped because of this bug; new
file-backed `null_values_round_trip_through_disk`. The previous
`process_command_insert_missing_integer_returns_error_test` codified
the bug as a graceful error and is replaced with a positive
"omitted INTEGER stores NULL" assertion.... (continued)

185 of 268 new or added lines in 4 files covered. (69.03%)

1 existing line in 1 file now uncovered.

7795 of 12255 relevant lines covered (63.61%)

1.21 hits per line

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

92.84
/src/sql/mod.rs
1
pub mod db;
2
pub mod executor;
3
pub mod fts;
4
pub mod hnsw;
5
pub mod pager;
6
pub mod parser;
7
// pub mod tokenizer;
8

9
use parser::create::CreateQuery;
10
use parser::insert::InsertQuery;
11
use parser::select::SelectQuery;
12

13
use sqlparser::ast::{AlterTableOperation, ObjectType, Statement};
14
use sqlparser::dialect::SQLiteDialect;
15
use sqlparser::parser::{Parser, ParserError};
16

17
use crate::error::{Result, SQLRiteError};
18
use crate::sql::db::database::Database;
19
use crate::sql::db::table::Table;
20

21
#[derive(Debug, PartialEq)]
22
pub enum SQLCommand {
23
    Insert(String),
24
    Delete(String),
25
    Update(String),
26
    CreateTable(String),
27
    Select(String),
28
    Unknown(String),
29
}
30

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

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

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

81
/// Performs initial parsing of SQL Statement using sqlparser-rs.
82
///
83
/// Returns a [`CommandOutput`] carrying both the status string and (for
84
/// SELECT statements) the pre-rendered prettytable output. **Never writes
85
/// to stdout.** The REPL is responsible for printing whatever it wants
86
/// from the returned struct.
87
pub fn process_command_with_render(query: &str, db: &mut Database) -> Result<CommandOutput> {
2✔
88
    let dialect = SQLiteDialect {};
2✔
89
    let message: String;
90
    let mut rendered: Option<String> = None;
2✔
91
    let mut ast = Parser::parse_sql(&dialect, query).map_err(SQLRiteError::from)?;
4✔
92

93
    if ast.len() > 1 {
4✔
94
        return Err(SQLRiteError::SqlError(ParserError::ParserError(format!(
×
95
            "Expected a single query statement, but there are {}",
96
            ast.len()
×
97
        ))));
98
    }
99

100
    // Comment-only or whitespace-only input parses to an empty Vec<Statement>.
101
    // Return a benign status rather than panicking on `pop().unwrap()`. Callers
102
    // (REPL, Tauri app) treat this as a no-op with no disk write triggered.
103
    let Some(query) = ast.pop() else {
4✔
104
        return Ok(CommandOutput {
1✔
105
            status: "No statement to execute.".to_string(),
1✔
106
            rendered: None,
1✔
107
        });
108
    };
109

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

161
    // Statements that mutate state — trigger auto-save on success. Read-only
162
    // SELECTs skip the save entirely to avoid pointless file writes.
163
    // VACUUM is a write statement (rewrites the entire file) but it does
164
    // its own save internally, so it's also explicitly excluded from the
165
    // post-dispatch auto-save block at the bottom.
166
    let is_write_statement = matches!(
3✔
167
        &query,
2✔
168
        Statement::CreateTable(_)
169
            | Statement::CreateIndex(_)
170
            | Statement::Insert(_)
171
            | Statement::Update(_)
172
            | Statement::Delete(_)
173
            | Statement::Drop { .. }
174
            | Statement::AlterTable(_)
175
            | Statement::Vacuum(_)
176
    );
177
    let is_vacuum = matches!(&query, Statement::Vacuum(_));
2✔
178

179
    // SQLR-10: statements that release pages onto the freelist.
180
    // After the auto-save flushes them, we'll consult
181
    // `db.auto_vacuum_threshold` and possibly compact in place.
182
    // ALTER TABLE here matches only DROP COLUMN — RENAME / ADD COLUMN
183
    // don't grow the freelist, so they shouldn't pay the trigger cost.
184
    let releases_pages = match &query {
2✔
185
        Statement::Drop { object_type, .. } => {
1✔
186
            matches!(object_type, ObjectType::Table | ObjectType::Index)
1✔
187
        }
188
        Statement::AlterTable(alter) => alter
3✔
189
            .operations
190
            .iter()
191
            .any(|op| matches!(op, AlterTableOperation::DropColumn { .. })),
3✔
192
        _ => false,
2✔
193
    };
194

195
    // Early-reject mutations on a read-only database before they touch
196
    // in-memory state. Phase 4e: without this, a user running INSERT
197
    // on a `--readonly` REPL would see the row appear in the printed
198
    // table, and then the auto-save would fail — leaving the in-memory
199
    // Database visibly diverged from disk.
200
    if is_write_statement && db.is_read_only() {
4✔
201
        return Err(SQLRiteError::General(
1✔
202
            "cannot execute: database is opened read-only".to_string(),
1✔
203
        ));
204
    }
205

206
    // Initialy only implementing some basic SQL Statements
207
    match query {
2✔
208
        Statement::CreateTable(_) => {
209
            let create_query = CreateQuery::new(&query);
2✔
210
            match create_query {
2✔
211
                Ok(payload) => {
2✔
212
                    let table_name = payload.table_name.clone();
2✔
213
                    if table_name == pager::MASTER_TABLE_NAME {
4✔
214
                        return Err(SQLRiteError::General(format!(
×
215
                            "'{}' is a reserved name used by the internal schema catalog",
216
                            pager::MASTER_TABLE_NAME
217
                        )));
218
                    }
219
                    // Checking if table already exists, after parsing CREATE TABLE query
220
                    match db.contains_table(table_name.to_string()) {
4✔
221
                        true => {
222
                            return Err(SQLRiteError::Internal(
×
223
                                "Cannot create, table already exists.".to_string(),
×
224
                            ));
225
                        }
226
                        false => {
227
                            let table = Table::new(payload);
4✔
228
                            // Note: we used to call `table.print_table_schema()` here
229
                            // for REPL convenience. Removed because it wrote
230
                            // directly to stdout, which corrupted any non-REPL
231
                            // protocol channel (most painfully the MCP server's
232
                            // JSON-RPC wire). The status line below is enough for
233
                            // the REPL; users who want to inspect the schema can
234
                            // run a follow-up describe / `.tables`-style command.
235
                            db.tables.insert(table_name.to_string(), table);
4✔
236
                            message = String::from("CREATE TABLE Statement executed.");
2✔
237
                        }
238
                    }
239
                }
240
                Err(err) => return Err(err),
1✔
241
            }
242
        }
243
        Statement::Insert(_) => {
244
            let insert_query = InsertQuery::new(&query);
2✔
245
            match insert_query {
2✔
246
                Ok(payload) => {
2✔
247
                    let table_name = payload.table_name;
2✔
248
                    let columns = payload.columns;
2✔
249
                    let values = payload.rows;
2✔
250

251
                    // println!("table_name = {:?}\n cols = {:?}\n vals = {:?}", table_name, columns, values);
252
                    // Checking if Table exists in Database
253
                    match db.contains_table(table_name.to_string()) {
4✔
254
                        true => {
255
                            let db_table = db.get_table_mut(table_name.to_string()).unwrap();
4✔
256
                            // Checking if columns on INSERT query exist on Table
257
                            match columns
6✔
258
                                .iter()
2✔
259
                                .all(|column| db_table.contains_column(column.to_string()))
6✔
260
                            {
261
                                true => {
262
                                    for value in &values {
4✔
263
                                        // Checking if number of columns in query are the same as number of values
264
                                        if columns.len() != value.len() {
4✔
265
                                            return Err(SQLRiteError::Internal(format!(
×
266
                                                "{} values for {} columns",
267
                                                value.len(),
×
268
                                                columns.len()
×
269
                                            )));
270
                                        }
271
                                        db_table
2✔
272
                                            .validate_unique_constraint(&columns, value)
2✔
273
                                            .map_err(|err| {
2✔
274
                                                SQLRiteError::Internal(format!(
×
275
                                                    "Unique key constraint violation: {err}"
276
                                                ))
277
                                            })?;
278
                                        db_table.insert_row(&columns, value)?;
2✔
279
                                    }
280
                                }
281
                                false => {
282
                                    return Err(SQLRiteError::Internal(
×
283
                                        "Cannot insert, some of the columns do not exist"
284
                                            .to_string(),
×
285
                                    ));
286
                                }
287
                            }
288
                            // Note: we used to call `db_table.print_table_data()`
289
                            // here, which dumped the *entire* table to stdout
290
                            // after every INSERT. Beyond corrupting non-REPL
291
                            // stdout channels, that's actively bad UX on any
292
                            // table with more than a few rows. Removed in the
293
                            // engine-stdout-pollution cleanup.
294
                        }
295
                        false => {
296
                            return Err(SQLRiteError::Internal("Table doesn't exist".to_string()));
2✔
297
                        }
298
                    }
299
                }
300
                Err(err) => return Err(err),
×
301
            }
302

303
            message = String::from("INSERT Statement executed.")
2✔
304
        }
305
        Statement::Query(_) => {
306
            let select_query = SelectQuery::new(&query)?;
3✔
307
            let (rendered_table, rows) = executor::execute_select(select_query, db)?;
3✔
308
            // Stash the rendered prettytable in the output so the REPL
309
            // (or any terminal-style consumer) can print it above the
310
            // status line. SDK / FFI / MCP callers ignore this field.
311
            // The previous implementation `print!("{rendered}")`-ed
312
            // directly to stdout, which broke every non-REPL embedder.
313
            rendered = Some(rendered_table);
1✔
314
            message = format!(
2✔
315
                "SELECT Statement executed. {rows} row{s} returned.",
316
                s = if rows == 1 { "" } else { "s" }
1✔
317
            );
318
        }
319
        Statement::Delete(_) => {
320
            let rows = executor::execute_delete(&query, db)?;
2✔
321
            message = format!(
1✔
322
                "DELETE Statement executed. {rows} row{s} deleted.",
323
                s = if rows == 1 { "" } else { "s" }
1✔
324
            );
325
        }
326
        Statement::Update(_) => {
327
            let rows = executor::execute_update(&query, db)?;
3✔
328
            message = format!(
1✔
329
                "UPDATE Statement executed. {rows} row{s} updated.",
330
                s = if rows == 1 { "" } else { "s" }
1✔
331
            );
332
        }
333
        Statement::CreateIndex(_) => {
334
            let name = executor::execute_create_index(&query, db)?;
3✔
335
            message = format!("CREATE INDEX '{name}' executed.");
2✔
336
        }
337
        Statement::Drop {
338
            object_type,
1✔
339
            if_exists,
1✔
340
            names,
1✔
341
            ..
342
        } => match object_type {
1✔
343
            ObjectType::Table => {
344
                let count = executor::execute_drop_table(&names, if_exists, db)?;
3✔
345
                let plural = if count == 1 { "table" } else { "tables" };
1✔
346
                message = format!("DROP TABLE Statement executed. {count} {plural} dropped.");
1✔
347
            }
348
            ObjectType::Index => {
349
                let count = executor::execute_drop_index(&names, if_exists, db)?;
3✔
350
                let plural = if count == 1 { "index" } else { "indexes" };
1✔
351
                message = format!("DROP INDEX Statement executed. {count} {plural} dropped.");
1✔
352
            }
353
            other => {
×
354
                return Err(SQLRiteError::NotImplemented(format!(
×
355
                    "DROP {other:?} is not supported (only TABLE and INDEX)"
356
                )));
357
            }
358
        },
359
        Statement::AlterTable(alter) => {
1✔
360
            message = executor::execute_alter_table(alter, db)?;
3✔
361
        }
362
        Statement::Vacuum(vac) => {
1✔
363
            // SQLR-6 — only bare `VACUUM;` is supported. The crate-level
364
            // `VacuumStatement` carries Redshift-style modifiers we don't
365
            // implement; reject any non-default flag rather than silently
366
            // ignoring it.
367
            if vac.full
1✔
368
                || vac.sort_only
1✔
369
                || vac.delete_only
1✔
370
                || vac.reindex
1✔
371
                || vac.recluster
1✔
372
                || vac.boost
1✔
373
                || vac.table_name.is_some()
2✔
374
                || vac.threshold.is_some()
1✔
375
            {
376
                return Err(SQLRiteError::NotImplemented(
1✔
377
                    "VACUUM modifiers (FULL, REINDEX, table targets, etc.) are not supported; use bare VACUUM;"
378
                        .to_string(),
1✔
379
                ));
380
            }
381
            message = executor::execute_vacuum(db)?;
2✔
382
        }
383
        _ => {
384
            return Err(SQLRiteError::NotImplemented(
1✔
385
                "SQL Statement not supported yet.".to_string(),
1✔
386
            ));
387
        }
388
    };
389

390
    // Auto-save: if the database is backed by a file AND no explicit
391
    // transaction is open AND the statement changed state, flush to
392
    // disk before returning. Inside a `BEGIN … COMMIT` block the
393
    // mutations accumulate in memory (protected by the ROLLBACK
394
    // snapshot) and land on disk in one shot when COMMIT runs.
395
    //
396
    // A failed save surfaces as an error — the in-memory state already
397
    // mutated, so the caller should know disk is out of sync. The
398
    // Pager held on `db` diffs against its last-committed snapshot,
399
    // so only pages whose bytes actually changed are written.
400
    //
401
    // VACUUM is a write-shaped statement but already wrote the file
402
    // internally — skip the second save to avoid undoing the compact.
403
    if is_write_statement && !is_vacuum && db.source_path.is_some() && !db.in_transaction() {
4✔
404
        let path = db.source_path.clone().unwrap();
2✔
405
        pager::save_database(db, &path)?;
4✔
406
    }
407

408
    // SQLR-10 auto-VACUUM trigger. Runs *after* the auto-save above so
409
    // the orphaned pages from the just-executed DROP/ALTER have actually
410
    // landed on the freelist (the bottom-up rebuild populates it during
411
    // save). Skipped mid-transaction (no commit yet → no save → freelist
412
    // is stale), on in-memory DBs (nothing to compact), and when the
413
    // user has explicitly disabled the trigger via
414
    // `set_auto_vacuum_threshold(None)`. We deliberately bypass
415
    // `executor::execute_vacuum` and call `pager::vacuum_database`
416
    // directly: the executor wrapper builds a user-facing status string
417
    // and rejects in-transaction calls — both wrong for this silent
418
    // maintenance path.
419
    if releases_pages && !db.in_transaction() {
3✔
420
        if let (Some(threshold), Some(path)) = (db.auto_vacuum_threshold(), db.source_path.clone())
4✔
421
        {
422
            let should = match db.pager.as_ref() {
2✔
423
                Some(p) => pager::freelist::should_auto_vacuum(p, threshold)?,
2✔
424
                None => false,
×
425
            };
426
            if should {
1✔
427
                pager::vacuum_database(db, &path)?;
1✔
428
            }
429
        }
430
    }
431

432
    Ok(CommandOutput {
2✔
433
        status: message,
2✔
434
        rendered,
2✔
435
    })
436
}
437

438
#[cfg(test)]
439
mod tests {
440
    use super::*;
441
    use crate::sql::db::table::Value;
442

443
    /// Builds a `users(id INTEGER PK, name TEXT, age INTEGER)` table populated
444
    /// with three rows, for use in executor-level tests.
445
    fn seed_users_table() -> Database {
1✔
446
        let mut db = Database::new("tempdb".to_string());
1✔
447
        process_command(
448
            "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, age INTEGER);",
449
            &mut db,
450
        )
451
        .expect("create table");
452
        process_command(
453
            "INSERT INTO users (name, age) VALUES ('alice', 30);",
454
            &mut db,
455
        )
456
        .expect("insert alice");
457
        process_command("INSERT INTO users (name, age) VALUES ('bob', 25);", &mut db)
1✔
458
            .expect("insert bob");
459
        process_command(
460
            "INSERT INTO users (name, age) VALUES ('carol', 40);",
461
            &mut db,
462
        )
463
        .expect("insert carol");
464
        db
1✔
465
    }
466

467
    #[test]
468
    fn process_command_select_all_test() {
3✔
469
        let mut db = seed_users_table();
1✔
470
        let response = process_command("SELECT * FROM users;", &mut db).expect("select");
2✔
471
        assert!(response.contains("3 rows returned"));
2✔
472
    }
473

474
    #[test]
475
    fn process_command_select_where_test() {
3✔
476
        let mut db = seed_users_table();
1✔
477
        let response =
2✔
478
            process_command("SELECT name FROM users WHERE age > 25;", &mut db).expect("select");
479
        assert!(response.contains("2 rows returned"));
2✔
480
    }
481

482
    #[test]
483
    fn process_command_select_eq_string_test() {
3✔
484
        let mut db = seed_users_table();
1✔
485
        let response =
2✔
486
            process_command("SELECT name FROM users WHERE name = 'bob';", &mut db).expect("select");
487
        assert!(response.contains("1 row returned"));
2✔
488
    }
489

490
    #[test]
491
    fn process_command_select_limit_test() {
3✔
492
        let mut db = seed_users_table();
1✔
493
        let response = process_command("SELECT * FROM users ORDER BY age ASC LIMIT 2;", &mut db)
1✔
494
            .expect("select");
495
        assert!(response.contains("2 rows returned"));
2✔
496
    }
497

498
    #[test]
499
    fn process_command_select_unknown_table_test() {
3✔
500
        let mut db = Database::new("tempdb".to_string());
1✔
501
        let result = process_command("SELECT * FROM nope;", &mut db);
1✔
502
        assert!(result.is_err());
2✔
503
    }
504

505
    #[test]
506
    fn process_command_select_unknown_column_test() {
3✔
507
        let mut db = seed_users_table();
1✔
508
        let result = process_command("SELECT height FROM users;", &mut db);
1✔
509
        assert!(result.is_err());
2✔
510
    }
511

512
    #[test]
513
    fn process_command_insert_test() {
3✔
514
        // Creating temporary database
515
        let mut db = Database::new("tempdb".to_string());
1✔
516

517
        // Creating temporary table for testing purposes
518
        let query_statement = "CREATE TABLE users (
1✔
519
            id INTEGER PRIMARY KEY,
520
            name TEXT
521
        );";
522
        let dialect = SQLiteDialect {};
523
        let mut ast = Parser::parse_sql(&dialect, query_statement).unwrap();
2✔
524
        if ast.len() > 1 {
2✔
525
            panic!("Expected a single query statement, but there are more then 1.")
×
526
        }
527
        let query = ast.pop().unwrap();
2✔
528
        let create_query = CreateQuery::new(&query).unwrap();
2✔
529

530
        // Inserting table into database
531
        db.tables.insert(
2✔
532
            create_query.table_name.to_string(),
2✔
533
            Table::new(create_query),
1✔
534
        );
535

536
        // Inserting data into table
537
        let insert_query = String::from("INSERT INTO users (name) Values ('josh');");
1✔
538
        match process_command(&insert_query, &mut db) {
2✔
539
            Ok(response) => assert_eq!(response, "INSERT Statement executed."),
1✔
540
            Err(err) => {
×
541
                eprintln!("Error: {}", err);
×
542
                assert!(false)
×
543
            }
544
        };
545
    }
546

547
    #[test]
548
    fn process_command_insert_no_pk_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
            name TEXT
555
        );";
556
        let dialect = SQLiteDialect {};
557
        let mut ast = Parser::parse_sql(&dialect, query_statement).unwrap();
2✔
558
        if ast.len() > 1 {
2✔
559
            panic!("Expected a single query statement, but there are more then 1.")
×
560
        }
561
        let query = ast.pop().unwrap();
2✔
562
        let create_query = CreateQuery::new(&query).unwrap();
2✔
563

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

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

581
    #[test]
582
    fn process_command_delete_where_test() {
4✔
583
        let mut db = seed_users_table();
1✔
584
        let response =
2✔
585
            process_command("DELETE FROM users WHERE name = 'bob';", &mut db).expect("delete");
586
        assert!(response.contains("1 row deleted"));
2✔
587

588
        let remaining = process_command("SELECT * FROM users;", &mut db).expect("select");
1✔
589
        assert!(remaining.contains("2 rows returned"));
2✔
590
    }
591

592
    #[test]
593
    fn process_command_delete_all_test() {
3✔
594
        let mut db = seed_users_table();
1✔
595
        let response = process_command("DELETE FROM users;", &mut db).expect("delete");
2✔
596
        assert!(response.contains("3 rows deleted"));
2✔
597
    }
598

599
    #[test]
600
    fn process_command_update_where_test() {
3✔
601
        use crate::sql::db::table::Value;
602

603
        let mut db = seed_users_table();
1✔
604
        let response = process_command("UPDATE users SET age = 99 WHERE name = 'bob';", &mut db)
1✔
605
            .expect("update");
606
        assert!(response.contains("1 row updated"));
2✔
607

608
        // Confirm the cell was actually rewritten.
609
        let users = db.get_table("users".to_string()).unwrap();
1✔
610
        let bob_rowid = users
1✔
611
            .rowids()
612
            .into_iter()
613
            .find(|r| users.get_value("name", *r) == Some(Value::Text("bob".to_string())))
3✔
614
            .expect("bob row must exist");
615
        assert_eq!(users.get_value("age", bob_rowid), Some(Value::Integer(99)));
1✔
616
    }
617

618
    #[test]
619
    fn process_command_update_unique_violation_test() {
3✔
620
        let mut db = seed_users_table();
1✔
621
        // `name` is not UNIQUE in the seed — reinforce with an explicit unique column.
622
        process_command(
623
            "CREATE TABLE tags (id INTEGER PRIMARY KEY, label TEXT UNIQUE);",
624
            &mut db,
625
        )
626
        .unwrap();
627
        process_command("INSERT INTO tags (label) VALUES ('a');", &mut db).unwrap();
1✔
628
        process_command("INSERT INTO tags (label) VALUES ('b');", &mut db).unwrap();
1✔
629

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

634
    #[test]
635
    fn process_command_insert_type_mismatch_returns_error_test() {
3✔
636
        // Previously this panicked in parse::<i32>().unwrap(); now it should return an error cleanly.
637
        let mut db = Database::new("tempdb".to_string());
1✔
638
        process_command(
639
            "CREATE TABLE items (id INTEGER PRIMARY KEY, qty INTEGER);",
640
            &mut db,
641
        )
642
        .unwrap();
643
        let result = process_command("INSERT INTO items (qty) VALUES ('not a number');", &mut db);
1✔
644
        assert!(result.is_err(), "expected error, got {result:?}");
2✔
645
    }
646

647
    #[test]
648
    fn insert_omitted_integer_column_is_stored_as_null() {
3✔
649
        // SQLR-7 — pre-fix this errored because the omitted column was
650
        // padded with the literal `"Null"` and then re-parsed as i32. The
651
        // new INSERT pipeline carries `Option<Value>` from the parser
652
        // through to `insert_row`, so a missing non-PK column is just
653
        // SQL NULL (matches SQLite).
654
        use crate::sql::db::table::Value;
655

656
        let mut db = Database::new("tempdb".to_string());
1✔
657
        process_command(
658
            "CREATE TABLE items (id INTEGER PRIMARY KEY, qty INTEGER);",
659
            &mut db,
660
        )
661
        .unwrap();
662
        process_command("INSERT INTO items (id) VALUES (1);", &mut db)
1✔
663
            .expect("INSERT with omitted INTEGER column should succeed and store NULL");
664

665
        let table = db.get_table("items".to_string()).unwrap();
1✔
666
        let rowid = table.rowids().pop().expect("one row");
1✔
667
        // BTreeMap entry was never written → get_value returns None,
668
        // which the executor renders as Value::Null.
669
        assert_eq!(table.get_value("qty", rowid), None);
1✔
670
        // IS NULL via the executor sees the same NULL.
671
        let response = process_command("SELECT id FROM items WHERE qty IS NULL;", &mut db)
1✔
672
            .expect("select IS NULL");
NEW
673
        assert!(
×
674
            response.contains("1 row returned"),
2✔
675
            "qty IS NULL should match the omitted-column row, got: {response}"
676
        );
677
        // Sanity: explicit literal stays Integer.
678
        process_command("INSERT INTO items (id, qty) VALUES (2, 7);", &mut db).unwrap();
2✔
679
        let table = db.get_table("items".to_string()).unwrap();
1✔
680
        let row_two = table
1✔
681
            .rowids()
682
            .into_iter()
683
            .find(|r| table.get_value("id", *r) == Some(Value::Integer(2)))
3✔
684
            .unwrap();
685
        assert_eq!(table.get_value("qty", row_two), Some(Value::Integer(7)));
1✔
686
    }
687

688
    #[test]
689
    fn insert_explicit_null_into_integer_column() {
3✔
690
        let mut db = Database::new("tempdb".to_string());
1✔
691
        process_command(
692
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER);",
693
            &mut db,
694
        )
695
        .unwrap();
696
        process_command("INSERT INTO t (id, n) VALUES (1, NULL);", &mut db)
1✔
697
            .expect("INSERT explicit NULL into INTEGER must not panic on parse::<i32>()");
698
        let table = db.get_table("t".to_string()).unwrap();
1✔
699
        let rowid = table.rowids().pop().unwrap();
1✔
700
        assert_eq!(table.get_value("n", rowid), None);
1✔
701
    }
702

703
    #[test]
704
    fn insert_explicit_null_into_text_column() {
3✔
705
        // Pre-fix: the literal string "Null" was stored in the BTreeMap and
706
        // a read-side workaround (`if v == "Null"`) re-mapped it back to
707
        // Value::Null. Post-fix: nothing is stored at all, so a user-typed
708
        // string `'Null'` no longer collides with SQL NULL.
709
        use crate::sql::db::table::Value;
710

711
        let mut db = Database::new("tempdb".to_string());
1✔
712
        process_command("CREATE TABLE t (id INTEGER PRIMARY KEY, s TEXT);", &mut db).unwrap();
2✔
713
        process_command("INSERT INTO t (id, s) VALUES (1, NULL);", &mut db).unwrap();
1✔
714
        process_command("INSERT INTO t (id, s) VALUES (2, 'hi');", &mut db).unwrap();
1✔
715

716
        let table = db.get_table("t".to_string()).unwrap();
1✔
717
        let row_one = table
1✔
718
            .rowids()
719
            .into_iter()
720
            .find(|r| table.get_value("id", *r) == Some(Value::Integer(1)))
3✔
721
            .unwrap();
722
        let row_two = table
1✔
723
            .rowids()
724
            .into_iter()
725
            .find(|r| table.get_value("id", *r) == Some(Value::Integer(2)))
3✔
726
            .unwrap();
727
        assert_eq!(table.get_value("s", row_one), None);
1✔
728
        assert_eq!(
1✔
729
            table.get_value("s", row_two),
1✔
730
            Some(Value::Text("hi".to_string()))
2✔
731
        );
732
    }
733

734
    #[test]
735
    fn insert_explicit_null_into_real_column() {
3✔
736
        let mut db = Database::new("tempdb".to_string());
1✔
737
        process_command(
738
            "CREATE TABLE t (id INTEGER PRIMARY KEY, score REAL);",
739
            &mut db,
740
        )
741
        .unwrap();
742
        process_command("INSERT INTO t (id, score) VALUES (1, NULL);", &mut db)
1✔
743
            .expect("INSERT explicit NULL into REAL must not panic on parse::<f32>()");
744
        let table = db.get_table("t".to_string()).unwrap();
1✔
745
        let rowid = table.rowids().pop().unwrap();
1✔
746
        assert_eq!(table.get_value("score", rowid), None);
1✔
747
    }
748

749
    #[test]
750
    fn insert_explicit_null_into_bool_column() {
3✔
751
        let mut db = Database::new("tempdb".to_string());
1✔
752
        process_command(
753
            "CREATE TABLE t (id INTEGER PRIMARY KEY, flag BOOLEAN);",
754
            &mut db,
755
        )
756
        .unwrap();
757
        process_command("INSERT INTO t (id, flag) VALUES (1, NULL);", &mut db)
1✔
758
            .expect("INSERT explicit NULL into BOOL must not panic on parse::<bool>()");
759
        let table = db.get_table("t".to_string()).unwrap();
1✔
760
        let rowid = table.rowids().pop().unwrap();
1✔
761
        assert_eq!(table.get_value("flag", rowid), None);
1✔
762
    }
763

764
    #[test]
765
    fn insert_explicit_null_into_vector_column() {
3✔
766
        let mut db = Database::new("tempdb".to_string());
1✔
767
        process_command(
768
            "CREATE TABLE t (id INTEGER PRIMARY KEY, v VECTOR(3));",
769
            &mut db,
770
        )
771
        .unwrap();
772
        process_command("INSERT INTO t (id, v) VALUES (1, NULL);", &mut db)
1✔
773
            .expect("INSERT explicit NULL into VECTOR must not panic on parse_vector_literal");
774
        let table = db.get_table("t".to_string()).unwrap();
1✔
775
        let rowid = table.rowids().pop().unwrap();
1✔
776
        assert_eq!(table.get_value("v", rowid), None);
1✔
777
    }
778

779
    #[test]
780
    fn insert_explicit_null_into_json_column() {
3✔
781
        let mut db = Database::new("tempdb".to_string());
1✔
782
        process_command(
783
            "CREATE TABLE t (id INTEGER PRIMARY KEY, doc JSON);",
784
            &mut db,
785
        )
786
        .unwrap();
787
        process_command("INSERT INTO t (id, doc) VALUES (1, NULL);", &mut db)
1✔
788
            .expect("INSERT explicit NULL into JSON must skip serde_json validation");
789
        let table = db.get_table("t".to_string()).unwrap();
1✔
790
        let rowid = table.rowids().pop().unwrap();
1✔
791
        assert_eq!(table.get_value("doc", rowid), None);
1✔
792
    }
793

794
    #[test]
795
    fn default_does_not_override_explicit_null() {
3✔
796
        // Restored from SQLR-2 (was dropped because it collided with the
797
        // stringly-typed NULL handling SQLR-7 fixes). Column has DEFAULT 0;
798
        // an explicit NULL in the INSERT must override the default and
799
        // store NULL — the default only fires when the column is omitted.
800
        use crate::sql::db::table::Value;
801

802
        let mut db = Database::new("tempdb".to_string());
1✔
803
        process_command(
804
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER DEFAULT 0);",
805
            &mut db,
806
        )
807
        .unwrap();
808
        process_command("INSERT INTO t (id, n) VALUES (1, NULL);", &mut db).unwrap();
1✔
809
        process_command("INSERT INTO t (id) VALUES (2);", &mut db).unwrap();
1✔
810

811
        let table = db.get_table("t".to_string()).unwrap();
1✔
812
        let row_one = table
1✔
813
            .rowids()
814
            .into_iter()
815
            .find(|r| table.get_value("id", *r) == Some(Value::Integer(1)))
3✔
816
            .unwrap();
817
        let row_two = table
1✔
818
            .rowids()
819
            .into_iter()
820
            .find(|r| table.get_value("id", *r) == Some(Value::Integer(2)))
3✔
821
            .unwrap();
822
        // Explicit NULL: stored as NULL, not the default.
823
        assert_eq!(table.get_value("n", row_one), None);
1✔
824
        // Omitted: stored as the DEFAULT 0.
825
        assert_eq!(table.get_value("n", row_two), Some(Value::Integer(0)));
1✔
826
    }
827

828
    #[test]
829
    fn process_command_update_arith_test() {
3✔
830
        use crate::sql::db::table::Value;
831

832
        let mut db = seed_users_table();
1✔
833
        process_command("UPDATE users SET age = age + 1;", &mut db).expect("update +1");
2✔
834

835
        let users = db.get_table("users".to_string()).unwrap();
1✔
836
        let mut ages: Vec<i64> = users
837
            .rowids()
838
            .into_iter()
839
            .filter_map(|r| match users.get_value("age", r) {
3✔
840
                Some(Value::Integer(n)) => Some(n),
1✔
841
                _ => None,
×
842
            })
843
            .collect();
844
        ages.sort();
2✔
845
        assert_eq!(ages, vec![26, 31, 41]); // 25+1, 30+1, 40+1
1✔
846
    }
847

848
    #[test]
849
    fn process_command_select_arithmetic_where_test() {
3✔
850
        let mut db = seed_users_table();
1✔
851
        // age * 2 > 55  →  only ages > 27.5  →  alice(30) + carol(40)
852
        let response =
2✔
853
            process_command("SELECT name FROM users WHERE age * 2 > 55;", &mut db).expect("select");
854
        assert!(response.contains("2 rows returned"));
2✔
855
    }
856

857
    #[test]
858
    fn process_command_divide_by_zero_test() {
3✔
859
        let mut db = seed_users_table();
1✔
860
        let result = process_command("SELECT age / 0 FROM users;", &mut db);
1✔
861
        // Projection only supports bare columns, so this errors earlier; still shouldn't panic.
862
        assert!(result.is_err());
2✔
863
    }
864

865
    #[test]
866
    fn process_command_unsupported_statement_test() {
3✔
867
        let mut db = Database::new("tempdb".to_string());
1✔
868
        // CREATE VIEW is firmly in the "Not yet supported" list — used as
869
        // the canary for the dispatcher's NotImplemented arm. (DROP TABLE
870
        // moved out of unsupported in this branch.)
871
        let result = process_command("CREATE VIEW v AS SELECT * FROM users;", &mut db);
1✔
872
        assert!(result.is_err());
2✔
873
    }
874

875
    #[test]
876
    fn empty_input_is_a_noop_not_a_panic() {
3✔
877
        // Regression for: desktop app pre-fills the textarea with a
878
        // comment-only placeholder, and hitting Run used to panic because
879
        // sqlparser produced zero statements and pop().unwrap() exploded.
880
        let mut db = Database::new("t".to_string());
1✔
881
        for input in ["", "   ", "-- just a comment", "-- comment\n-- another"] {
4✔
882
            let result = process_command(input, &mut db);
2✔
883
            assert!(result.is_ok(), "input {input:?} should not error");
2✔
884
            let msg = result.unwrap();
1✔
885
            assert!(msg.contains("No statement"), "got: {msg:?}");
2✔
886
        }
887
    }
888

889
    #[test]
890
    fn create_index_adds_explicit_index() {
3✔
891
        let mut db = seed_users_table();
1✔
892
        let response = process_command("CREATE INDEX users_age_idx ON users (age);", &mut db)
1✔
893
            .expect("create index");
894
        assert!(response.contains("users_age_idx"));
2✔
895

896
        // The index should now be attached to the users table.
897
        let users = db.get_table("users".to_string()).unwrap();
1✔
898
        let idx = users
1✔
899
            .index_by_name("users_age_idx")
900
            .expect("index should exist after CREATE INDEX");
901
        assert_eq!(idx.column_name, "age");
1✔
902
        assert!(!idx.is_unique);
1✔
903
    }
904

905
    #[test]
906
    fn create_unique_index_rejects_duplicate_existing_values() {
3✔
907
        let mut db = seed_users_table();
1✔
908
        // `name` is already UNIQUE (auto-indexed); insert a duplicate-age row
909
        // first so CREATE UNIQUE INDEX on age catches the conflict.
910
        process_command("INSERT INTO users (name, age) VALUES ('dan', 30);", &mut db).unwrap();
2✔
911
        let result = process_command(
912
            "CREATE UNIQUE INDEX users_age_unique ON users (age);",
913
            &mut db,
914
        );
915
        assert!(
×
916
            result.is_err(),
2✔
917
            "expected unique-index failure, got {result:?}"
918
        );
919
    }
920

921
    #[test]
922
    fn where_eq_on_indexed_column_uses_index_probe() {
3✔
923
        // Build a table big enough that a full scan would be expensive,
924
        // then rely on the index-probe fast path. This test verifies
925
        // correctness (right rows returned); the perf win is implicit.
926
        let mut db = Database::new("t".to_string());
1✔
927
        process_command(
928
            "CREATE TABLE big (id INTEGER PRIMARY KEY, tag TEXT);",
929
            &mut db,
930
        )
931
        .unwrap();
932
        process_command("CREATE INDEX big_tag_idx ON big (tag);", &mut db).unwrap();
1✔
933
        for i in 1..=100 {
1✔
934
            let tag = if i % 3 == 0 { "hot" } else { "cold" };
2✔
935
            process_command(&format!("INSERT INTO big (tag) VALUES ('{tag}');"), &mut db).unwrap();
1✔
936
        }
937
        let response =
1✔
938
            process_command("SELECT id FROM big WHERE tag = 'hot';", &mut db).expect("select");
939
        // 1..=100 has 33 multiples of 3.
940
        assert!(
×
941
            response.contains("33 rows returned"),
2✔
942
            "response was {response:?}"
943
        );
944
    }
945

946
    #[test]
947
    fn where_eq_on_indexed_column_inside_parens_uses_index_probe() {
3✔
948
        let mut db = seed_users_table();
1✔
949
        let response = process_command("SELECT name FROM users WHERE (name = 'bob');", &mut db)
1✔
950
            .expect("select");
951
        assert!(response.contains("1 row returned"));
2✔
952
    }
953

954
    #[test]
955
    fn where_eq_literal_first_side_uses_index_probe() {
3✔
956
        let mut db = seed_users_table();
1✔
957
        // `'bob' = name` should hit the same path as `name = 'bob'`.
958
        let response =
2✔
959
            process_command("SELECT name FROM users WHERE 'bob' = name;", &mut db).expect("select");
960
        assert!(response.contains("1 row returned"));
2✔
961
    }
962

963
    #[test]
964
    fn non_equality_where_still_falls_back_to_full_scan() {
3✔
965
        // Sanity: range predicates bypass the optimizer and the full-scan
966
        // path still returns correct results.
967
        let mut db = seed_users_table();
1✔
968
        let response =
2✔
969
            process_command("SELECT name FROM users WHERE age > 28;", &mut db).expect("select");
970
        assert!(response.contains("2 rows returned"));
2✔
971
    }
972

973
    // -------------------------------------------------------------------
974
    // Phase 4f — Transactions (BEGIN / COMMIT / ROLLBACK)
975
    // -------------------------------------------------------------------
976

977
    #[test]
978
    fn rollback_restores_pre_begin_in_memory_state() {
3✔
979
        // In-memory DB (no pager): BEGIN, insert a row, ROLLBACK.
980
        // The row must disappear from the live tables HashMap.
981
        let mut db = seed_users_table();
1✔
982
        let before = db.get_table("users".to_string()).unwrap().rowids().len();
2✔
983
        assert_eq!(before, 3);
1✔
984

985
        process_command("BEGIN;", &mut db).expect("BEGIN");
1✔
986
        assert!(db.in_transaction());
1✔
987
        process_command("INSERT INTO users (name, age) VALUES ('dan', 50);", &mut db)
1✔
988
            .expect("INSERT inside txn");
989
        // Mid-transaction read sees the new row.
990
        let mid = db.get_table("users".to_string()).unwrap().rowids().len();
1✔
991
        assert_eq!(mid, 4);
1✔
992

993
        process_command("ROLLBACK;", &mut db).expect("ROLLBACK");
1✔
994
        assert!(!db.in_transaction());
1✔
995
        let after = db.get_table("users".to_string()).unwrap().rowids().len();
2✔
996
        assert_eq!(
1✔
997
            after, 3,
998
            "ROLLBACK should have restored the pre-BEGIN state"
999
        );
1000
    }
1001

1002
    #[test]
1003
    fn commit_keeps_mutations_and_clears_txn_flag() {
3✔
1004
        let mut db = seed_users_table();
1✔
1005
        process_command("BEGIN;", &mut db).expect("BEGIN");
2✔
1006
        process_command("INSERT INTO users (name, age) VALUES ('dan', 50);", &mut db)
1✔
1007
            .expect("INSERT inside txn");
1008
        process_command("COMMIT;", &mut db).expect("COMMIT");
1✔
1009
        assert!(!db.in_transaction());
1✔
1010
        let after = db.get_table("users".to_string()).unwrap().rowids().len();
2✔
1011
        assert_eq!(after, 4);
1✔
1012
    }
1013

1014
    #[test]
1015
    fn rollback_undoes_update_and_delete_side_by_side() {
3✔
1016
        use crate::sql::db::table::Value;
1017

1018
        let mut db = seed_users_table();
1✔
1019
        process_command("BEGIN;", &mut db).unwrap();
2✔
1020
        process_command("UPDATE users SET age = 999;", &mut db).unwrap();
1✔
1021
        process_command("DELETE FROM users WHERE name = 'bob';", &mut db).unwrap();
1✔
1022
        // Mid-txn: one row gone, others have age=999.
1023
        let users = db.get_table("users".to_string()).unwrap();
1✔
1024
        assert_eq!(users.rowids().len(), 2);
1✔
1025
        for r in users.rowids() {
2✔
1026
            assert_eq!(users.get_value("age", r), Some(Value::Integer(999)));
2✔
1027
        }
1028

1029
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1030
        let users = db.get_table("users".to_string()).unwrap();
1✔
1031
        assert_eq!(users.rowids().len(), 3);
1✔
1032
        // Original ages {30, 25, 40} — none should be 999.
1033
        for r in users.rowids() {
2✔
1034
            assert_ne!(users.get_value("age", r), Some(Value::Integer(999)));
2✔
1035
        }
1036
    }
1037

1038
    #[test]
1039
    fn nested_begin_is_rejected() {
3✔
1040
        let mut db = seed_users_table();
1✔
1041
        process_command("BEGIN;", &mut db).unwrap();
2✔
1042
        let err = process_command("BEGIN;", &mut db).unwrap_err();
1✔
1043
        assert!(
×
1044
            format!("{err}").contains("already open"),
3✔
1045
            "nested BEGIN should error; got: {err}"
1046
        );
1047
        // Still in the original transaction; a ROLLBACK clears it.
1048
        assert!(db.in_transaction());
1✔
1049
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1050
    }
1051

1052
    #[test]
1053
    fn orphan_commit_and_rollback_are_rejected() {
3✔
1054
        let mut db = seed_users_table();
1✔
1055
        let commit_err = process_command("COMMIT;", &mut db).unwrap_err();
2✔
1056
        assert!(format!("{commit_err}").contains("no transaction"));
2✔
1057
        let rollback_err = process_command("ROLLBACK;", &mut db).unwrap_err();
1✔
1058
        assert!(format!("{rollback_err}").contains("no transaction"));
2✔
1059
    }
1060

1061
    #[test]
1062
    fn error_inside_transaction_keeps_txn_open() {
3✔
1063
        // A bad INSERT inside a txn doesn't commit or abort automatically —
1064
        // the user can still ROLLBACK. SQLite's implicit-rollback behavior
1065
        // isn't modeled here.
1066
        let mut db = seed_users_table();
1✔
1067
        process_command("BEGIN;", &mut db).unwrap();
2✔
1068
        let err = process_command("INSERT INTO nope (x) VALUES (1);", &mut db);
1✔
1069
        assert!(err.is_err());
2✔
1070
        assert!(db.in_transaction(), "txn should stay open after error");
1✔
1071
        process_command("ROLLBACK;", &mut db).unwrap();
2✔
1072
    }
1073

1074
    /// Builds a file-backed Database at a unique temp path, with the
1075
    /// schema seeded and `source_path` set so subsequent process_command
1076
    /// calls auto-save. Returns (path, db). Drop the db before deleting
1077
    /// the files.
1078
    fn seed_file_backed(name: &str, schema: &str) -> (std::path::PathBuf, Database) {
1✔
1079
        use crate::sql::pager::{open_database, save_database};
1080
        let mut p = std::env::temp_dir();
1✔
1081
        let pid = std::process::id();
2✔
1082
        let nanos = std::time::SystemTime::now()
2✔
1083
            .duration_since(std::time::UNIX_EPOCH)
1✔
1084
            .map(|d| d.as_nanos())
3✔
1085
            .unwrap_or(0);
1086
        p.push(format!("sqlrite-txn-{name}-{pid}-{nanos}.sqlrite"));
1✔
1087

1088
        // Seed the file, then reopen to get a source_path-attached db
1089
        // (save_database alone doesn't attach a fresh pager to a db
1090
        // whose source_path was None before the call).
1091
        {
1092
            let mut seed = Database::new("t".to_string());
1✔
1093
            process_command(schema, &mut seed).unwrap();
2✔
1094
            save_database(&mut seed, &p).unwrap();
1✔
1095
        }
1096
        let db = open_database(&p, "t".to_string()).unwrap();
1✔
1097
        (p, db)
1✔
1098
    }
1099

1100
    fn cleanup_file(path: &std::path::Path) {
1✔
1101
        let _ = std::fs::remove_file(path);
1✔
1102
        let mut wal = path.as_os_str().to_owned();
1✔
1103
        wal.push("-wal");
1✔
1104
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
1105
    }
1106

1107
    #[test]
1108
    fn null_values_round_trip_through_disk() {
3✔
1109
        // SQLR-7 — explicit NULLs and omitted columns must persist.
1110
        // Pre-fix, restore_row rejected NULL for INTEGER/REAL/BOOL/VECTOR
1111
        // columns ("Integer column 'n' cannot store NULL — corrupt
1112
        // cell?"), so any DB containing a NULL in those types failed to
1113
        // reopen.
1114
        use crate::sql::db::table::Value;
1115
        use crate::sql::pager::open_database;
1116

1117
        let (path, mut db) = seed_file_backed(
1118
            "nullrt",
1119
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER, s TEXT, score REAL, flag BOOLEAN);",
1120
        );
1121
        process_command(
1122
            "INSERT INTO t (id, n, s, score, flag) VALUES (1, 10, 'hi', 1.5, true);",
1123
            &mut db,
1124
        )
1125
        .unwrap();
1126
        process_command(
1127
            "INSERT INTO t (id, n, s, score, flag) VALUES (2, NULL, NULL, NULL, NULL);",
1128
            &mut db,
1129
        )
1130
        .unwrap();
1131
        // Row 3 omits every nullable column.
1132
        process_command("INSERT INTO t (id) VALUES (3);", &mut db).unwrap();
1✔
1133

1134
        drop(db); // release pager lock
1✔
1135

1136
        let reopened = open_database(&path, "t".to_string()).unwrap();
1✔
1137
        let t = reopened.get_table("t".to_string()).unwrap();
2✔
1138
        let by_id = |id: i64| {
2✔
1139
            t.rowids()
1✔
1140
                .into_iter()
1141
                .find(|r| t.get_value("id", *r) == Some(Value::Integer(id)))
3✔
NEW
1142
                .unwrap_or_else(|| panic!("row id={id} not found"))
×
1143
        };
1144

1145
        let r1 = by_id(1);
1✔
1146
        assert_eq!(t.get_value("n", r1), Some(Value::Integer(10)));
1✔
1147
        assert_eq!(t.get_value("s", r1), Some(Value::Text("hi".to_string())));
1✔
1148
        assert_eq!(t.get_value("score", r1), Some(Value::Real(1.5)));
1✔
1149
        assert_eq!(t.get_value("flag", r1), Some(Value::Bool(true)));
1✔
1150

1151
        for r in [by_id(2), by_id(3)] {
2✔
1152
            assert_eq!(t.get_value("n", r), None, "INTEGER NULL must round-trip");
2✔
1153
            assert_eq!(t.get_value("s", r), None, "TEXT NULL must round-trip");
1✔
1154
            assert_eq!(t.get_value("score", r), None, "REAL NULL must round-trip");
1✔
1155
            assert_eq!(t.get_value("flag", r), None, "BOOL NULL must round-trip");
1✔
1156
        }
1157

1158
        drop(reopened);
1✔
1159
        cleanup_file(&path);
1✔
1160
    }
1161

1162
    #[test]
1163
    fn begin_commit_rollback_round_trip_through_disk() {
3✔
1164
        // File-backed DB: commit inside a transaction must actually
1165
        // persist. ROLLBACK inside a *later* transaction must not
1166
        // un-do the previously-committed changes.
1167
        use crate::sql::pager::open_database;
1168

1169
        let (path, mut db) = seed_file_backed(
1170
            "roundtrip",
1171
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1172
        );
1173

1174
        // Transaction 1: insert two rows, commit.
1175
        process_command("BEGIN;", &mut db).unwrap();
2✔
1176
        process_command("INSERT INTO notes (body) VALUES ('a');", &mut db).unwrap();
1✔
1177
        process_command("INSERT INTO notes (body) VALUES ('b');", &mut db).unwrap();
1✔
1178
        process_command("COMMIT;", &mut db).unwrap();
1✔
1179

1180
        // Transaction 2: insert another, roll back.
1181
        process_command("BEGIN;", &mut db).unwrap();
1✔
1182
        process_command("INSERT INTO notes (body) VALUES ('c');", &mut db).unwrap();
1✔
1183
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1184

1185
        drop(db); // release pager lock
1✔
1186

1187
        let reopened = open_database(&path, "t".to_string()).unwrap();
1✔
1188
        let notes = reopened.get_table("notes".to_string()).unwrap();
2✔
1189
        assert_eq!(notes.rowids().len(), 2, "committed rows should survive");
1✔
1190

1191
        drop(reopened);
1✔
1192
        cleanup_file(&path);
1✔
1193
    }
1194

1195
    #[test]
1196
    fn write_inside_transaction_does_not_autosave() {
3✔
1197
        // File-backed DB: writes inside BEGIN/…/COMMIT must NOT hit
1198
        // the WAL until COMMIT. We prove it by checking the WAL file
1199
        // size before vs during the transaction.
1200
        let (path, mut db) =
1✔
1201
            seed_file_backed("noas", "CREATE TABLE t (id INTEGER PRIMARY KEY, x TEXT);");
1202

1203
        let mut wal_path = path.as_os_str().to_owned();
2✔
1204
        wal_path.push("-wal");
1✔
1205
        let wal_path = std::path::PathBuf::from(wal_path);
1✔
1206
        let frames_before = std::fs::metadata(&wal_path).unwrap().len();
2✔
1207

1208
        process_command("BEGIN;", &mut db).unwrap();
1✔
1209
        process_command("INSERT INTO t (x) VALUES ('a');", &mut db).unwrap();
1✔
1210
        process_command("INSERT INTO t (x) VALUES ('b');", &mut db).unwrap();
1✔
1211

1212
        // Mid-transaction: WAL must be unchanged — no auto-save fired.
1213
        let frames_mid = std::fs::metadata(&wal_path).unwrap().len();
1✔
1214
        assert_eq!(
1✔
1215
            frames_before, frames_mid,
1216
            "WAL should not grow during an open transaction"
1217
        );
1218

1219
        process_command("COMMIT;", &mut db).unwrap();
2✔
1220

1221
        drop(db); // release pager lock
1✔
1222
        let fresh = crate::sql::pager::open_database(&path, "t".to_string()).unwrap();
1✔
1223
        assert_eq!(
1✔
1224
            fresh.get_table("t".to_string()).unwrap().rowids().len(),
3✔
1225
            2,
1226
            "COMMIT should have persisted both inserted rows"
1227
        );
1228
        drop(fresh);
1✔
1229
        cleanup_file(&path);
1✔
1230
    }
1231

1232
    #[test]
1233
    fn rollback_undoes_create_table() {
3✔
1234
        // Schema DDL inside a txn: ROLLBACK must make the new table
1235
        // disappear. The txn snapshot captures db.tables as of BEGIN,
1236
        // and ROLLBACK reassigns tables from that snapshot, so a table
1237
        // created mid-transaction has no entry in the snapshot.
1238
        let mut db = seed_users_table();
1✔
1239
        assert_eq!(db.tables.len(), 1);
2✔
1240

1241
        process_command("BEGIN;", &mut db).unwrap();
1✔
1242
        process_command(
1243
            "CREATE TABLE dropme (id INTEGER PRIMARY KEY, x TEXT);",
1244
            &mut db,
1245
        )
1246
        .unwrap();
1247
        process_command("INSERT INTO dropme (x) VALUES ('stuff');", &mut db).unwrap();
1✔
1248
        assert_eq!(db.tables.len(), 2);
1✔
1249

1250
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1251
        assert_eq!(
1✔
1252
            db.tables.len(),
1✔
1253
            1,
1254
            "CREATE TABLE should have been rolled back"
1255
        );
1256
        assert!(db.get_table("dropme".to_string()).is_err());
2✔
1257
    }
1258

1259
    #[test]
1260
    fn rollback_restores_secondary_index_state() {
3✔
1261
        // Phase 4f edge case: rolling back an INSERT on a UNIQUE-indexed
1262
        // column must also clean up the index, otherwise a re-insert of
1263
        // the same value would spuriously collide.
1264
        let mut db = Database::new("t".to_string());
1✔
1265
        process_command(
1266
            "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
1267
            &mut db,
1268
        )
1269
        .unwrap();
1270
        process_command("INSERT INTO users (email) VALUES ('a@x');", &mut db).unwrap();
1✔
1271

1272
        process_command("BEGIN;", &mut db).unwrap();
1✔
1273
        process_command("INSERT INTO users (email) VALUES ('b@x');", &mut db).unwrap();
1✔
1274
        // Inside the txn: the index now contains both 'a@x' and 'b@x'.
1275
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1276

1277
        // Re-inserting 'b@x' after rollback must succeed — if the index
1278
        // wasn't properly restored, it would think 'b@x' is still a
1279
        // collision and fail with a UNIQUE violation.
1280
        let reinsert = process_command("INSERT INTO users (email) VALUES ('b@x');", &mut db);
1✔
1281
        assert!(
×
1282
            reinsert.is_ok(),
2✔
1283
            "re-insert after rollback should succeed, got {reinsert:?}"
1284
        );
1285
    }
1286

1287
    #[test]
1288
    fn rollback_restores_last_rowid_counter() {
3✔
1289
        // Rowids allocated inside a rolled-back transaction should be
1290
        // reusable. The snapshot restores Table::last_rowid, so the
1291
        // next insert picks up where the pre-BEGIN state left off.
1292
        use crate::sql::db::table::Value;
1293

1294
        let mut db = seed_users_table(); // 3 rows, last_rowid = 3
1✔
1295
        let pre = db.get_table("users".to_string()).unwrap().last_rowid;
2✔
1296

1297
        process_command("BEGIN;", &mut db).unwrap();
1✔
1298
        process_command("INSERT INTO users (name, age) VALUES ('d', 50);", &mut db).unwrap(); // would be rowid 4
1✔
1299
        process_command("INSERT INTO users (name, age) VALUES ('e', 60);", &mut db).unwrap(); // would be rowid 5
1✔
1300
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1301

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

1305
        // Confirm: the next insert reuses rowid pre+1.
1306
        process_command("INSERT INTO users (name, age) VALUES ('d', 50);", &mut db).unwrap();
2✔
1307
        let users = db.get_table("users".to_string()).unwrap();
1✔
1308
        let d_rowid = users
1✔
1309
            .rowids()
1310
            .into_iter()
1311
            .find(|r| users.get_value("name", *r) == Some(Value::Text("d".into())))
3✔
1312
            .expect("d row must exist");
1313
        assert_eq!(d_rowid, pre + 1);
1✔
1314
    }
1315

1316
    #[test]
1317
    fn commit_on_in_memory_db_clears_txn_without_pager_call() {
3✔
1318
        // In-memory DB (no source_path): COMMIT must still work — just
1319
        // no disk flush. Covers the `if let Some(path) = …` branch
1320
        // where the guard falls through without calling save_database.
1321
        let mut db = seed_users_table(); // no source_path
1✔
1322
        assert!(db.source_path.is_none());
2✔
1323

1324
        process_command("BEGIN;", &mut db).unwrap();
1✔
1325
        process_command("INSERT INTO users (name, age) VALUES ('z', 99);", &mut db).unwrap();
1✔
1326
        process_command("COMMIT;", &mut db).unwrap();
1✔
1327

1328
        assert!(!db.in_transaction());
1✔
1329
        assert_eq!(db.get_table("users".to_string()).unwrap().rowids().len(), 4);
2✔
1330
    }
1331

1332
    #[test]
1333
    fn failed_commit_auto_rolls_back_in_memory_state() {
3✔
1334
        // Data-safety regression: on COMMIT save failure we must auto-
1335
        // rollback the in-memory state. Otherwise, any subsequent
1336
        // non-transactional statement would auto-save the partial
1337
        // mid-transaction work, silently publishing uncommitted
1338
        // changes to disk.
1339
        //
1340
        // We simulate a save failure by making the WAL sidecar path
1341
        // unavailable mid-transaction: after BEGIN, we take an
1342
        // exclusive OS lock on the WAL via a second File handle,
1343
        // forcing the next save to fail when it tries to append.
1344
        //
1345
        // Simpler repro: point source_path at a directory (not a file).
1346
        // `OpenOptions::open` will fail with EISDIR on save.
1347
        use crate::sql::pager::save_database;
1348

1349
        // Seed a file-backed db.
1350
        let (path, mut db) = seed_file_backed(
1351
            "failcommit",
1352
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1353
        );
1354

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

1358
        // Open a new txn and add a row.
1359
        process_command("BEGIN;", &mut db).unwrap();
1✔
1360
        process_command("INSERT INTO notes (body) VALUES ('inflight');", &mut db).unwrap();
1✔
1361
        assert_eq!(
1✔
1362
            db.get_table("notes".to_string()).unwrap().rowids().len(),
2✔
1363
            2,
1364
            "inflight row visible mid-txn"
1365
        );
1366

1367
        // Swap source_path to a path that will fail on open. A
1368
        // directory is a reliable failure mode — Pager::open on a
1369
        // directory errors with an I/O error.
1370
        let orig_source = db.source_path.clone();
1✔
1371
        let orig_pager = db.pager.take();
2✔
1372
        db.source_path = Some(std::env::temp_dir());
2✔
1373

1374
        let commit_result = process_command("COMMIT;", &mut db);
1✔
1375
        assert!(commit_result.is_err(), "commit must fail");
2✔
1376
        let err_str = format!("{}", commit_result.unwrap_err());
2✔
1377
        assert!(
×
1378
            err_str.contains("COMMIT failed") && err_str.contains("rolled back"),
3✔
1379
            "error must surface auto-rollback; got: {err_str}"
1380
        );
1381

1382
        // Auto-rollback fired: the inflight row is gone, the txn flag
1383
        // is cleared, and a follow-up non-txn statement won't leak
1384
        // stale state.
1385
        assert!(
×
1386
            !db.in_transaction(),
2✔
1387
            "txn must be cleared after auto-rollback"
1388
        );
1389
        assert_eq!(
1✔
1390
            db.get_table("notes".to_string()).unwrap().rowids().len(),
3✔
1391
            1,
1392
            "inflight row must be rolled back"
1393
        );
1394

1395
        // Restore the real source_path + pager and verify a clean
1396
        // subsequent write goes through.
1397
        db.source_path = orig_source;
1✔
1398
        db.pager = orig_pager;
1✔
1399
        process_command("INSERT INTO notes (body) VALUES ('after');", &mut db).unwrap();
1✔
1400
        drop(db);
1✔
1401

1402
        // Reopen and assert only 'before' + 'after' landed on disk.
1403
        let reopened = crate::sql::pager::open_database(&path, "t".to_string()).unwrap();
1✔
1404
        let notes = reopened.get_table("notes".to_string()).unwrap();
2✔
1405
        assert_eq!(notes.rowids().len(), 2);
1✔
1406
        // Ensure no leaked save_database partial happened.
1407
        let _ = save_database; // silence unused-import lint if any
1408
        drop(reopened);
1✔
1409
        cleanup_file(&path);
1✔
1410
    }
1411

1412
    #[test]
1413
    fn begin_on_read_only_is_rejected() {
3✔
1414
        use crate::sql::pager::{open_database_read_only, save_database};
1415

1416
        let path = {
1✔
1417
            let mut p = std::env::temp_dir();
1✔
1418
            let pid = std::process::id();
2✔
1419
            let nanos = std::time::SystemTime::now()
2✔
1420
                .duration_since(std::time::UNIX_EPOCH)
1✔
1421
                .map(|d| d.as_nanos())
3✔
1422
                .unwrap_or(0);
1423
            p.push(format!("sqlrite-txn-ro-{pid}-{nanos}.sqlrite"));
1✔
1424
            p
1✔
1425
        };
1426
        {
1427
            let mut seed = Database::new("t".to_string());
2✔
1428
            process_command("CREATE TABLE t (id INTEGER PRIMARY KEY);", &mut seed).unwrap();
2✔
1429
            save_database(&mut seed, &path).unwrap();
1✔
1430
        }
1431

1432
        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1✔
1433
        let err = process_command("BEGIN;", &mut ro).unwrap_err();
2✔
1434
        assert!(
×
1435
            format!("{err}").contains("read-only"),
3✔
1436
            "BEGIN on RO db should surface read-only; got: {err}"
1437
        );
1438
        assert!(!ro.in_transaction());
1✔
1439

1440
        let _ = std::fs::remove_file(&path);
2✔
1441
        let mut wal = path.as_os_str().to_owned();
1✔
1442
        wal.push("-wal");
1✔
1443
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
1444
    }
1445

1446
    #[test]
1447
    fn read_only_database_rejects_mutations_before_touching_state() {
3✔
1448
        // Phase 4e end-to-end: a `--readonly` caller that runs INSERT
1449
        // must error *before* the row is added to the in-memory table.
1450
        // Otherwise the user sees a rendered result table with the
1451
        // phantom row, followed by the auto-save error — UX rot and a
1452
        // state-drift risk.
1453
        use crate::sql::pager::open_database_read_only;
1454

1455
        let mut seed = Database::new("t".to_string());
1✔
1456
        process_command(
1457
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1458
            &mut seed,
1459
        )
1460
        .unwrap();
1461
        process_command("INSERT INTO notes (body) VALUES ('alpha');", &mut seed).unwrap();
1✔
1462

1463
        let path = {
1464
            let mut p = std::env::temp_dir();
1✔
1465
            let pid = std::process::id();
2✔
1466
            let nanos = std::time::SystemTime::now()
2✔
1467
                .duration_since(std::time::UNIX_EPOCH)
1✔
1468
                .map(|d| d.as_nanos())
3✔
1469
                .unwrap_or(0);
1470
            p.push(format!("sqlrite-ro-reject-{pid}-{nanos}.sqlrite"));
1✔
1471
            p
1✔
1472
        };
1473
        crate::sql::pager::save_database(&mut seed, &path).unwrap();
2✔
1474
        drop(seed);
1✔
1475

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

1479
        for stmt in [
2✔
1480
            "INSERT INTO notes (body) VALUES ('beta');",
1481
            "UPDATE notes SET body = 'x';",
1482
            "DELETE FROM notes;",
1483
            "CREATE TABLE more (id INTEGER PRIMARY KEY);",
1484
            "CREATE INDEX notes_body ON notes (body);",
1485
        ] {
1486
            let err = process_command(stmt, &mut ro).unwrap_err();
2✔
1487
            assert!(
×
1488
                format!("{err}").contains("read-only"),
3✔
1489
                "stmt {stmt:?} should surface a read-only error; got: {err}"
1490
            );
1491
        }
1492

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

1499
        // Cleanup.
1500
        drop(ro);
1✔
1501
        let _ = std::fs::remove_file(&path);
1✔
1502
        let mut wal = path.as_os_str().to_owned();
1✔
1503
        wal.push("-wal");
1✔
1504
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
1505
    }
1506

1507
    // -----------------------------------------------------------------
1508
    // Phase 7a — VECTOR(N) end-to-end through process_command
1509
    // -----------------------------------------------------------------
1510

1511
    #[test]
1512
    fn vector_create_table_and_insert_basic() {
3✔
1513
        let mut db = Database::new("tempdb".to_string());
1✔
1514
        process_command(
1515
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(3));",
1516
            &mut db,
1517
        )
1518
        .expect("create table with VECTOR(3)");
1519
        process_command(
1520
            "INSERT INTO docs (embedding) VALUES ([0.1, 0.2, 0.3]);",
1521
            &mut db,
1522
        )
1523
        .expect("insert vector");
1524

1525
        // process_command returns a status string; the rendered table
1526
        // goes to stdout via print_table. Verify state by inspecting
1527
        // the database directly.
1528
        let sel = process_command("SELECT * FROM docs;", &mut db).expect("select");
1✔
1529
        assert!(sel.contains("1 row returned"));
2✔
1530

1531
        let docs = db.get_table("docs".to_string()).expect("docs table");
1✔
1532
        let rowids = docs.rowids();
1✔
1533
        assert_eq!(rowids.len(), 1);
2✔
1534
        match docs.get_value("embedding", rowids[0]) {
1✔
1535
            Some(Value::Vector(v)) => assert_eq!(v, vec![0.1f32, 0.2, 0.3]),
1✔
1536
            other => panic!("expected Value::Vector(...), got {other:?}"),
×
1537
        }
1538
    }
1539

1540
    #[test]
1541
    fn vector_dim_mismatch_at_insert_is_clean_error() {
3✔
1542
        let mut db = Database::new("tempdb".to_string());
1✔
1543
        process_command(
1544
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(3));",
1545
            &mut db,
1546
        )
1547
        .expect("create table");
1548

1549
        // Too few elements.
1550
        let err = process_command("INSERT INTO docs (embedding) VALUES ([0.1, 0.2]);", &mut db)
1✔
1551
            .unwrap_err();
1552
        let msg = format!("{err}");
2✔
1553
        assert!(
×
1554
            msg.to_lowercase().contains("dimension")
2✔
1555
                && msg.contains("declared 3")
1✔
1556
                && msg.contains("got 2"),
1✔
1557
            "expected clear dim-mismatch error, got: {msg}"
1558
        );
1559

1560
        // Too many elements.
1561
        let err = process_command(
1562
            "INSERT INTO docs (embedding) VALUES ([0.1, 0.2, 0.3, 0.4, 0.5]);",
1563
            &mut db,
1564
        )
1565
        .unwrap_err();
1566
        assert!(
×
1567
            format!("{err}").contains("got 5"),
3✔
1568
            "expected dim-mismatch error mentioning got 5, got: {err}"
1569
        );
1570
    }
1571

1572
    #[test]
1573
    fn vector_create_table_rejects_missing_dim() {
3✔
1574
        let mut db = Database::new("tempdb".to_string());
1✔
1575
        // `VECTOR` (no parens) currently parses as `DataType::Custom` with
1576
        // empty args from sqlparser, OR may not parse as Custom at all
1577
        // depending on dialect. Either way, the column shouldn't end up
1578
        // as a usable Vector type. Accept any error here — the precise
1579
        // message is parser-version-dependent.
1580
        let result = process_command(
1581
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR);",
1582
            &mut db,
1583
        );
1584
        assert!(
×
1585
            result.is_err(),
2✔
1586
            "expected CREATE TABLE with bare VECTOR to fail (no dim)"
1587
        );
1588
    }
1589

1590
    #[test]
1591
    fn vector_create_table_rejects_zero_dim() {
3✔
1592
        let mut db = Database::new("tempdb".to_string());
1✔
1593
        let err = process_command(
1594
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(0));",
1595
            &mut db,
1596
        )
1597
        .unwrap_err();
1598
        let msg = format!("{err}");
2✔
1599
        assert!(
×
1600
            msg.to_lowercase().contains("vector"),
3✔
1601
            "expected VECTOR-related error for VECTOR(0), got: {msg}"
1602
        );
1603
    }
1604

1605
    #[test]
1606
    fn vector_high_dim_works() {
3✔
1607
        // 384-dim vector (OpenAI text-embedding-3-small size). Mostly a
1608
        // smoke test — if cell encoding mishandles the size, this fails.
1609
        let mut db = Database::new("tempdb".to_string());
1✔
1610
        process_command(
1611
            "CREATE TABLE embeddings (id INTEGER PRIMARY KEY, e VECTOR(384));",
1612
            &mut db,
1613
        )
1614
        .expect("create table VECTOR(384)");
1615

1616
        let lit = format!(
1✔
1617
            "[{}]",
1618
            (0..384)
1✔
1619
                .map(|i| format!("{}", i as f32 * 0.001))
3✔
1620
                .collect::<Vec<_>>()
1✔
1621
                .join(",")
1✔
1622
        );
1623
        let sql = format!("INSERT INTO embeddings (e) VALUES ({lit});");
2✔
1624
        process_command(&sql, &mut db).expect("insert 384-dim vector");
2✔
1625

1626
        let sel = process_command("SELECT id FROM embeddings;", &mut db).expect("select id");
1✔
1627
        assert!(sel.contains("1 row returned"));
2✔
1628
    }
1629

1630
    #[test]
1631
    fn vector_multiple_rows() {
3✔
1632
        // Three rows with different vectors — exercises the Row::Vector
1633
        // BTreeMap path (not just single-row insertion).
1634
        let mut db = Database::new("tempdb".to_string());
1✔
1635
        process_command(
1636
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1637
            &mut db,
1638
        )
1639
        .expect("create");
1640
        for i in 0..3 {
1✔
1641
            let sql = format!("INSERT INTO docs (e) VALUES ([{i}.0, {}.0]);", i + 1);
2✔
1642
            process_command(&sql, &mut db).expect("insert");
2✔
1643
        }
1644
        let sel = process_command("SELECT * FROM docs;", &mut db).expect("select");
1✔
1645
        assert!(sel.contains("3 rows returned"));
2✔
1646

1647
        // Verify each vector round-tripped correctly via direct DB inspection.
1648
        let docs = db.get_table("docs".to_string()).expect("docs table");
1✔
1649
        let rowids = docs.rowids();
1✔
1650
        assert_eq!(rowids.len(), 3);
2✔
1651
        let mut vectors: Vec<Vec<f32>> = rowids
1✔
1652
            .iter()
1653
            .filter_map(|r| match docs.get_value("e", *r) {
3✔
1654
                Some(Value::Vector(v)) => Some(v),
1✔
1655
                _ => None,
×
1656
            })
1657
            .collect();
1658
        vectors.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap());
4✔
1659
        assert_eq!(vectors[0], vec![0.0f32, 1.0]);
1✔
1660
        assert_eq!(vectors[1], vec![1.0f32, 2.0]);
1✔
1661
        assert_eq!(vectors[2], vec![2.0f32, 3.0]);
1✔
1662
    }
1663

1664
    // -----------------------------------------------------------------
1665
    // Phase 7d.2 — CREATE INDEX … USING hnsw end-to-end
1666
    // -----------------------------------------------------------------
1667

1668
    /// Builds a 5-row docs(id, e VECTOR(2)) table with vectors arranged
1669
    /// at known positions for clear distance reasoning. Used by both
1670
    /// the 7d.2 KNN tests and the refuse-DELETE/UPDATE tests.
1671
    fn seed_hnsw_table() -> Database {
1✔
1672
        let mut db = Database::new("tempdb".to_string());
1✔
1673
        process_command(
1674
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1675
            &mut db,
1676
        )
1677
        .unwrap();
1678
        for v in &[
1✔
1679
            "[1.0, 0.0]",   // id=1
1680
            "[2.0, 0.0]",   // id=2
1681
            "[0.0, 3.0]",   // id=3
1682
            "[1.0, 4.0]",   // id=4
1683
            "[10.0, 10.0]", // id=5
1684
        ] {
1685
            process_command(&format!("INSERT INTO docs (e) VALUES ({v});"), &mut db).unwrap();
2✔
1686
        }
1687
        db
1✔
1688
    }
1689

1690
    #[test]
1691
    fn create_index_using_hnsw_succeeds() {
3✔
1692
        let mut db = seed_hnsw_table();
1✔
1693
        let resp = process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1694
        assert!(resp.to_lowercase().contains("create index"));
2✔
1695
        // Index attached.
1696
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1697
        assert_eq!(table.hnsw_indexes.len(), 1);
1✔
1698
        assert_eq!(table.hnsw_indexes[0].name, "ix_e");
1✔
1699
        assert_eq!(table.hnsw_indexes[0].column_name, "e");
1✔
1700
        // Existing rows landed in the graph.
1701
        assert_eq!(table.hnsw_indexes[0].index.len(), 5);
1✔
1702
    }
1703

1704
    #[test]
1705
    fn create_index_using_hnsw_rejects_non_vector_column() {
3✔
1706
        let mut db = Database::new("tempdb".to_string());
1✔
1707
        process_command(
1708
            "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);",
1709
            &mut db,
1710
        )
1711
        .unwrap();
1712
        let err =
1✔
1713
            process_command("CREATE INDEX ix_name ON t USING hnsw (name);", &mut db).unwrap_err();
1714
        let msg = format!("{err}");
2✔
1715
        assert!(
×
1716
            msg.to_lowercase().contains("vector"),
3✔
1717
            "expected error mentioning VECTOR; got: {msg}"
1718
        );
1719
    }
1720

1721
    #[test]
1722
    fn knn_query_uses_hnsw_after_create_index() {
3✔
1723
        // The KNN-shaped query route through try_hnsw_probe rather than
1724
        // the brute-force select_topk. The user-visible result should
1725
        // be the same (HNSW recall is high on small graphs); we
1726
        // primarily verify the index is being hit by checking that
1727
        // the right rowids come back in the right order.
1728
        let mut db = seed_hnsw_table();
1✔
1729
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1730

1731
        // Top-3 closest to [1.0, 0.0]:
1732
        //   id=1 [1.0, 0.0]   distance=0
1733
        //   id=2 [2.0, 0.0]   distance=1
1734
        //   id=3 [0.0, 3.0]   distance≈3.16
1735
        let resp = process_command(
1736
            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [1.0, 0.0]) ASC LIMIT 3;",
1737
            &mut db,
1738
        )
1739
        .unwrap();
1740
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
1741
    }
1742

1743
    #[test]
1744
    fn knn_query_works_after_subsequent_inserts() {
3✔
1745
        // Index built when 5 rows existed; insert 2 more after; the
1746
        // HNSW gets maintained incrementally by insert_row, so the
1747
        // KNN query should see the newly-inserted vectors.
1748
        let mut db = seed_hnsw_table();
1✔
1749
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1750
        process_command("INSERT INTO docs (e) VALUES ([0.5, 0.0]);", &mut db).unwrap(); // id=6
1✔
1751
        process_command("INSERT INTO docs (e) VALUES ([0.1, 0.1]);", &mut db).unwrap(); // id=7
1✔
1752

1753
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1754
        assert_eq!(
1✔
1755
            table.hnsw_indexes[0].index.len(),
1✔
1756
            7,
1757
            "incremental insert should grow HNSW alongside row storage"
1758
        );
1759

1760
        // Now query: id=7 [0.1, 0.1] is closer to [0.0, 0.0] than the
1761
        // original 5 rows.
1762
        let resp = process_command(
1763
            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [0.0, 0.0]) ASC LIMIT 1;",
1764
            &mut db,
1765
        )
1766
        .unwrap();
1767
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
1768
    }
1769

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

1775
    #[test]
1776
    fn delete_on_hnsw_indexed_table_succeeds_and_marks_dirty() {
3✔
1777
        let mut db = seed_hnsw_table();
1✔
1778
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1779
        let resp = process_command("DELETE FROM docs WHERE id = 1;", &mut db).unwrap();
1✔
1780
        assert!(resp.contains("1 row"), "expected 1 row deleted: {resp}");
2✔
1781

1782
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
1783
        let entry = docs.hnsw_indexes.iter().find(|e| e.name == "ix_e").unwrap();
3✔
1784
        assert!(
×
1785
            entry.needs_rebuild,
1✔
1786
            "DELETE should have marked HNSW index dirty for rebuild on next save"
1787
        );
1788
    }
1789

1790
    #[test]
1791
    fn update_on_hnsw_indexed_vector_col_succeeds_and_marks_dirty() {
3✔
1792
        let mut db = seed_hnsw_table();
1✔
1793
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1794
        let resp =
1✔
1795
            process_command("UPDATE docs SET e = [9.0, 9.0] WHERE id = 1;", &mut db).unwrap();
1796
        assert!(resp.contains("1 row"), "expected 1 row updated: {resp}");
2✔
1797

1798
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
1799
        let entry = docs.hnsw_indexes.iter().find(|e| e.name == "ix_e").unwrap();
3✔
1800
        assert!(
×
1801
            entry.needs_rebuild,
1✔
1802
            "UPDATE on the vector column should have marked HNSW index dirty"
1803
        );
1804
    }
1805

1806
    #[test]
1807
    fn duplicate_index_name_errors() {
3✔
1808
        let mut db = seed_hnsw_table();
1✔
1809
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1810
        let err =
1✔
1811
            process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap_err();
1812
        let msg = format!("{err}");
2✔
1813
        assert!(
×
1814
            msg.to_lowercase().contains("already exists"),
3✔
1815
            "expected duplicate-index error; got: {msg}"
1816
        );
1817
    }
1818

1819
    #[test]
1820
    fn index_if_not_exists_is_idempotent() {
3✔
1821
        let mut db = seed_hnsw_table();
1✔
1822
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1823
        // Second time with IF NOT EXISTS should succeed (no-op).
1824
        process_command(
1825
            "CREATE INDEX IF NOT EXISTS ix_e ON docs USING hnsw (e);",
1826
            &mut db,
1827
        )
1828
        .unwrap();
1829
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1830
        assert_eq!(table.hnsw_indexes.len(), 1);
1✔
1831
    }
1832

1833
    // -----------------------------------------------------------------
1834
    // Phase 8b — CREATE INDEX … USING fts end-to-end
1835
    // -----------------------------------------------------------------
1836

1837
    /// 5-row docs(id INTEGER PK, body TEXT) populated with overlapping
1838
    /// vocabulary so BM25 ranking has interesting structure.
1839
    fn seed_fts_table() -> Database {
1✔
1840
        let mut db = Database::new("tempdb".to_string());
1✔
1841
        process_command(
1842
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT);",
1843
            &mut db,
1844
        )
1845
        .unwrap();
1846
        for body in &[
1✔
1847
            "rust embedded database",        // id=1 — both 'rust' and 'embedded'
1848
            "rust web framework",            // id=2 — 'rust' only
1849
            "go embedded systems",           // id=3 — 'embedded' only
1850
            "python web framework",          // id=4 — neither
1851
            "rust rust rust embedded power", // id=5 — heavy on 'rust'
1852
        ] {
1853
            process_command(
1854
                &format!("INSERT INTO docs (body) VALUES ('{body}');"),
2✔
1855
                &mut db,
1856
            )
1857
            .unwrap();
1858
        }
1859
        db
1✔
1860
    }
1861

1862
    #[test]
1863
    fn create_index_using_fts_succeeds_and_indexes_existing_rows() {
3✔
1864
        let mut db = seed_fts_table();
1✔
1865
        let resp =
2✔
1866
            process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
1867
        assert!(resp.to_lowercase().contains("create index"), "got {resp}");
2✔
1868
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1869
        assert_eq!(table.fts_indexes.len(), 1);
1✔
1870
        assert_eq!(table.fts_indexes[0].name, "ix_body");
1✔
1871
        assert_eq!(table.fts_indexes[0].column_name, "body");
1✔
1872
        // All five rows should be in the in-memory PostingList.
1873
        assert_eq!(table.fts_indexes[0].index.len(), 5);
1✔
1874
    }
1875

1876
    #[test]
1877
    fn create_index_using_fts_rejects_non_text_column() {
3✔
1878
        let mut db = Database::new("tempdb".to_string());
1✔
1879
        process_command(
1880
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER);",
1881
            &mut db,
1882
        )
1883
        .unwrap();
1884
        let err = process_command("CREATE INDEX ix_n ON t USING fts (n);", &mut db).unwrap_err();
1✔
1885
        let msg = format!("{err}");
2✔
1886
        assert!(
×
1887
            msg.to_lowercase().contains("text"),
3✔
1888
            "expected error mentioning TEXT; got: {msg}"
1889
        );
1890
    }
1891

1892
    #[test]
1893
    fn fts_match_returns_expected_rows() {
3✔
1894
        let mut db = seed_fts_table();
1✔
1895
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
1896
        // Rows that contain 'rust': ids 1, 2, 5.
1897
        let resp = process_command(
1898
            "SELECT id FROM docs WHERE fts_match(body, 'rust');",
1899
            &mut db,
1900
        )
1901
        .unwrap();
1902
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
1903
    }
1904

1905
    #[test]
1906
    fn fts_match_without_index_errors_clearly() {
3✔
1907
        let mut db = seed_fts_table();
1✔
1908
        // No CREATE INDEX — fts_match must surface a useful error.
1909
        let err = process_command(
1910
            "SELECT id FROM docs WHERE fts_match(body, 'rust');",
1911
            &mut db,
1912
        )
1913
        .unwrap_err();
1914
        let msg = format!("{err}");
2✔
1915
        assert!(
×
1916
            msg.contains("no FTS index"),
2✔
1917
            "expected no-index error; got: {msg}"
1918
        );
1919
    }
1920

1921
    #[test]
1922
    fn bm25_score_orders_descending_by_relevance() {
3✔
1923
        let mut db = seed_fts_table();
1✔
1924
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
1925
        // ORDER BY bm25_score DESC LIMIT 1: id=5 has 'rust' three times in
1926
        // a 5-token doc — highest tf, modest length penalty → top score.
1927
        let out = process_command_with_render(
1928
            "SELECT id FROM docs WHERE fts_match(body, 'rust') \
1929
             ORDER BY bm25_score(body, 'rust') DESC LIMIT 1;",
1930
            &mut db,
1931
        )
1932
        .unwrap();
1933
        assert!(out.status.contains("1 row returned"), "got: {}", out.status);
2✔
1934
        let rendered = out.rendered.expect("SELECT should produce rendered output");
1✔
1935
        // The rendered prettytable contains the integer 5 in a cell.
1936
        assert!(
×
1937
            rendered.contains(" 5 "),
2✔
1938
            "expected id=5 to be top-ranked; rendered:\n{rendered}"
1939
        );
1940
    }
1941

1942
    #[test]
1943
    fn bm25_score_without_index_errors_clearly() {
3✔
1944
        let mut db = seed_fts_table();
1✔
1945
        let err = process_command(
1946
            "SELECT id FROM docs ORDER BY bm25_score(body, 'rust') DESC LIMIT 1;",
1947
            &mut db,
1948
        )
1949
        .unwrap_err();
1950
        let msg = format!("{err}");
2✔
1951
        assert!(
×
1952
            msg.contains("no FTS index"),
2✔
1953
            "expected no-index error; got: {msg}"
1954
        );
1955
    }
1956

1957
    #[test]
1958
    fn fts_post_create_inserts_are_indexed_incrementally() {
3✔
1959
        let mut db = seed_fts_table();
1✔
1960
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
1961
        process_command(
1962
            "INSERT INTO docs (body) VALUES ('rust embedded analytics');",
1963
            &mut db,
1964
        )
1965
        .unwrap();
1966
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1967
        // PostingList::len() reports doc count; should be 6 now.
1968
        assert_eq!(table.fts_indexes[0].index.len(), 6);
1✔
1969
        // 'analytics' appears only in the new row → query returns 1 hit.
1970
        let resp = process_command(
1971
            "SELECT id FROM docs WHERE fts_match(body, 'analytics');",
1972
            &mut db,
1973
        )
1974
        .unwrap();
1975
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
1976
    }
1977

1978
    #[test]
1979
    fn delete_on_fts_indexed_table_marks_dirty() {
3✔
1980
        let mut db = seed_fts_table();
1✔
1981
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
1982
        let resp = process_command("DELETE FROM docs WHERE id = 1;", &mut db).unwrap();
1✔
1983
        assert!(resp.contains("1 row"), "got: {resp}");
2✔
1984
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
1985
        let entry = docs
3✔
1986
            .fts_indexes
1987
            .iter()
1988
            .find(|e| e.name == "ix_body")
3✔
1989
            .unwrap();
1990
        assert!(
×
1991
            entry.needs_rebuild,
1✔
1992
            "DELETE should have flagged the FTS index dirty"
1993
        );
1994
    }
1995

1996
    #[test]
1997
    fn update_on_fts_indexed_text_col_marks_dirty() {
3✔
1998
        let mut db = seed_fts_table();
1✔
1999
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2000
        let resp = process_command(
2001
            "UPDATE docs SET body = 'java spring framework' WHERE id = 1;",
2002
            &mut db,
2003
        )
2004
        .unwrap();
2005
        assert!(resp.contains("1 row"), "got: {resp}");
2✔
2006
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
2007
        let entry = docs
3✔
2008
            .fts_indexes
2009
            .iter()
2010
            .find(|e| e.name == "ix_body")
3✔
2011
            .unwrap();
2012
        assert!(
×
2013
            entry.needs_rebuild,
1✔
2014
            "UPDATE on the indexed TEXT column should have flagged dirty"
2015
        );
2016
    }
2017

2018
    #[test]
2019
    fn fts_index_name_collides_with_btree_and_hnsw_namespaces() {
3✔
2020
        let mut db = seed_fts_table();
1✔
2021
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2022
        let err = process_command("CREATE INDEX ix_body ON docs (body);", &mut db).unwrap_err();
1✔
2023
        let msg = format!("{err}");
2✔
2024
        assert!(
×
2025
            msg.to_lowercase().contains("already exists"),
3✔
2026
            "expected duplicate-index error; got: {msg}"
2027
        );
2028
    }
2029

2030
    #[test]
2031
    fn fts_index_rejects_unique() {
3✔
2032
        let mut db = seed_fts_table();
1✔
2033
        let err = process_command(
2034
            "CREATE UNIQUE INDEX ix_body ON docs USING fts (body);",
2035
            &mut db,
2036
        )
2037
        .unwrap_err();
2038
        let msg = format!("{err}");
2✔
2039
        assert!(
×
2040
            msg.to_lowercase().contains("unique"),
3✔
2041
            "expected UNIQUE-rejection error; got: {msg}"
2042
        );
2043
    }
2044

2045
    #[test]
2046
    fn try_fts_probe_falls_through_on_ascending() {
3✔
2047
        // BM25 is "higher = better"; ASC is rejected so the slow path
2048
        // applies. We verify by running the query and checking the
2049
        // result is still correct (the slow path goes through scalar
2050
        // bm25_score on every row).
2051
        let mut db = seed_fts_table();
1✔
2052
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2053
        // Same query as bm25_score_orders_descending but ASC → should
2054
        // still succeed (slow path), and id=5 should now be LAST.
2055
        let resp = process_command(
2056
            "SELECT id FROM docs WHERE fts_match(body, 'rust') \
2057
             ORDER BY bm25_score(body, 'rust') ASC LIMIT 3;",
2058
            &mut db,
2059
        )
2060
        .unwrap();
2061
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
2062
    }
2063

2064
    // -----------------------------------------------------------------
2065
    // Phase 7b — vector distance functions through process_command
2066
    // -----------------------------------------------------------------
2067

2068
    /// Builds a 3-row docs table with 2-dim vectors aligned along the
2069
    /// axes so the expected distances are easy to reason about:
2070
    ///   id=1: [1, 0]
2071
    ///   id=2: [0, 1]
2072
    ///   id=3: [1, 1]
2073
    fn seed_vector_docs() -> Database {
1✔
2074
        let mut db = Database::new("tempdb".to_string());
1✔
2075
        process_command(
2076
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
2077
            &mut db,
2078
        )
2079
        .expect("create");
2080
        process_command("INSERT INTO docs (e) VALUES ([1.0, 0.0]);", &mut db).expect("insert 1");
1✔
2081
        process_command("INSERT INTO docs (e) VALUES ([0.0, 1.0]);", &mut db).expect("insert 2");
1✔
2082
        process_command("INSERT INTO docs (e) VALUES ([1.0, 1.0]);", &mut db).expect("insert 3");
1✔
2083
        db
1✔
2084
    }
2085

2086
    #[test]
2087
    fn vec_distance_l2_in_where_filters_correctly() {
3✔
2088
        // Distance from [1,0]:
2089
        //   id=1 [1,0]: 0
2090
        //   id=2 [0,1]: √2 ≈ 1.414
2091
        //   id=3 [1,1]: 1
2092
        // WHERE distance < 1.1 should match id=1 and id=3 (2 rows).
2093
        let mut db = seed_vector_docs();
1✔
2094
        let resp = process_command(
2095
            "SELECT * FROM docs WHERE vec_distance_l2(e, [1.0, 0.0]) < 1.1;",
2096
            &mut db,
2097
        )
2098
        .expect("select");
2099
        assert!(
×
2100
            resp.contains("2 rows returned"),
2✔
2101
            "expected 2 rows, got: {resp}"
2102
        );
2103
    }
2104

2105
    #[test]
2106
    fn vec_distance_cosine_in_where() {
3✔
2107
        // [1,0] vs [1,0]: cosine distance = 0
2108
        // [1,0] vs [0,1]: cosine distance = 1 (orthogonal)
2109
        // [1,0] vs [1,1]: cosine distance = 1 - 1/√2 ≈ 0.293
2110
        // WHERE distance < 0.5 → id=1 and id=3 (2 rows).
2111
        let mut db = seed_vector_docs();
1✔
2112
        let resp = process_command(
2113
            "SELECT * FROM docs WHERE vec_distance_cosine(e, [1.0, 0.0]) < 0.5;",
2114
            &mut db,
2115
        )
2116
        .expect("select");
2117
        assert!(
×
2118
            resp.contains("2 rows returned"),
2✔
2119
            "expected 2 rows, got: {resp}"
2120
        );
2121
    }
2122

2123
    #[test]
2124
    fn vec_distance_dot_negated() {
3✔
2125
        // [1,0]·[1,0] = 1 → -1
2126
        // [1,0]·[0,1] = 0 → 0
2127
        // [1,0]·[1,1] = 1 → -1
2128
        // WHERE -dot < 0 (i.e. dot > 0) → id=1 and id=3 (2 rows).
2129
        let mut db = seed_vector_docs();
1✔
2130
        let resp = process_command(
2131
            "SELECT * FROM docs WHERE vec_distance_dot(e, [1.0, 0.0]) < 0.0;",
2132
            &mut db,
2133
        )
2134
        .expect("select");
2135
        assert!(
×
2136
            resp.contains("2 rows returned"),
2✔
2137
            "expected 2 rows, got: {resp}"
2138
        );
2139
    }
2140

2141
    #[test]
2142
    fn knn_via_order_by_distance_limit() {
3✔
2143
        // Classic KNN shape: ORDER BY distance LIMIT k.
2144
        // Distances from [1,0]: id=1=0, id=3=1, id=2=√2.
2145
        // LIMIT 2 should return id=1 then id=3 in that order.
2146
        let mut db = seed_vector_docs();
1✔
2147
        let resp = process_command(
2148
            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [1.0, 0.0]) ASC LIMIT 2;",
2149
            &mut db,
2150
        )
2151
        .expect("select");
2152
        assert!(
×
2153
            resp.contains("2 rows returned"),
2✔
2154
            "expected 2 rows, got: {resp}"
2155
        );
2156
    }
2157

2158
    #[test]
2159
    fn distance_function_dim_mismatch_errors() {
3✔
2160
        // 2-dim column queried with a 3-dim probe → clean error.
2161
        let mut db = seed_vector_docs();
1✔
2162
        let err = process_command(
2163
            "SELECT * FROM docs WHERE vec_distance_l2(e, [1.0, 0.0, 0.0]) < 1.0;",
2164
            &mut db,
2165
        )
2166
        .unwrap_err();
2167
        let msg = format!("{err}");
2✔
2168
        assert!(
×
2169
            msg.to_lowercase().contains("dimension")
2✔
2170
                && msg.contains("lhs=2")
1✔
2171
                && msg.contains("rhs=3"),
1✔
2172
            "expected dim mismatch error, got: {msg}"
2173
        );
2174
    }
2175

2176
    #[test]
2177
    fn unknown_function_errors_with_name() {
3✔
2178
        // Use the function in WHERE, not projection — the projection
2179
        // parser still requires bare column references; function calls
2180
        // there are a future enhancement (with `AS alias` support).
2181
        let mut db = seed_vector_docs();
1✔
2182
        let err = process_command(
2183
            "SELECT * FROM docs WHERE vec_does_not_exist(e, [1.0, 0.0]) < 1.0;",
2184
            &mut db,
2185
        )
2186
        .unwrap_err();
2187
        let msg = format!("{err}");
2✔
2188
        assert!(
×
2189
            msg.contains("vec_does_not_exist"),
2✔
2190
            "expected error mentioning function name, got: {msg}"
2191
        );
2192
    }
2193

2194
    // -----------------------------------------------------------------
2195
    // Phase 7e — JSON column type + path-extraction functions
2196
    // -----------------------------------------------------------------
2197

2198
    fn seed_json_table() -> Database {
1✔
2199
        let mut db = Database::new("tempdb".to_string());
1✔
2200
        process_command(
2201
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, payload JSON);",
2202
            &mut db,
2203
        )
2204
        .expect("create json table");
2205
        db
1✔
2206
    }
2207

2208
    #[test]
2209
    fn json_column_round_trip_primitive_values() {
3✔
2210
        let mut db = seed_json_table();
1✔
2211
        process_command(
2212
            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice", "age": 30}');"#,
2213
            &mut db,
2214
        )
2215
        .expect("insert json");
2216
        let docs = db.get_table("docs".to_string()).unwrap();
1✔
2217
        let rowids = docs.rowids();
1✔
2218
        assert_eq!(rowids.len(), 1);
2✔
2219
        // Stored verbatim as Text underneath.
2220
        match docs.get_value("payload", rowids[0]) {
1✔
2221
            Some(Value::Text(s)) => {
1✔
2222
                assert!(s.contains("alice"), "expected JSON text to round-trip: {s}");
2✔
2223
            }
2224
            other => panic!("expected Value::Text holding JSON, got {other:?}"),
×
2225
        }
2226
    }
2227

2228
    #[test]
2229
    fn json_insert_rejects_invalid_json() {
4✔
2230
        let mut db = seed_json_table();
1✔
2231
        let err = process_command(
2232
            "INSERT INTO docs (payload) VALUES ('not-valid-json{');",
2233
            &mut db,
2234
        )
2235
        .unwrap_err();
2236
        let msg = format!("{err}").to_lowercase();
2✔
2237
        assert!(
×
2238
            msg.contains("json") && msg.contains("payload"),
2✔
2239
            "expected JSON validation error mentioning column, got: {msg}"
2240
        );
2241
    }
2242

2243
    #[test]
2244
    fn json_extract_object_field() {
3✔
2245
        let mut db = seed_json_table();
1✔
2246
        process_command(
2247
            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice", "age": 30}');"#,
2248
            &mut db,
2249
        )
2250
        .unwrap();
2251
        // We don't have function calls in projection (yet), so test
2252
        // the function via WHERE.
2253
        let resp = process_command(
2254
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.name') = 'alice';"#,
2255
            &mut db,
2256
        )
2257
        .expect("select via json_extract");
2258
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2259

2260
        let resp = process_command(
2261
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.age') = 30;"#,
2262
            &mut db,
2263
        )
2264
        .expect("select via numeric json_extract");
2265
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2266
    }
2267

2268
    #[test]
2269
    fn json_extract_array_index_and_nested() {
3✔
2270
        let mut db = seed_json_table();
1✔
2271
        process_command(
2272
            r#"INSERT INTO docs (payload) VALUES ('{"tags": ["rust", "sql", "vectors"], "meta": {"author": "joao"}}');"#,
2273
            &mut db,
2274
        )
2275
        .unwrap();
2276
        let resp = process_command(
2277
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.tags[0]') = 'rust';"#,
2278
            &mut db,
2279
        )
2280
        .expect("select via array index");
2281
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2282

2283
        let resp = process_command(
2284
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.meta.author') = 'joao';"#,
2285
            &mut db,
2286
        )
2287
        .expect("select via nested object");
2288
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2289
    }
2290

2291
    #[test]
2292
    fn json_extract_missing_path_returns_null() {
3✔
2293
        let mut db = seed_json_table();
1✔
2294
        process_command(
2295
            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice"}');"#,
2296
            &mut db,
2297
        )
2298
        .unwrap();
2299
        // Missing key under WHERE returns NULL → predicate is false →
2300
        // 0 rows returned. (Standard SQL three-valued logic.)
2301
        let resp = process_command(
2302
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.missing') = 'something';"#,
2303
            &mut db,
2304
        )
2305
        .expect("select with missing path");
2306
        assert!(resp.contains("0 rows returned"), "got: {resp}");
2✔
2307
    }
2308

2309
    #[test]
2310
    fn json_extract_malformed_path_errors() {
3✔
2311
        let mut db = seed_json_table();
1✔
2312
        process_command(
2313
            r#"INSERT INTO docs (payload) VALUES ('{"a": 1}');"#,
2314
            &mut db,
2315
        )
2316
        .unwrap();
2317
        // Path doesn't start with '$' — syntax error.
2318
        let err = process_command(
2319
            r#"SELECT id FROM docs WHERE json_extract(payload, 'a.b') = 1;"#,
2320
            &mut db,
2321
        )
2322
        .unwrap_err();
2323
        assert!(format!("{err}").contains("'$'"));
2✔
2324
    }
2325

2326
    #[test]
2327
    fn json_array_length_on_array() {
3✔
2328
        // Note: json_array_length used in WHERE clause where it can be
2329
        // compared; that exercises the function dispatch end-to-end.
2330
        let mut db = seed_json_table();
1✔
2331
        process_command(
2332
            r#"INSERT INTO docs (payload) VALUES ('{"tags": ["a", "b", "c"]}');"#,
2333
            &mut db,
2334
        )
2335
        .unwrap();
2336
        let resp = process_command(
2337
            r#"SELECT id FROM docs WHERE json_array_length(payload, '$.tags') = 3;"#,
2338
            &mut db,
2339
        )
2340
        .expect("select via array_length");
2341
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2342
    }
2343

2344
    #[test]
2345
    fn json_array_length_on_non_array_errors() {
3✔
2346
        let mut db = seed_json_table();
1✔
2347
        process_command(
2348
            r#"INSERT INTO docs (payload) VALUES ('{"tags": "not-an-array"}');"#,
2349
            &mut db,
2350
        )
2351
        .unwrap();
2352
        let err = process_command(
2353
            r#"SELECT id FROM docs WHERE json_array_length(payload, '$.tags') = 1;"#,
2354
            &mut db,
2355
        )
2356
        .unwrap_err();
2357
        let msg = format!("{err}").to_lowercase();
2✔
2358
        assert!(
×
2359
            msg.contains("non-array"),
1✔
2360
            "expected non-array error, got: {msg}"
2361
        );
2362
    }
2363

2364
    #[test]
2365
    fn json_type_recognizes_each_kind() {
3✔
2366
        let mut db = seed_json_table();
1✔
2367
        process_command(
2368
            r#"INSERT INTO docs (payload) VALUES ('{"o": {}, "a": [], "s": "x", "i": 1, "f": 1.5, "t": true, "n": null}');"#,
2369
            &mut db,
2370
        )
2371
        .unwrap();
2372
        let cases = &[
1✔
2373
            ("$.o", "object"),
2374
            ("$.a", "array"),
2375
            ("$.s", "text"),
2376
            ("$.i", "integer"),
2377
            ("$.f", "real"),
2378
            ("$.t", "true"),
2379
            ("$.n", "null"),
2380
        ];
2381
        for (path, expected_type) in cases {
1✔
2382
            let sql = format!(
2✔
2383
                "SELECT id FROM docs WHERE json_type(payload, '{path}') = '{expected_type}';"
2384
            );
2385
            let resp =
2✔
2386
                process_command(&sql, &mut db).unwrap_or_else(|e| panic!("path {path}: {e}"));
2387
            assert!(
×
2388
                resp.contains("1 row returned"),
2✔
2389
                "path {path} expected type {expected_type}; got response: {resp}"
2390
            );
2391
        }
2392
    }
2393

2394
    #[test]
2395
    fn update_on_json_column_revalidates() {
3✔
2396
        let mut db = seed_json_table();
1✔
2397
        process_command(
2398
            r#"INSERT INTO docs (payload) VALUES ('{"a": 1}');"#,
2399
            &mut db,
2400
        )
2401
        .unwrap();
2402
        // Valid JSON update succeeds.
2403
        process_command(
2404
            r#"UPDATE docs SET payload = '{"a": 2, "b": 3}' WHERE id = 1;"#,
2405
            &mut db,
2406
        )
2407
        .expect("valid JSON UPDATE");
2408
        // Invalid JSON in UPDATE is rejected with the same shape of
2409
        // error as INSERT.
2410
        let err = process_command(
2411
            r#"UPDATE docs SET payload = 'not-json{' WHERE id = 1;"#,
2412
            &mut db,
2413
        )
2414
        .unwrap_err();
2415
        let msg = format!("{err}").to_lowercase();
2✔
2416
        assert!(
×
2417
            msg.contains("json") && msg.contains("payload"),
2✔
2418
            "got: {msg}"
2419
        );
2420
    }
2421

2422
    // -------------------------------------------------------------------
2423
    // DEFAULT clause on CREATE TABLE columns
2424
    // -------------------------------------------------------------------
2425

2426
    #[test]
2427
    fn default_literal_int_applies_when_column_omitted() {
3✔
2428
        let mut db = Database::new("t".to_string());
1✔
2429
        process_command(
2430
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER DEFAULT 42);",
2431
            &mut db,
2432
        )
2433
        .unwrap();
2434
        process_command("INSERT INTO t (id) VALUES (1);", &mut db).unwrap();
1✔
2435

2436
        let table = db.get_table("t".to_string()).unwrap();
1✔
2437
        assert_eq!(table.get_value("n", 1), Some(Value::Integer(42)));
1✔
2438
    }
2439

2440
    #[test]
2441
    fn default_literal_text_applies_when_column_omitted() {
3✔
2442
        let mut db = Database::new("t".to_string());
1✔
2443
        process_command(
2444
            "CREATE TABLE users (id INTEGER PRIMARY KEY, status TEXT DEFAULT 'active');",
2445
            &mut db,
2446
        )
2447
        .unwrap();
2448
        process_command("INSERT INTO users (id) VALUES (1);", &mut db).unwrap();
1✔
2449

2450
        let table = db.get_table("users".to_string()).unwrap();
1✔
2451
        assert_eq!(
1✔
2452
            table.get_value("status", 1),
1✔
2453
            Some(Value::Text("active".to_string()))
2✔
2454
        );
2455
    }
2456

2457
    #[test]
2458
    fn default_literal_real_negative_applies_when_column_omitted() {
3✔
2459
        // `DEFAULT -1.5` arrives as a UnaryOp(Minus, Number) — exercise that path.
2460
        let mut db = Database::new("t".to_string());
1✔
2461
        process_command(
2462
            "CREATE TABLE t (id INTEGER PRIMARY KEY, score REAL DEFAULT -1.5);",
2463
            &mut db,
2464
        )
2465
        .unwrap();
2466
        process_command("INSERT INTO t (id) VALUES (1);", &mut db).unwrap();
1✔
2467

2468
        let table = db.get_table("t".to_string()).unwrap();
1✔
2469
        assert_eq!(table.get_value("score", 1), Some(Value::Real(-1.5)));
1✔
2470
    }
2471

2472
    #[test]
2473
    fn default_with_type_mismatch_errors_at_create_time() {
4✔
2474
        let mut db = Database::new("t".to_string());
1✔
2475
        let result = process_command(
2476
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER DEFAULT 'oops');",
2477
            &mut db,
2478
        );
2479
        let err = result.expect_err("text default on INTEGER column should be rejected");
1✔
2480
        let msg = format!("{err}").to_lowercase();
2✔
2481
        assert!(msg.contains("default"), "got: {msg}");
1✔
2482
    }
2483

2484
    #[test]
2485
    fn default_for_json_column_must_be_valid_json() {
4✔
2486
        // ADD COLUMN ... JSON DEFAULT 'not-json' would otherwise backfill
2487
        // every existing row with invalid JSON (insert_row's validation
2488
        // is bypassed for the backfill path).
2489
        let mut db = Database::new("t".to_string());
1✔
2490
        let err = process_command(
2491
            "CREATE TABLE t (id INTEGER PRIMARY KEY, doc JSON DEFAULT 'not-json{');",
2492
            &mut db,
2493
        )
2494
        .unwrap_err();
2495
        assert!(
×
2496
            format!("{err}").to_lowercase().contains("json"),
4✔
2497
            "got: {err}"
2498
        );
2499

2500
        // Valid JSON DEFAULT works.
2501
        process_command(
2502
            "CREATE TABLE t2 (id INTEGER PRIMARY KEY, doc JSON DEFAULT '{\"k\":1}');",
2503
            &mut db,
2504
        )
2505
        .expect("valid JSON DEFAULT should be accepted");
2506
    }
2507

2508
    #[test]
2509
    fn default_with_non_literal_expression_errors_at_create_time() {
3✔
2510
        let mut db = Database::new("t".to_string());
1✔
2511
        // Function-call DEFAULT (e.g. CURRENT_TIMESTAMP) → rejected; we only
2512
        // accept literal expressions for now.
2513
        let result = process_command(
2514
            "CREATE TABLE t (id INTEGER PRIMARY KEY, ts TEXT DEFAULT CURRENT_TIMESTAMP);",
2515
            &mut db,
2516
        );
2517
        let err = result.expect_err("non-literal DEFAULT should be rejected");
1✔
2518
        let msg = format!("{err}").to_lowercase();
2✔
2519
        assert!(
×
2520
            msg.contains("default") && msg.contains("literal"),
2✔
2521
            "got: {msg}"
2522
        );
2523
    }
2524

2525
    #[test]
2526
    fn default_null_is_accepted_at_create_time() {
3✔
2527
        // `DEFAULT NULL` is a no-op equivalent to no DEFAULT clause; the
2528
        // important thing is that CREATE TABLE accepts it without error
2529
        // (some DDL exporters emit `DEFAULT NULL` redundantly).
2530
        let mut db = Database::new("t".to_string());
1✔
2531
        process_command(
2532
            "CREATE TABLE t (id INTEGER PRIMARY KEY, note TEXT DEFAULT NULL);",
2533
            &mut db,
2534
        )
2535
        .expect("CREATE TABLE with DEFAULT NULL should be accepted");
2536
        let table = db.get_table("t".to_string()).unwrap();
1✔
2537
        let note = table
3✔
2538
            .columns
2539
            .iter()
2540
            .find(|c| c.column_name == "note")
3✔
2541
            .unwrap();
2542
        assert_eq!(note.default, Some(Value::Null));
1✔
2543
    }
2544

2545
    // -------------------------------------------------------------------
2546
    // DROP TABLE / DROP INDEX
2547
    // -------------------------------------------------------------------
2548

2549
    #[test]
2550
    fn drop_table_basic() {
3✔
2551
        let mut db = seed_users_table();
1✔
2552
        let response = process_command("DROP TABLE users;", &mut db).expect("drop table");
2✔
2553
        assert!(response.contains("1 table dropped"));
2✔
2554
        assert!(!db.contains_table("users".to_string()));
1✔
2555
    }
2556

2557
    #[test]
2558
    fn drop_table_if_exists_noop_on_missing() {
3✔
2559
        let mut db = Database::new("t".to_string());
1✔
2560
        let response =
2✔
2561
            process_command("DROP TABLE IF EXISTS missing;", &mut db).expect("drop if exists");
2562
        assert!(response.contains("0 tables dropped"));
2✔
2563
    }
2564

2565
    #[test]
2566
    fn drop_table_missing_errors_without_if_exists() {
3✔
2567
        let mut db = Database::new("t".to_string());
1✔
2568
        let err = process_command("DROP TABLE missing;", &mut db).unwrap_err();
2✔
2569
        assert!(format!("{err}").contains("does not exist"), "got: {err}");
2✔
2570
    }
2571

2572
    #[test]
2573
    fn drop_table_reserved_name_errors() {
3✔
2574
        let mut db = Database::new("t".to_string());
1✔
2575
        let err = process_command("DROP TABLE sqlrite_master;", &mut db).unwrap_err();
2✔
2576
        assert!(format!("{err}").contains("reserved"), "got: {err}");
2✔
2577
    }
2578

2579
    #[test]
2580
    fn drop_table_multi_target_rejected() {
3✔
2581
        let mut db = seed_users_table();
1✔
2582
        process_command("CREATE TABLE other (id INTEGER PRIMARY KEY);", &mut db).unwrap();
2✔
2583
        // sqlparser accepts `DROP TABLE a, b` as one statement; we reject
2584
        // to keep error semantics simple (no partial-failure rollback).
2585
        let err = process_command("DROP TABLE users, other;", &mut db).unwrap_err();
1✔
2586
        assert!(format!("{err}").contains("single table"), "got: {err}");
2✔
2587
    }
2588

2589
    #[test]
2590
    fn drop_table_cascades_indexes_in_memory() {
3✔
2591
        let mut db = seed_users_table();
1✔
2592
        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2✔
2593
        // PK auto-index + UNIQUE-on-name auto-index + the explicit one.
2594
        let users = db.get_table("users".to_string()).unwrap();
1✔
2595
        assert!(
×
2596
            users
3✔
2597
                .secondary_indexes
2598
                .iter()
1✔
2599
                .any(|i| i.name == "users_age_idx")
3✔
2600
        );
2601

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

2604
        // After DROP TABLE, no other table should claim the dropped indexes.
2605
        for table in db.tables.values() {
1✔
2606
            assert!(
×
2607
                !table
×
2608
                    .secondary_indexes
2609
                    .iter()
×
2610
                    .any(|i| i.name.contains("users")),
×
2611
                "dropped table's indexes should not survive on any other table"
2612
            );
2613
        }
2614
    }
2615

2616
    #[test]
2617
    fn drop_index_explicit_basic() {
3✔
2618
        let mut db = seed_users_table();
1✔
2619
        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2✔
2620
        let response = process_command("DROP INDEX users_age_idx;", &mut db).expect("drop index");
1✔
2621
        assert!(response.contains("1 index dropped"));
2✔
2622

2623
        let users = db.get_table("users".to_string()).unwrap();
1✔
2624
        assert!(users.index_by_name("users_age_idx").is_none());
1✔
2625
    }
2626

2627
    #[test]
2628
    fn drop_index_refuses_auto_index() {
3✔
2629
        let mut db = seed_users_table();
1✔
2630
        // `users` was created with `id INTEGER PRIMARY KEY` → auto-index
2631
        // named `sqlrite_autoindex_users_id`.
2632
        let err = process_command("DROP INDEX sqlrite_autoindex_users_id;", &mut db).unwrap_err();
2✔
2633
        assert!(format!("{err}").contains("auto-created"), "got: {err}");
2✔
2634
    }
2635

2636
    #[test]
2637
    fn drop_index_if_exists_noop_on_missing() {
3✔
2638
        let mut db = Database::new("t".to_string());
1✔
2639
        let response =
2✔
2640
            process_command("DROP INDEX IF EXISTS nope;", &mut db).expect("drop index if exists");
2641
        assert!(response.contains("0 indexes dropped"));
2✔
2642
    }
2643

2644
    #[test]
2645
    fn drop_index_missing_errors_without_if_exists() {
3✔
2646
        let mut db = Database::new("t".to_string());
1✔
2647
        let err = process_command("DROP INDEX nope;", &mut db).unwrap_err();
2✔
2648
        assert!(format!("{err}").contains("does not exist"), "got: {err}");
2✔
2649
    }
2650

2651
    #[test]
2652
    fn drop_statements_rejected_on_readonly_db() {
3✔
2653
        use crate::sql::pager::{open_database_read_only, save_database};
2654

2655
        let mut seed = Database::new("t".to_string());
1✔
2656
        process_command(
2657
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
2658
            &mut seed,
2659
        )
2660
        .unwrap();
2661
        process_command("CREATE INDEX notes_body ON notes (body);", &mut seed).unwrap();
1✔
2662
        let path = {
2663
            let mut p = std::env::temp_dir();
1✔
2664
            let pid = std::process::id();
2✔
2665
            let nanos = std::time::SystemTime::now()
2✔
2666
                .duration_since(std::time::UNIX_EPOCH)
1✔
2667
                .map(|d| d.as_nanos())
3✔
2668
                .unwrap_or(0);
2669
            p.push(format!("sqlrite-drop-ro-{pid}-{nanos}.sqlrite"));
1✔
2670
            p
1✔
2671
        };
2672
        save_database(&mut seed, &path).unwrap();
2✔
2673
        drop(seed);
1✔
2674

2675
        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1✔
2676
        for stmt in ["DROP TABLE notes;", "DROP INDEX notes_body;"] {
3✔
2677
            let err = process_command(stmt, &mut ro).unwrap_err();
2✔
2678
            assert!(
×
2679
                format!("{err}").contains("read-only"),
3✔
2680
                "{stmt:?} should surface read-only error, got: {err}"
2681
            );
2682
        }
2683

2684
        let _ = std::fs::remove_file(&path);
1✔
2685
        let mut wal = path.as_os_str().to_owned();
1✔
2686
        wal.push("-wal");
1✔
2687
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
2688
    }
2689

2690
    // -------------------------------------------------------------------
2691
    // ALTER TABLE — RENAME TO / RENAME COLUMN / ADD COLUMN / DROP COLUMN
2692
    // -------------------------------------------------------------------
2693

2694
    #[test]
2695
    fn alter_rename_table_basic() {
3✔
2696
        let mut db = seed_users_table();
1✔
2697
        process_command("ALTER TABLE users RENAME TO members;", &mut db).expect("rename table");
2✔
2698
        assert!(!db.contains_table("users".to_string()));
1✔
2699
        assert!(db.contains_table("members".to_string()));
2✔
2700
        // Data still queryable under the new name.
2701
        let response = process_command("SELECT * FROM members;", &mut db).expect("select");
1✔
2702
        assert!(response.contains("3 rows returned"));
2✔
2703
    }
2704

2705
    #[test]
2706
    fn alter_rename_table_renames_auto_indexes() {
3✔
2707
        // Use a fresh table with both PK and a UNIQUE column so we
2708
        // exercise both auto-index renames in one shot.
2709
        let mut db = Database::new("t".to_string());
1✔
2710
        process_command(
2711
            "CREATE TABLE accounts (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
2712
            &mut db,
2713
        )
2714
        .unwrap();
2715
        {
2716
            let accounts = db.get_table("accounts".to_string()).unwrap();
1✔
2717
            assert!(
×
2718
                accounts
2✔
2719
                    .index_by_name("sqlrite_autoindex_accounts_id")
1✔
2720
                    .is_some()
1✔
2721
            );
2722
            assert!(
×
2723
                accounts
2✔
2724
                    .index_by_name("sqlrite_autoindex_accounts_email")
1✔
2725
                    .is_some()
1✔
2726
            );
2727
        }
2728
        process_command("ALTER TABLE accounts RENAME TO members;", &mut db).expect("rename");
1✔
2729
        let members = db.get_table("members".to_string()).unwrap();
1✔
2730
        assert!(
×
2731
            members
2✔
2732
                .index_by_name("sqlrite_autoindex_members_id")
1✔
2733
                .is_some(),
1✔
2734
            "PK auto-index should be renamed to match new table"
2735
        );
2736
        assert!(
×
2737
            members
2✔
2738
                .index_by_name("sqlrite_autoindex_members_email")
1✔
2739
                .is_some()
1✔
2740
        );
2741
        // The old-named auto-indexes should be gone.
2742
        assert!(
×
2743
            members
2✔
2744
                .index_by_name("sqlrite_autoindex_accounts_id")
1✔
2745
                .is_none()
1✔
2746
        );
2747
        // table_name field on each index should also reflect the rename.
2748
        for idx in &members.secondary_indexes {
1✔
2749
            assert_eq!(idx.table_name, "members");
2✔
2750
        }
2751
    }
2752

2753
    #[test]
2754
    fn alter_rename_table_to_existing_errors() {
3✔
2755
        let mut db = seed_users_table();
1✔
2756
        process_command("CREATE TABLE other (id INTEGER PRIMARY KEY);", &mut db).unwrap();
2✔
2757
        let err = process_command("ALTER TABLE users RENAME TO other;", &mut db).unwrap_err();
1✔
2758
        assert!(format!("{err}").contains("already exists"), "got: {err}");
2✔
2759
        // Both tables still present.
2760
        assert!(db.contains_table("users".to_string()));
1✔
2761
        assert!(db.contains_table("other".to_string()));
1✔
2762
    }
2763

2764
    #[test]
2765
    fn alter_rename_table_to_reserved_name_errors() {
3✔
2766
        let mut db = seed_users_table();
1✔
2767
        let err =
2✔
2768
            process_command("ALTER TABLE users RENAME TO sqlrite_master;", &mut db).unwrap_err();
2769
        assert!(format!("{err}").contains("reserved"), "got: {err}");
2✔
2770
    }
2771

2772
    #[test]
2773
    fn alter_rename_column_basic() {
3✔
2774
        let mut db = seed_users_table();
1✔
2775
        process_command(
2776
            "ALTER TABLE users RENAME COLUMN name TO full_name;",
2777
            &mut db,
2778
        )
2779
        .expect("rename column");
2780

2781
        let users = db.get_table("users".to_string()).unwrap();
1✔
2782
        assert!(users.contains_column("full_name".to_string()));
1✔
2783
        assert!(!users.contains_column("name".to_string()));
1✔
2784

2785
        // Existing data is queryable under the new column name and value
2786
        // is preserved at the same rowid.
2787
        let bob_rowid = users
1✔
2788
            .rowids()
2789
            .into_iter()
2790
            .find(|r| users.get_value("full_name", *r) == Some(Value::Text("bob".to_string())))
3✔
2791
            .expect("bob row should be findable under the new column name");
2792
        assert_eq!(
1✔
2793
            users.get_value("full_name", bob_rowid),
1✔
2794
            Some(Value::Text("bob".to_string()))
2✔
2795
        );
2796
    }
2797

2798
    #[test]
2799
    fn alter_rename_column_collision_errors() {
3✔
2800
        let mut db = seed_users_table();
1✔
2801
        let err =
2✔
2802
            process_command("ALTER TABLE users RENAME COLUMN name TO age;", &mut db).unwrap_err();
2803
        assert!(format!("{err}").contains("already exists"), "got: {err}");
2✔
2804
    }
2805

2806
    #[test]
2807
    fn alter_rename_column_updates_indexes() {
3✔
2808
        // `accounts.email` is UNIQUE → has a renameable auto-index.
2809
        let mut db = Database::new("t".to_string());
1✔
2810
        process_command(
2811
            "CREATE TABLE accounts (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
2812
            &mut db,
2813
        )
2814
        .unwrap();
2815
        process_command(
2816
            "ALTER TABLE accounts RENAME COLUMN email TO contact;",
2817
            &mut db,
2818
        )
2819
        .unwrap();
2820
        let accounts = db.get_table("accounts".to_string()).unwrap();
1✔
2821
        assert!(
×
2822
            accounts
2✔
2823
                .index_by_name("sqlrite_autoindex_accounts_contact")
1✔
2824
                .is_some()
1✔
2825
        );
2826
        assert!(
×
2827
            accounts
2✔
2828
                .index_by_name("sqlrite_autoindex_accounts_email")
1✔
2829
                .is_none()
1✔
2830
        );
2831
    }
2832

2833
    #[test]
2834
    fn alter_add_column_basic() {
3✔
2835
        let mut db = seed_users_table();
1✔
2836
        process_command("ALTER TABLE users ADD COLUMN nickname TEXT;", &mut db)
1✔
2837
            .expect("add column");
2838
        let users = db.get_table("users".to_string()).unwrap();
1✔
2839
        assert!(users.contains_column("nickname".to_string()));
1✔
2840
        // Existing rows read NULL for the new column (no default given).
2841
        let any_rowid = *users.rowids().first().expect("seed has rows");
1✔
2842
        assert_eq!(users.get_value("nickname", any_rowid), None);
1✔
2843

2844
        // A new INSERT supplying the new column works.
2845
        process_command(
2846
            "INSERT INTO users (name, age, nickname) VALUES ('dan', 22, 'd');",
2847
            &mut db,
2848
        )
2849
        .expect("insert with new col");
2850
        let users = db.get_table("users".to_string()).unwrap();
1✔
2851
        let dan_rowid = users
1✔
2852
            .rowids()
2853
            .into_iter()
2854
            .find(|r| users.get_value("name", *r) == Some(Value::Text("dan".to_string())))
3✔
2855
            .unwrap();
2856
        assert_eq!(
1✔
2857
            users.get_value("nickname", dan_rowid),
1✔
2858
            Some(Value::Text("d".to_string()))
2✔
2859
        );
2860
    }
2861

2862
    #[test]
2863
    fn alter_add_column_with_default_backfills_existing_rows() {
3✔
2864
        let mut db = seed_users_table();
1✔
2865
        process_command(
2866
            "ALTER TABLE users ADD COLUMN status TEXT DEFAULT 'active';",
2867
            &mut db,
2868
        )
2869
        .expect("add column with default");
2870
        let users = db.get_table("users".to_string()).unwrap();
1✔
2871
        for rowid in users.rowids() {
2✔
2872
            assert_eq!(
1✔
2873
                users.get_value("status", rowid),
1✔
2874
                Some(Value::Text("active".to_string())),
2✔
2875
                "rowid {rowid} should have been backfilled with the default"
2876
            );
2877
        }
2878
    }
2879

2880
    #[test]
2881
    fn alter_add_column_not_null_with_default_works_on_nonempty_table() {
3✔
2882
        let mut db = seed_users_table();
1✔
2883
        process_command(
2884
            "ALTER TABLE users ADD COLUMN score INTEGER NOT NULL DEFAULT 0;",
2885
            &mut db,
2886
        )
2887
        .expect("NOT NULL ADD with DEFAULT should succeed even with existing rows");
2888
        let users = db.get_table("users".to_string()).unwrap();
1✔
2889
        for rowid in users.rowids() {
2✔
2890
            assert_eq!(users.get_value("score", rowid), Some(Value::Integer(0)));
2✔
2891
        }
2892
    }
2893

2894
    #[test]
2895
    fn alter_add_column_not_null_without_default_errors_on_nonempty_table() {
4✔
2896
        let mut db = seed_users_table();
1✔
2897
        let err = process_command(
2898
            "ALTER TABLE users ADD COLUMN score INTEGER NOT NULL;",
2899
            &mut db,
2900
        )
2901
        .unwrap_err();
2902
        let msg = format!("{err}").to_lowercase();
2✔
2903
        assert!(
×
2904
            msg.contains("not null") && msg.contains("default"),
2✔
2905
            "got: {msg}"
2906
        );
2907
    }
2908

2909
    #[test]
2910
    fn alter_add_column_pk_rejected() {
3✔
2911
        let mut db = seed_users_table();
1✔
2912
        let err = process_command(
2913
            "ALTER TABLE users ADD COLUMN extra INTEGER PRIMARY KEY;",
2914
            &mut db,
2915
        )
2916
        .unwrap_err();
2917
        assert!(
×
2918
            format!("{err}").to_lowercase().contains("primary key"),
4✔
2919
            "got: {err}"
2920
        );
2921
    }
2922

2923
    #[test]
2924
    fn alter_add_column_unique_rejected() {
3✔
2925
        let mut db = seed_users_table();
1✔
2926
        let err = process_command(
2927
            "ALTER TABLE users ADD COLUMN extra INTEGER UNIQUE;",
2928
            &mut db,
2929
        )
2930
        .unwrap_err();
2931
        assert!(
×
2932
            format!("{err}").to_lowercase().contains("unique"),
4✔
2933
            "got: {err}"
2934
        );
2935
    }
2936

2937
    #[test]
2938
    fn alter_add_column_existing_name_errors() {
3✔
2939
        let mut db = seed_users_table();
1✔
2940
        let err =
2✔
2941
            process_command("ALTER TABLE users ADD COLUMN age INTEGER;", &mut db).unwrap_err();
2942
        assert!(format!("{err}").contains("already exists"), "got: {err}");
2✔
2943
    }
2944

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

2950
    #[test]
2951
    fn alter_drop_column_basic() {
3✔
2952
        let mut db = seed_users_table();
1✔
2953
        process_command("ALTER TABLE users DROP COLUMN age;", &mut db).expect("drop column");
2✔
2954
        let users = db.get_table("users".to_string()).unwrap();
1✔
2955
        assert!(!users.contains_column("age".to_string()));
1✔
2956
        // Other columns and rowids still intact.
2957
        assert!(users.contains_column("name".to_string()));
2✔
2958
        assert_eq!(users.rowids().len(), 3);
1✔
2959
    }
2960

2961
    #[test]
2962
    fn alter_drop_column_drops_dependent_indexes() {
3✔
2963
        let mut db = seed_users_table();
1✔
2964
        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2✔
2965
        process_command("ALTER TABLE users DROP COLUMN age;", &mut db).unwrap();
1✔
2966
        let users = db.get_table("users".to_string()).unwrap();
1✔
2967
        assert!(users.index_by_name("users_age_idx").is_none());
1✔
2968
    }
2969

2970
    #[test]
2971
    fn alter_drop_column_pk_errors() {
3✔
2972
        let mut db = seed_users_table();
1✔
2973
        let err = process_command("ALTER TABLE users DROP COLUMN id;", &mut db).unwrap_err();
2✔
2974
        assert!(
×
2975
            format!("{err}").to_lowercase().contains("primary key"),
4✔
2976
            "got: {err}"
2977
        );
2978
    }
2979

2980
    #[test]
2981
    fn alter_drop_column_only_column_errors() {
3✔
2982
        let mut db = Database::new("t".to_string());
1✔
2983
        process_command("CREATE TABLE solo (only_col TEXT);", &mut db).unwrap();
2✔
2984
        let err = process_command("ALTER TABLE solo DROP COLUMN only_col;", &mut db).unwrap_err();
1✔
2985
        assert!(
×
2986
            format!("{err}").to_lowercase().contains("only column"),
4✔
2987
            "got: {err}"
2988
        );
2989
    }
2990

2991
    #[test]
2992
    fn alter_unknown_table_errors_without_if_exists() {
3✔
2993
        let mut db = Database::new("t".to_string());
1✔
2994
        let err = process_command("ALTER TABLE missing RENAME TO other;", &mut db).unwrap_err();
2✔
2995
        assert!(format!("{err}").contains("does not exist"), "got: {err}");
2✔
2996
    }
2997

2998
    #[test]
2999
    fn alter_unknown_table_if_exists_noop() {
3✔
3000
        let mut db = Database::new("t".to_string());
1✔
3001
        let response = process_command("ALTER TABLE IF EXISTS missing RENAME TO other;", &mut db)
1✔
3002
            .expect("IF EXISTS makes missing-table ALTER a no-op");
3003
        assert!(response.contains("no-op"));
2✔
3004
    }
3005

3006
    #[test]
3007
    fn drop_table_inside_transaction_rolls_back() {
3✔
3008
        // Exercises Database::deep_clone snapshot path with DROP TABLE.
3009
        // A wholesale tables-HashMap restore on ROLLBACK should resurrect
3010
        // the dropped table — including its data and dependent indexes.
3011
        let mut db = seed_users_table();
1✔
3012
        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2✔
3013
        process_command("BEGIN;", &mut db).unwrap();
1✔
3014
        process_command("DROP TABLE users;", &mut db).unwrap();
1✔
3015
        assert!(!db.contains_table("users".to_string()));
1✔
3016
        process_command("ROLLBACK;", &mut db).unwrap();
2✔
3017
        assert!(db.contains_table("users".to_string()));
1✔
3018
        let users = db.get_table("users".to_string()).unwrap();
1✔
3019
        assert_eq!(users.rowids().len(), 3);
1✔
3020
        assert!(users.index_by_name("users_age_idx").is_some());
1✔
3021
    }
3022

3023
    #[test]
3024
    fn alter_inside_transaction_rolls_back() {
3✔
3025
        let mut db = seed_users_table();
1✔
3026
        process_command("BEGIN;", &mut db).unwrap();
2✔
3027
        process_command(
3028
            "ALTER TABLE users ADD COLUMN status TEXT DEFAULT 'active';",
3029
            &mut db,
3030
        )
3031
        .unwrap();
3032
        // Confirm in-flight visibility.
3033
        assert!(
×
3034
            db.get_table("users".to_string())
2✔
3035
                .unwrap()
1✔
3036
                .contains_column("status".to_string())
1✔
3037
        );
3038
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
3039
        // Snapshot restore should erase the ALTER.
3040
        assert!(
×
3041
            !db.get_table("users".to_string())
2✔
3042
                .unwrap()
1✔
3043
                .contains_column("status".to_string())
1✔
3044
        );
3045
    }
3046

3047
    #[test]
3048
    fn alter_rejected_on_readonly_db() {
3✔
3049
        use crate::sql::pager::{open_database_read_only, save_database};
3050

3051
        let mut seed = Database::new("t".to_string());
1✔
3052
        process_command(
3053
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
3054
            &mut seed,
3055
        )
3056
        .unwrap();
3057
        let path = {
3058
            let mut p = std::env::temp_dir();
1✔
3059
            let pid = std::process::id();
2✔
3060
            let nanos = std::time::SystemTime::now()
2✔
3061
                .duration_since(std::time::UNIX_EPOCH)
1✔
3062
                .map(|d| d.as_nanos())
3✔
3063
                .unwrap_or(0);
3064
            p.push(format!("sqlrite-alter-ro-{pid}-{nanos}.sqlrite"));
1✔
3065
            p
1✔
3066
        };
3067
        save_database(&mut seed, &path).unwrap();
2✔
3068
        drop(seed);
1✔
3069

3070
        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1✔
3071
        for stmt in [
3✔
3072
            "ALTER TABLE notes RENAME TO n2;",
3073
            "ALTER TABLE notes RENAME COLUMN body TO b;",
3074
            "ALTER TABLE notes ADD COLUMN extra TEXT;",
3075
            "ALTER TABLE notes DROP COLUMN body;",
3076
        ] {
3077
            let err = process_command(stmt, &mut ro).unwrap_err();
2✔
3078
            assert!(
×
3079
                format!("{err}").contains("read-only"),
3✔
3080
                "{stmt:?} should surface read-only error, got: {err}"
3081
            );
3082
        }
3083

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

© 2026 Coveralls, Inc