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

joaoh82 / rust_sqlite / 26391649718

25 May 2026 08:39AM UTC coverage: 68.845%. Remained the same
26391649718

push

github

joaoh82
Merge branch 'main' of https://github.com/joaoh82/rust_sqlite

101 of 108 new or added lines in 5 files covered. (93.52%)

376 existing lines in 5 files now uncovered.

11190 of 16254 relevant lines covered (68.84%)

1.24 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

227
    // Initialy only implementing some basic SQL Statements
228
    match query {
2✔
229
        Statement::CreateTable(_) => {
230
            let create_query = CreateQuery::new(&query);
2✔
231
            match create_query {
2✔
232
                Ok(payload) => {
2✔
233
                    let table_name = payload.table_name.clone();
2✔
234
                    if table_name == pager::MASTER_TABLE_NAME {
4✔
235
                        return Err(SQLRiteError::General(format!(
×
236
                            "'{}' is a reserved name used by the internal schema catalog",
237
                            pager::MASTER_TABLE_NAME
238
                        )));
239
                    }
240
                    // Checking if table already exists, after parsing CREATE TABLE query
241
                    match db.contains_table(table_name.to_string()) {
4✔
242
                        true => {
243
                            return Err(SQLRiteError::Internal(
×
244
                                "Cannot create, table already exists.".to_string(),
×
245
                            ));
246
                        }
247
                        false => {
248
                            let table = Table::new(payload);
4✔
249
                            // Note: we used to call `table.print_table_schema()` here
250
                            // for REPL convenience. Removed because it wrote
251
                            // directly to stdout, which corrupted any non-REPL
252
                            // protocol channel (most painfully the MCP server's
253
                            // JSON-RPC wire). The status line below is enough for
254
                            // the REPL; users who want to inspect the schema can
255
                            // run a follow-up describe / `.tables`-style command.
256
                            db.tables.insert(table_name.to_string(), table);
4✔
257
                            message = String::from("CREATE TABLE Statement executed.");
2✔
258
                        }
259
                    }
260
                }
261
                Err(err) => return Err(err),
1✔
262
            }
263
        }
264
        Statement::Insert(_) => {
265
            let insert_query = InsertQuery::new(&query);
2✔
266
            match insert_query {
2✔
267
                Ok(payload) => {
2✔
268
                    let table_name = payload.table_name;
2✔
269
                    let columns = payload.columns;
2✔
270
                    let values = payload.rows;
2✔
271

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

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

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

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

453
    Ok(CommandOutput {
2✔
454
        status: message,
2✔
455
        rendered,
2✔
456
    })
457
}
458

459
#[cfg(test)]
460
mod tests {
461
    use super::*;
462
    use crate::sql::db::table::Value;
463

464
    /// Builds a `users(id INTEGER PK, name TEXT, age INTEGER)` table populated
465
    /// with three rows, for use in executor-level tests.
466
    fn seed_users_table() -> Database {
1✔
467
        let mut db = Database::new("tempdb".to_string());
1✔
468
        process_command(
469
            "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, age INTEGER);",
470
            &mut db,
471
        )
472
        .expect("create table");
473
        process_command(
474
            "INSERT INTO users (name, age) VALUES ('alice', 30);",
475
            &mut db,
476
        )
477
        .expect("insert alice");
478
        process_command("INSERT INTO users (name, age) VALUES ('bob', 25);", &mut db)
1✔
479
            .expect("insert bob");
480
        process_command(
481
            "INSERT INTO users (name, age) VALUES ('carol', 40);",
482
            &mut db,
483
        )
484
        .expect("insert carol");
485
        db
1✔
486
    }
487

488
    #[test]
489
    fn process_command_select_all_test() {
3✔
490
        let mut db = seed_users_table();
1✔
491
        let response = process_command("SELECT * FROM users;", &mut db).expect("select");
2✔
492
        assert!(response.contains("3 rows returned"));
2✔
493
    }
494

495
    #[test]
496
    fn process_command_select_where_test() {
3✔
497
        let mut db = seed_users_table();
1✔
498
        let response =
2✔
499
            process_command("SELECT name FROM users WHERE age > 25;", &mut db).expect("select");
500
        assert!(response.contains("2 rows returned"));
2✔
501
    }
502

503
    #[test]
504
    fn process_command_select_eq_string_test() {
3✔
505
        let mut db = seed_users_table();
1✔
506
        let response =
2✔
507
            process_command("SELECT name FROM users WHERE name = 'bob';", &mut db).expect("select");
508
        assert!(response.contains("1 row returned"));
2✔
509
    }
510

511
    #[test]
512
    fn process_command_select_limit_test() {
3✔
513
        let mut db = seed_users_table();
1✔
514
        let response = process_command("SELECT * FROM users ORDER BY age ASC LIMIT 2;", &mut db)
1✔
515
            .expect("select");
516
        assert!(response.contains("2 rows returned"));
2✔
517
    }
518

519
    #[test]
520
    fn process_command_select_unknown_table_test() {
3✔
521
        let mut db = Database::new("tempdb".to_string());
1✔
522
        let result = process_command("SELECT * FROM nope;", &mut db);
1✔
523
        assert!(result.is_err());
2✔
524
    }
525

526
    #[test]
527
    fn process_command_select_unknown_column_test() {
3✔
528
        let mut db = seed_users_table();
1✔
529
        let result = process_command("SELECT height FROM users;", &mut db);
1✔
530
        assert!(result.is_err());
2✔
531
    }
532

533
    #[test]
534
    fn process_command_insert_test() {
3✔
535
        // Creating temporary database
536
        let mut db = Database::new("tempdb".to_string());
1✔
537

538
        // Creating temporary table for testing purposes
539
        let query_statement = "CREATE TABLE users (
1✔
540
            id INTEGER PRIMARY KEY,
541
            name TEXT
542
        );";
543
        let dialect = SqlriteDialect::new();
1✔
544
        let mut ast = Parser::parse_sql(&dialect, query_statement).unwrap();
1✔
545
        if ast.len() > 1 {
2✔
546
            panic!("Expected a single query statement, but there are more then 1.")
×
547
        }
548
        let query = ast.pop().unwrap();
2✔
549
        let create_query = CreateQuery::new(&query).unwrap();
2✔
550

551
        // Inserting table into database
552
        db.tables.insert(
2✔
553
            create_query.table_name.to_string(),
2✔
554
            Table::new(create_query),
1✔
555
        );
556

557
        // Inserting data into table
558
        let insert_query = String::from("INSERT INTO users (name) Values ('josh');");
1✔
559
        match process_command(&insert_query, &mut db) {
2✔
560
            Ok(response) => assert_eq!(response, "INSERT Statement executed."),
1✔
561
            Err(err) => {
×
562
                eprintln!("Error: {}", err);
×
563
                assert!(false)
×
564
            }
565
        };
566
    }
567

568
    #[test]
569
    fn process_command_insert_no_pk_test() {
3✔
570
        // Creating temporary database
571
        let mut db = Database::new("tempdb".to_string());
1✔
572

573
        // Creating temporary table for testing purposes
574
        let query_statement = "CREATE TABLE users (
1✔
575
            name TEXT
576
        );";
577
        let dialect = SqlriteDialect::new();
1✔
578
        let mut ast = Parser::parse_sql(&dialect, query_statement).unwrap();
1✔
579
        if ast.len() > 1 {
2✔
580
            panic!("Expected a single query statement, but there are more then 1.")
×
581
        }
582
        let query = ast.pop().unwrap();
2✔
583
        let create_query = CreateQuery::new(&query).unwrap();
2✔
584

585
        // Inserting table into database
586
        db.tables.insert(
2✔
587
            create_query.table_name.to_string(),
2✔
588
            Table::new(create_query),
1✔
589
        );
590

591
        // Inserting data into table
592
        let insert_query = String::from("INSERT INTO users (name) Values ('josh');");
1✔
593
        match process_command(&insert_query, &mut db) {
2✔
594
            Ok(response) => assert_eq!(response, "INSERT Statement executed."),
1✔
595
            Err(err) => {
×
596
                eprintln!("Error: {}", err);
×
597
                assert!(false)
×
598
            }
599
        };
600
    }
601

602
    #[test]
603
    fn process_command_delete_where_test() {
4✔
604
        let mut db = seed_users_table();
1✔
605
        let response =
2✔
606
            process_command("DELETE FROM users WHERE name = 'bob';", &mut db).expect("delete");
607
        assert!(response.contains("1 row deleted"));
2✔
608

609
        let remaining = process_command("SELECT * FROM users;", &mut db).expect("select");
1✔
610
        assert!(remaining.contains("2 rows returned"));
2✔
611
    }
612

613
    #[test]
614
    fn process_command_delete_all_test() {
3✔
615
        let mut db = seed_users_table();
1✔
616
        let response = process_command("DELETE FROM users;", &mut db).expect("delete");
2✔
617
        assert!(response.contains("3 rows deleted"));
2✔
618
    }
619

620
    #[test]
621
    fn process_command_update_where_test() {
3✔
622
        use crate::sql::db::table::Value;
623

624
        let mut db = seed_users_table();
1✔
625
        let response = process_command("UPDATE users SET age = 99 WHERE name = 'bob';", &mut db)
1✔
626
            .expect("update");
627
        assert!(response.contains("1 row updated"));
2✔
628

629
        // Confirm the cell was actually rewritten.
630
        let users = db.get_table("users".to_string()).unwrap();
1✔
631
        let bob_rowid = users
1✔
632
            .rowids()
633
            .into_iter()
634
            .find(|r| users.get_value("name", *r) == Some(Value::Text("bob".to_string())))
3✔
635
            .expect("bob row must exist");
636
        assert_eq!(users.get_value("age", bob_rowid), Some(Value::Integer(99)));
1✔
637
    }
638

639
    #[test]
640
    fn process_command_update_unique_violation_test() {
3✔
641
        let mut db = seed_users_table();
1✔
642
        // `name` is not UNIQUE in the seed — reinforce with an explicit unique column.
643
        process_command(
644
            "CREATE TABLE tags (id INTEGER PRIMARY KEY, label TEXT UNIQUE);",
645
            &mut db,
646
        )
647
        .unwrap();
648
        process_command("INSERT INTO tags (label) VALUES ('a');", &mut db).unwrap();
1✔
649
        process_command("INSERT INTO tags (label) VALUES ('b');", &mut db).unwrap();
1✔
650

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

655
    #[test]
656
    fn process_command_insert_type_mismatch_returns_error_test() {
3✔
657
        // Previously this panicked in parse::<i32>().unwrap(); now it should return an error cleanly.
658
        let mut db = Database::new("tempdb".to_string());
1✔
659
        process_command(
660
            "CREATE TABLE items (id INTEGER PRIMARY KEY, qty INTEGER);",
661
            &mut db,
662
        )
663
        .unwrap();
664
        let result = process_command("INSERT INTO items (qty) VALUES ('not a number');", &mut db);
1✔
665
        assert!(result.is_err(), "expected error, got {result:?}");
2✔
666
    }
667

668
    #[test]
669
    fn insert_omitted_integer_column_is_stored_as_null() {
3✔
670
        // SQLR-7 — pre-fix this errored because the omitted column was
671
        // padded with the literal `"Null"` and then re-parsed as i32. The
672
        // new INSERT pipeline carries `Option<Value>` from the parser
673
        // through to `insert_row`, so a missing non-PK column is just
674
        // SQL NULL (matches SQLite).
675
        use crate::sql::db::table::Value;
676

677
        let mut db = Database::new("tempdb".to_string());
1✔
678
        process_command(
679
            "CREATE TABLE items (id INTEGER PRIMARY KEY, qty INTEGER);",
680
            &mut db,
681
        )
682
        .unwrap();
683
        process_command("INSERT INTO items (id) VALUES (1);", &mut db)
1✔
684
            .expect("INSERT with omitted INTEGER column should succeed and store NULL");
685

686
        let table = db.get_table("items".to_string()).unwrap();
1✔
687
        let rowid = table.rowids().pop().expect("one row");
1✔
688
        // BTreeMap entry was never written → get_value returns None,
689
        // which the executor renders as Value::Null.
690
        assert_eq!(table.get_value("qty", rowid), None);
1✔
691
        // IS NULL via the executor sees the same NULL.
692
        let response = process_command("SELECT id FROM items WHERE qty IS NULL;", &mut db)
1✔
693
            .expect("select IS NULL");
694
        assert!(
×
695
            response.contains("1 row returned"),
2✔
696
            "qty IS NULL should match the omitted-column row, got: {response}"
697
        );
698
        // Sanity: explicit literal stays Integer.
699
        process_command("INSERT INTO items (id, qty) VALUES (2, 7);", &mut db).unwrap();
2✔
700
        let table = db.get_table("items".to_string()).unwrap();
1✔
701
        let row_two = table
1✔
702
            .rowids()
703
            .into_iter()
704
            .find(|r| table.get_value("id", *r) == Some(Value::Integer(2)))
3✔
705
            .unwrap();
706
        assert_eq!(table.get_value("qty", row_two), Some(Value::Integer(7)));
1✔
707
    }
708

709
    #[test]
710
    fn insert_explicit_null_into_integer_column() {
3✔
711
        let mut db = Database::new("tempdb".to_string());
1✔
712
        process_command(
713
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER);",
714
            &mut db,
715
        )
716
        .unwrap();
717
        process_command("INSERT INTO t (id, n) VALUES (1, NULL);", &mut db)
1✔
718
            .expect("INSERT explicit NULL into INTEGER must not panic on parse::<i32>()");
719
        let table = db.get_table("t".to_string()).unwrap();
1✔
720
        let rowid = table.rowids().pop().unwrap();
1✔
721
        assert_eq!(table.get_value("n", rowid), None);
1✔
722
    }
723

724
    #[test]
725
    fn insert_explicit_null_into_text_column() {
3✔
726
        // Pre-fix: the literal string "Null" was stored in the BTreeMap and
727
        // a read-side workaround (`if v == "Null"`) re-mapped it back to
728
        // Value::Null. Post-fix: nothing is stored at all, so a user-typed
729
        // string `'Null'` no longer collides with SQL NULL.
730
        use crate::sql::db::table::Value;
731

732
        let mut db = Database::new("tempdb".to_string());
1✔
733
        process_command("CREATE TABLE t (id INTEGER PRIMARY KEY, s TEXT);", &mut db).unwrap();
2✔
734
        process_command("INSERT INTO t (id, s) VALUES (1, NULL);", &mut db).unwrap();
1✔
735
        process_command("INSERT INTO t (id, s) VALUES (2, 'hi');", &mut db).unwrap();
1✔
736

737
        let table = db.get_table("t".to_string()).unwrap();
1✔
738
        let row_one = table
1✔
739
            .rowids()
740
            .into_iter()
741
            .find(|r| table.get_value("id", *r) == Some(Value::Integer(1)))
3✔
742
            .unwrap();
743
        let row_two = table
1✔
744
            .rowids()
745
            .into_iter()
746
            .find(|r| table.get_value("id", *r) == Some(Value::Integer(2)))
3✔
747
            .unwrap();
748
        assert_eq!(table.get_value("s", row_one), None);
1✔
749
        assert_eq!(
1✔
750
            table.get_value("s", row_two),
1✔
751
            Some(Value::Text("hi".to_string()))
2✔
752
        );
753
    }
754

755
    #[test]
756
    fn insert_explicit_null_into_real_column() {
3✔
757
        let mut db = Database::new("tempdb".to_string());
1✔
758
        process_command(
759
            "CREATE TABLE t (id INTEGER PRIMARY KEY, score REAL);",
760
            &mut db,
761
        )
762
        .unwrap();
763
        process_command("INSERT INTO t (id, score) VALUES (1, NULL);", &mut db)
1✔
764
            .expect("INSERT explicit NULL into REAL must not panic on parse::<f32>()");
765
        let table = db.get_table("t".to_string()).unwrap();
1✔
766
        let rowid = table.rowids().pop().unwrap();
1✔
767
        assert_eq!(table.get_value("score", rowid), None);
1✔
768
    }
769

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

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

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

815
    #[test]
816
    fn default_does_not_override_explicit_null() {
3✔
817
        // Restored from SQLR-2 (was dropped because it collided with the
818
        // stringly-typed NULL handling SQLR-7 fixes). Column has DEFAULT 0;
819
        // an explicit NULL in the INSERT must override the default and
820
        // store NULL — the default only fires when the column is omitted.
821
        use crate::sql::db::table::Value;
822

823
        let mut db = Database::new("tempdb".to_string());
1✔
824
        process_command(
825
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER DEFAULT 0);",
826
            &mut db,
827
        )
828
        .unwrap();
829
        process_command("INSERT INTO t (id, n) VALUES (1, NULL);", &mut db).unwrap();
1✔
830
        process_command("INSERT INTO t (id) VALUES (2);", &mut db).unwrap();
1✔
831

832
        let table = db.get_table("t".to_string()).unwrap();
1✔
833
        let row_one = table
1✔
834
            .rowids()
835
            .into_iter()
836
            .find(|r| table.get_value("id", *r) == Some(Value::Integer(1)))
3✔
837
            .unwrap();
838
        let row_two = table
1✔
839
            .rowids()
840
            .into_iter()
841
            .find(|r| table.get_value("id", *r) == Some(Value::Integer(2)))
3✔
842
            .unwrap();
843
        // Explicit NULL: stored as NULL, not the default.
844
        assert_eq!(table.get_value("n", row_one), None);
1✔
845
        // Omitted: stored as the DEFAULT 0.
846
        assert_eq!(table.get_value("n", row_two), Some(Value::Integer(0)));
1✔
847
    }
848

849
    #[test]
850
    fn process_command_update_arith_test() {
3✔
851
        use crate::sql::db::table::Value;
852

853
        let mut db = seed_users_table();
1✔
854
        process_command("UPDATE users SET age = age + 1;", &mut db).expect("update +1");
2✔
855

856
        let users = db.get_table("users".to_string()).unwrap();
1✔
857
        let mut ages: Vec<i64> = users
858
            .rowids()
859
            .into_iter()
860
            .filter_map(|r| match users.get_value("age", r) {
3✔
861
                Some(Value::Integer(n)) => Some(n),
1✔
862
                _ => None,
×
863
            })
864
            .collect();
865
        ages.sort();
2✔
866
        assert_eq!(ages, vec![26, 31, 41]); // 25+1, 30+1, 40+1
1✔
867
    }
868

869
    #[test]
870
    fn process_command_select_arithmetic_where_test() {
3✔
871
        let mut db = seed_users_table();
1✔
872
        // age * 2 > 55  →  only ages > 27.5  →  alice(30) + carol(40)
873
        let response =
2✔
874
            process_command("SELECT name FROM users WHERE age * 2 > 55;", &mut db).expect("select");
875
        assert!(response.contains("2 rows returned"));
2✔
876
    }
877

878
    #[test]
879
    fn process_command_divide_by_zero_test() {
3✔
880
        let mut db = seed_users_table();
1✔
881
        let result = process_command("SELECT age / 0 FROM users;", &mut db);
1✔
882
        // Projection only supports bare columns, so this errors earlier; still shouldn't panic.
883
        assert!(result.is_err());
2✔
884
    }
885

886
    #[test]
887
    fn process_command_unsupported_statement_test() {
3✔
888
        let mut db = Database::new("tempdb".to_string());
1✔
889
        // CREATE VIEW is firmly in the "Not yet supported" list — used as
890
        // the canary for the dispatcher's NotImplemented arm. (DROP TABLE
891
        // moved out of unsupported in this branch.)
892
        let result = process_command("CREATE VIEW v AS SELECT * FROM users;", &mut db);
1✔
893
        assert!(result.is_err());
2✔
894
    }
895

896
    #[test]
897
    fn empty_input_is_a_noop_not_a_panic() {
3✔
898
        // Regression for: desktop app pre-fills the textarea with a
899
        // comment-only placeholder, and hitting Run used to panic because
900
        // sqlparser produced zero statements and pop().unwrap() exploded.
901
        let mut db = Database::new("t".to_string());
1✔
902
        for input in ["", "   ", "-- just a comment", "-- comment\n-- another"] {
4✔
903
            let result = process_command(input, &mut db);
2✔
904
            assert!(result.is_ok(), "input {input:?} should not error");
2✔
905
            let msg = result.unwrap();
1✔
906
            assert!(msg.contains("No statement"), "got: {msg:?}");
2✔
907
        }
908
    }
909

910
    #[test]
911
    fn create_index_adds_explicit_index() {
3✔
912
        let mut db = seed_users_table();
1✔
913
        let response = process_command("CREATE INDEX users_age_idx ON users (age);", &mut db)
1✔
914
            .expect("create index");
915
        assert!(response.contains("users_age_idx"));
2✔
916

917
        // The index should now be attached to the users table.
918
        let users = db.get_table("users".to_string()).unwrap();
1✔
919
        let idx = users
1✔
920
            .index_by_name("users_age_idx")
921
            .expect("index should exist after CREATE INDEX");
922
        assert_eq!(idx.column_name, "age");
1✔
923
        assert!(!idx.is_unique);
1✔
924
    }
925

926
    #[test]
927
    fn create_unique_index_rejects_duplicate_existing_values() {
3✔
928
        let mut db = seed_users_table();
1✔
929
        // `name` is already UNIQUE (auto-indexed); insert a duplicate-age row
930
        // first so CREATE UNIQUE INDEX on age catches the conflict.
931
        process_command("INSERT INTO users (name, age) VALUES ('dan', 30);", &mut db).unwrap();
2✔
932
        let result = process_command(
933
            "CREATE UNIQUE INDEX users_age_unique ON users (age);",
934
            &mut db,
935
        );
936
        assert!(
×
937
            result.is_err(),
2✔
938
            "expected unique-index failure, got {result:?}"
939
        );
940
    }
941

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

967
    #[test]
968
    fn where_eq_on_indexed_column_inside_parens_uses_index_probe() {
3✔
969
        let mut db = seed_users_table();
1✔
970
        let response = process_command("SELECT name FROM users WHERE (name = 'bob');", &mut db)
1✔
971
            .expect("select");
972
        assert!(response.contains("1 row returned"));
2✔
973
    }
974

975
    #[test]
976
    fn where_eq_literal_first_side_uses_index_probe() {
3✔
977
        let mut db = seed_users_table();
1✔
978
        // `'bob' = name` should hit the same path as `name = 'bob'`.
979
        let response =
2✔
980
            process_command("SELECT name FROM users WHERE 'bob' = name;", &mut db).expect("select");
981
        assert!(response.contains("1 row returned"));
2✔
982
    }
983

984
    #[test]
985
    fn non_equality_where_still_falls_back_to_full_scan() {
3✔
986
        // Sanity: range predicates bypass the optimizer and the full-scan
987
        // path still returns correct results.
988
        let mut db = seed_users_table();
1✔
989
        let response =
2✔
990
            process_command("SELECT name FROM users WHERE age > 28;", &mut db).expect("select");
991
        assert!(response.contains("2 rows returned"));
2✔
992
    }
993

994
    // -------------------------------------------------------------------
995
    // Phase 4f — Transactions (BEGIN / COMMIT / ROLLBACK)
996
    // -------------------------------------------------------------------
997

998
    #[test]
999
    fn rollback_restores_pre_begin_in_memory_state() {
3✔
1000
        // In-memory DB (no pager): BEGIN, insert a row, ROLLBACK.
1001
        // The row must disappear from the live tables HashMap.
1002
        let mut db = seed_users_table();
1✔
1003
        let before = db.get_table("users".to_string()).unwrap().rowids().len();
2✔
1004
        assert_eq!(before, 3);
1✔
1005

1006
        process_command("BEGIN;", &mut db).expect("BEGIN");
1✔
1007
        assert!(db.in_transaction());
1✔
1008
        process_command("INSERT INTO users (name, age) VALUES ('dan', 50);", &mut db)
1✔
1009
            .expect("INSERT inside txn");
1010
        // Mid-transaction read sees the new row.
1011
        let mid = db.get_table("users".to_string()).unwrap().rowids().len();
1✔
1012
        assert_eq!(mid, 4);
1✔
1013

1014
        process_command("ROLLBACK;", &mut db).expect("ROLLBACK");
1✔
1015
        assert!(!db.in_transaction());
1✔
1016
        let after = db.get_table("users".to_string()).unwrap().rowids().len();
2✔
1017
        assert_eq!(
1✔
1018
            after, 3,
1019
            "ROLLBACK should have restored the pre-BEGIN state"
1020
        );
1021
    }
1022

1023
    #[test]
1024
    fn commit_keeps_mutations_and_clears_txn_flag() {
3✔
1025
        let mut db = seed_users_table();
1✔
1026
        process_command("BEGIN;", &mut db).expect("BEGIN");
2✔
1027
        process_command("INSERT INTO users (name, age) VALUES ('dan', 50);", &mut db)
1✔
1028
            .expect("INSERT inside txn");
1029
        process_command("COMMIT;", &mut db).expect("COMMIT");
1✔
1030
        assert!(!db.in_transaction());
1✔
1031
        let after = db.get_table("users".to_string()).unwrap().rowids().len();
2✔
1032
        assert_eq!(after, 4);
1✔
1033
    }
1034

1035
    #[test]
1036
    fn rollback_undoes_update_and_delete_side_by_side() {
3✔
1037
        use crate::sql::db::table::Value;
1038

1039
        let mut db = seed_users_table();
1✔
1040
        process_command("BEGIN;", &mut db).unwrap();
2✔
1041
        process_command("UPDATE users SET age = 999;", &mut db).unwrap();
1✔
1042
        process_command("DELETE FROM users WHERE name = 'bob';", &mut db).unwrap();
1✔
1043
        // Mid-txn: one row gone, others have age=999.
1044
        let users = db.get_table("users".to_string()).unwrap();
1✔
1045
        assert_eq!(users.rowids().len(), 2);
1✔
1046
        for r in users.rowids() {
2✔
1047
            assert_eq!(users.get_value("age", r), Some(Value::Integer(999)));
2✔
1048
        }
1049

1050
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1051
        let users = db.get_table("users".to_string()).unwrap();
1✔
1052
        assert_eq!(users.rowids().len(), 3);
1✔
1053
        // Original ages {30, 25, 40} — none should be 999.
1054
        for r in users.rowids() {
2✔
1055
            assert_ne!(users.get_value("age", r), Some(Value::Integer(999)));
2✔
1056
        }
1057
    }
1058

1059
    #[test]
1060
    fn nested_begin_is_rejected() {
3✔
1061
        let mut db = seed_users_table();
1✔
1062
        process_command("BEGIN;", &mut db).unwrap();
2✔
1063
        let err = process_command("BEGIN;", &mut db).unwrap_err();
1✔
1064
        assert!(
×
1065
            format!("{err}").contains("already open"),
3✔
1066
            "nested BEGIN should error; got: {err}"
1067
        );
1068
        // Still in the original transaction; a ROLLBACK clears it.
1069
        assert!(db.in_transaction());
1✔
1070
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1071
    }
1072

1073
    #[test]
1074
    fn orphan_commit_and_rollback_are_rejected() {
3✔
1075
        let mut db = seed_users_table();
1✔
1076
        let commit_err = process_command("COMMIT;", &mut db).unwrap_err();
2✔
1077
        assert!(format!("{commit_err}").contains("no transaction"));
2✔
1078
        let rollback_err = process_command("ROLLBACK;", &mut db).unwrap_err();
1✔
1079
        assert!(format!("{rollback_err}").contains("no transaction"));
2✔
1080
    }
1081

1082
    #[test]
1083
    fn error_inside_transaction_keeps_txn_open() {
3✔
1084
        // A bad INSERT inside a txn doesn't commit or abort automatically —
1085
        // the user can still ROLLBACK. SQLite's implicit-rollback behavior
1086
        // isn't modeled here.
1087
        let mut db = seed_users_table();
1✔
1088
        process_command("BEGIN;", &mut db).unwrap();
2✔
1089
        let err = process_command("INSERT INTO nope (x) VALUES (1);", &mut db);
1✔
1090
        assert!(err.is_err());
2✔
1091
        assert!(db.in_transaction(), "txn should stay open after error");
1✔
1092
        process_command("ROLLBACK;", &mut db).unwrap();
2✔
1093
    }
1094

1095
    /// Builds a file-backed Database at a unique temp path, with the
1096
    /// schema seeded and `source_path` set so subsequent process_command
1097
    /// calls auto-save. Returns (path, db). Drop the db before deleting
1098
    /// the files.
1099
    fn seed_file_backed(name: &str, schema: &str) -> (std::path::PathBuf, Database) {
1✔
1100
        use crate::sql::pager::{open_database, save_database};
1101
        let mut p = std::env::temp_dir();
1✔
1102
        let pid = std::process::id();
2✔
1103
        let nanos = std::time::SystemTime::now()
2✔
1104
            .duration_since(std::time::UNIX_EPOCH)
1✔
1105
            .map(|d| d.as_nanos())
3✔
1106
            .unwrap_or(0);
1107
        p.push(format!("sqlrite-txn-{name}-{pid}-{nanos}.sqlrite"));
1✔
1108

1109
        // Seed the file, then reopen to get a source_path-attached db
1110
        // (save_database alone doesn't attach a fresh pager to a db
1111
        // whose source_path was None before the call).
1112
        {
1113
            let mut seed = Database::new("t".to_string());
1✔
1114
            process_command(schema, &mut seed).unwrap();
2✔
1115
            save_database(&mut seed, &p).unwrap();
1✔
1116
        }
1117
        let db = open_database(&p, "t".to_string()).unwrap();
1✔
1118
        (p, db)
1✔
1119
    }
1120

1121
    fn cleanup_file(path: &std::path::Path) {
1✔
1122
        let _ = std::fs::remove_file(path);
1✔
1123
        let mut wal = path.as_os_str().to_owned();
1✔
1124
        wal.push("-wal");
1✔
1125
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
1126
    }
1127

1128
    #[test]
1129
    fn null_values_round_trip_through_disk() {
3✔
1130
        // SQLR-7 — explicit NULLs and omitted columns must persist.
1131
        // Pre-fix, restore_row rejected NULL for INTEGER/REAL/BOOL/VECTOR
1132
        // columns ("Integer column 'n' cannot store NULL — corrupt
1133
        // cell?"), so any DB containing a NULL in those types failed to
1134
        // reopen.
1135
        use crate::sql::db::table::Value;
1136
        use crate::sql::pager::open_database;
1137

1138
        let (path, mut db) = seed_file_backed(
1139
            "nullrt",
1140
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER, s TEXT, score REAL, flag BOOLEAN);",
1141
        );
1142
        process_command(
1143
            "INSERT INTO t (id, n, s, score, flag) VALUES (1, 10, 'hi', 1.5, true);",
1144
            &mut db,
1145
        )
1146
        .unwrap();
1147
        process_command(
1148
            "INSERT INTO t (id, n, s, score, flag) VALUES (2, NULL, NULL, NULL, NULL);",
1149
            &mut db,
1150
        )
1151
        .unwrap();
1152
        // Row 3 omits every nullable column.
1153
        process_command("INSERT INTO t (id) VALUES (3);", &mut db).unwrap();
1✔
1154

1155
        drop(db); // release pager lock
1✔
1156

1157
        let reopened = open_database(&path, "t".to_string()).unwrap();
1✔
1158
        let t = reopened.get_table("t".to_string()).unwrap();
2✔
1159
        let by_id = |id: i64| {
2✔
1160
            t.rowids()
1✔
1161
                .into_iter()
1162
                .find(|r| t.get_value("id", *r) == Some(Value::Integer(id)))
3✔
1163
                .unwrap_or_else(|| panic!("row id={id} not found"))
×
1164
        };
1165

1166
        let r1 = by_id(1);
1✔
1167
        assert_eq!(t.get_value("n", r1), Some(Value::Integer(10)));
1✔
1168
        assert_eq!(t.get_value("s", r1), Some(Value::Text("hi".to_string())));
1✔
1169
        assert_eq!(t.get_value("score", r1), Some(Value::Real(1.5)));
1✔
1170
        assert_eq!(t.get_value("flag", r1), Some(Value::Bool(true)));
1✔
1171

1172
        for r in [by_id(2), by_id(3)] {
2✔
1173
            assert_eq!(t.get_value("n", r), None, "INTEGER NULL must round-trip");
2✔
1174
            assert_eq!(t.get_value("s", r), None, "TEXT NULL must round-trip");
1✔
1175
            assert_eq!(t.get_value("score", r), None, "REAL NULL must round-trip");
1✔
1176
            assert_eq!(t.get_value("flag", r), None, "BOOL NULL must round-trip");
1✔
1177
        }
1178

1179
        drop(reopened);
1✔
1180
        cleanup_file(&path);
1✔
1181
    }
1182

1183
    #[test]
1184
    fn begin_commit_rollback_round_trip_through_disk() {
3✔
1185
        // File-backed DB: commit inside a transaction must actually
1186
        // persist. ROLLBACK inside a *later* transaction must not
1187
        // un-do the previously-committed changes.
1188
        use crate::sql::pager::open_database;
1189

1190
        let (path, mut db) = seed_file_backed(
1191
            "roundtrip",
1192
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1193
        );
1194

1195
        // Transaction 1: insert two rows, commit.
1196
        process_command("BEGIN;", &mut db).unwrap();
2✔
1197
        process_command("INSERT INTO notes (body) VALUES ('a');", &mut db).unwrap();
1✔
1198
        process_command("INSERT INTO notes (body) VALUES ('b');", &mut db).unwrap();
1✔
1199
        process_command("COMMIT;", &mut db).unwrap();
1✔
1200

1201
        // Transaction 2: insert another, roll back.
1202
        process_command("BEGIN;", &mut db).unwrap();
1✔
1203
        process_command("INSERT INTO notes (body) VALUES ('c');", &mut db).unwrap();
1✔
1204
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1205

1206
        drop(db); // release pager lock
1✔
1207

1208
        let reopened = open_database(&path, "t".to_string()).unwrap();
1✔
1209
        let notes = reopened.get_table("notes".to_string()).unwrap();
2✔
1210
        assert_eq!(notes.rowids().len(), 2, "committed rows should survive");
1✔
1211

1212
        drop(reopened);
1✔
1213
        cleanup_file(&path);
1✔
1214
    }
1215

1216
    #[test]
1217
    fn write_inside_transaction_does_not_autosave() {
3✔
1218
        // File-backed DB: writes inside BEGIN/…/COMMIT must NOT hit
1219
        // the WAL until COMMIT. We prove it by checking the WAL file
1220
        // size before vs during the transaction.
1221
        let (path, mut db) =
1✔
1222
            seed_file_backed("noas", "CREATE TABLE t (id INTEGER PRIMARY KEY, x TEXT);");
1223

1224
        let mut wal_path = path.as_os_str().to_owned();
2✔
1225
        wal_path.push("-wal");
1✔
1226
        let wal_path = std::path::PathBuf::from(wal_path);
1✔
1227
        let frames_before = std::fs::metadata(&wal_path).unwrap().len();
2✔
1228

1229
        process_command("BEGIN;", &mut db).unwrap();
1✔
1230
        process_command("INSERT INTO t (x) VALUES ('a');", &mut db).unwrap();
1✔
1231
        process_command("INSERT INTO t (x) VALUES ('b');", &mut db).unwrap();
1✔
1232

1233
        // Mid-transaction: WAL must be unchanged — no auto-save fired.
1234
        let frames_mid = std::fs::metadata(&wal_path).unwrap().len();
1✔
1235
        assert_eq!(
1✔
1236
            frames_before, frames_mid,
1237
            "WAL should not grow during an open transaction"
1238
        );
1239

1240
        process_command("COMMIT;", &mut db).unwrap();
2✔
1241

1242
        drop(db); // release pager lock
1✔
1243
        let fresh = crate::sql::pager::open_database(&path, "t".to_string()).unwrap();
1✔
1244
        assert_eq!(
1✔
1245
            fresh.get_table("t".to_string()).unwrap().rowids().len(),
3✔
1246
            2,
1247
            "COMMIT should have persisted both inserted rows"
1248
        );
1249
        drop(fresh);
1✔
1250
        cleanup_file(&path);
1✔
1251
    }
1252

1253
    #[test]
1254
    fn rollback_undoes_create_table() {
3✔
1255
        // Schema DDL inside a txn: ROLLBACK must make the new table
1256
        // disappear. The txn snapshot captures db.tables as of BEGIN,
1257
        // and ROLLBACK reassigns tables from that snapshot, so a table
1258
        // created mid-transaction has no entry in the snapshot.
1259
        let mut db = seed_users_table();
1✔
1260
        assert_eq!(db.tables.len(), 1);
2✔
1261

1262
        process_command("BEGIN;", &mut db).unwrap();
1✔
1263
        process_command(
1264
            "CREATE TABLE dropme (id INTEGER PRIMARY KEY, x TEXT);",
1265
            &mut db,
1266
        )
1267
        .unwrap();
1268
        process_command("INSERT INTO dropme (x) VALUES ('stuff');", &mut db).unwrap();
1✔
1269
        assert_eq!(db.tables.len(), 2);
1✔
1270

1271
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1272
        assert_eq!(
1✔
1273
            db.tables.len(),
1✔
1274
            1,
1275
            "CREATE TABLE should have been rolled back"
1276
        );
1277
        assert!(db.get_table("dropme".to_string()).is_err());
2✔
1278
    }
1279

1280
    #[test]
1281
    fn rollback_restores_secondary_index_state() {
3✔
1282
        // Phase 4f edge case: rolling back an INSERT on a UNIQUE-indexed
1283
        // column must also clean up the index, otherwise a re-insert of
1284
        // the same value would spuriously collide.
1285
        let mut db = Database::new("t".to_string());
1✔
1286
        process_command(
1287
            "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
1288
            &mut db,
1289
        )
1290
        .unwrap();
1291
        process_command("INSERT INTO users (email) VALUES ('a@x');", &mut db).unwrap();
1✔
1292

1293
        process_command("BEGIN;", &mut db).unwrap();
1✔
1294
        process_command("INSERT INTO users (email) VALUES ('b@x');", &mut db).unwrap();
1✔
1295
        // Inside the txn: the index now contains both 'a@x' and 'b@x'.
1296
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1297

1298
        // Re-inserting 'b@x' after rollback must succeed — if the index
1299
        // wasn't properly restored, it would think 'b@x' is still a
1300
        // collision and fail with a UNIQUE violation.
1301
        let reinsert = process_command("INSERT INTO users (email) VALUES ('b@x');", &mut db);
1✔
1302
        assert!(
×
1303
            reinsert.is_ok(),
2✔
1304
            "re-insert after rollback should succeed, got {reinsert:?}"
1305
        );
1306
    }
1307

1308
    #[test]
1309
    fn rollback_restores_last_rowid_counter() {
3✔
1310
        // Rowids allocated inside a rolled-back transaction should be
1311
        // reusable. The snapshot restores Table::last_rowid, so the
1312
        // next insert picks up where the pre-BEGIN state left off.
1313
        use crate::sql::db::table::Value;
1314

1315
        let mut db = seed_users_table(); // 3 rows, last_rowid = 3
1✔
1316
        let pre = db.get_table("users".to_string()).unwrap().last_rowid;
2✔
1317

1318
        process_command("BEGIN;", &mut db).unwrap();
1✔
1319
        process_command("INSERT INTO users (name, age) VALUES ('d', 50);", &mut db).unwrap(); // would be rowid 4
1✔
1320
        process_command("INSERT INTO users (name, age) VALUES ('e', 60);", &mut db).unwrap(); // would be rowid 5
1✔
1321
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1322

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

1326
        // Confirm: the next insert reuses rowid pre+1.
1327
        process_command("INSERT INTO users (name, age) VALUES ('d', 50);", &mut db).unwrap();
2✔
1328
        let users = db.get_table("users".to_string()).unwrap();
1✔
1329
        let d_rowid = users
1✔
1330
            .rowids()
1331
            .into_iter()
1332
            .find(|r| users.get_value("name", *r) == Some(Value::Text("d".into())))
3✔
1333
            .expect("d row must exist");
1334
        assert_eq!(d_rowid, pre + 1);
1✔
1335
    }
1336

1337
    #[test]
1338
    fn commit_on_in_memory_db_clears_txn_without_pager_call() {
3✔
1339
        // In-memory DB (no source_path): COMMIT must still work — just
1340
        // no disk flush. Covers the `if let Some(path) = …` branch
1341
        // where the guard falls through without calling save_database.
1342
        let mut db = seed_users_table(); // no source_path
1✔
1343
        assert!(db.source_path.is_none());
2✔
1344

1345
        process_command("BEGIN;", &mut db).unwrap();
1✔
1346
        process_command("INSERT INTO users (name, age) VALUES ('z', 99);", &mut db).unwrap();
1✔
1347
        process_command("COMMIT;", &mut db).unwrap();
1✔
1348

1349
        assert!(!db.in_transaction());
1✔
1350
        assert_eq!(db.get_table("users".to_string()).unwrap().rowids().len(), 4);
2✔
1351
    }
1352

1353
    #[test]
1354
    fn failed_commit_auto_rolls_back_in_memory_state() {
3✔
1355
        // Data-safety regression: on COMMIT save failure we must auto-
1356
        // rollback the in-memory state. Otherwise, any subsequent
1357
        // non-transactional statement would auto-save the partial
1358
        // mid-transaction work, silently publishing uncommitted
1359
        // changes to disk.
1360
        //
1361
        // We simulate a save failure by making the WAL sidecar path
1362
        // unavailable mid-transaction: after BEGIN, we take an
1363
        // exclusive OS lock on the WAL via a second File handle,
1364
        // forcing the next save to fail when it tries to append.
1365
        //
1366
        // Simpler repro: point source_path at a directory (not a file).
1367
        // `OpenOptions::open` will fail with EISDIR on save.
1368
        use crate::sql::pager::save_database;
1369

1370
        // Seed a file-backed db.
1371
        let (path, mut db) = seed_file_backed(
1372
            "failcommit",
1373
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1374
        );
1375

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

1379
        // Open a new txn and add a row.
1380
        process_command("BEGIN;", &mut db).unwrap();
1✔
1381
        process_command("INSERT INTO notes (body) VALUES ('inflight');", &mut db).unwrap();
1✔
1382
        assert_eq!(
1✔
1383
            db.get_table("notes".to_string()).unwrap().rowids().len(),
2✔
1384
            2,
1385
            "inflight row visible mid-txn"
1386
        );
1387

1388
        // Swap source_path to a path that will fail on open. A
1389
        // directory is a reliable failure mode — Pager::open on a
1390
        // directory errors with an I/O error.
1391
        let orig_source = db.source_path.clone();
1✔
1392
        let orig_pager = db.pager.take();
2✔
1393
        db.source_path = Some(std::env::temp_dir());
2✔
1394

1395
        let commit_result = process_command("COMMIT;", &mut db);
1✔
1396
        assert!(commit_result.is_err(), "commit must fail");
2✔
1397
        let err_str = format!("{}", commit_result.unwrap_err());
2✔
1398
        assert!(
×
1399
            err_str.contains("COMMIT failed") && err_str.contains("rolled back"),
3✔
1400
            "error must surface auto-rollback; got: {err_str}"
1401
        );
1402

1403
        // Auto-rollback fired: the inflight row is gone, the txn flag
1404
        // is cleared, and a follow-up non-txn statement won't leak
1405
        // stale state.
1406
        assert!(
×
1407
            !db.in_transaction(),
2✔
1408
            "txn must be cleared after auto-rollback"
1409
        );
1410
        assert_eq!(
1✔
1411
            db.get_table("notes".to_string()).unwrap().rowids().len(),
3✔
1412
            1,
1413
            "inflight row must be rolled back"
1414
        );
1415

1416
        // Restore the real source_path + pager and verify a clean
1417
        // subsequent write goes through.
1418
        db.source_path = orig_source;
1✔
1419
        db.pager = orig_pager;
1✔
1420
        process_command("INSERT INTO notes (body) VALUES ('after');", &mut db).unwrap();
1✔
1421
        drop(db);
1✔
1422

1423
        // Reopen and assert only 'before' + 'after' landed on disk.
1424
        let reopened = crate::sql::pager::open_database(&path, "t".to_string()).unwrap();
1✔
1425
        let notes = reopened.get_table("notes".to_string()).unwrap();
2✔
1426
        assert_eq!(notes.rowids().len(), 2);
1✔
1427
        // Ensure no leaked save_database partial happened.
1428
        let _ = save_database; // silence unused-import lint if any
1429
        drop(reopened);
1✔
1430
        cleanup_file(&path);
1✔
1431
    }
1432

1433
    #[test]
1434
    fn begin_on_read_only_is_rejected() {
3✔
1435
        use crate::sql::pager::{open_database_read_only, save_database};
1436

1437
        let path = {
1✔
1438
            let mut p = std::env::temp_dir();
1✔
1439
            let pid = std::process::id();
2✔
1440
            let nanos = std::time::SystemTime::now()
2✔
1441
                .duration_since(std::time::UNIX_EPOCH)
1✔
1442
                .map(|d| d.as_nanos())
3✔
1443
                .unwrap_or(0);
1444
            p.push(format!("sqlrite-txn-ro-{pid}-{nanos}.sqlrite"));
1✔
1445
            p
1✔
1446
        };
1447
        {
1448
            let mut seed = Database::new("t".to_string());
2✔
1449
            process_command("CREATE TABLE t (id INTEGER PRIMARY KEY);", &mut seed).unwrap();
2✔
1450
            save_database(&mut seed, &path).unwrap();
1✔
1451
        }
1452

1453
        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1✔
1454
        let err = process_command("BEGIN;", &mut ro).unwrap_err();
2✔
1455
        assert!(
×
1456
            format!("{err}").contains("read-only"),
3✔
1457
            "BEGIN on RO db should surface read-only; got: {err}"
1458
        );
1459
        assert!(!ro.in_transaction());
1✔
1460

1461
        let _ = std::fs::remove_file(&path);
2✔
1462
        let mut wal = path.as_os_str().to_owned();
1✔
1463
        wal.push("-wal");
1✔
1464
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
1465
    }
1466

1467
    #[test]
1468
    fn read_only_database_rejects_mutations_before_touching_state() {
3✔
1469
        // Phase 4e end-to-end: a `--readonly` caller that runs INSERT
1470
        // must error *before* the row is added to the in-memory table.
1471
        // Otherwise the user sees a rendered result table with the
1472
        // phantom row, followed by the auto-save error — UX rot and a
1473
        // state-drift risk.
1474
        use crate::sql::pager::open_database_read_only;
1475

1476
        let mut seed = Database::new("t".to_string());
1✔
1477
        process_command(
1478
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1479
            &mut seed,
1480
        )
1481
        .unwrap();
1482
        process_command("INSERT INTO notes (body) VALUES ('alpha');", &mut seed).unwrap();
1✔
1483

1484
        let path = {
1485
            let mut p = std::env::temp_dir();
1✔
1486
            let pid = std::process::id();
2✔
1487
            let nanos = std::time::SystemTime::now()
2✔
1488
                .duration_since(std::time::UNIX_EPOCH)
1✔
1489
                .map(|d| d.as_nanos())
3✔
1490
                .unwrap_or(0);
1491
            p.push(format!("sqlrite-ro-reject-{pid}-{nanos}.sqlrite"));
1✔
1492
            p
1✔
1493
        };
1494
        crate::sql::pager::save_database(&mut seed, &path).unwrap();
2✔
1495
        drop(seed);
1✔
1496

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

1500
        for stmt in [
2✔
1501
            "INSERT INTO notes (body) VALUES ('beta');",
1502
            "UPDATE notes SET body = 'x';",
1503
            "DELETE FROM notes;",
1504
            "CREATE TABLE more (id INTEGER PRIMARY KEY);",
1505
            "CREATE INDEX notes_body ON notes (body);",
1506
        ] {
1507
            let err = process_command(stmt, &mut ro).unwrap_err();
2✔
1508
            assert!(
×
1509
                format!("{err}").contains("read-only"),
3✔
1510
                "stmt {stmt:?} should surface a read-only error; got: {err}"
1511
            );
1512
        }
1513

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

1520
        // Cleanup.
1521
        drop(ro);
1✔
1522
        let _ = std::fs::remove_file(&path);
1✔
1523
        let mut wal = path.as_os_str().to_owned();
1✔
1524
        wal.push("-wal");
1✔
1525
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
1526
    }
1527

1528
    // -----------------------------------------------------------------
1529
    // Phase 7a — VECTOR(N) end-to-end through process_command
1530
    // -----------------------------------------------------------------
1531

1532
    #[test]
1533
    fn vector_create_table_and_insert_basic() {
3✔
1534
        let mut db = Database::new("tempdb".to_string());
1✔
1535
        process_command(
1536
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(3));",
1537
            &mut db,
1538
        )
1539
        .expect("create table with VECTOR(3)");
1540
        process_command(
1541
            "INSERT INTO docs (embedding) VALUES ([0.1, 0.2, 0.3]);",
1542
            &mut db,
1543
        )
1544
        .expect("insert vector");
1545

1546
        // process_command returns a status string; the rendered table
1547
        // goes to stdout via print_table. Verify state by inspecting
1548
        // the database directly.
1549
        let sel = process_command("SELECT * FROM docs;", &mut db).expect("select");
1✔
1550
        assert!(sel.contains("1 row returned"));
2✔
1551

1552
        let docs = db.get_table("docs".to_string()).expect("docs table");
1✔
1553
        let rowids = docs.rowids();
1✔
1554
        assert_eq!(rowids.len(), 1);
2✔
1555
        match docs.get_value("embedding", rowids[0]) {
1✔
1556
            Some(Value::Vector(v)) => assert_eq!(v, vec![0.1f32, 0.2, 0.3]),
1✔
1557
            other => panic!("expected Value::Vector(...), got {other:?}"),
×
1558
        }
1559
    }
1560

1561
    #[test]
1562
    fn vector_dim_mismatch_at_insert_is_clean_error() {
3✔
1563
        let mut db = Database::new("tempdb".to_string());
1✔
1564
        process_command(
1565
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(3));",
1566
            &mut db,
1567
        )
1568
        .expect("create table");
1569

1570
        // Too few elements.
1571
        let err = process_command("INSERT INTO docs (embedding) VALUES ([0.1, 0.2]);", &mut db)
1✔
1572
            .unwrap_err();
1573
        let msg = format!("{err}");
2✔
1574
        assert!(
×
1575
            msg.to_lowercase().contains("dimension")
2✔
1576
                && msg.contains("declared 3")
1✔
1577
                && msg.contains("got 2"),
1✔
1578
            "expected clear dim-mismatch error, got: {msg}"
1579
        );
1580

1581
        // Too many elements.
1582
        let err = process_command(
1583
            "INSERT INTO docs (embedding) VALUES ([0.1, 0.2, 0.3, 0.4, 0.5]);",
1584
            &mut db,
1585
        )
1586
        .unwrap_err();
1587
        assert!(
×
1588
            format!("{err}").contains("got 5"),
3✔
1589
            "expected dim-mismatch error mentioning got 5, got: {err}"
1590
        );
1591
    }
1592

1593
    #[test]
1594
    fn vector_create_table_rejects_missing_dim() {
3✔
1595
        let mut db = Database::new("tempdb".to_string());
1✔
1596
        // `VECTOR` (no parens) currently parses as `DataType::Custom` with
1597
        // empty args from sqlparser, OR may not parse as Custom at all
1598
        // depending on dialect. Either way, the column shouldn't end up
1599
        // as a usable Vector type. Accept any error here — the precise
1600
        // message is parser-version-dependent.
1601
        let result = process_command(
1602
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR);",
1603
            &mut db,
1604
        );
1605
        assert!(
×
1606
            result.is_err(),
2✔
1607
            "expected CREATE TABLE with bare VECTOR to fail (no dim)"
1608
        );
1609
    }
1610

1611
    #[test]
1612
    fn vector_create_table_rejects_zero_dim() {
3✔
1613
        let mut db = Database::new("tempdb".to_string());
1✔
1614
        let err = process_command(
1615
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(0));",
1616
            &mut db,
1617
        )
1618
        .unwrap_err();
1619
        let msg = format!("{err}");
2✔
1620
        assert!(
×
1621
            msg.to_lowercase().contains("vector"),
3✔
1622
            "expected VECTOR-related error for VECTOR(0), got: {msg}"
1623
        );
1624
    }
1625

1626
    #[test]
1627
    fn vector_high_dim_works() {
3✔
1628
        // 384-dim vector (OpenAI text-embedding-3-small size). Mostly a
1629
        // smoke test — if cell encoding mishandles the size, this fails.
1630
        let mut db = Database::new("tempdb".to_string());
1✔
1631
        process_command(
1632
            "CREATE TABLE embeddings (id INTEGER PRIMARY KEY, e VECTOR(384));",
1633
            &mut db,
1634
        )
1635
        .expect("create table VECTOR(384)");
1636

1637
        let lit = format!(
1✔
1638
            "[{}]",
1639
            (0..384)
1✔
1640
                .map(|i| format!("{}", i as f32 * 0.001))
3✔
1641
                .collect::<Vec<_>>()
1✔
1642
                .join(",")
1✔
1643
        );
1644
        let sql = format!("INSERT INTO embeddings (e) VALUES ({lit});");
2✔
1645
        process_command(&sql, &mut db).expect("insert 384-dim vector");
2✔
1646

1647
        let sel = process_command("SELECT id FROM embeddings;", &mut db).expect("select id");
1✔
1648
        assert!(sel.contains("1 row returned"));
2✔
1649
    }
1650

1651
    #[test]
1652
    fn vector_multiple_rows() {
3✔
1653
        // Three rows with different vectors — exercises the Row::Vector
1654
        // BTreeMap path (not just single-row insertion).
1655
        let mut db = Database::new("tempdb".to_string());
1✔
1656
        process_command(
1657
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1658
            &mut db,
1659
        )
1660
        .expect("create");
1661
        for i in 0..3 {
1✔
1662
            let sql = format!("INSERT INTO docs (e) VALUES ([{i}.0, {}.0]);", i + 1);
2✔
1663
            process_command(&sql, &mut db).expect("insert");
2✔
1664
        }
1665
        let sel = process_command("SELECT * FROM docs;", &mut db).expect("select");
1✔
1666
        assert!(sel.contains("3 rows returned"));
2✔
1667

1668
        // Verify each vector round-tripped correctly via direct DB inspection.
1669
        let docs = db.get_table("docs".to_string()).expect("docs table");
1✔
1670
        let rowids = docs.rowids();
1✔
1671
        assert_eq!(rowids.len(), 3);
2✔
1672
        let mut vectors: Vec<Vec<f32>> = rowids
1✔
1673
            .iter()
1674
            .filter_map(|r| match docs.get_value("e", *r) {
3✔
1675
                Some(Value::Vector(v)) => Some(v),
1✔
1676
                _ => None,
×
1677
            })
1678
            .collect();
1679
        vectors.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap());
4✔
1680
        assert_eq!(vectors[0], vec![0.0f32, 1.0]);
1✔
1681
        assert_eq!(vectors[1], vec![1.0f32, 2.0]);
1✔
1682
        assert_eq!(vectors[2], vec![2.0f32, 3.0]);
1✔
1683
    }
1684

1685
    // -----------------------------------------------------------------
1686
    // Phase 7d.2 — CREATE INDEX … USING hnsw end-to-end
1687
    // -----------------------------------------------------------------
1688

1689
    /// Builds a 5-row docs(id, e VECTOR(2)) table with vectors arranged
1690
    /// at known positions for clear distance reasoning. Used by both
1691
    /// the 7d.2 KNN tests and the refuse-DELETE/UPDATE tests.
1692
    fn seed_hnsw_table() -> Database {
1✔
1693
        let mut db = Database::new("tempdb".to_string());
1✔
1694
        process_command(
1695
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1696
            &mut db,
1697
        )
1698
        .unwrap();
1699
        for v in &[
1✔
1700
            "[1.0, 0.0]",   // id=1
1701
            "[2.0, 0.0]",   // id=2
1702
            "[0.0, 3.0]",   // id=3
1703
            "[1.0, 4.0]",   // id=4
1704
            "[10.0, 10.0]", // id=5
1705
        ] {
1706
            process_command(&format!("INSERT INTO docs (e) VALUES ({v});"), &mut db).unwrap();
2✔
1707
        }
1708
        db
1✔
1709
    }
1710

1711
    #[test]
1712
    fn create_index_using_hnsw_succeeds() {
4✔
1713
        let mut db = seed_hnsw_table();
1✔
1714
        let resp = process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1715
        assert!(resp.to_lowercase().contains("create index"));
2✔
1716
        // Index attached.
1717
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1718
        assert_eq!(table.hnsw_indexes.len(), 1);
1✔
1719
        assert_eq!(table.hnsw_indexes[0].name, "ix_e");
1✔
1720
        assert_eq!(table.hnsw_indexes[0].column_name, "e");
1✔
1721
        // Existing rows landed in the graph.
1722
        assert_eq!(table.hnsw_indexes[0].index.len(), 5);
1✔
1723
    }
1724

1725
    #[test]
1726
    fn create_index_using_hnsw_rejects_non_vector_column() {
3✔
1727
        let mut db = Database::new("tempdb".to_string());
1✔
1728
        process_command(
1729
            "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);",
1730
            &mut db,
1731
        )
1732
        .unwrap();
1733
        let err =
1✔
1734
            process_command("CREATE INDEX ix_name ON t USING hnsw (name);", &mut db).unwrap_err();
1735
        let msg = format!("{err}");
2✔
1736
        assert!(
×
1737
            msg.to_lowercase().contains("vector"),
3✔
1738
            "expected error mentioning VECTOR; got: {msg}"
1739
        );
1740
    }
1741

1742
    #[test]
1743
    fn knn_query_uses_hnsw_after_create_index() {
3✔
1744
        // The KNN-shaped query route through try_hnsw_probe rather than
1745
        // the brute-force select_topk. The user-visible result should
1746
        // be the same (HNSW recall is high on small graphs); we
1747
        // primarily verify the index is being hit by checking that
1748
        // the right rowids come back in the right order.
1749
        let mut db = seed_hnsw_table();
1✔
1750
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1751

1752
        // Top-3 closest to [1.0, 0.0]:
1753
        //   id=1 [1.0, 0.0]   distance=0
1754
        //   id=2 [2.0, 0.0]   distance=1
1755
        //   id=3 [0.0, 3.0]   distance≈3.16
1756
        let resp = process_command(
1757
            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [1.0, 0.0]) ASC LIMIT 3;",
1758
            &mut db,
1759
        )
1760
        .unwrap();
1761
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
1762
    }
1763

1764
    #[test]
1765
    fn knn_query_works_after_subsequent_inserts() {
3✔
1766
        // Index built when 5 rows existed; insert 2 more after; the
1767
        // HNSW gets maintained incrementally by insert_row, so the
1768
        // KNN query should see the newly-inserted vectors.
1769
        let mut db = seed_hnsw_table();
1✔
1770
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1771
        process_command("INSERT INTO docs (e) VALUES ([0.5, 0.0]);", &mut db).unwrap(); // id=6
1✔
1772
        process_command("INSERT INTO docs (e) VALUES ([0.1, 0.1]);", &mut db).unwrap(); // id=7
1✔
1773

1774
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1775
        assert_eq!(
1✔
1776
            table.hnsw_indexes[0].index.len(),
1✔
1777
            7,
1778
            "incremental insert should grow HNSW alongside row storage"
1779
        );
1780

1781
        // Now query: id=7 [0.1, 0.1] is closer to [0.0, 0.0] than the
1782
        // original 5 rows.
1783
        let resp = process_command(
1784
            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [0.0, 0.0]) ASC LIMIT 1;",
1785
            &mut db,
1786
        )
1787
        .unwrap();
1788
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
1789
    }
1790

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

1796
    #[test]
1797
    fn delete_on_hnsw_indexed_table_succeeds_and_marks_dirty() {
3✔
1798
        let mut db = seed_hnsw_table();
1✔
1799
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1800
        let resp = process_command("DELETE FROM docs WHERE id = 1;", &mut db).unwrap();
1✔
1801
        assert!(resp.contains("1 row"), "expected 1 row deleted: {resp}");
2✔
1802

1803
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
1804
        let entry = docs.hnsw_indexes.iter().find(|e| e.name == "ix_e").unwrap();
3✔
1805
        assert!(
×
1806
            entry.needs_rebuild,
1✔
1807
            "DELETE should have marked HNSW index dirty for rebuild on next save"
1808
        );
1809
    }
1810

1811
    #[test]
1812
    fn hnsw_delete_then_insert_rebuilds_in_same_connection() {
3✔
1813
        let mut db = Database::new(String::from("test_db"));
1✔
1814
        process_command(
1815
            "CREATE TABLE chunks (id INTEGER PRIMARY KEY, document_id INTEGER, embedding VECTOR(4));",
1816
            &mut db,
1817
        )
1818
        .unwrap();
1819
        process_command(
1820
            "CREATE INDEX idx_emb ON chunks USING hnsw (embedding);",
1821
            &mut db,
1822
        )
1823
        .unwrap();
1824
        process_command(
1825
            "INSERT INTO chunks (document_id, embedding) VALUES (1, [1, 0, 0, 0]);",
1826
            &mut db,
1827
        )
1828
        .unwrap();
1829
        process_command(
1830
            "INSERT INTO chunks (document_id, embedding) VALUES (1, [0, 1, 0, 0]);",
1831
            &mut db,
1832
        )
1833
        .unwrap();
1834
        process_command("DELETE FROM chunks WHERE document_id = 1;", &mut db).unwrap();
1✔
1835

1836
        process_command(
1837
            "INSERT INTO chunks (document_id, embedding) VALUES (2, [0, 0, 1, 0]);",
1838
            &mut db,
1839
        )
1840
        .unwrap();
1841
        let chunks = db.get_table("chunks".to_string()).unwrap();
1✔
1842
        let entry = chunks
3✔
1843
            .hnsw_indexes
1844
            .iter()
1845
            .find(|e| e.name == "idx_emb")
3✔
1846
            .unwrap();
NEW
1847
        assert!(
×
1848
            !entry.needs_rebuild,
1✔
1849
            "INSERT should rebuild the dirty index"
1850
        );
1851
        assert_eq!(entry.index.len(), 1);
2✔
1852

1853
        let out = process_command_with_render(
1854
            "SELECT document_id FROM chunks ORDER BY vec_distance_l2(embedding, [0, 0, 1, 0]) ASC LIMIT 1;",
1855
            &mut db,
1856
        )
1857
        .unwrap();
1858
        assert!(out.status.contains("1 row returned"), "got: {}", out.status);
2✔
1859
        let rendered = out.rendered.expect("SELECT should render rows");
1✔
NEW
1860
        assert!(
×
1861
            rendered.contains("| 2           |"),
2✔
1862
            "expected the post-delete inserted row: {rendered}"
1863
        );
1864
    }
1865

1866
    #[test]
1867
    fn update_on_hnsw_indexed_vector_col_succeeds_and_marks_dirty() {
3✔
1868
        let mut db = seed_hnsw_table();
1✔
1869
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1870
        let resp =
1✔
1871
            process_command("UPDATE docs SET e = [9.0, 9.0] WHERE id = 1;", &mut db).unwrap();
1872
        assert!(resp.contains("1 row"), "expected 1 row updated: {resp}");
2✔
1873

1874
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
1875
        let entry = docs.hnsw_indexes.iter().find(|e| e.name == "ix_e").unwrap();
3✔
UNCOV
1876
        assert!(
×
1877
            entry.needs_rebuild,
1✔
1878
            "UPDATE on the vector column should have marked HNSW index dirty"
1879
        );
1880
    }
1881

1882
    #[test]
1883
    fn duplicate_index_name_errors() {
3✔
1884
        let mut db = seed_hnsw_table();
1✔
1885
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1886
        let err =
1✔
1887
            process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap_err();
1888
        let msg = format!("{err}");
2✔
UNCOV
1889
        assert!(
×
1890
            msg.to_lowercase().contains("already exists"),
3✔
1891
            "expected duplicate-index error; got: {msg}"
1892
        );
1893
    }
1894

1895
    #[test]
1896
    fn index_if_not_exists_is_idempotent() {
3✔
1897
        let mut db = seed_hnsw_table();
1✔
1898
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1899
        // Second time with IF NOT EXISTS should succeed (no-op).
1900
        process_command(
1901
            "CREATE INDEX IF NOT EXISTS ix_e ON docs USING hnsw (e);",
1902
            &mut db,
1903
        )
1904
        .unwrap();
1905
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1906
        assert_eq!(table.hnsw_indexes.len(), 1);
1✔
1907
    }
1908

1909
    // -----------------------------------------------------------------
1910
    // Phase 8b — CREATE INDEX … USING fts end-to-end
1911
    // -----------------------------------------------------------------
1912

1913
    /// 5-row docs(id INTEGER PK, body TEXT) populated with overlapping
1914
    /// vocabulary so BM25 ranking has interesting structure.
1915
    fn seed_fts_table() -> Database {
1✔
1916
        let mut db = Database::new("tempdb".to_string());
1✔
1917
        process_command(
1918
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT);",
1919
            &mut db,
1920
        )
1921
        .unwrap();
1922
        for body in &[
1✔
1923
            "rust embedded database",        // id=1 — both 'rust' and 'embedded'
1924
            "rust web framework",            // id=2 — 'rust' only
1925
            "go embedded systems",           // id=3 — 'embedded' only
1926
            "python web framework",          // id=4 — neither
1927
            "rust rust rust embedded power", // id=5 — heavy on 'rust'
1928
        ] {
1929
            process_command(
1930
                &format!("INSERT INTO docs (body) VALUES ('{body}');"),
2✔
1931
                &mut db,
1932
            )
1933
            .unwrap();
1934
        }
1935
        db
1✔
1936
    }
1937

1938
    #[test]
1939
    fn create_index_using_fts_succeeds_and_indexes_existing_rows() {
3✔
1940
        let mut db = seed_fts_table();
1✔
1941
        let resp =
2✔
1942
            process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
1943
        assert!(resp.to_lowercase().contains("create index"), "got {resp}");
2✔
1944
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1945
        assert_eq!(table.fts_indexes.len(), 1);
1✔
1946
        assert_eq!(table.fts_indexes[0].name, "ix_body");
1✔
1947
        assert_eq!(table.fts_indexes[0].column_name, "body");
1✔
1948
        // All five rows should be in the in-memory PostingList.
1949
        assert_eq!(table.fts_indexes[0].index.len(), 5);
1✔
1950
    }
1951

1952
    #[test]
1953
    fn create_index_using_fts_rejects_non_text_column() {
3✔
1954
        let mut db = Database::new("tempdb".to_string());
1✔
1955
        process_command(
1956
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER);",
1957
            &mut db,
1958
        )
1959
        .unwrap();
1960
        let err = process_command("CREATE INDEX ix_n ON t USING fts (n);", &mut db).unwrap_err();
1✔
1961
        let msg = format!("{err}");
2✔
UNCOV
1962
        assert!(
×
1963
            msg.to_lowercase().contains("text"),
3✔
1964
            "expected error mentioning TEXT; got: {msg}"
1965
        );
1966
    }
1967

1968
    #[test]
1969
    fn fts_match_returns_expected_rows() {
3✔
1970
        let mut db = seed_fts_table();
1✔
1971
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
1972
        // Rows that contain 'rust': ids 1, 2, 5.
1973
        let resp = process_command(
1974
            "SELECT id FROM docs WHERE fts_match(body, 'rust');",
1975
            &mut db,
1976
        )
1977
        .unwrap();
1978
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
1979
    }
1980

1981
    #[test]
1982
    fn fts_match_without_index_errors_clearly() {
3✔
1983
        let mut db = seed_fts_table();
1✔
1984
        // No CREATE INDEX — fts_match must surface a useful error.
1985
        let err = process_command(
1986
            "SELECT id FROM docs WHERE fts_match(body, 'rust');",
1987
            &mut db,
1988
        )
1989
        .unwrap_err();
1990
        let msg = format!("{err}");
2✔
UNCOV
1991
        assert!(
×
1992
            msg.contains("no FTS index"),
2✔
1993
            "expected no-index error; got: {msg}"
1994
        );
1995
    }
1996

1997
    #[test]
1998
    fn bm25_score_orders_descending_by_relevance() {
3✔
1999
        let mut db = seed_fts_table();
1✔
2000
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2001
        // ORDER BY bm25_score DESC LIMIT 1: id=5 has 'rust' three times in
2002
        // a 5-token doc — highest tf, modest length penalty → top score.
2003
        let out = process_command_with_render(
2004
            "SELECT id FROM docs WHERE fts_match(body, 'rust') \
2005
             ORDER BY bm25_score(body, 'rust') DESC LIMIT 1;",
2006
            &mut db,
2007
        )
2008
        .unwrap();
2009
        assert!(out.status.contains("1 row returned"), "got: {}", out.status);
2✔
2010
        let rendered = out.rendered.expect("SELECT should produce rendered output");
1✔
2011
        // The rendered prettytable contains the integer 5 in a cell.
UNCOV
2012
        assert!(
×
2013
            rendered.contains(" 5 "),
2✔
2014
            "expected id=5 to be top-ranked; rendered:\n{rendered}"
2015
        );
2016
    }
2017

2018
    #[test]
2019
    fn bm25_score_without_index_errors_clearly() {
3✔
2020
        let mut db = seed_fts_table();
1✔
2021
        let err = process_command(
2022
            "SELECT id FROM docs ORDER BY bm25_score(body, 'rust') DESC LIMIT 1;",
2023
            &mut db,
2024
        )
2025
        .unwrap_err();
2026
        let msg = format!("{err}");
2✔
UNCOV
2027
        assert!(
×
2028
            msg.contains("no FTS index"),
2✔
2029
            "expected no-index error; got: {msg}"
2030
        );
2031
    }
2032

2033
    #[test]
2034
    fn fts_post_create_inserts_are_indexed_incrementally() {
3✔
2035
        let mut db = seed_fts_table();
1✔
2036
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2037
        process_command(
2038
            "INSERT INTO docs (body) VALUES ('rust embedded analytics');",
2039
            &mut db,
2040
        )
2041
        .unwrap();
2042
        let table = db.get_table("docs".to_string()).unwrap();
1✔
2043
        // PostingList::len() reports doc count; should be 6 now.
2044
        assert_eq!(table.fts_indexes[0].index.len(), 6);
1✔
2045
        // 'analytics' appears only in the new row → query returns 1 hit.
2046
        let resp = process_command(
2047
            "SELECT id FROM docs WHERE fts_match(body, 'analytics');",
2048
            &mut db,
2049
        )
2050
        .unwrap();
2051
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2052
    }
2053

2054
    #[test]
2055
    fn delete_on_fts_indexed_table_marks_dirty() {
3✔
2056
        let mut db = seed_fts_table();
1✔
2057
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2058
        let resp = process_command("DELETE FROM docs WHERE id = 1;", &mut db).unwrap();
1✔
2059
        assert!(resp.contains("1 row"), "got: {resp}");
2✔
2060
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
2061
        let entry = docs
3✔
2062
            .fts_indexes
2063
            .iter()
2064
            .find(|e| e.name == "ix_body")
3✔
2065
            .unwrap();
UNCOV
2066
        assert!(
×
2067
            entry.needs_rebuild,
1✔
2068
            "DELETE should have flagged the FTS index dirty"
2069
        );
2070
    }
2071

2072
    #[test]
2073
    fn update_on_fts_indexed_text_col_marks_dirty() {
3✔
2074
        let mut db = seed_fts_table();
1✔
2075
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2076
        let resp = process_command(
2077
            "UPDATE docs SET body = 'java spring framework' WHERE id = 1;",
2078
            &mut db,
2079
        )
2080
        .unwrap();
2081
        assert!(resp.contains("1 row"), "got: {resp}");
2✔
2082
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
2083
        let entry = docs
3✔
2084
            .fts_indexes
2085
            .iter()
2086
            .find(|e| e.name == "ix_body")
3✔
2087
            .unwrap();
UNCOV
2088
        assert!(
×
2089
            entry.needs_rebuild,
1✔
2090
            "UPDATE on the indexed TEXT column should have flagged dirty"
2091
        );
2092
    }
2093

2094
    #[test]
2095
    fn fts_index_name_collides_with_btree_and_hnsw_namespaces() {
3✔
2096
        let mut db = seed_fts_table();
1✔
2097
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2098
        let err = process_command("CREATE INDEX ix_body ON docs (body);", &mut db).unwrap_err();
1✔
2099
        let msg = format!("{err}");
2✔
UNCOV
2100
        assert!(
×
2101
            msg.to_lowercase().contains("already exists"),
3✔
2102
            "expected duplicate-index error; got: {msg}"
2103
        );
2104
    }
2105

2106
    #[test]
2107
    fn fts_index_rejects_unique() {
3✔
2108
        let mut db = seed_fts_table();
1✔
2109
        let err = process_command(
2110
            "CREATE UNIQUE INDEX ix_body ON docs USING fts (body);",
2111
            &mut db,
2112
        )
2113
        .unwrap_err();
2114
        let msg = format!("{err}");
2✔
UNCOV
2115
        assert!(
×
2116
            msg.to_lowercase().contains("unique"),
3✔
2117
            "expected UNIQUE-rejection error; got: {msg}"
2118
        );
2119
    }
2120

2121
    #[test]
2122
    fn try_fts_probe_falls_through_on_ascending() {
3✔
2123
        // BM25 is "higher = better"; ASC is rejected so the slow path
2124
        // applies. We verify by running the query and checking the
2125
        // result is still correct (the slow path goes through scalar
2126
        // bm25_score on every row).
2127
        let mut db = seed_fts_table();
1✔
2128
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2129
        // Same query as bm25_score_orders_descending but ASC → should
2130
        // still succeed (slow path), and id=5 should now be LAST.
2131
        let resp = process_command(
2132
            "SELECT id FROM docs WHERE fts_match(body, 'rust') \
2133
             ORDER BY bm25_score(body, 'rust') ASC LIMIT 3;",
2134
            &mut db,
2135
        )
2136
        .unwrap();
2137
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
2138
    }
2139

2140
    // -----------------------------------------------------------------
2141
    // Phase 7b — vector distance functions through process_command
2142
    // -----------------------------------------------------------------
2143

2144
    /// Builds a 3-row docs table with 2-dim vectors aligned along the
2145
    /// axes so the expected distances are easy to reason about:
2146
    ///   id=1: [1, 0]
2147
    ///   id=2: [0, 1]
2148
    ///   id=3: [1, 1]
2149
    fn seed_vector_docs() -> Database {
1✔
2150
        let mut db = Database::new("tempdb".to_string());
1✔
2151
        process_command(
2152
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
2153
            &mut db,
2154
        )
2155
        .expect("create");
2156
        process_command("INSERT INTO docs (e) VALUES ([1.0, 0.0]);", &mut db).expect("insert 1");
1✔
2157
        process_command("INSERT INTO docs (e) VALUES ([0.0, 1.0]);", &mut db).expect("insert 2");
1✔
2158
        process_command("INSERT INTO docs (e) VALUES ([1.0, 1.0]);", &mut db).expect("insert 3");
1✔
2159
        db
1✔
2160
    }
2161

2162
    #[test]
2163
    fn vec_distance_l2_in_where_filters_correctly() {
3✔
2164
        // Distance from [1,0]:
2165
        //   id=1 [1,0]: 0
2166
        //   id=2 [0,1]: √2 ≈ 1.414
2167
        //   id=3 [1,1]: 1
2168
        // WHERE distance < 1.1 should match id=1 and id=3 (2 rows).
2169
        let mut db = seed_vector_docs();
1✔
2170
        let resp = process_command(
2171
            "SELECT * FROM docs WHERE vec_distance_l2(e, [1.0, 0.0]) < 1.1;",
2172
            &mut db,
2173
        )
2174
        .expect("select");
UNCOV
2175
        assert!(
×
2176
            resp.contains("2 rows returned"),
2✔
2177
            "expected 2 rows, got: {resp}"
2178
        );
2179
    }
2180

2181
    #[test]
2182
    fn vec_distance_cosine_in_where() {
3✔
2183
        // [1,0] vs [1,0]: cosine distance = 0
2184
        // [1,0] vs [0,1]: cosine distance = 1 (orthogonal)
2185
        // [1,0] vs [1,1]: cosine distance = 1 - 1/√2 ≈ 0.293
2186
        // WHERE distance < 0.5 → id=1 and id=3 (2 rows).
2187
        let mut db = seed_vector_docs();
1✔
2188
        let resp = process_command(
2189
            "SELECT * FROM docs WHERE vec_distance_cosine(e, [1.0, 0.0]) < 0.5;",
2190
            &mut db,
2191
        )
2192
        .expect("select");
UNCOV
2193
        assert!(
×
2194
            resp.contains("2 rows returned"),
2✔
2195
            "expected 2 rows, got: {resp}"
2196
        );
2197
    }
2198

2199
    #[test]
2200
    fn vec_distance_dot_negated() {
3✔
2201
        // [1,0]·[1,0] = 1 → -1
2202
        // [1,0]·[0,1] = 0 → 0
2203
        // [1,0]·[1,1] = 1 → -1
2204
        // WHERE -dot < 0 (i.e. dot > 0) → id=1 and id=3 (2 rows).
2205
        let mut db = seed_vector_docs();
1✔
2206
        let resp = process_command(
2207
            "SELECT * FROM docs WHERE vec_distance_dot(e, [1.0, 0.0]) < 0.0;",
2208
            &mut db,
2209
        )
2210
        .expect("select");
UNCOV
2211
        assert!(
×
2212
            resp.contains("2 rows returned"),
2✔
2213
            "expected 2 rows, got: {resp}"
2214
        );
2215
    }
2216

2217
    #[test]
2218
    fn knn_via_order_by_distance_limit() {
3✔
2219
        // Classic KNN shape: ORDER BY distance LIMIT k.
2220
        // Distances from [1,0]: id=1=0, id=3=1, id=2=√2.
2221
        // LIMIT 2 should return id=1 then id=3 in that order.
2222
        let mut db = seed_vector_docs();
1✔
2223
        let resp = process_command(
2224
            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [1.0, 0.0]) ASC LIMIT 2;",
2225
            &mut db,
2226
        )
2227
        .expect("select");
UNCOV
2228
        assert!(
×
2229
            resp.contains("2 rows returned"),
2✔
2230
            "expected 2 rows, got: {resp}"
2231
        );
2232
    }
2233

2234
    #[test]
2235
    fn distance_function_dim_mismatch_errors() {
3✔
2236
        // 2-dim column queried with a 3-dim probe → clean error.
2237
        let mut db = seed_vector_docs();
1✔
2238
        let err = process_command(
2239
            "SELECT * FROM docs WHERE vec_distance_l2(e, [1.0, 0.0, 0.0]) < 1.0;",
2240
            &mut db,
2241
        )
2242
        .unwrap_err();
2243
        let msg = format!("{err}");
2✔
UNCOV
2244
        assert!(
×
2245
            msg.to_lowercase().contains("dimension")
2✔
2246
                && msg.contains("lhs=2")
1✔
2247
                && msg.contains("rhs=3"),
1✔
2248
            "expected dim mismatch error, got: {msg}"
2249
        );
2250
    }
2251

2252
    #[test]
2253
    fn unknown_function_errors_with_name() {
3✔
2254
        // Use the function in WHERE, not projection — the projection
2255
        // parser still requires bare column references; function calls
2256
        // there are a future enhancement (with `AS alias` support).
2257
        let mut db = seed_vector_docs();
1✔
2258
        let err = process_command(
2259
            "SELECT * FROM docs WHERE vec_does_not_exist(e, [1.0, 0.0]) < 1.0;",
2260
            &mut db,
2261
        )
2262
        .unwrap_err();
2263
        let msg = format!("{err}");
2✔
UNCOV
2264
        assert!(
×
2265
            msg.contains("vec_does_not_exist"),
2✔
2266
            "expected error mentioning function name, got: {msg}"
2267
        );
2268
    }
2269

2270
    // -----------------------------------------------------------------
2271
    // Phase 7e — JSON column type + path-extraction functions
2272
    // -----------------------------------------------------------------
2273

2274
    fn seed_json_table() -> Database {
1✔
2275
        let mut db = Database::new("tempdb".to_string());
1✔
2276
        process_command(
2277
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, payload JSON);",
2278
            &mut db,
2279
        )
2280
        .expect("create json table");
2281
        db
1✔
2282
    }
2283

2284
    #[test]
2285
    fn json_column_round_trip_primitive_values() {
3✔
2286
        let mut db = seed_json_table();
1✔
2287
        process_command(
2288
            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice", "age": 30}');"#,
2289
            &mut db,
2290
        )
2291
        .expect("insert json");
2292
        let docs = db.get_table("docs".to_string()).unwrap();
1✔
2293
        let rowids = docs.rowids();
1✔
2294
        assert_eq!(rowids.len(), 1);
2✔
2295
        // Stored verbatim as Text underneath.
2296
        match docs.get_value("payload", rowids[0]) {
1✔
2297
            Some(Value::Text(s)) => {
1✔
2298
                assert!(s.contains("alice"), "expected JSON text to round-trip: {s}");
2✔
2299
            }
UNCOV
2300
            other => panic!("expected Value::Text holding JSON, got {other:?}"),
×
2301
        }
2302
    }
2303

2304
    #[test]
2305
    fn json_insert_rejects_invalid_json() {
3✔
2306
        let mut db = seed_json_table();
1✔
2307
        let err = process_command(
2308
            "INSERT INTO docs (payload) VALUES ('not-valid-json{');",
2309
            &mut db,
2310
        )
2311
        .unwrap_err();
2312
        let msg = format!("{err}").to_lowercase();
2✔
UNCOV
2313
        assert!(
×
2314
            msg.contains("json") && msg.contains("payload"),
2✔
2315
            "expected JSON validation error mentioning column, got: {msg}"
2316
        );
2317
    }
2318

2319
    #[test]
2320
    fn json_extract_object_field() {
3✔
2321
        let mut db = seed_json_table();
1✔
2322
        process_command(
2323
            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice", "age": 30}');"#,
2324
            &mut db,
2325
        )
2326
        .unwrap();
2327
        // We don't have function calls in projection (yet), so test
2328
        // the function via WHERE.
2329
        let resp = process_command(
2330
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.name') = 'alice';"#,
2331
            &mut db,
2332
        )
2333
        .expect("select via json_extract");
2334
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2335

2336
        let resp = process_command(
2337
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.age') = 30;"#,
2338
            &mut db,
2339
        )
2340
        .expect("select via numeric json_extract");
2341
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2342
    }
2343

2344
    #[test]
2345
    fn json_extract_array_index_and_nested() {
3✔
2346
        let mut db = seed_json_table();
1✔
2347
        process_command(
2348
            r#"INSERT INTO docs (payload) VALUES ('{"tags": ["rust", "sql", "vectors"], "meta": {"author": "joao"}}');"#,
2349
            &mut db,
2350
        )
2351
        .unwrap();
2352
        let resp = process_command(
2353
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.tags[0]') = 'rust';"#,
2354
            &mut db,
2355
        )
2356
        .expect("select via array index");
2357
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2358

2359
        let resp = process_command(
2360
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.meta.author') = 'joao';"#,
2361
            &mut db,
2362
        )
2363
        .expect("select via nested object");
2364
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2365
    }
2366

2367
    #[test]
2368
    fn json_extract_missing_path_returns_null() {
3✔
2369
        let mut db = seed_json_table();
1✔
2370
        process_command(
2371
            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice"}');"#,
2372
            &mut db,
2373
        )
2374
        .unwrap();
2375
        // Missing key under WHERE returns NULL → predicate is false →
2376
        // 0 rows returned. (Standard SQL three-valued logic.)
2377
        let resp = process_command(
2378
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.missing') = 'something';"#,
2379
            &mut db,
2380
        )
2381
        .expect("select with missing path");
2382
        assert!(resp.contains("0 rows returned"), "got: {resp}");
2✔
2383
    }
2384

2385
    #[test]
2386
    fn json_extract_malformed_path_errors() {
3✔
2387
        let mut db = seed_json_table();
1✔
2388
        process_command(
2389
            r#"INSERT INTO docs (payload) VALUES ('{"a": 1}');"#,
2390
            &mut db,
2391
        )
2392
        .unwrap();
2393
        // Path doesn't start with '$' — syntax error.
2394
        let err = process_command(
2395
            r#"SELECT id FROM docs WHERE json_extract(payload, 'a.b') = 1;"#,
2396
            &mut db,
2397
        )
2398
        .unwrap_err();
2399
        assert!(format!("{err}").contains("'$'"));
2✔
2400
    }
2401

2402
    #[test]
2403
    fn json_array_length_on_array() {
3✔
2404
        // Note: json_array_length used in WHERE clause where it can be
2405
        // compared; that exercises the function dispatch end-to-end.
2406
        let mut db = seed_json_table();
1✔
2407
        process_command(
2408
            r#"INSERT INTO docs (payload) VALUES ('{"tags": ["a", "b", "c"]}');"#,
2409
            &mut db,
2410
        )
2411
        .unwrap();
2412
        let resp = process_command(
2413
            r#"SELECT id FROM docs WHERE json_array_length(payload, '$.tags') = 3;"#,
2414
            &mut db,
2415
        )
2416
        .expect("select via array_length");
2417
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2418
    }
2419

2420
    #[test]
2421
    fn json_array_length_on_non_array_errors() {
3✔
2422
        let mut db = seed_json_table();
1✔
2423
        process_command(
2424
            r#"INSERT INTO docs (payload) VALUES ('{"tags": "not-an-array"}');"#,
2425
            &mut db,
2426
        )
2427
        .unwrap();
2428
        let err = process_command(
2429
            r#"SELECT id FROM docs WHERE json_array_length(payload, '$.tags') = 1;"#,
2430
            &mut db,
2431
        )
2432
        .unwrap_err();
2433
        let msg = format!("{err}").to_lowercase();
2✔
UNCOV
2434
        assert!(
×
2435
            msg.contains("non-array"),
1✔
2436
            "expected non-array error, got: {msg}"
2437
        );
2438
    }
2439

2440
    #[test]
2441
    fn json_type_recognizes_each_kind() {
3✔
2442
        let mut db = seed_json_table();
1✔
2443
        process_command(
2444
            r#"INSERT INTO docs (payload) VALUES ('{"o": {}, "a": [], "s": "x", "i": 1, "f": 1.5, "t": true, "n": null}');"#,
2445
            &mut db,
2446
        )
2447
        .unwrap();
2448
        let cases = &[
1✔
2449
            ("$.o", "object"),
2450
            ("$.a", "array"),
2451
            ("$.s", "text"),
2452
            ("$.i", "integer"),
2453
            ("$.f", "real"),
2454
            ("$.t", "true"),
2455
            ("$.n", "null"),
2456
        ];
2457
        for (path, expected_type) in cases {
1✔
2458
            let sql = format!(
2✔
2459
                "SELECT id FROM docs WHERE json_type(payload, '{path}') = '{expected_type}';"
2460
            );
2461
            let resp =
2✔
2462
                process_command(&sql, &mut db).unwrap_or_else(|e| panic!("path {path}: {e}"));
UNCOV
2463
            assert!(
×
2464
                resp.contains("1 row returned"),
2✔
2465
                "path {path} expected type {expected_type}; got response: {resp}"
2466
            );
2467
        }
2468
    }
2469

2470
    #[test]
2471
    fn update_on_json_column_revalidates() {
3✔
2472
        let mut db = seed_json_table();
1✔
2473
        process_command(
2474
            r#"INSERT INTO docs (payload) VALUES ('{"a": 1}');"#,
2475
            &mut db,
2476
        )
2477
        .unwrap();
2478
        // Valid JSON update succeeds.
2479
        process_command(
2480
            r#"UPDATE docs SET payload = '{"a": 2, "b": 3}' WHERE id = 1;"#,
2481
            &mut db,
2482
        )
2483
        .expect("valid JSON UPDATE");
2484
        // Invalid JSON in UPDATE is rejected with the same shape of
2485
        // error as INSERT.
2486
        let err = process_command(
2487
            r#"UPDATE docs SET payload = 'not-json{' WHERE id = 1;"#,
2488
            &mut db,
2489
        )
2490
        .unwrap_err();
2491
        let msg = format!("{err}").to_lowercase();
2✔
UNCOV
2492
        assert!(
×
2493
            msg.contains("json") && msg.contains("payload"),
2✔
2494
            "got: {msg}"
2495
        );
2496
    }
2497

2498
    // -------------------------------------------------------------------
2499
    // DEFAULT clause on CREATE TABLE columns
2500
    // -------------------------------------------------------------------
2501

2502
    #[test]
2503
    fn default_literal_int_applies_when_column_omitted() {
3✔
2504
        let mut db = Database::new("t".to_string());
1✔
2505
        process_command(
2506
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER DEFAULT 42);",
2507
            &mut db,
2508
        )
2509
        .unwrap();
2510
        process_command("INSERT INTO t (id) VALUES (1);", &mut db).unwrap();
1✔
2511

2512
        let table = db.get_table("t".to_string()).unwrap();
1✔
2513
        assert_eq!(table.get_value("n", 1), Some(Value::Integer(42)));
1✔
2514
    }
2515

2516
    #[test]
2517
    fn default_literal_text_applies_when_column_omitted() {
3✔
2518
        let mut db = Database::new("t".to_string());
1✔
2519
        process_command(
2520
            "CREATE TABLE users (id INTEGER PRIMARY KEY, status TEXT DEFAULT 'active');",
2521
            &mut db,
2522
        )
2523
        .unwrap();
2524
        process_command("INSERT INTO users (id) VALUES (1);", &mut db).unwrap();
1✔
2525

2526
        let table = db.get_table("users".to_string()).unwrap();
1✔
2527
        assert_eq!(
1✔
2528
            table.get_value("status", 1),
1✔
2529
            Some(Value::Text("active".to_string()))
2✔
2530
        );
2531
    }
2532

2533
    #[test]
2534
    fn default_literal_real_negative_applies_when_column_omitted() {
3✔
2535
        // `DEFAULT -1.5` arrives as a UnaryOp(Minus, Number) — exercise that path.
2536
        let mut db = Database::new("t".to_string());
1✔
2537
        process_command(
2538
            "CREATE TABLE t (id INTEGER PRIMARY KEY, score REAL DEFAULT -1.5);",
2539
            &mut db,
2540
        )
2541
        .unwrap();
2542
        process_command("INSERT INTO t (id) VALUES (1);", &mut db).unwrap();
1✔
2543

2544
        let table = db.get_table("t".to_string()).unwrap();
1✔
2545
        assert_eq!(table.get_value("score", 1), Some(Value::Real(-1.5)));
1✔
2546
    }
2547

2548
    #[test]
2549
    fn default_with_type_mismatch_errors_at_create_time() {
3✔
2550
        let mut db = Database::new("t".to_string());
1✔
2551
        let result = process_command(
2552
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER DEFAULT 'oops');",
2553
            &mut db,
2554
        );
2555
        let err = result.expect_err("text default on INTEGER column should be rejected");
1✔
2556
        let msg = format!("{err}").to_lowercase();
2✔
2557
        assert!(msg.contains("default"), "got: {msg}");
1✔
2558
    }
2559

2560
    #[test]
2561
    fn default_for_json_column_must_be_valid_json() {
3✔
2562
        // ADD COLUMN ... JSON DEFAULT 'not-json' would otherwise backfill
2563
        // every existing row with invalid JSON (insert_row's validation
2564
        // is bypassed for the backfill path).
2565
        let mut db = Database::new("t".to_string());
1✔
2566
        let err = process_command(
2567
            "CREATE TABLE t (id INTEGER PRIMARY KEY, doc JSON DEFAULT 'not-json{');",
2568
            &mut db,
2569
        )
2570
        .unwrap_err();
UNCOV
2571
        assert!(
×
2572
            format!("{err}").to_lowercase().contains("json"),
4✔
2573
            "got: {err}"
2574
        );
2575

2576
        // Valid JSON DEFAULT works.
2577
        process_command(
2578
            "CREATE TABLE t2 (id INTEGER PRIMARY KEY, doc JSON DEFAULT '{\"k\":1}');",
2579
            &mut db,
2580
        )
2581
        .expect("valid JSON DEFAULT should be accepted");
2582
    }
2583

2584
    #[test]
2585
    fn default_with_non_literal_expression_errors_at_create_time() {
3✔
2586
        let mut db = Database::new("t".to_string());
1✔
2587
        // Function-call DEFAULT (e.g. CURRENT_TIMESTAMP) → rejected; we only
2588
        // accept literal expressions for now.
2589
        let result = process_command(
2590
            "CREATE TABLE t (id INTEGER PRIMARY KEY, ts TEXT DEFAULT CURRENT_TIMESTAMP);",
2591
            &mut db,
2592
        );
2593
        let err = result.expect_err("non-literal DEFAULT should be rejected");
1✔
2594
        let msg = format!("{err}").to_lowercase();
2✔
UNCOV
2595
        assert!(
×
2596
            msg.contains("default") && msg.contains("literal"),
2✔
2597
            "got: {msg}"
2598
        );
2599
    }
2600

2601
    #[test]
2602
    fn default_null_is_accepted_at_create_time() {
3✔
2603
        // `DEFAULT NULL` is a no-op equivalent to no DEFAULT clause; the
2604
        // important thing is that CREATE TABLE accepts it without error
2605
        // (some DDL exporters emit `DEFAULT NULL` redundantly).
2606
        let mut db = Database::new("t".to_string());
1✔
2607
        process_command(
2608
            "CREATE TABLE t (id INTEGER PRIMARY KEY, note TEXT DEFAULT NULL);",
2609
            &mut db,
2610
        )
2611
        .expect("CREATE TABLE with DEFAULT NULL should be accepted");
2612
        let table = db.get_table("t".to_string()).unwrap();
1✔
2613
        let note = table
3✔
2614
            .columns
2615
            .iter()
2616
            .find(|c| c.column_name == "note")
3✔
2617
            .unwrap();
2618
        assert_eq!(note.default, Some(Value::Null));
1✔
2619
    }
2620

2621
    // -------------------------------------------------------------------
2622
    // DROP TABLE / DROP INDEX
2623
    // -------------------------------------------------------------------
2624

2625
    #[test]
2626
    fn drop_table_basic() {
3✔
2627
        let mut db = seed_users_table();
1✔
2628
        let response = process_command("DROP TABLE users;", &mut db).expect("drop table");
2✔
2629
        assert!(response.contains("1 table dropped"));
2✔
2630
        assert!(!db.contains_table("users".to_string()));
1✔
2631
    }
2632

2633
    #[test]
2634
    fn drop_table_if_exists_noop_on_missing() {
3✔
2635
        let mut db = Database::new("t".to_string());
1✔
2636
        let response =
2✔
2637
            process_command("DROP TABLE IF EXISTS missing;", &mut db).expect("drop if exists");
2638
        assert!(response.contains("0 tables dropped"));
2✔
2639
    }
2640

2641
    #[test]
2642
    fn drop_table_missing_errors_without_if_exists() {
3✔
2643
        let mut db = Database::new("t".to_string());
1✔
2644
        let err = process_command("DROP TABLE missing;", &mut db).unwrap_err();
2✔
2645
        assert!(format!("{err}").contains("does not exist"), "got: {err}");
2✔
2646
    }
2647

2648
    #[test]
2649
    fn drop_table_reserved_name_errors() {
3✔
2650
        let mut db = Database::new("t".to_string());
1✔
2651
        let err = process_command("DROP TABLE sqlrite_master;", &mut db).unwrap_err();
2✔
2652
        assert!(format!("{err}").contains("reserved"), "got: {err}");
2✔
2653
    }
2654

2655
    #[test]
2656
    fn drop_table_multi_target_rejected() {
3✔
2657
        let mut db = seed_users_table();
1✔
2658
        process_command("CREATE TABLE other (id INTEGER PRIMARY KEY);", &mut db).unwrap();
2✔
2659
        // sqlparser accepts `DROP TABLE a, b` as one statement; we reject
2660
        // to keep error semantics simple (no partial-failure rollback).
2661
        let err = process_command("DROP TABLE users, other;", &mut db).unwrap_err();
1✔
2662
        assert!(format!("{err}").contains("single table"), "got: {err}");
2✔
2663
    }
2664

2665
    #[test]
2666
    fn drop_table_cascades_indexes_in_memory() {
3✔
2667
        let mut db = seed_users_table();
1✔
2668
        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2✔
2669
        // PK auto-index + UNIQUE-on-name auto-index + the explicit one.
2670
        let users = db.get_table("users".to_string()).unwrap();
1✔
UNCOV
2671
        assert!(
×
2672
            users
3✔
2673
                .secondary_indexes
2674
                .iter()
1✔
2675
                .any(|i| i.name == "users_age_idx")
3✔
2676
        );
2677

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

2680
        // After DROP TABLE, no other table should claim the dropped indexes.
2681
        for table in db.tables.values() {
1✔
UNCOV
2682
            assert!(
×
UNCOV
2683
                !table
×
2684
                    .secondary_indexes
UNCOV
2685
                    .iter()
×
UNCOV
2686
                    .any(|i| i.name.contains("users")),
×
2687
                "dropped table's indexes should not survive on any other table"
2688
            );
2689
        }
2690
    }
2691

2692
    #[test]
2693
    fn drop_index_explicit_basic() {
3✔
2694
        let mut db = seed_users_table();
1✔
2695
        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2✔
2696
        let response = process_command("DROP INDEX users_age_idx;", &mut db).expect("drop index");
1✔
2697
        assert!(response.contains("1 index dropped"));
2✔
2698

2699
        let users = db.get_table("users".to_string()).unwrap();
1✔
2700
        assert!(users.index_by_name("users_age_idx").is_none());
1✔
2701
    }
2702

2703
    #[test]
2704
    fn drop_index_refuses_auto_index() {
3✔
2705
        let mut db = seed_users_table();
1✔
2706
        // `users` was created with `id INTEGER PRIMARY KEY` → auto-index
2707
        // named `sqlrite_autoindex_users_id`.
2708
        let err = process_command("DROP INDEX sqlrite_autoindex_users_id;", &mut db).unwrap_err();
2✔
2709
        assert!(format!("{err}").contains("auto-created"), "got: {err}");
2✔
2710
    }
2711

2712
    #[test]
2713
    fn drop_index_if_exists_noop_on_missing() {
3✔
2714
        let mut db = Database::new("t".to_string());
1✔
2715
        let response =
2✔
2716
            process_command("DROP INDEX IF EXISTS nope;", &mut db).expect("drop index if exists");
2717
        assert!(response.contains("0 indexes dropped"));
2✔
2718
    }
2719

2720
    #[test]
2721
    fn drop_index_missing_errors_without_if_exists() {
3✔
2722
        let mut db = Database::new("t".to_string());
1✔
2723
        let err = process_command("DROP INDEX nope;", &mut db).unwrap_err();
2✔
2724
        assert!(format!("{err}").contains("does not exist"), "got: {err}");
2✔
2725
    }
2726

2727
    #[test]
2728
    fn drop_statements_rejected_on_readonly_db() {
3✔
2729
        use crate::sql::pager::{open_database_read_only, save_database};
2730

2731
        let mut seed = Database::new("t".to_string());
1✔
2732
        process_command(
2733
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
2734
            &mut seed,
2735
        )
2736
        .unwrap();
2737
        process_command("CREATE INDEX notes_body ON notes (body);", &mut seed).unwrap();
1✔
2738
        let path = {
2739
            let mut p = std::env::temp_dir();
1✔
2740
            let pid = std::process::id();
2✔
2741
            let nanos = std::time::SystemTime::now()
2✔
2742
                .duration_since(std::time::UNIX_EPOCH)
1✔
2743
                .map(|d| d.as_nanos())
3✔
2744
                .unwrap_or(0);
2745
            p.push(format!("sqlrite-drop-ro-{pid}-{nanos}.sqlrite"));
1✔
2746
            p
1✔
2747
        };
2748
        save_database(&mut seed, &path).unwrap();
2✔
2749
        drop(seed);
1✔
2750

2751
        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1✔
2752
        for stmt in ["DROP TABLE notes;", "DROP INDEX notes_body;"] {
3✔
2753
            let err = process_command(stmt, &mut ro).unwrap_err();
2✔
UNCOV
2754
            assert!(
×
2755
                format!("{err}").contains("read-only"),
3✔
2756
                "{stmt:?} should surface read-only error, got: {err}"
2757
            );
2758
        }
2759

2760
        let _ = std::fs::remove_file(&path);
1✔
2761
        let mut wal = path.as_os_str().to_owned();
1✔
2762
        wal.push("-wal");
1✔
2763
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
2764
    }
2765

2766
    // -------------------------------------------------------------------
2767
    // ALTER TABLE — RENAME TO / RENAME COLUMN / ADD COLUMN / DROP COLUMN
2768
    // -------------------------------------------------------------------
2769

2770
    #[test]
2771
    fn alter_rename_table_basic() {
3✔
2772
        let mut db = seed_users_table();
1✔
2773
        process_command("ALTER TABLE users RENAME TO members;", &mut db).expect("rename table");
2✔
2774
        assert!(!db.contains_table("users".to_string()));
1✔
2775
        assert!(db.contains_table("members".to_string()));
2✔
2776
        // Data still queryable under the new name.
2777
        let response = process_command("SELECT * FROM members;", &mut db).expect("select");
1✔
2778
        assert!(response.contains("3 rows returned"));
2✔
2779
    }
2780

2781
    #[test]
2782
    fn alter_rename_table_renames_auto_indexes() {
3✔
2783
        // Use a fresh table with both PK and a UNIQUE column so we
2784
        // exercise both auto-index renames in one shot.
2785
        let mut db = Database::new("t".to_string());
1✔
2786
        process_command(
2787
            "CREATE TABLE accounts (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
2788
            &mut db,
2789
        )
2790
        .unwrap();
2791
        {
2792
            let accounts = db.get_table("accounts".to_string()).unwrap();
1✔
UNCOV
2793
            assert!(
×
2794
                accounts
2✔
2795
                    .index_by_name("sqlrite_autoindex_accounts_id")
1✔
2796
                    .is_some()
1✔
2797
            );
UNCOV
2798
            assert!(
×
2799
                accounts
2✔
2800
                    .index_by_name("sqlrite_autoindex_accounts_email")
1✔
2801
                    .is_some()
1✔
2802
            );
2803
        }
2804
        process_command("ALTER TABLE accounts RENAME TO members;", &mut db).expect("rename");
1✔
2805
        let members = db.get_table("members".to_string()).unwrap();
1✔
UNCOV
2806
        assert!(
×
2807
            members
2✔
2808
                .index_by_name("sqlrite_autoindex_members_id")
1✔
2809
                .is_some(),
1✔
2810
            "PK auto-index should be renamed to match new table"
2811
        );
UNCOV
2812
        assert!(
×
2813
            members
2✔
2814
                .index_by_name("sqlrite_autoindex_members_email")
1✔
2815
                .is_some()
1✔
2816
        );
2817
        // The old-named auto-indexes should be gone.
UNCOV
2818
        assert!(
×
2819
            members
2✔
2820
                .index_by_name("sqlrite_autoindex_accounts_id")
1✔
2821
                .is_none()
1✔
2822
        );
2823
        // table_name field on each index should also reflect the rename.
2824
        for idx in &members.secondary_indexes {
1✔
2825
            assert_eq!(idx.table_name, "members");
2✔
2826
        }
2827
    }
2828

2829
    #[test]
2830
    fn alter_rename_table_to_existing_errors() {
3✔
2831
        let mut db = seed_users_table();
1✔
2832
        process_command("CREATE TABLE other (id INTEGER PRIMARY KEY);", &mut db).unwrap();
2✔
2833
        let err = process_command("ALTER TABLE users RENAME TO other;", &mut db).unwrap_err();
1✔
2834
        assert!(format!("{err}").contains("already exists"), "got: {err}");
2✔
2835
        // Both tables still present.
2836
        assert!(db.contains_table("users".to_string()));
1✔
2837
        assert!(db.contains_table("other".to_string()));
1✔
2838
    }
2839

2840
    #[test]
2841
    fn alter_rename_table_to_reserved_name_errors() {
3✔
2842
        let mut db = seed_users_table();
1✔
2843
        let err =
2✔
2844
            process_command("ALTER TABLE users RENAME TO sqlrite_master;", &mut db).unwrap_err();
2845
        assert!(format!("{err}").contains("reserved"), "got: {err}");
2✔
2846
    }
2847

2848
    #[test]
2849
    fn alter_rename_column_basic() {
3✔
2850
        let mut db = seed_users_table();
1✔
2851
        process_command(
2852
            "ALTER TABLE users RENAME COLUMN name TO full_name;",
2853
            &mut db,
2854
        )
2855
        .expect("rename column");
2856

2857
        let users = db.get_table("users".to_string()).unwrap();
1✔
2858
        assert!(users.contains_column("full_name".to_string()));
1✔
2859
        assert!(!users.contains_column("name".to_string()));
1✔
2860

2861
        // Existing data is queryable under the new column name and value
2862
        // is preserved at the same rowid.
2863
        let bob_rowid = users
1✔
2864
            .rowids()
2865
            .into_iter()
2866
            .find(|r| users.get_value("full_name", *r) == Some(Value::Text("bob".to_string())))
3✔
2867
            .expect("bob row should be findable under the new column name");
2868
        assert_eq!(
1✔
2869
            users.get_value("full_name", bob_rowid),
1✔
2870
            Some(Value::Text("bob".to_string()))
2✔
2871
        );
2872
    }
2873

2874
    #[test]
2875
    fn alter_rename_column_collision_errors() {
3✔
2876
        let mut db = seed_users_table();
1✔
2877
        let err =
2✔
2878
            process_command("ALTER TABLE users RENAME COLUMN name TO age;", &mut db).unwrap_err();
2879
        assert!(format!("{err}").contains("already exists"), "got: {err}");
2✔
2880
    }
2881

2882
    #[test]
2883
    fn alter_rename_column_updates_indexes() {
3✔
2884
        // `accounts.email` is UNIQUE → has a renameable auto-index.
2885
        let mut db = Database::new("t".to_string());
1✔
2886
        process_command(
2887
            "CREATE TABLE accounts (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
2888
            &mut db,
2889
        )
2890
        .unwrap();
2891
        process_command(
2892
            "ALTER TABLE accounts RENAME COLUMN email TO contact;",
2893
            &mut db,
2894
        )
2895
        .unwrap();
2896
        let accounts = db.get_table("accounts".to_string()).unwrap();
1✔
UNCOV
2897
        assert!(
×
2898
            accounts
2✔
2899
                .index_by_name("sqlrite_autoindex_accounts_contact")
1✔
2900
                .is_some()
1✔
2901
        );
UNCOV
2902
        assert!(
×
2903
            accounts
2✔
2904
                .index_by_name("sqlrite_autoindex_accounts_email")
1✔
2905
                .is_none()
1✔
2906
        );
2907
    }
2908

2909
    #[test]
2910
    fn alter_add_column_basic() {
3✔
2911
        let mut db = seed_users_table();
1✔
2912
        process_command("ALTER TABLE users ADD COLUMN nickname TEXT;", &mut db)
1✔
2913
            .expect("add column");
2914
        let users = db.get_table("users".to_string()).unwrap();
1✔
2915
        assert!(users.contains_column("nickname".to_string()));
1✔
2916
        // Existing rows read NULL for the new column (no default given).
2917
        let any_rowid = *users.rowids().first().expect("seed has rows");
1✔
2918
        assert_eq!(users.get_value("nickname", any_rowid), None);
1✔
2919

2920
        // A new INSERT supplying the new column works.
2921
        process_command(
2922
            "INSERT INTO users (name, age, nickname) VALUES ('dan', 22, 'd');",
2923
            &mut db,
2924
        )
2925
        .expect("insert with new col");
2926
        let users = db.get_table("users".to_string()).unwrap();
1✔
2927
        let dan_rowid = users
1✔
2928
            .rowids()
2929
            .into_iter()
2930
            .find(|r| users.get_value("name", *r) == Some(Value::Text("dan".to_string())))
3✔
2931
            .unwrap();
2932
        assert_eq!(
1✔
2933
            users.get_value("nickname", dan_rowid),
1✔
2934
            Some(Value::Text("d".to_string()))
2✔
2935
        );
2936
    }
2937

2938
    #[test]
2939
    fn alter_add_column_with_default_backfills_existing_rows() {
3✔
2940
        let mut db = seed_users_table();
1✔
2941
        process_command(
2942
            "ALTER TABLE users ADD COLUMN status TEXT DEFAULT 'active';",
2943
            &mut db,
2944
        )
2945
        .expect("add column with default");
2946
        let users = db.get_table("users".to_string()).unwrap();
1✔
2947
        for rowid in users.rowids() {
2✔
2948
            assert_eq!(
1✔
2949
                users.get_value("status", rowid),
1✔
2950
                Some(Value::Text("active".to_string())),
2✔
2951
                "rowid {rowid} should have been backfilled with the default"
2952
            );
2953
        }
2954
    }
2955

2956
    #[test]
2957
    fn alter_add_column_not_null_with_default_works_on_nonempty_table() {
3✔
2958
        let mut db = seed_users_table();
1✔
2959
        process_command(
2960
            "ALTER TABLE users ADD COLUMN score INTEGER NOT NULL DEFAULT 0;",
2961
            &mut db,
2962
        )
2963
        .expect("NOT NULL ADD with DEFAULT should succeed even with existing rows");
2964
        let users = db.get_table("users".to_string()).unwrap();
1✔
2965
        for rowid in users.rowids() {
2✔
2966
            assert_eq!(users.get_value("score", rowid), Some(Value::Integer(0)));
2✔
2967
        }
2968
    }
2969

2970
    #[test]
2971
    fn alter_add_column_not_null_without_default_errors_on_nonempty_table() {
3✔
2972
        let mut db = seed_users_table();
1✔
2973
        let err = process_command(
2974
            "ALTER TABLE users ADD COLUMN score INTEGER NOT NULL;",
2975
            &mut db,
2976
        )
2977
        .unwrap_err();
2978
        let msg = format!("{err}").to_lowercase();
2✔
UNCOV
2979
        assert!(
×
2980
            msg.contains("not null") && msg.contains("default"),
2✔
2981
            "got: {msg}"
2982
        );
2983
    }
2984

2985
    #[test]
2986
    fn alter_add_column_pk_rejected() {
3✔
2987
        let mut db = seed_users_table();
1✔
2988
        let err = process_command(
2989
            "ALTER TABLE users ADD COLUMN extra INTEGER PRIMARY KEY;",
2990
            &mut db,
2991
        )
2992
        .unwrap_err();
UNCOV
2993
        assert!(
×
2994
            format!("{err}").to_lowercase().contains("primary key"),
4✔
2995
            "got: {err}"
2996
        );
2997
    }
2998

2999
    #[test]
3000
    fn alter_add_column_unique_rejected() {
3✔
3001
        let mut db = seed_users_table();
1✔
3002
        let err = process_command(
3003
            "ALTER TABLE users ADD COLUMN extra INTEGER UNIQUE;",
3004
            &mut db,
3005
        )
3006
        .unwrap_err();
UNCOV
3007
        assert!(
×
3008
            format!("{err}").to_lowercase().contains("unique"),
4✔
3009
            "got: {err}"
3010
        );
3011
    }
3012

3013
    #[test]
3014
    fn alter_add_column_existing_name_errors() {
3✔
3015
        let mut db = seed_users_table();
1✔
3016
        let err =
2✔
3017
            process_command("ALTER TABLE users ADD COLUMN age INTEGER;", &mut db).unwrap_err();
3018
        assert!(format!("{err}").contains("already exists"), "got: {err}");
2✔
3019
    }
3020

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

3026
    #[test]
3027
    fn alter_drop_column_basic() {
3✔
3028
        let mut db = seed_users_table();
1✔
3029
        process_command("ALTER TABLE users DROP COLUMN age;", &mut db).expect("drop column");
2✔
3030
        let users = db.get_table("users".to_string()).unwrap();
1✔
3031
        assert!(!users.contains_column("age".to_string()));
1✔
3032
        // Other columns and rowids still intact.
3033
        assert!(users.contains_column("name".to_string()));
2✔
3034
        assert_eq!(users.rowids().len(), 3);
1✔
3035
    }
3036

3037
    #[test]
3038
    fn alter_drop_column_drops_dependent_indexes() {
3✔
3039
        let mut db = seed_users_table();
1✔
3040
        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2✔
3041
        process_command("ALTER TABLE users DROP COLUMN age;", &mut db).unwrap();
1✔
3042
        let users = db.get_table("users".to_string()).unwrap();
1✔
3043
        assert!(users.index_by_name("users_age_idx").is_none());
1✔
3044
    }
3045

3046
    #[test]
3047
    fn alter_drop_column_pk_errors() {
3✔
3048
        let mut db = seed_users_table();
1✔
3049
        let err = process_command("ALTER TABLE users DROP COLUMN id;", &mut db).unwrap_err();
2✔
UNCOV
3050
        assert!(
×
3051
            format!("{err}").to_lowercase().contains("primary key"),
4✔
3052
            "got: {err}"
3053
        );
3054
    }
3055

3056
    #[test]
3057
    fn alter_drop_column_only_column_errors() {
3✔
3058
        let mut db = Database::new("t".to_string());
1✔
3059
        process_command("CREATE TABLE solo (only_col TEXT);", &mut db).unwrap();
2✔
3060
        let err = process_command("ALTER TABLE solo DROP COLUMN only_col;", &mut db).unwrap_err();
1✔
UNCOV
3061
        assert!(
×
3062
            format!("{err}").to_lowercase().contains("only column"),
4✔
3063
            "got: {err}"
3064
        );
3065
    }
3066

3067
    #[test]
3068
    fn alter_unknown_table_errors_without_if_exists() {
3✔
3069
        let mut db = Database::new("t".to_string());
1✔
3070
        let err = process_command("ALTER TABLE missing RENAME TO other;", &mut db).unwrap_err();
2✔
3071
        assert!(format!("{err}").contains("does not exist"), "got: {err}");
2✔
3072
    }
3073

3074
    #[test]
3075
    fn alter_unknown_table_if_exists_noop() {
3✔
3076
        let mut db = Database::new("t".to_string());
1✔
3077
        let response = process_command("ALTER TABLE IF EXISTS missing RENAME TO other;", &mut db)
1✔
3078
            .expect("IF EXISTS makes missing-table ALTER a no-op");
3079
        assert!(response.contains("no-op"));
2✔
3080
    }
3081

3082
    #[test]
3083
    fn drop_table_inside_transaction_rolls_back() {
3✔
3084
        // Exercises Database::deep_clone snapshot path with DROP TABLE.
3085
        // A wholesale tables-HashMap restore on ROLLBACK should resurrect
3086
        // the dropped table — including its data and dependent indexes.
3087
        let mut db = seed_users_table();
1✔
3088
        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2✔
3089
        process_command("BEGIN;", &mut db).unwrap();
1✔
3090
        process_command("DROP TABLE users;", &mut db).unwrap();
1✔
3091
        assert!(!db.contains_table("users".to_string()));
1✔
3092
        process_command("ROLLBACK;", &mut db).unwrap();
2✔
3093
        assert!(db.contains_table("users".to_string()));
1✔
3094
        let users = db.get_table("users".to_string()).unwrap();
1✔
3095
        assert_eq!(users.rowids().len(), 3);
1✔
3096
        assert!(users.index_by_name("users_age_idx").is_some());
1✔
3097
    }
3098

3099
    #[test]
3100
    fn alter_inside_transaction_rolls_back() {
3✔
3101
        let mut db = seed_users_table();
1✔
3102
        process_command("BEGIN;", &mut db).unwrap();
2✔
3103
        process_command(
3104
            "ALTER TABLE users ADD COLUMN status TEXT DEFAULT 'active';",
3105
            &mut db,
3106
        )
3107
        .unwrap();
3108
        // Confirm in-flight visibility.
UNCOV
3109
        assert!(
×
3110
            db.get_table("users".to_string())
2✔
3111
                .unwrap()
1✔
3112
                .contains_column("status".to_string())
1✔
3113
        );
3114
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
3115
        // Snapshot restore should erase the ALTER.
3116
        assert!(
×
3117
            !db.get_table("users".to_string())
2✔
3118
                .unwrap()
1✔
3119
                .contains_column("status".to_string())
1✔
3120
        );
3121
    }
3122

3123
    #[test]
3124
    fn alter_rejected_on_readonly_db() {
3✔
3125
        use crate::sql::pager::{open_database_read_only, save_database};
3126

3127
        let mut seed = Database::new("t".to_string());
1✔
3128
        process_command(
3129
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
3130
            &mut seed,
3131
        )
3132
        .unwrap();
3133
        let path = {
3134
            let mut p = std::env::temp_dir();
1✔
3135
            let pid = std::process::id();
2✔
3136
            let nanos = std::time::SystemTime::now()
2✔
3137
                .duration_since(std::time::UNIX_EPOCH)
1✔
3138
                .map(|d| d.as_nanos())
3✔
3139
                .unwrap_or(0);
3140
            p.push(format!("sqlrite-alter-ro-{pid}-{nanos}.sqlrite"));
1✔
3141
            p
1✔
3142
        };
3143
        save_database(&mut seed, &path).unwrap();
2✔
3144
        drop(seed);
1✔
3145

3146
        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1✔
3147
        for stmt in [
3✔
3148
            "ALTER TABLE notes RENAME TO n2;",
3149
            "ALTER TABLE notes RENAME COLUMN body TO b;",
3150
            "ALTER TABLE notes ADD COLUMN extra TEXT;",
3151
            "ALTER TABLE notes DROP COLUMN body;",
3152
        ] {
3153
            let err = process_command(stmt, &mut ro).unwrap_err();
2✔
UNCOV
3154
            assert!(
×
3155
                format!("{err}").contains("read-only"),
3✔
3156
                "{stmt:?} should surface read-only error, got: {err}"
3157
            );
3158
        }
3159

3160
        let _ = std::fs::remove_file(&path);
1✔
3161
        let mut wal = path.as_os_str().to_owned();
1✔
3162
        wal.push("-wal");
1✔
3163
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
3164
    }
3165
}
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