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

joaoh82 / rust_sqlite / 25068257030

28 Apr 2026 05:36PM UTC coverage: 70.968% (+0.6%) from 70.322%
25068257030

Pull #52

github

web-flow
Merge e34433f24 into 14b746634
Pull Request #52: Phase 7d.3: HNSW persistence + DELETE/UPDATE unblock (closes Phase 7d)

251 of 293 new or added lines in 5 files covered. (85.67%)

2 existing lines in 1 file now uncovered.

5214 of 7347 relevant lines covered (70.97%)

1.46 hits per line

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

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

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

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

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

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

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

44
/// Performs initial parsing of SQL Statement using sqlparser-rs
45
pub fn process_command(query: &str, db: &mut Database) -> Result<String> {
2✔
46
    let dialect = SQLiteDialect {};
2✔
47
    let message: String;
48
    let mut ast = Parser::parse_sql(&dialect, query).map_err(SQLRiteError::from)?;
4✔
49

50
    if ast.len() > 1 {
4✔
51
        return Err(SQLRiteError::SqlError(ParserError::ParserError(format!(
×
52
            "Expected a single query statement, but there are {}",
53
            ast.len()
×
54
        ))));
55
    }
56

57
    // Comment-only or whitespace-only input parses to an empty Vec<Statement>.
58
    // Return a benign status rather than panicking on `pop().unwrap()`. Callers
59
    // (REPL, Tauri app) treat this as a no-op with no disk write triggered.
60
    let Some(query) = ast.pop() else {
4✔
61
        return Ok("No statement to execute.".to_string());
2✔
62
    };
63

64
    // Transaction boundary statements are routed to Database-level
65
    // handlers before we even inspect the rest of the AST. They don't
66
    // mutate table data directly, so they short-circuit the
67
    // is_write_statement / auto-save path.
68
    match &query {
2✔
69
        Statement::StartTransaction { .. } => {
70
            db.begin_transaction()?;
3✔
71
            return Ok(String::from("BEGIN"));
1✔
72
        }
73
        Statement::Commit { .. } => {
74
            if !db.in_transaction() {
2✔
75
                return Err(SQLRiteError::General(
1✔
76
                    "cannot COMMIT: no transaction is open".to_string(),
1✔
77
                ));
78
            }
79
            // Flush accumulated in-memory changes to disk. If the save
80
            // fails we auto-rollback the in-memory state to the
81
            // pre-BEGIN snapshot and surface a combined error. Leaving
82
            // the transaction open after a failed COMMIT would be
83
            // unsafe: auto-save on any subsequent non-transactional
84
            // statement would silently publish partial mid-transaction
85
            // work. Auto-rollback keeps the disk-plus-memory pair
86
            // coherent — the user loses their in-flight work on a disk
87
            // error, but that's the only safe outcome.
88
            if let Some(path) = db.source_path.clone() {
2✔
89
                if let Err(save_err) = pager::save_database(db, &path) {
2✔
90
                    let _ = db.rollback_transaction();
2✔
91
                    return Err(SQLRiteError::General(format!(
1✔
92
                        "COMMIT failed — transaction rolled back: {save_err}"
93
                    )));
94
                }
95
            }
96
            db.commit_transaction()?;
2✔
97
            return Ok(String::from("COMMIT"));
1✔
98
        }
99
        Statement::Rollback { .. } => {
100
            db.rollback_transaction()?;
3✔
101
            return Ok(String::from("ROLLBACK"));
1✔
102
        }
103
        _ => {}
104
    }
105

106
    // Statements that mutate state — trigger auto-save on success. Read-only
107
    // SELECTs skip the save entirely to avoid pointless file writes.
108
    let is_write_statement = matches!(
3✔
109
        &query,
2✔
110
        Statement::CreateTable(_)
111
            | Statement::CreateIndex(_)
112
            | Statement::Insert(_)
113
            | Statement::Update(_)
114
            | Statement::Delete(_)
115
    );
116

117
    // Early-reject mutations on a read-only database before they touch
118
    // in-memory state. Phase 4e: without this, a user running INSERT
119
    // on a `--readonly` REPL would see the row appear in the printed
120
    // table, and then the auto-save would fail — leaving the in-memory
121
    // Database visibly diverged from disk.
122
    if is_write_statement && db.is_read_only() {
4✔
123
        return Err(SQLRiteError::General(
1✔
124
            "cannot execute: database is opened read-only".to_string(),
1✔
125
        ));
126
    }
127

128
    // Initialy only implementing some basic SQL Statements
129
    match query {
2✔
130
        Statement::CreateTable(_) => {
131
            let create_query = CreateQuery::new(&query);
2✔
132
            match create_query {
2✔
133
                Ok(payload) => {
2✔
134
                    let table_name = payload.table_name.clone();
2✔
135
                    if table_name == pager::MASTER_TABLE_NAME {
4✔
136
                        return Err(SQLRiteError::General(format!(
×
137
                            "'{}' is a reserved name used by the internal schema catalog",
138
                            pager::MASTER_TABLE_NAME
139
                        )));
140
                    }
141
                    // Checking if table already exists, after parsing CREATE TABLE query
142
                    match db.contains_table(table_name.to_string()) {
4✔
143
                        true => {
144
                            return Err(SQLRiteError::Internal(
×
145
                                "Cannot create, table already exists.".to_string(),
×
146
                            ));
147
                        }
148
                        false => {
149
                            let table = Table::new(payload);
4✔
150
                            let _ = table.print_table_schema();
4✔
151
                            db.tables.insert(table_name.to_string(), table);
2✔
152
                            // Iterate over everything.
153
                            // for (table_name, _) in &db.tables {
154
                            //     println!("{}" , table_name);
155
                            // }
156
                            message = String::from("CREATE TABLE Statement executed.");
2✔
157
                        }
158
                    }
159
                }
160
                Err(err) => return Err(err),
1✔
161
            }
162
        }
163
        Statement::Insert(_) => {
164
            let insert_query = InsertQuery::new(&query);
2✔
165
            match insert_query {
2✔
166
                Ok(payload) => {
2✔
167
                    let table_name = payload.table_name;
2✔
168
                    let columns = payload.columns;
2✔
169
                    let values = payload.rows;
2✔
170

171
                    // println!("table_name = {:?}\n cols = {:?}\n vals = {:?}", table_name, columns, values);
172
                    // Checking if Table exists in Database
173
                    match db.contains_table(table_name.to_string()) {
4✔
174
                        true => {
175
                            let db_table = db.get_table_mut(table_name.to_string()).unwrap();
4✔
176
                            // Checking if columns on INSERT query exist on Table
177
                            match columns
6✔
178
                                .iter()
2✔
179
                                .all(|column| db_table.contains_column(column.to_string()))
6✔
180
                            {
181
                                true => {
182
                                    for value in &values {
4✔
183
                                        // Checking if number of columns in query are the same as number of values
184
                                        if columns.len() != value.len() {
4✔
185
                                            return Err(SQLRiteError::Internal(format!(
×
186
                                                "{} values for {} columns",
187
                                                value.len(),
×
188
                                                columns.len()
×
189
                                            )));
190
                                        }
191
                                        db_table
2✔
192
                                            .validate_unique_constraint(&columns, value)
2✔
193
                                            .map_err(|err| {
2✔
194
                                                SQLRiteError::Internal(format!(
×
195
                                                    "Unique key constraint violation: {err}"
196
                                                ))
197
                                            })?;
198
                                        db_table.insert_row(&columns, value)?;
2✔
199
                                    }
200
                                }
201
                                false => {
202
                                    return Err(SQLRiteError::Internal(
×
203
                                        "Cannot insert, some of the columns do not exist"
204
                                            .to_string(),
×
205
                                    ));
206
                                }
207
                            }
208
                            db_table.print_table_data();
2✔
209
                        }
210
                        false => {
211
                            return Err(SQLRiteError::Internal("Table doesn't exist".to_string()));
2✔
212
                        }
213
                    }
214
                }
215
                Err(err) => return Err(err),
×
216
            }
217

218
            message = String::from("INSERT Statement executed.")
2✔
219
        }
220
        Statement::Query(_) => {
221
            let select_query = SelectQuery::new(&query)?;
3✔
222
            let (rendered, rows) = executor::execute_select(select_query, db)?;
3✔
223
            // Print the result table above the status message so the REPL shows both.
224
            print!("{rendered}");
2✔
225
            message = format!(
1✔
226
                "SELECT Statement executed. {rows} row{s} returned.",
227
                s = if rows == 1 { "" } else { "s" }
1✔
228
            );
229
        }
230
        Statement::Delete(_) => {
231
            let rows = executor::execute_delete(&query, db)?;
2✔
232
            message = format!(
1✔
233
                "DELETE Statement executed. {rows} row{s} deleted.",
234
                s = if rows == 1 { "" } else { "s" }
1✔
235
            );
236
        }
237
        Statement::Update(_) => {
238
            let rows = executor::execute_update(&query, db)?;
3✔
239
            message = format!(
1✔
240
                "UPDATE Statement executed. {rows} row{s} updated.",
241
                s = if rows == 1 { "" } else { "s" }
1✔
242
            );
243
        }
244
        Statement::CreateIndex(_) => {
245
            let name = executor::execute_create_index(&query, db)?;
3✔
246
            message = format!("CREATE INDEX '{name}' executed.");
2✔
247
        }
248
        _ => {
249
            return Err(SQLRiteError::NotImplemented(
1✔
250
                "SQL Statement not supported yet.".to_string(),
1✔
251
            ));
252
        }
253
    };
254

255
    // Auto-save: if the database is backed by a file AND no explicit
256
    // transaction is open AND the statement changed state, flush to
257
    // disk before returning. Inside a `BEGIN … COMMIT` block the
258
    // mutations accumulate in memory (protected by the ROLLBACK
259
    // snapshot) and land on disk in one shot when COMMIT runs.
260
    //
261
    // A failed save surfaces as an error — the in-memory state already
262
    // mutated, so the caller should know disk is out of sync. The
263
    // Pager held on `db` diffs against its last-committed snapshot,
264
    // so only pages whose bytes actually changed are written.
265
    if is_write_statement && db.source_path.is_some() && !db.in_transaction() {
4✔
266
        let path = db.source_path.clone().unwrap();
2✔
267
        pager::save_database(db, &path)?;
4✔
268
    }
269

270
    Ok(message)
2✔
271
}
272

273
#[cfg(test)]
274
mod tests {
275
    use super::*;
276
    use crate::sql::db::table::Value;
277

278
    /// Builds a `users(id INTEGER PK, name TEXT, age INTEGER)` table populated
279
    /// with three rows, for use in executor-level tests.
280
    fn seed_users_table() -> Database {
1✔
281
        let mut db = Database::new("tempdb".to_string());
1✔
282
        process_command(
283
            "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, age INTEGER);",
284
            &mut db,
285
        )
286
        .expect("create table");
287
        process_command(
288
            "INSERT INTO users (name, age) VALUES ('alice', 30);",
289
            &mut db,
290
        )
291
        .expect("insert alice");
292
        process_command("INSERT INTO users (name, age) VALUES ('bob', 25);", &mut db)
1✔
293
            .expect("insert bob");
294
        process_command(
295
            "INSERT INTO users (name, age) VALUES ('carol', 40);",
296
            &mut db,
297
        )
298
        .expect("insert carol");
299
        db
1✔
300
    }
301

302
    #[test]
303
    fn process_command_select_all_test() {
3✔
304
        let mut db = seed_users_table();
1✔
305
        let response = process_command("SELECT * FROM users;", &mut db).expect("select");
2✔
306
        assert!(response.contains("3 rows returned"));
2✔
307
    }
308

309
    #[test]
310
    fn process_command_select_where_test() {
3✔
311
        let mut db = seed_users_table();
1✔
312
        let response =
2✔
313
            process_command("SELECT name FROM users WHERE age > 25;", &mut db).expect("select");
314
        assert!(response.contains("2 rows returned"));
2✔
315
    }
316

317
    #[test]
318
    fn process_command_select_eq_string_test() {
3✔
319
        let mut db = seed_users_table();
1✔
320
        let response =
2✔
321
            process_command("SELECT name FROM users WHERE name = 'bob';", &mut db).expect("select");
322
        assert!(response.contains("1 row returned"));
2✔
323
    }
324

325
    #[test]
326
    fn process_command_select_limit_test() {
3✔
327
        let mut db = seed_users_table();
1✔
328
        let response = process_command("SELECT * FROM users ORDER BY age ASC LIMIT 2;", &mut db)
1✔
329
            .expect("select");
330
        assert!(response.contains("2 rows returned"));
2✔
331
    }
332

333
    #[test]
334
    fn process_command_select_unknown_table_test() {
3✔
335
        let mut db = Database::new("tempdb".to_string());
1✔
336
        let result = process_command("SELECT * FROM nope;", &mut db);
1✔
337
        assert!(result.is_err());
2✔
338
    }
339

340
    #[test]
341
    fn process_command_select_unknown_column_test() {
3✔
342
        let mut db = seed_users_table();
1✔
343
        let result = process_command("SELECT height FROM users;", &mut db);
1✔
344
        assert!(result.is_err());
2✔
345
    }
346

347
    #[test]
348
    fn process_command_insert_test() {
3✔
349
        // Creating temporary database
350
        let mut db = Database::new("tempdb".to_string());
1✔
351

352
        // Creating temporary table for testing purposes
353
        let query_statement = "CREATE TABLE users (
1✔
354
            id INTEGER PRIMARY KEY,
355
            name TEXT
356
        );";
357
        let dialect = SQLiteDialect {};
358
        let mut ast = Parser::parse_sql(&dialect, query_statement).unwrap();
2✔
359
        if ast.len() > 1 {
2✔
360
            panic!("Expected a single query statement, but there are more then 1.")
×
361
        }
362
        let query = ast.pop().unwrap();
2✔
363
        let create_query = CreateQuery::new(&query).unwrap();
2✔
364

365
        // Inserting table into database
366
        db.tables.insert(
2✔
367
            create_query.table_name.to_string(),
2✔
368
            Table::new(create_query),
1✔
369
        );
370

371
        // Inserting data into table
372
        let insert_query = String::from("INSERT INTO users (name) Values ('josh');");
1✔
373
        match process_command(&insert_query, &mut db) {
2✔
374
            Ok(response) => assert_eq!(response, "INSERT Statement executed."),
1✔
375
            Err(err) => {
×
376
                eprintln!("Error: {}", err);
×
377
                assert!(false)
×
378
            }
379
        };
380
    }
381

382
    #[test]
383
    fn process_command_insert_no_pk_test() {
3✔
384
        // Creating temporary database
385
        let mut db = Database::new("tempdb".to_string());
1✔
386

387
        // Creating temporary table for testing purposes
388
        let query_statement = "CREATE TABLE users (
1✔
389
            name TEXT
390
        );";
391
        let dialect = SQLiteDialect {};
392
        let mut ast = Parser::parse_sql(&dialect, query_statement).unwrap();
2✔
393
        if ast.len() > 1 {
2✔
394
            panic!("Expected a single query statement, but there are more then 1.")
×
395
        }
396
        let query = ast.pop().unwrap();
2✔
397
        let create_query = CreateQuery::new(&query).unwrap();
2✔
398

399
        // Inserting table into database
400
        db.tables.insert(
2✔
401
            create_query.table_name.to_string(),
2✔
402
            Table::new(create_query),
1✔
403
        );
404

405
        // Inserting data into table
406
        let insert_query = String::from("INSERT INTO users (name) Values ('josh');");
1✔
407
        match process_command(&insert_query, &mut db) {
2✔
408
            Ok(response) => assert_eq!(response, "INSERT Statement executed."),
1✔
409
            Err(err) => {
×
410
                eprintln!("Error: {}", err);
×
411
                assert!(false)
×
412
            }
413
        };
414
    }
415

416
    #[test]
417
    fn process_command_delete_where_test() {
4✔
418
        let mut db = seed_users_table();
1✔
419
        let response =
2✔
420
            process_command("DELETE FROM users WHERE name = 'bob';", &mut db).expect("delete");
421
        assert!(response.contains("1 row deleted"));
2✔
422

423
        let remaining = process_command("SELECT * FROM users;", &mut db).expect("select");
1✔
424
        assert!(remaining.contains("2 rows returned"));
2✔
425
    }
426

427
    #[test]
428
    fn process_command_delete_all_test() {
3✔
429
        let mut db = seed_users_table();
1✔
430
        let response = process_command("DELETE FROM users;", &mut db).expect("delete");
2✔
431
        assert!(response.contains("3 rows deleted"));
2✔
432
    }
433

434
    #[test]
435
    fn process_command_update_where_test() {
3✔
436
        use crate::sql::db::table::Value;
437

438
        let mut db = seed_users_table();
1✔
439
        let response = process_command("UPDATE users SET age = 99 WHERE name = 'bob';", &mut db)
1✔
440
            .expect("update");
441
        assert!(response.contains("1 row updated"));
2✔
442

443
        // Confirm the cell was actually rewritten.
444
        let users = db.get_table("users".to_string()).unwrap();
1✔
445
        let bob_rowid = users
1✔
446
            .rowids()
447
            .into_iter()
448
            .find(|r| users.get_value("name", *r) == Some(Value::Text("bob".to_string())))
3✔
449
            .expect("bob row must exist");
450
        assert_eq!(users.get_value("age", bob_rowid), Some(Value::Integer(99)));
1✔
451
    }
452

453
    #[test]
454
    fn process_command_update_unique_violation_test() {
3✔
455
        let mut db = seed_users_table();
1✔
456
        // `name` is not UNIQUE in the seed — reinforce with an explicit unique column.
457
        process_command(
458
            "CREATE TABLE tags (id INTEGER PRIMARY KEY, label TEXT UNIQUE);",
459
            &mut db,
460
        )
461
        .unwrap();
462
        process_command("INSERT INTO tags (label) VALUES ('a');", &mut db).unwrap();
1✔
463
        process_command("INSERT INTO tags (label) VALUES ('b');", &mut db).unwrap();
1✔
464

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

469
    #[test]
470
    fn process_command_insert_type_mismatch_returns_error_test() {
3✔
471
        // Previously this panicked in parse::<i32>().unwrap(); now it should return an error cleanly.
472
        let mut db = Database::new("tempdb".to_string());
1✔
473
        process_command(
474
            "CREATE TABLE items (id INTEGER PRIMARY KEY, qty INTEGER);",
475
            &mut db,
476
        )
477
        .unwrap();
478
        let result = process_command("INSERT INTO items (qty) VALUES ('not a number');", &mut db);
1✔
479
        assert!(result.is_err(), "expected error, got {result:?}");
2✔
480
    }
481

482
    #[test]
483
    fn process_command_insert_missing_integer_returns_error_test() {
3✔
484
        // Non-PK INTEGER without a value should error (not panic on "Null".parse()).
485
        let mut db = Database::new("tempdb".to_string());
1✔
486
        process_command(
487
            "CREATE TABLE items (id INTEGER PRIMARY KEY, qty INTEGER);",
488
            &mut db,
489
        )
490
        .unwrap();
491
        let result = process_command("INSERT INTO items (id) VALUES (1);", &mut db);
1✔
492
        assert!(result.is_err(), "expected error, got {result:?}");
2✔
493
    }
494

495
    #[test]
496
    fn process_command_update_arith_test() {
3✔
497
        use crate::sql::db::table::Value;
498

499
        let mut db = seed_users_table();
1✔
500
        process_command("UPDATE users SET age = age + 1;", &mut db).expect("update +1");
2✔
501

502
        let users = db.get_table("users".to_string()).unwrap();
1✔
503
        let mut ages: Vec<i64> = users
504
            .rowids()
505
            .into_iter()
506
            .filter_map(|r| match users.get_value("age", r) {
3✔
507
                Some(Value::Integer(n)) => Some(n),
1✔
508
                _ => None,
×
509
            })
510
            .collect();
511
        ages.sort();
2✔
512
        assert_eq!(ages, vec![26, 31, 41]); // 25+1, 30+1, 40+1
1✔
513
    }
514

515
    #[test]
516
    fn process_command_select_arithmetic_where_test() {
3✔
517
        let mut db = seed_users_table();
1✔
518
        // age * 2 > 55  →  only ages > 27.5  →  alice(30) + carol(40)
519
        let response =
2✔
520
            process_command("SELECT name FROM users WHERE age * 2 > 55;", &mut db).expect("select");
521
        assert!(response.contains("2 rows returned"));
2✔
522
    }
523

524
    #[test]
525
    fn process_command_divide_by_zero_test() {
3✔
526
        let mut db = seed_users_table();
1✔
527
        let result = process_command("SELECT age / 0 FROM users;", &mut db);
1✔
528
        // Projection only supports bare columns, so this errors earlier; still shouldn't panic.
529
        assert!(result.is_err());
2✔
530
    }
531

532
    #[test]
533
    fn process_command_unsupported_statement_test() {
3✔
534
        let mut db = Database::new("tempdb".to_string());
1✔
535
        // Nothing in Phase 1 handles DROP.
536
        let result = process_command("DROP TABLE users;", &mut db);
1✔
537
        assert!(result.is_err());
2✔
538
    }
539

540
    #[test]
541
    fn empty_input_is_a_noop_not_a_panic() {
3✔
542
        // Regression for: desktop app pre-fills the textarea with a
543
        // comment-only placeholder, and hitting Run used to panic because
544
        // sqlparser produced zero statements and pop().unwrap() exploded.
545
        let mut db = Database::new("t".to_string());
1✔
546
        for input in ["", "   ", "-- just a comment", "-- comment\n-- another"] {
4✔
547
            let result = process_command(input, &mut db);
2✔
548
            assert!(result.is_ok(), "input {input:?} should not error");
2✔
549
            let msg = result.unwrap();
1✔
550
            assert!(msg.contains("No statement"), "got: {msg:?}");
2✔
551
        }
552
    }
553

554
    #[test]
555
    fn create_index_adds_explicit_index() {
3✔
556
        let mut db = seed_users_table();
1✔
557
        let response = process_command("CREATE INDEX users_age_idx ON users (age);", &mut db)
1✔
558
            .expect("create index");
559
        assert!(response.contains("users_age_idx"));
2✔
560

561
        // The index should now be attached to the users table.
562
        let users = db.get_table("users".to_string()).unwrap();
1✔
563
        let idx = users
1✔
564
            .index_by_name("users_age_idx")
565
            .expect("index should exist after CREATE INDEX");
566
        assert_eq!(idx.column_name, "age");
1✔
567
        assert!(!idx.is_unique);
1✔
568
    }
569

570
    #[test]
571
    fn create_unique_index_rejects_duplicate_existing_values() {
3✔
572
        let mut db = seed_users_table();
1✔
573
        // `name` is already UNIQUE (auto-indexed); insert a duplicate-age row
574
        // first so CREATE UNIQUE INDEX on age catches the conflict.
575
        process_command("INSERT INTO users (name, age) VALUES ('dan', 30);", &mut db).unwrap();
2✔
576
        let result = process_command(
577
            "CREATE UNIQUE INDEX users_age_unique ON users (age);",
578
            &mut db,
579
        );
580
        assert!(
×
581
            result.is_err(),
2✔
582
            "expected unique-index failure, got {result:?}"
583
        );
584
    }
585

586
    #[test]
587
    fn where_eq_on_indexed_column_uses_index_probe() {
3✔
588
        // Build a table big enough that a full scan would be expensive,
589
        // then rely on the index-probe fast path. This test verifies
590
        // correctness (right rows returned); the perf win is implicit.
591
        let mut db = Database::new("t".to_string());
1✔
592
        process_command(
593
            "CREATE TABLE big (id INTEGER PRIMARY KEY, tag TEXT);",
594
            &mut db,
595
        )
596
        .unwrap();
597
        process_command("CREATE INDEX big_tag_idx ON big (tag);", &mut db).unwrap();
1✔
598
        for i in 1..=100 {
1✔
599
            let tag = if i % 3 == 0 { "hot" } else { "cold" };
2✔
600
            process_command(&format!("INSERT INTO big (tag) VALUES ('{tag}');"), &mut db).unwrap();
1✔
601
        }
602
        let response =
1✔
603
            process_command("SELECT id FROM big WHERE tag = 'hot';", &mut db).expect("select");
604
        // 1..=100 has 33 multiples of 3.
605
        assert!(
×
606
            response.contains("33 rows returned"),
2✔
607
            "response was {response:?}"
608
        );
609
    }
610

611
    #[test]
612
    fn where_eq_on_indexed_column_inside_parens_uses_index_probe() {
3✔
613
        let mut db = seed_users_table();
1✔
614
        let response = process_command("SELECT name FROM users WHERE (name = 'bob');", &mut db)
1✔
615
            .expect("select");
616
        assert!(response.contains("1 row returned"));
2✔
617
    }
618

619
    #[test]
620
    fn where_eq_literal_first_side_uses_index_probe() {
3✔
621
        let mut db = seed_users_table();
1✔
622
        // `'bob' = name` should hit the same path as `name = 'bob'`.
623
        let response =
2✔
624
            process_command("SELECT name FROM users WHERE 'bob' = name;", &mut db).expect("select");
625
        assert!(response.contains("1 row returned"));
2✔
626
    }
627

628
    #[test]
629
    fn non_equality_where_still_falls_back_to_full_scan() {
3✔
630
        // Sanity: range predicates bypass the optimizer and the full-scan
631
        // path still returns correct results.
632
        let mut db = seed_users_table();
1✔
633
        let response =
2✔
634
            process_command("SELECT name FROM users WHERE age > 28;", &mut db).expect("select");
635
        assert!(response.contains("2 rows returned"));
2✔
636
    }
637

638
    // -------------------------------------------------------------------
639
    // Phase 4f — Transactions (BEGIN / COMMIT / ROLLBACK)
640
    // -------------------------------------------------------------------
641

642
    #[test]
643
    fn rollback_restores_pre_begin_in_memory_state() {
3✔
644
        // In-memory DB (no pager): BEGIN, insert a row, ROLLBACK.
645
        // The row must disappear from the live tables HashMap.
646
        let mut db = seed_users_table();
1✔
647
        let before = db.get_table("users".to_string()).unwrap().rowids().len();
2✔
648
        assert_eq!(before, 3);
1✔
649

650
        process_command("BEGIN;", &mut db).expect("BEGIN");
1✔
651
        assert!(db.in_transaction());
1✔
652
        process_command("INSERT INTO users (name, age) VALUES ('dan', 50);", &mut db)
1✔
653
            .expect("INSERT inside txn");
654
        // Mid-transaction read sees the new row.
655
        let mid = db.get_table("users".to_string()).unwrap().rowids().len();
1✔
656
        assert_eq!(mid, 4);
1✔
657

658
        process_command("ROLLBACK;", &mut db).expect("ROLLBACK");
1✔
659
        assert!(!db.in_transaction());
1✔
660
        let after = db.get_table("users".to_string()).unwrap().rowids().len();
2✔
661
        assert_eq!(
1✔
662
            after, 3,
663
            "ROLLBACK should have restored the pre-BEGIN state"
664
        );
665
    }
666

667
    #[test]
668
    fn commit_keeps_mutations_and_clears_txn_flag() {
3✔
669
        let mut db = seed_users_table();
1✔
670
        process_command("BEGIN;", &mut db).expect("BEGIN");
2✔
671
        process_command("INSERT INTO users (name, age) VALUES ('dan', 50);", &mut db)
1✔
672
            .expect("INSERT inside txn");
673
        process_command("COMMIT;", &mut db).expect("COMMIT");
1✔
674
        assert!(!db.in_transaction());
1✔
675
        let after = db.get_table("users".to_string()).unwrap().rowids().len();
2✔
676
        assert_eq!(after, 4);
1✔
677
    }
678

679
    #[test]
680
    fn rollback_undoes_update_and_delete_side_by_side() {
3✔
681
        use crate::sql::db::table::Value;
682

683
        let mut db = seed_users_table();
1✔
684
        process_command("BEGIN;", &mut db).unwrap();
2✔
685
        process_command("UPDATE users SET age = 999;", &mut db).unwrap();
1✔
686
        process_command("DELETE FROM users WHERE name = 'bob';", &mut db).unwrap();
1✔
687
        // Mid-txn: one row gone, others have age=999.
688
        let users = db.get_table("users".to_string()).unwrap();
1✔
689
        assert_eq!(users.rowids().len(), 2);
1✔
690
        for r in users.rowids() {
2✔
691
            assert_eq!(users.get_value("age", r), Some(Value::Integer(999)));
2✔
692
        }
693

694
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
695
        let users = db.get_table("users".to_string()).unwrap();
1✔
696
        assert_eq!(users.rowids().len(), 3);
1✔
697
        // Original ages {30, 25, 40} — none should be 999.
698
        for r in users.rowids() {
2✔
699
            assert_ne!(users.get_value("age", r), Some(Value::Integer(999)));
2✔
700
        }
701
    }
702

703
    #[test]
704
    fn nested_begin_is_rejected() {
3✔
705
        let mut db = seed_users_table();
1✔
706
        process_command("BEGIN;", &mut db).unwrap();
2✔
707
        let err = process_command("BEGIN;", &mut db).unwrap_err();
1✔
708
        assert!(
×
709
            format!("{err}").contains("already open"),
3✔
710
            "nested BEGIN should error; got: {err}"
711
        );
712
        // Still in the original transaction; a ROLLBACK clears it.
713
        assert!(db.in_transaction());
1✔
714
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
715
    }
716

717
    #[test]
718
    fn orphan_commit_and_rollback_are_rejected() {
3✔
719
        let mut db = seed_users_table();
1✔
720
        let commit_err = process_command("COMMIT;", &mut db).unwrap_err();
2✔
721
        assert!(format!("{commit_err}").contains("no transaction"));
2✔
722
        let rollback_err = process_command("ROLLBACK;", &mut db).unwrap_err();
1✔
723
        assert!(format!("{rollback_err}").contains("no transaction"));
2✔
724
    }
725

726
    #[test]
727
    fn error_inside_transaction_keeps_txn_open() {
3✔
728
        // A bad INSERT inside a txn doesn't commit or abort automatically —
729
        // the user can still ROLLBACK. SQLite's implicit-rollback behavior
730
        // isn't modeled here.
731
        let mut db = seed_users_table();
1✔
732
        process_command("BEGIN;", &mut db).unwrap();
2✔
733
        let err = process_command("INSERT INTO nope (x) VALUES (1);", &mut db);
1✔
734
        assert!(err.is_err());
2✔
735
        assert!(db.in_transaction(), "txn should stay open after error");
1✔
736
        process_command("ROLLBACK;", &mut db).unwrap();
2✔
737
    }
738

739
    /// Builds a file-backed Database at a unique temp path, with the
740
    /// schema seeded and `source_path` set so subsequent process_command
741
    /// calls auto-save. Returns (path, db). Drop the db before deleting
742
    /// the files.
743
    fn seed_file_backed(name: &str, schema: &str) -> (std::path::PathBuf, Database) {
1✔
744
        use crate::sql::pager::{open_database, save_database};
745
        let mut p = std::env::temp_dir();
1✔
746
        let pid = std::process::id();
2✔
747
        let nanos = std::time::SystemTime::now()
2✔
748
            .duration_since(std::time::UNIX_EPOCH)
1✔
749
            .map(|d| d.as_nanos())
3✔
750
            .unwrap_or(0);
751
        p.push(format!("sqlrite-txn-{name}-{pid}-{nanos}.sqlrite"));
1✔
752

753
        // Seed the file, then reopen to get a source_path-attached db
754
        // (save_database alone doesn't attach a fresh pager to a db
755
        // whose source_path was None before the call).
756
        {
757
            let mut seed = Database::new("t".to_string());
1✔
758
            process_command(schema, &mut seed).unwrap();
2✔
759
            save_database(&mut seed, &p).unwrap();
1✔
760
        }
761
        let db = open_database(&p, "t".to_string()).unwrap();
1✔
762
        (p, db)
1✔
763
    }
764

765
    fn cleanup_file(path: &std::path::Path) {
1✔
766
        let _ = std::fs::remove_file(path);
1✔
767
        let mut wal = path.as_os_str().to_owned();
1✔
768
        wal.push("-wal");
1✔
769
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
770
    }
771

772
    #[test]
773
    fn begin_commit_rollback_round_trip_through_disk() {
3✔
774
        // File-backed DB: commit inside a transaction must actually
775
        // persist. ROLLBACK inside a *later* transaction must not
776
        // un-do the previously-committed changes.
777
        use crate::sql::pager::open_database;
778

779
        let (path, mut db) = seed_file_backed(
780
            "roundtrip",
781
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
782
        );
783

784
        // Transaction 1: insert two rows, commit.
785
        process_command("BEGIN;", &mut db).unwrap();
2✔
786
        process_command("INSERT INTO notes (body) VALUES ('a');", &mut db).unwrap();
1✔
787
        process_command("INSERT INTO notes (body) VALUES ('b');", &mut db).unwrap();
1✔
788
        process_command("COMMIT;", &mut db).unwrap();
1✔
789

790
        // Transaction 2: insert another, roll back.
791
        process_command("BEGIN;", &mut db).unwrap();
1✔
792
        process_command("INSERT INTO notes (body) VALUES ('c');", &mut db).unwrap();
1✔
793
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
794

795
        drop(db); // release pager lock
1✔
796

797
        let reopened = open_database(&path, "t".to_string()).unwrap();
1✔
798
        let notes = reopened.get_table("notes".to_string()).unwrap();
2✔
799
        assert_eq!(notes.rowids().len(), 2, "committed rows should survive");
1✔
800

801
        drop(reopened);
1✔
802
        cleanup_file(&path);
1✔
803
    }
804

805
    #[test]
806
    fn write_inside_transaction_does_not_autosave() {
3✔
807
        // File-backed DB: writes inside BEGIN/…/COMMIT must NOT hit
808
        // the WAL until COMMIT. We prove it by checking the WAL file
809
        // size before vs during the transaction.
810
        let (path, mut db) =
1✔
811
            seed_file_backed("noas", "CREATE TABLE t (id INTEGER PRIMARY KEY, x TEXT);");
812

813
        let mut wal_path = path.as_os_str().to_owned();
2✔
814
        wal_path.push("-wal");
1✔
815
        let wal_path = std::path::PathBuf::from(wal_path);
1✔
816
        let frames_before = std::fs::metadata(&wal_path).unwrap().len();
2✔
817

818
        process_command("BEGIN;", &mut db).unwrap();
1✔
819
        process_command("INSERT INTO t (x) VALUES ('a');", &mut db).unwrap();
1✔
820
        process_command("INSERT INTO t (x) VALUES ('b');", &mut db).unwrap();
1✔
821

822
        // Mid-transaction: WAL must be unchanged — no auto-save fired.
823
        let frames_mid = std::fs::metadata(&wal_path).unwrap().len();
1✔
824
        assert_eq!(
1✔
825
            frames_before, frames_mid,
826
            "WAL should not grow during an open transaction"
827
        );
828

829
        process_command("COMMIT;", &mut db).unwrap();
2✔
830

831
        drop(db); // release pager lock
1✔
832
        let fresh = crate::sql::pager::open_database(&path, "t".to_string()).unwrap();
1✔
833
        assert_eq!(
1✔
834
            fresh.get_table("t".to_string()).unwrap().rowids().len(),
3✔
835
            2,
836
            "COMMIT should have persisted both inserted rows"
837
        );
838
        drop(fresh);
1✔
839
        cleanup_file(&path);
1✔
840
    }
841

842
    #[test]
843
    fn rollback_undoes_create_table() {
3✔
844
        // Schema DDL inside a txn: ROLLBACK must make the new table
845
        // disappear. The txn snapshot captures db.tables as of BEGIN,
846
        // and ROLLBACK reassigns tables from that snapshot, so a table
847
        // created mid-transaction has no entry in the snapshot.
848
        let mut db = seed_users_table();
1✔
849
        assert_eq!(db.tables.len(), 1);
2✔
850

851
        process_command("BEGIN;", &mut db).unwrap();
1✔
852
        process_command(
853
            "CREATE TABLE dropme (id INTEGER PRIMARY KEY, x TEXT);",
854
            &mut db,
855
        )
856
        .unwrap();
857
        process_command("INSERT INTO dropme (x) VALUES ('stuff');", &mut db).unwrap();
1✔
858
        assert_eq!(db.tables.len(), 2);
1✔
859

860
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
861
        assert_eq!(
1✔
862
            db.tables.len(),
1✔
863
            1,
864
            "CREATE TABLE should have been rolled back"
865
        );
866
        assert!(db.get_table("dropme".to_string()).is_err());
2✔
867
    }
868

869
    #[test]
870
    fn rollback_restores_secondary_index_state() {
3✔
871
        // Phase 4f edge case: rolling back an INSERT on a UNIQUE-indexed
872
        // column must also clean up the index, otherwise a re-insert of
873
        // the same value would spuriously collide.
874
        let mut db = Database::new("t".to_string());
1✔
875
        process_command(
876
            "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
877
            &mut db,
878
        )
879
        .unwrap();
880
        process_command("INSERT INTO users (email) VALUES ('a@x');", &mut db).unwrap();
1✔
881

882
        process_command("BEGIN;", &mut db).unwrap();
1✔
883
        process_command("INSERT INTO users (email) VALUES ('b@x');", &mut db).unwrap();
1✔
884
        // Inside the txn: the index now contains both 'a@x' and 'b@x'.
885
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
886

887
        // Re-inserting 'b@x' after rollback must succeed — if the index
888
        // wasn't properly restored, it would think 'b@x' is still a
889
        // collision and fail with a UNIQUE violation.
890
        let reinsert = process_command("INSERT INTO users (email) VALUES ('b@x');", &mut db);
1✔
891
        assert!(
×
892
            reinsert.is_ok(),
2✔
893
            "re-insert after rollback should succeed, got {reinsert:?}"
894
        );
895
    }
896

897
    #[test]
898
    fn rollback_restores_last_rowid_counter() {
3✔
899
        // Rowids allocated inside a rolled-back transaction should be
900
        // reusable. The snapshot restores Table::last_rowid, so the
901
        // next insert picks up where the pre-BEGIN state left off.
902
        use crate::sql::db::table::Value;
903

904
        let mut db = seed_users_table(); // 3 rows, last_rowid = 3
1✔
905
        let pre = db.get_table("users".to_string()).unwrap().last_rowid;
2✔
906

907
        process_command("BEGIN;", &mut db).unwrap();
1✔
908
        process_command("INSERT INTO users (name, age) VALUES ('d', 50);", &mut db).unwrap(); // would be rowid 4
1✔
909
        process_command("INSERT INTO users (name, age) VALUES ('e', 60);", &mut db).unwrap(); // would be rowid 5
1✔
910
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
911

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

915
        // Confirm: the next insert reuses rowid pre+1.
916
        process_command("INSERT INTO users (name, age) VALUES ('d', 50);", &mut db).unwrap();
2✔
917
        let users = db.get_table("users".to_string()).unwrap();
1✔
918
        let d_rowid = users
1✔
919
            .rowids()
920
            .into_iter()
921
            .find(|r| users.get_value("name", *r) == Some(Value::Text("d".into())))
3✔
922
            .expect("d row must exist");
923
        assert_eq!(d_rowid, pre + 1);
1✔
924
    }
925

926
    #[test]
927
    fn commit_on_in_memory_db_clears_txn_without_pager_call() {
3✔
928
        // In-memory DB (no source_path): COMMIT must still work — just
929
        // no disk flush. Covers the `if let Some(path) = …` branch
930
        // where the guard falls through without calling save_database.
931
        let mut db = seed_users_table(); // no source_path
1✔
932
        assert!(db.source_path.is_none());
2✔
933

934
        process_command("BEGIN;", &mut db).unwrap();
1✔
935
        process_command("INSERT INTO users (name, age) VALUES ('z', 99);", &mut db).unwrap();
1✔
936
        process_command("COMMIT;", &mut db).unwrap();
1✔
937

938
        assert!(!db.in_transaction());
1✔
939
        assert_eq!(db.get_table("users".to_string()).unwrap().rowids().len(), 4);
2✔
940
    }
941

942
    #[test]
943
    fn failed_commit_auto_rolls_back_in_memory_state() {
3✔
944
        // Data-safety regression: on COMMIT save failure we must auto-
945
        // rollback the in-memory state. Otherwise, any subsequent
946
        // non-transactional statement would auto-save the partial
947
        // mid-transaction work, silently publishing uncommitted
948
        // changes to disk.
949
        //
950
        // We simulate a save failure by making the WAL sidecar path
951
        // unavailable mid-transaction: after BEGIN, we take an
952
        // exclusive OS lock on the WAL via a second File handle,
953
        // forcing the next save to fail when it tries to append.
954
        //
955
        // Simpler repro: point source_path at a directory (not a file).
956
        // `OpenOptions::open` will fail with EISDIR on save.
957
        use crate::sql::pager::save_database;
958

959
        // Seed a file-backed db.
960
        let (path, mut db) = seed_file_backed(
961
            "failcommit",
962
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
963
        );
964

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

968
        // Open a new txn and add a row.
969
        process_command("BEGIN;", &mut db).unwrap();
1✔
970
        process_command("INSERT INTO notes (body) VALUES ('inflight');", &mut db).unwrap();
1✔
971
        assert_eq!(
1✔
972
            db.get_table("notes".to_string()).unwrap().rowids().len(),
2✔
973
            2,
974
            "inflight row visible mid-txn"
975
        );
976

977
        // Swap source_path to a path that will fail on open. A
978
        // directory is a reliable failure mode — Pager::open on a
979
        // directory errors with an I/O error.
980
        let orig_source = db.source_path.clone();
1✔
981
        let orig_pager = db.pager.take();
2✔
982
        db.source_path = Some(std::env::temp_dir());
2✔
983

984
        let commit_result = process_command("COMMIT;", &mut db);
1✔
985
        assert!(commit_result.is_err(), "commit must fail");
2✔
986
        let err_str = format!("{}", commit_result.unwrap_err());
2✔
987
        assert!(
×
988
            err_str.contains("COMMIT failed") && err_str.contains("rolled back"),
3✔
989
            "error must surface auto-rollback; got: {err_str}"
990
        );
991

992
        // Auto-rollback fired: the inflight row is gone, the txn flag
993
        // is cleared, and a follow-up non-txn statement won't leak
994
        // stale state.
995
        assert!(
×
996
            !db.in_transaction(),
2✔
997
            "txn must be cleared after auto-rollback"
998
        );
999
        assert_eq!(
1✔
1000
            db.get_table("notes".to_string()).unwrap().rowids().len(),
3✔
1001
            1,
1002
            "inflight row must be rolled back"
1003
        );
1004

1005
        // Restore the real source_path + pager and verify a clean
1006
        // subsequent write goes through.
1007
        db.source_path = orig_source;
1✔
1008
        db.pager = orig_pager;
1✔
1009
        process_command("INSERT INTO notes (body) VALUES ('after');", &mut db).unwrap();
1✔
1010
        drop(db);
1✔
1011

1012
        // Reopen and assert only 'before' + 'after' landed on disk.
1013
        let reopened = crate::sql::pager::open_database(&path, "t".to_string()).unwrap();
1✔
1014
        let notes = reopened.get_table("notes".to_string()).unwrap();
2✔
1015
        assert_eq!(notes.rowids().len(), 2);
1✔
1016
        // Ensure no leaked save_database partial happened.
1017
        let _ = save_database; // silence unused-import lint if any
1018
        drop(reopened);
1✔
1019
        cleanup_file(&path);
1✔
1020
    }
1021

1022
    #[test]
1023
    fn begin_on_read_only_is_rejected() {
3✔
1024
        use crate::sql::pager::{open_database_read_only, save_database};
1025

1026
        let path = {
1✔
1027
            let mut p = std::env::temp_dir();
1✔
1028
            let pid = std::process::id();
2✔
1029
            let nanos = std::time::SystemTime::now()
2✔
1030
                .duration_since(std::time::UNIX_EPOCH)
1✔
1031
                .map(|d| d.as_nanos())
3✔
1032
                .unwrap_or(0);
1033
            p.push(format!("sqlrite-txn-ro-{pid}-{nanos}.sqlrite"));
1✔
1034
            p
1✔
1035
        };
1036
        {
1037
            let mut seed = Database::new("t".to_string());
2✔
1038
            process_command("CREATE TABLE t (id INTEGER PRIMARY KEY);", &mut seed).unwrap();
2✔
1039
            save_database(&mut seed, &path).unwrap();
1✔
1040
        }
1041

1042
        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1✔
1043
        let err = process_command("BEGIN;", &mut ro).unwrap_err();
2✔
1044
        assert!(
×
1045
            format!("{err}").contains("read-only"),
3✔
1046
            "BEGIN on RO db should surface read-only; got: {err}"
1047
        );
1048
        assert!(!ro.in_transaction());
1✔
1049

1050
        let _ = std::fs::remove_file(&path);
2✔
1051
        let mut wal = path.as_os_str().to_owned();
1✔
1052
        wal.push("-wal");
1✔
1053
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
1054
    }
1055

1056
    #[test]
1057
    fn read_only_database_rejects_mutations_before_touching_state() {
3✔
1058
        // Phase 4e end-to-end: a `--readonly` caller that runs INSERT
1059
        // must error *before* the row is added to the in-memory table.
1060
        // Otherwise the user sees a rendered result table with the
1061
        // phantom row, followed by the auto-save error — UX rot and a
1062
        // state-drift risk.
1063
        use crate::sql::pager::open_database_read_only;
1064

1065
        let mut seed = Database::new("t".to_string());
1✔
1066
        process_command(
1067
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1068
            &mut seed,
1069
        )
1070
        .unwrap();
1071
        process_command("INSERT INTO notes (body) VALUES ('alpha');", &mut seed).unwrap();
1✔
1072

1073
        let path = {
1074
            let mut p = std::env::temp_dir();
1✔
1075
            let pid = std::process::id();
2✔
1076
            let nanos = std::time::SystemTime::now()
2✔
1077
                .duration_since(std::time::UNIX_EPOCH)
1✔
1078
                .map(|d| d.as_nanos())
3✔
1079
                .unwrap_or(0);
1080
            p.push(format!("sqlrite-ro-reject-{pid}-{nanos}.sqlrite"));
1✔
1081
            p
1✔
1082
        };
1083
        crate::sql::pager::save_database(&mut seed, &path).unwrap();
2✔
1084
        drop(seed);
1✔
1085

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

1089
        for stmt in [
2✔
1090
            "INSERT INTO notes (body) VALUES ('beta');",
1091
            "UPDATE notes SET body = 'x';",
1092
            "DELETE FROM notes;",
1093
            "CREATE TABLE more (id INTEGER PRIMARY KEY);",
1094
            "CREATE INDEX notes_body ON notes (body);",
1095
        ] {
1096
            let err = process_command(stmt, &mut ro).unwrap_err();
2✔
1097
            assert!(
×
1098
                format!("{err}").contains("read-only"),
3✔
1099
                "stmt {stmt:?} should surface a read-only error; got: {err}"
1100
            );
1101
        }
1102

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

1109
        // Cleanup.
1110
        drop(ro);
1✔
1111
        let _ = std::fs::remove_file(&path);
1✔
1112
        let mut wal = path.as_os_str().to_owned();
1✔
1113
        wal.push("-wal");
1✔
1114
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
1115
    }
1116

1117
    // -----------------------------------------------------------------
1118
    // Phase 7a — VECTOR(N) end-to-end through process_command
1119
    // -----------------------------------------------------------------
1120

1121
    #[test]
1122
    fn vector_create_table_and_insert_basic() {
3✔
1123
        let mut db = Database::new("tempdb".to_string());
1✔
1124
        process_command(
1125
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(3));",
1126
            &mut db,
1127
        )
1128
        .expect("create table with VECTOR(3)");
1129
        process_command(
1130
            "INSERT INTO docs (embedding) VALUES ([0.1, 0.2, 0.3]);",
1131
            &mut db,
1132
        )
1133
        .expect("insert vector");
1134

1135
        // process_command returns a status string; the rendered table
1136
        // goes to stdout via print_table. Verify state by inspecting
1137
        // the database directly.
1138
        let sel = process_command("SELECT * FROM docs;", &mut db).expect("select");
1✔
1139
        assert!(sel.contains("1 row returned"));
2✔
1140

1141
        let docs = db.get_table("docs".to_string()).expect("docs table");
1✔
1142
        let rowids = docs.rowids();
1✔
1143
        assert_eq!(rowids.len(), 1);
2✔
1144
        match docs.get_value("embedding", rowids[0]) {
1✔
1145
            Some(Value::Vector(v)) => assert_eq!(v, vec![0.1f32, 0.2, 0.3]),
1✔
1146
            other => panic!("expected Value::Vector(...), got {other:?}"),
×
1147
        }
1148
    }
1149

1150
    #[test]
1151
    fn vector_dim_mismatch_at_insert_is_clean_error() {
3✔
1152
        let mut db = Database::new("tempdb".to_string());
1✔
1153
        process_command(
1154
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(3));",
1155
            &mut db,
1156
        )
1157
        .expect("create table");
1158

1159
        // Too few elements.
1160
        let err = process_command("INSERT INTO docs (embedding) VALUES ([0.1, 0.2]);", &mut db)
1✔
1161
            .unwrap_err();
1162
        let msg = format!("{err}");
2✔
1163
        assert!(
×
1164
            msg.to_lowercase().contains("dimension")
2✔
1165
                && msg.contains("declared 3")
1✔
1166
                && msg.contains("got 2"),
1✔
1167
            "expected clear dim-mismatch error, got: {msg}"
1168
        );
1169

1170
        // Too many elements.
1171
        let err = process_command(
1172
            "INSERT INTO docs (embedding) VALUES ([0.1, 0.2, 0.3, 0.4, 0.5]);",
1173
            &mut db,
1174
        )
1175
        .unwrap_err();
1176
        assert!(
×
1177
            format!("{err}").contains("got 5"),
3✔
1178
            "expected dim-mismatch error mentioning got 5, got: {err}"
1179
        );
1180
    }
1181

1182
    #[test]
1183
    fn vector_create_table_rejects_missing_dim() {
3✔
1184
        let mut db = Database::new("tempdb".to_string());
1✔
1185
        // `VECTOR` (no parens) currently parses as `DataType::Custom` with
1186
        // empty args from sqlparser, OR may not parse as Custom at all
1187
        // depending on dialect. Either way, the column shouldn't end up
1188
        // as a usable Vector type. Accept any error here — the precise
1189
        // message is parser-version-dependent.
1190
        let result = process_command(
1191
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR);",
1192
            &mut db,
1193
        );
1194
        assert!(
×
1195
            result.is_err(),
2✔
1196
            "expected CREATE TABLE with bare VECTOR to fail (no dim)"
1197
        );
1198
    }
1199

1200
    #[test]
1201
    fn vector_create_table_rejects_zero_dim() {
3✔
1202
        let mut db = Database::new("tempdb".to_string());
1✔
1203
        let err = process_command(
1204
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(0));",
1205
            &mut db,
1206
        )
1207
        .unwrap_err();
1208
        let msg = format!("{err}");
2✔
1209
        assert!(
×
1210
            msg.to_lowercase().contains("vector"),
3✔
1211
            "expected VECTOR-related error for VECTOR(0), got: {msg}"
1212
        );
1213
    }
1214

1215
    #[test]
1216
    fn vector_high_dim_works() {
3✔
1217
        // 384-dim vector (OpenAI text-embedding-3-small size). Mostly a
1218
        // smoke test — if cell encoding mishandles the size, this fails.
1219
        let mut db = Database::new("tempdb".to_string());
1✔
1220
        process_command(
1221
            "CREATE TABLE embeddings (id INTEGER PRIMARY KEY, e VECTOR(384));",
1222
            &mut db,
1223
        )
1224
        .expect("create table VECTOR(384)");
1225

1226
        let lit = format!(
1✔
1227
            "[{}]",
1228
            (0..384)
1✔
1229
                .map(|i| format!("{}", i as f32 * 0.001))
3✔
1230
                .collect::<Vec<_>>()
1✔
1231
                .join(",")
1✔
1232
        );
1233
        let sql = format!("INSERT INTO embeddings (e) VALUES ({lit});");
2✔
1234
        process_command(&sql, &mut db).expect("insert 384-dim vector");
2✔
1235

1236
        let sel = process_command("SELECT id FROM embeddings;", &mut db).expect("select id");
1✔
1237
        assert!(sel.contains("1 row returned"));
2✔
1238
    }
1239

1240
    #[test]
1241
    fn vector_multiple_rows() {
3✔
1242
        // Three rows with different vectors — exercises the Row::Vector
1243
        // BTreeMap path (not just single-row insertion).
1244
        let mut db = Database::new("tempdb".to_string());
1✔
1245
        process_command(
1246
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1247
            &mut db,
1248
        )
1249
        .expect("create");
1250
        for i in 0..3 {
1✔
1251
            let sql = format!("INSERT INTO docs (e) VALUES ([{i}.0, {}.0]);", i + 1);
2✔
1252
            process_command(&sql, &mut db).expect("insert");
2✔
1253
        }
1254
        let sel = process_command("SELECT * FROM docs;", &mut db).expect("select");
1✔
1255
        assert!(sel.contains("3 rows returned"));
2✔
1256

1257
        // Verify each vector round-tripped correctly via direct DB inspection.
1258
        let docs = db.get_table("docs".to_string()).expect("docs table");
1✔
1259
        let rowids = docs.rowids();
1✔
1260
        assert_eq!(rowids.len(), 3);
2✔
1261
        let mut vectors: Vec<Vec<f32>> = rowids
1✔
1262
            .iter()
1263
            .filter_map(|r| match docs.get_value("e", *r) {
3✔
1264
                Some(Value::Vector(v)) => Some(v),
1✔
1265
                _ => None,
×
1266
            })
1267
            .collect();
1268
        vectors.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap());
4✔
1269
        assert_eq!(vectors[0], vec![0.0f32, 1.0]);
1✔
1270
        assert_eq!(vectors[1], vec![1.0f32, 2.0]);
1✔
1271
        assert_eq!(vectors[2], vec![2.0f32, 3.0]);
1✔
1272
    }
1273

1274
    // -----------------------------------------------------------------
1275
    // Phase 7d.2 — CREATE INDEX … USING hnsw end-to-end
1276
    // -----------------------------------------------------------------
1277

1278
    /// Builds a 5-row docs(id, e VECTOR(2)) table with vectors arranged
1279
    /// at known positions for clear distance reasoning. Used by both
1280
    /// the 7d.2 KNN tests and the refuse-DELETE/UPDATE tests.
1281
    fn seed_hnsw_table() -> Database {
1✔
1282
        let mut db = Database::new("tempdb".to_string());
1✔
1283
        process_command(
1284
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1285
            &mut db,
1286
        )
1287
        .unwrap();
1288
        for v in &[
1✔
1289
            "[1.0, 0.0]",   // id=1
1290
            "[2.0, 0.0]",   // id=2
1291
            "[0.0, 3.0]",   // id=3
1292
            "[1.0, 4.0]",   // id=4
1293
            "[10.0, 10.0]", // id=5
1294
        ] {
1295
            process_command(&format!("INSERT INTO docs (e) VALUES ({v});"), &mut db).unwrap();
2✔
1296
        }
1297
        db
1✔
1298
    }
1299

1300
    #[test]
1301
    fn create_index_using_hnsw_succeeds() {
3✔
1302
        let mut db = seed_hnsw_table();
1✔
1303
        let resp = process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1304
        assert!(resp.to_lowercase().contains("create index"));
2✔
1305
        // Index attached.
1306
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1307
        assert_eq!(table.hnsw_indexes.len(), 1);
1✔
1308
        assert_eq!(table.hnsw_indexes[0].name, "ix_e");
1✔
1309
        assert_eq!(table.hnsw_indexes[0].column_name, "e");
1✔
1310
        // Existing rows landed in the graph.
1311
        assert_eq!(table.hnsw_indexes[0].index.len(), 5);
1✔
1312
    }
1313

1314
    #[test]
1315
    fn create_index_using_hnsw_rejects_non_vector_column() {
3✔
1316
        let mut db = Database::new("tempdb".to_string());
1✔
1317
        process_command(
1318
            "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);",
1319
            &mut db,
1320
        )
1321
        .unwrap();
1322
        let err =
1✔
1323
            process_command("CREATE INDEX ix_name ON t USING hnsw (name);", &mut db).unwrap_err();
1324
        let msg = format!("{err}");
2✔
1325
        assert!(
×
1326
            msg.to_lowercase().contains("vector"),
3✔
1327
            "expected error mentioning VECTOR; got: {msg}"
1328
        );
1329
    }
1330

1331
    #[test]
1332
    fn knn_query_uses_hnsw_after_create_index() {
3✔
1333
        // The KNN-shaped query route through try_hnsw_probe rather than
1334
        // the brute-force select_topk. The user-visible result should
1335
        // be the same (HNSW recall is high on small graphs); we
1336
        // primarily verify the index is being hit by checking that
1337
        // the right rowids come back in the right order.
1338
        let mut db = seed_hnsw_table();
1✔
1339
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1340

1341
        // Top-3 closest to [1.0, 0.0]:
1342
        //   id=1 [1.0, 0.0]   distance=0
1343
        //   id=2 [2.0, 0.0]   distance=1
1344
        //   id=3 [0.0, 3.0]   distance≈3.16
1345
        let resp = process_command(
1346
            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [1.0, 0.0]) ASC LIMIT 3;",
1347
            &mut db,
1348
        )
1349
        .unwrap();
1350
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
1351
    }
1352

1353
    #[test]
1354
    fn knn_query_works_after_subsequent_inserts() {
3✔
1355
        // Index built when 5 rows existed; insert 2 more after; the
1356
        // HNSW gets maintained incrementally by insert_row, so the
1357
        // KNN query should see the newly-inserted vectors.
1358
        let mut db = seed_hnsw_table();
1✔
1359
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1360
        process_command("INSERT INTO docs (e) VALUES ([0.5, 0.0]);", &mut db).unwrap(); // id=6
1✔
1361
        process_command("INSERT INTO docs (e) VALUES ([0.1, 0.1]);", &mut db).unwrap(); // id=7
1✔
1362

1363
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1364
        assert_eq!(
1✔
1365
            table.hnsw_indexes[0].index.len(),
1✔
1366
            7,
1367
            "incremental insert should grow HNSW alongside row storage"
1368
        );
1369

1370
        // Now query: id=7 [0.1, 0.1] is closer to [0.0, 0.0] than the
1371
        // original 5 rows.
1372
        let resp = process_command(
1373
            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [0.0, 0.0]) ASC LIMIT 1;",
1374
            &mut db,
1375
        )
1376
        .unwrap();
1377
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
1378
    }
1379

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

1385
    #[test]
1386
    fn delete_on_hnsw_indexed_table_succeeds_and_marks_dirty() {
3✔
1387
        let mut db = seed_hnsw_table();
1✔
1388
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1389
        let resp = process_command("DELETE FROM docs WHERE id = 1;", &mut db).unwrap();
1✔
1390
        assert!(resp.contains("1 row"), "expected 1 row deleted: {resp}");
2✔
1391

1392
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
1393
        let entry = docs.hnsw_indexes.iter().find(|e| e.name == "ix_e").unwrap();
3✔
UNCOV
1394
        assert!(
×
1395
            entry.needs_rebuild,
1✔
1396
            "DELETE should have marked HNSW index dirty for rebuild on next save"
1397
        );
1398
    }
1399

1400
    #[test]
1401
    fn update_on_hnsw_indexed_vector_col_succeeds_and_marks_dirty() {
3✔
1402
        let mut db = seed_hnsw_table();
1✔
1403
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1404
        let resp =
1✔
1405
            process_command("UPDATE docs SET e = [9.0, 9.0] WHERE id = 1;", &mut db).unwrap();
1406
        assert!(resp.contains("1 row"), "expected 1 row updated: {resp}");
2✔
1407

1408
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
1409
        let entry = docs.hnsw_indexes.iter().find(|e| e.name == "ix_e").unwrap();
3✔
UNCOV
1410
        assert!(
×
1411
            entry.needs_rebuild,
1✔
1412
            "UPDATE on the vector column should have marked HNSW index dirty"
1413
        );
1414
    }
1415

1416
    #[test]
1417
    fn duplicate_index_name_errors() {
3✔
1418
        let mut db = seed_hnsw_table();
1✔
1419
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1420
        let err =
1✔
1421
            process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap_err();
1422
        let msg = format!("{err}");
2✔
1423
        assert!(
×
1424
            msg.to_lowercase().contains("already exists"),
3✔
1425
            "expected duplicate-index error; got: {msg}"
1426
        );
1427
    }
1428

1429
    #[test]
1430
    fn index_if_not_exists_is_idempotent() {
3✔
1431
        let mut db = seed_hnsw_table();
1✔
1432
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1433
        // Second time with IF NOT EXISTS should succeed (no-op).
1434
        process_command(
1435
            "CREATE INDEX IF NOT EXISTS ix_e ON docs USING hnsw (e);",
1436
            &mut db,
1437
        )
1438
        .unwrap();
1439
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1440
        assert_eq!(table.hnsw_indexes.len(), 1);
1✔
1441
    }
1442

1443
    // -----------------------------------------------------------------
1444
    // Phase 7b — vector distance functions through process_command
1445
    // -----------------------------------------------------------------
1446

1447
    /// Builds a 3-row docs table with 2-dim vectors aligned along the
1448
    /// axes so the expected distances are easy to reason about:
1449
    ///   id=1: [1, 0]
1450
    ///   id=2: [0, 1]
1451
    ///   id=3: [1, 1]
1452
    fn seed_vector_docs() -> Database {
1✔
1453
        let mut db = Database::new("tempdb".to_string());
1✔
1454
        process_command(
1455
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1456
            &mut db,
1457
        )
1458
        .expect("create");
1459
        process_command("INSERT INTO docs (e) VALUES ([1.0, 0.0]);", &mut db).expect("insert 1");
1✔
1460
        process_command("INSERT INTO docs (e) VALUES ([0.0, 1.0]);", &mut db).expect("insert 2");
1✔
1461
        process_command("INSERT INTO docs (e) VALUES ([1.0, 1.0]);", &mut db).expect("insert 3");
1✔
1462
        db
1✔
1463
    }
1464

1465
    #[test]
1466
    fn vec_distance_l2_in_where_filters_correctly() {
3✔
1467
        // Distance from [1,0]:
1468
        //   id=1 [1,0]: 0
1469
        //   id=2 [0,1]: √2 ≈ 1.414
1470
        //   id=3 [1,1]: 1
1471
        // WHERE distance < 1.1 should match id=1 and id=3 (2 rows).
1472
        let mut db = seed_vector_docs();
1✔
1473
        let resp = process_command(
1474
            "SELECT * FROM docs WHERE vec_distance_l2(e, [1.0, 0.0]) < 1.1;",
1475
            &mut db,
1476
        )
1477
        .expect("select");
1478
        assert!(
×
1479
            resp.contains("2 rows returned"),
2✔
1480
            "expected 2 rows, got: {resp}"
1481
        );
1482
    }
1483

1484
    #[test]
1485
    fn vec_distance_cosine_in_where() {
3✔
1486
        // [1,0] vs [1,0]: cosine distance = 0
1487
        // [1,0] vs [0,1]: cosine distance = 1 (orthogonal)
1488
        // [1,0] vs [1,1]: cosine distance = 1 - 1/√2 ≈ 0.293
1489
        // WHERE distance < 0.5 → id=1 and id=3 (2 rows).
1490
        let mut db = seed_vector_docs();
1✔
1491
        let resp = process_command(
1492
            "SELECT * FROM docs WHERE vec_distance_cosine(e, [1.0, 0.0]) < 0.5;",
1493
            &mut db,
1494
        )
1495
        .expect("select");
1496
        assert!(
×
1497
            resp.contains("2 rows returned"),
2✔
1498
            "expected 2 rows, got: {resp}"
1499
        );
1500
    }
1501

1502
    #[test]
1503
    fn vec_distance_dot_negated() {
3✔
1504
        // [1,0]·[1,0] = 1 → -1
1505
        // [1,0]·[0,1] = 0 → 0
1506
        // [1,0]·[1,1] = 1 → -1
1507
        // WHERE -dot < 0 (i.e. dot > 0) → id=1 and id=3 (2 rows).
1508
        let mut db = seed_vector_docs();
1✔
1509
        let resp = process_command(
1510
            "SELECT * FROM docs WHERE vec_distance_dot(e, [1.0, 0.0]) < 0.0;",
1511
            &mut db,
1512
        )
1513
        .expect("select");
1514
        assert!(
×
1515
            resp.contains("2 rows returned"),
2✔
1516
            "expected 2 rows, got: {resp}"
1517
        );
1518
    }
1519

1520
    #[test]
1521
    fn knn_via_order_by_distance_limit() {
3✔
1522
        // Classic KNN shape: ORDER BY distance LIMIT k.
1523
        // Distances from [1,0]: id=1=0, id=3=1, id=2=√2.
1524
        // LIMIT 2 should return id=1 then id=3 in that order.
1525
        let mut db = seed_vector_docs();
1✔
1526
        let resp = process_command(
1527
            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [1.0, 0.0]) ASC LIMIT 2;",
1528
            &mut db,
1529
        )
1530
        .expect("select");
1531
        assert!(
×
1532
            resp.contains("2 rows returned"),
2✔
1533
            "expected 2 rows, got: {resp}"
1534
        );
1535
    }
1536

1537
    #[test]
1538
    fn distance_function_dim_mismatch_errors() {
3✔
1539
        // 2-dim column queried with a 3-dim probe → clean error.
1540
        let mut db = seed_vector_docs();
1✔
1541
        let err = process_command(
1542
            "SELECT * FROM docs WHERE vec_distance_l2(e, [1.0, 0.0, 0.0]) < 1.0;",
1543
            &mut db,
1544
        )
1545
        .unwrap_err();
1546
        let msg = format!("{err}");
2✔
1547
        assert!(
×
1548
            msg.to_lowercase().contains("dimension")
2✔
1549
                && msg.contains("lhs=2")
1✔
1550
                && msg.contains("rhs=3"),
1✔
1551
            "expected dim mismatch error, got: {msg}"
1552
        );
1553
    }
1554

1555
    #[test]
1556
    fn unknown_function_errors_with_name() {
3✔
1557
        // Use the function in WHERE, not projection — the projection
1558
        // parser still requires bare column references; function calls
1559
        // there are a future enhancement (with `AS alias` support).
1560
        let mut db = seed_vector_docs();
1✔
1561
        let err = process_command(
1562
            "SELECT * FROM docs WHERE vec_does_not_exist(e, [1.0, 0.0]) < 1.0;",
1563
            &mut db,
1564
        )
1565
        .unwrap_err();
1566
        let msg = format!("{err}");
2✔
1567
        assert!(
×
1568
            msg.contains("vec_does_not_exist"),
2✔
1569
            "expected error mentioning function name, got: {msg}"
1570
        );
1571
    }
1572
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc