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

joaoh82 / rust_sqlite / 24985739099

27 Apr 2026 08:54AM UTC coverage: 68.221% (+0.6%) from 67.574%
24985739099

Pull #44

github

web-flow
Merge c9c04ebbc into 8594bf3cd
Pull Request #44: Phase 7b: vector distance functions + ORDER BY expressions (operators deferred to 7b.1)

148 of 164 new or added lines in 3 files covered. (90.24%)

1 existing line in 1 file now uncovered.

4390 of 6435 relevant lines covered (68.22%)

1.25 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

269
    Ok(message)
2✔
270
}
271

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1273
    // -----------------------------------------------------------------
1274
    // Phase 7b — vector distance functions through process_command
1275
    // -----------------------------------------------------------------
1276

1277
    /// Builds a 3-row docs table with 2-dim vectors aligned along the
1278
    /// axes so the expected distances are easy to reason about:
1279
    ///   id=1: [1, 0]
1280
    ///   id=2: [0, 1]
1281
    ///   id=3: [1, 1]
1282
    fn seed_vector_docs() -> Database {
1✔
1283
        let mut db = Database::new("tempdb".to_string());
1✔
1284
        process_command(
1285
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1286
            &mut db,
1287
        )
1288
        .expect("create");
1289
        process_command("INSERT INTO docs (e) VALUES ([1.0, 0.0]);", &mut db).expect("insert 1");
1✔
1290
        process_command("INSERT INTO docs (e) VALUES ([0.0, 1.0]);", &mut db).expect("insert 2");
1✔
1291
        process_command("INSERT INTO docs (e) VALUES ([1.0, 1.0]);", &mut db).expect("insert 3");
1✔
1292
        db
1✔
1293
    }
1294

1295
    #[test]
1296
    fn vec_distance_l2_in_where_filters_correctly() {
3✔
1297
        // Distance from [1,0]:
1298
        //   id=1 [1,0]: 0
1299
        //   id=2 [0,1]: √2 ≈ 1.414
1300
        //   id=3 [1,1]: 1
1301
        // WHERE distance < 1.1 should match id=1 and id=3 (2 rows).
1302
        let mut db = seed_vector_docs();
1✔
1303
        let resp = process_command(
1304
            "SELECT * FROM docs WHERE vec_distance_l2(e, [1.0, 0.0]) < 1.1;",
1305
            &mut db,
1306
        )
1307
        .expect("select");
NEW
1308
        assert!(
×
1309
            resp.contains("2 rows returned"),
2✔
1310
            "expected 2 rows, got: {resp}"
1311
        );
1312
    }
1313

1314
    #[test]
1315
    fn vec_distance_cosine_in_where() {
3✔
1316
        // [1,0] vs [1,0]: cosine distance = 0
1317
        // [1,0] vs [0,1]: cosine distance = 1 (orthogonal)
1318
        // [1,0] vs [1,1]: cosine distance = 1 - 1/√2 ≈ 0.293
1319
        // WHERE distance < 0.5 → id=1 and id=3 (2 rows).
1320
        let mut db = seed_vector_docs();
1✔
1321
        let resp = process_command(
1322
            "SELECT * FROM docs WHERE vec_distance_cosine(e, [1.0, 0.0]) < 0.5;",
1323
            &mut db,
1324
        )
1325
        .expect("select");
NEW
1326
        assert!(
×
1327
            resp.contains("2 rows returned"),
2✔
1328
            "expected 2 rows, got: {resp}"
1329
        );
1330
    }
1331

1332
    #[test]
1333
    fn vec_distance_dot_negated() {
3✔
1334
        // [1,0]·[1,0] = 1 → -1
1335
        // [1,0]·[0,1] = 0 → 0
1336
        // [1,0]·[1,1] = 1 → -1
1337
        // WHERE -dot < 0 (i.e. dot > 0) → id=1 and id=3 (2 rows).
1338
        let mut db = seed_vector_docs();
1✔
1339
        let resp = process_command(
1340
            "SELECT * FROM docs WHERE vec_distance_dot(e, [1.0, 0.0]) < 0.0;",
1341
            &mut db,
1342
        )
1343
        .expect("select");
NEW
1344
        assert!(
×
1345
            resp.contains("2 rows returned"),
2✔
1346
            "expected 2 rows, got: {resp}"
1347
        );
1348
    }
1349

1350
    #[test]
1351
    fn knn_via_order_by_distance_limit() {
3✔
1352
        // Classic KNN shape: ORDER BY distance LIMIT k.
1353
        // Distances from [1,0]: id=1=0, id=3=1, id=2=√2.
1354
        // LIMIT 2 should return id=1 then id=3 in that order.
1355
        let mut db = seed_vector_docs();
1✔
1356
        let resp = process_command(
1357
            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [1.0, 0.0]) ASC LIMIT 2;",
1358
            &mut db,
1359
        )
1360
        .expect("select");
NEW
1361
        assert!(
×
1362
            resp.contains("2 rows returned"),
2✔
1363
            "expected 2 rows, got: {resp}"
1364
        );
1365
    }
1366

1367
    #[test]
1368
    fn distance_function_dim_mismatch_errors() {
3✔
1369
        // 2-dim column queried with a 3-dim probe → clean error.
1370
        let mut db = seed_vector_docs();
1✔
1371
        let err = process_command(
1372
            "SELECT * FROM docs WHERE vec_distance_l2(e, [1.0, 0.0, 0.0]) < 1.0;",
1373
            &mut db,
1374
        )
1375
        .unwrap_err();
1376
        let msg = format!("{err}");
2✔
NEW
1377
        assert!(
×
1378
            msg.to_lowercase().contains("dimension")
2✔
1379
                && msg.contains("lhs=2")
1✔
1380
                && msg.contains("rhs=3"),
1✔
1381
            "expected dim mismatch error, got: {msg}"
1382
        );
1383
    }
1384

1385
    #[test]
1386
    fn unknown_function_errors_with_name() {
3✔
1387
        // Use the function in WHERE, not projection — the projection
1388
        // parser still requires bare column references; function calls
1389
        // there are a future enhancement (with `AS alias` support).
1390
        let mut db = seed_vector_docs();
1✔
1391
        let err = process_command(
1392
            "SELECT * FROM docs WHERE vec_does_not_exist(e, [1.0, 0.0]) < 1.0;",
1393
            &mut db,
1394
        )
1395
        .unwrap_err();
1396
        let msg = format!("{err}");
2✔
NEW
1397
        assert!(
×
1398
            msg.contains("vec_does_not_exist"),
2✔
1399
            "expected error mentioning function name, got: {msg}"
1400
        );
1401
    }
1402
}
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