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

joaoh82 / rust_sqlite / 25306044810

04 May 2026 07:14AM UTC coverage: 61.422% (+1.7%) from 59.771%
25306044810

push

github

web-flow
feat(ddl): DEFAULT clause, DROP TABLE/INDEX, ALTER TABLE (#86)

* feat(ddl): DEFAULT clause for CREATE TABLE columns

Honour `DEFAULT <literal>` on column definitions. Literal expressions
only — text, integer, real, boolean, NULL, and unary +/- on numerics.
Function calls and other non-literal expressions are rejected at
CREATE TABLE time so users see the limit upfront.

Default fires only when the column is omitted from INSERT (matches
SQLite — explicit NULL is preserved as NULL). Persists through save
and reopen via `table_to_create_sql` emitting the DEFAULT clause and
`parse_create_sql` propagating it back into Column.

Refactors `CreateQuery::new`'s per-column body into a free
`parse_one_column` helper so ALTER TABLE ADD COLUMN can reuse the
same column-shape parsing in a follow-up commit.

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

* feat(ddl): DROP TABLE and DROP INDEX

Mirror SQLite's DROP semantics on the in-memory engine:

- DROP TABLE [IF EXISTS] <name>; — single target, rejects the reserved
  catalog name `sqlrite_master`. The dropped table's
  secondary/HNSW/FTS indexes ride along with the Table struct.
- DROP INDEX [IF EXISTS] <name>; — single target. Walks every table
  looking across all three index families. Refuses to drop
  auto-indexes (`sqlrite_autoindex_*` from PK / UNIQUE columns) —
  same rule SQLite enforces.

The full-rebuild-on-save pager naturally cascades drops: the next
`save_database` call regenerates `sqlrite_master` from current state
and simply doesn't write rows for the dropped objects. Pages
previously occupied become orphans on disk (no free-list yet — file
size doesn't shrink until a future VACUUM lands).

Replaces the existing `process_command_unsupported_statement_test`
which used DROP TABLE as the canary; switched to CREATE VIEW since
DROP TABLE now executes.

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

* feat(ddl): ALTER TABLE — RENAME / ADD COLUMN / DROP COL... (continued)

649 of 766 new or added lines in 5 files covered. (84.73%)

1 existing line in 1 file now uncovered.

6991 of 11382 relevant lines covered (61.42%)

1.18 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

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

161
    // Statements that mutate state — trigger auto-save on success. Read-only
162
    // SELECTs skip the save entirely to avoid pointless file writes.
163
    let is_write_statement = matches!(
3✔
164
        &query,
2✔
165
        Statement::CreateTable(_)
166
            | Statement::CreateIndex(_)
167
            | Statement::Insert(_)
168
            | Statement::Update(_)
169
            | Statement::Delete(_)
170
            | Statement::Drop { .. }
171
            | Statement::AlterTable(_)
172
    );
173

174
    // Early-reject mutations on a read-only database before they touch
175
    // in-memory state. Phase 4e: without this, a user running INSERT
176
    // on a `--readonly` REPL would see the row appear in the printed
177
    // table, and then the auto-save would fail — leaving the in-memory
178
    // Database visibly diverged from disk.
179
    if is_write_statement && db.is_read_only() {
4✔
180
        return Err(SQLRiteError::General(
1✔
181
            "cannot execute: database is opened read-only".to_string(),
1✔
182
        ));
183
    }
184

185
    // Initialy only implementing some basic SQL Statements
186
    match query {
2✔
187
        Statement::CreateTable(_) => {
188
            let create_query = CreateQuery::new(&query);
2✔
189
            match create_query {
2✔
190
                Ok(payload) => {
2✔
191
                    let table_name = payload.table_name.clone();
2✔
192
                    if table_name == pager::MASTER_TABLE_NAME {
4✔
193
                        return Err(SQLRiteError::General(format!(
×
194
                            "'{}' is a reserved name used by the internal schema catalog",
195
                            pager::MASTER_TABLE_NAME
196
                        )));
197
                    }
198
                    // Checking if table already exists, after parsing CREATE TABLE query
199
                    match db.contains_table(table_name.to_string()) {
4✔
200
                        true => {
201
                            return Err(SQLRiteError::Internal(
×
202
                                "Cannot create, table already exists.".to_string(),
×
203
                            ));
204
                        }
205
                        false => {
206
                            let table = Table::new(payload);
4✔
207
                            // Note: we used to call `table.print_table_schema()` here
208
                            // for REPL convenience. Removed because it wrote
209
                            // directly to stdout, which corrupted any non-REPL
210
                            // protocol channel (most painfully the MCP server's
211
                            // JSON-RPC wire). The status line below is enough for
212
                            // the REPL; users who want to inspect the schema can
213
                            // run a follow-up describe / `.tables`-style command.
214
                            db.tables.insert(table_name.to_string(), table);
4✔
215
                            message = String::from("CREATE TABLE Statement executed.");
2✔
216
                        }
217
                    }
218
                }
219
                Err(err) => return Err(err),
1✔
220
            }
221
        }
222
        Statement::Insert(_) => {
223
            let insert_query = InsertQuery::new(&query);
2✔
224
            match insert_query {
2✔
225
                Ok(payload) => {
2✔
226
                    let table_name = payload.table_name;
2✔
227
                    let columns = payload.columns;
2✔
228
                    let values = payload.rows;
2✔
229

230
                    // println!("table_name = {:?}\n cols = {:?}\n vals = {:?}", table_name, columns, values);
231
                    // Checking if Table exists in Database
232
                    match db.contains_table(table_name.to_string()) {
4✔
233
                        true => {
234
                            let db_table = db.get_table_mut(table_name.to_string()).unwrap();
4✔
235
                            // Checking if columns on INSERT query exist on Table
236
                            match columns
6✔
237
                                .iter()
2✔
238
                                .all(|column| db_table.contains_column(column.to_string()))
6✔
239
                            {
240
                                true => {
241
                                    for value in &values {
4✔
242
                                        // Checking if number of columns in query are the same as number of values
243
                                        if columns.len() != value.len() {
4✔
244
                                            return Err(SQLRiteError::Internal(format!(
×
245
                                                "{} values for {} columns",
246
                                                value.len(),
×
247
                                                columns.len()
×
248
                                            )));
249
                                        }
250
                                        db_table
2✔
251
                                            .validate_unique_constraint(&columns, value)
2✔
252
                                            .map_err(|err| {
2✔
253
                                                SQLRiteError::Internal(format!(
×
254
                                                    "Unique key constraint violation: {err}"
255
                                                ))
256
                                            })?;
257
                                        db_table.insert_row(&columns, value)?;
2✔
258
                                    }
259
                                }
260
                                false => {
261
                                    return Err(SQLRiteError::Internal(
×
262
                                        "Cannot insert, some of the columns do not exist"
263
                                            .to_string(),
×
264
                                    ));
265
                                }
266
                            }
267
                            // Note: we used to call `db_table.print_table_data()`
268
                            // here, which dumped the *entire* table to stdout
269
                            // after every INSERT. Beyond corrupting non-REPL
270
                            // stdout channels, that's actively bad UX on any
271
                            // table with more than a few rows. Removed in the
272
                            // engine-stdout-pollution cleanup.
273
                        }
274
                        false => {
275
                            return Err(SQLRiteError::Internal("Table doesn't exist".to_string()));
2✔
276
                        }
277
                    }
278
                }
279
                Err(err) => return Err(err),
×
280
            }
281

282
            message = String::from("INSERT Statement executed.")
2✔
283
        }
284
        Statement::Query(_) => {
285
            let select_query = SelectQuery::new(&query)?;
3✔
286
            let (rendered_table, rows) = executor::execute_select(select_query, db)?;
3✔
287
            // Stash the rendered prettytable in the output so the REPL
288
            // (or any terminal-style consumer) can print it above the
289
            // status line. SDK / FFI / MCP callers ignore this field.
290
            // The previous implementation `print!("{rendered}")`-ed
291
            // directly to stdout, which broke every non-REPL embedder.
292
            rendered = Some(rendered_table);
1✔
293
            message = format!(
2✔
294
                "SELECT Statement executed. {rows} row{s} returned.",
295
                s = if rows == 1 { "" } else { "s" }
1✔
296
            );
297
        }
298
        Statement::Delete(_) => {
299
            let rows = executor::execute_delete(&query, db)?;
2✔
300
            message = format!(
1✔
301
                "DELETE Statement executed. {rows} row{s} deleted.",
302
                s = if rows == 1 { "" } else { "s" }
1✔
303
            );
304
        }
305
        Statement::Update(_) => {
306
            let rows = executor::execute_update(&query, db)?;
3✔
307
            message = format!(
1✔
308
                "UPDATE Statement executed. {rows} row{s} updated.",
309
                s = if rows == 1 { "" } else { "s" }
1✔
310
            );
311
        }
312
        Statement::CreateIndex(_) => {
313
            let name = executor::execute_create_index(&query, db)?;
3✔
314
            message = format!("CREATE INDEX '{name}' executed.");
2✔
315
        }
316
        Statement::Drop {
317
            object_type,
1✔
318
            if_exists,
1✔
319
            names,
1✔
320
            ..
321
        } => match object_type {
1✔
322
            ObjectType::Table => {
323
                let count = executor::execute_drop_table(&names, if_exists, db)?;
3✔
324
                let plural = if count == 1 { "table" } else { "tables" };
1✔
325
                message = format!("DROP TABLE Statement executed. {count} {plural} dropped.");
1✔
326
            }
327
            ObjectType::Index => {
328
                let count = executor::execute_drop_index(&names, if_exists, db)?;
3✔
329
                let plural = if count == 1 { "index" } else { "indexes" };
1✔
330
                message = format!("DROP INDEX Statement executed. {count} {plural} dropped.");
1✔
331
            }
NEW
332
            other => {
×
NEW
333
                return Err(SQLRiteError::NotImplemented(format!(
×
334
                    "DROP {other:?} is not supported (only TABLE and INDEX)"
335
                )));
336
            }
337
        },
338
        Statement::AlterTable(alter) => {
1✔
339
            message = executor::execute_alter_table(alter, db)?;
3✔
340
        }
341
        _ => {
342
            return Err(SQLRiteError::NotImplemented(
1✔
343
                "SQL Statement not supported yet.".to_string(),
1✔
344
            ));
345
        }
346
    };
347

348
    // Auto-save: if the database is backed by a file AND no explicit
349
    // transaction is open AND the statement changed state, flush to
350
    // disk before returning. Inside a `BEGIN … COMMIT` block the
351
    // mutations accumulate in memory (protected by the ROLLBACK
352
    // snapshot) and land on disk in one shot when COMMIT runs.
353
    //
354
    // A failed save surfaces as an error — the in-memory state already
355
    // mutated, so the caller should know disk is out of sync. The
356
    // Pager held on `db` diffs against its last-committed snapshot,
357
    // so only pages whose bytes actually changed are written.
358
    if is_write_statement && db.source_path.is_some() && !db.in_transaction() {
4✔
359
        let path = db.source_path.clone().unwrap();
2✔
360
        pager::save_database(db, &path)?;
4✔
361
    }
362

363
    Ok(CommandOutput {
2✔
364
        status: message,
2✔
365
        rendered,
2✔
366
    })
367
}
368

369
#[cfg(test)]
370
mod tests {
371
    use super::*;
372
    use crate::sql::db::table::Value;
373

374
    /// Builds a `users(id INTEGER PK, name TEXT, age INTEGER)` table populated
375
    /// with three rows, for use in executor-level tests.
376
    fn seed_users_table() -> Database {
1✔
377
        let mut db = Database::new("tempdb".to_string());
1✔
378
        process_command(
379
            "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, age INTEGER);",
380
            &mut db,
381
        )
382
        .expect("create table");
383
        process_command(
384
            "INSERT INTO users (name, age) VALUES ('alice', 30);",
385
            &mut db,
386
        )
387
        .expect("insert alice");
388
        process_command("INSERT INTO users (name, age) VALUES ('bob', 25);", &mut db)
1✔
389
            .expect("insert bob");
390
        process_command(
391
            "INSERT INTO users (name, age) VALUES ('carol', 40);",
392
            &mut db,
393
        )
394
        .expect("insert carol");
395
        db
1✔
396
    }
397

398
    #[test]
399
    fn process_command_select_all_test() {
3✔
400
        let mut db = seed_users_table();
1✔
401
        let response = process_command("SELECT * FROM users;", &mut db).expect("select");
2✔
402
        assert!(response.contains("3 rows returned"));
2✔
403
    }
404

405
    #[test]
406
    fn process_command_select_where_test() {
3✔
407
        let mut db = seed_users_table();
1✔
408
        let response =
2✔
409
            process_command("SELECT name FROM users WHERE age > 25;", &mut db).expect("select");
410
        assert!(response.contains("2 rows returned"));
2✔
411
    }
412

413
    #[test]
414
    fn process_command_select_eq_string_test() {
3✔
415
        let mut db = seed_users_table();
1✔
416
        let response =
2✔
417
            process_command("SELECT name FROM users WHERE name = 'bob';", &mut db).expect("select");
418
        assert!(response.contains("1 row returned"));
2✔
419
    }
420

421
    #[test]
422
    fn process_command_select_limit_test() {
3✔
423
        let mut db = seed_users_table();
1✔
424
        let response = process_command("SELECT * FROM users ORDER BY age ASC LIMIT 2;", &mut db)
1✔
425
            .expect("select");
426
        assert!(response.contains("2 rows returned"));
2✔
427
    }
428

429
    #[test]
430
    fn process_command_select_unknown_table_test() {
3✔
431
        let mut db = Database::new("tempdb".to_string());
1✔
432
        let result = process_command("SELECT * FROM nope;", &mut db);
1✔
433
        assert!(result.is_err());
2✔
434
    }
435

436
    #[test]
437
    fn process_command_select_unknown_column_test() {
3✔
438
        let mut db = seed_users_table();
1✔
439
        let result = process_command("SELECT height FROM users;", &mut db);
1✔
440
        assert!(result.is_err());
2✔
441
    }
442

443
    #[test]
444
    fn process_command_insert_test() {
3✔
445
        // Creating temporary database
446
        let mut db = Database::new("tempdb".to_string());
1✔
447

448
        // Creating temporary table for testing purposes
449
        let query_statement = "CREATE TABLE users (
1✔
450
            id INTEGER PRIMARY KEY,
451
            name TEXT
452
        );";
453
        let dialect = SQLiteDialect {};
454
        let mut ast = Parser::parse_sql(&dialect, query_statement).unwrap();
2✔
455
        if ast.len() > 1 {
2✔
456
            panic!("Expected a single query statement, but there are more then 1.")
×
457
        }
458
        let query = ast.pop().unwrap();
2✔
459
        let create_query = CreateQuery::new(&query).unwrap();
2✔
460

461
        // Inserting table into database
462
        db.tables.insert(
2✔
463
            create_query.table_name.to_string(),
2✔
464
            Table::new(create_query),
1✔
465
        );
466

467
        // Inserting data into table
468
        let insert_query = String::from("INSERT INTO users (name) Values ('josh');");
1✔
469
        match process_command(&insert_query, &mut db) {
2✔
470
            Ok(response) => assert_eq!(response, "INSERT Statement executed."),
1✔
471
            Err(err) => {
×
472
                eprintln!("Error: {}", err);
×
473
                assert!(false)
×
474
            }
475
        };
476
    }
477

478
    #[test]
479
    fn process_command_insert_no_pk_test() {
3✔
480
        // Creating temporary database
481
        let mut db = Database::new("tempdb".to_string());
1✔
482

483
        // Creating temporary table for testing purposes
484
        let query_statement = "CREATE TABLE users (
1✔
485
            name TEXT
486
        );";
487
        let dialect = SQLiteDialect {};
488
        let mut ast = Parser::parse_sql(&dialect, query_statement).unwrap();
2✔
489
        if ast.len() > 1 {
2✔
490
            panic!("Expected a single query statement, but there are more then 1.")
×
491
        }
492
        let query = ast.pop().unwrap();
2✔
493
        let create_query = CreateQuery::new(&query).unwrap();
2✔
494

495
        // Inserting table into database
496
        db.tables.insert(
2✔
497
            create_query.table_name.to_string(),
2✔
498
            Table::new(create_query),
1✔
499
        );
500

501
        // Inserting data into table
502
        let insert_query = String::from("INSERT INTO users (name) Values ('josh');");
1✔
503
        match process_command(&insert_query, &mut db) {
2✔
504
            Ok(response) => assert_eq!(response, "INSERT Statement executed."),
1✔
505
            Err(err) => {
×
506
                eprintln!("Error: {}", err);
×
507
                assert!(false)
×
508
            }
509
        };
510
    }
511

512
    #[test]
513
    fn process_command_delete_where_test() {
4✔
514
        let mut db = seed_users_table();
1✔
515
        let response =
2✔
516
            process_command("DELETE FROM users WHERE name = 'bob';", &mut db).expect("delete");
517
        assert!(response.contains("1 row deleted"));
2✔
518

519
        let remaining = process_command("SELECT * FROM users;", &mut db).expect("select");
1✔
520
        assert!(remaining.contains("2 rows returned"));
2✔
521
    }
522

523
    #[test]
524
    fn process_command_delete_all_test() {
3✔
525
        let mut db = seed_users_table();
1✔
526
        let response = process_command("DELETE FROM users;", &mut db).expect("delete");
2✔
527
        assert!(response.contains("3 rows deleted"));
2✔
528
    }
529

530
    #[test]
531
    fn process_command_update_where_test() {
3✔
532
        use crate::sql::db::table::Value;
533

534
        let mut db = seed_users_table();
1✔
535
        let response = process_command("UPDATE users SET age = 99 WHERE name = 'bob';", &mut db)
1✔
536
            .expect("update");
537
        assert!(response.contains("1 row updated"));
2✔
538

539
        // Confirm the cell was actually rewritten.
540
        let users = db.get_table("users".to_string()).unwrap();
1✔
541
        let bob_rowid = users
1✔
542
            .rowids()
543
            .into_iter()
544
            .find(|r| users.get_value("name", *r) == Some(Value::Text("bob".to_string())))
3✔
545
            .expect("bob row must exist");
546
        assert_eq!(users.get_value("age", bob_rowid), Some(Value::Integer(99)));
1✔
547
    }
548

549
    #[test]
550
    fn process_command_update_unique_violation_test() {
3✔
551
        let mut db = seed_users_table();
1✔
552
        // `name` is not UNIQUE in the seed — reinforce with an explicit unique column.
553
        process_command(
554
            "CREATE TABLE tags (id INTEGER PRIMARY KEY, label TEXT UNIQUE);",
555
            &mut db,
556
        )
557
        .unwrap();
558
        process_command("INSERT INTO tags (label) VALUES ('a');", &mut db).unwrap();
1✔
559
        process_command("INSERT INTO tags (label) VALUES ('b');", &mut db).unwrap();
1✔
560

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

565
    #[test]
566
    fn process_command_insert_type_mismatch_returns_error_test() {
3✔
567
        // Previously this panicked in parse::<i32>().unwrap(); now it should return an error cleanly.
568
        let mut db = Database::new("tempdb".to_string());
1✔
569
        process_command(
570
            "CREATE TABLE items (id INTEGER PRIMARY KEY, qty INTEGER);",
571
            &mut db,
572
        )
573
        .unwrap();
574
        let result = process_command("INSERT INTO items (qty) VALUES ('not a number');", &mut db);
1✔
575
        assert!(result.is_err(), "expected error, got {result:?}");
2✔
576
    }
577

578
    #[test]
579
    fn process_command_insert_missing_integer_returns_error_test() {
3✔
580
        // Non-PK INTEGER without a value should error (not panic on "Null".parse()).
581
        let mut db = Database::new("tempdb".to_string());
1✔
582
        process_command(
583
            "CREATE TABLE items (id INTEGER PRIMARY KEY, qty INTEGER);",
584
            &mut db,
585
        )
586
        .unwrap();
587
        let result = process_command("INSERT INTO items (id) VALUES (1);", &mut db);
1✔
588
        assert!(result.is_err(), "expected error, got {result:?}");
2✔
589
    }
590

591
    #[test]
592
    fn process_command_update_arith_test() {
3✔
593
        use crate::sql::db::table::Value;
594

595
        let mut db = seed_users_table();
1✔
596
        process_command("UPDATE users SET age = age + 1;", &mut db).expect("update +1");
2✔
597

598
        let users = db.get_table("users".to_string()).unwrap();
1✔
599
        let mut ages: Vec<i64> = users
600
            .rowids()
601
            .into_iter()
602
            .filter_map(|r| match users.get_value("age", r) {
3✔
603
                Some(Value::Integer(n)) => Some(n),
1✔
604
                _ => None,
×
605
            })
606
            .collect();
607
        ages.sort();
2✔
608
        assert_eq!(ages, vec![26, 31, 41]); // 25+1, 30+1, 40+1
1✔
609
    }
610

611
    #[test]
612
    fn process_command_select_arithmetic_where_test() {
3✔
613
        let mut db = seed_users_table();
1✔
614
        // age * 2 > 55  →  only ages > 27.5  →  alice(30) + carol(40)
615
        let response =
2✔
616
            process_command("SELECT name FROM users WHERE age * 2 > 55;", &mut db).expect("select");
617
        assert!(response.contains("2 rows returned"));
2✔
618
    }
619

620
    #[test]
621
    fn process_command_divide_by_zero_test() {
3✔
622
        let mut db = seed_users_table();
1✔
623
        let result = process_command("SELECT age / 0 FROM users;", &mut db);
1✔
624
        // Projection only supports bare columns, so this errors earlier; still shouldn't panic.
625
        assert!(result.is_err());
2✔
626
    }
627

628
    #[test]
629
    fn process_command_unsupported_statement_test() {
3✔
630
        let mut db = Database::new("tempdb".to_string());
1✔
631
        // CREATE VIEW is firmly in the "Not yet supported" list — used as
632
        // the canary for the dispatcher's NotImplemented arm. (DROP TABLE
633
        // moved out of unsupported in this branch.)
634
        let result = process_command("CREATE VIEW v AS SELECT * FROM users;", &mut db);
1✔
635
        assert!(result.is_err());
2✔
636
    }
637

638
    #[test]
639
    fn empty_input_is_a_noop_not_a_panic() {
3✔
640
        // Regression for: desktop app pre-fills the textarea with a
641
        // comment-only placeholder, and hitting Run used to panic because
642
        // sqlparser produced zero statements and pop().unwrap() exploded.
643
        let mut db = Database::new("t".to_string());
1✔
644
        for input in ["", "   ", "-- just a comment", "-- comment\n-- another"] {
4✔
645
            let result = process_command(input, &mut db);
2✔
646
            assert!(result.is_ok(), "input {input:?} should not error");
2✔
647
            let msg = result.unwrap();
1✔
648
            assert!(msg.contains("No statement"), "got: {msg:?}");
2✔
649
        }
650
    }
651

652
    #[test]
653
    fn create_index_adds_explicit_index() {
3✔
654
        let mut db = seed_users_table();
1✔
655
        let response = process_command("CREATE INDEX users_age_idx ON users (age);", &mut db)
1✔
656
            .expect("create index");
657
        assert!(response.contains("users_age_idx"));
2✔
658

659
        // The index should now be attached to the users table.
660
        let users = db.get_table("users".to_string()).unwrap();
1✔
661
        let idx = users
1✔
662
            .index_by_name("users_age_idx")
663
            .expect("index should exist after CREATE INDEX");
664
        assert_eq!(idx.column_name, "age");
1✔
665
        assert!(!idx.is_unique);
1✔
666
    }
667

668
    #[test]
669
    fn create_unique_index_rejects_duplicate_existing_values() {
3✔
670
        let mut db = seed_users_table();
1✔
671
        // `name` is already UNIQUE (auto-indexed); insert a duplicate-age row
672
        // first so CREATE UNIQUE INDEX on age catches the conflict.
673
        process_command("INSERT INTO users (name, age) VALUES ('dan', 30);", &mut db).unwrap();
2✔
674
        let result = process_command(
675
            "CREATE UNIQUE INDEX users_age_unique ON users (age);",
676
            &mut db,
677
        );
678
        assert!(
×
679
            result.is_err(),
2✔
680
            "expected unique-index failure, got {result:?}"
681
        );
682
    }
683

684
    #[test]
685
    fn where_eq_on_indexed_column_uses_index_probe() {
3✔
686
        // Build a table big enough that a full scan would be expensive,
687
        // then rely on the index-probe fast path. This test verifies
688
        // correctness (right rows returned); the perf win is implicit.
689
        let mut db = Database::new("t".to_string());
1✔
690
        process_command(
691
            "CREATE TABLE big (id INTEGER PRIMARY KEY, tag TEXT);",
692
            &mut db,
693
        )
694
        .unwrap();
695
        process_command("CREATE INDEX big_tag_idx ON big (tag);", &mut db).unwrap();
1✔
696
        for i in 1..=100 {
1✔
697
            let tag = if i % 3 == 0 { "hot" } else { "cold" };
2✔
698
            process_command(&format!("INSERT INTO big (tag) VALUES ('{tag}');"), &mut db).unwrap();
1✔
699
        }
700
        let response =
1✔
701
            process_command("SELECT id FROM big WHERE tag = 'hot';", &mut db).expect("select");
702
        // 1..=100 has 33 multiples of 3.
703
        assert!(
×
704
            response.contains("33 rows returned"),
2✔
705
            "response was {response:?}"
706
        );
707
    }
708

709
    #[test]
710
    fn where_eq_on_indexed_column_inside_parens_uses_index_probe() {
3✔
711
        let mut db = seed_users_table();
1✔
712
        let response = process_command("SELECT name FROM users WHERE (name = 'bob');", &mut db)
1✔
713
            .expect("select");
714
        assert!(response.contains("1 row returned"));
2✔
715
    }
716

717
    #[test]
718
    fn where_eq_literal_first_side_uses_index_probe() {
3✔
719
        let mut db = seed_users_table();
1✔
720
        // `'bob' = name` should hit the same path as `name = 'bob'`.
721
        let response =
2✔
722
            process_command("SELECT name FROM users WHERE 'bob' = name;", &mut db).expect("select");
723
        assert!(response.contains("1 row returned"));
2✔
724
    }
725

726
    #[test]
727
    fn non_equality_where_still_falls_back_to_full_scan() {
3✔
728
        // Sanity: range predicates bypass the optimizer and the full-scan
729
        // path still returns correct results.
730
        let mut db = seed_users_table();
1✔
731
        let response =
2✔
732
            process_command("SELECT name FROM users WHERE age > 28;", &mut db).expect("select");
733
        assert!(response.contains("2 rows returned"));
2✔
734
    }
735

736
    // -------------------------------------------------------------------
737
    // Phase 4f — Transactions (BEGIN / COMMIT / ROLLBACK)
738
    // -------------------------------------------------------------------
739

740
    #[test]
741
    fn rollback_restores_pre_begin_in_memory_state() {
3✔
742
        // In-memory DB (no pager): BEGIN, insert a row, ROLLBACK.
743
        // The row must disappear from the live tables HashMap.
744
        let mut db = seed_users_table();
1✔
745
        let before = db.get_table("users".to_string()).unwrap().rowids().len();
2✔
746
        assert_eq!(before, 3);
1✔
747

748
        process_command("BEGIN;", &mut db).expect("BEGIN");
1✔
749
        assert!(db.in_transaction());
1✔
750
        process_command("INSERT INTO users (name, age) VALUES ('dan', 50);", &mut db)
1✔
751
            .expect("INSERT inside txn");
752
        // Mid-transaction read sees the new row.
753
        let mid = db.get_table("users".to_string()).unwrap().rowids().len();
1✔
754
        assert_eq!(mid, 4);
1✔
755

756
        process_command("ROLLBACK;", &mut db).expect("ROLLBACK");
1✔
757
        assert!(!db.in_transaction());
1✔
758
        let after = db.get_table("users".to_string()).unwrap().rowids().len();
2✔
759
        assert_eq!(
1✔
760
            after, 3,
761
            "ROLLBACK should have restored the pre-BEGIN state"
762
        );
763
    }
764

765
    #[test]
766
    fn commit_keeps_mutations_and_clears_txn_flag() {
3✔
767
        let mut db = seed_users_table();
1✔
768
        process_command("BEGIN;", &mut db).expect("BEGIN");
2✔
769
        process_command("INSERT INTO users (name, age) VALUES ('dan', 50);", &mut db)
1✔
770
            .expect("INSERT inside txn");
771
        process_command("COMMIT;", &mut db).expect("COMMIT");
1✔
772
        assert!(!db.in_transaction());
1✔
773
        let after = db.get_table("users".to_string()).unwrap().rowids().len();
2✔
774
        assert_eq!(after, 4);
1✔
775
    }
776

777
    #[test]
778
    fn rollback_undoes_update_and_delete_side_by_side() {
3✔
779
        use crate::sql::db::table::Value;
780

781
        let mut db = seed_users_table();
1✔
782
        process_command("BEGIN;", &mut db).unwrap();
2✔
783
        process_command("UPDATE users SET age = 999;", &mut db).unwrap();
1✔
784
        process_command("DELETE FROM users WHERE name = 'bob';", &mut db).unwrap();
1✔
785
        // Mid-txn: one row gone, others have age=999.
786
        let users = db.get_table("users".to_string()).unwrap();
1✔
787
        assert_eq!(users.rowids().len(), 2);
1✔
788
        for r in users.rowids() {
2✔
789
            assert_eq!(users.get_value("age", r), Some(Value::Integer(999)));
2✔
790
        }
791

792
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
793
        let users = db.get_table("users".to_string()).unwrap();
1✔
794
        assert_eq!(users.rowids().len(), 3);
1✔
795
        // Original ages {30, 25, 40} — none should be 999.
796
        for r in users.rowids() {
2✔
797
            assert_ne!(users.get_value("age", r), Some(Value::Integer(999)));
2✔
798
        }
799
    }
800

801
    #[test]
802
    fn nested_begin_is_rejected() {
3✔
803
        let mut db = seed_users_table();
1✔
804
        process_command("BEGIN;", &mut db).unwrap();
2✔
805
        let err = process_command("BEGIN;", &mut db).unwrap_err();
1✔
806
        assert!(
×
807
            format!("{err}").contains("already open"),
3✔
808
            "nested BEGIN should error; got: {err}"
809
        );
810
        // Still in the original transaction; a ROLLBACK clears it.
811
        assert!(db.in_transaction());
1✔
812
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
813
    }
814

815
    #[test]
816
    fn orphan_commit_and_rollback_are_rejected() {
3✔
817
        let mut db = seed_users_table();
1✔
818
        let commit_err = process_command("COMMIT;", &mut db).unwrap_err();
2✔
819
        assert!(format!("{commit_err}").contains("no transaction"));
2✔
820
        let rollback_err = process_command("ROLLBACK;", &mut db).unwrap_err();
1✔
821
        assert!(format!("{rollback_err}").contains("no transaction"));
2✔
822
    }
823

824
    #[test]
825
    fn error_inside_transaction_keeps_txn_open() {
3✔
826
        // A bad INSERT inside a txn doesn't commit or abort automatically —
827
        // the user can still ROLLBACK. SQLite's implicit-rollback behavior
828
        // isn't modeled here.
829
        let mut db = seed_users_table();
1✔
830
        process_command("BEGIN;", &mut db).unwrap();
2✔
831
        let err = process_command("INSERT INTO nope (x) VALUES (1);", &mut db);
1✔
832
        assert!(err.is_err());
2✔
833
        assert!(db.in_transaction(), "txn should stay open after error");
1✔
834
        process_command("ROLLBACK;", &mut db).unwrap();
2✔
835
    }
836

837
    /// Builds a file-backed Database at a unique temp path, with the
838
    /// schema seeded and `source_path` set so subsequent process_command
839
    /// calls auto-save. Returns (path, db). Drop the db before deleting
840
    /// the files.
841
    fn seed_file_backed(name: &str, schema: &str) -> (std::path::PathBuf, Database) {
1✔
842
        use crate::sql::pager::{open_database, save_database};
843
        let mut p = std::env::temp_dir();
1✔
844
        let pid = std::process::id();
2✔
845
        let nanos = std::time::SystemTime::now()
2✔
846
            .duration_since(std::time::UNIX_EPOCH)
1✔
847
            .map(|d| d.as_nanos())
3✔
848
            .unwrap_or(0);
849
        p.push(format!("sqlrite-txn-{name}-{pid}-{nanos}.sqlrite"));
1✔
850

851
        // Seed the file, then reopen to get a source_path-attached db
852
        // (save_database alone doesn't attach a fresh pager to a db
853
        // whose source_path was None before the call).
854
        {
855
            let mut seed = Database::new("t".to_string());
1✔
856
            process_command(schema, &mut seed).unwrap();
2✔
857
            save_database(&mut seed, &p).unwrap();
1✔
858
        }
859
        let db = open_database(&p, "t".to_string()).unwrap();
1✔
860
        (p, db)
1✔
861
    }
862

863
    fn cleanup_file(path: &std::path::Path) {
1✔
864
        let _ = std::fs::remove_file(path);
1✔
865
        let mut wal = path.as_os_str().to_owned();
1✔
866
        wal.push("-wal");
1✔
867
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
868
    }
869

870
    #[test]
871
    fn begin_commit_rollback_round_trip_through_disk() {
3✔
872
        // File-backed DB: commit inside a transaction must actually
873
        // persist. ROLLBACK inside a *later* transaction must not
874
        // un-do the previously-committed changes.
875
        use crate::sql::pager::open_database;
876

877
        let (path, mut db) = seed_file_backed(
878
            "roundtrip",
879
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
880
        );
881

882
        // Transaction 1: insert two rows, commit.
883
        process_command("BEGIN;", &mut db).unwrap();
2✔
884
        process_command("INSERT INTO notes (body) VALUES ('a');", &mut db).unwrap();
1✔
885
        process_command("INSERT INTO notes (body) VALUES ('b');", &mut db).unwrap();
1✔
886
        process_command("COMMIT;", &mut db).unwrap();
1✔
887

888
        // Transaction 2: insert another, roll back.
889
        process_command("BEGIN;", &mut db).unwrap();
1✔
890
        process_command("INSERT INTO notes (body) VALUES ('c');", &mut db).unwrap();
1✔
891
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
892

893
        drop(db); // release pager lock
1✔
894

895
        let reopened = open_database(&path, "t".to_string()).unwrap();
1✔
896
        let notes = reopened.get_table("notes".to_string()).unwrap();
2✔
897
        assert_eq!(notes.rowids().len(), 2, "committed rows should survive");
1✔
898

899
        drop(reopened);
1✔
900
        cleanup_file(&path);
1✔
901
    }
902

903
    #[test]
904
    fn write_inside_transaction_does_not_autosave() {
3✔
905
        // File-backed DB: writes inside BEGIN/…/COMMIT must NOT hit
906
        // the WAL until COMMIT. We prove it by checking the WAL file
907
        // size before vs during the transaction.
908
        let (path, mut db) =
1✔
909
            seed_file_backed("noas", "CREATE TABLE t (id INTEGER PRIMARY KEY, x TEXT);");
910

911
        let mut wal_path = path.as_os_str().to_owned();
2✔
912
        wal_path.push("-wal");
1✔
913
        let wal_path = std::path::PathBuf::from(wal_path);
1✔
914
        let frames_before = std::fs::metadata(&wal_path).unwrap().len();
2✔
915

916
        process_command("BEGIN;", &mut db).unwrap();
1✔
917
        process_command("INSERT INTO t (x) VALUES ('a');", &mut db).unwrap();
1✔
918
        process_command("INSERT INTO t (x) VALUES ('b');", &mut db).unwrap();
1✔
919

920
        // Mid-transaction: WAL must be unchanged — no auto-save fired.
921
        let frames_mid = std::fs::metadata(&wal_path).unwrap().len();
1✔
922
        assert_eq!(
1✔
923
            frames_before, frames_mid,
924
            "WAL should not grow during an open transaction"
925
        );
926

927
        process_command("COMMIT;", &mut db).unwrap();
2✔
928

929
        drop(db); // release pager lock
1✔
930
        let fresh = crate::sql::pager::open_database(&path, "t".to_string()).unwrap();
1✔
931
        assert_eq!(
1✔
932
            fresh.get_table("t".to_string()).unwrap().rowids().len(),
3✔
933
            2,
934
            "COMMIT should have persisted both inserted rows"
935
        );
936
        drop(fresh);
1✔
937
        cleanup_file(&path);
1✔
938
    }
939

940
    #[test]
941
    fn rollback_undoes_create_table() {
3✔
942
        // Schema DDL inside a txn: ROLLBACK must make the new table
943
        // disappear. The txn snapshot captures db.tables as of BEGIN,
944
        // and ROLLBACK reassigns tables from that snapshot, so a table
945
        // created mid-transaction has no entry in the snapshot.
946
        let mut db = seed_users_table();
1✔
947
        assert_eq!(db.tables.len(), 1);
2✔
948

949
        process_command("BEGIN;", &mut db).unwrap();
1✔
950
        process_command(
951
            "CREATE TABLE dropme (id INTEGER PRIMARY KEY, x TEXT);",
952
            &mut db,
953
        )
954
        .unwrap();
955
        process_command("INSERT INTO dropme (x) VALUES ('stuff');", &mut db).unwrap();
1✔
956
        assert_eq!(db.tables.len(), 2);
1✔
957

958
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
959
        assert_eq!(
1✔
960
            db.tables.len(),
1✔
961
            1,
962
            "CREATE TABLE should have been rolled back"
963
        );
964
        assert!(db.get_table("dropme".to_string()).is_err());
2✔
965
    }
966

967
    #[test]
968
    fn rollback_restores_secondary_index_state() {
3✔
969
        // Phase 4f edge case: rolling back an INSERT on a UNIQUE-indexed
970
        // column must also clean up the index, otherwise a re-insert of
971
        // the same value would spuriously collide.
972
        let mut db = Database::new("t".to_string());
1✔
973
        process_command(
974
            "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
975
            &mut db,
976
        )
977
        .unwrap();
978
        process_command("INSERT INTO users (email) VALUES ('a@x');", &mut db).unwrap();
1✔
979

980
        process_command("BEGIN;", &mut db).unwrap();
1✔
981
        process_command("INSERT INTO users (email) VALUES ('b@x');", &mut db).unwrap();
1✔
982
        // Inside the txn: the index now contains both 'a@x' and 'b@x'.
983
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
984

985
        // Re-inserting 'b@x' after rollback must succeed — if the index
986
        // wasn't properly restored, it would think 'b@x' is still a
987
        // collision and fail with a UNIQUE violation.
988
        let reinsert = process_command("INSERT INTO users (email) VALUES ('b@x');", &mut db);
1✔
989
        assert!(
×
990
            reinsert.is_ok(),
2✔
991
            "re-insert after rollback should succeed, got {reinsert:?}"
992
        );
993
    }
994

995
    #[test]
996
    fn rollback_restores_last_rowid_counter() {
3✔
997
        // Rowids allocated inside a rolled-back transaction should be
998
        // reusable. The snapshot restores Table::last_rowid, so the
999
        // next insert picks up where the pre-BEGIN state left off.
1000
        use crate::sql::db::table::Value;
1001

1002
        let mut db = seed_users_table(); // 3 rows, last_rowid = 3
1✔
1003
        let pre = db.get_table("users".to_string()).unwrap().last_rowid;
2✔
1004

1005
        process_command("BEGIN;", &mut db).unwrap();
1✔
1006
        process_command("INSERT INTO users (name, age) VALUES ('d', 50);", &mut db).unwrap(); // would be rowid 4
1✔
1007
        process_command("INSERT INTO users (name, age) VALUES ('e', 60);", &mut db).unwrap(); // would be rowid 5
1✔
1008
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1009

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

1013
        // Confirm: the next insert reuses rowid pre+1.
1014
        process_command("INSERT INTO users (name, age) VALUES ('d', 50);", &mut db).unwrap();
2✔
1015
        let users = db.get_table("users".to_string()).unwrap();
1✔
1016
        let d_rowid = users
1✔
1017
            .rowids()
1018
            .into_iter()
1019
            .find(|r| users.get_value("name", *r) == Some(Value::Text("d".into())))
3✔
1020
            .expect("d row must exist");
1021
        assert_eq!(d_rowid, pre + 1);
1✔
1022
    }
1023

1024
    #[test]
1025
    fn commit_on_in_memory_db_clears_txn_without_pager_call() {
3✔
1026
        // In-memory DB (no source_path): COMMIT must still work — just
1027
        // no disk flush. Covers the `if let Some(path) = …` branch
1028
        // where the guard falls through without calling save_database.
1029
        let mut db = seed_users_table(); // no source_path
1✔
1030
        assert!(db.source_path.is_none());
2✔
1031

1032
        process_command("BEGIN;", &mut db).unwrap();
1✔
1033
        process_command("INSERT INTO users (name, age) VALUES ('z', 99);", &mut db).unwrap();
1✔
1034
        process_command("COMMIT;", &mut db).unwrap();
1✔
1035

1036
        assert!(!db.in_transaction());
1✔
1037
        assert_eq!(db.get_table("users".to_string()).unwrap().rowids().len(), 4);
2✔
1038
    }
1039

1040
    #[test]
1041
    fn failed_commit_auto_rolls_back_in_memory_state() {
3✔
1042
        // Data-safety regression: on COMMIT save failure we must auto-
1043
        // rollback the in-memory state. Otherwise, any subsequent
1044
        // non-transactional statement would auto-save the partial
1045
        // mid-transaction work, silently publishing uncommitted
1046
        // changes to disk.
1047
        //
1048
        // We simulate a save failure by making the WAL sidecar path
1049
        // unavailable mid-transaction: after BEGIN, we take an
1050
        // exclusive OS lock on the WAL via a second File handle,
1051
        // forcing the next save to fail when it tries to append.
1052
        //
1053
        // Simpler repro: point source_path at a directory (not a file).
1054
        // `OpenOptions::open` will fail with EISDIR on save.
1055
        use crate::sql::pager::save_database;
1056

1057
        // Seed a file-backed db.
1058
        let (path, mut db) = seed_file_backed(
1059
            "failcommit",
1060
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1061
        );
1062

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

1066
        // Open a new txn and add a row.
1067
        process_command("BEGIN;", &mut db).unwrap();
1✔
1068
        process_command("INSERT INTO notes (body) VALUES ('inflight');", &mut db).unwrap();
1✔
1069
        assert_eq!(
1✔
1070
            db.get_table("notes".to_string()).unwrap().rowids().len(),
2✔
1071
            2,
1072
            "inflight row visible mid-txn"
1073
        );
1074

1075
        // Swap source_path to a path that will fail on open. A
1076
        // directory is a reliable failure mode — Pager::open on a
1077
        // directory errors with an I/O error.
1078
        let orig_source = db.source_path.clone();
1✔
1079
        let orig_pager = db.pager.take();
2✔
1080
        db.source_path = Some(std::env::temp_dir());
2✔
1081

1082
        let commit_result = process_command("COMMIT;", &mut db);
1✔
1083
        assert!(commit_result.is_err(), "commit must fail");
2✔
1084
        let err_str = format!("{}", commit_result.unwrap_err());
2✔
1085
        assert!(
×
1086
            err_str.contains("COMMIT failed") && err_str.contains("rolled back"),
3✔
1087
            "error must surface auto-rollback; got: {err_str}"
1088
        );
1089

1090
        // Auto-rollback fired: the inflight row is gone, the txn flag
1091
        // is cleared, and a follow-up non-txn statement won't leak
1092
        // stale state.
1093
        assert!(
×
1094
            !db.in_transaction(),
2✔
1095
            "txn must be cleared after auto-rollback"
1096
        );
1097
        assert_eq!(
1✔
1098
            db.get_table("notes".to_string()).unwrap().rowids().len(),
3✔
1099
            1,
1100
            "inflight row must be rolled back"
1101
        );
1102

1103
        // Restore the real source_path + pager and verify a clean
1104
        // subsequent write goes through.
1105
        db.source_path = orig_source;
1✔
1106
        db.pager = orig_pager;
1✔
1107
        process_command("INSERT INTO notes (body) VALUES ('after');", &mut db).unwrap();
1✔
1108
        drop(db);
1✔
1109

1110
        // Reopen and assert only 'before' + 'after' landed on disk.
1111
        let reopened = crate::sql::pager::open_database(&path, "t".to_string()).unwrap();
1✔
1112
        let notes = reopened.get_table("notes".to_string()).unwrap();
2✔
1113
        assert_eq!(notes.rowids().len(), 2);
1✔
1114
        // Ensure no leaked save_database partial happened.
1115
        let _ = save_database; // silence unused-import lint if any
1116
        drop(reopened);
1✔
1117
        cleanup_file(&path);
1✔
1118
    }
1119

1120
    #[test]
1121
    fn begin_on_read_only_is_rejected() {
3✔
1122
        use crate::sql::pager::{open_database_read_only, save_database};
1123

1124
        let path = {
1✔
1125
            let mut p = std::env::temp_dir();
1✔
1126
            let pid = std::process::id();
2✔
1127
            let nanos = std::time::SystemTime::now()
2✔
1128
                .duration_since(std::time::UNIX_EPOCH)
1✔
1129
                .map(|d| d.as_nanos())
3✔
1130
                .unwrap_or(0);
1131
            p.push(format!("sqlrite-txn-ro-{pid}-{nanos}.sqlrite"));
1✔
1132
            p
1✔
1133
        };
1134
        {
1135
            let mut seed = Database::new("t".to_string());
2✔
1136
            process_command("CREATE TABLE t (id INTEGER PRIMARY KEY);", &mut seed).unwrap();
2✔
1137
            save_database(&mut seed, &path).unwrap();
1✔
1138
        }
1139

1140
        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1✔
1141
        let err = process_command("BEGIN;", &mut ro).unwrap_err();
2✔
1142
        assert!(
×
1143
            format!("{err}").contains("read-only"),
3✔
1144
            "BEGIN on RO db should surface read-only; got: {err}"
1145
        );
1146
        assert!(!ro.in_transaction());
1✔
1147

1148
        let _ = std::fs::remove_file(&path);
2✔
1149
        let mut wal = path.as_os_str().to_owned();
1✔
1150
        wal.push("-wal");
1✔
1151
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
1152
    }
1153

1154
    #[test]
1155
    fn read_only_database_rejects_mutations_before_touching_state() {
3✔
1156
        // Phase 4e end-to-end: a `--readonly` caller that runs INSERT
1157
        // must error *before* the row is added to the in-memory table.
1158
        // Otherwise the user sees a rendered result table with the
1159
        // phantom row, followed by the auto-save error — UX rot and a
1160
        // state-drift risk.
1161
        use crate::sql::pager::open_database_read_only;
1162

1163
        let mut seed = Database::new("t".to_string());
1✔
1164
        process_command(
1165
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1166
            &mut seed,
1167
        )
1168
        .unwrap();
1169
        process_command("INSERT INTO notes (body) VALUES ('alpha');", &mut seed).unwrap();
1✔
1170

1171
        let path = {
1172
            let mut p = std::env::temp_dir();
1✔
1173
            let pid = std::process::id();
2✔
1174
            let nanos = std::time::SystemTime::now()
2✔
1175
                .duration_since(std::time::UNIX_EPOCH)
1✔
1176
                .map(|d| d.as_nanos())
3✔
1177
                .unwrap_or(0);
1178
            p.push(format!("sqlrite-ro-reject-{pid}-{nanos}.sqlrite"));
1✔
1179
            p
1✔
1180
        };
1181
        crate::sql::pager::save_database(&mut seed, &path).unwrap();
2✔
1182
        drop(seed);
1✔
1183

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

1187
        for stmt in [
2✔
1188
            "INSERT INTO notes (body) VALUES ('beta');",
1189
            "UPDATE notes SET body = 'x';",
1190
            "DELETE FROM notes;",
1191
            "CREATE TABLE more (id INTEGER PRIMARY KEY);",
1192
            "CREATE INDEX notes_body ON notes (body);",
1193
        ] {
1194
            let err = process_command(stmt, &mut ro).unwrap_err();
2✔
1195
            assert!(
×
1196
                format!("{err}").contains("read-only"),
3✔
1197
                "stmt {stmt:?} should surface a read-only error; got: {err}"
1198
            );
1199
        }
1200

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

1207
        // Cleanup.
1208
        drop(ro);
1✔
1209
        let _ = std::fs::remove_file(&path);
1✔
1210
        let mut wal = path.as_os_str().to_owned();
1✔
1211
        wal.push("-wal");
1✔
1212
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
1213
    }
1214

1215
    // -----------------------------------------------------------------
1216
    // Phase 7a — VECTOR(N) end-to-end through process_command
1217
    // -----------------------------------------------------------------
1218

1219
    #[test]
1220
    fn vector_create_table_and_insert_basic() {
3✔
1221
        let mut db = Database::new("tempdb".to_string());
1✔
1222
        process_command(
1223
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(3));",
1224
            &mut db,
1225
        )
1226
        .expect("create table with VECTOR(3)");
1227
        process_command(
1228
            "INSERT INTO docs (embedding) VALUES ([0.1, 0.2, 0.3]);",
1229
            &mut db,
1230
        )
1231
        .expect("insert vector");
1232

1233
        // process_command returns a status string; the rendered table
1234
        // goes to stdout via print_table. Verify state by inspecting
1235
        // the database directly.
1236
        let sel = process_command("SELECT * FROM docs;", &mut db).expect("select");
1✔
1237
        assert!(sel.contains("1 row returned"));
2✔
1238

1239
        let docs = db.get_table("docs".to_string()).expect("docs table");
1✔
1240
        let rowids = docs.rowids();
1✔
1241
        assert_eq!(rowids.len(), 1);
2✔
1242
        match docs.get_value("embedding", rowids[0]) {
1✔
1243
            Some(Value::Vector(v)) => assert_eq!(v, vec![0.1f32, 0.2, 0.3]),
1✔
1244
            other => panic!("expected Value::Vector(...), got {other:?}"),
×
1245
        }
1246
    }
1247

1248
    #[test]
1249
    fn vector_dim_mismatch_at_insert_is_clean_error() {
3✔
1250
        let mut db = Database::new("tempdb".to_string());
1✔
1251
        process_command(
1252
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(3));",
1253
            &mut db,
1254
        )
1255
        .expect("create table");
1256

1257
        // Too few elements.
1258
        let err = process_command("INSERT INTO docs (embedding) VALUES ([0.1, 0.2]);", &mut db)
1✔
1259
            .unwrap_err();
1260
        let msg = format!("{err}");
2✔
1261
        assert!(
×
1262
            msg.to_lowercase().contains("dimension")
2✔
1263
                && msg.contains("declared 3")
1✔
1264
                && msg.contains("got 2"),
1✔
1265
            "expected clear dim-mismatch error, got: {msg}"
1266
        );
1267

1268
        // Too many elements.
1269
        let err = process_command(
1270
            "INSERT INTO docs (embedding) VALUES ([0.1, 0.2, 0.3, 0.4, 0.5]);",
1271
            &mut db,
1272
        )
1273
        .unwrap_err();
1274
        assert!(
×
1275
            format!("{err}").contains("got 5"),
3✔
1276
            "expected dim-mismatch error mentioning got 5, got: {err}"
1277
        );
1278
    }
1279

1280
    #[test]
1281
    fn vector_create_table_rejects_missing_dim() {
3✔
1282
        let mut db = Database::new("tempdb".to_string());
1✔
1283
        // `VECTOR` (no parens) currently parses as `DataType::Custom` with
1284
        // empty args from sqlparser, OR may not parse as Custom at all
1285
        // depending on dialect. Either way, the column shouldn't end up
1286
        // as a usable Vector type. Accept any error here — the precise
1287
        // message is parser-version-dependent.
1288
        let result = process_command(
1289
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR);",
1290
            &mut db,
1291
        );
1292
        assert!(
×
1293
            result.is_err(),
2✔
1294
            "expected CREATE TABLE with bare VECTOR to fail (no dim)"
1295
        );
1296
    }
1297

1298
    #[test]
1299
    fn vector_create_table_rejects_zero_dim() {
3✔
1300
        let mut db = Database::new("tempdb".to_string());
1✔
1301
        let err = process_command(
1302
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(0));",
1303
            &mut db,
1304
        )
1305
        .unwrap_err();
1306
        let msg = format!("{err}");
2✔
1307
        assert!(
×
1308
            msg.to_lowercase().contains("vector"),
3✔
1309
            "expected VECTOR-related error for VECTOR(0), got: {msg}"
1310
        );
1311
    }
1312

1313
    #[test]
1314
    fn vector_high_dim_works() {
3✔
1315
        // 384-dim vector (OpenAI text-embedding-3-small size). Mostly a
1316
        // smoke test — if cell encoding mishandles the size, this fails.
1317
        let mut db = Database::new("tempdb".to_string());
1✔
1318
        process_command(
1319
            "CREATE TABLE embeddings (id INTEGER PRIMARY KEY, e VECTOR(384));",
1320
            &mut db,
1321
        )
1322
        .expect("create table VECTOR(384)");
1323

1324
        let lit = format!(
1✔
1325
            "[{}]",
1326
            (0..384)
1✔
1327
                .map(|i| format!("{}", i as f32 * 0.001))
3✔
1328
                .collect::<Vec<_>>()
1✔
1329
                .join(",")
1✔
1330
        );
1331
        let sql = format!("INSERT INTO embeddings (e) VALUES ({lit});");
2✔
1332
        process_command(&sql, &mut db).expect("insert 384-dim vector");
2✔
1333

1334
        let sel = process_command("SELECT id FROM embeddings;", &mut db).expect("select id");
1✔
1335
        assert!(sel.contains("1 row returned"));
2✔
1336
    }
1337

1338
    #[test]
1339
    fn vector_multiple_rows() {
3✔
1340
        // Three rows with different vectors — exercises the Row::Vector
1341
        // BTreeMap path (not just single-row insertion).
1342
        let mut db = Database::new("tempdb".to_string());
1✔
1343
        process_command(
1344
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1345
            &mut db,
1346
        )
1347
        .expect("create");
1348
        for i in 0..3 {
1✔
1349
            let sql = format!("INSERT INTO docs (e) VALUES ([{i}.0, {}.0]);", i + 1);
2✔
1350
            process_command(&sql, &mut db).expect("insert");
2✔
1351
        }
1352
        let sel = process_command("SELECT * FROM docs;", &mut db).expect("select");
1✔
1353
        assert!(sel.contains("3 rows returned"));
2✔
1354

1355
        // Verify each vector round-tripped correctly via direct DB inspection.
1356
        let docs = db.get_table("docs".to_string()).expect("docs table");
1✔
1357
        let rowids = docs.rowids();
1✔
1358
        assert_eq!(rowids.len(), 3);
2✔
1359
        let mut vectors: Vec<Vec<f32>> = rowids
1✔
1360
            .iter()
1361
            .filter_map(|r| match docs.get_value("e", *r) {
3✔
1362
                Some(Value::Vector(v)) => Some(v),
1✔
1363
                _ => None,
×
1364
            })
1365
            .collect();
1366
        vectors.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap());
4✔
1367
        assert_eq!(vectors[0], vec![0.0f32, 1.0]);
1✔
1368
        assert_eq!(vectors[1], vec![1.0f32, 2.0]);
1✔
1369
        assert_eq!(vectors[2], vec![2.0f32, 3.0]);
1✔
1370
    }
1371

1372
    // -----------------------------------------------------------------
1373
    // Phase 7d.2 — CREATE INDEX … USING hnsw end-to-end
1374
    // -----------------------------------------------------------------
1375

1376
    /// Builds a 5-row docs(id, e VECTOR(2)) table with vectors arranged
1377
    /// at known positions for clear distance reasoning. Used by both
1378
    /// the 7d.2 KNN tests and the refuse-DELETE/UPDATE tests.
1379
    fn seed_hnsw_table() -> Database {
1✔
1380
        let mut db = Database::new("tempdb".to_string());
1✔
1381
        process_command(
1382
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1383
            &mut db,
1384
        )
1385
        .unwrap();
1386
        for v in &[
1✔
1387
            "[1.0, 0.0]",   // id=1
1388
            "[2.0, 0.0]",   // id=2
1389
            "[0.0, 3.0]",   // id=3
1390
            "[1.0, 4.0]",   // id=4
1391
            "[10.0, 10.0]", // id=5
1392
        ] {
1393
            process_command(&format!("INSERT INTO docs (e) VALUES ({v});"), &mut db).unwrap();
2✔
1394
        }
1395
        db
1✔
1396
    }
1397

1398
    #[test]
1399
    fn create_index_using_hnsw_succeeds() {
4✔
1400
        let mut db = seed_hnsw_table();
1✔
1401
        let resp = process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1402
        assert!(resp.to_lowercase().contains("create index"));
2✔
1403
        // Index attached.
1404
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1405
        assert_eq!(table.hnsw_indexes.len(), 1);
1✔
1406
        assert_eq!(table.hnsw_indexes[0].name, "ix_e");
1✔
1407
        assert_eq!(table.hnsw_indexes[0].column_name, "e");
1✔
1408
        // Existing rows landed in the graph.
1409
        assert_eq!(table.hnsw_indexes[0].index.len(), 5);
1✔
1410
    }
1411

1412
    #[test]
1413
    fn create_index_using_hnsw_rejects_non_vector_column() {
3✔
1414
        let mut db = Database::new("tempdb".to_string());
1✔
1415
        process_command(
1416
            "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);",
1417
            &mut db,
1418
        )
1419
        .unwrap();
1420
        let err =
1✔
1421
            process_command("CREATE INDEX ix_name ON t USING hnsw (name);", &mut db).unwrap_err();
1422
        let msg = format!("{err}");
2✔
1423
        assert!(
×
1424
            msg.to_lowercase().contains("vector"),
3✔
1425
            "expected error mentioning VECTOR; got: {msg}"
1426
        );
1427
    }
1428

1429
    #[test]
1430
    fn knn_query_uses_hnsw_after_create_index() {
3✔
1431
        // The KNN-shaped query route through try_hnsw_probe rather than
1432
        // the brute-force select_topk. The user-visible result should
1433
        // be the same (HNSW recall is high on small graphs); we
1434
        // primarily verify the index is being hit by checking that
1435
        // the right rowids come back in the right order.
1436
        let mut db = seed_hnsw_table();
1✔
1437
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1438

1439
        // Top-3 closest to [1.0, 0.0]:
1440
        //   id=1 [1.0, 0.0]   distance=0
1441
        //   id=2 [2.0, 0.0]   distance=1
1442
        //   id=3 [0.0, 3.0]   distance≈3.16
1443
        let resp = process_command(
1444
            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [1.0, 0.0]) ASC LIMIT 3;",
1445
            &mut db,
1446
        )
1447
        .unwrap();
1448
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
1449
    }
1450

1451
    #[test]
1452
    fn knn_query_works_after_subsequent_inserts() {
3✔
1453
        // Index built when 5 rows existed; insert 2 more after; the
1454
        // HNSW gets maintained incrementally by insert_row, so the
1455
        // KNN query should see the newly-inserted vectors.
1456
        let mut db = seed_hnsw_table();
1✔
1457
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1458
        process_command("INSERT INTO docs (e) VALUES ([0.5, 0.0]);", &mut db).unwrap(); // id=6
1✔
1459
        process_command("INSERT INTO docs (e) VALUES ([0.1, 0.1]);", &mut db).unwrap(); // id=7
1✔
1460

1461
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1462
        assert_eq!(
1✔
1463
            table.hnsw_indexes[0].index.len(),
1✔
1464
            7,
1465
            "incremental insert should grow HNSW alongside row storage"
1466
        );
1467

1468
        // Now query: id=7 [0.1, 0.1] is closer to [0.0, 0.0] than the
1469
        // original 5 rows.
1470
        let resp = process_command(
1471
            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [0.0, 0.0]) ASC LIMIT 1;",
1472
            &mut db,
1473
        )
1474
        .unwrap();
1475
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
1476
    }
1477

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

1483
    #[test]
1484
    fn delete_on_hnsw_indexed_table_succeeds_and_marks_dirty() {
3✔
1485
        let mut db = seed_hnsw_table();
1✔
1486
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1487
        let resp = process_command("DELETE FROM docs WHERE id = 1;", &mut db).unwrap();
1✔
1488
        assert!(resp.contains("1 row"), "expected 1 row deleted: {resp}");
2✔
1489

1490
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
1491
        let entry = docs.hnsw_indexes.iter().find(|e| e.name == "ix_e").unwrap();
3✔
1492
        assert!(
×
1493
            entry.needs_rebuild,
1✔
1494
            "DELETE should have marked HNSW index dirty for rebuild on next save"
1495
        );
1496
    }
1497

1498
    #[test]
1499
    fn update_on_hnsw_indexed_vector_col_succeeds_and_marks_dirty() {
3✔
1500
        let mut db = seed_hnsw_table();
1✔
1501
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1502
        let resp =
1✔
1503
            process_command("UPDATE docs SET e = [9.0, 9.0] WHERE id = 1;", &mut db).unwrap();
1504
        assert!(resp.contains("1 row"), "expected 1 row updated: {resp}");
2✔
1505

1506
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
1507
        let entry = docs.hnsw_indexes.iter().find(|e| e.name == "ix_e").unwrap();
3✔
1508
        assert!(
×
1509
            entry.needs_rebuild,
1✔
1510
            "UPDATE on the vector column should have marked HNSW index dirty"
1511
        );
1512
    }
1513

1514
    #[test]
1515
    fn duplicate_index_name_errors() {
3✔
1516
        let mut db = seed_hnsw_table();
1✔
1517
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1518
        let err =
1✔
1519
            process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap_err();
1520
        let msg = format!("{err}");
2✔
1521
        assert!(
×
1522
            msg.to_lowercase().contains("already exists"),
3✔
1523
            "expected duplicate-index error; got: {msg}"
1524
        );
1525
    }
1526

1527
    #[test]
1528
    fn index_if_not_exists_is_idempotent() {
3✔
1529
        let mut db = seed_hnsw_table();
1✔
1530
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1531
        // Second time with IF NOT EXISTS should succeed (no-op).
1532
        process_command(
1533
            "CREATE INDEX IF NOT EXISTS ix_e ON docs USING hnsw (e);",
1534
            &mut db,
1535
        )
1536
        .unwrap();
1537
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1538
        assert_eq!(table.hnsw_indexes.len(), 1);
1✔
1539
    }
1540

1541
    // -----------------------------------------------------------------
1542
    // Phase 8b — CREATE INDEX … USING fts end-to-end
1543
    // -----------------------------------------------------------------
1544

1545
    /// 5-row docs(id INTEGER PK, body TEXT) populated with overlapping
1546
    /// vocabulary so BM25 ranking has interesting structure.
1547
    fn seed_fts_table() -> Database {
1✔
1548
        let mut db = Database::new("tempdb".to_string());
1✔
1549
        process_command(
1550
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT);",
1551
            &mut db,
1552
        )
1553
        .unwrap();
1554
        for body in &[
1✔
1555
            "rust embedded database",        // id=1 — both 'rust' and 'embedded'
1556
            "rust web framework",            // id=2 — 'rust' only
1557
            "go embedded systems",           // id=3 — 'embedded' only
1558
            "python web framework",          // id=4 — neither
1559
            "rust rust rust embedded power", // id=5 — heavy on 'rust'
1560
        ] {
1561
            process_command(
1562
                &format!("INSERT INTO docs (body) VALUES ('{body}');"),
2✔
1563
                &mut db,
1564
            )
1565
            .unwrap();
1566
        }
1567
        db
1✔
1568
    }
1569

1570
    #[test]
1571
    fn create_index_using_fts_succeeds_and_indexes_existing_rows() {
3✔
1572
        let mut db = seed_fts_table();
1✔
1573
        let resp =
2✔
1574
            process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
1575
        assert!(resp.to_lowercase().contains("create index"), "got {resp}");
2✔
1576
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1577
        assert_eq!(table.fts_indexes.len(), 1);
1✔
1578
        assert_eq!(table.fts_indexes[0].name, "ix_body");
1✔
1579
        assert_eq!(table.fts_indexes[0].column_name, "body");
1✔
1580
        // All five rows should be in the in-memory PostingList.
1581
        assert_eq!(table.fts_indexes[0].index.len(), 5);
1✔
1582
    }
1583

1584
    #[test]
1585
    fn create_index_using_fts_rejects_non_text_column() {
3✔
1586
        let mut db = Database::new("tempdb".to_string());
1✔
1587
        process_command(
1588
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER);",
1589
            &mut db,
1590
        )
1591
        .unwrap();
1592
        let err = process_command("CREATE INDEX ix_n ON t USING fts (n);", &mut db).unwrap_err();
1✔
1593
        let msg = format!("{err}");
2✔
1594
        assert!(
×
1595
            msg.to_lowercase().contains("text"),
3✔
1596
            "expected error mentioning TEXT; got: {msg}"
1597
        );
1598
    }
1599

1600
    #[test]
1601
    fn fts_match_returns_expected_rows() {
3✔
1602
        let mut db = seed_fts_table();
1✔
1603
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
1604
        // Rows that contain 'rust': ids 1, 2, 5.
1605
        let resp = process_command(
1606
            "SELECT id FROM docs WHERE fts_match(body, 'rust');",
1607
            &mut db,
1608
        )
1609
        .unwrap();
1610
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
1611
    }
1612

1613
    #[test]
1614
    fn fts_match_without_index_errors_clearly() {
3✔
1615
        let mut db = seed_fts_table();
1✔
1616
        // No CREATE INDEX — fts_match must surface a useful error.
1617
        let err = process_command(
1618
            "SELECT id FROM docs WHERE fts_match(body, 'rust');",
1619
            &mut db,
1620
        )
1621
        .unwrap_err();
1622
        let msg = format!("{err}");
2✔
1623
        assert!(
×
1624
            msg.contains("no FTS index"),
2✔
1625
            "expected no-index error; got: {msg}"
1626
        );
1627
    }
1628

1629
    #[test]
1630
    fn bm25_score_orders_descending_by_relevance() {
3✔
1631
        let mut db = seed_fts_table();
1✔
1632
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
1633
        // ORDER BY bm25_score DESC LIMIT 1: id=5 has 'rust' three times in
1634
        // a 5-token doc — highest tf, modest length penalty → top score.
1635
        let out = process_command_with_render(
1636
            "SELECT id FROM docs WHERE fts_match(body, 'rust') \
1637
             ORDER BY bm25_score(body, 'rust') DESC LIMIT 1;",
1638
            &mut db,
1639
        )
1640
        .unwrap();
1641
        assert!(out.status.contains("1 row returned"), "got: {}", out.status);
2✔
1642
        let rendered = out.rendered.expect("SELECT should produce rendered output");
1✔
1643
        // The rendered prettytable contains the integer 5 in a cell.
1644
        assert!(
×
1645
            rendered.contains(" 5 "),
2✔
1646
            "expected id=5 to be top-ranked; rendered:\n{rendered}"
1647
        );
1648
    }
1649

1650
    #[test]
1651
    fn bm25_score_without_index_errors_clearly() {
3✔
1652
        let mut db = seed_fts_table();
1✔
1653
        let err = process_command(
1654
            "SELECT id FROM docs ORDER BY bm25_score(body, 'rust') DESC LIMIT 1;",
1655
            &mut db,
1656
        )
1657
        .unwrap_err();
1658
        let msg = format!("{err}");
2✔
1659
        assert!(
×
1660
            msg.contains("no FTS index"),
2✔
1661
            "expected no-index error; got: {msg}"
1662
        );
1663
    }
1664

1665
    #[test]
1666
    fn fts_post_create_inserts_are_indexed_incrementally() {
3✔
1667
        let mut db = seed_fts_table();
1✔
1668
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
1669
        process_command(
1670
            "INSERT INTO docs (body) VALUES ('rust embedded analytics');",
1671
            &mut db,
1672
        )
1673
        .unwrap();
1674
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1675
        // PostingList::len() reports doc count; should be 6 now.
1676
        assert_eq!(table.fts_indexes[0].index.len(), 6);
1✔
1677
        // 'analytics' appears only in the new row → query returns 1 hit.
1678
        let resp = process_command(
1679
            "SELECT id FROM docs WHERE fts_match(body, 'analytics');",
1680
            &mut db,
1681
        )
1682
        .unwrap();
1683
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
1684
    }
1685

1686
    #[test]
1687
    fn delete_on_fts_indexed_table_marks_dirty() {
3✔
1688
        let mut db = seed_fts_table();
1✔
1689
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
1690
        let resp = process_command("DELETE FROM docs WHERE id = 1;", &mut db).unwrap();
1✔
1691
        assert!(resp.contains("1 row"), "got: {resp}");
2✔
1692
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
1693
        let entry = docs
3✔
1694
            .fts_indexes
1695
            .iter()
1696
            .find(|e| e.name == "ix_body")
3✔
1697
            .unwrap();
1698
        assert!(
×
1699
            entry.needs_rebuild,
1✔
1700
            "DELETE should have flagged the FTS index dirty"
1701
        );
1702
    }
1703

1704
    #[test]
1705
    fn update_on_fts_indexed_text_col_marks_dirty() {
3✔
1706
        let mut db = seed_fts_table();
1✔
1707
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
1708
        let resp = process_command(
1709
            "UPDATE docs SET body = 'java spring framework' WHERE id = 1;",
1710
            &mut db,
1711
        )
1712
        .unwrap();
1713
        assert!(resp.contains("1 row"), "got: {resp}");
2✔
1714
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
1715
        let entry = docs
3✔
1716
            .fts_indexes
1717
            .iter()
1718
            .find(|e| e.name == "ix_body")
3✔
1719
            .unwrap();
1720
        assert!(
×
1721
            entry.needs_rebuild,
1✔
1722
            "UPDATE on the indexed TEXT column should have flagged dirty"
1723
        );
1724
    }
1725

1726
    #[test]
1727
    fn fts_index_name_collides_with_btree_and_hnsw_namespaces() {
3✔
1728
        let mut db = seed_fts_table();
1✔
1729
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
1730
        let err = process_command("CREATE INDEX ix_body ON docs (body);", &mut db).unwrap_err();
1✔
1731
        let msg = format!("{err}");
2✔
1732
        assert!(
×
1733
            msg.to_lowercase().contains("already exists"),
3✔
1734
            "expected duplicate-index error; got: {msg}"
1735
        );
1736
    }
1737

1738
    #[test]
1739
    fn fts_index_rejects_unique() {
3✔
1740
        let mut db = seed_fts_table();
1✔
1741
        let err = process_command(
1742
            "CREATE UNIQUE INDEX ix_body ON docs USING fts (body);",
1743
            &mut db,
1744
        )
1745
        .unwrap_err();
1746
        let msg = format!("{err}");
2✔
1747
        assert!(
×
1748
            msg.to_lowercase().contains("unique"),
3✔
1749
            "expected UNIQUE-rejection error; got: {msg}"
1750
        );
1751
    }
1752

1753
    #[test]
1754
    fn try_fts_probe_falls_through_on_ascending() {
3✔
1755
        // BM25 is "higher = better"; ASC is rejected so the slow path
1756
        // applies. We verify by running the query and checking the
1757
        // result is still correct (the slow path goes through scalar
1758
        // bm25_score on every row).
1759
        let mut db = seed_fts_table();
1✔
1760
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
1761
        // Same query as bm25_score_orders_descending but ASC → should
1762
        // still succeed (slow path), and id=5 should now be LAST.
1763
        let resp = process_command(
1764
            "SELECT id FROM docs WHERE fts_match(body, 'rust') \
1765
             ORDER BY bm25_score(body, 'rust') ASC LIMIT 3;",
1766
            &mut db,
1767
        )
1768
        .unwrap();
1769
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
1770
    }
1771

1772
    // -----------------------------------------------------------------
1773
    // Phase 7b — vector distance functions through process_command
1774
    // -----------------------------------------------------------------
1775

1776
    /// Builds a 3-row docs table with 2-dim vectors aligned along the
1777
    /// axes so the expected distances are easy to reason about:
1778
    ///   id=1: [1, 0]
1779
    ///   id=2: [0, 1]
1780
    ///   id=3: [1, 1]
1781
    fn seed_vector_docs() -> Database {
1✔
1782
        let mut db = Database::new("tempdb".to_string());
1✔
1783
        process_command(
1784
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1785
            &mut db,
1786
        )
1787
        .expect("create");
1788
        process_command("INSERT INTO docs (e) VALUES ([1.0, 0.0]);", &mut db).expect("insert 1");
1✔
1789
        process_command("INSERT INTO docs (e) VALUES ([0.0, 1.0]);", &mut db).expect("insert 2");
1✔
1790
        process_command("INSERT INTO docs (e) VALUES ([1.0, 1.0]);", &mut db).expect("insert 3");
1✔
1791
        db
1✔
1792
    }
1793

1794
    #[test]
1795
    fn vec_distance_l2_in_where_filters_correctly() {
3✔
1796
        // Distance from [1,0]:
1797
        //   id=1 [1,0]: 0
1798
        //   id=2 [0,1]: √2 ≈ 1.414
1799
        //   id=3 [1,1]: 1
1800
        // WHERE distance < 1.1 should match id=1 and id=3 (2 rows).
1801
        let mut db = seed_vector_docs();
1✔
1802
        let resp = process_command(
1803
            "SELECT * FROM docs WHERE vec_distance_l2(e, [1.0, 0.0]) < 1.1;",
1804
            &mut db,
1805
        )
1806
        .expect("select");
1807
        assert!(
×
1808
            resp.contains("2 rows returned"),
2✔
1809
            "expected 2 rows, got: {resp}"
1810
        );
1811
    }
1812

1813
    #[test]
1814
    fn vec_distance_cosine_in_where() {
3✔
1815
        // [1,0] vs [1,0]: cosine distance = 0
1816
        // [1,0] vs [0,1]: cosine distance = 1 (orthogonal)
1817
        // [1,0] vs [1,1]: cosine distance = 1 - 1/√2 ≈ 0.293
1818
        // WHERE distance < 0.5 → id=1 and id=3 (2 rows).
1819
        let mut db = seed_vector_docs();
1✔
1820
        let resp = process_command(
1821
            "SELECT * FROM docs WHERE vec_distance_cosine(e, [1.0, 0.0]) < 0.5;",
1822
            &mut db,
1823
        )
1824
        .expect("select");
1825
        assert!(
×
1826
            resp.contains("2 rows returned"),
2✔
1827
            "expected 2 rows, got: {resp}"
1828
        );
1829
    }
1830

1831
    #[test]
1832
    fn vec_distance_dot_negated() {
3✔
1833
        // [1,0]·[1,0] = 1 → -1
1834
        // [1,0]·[0,1] = 0 → 0
1835
        // [1,0]·[1,1] = 1 → -1
1836
        // WHERE -dot < 0 (i.e. dot > 0) → id=1 and id=3 (2 rows).
1837
        let mut db = seed_vector_docs();
1✔
1838
        let resp = process_command(
1839
            "SELECT * FROM docs WHERE vec_distance_dot(e, [1.0, 0.0]) < 0.0;",
1840
            &mut db,
1841
        )
1842
        .expect("select");
1843
        assert!(
×
1844
            resp.contains("2 rows returned"),
2✔
1845
            "expected 2 rows, got: {resp}"
1846
        );
1847
    }
1848

1849
    #[test]
1850
    fn knn_via_order_by_distance_limit() {
3✔
1851
        // Classic KNN shape: ORDER BY distance LIMIT k.
1852
        // Distances from [1,0]: id=1=0, id=3=1, id=2=√2.
1853
        // LIMIT 2 should return id=1 then id=3 in that order.
1854
        let mut db = seed_vector_docs();
1✔
1855
        let resp = process_command(
1856
            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [1.0, 0.0]) ASC LIMIT 2;",
1857
            &mut db,
1858
        )
1859
        .expect("select");
1860
        assert!(
×
1861
            resp.contains("2 rows returned"),
2✔
1862
            "expected 2 rows, got: {resp}"
1863
        );
1864
    }
1865

1866
    #[test]
1867
    fn distance_function_dim_mismatch_errors() {
3✔
1868
        // 2-dim column queried with a 3-dim probe → clean error.
1869
        let mut db = seed_vector_docs();
1✔
1870
        let err = process_command(
1871
            "SELECT * FROM docs WHERE vec_distance_l2(e, [1.0, 0.0, 0.0]) < 1.0;",
1872
            &mut db,
1873
        )
1874
        .unwrap_err();
1875
        let msg = format!("{err}");
2✔
1876
        assert!(
×
1877
            msg.to_lowercase().contains("dimension")
2✔
1878
                && msg.contains("lhs=2")
1✔
1879
                && msg.contains("rhs=3"),
1✔
1880
            "expected dim mismatch error, got: {msg}"
1881
        );
1882
    }
1883

1884
    #[test]
1885
    fn unknown_function_errors_with_name() {
3✔
1886
        // Use the function in WHERE, not projection — the projection
1887
        // parser still requires bare column references; function calls
1888
        // there are a future enhancement (with `AS alias` support).
1889
        let mut db = seed_vector_docs();
1✔
1890
        let err = process_command(
1891
            "SELECT * FROM docs WHERE vec_does_not_exist(e, [1.0, 0.0]) < 1.0;",
1892
            &mut db,
1893
        )
1894
        .unwrap_err();
1895
        let msg = format!("{err}");
2✔
1896
        assert!(
×
1897
            msg.contains("vec_does_not_exist"),
2✔
1898
            "expected error mentioning function name, got: {msg}"
1899
        );
1900
    }
1901

1902
    // -----------------------------------------------------------------
1903
    // Phase 7e — JSON column type + path-extraction functions
1904
    // -----------------------------------------------------------------
1905

1906
    fn seed_json_table() -> Database {
1✔
1907
        let mut db = Database::new("tempdb".to_string());
1✔
1908
        process_command(
1909
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, payload JSON);",
1910
            &mut db,
1911
        )
1912
        .expect("create json table");
1913
        db
1✔
1914
    }
1915

1916
    #[test]
1917
    fn json_column_round_trip_primitive_values() {
3✔
1918
        let mut db = seed_json_table();
1✔
1919
        process_command(
1920
            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice", "age": 30}');"#,
1921
            &mut db,
1922
        )
1923
        .expect("insert json");
1924
        let docs = db.get_table("docs".to_string()).unwrap();
1✔
1925
        let rowids = docs.rowids();
1✔
1926
        assert_eq!(rowids.len(), 1);
2✔
1927
        // Stored verbatim as Text underneath.
1928
        match docs.get_value("payload", rowids[0]) {
1✔
1929
            Some(Value::Text(s)) => {
1✔
1930
                assert!(s.contains("alice"), "expected JSON text to round-trip: {s}");
2✔
1931
            }
1932
            other => panic!("expected Value::Text holding JSON, got {other:?}"),
×
1933
        }
1934
    }
1935

1936
    #[test]
1937
    fn json_insert_rejects_invalid_json() {
3✔
1938
        let mut db = seed_json_table();
1✔
1939
        let err = process_command(
1940
            "INSERT INTO docs (payload) VALUES ('not-valid-json{');",
1941
            &mut db,
1942
        )
1943
        .unwrap_err();
1944
        let msg = format!("{err}").to_lowercase();
2✔
1945
        assert!(
×
1946
            msg.contains("json") && msg.contains("payload"),
2✔
1947
            "expected JSON validation error mentioning column, got: {msg}"
1948
        );
1949
    }
1950

1951
    #[test]
1952
    fn json_extract_object_field() {
3✔
1953
        let mut db = seed_json_table();
1✔
1954
        process_command(
1955
            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice", "age": 30}');"#,
1956
            &mut db,
1957
        )
1958
        .unwrap();
1959
        // We don't have function calls in projection (yet), so test
1960
        // the function via WHERE.
1961
        let resp = process_command(
1962
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.name') = 'alice';"#,
1963
            &mut db,
1964
        )
1965
        .expect("select via json_extract");
1966
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
1967

1968
        let resp = process_command(
1969
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.age') = 30;"#,
1970
            &mut db,
1971
        )
1972
        .expect("select via numeric json_extract");
1973
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
1974
    }
1975

1976
    #[test]
1977
    fn json_extract_array_index_and_nested() {
3✔
1978
        let mut db = seed_json_table();
1✔
1979
        process_command(
1980
            r#"INSERT INTO docs (payload) VALUES ('{"tags": ["rust", "sql", "vectors"], "meta": {"author": "joao"}}');"#,
1981
            &mut db,
1982
        )
1983
        .unwrap();
1984
        let resp = process_command(
1985
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.tags[0]') = 'rust';"#,
1986
            &mut db,
1987
        )
1988
        .expect("select via array index");
1989
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
1990

1991
        let resp = process_command(
1992
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.meta.author') = 'joao';"#,
1993
            &mut db,
1994
        )
1995
        .expect("select via nested object");
1996
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
1997
    }
1998

1999
    #[test]
2000
    fn json_extract_missing_path_returns_null() {
3✔
2001
        let mut db = seed_json_table();
1✔
2002
        process_command(
2003
            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice"}');"#,
2004
            &mut db,
2005
        )
2006
        .unwrap();
2007
        // Missing key under WHERE returns NULL → predicate is false →
2008
        // 0 rows returned. (Standard SQL three-valued logic.)
2009
        let resp = process_command(
2010
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.missing') = 'something';"#,
2011
            &mut db,
2012
        )
2013
        .expect("select with missing path");
2014
        assert!(resp.contains("0 rows returned"), "got: {resp}");
2✔
2015
    }
2016

2017
    #[test]
2018
    fn json_extract_malformed_path_errors() {
3✔
2019
        let mut db = seed_json_table();
1✔
2020
        process_command(
2021
            r#"INSERT INTO docs (payload) VALUES ('{"a": 1}');"#,
2022
            &mut db,
2023
        )
2024
        .unwrap();
2025
        // Path doesn't start with '$' — syntax error.
2026
        let err = process_command(
2027
            r#"SELECT id FROM docs WHERE json_extract(payload, 'a.b') = 1;"#,
2028
            &mut db,
2029
        )
2030
        .unwrap_err();
2031
        assert!(format!("{err}").contains("'$'"));
2✔
2032
    }
2033

2034
    #[test]
2035
    fn json_array_length_on_array() {
3✔
2036
        // Note: json_array_length used in WHERE clause where it can be
2037
        // compared; that exercises the function dispatch end-to-end.
2038
        let mut db = seed_json_table();
1✔
2039
        process_command(
2040
            r#"INSERT INTO docs (payload) VALUES ('{"tags": ["a", "b", "c"]}');"#,
2041
            &mut db,
2042
        )
2043
        .unwrap();
2044
        let resp = process_command(
2045
            r#"SELECT id FROM docs WHERE json_array_length(payload, '$.tags') = 3;"#,
2046
            &mut db,
2047
        )
2048
        .expect("select via array_length");
2049
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2050
    }
2051

2052
    #[test]
2053
    fn json_array_length_on_non_array_errors() {
3✔
2054
        let mut db = seed_json_table();
1✔
2055
        process_command(
2056
            r#"INSERT INTO docs (payload) VALUES ('{"tags": "not-an-array"}');"#,
2057
            &mut db,
2058
        )
2059
        .unwrap();
2060
        let err = process_command(
2061
            r#"SELECT id FROM docs WHERE json_array_length(payload, '$.tags') = 1;"#,
2062
            &mut db,
2063
        )
2064
        .unwrap_err();
2065
        let msg = format!("{err}").to_lowercase();
2✔
2066
        assert!(
×
2067
            msg.contains("non-array"),
1✔
2068
            "expected non-array error, got: {msg}"
2069
        );
2070
    }
2071

2072
    #[test]
2073
    fn json_type_recognizes_each_kind() {
3✔
2074
        let mut db = seed_json_table();
1✔
2075
        process_command(
2076
            r#"INSERT INTO docs (payload) VALUES ('{"o": {}, "a": [], "s": "x", "i": 1, "f": 1.5, "t": true, "n": null}');"#,
2077
            &mut db,
2078
        )
2079
        .unwrap();
2080
        let cases = &[
1✔
2081
            ("$.o", "object"),
2082
            ("$.a", "array"),
2083
            ("$.s", "text"),
2084
            ("$.i", "integer"),
2085
            ("$.f", "real"),
2086
            ("$.t", "true"),
2087
            ("$.n", "null"),
2088
        ];
2089
        for (path, expected_type) in cases {
1✔
2090
            let sql = format!(
2✔
2091
                "SELECT id FROM docs WHERE json_type(payload, '{path}') = '{expected_type}';"
2092
            );
2093
            let resp =
2✔
2094
                process_command(&sql, &mut db).unwrap_or_else(|e| panic!("path {path}: {e}"));
2095
            assert!(
×
2096
                resp.contains("1 row returned"),
2✔
2097
                "path {path} expected type {expected_type}; got response: {resp}"
2098
            );
2099
        }
2100
    }
2101

2102
    #[test]
2103
    fn update_on_json_column_revalidates() {
3✔
2104
        let mut db = seed_json_table();
1✔
2105
        process_command(
2106
            r#"INSERT INTO docs (payload) VALUES ('{"a": 1}');"#,
2107
            &mut db,
2108
        )
2109
        .unwrap();
2110
        // Valid JSON update succeeds.
2111
        process_command(
2112
            r#"UPDATE docs SET payload = '{"a": 2, "b": 3}' WHERE id = 1;"#,
2113
            &mut db,
2114
        )
2115
        .expect("valid JSON UPDATE");
2116
        // Invalid JSON in UPDATE is rejected with the same shape of
2117
        // error as INSERT.
2118
        let err = process_command(
2119
            r#"UPDATE docs SET payload = 'not-json{' WHERE id = 1;"#,
2120
            &mut db,
2121
        )
2122
        .unwrap_err();
2123
        let msg = format!("{err}").to_lowercase();
2✔
2124
        assert!(
×
2125
            msg.contains("json") && msg.contains("payload"),
2✔
2126
            "got: {msg}"
2127
        );
2128
    }
2129

2130
    // -------------------------------------------------------------------
2131
    // DEFAULT clause on CREATE TABLE columns
2132
    // -------------------------------------------------------------------
2133

2134
    #[test]
2135
    fn default_literal_int_applies_when_column_omitted() {
3✔
2136
        let mut db = Database::new("t".to_string());
1✔
2137
        process_command(
2138
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER DEFAULT 42);",
2139
            &mut db,
2140
        )
2141
        .unwrap();
2142
        process_command("INSERT INTO t (id) VALUES (1);", &mut db).unwrap();
1✔
2143

2144
        let table = db.get_table("t".to_string()).unwrap();
1✔
2145
        assert_eq!(table.get_value("n", 1), Some(Value::Integer(42)));
1✔
2146
    }
2147

2148
    #[test]
2149
    fn default_literal_text_applies_when_column_omitted() {
3✔
2150
        let mut db = Database::new("t".to_string());
1✔
2151
        process_command(
2152
            "CREATE TABLE users (id INTEGER PRIMARY KEY, status TEXT DEFAULT 'active');",
2153
            &mut db,
2154
        )
2155
        .unwrap();
2156
        process_command("INSERT INTO users (id) VALUES (1);", &mut db).unwrap();
1✔
2157

2158
        let table = db.get_table("users".to_string()).unwrap();
1✔
2159
        assert_eq!(
1✔
2160
            table.get_value("status", 1),
1✔
2161
            Some(Value::Text("active".to_string()))
2✔
2162
        );
2163
    }
2164

2165
    #[test]
2166
    fn default_literal_real_negative_applies_when_column_omitted() {
3✔
2167
        // `DEFAULT -1.5` arrives as a UnaryOp(Minus, Number) — exercise that path.
2168
        let mut db = Database::new("t".to_string());
1✔
2169
        process_command(
2170
            "CREATE TABLE t (id INTEGER PRIMARY KEY, score REAL DEFAULT -1.5);",
2171
            &mut db,
2172
        )
2173
        .unwrap();
2174
        process_command("INSERT INTO t (id) VALUES (1);", &mut db).unwrap();
1✔
2175

2176
        let table = db.get_table("t".to_string()).unwrap();
1✔
2177
        assert_eq!(table.get_value("score", 1), Some(Value::Real(-1.5)));
1✔
2178
    }
2179

2180
    #[test]
2181
    fn default_with_type_mismatch_errors_at_create_time() {
3✔
2182
        let mut db = Database::new("t".to_string());
1✔
2183
        let result = process_command(
2184
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER DEFAULT 'oops');",
2185
            &mut db,
2186
        );
2187
        let err = result.expect_err("text default on INTEGER column should be rejected");
1✔
2188
        let msg = format!("{err}").to_lowercase();
2✔
2189
        assert!(msg.contains("default"), "got: {msg}");
1✔
2190
    }
2191

2192
    #[test]
2193
    fn default_for_json_column_must_be_valid_json() {
3✔
2194
        // ADD COLUMN ... JSON DEFAULT 'not-json' would otherwise backfill
2195
        // every existing row with invalid JSON (insert_row's validation
2196
        // is bypassed for the backfill path).
2197
        let mut db = Database::new("t".to_string());
1✔
2198
        let err = process_command(
2199
            "CREATE TABLE t (id INTEGER PRIMARY KEY, doc JSON DEFAULT 'not-json{');",
2200
            &mut db,
2201
        )
2202
        .unwrap_err();
NEW
2203
        assert!(
×
2204
            format!("{err}").to_lowercase().contains("json"),
4✔
2205
            "got: {err}"
2206
        );
2207

2208
        // Valid JSON DEFAULT works.
2209
        process_command(
2210
            "CREATE TABLE t2 (id INTEGER PRIMARY KEY, doc JSON DEFAULT '{\"k\":1}');",
2211
            &mut db,
2212
        )
2213
        .expect("valid JSON DEFAULT should be accepted");
2214
    }
2215

2216
    #[test]
2217
    fn default_with_non_literal_expression_errors_at_create_time() {
3✔
2218
        let mut db = Database::new("t".to_string());
1✔
2219
        // Function-call DEFAULT (e.g. CURRENT_TIMESTAMP) → rejected; we only
2220
        // accept literal expressions for now.
2221
        let result = process_command(
2222
            "CREATE TABLE t (id INTEGER PRIMARY KEY, ts TEXT DEFAULT CURRENT_TIMESTAMP);",
2223
            &mut db,
2224
        );
2225
        let err = result.expect_err("non-literal DEFAULT should be rejected");
1✔
2226
        let msg = format!("{err}").to_lowercase();
2✔
NEW
2227
        assert!(
×
2228
            msg.contains("default") && msg.contains("literal"),
2✔
2229
            "got: {msg}"
2230
        );
2231
    }
2232

2233
    #[test]
2234
    fn default_null_is_accepted_at_create_time() {
3✔
2235
        // `DEFAULT NULL` is a no-op equivalent to no DEFAULT clause; the
2236
        // important thing is that CREATE TABLE accepts it without error
2237
        // (some DDL exporters emit `DEFAULT NULL` redundantly).
2238
        let mut db = Database::new("t".to_string());
1✔
2239
        process_command(
2240
            "CREATE TABLE t (id INTEGER PRIMARY KEY, note TEXT DEFAULT NULL);",
2241
            &mut db,
2242
        )
2243
        .expect("CREATE TABLE with DEFAULT NULL should be accepted");
2244
        let table = db.get_table("t".to_string()).unwrap();
1✔
2245
        let note = table
3✔
2246
            .columns
2247
            .iter()
2248
            .find(|c| c.column_name == "note")
3✔
2249
            .unwrap();
2250
        assert_eq!(note.default, Some(Value::Null));
1✔
2251
    }
2252

2253
    // -------------------------------------------------------------------
2254
    // DROP TABLE / DROP INDEX
2255
    // -------------------------------------------------------------------
2256

2257
    #[test]
2258
    fn drop_table_basic() {
3✔
2259
        let mut db = seed_users_table();
1✔
2260
        let response = process_command("DROP TABLE users;", &mut db).expect("drop table");
2✔
2261
        assert!(response.contains("1 table dropped"));
2✔
2262
        assert!(!db.contains_table("users".to_string()));
1✔
2263
    }
2264

2265
    #[test]
2266
    fn drop_table_if_exists_noop_on_missing() {
3✔
2267
        let mut db = Database::new("t".to_string());
1✔
2268
        let response =
2✔
2269
            process_command("DROP TABLE IF EXISTS missing;", &mut db).expect("drop if exists");
2270
        assert!(response.contains("0 tables dropped"));
2✔
2271
    }
2272

2273
    #[test]
2274
    fn drop_table_missing_errors_without_if_exists() {
3✔
2275
        let mut db = Database::new("t".to_string());
1✔
2276
        let err = process_command("DROP TABLE missing;", &mut db).unwrap_err();
2✔
2277
        assert!(format!("{err}").contains("does not exist"), "got: {err}");
2✔
2278
    }
2279

2280
    #[test]
2281
    fn drop_table_reserved_name_errors() {
3✔
2282
        let mut db = Database::new("t".to_string());
1✔
2283
        let err = process_command("DROP TABLE sqlrite_master;", &mut db).unwrap_err();
2✔
2284
        assert!(format!("{err}").contains("reserved"), "got: {err}");
2✔
2285
    }
2286

2287
    #[test]
2288
    fn drop_table_multi_target_rejected() {
3✔
2289
        let mut db = seed_users_table();
1✔
2290
        process_command("CREATE TABLE other (id INTEGER PRIMARY KEY);", &mut db).unwrap();
2✔
2291
        // sqlparser accepts `DROP TABLE a, b` as one statement; we reject
2292
        // to keep error semantics simple (no partial-failure rollback).
2293
        let err = process_command("DROP TABLE users, other;", &mut db).unwrap_err();
1✔
2294
        assert!(format!("{err}").contains("single table"), "got: {err}");
2✔
2295
    }
2296

2297
    #[test]
2298
    fn drop_table_cascades_indexes_in_memory() {
3✔
2299
        let mut db = seed_users_table();
1✔
2300
        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2✔
2301
        // PK auto-index + UNIQUE-on-name auto-index + the explicit one.
2302
        let users = db.get_table("users".to_string()).unwrap();
1✔
NEW
2303
        assert!(
×
2304
            users
3✔
2305
                .secondary_indexes
2306
                .iter()
1✔
2307
                .any(|i| i.name == "users_age_idx")
3✔
2308
        );
2309

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

2312
        // After DROP TABLE, no other table should claim the dropped indexes.
2313
        for table in db.tables.values() {
1✔
NEW
2314
            assert!(
×
NEW
2315
                !table
×
2316
                    .secondary_indexes
NEW
2317
                    .iter()
×
NEW
2318
                    .any(|i| i.name.contains("users")),
×
2319
                "dropped table's indexes should not survive on any other table"
2320
            );
2321
        }
2322
    }
2323

2324
    #[test]
2325
    fn drop_index_explicit_basic() {
3✔
2326
        let mut db = seed_users_table();
1✔
2327
        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2✔
2328
        let response = process_command("DROP INDEX users_age_idx;", &mut db).expect("drop index");
1✔
2329
        assert!(response.contains("1 index dropped"));
2✔
2330

2331
        let users = db.get_table("users".to_string()).unwrap();
1✔
2332
        assert!(users.index_by_name("users_age_idx").is_none());
1✔
2333
    }
2334

2335
    #[test]
2336
    fn drop_index_refuses_auto_index() {
3✔
2337
        let mut db = seed_users_table();
1✔
2338
        // `users` was created with `id INTEGER PRIMARY KEY` → auto-index
2339
        // named `sqlrite_autoindex_users_id`.
2340
        let err = process_command("DROP INDEX sqlrite_autoindex_users_id;", &mut db).unwrap_err();
2✔
2341
        assert!(format!("{err}").contains("auto-created"), "got: {err}");
2✔
2342
    }
2343

2344
    #[test]
2345
    fn drop_index_if_exists_noop_on_missing() {
3✔
2346
        let mut db = Database::new("t".to_string());
1✔
2347
        let response =
2✔
2348
            process_command("DROP INDEX IF EXISTS nope;", &mut db).expect("drop index if exists");
2349
        assert!(response.contains("0 indexes dropped"));
2✔
2350
    }
2351

2352
    #[test]
2353
    fn drop_index_missing_errors_without_if_exists() {
3✔
2354
        let mut db = Database::new("t".to_string());
1✔
2355
        let err = process_command("DROP INDEX nope;", &mut db).unwrap_err();
2✔
2356
        assert!(format!("{err}").contains("does not exist"), "got: {err}");
2✔
2357
    }
2358

2359
    #[test]
2360
    fn drop_statements_rejected_on_readonly_db() {
3✔
2361
        use crate::sql::pager::{open_database_read_only, save_database};
2362

2363
        let mut seed = Database::new("t".to_string());
1✔
2364
        process_command(
2365
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
2366
            &mut seed,
2367
        )
2368
        .unwrap();
2369
        process_command("CREATE INDEX notes_body ON notes (body);", &mut seed).unwrap();
1✔
2370
        let path = {
2371
            let mut p = std::env::temp_dir();
1✔
2372
            let pid = std::process::id();
2✔
2373
            let nanos = std::time::SystemTime::now()
2✔
2374
                .duration_since(std::time::UNIX_EPOCH)
1✔
2375
                .map(|d| d.as_nanos())
3✔
2376
                .unwrap_or(0);
2377
            p.push(format!("sqlrite-drop-ro-{pid}-{nanos}.sqlrite"));
1✔
2378
            p
1✔
2379
        };
2380
        save_database(&mut seed, &path).unwrap();
2✔
2381
        drop(seed);
1✔
2382

2383
        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1✔
2384
        for stmt in ["DROP TABLE notes;", "DROP INDEX notes_body;"] {
3✔
2385
            let err = process_command(stmt, &mut ro).unwrap_err();
2✔
NEW
2386
            assert!(
×
2387
                format!("{err}").contains("read-only"),
3✔
2388
                "{stmt:?} should surface read-only error, got: {err}"
2389
            );
2390
        }
2391

2392
        let _ = std::fs::remove_file(&path);
1✔
2393
        let mut wal = path.as_os_str().to_owned();
1✔
2394
        wal.push("-wal");
1✔
2395
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
2396
    }
2397

2398
    // -------------------------------------------------------------------
2399
    // ALTER TABLE — RENAME TO / RENAME COLUMN / ADD COLUMN / DROP COLUMN
2400
    // -------------------------------------------------------------------
2401

2402
    #[test]
2403
    fn alter_rename_table_basic() {
3✔
2404
        let mut db = seed_users_table();
1✔
2405
        process_command("ALTER TABLE users RENAME TO members;", &mut db).expect("rename table");
2✔
2406
        assert!(!db.contains_table("users".to_string()));
1✔
2407
        assert!(db.contains_table("members".to_string()));
2✔
2408
        // Data still queryable under the new name.
2409
        let response = process_command("SELECT * FROM members;", &mut db).expect("select");
1✔
2410
        assert!(response.contains("3 rows returned"));
2✔
2411
    }
2412

2413
    #[test]
2414
    fn alter_rename_table_renames_auto_indexes() {
3✔
2415
        // Use a fresh table with both PK and a UNIQUE column so we
2416
        // exercise both auto-index renames in one shot.
2417
        let mut db = Database::new("t".to_string());
1✔
2418
        process_command(
2419
            "CREATE TABLE accounts (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
2420
            &mut db,
2421
        )
2422
        .unwrap();
2423
        {
2424
            let accounts = db.get_table("accounts".to_string()).unwrap();
1✔
NEW
2425
            assert!(
×
2426
                accounts
2✔
2427
                    .index_by_name("sqlrite_autoindex_accounts_id")
1✔
2428
                    .is_some()
1✔
2429
            );
NEW
2430
            assert!(
×
2431
                accounts
2✔
2432
                    .index_by_name("sqlrite_autoindex_accounts_email")
1✔
2433
                    .is_some()
1✔
2434
            );
2435
        }
2436
        process_command("ALTER TABLE accounts RENAME TO members;", &mut db).expect("rename");
1✔
2437
        let members = db.get_table("members".to_string()).unwrap();
1✔
NEW
2438
        assert!(
×
2439
            members
2✔
2440
                .index_by_name("sqlrite_autoindex_members_id")
1✔
2441
                .is_some(),
1✔
2442
            "PK auto-index should be renamed to match new table"
2443
        );
NEW
2444
        assert!(
×
2445
            members
2✔
2446
                .index_by_name("sqlrite_autoindex_members_email")
1✔
2447
                .is_some()
1✔
2448
        );
2449
        // The old-named auto-indexes should be gone.
NEW
2450
        assert!(
×
2451
            members
2✔
2452
                .index_by_name("sqlrite_autoindex_accounts_id")
1✔
2453
                .is_none()
1✔
2454
        );
2455
        // table_name field on each index should also reflect the rename.
2456
        for idx in &members.secondary_indexes {
1✔
2457
            assert_eq!(idx.table_name, "members");
2✔
2458
        }
2459
    }
2460

2461
    #[test]
2462
    fn alter_rename_table_to_existing_errors() {
3✔
2463
        let mut db = seed_users_table();
1✔
2464
        process_command("CREATE TABLE other (id INTEGER PRIMARY KEY);", &mut db).unwrap();
2✔
2465
        let err = process_command("ALTER TABLE users RENAME TO other;", &mut db).unwrap_err();
1✔
2466
        assert!(format!("{err}").contains("already exists"), "got: {err}");
2✔
2467
        // Both tables still present.
2468
        assert!(db.contains_table("users".to_string()));
1✔
2469
        assert!(db.contains_table("other".to_string()));
1✔
2470
    }
2471

2472
    #[test]
2473
    fn alter_rename_table_to_reserved_name_errors() {
3✔
2474
        let mut db = seed_users_table();
1✔
2475
        let err =
2✔
2476
            process_command("ALTER TABLE users RENAME TO sqlrite_master;", &mut db).unwrap_err();
2477
        assert!(format!("{err}").contains("reserved"), "got: {err}");
2✔
2478
    }
2479

2480
    #[test]
2481
    fn alter_rename_column_basic() {
3✔
2482
        let mut db = seed_users_table();
1✔
2483
        process_command(
2484
            "ALTER TABLE users RENAME COLUMN name TO full_name;",
2485
            &mut db,
2486
        )
2487
        .expect("rename column");
2488

2489
        let users = db.get_table("users".to_string()).unwrap();
1✔
2490
        assert!(users.contains_column("full_name".to_string()));
1✔
2491
        assert!(!users.contains_column("name".to_string()));
1✔
2492

2493
        // Existing data is queryable under the new column name and value
2494
        // is preserved at the same rowid.
2495
        let bob_rowid = users
1✔
2496
            .rowids()
2497
            .into_iter()
2498
            .find(|r| users.get_value("full_name", *r) == Some(Value::Text("bob".to_string())))
3✔
2499
            .expect("bob row should be findable under the new column name");
2500
        assert_eq!(
1✔
2501
            users.get_value("full_name", bob_rowid),
1✔
2502
            Some(Value::Text("bob".to_string()))
2✔
2503
        );
2504
    }
2505

2506
    #[test]
2507
    fn alter_rename_column_collision_errors() {
3✔
2508
        let mut db = seed_users_table();
1✔
2509
        let err =
2✔
2510
            process_command("ALTER TABLE users RENAME COLUMN name TO age;", &mut db).unwrap_err();
2511
        assert!(format!("{err}").contains("already exists"), "got: {err}");
2✔
2512
    }
2513

2514
    #[test]
2515
    fn alter_rename_column_updates_indexes() {
3✔
2516
        // `accounts.email` is UNIQUE → has a renameable auto-index.
2517
        let mut db = Database::new("t".to_string());
1✔
2518
        process_command(
2519
            "CREATE TABLE accounts (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
2520
            &mut db,
2521
        )
2522
        .unwrap();
2523
        process_command(
2524
            "ALTER TABLE accounts RENAME COLUMN email TO contact;",
2525
            &mut db,
2526
        )
2527
        .unwrap();
2528
        let accounts = db.get_table("accounts".to_string()).unwrap();
1✔
NEW
2529
        assert!(
×
2530
            accounts
2✔
2531
                .index_by_name("sqlrite_autoindex_accounts_contact")
1✔
2532
                .is_some()
1✔
2533
        );
NEW
2534
        assert!(
×
2535
            accounts
2✔
2536
                .index_by_name("sqlrite_autoindex_accounts_email")
1✔
2537
                .is_none()
1✔
2538
        );
2539
    }
2540

2541
    #[test]
2542
    fn alter_add_column_basic() {
3✔
2543
        let mut db = seed_users_table();
1✔
2544
        process_command("ALTER TABLE users ADD COLUMN nickname TEXT;", &mut db)
1✔
2545
            .expect("add column");
2546
        let users = db.get_table("users".to_string()).unwrap();
1✔
2547
        assert!(users.contains_column("nickname".to_string()));
1✔
2548
        // Existing rows read NULL for the new column (no default given).
2549
        let any_rowid = *users.rowids().first().expect("seed has rows");
1✔
2550
        assert_eq!(users.get_value("nickname", any_rowid), None);
1✔
2551

2552
        // A new INSERT supplying the new column works.
2553
        process_command(
2554
            "INSERT INTO users (name, age, nickname) VALUES ('dan', 22, 'd');",
2555
            &mut db,
2556
        )
2557
        .expect("insert with new col");
2558
        let users = db.get_table("users".to_string()).unwrap();
1✔
2559
        let dan_rowid = users
1✔
2560
            .rowids()
2561
            .into_iter()
2562
            .find(|r| users.get_value("name", *r) == Some(Value::Text("dan".to_string())))
3✔
2563
            .unwrap();
2564
        assert_eq!(
1✔
2565
            users.get_value("nickname", dan_rowid),
1✔
2566
            Some(Value::Text("d".to_string()))
2✔
2567
        );
2568
    }
2569

2570
    #[test]
2571
    fn alter_add_column_with_default_backfills_existing_rows() {
3✔
2572
        let mut db = seed_users_table();
1✔
2573
        process_command(
2574
            "ALTER TABLE users ADD COLUMN status TEXT DEFAULT 'active';",
2575
            &mut db,
2576
        )
2577
        .expect("add column with default");
2578
        let users = db.get_table("users".to_string()).unwrap();
1✔
2579
        for rowid in users.rowids() {
2✔
2580
            assert_eq!(
1✔
2581
                users.get_value("status", rowid),
1✔
2582
                Some(Value::Text("active".to_string())),
2✔
2583
                "rowid {rowid} should have been backfilled with the default"
2584
            );
2585
        }
2586
    }
2587

2588
    #[test]
2589
    fn alter_add_column_not_null_with_default_works_on_nonempty_table() {
3✔
2590
        let mut db = seed_users_table();
1✔
2591
        process_command(
2592
            "ALTER TABLE users ADD COLUMN score INTEGER NOT NULL DEFAULT 0;",
2593
            &mut db,
2594
        )
2595
        .expect("NOT NULL ADD with DEFAULT should succeed even with existing rows");
2596
        let users = db.get_table("users".to_string()).unwrap();
1✔
2597
        for rowid in users.rowids() {
2✔
2598
            assert_eq!(users.get_value("score", rowid), Some(Value::Integer(0)));
2✔
2599
        }
2600
    }
2601

2602
    #[test]
2603
    fn alter_add_column_not_null_without_default_errors_on_nonempty_table() {
3✔
2604
        let mut db = seed_users_table();
1✔
2605
        let err = process_command(
2606
            "ALTER TABLE users ADD COLUMN score INTEGER NOT NULL;",
2607
            &mut db,
2608
        )
2609
        .unwrap_err();
2610
        let msg = format!("{err}").to_lowercase();
2✔
NEW
2611
        assert!(
×
2612
            msg.contains("not null") && msg.contains("default"),
2✔
2613
            "got: {msg}"
2614
        );
2615
    }
2616

2617
    #[test]
2618
    fn alter_add_column_pk_rejected() {
3✔
2619
        let mut db = seed_users_table();
1✔
2620
        let err = process_command(
2621
            "ALTER TABLE users ADD COLUMN extra INTEGER PRIMARY KEY;",
2622
            &mut db,
2623
        )
2624
        .unwrap_err();
NEW
2625
        assert!(
×
2626
            format!("{err}").to_lowercase().contains("primary key"),
4✔
2627
            "got: {err}"
2628
        );
2629
    }
2630

2631
    #[test]
2632
    fn alter_add_column_unique_rejected() {
3✔
2633
        let mut db = seed_users_table();
1✔
2634
        let err = process_command(
2635
            "ALTER TABLE users ADD COLUMN extra INTEGER UNIQUE;",
2636
            &mut db,
2637
        )
2638
        .unwrap_err();
NEW
2639
        assert!(
×
2640
            format!("{err}").to_lowercase().contains("unique"),
4✔
2641
            "got: {err}"
2642
        );
2643
    }
2644

2645
    #[test]
2646
    fn alter_add_column_existing_name_errors() {
3✔
2647
        let mut db = seed_users_table();
1✔
2648
        let err =
2✔
2649
            process_command("ALTER TABLE users ADD COLUMN age INTEGER;", &mut db).unwrap_err();
2650
        assert!(format!("{err}").contains("already exists"), "got: {err}");
2✔
2651
    }
2652

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

2658
    #[test]
2659
    fn alter_drop_column_basic() {
3✔
2660
        let mut db = seed_users_table();
1✔
2661
        process_command("ALTER TABLE users DROP COLUMN age;", &mut db).expect("drop column");
2✔
2662
        let users = db.get_table("users".to_string()).unwrap();
1✔
2663
        assert!(!users.contains_column("age".to_string()));
1✔
2664
        // Other columns and rowids still intact.
2665
        assert!(users.contains_column("name".to_string()));
2✔
2666
        assert_eq!(users.rowids().len(), 3);
1✔
2667
    }
2668

2669
    #[test]
2670
    fn alter_drop_column_drops_dependent_indexes() {
3✔
2671
        let mut db = seed_users_table();
1✔
2672
        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2✔
2673
        process_command("ALTER TABLE users DROP COLUMN age;", &mut db).unwrap();
1✔
2674
        let users = db.get_table("users".to_string()).unwrap();
1✔
2675
        assert!(users.index_by_name("users_age_idx").is_none());
1✔
2676
    }
2677

2678
    #[test]
2679
    fn alter_drop_column_pk_errors() {
3✔
2680
        let mut db = seed_users_table();
1✔
2681
        let err = process_command("ALTER TABLE users DROP COLUMN id;", &mut db).unwrap_err();
2✔
NEW
2682
        assert!(
×
2683
            format!("{err}").to_lowercase().contains("primary key"),
4✔
2684
            "got: {err}"
2685
        );
2686
    }
2687

2688
    #[test]
2689
    fn alter_drop_column_only_column_errors() {
3✔
2690
        let mut db = Database::new("t".to_string());
1✔
2691
        process_command("CREATE TABLE solo (only_col TEXT);", &mut db).unwrap();
2✔
2692
        let err = process_command("ALTER TABLE solo DROP COLUMN only_col;", &mut db).unwrap_err();
1✔
NEW
2693
        assert!(
×
2694
            format!("{err}").to_lowercase().contains("only column"),
4✔
2695
            "got: {err}"
2696
        );
2697
    }
2698

2699
    #[test]
2700
    fn alter_unknown_table_errors_without_if_exists() {
3✔
2701
        let mut db = Database::new("t".to_string());
1✔
2702
        let err = process_command("ALTER TABLE missing RENAME TO other;", &mut db).unwrap_err();
2✔
2703
        assert!(format!("{err}").contains("does not exist"), "got: {err}");
2✔
2704
    }
2705

2706
    #[test]
2707
    fn alter_unknown_table_if_exists_noop() {
3✔
2708
        let mut db = Database::new("t".to_string());
1✔
2709
        let response = process_command("ALTER TABLE IF EXISTS missing RENAME TO other;", &mut db)
1✔
2710
            .expect("IF EXISTS makes missing-table ALTER a no-op");
2711
        assert!(response.contains("no-op"));
2✔
2712
    }
2713

2714
    #[test]
2715
    fn drop_table_inside_transaction_rolls_back() {
3✔
2716
        // Exercises Database::deep_clone snapshot path with DROP TABLE.
2717
        // A wholesale tables-HashMap restore on ROLLBACK should resurrect
2718
        // the dropped table — including its data and dependent indexes.
2719
        let mut db = seed_users_table();
1✔
2720
        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2✔
2721
        process_command("BEGIN;", &mut db).unwrap();
1✔
2722
        process_command("DROP TABLE users;", &mut db).unwrap();
1✔
2723
        assert!(!db.contains_table("users".to_string()));
1✔
2724
        process_command("ROLLBACK;", &mut db).unwrap();
2✔
2725
        assert!(db.contains_table("users".to_string()));
1✔
2726
        let users = db.get_table("users".to_string()).unwrap();
1✔
2727
        assert_eq!(users.rowids().len(), 3);
1✔
2728
        assert!(users.index_by_name("users_age_idx").is_some());
1✔
2729
    }
2730

2731
    #[test]
2732
    fn alter_inside_transaction_rolls_back() {
3✔
2733
        let mut db = seed_users_table();
1✔
2734
        process_command("BEGIN;", &mut db).unwrap();
2✔
2735
        process_command(
2736
            "ALTER TABLE users ADD COLUMN status TEXT DEFAULT 'active';",
2737
            &mut db,
2738
        )
2739
        .unwrap();
2740
        // Confirm in-flight visibility.
NEW
2741
        assert!(
×
2742
            db.get_table("users".to_string())
2✔
2743
                .unwrap()
1✔
2744
                .contains_column("status".to_string())
1✔
2745
        );
2746
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
2747
        // Snapshot restore should erase the ALTER.
NEW
2748
        assert!(
×
2749
            !db.get_table("users".to_string())
2✔
2750
                .unwrap()
1✔
2751
                .contains_column("status".to_string())
1✔
2752
        );
2753
    }
2754

2755
    #[test]
2756
    fn alter_rejected_on_readonly_db() {
3✔
2757
        use crate::sql::pager::{open_database_read_only, save_database};
2758

2759
        let mut seed = Database::new("t".to_string());
1✔
2760
        process_command(
2761
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
2762
            &mut seed,
2763
        )
2764
        .unwrap();
2765
        let path = {
2766
            let mut p = std::env::temp_dir();
1✔
2767
            let pid = std::process::id();
2✔
2768
            let nanos = std::time::SystemTime::now()
2✔
2769
                .duration_since(std::time::UNIX_EPOCH)
1✔
2770
                .map(|d| d.as_nanos())
3✔
2771
                .unwrap_or(0);
2772
            p.push(format!("sqlrite-alter-ro-{pid}-{nanos}.sqlrite"));
1✔
2773
            p
1✔
2774
        };
2775
        save_database(&mut seed, &path).unwrap();
2✔
2776
        drop(seed);
1✔
2777

2778
        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1✔
2779
        for stmt in [
3✔
2780
            "ALTER TABLE notes RENAME TO n2;",
2781
            "ALTER TABLE notes RENAME COLUMN body TO b;",
2782
            "ALTER TABLE notes ADD COLUMN extra TEXT;",
2783
            "ALTER TABLE notes DROP COLUMN body;",
2784
        ] {
2785
            let err = process_command(stmt, &mut ro).unwrap_err();
2✔
NEW
2786
            assert!(
×
2787
                format!("{err}").contains("read-only"),
3✔
2788
                "{stmt:?} should surface read-only error, got: {err}"
2789
            );
2790
        }
2791

2792
        let _ = std::fs::remove_file(&path);
1✔
2793
        let mut wal = path.as_os_str().to_owned();
1✔
2794
        wal.push("-wal");
1✔
2795
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
2796
    }
2797
}
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