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

joaoh82 / rust_sqlite / 25364033136

05 May 2026 07:42AM UTC coverage: 63.042% (+0.4%) from 62.595%
25364033136

push

github

web-flow
feat(storage): auto-VACUUM trigger after page-releasing DDL (SQLR-10) (#91)

Compacts the file in place after DROP TABLE / DROP INDEX / ALTER TABLE
DROP COLUMN whenever the freelist crosses a configurable fraction of
page_count (default 0.25, SQLite parity). The threshold is tunable
per-connection via Connection::set_auto_vacuum_threshold; pass None to
opt out and keep the manual-VACUUM-only behavior. Skipped mid-txn, on
in-memory / read-only DBs, and below a 16-page floor so tiny files
don't churn.

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

154 of 170 new or added lines in 5 files covered. (90.59%)

7560 of 11992 relevant lines covered (63.04%)

1.2 hits per line

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

92.47
/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✔
NEW
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 process_command_insert_missing_integer_returns_error_test() {
3✔
649
        // Non-PK INTEGER without a value should error (not panic on "Null".parse()).
650
        let mut db = Database::new("tempdb".to_string());
1✔
651
        process_command(
652
            "CREATE TABLE items (id INTEGER PRIMARY KEY, qty INTEGER);",
653
            &mut db,
654
        )
655
        .unwrap();
656
        let result = process_command("INSERT INTO items (id) VALUES (1);", &mut db);
1✔
657
        assert!(result.is_err(), "expected error, got {result:?}");
2✔
658
    }
659

660
    #[test]
661
    fn process_command_update_arith_test() {
3✔
662
        use crate::sql::db::table::Value;
663

664
        let mut db = seed_users_table();
1✔
665
        process_command("UPDATE users SET age = age + 1;", &mut db).expect("update +1");
2✔
666

667
        let users = db.get_table("users".to_string()).unwrap();
1✔
668
        let mut ages: Vec<i64> = users
669
            .rowids()
670
            .into_iter()
671
            .filter_map(|r| match users.get_value("age", r) {
3✔
672
                Some(Value::Integer(n)) => Some(n),
1✔
673
                _ => None,
×
674
            })
675
            .collect();
676
        ages.sort();
2✔
677
        assert_eq!(ages, vec![26, 31, 41]); // 25+1, 30+1, 40+1
1✔
678
    }
679

680
    #[test]
681
    fn process_command_select_arithmetic_where_test() {
3✔
682
        let mut db = seed_users_table();
1✔
683
        // age * 2 > 55  →  only ages > 27.5  →  alice(30) + carol(40)
684
        let response =
2✔
685
            process_command("SELECT name FROM users WHERE age * 2 > 55;", &mut db).expect("select");
686
        assert!(response.contains("2 rows returned"));
2✔
687
    }
688

689
    #[test]
690
    fn process_command_divide_by_zero_test() {
3✔
691
        let mut db = seed_users_table();
1✔
692
        let result = process_command("SELECT age / 0 FROM users;", &mut db);
1✔
693
        // Projection only supports bare columns, so this errors earlier; still shouldn't panic.
694
        assert!(result.is_err());
2✔
695
    }
696

697
    #[test]
698
    fn process_command_unsupported_statement_test() {
3✔
699
        let mut db = Database::new("tempdb".to_string());
1✔
700
        // CREATE VIEW is firmly in the "Not yet supported" list — used as
701
        // the canary for the dispatcher's NotImplemented arm. (DROP TABLE
702
        // moved out of unsupported in this branch.)
703
        let result = process_command("CREATE VIEW v AS SELECT * FROM users;", &mut db);
1✔
704
        assert!(result.is_err());
2✔
705
    }
706

707
    #[test]
708
    fn empty_input_is_a_noop_not_a_panic() {
3✔
709
        // Regression for: desktop app pre-fills the textarea with a
710
        // comment-only placeholder, and hitting Run used to panic because
711
        // sqlparser produced zero statements and pop().unwrap() exploded.
712
        let mut db = Database::new("t".to_string());
1✔
713
        for input in ["", "   ", "-- just a comment", "-- comment\n-- another"] {
4✔
714
            let result = process_command(input, &mut db);
2✔
715
            assert!(result.is_ok(), "input {input:?} should not error");
2✔
716
            let msg = result.unwrap();
1✔
717
            assert!(msg.contains("No statement"), "got: {msg:?}");
2✔
718
        }
719
    }
720

721
    #[test]
722
    fn create_index_adds_explicit_index() {
3✔
723
        let mut db = seed_users_table();
1✔
724
        let response = process_command("CREATE INDEX users_age_idx ON users (age);", &mut db)
1✔
725
            .expect("create index");
726
        assert!(response.contains("users_age_idx"));
2✔
727

728
        // The index should now be attached to the users table.
729
        let users = db.get_table("users".to_string()).unwrap();
1✔
730
        let idx = users
1✔
731
            .index_by_name("users_age_idx")
732
            .expect("index should exist after CREATE INDEX");
733
        assert_eq!(idx.column_name, "age");
1✔
734
        assert!(!idx.is_unique);
1✔
735
    }
736

737
    #[test]
738
    fn create_unique_index_rejects_duplicate_existing_values() {
3✔
739
        let mut db = seed_users_table();
1✔
740
        // `name` is already UNIQUE (auto-indexed); insert a duplicate-age row
741
        // first so CREATE UNIQUE INDEX on age catches the conflict.
742
        process_command("INSERT INTO users (name, age) VALUES ('dan', 30);", &mut db).unwrap();
2✔
743
        let result = process_command(
744
            "CREATE UNIQUE INDEX users_age_unique ON users (age);",
745
            &mut db,
746
        );
747
        assert!(
×
748
            result.is_err(),
2✔
749
            "expected unique-index failure, got {result:?}"
750
        );
751
    }
752

753
    #[test]
754
    fn where_eq_on_indexed_column_uses_index_probe() {
3✔
755
        // Build a table big enough that a full scan would be expensive,
756
        // then rely on the index-probe fast path. This test verifies
757
        // correctness (right rows returned); the perf win is implicit.
758
        let mut db = Database::new("t".to_string());
1✔
759
        process_command(
760
            "CREATE TABLE big (id INTEGER PRIMARY KEY, tag TEXT);",
761
            &mut db,
762
        )
763
        .unwrap();
764
        process_command("CREATE INDEX big_tag_idx ON big (tag);", &mut db).unwrap();
1✔
765
        for i in 1..=100 {
1✔
766
            let tag = if i % 3 == 0 { "hot" } else { "cold" };
2✔
767
            process_command(&format!("INSERT INTO big (tag) VALUES ('{tag}');"), &mut db).unwrap();
1✔
768
        }
769
        let response =
1✔
770
            process_command("SELECT id FROM big WHERE tag = 'hot';", &mut db).expect("select");
771
        // 1..=100 has 33 multiples of 3.
772
        assert!(
×
773
            response.contains("33 rows returned"),
2✔
774
            "response was {response:?}"
775
        );
776
    }
777

778
    #[test]
779
    fn where_eq_on_indexed_column_inside_parens_uses_index_probe() {
3✔
780
        let mut db = seed_users_table();
1✔
781
        let response = process_command("SELECT name FROM users WHERE (name = 'bob');", &mut db)
1✔
782
            .expect("select");
783
        assert!(response.contains("1 row returned"));
2✔
784
    }
785

786
    #[test]
787
    fn where_eq_literal_first_side_uses_index_probe() {
3✔
788
        let mut db = seed_users_table();
1✔
789
        // `'bob' = name` should hit the same path as `name = 'bob'`.
790
        let response =
2✔
791
            process_command("SELECT name FROM users WHERE 'bob' = name;", &mut db).expect("select");
792
        assert!(response.contains("1 row returned"));
2✔
793
    }
794

795
    #[test]
796
    fn non_equality_where_still_falls_back_to_full_scan() {
3✔
797
        // Sanity: range predicates bypass the optimizer and the full-scan
798
        // path still returns correct results.
799
        let mut db = seed_users_table();
1✔
800
        let response =
2✔
801
            process_command("SELECT name FROM users WHERE age > 28;", &mut db).expect("select");
802
        assert!(response.contains("2 rows returned"));
2✔
803
    }
804

805
    // -------------------------------------------------------------------
806
    // Phase 4f — Transactions (BEGIN / COMMIT / ROLLBACK)
807
    // -------------------------------------------------------------------
808

809
    #[test]
810
    fn rollback_restores_pre_begin_in_memory_state() {
3✔
811
        // In-memory DB (no pager): BEGIN, insert a row, ROLLBACK.
812
        // The row must disappear from the live tables HashMap.
813
        let mut db = seed_users_table();
1✔
814
        let before = db.get_table("users".to_string()).unwrap().rowids().len();
2✔
815
        assert_eq!(before, 3);
1✔
816

817
        process_command("BEGIN;", &mut db).expect("BEGIN");
1✔
818
        assert!(db.in_transaction());
1✔
819
        process_command("INSERT INTO users (name, age) VALUES ('dan', 50);", &mut db)
1✔
820
            .expect("INSERT inside txn");
821
        // Mid-transaction read sees the new row.
822
        let mid = db.get_table("users".to_string()).unwrap().rowids().len();
1✔
823
        assert_eq!(mid, 4);
1✔
824

825
        process_command("ROLLBACK;", &mut db).expect("ROLLBACK");
1✔
826
        assert!(!db.in_transaction());
1✔
827
        let after = db.get_table("users".to_string()).unwrap().rowids().len();
2✔
828
        assert_eq!(
1✔
829
            after, 3,
830
            "ROLLBACK should have restored the pre-BEGIN state"
831
        );
832
    }
833

834
    #[test]
835
    fn commit_keeps_mutations_and_clears_txn_flag() {
3✔
836
        let mut db = seed_users_table();
1✔
837
        process_command("BEGIN;", &mut db).expect("BEGIN");
2✔
838
        process_command("INSERT INTO users (name, age) VALUES ('dan', 50);", &mut db)
1✔
839
            .expect("INSERT inside txn");
840
        process_command("COMMIT;", &mut db).expect("COMMIT");
1✔
841
        assert!(!db.in_transaction());
1✔
842
        let after = db.get_table("users".to_string()).unwrap().rowids().len();
2✔
843
        assert_eq!(after, 4);
1✔
844
    }
845

846
    #[test]
847
    fn rollback_undoes_update_and_delete_side_by_side() {
3✔
848
        use crate::sql::db::table::Value;
849

850
        let mut db = seed_users_table();
1✔
851
        process_command("BEGIN;", &mut db).unwrap();
2✔
852
        process_command("UPDATE users SET age = 999;", &mut db).unwrap();
1✔
853
        process_command("DELETE FROM users WHERE name = 'bob';", &mut db).unwrap();
1✔
854
        // Mid-txn: one row gone, others have age=999.
855
        let users = db.get_table("users".to_string()).unwrap();
1✔
856
        assert_eq!(users.rowids().len(), 2);
1✔
857
        for r in users.rowids() {
2✔
858
            assert_eq!(users.get_value("age", r), Some(Value::Integer(999)));
2✔
859
        }
860

861
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
862
        let users = db.get_table("users".to_string()).unwrap();
1✔
863
        assert_eq!(users.rowids().len(), 3);
1✔
864
        // Original ages {30, 25, 40} — none should be 999.
865
        for r in users.rowids() {
2✔
866
            assert_ne!(users.get_value("age", r), Some(Value::Integer(999)));
2✔
867
        }
868
    }
869

870
    #[test]
871
    fn nested_begin_is_rejected() {
3✔
872
        let mut db = seed_users_table();
1✔
873
        process_command("BEGIN;", &mut db).unwrap();
2✔
874
        let err = process_command("BEGIN;", &mut db).unwrap_err();
1✔
875
        assert!(
×
876
            format!("{err}").contains("already open"),
3✔
877
            "nested BEGIN should error; got: {err}"
878
        );
879
        // Still in the original transaction; a ROLLBACK clears it.
880
        assert!(db.in_transaction());
1✔
881
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
882
    }
883

884
    #[test]
885
    fn orphan_commit_and_rollback_are_rejected() {
3✔
886
        let mut db = seed_users_table();
1✔
887
        let commit_err = process_command("COMMIT;", &mut db).unwrap_err();
2✔
888
        assert!(format!("{commit_err}").contains("no transaction"));
2✔
889
        let rollback_err = process_command("ROLLBACK;", &mut db).unwrap_err();
1✔
890
        assert!(format!("{rollback_err}").contains("no transaction"));
2✔
891
    }
892

893
    #[test]
894
    fn error_inside_transaction_keeps_txn_open() {
3✔
895
        // A bad INSERT inside a txn doesn't commit or abort automatically —
896
        // the user can still ROLLBACK. SQLite's implicit-rollback behavior
897
        // isn't modeled here.
898
        let mut db = seed_users_table();
1✔
899
        process_command("BEGIN;", &mut db).unwrap();
2✔
900
        let err = process_command("INSERT INTO nope (x) VALUES (1);", &mut db);
1✔
901
        assert!(err.is_err());
2✔
902
        assert!(db.in_transaction(), "txn should stay open after error");
1✔
903
        process_command("ROLLBACK;", &mut db).unwrap();
2✔
904
    }
905

906
    /// Builds a file-backed Database at a unique temp path, with the
907
    /// schema seeded and `source_path` set so subsequent process_command
908
    /// calls auto-save. Returns (path, db). Drop the db before deleting
909
    /// the files.
910
    fn seed_file_backed(name: &str, schema: &str) -> (std::path::PathBuf, Database) {
1✔
911
        use crate::sql::pager::{open_database, save_database};
912
        let mut p = std::env::temp_dir();
1✔
913
        let pid = std::process::id();
2✔
914
        let nanos = std::time::SystemTime::now()
2✔
915
            .duration_since(std::time::UNIX_EPOCH)
1✔
916
            .map(|d| d.as_nanos())
3✔
917
            .unwrap_or(0);
918
        p.push(format!("sqlrite-txn-{name}-{pid}-{nanos}.sqlrite"));
1✔
919

920
        // Seed the file, then reopen to get a source_path-attached db
921
        // (save_database alone doesn't attach a fresh pager to a db
922
        // whose source_path was None before the call).
923
        {
924
            let mut seed = Database::new("t".to_string());
1✔
925
            process_command(schema, &mut seed).unwrap();
2✔
926
            save_database(&mut seed, &p).unwrap();
1✔
927
        }
928
        let db = open_database(&p, "t".to_string()).unwrap();
1✔
929
        (p, db)
1✔
930
    }
931

932
    fn cleanup_file(path: &std::path::Path) {
1✔
933
        let _ = std::fs::remove_file(path);
1✔
934
        let mut wal = path.as_os_str().to_owned();
1✔
935
        wal.push("-wal");
1✔
936
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
937
    }
938

939
    #[test]
940
    fn begin_commit_rollback_round_trip_through_disk() {
3✔
941
        // File-backed DB: commit inside a transaction must actually
942
        // persist. ROLLBACK inside a *later* transaction must not
943
        // un-do the previously-committed changes.
944
        use crate::sql::pager::open_database;
945

946
        let (path, mut db) = seed_file_backed(
947
            "roundtrip",
948
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
949
        );
950

951
        // Transaction 1: insert two rows, commit.
952
        process_command("BEGIN;", &mut db).unwrap();
2✔
953
        process_command("INSERT INTO notes (body) VALUES ('a');", &mut db).unwrap();
1✔
954
        process_command("INSERT INTO notes (body) VALUES ('b');", &mut db).unwrap();
1✔
955
        process_command("COMMIT;", &mut db).unwrap();
1✔
956

957
        // Transaction 2: insert another, roll back.
958
        process_command("BEGIN;", &mut db).unwrap();
1✔
959
        process_command("INSERT INTO notes (body) VALUES ('c');", &mut db).unwrap();
1✔
960
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
961

962
        drop(db); // release pager lock
1✔
963

964
        let reopened = open_database(&path, "t".to_string()).unwrap();
1✔
965
        let notes = reopened.get_table("notes".to_string()).unwrap();
2✔
966
        assert_eq!(notes.rowids().len(), 2, "committed rows should survive");
1✔
967

968
        drop(reopened);
1✔
969
        cleanup_file(&path);
1✔
970
    }
971

972
    #[test]
973
    fn write_inside_transaction_does_not_autosave() {
3✔
974
        // File-backed DB: writes inside BEGIN/…/COMMIT must NOT hit
975
        // the WAL until COMMIT. We prove it by checking the WAL file
976
        // size before vs during the transaction.
977
        let (path, mut db) =
1✔
978
            seed_file_backed("noas", "CREATE TABLE t (id INTEGER PRIMARY KEY, x TEXT);");
979

980
        let mut wal_path = path.as_os_str().to_owned();
2✔
981
        wal_path.push("-wal");
1✔
982
        let wal_path = std::path::PathBuf::from(wal_path);
1✔
983
        let frames_before = std::fs::metadata(&wal_path).unwrap().len();
2✔
984

985
        process_command("BEGIN;", &mut db).unwrap();
1✔
986
        process_command("INSERT INTO t (x) VALUES ('a');", &mut db).unwrap();
1✔
987
        process_command("INSERT INTO t (x) VALUES ('b');", &mut db).unwrap();
1✔
988

989
        // Mid-transaction: WAL must be unchanged — no auto-save fired.
990
        let frames_mid = std::fs::metadata(&wal_path).unwrap().len();
1✔
991
        assert_eq!(
1✔
992
            frames_before, frames_mid,
993
            "WAL should not grow during an open transaction"
994
        );
995

996
        process_command("COMMIT;", &mut db).unwrap();
2✔
997

998
        drop(db); // release pager lock
1✔
999
        let fresh = crate::sql::pager::open_database(&path, "t".to_string()).unwrap();
1✔
1000
        assert_eq!(
1✔
1001
            fresh.get_table("t".to_string()).unwrap().rowids().len(),
3✔
1002
            2,
1003
            "COMMIT should have persisted both inserted rows"
1004
        );
1005
        drop(fresh);
1✔
1006
        cleanup_file(&path);
1✔
1007
    }
1008

1009
    #[test]
1010
    fn rollback_undoes_create_table() {
3✔
1011
        // Schema DDL inside a txn: ROLLBACK must make the new table
1012
        // disappear. The txn snapshot captures db.tables as of BEGIN,
1013
        // and ROLLBACK reassigns tables from that snapshot, so a table
1014
        // created mid-transaction has no entry in the snapshot.
1015
        let mut db = seed_users_table();
1✔
1016
        assert_eq!(db.tables.len(), 1);
2✔
1017

1018
        process_command("BEGIN;", &mut db).unwrap();
1✔
1019
        process_command(
1020
            "CREATE TABLE dropme (id INTEGER PRIMARY KEY, x TEXT);",
1021
            &mut db,
1022
        )
1023
        .unwrap();
1024
        process_command("INSERT INTO dropme (x) VALUES ('stuff');", &mut db).unwrap();
1✔
1025
        assert_eq!(db.tables.len(), 2);
1✔
1026

1027
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1028
        assert_eq!(
1✔
1029
            db.tables.len(),
1✔
1030
            1,
1031
            "CREATE TABLE should have been rolled back"
1032
        );
1033
        assert!(db.get_table("dropme".to_string()).is_err());
2✔
1034
    }
1035

1036
    #[test]
1037
    fn rollback_restores_secondary_index_state() {
3✔
1038
        // Phase 4f edge case: rolling back an INSERT on a UNIQUE-indexed
1039
        // column must also clean up the index, otherwise a re-insert of
1040
        // the same value would spuriously collide.
1041
        let mut db = Database::new("t".to_string());
1✔
1042
        process_command(
1043
            "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
1044
            &mut db,
1045
        )
1046
        .unwrap();
1047
        process_command("INSERT INTO users (email) VALUES ('a@x');", &mut db).unwrap();
1✔
1048

1049
        process_command("BEGIN;", &mut db).unwrap();
1✔
1050
        process_command("INSERT INTO users (email) VALUES ('b@x');", &mut db).unwrap();
1✔
1051
        // Inside the txn: the index now contains both 'a@x' and 'b@x'.
1052
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1053

1054
        // Re-inserting 'b@x' after rollback must succeed — if the index
1055
        // wasn't properly restored, it would think 'b@x' is still a
1056
        // collision and fail with a UNIQUE violation.
1057
        let reinsert = process_command("INSERT INTO users (email) VALUES ('b@x');", &mut db);
1✔
1058
        assert!(
×
1059
            reinsert.is_ok(),
2✔
1060
            "re-insert after rollback should succeed, got {reinsert:?}"
1061
        );
1062
    }
1063

1064
    #[test]
1065
    fn rollback_restores_last_rowid_counter() {
3✔
1066
        // Rowids allocated inside a rolled-back transaction should be
1067
        // reusable. The snapshot restores Table::last_rowid, so the
1068
        // next insert picks up where the pre-BEGIN state left off.
1069
        use crate::sql::db::table::Value;
1070

1071
        let mut db = seed_users_table(); // 3 rows, last_rowid = 3
1✔
1072
        let pre = db.get_table("users".to_string()).unwrap().last_rowid;
2✔
1073

1074
        process_command("BEGIN;", &mut db).unwrap();
1✔
1075
        process_command("INSERT INTO users (name, age) VALUES ('d', 50);", &mut db).unwrap(); // would be rowid 4
1✔
1076
        process_command("INSERT INTO users (name, age) VALUES ('e', 60);", &mut db).unwrap(); // would be rowid 5
1✔
1077
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1078

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

1082
        // Confirm: the next insert reuses rowid pre+1.
1083
        process_command("INSERT INTO users (name, age) VALUES ('d', 50);", &mut db).unwrap();
2✔
1084
        let users = db.get_table("users".to_string()).unwrap();
1✔
1085
        let d_rowid = users
1✔
1086
            .rowids()
1087
            .into_iter()
1088
            .find(|r| users.get_value("name", *r) == Some(Value::Text("d".into())))
3✔
1089
            .expect("d row must exist");
1090
        assert_eq!(d_rowid, pre + 1);
1✔
1091
    }
1092

1093
    #[test]
1094
    fn commit_on_in_memory_db_clears_txn_without_pager_call() {
3✔
1095
        // In-memory DB (no source_path): COMMIT must still work — just
1096
        // no disk flush. Covers the `if let Some(path) = …` branch
1097
        // where the guard falls through without calling save_database.
1098
        let mut db = seed_users_table(); // no source_path
1✔
1099
        assert!(db.source_path.is_none());
2✔
1100

1101
        process_command("BEGIN;", &mut db).unwrap();
1✔
1102
        process_command("INSERT INTO users (name, age) VALUES ('z', 99);", &mut db).unwrap();
1✔
1103
        process_command("COMMIT;", &mut db).unwrap();
1✔
1104

1105
        assert!(!db.in_transaction());
1✔
1106
        assert_eq!(db.get_table("users".to_string()).unwrap().rowids().len(), 4);
2✔
1107
    }
1108

1109
    #[test]
1110
    fn failed_commit_auto_rolls_back_in_memory_state() {
3✔
1111
        // Data-safety regression: on COMMIT save failure we must auto-
1112
        // rollback the in-memory state. Otherwise, any subsequent
1113
        // non-transactional statement would auto-save the partial
1114
        // mid-transaction work, silently publishing uncommitted
1115
        // changes to disk.
1116
        //
1117
        // We simulate a save failure by making the WAL sidecar path
1118
        // unavailable mid-transaction: after BEGIN, we take an
1119
        // exclusive OS lock on the WAL via a second File handle,
1120
        // forcing the next save to fail when it tries to append.
1121
        //
1122
        // Simpler repro: point source_path at a directory (not a file).
1123
        // `OpenOptions::open` will fail with EISDIR on save.
1124
        use crate::sql::pager::save_database;
1125

1126
        // Seed a file-backed db.
1127
        let (path, mut db) = seed_file_backed(
1128
            "failcommit",
1129
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1130
        );
1131

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

1135
        // Open a new txn and add a row.
1136
        process_command("BEGIN;", &mut db).unwrap();
1✔
1137
        process_command("INSERT INTO notes (body) VALUES ('inflight');", &mut db).unwrap();
1✔
1138
        assert_eq!(
1✔
1139
            db.get_table("notes".to_string()).unwrap().rowids().len(),
2✔
1140
            2,
1141
            "inflight row visible mid-txn"
1142
        );
1143

1144
        // Swap source_path to a path that will fail on open. A
1145
        // directory is a reliable failure mode — Pager::open on a
1146
        // directory errors with an I/O error.
1147
        let orig_source = db.source_path.clone();
1✔
1148
        let orig_pager = db.pager.take();
2✔
1149
        db.source_path = Some(std::env::temp_dir());
2✔
1150

1151
        let commit_result = process_command("COMMIT;", &mut db);
1✔
1152
        assert!(commit_result.is_err(), "commit must fail");
2✔
1153
        let err_str = format!("{}", commit_result.unwrap_err());
2✔
1154
        assert!(
×
1155
            err_str.contains("COMMIT failed") && err_str.contains("rolled back"),
3✔
1156
            "error must surface auto-rollback; got: {err_str}"
1157
        );
1158

1159
        // Auto-rollback fired: the inflight row is gone, the txn flag
1160
        // is cleared, and a follow-up non-txn statement won't leak
1161
        // stale state.
1162
        assert!(
×
1163
            !db.in_transaction(),
2✔
1164
            "txn must be cleared after auto-rollback"
1165
        );
1166
        assert_eq!(
1✔
1167
            db.get_table("notes".to_string()).unwrap().rowids().len(),
3✔
1168
            1,
1169
            "inflight row must be rolled back"
1170
        );
1171

1172
        // Restore the real source_path + pager and verify a clean
1173
        // subsequent write goes through.
1174
        db.source_path = orig_source;
1✔
1175
        db.pager = orig_pager;
1✔
1176
        process_command("INSERT INTO notes (body) VALUES ('after');", &mut db).unwrap();
1✔
1177
        drop(db);
1✔
1178

1179
        // Reopen and assert only 'before' + 'after' landed on disk.
1180
        let reopened = crate::sql::pager::open_database(&path, "t".to_string()).unwrap();
1✔
1181
        let notes = reopened.get_table("notes".to_string()).unwrap();
2✔
1182
        assert_eq!(notes.rowids().len(), 2);
1✔
1183
        // Ensure no leaked save_database partial happened.
1184
        let _ = save_database; // silence unused-import lint if any
1185
        drop(reopened);
1✔
1186
        cleanup_file(&path);
1✔
1187
    }
1188

1189
    #[test]
1190
    fn begin_on_read_only_is_rejected() {
3✔
1191
        use crate::sql::pager::{open_database_read_only, save_database};
1192

1193
        let path = {
1✔
1194
            let mut p = std::env::temp_dir();
1✔
1195
            let pid = std::process::id();
2✔
1196
            let nanos = std::time::SystemTime::now()
2✔
1197
                .duration_since(std::time::UNIX_EPOCH)
1✔
1198
                .map(|d| d.as_nanos())
3✔
1199
                .unwrap_or(0);
1200
            p.push(format!("sqlrite-txn-ro-{pid}-{nanos}.sqlrite"));
1✔
1201
            p
1✔
1202
        };
1203
        {
1204
            let mut seed = Database::new("t".to_string());
2✔
1205
            process_command("CREATE TABLE t (id INTEGER PRIMARY KEY);", &mut seed).unwrap();
2✔
1206
            save_database(&mut seed, &path).unwrap();
1✔
1207
        }
1208

1209
        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1✔
1210
        let err = process_command("BEGIN;", &mut ro).unwrap_err();
2✔
1211
        assert!(
×
1212
            format!("{err}").contains("read-only"),
3✔
1213
            "BEGIN on RO db should surface read-only; got: {err}"
1214
        );
1215
        assert!(!ro.in_transaction());
1✔
1216

1217
        let _ = std::fs::remove_file(&path);
2✔
1218
        let mut wal = path.as_os_str().to_owned();
1✔
1219
        wal.push("-wal");
1✔
1220
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
1221
    }
1222

1223
    #[test]
1224
    fn read_only_database_rejects_mutations_before_touching_state() {
3✔
1225
        // Phase 4e end-to-end: a `--readonly` caller that runs INSERT
1226
        // must error *before* the row is added to the in-memory table.
1227
        // Otherwise the user sees a rendered result table with the
1228
        // phantom row, followed by the auto-save error — UX rot and a
1229
        // state-drift risk.
1230
        use crate::sql::pager::open_database_read_only;
1231

1232
        let mut seed = Database::new("t".to_string());
1✔
1233
        process_command(
1234
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1235
            &mut seed,
1236
        )
1237
        .unwrap();
1238
        process_command("INSERT INTO notes (body) VALUES ('alpha');", &mut seed).unwrap();
1✔
1239

1240
        let path = {
1241
            let mut p = std::env::temp_dir();
1✔
1242
            let pid = std::process::id();
2✔
1243
            let nanos = std::time::SystemTime::now()
2✔
1244
                .duration_since(std::time::UNIX_EPOCH)
1✔
1245
                .map(|d| d.as_nanos())
3✔
1246
                .unwrap_or(0);
1247
            p.push(format!("sqlrite-ro-reject-{pid}-{nanos}.sqlrite"));
1✔
1248
            p
1✔
1249
        };
1250
        crate::sql::pager::save_database(&mut seed, &path).unwrap();
2✔
1251
        drop(seed);
1✔
1252

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

1256
        for stmt in [
2✔
1257
            "INSERT INTO notes (body) VALUES ('beta');",
1258
            "UPDATE notes SET body = 'x';",
1259
            "DELETE FROM notes;",
1260
            "CREATE TABLE more (id INTEGER PRIMARY KEY);",
1261
            "CREATE INDEX notes_body ON notes (body);",
1262
        ] {
1263
            let err = process_command(stmt, &mut ro).unwrap_err();
2✔
1264
            assert!(
×
1265
                format!("{err}").contains("read-only"),
3✔
1266
                "stmt {stmt:?} should surface a read-only error; got: {err}"
1267
            );
1268
        }
1269

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

1276
        // Cleanup.
1277
        drop(ro);
1✔
1278
        let _ = std::fs::remove_file(&path);
1✔
1279
        let mut wal = path.as_os_str().to_owned();
1✔
1280
        wal.push("-wal");
1✔
1281
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
1282
    }
1283

1284
    // -----------------------------------------------------------------
1285
    // Phase 7a — VECTOR(N) end-to-end through process_command
1286
    // -----------------------------------------------------------------
1287

1288
    #[test]
1289
    fn vector_create_table_and_insert_basic() {
3✔
1290
        let mut db = Database::new("tempdb".to_string());
1✔
1291
        process_command(
1292
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(3));",
1293
            &mut db,
1294
        )
1295
        .expect("create table with VECTOR(3)");
1296
        process_command(
1297
            "INSERT INTO docs (embedding) VALUES ([0.1, 0.2, 0.3]);",
1298
            &mut db,
1299
        )
1300
        .expect("insert vector");
1301

1302
        // process_command returns a status string; the rendered table
1303
        // goes to stdout via print_table. Verify state by inspecting
1304
        // the database directly.
1305
        let sel = process_command("SELECT * FROM docs;", &mut db).expect("select");
1✔
1306
        assert!(sel.contains("1 row returned"));
2✔
1307

1308
        let docs = db.get_table("docs".to_string()).expect("docs table");
1✔
1309
        let rowids = docs.rowids();
1✔
1310
        assert_eq!(rowids.len(), 1);
2✔
1311
        match docs.get_value("embedding", rowids[0]) {
1✔
1312
            Some(Value::Vector(v)) => assert_eq!(v, vec![0.1f32, 0.2, 0.3]),
1✔
1313
            other => panic!("expected Value::Vector(...), got {other:?}"),
×
1314
        }
1315
    }
1316

1317
    #[test]
1318
    fn vector_dim_mismatch_at_insert_is_clean_error() {
3✔
1319
        let mut db = Database::new("tempdb".to_string());
1✔
1320
        process_command(
1321
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(3));",
1322
            &mut db,
1323
        )
1324
        .expect("create table");
1325

1326
        // Too few elements.
1327
        let err = process_command("INSERT INTO docs (embedding) VALUES ([0.1, 0.2]);", &mut db)
1✔
1328
            .unwrap_err();
1329
        let msg = format!("{err}");
2✔
1330
        assert!(
×
1331
            msg.to_lowercase().contains("dimension")
2✔
1332
                && msg.contains("declared 3")
1✔
1333
                && msg.contains("got 2"),
1✔
1334
            "expected clear dim-mismatch error, got: {msg}"
1335
        );
1336

1337
        // Too many elements.
1338
        let err = process_command(
1339
            "INSERT INTO docs (embedding) VALUES ([0.1, 0.2, 0.3, 0.4, 0.5]);",
1340
            &mut db,
1341
        )
1342
        .unwrap_err();
1343
        assert!(
×
1344
            format!("{err}").contains("got 5"),
3✔
1345
            "expected dim-mismatch error mentioning got 5, got: {err}"
1346
        );
1347
    }
1348

1349
    #[test]
1350
    fn vector_create_table_rejects_missing_dim() {
3✔
1351
        let mut db = Database::new("tempdb".to_string());
1✔
1352
        // `VECTOR` (no parens) currently parses as `DataType::Custom` with
1353
        // empty args from sqlparser, OR may not parse as Custom at all
1354
        // depending on dialect. Either way, the column shouldn't end up
1355
        // as a usable Vector type. Accept any error here — the precise
1356
        // message is parser-version-dependent.
1357
        let result = process_command(
1358
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR);",
1359
            &mut db,
1360
        );
1361
        assert!(
×
1362
            result.is_err(),
2✔
1363
            "expected CREATE TABLE with bare VECTOR to fail (no dim)"
1364
        );
1365
    }
1366

1367
    #[test]
1368
    fn vector_create_table_rejects_zero_dim() {
3✔
1369
        let mut db = Database::new("tempdb".to_string());
1✔
1370
        let err = process_command(
1371
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(0));",
1372
            &mut db,
1373
        )
1374
        .unwrap_err();
1375
        let msg = format!("{err}");
2✔
1376
        assert!(
×
1377
            msg.to_lowercase().contains("vector"),
3✔
1378
            "expected VECTOR-related error for VECTOR(0), got: {msg}"
1379
        );
1380
    }
1381

1382
    #[test]
1383
    fn vector_high_dim_works() {
3✔
1384
        // 384-dim vector (OpenAI text-embedding-3-small size). Mostly a
1385
        // smoke test — if cell encoding mishandles the size, this fails.
1386
        let mut db = Database::new("tempdb".to_string());
1✔
1387
        process_command(
1388
            "CREATE TABLE embeddings (id INTEGER PRIMARY KEY, e VECTOR(384));",
1389
            &mut db,
1390
        )
1391
        .expect("create table VECTOR(384)");
1392

1393
        let lit = format!(
1✔
1394
            "[{}]",
1395
            (0..384)
1✔
1396
                .map(|i| format!("{}", i as f32 * 0.001))
3✔
1397
                .collect::<Vec<_>>()
1✔
1398
                .join(",")
1✔
1399
        );
1400
        let sql = format!("INSERT INTO embeddings (e) VALUES ({lit});");
2✔
1401
        process_command(&sql, &mut db).expect("insert 384-dim vector");
2✔
1402

1403
        let sel = process_command("SELECT id FROM embeddings;", &mut db).expect("select id");
1✔
1404
        assert!(sel.contains("1 row returned"));
2✔
1405
    }
1406

1407
    #[test]
1408
    fn vector_multiple_rows() {
3✔
1409
        // Three rows with different vectors — exercises the Row::Vector
1410
        // BTreeMap path (not just single-row insertion).
1411
        let mut db = Database::new("tempdb".to_string());
1✔
1412
        process_command(
1413
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1414
            &mut db,
1415
        )
1416
        .expect("create");
1417
        for i in 0..3 {
1✔
1418
            let sql = format!("INSERT INTO docs (e) VALUES ([{i}.0, {}.0]);", i + 1);
2✔
1419
            process_command(&sql, &mut db).expect("insert");
2✔
1420
        }
1421
        let sel = process_command("SELECT * FROM docs;", &mut db).expect("select");
1✔
1422
        assert!(sel.contains("3 rows returned"));
2✔
1423

1424
        // Verify each vector round-tripped correctly via direct DB inspection.
1425
        let docs = db.get_table("docs".to_string()).expect("docs table");
1✔
1426
        let rowids = docs.rowids();
1✔
1427
        assert_eq!(rowids.len(), 3);
2✔
1428
        let mut vectors: Vec<Vec<f32>> = rowids
1✔
1429
            .iter()
1430
            .filter_map(|r| match docs.get_value("e", *r) {
3✔
1431
                Some(Value::Vector(v)) => Some(v),
1✔
1432
                _ => None,
×
1433
            })
1434
            .collect();
1435
        vectors.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap());
4✔
1436
        assert_eq!(vectors[0], vec![0.0f32, 1.0]);
1✔
1437
        assert_eq!(vectors[1], vec![1.0f32, 2.0]);
1✔
1438
        assert_eq!(vectors[2], vec![2.0f32, 3.0]);
1✔
1439
    }
1440

1441
    // -----------------------------------------------------------------
1442
    // Phase 7d.2 — CREATE INDEX … USING hnsw end-to-end
1443
    // -----------------------------------------------------------------
1444

1445
    /// Builds a 5-row docs(id, e VECTOR(2)) table with vectors arranged
1446
    /// at known positions for clear distance reasoning. Used by both
1447
    /// the 7d.2 KNN tests and the refuse-DELETE/UPDATE tests.
1448
    fn seed_hnsw_table() -> Database {
1✔
1449
        let mut db = Database::new("tempdb".to_string());
1✔
1450
        process_command(
1451
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1452
            &mut db,
1453
        )
1454
        .unwrap();
1455
        for v in &[
1✔
1456
            "[1.0, 0.0]",   // id=1
1457
            "[2.0, 0.0]",   // id=2
1458
            "[0.0, 3.0]",   // id=3
1459
            "[1.0, 4.0]",   // id=4
1460
            "[10.0, 10.0]", // id=5
1461
        ] {
1462
            process_command(&format!("INSERT INTO docs (e) VALUES ({v});"), &mut db).unwrap();
2✔
1463
        }
1464
        db
1✔
1465
    }
1466

1467
    #[test]
1468
    fn create_index_using_hnsw_succeeds() {
3✔
1469
        let mut db = seed_hnsw_table();
1✔
1470
        let resp = process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1471
        assert!(resp.to_lowercase().contains("create index"));
2✔
1472
        // Index attached.
1473
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1474
        assert_eq!(table.hnsw_indexes.len(), 1);
1✔
1475
        assert_eq!(table.hnsw_indexes[0].name, "ix_e");
1✔
1476
        assert_eq!(table.hnsw_indexes[0].column_name, "e");
1✔
1477
        // Existing rows landed in the graph.
1478
        assert_eq!(table.hnsw_indexes[0].index.len(), 5);
1✔
1479
    }
1480

1481
    #[test]
1482
    fn create_index_using_hnsw_rejects_non_vector_column() {
3✔
1483
        let mut db = Database::new("tempdb".to_string());
1✔
1484
        process_command(
1485
            "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);",
1486
            &mut db,
1487
        )
1488
        .unwrap();
1489
        let err =
1✔
1490
            process_command("CREATE INDEX ix_name ON t USING hnsw (name);", &mut db).unwrap_err();
1491
        let msg = format!("{err}");
2✔
1492
        assert!(
×
1493
            msg.to_lowercase().contains("vector"),
3✔
1494
            "expected error mentioning VECTOR; got: {msg}"
1495
        );
1496
    }
1497

1498
    #[test]
1499
    fn knn_query_uses_hnsw_after_create_index() {
3✔
1500
        // The KNN-shaped query route through try_hnsw_probe rather than
1501
        // the brute-force select_topk. The user-visible result should
1502
        // be the same (HNSW recall is high on small graphs); we
1503
        // primarily verify the index is being hit by checking that
1504
        // the right rowids come back in the right order.
1505
        let mut db = seed_hnsw_table();
1✔
1506
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1507

1508
        // Top-3 closest to [1.0, 0.0]:
1509
        //   id=1 [1.0, 0.0]   distance=0
1510
        //   id=2 [2.0, 0.0]   distance=1
1511
        //   id=3 [0.0, 3.0]   distance≈3.16
1512
        let resp = process_command(
1513
            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [1.0, 0.0]) ASC LIMIT 3;",
1514
            &mut db,
1515
        )
1516
        .unwrap();
1517
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
1518
    }
1519

1520
    #[test]
1521
    fn knn_query_works_after_subsequent_inserts() {
3✔
1522
        // Index built when 5 rows existed; insert 2 more after; the
1523
        // HNSW gets maintained incrementally by insert_row, so the
1524
        // KNN query should see the newly-inserted vectors.
1525
        let mut db = seed_hnsw_table();
1✔
1526
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1527
        process_command("INSERT INTO docs (e) VALUES ([0.5, 0.0]);", &mut db).unwrap(); // id=6
1✔
1528
        process_command("INSERT INTO docs (e) VALUES ([0.1, 0.1]);", &mut db).unwrap(); // id=7
1✔
1529

1530
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1531
        assert_eq!(
1✔
1532
            table.hnsw_indexes[0].index.len(),
1✔
1533
            7,
1534
            "incremental insert should grow HNSW alongside row storage"
1535
        );
1536

1537
        // Now query: id=7 [0.1, 0.1] is closer to [0.0, 0.0] than the
1538
        // original 5 rows.
1539
        let resp = process_command(
1540
            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [0.0, 0.0]) ASC LIMIT 1;",
1541
            &mut db,
1542
        )
1543
        .unwrap();
1544
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
1545
    }
1546

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

1552
    #[test]
1553
    fn delete_on_hnsw_indexed_table_succeeds_and_marks_dirty() {
3✔
1554
        let mut db = seed_hnsw_table();
1✔
1555
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1556
        let resp = process_command("DELETE FROM docs WHERE id = 1;", &mut db).unwrap();
1✔
1557
        assert!(resp.contains("1 row"), "expected 1 row deleted: {resp}");
2✔
1558

1559
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
1560
        let entry = docs.hnsw_indexes.iter().find(|e| e.name == "ix_e").unwrap();
3✔
1561
        assert!(
×
1562
            entry.needs_rebuild,
1✔
1563
            "DELETE should have marked HNSW index dirty for rebuild on next save"
1564
        );
1565
    }
1566

1567
    #[test]
1568
    fn update_on_hnsw_indexed_vector_col_succeeds_and_marks_dirty() {
3✔
1569
        let mut db = seed_hnsw_table();
1✔
1570
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1571
        let resp =
1✔
1572
            process_command("UPDATE docs SET e = [9.0, 9.0] WHERE id = 1;", &mut db).unwrap();
1573
        assert!(resp.contains("1 row"), "expected 1 row updated: {resp}");
2✔
1574

1575
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
1576
        let entry = docs.hnsw_indexes.iter().find(|e| e.name == "ix_e").unwrap();
3✔
1577
        assert!(
×
1578
            entry.needs_rebuild,
1✔
1579
            "UPDATE on the vector column should have marked HNSW index dirty"
1580
        );
1581
    }
1582

1583
    #[test]
1584
    fn duplicate_index_name_errors() {
3✔
1585
        let mut db = seed_hnsw_table();
1✔
1586
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1587
        let err =
1✔
1588
            process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap_err();
1589
        let msg = format!("{err}");
2✔
1590
        assert!(
×
1591
            msg.to_lowercase().contains("already exists"),
3✔
1592
            "expected duplicate-index error; got: {msg}"
1593
        );
1594
    }
1595

1596
    #[test]
1597
    fn index_if_not_exists_is_idempotent() {
3✔
1598
        let mut db = seed_hnsw_table();
1✔
1599
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1600
        // Second time with IF NOT EXISTS should succeed (no-op).
1601
        process_command(
1602
            "CREATE INDEX IF NOT EXISTS ix_e ON docs USING hnsw (e);",
1603
            &mut db,
1604
        )
1605
        .unwrap();
1606
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1607
        assert_eq!(table.hnsw_indexes.len(), 1);
1✔
1608
    }
1609

1610
    // -----------------------------------------------------------------
1611
    // Phase 8b — CREATE INDEX … USING fts end-to-end
1612
    // -----------------------------------------------------------------
1613

1614
    /// 5-row docs(id INTEGER PK, body TEXT) populated with overlapping
1615
    /// vocabulary so BM25 ranking has interesting structure.
1616
    fn seed_fts_table() -> Database {
1✔
1617
        let mut db = Database::new("tempdb".to_string());
1✔
1618
        process_command(
1619
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT);",
1620
            &mut db,
1621
        )
1622
        .unwrap();
1623
        for body in &[
1✔
1624
            "rust embedded database",        // id=1 — both 'rust' and 'embedded'
1625
            "rust web framework",            // id=2 — 'rust' only
1626
            "go embedded systems",           // id=3 — 'embedded' only
1627
            "python web framework",          // id=4 — neither
1628
            "rust rust rust embedded power", // id=5 — heavy on 'rust'
1629
        ] {
1630
            process_command(
1631
                &format!("INSERT INTO docs (body) VALUES ('{body}');"),
2✔
1632
                &mut db,
1633
            )
1634
            .unwrap();
1635
        }
1636
        db
1✔
1637
    }
1638

1639
    #[test]
1640
    fn create_index_using_fts_succeeds_and_indexes_existing_rows() {
3✔
1641
        let mut db = seed_fts_table();
1✔
1642
        let resp =
2✔
1643
            process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
1644
        assert!(resp.to_lowercase().contains("create index"), "got {resp}");
2✔
1645
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1646
        assert_eq!(table.fts_indexes.len(), 1);
1✔
1647
        assert_eq!(table.fts_indexes[0].name, "ix_body");
1✔
1648
        assert_eq!(table.fts_indexes[0].column_name, "body");
1✔
1649
        // All five rows should be in the in-memory PostingList.
1650
        assert_eq!(table.fts_indexes[0].index.len(), 5);
1✔
1651
    }
1652

1653
    #[test]
1654
    fn create_index_using_fts_rejects_non_text_column() {
3✔
1655
        let mut db = Database::new("tempdb".to_string());
1✔
1656
        process_command(
1657
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER);",
1658
            &mut db,
1659
        )
1660
        .unwrap();
1661
        let err = process_command("CREATE INDEX ix_n ON t USING fts (n);", &mut db).unwrap_err();
1✔
1662
        let msg = format!("{err}");
2✔
1663
        assert!(
×
1664
            msg.to_lowercase().contains("text"),
3✔
1665
            "expected error mentioning TEXT; got: {msg}"
1666
        );
1667
    }
1668

1669
    #[test]
1670
    fn fts_match_returns_expected_rows() {
3✔
1671
        let mut db = seed_fts_table();
1✔
1672
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
1673
        // Rows that contain 'rust': ids 1, 2, 5.
1674
        let resp = process_command(
1675
            "SELECT id FROM docs WHERE fts_match(body, 'rust');",
1676
            &mut db,
1677
        )
1678
        .unwrap();
1679
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
1680
    }
1681

1682
    #[test]
1683
    fn fts_match_without_index_errors_clearly() {
3✔
1684
        let mut db = seed_fts_table();
1✔
1685
        // No CREATE INDEX — fts_match must surface a useful error.
1686
        let err = process_command(
1687
            "SELECT id FROM docs WHERE fts_match(body, 'rust');",
1688
            &mut db,
1689
        )
1690
        .unwrap_err();
1691
        let msg = format!("{err}");
2✔
1692
        assert!(
×
1693
            msg.contains("no FTS index"),
2✔
1694
            "expected no-index error; got: {msg}"
1695
        );
1696
    }
1697

1698
    #[test]
1699
    fn bm25_score_orders_descending_by_relevance() {
3✔
1700
        let mut db = seed_fts_table();
1✔
1701
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
1702
        // ORDER BY bm25_score DESC LIMIT 1: id=5 has 'rust' three times in
1703
        // a 5-token doc — highest tf, modest length penalty → top score.
1704
        let out = process_command_with_render(
1705
            "SELECT id FROM docs WHERE fts_match(body, 'rust') \
1706
             ORDER BY bm25_score(body, 'rust') DESC LIMIT 1;",
1707
            &mut db,
1708
        )
1709
        .unwrap();
1710
        assert!(out.status.contains("1 row returned"), "got: {}", out.status);
2✔
1711
        let rendered = out.rendered.expect("SELECT should produce rendered output");
1✔
1712
        // The rendered prettytable contains the integer 5 in a cell.
1713
        assert!(
×
1714
            rendered.contains(" 5 "),
2✔
1715
            "expected id=5 to be top-ranked; rendered:\n{rendered}"
1716
        );
1717
    }
1718

1719
    #[test]
1720
    fn bm25_score_without_index_errors_clearly() {
3✔
1721
        let mut db = seed_fts_table();
1✔
1722
        let err = process_command(
1723
            "SELECT id FROM docs ORDER BY bm25_score(body, 'rust') DESC LIMIT 1;",
1724
            &mut db,
1725
        )
1726
        .unwrap_err();
1727
        let msg = format!("{err}");
2✔
1728
        assert!(
×
1729
            msg.contains("no FTS index"),
2✔
1730
            "expected no-index error; got: {msg}"
1731
        );
1732
    }
1733

1734
    #[test]
1735
    fn fts_post_create_inserts_are_indexed_incrementally() {
3✔
1736
        let mut db = seed_fts_table();
1✔
1737
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
1738
        process_command(
1739
            "INSERT INTO docs (body) VALUES ('rust embedded analytics');",
1740
            &mut db,
1741
        )
1742
        .unwrap();
1743
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1744
        // PostingList::len() reports doc count; should be 6 now.
1745
        assert_eq!(table.fts_indexes[0].index.len(), 6);
1✔
1746
        // 'analytics' appears only in the new row → query returns 1 hit.
1747
        let resp = process_command(
1748
            "SELECT id FROM docs WHERE fts_match(body, 'analytics');",
1749
            &mut db,
1750
        )
1751
        .unwrap();
1752
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
1753
    }
1754

1755
    #[test]
1756
    fn delete_on_fts_indexed_table_marks_dirty() {
3✔
1757
        let mut db = seed_fts_table();
1✔
1758
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
1759
        let resp = process_command("DELETE FROM docs WHERE id = 1;", &mut db).unwrap();
1✔
1760
        assert!(resp.contains("1 row"), "got: {resp}");
2✔
1761
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
1762
        let entry = docs
3✔
1763
            .fts_indexes
1764
            .iter()
1765
            .find(|e| e.name == "ix_body")
3✔
1766
            .unwrap();
1767
        assert!(
×
1768
            entry.needs_rebuild,
1✔
1769
            "DELETE should have flagged the FTS index dirty"
1770
        );
1771
    }
1772

1773
    #[test]
1774
    fn update_on_fts_indexed_text_col_marks_dirty() {
3✔
1775
        let mut db = seed_fts_table();
1✔
1776
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
1777
        let resp = process_command(
1778
            "UPDATE docs SET body = 'java spring framework' WHERE id = 1;",
1779
            &mut db,
1780
        )
1781
        .unwrap();
1782
        assert!(resp.contains("1 row"), "got: {resp}");
2✔
1783
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
1784
        let entry = docs
3✔
1785
            .fts_indexes
1786
            .iter()
1787
            .find(|e| e.name == "ix_body")
3✔
1788
            .unwrap();
1789
        assert!(
×
1790
            entry.needs_rebuild,
1✔
1791
            "UPDATE on the indexed TEXT column should have flagged dirty"
1792
        );
1793
    }
1794

1795
    #[test]
1796
    fn fts_index_name_collides_with_btree_and_hnsw_namespaces() {
3✔
1797
        let mut db = seed_fts_table();
1✔
1798
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
1799
        let err = process_command("CREATE INDEX ix_body ON docs (body);", &mut db).unwrap_err();
1✔
1800
        let msg = format!("{err}");
2✔
1801
        assert!(
×
1802
            msg.to_lowercase().contains("already exists"),
3✔
1803
            "expected duplicate-index error; got: {msg}"
1804
        );
1805
    }
1806

1807
    #[test]
1808
    fn fts_index_rejects_unique() {
3✔
1809
        let mut db = seed_fts_table();
1✔
1810
        let err = process_command(
1811
            "CREATE UNIQUE INDEX ix_body ON docs USING fts (body);",
1812
            &mut db,
1813
        )
1814
        .unwrap_err();
1815
        let msg = format!("{err}");
2✔
1816
        assert!(
×
1817
            msg.to_lowercase().contains("unique"),
3✔
1818
            "expected UNIQUE-rejection error; got: {msg}"
1819
        );
1820
    }
1821

1822
    #[test]
1823
    fn try_fts_probe_falls_through_on_ascending() {
3✔
1824
        // BM25 is "higher = better"; ASC is rejected so the slow path
1825
        // applies. We verify by running the query and checking the
1826
        // result is still correct (the slow path goes through scalar
1827
        // bm25_score on every row).
1828
        let mut db = seed_fts_table();
1✔
1829
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
1830
        // Same query as bm25_score_orders_descending but ASC → should
1831
        // still succeed (slow path), and id=5 should now be LAST.
1832
        let resp = process_command(
1833
            "SELECT id FROM docs WHERE fts_match(body, 'rust') \
1834
             ORDER BY bm25_score(body, 'rust') ASC LIMIT 3;",
1835
            &mut db,
1836
        )
1837
        .unwrap();
1838
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
1839
    }
1840

1841
    // -----------------------------------------------------------------
1842
    // Phase 7b — vector distance functions through process_command
1843
    // -----------------------------------------------------------------
1844

1845
    /// Builds a 3-row docs table with 2-dim vectors aligned along the
1846
    /// axes so the expected distances are easy to reason about:
1847
    ///   id=1: [1, 0]
1848
    ///   id=2: [0, 1]
1849
    ///   id=3: [1, 1]
1850
    fn seed_vector_docs() -> Database {
1✔
1851
        let mut db = Database::new("tempdb".to_string());
1✔
1852
        process_command(
1853
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1854
            &mut db,
1855
        )
1856
        .expect("create");
1857
        process_command("INSERT INTO docs (e) VALUES ([1.0, 0.0]);", &mut db).expect("insert 1");
1✔
1858
        process_command("INSERT INTO docs (e) VALUES ([0.0, 1.0]);", &mut db).expect("insert 2");
1✔
1859
        process_command("INSERT INTO docs (e) VALUES ([1.0, 1.0]);", &mut db).expect("insert 3");
1✔
1860
        db
1✔
1861
    }
1862

1863
    #[test]
1864
    fn vec_distance_l2_in_where_filters_correctly() {
3✔
1865
        // Distance from [1,0]:
1866
        //   id=1 [1,0]: 0
1867
        //   id=2 [0,1]: √2 ≈ 1.414
1868
        //   id=3 [1,1]: 1
1869
        // WHERE distance < 1.1 should match id=1 and id=3 (2 rows).
1870
        let mut db = seed_vector_docs();
1✔
1871
        let resp = process_command(
1872
            "SELECT * FROM docs WHERE vec_distance_l2(e, [1.0, 0.0]) < 1.1;",
1873
            &mut db,
1874
        )
1875
        .expect("select");
1876
        assert!(
×
1877
            resp.contains("2 rows returned"),
2✔
1878
            "expected 2 rows, got: {resp}"
1879
        );
1880
    }
1881

1882
    #[test]
1883
    fn vec_distance_cosine_in_where() {
3✔
1884
        // [1,0] vs [1,0]: cosine distance = 0
1885
        // [1,0] vs [0,1]: cosine distance = 1 (orthogonal)
1886
        // [1,0] vs [1,1]: cosine distance = 1 - 1/√2 ≈ 0.293
1887
        // WHERE distance < 0.5 → id=1 and id=3 (2 rows).
1888
        let mut db = seed_vector_docs();
1✔
1889
        let resp = process_command(
1890
            "SELECT * FROM docs WHERE vec_distance_cosine(e, [1.0, 0.0]) < 0.5;",
1891
            &mut db,
1892
        )
1893
        .expect("select");
1894
        assert!(
×
1895
            resp.contains("2 rows returned"),
2✔
1896
            "expected 2 rows, got: {resp}"
1897
        );
1898
    }
1899

1900
    #[test]
1901
    fn vec_distance_dot_negated() {
3✔
1902
        // [1,0]·[1,0] = 1 → -1
1903
        // [1,0]·[0,1] = 0 → 0
1904
        // [1,0]·[1,1] = 1 → -1
1905
        // WHERE -dot < 0 (i.e. dot > 0) → id=1 and id=3 (2 rows).
1906
        let mut db = seed_vector_docs();
1✔
1907
        let resp = process_command(
1908
            "SELECT * FROM docs WHERE vec_distance_dot(e, [1.0, 0.0]) < 0.0;",
1909
            &mut db,
1910
        )
1911
        .expect("select");
1912
        assert!(
×
1913
            resp.contains("2 rows returned"),
2✔
1914
            "expected 2 rows, got: {resp}"
1915
        );
1916
    }
1917

1918
    #[test]
1919
    fn knn_via_order_by_distance_limit() {
3✔
1920
        // Classic KNN shape: ORDER BY distance LIMIT k.
1921
        // Distances from [1,0]: id=1=0, id=3=1, id=2=√2.
1922
        // LIMIT 2 should return id=1 then id=3 in that order.
1923
        let mut db = seed_vector_docs();
1✔
1924
        let resp = process_command(
1925
            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [1.0, 0.0]) ASC LIMIT 2;",
1926
            &mut db,
1927
        )
1928
        .expect("select");
1929
        assert!(
×
1930
            resp.contains("2 rows returned"),
2✔
1931
            "expected 2 rows, got: {resp}"
1932
        );
1933
    }
1934

1935
    #[test]
1936
    fn distance_function_dim_mismatch_errors() {
3✔
1937
        // 2-dim column queried with a 3-dim probe → clean error.
1938
        let mut db = seed_vector_docs();
1✔
1939
        let err = process_command(
1940
            "SELECT * FROM docs WHERE vec_distance_l2(e, [1.0, 0.0, 0.0]) < 1.0;",
1941
            &mut db,
1942
        )
1943
        .unwrap_err();
1944
        let msg = format!("{err}");
2✔
1945
        assert!(
×
1946
            msg.to_lowercase().contains("dimension")
2✔
1947
                && msg.contains("lhs=2")
1✔
1948
                && msg.contains("rhs=3"),
1✔
1949
            "expected dim mismatch error, got: {msg}"
1950
        );
1951
    }
1952

1953
    #[test]
1954
    fn unknown_function_errors_with_name() {
3✔
1955
        // Use the function in WHERE, not projection — the projection
1956
        // parser still requires bare column references; function calls
1957
        // there are a future enhancement (with `AS alias` support).
1958
        let mut db = seed_vector_docs();
1✔
1959
        let err = process_command(
1960
            "SELECT * FROM docs WHERE vec_does_not_exist(e, [1.0, 0.0]) < 1.0;",
1961
            &mut db,
1962
        )
1963
        .unwrap_err();
1964
        let msg = format!("{err}");
2✔
1965
        assert!(
×
1966
            msg.contains("vec_does_not_exist"),
2✔
1967
            "expected error mentioning function name, got: {msg}"
1968
        );
1969
    }
1970

1971
    // -----------------------------------------------------------------
1972
    // Phase 7e — JSON column type + path-extraction functions
1973
    // -----------------------------------------------------------------
1974

1975
    fn seed_json_table() -> Database {
1✔
1976
        let mut db = Database::new("tempdb".to_string());
1✔
1977
        process_command(
1978
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, payload JSON);",
1979
            &mut db,
1980
        )
1981
        .expect("create json table");
1982
        db
1✔
1983
    }
1984

1985
    #[test]
1986
    fn json_column_round_trip_primitive_values() {
3✔
1987
        let mut db = seed_json_table();
1✔
1988
        process_command(
1989
            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice", "age": 30}');"#,
1990
            &mut db,
1991
        )
1992
        .expect("insert json");
1993
        let docs = db.get_table("docs".to_string()).unwrap();
1✔
1994
        let rowids = docs.rowids();
1✔
1995
        assert_eq!(rowids.len(), 1);
2✔
1996
        // Stored verbatim as Text underneath.
1997
        match docs.get_value("payload", rowids[0]) {
1✔
1998
            Some(Value::Text(s)) => {
1✔
1999
                assert!(s.contains("alice"), "expected JSON text to round-trip: {s}");
2✔
2000
            }
2001
            other => panic!("expected Value::Text holding JSON, got {other:?}"),
×
2002
        }
2003
    }
2004

2005
    #[test]
2006
    fn json_insert_rejects_invalid_json() {
4✔
2007
        let mut db = seed_json_table();
1✔
2008
        let err = process_command(
2009
            "INSERT INTO docs (payload) VALUES ('not-valid-json{');",
2010
            &mut db,
2011
        )
2012
        .unwrap_err();
2013
        let msg = format!("{err}").to_lowercase();
2✔
2014
        assert!(
×
2015
            msg.contains("json") && msg.contains("payload"),
2✔
2016
            "expected JSON validation error mentioning column, got: {msg}"
2017
        );
2018
    }
2019

2020
    #[test]
2021
    fn json_extract_object_field() {
3✔
2022
        let mut db = seed_json_table();
1✔
2023
        process_command(
2024
            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice", "age": 30}');"#,
2025
            &mut db,
2026
        )
2027
        .unwrap();
2028
        // We don't have function calls in projection (yet), so test
2029
        // the function via WHERE.
2030
        let resp = process_command(
2031
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.name') = 'alice';"#,
2032
            &mut db,
2033
        )
2034
        .expect("select via json_extract");
2035
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2036

2037
        let resp = process_command(
2038
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.age') = 30;"#,
2039
            &mut db,
2040
        )
2041
        .expect("select via numeric json_extract");
2042
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2043
    }
2044

2045
    #[test]
2046
    fn json_extract_array_index_and_nested() {
3✔
2047
        let mut db = seed_json_table();
1✔
2048
        process_command(
2049
            r#"INSERT INTO docs (payload) VALUES ('{"tags": ["rust", "sql", "vectors"], "meta": {"author": "joao"}}');"#,
2050
            &mut db,
2051
        )
2052
        .unwrap();
2053
        let resp = process_command(
2054
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.tags[0]') = 'rust';"#,
2055
            &mut db,
2056
        )
2057
        .expect("select via array index");
2058
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2059

2060
        let resp = process_command(
2061
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.meta.author') = 'joao';"#,
2062
            &mut db,
2063
        )
2064
        .expect("select via nested object");
2065
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2066
    }
2067

2068
    #[test]
2069
    fn json_extract_missing_path_returns_null() {
3✔
2070
        let mut db = seed_json_table();
1✔
2071
        process_command(
2072
            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice"}');"#,
2073
            &mut db,
2074
        )
2075
        .unwrap();
2076
        // Missing key under WHERE returns NULL → predicate is false →
2077
        // 0 rows returned. (Standard SQL three-valued logic.)
2078
        let resp = process_command(
2079
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.missing') = 'something';"#,
2080
            &mut db,
2081
        )
2082
        .expect("select with missing path");
2083
        assert!(resp.contains("0 rows returned"), "got: {resp}");
2✔
2084
    }
2085

2086
    #[test]
2087
    fn json_extract_malformed_path_errors() {
3✔
2088
        let mut db = seed_json_table();
1✔
2089
        process_command(
2090
            r#"INSERT INTO docs (payload) VALUES ('{"a": 1}');"#,
2091
            &mut db,
2092
        )
2093
        .unwrap();
2094
        // Path doesn't start with '$' — syntax error.
2095
        let err = process_command(
2096
            r#"SELECT id FROM docs WHERE json_extract(payload, 'a.b') = 1;"#,
2097
            &mut db,
2098
        )
2099
        .unwrap_err();
2100
        assert!(format!("{err}").contains("'$'"));
2✔
2101
    }
2102

2103
    #[test]
2104
    fn json_array_length_on_array() {
3✔
2105
        // Note: json_array_length used in WHERE clause where it can be
2106
        // compared; that exercises the function dispatch end-to-end.
2107
        let mut db = seed_json_table();
1✔
2108
        process_command(
2109
            r#"INSERT INTO docs (payload) VALUES ('{"tags": ["a", "b", "c"]}');"#,
2110
            &mut db,
2111
        )
2112
        .unwrap();
2113
        let resp = process_command(
2114
            r#"SELECT id FROM docs WHERE json_array_length(payload, '$.tags') = 3;"#,
2115
            &mut db,
2116
        )
2117
        .expect("select via array_length");
2118
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2119
    }
2120

2121
    #[test]
2122
    fn json_array_length_on_non_array_errors() {
3✔
2123
        let mut db = seed_json_table();
1✔
2124
        process_command(
2125
            r#"INSERT INTO docs (payload) VALUES ('{"tags": "not-an-array"}');"#,
2126
            &mut db,
2127
        )
2128
        .unwrap();
2129
        let err = process_command(
2130
            r#"SELECT id FROM docs WHERE json_array_length(payload, '$.tags') = 1;"#,
2131
            &mut db,
2132
        )
2133
        .unwrap_err();
2134
        let msg = format!("{err}").to_lowercase();
2✔
2135
        assert!(
×
2136
            msg.contains("non-array"),
1✔
2137
            "expected non-array error, got: {msg}"
2138
        );
2139
    }
2140

2141
    #[test]
2142
    fn json_type_recognizes_each_kind() {
3✔
2143
        let mut db = seed_json_table();
1✔
2144
        process_command(
2145
            r#"INSERT INTO docs (payload) VALUES ('{"o": {}, "a": [], "s": "x", "i": 1, "f": 1.5, "t": true, "n": null}');"#,
2146
            &mut db,
2147
        )
2148
        .unwrap();
2149
        let cases = &[
1✔
2150
            ("$.o", "object"),
2151
            ("$.a", "array"),
2152
            ("$.s", "text"),
2153
            ("$.i", "integer"),
2154
            ("$.f", "real"),
2155
            ("$.t", "true"),
2156
            ("$.n", "null"),
2157
        ];
2158
        for (path, expected_type) in cases {
1✔
2159
            let sql = format!(
2✔
2160
                "SELECT id FROM docs WHERE json_type(payload, '{path}') = '{expected_type}';"
2161
            );
2162
            let resp =
2✔
2163
                process_command(&sql, &mut db).unwrap_or_else(|e| panic!("path {path}: {e}"));
2164
            assert!(
×
2165
                resp.contains("1 row returned"),
2✔
2166
                "path {path} expected type {expected_type}; got response: {resp}"
2167
            );
2168
        }
2169
    }
2170

2171
    #[test]
2172
    fn update_on_json_column_revalidates() {
3✔
2173
        let mut db = seed_json_table();
1✔
2174
        process_command(
2175
            r#"INSERT INTO docs (payload) VALUES ('{"a": 1}');"#,
2176
            &mut db,
2177
        )
2178
        .unwrap();
2179
        // Valid JSON update succeeds.
2180
        process_command(
2181
            r#"UPDATE docs SET payload = '{"a": 2, "b": 3}' WHERE id = 1;"#,
2182
            &mut db,
2183
        )
2184
        .expect("valid JSON UPDATE");
2185
        // Invalid JSON in UPDATE is rejected with the same shape of
2186
        // error as INSERT.
2187
        let err = process_command(
2188
            r#"UPDATE docs SET payload = 'not-json{' WHERE id = 1;"#,
2189
            &mut db,
2190
        )
2191
        .unwrap_err();
2192
        let msg = format!("{err}").to_lowercase();
2✔
2193
        assert!(
×
2194
            msg.contains("json") && msg.contains("payload"),
2✔
2195
            "got: {msg}"
2196
        );
2197
    }
2198

2199
    // -------------------------------------------------------------------
2200
    // DEFAULT clause on CREATE TABLE columns
2201
    // -------------------------------------------------------------------
2202

2203
    #[test]
2204
    fn default_literal_int_applies_when_column_omitted() {
3✔
2205
        let mut db = Database::new("t".to_string());
1✔
2206
        process_command(
2207
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER DEFAULT 42);",
2208
            &mut db,
2209
        )
2210
        .unwrap();
2211
        process_command("INSERT INTO t (id) VALUES (1);", &mut db).unwrap();
1✔
2212

2213
        let table = db.get_table("t".to_string()).unwrap();
1✔
2214
        assert_eq!(table.get_value("n", 1), Some(Value::Integer(42)));
1✔
2215
    }
2216

2217
    #[test]
2218
    fn default_literal_text_applies_when_column_omitted() {
3✔
2219
        let mut db = Database::new("t".to_string());
1✔
2220
        process_command(
2221
            "CREATE TABLE users (id INTEGER PRIMARY KEY, status TEXT DEFAULT 'active');",
2222
            &mut db,
2223
        )
2224
        .unwrap();
2225
        process_command("INSERT INTO users (id) VALUES (1);", &mut db).unwrap();
1✔
2226

2227
        let table = db.get_table("users".to_string()).unwrap();
1✔
2228
        assert_eq!(
1✔
2229
            table.get_value("status", 1),
1✔
2230
            Some(Value::Text("active".to_string()))
2✔
2231
        );
2232
    }
2233

2234
    #[test]
2235
    fn default_literal_real_negative_applies_when_column_omitted() {
3✔
2236
        // `DEFAULT -1.5` arrives as a UnaryOp(Minus, Number) — exercise that path.
2237
        let mut db = Database::new("t".to_string());
1✔
2238
        process_command(
2239
            "CREATE TABLE t (id INTEGER PRIMARY KEY, score REAL DEFAULT -1.5);",
2240
            &mut db,
2241
        )
2242
        .unwrap();
2243
        process_command("INSERT INTO t (id) VALUES (1);", &mut db).unwrap();
1✔
2244

2245
        let table = db.get_table("t".to_string()).unwrap();
1✔
2246
        assert_eq!(table.get_value("score", 1), Some(Value::Real(-1.5)));
1✔
2247
    }
2248

2249
    #[test]
2250
    fn default_with_type_mismatch_errors_at_create_time() {
4✔
2251
        let mut db = Database::new("t".to_string());
1✔
2252
        let result = process_command(
2253
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER DEFAULT 'oops');",
2254
            &mut db,
2255
        );
2256
        let err = result.expect_err("text default on INTEGER column should be rejected");
1✔
2257
        let msg = format!("{err}").to_lowercase();
2✔
2258
        assert!(msg.contains("default"), "got: {msg}");
1✔
2259
    }
2260

2261
    #[test]
2262
    fn default_for_json_column_must_be_valid_json() {
4✔
2263
        // ADD COLUMN ... JSON DEFAULT 'not-json' would otherwise backfill
2264
        // every existing row with invalid JSON (insert_row's validation
2265
        // is bypassed for the backfill path).
2266
        let mut db = Database::new("t".to_string());
1✔
2267
        let err = process_command(
2268
            "CREATE TABLE t (id INTEGER PRIMARY KEY, doc JSON DEFAULT 'not-json{');",
2269
            &mut db,
2270
        )
2271
        .unwrap_err();
2272
        assert!(
×
2273
            format!("{err}").to_lowercase().contains("json"),
4✔
2274
            "got: {err}"
2275
        );
2276

2277
        // Valid JSON DEFAULT works.
2278
        process_command(
2279
            "CREATE TABLE t2 (id INTEGER PRIMARY KEY, doc JSON DEFAULT '{\"k\":1}');",
2280
            &mut db,
2281
        )
2282
        .expect("valid JSON DEFAULT should be accepted");
2283
    }
2284

2285
    #[test]
2286
    fn default_with_non_literal_expression_errors_at_create_time() {
3✔
2287
        let mut db = Database::new("t".to_string());
1✔
2288
        // Function-call DEFAULT (e.g. CURRENT_TIMESTAMP) → rejected; we only
2289
        // accept literal expressions for now.
2290
        let result = process_command(
2291
            "CREATE TABLE t (id INTEGER PRIMARY KEY, ts TEXT DEFAULT CURRENT_TIMESTAMP);",
2292
            &mut db,
2293
        );
2294
        let err = result.expect_err("non-literal DEFAULT should be rejected");
1✔
2295
        let msg = format!("{err}").to_lowercase();
2✔
2296
        assert!(
×
2297
            msg.contains("default") && msg.contains("literal"),
2✔
2298
            "got: {msg}"
2299
        );
2300
    }
2301

2302
    #[test]
2303
    fn default_null_is_accepted_at_create_time() {
3✔
2304
        // `DEFAULT NULL` is a no-op equivalent to no DEFAULT clause; the
2305
        // important thing is that CREATE TABLE accepts it without error
2306
        // (some DDL exporters emit `DEFAULT NULL` redundantly).
2307
        let mut db = Database::new("t".to_string());
1✔
2308
        process_command(
2309
            "CREATE TABLE t (id INTEGER PRIMARY KEY, note TEXT DEFAULT NULL);",
2310
            &mut db,
2311
        )
2312
        .expect("CREATE TABLE with DEFAULT NULL should be accepted");
2313
        let table = db.get_table("t".to_string()).unwrap();
1✔
2314
        let note = table
3✔
2315
            .columns
2316
            .iter()
2317
            .find(|c| c.column_name == "note")
3✔
2318
            .unwrap();
2319
        assert_eq!(note.default, Some(Value::Null));
1✔
2320
    }
2321

2322
    // -------------------------------------------------------------------
2323
    // DROP TABLE / DROP INDEX
2324
    // -------------------------------------------------------------------
2325

2326
    #[test]
2327
    fn drop_table_basic() {
3✔
2328
        let mut db = seed_users_table();
1✔
2329
        let response = process_command("DROP TABLE users;", &mut db).expect("drop table");
2✔
2330
        assert!(response.contains("1 table dropped"));
2✔
2331
        assert!(!db.contains_table("users".to_string()));
1✔
2332
    }
2333

2334
    #[test]
2335
    fn drop_table_if_exists_noop_on_missing() {
3✔
2336
        let mut db = Database::new("t".to_string());
1✔
2337
        let response =
2✔
2338
            process_command("DROP TABLE IF EXISTS missing;", &mut db).expect("drop if exists");
2339
        assert!(response.contains("0 tables dropped"));
2✔
2340
    }
2341

2342
    #[test]
2343
    fn drop_table_missing_errors_without_if_exists() {
3✔
2344
        let mut db = Database::new("t".to_string());
1✔
2345
        let err = process_command("DROP TABLE missing;", &mut db).unwrap_err();
2✔
2346
        assert!(format!("{err}").contains("does not exist"), "got: {err}");
2✔
2347
    }
2348

2349
    #[test]
2350
    fn drop_table_reserved_name_errors() {
3✔
2351
        let mut db = Database::new("t".to_string());
1✔
2352
        let err = process_command("DROP TABLE sqlrite_master;", &mut db).unwrap_err();
2✔
2353
        assert!(format!("{err}").contains("reserved"), "got: {err}");
2✔
2354
    }
2355

2356
    #[test]
2357
    fn drop_table_multi_target_rejected() {
3✔
2358
        let mut db = seed_users_table();
1✔
2359
        process_command("CREATE TABLE other (id INTEGER PRIMARY KEY);", &mut db).unwrap();
2✔
2360
        // sqlparser accepts `DROP TABLE a, b` as one statement; we reject
2361
        // to keep error semantics simple (no partial-failure rollback).
2362
        let err = process_command("DROP TABLE users, other;", &mut db).unwrap_err();
1✔
2363
        assert!(format!("{err}").contains("single table"), "got: {err}");
2✔
2364
    }
2365

2366
    #[test]
2367
    fn drop_table_cascades_indexes_in_memory() {
3✔
2368
        let mut db = seed_users_table();
1✔
2369
        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2✔
2370
        // PK auto-index + UNIQUE-on-name auto-index + the explicit one.
2371
        let users = db.get_table("users".to_string()).unwrap();
1✔
2372
        assert!(
×
2373
            users
3✔
2374
                .secondary_indexes
2375
                .iter()
1✔
2376
                .any(|i| i.name == "users_age_idx")
3✔
2377
        );
2378

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

2381
        // After DROP TABLE, no other table should claim the dropped indexes.
2382
        for table in db.tables.values() {
1✔
2383
            assert!(
×
2384
                !table
×
2385
                    .secondary_indexes
2386
                    .iter()
×
2387
                    .any(|i| i.name.contains("users")),
×
2388
                "dropped table's indexes should not survive on any other table"
2389
            );
2390
        }
2391
    }
2392

2393
    #[test]
2394
    fn drop_index_explicit_basic() {
3✔
2395
        let mut db = seed_users_table();
1✔
2396
        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2✔
2397
        let response = process_command("DROP INDEX users_age_idx;", &mut db).expect("drop index");
1✔
2398
        assert!(response.contains("1 index dropped"));
2✔
2399

2400
        let users = db.get_table("users".to_string()).unwrap();
1✔
2401
        assert!(users.index_by_name("users_age_idx").is_none());
1✔
2402
    }
2403

2404
    #[test]
2405
    fn drop_index_refuses_auto_index() {
3✔
2406
        let mut db = seed_users_table();
1✔
2407
        // `users` was created with `id INTEGER PRIMARY KEY` → auto-index
2408
        // named `sqlrite_autoindex_users_id`.
2409
        let err = process_command("DROP INDEX sqlrite_autoindex_users_id;", &mut db).unwrap_err();
2✔
2410
        assert!(format!("{err}").contains("auto-created"), "got: {err}");
2✔
2411
    }
2412

2413
    #[test]
2414
    fn drop_index_if_exists_noop_on_missing() {
3✔
2415
        let mut db = Database::new("t".to_string());
1✔
2416
        let response =
2✔
2417
            process_command("DROP INDEX IF EXISTS nope;", &mut db).expect("drop index if exists");
2418
        assert!(response.contains("0 indexes dropped"));
2✔
2419
    }
2420

2421
    #[test]
2422
    fn drop_index_missing_errors_without_if_exists() {
3✔
2423
        let mut db = Database::new("t".to_string());
1✔
2424
        let err = process_command("DROP INDEX nope;", &mut db).unwrap_err();
2✔
2425
        assert!(format!("{err}").contains("does not exist"), "got: {err}");
2✔
2426
    }
2427

2428
    #[test]
2429
    fn drop_statements_rejected_on_readonly_db() {
3✔
2430
        use crate::sql::pager::{open_database_read_only, save_database};
2431

2432
        let mut seed = Database::new("t".to_string());
1✔
2433
        process_command(
2434
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
2435
            &mut seed,
2436
        )
2437
        .unwrap();
2438
        process_command("CREATE INDEX notes_body ON notes (body);", &mut seed).unwrap();
1✔
2439
        let path = {
2440
            let mut p = std::env::temp_dir();
1✔
2441
            let pid = std::process::id();
2✔
2442
            let nanos = std::time::SystemTime::now()
2✔
2443
                .duration_since(std::time::UNIX_EPOCH)
1✔
2444
                .map(|d| d.as_nanos())
3✔
2445
                .unwrap_or(0);
2446
            p.push(format!("sqlrite-drop-ro-{pid}-{nanos}.sqlrite"));
1✔
2447
            p
1✔
2448
        };
2449
        save_database(&mut seed, &path).unwrap();
2✔
2450
        drop(seed);
1✔
2451

2452
        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1✔
2453
        for stmt in ["DROP TABLE notes;", "DROP INDEX notes_body;"] {
3✔
2454
            let err = process_command(stmt, &mut ro).unwrap_err();
2✔
2455
            assert!(
×
2456
                format!("{err}").contains("read-only"),
3✔
2457
                "{stmt:?} should surface read-only error, got: {err}"
2458
            );
2459
        }
2460

2461
        let _ = std::fs::remove_file(&path);
1✔
2462
        let mut wal = path.as_os_str().to_owned();
1✔
2463
        wal.push("-wal");
1✔
2464
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
2465
    }
2466

2467
    // -------------------------------------------------------------------
2468
    // ALTER TABLE — RENAME TO / RENAME COLUMN / ADD COLUMN / DROP COLUMN
2469
    // -------------------------------------------------------------------
2470

2471
    #[test]
2472
    fn alter_rename_table_basic() {
3✔
2473
        let mut db = seed_users_table();
1✔
2474
        process_command("ALTER TABLE users RENAME TO members;", &mut db).expect("rename table");
2✔
2475
        assert!(!db.contains_table("users".to_string()));
1✔
2476
        assert!(db.contains_table("members".to_string()));
2✔
2477
        // Data still queryable under the new name.
2478
        let response = process_command("SELECT * FROM members;", &mut db).expect("select");
1✔
2479
        assert!(response.contains("3 rows returned"));
2✔
2480
    }
2481

2482
    #[test]
2483
    fn alter_rename_table_renames_auto_indexes() {
3✔
2484
        // Use a fresh table with both PK and a UNIQUE column so we
2485
        // exercise both auto-index renames in one shot.
2486
        let mut db = Database::new("t".to_string());
1✔
2487
        process_command(
2488
            "CREATE TABLE accounts (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
2489
            &mut db,
2490
        )
2491
        .unwrap();
2492
        {
2493
            let accounts = db.get_table("accounts".to_string()).unwrap();
1✔
2494
            assert!(
×
2495
                accounts
2✔
2496
                    .index_by_name("sqlrite_autoindex_accounts_id")
1✔
2497
                    .is_some()
1✔
2498
            );
2499
            assert!(
×
2500
                accounts
2✔
2501
                    .index_by_name("sqlrite_autoindex_accounts_email")
1✔
2502
                    .is_some()
1✔
2503
            );
2504
        }
2505
        process_command("ALTER TABLE accounts RENAME TO members;", &mut db).expect("rename");
1✔
2506
        let members = db.get_table("members".to_string()).unwrap();
1✔
2507
        assert!(
×
2508
            members
2✔
2509
                .index_by_name("sqlrite_autoindex_members_id")
1✔
2510
                .is_some(),
1✔
2511
            "PK auto-index should be renamed to match new table"
2512
        );
2513
        assert!(
×
2514
            members
2✔
2515
                .index_by_name("sqlrite_autoindex_members_email")
1✔
2516
                .is_some()
1✔
2517
        );
2518
        // The old-named auto-indexes should be gone.
2519
        assert!(
×
2520
            members
2✔
2521
                .index_by_name("sqlrite_autoindex_accounts_id")
1✔
2522
                .is_none()
1✔
2523
        );
2524
        // table_name field on each index should also reflect the rename.
2525
        for idx in &members.secondary_indexes {
1✔
2526
            assert_eq!(idx.table_name, "members");
2✔
2527
        }
2528
    }
2529

2530
    #[test]
2531
    fn alter_rename_table_to_existing_errors() {
3✔
2532
        let mut db = seed_users_table();
1✔
2533
        process_command("CREATE TABLE other (id INTEGER PRIMARY KEY);", &mut db).unwrap();
2✔
2534
        let err = process_command("ALTER TABLE users RENAME TO other;", &mut db).unwrap_err();
1✔
2535
        assert!(format!("{err}").contains("already exists"), "got: {err}");
2✔
2536
        // Both tables still present.
2537
        assert!(db.contains_table("users".to_string()));
1✔
2538
        assert!(db.contains_table("other".to_string()));
1✔
2539
    }
2540

2541
    #[test]
2542
    fn alter_rename_table_to_reserved_name_errors() {
3✔
2543
        let mut db = seed_users_table();
1✔
2544
        let err =
2✔
2545
            process_command("ALTER TABLE users RENAME TO sqlrite_master;", &mut db).unwrap_err();
2546
        assert!(format!("{err}").contains("reserved"), "got: {err}");
2✔
2547
    }
2548

2549
    #[test]
2550
    fn alter_rename_column_basic() {
3✔
2551
        let mut db = seed_users_table();
1✔
2552
        process_command(
2553
            "ALTER TABLE users RENAME COLUMN name TO full_name;",
2554
            &mut db,
2555
        )
2556
        .expect("rename column");
2557

2558
        let users = db.get_table("users".to_string()).unwrap();
1✔
2559
        assert!(users.contains_column("full_name".to_string()));
1✔
2560
        assert!(!users.contains_column("name".to_string()));
1✔
2561

2562
        // Existing data is queryable under the new column name and value
2563
        // is preserved at the same rowid.
2564
        let bob_rowid = users
1✔
2565
            .rowids()
2566
            .into_iter()
2567
            .find(|r| users.get_value("full_name", *r) == Some(Value::Text("bob".to_string())))
3✔
2568
            .expect("bob row should be findable under the new column name");
2569
        assert_eq!(
1✔
2570
            users.get_value("full_name", bob_rowid),
1✔
2571
            Some(Value::Text("bob".to_string()))
2✔
2572
        );
2573
    }
2574

2575
    #[test]
2576
    fn alter_rename_column_collision_errors() {
3✔
2577
        let mut db = seed_users_table();
1✔
2578
        let err =
2✔
2579
            process_command("ALTER TABLE users RENAME COLUMN name TO age;", &mut db).unwrap_err();
2580
        assert!(format!("{err}").contains("already exists"), "got: {err}");
2✔
2581
    }
2582

2583
    #[test]
2584
    fn alter_rename_column_updates_indexes() {
3✔
2585
        // `accounts.email` is UNIQUE → has a renameable auto-index.
2586
        let mut db = Database::new("t".to_string());
1✔
2587
        process_command(
2588
            "CREATE TABLE accounts (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
2589
            &mut db,
2590
        )
2591
        .unwrap();
2592
        process_command(
2593
            "ALTER TABLE accounts RENAME COLUMN email TO contact;",
2594
            &mut db,
2595
        )
2596
        .unwrap();
2597
        let accounts = db.get_table("accounts".to_string()).unwrap();
1✔
2598
        assert!(
×
2599
            accounts
2✔
2600
                .index_by_name("sqlrite_autoindex_accounts_contact")
1✔
2601
                .is_some()
1✔
2602
        );
2603
        assert!(
×
2604
            accounts
2✔
2605
                .index_by_name("sqlrite_autoindex_accounts_email")
1✔
2606
                .is_none()
1✔
2607
        );
2608
    }
2609

2610
    #[test]
2611
    fn alter_add_column_basic() {
3✔
2612
        let mut db = seed_users_table();
1✔
2613
        process_command("ALTER TABLE users ADD COLUMN nickname TEXT;", &mut db)
1✔
2614
            .expect("add column");
2615
        let users = db.get_table("users".to_string()).unwrap();
1✔
2616
        assert!(users.contains_column("nickname".to_string()));
1✔
2617
        // Existing rows read NULL for the new column (no default given).
2618
        let any_rowid = *users.rowids().first().expect("seed has rows");
1✔
2619
        assert_eq!(users.get_value("nickname", any_rowid), None);
1✔
2620

2621
        // A new INSERT supplying the new column works.
2622
        process_command(
2623
            "INSERT INTO users (name, age, nickname) VALUES ('dan', 22, 'd');",
2624
            &mut db,
2625
        )
2626
        .expect("insert with new col");
2627
        let users = db.get_table("users".to_string()).unwrap();
1✔
2628
        let dan_rowid = users
1✔
2629
            .rowids()
2630
            .into_iter()
2631
            .find(|r| users.get_value("name", *r) == Some(Value::Text("dan".to_string())))
3✔
2632
            .unwrap();
2633
        assert_eq!(
1✔
2634
            users.get_value("nickname", dan_rowid),
1✔
2635
            Some(Value::Text("d".to_string()))
2✔
2636
        );
2637
    }
2638

2639
    #[test]
2640
    fn alter_add_column_with_default_backfills_existing_rows() {
3✔
2641
        let mut db = seed_users_table();
1✔
2642
        process_command(
2643
            "ALTER TABLE users ADD COLUMN status TEXT DEFAULT 'active';",
2644
            &mut db,
2645
        )
2646
        .expect("add column with default");
2647
        let users = db.get_table("users".to_string()).unwrap();
1✔
2648
        for rowid in users.rowids() {
2✔
2649
            assert_eq!(
1✔
2650
                users.get_value("status", rowid),
1✔
2651
                Some(Value::Text("active".to_string())),
2✔
2652
                "rowid {rowid} should have been backfilled with the default"
2653
            );
2654
        }
2655
    }
2656

2657
    #[test]
2658
    fn alter_add_column_not_null_with_default_works_on_nonempty_table() {
3✔
2659
        let mut db = seed_users_table();
1✔
2660
        process_command(
2661
            "ALTER TABLE users ADD COLUMN score INTEGER NOT NULL DEFAULT 0;",
2662
            &mut db,
2663
        )
2664
        .expect("NOT NULL ADD with DEFAULT should succeed even with existing rows");
2665
        let users = db.get_table("users".to_string()).unwrap();
1✔
2666
        for rowid in users.rowids() {
2✔
2667
            assert_eq!(users.get_value("score", rowid), Some(Value::Integer(0)));
2✔
2668
        }
2669
    }
2670

2671
    #[test]
2672
    fn alter_add_column_not_null_without_default_errors_on_nonempty_table() {
4✔
2673
        let mut db = seed_users_table();
1✔
2674
        let err = process_command(
2675
            "ALTER TABLE users ADD COLUMN score INTEGER NOT NULL;",
2676
            &mut db,
2677
        )
2678
        .unwrap_err();
2679
        let msg = format!("{err}").to_lowercase();
2✔
2680
        assert!(
×
2681
            msg.contains("not null") && msg.contains("default"),
2✔
2682
            "got: {msg}"
2683
        );
2684
    }
2685

2686
    #[test]
2687
    fn alter_add_column_pk_rejected() {
3✔
2688
        let mut db = seed_users_table();
1✔
2689
        let err = process_command(
2690
            "ALTER TABLE users ADD COLUMN extra INTEGER PRIMARY KEY;",
2691
            &mut db,
2692
        )
2693
        .unwrap_err();
2694
        assert!(
×
2695
            format!("{err}").to_lowercase().contains("primary key"),
4✔
2696
            "got: {err}"
2697
        );
2698
    }
2699

2700
    #[test]
2701
    fn alter_add_column_unique_rejected() {
3✔
2702
        let mut db = seed_users_table();
1✔
2703
        let err = process_command(
2704
            "ALTER TABLE users ADD COLUMN extra INTEGER UNIQUE;",
2705
            &mut db,
2706
        )
2707
        .unwrap_err();
2708
        assert!(
×
2709
            format!("{err}").to_lowercase().contains("unique"),
4✔
2710
            "got: {err}"
2711
        );
2712
    }
2713

2714
    #[test]
2715
    fn alter_add_column_existing_name_errors() {
3✔
2716
        let mut db = seed_users_table();
1✔
2717
        let err =
2✔
2718
            process_command("ALTER TABLE users ADD COLUMN age INTEGER;", &mut db).unwrap_err();
2719
        assert!(format!("{err}").contains("already exists"), "got: {err}");
2✔
2720
    }
2721

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

2727
    #[test]
2728
    fn alter_drop_column_basic() {
3✔
2729
        let mut db = seed_users_table();
1✔
2730
        process_command("ALTER TABLE users DROP COLUMN age;", &mut db).expect("drop column");
2✔
2731
        let users = db.get_table("users".to_string()).unwrap();
1✔
2732
        assert!(!users.contains_column("age".to_string()));
1✔
2733
        // Other columns and rowids still intact.
2734
        assert!(users.contains_column("name".to_string()));
2✔
2735
        assert_eq!(users.rowids().len(), 3);
1✔
2736
    }
2737

2738
    #[test]
2739
    fn alter_drop_column_drops_dependent_indexes() {
3✔
2740
        let mut db = seed_users_table();
1✔
2741
        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2✔
2742
        process_command("ALTER TABLE users DROP COLUMN age;", &mut db).unwrap();
1✔
2743
        let users = db.get_table("users".to_string()).unwrap();
1✔
2744
        assert!(users.index_by_name("users_age_idx").is_none());
1✔
2745
    }
2746

2747
    #[test]
2748
    fn alter_drop_column_pk_errors() {
3✔
2749
        let mut db = seed_users_table();
1✔
2750
        let err = process_command("ALTER TABLE users DROP COLUMN id;", &mut db).unwrap_err();
2✔
2751
        assert!(
×
2752
            format!("{err}").to_lowercase().contains("primary key"),
4✔
2753
            "got: {err}"
2754
        );
2755
    }
2756

2757
    #[test]
2758
    fn alter_drop_column_only_column_errors() {
3✔
2759
        let mut db = Database::new("t".to_string());
1✔
2760
        process_command("CREATE TABLE solo (only_col TEXT);", &mut db).unwrap();
2✔
2761
        let err = process_command("ALTER TABLE solo DROP COLUMN only_col;", &mut db).unwrap_err();
1✔
2762
        assert!(
×
2763
            format!("{err}").to_lowercase().contains("only column"),
4✔
2764
            "got: {err}"
2765
        );
2766
    }
2767

2768
    #[test]
2769
    fn alter_unknown_table_errors_without_if_exists() {
3✔
2770
        let mut db = Database::new("t".to_string());
1✔
2771
        let err = process_command("ALTER TABLE missing RENAME TO other;", &mut db).unwrap_err();
2✔
2772
        assert!(format!("{err}").contains("does not exist"), "got: {err}");
2✔
2773
    }
2774

2775
    #[test]
2776
    fn alter_unknown_table_if_exists_noop() {
3✔
2777
        let mut db = Database::new("t".to_string());
1✔
2778
        let response = process_command("ALTER TABLE IF EXISTS missing RENAME TO other;", &mut db)
1✔
2779
            .expect("IF EXISTS makes missing-table ALTER a no-op");
2780
        assert!(response.contains("no-op"));
2✔
2781
    }
2782

2783
    #[test]
2784
    fn drop_table_inside_transaction_rolls_back() {
3✔
2785
        // Exercises Database::deep_clone snapshot path with DROP TABLE.
2786
        // A wholesale tables-HashMap restore on ROLLBACK should resurrect
2787
        // the dropped table — including its data and dependent indexes.
2788
        let mut db = seed_users_table();
1✔
2789
        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2✔
2790
        process_command("BEGIN;", &mut db).unwrap();
1✔
2791
        process_command("DROP TABLE users;", &mut db).unwrap();
1✔
2792
        assert!(!db.contains_table("users".to_string()));
1✔
2793
        process_command("ROLLBACK;", &mut db).unwrap();
2✔
2794
        assert!(db.contains_table("users".to_string()));
1✔
2795
        let users = db.get_table("users".to_string()).unwrap();
1✔
2796
        assert_eq!(users.rowids().len(), 3);
1✔
2797
        assert!(users.index_by_name("users_age_idx").is_some());
1✔
2798
    }
2799

2800
    #[test]
2801
    fn alter_inside_transaction_rolls_back() {
3✔
2802
        let mut db = seed_users_table();
1✔
2803
        process_command("BEGIN;", &mut db).unwrap();
2✔
2804
        process_command(
2805
            "ALTER TABLE users ADD COLUMN status TEXT DEFAULT 'active';",
2806
            &mut db,
2807
        )
2808
        .unwrap();
2809
        // Confirm in-flight visibility.
2810
        assert!(
×
2811
            db.get_table("users".to_string())
2✔
2812
                .unwrap()
1✔
2813
                .contains_column("status".to_string())
1✔
2814
        );
2815
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
2816
        // Snapshot restore should erase the ALTER.
2817
        assert!(
×
2818
            !db.get_table("users".to_string())
2✔
2819
                .unwrap()
1✔
2820
                .contains_column("status".to_string())
1✔
2821
        );
2822
    }
2823

2824
    #[test]
2825
    fn alter_rejected_on_readonly_db() {
3✔
2826
        use crate::sql::pager::{open_database_read_only, save_database};
2827

2828
        let mut seed = Database::new("t".to_string());
1✔
2829
        process_command(
2830
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
2831
            &mut seed,
2832
        )
2833
        .unwrap();
2834
        let path = {
2835
            let mut p = std::env::temp_dir();
1✔
2836
            let pid = std::process::id();
2✔
2837
            let nanos = std::time::SystemTime::now()
2✔
2838
                .duration_since(std::time::UNIX_EPOCH)
1✔
2839
                .map(|d| d.as_nanos())
3✔
2840
                .unwrap_or(0);
2841
            p.push(format!("sqlrite-alter-ro-{pid}-{nanos}.sqlrite"));
1✔
2842
            p
1✔
2843
        };
2844
        save_database(&mut seed, &path).unwrap();
2✔
2845
        drop(seed);
1✔
2846

2847
        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1✔
2848
        for stmt in [
3✔
2849
            "ALTER TABLE notes RENAME TO n2;",
2850
            "ALTER TABLE notes RENAME COLUMN body TO b;",
2851
            "ALTER TABLE notes ADD COLUMN extra TEXT;",
2852
            "ALTER TABLE notes DROP COLUMN body;",
2853
        ] {
2854
            let err = process_command(stmt, &mut ro).unwrap_err();
2✔
2855
            assert!(
×
2856
                format!("{err}").contains("read-only"),
3✔
2857
                "{stmt:?} should surface read-only error, got: {err}"
2858
            );
2859
        }
2860

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

© 2026 Coveralls, Inc