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

joaoh82 / rust_sqlite / 27375296580

11 Jun 2026 08:26PM UTC coverage: 69.712% (+0.05%) from 69.659%
27375296580

push

github

web-flow
fix(sql): validate table qualifiers in FTS function column args (SQLR-15) (#168)

fts_match() / bm25_score() extract their column argument syntactically
(they need the column name to find the index, not its value), so the
qualifier never passed through RowScope::lookup and SQLR-14's check.
A bogus qualifier was silently dropped: fts_match(bogus.body, 'q') ran
as fts_match(body, 'q').

Add RowScope::scope_name() (Some(name) for single-table scope, None for
joined / group-row scopes) and run check_single_scope_qualifier in
resolve_fts_args when the column arg is a CompoundIdentifier. Error
wording matches SQLR-14. 3+-part identifiers now error like the main
evaluator instead of being silently truncated.

Audited the other syntactic extraction sites: JSON and vec_distance
helpers evaluate args through eval_expr_scope (already covered);
pager catalog re-parses are trusted engine-written SQL.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

39 of 47 new or added lines in 2 files covered. (82.98%)

1 existing line in 1 file now uncovered.

11913 of 17089 relevant lines covered (69.71%)

1.26 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

227
    // Initialy only implementing some basic SQL Statements
228
    match query {
2✔
229
        Statement::CreateTable(_) => {
230
            let create_query = CreateQuery::new(&query);
2✔
231
            match create_query {
2✔
232
                Ok(payload) => {
2✔
233
                    let table_name = payload.table_name.clone();
2✔
234
                    if table_name == pager::MASTER_TABLE_NAME {
4✔
235
                        return Err(SQLRiteError::General(format!(
×
236
                            "'{}' is a reserved name used by the internal schema catalog",
237
                            pager::MASTER_TABLE_NAME
238
                        )));
239
                    }
240
                    // Checking if table already exists, after parsing CREATE TABLE query
241
                    match db.contains_table(table_name.to_string()) {
4✔
242
                        true => {
243
                            // SQLR-10: `CREATE TABLE IF NOT EXISTS` is a no-op
244
                            // when the table already exists, so idempotent
245
                            // "run my schema on every startup" migrations work
246
                            // against a populated DB. Matches the existing
247
                            // `CREATE INDEX IF NOT EXISTS` behaviour and SQLite.
248
                            // The clause is honoured by name only — we do NOT
249
                            // diff the existing schema against the new column
250
                            // list (SQLite doesn't either).
251
                            if payload.if_not_exists {
1✔
252
                                message = format!(
2✔
253
                                    "CREATE TABLE Statement executed. (table '{table_name}' already exists, no-op)"
254
                                );
255
                            } else {
256
                                return Err(SQLRiteError::Internal(
1✔
257
                                    "Cannot create, table already exists.".to_string(),
1✔
258
                                ));
259
                            }
260
                        }
261
                        false => {
262
                            let table = Table::new(payload);
4✔
263
                            // Note: we used to call `table.print_table_schema()` here
264
                            // for REPL convenience. Removed because it wrote
265
                            // directly to stdout, which corrupted any non-REPL
266
                            // protocol channel (most painfully the MCP server's
267
                            // JSON-RPC wire). The status line below is enough for
268
                            // the REPL; users who want to inspect the schema can
269
                            // run a follow-up describe / `.tables`-style command.
270
                            db.tables.insert(table_name.to_string(), table);
4✔
271
                            message = String::from("CREATE TABLE Statement executed.");
2✔
272
                        }
273
                    }
274
                }
275
                Err(err) => return Err(err),
1✔
276
            }
277
        }
278
        Statement::Insert(_) => {
279
            let insert_query = InsertQuery::new(&query);
2✔
280
            match insert_query {
2✔
281
                Ok(payload) => {
2✔
282
                    let table_name = payload.table_name;
2✔
283
                    let columns = payload.columns;
2✔
284
                    let values = payload.rows;
2✔
285

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

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

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

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

467
    Ok(CommandOutput {
2✔
468
        status: message,
2✔
469
        rendered,
2✔
470
    })
471
}
472

473
#[cfg(test)]
474
mod tests {
475
    use super::*;
476
    use crate::sql::db::table::Value;
477

478
    /// Builds a `users(id INTEGER PK, name TEXT, age INTEGER)` table populated
479
    /// with three rows, for use in executor-level tests.
480
    fn seed_users_table() -> Database {
1✔
481
        let mut db = Database::new("tempdb".to_string());
1✔
482
        process_command(
483
            "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, age INTEGER);",
484
            &mut db,
485
        )
486
        .expect("create table");
487
        process_command(
488
            "INSERT INTO users (name, age) VALUES ('alice', 30);",
489
            &mut db,
490
        )
491
        .expect("insert alice");
492
        process_command("INSERT INTO users (name, age) VALUES ('bob', 25);", &mut db)
1✔
493
            .expect("insert bob");
494
        process_command(
495
            "INSERT INTO users (name, age) VALUES ('carol', 40);",
496
            &mut db,
497
        )
498
        .expect("insert carol");
499
        db
1✔
500
    }
501

502
    #[test]
503
    fn process_command_select_all_test() {
3✔
504
        let mut db = seed_users_table();
1✔
505
        let response = process_command("SELECT * FROM users;", &mut db).expect("select");
2✔
506
        assert!(response.contains("3 rows returned"));
2✔
507
    }
508

509
    #[test]
510
    fn process_command_select_where_test() {
3✔
511
        let mut db = seed_users_table();
1✔
512
        let response =
2✔
513
            process_command("SELECT name FROM users WHERE age > 25;", &mut db).expect("select");
514
        assert!(response.contains("2 rows returned"));
2✔
515
    }
516

517
    #[test]
518
    fn process_command_select_eq_string_test() {
3✔
519
        let mut db = seed_users_table();
1✔
520
        let response =
2✔
521
            process_command("SELECT name FROM users WHERE name = 'bob';", &mut db).expect("select");
522
        assert!(response.contains("1 row returned"));
2✔
523
    }
524

525
    #[test]
526
    fn process_command_select_limit_test() {
3✔
527
        let mut db = seed_users_table();
1✔
528
        let response = process_command("SELECT * FROM users ORDER BY age ASC LIMIT 2;", &mut db)
1✔
529
            .expect("select");
530
        assert!(response.contains("2 rows returned"));
2✔
531
    }
532

533
    #[test]
534
    fn process_command_select_unknown_table_test() {
3✔
535
        let mut db = Database::new("tempdb".to_string());
1✔
536
        let result = process_command("SELECT * FROM nope;", &mut db);
1✔
537
        assert!(result.is_err());
2✔
538
    }
539

540
    #[test]
541
    fn process_command_select_unknown_column_test() {
3✔
542
        let mut db = seed_users_table();
1✔
543
        let result = process_command("SELECT height FROM users;", &mut db);
1✔
544
        assert!(result.is_err());
2✔
545
    }
546

547
    #[test]
548
    fn process_command_insert_test() {
3✔
549
        // Creating temporary database
550
        let mut db = Database::new("tempdb".to_string());
1✔
551

552
        // Creating temporary table for testing purposes
553
        let query_statement = "CREATE TABLE users (
1✔
554
            id INTEGER PRIMARY KEY,
555
            name TEXT
556
        );";
557
        let dialect = SqlriteDialect::new();
1✔
558
        let mut ast = Parser::parse_sql(&dialect, query_statement).unwrap();
1✔
559
        if ast.len() > 1 {
2✔
560
            panic!("Expected a single query statement, but there are more then 1.")
×
561
        }
562
        let query = ast.pop().unwrap();
2✔
563
        let create_query = CreateQuery::new(&query).unwrap();
2✔
564

565
        // Inserting table into database
566
        db.tables.insert(
2✔
567
            create_query.table_name.to_string(),
2✔
568
            Table::new(create_query),
1✔
569
        );
570

571
        // Inserting data into table
572
        let insert_query = String::from("INSERT INTO users (name) Values ('josh');");
1✔
573
        match process_command(&insert_query, &mut db) {
2✔
574
            Ok(response) => assert_eq!(response, "INSERT Statement executed."),
1✔
575
            Err(err) => {
×
576
                eprintln!("Error: {}", err);
×
577
                assert!(false)
×
578
            }
579
        };
580
    }
581

582
    #[test]
583
    fn process_command_insert_no_pk_test() {
3✔
584
        // Creating temporary database
585
        let mut db = Database::new("tempdb".to_string());
1✔
586

587
        // Creating temporary table for testing purposes
588
        let query_statement = "CREATE TABLE users (
1✔
589
            name TEXT
590
        );";
591
        let dialect = SqlriteDialect::new();
1✔
592
        let mut ast = Parser::parse_sql(&dialect, query_statement).unwrap();
1✔
593
        if ast.len() > 1 {
2✔
594
            panic!("Expected a single query statement, but there are more then 1.")
×
595
        }
596
        let query = ast.pop().unwrap();
2✔
597
        let create_query = CreateQuery::new(&query).unwrap();
2✔
598

599
        // Inserting table into database
600
        db.tables.insert(
2✔
601
            create_query.table_name.to_string(),
2✔
602
            Table::new(create_query),
1✔
603
        );
604

605
        // Inserting data into table
606
        let insert_query = String::from("INSERT INTO users (name) Values ('josh');");
1✔
607
        match process_command(&insert_query, &mut db) {
2✔
608
            Ok(response) => assert_eq!(response, "INSERT Statement executed."),
1✔
609
            Err(err) => {
×
610
                eprintln!("Error: {}", err);
×
611
                assert!(false)
×
612
            }
613
        };
614
    }
615

616
    #[test]
617
    fn process_command_delete_where_test() {
4✔
618
        let mut db = seed_users_table();
1✔
619
        let response =
2✔
620
            process_command("DELETE FROM users WHERE name = 'bob';", &mut db).expect("delete");
621
        assert!(response.contains("1 row deleted"));
2✔
622

623
        let remaining = process_command("SELECT * FROM users;", &mut db).expect("select");
1✔
624
        assert!(remaining.contains("2 rows returned"));
2✔
625
    }
626

627
    #[test]
628
    fn process_command_delete_all_test() {
3✔
629
        let mut db = seed_users_table();
1✔
630
        let response = process_command("DELETE FROM users;", &mut db).expect("delete");
2✔
631
        assert!(response.contains("3 rows deleted"));
2✔
632
    }
633

634
    #[test]
635
    fn process_command_update_where_test() {
3✔
636
        use crate::sql::db::table::Value;
637

638
        let mut db = seed_users_table();
1✔
639
        let response = process_command("UPDATE users SET age = 99 WHERE name = 'bob';", &mut db)
1✔
640
            .expect("update");
641
        assert!(response.contains("1 row updated"));
2✔
642

643
        // Confirm the cell was actually rewritten.
644
        let users = db.get_table("users".to_string()).unwrap();
1✔
645
        let bob_rowid = users
1✔
646
            .rowids()
647
            .into_iter()
648
            .find(|r| users.get_value("name", *r) == Some(Value::Text("bob".to_string())))
3✔
649
            .expect("bob row must exist");
650
        assert_eq!(users.get_value("age", bob_rowid), Some(Value::Integer(99)));
1✔
651
    }
652

653
    #[test]
654
    fn process_command_update_unique_violation_test() {
3✔
655
        let mut db = seed_users_table();
1✔
656
        // `name` is not UNIQUE in the seed — reinforce with an explicit unique column.
657
        process_command(
658
            "CREATE TABLE tags (id INTEGER PRIMARY KEY, label TEXT UNIQUE);",
659
            &mut db,
660
        )
661
        .unwrap();
662
        process_command("INSERT INTO tags (label) VALUES ('a');", &mut db).unwrap();
1✔
663
        process_command("INSERT INTO tags (label) VALUES ('b');", &mut db).unwrap();
1✔
664

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

669
    #[test]
670
    fn process_command_insert_type_mismatch_returns_error_test() {
3✔
671
        // Previously this panicked in parse::<i32>().unwrap(); now it should return an error cleanly.
672
        let mut db = Database::new("tempdb".to_string());
1✔
673
        process_command(
674
            "CREATE TABLE items (id INTEGER PRIMARY KEY, qty INTEGER);",
675
            &mut db,
676
        )
677
        .unwrap();
678
        let result = process_command("INSERT INTO items (qty) VALUES ('not a number');", &mut db);
1✔
679
        assert!(result.is_err(), "expected error, got {result:?}");
2✔
680
    }
681

682
    #[test]
683
    fn insert_omitted_integer_column_is_stored_as_null() {
3✔
684
        // SQLR-7 — pre-fix this errored because the omitted column was
685
        // padded with the literal `"Null"` and then re-parsed as i32. The
686
        // new INSERT pipeline carries `Option<Value>` from the parser
687
        // through to `insert_row`, so a missing non-PK column is just
688
        // SQL NULL (matches SQLite).
689
        use crate::sql::db::table::Value;
690

691
        let mut db = Database::new("tempdb".to_string());
1✔
692
        process_command(
693
            "CREATE TABLE items (id INTEGER PRIMARY KEY, qty INTEGER);",
694
            &mut db,
695
        )
696
        .unwrap();
697
        process_command("INSERT INTO items (id) VALUES (1);", &mut db)
1✔
698
            .expect("INSERT with omitted INTEGER column should succeed and store NULL");
699

700
        let table = db.get_table("items".to_string()).unwrap();
1✔
701
        let rowid = table.rowids().pop().expect("one row");
1✔
702
        // BTreeMap entry was never written → get_value returns None,
703
        // which the executor renders as Value::Null.
704
        assert_eq!(table.get_value("qty", rowid), None);
1✔
705
        // IS NULL via the executor sees the same NULL.
706
        let response = process_command("SELECT id FROM items WHERE qty IS NULL;", &mut db)
1✔
707
            .expect("select IS NULL");
708
        assert!(
×
709
            response.contains("1 row returned"),
2✔
710
            "qty IS NULL should match the omitted-column row, got: {response}"
711
        );
712
        // Sanity: explicit literal stays Integer.
713
        process_command("INSERT INTO items (id, qty) VALUES (2, 7);", &mut db).unwrap();
2✔
714
        let table = db.get_table("items".to_string()).unwrap();
1✔
715
        let row_two = table
1✔
716
            .rowids()
717
            .into_iter()
718
            .find(|r| table.get_value("id", *r) == Some(Value::Integer(2)))
3✔
719
            .unwrap();
720
        assert_eq!(table.get_value("qty", row_two), Some(Value::Integer(7)));
1✔
721
    }
722

723
    #[test]
724
    fn insert_explicit_null_into_integer_column() {
3✔
725
        let mut db = Database::new("tempdb".to_string());
1✔
726
        process_command(
727
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER);",
728
            &mut db,
729
        )
730
        .unwrap();
731
        process_command("INSERT INTO t (id, n) VALUES (1, NULL);", &mut db)
1✔
732
            .expect("INSERT explicit NULL into INTEGER must not panic on parse::<i32>()");
733
        let table = db.get_table("t".to_string()).unwrap();
1✔
734
        let rowid = table.rowids().pop().unwrap();
1✔
735
        assert_eq!(table.get_value("n", rowid), None);
1✔
736
    }
737

738
    #[test]
739
    fn insert_explicit_null_into_text_column() {
3✔
740
        // Pre-fix: the literal string "Null" was stored in the BTreeMap and
741
        // a read-side workaround (`if v == "Null"`) re-mapped it back to
742
        // Value::Null. Post-fix: nothing is stored at all, so a user-typed
743
        // string `'Null'` no longer collides with SQL NULL.
744
        use crate::sql::db::table::Value;
745

746
        let mut db = Database::new("tempdb".to_string());
1✔
747
        process_command("CREATE TABLE t (id INTEGER PRIMARY KEY, s TEXT);", &mut db).unwrap();
2✔
748
        process_command("INSERT INTO t (id, s) VALUES (1, NULL);", &mut db).unwrap();
1✔
749
        process_command("INSERT INTO t (id, s) VALUES (2, 'hi');", &mut db).unwrap();
1✔
750

751
        let table = db.get_table("t".to_string()).unwrap();
1✔
752
        let row_one = table
1✔
753
            .rowids()
754
            .into_iter()
755
            .find(|r| table.get_value("id", *r) == Some(Value::Integer(1)))
3✔
756
            .unwrap();
757
        let row_two = table
1✔
758
            .rowids()
759
            .into_iter()
760
            .find(|r| table.get_value("id", *r) == Some(Value::Integer(2)))
3✔
761
            .unwrap();
762
        assert_eq!(table.get_value("s", row_one), None);
1✔
763
        assert_eq!(
1✔
764
            table.get_value("s", row_two),
1✔
765
            Some(Value::Text("hi".to_string()))
2✔
766
        );
767
    }
768

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

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

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

814
    #[test]
815
    fn insert_explicit_null_into_json_column() {
3✔
816
        let mut db = Database::new("tempdb".to_string());
1✔
817
        process_command(
818
            "CREATE TABLE t (id INTEGER PRIMARY KEY, doc JSON);",
819
            &mut db,
820
        )
821
        .unwrap();
822
        process_command("INSERT INTO t (id, doc) VALUES (1, NULL);", &mut db)
1✔
823
            .expect("INSERT explicit NULL into JSON must skip serde_json validation");
824
        let table = db.get_table("t".to_string()).unwrap();
1✔
825
        let rowid = table.rowids().pop().unwrap();
1✔
826
        assert_eq!(table.get_value("doc", rowid), None);
1✔
827
    }
828

829
    #[test]
830
    fn default_does_not_override_explicit_null() {
3✔
831
        // Restored from SQLR-2 (was dropped because it collided with the
832
        // stringly-typed NULL handling SQLR-7 fixes). Column has DEFAULT 0;
833
        // an explicit NULL in the INSERT must override the default and
834
        // store NULL — the default only fires when the column is omitted.
835
        use crate::sql::db::table::Value;
836

837
        let mut db = Database::new("tempdb".to_string());
1✔
838
        process_command(
839
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER DEFAULT 0);",
840
            &mut db,
841
        )
842
        .unwrap();
843
        process_command("INSERT INTO t (id, n) VALUES (1, NULL);", &mut db).unwrap();
1✔
844
        process_command("INSERT INTO t (id) VALUES (2);", &mut db).unwrap();
1✔
845

846
        let table = db.get_table("t".to_string()).unwrap();
1✔
847
        let row_one = table
1✔
848
            .rowids()
849
            .into_iter()
850
            .find(|r| table.get_value("id", *r) == Some(Value::Integer(1)))
3✔
851
            .unwrap();
852
        let row_two = table
1✔
853
            .rowids()
854
            .into_iter()
855
            .find(|r| table.get_value("id", *r) == Some(Value::Integer(2)))
3✔
856
            .unwrap();
857
        // Explicit NULL: stored as NULL, not the default.
858
        assert_eq!(table.get_value("n", row_one), None);
1✔
859
        // Omitted: stored as the DEFAULT 0.
860
        assert_eq!(table.get_value("n", row_two), Some(Value::Integer(0)));
1✔
861
    }
862

863
    #[test]
864
    fn process_command_update_arith_test() {
3✔
865
        use crate::sql::db::table::Value;
866

867
        let mut db = seed_users_table();
1✔
868
        process_command("UPDATE users SET age = age + 1;", &mut db).expect("update +1");
2✔
869

870
        let users = db.get_table("users".to_string()).unwrap();
1✔
871
        let mut ages: Vec<i64> = users
872
            .rowids()
873
            .into_iter()
874
            .filter_map(|r| match users.get_value("age", r) {
3✔
875
                Some(Value::Integer(n)) => Some(n),
1✔
876
                _ => None,
×
877
            })
878
            .collect();
879
        ages.sort();
2✔
880
        assert_eq!(ages, vec![26, 31, 41]); // 25+1, 30+1, 40+1
1✔
881
    }
882

883
    #[test]
884
    fn process_command_select_arithmetic_where_test() {
3✔
885
        let mut db = seed_users_table();
1✔
886
        // age * 2 > 55  →  only ages > 27.5  →  alice(30) + carol(40)
887
        let response =
2✔
888
            process_command("SELECT name FROM users WHERE age * 2 > 55;", &mut db).expect("select");
889
        assert!(response.contains("2 rows returned"));
2✔
890
    }
891

892
    #[test]
893
    fn process_command_divide_by_zero_test() {
3✔
894
        let mut db = seed_users_table();
1✔
895
        let result = process_command("SELECT age / 0 FROM users;", &mut db);
1✔
896
        // Projection only supports bare columns, so this errors earlier; still shouldn't panic.
897
        assert!(result.is_err());
2✔
898
    }
899

900
    #[test]
901
    fn process_command_unsupported_statement_test() {
3✔
902
        let mut db = Database::new("tempdb".to_string());
1✔
903
        // CREATE VIEW is firmly in the "Not yet supported" list — used as
904
        // the canary for the dispatcher's NotImplemented arm. (DROP TABLE
905
        // moved out of unsupported in this branch.)
906
        let result = process_command("CREATE VIEW v AS SELECT * FROM users;", &mut db);
1✔
907
        assert!(result.is_err());
2✔
908
    }
909

910
    #[test]
911
    fn empty_input_is_a_noop_not_a_panic() {
3✔
912
        // Regression for: desktop app pre-fills the textarea with a
913
        // comment-only placeholder, and hitting Run used to panic because
914
        // sqlparser produced zero statements and pop().unwrap() exploded.
915
        let mut db = Database::new("t".to_string());
1✔
916
        for input in ["", "   ", "-- just a comment", "-- comment\n-- another"] {
4✔
917
            let result = process_command(input, &mut db);
2✔
918
            assert!(result.is_ok(), "input {input:?} should not error");
2✔
919
            let msg = result.unwrap();
1✔
920
            assert!(msg.contains("No statement"), "got: {msg:?}");
2✔
921
        }
922
    }
923

924
    #[test]
925
    fn create_index_adds_explicit_index() {
3✔
926
        let mut db = seed_users_table();
1✔
927
        let response = process_command("CREATE INDEX users_age_idx ON users (age);", &mut db)
1✔
928
            .expect("create index");
929
        assert!(response.contains("users_age_idx"));
2✔
930

931
        // The index should now be attached to the users table.
932
        let users = db.get_table("users".to_string()).unwrap();
1✔
933
        let idx = users
1✔
934
            .index_by_name("users_age_idx")
935
            .expect("index should exist after CREATE INDEX");
936
        assert_eq!(idx.column_name, "age");
1✔
937
        assert!(!idx.is_unique);
1✔
938
    }
939

940
    #[test]
941
    fn create_unique_index_rejects_duplicate_existing_values() {
3✔
942
        let mut db = seed_users_table();
1✔
943
        // `name` is already UNIQUE (auto-indexed); insert a duplicate-age row
944
        // first so CREATE UNIQUE INDEX on age catches the conflict.
945
        process_command("INSERT INTO users (name, age) VALUES ('dan', 30);", &mut db).unwrap();
2✔
946
        let result = process_command(
947
            "CREATE UNIQUE INDEX users_age_unique ON users (age);",
948
            &mut db,
949
        );
950
        assert!(
×
951
            result.is_err(),
2✔
952
            "expected unique-index failure, got {result:?}"
953
        );
954
    }
955

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

981
    #[test]
982
    fn where_eq_on_indexed_column_inside_parens_uses_index_probe() {
3✔
983
        let mut db = seed_users_table();
1✔
984
        let response = process_command("SELECT name FROM users WHERE (name = 'bob');", &mut db)
1✔
985
            .expect("select");
986
        assert!(response.contains("1 row returned"));
2✔
987
    }
988

989
    #[test]
990
    fn where_eq_literal_first_side_uses_index_probe() {
3✔
991
        let mut db = seed_users_table();
1✔
992
        // `'bob' = name` should hit the same path as `name = 'bob'`.
993
        let response =
2✔
994
            process_command("SELECT name FROM users WHERE 'bob' = name;", &mut db).expect("select");
995
        assert!(response.contains("1 row returned"));
2✔
996
    }
997

998
    #[test]
999
    fn non_equality_where_still_falls_back_to_full_scan() {
3✔
1000
        // Sanity: range predicates bypass the optimizer and the full-scan
1001
        // path still returns correct results.
1002
        let mut db = seed_users_table();
1✔
1003
        let response =
2✔
1004
            process_command("SELECT name FROM users WHERE age > 28;", &mut db).expect("select");
1005
        assert!(response.contains("2 rows returned"));
2✔
1006
    }
1007

1008
    // -------------------------------------------------------------------
1009
    // Phase 4f — Transactions (BEGIN / COMMIT / ROLLBACK)
1010
    // -------------------------------------------------------------------
1011

1012
    #[test]
1013
    fn rollback_restores_pre_begin_in_memory_state() {
3✔
1014
        // In-memory DB (no pager): BEGIN, insert a row, ROLLBACK.
1015
        // The row must disappear from the live tables HashMap.
1016
        let mut db = seed_users_table();
1✔
1017
        let before = db.get_table("users".to_string()).unwrap().rowids().len();
2✔
1018
        assert_eq!(before, 3);
1✔
1019

1020
        process_command("BEGIN;", &mut db).expect("BEGIN");
1✔
1021
        assert!(db.in_transaction());
1✔
1022
        process_command("INSERT INTO users (name, age) VALUES ('dan', 50);", &mut db)
1✔
1023
            .expect("INSERT inside txn");
1024
        // Mid-transaction read sees the new row.
1025
        let mid = db.get_table("users".to_string()).unwrap().rowids().len();
1✔
1026
        assert_eq!(mid, 4);
1✔
1027

1028
        process_command("ROLLBACK;", &mut db).expect("ROLLBACK");
1✔
1029
        assert!(!db.in_transaction());
1✔
1030
        let after = db.get_table("users".to_string()).unwrap().rowids().len();
2✔
1031
        assert_eq!(
1✔
1032
            after, 3,
1033
            "ROLLBACK should have restored the pre-BEGIN state"
1034
        );
1035
    }
1036

1037
    #[test]
1038
    fn commit_keeps_mutations_and_clears_txn_flag() {
3✔
1039
        let mut db = seed_users_table();
1✔
1040
        process_command("BEGIN;", &mut db).expect("BEGIN");
2✔
1041
        process_command("INSERT INTO users (name, age) VALUES ('dan', 50);", &mut db)
1✔
1042
            .expect("INSERT inside txn");
1043
        process_command("COMMIT;", &mut db).expect("COMMIT");
1✔
1044
        assert!(!db.in_transaction());
1✔
1045
        let after = db.get_table("users".to_string()).unwrap().rowids().len();
2✔
1046
        assert_eq!(after, 4);
1✔
1047
    }
1048

1049
    #[test]
1050
    fn rollback_undoes_update_and_delete_side_by_side() {
3✔
1051
        use crate::sql::db::table::Value;
1052

1053
        let mut db = seed_users_table();
1✔
1054
        process_command("BEGIN;", &mut db).unwrap();
2✔
1055
        process_command("UPDATE users SET age = 999;", &mut db).unwrap();
1✔
1056
        process_command("DELETE FROM users WHERE name = 'bob';", &mut db).unwrap();
1✔
1057
        // Mid-txn: one row gone, others have age=999.
1058
        let users = db.get_table("users".to_string()).unwrap();
1✔
1059
        assert_eq!(users.rowids().len(), 2);
1✔
1060
        for r in users.rowids() {
2✔
1061
            assert_eq!(users.get_value("age", r), Some(Value::Integer(999)));
2✔
1062
        }
1063

1064
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1065
        let users = db.get_table("users".to_string()).unwrap();
1✔
1066
        assert_eq!(users.rowids().len(), 3);
1✔
1067
        // Original ages {30, 25, 40} — none should be 999.
1068
        for r in users.rowids() {
2✔
1069
            assert_ne!(users.get_value("age", r), Some(Value::Integer(999)));
2✔
1070
        }
1071
    }
1072

1073
    #[test]
1074
    fn nested_begin_is_rejected() {
3✔
1075
        let mut db = seed_users_table();
1✔
1076
        process_command("BEGIN;", &mut db).unwrap();
2✔
1077
        let err = process_command("BEGIN;", &mut db).unwrap_err();
1✔
1078
        assert!(
×
1079
            format!("{err}").contains("already open"),
3✔
1080
            "nested BEGIN should error; got: {err}"
1081
        );
1082
        // Still in the original transaction; a ROLLBACK clears it.
1083
        assert!(db.in_transaction());
1✔
1084
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1085
    }
1086

1087
    #[test]
1088
    fn orphan_commit_and_rollback_are_rejected() {
3✔
1089
        let mut db = seed_users_table();
1✔
1090
        let commit_err = process_command("COMMIT;", &mut db).unwrap_err();
2✔
1091
        assert!(format!("{commit_err}").contains("no transaction"));
2✔
1092
        let rollback_err = process_command("ROLLBACK;", &mut db).unwrap_err();
1✔
1093
        assert!(format!("{rollback_err}").contains("no transaction"));
2✔
1094
    }
1095

1096
    #[test]
1097
    fn error_inside_transaction_keeps_txn_open() {
3✔
1098
        // A bad INSERT inside a txn doesn't commit or abort automatically —
1099
        // the user can still ROLLBACK. SQLite's implicit-rollback behavior
1100
        // isn't modeled here.
1101
        let mut db = seed_users_table();
1✔
1102
        process_command("BEGIN;", &mut db).unwrap();
2✔
1103
        let err = process_command("INSERT INTO nope (x) VALUES (1);", &mut db);
1✔
1104
        assert!(err.is_err());
2✔
1105
        assert!(db.in_transaction(), "txn should stay open after error");
1✔
1106
        process_command("ROLLBACK;", &mut db).unwrap();
2✔
1107
    }
1108

1109
    /// Builds a file-backed Database at a unique temp path, with the
1110
    /// schema seeded and `source_path` set so subsequent process_command
1111
    /// calls auto-save. Returns (path, db). Drop the db before deleting
1112
    /// the files.
1113
    fn seed_file_backed(name: &str, schema: &str) -> (std::path::PathBuf, Database) {
1✔
1114
        use crate::sql::pager::{open_database, save_database};
1115
        let mut p = std::env::temp_dir();
1✔
1116
        let pid = std::process::id();
2✔
1117
        let nanos = std::time::SystemTime::now()
2✔
1118
            .duration_since(std::time::UNIX_EPOCH)
1✔
1119
            .map(|d| d.as_nanos())
3✔
1120
            .unwrap_or(0);
1121
        p.push(format!("sqlrite-txn-{name}-{pid}-{nanos}.sqlrite"));
1✔
1122

1123
        // Seed the file, then reopen to get a source_path-attached db
1124
        // (save_database alone doesn't attach a fresh pager to a db
1125
        // whose source_path was None before the call).
1126
        {
1127
            let mut seed = Database::new("t".to_string());
1✔
1128
            process_command(schema, &mut seed).unwrap();
2✔
1129
            save_database(&mut seed, &p).unwrap();
1✔
1130
        }
1131
        let db = open_database(&p, "t".to_string()).unwrap();
1✔
1132
        (p, db)
1✔
1133
    }
1134

1135
    fn cleanup_file(path: &std::path::Path) {
1✔
1136
        let _ = std::fs::remove_file(path);
1✔
1137
        let mut wal = path.as_os_str().to_owned();
1✔
1138
        wal.push("-wal");
1✔
1139
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
1140
    }
1141

1142
    #[test]
1143
    fn null_values_round_trip_through_disk() {
3✔
1144
        // SQLR-7 — explicit NULLs and omitted columns must persist.
1145
        // Pre-fix, restore_row rejected NULL for INTEGER/REAL/BOOL/VECTOR
1146
        // columns ("Integer column 'n' cannot store NULL — corrupt
1147
        // cell?"), so any DB containing a NULL in those types failed to
1148
        // reopen.
1149
        use crate::sql::db::table::Value;
1150
        use crate::sql::pager::open_database;
1151

1152
        let (path, mut db) = seed_file_backed(
1153
            "nullrt",
1154
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER, s TEXT, score REAL, flag BOOLEAN);",
1155
        );
1156
        process_command(
1157
            "INSERT INTO t (id, n, s, score, flag) VALUES (1, 10, 'hi', 1.5, true);",
1158
            &mut db,
1159
        )
1160
        .unwrap();
1161
        process_command(
1162
            "INSERT INTO t (id, n, s, score, flag) VALUES (2, NULL, NULL, NULL, NULL);",
1163
            &mut db,
1164
        )
1165
        .unwrap();
1166
        // Row 3 omits every nullable column.
1167
        process_command("INSERT INTO t (id) VALUES (3);", &mut db).unwrap();
1✔
1168

1169
        drop(db); // release pager lock
1✔
1170

1171
        let reopened = open_database(&path, "t".to_string()).unwrap();
1✔
1172
        let t = reopened.get_table("t".to_string()).unwrap();
2✔
1173
        let by_id = |id: i64| {
2✔
1174
            t.rowids()
1✔
1175
                .into_iter()
1176
                .find(|r| t.get_value("id", *r) == Some(Value::Integer(id)))
3✔
1177
                .unwrap_or_else(|| panic!("row id={id} not found"))
×
1178
        };
1179

1180
        let r1 = by_id(1);
1✔
1181
        assert_eq!(t.get_value("n", r1), Some(Value::Integer(10)));
1✔
1182
        assert_eq!(t.get_value("s", r1), Some(Value::Text("hi".to_string())));
1✔
1183
        assert_eq!(t.get_value("score", r1), Some(Value::Real(1.5)));
1✔
1184
        assert_eq!(t.get_value("flag", r1), Some(Value::Bool(true)));
1✔
1185

1186
        for r in [by_id(2), by_id(3)] {
2✔
1187
            assert_eq!(t.get_value("n", r), None, "INTEGER NULL must round-trip");
2✔
1188
            assert_eq!(t.get_value("s", r), None, "TEXT NULL must round-trip");
1✔
1189
            assert_eq!(t.get_value("score", r), None, "REAL NULL must round-trip");
1✔
1190
            assert_eq!(t.get_value("flag", r), None, "BOOL NULL must round-trip");
1✔
1191
        }
1192

1193
        drop(reopened);
1✔
1194
        cleanup_file(&path);
1✔
1195
    }
1196

1197
    #[test]
1198
    fn begin_commit_rollback_round_trip_through_disk() {
3✔
1199
        // File-backed DB: commit inside a transaction must actually
1200
        // persist. ROLLBACK inside a *later* transaction must not
1201
        // un-do the previously-committed changes.
1202
        use crate::sql::pager::open_database;
1203

1204
        let (path, mut db) = seed_file_backed(
1205
            "roundtrip",
1206
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1207
        );
1208

1209
        // Transaction 1: insert two rows, commit.
1210
        process_command("BEGIN;", &mut db).unwrap();
2✔
1211
        process_command("INSERT INTO notes (body) VALUES ('a');", &mut db).unwrap();
1✔
1212
        process_command("INSERT INTO notes (body) VALUES ('b');", &mut db).unwrap();
1✔
1213
        process_command("COMMIT;", &mut db).unwrap();
1✔
1214

1215
        // Transaction 2: insert another, roll back.
1216
        process_command("BEGIN;", &mut db).unwrap();
1✔
1217
        process_command("INSERT INTO notes (body) VALUES ('c');", &mut db).unwrap();
1✔
1218
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1219

1220
        drop(db); // release pager lock
1✔
1221

1222
        let reopened = open_database(&path, "t".to_string()).unwrap();
1✔
1223
        let notes = reopened.get_table("notes".to_string()).unwrap();
2✔
1224
        assert_eq!(notes.rowids().len(), 2, "committed rows should survive");
1✔
1225

1226
        drop(reopened);
1✔
1227
        cleanup_file(&path);
1✔
1228
    }
1229

1230
    #[test]
1231
    fn write_inside_transaction_does_not_autosave() {
3✔
1232
        // File-backed DB: writes inside BEGIN/…/COMMIT must NOT hit
1233
        // the WAL until COMMIT. We prove it by checking the WAL file
1234
        // size before vs during the transaction.
1235
        let (path, mut db) =
1✔
1236
            seed_file_backed("noas", "CREATE TABLE t (id INTEGER PRIMARY KEY, x TEXT);");
1237

1238
        let mut wal_path = path.as_os_str().to_owned();
2✔
1239
        wal_path.push("-wal");
1✔
1240
        let wal_path = std::path::PathBuf::from(wal_path);
1✔
1241
        let frames_before = std::fs::metadata(&wal_path).unwrap().len();
2✔
1242

1243
        process_command("BEGIN;", &mut db).unwrap();
1✔
1244
        process_command("INSERT INTO t (x) VALUES ('a');", &mut db).unwrap();
1✔
1245
        process_command("INSERT INTO t (x) VALUES ('b');", &mut db).unwrap();
1✔
1246

1247
        // Mid-transaction: WAL must be unchanged — no auto-save fired.
1248
        let frames_mid = std::fs::metadata(&wal_path).unwrap().len();
1✔
1249
        assert_eq!(
1✔
1250
            frames_before, frames_mid,
1251
            "WAL should not grow during an open transaction"
1252
        );
1253

1254
        process_command("COMMIT;", &mut db).unwrap();
2✔
1255

1256
        drop(db); // release pager lock
1✔
1257
        let fresh = crate::sql::pager::open_database(&path, "t".to_string()).unwrap();
1✔
1258
        assert_eq!(
1✔
1259
            fresh.get_table("t".to_string()).unwrap().rowids().len(),
3✔
1260
            2,
1261
            "COMMIT should have persisted both inserted rows"
1262
        );
1263
        drop(fresh);
1✔
1264
        cleanup_file(&path);
1✔
1265
    }
1266

1267
    #[test]
1268
    fn rollback_undoes_create_table() {
3✔
1269
        // Schema DDL inside a txn: ROLLBACK must make the new table
1270
        // disappear. The txn snapshot captures db.tables as of BEGIN,
1271
        // and ROLLBACK reassigns tables from that snapshot, so a table
1272
        // created mid-transaction has no entry in the snapshot.
1273
        let mut db = seed_users_table();
1✔
1274
        assert_eq!(db.tables.len(), 1);
2✔
1275

1276
        process_command("BEGIN;", &mut db).unwrap();
1✔
1277
        process_command(
1278
            "CREATE TABLE dropme (id INTEGER PRIMARY KEY, x TEXT);",
1279
            &mut db,
1280
        )
1281
        .unwrap();
1282
        process_command("INSERT INTO dropme (x) VALUES ('stuff');", &mut db).unwrap();
1✔
1283
        assert_eq!(db.tables.len(), 2);
1✔
1284

1285
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1286
        assert_eq!(
1✔
1287
            db.tables.len(),
1✔
1288
            1,
1289
            "CREATE TABLE should have been rolled back"
1290
        );
1291
        assert!(db.get_table("dropme".to_string()).is_err());
2✔
1292
    }
1293

1294
    #[test]
1295
    fn rollback_restores_secondary_index_state() {
3✔
1296
        // Phase 4f edge case: rolling back an INSERT on a UNIQUE-indexed
1297
        // column must also clean up the index, otherwise a re-insert of
1298
        // the same value would spuriously collide.
1299
        let mut db = Database::new("t".to_string());
1✔
1300
        process_command(
1301
            "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
1302
            &mut db,
1303
        )
1304
        .unwrap();
1305
        process_command("INSERT INTO users (email) VALUES ('a@x');", &mut db).unwrap();
1✔
1306

1307
        process_command("BEGIN;", &mut db).unwrap();
1✔
1308
        process_command("INSERT INTO users (email) VALUES ('b@x');", &mut db).unwrap();
1✔
1309
        // Inside the txn: the index now contains both 'a@x' and 'b@x'.
1310
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1311

1312
        // Re-inserting 'b@x' after rollback must succeed — if the index
1313
        // wasn't properly restored, it would think 'b@x' is still a
1314
        // collision and fail with a UNIQUE violation.
1315
        let reinsert = process_command("INSERT INTO users (email) VALUES ('b@x');", &mut db);
1✔
1316
        assert!(
×
1317
            reinsert.is_ok(),
2✔
1318
            "re-insert after rollback should succeed, got {reinsert:?}"
1319
        );
1320
    }
1321

1322
    #[test]
1323
    fn rollback_restores_last_rowid_counter() {
3✔
1324
        // Rowids allocated inside a rolled-back transaction should be
1325
        // reusable. The snapshot restores Table::last_rowid, so the
1326
        // next insert picks up where the pre-BEGIN state left off.
1327
        use crate::sql::db::table::Value;
1328

1329
        let mut db = seed_users_table(); // 3 rows, last_rowid = 3
1✔
1330
        let pre = db.get_table("users".to_string()).unwrap().last_rowid;
2✔
1331

1332
        process_command("BEGIN;", &mut db).unwrap();
1✔
1333
        process_command("INSERT INTO users (name, age) VALUES ('d', 50);", &mut db).unwrap(); // would be rowid 4
1✔
1334
        process_command("INSERT INTO users (name, age) VALUES ('e', 60);", &mut db).unwrap(); // would be rowid 5
1✔
1335
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
1336

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

1340
        // Confirm: the next insert reuses rowid pre+1.
1341
        process_command("INSERT INTO users (name, age) VALUES ('d', 50);", &mut db).unwrap();
2✔
1342
        let users = db.get_table("users".to_string()).unwrap();
1✔
1343
        let d_rowid = users
1✔
1344
            .rowids()
1345
            .into_iter()
1346
            .find(|r| users.get_value("name", *r) == Some(Value::Text("d".into())))
3✔
1347
            .expect("d row must exist");
1348
        assert_eq!(d_rowid, pre + 1);
1✔
1349
    }
1350

1351
    #[test]
1352
    fn commit_on_in_memory_db_clears_txn_without_pager_call() {
3✔
1353
        // In-memory DB (no source_path): COMMIT must still work — just
1354
        // no disk flush. Covers the `if let Some(path) = …` branch
1355
        // where the guard falls through without calling save_database.
1356
        let mut db = seed_users_table(); // no source_path
1✔
1357
        assert!(db.source_path.is_none());
2✔
1358

1359
        process_command("BEGIN;", &mut db).unwrap();
1✔
1360
        process_command("INSERT INTO users (name, age) VALUES ('z', 99);", &mut db).unwrap();
1✔
1361
        process_command("COMMIT;", &mut db).unwrap();
1✔
1362

1363
        assert!(!db.in_transaction());
1✔
1364
        assert_eq!(db.get_table("users".to_string()).unwrap().rowids().len(), 4);
2✔
1365
    }
1366

1367
    #[test]
1368
    fn failed_commit_auto_rolls_back_in_memory_state() {
3✔
1369
        // Data-safety regression: on COMMIT save failure we must auto-
1370
        // rollback the in-memory state. Otherwise, any subsequent
1371
        // non-transactional statement would auto-save the partial
1372
        // mid-transaction work, silently publishing uncommitted
1373
        // changes to disk.
1374
        //
1375
        // We simulate a save failure by making the WAL sidecar path
1376
        // unavailable mid-transaction: after BEGIN, we take an
1377
        // exclusive OS lock on the WAL via a second File handle,
1378
        // forcing the next save to fail when it tries to append.
1379
        //
1380
        // Simpler repro: point source_path at a directory (not a file).
1381
        // `OpenOptions::open` will fail with EISDIR on save.
1382
        use crate::sql::pager::save_database;
1383

1384
        // Seed a file-backed db.
1385
        let (path, mut db) = seed_file_backed(
1386
            "failcommit",
1387
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1388
        );
1389

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

1393
        // Open a new txn and add a row.
1394
        process_command("BEGIN;", &mut db).unwrap();
1✔
1395
        process_command("INSERT INTO notes (body) VALUES ('inflight');", &mut db).unwrap();
1✔
1396
        assert_eq!(
1✔
1397
            db.get_table("notes".to_string()).unwrap().rowids().len(),
2✔
1398
            2,
1399
            "inflight row visible mid-txn"
1400
        );
1401

1402
        // Swap source_path to a path that will fail on open. A
1403
        // directory is a reliable failure mode — Pager::open on a
1404
        // directory errors with an I/O error.
1405
        let orig_source = db.source_path.clone();
1✔
1406
        let orig_pager = db.pager.take();
2✔
1407
        db.source_path = Some(std::env::temp_dir());
2✔
1408

1409
        let commit_result = process_command("COMMIT;", &mut db);
1✔
1410
        assert!(commit_result.is_err(), "commit must fail");
2✔
1411
        let err_str = format!("{}", commit_result.unwrap_err());
2✔
1412
        assert!(
×
1413
            err_str.contains("COMMIT failed") && err_str.contains("rolled back"),
3✔
1414
            "error must surface auto-rollback; got: {err_str}"
1415
        );
1416

1417
        // Auto-rollback fired: the inflight row is gone, the txn flag
1418
        // is cleared, and a follow-up non-txn statement won't leak
1419
        // stale state.
1420
        assert!(
×
1421
            !db.in_transaction(),
2✔
1422
            "txn must be cleared after auto-rollback"
1423
        );
1424
        assert_eq!(
1✔
1425
            db.get_table("notes".to_string()).unwrap().rowids().len(),
3✔
1426
            1,
1427
            "inflight row must be rolled back"
1428
        );
1429

1430
        // Restore the real source_path + pager and verify a clean
1431
        // subsequent write goes through.
1432
        db.source_path = orig_source;
1✔
1433
        db.pager = orig_pager;
1✔
1434
        process_command("INSERT INTO notes (body) VALUES ('after');", &mut db).unwrap();
1✔
1435
        drop(db);
1✔
1436

1437
        // Reopen and assert only 'before' + 'after' landed on disk.
1438
        let reopened = crate::sql::pager::open_database(&path, "t".to_string()).unwrap();
1✔
1439
        let notes = reopened.get_table("notes".to_string()).unwrap();
2✔
1440
        assert_eq!(notes.rowids().len(), 2);
1✔
1441
        // Ensure no leaked save_database partial happened.
1442
        let _ = save_database; // silence unused-import lint if any
1443
        drop(reopened);
1✔
1444
        cleanup_file(&path);
1✔
1445
    }
1446

1447
    #[test]
1448
    fn begin_on_read_only_is_rejected() {
3✔
1449
        use crate::sql::pager::{open_database_read_only, save_database};
1450

1451
        let path = {
1✔
1452
            let mut p = std::env::temp_dir();
1✔
1453
            let pid = std::process::id();
2✔
1454
            let nanos = std::time::SystemTime::now()
2✔
1455
                .duration_since(std::time::UNIX_EPOCH)
1✔
1456
                .map(|d| d.as_nanos())
3✔
1457
                .unwrap_or(0);
1458
            p.push(format!("sqlrite-txn-ro-{pid}-{nanos}.sqlrite"));
1✔
1459
            p
1✔
1460
        };
1461
        {
1462
            let mut seed = Database::new("t".to_string());
2✔
1463
            process_command("CREATE TABLE t (id INTEGER PRIMARY KEY);", &mut seed).unwrap();
2✔
1464
            save_database(&mut seed, &path).unwrap();
1✔
1465
        }
1466

1467
        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1✔
1468
        let err = process_command("BEGIN;", &mut ro).unwrap_err();
2✔
1469
        assert!(
×
1470
            format!("{err}").contains("read-only"),
3✔
1471
            "BEGIN on RO db should surface read-only; got: {err}"
1472
        );
1473
        assert!(!ro.in_transaction());
1✔
1474

1475
        let _ = std::fs::remove_file(&path);
2✔
1476
        let mut wal = path.as_os_str().to_owned();
1✔
1477
        wal.push("-wal");
1✔
1478
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
1479
    }
1480

1481
    #[test]
1482
    fn read_only_database_rejects_mutations_before_touching_state() {
3✔
1483
        // Phase 4e end-to-end: a `--readonly` caller that runs INSERT
1484
        // must error *before* the row is added to the in-memory table.
1485
        // Otherwise the user sees a rendered result table with the
1486
        // phantom row, followed by the auto-save error — UX rot and a
1487
        // state-drift risk.
1488
        use crate::sql::pager::open_database_read_only;
1489

1490
        let mut seed = Database::new("t".to_string());
1✔
1491
        process_command(
1492
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
1493
            &mut seed,
1494
        )
1495
        .unwrap();
1496
        process_command("INSERT INTO notes (body) VALUES ('alpha');", &mut seed).unwrap();
1✔
1497

1498
        let path = {
1499
            let mut p = std::env::temp_dir();
1✔
1500
            let pid = std::process::id();
2✔
1501
            let nanos = std::time::SystemTime::now()
2✔
1502
                .duration_since(std::time::UNIX_EPOCH)
1✔
1503
                .map(|d| d.as_nanos())
3✔
1504
                .unwrap_or(0);
1505
            p.push(format!("sqlrite-ro-reject-{pid}-{nanos}.sqlrite"));
1✔
1506
            p
1✔
1507
        };
1508
        crate::sql::pager::save_database(&mut seed, &path).unwrap();
2✔
1509
        drop(seed);
1✔
1510

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

1514
        for stmt in [
2✔
1515
            "INSERT INTO notes (body) VALUES ('beta');",
1516
            "UPDATE notes SET body = 'x';",
1517
            "DELETE FROM notes;",
1518
            "CREATE TABLE more (id INTEGER PRIMARY KEY);",
1519
            "CREATE INDEX notes_body ON notes (body);",
1520
        ] {
1521
            let err = process_command(stmt, &mut ro).unwrap_err();
2✔
1522
            assert!(
×
1523
                format!("{err}").contains("read-only"),
3✔
1524
                "stmt {stmt:?} should surface a read-only error; got: {err}"
1525
            );
1526
        }
1527

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

1534
        // Cleanup.
1535
        drop(ro);
1✔
1536
        let _ = std::fs::remove_file(&path);
1✔
1537
        let mut wal = path.as_os_str().to_owned();
1✔
1538
        wal.push("-wal");
1✔
1539
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
1540
    }
1541

1542
    // -----------------------------------------------------------------
1543
    // Phase 7a — VECTOR(N) end-to-end through process_command
1544
    // -----------------------------------------------------------------
1545

1546
    #[test]
1547
    fn vector_create_table_and_insert_basic() {
3✔
1548
        let mut db = Database::new("tempdb".to_string());
1✔
1549
        process_command(
1550
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(3));",
1551
            &mut db,
1552
        )
1553
        .expect("create table with VECTOR(3)");
1554
        process_command(
1555
            "INSERT INTO docs (embedding) VALUES ([0.1, 0.2, 0.3]);",
1556
            &mut db,
1557
        )
1558
        .expect("insert vector");
1559

1560
        // process_command returns a status string; the rendered table
1561
        // goes to stdout via print_table. Verify state by inspecting
1562
        // the database directly.
1563
        let sel = process_command("SELECT * FROM docs;", &mut db).expect("select");
1✔
1564
        assert!(sel.contains("1 row returned"));
2✔
1565

1566
        let docs = db.get_table("docs".to_string()).expect("docs table");
1✔
1567
        let rowids = docs.rowids();
1✔
1568
        assert_eq!(rowids.len(), 1);
2✔
1569
        match docs.get_value("embedding", rowids[0]) {
1✔
1570
            Some(Value::Vector(v)) => assert_eq!(v, vec![0.1f32, 0.2, 0.3]),
1✔
1571
            other => panic!("expected Value::Vector(...), got {other:?}"),
×
1572
        }
1573
    }
1574

1575
    #[test]
1576
    fn vector_dim_mismatch_at_insert_is_clean_error() {
3✔
1577
        let mut db = Database::new("tempdb".to_string());
1✔
1578
        process_command(
1579
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(3));",
1580
            &mut db,
1581
        )
1582
        .expect("create table");
1583

1584
        // Too few elements.
1585
        let err = process_command("INSERT INTO docs (embedding) VALUES ([0.1, 0.2]);", &mut db)
1✔
1586
            .unwrap_err();
1587
        let msg = format!("{err}");
2✔
1588
        assert!(
×
1589
            msg.to_lowercase().contains("dimension")
2✔
1590
                && msg.contains("declared 3")
1✔
1591
                && msg.contains("got 2"),
1✔
1592
            "expected clear dim-mismatch error, got: {msg}"
1593
        );
1594

1595
        // Too many elements.
1596
        let err = process_command(
1597
            "INSERT INTO docs (embedding) VALUES ([0.1, 0.2, 0.3, 0.4, 0.5]);",
1598
            &mut db,
1599
        )
1600
        .unwrap_err();
1601
        assert!(
×
1602
            format!("{err}").contains("got 5"),
3✔
1603
            "expected dim-mismatch error mentioning got 5, got: {err}"
1604
        );
1605
    }
1606

1607
    #[test]
1608
    fn vector_create_table_rejects_missing_dim() {
3✔
1609
        let mut db = Database::new("tempdb".to_string());
1✔
1610
        // `VECTOR` (no parens) currently parses as `DataType::Custom` with
1611
        // empty args from sqlparser, OR may not parse as Custom at all
1612
        // depending on dialect. Either way, the column shouldn't end up
1613
        // as a usable Vector type. Accept any error here — the precise
1614
        // message is parser-version-dependent.
1615
        let result = process_command(
1616
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR);",
1617
            &mut db,
1618
        );
1619
        assert!(
×
1620
            result.is_err(),
2✔
1621
            "expected CREATE TABLE with bare VECTOR to fail (no dim)"
1622
        );
1623
    }
1624

1625
    #[test]
1626
    fn vector_create_table_rejects_zero_dim() {
3✔
1627
        let mut db = Database::new("tempdb".to_string());
1✔
1628
        let err = process_command(
1629
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding VECTOR(0));",
1630
            &mut db,
1631
        )
1632
        .unwrap_err();
1633
        let msg = format!("{err}");
2✔
1634
        assert!(
×
1635
            msg.to_lowercase().contains("vector"),
3✔
1636
            "expected VECTOR-related error for VECTOR(0), got: {msg}"
1637
        );
1638
    }
1639

1640
    #[test]
1641
    fn vector_high_dim_works() {
3✔
1642
        // 384-dim vector (OpenAI text-embedding-3-small size). Mostly a
1643
        // smoke test — if cell encoding mishandles the size, this fails.
1644
        let mut db = Database::new("tempdb".to_string());
1✔
1645
        process_command(
1646
            "CREATE TABLE embeddings (id INTEGER PRIMARY KEY, e VECTOR(384));",
1647
            &mut db,
1648
        )
1649
        .expect("create table VECTOR(384)");
1650

1651
        let lit = format!(
1✔
1652
            "[{}]",
1653
            (0..384)
1✔
1654
                .map(|i| format!("{}", i as f32 * 0.001))
3✔
1655
                .collect::<Vec<_>>()
1✔
1656
                .join(",")
1✔
1657
        );
1658
        let sql = format!("INSERT INTO embeddings (e) VALUES ({lit});");
2✔
1659
        process_command(&sql, &mut db).expect("insert 384-dim vector");
2✔
1660

1661
        let sel = process_command("SELECT id FROM embeddings;", &mut db).expect("select id");
1✔
1662
        assert!(sel.contains("1 row returned"));
2✔
1663
    }
1664

1665
    #[test]
1666
    fn vector_multiple_rows() {
3✔
1667
        // Three rows with different vectors — exercises the Row::Vector
1668
        // BTreeMap path (not just single-row insertion).
1669
        let mut db = Database::new("tempdb".to_string());
1✔
1670
        process_command(
1671
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1672
            &mut db,
1673
        )
1674
        .expect("create");
1675
        for i in 0..3 {
1✔
1676
            let sql = format!("INSERT INTO docs (e) VALUES ([{i}.0, {}.0]);", i + 1);
2✔
1677
            process_command(&sql, &mut db).expect("insert");
2✔
1678
        }
1679
        let sel = process_command("SELECT * FROM docs;", &mut db).expect("select");
1✔
1680
        assert!(sel.contains("3 rows returned"));
2✔
1681

1682
        // Verify each vector round-tripped correctly via direct DB inspection.
1683
        let docs = db.get_table("docs".to_string()).expect("docs table");
1✔
1684
        let rowids = docs.rowids();
1✔
1685
        assert_eq!(rowids.len(), 3);
2✔
1686
        let mut vectors: Vec<Vec<f32>> = rowids
1✔
1687
            .iter()
1688
            .filter_map(|r| match docs.get_value("e", *r) {
3✔
1689
                Some(Value::Vector(v)) => Some(v),
1✔
1690
                _ => None,
×
1691
            })
1692
            .collect();
1693
        vectors.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap());
4✔
1694
        assert_eq!(vectors[0], vec![0.0f32, 1.0]);
1✔
1695
        assert_eq!(vectors[1], vec![1.0f32, 2.0]);
1✔
1696
        assert_eq!(vectors[2], vec![2.0f32, 3.0]);
1✔
1697
    }
1698

1699
    // -----------------------------------------------------------------
1700
    // Phase 7d.2 — CREATE INDEX … USING hnsw end-to-end
1701
    // -----------------------------------------------------------------
1702

1703
    /// Builds a 5-row docs(id, e VECTOR(2)) table with vectors arranged
1704
    /// at known positions for clear distance reasoning. Used by both
1705
    /// the 7d.2 KNN tests and the refuse-DELETE/UPDATE tests.
1706
    fn seed_hnsw_table() -> Database {
1✔
1707
        let mut db = Database::new("tempdb".to_string());
1✔
1708
        process_command(
1709
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
1710
            &mut db,
1711
        )
1712
        .unwrap();
1713
        for v in &[
1✔
1714
            "[1.0, 0.0]",   // id=1
1715
            "[2.0, 0.0]",   // id=2
1716
            "[0.0, 3.0]",   // id=3
1717
            "[1.0, 4.0]",   // id=4
1718
            "[10.0, 10.0]", // id=5
1719
        ] {
1720
            process_command(&format!("INSERT INTO docs (e) VALUES ({v});"), &mut db).unwrap();
2✔
1721
        }
1722
        db
1✔
1723
    }
1724

1725
    #[test]
1726
    fn create_index_using_hnsw_succeeds() {
4✔
1727
        let mut db = seed_hnsw_table();
1✔
1728
        let resp = process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1729
        assert!(resp.to_lowercase().contains("create index"));
2✔
1730
        // Index attached.
1731
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1732
        assert_eq!(table.hnsw_indexes.len(), 1);
1✔
1733
        assert_eq!(table.hnsw_indexes[0].name, "ix_e");
1✔
1734
        assert_eq!(table.hnsw_indexes[0].column_name, "e");
1✔
1735
        // Existing rows landed in the graph.
1736
        assert_eq!(table.hnsw_indexes[0].index.len(), 5);
1✔
1737
    }
1738

1739
    #[test]
1740
    fn create_index_using_hnsw_rejects_non_vector_column() {
3✔
1741
        let mut db = Database::new("tempdb".to_string());
1✔
1742
        process_command(
1743
            "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);",
1744
            &mut db,
1745
        )
1746
        .unwrap();
1747
        let err =
1✔
1748
            process_command("CREATE INDEX ix_name ON t USING hnsw (name);", &mut db).unwrap_err();
1749
        let msg = format!("{err}");
2✔
1750
        assert!(
×
1751
            msg.to_lowercase().contains("vector"),
3✔
1752
            "expected error mentioning VECTOR; got: {msg}"
1753
        );
1754
    }
1755

1756
    #[test]
1757
    fn knn_query_uses_hnsw_after_create_index() {
3✔
1758
        // The KNN-shaped query route through try_hnsw_probe rather than
1759
        // the brute-force select_topk. The user-visible result should
1760
        // be the same (HNSW recall is high on small graphs); we
1761
        // primarily verify the index is being hit by checking that
1762
        // the right rowids come back in the right order.
1763
        let mut db = seed_hnsw_table();
1✔
1764
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1765

1766
        // Top-3 closest to [1.0, 0.0]:
1767
        //   id=1 [1.0, 0.0]   distance=0
1768
        //   id=2 [2.0, 0.0]   distance=1
1769
        //   id=3 [0.0, 3.0]   distance≈3.16
1770
        let resp = process_command(
1771
            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [1.0, 0.0]) ASC LIMIT 3;",
1772
            &mut db,
1773
        )
1774
        .unwrap();
1775
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
1776
    }
1777

1778
    #[test]
1779
    fn knn_query_works_after_subsequent_inserts() {
3✔
1780
        // Index built when 5 rows existed; insert 2 more after; the
1781
        // HNSW gets maintained incrementally by insert_row, so the
1782
        // KNN query should see the newly-inserted vectors.
1783
        let mut db = seed_hnsw_table();
1✔
1784
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1785
        process_command("INSERT INTO docs (e) VALUES ([0.5, 0.0]);", &mut db).unwrap(); // id=6
1✔
1786
        process_command("INSERT INTO docs (e) VALUES ([0.1, 0.1]);", &mut db).unwrap(); // id=7
1✔
1787

1788
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1789
        assert_eq!(
1✔
1790
            table.hnsw_indexes[0].index.len(),
1✔
1791
            7,
1792
            "incremental insert should grow HNSW alongside row storage"
1793
        );
1794

1795
        // Now query: id=7 [0.1, 0.1] is closer to [0.0, 0.0] than the
1796
        // original 5 rows.
1797
        let resp = process_command(
1798
            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [0.0, 0.0]) ASC LIMIT 1;",
1799
            &mut db,
1800
        )
1801
        .unwrap();
1802
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
1803
    }
1804

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

1810
    #[test]
1811
    fn delete_on_hnsw_indexed_table_succeeds_and_marks_dirty() {
3✔
1812
        let mut db = seed_hnsw_table();
1✔
1813
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1814
        let resp = process_command("DELETE FROM docs WHERE id = 1;", &mut db).unwrap();
1✔
1815
        assert!(resp.contains("1 row"), "expected 1 row deleted: {resp}");
2✔
1816

1817
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
1818
        let entry = docs.hnsw_indexes.iter().find(|e| e.name == "ix_e").unwrap();
3✔
1819
        assert!(
×
1820
            entry.needs_rebuild,
1✔
1821
            "DELETE should have marked HNSW index dirty for rebuild on next save"
1822
        );
1823
    }
1824

1825
    #[test]
1826
    fn hnsw_delete_then_insert_rebuilds_in_same_connection() {
3✔
1827
        let mut db = Database::new(String::from("test_db"));
1✔
1828
        process_command(
1829
            "CREATE TABLE chunks (id INTEGER PRIMARY KEY, document_id INTEGER, embedding VECTOR(4));",
1830
            &mut db,
1831
        )
1832
        .unwrap();
1833
        process_command(
1834
            "CREATE INDEX idx_emb ON chunks USING hnsw (embedding);",
1835
            &mut db,
1836
        )
1837
        .unwrap();
1838
        process_command(
1839
            "INSERT INTO chunks (document_id, embedding) VALUES (1, [1, 0, 0, 0]);",
1840
            &mut db,
1841
        )
1842
        .unwrap();
1843
        process_command(
1844
            "INSERT INTO chunks (document_id, embedding) VALUES (1, [0, 1, 0, 0]);",
1845
            &mut db,
1846
        )
1847
        .unwrap();
1848
        process_command("DELETE FROM chunks WHERE document_id = 1;", &mut db).unwrap();
1✔
1849

1850
        process_command(
1851
            "INSERT INTO chunks (document_id, embedding) VALUES (2, [0, 0, 1, 0]);",
1852
            &mut db,
1853
        )
1854
        .unwrap();
1855
        let chunks = db.get_table("chunks".to_string()).unwrap();
1✔
1856
        let entry = chunks
3✔
1857
            .hnsw_indexes
1858
            .iter()
1859
            .find(|e| e.name == "idx_emb")
3✔
1860
            .unwrap();
1861
        assert!(
×
1862
            !entry.needs_rebuild,
1✔
1863
            "INSERT should rebuild the dirty index"
1864
        );
1865
        assert_eq!(entry.index.len(), 1);
2✔
1866

1867
        let out = process_command_with_render(
1868
            "SELECT document_id FROM chunks ORDER BY vec_distance_l2(embedding, [0, 0, 1, 0]) ASC LIMIT 1;",
1869
            &mut db,
1870
        )
1871
        .unwrap();
1872
        assert!(out.status.contains("1 row returned"), "got: {}", out.status);
2✔
1873
        let rendered = out.rendered.expect("SELECT should render rows");
1✔
1874
        assert!(
×
1875
            rendered.contains("| 2           |"),
2✔
1876
            "expected the post-delete inserted row: {rendered}"
1877
        );
1878
    }
1879

1880
    #[test]
1881
    fn update_on_hnsw_indexed_vector_col_succeeds_and_marks_dirty() {
3✔
1882
        let mut db = seed_hnsw_table();
1✔
1883
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1884
        let resp =
1✔
1885
            process_command("UPDATE docs SET e = [9.0, 9.0] WHERE id = 1;", &mut db).unwrap();
1886
        assert!(resp.contains("1 row"), "expected 1 row updated: {resp}");
2✔
1887

1888
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
1889
        let entry = docs.hnsw_indexes.iter().find(|e| e.name == "ix_e").unwrap();
3✔
1890
        assert!(
×
1891
            entry.needs_rebuild,
1✔
1892
            "UPDATE on the vector column should have marked HNSW index dirty"
1893
        );
1894
    }
1895

1896
    #[test]
1897
    fn duplicate_index_name_errors() {
3✔
1898
        let mut db = seed_hnsw_table();
1✔
1899
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1900
        let err =
1✔
1901
            process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap_err();
1902
        let msg = format!("{err}");
2✔
1903
        assert!(
×
1904
            msg.to_lowercase().contains("already exists"),
3✔
1905
            "expected duplicate-index error; got: {msg}"
1906
        );
1907
    }
1908

1909
    #[test]
1910
    fn index_if_not_exists_is_idempotent() {
3✔
1911
        let mut db = seed_hnsw_table();
1✔
1912
        process_command("CREATE INDEX ix_e ON docs USING hnsw (e);", &mut db).unwrap();
2✔
1913
        // Second time with IF NOT EXISTS should succeed (no-op).
1914
        process_command(
1915
            "CREATE INDEX IF NOT EXISTS ix_e ON docs USING hnsw (e);",
1916
            &mut db,
1917
        )
1918
        .unwrap();
1919
        let table = db.get_table("docs".to_string()).unwrap();
1✔
1920
        assert_eq!(table.hnsw_indexes.len(), 1);
1✔
1921
    }
1922

1923
    // -----------------------------------------------------------------
1924
    // SQLR-10 — CREATE TABLE IF NOT EXISTS + sqlrite_master introspection
1925
    // -----------------------------------------------------------------
1926

1927
    #[test]
1928
    fn create_table_if_not_exists_is_idempotent() {
3✔
1929
        let mut db = Database::new("tempdb".to_string());
1✔
1930
        process_command(
1931
            "CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER);",
1932
            &mut db,
1933
        )
1934
        .unwrap();
1935
        // Second CREATE with IF NOT EXISTS must succeed as a no-op
1936
        // (this is the bug: it used to error "table already exists").
1937
        let msg = process_command(
1938
            "CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, v INTEGER);",
1939
            &mut db,
1940
        )
1941
        .expect("CREATE TABLE IF NOT EXISTS should be a no-op on an existing table");
1942
        assert!(
×
1943
            msg.to_lowercase().contains("no-op"),
3✔
1944
            "expected a no-op status; got: {msg}"
1945
        );
1946
        // The original table is untouched — still exactly one table.
1947
        assert_eq!(db.tables.len(), 1);
1✔
1948
    }
1949

1950
    #[test]
1951
    fn create_table_without_if_not_exists_still_errors() {
3✔
1952
        let mut db = Database::new("tempdb".to_string());
1✔
1953
        process_command("CREATE TABLE t (id INTEGER PRIMARY KEY);", &mut db).unwrap();
2✔
1954
        let err = process_command("CREATE TABLE t (id INTEGER PRIMARY KEY);", &mut db).unwrap_err();
1✔
1955
        assert!(
×
1956
            format!("{err}").to_lowercase().contains("already exists"),
4✔
1957
            "plain CREATE TABLE on an existing table must still error; got: {err}"
1958
        );
1959
    }
1960

1961
    #[test]
1962
    fn create_table_if_not_exists_on_fresh_table_creates_it() {
3✔
1963
        let mut db = Database::new("tempdb".to_string());
1✔
1964
        // IF NOT EXISTS on a brand-new name still creates the table.
1965
        process_command(
1966
            "CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, v INTEGER);",
1967
            &mut db,
1968
        )
1969
        .unwrap();
1970
        assert!(db.contains_table("t".to_string()));
1✔
1971
    }
1972

1973
    #[test]
1974
    fn select_from_sqlrite_master_lists_tables_and_indexes() {
3✔
1975
        use crate::sql::executor::execute_select_rows;
1976
        use crate::sql::parser::select::SelectQuery;
1977

1978
        let mut db = Database::new("tempdb".to_string());
1✔
1979
        process_command(
1980
            "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
1981
            &mut db,
1982
        )
1983
        .unwrap();
1984
        process_command("CREATE TABLE posts (id INTEGER PRIMARY KEY);", &mut db).unwrap();
1✔
1985
        process_command("CREATE INDEX ix_email ON users (email);", &mut db).unwrap();
1✔
1986

1987
        // Helper: run a SELECT and return structured rows.
1988
        let run = |sql: &str, db: &Database| -> Vec<Vec<Value>> {
1✔
1989
            let dialect = SqlriteDialect::new();
1✔
1990
            let mut ast = Parser::parse_sql(&dialect, sql).unwrap();
1✔
1991
            let sq = SelectQuery::new(&ast.pop().unwrap()).unwrap();
2✔
1992
            execute_select_rows(sq, db).unwrap().rows
1✔
1993
        };
1994

1995
        // The exact bug from the issue: SELECT name FROM sqlrite_master.
1996
        let names: Vec<String> = run("SELECT name FROM sqlrite_master;", &db)
1✔
1997
            .into_iter()
1998
            .map(|r| match &r[0] {
4✔
1999
                Value::Text(s) => s.clone(),
1✔
2000
                other => panic!("expected Text name, got {other:?}"),
×
2001
            })
2002
            .collect();
2003
        assert!(names.contains(&"users".to_string()));
2✔
2004
        assert!(names.contains(&"posts".to_string()));
1✔
2005
        // The user's UNIQUE column produced an auto-index plus our explicit
2006
        // one — both should be visible by name.
2007
        assert!(names.contains(&"ix_email".to_string()));
1✔
2008

2009
        // type filtering works through the normal WHERE path.
2010
        let table_rows = run("SELECT name FROM sqlrite_master WHERE type = 'table';", &db);
1✔
2011
        assert_eq!(table_rows.len(), 2, "two user tables");
2✔
2012

2013
        let index_rows = run("SELECT name FROM sqlrite_master WHERE type = 'index';", &db);
1✔
2014
        assert!(
×
2015
            !index_rows.is_empty(),
2✔
2016
            "at least the explicit ix_email index"
2017
        );
2018

2019
        // SELECT * exposes the full catalog schema (type, name, sql, …).
2020
        let all = run("SELECT * FROM sqlrite_master WHERE name = 'users';", &db);
1✔
2021
        assert_eq!(all.len(), 1);
2✔
2022
        assert_eq!(all[0].len(), 5, "type, name, sql, rootpage, last_rowid");
1✔
2023
        match &all[0][2] {
2✔
2024
            Value::Text(sql) => assert!(
1✔
2025
                sql.to_uppercase().contains("CREATE TABLE"),
3✔
2026
                "sql column should carry the CREATE TABLE text; got: {sql}"
2027
            ),
2028
            other => panic!("expected Text sql, got {other:?}"),
×
2029
        }
2030
    }
2031

2032
    #[test]
2033
    fn writes_to_sqlrite_master_are_rejected() {
3✔
2034
        let mut db = Database::new("tempdb".to_string());
1✔
2035
        process_command("CREATE TABLE t (id INTEGER PRIMARY KEY);", &mut db).unwrap();
2✔
2036
        // sqlrite_master is read-only: it never lands in db.tables, so an
2037
        // INSERT can't find it. (The reserved-name guard also blocks
2038
        // CREATE/DROP/ALTER against it.)
2039
        assert!(
×
2040
            process_command(
2✔
2041
                "INSERT INTO sqlrite_master (type, name) VALUES ('table', 'x');",
2042
                &mut db
2043
            )
2044
            .is_err()
1✔
2045
        );
2046
    }
2047

2048
    #[test]
2049
    fn select_from_sqlrite_master_survives_save_and_reopen() {
3✔
2050
        use crate::sql::executor::execute_select_rows;
2051
        use crate::sql::pager::{open_database, save_database};
2052
        use crate::sql::parser::select::SelectQuery;
2053

2054
        let dir = std::env::temp_dir();
1✔
2055
        let path = dir.join(format!("sqlr10_master_{}.sqlrite", std::process::id()));
2✔
2056
        let _ = std::fs::remove_file(&path);
2✔
2057

2058
        let mut db = Database::new("tempdb".to_string());
1✔
2059
        process_command("CREATE TABLE alpha (id INTEGER PRIMARY KEY);", &mut db).unwrap();
2✔
2060
        process_command("CREATE TABLE beta (id INTEGER PRIMARY KEY);", &mut db).unwrap();
1✔
2061
        save_database(&mut db, &path).unwrap();
1✔
2062

2063
        let reopened = open_database(&path, "tempdb".to_string()).unwrap();
1✔
2064
        let dialect = SqlriteDialect::new();
1✔
2065
        let mut ast = Parser::parse_sql(
2066
            &dialect,
2067
            "SELECT name FROM sqlrite_master WHERE type = 'table';",
2068
        )
2069
        .unwrap();
2070
        let sq = SelectQuery::new(&ast.pop().unwrap()).unwrap();
2✔
2071
        let names: Vec<String> = execute_select_rows(sq, &reopened)
2✔
2072
            .unwrap()
1✔
2073
            .rows
2074
            .into_iter()
2075
            .map(|r| match &r[0] {
4✔
2076
                Value::Text(s) => s.clone(),
1✔
2077
                other => panic!("expected Text, got {other:?}"),
×
2078
            })
2079
            .collect();
2080
        assert!(names.contains(&"alpha".to_string()));
1✔
2081
        assert!(names.contains(&"beta".to_string()));
1✔
2082

2083
        let _ = std::fs::remove_file(&path);
1✔
2084
    }
2085

2086
    // -----------------------------------------------------------------
2087
    // Phase 8b — CREATE INDEX … USING fts end-to-end
2088
    // -----------------------------------------------------------------
2089

2090
    /// 5-row docs(id INTEGER PK, body TEXT) populated with overlapping
2091
    /// vocabulary so BM25 ranking has interesting structure.
2092
    fn seed_fts_table() -> Database {
1✔
2093
        let mut db = Database::new("tempdb".to_string());
1✔
2094
        process_command(
2095
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT);",
2096
            &mut db,
2097
        )
2098
        .unwrap();
2099
        for body in &[
1✔
2100
            "rust embedded database",        // id=1 — both 'rust' and 'embedded'
2101
            "rust web framework",            // id=2 — 'rust' only
2102
            "go embedded systems",           // id=3 — 'embedded' only
2103
            "python web framework",          // id=4 — neither
2104
            "rust rust rust embedded power", // id=5 — heavy on 'rust'
2105
        ] {
2106
            process_command(
2107
                &format!("INSERT INTO docs (body) VALUES ('{body}');"),
2✔
2108
                &mut db,
2109
            )
2110
            .unwrap();
2111
        }
2112
        db
1✔
2113
    }
2114

2115
    #[test]
2116
    fn create_index_using_fts_succeeds_and_indexes_existing_rows() {
3✔
2117
        let mut db = seed_fts_table();
1✔
2118
        let resp =
2✔
2119
            process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2120
        assert!(resp.to_lowercase().contains("create index"), "got {resp}");
2✔
2121
        let table = db.get_table("docs".to_string()).unwrap();
1✔
2122
        assert_eq!(table.fts_indexes.len(), 1);
1✔
2123
        assert_eq!(table.fts_indexes[0].name, "ix_body");
1✔
2124
        assert_eq!(table.fts_indexes[0].column_name, "body");
1✔
2125
        // All five rows should be in the in-memory PostingList.
2126
        assert_eq!(table.fts_indexes[0].index.len(), 5);
1✔
2127
    }
2128

2129
    #[test]
2130
    fn create_index_using_fts_rejects_non_text_column() {
3✔
2131
        let mut db = Database::new("tempdb".to_string());
1✔
2132
        process_command(
2133
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER);",
2134
            &mut db,
2135
        )
2136
        .unwrap();
2137
        let err = process_command("CREATE INDEX ix_n ON t USING fts (n);", &mut db).unwrap_err();
1✔
2138
        let msg = format!("{err}");
2✔
2139
        assert!(
×
2140
            msg.to_lowercase().contains("text"),
3✔
2141
            "expected error mentioning TEXT; got: {msg}"
2142
        );
2143
    }
2144

2145
    #[test]
2146
    fn fts_match_returns_expected_rows() {
3✔
2147
        let mut db = seed_fts_table();
1✔
2148
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2149
        // Rows that contain 'rust': ids 1, 2, 5.
2150
        let resp = process_command(
2151
            "SELECT id FROM docs WHERE fts_match(body, 'rust');",
2152
            &mut db,
2153
        )
2154
        .unwrap();
2155
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
2156
    }
2157

2158
    #[test]
2159
    fn fts_match_without_index_errors_clearly() {
3✔
2160
        let mut db = seed_fts_table();
1✔
2161
        // No CREATE INDEX — fts_match must surface a useful error.
2162
        let err = process_command(
2163
            "SELECT id FROM docs WHERE fts_match(body, 'rust');",
2164
            &mut db,
2165
        )
2166
        .unwrap_err();
2167
        let msg = format!("{err}");
2✔
2168
        assert!(
×
2169
            msg.contains("no FTS index"),
2✔
2170
            "expected no-index error; got: {msg}"
2171
        );
2172
    }
2173

2174
    #[test]
2175
    fn bm25_score_orders_descending_by_relevance() {
3✔
2176
        let mut db = seed_fts_table();
1✔
2177
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2178
        // ORDER BY bm25_score DESC LIMIT 1: id=5 has 'rust' three times in
2179
        // a 5-token doc — highest tf, modest length penalty → top score.
2180
        let out = process_command_with_render(
2181
            "SELECT id FROM docs WHERE fts_match(body, 'rust') \
2182
             ORDER BY bm25_score(body, 'rust') DESC LIMIT 1;",
2183
            &mut db,
2184
        )
2185
        .unwrap();
2186
        assert!(out.status.contains("1 row returned"), "got: {}", out.status);
2✔
2187
        let rendered = out.rendered.expect("SELECT should produce rendered output");
1✔
2188
        // The rendered prettytable contains the integer 5 in a cell.
2189
        assert!(
×
2190
            rendered.contains(" 5 "),
2✔
2191
            "expected id=5 to be top-ranked; rendered:\n{rendered}"
2192
        );
2193
    }
2194

2195
    #[test]
2196
    fn bm25_score_without_index_errors_clearly() {
3✔
2197
        let mut db = seed_fts_table();
1✔
2198
        let err = process_command(
2199
            "SELECT id FROM docs ORDER BY bm25_score(body, 'rust') DESC LIMIT 1;",
2200
            &mut db,
2201
        )
2202
        .unwrap_err();
2203
        let msg = format!("{err}");
2✔
2204
        assert!(
×
2205
            msg.contains("no FTS index"),
2✔
2206
            "expected no-index error; got: {msg}"
2207
        );
2208
    }
2209

2210
    #[test]
2211
    fn fts_post_create_inserts_are_indexed_incrementally() {
3✔
2212
        let mut db = seed_fts_table();
1✔
2213
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2214
        process_command(
2215
            "INSERT INTO docs (body) VALUES ('rust embedded analytics');",
2216
            &mut db,
2217
        )
2218
        .unwrap();
2219
        let table = db.get_table("docs".to_string()).unwrap();
1✔
2220
        // PostingList::len() reports doc count; should be 6 now.
2221
        assert_eq!(table.fts_indexes[0].index.len(), 6);
1✔
2222
        // 'analytics' appears only in the new row → query returns 1 hit.
2223
        let resp = process_command(
2224
            "SELECT id FROM docs WHERE fts_match(body, 'analytics');",
2225
            &mut db,
2226
        )
2227
        .unwrap();
2228
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2229
    }
2230

2231
    #[test]
2232
    fn delete_on_fts_indexed_table_marks_dirty() {
3✔
2233
        let mut db = seed_fts_table();
1✔
2234
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2235
        let resp = process_command("DELETE FROM docs WHERE id = 1;", &mut db).unwrap();
1✔
2236
        assert!(resp.contains("1 row"), "got: {resp}");
2✔
2237
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
2238
        let entry = docs
3✔
2239
            .fts_indexes
2240
            .iter()
2241
            .find(|e| e.name == "ix_body")
3✔
2242
            .unwrap();
2243
        assert!(
×
2244
            entry.needs_rebuild,
1✔
2245
            "DELETE should have flagged the FTS index dirty"
2246
        );
2247
    }
2248

2249
    #[test]
2250
    fn update_on_fts_indexed_text_col_marks_dirty() {
3✔
2251
        let mut db = seed_fts_table();
1✔
2252
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2253
        let resp = process_command(
2254
            "UPDATE docs SET body = 'java spring framework' WHERE id = 1;",
2255
            &mut db,
2256
        )
2257
        .unwrap();
2258
        assert!(resp.contains("1 row"), "got: {resp}");
2✔
2259
        let docs = db.get_table("docs".to_string()).unwrap();
2✔
2260
        let entry = docs
3✔
2261
            .fts_indexes
2262
            .iter()
2263
            .find(|e| e.name == "ix_body")
3✔
2264
            .unwrap();
2265
        assert!(
×
2266
            entry.needs_rebuild,
1✔
2267
            "UPDATE on the indexed TEXT column should have flagged dirty"
2268
        );
2269
    }
2270

2271
    #[test]
2272
    fn fts_index_name_collides_with_btree_and_hnsw_namespaces() {
3✔
2273
        let mut db = seed_fts_table();
1✔
2274
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2275
        let err = process_command("CREATE INDEX ix_body ON docs (body);", &mut db).unwrap_err();
1✔
2276
        let msg = format!("{err}");
2✔
2277
        assert!(
×
2278
            msg.to_lowercase().contains("already exists"),
3✔
2279
            "expected duplicate-index error; got: {msg}"
2280
        );
2281
    }
2282

2283
    #[test]
2284
    fn fts_index_rejects_unique() {
3✔
2285
        let mut db = seed_fts_table();
1✔
2286
        let err = process_command(
2287
            "CREATE UNIQUE INDEX ix_body ON docs USING fts (body);",
2288
            &mut db,
2289
        )
2290
        .unwrap_err();
2291
        let msg = format!("{err}");
2✔
2292
        assert!(
×
2293
            msg.to_lowercase().contains("unique"),
3✔
2294
            "expected UNIQUE-rejection error; got: {msg}"
2295
        );
2296
    }
2297

2298
    #[test]
2299
    fn try_fts_probe_falls_through_on_ascending() {
3✔
2300
        // BM25 is "higher = better"; ASC is rejected so the slow path
2301
        // applies. We verify by running the query and checking the
2302
        // result is still correct (the slow path goes through scalar
2303
        // bm25_score on every row).
2304
        let mut db = seed_fts_table();
1✔
2305
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2306
        // Same query as bm25_score_orders_descending but ASC → should
2307
        // still succeed (slow path), and id=5 should now be LAST.
2308
        let resp = process_command(
2309
            "SELECT id FROM docs WHERE fts_match(body, 'rust') \
2310
             ORDER BY bm25_score(body, 'rust') ASC LIMIT 3;",
2311
            &mut db,
2312
        )
2313
        .unwrap();
2314
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
2315
    }
2316

2317
    // -----------------------------------------------------------------
2318
    // SQLR-15 — table qualifiers in FTS function column args are
2319
    // validated like plain column references (SQLR-14). Before the
2320
    // fix, `fts_match(bogus.body, …)` silently dropped the `bogus.`.
2321
    // -----------------------------------------------------------------
2322

2323
    /// Assert `sql` fails with the SQLR-14/15 unknown-qualifier message.
2324
    fn assert_fts_unknown_qualifier(db: &mut Database, sql: &str, qualifier: &str) {
1✔
2325
        let err = process_command(sql, db)
1✔
2326
            .expect_err("a bogus qualifier in an FTS function arg must error, not be ignored");
2327
        let msg = format!("{err}");
2✔
NEW
2328
        assert!(
×
2329
            msg.contains(&format!("unknown table qualifier '{qualifier}'")),
3✔
2330
            "expected unknown-qualifier error for `{sql}`, got: {msg}"
2331
        );
2332
    }
2333

2334
    #[test]
2335
    fn fts_match_with_matching_table_qualifier_works() {
3✔
2336
        let mut db = seed_fts_table();
1✔
2337
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2338
        let resp = process_command(
2339
            "SELECT id FROM docs WHERE fts_match(docs.body, 'rust');",
2340
            &mut db,
2341
        )
2342
        .unwrap();
2343
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
2344
        // Case-insensitive match, same as plain column references.
2345
        let resp = process_command(
2346
            "SELECT id FROM docs WHERE fts_match(DOCS.body, 'rust');",
2347
            &mut db,
2348
        )
2349
        .unwrap();
2350
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
2351
    }
2352

2353
    #[test]
2354
    fn fts_match_with_matching_alias_qualifier_works() {
3✔
2355
        let mut db = seed_fts_table();
1✔
2356
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2357
        let resp = process_command(
2358
            "SELECT id FROM docs AS d WHERE fts_match(d.body, 'rust');",
2359
            &mut db,
2360
        )
2361
        .unwrap();
2362
        assert!(resp.contains("3 rows returned"), "got: {resp}");
2✔
2363
    }
2364

2365
    #[test]
2366
    fn fts_match_alias_shadows_table_name_as_qualifier() {
3✔
2367
        // Once `FROM docs AS d` declares an alias, the alias is the
2368
        // only valid qualifier — `docs.body` errors (SQLite semantics,
2369
        // mirrors the SQLR-14 plain-column tests).
2370
        let mut db = seed_fts_table();
1✔
2371
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2372
        assert_fts_unknown_qualifier(
2373
            &mut db,
2374
            "SELECT id FROM docs AS d WHERE fts_match(docs.body, 'rust');",
2375
            "docs",
2376
        );
2377
    }
2378

2379
    #[test]
2380
    fn fts_match_unknown_qualifier_in_where_errors() {
3✔
2381
        let mut db = seed_fts_table();
1✔
2382
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2383
        assert_fts_unknown_qualifier(
2384
            &mut db,
2385
            "SELECT id FROM docs WHERE fts_match(bogus.body, 'rust');",
2386
            "bogus",
2387
        );
2388
    }
2389

2390
    #[test]
2391
    fn bm25_score_unknown_qualifier_in_order_by_errors() {
3✔
2392
        // ORDER BY position exercises the scalar-eval path: the FTS
2393
        // probe only matches bare identifiers, so the qualified arg
2394
        // falls through to resolve_fts_args.
2395
        let mut db = seed_fts_table();
1✔
2396
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2397
        assert_fts_unknown_qualifier(
2398
            &mut db,
2399
            "SELECT id FROM docs ORDER BY bm25_score(bogus.body, 'rust') DESC LIMIT 1;",
2400
            "bogus",
2401
        );
2402
    }
2403

2404
    #[test]
2405
    fn bm25_score_unknown_qualifier_in_where_errors() {
3✔
2406
        // (Projection position can't reach the FTS helper — scalar
2407
        // functions are rejected in the projection list outright, so
2408
        // WHERE-comparison is the other scalar-eval position.)
2409
        let mut db = seed_fts_table();
1✔
2410
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2411
        assert_fts_unknown_qualifier(
2412
            &mut db,
2413
            "SELECT id FROM docs WHERE bm25_score(bogus.body, 'rust') > 0.0;",
2414
            "bogus",
2415
        );
2416
    }
2417

2418
    #[test]
2419
    fn bm25_score_with_matching_qualifier_still_ranks_correctly() {
3✔
2420
        // Qualified arg bypasses the FTS probe (bare-identifier match
2421
        // only) — the scalar path must produce the same top result.
2422
        let mut db = seed_fts_table();
1✔
2423
        process_command("CREATE INDEX ix_body ON docs USING fts (body);", &mut db).unwrap();
2✔
2424
        let out = process_command_with_render(
2425
            "SELECT id FROM docs WHERE fts_match(docs.body, 'rust') \
2426
             ORDER BY bm25_score(docs.body, 'rust') DESC LIMIT 1;",
2427
            &mut db,
2428
        )
2429
        .unwrap();
2430
        assert!(out.status.contains("1 row returned"), "got: {}", out.status);
2✔
2431
        let rendered = out.rendered.expect("SELECT should produce rendered output");
1✔
NEW
2432
        assert!(
×
2433
            rendered.contains(" 5 "),
2✔
2434
            "expected id=5 to be top-ranked; rendered:\n{rendered}"
2435
        );
2436
    }
2437

2438
    // -----------------------------------------------------------------
2439
    // Phase 7b — vector distance functions through process_command
2440
    // -----------------------------------------------------------------
2441

2442
    /// Builds a 3-row docs table with 2-dim vectors aligned along the
2443
    /// axes so the expected distances are easy to reason about:
2444
    ///   id=1: [1, 0]
2445
    ///   id=2: [0, 1]
2446
    ///   id=3: [1, 1]
2447
    fn seed_vector_docs() -> Database {
1✔
2448
        let mut db = Database::new("tempdb".to_string());
1✔
2449
        process_command(
2450
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, e VECTOR(2));",
2451
            &mut db,
2452
        )
2453
        .expect("create");
2454
        process_command("INSERT INTO docs (e) VALUES ([1.0, 0.0]);", &mut db).expect("insert 1");
1✔
2455
        process_command("INSERT INTO docs (e) VALUES ([0.0, 1.0]);", &mut db).expect("insert 2");
1✔
2456
        process_command("INSERT INTO docs (e) VALUES ([1.0, 1.0]);", &mut db).expect("insert 3");
1✔
2457
        db
1✔
2458
    }
2459

2460
    #[test]
2461
    fn vec_distance_l2_in_where_filters_correctly() {
3✔
2462
        // Distance from [1,0]:
2463
        //   id=1 [1,0]: 0
2464
        //   id=2 [0,1]: √2 ≈ 1.414
2465
        //   id=3 [1,1]: 1
2466
        // WHERE distance < 1.1 should match id=1 and id=3 (2 rows).
2467
        let mut db = seed_vector_docs();
1✔
2468
        let resp = process_command(
2469
            "SELECT * FROM docs WHERE vec_distance_l2(e, [1.0, 0.0]) < 1.1;",
2470
            &mut db,
2471
        )
2472
        .expect("select");
2473
        assert!(
×
2474
            resp.contains("2 rows returned"),
2✔
2475
            "expected 2 rows, got: {resp}"
2476
        );
2477
    }
2478

2479
    #[test]
2480
    fn vec_distance_cosine_in_where() {
3✔
2481
        // [1,0] vs [1,0]: cosine distance = 0
2482
        // [1,0] vs [0,1]: cosine distance = 1 (orthogonal)
2483
        // [1,0] vs [1,1]: cosine distance = 1 - 1/√2 ≈ 0.293
2484
        // WHERE distance < 0.5 → id=1 and id=3 (2 rows).
2485
        let mut db = seed_vector_docs();
1✔
2486
        let resp = process_command(
2487
            "SELECT * FROM docs WHERE vec_distance_cosine(e, [1.0, 0.0]) < 0.5;",
2488
            &mut db,
2489
        )
2490
        .expect("select");
2491
        assert!(
×
2492
            resp.contains("2 rows returned"),
2✔
2493
            "expected 2 rows, got: {resp}"
2494
        );
2495
    }
2496

2497
    #[test]
2498
    fn vec_distance_dot_negated() {
3✔
2499
        // [1,0]·[1,0] = 1 → -1
2500
        // [1,0]·[0,1] = 0 → 0
2501
        // [1,0]·[1,1] = 1 → -1
2502
        // WHERE -dot < 0 (i.e. dot > 0) → id=1 and id=3 (2 rows).
2503
        let mut db = seed_vector_docs();
1✔
2504
        let resp = process_command(
2505
            "SELECT * FROM docs WHERE vec_distance_dot(e, [1.0, 0.0]) < 0.0;",
2506
            &mut db,
2507
        )
2508
        .expect("select");
2509
        assert!(
×
2510
            resp.contains("2 rows returned"),
2✔
2511
            "expected 2 rows, got: {resp}"
2512
        );
2513
    }
2514

2515
    #[test]
2516
    fn knn_via_order_by_distance_limit() {
3✔
2517
        // Classic KNN shape: ORDER BY distance LIMIT k.
2518
        // Distances from [1,0]: id=1=0, id=3=1, id=2=√2.
2519
        // LIMIT 2 should return id=1 then id=3 in that order.
2520
        let mut db = seed_vector_docs();
1✔
2521
        let resp = process_command(
2522
            "SELECT id FROM docs ORDER BY vec_distance_l2(e, [1.0, 0.0]) ASC LIMIT 2;",
2523
            &mut db,
2524
        )
2525
        .expect("select");
2526
        assert!(
×
2527
            resp.contains("2 rows returned"),
2✔
2528
            "expected 2 rows, got: {resp}"
2529
        );
2530
    }
2531

2532
    #[test]
2533
    fn distance_function_dim_mismatch_errors() {
3✔
2534
        // 2-dim column queried with a 3-dim probe → clean error.
2535
        let mut db = seed_vector_docs();
1✔
2536
        let err = process_command(
2537
            "SELECT * FROM docs WHERE vec_distance_l2(e, [1.0, 0.0, 0.0]) < 1.0;",
2538
            &mut db,
2539
        )
2540
        .unwrap_err();
2541
        let msg = format!("{err}");
2✔
2542
        assert!(
×
2543
            msg.to_lowercase().contains("dimension")
2✔
2544
                && msg.contains("lhs=2")
1✔
2545
                && msg.contains("rhs=3"),
1✔
2546
            "expected dim mismatch error, got: {msg}"
2547
        );
2548
    }
2549

2550
    #[test]
2551
    fn unknown_function_errors_with_name() {
3✔
2552
        // Use the function in WHERE, not projection — the projection
2553
        // parser still requires bare column references; function calls
2554
        // there are a future enhancement (with `AS alias` support).
2555
        let mut db = seed_vector_docs();
1✔
2556
        let err = process_command(
2557
            "SELECT * FROM docs WHERE vec_does_not_exist(e, [1.0, 0.0]) < 1.0;",
2558
            &mut db,
2559
        )
2560
        .unwrap_err();
2561
        let msg = format!("{err}");
2✔
2562
        assert!(
×
2563
            msg.contains("vec_does_not_exist"),
2✔
2564
            "expected error mentioning function name, got: {msg}"
2565
        );
2566
    }
2567

2568
    // -----------------------------------------------------------------
2569
    // Phase 7e — JSON column type + path-extraction functions
2570
    // -----------------------------------------------------------------
2571

2572
    fn seed_json_table() -> Database {
1✔
2573
        let mut db = Database::new("tempdb".to_string());
1✔
2574
        process_command(
2575
            "CREATE TABLE docs (id INTEGER PRIMARY KEY, payload JSON);",
2576
            &mut db,
2577
        )
2578
        .expect("create json table");
2579
        db
1✔
2580
    }
2581

2582
    #[test]
2583
    fn json_column_round_trip_primitive_values() {
3✔
2584
        let mut db = seed_json_table();
1✔
2585
        process_command(
2586
            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice", "age": 30}');"#,
2587
            &mut db,
2588
        )
2589
        .expect("insert json");
2590
        let docs = db.get_table("docs".to_string()).unwrap();
1✔
2591
        let rowids = docs.rowids();
1✔
2592
        assert_eq!(rowids.len(), 1);
2✔
2593
        // Stored verbatim as Text underneath.
2594
        match docs.get_value("payload", rowids[0]) {
1✔
2595
            Some(Value::Text(s)) => {
1✔
2596
                assert!(s.contains("alice"), "expected JSON text to round-trip: {s}");
2✔
2597
            }
2598
            other => panic!("expected Value::Text holding JSON, got {other:?}"),
×
2599
        }
2600
    }
2601

2602
    #[test]
2603
    fn json_insert_rejects_invalid_json() {
3✔
2604
        let mut db = seed_json_table();
1✔
2605
        let err = process_command(
2606
            "INSERT INTO docs (payload) VALUES ('not-valid-json{');",
2607
            &mut db,
2608
        )
2609
        .unwrap_err();
2610
        let msg = format!("{err}").to_lowercase();
2✔
2611
        assert!(
×
2612
            msg.contains("json") && msg.contains("payload"),
2✔
2613
            "expected JSON validation error mentioning column, got: {msg}"
2614
        );
2615
    }
2616

2617
    #[test]
2618
    fn json_extract_object_field() {
3✔
2619
        let mut db = seed_json_table();
1✔
2620
        process_command(
2621
            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice", "age": 30}');"#,
2622
            &mut db,
2623
        )
2624
        .unwrap();
2625
        // We don't have function calls in projection (yet), so test
2626
        // the function via WHERE.
2627
        let resp = process_command(
2628
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.name') = 'alice';"#,
2629
            &mut db,
2630
        )
2631
        .expect("select via json_extract");
2632
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2633

2634
        let resp = process_command(
2635
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.age') = 30;"#,
2636
            &mut db,
2637
        )
2638
        .expect("select via numeric json_extract");
2639
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2640
    }
2641

2642
    #[test]
2643
    fn json_extract_array_index_and_nested() {
3✔
2644
        let mut db = seed_json_table();
1✔
2645
        process_command(
2646
            r#"INSERT INTO docs (payload) VALUES ('{"tags": ["rust", "sql", "vectors"], "meta": {"author": "joao"}}');"#,
2647
            &mut db,
2648
        )
2649
        .unwrap();
2650
        let resp = process_command(
2651
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.tags[0]') = 'rust';"#,
2652
            &mut db,
2653
        )
2654
        .expect("select via array index");
2655
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2656

2657
        let resp = process_command(
2658
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.meta.author') = 'joao';"#,
2659
            &mut db,
2660
        )
2661
        .expect("select via nested object");
2662
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2663
    }
2664

2665
    #[test]
2666
    fn json_extract_missing_path_returns_null() {
3✔
2667
        let mut db = seed_json_table();
1✔
2668
        process_command(
2669
            r#"INSERT INTO docs (payload) VALUES ('{"name": "alice"}');"#,
2670
            &mut db,
2671
        )
2672
        .unwrap();
2673
        // Missing key under WHERE returns NULL → predicate is false →
2674
        // 0 rows returned. (Standard SQL three-valued logic.)
2675
        let resp = process_command(
2676
            r#"SELECT id FROM docs WHERE json_extract(payload, '$.missing') = 'something';"#,
2677
            &mut db,
2678
        )
2679
        .expect("select with missing path");
2680
        assert!(resp.contains("0 rows returned"), "got: {resp}");
2✔
2681
    }
2682

2683
    #[test]
2684
    fn json_extract_malformed_path_errors() {
3✔
2685
        let mut db = seed_json_table();
1✔
2686
        process_command(
2687
            r#"INSERT INTO docs (payload) VALUES ('{"a": 1}');"#,
2688
            &mut db,
2689
        )
2690
        .unwrap();
2691
        // Path doesn't start with '$' — syntax error.
2692
        let err = process_command(
2693
            r#"SELECT id FROM docs WHERE json_extract(payload, 'a.b') = 1;"#,
2694
            &mut db,
2695
        )
2696
        .unwrap_err();
2697
        assert!(format!("{err}").contains("'$'"));
2✔
2698
    }
2699

2700
    #[test]
2701
    fn json_array_length_on_array() {
3✔
2702
        // Note: json_array_length used in WHERE clause where it can be
2703
        // compared; that exercises the function dispatch end-to-end.
2704
        let mut db = seed_json_table();
1✔
2705
        process_command(
2706
            r#"INSERT INTO docs (payload) VALUES ('{"tags": ["a", "b", "c"]}');"#,
2707
            &mut db,
2708
        )
2709
        .unwrap();
2710
        let resp = process_command(
2711
            r#"SELECT id FROM docs WHERE json_array_length(payload, '$.tags') = 3;"#,
2712
            &mut db,
2713
        )
2714
        .expect("select via array_length");
2715
        assert!(resp.contains("1 row returned"), "got: {resp}");
2✔
2716
    }
2717

2718
    #[test]
2719
    fn json_array_length_on_non_array_errors() {
3✔
2720
        let mut db = seed_json_table();
1✔
2721
        process_command(
2722
            r#"INSERT INTO docs (payload) VALUES ('{"tags": "not-an-array"}');"#,
2723
            &mut db,
2724
        )
2725
        .unwrap();
2726
        let err = process_command(
2727
            r#"SELECT id FROM docs WHERE json_array_length(payload, '$.tags') = 1;"#,
2728
            &mut db,
2729
        )
2730
        .unwrap_err();
2731
        let msg = format!("{err}").to_lowercase();
2✔
2732
        assert!(
×
2733
            msg.contains("non-array"),
1✔
2734
            "expected non-array error, got: {msg}"
2735
        );
2736
    }
2737

2738
    #[test]
2739
    fn json_type_recognizes_each_kind() {
3✔
2740
        let mut db = seed_json_table();
1✔
2741
        process_command(
2742
            r#"INSERT INTO docs (payload) VALUES ('{"o": {}, "a": [], "s": "x", "i": 1, "f": 1.5, "t": true, "n": null}');"#,
2743
            &mut db,
2744
        )
2745
        .unwrap();
2746
        let cases = &[
1✔
2747
            ("$.o", "object"),
2748
            ("$.a", "array"),
2749
            ("$.s", "text"),
2750
            ("$.i", "integer"),
2751
            ("$.f", "real"),
2752
            ("$.t", "true"),
2753
            ("$.n", "null"),
2754
        ];
2755
        for (path, expected_type) in cases {
1✔
2756
            let sql = format!(
2✔
2757
                "SELECT id FROM docs WHERE json_type(payload, '{path}') = '{expected_type}';"
2758
            );
2759
            let resp =
2✔
2760
                process_command(&sql, &mut db).unwrap_or_else(|e| panic!("path {path}: {e}"));
2761
            assert!(
×
2762
                resp.contains("1 row returned"),
2✔
2763
                "path {path} expected type {expected_type}; got response: {resp}"
2764
            );
2765
        }
2766
    }
2767

2768
    #[test]
2769
    fn update_on_json_column_revalidates() {
3✔
2770
        let mut db = seed_json_table();
1✔
2771
        process_command(
2772
            r#"INSERT INTO docs (payload) VALUES ('{"a": 1}');"#,
2773
            &mut db,
2774
        )
2775
        .unwrap();
2776
        // Valid JSON update succeeds.
2777
        process_command(
2778
            r#"UPDATE docs SET payload = '{"a": 2, "b": 3}' WHERE id = 1;"#,
2779
            &mut db,
2780
        )
2781
        .expect("valid JSON UPDATE");
2782
        // Invalid JSON in UPDATE is rejected with the same shape of
2783
        // error as INSERT.
2784
        let err = process_command(
2785
            r#"UPDATE docs SET payload = 'not-json{' WHERE id = 1;"#,
2786
            &mut db,
2787
        )
2788
        .unwrap_err();
2789
        let msg = format!("{err}").to_lowercase();
2✔
2790
        assert!(
×
2791
            msg.contains("json") && msg.contains("payload"),
2✔
2792
            "got: {msg}"
2793
        );
2794
    }
2795

2796
    // -------------------------------------------------------------------
2797
    // DEFAULT clause on CREATE TABLE columns
2798
    // -------------------------------------------------------------------
2799

2800
    #[test]
2801
    fn default_literal_int_applies_when_column_omitted() {
3✔
2802
        let mut db = Database::new("t".to_string());
1✔
2803
        process_command(
2804
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER DEFAULT 42);",
2805
            &mut db,
2806
        )
2807
        .unwrap();
2808
        process_command("INSERT INTO t (id) VALUES (1);", &mut db).unwrap();
1✔
2809

2810
        let table = db.get_table("t".to_string()).unwrap();
1✔
2811
        assert_eq!(table.get_value("n", 1), Some(Value::Integer(42)));
1✔
2812
    }
2813

2814
    #[test]
2815
    fn default_literal_text_applies_when_column_omitted() {
3✔
2816
        let mut db = Database::new("t".to_string());
1✔
2817
        process_command(
2818
            "CREATE TABLE users (id INTEGER PRIMARY KEY, status TEXT DEFAULT 'active');",
2819
            &mut db,
2820
        )
2821
        .unwrap();
2822
        process_command("INSERT INTO users (id) VALUES (1);", &mut db).unwrap();
1✔
2823

2824
        let table = db.get_table("users".to_string()).unwrap();
1✔
2825
        assert_eq!(
1✔
2826
            table.get_value("status", 1),
1✔
2827
            Some(Value::Text("active".to_string()))
2✔
2828
        );
2829
    }
2830

2831
    #[test]
2832
    fn default_literal_real_negative_applies_when_column_omitted() {
3✔
2833
        // `DEFAULT -1.5` arrives as a UnaryOp(Minus, Number) — exercise that path.
2834
        let mut db = Database::new("t".to_string());
1✔
2835
        process_command(
2836
            "CREATE TABLE t (id INTEGER PRIMARY KEY, score REAL DEFAULT -1.5);",
2837
            &mut db,
2838
        )
2839
        .unwrap();
2840
        process_command("INSERT INTO t (id) VALUES (1);", &mut db).unwrap();
1✔
2841

2842
        let table = db.get_table("t".to_string()).unwrap();
1✔
2843
        assert_eq!(table.get_value("score", 1), Some(Value::Real(-1.5)));
1✔
2844
    }
2845

2846
    #[test]
2847
    fn default_with_type_mismatch_errors_at_create_time() {
3✔
2848
        let mut db = Database::new("t".to_string());
1✔
2849
        let result = process_command(
2850
            "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER DEFAULT 'oops');",
2851
            &mut db,
2852
        );
2853
        let err = result.expect_err("text default on INTEGER column should be rejected");
1✔
2854
        let msg = format!("{err}").to_lowercase();
2✔
2855
        assert!(msg.contains("default"), "got: {msg}");
1✔
2856
    }
2857

2858
    #[test]
2859
    fn default_for_json_column_must_be_valid_json() {
3✔
2860
        // ADD COLUMN ... JSON DEFAULT 'not-json' would otherwise backfill
2861
        // every existing row with invalid JSON (insert_row's validation
2862
        // is bypassed for the backfill path).
2863
        let mut db = Database::new("t".to_string());
1✔
2864
        let err = process_command(
2865
            "CREATE TABLE t (id INTEGER PRIMARY KEY, doc JSON DEFAULT 'not-json{');",
2866
            &mut db,
2867
        )
2868
        .unwrap_err();
2869
        assert!(
×
2870
            format!("{err}").to_lowercase().contains("json"),
4✔
2871
            "got: {err}"
2872
        );
2873

2874
        // Valid JSON DEFAULT works.
2875
        process_command(
2876
            "CREATE TABLE t2 (id INTEGER PRIMARY KEY, doc JSON DEFAULT '{\"k\":1}');",
2877
            &mut db,
2878
        )
2879
        .expect("valid JSON DEFAULT should be accepted");
2880
    }
2881

2882
    #[test]
2883
    fn default_with_non_literal_expression_errors_at_create_time() {
3✔
2884
        let mut db = Database::new("t".to_string());
1✔
2885
        // Function-call DEFAULT (e.g. CURRENT_TIMESTAMP) → rejected; we only
2886
        // accept literal expressions for now.
2887
        let result = process_command(
2888
            "CREATE TABLE t (id INTEGER PRIMARY KEY, ts TEXT DEFAULT CURRENT_TIMESTAMP);",
2889
            &mut db,
2890
        );
2891
        let err = result.expect_err("non-literal DEFAULT should be rejected");
1✔
2892
        let msg = format!("{err}").to_lowercase();
2✔
2893
        assert!(
×
2894
            msg.contains("default") && msg.contains("literal"),
2✔
2895
            "got: {msg}"
2896
        );
2897
    }
2898

2899
    #[test]
2900
    fn default_null_is_accepted_at_create_time() {
3✔
2901
        // `DEFAULT NULL` is a no-op equivalent to no DEFAULT clause; the
2902
        // important thing is that CREATE TABLE accepts it without error
2903
        // (some DDL exporters emit `DEFAULT NULL` redundantly).
2904
        let mut db = Database::new("t".to_string());
1✔
2905
        process_command(
2906
            "CREATE TABLE t (id INTEGER PRIMARY KEY, note TEXT DEFAULT NULL);",
2907
            &mut db,
2908
        )
2909
        .expect("CREATE TABLE with DEFAULT NULL should be accepted");
2910
        let table = db.get_table("t".to_string()).unwrap();
1✔
2911
        let note = table
3✔
2912
            .columns
2913
            .iter()
2914
            .find(|c| c.column_name == "note")
3✔
2915
            .unwrap();
2916
        assert_eq!(note.default, Some(Value::Null));
1✔
2917
    }
2918

2919
    // -------------------------------------------------------------------
2920
    // DROP TABLE / DROP INDEX
2921
    // -------------------------------------------------------------------
2922

2923
    #[test]
2924
    fn drop_table_basic() {
3✔
2925
        let mut db = seed_users_table();
1✔
2926
        let response = process_command("DROP TABLE users;", &mut db).expect("drop table");
2✔
2927
        assert!(response.contains("1 table dropped"));
2✔
2928
        assert!(!db.contains_table("users".to_string()));
1✔
2929
    }
2930

2931
    #[test]
2932
    fn drop_table_if_exists_noop_on_missing() {
3✔
2933
        let mut db = Database::new("t".to_string());
1✔
2934
        let response =
2✔
2935
            process_command("DROP TABLE IF EXISTS missing;", &mut db).expect("drop if exists");
2936
        assert!(response.contains("0 tables dropped"));
2✔
2937
    }
2938

2939
    #[test]
2940
    fn drop_table_missing_errors_without_if_exists() {
3✔
2941
        let mut db = Database::new("t".to_string());
1✔
2942
        let err = process_command("DROP TABLE missing;", &mut db).unwrap_err();
2✔
2943
        assert!(format!("{err}").contains("does not exist"), "got: {err}");
2✔
2944
    }
2945

2946
    #[test]
2947
    fn drop_table_reserved_name_errors() {
3✔
2948
        let mut db = Database::new("t".to_string());
1✔
2949
        let err = process_command("DROP TABLE sqlrite_master;", &mut db).unwrap_err();
2✔
2950
        assert!(format!("{err}").contains("reserved"), "got: {err}");
2✔
2951
    }
2952

2953
    #[test]
2954
    fn drop_table_multi_target_rejected() {
3✔
2955
        let mut db = seed_users_table();
1✔
2956
        process_command("CREATE TABLE other (id INTEGER PRIMARY KEY);", &mut db).unwrap();
2✔
2957
        // sqlparser accepts `DROP TABLE a, b` as one statement; we reject
2958
        // to keep error semantics simple (no partial-failure rollback).
2959
        let err = process_command("DROP TABLE users, other;", &mut db).unwrap_err();
1✔
2960
        assert!(format!("{err}").contains("single table"), "got: {err}");
2✔
2961
    }
2962

2963
    #[test]
2964
    fn drop_table_cascades_indexes_in_memory() {
3✔
2965
        let mut db = seed_users_table();
1✔
2966
        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2✔
2967
        // PK auto-index + UNIQUE-on-name auto-index + the explicit one.
2968
        let users = db.get_table("users".to_string()).unwrap();
1✔
2969
        assert!(
×
2970
            users
3✔
2971
                .secondary_indexes
2972
                .iter()
1✔
2973
                .any(|i| i.name == "users_age_idx")
3✔
2974
        );
2975

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

2978
        // After DROP TABLE, no other table should claim the dropped indexes.
2979
        for table in db.tables.values() {
1✔
2980
            assert!(
×
2981
                !table
×
2982
                    .secondary_indexes
2983
                    .iter()
×
2984
                    .any(|i| i.name.contains("users")),
×
2985
                "dropped table's indexes should not survive on any other table"
2986
            );
2987
        }
2988
    }
2989

2990
    #[test]
2991
    fn drop_index_explicit_basic() {
3✔
2992
        let mut db = seed_users_table();
1✔
2993
        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2✔
2994
        let response = process_command("DROP INDEX users_age_idx;", &mut db).expect("drop index");
1✔
2995
        assert!(response.contains("1 index dropped"));
2✔
2996

2997
        let users = db.get_table("users".to_string()).unwrap();
1✔
2998
        assert!(users.index_by_name("users_age_idx").is_none());
1✔
2999
    }
3000

3001
    #[test]
3002
    fn drop_index_refuses_auto_index() {
3✔
3003
        let mut db = seed_users_table();
1✔
3004
        // `users` was created with `id INTEGER PRIMARY KEY` → auto-index
3005
        // named `sqlrite_autoindex_users_id`.
3006
        let err = process_command("DROP INDEX sqlrite_autoindex_users_id;", &mut db).unwrap_err();
2✔
3007
        assert!(format!("{err}").contains("auto-created"), "got: {err}");
2✔
3008
    }
3009

3010
    #[test]
3011
    fn drop_index_if_exists_noop_on_missing() {
3✔
3012
        let mut db = Database::new("t".to_string());
1✔
3013
        let response =
2✔
3014
            process_command("DROP INDEX IF EXISTS nope;", &mut db).expect("drop index if exists");
3015
        assert!(response.contains("0 indexes dropped"));
2✔
3016
    }
3017

3018
    #[test]
3019
    fn drop_index_missing_errors_without_if_exists() {
3✔
3020
        let mut db = Database::new("t".to_string());
1✔
3021
        let err = process_command("DROP INDEX nope;", &mut db).unwrap_err();
2✔
3022
        assert!(format!("{err}").contains("does not exist"), "got: {err}");
2✔
3023
    }
3024

3025
    #[test]
3026
    fn drop_statements_rejected_on_readonly_db() {
3✔
3027
        use crate::sql::pager::{open_database_read_only, save_database};
3028

3029
        let mut seed = Database::new("t".to_string());
1✔
3030
        process_command(
3031
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
3032
            &mut seed,
3033
        )
3034
        .unwrap();
3035
        process_command("CREATE INDEX notes_body ON notes (body);", &mut seed).unwrap();
1✔
3036
        let path = {
3037
            let mut p = std::env::temp_dir();
1✔
3038
            let pid = std::process::id();
2✔
3039
            let nanos = std::time::SystemTime::now()
2✔
3040
                .duration_since(std::time::UNIX_EPOCH)
1✔
3041
                .map(|d| d.as_nanos())
3✔
3042
                .unwrap_or(0);
3043
            p.push(format!("sqlrite-drop-ro-{pid}-{nanos}.sqlrite"));
1✔
3044
            p
1✔
3045
        };
3046
        save_database(&mut seed, &path).unwrap();
2✔
3047
        drop(seed);
1✔
3048

3049
        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1✔
3050
        for stmt in ["DROP TABLE notes;", "DROP INDEX notes_body;"] {
3✔
3051
            let err = process_command(stmt, &mut ro).unwrap_err();
2✔
3052
            assert!(
×
3053
                format!("{err}").contains("read-only"),
3✔
3054
                "{stmt:?} should surface read-only error, got: {err}"
3055
            );
3056
        }
3057

3058
        let _ = std::fs::remove_file(&path);
1✔
3059
        let mut wal = path.as_os_str().to_owned();
1✔
3060
        wal.push("-wal");
1✔
3061
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
3062
    }
3063

3064
    // -------------------------------------------------------------------
3065
    // ALTER TABLE — RENAME TO / RENAME COLUMN / ADD COLUMN / DROP COLUMN
3066
    // -------------------------------------------------------------------
3067

3068
    #[test]
3069
    fn alter_rename_table_basic() {
3✔
3070
        let mut db = seed_users_table();
1✔
3071
        process_command("ALTER TABLE users RENAME TO members;", &mut db).expect("rename table");
2✔
3072
        assert!(!db.contains_table("users".to_string()));
1✔
3073
        assert!(db.contains_table("members".to_string()));
2✔
3074
        // Data still queryable under the new name.
3075
        let response = process_command("SELECT * FROM members;", &mut db).expect("select");
1✔
3076
        assert!(response.contains("3 rows returned"));
2✔
3077
    }
3078

3079
    #[test]
3080
    fn alter_rename_table_renames_auto_indexes() {
3✔
3081
        // Use a fresh table with both PK and a UNIQUE column so we
3082
        // exercise both auto-index renames in one shot.
3083
        let mut db = Database::new("t".to_string());
1✔
3084
        process_command(
3085
            "CREATE TABLE accounts (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
3086
            &mut db,
3087
        )
3088
        .unwrap();
3089
        {
3090
            let accounts = db.get_table("accounts".to_string()).unwrap();
1✔
3091
            assert!(
×
3092
                accounts
2✔
3093
                    .index_by_name("sqlrite_autoindex_accounts_id")
1✔
3094
                    .is_some()
1✔
3095
            );
3096
            assert!(
×
3097
                accounts
2✔
3098
                    .index_by_name("sqlrite_autoindex_accounts_email")
1✔
3099
                    .is_some()
1✔
3100
            );
3101
        }
3102
        process_command("ALTER TABLE accounts RENAME TO members;", &mut db).expect("rename");
1✔
3103
        let members = db.get_table("members".to_string()).unwrap();
1✔
3104
        assert!(
×
3105
            members
2✔
3106
                .index_by_name("sqlrite_autoindex_members_id")
1✔
3107
                .is_some(),
1✔
3108
            "PK auto-index should be renamed to match new table"
3109
        );
3110
        assert!(
×
3111
            members
2✔
3112
                .index_by_name("sqlrite_autoindex_members_email")
1✔
3113
                .is_some()
1✔
3114
        );
3115
        // The old-named auto-indexes should be gone.
3116
        assert!(
×
3117
            members
2✔
3118
                .index_by_name("sqlrite_autoindex_accounts_id")
1✔
3119
                .is_none()
1✔
3120
        );
3121
        // table_name field on each index should also reflect the rename.
3122
        for idx in &members.secondary_indexes {
1✔
3123
            assert_eq!(idx.table_name, "members");
2✔
3124
        }
3125
    }
3126

3127
    #[test]
3128
    fn alter_rename_table_to_existing_errors() {
3✔
3129
        let mut db = seed_users_table();
1✔
3130
        process_command("CREATE TABLE other (id INTEGER PRIMARY KEY);", &mut db).unwrap();
2✔
3131
        let err = process_command("ALTER TABLE users RENAME TO other;", &mut db).unwrap_err();
1✔
3132
        assert!(format!("{err}").contains("already exists"), "got: {err}");
2✔
3133
        // Both tables still present.
3134
        assert!(db.contains_table("users".to_string()));
1✔
3135
        assert!(db.contains_table("other".to_string()));
1✔
3136
    }
3137

3138
    #[test]
3139
    fn alter_rename_table_to_reserved_name_errors() {
3✔
3140
        let mut db = seed_users_table();
1✔
3141
        let err =
2✔
3142
            process_command("ALTER TABLE users RENAME TO sqlrite_master;", &mut db).unwrap_err();
3143
        assert!(format!("{err}").contains("reserved"), "got: {err}");
2✔
3144
    }
3145

3146
    #[test]
3147
    fn alter_rename_column_basic() {
3✔
3148
        let mut db = seed_users_table();
1✔
3149
        process_command(
3150
            "ALTER TABLE users RENAME COLUMN name TO full_name;",
3151
            &mut db,
3152
        )
3153
        .expect("rename column");
3154

3155
        let users = db.get_table("users".to_string()).unwrap();
1✔
3156
        assert!(users.contains_column("full_name".to_string()));
1✔
3157
        assert!(!users.contains_column("name".to_string()));
1✔
3158

3159
        // Existing data is queryable under the new column name and value
3160
        // is preserved at the same rowid.
3161
        let bob_rowid = users
1✔
3162
            .rowids()
3163
            .into_iter()
3164
            .find(|r| users.get_value("full_name", *r) == Some(Value::Text("bob".to_string())))
3✔
3165
            .expect("bob row should be findable under the new column name");
3166
        assert_eq!(
1✔
3167
            users.get_value("full_name", bob_rowid),
1✔
3168
            Some(Value::Text("bob".to_string()))
2✔
3169
        );
3170
    }
3171

3172
    #[test]
3173
    fn alter_rename_column_collision_errors() {
3✔
3174
        let mut db = seed_users_table();
1✔
3175
        let err =
2✔
3176
            process_command("ALTER TABLE users RENAME COLUMN name TO age;", &mut db).unwrap_err();
3177
        assert!(format!("{err}").contains("already exists"), "got: {err}");
2✔
3178
    }
3179

3180
    #[test]
3181
    fn alter_rename_column_updates_indexes() {
3✔
3182
        // `accounts.email` is UNIQUE → has a renameable auto-index.
3183
        let mut db = Database::new("t".to_string());
1✔
3184
        process_command(
3185
            "CREATE TABLE accounts (id INTEGER PRIMARY KEY, email TEXT UNIQUE);",
3186
            &mut db,
3187
        )
3188
        .unwrap();
3189
        process_command(
3190
            "ALTER TABLE accounts RENAME COLUMN email TO contact;",
3191
            &mut db,
3192
        )
3193
        .unwrap();
3194
        let accounts = db.get_table("accounts".to_string()).unwrap();
1✔
3195
        assert!(
×
3196
            accounts
2✔
3197
                .index_by_name("sqlrite_autoindex_accounts_contact")
1✔
3198
                .is_some()
1✔
3199
        );
3200
        assert!(
×
3201
            accounts
2✔
3202
                .index_by_name("sqlrite_autoindex_accounts_email")
1✔
3203
                .is_none()
1✔
3204
        );
3205
    }
3206

3207
    #[test]
3208
    fn alter_add_column_basic() {
3✔
3209
        let mut db = seed_users_table();
1✔
3210
        process_command("ALTER TABLE users ADD COLUMN nickname TEXT;", &mut db)
1✔
3211
            .expect("add column");
3212
        let users = db.get_table("users".to_string()).unwrap();
1✔
3213
        assert!(users.contains_column("nickname".to_string()));
1✔
3214
        // Existing rows read NULL for the new column (no default given).
3215
        let any_rowid = *users.rowids().first().expect("seed has rows");
1✔
3216
        assert_eq!(users.get_value("nickname", any_rowid), None);
1✔
3217

3218
        // A new INSERT supplying the new column works.
3219
        process_command(
3220
            "INSERT INTO users (name, age, nickname) VALUES ('dan', 22, 'd');",
3221
            &mut db,
3222
        )
3223
        .expect("insert with new col");
3224
        let users = db.get_table("users".to_string()).unwrap();
1✔
3225
        let dan_rowid = users
1✔
3226
            .rowids()
3227
            .into_iter()
3228
            .find(|r| users.get_value("name", *r) == Some(Value::Text("dan".to_string())))
3✔
3229
            .unwrap();
3230
        assert_eq!(
1✔
3231
            users.get_value("nickname", dan_rowid),
1✔
3232
            Some(Value::Text("d".to_string()))
2✔
3233
        );
3234
    }
3235

3236
    #[test]
3237
    fn alter_add_column_with_default_backfills_existing_rows() {
3✔
3238
        let mut db = seed_users_table();
1✔
3239
        process_command(
3240
            "ALTER TABLE users ADD COLUMN status TEXT DEFAULT 'active';",
3241
            &mut db,
3242
        )
3243
        .expect("add column with default");
3244
        let users = db.get_table("users".to_string()).unwrap();
1✔
3245
        for rowid in users.rowids() {
2✔
3246
            assert_eq!(
1✔
3247
                users.get_value("status", rowid),
1✔
3248
                Some(Value::Text("active".to_string())),
2✔
3249
                "rowid {rowid} should have been backfilled with the default"
3250
            );
3251
        }
3252
    }
3253

3254
    #[test]
3255
    fn alter_add_column_not_null_with_default_works_on_nonempty_table() {
3✔
3256
        let mut db = seed_users_table();
1✔
3257
        process_command(
3258
            "ALTER TABLE users ADD COLUMN score INTEGER NOT NULL DEFAULT 0;",
3259
            &mut db,
3260
        )
3261
        .expect("NOT NULL ADD with DEFAULT should succeed even with existing rows");
3262
        let users = db.get_table("users".to_string()).unwrap();
1✔
3263
        for rowid in users.rowids() {
2✔
3264
            assert_eq!(users.get_value("score", rowid), Some(Value::Integer(0)));
2✔
3265
        }
3266
    }
3267

3268
    #[test]
3269
    fn alter_add_column_not_null_without_default_errors_on_nonempty_table() {
3✔
3270
        let mut db = seed_users_table();
1✔
3271
        let err = process_command(
3272
            "ALTER TABLE users ADD COLUMN score INTEGER NOT NULL;",
3273
            &mut db,
3274
        )
3275
        .unwrap_err();
3276
        let msg = format!("{err}").to_lowercase();
2✔
3277
        assert!(
×
3278
            msg.contains("not null") && msg.contains("default"),
2✔
3279
            "got: {msg}"
3280
        );
3281
    }
3282

3283
    #[test]
3284
    fn alter_add_column_pk_rejected() {
3✔
3285
        let mut db = seed_users_table();
1✔
3286
        let err = process_command(
3287
            "ALTER TABLE users ADD COLUMN extra INTEGER PRIMARY KEY;",
3288
            &mut db,
3289
        )
3290
        .unwrap_err();
3291
        assert!(
×
3292
            format!("{err}").to_lowercase().contains("primary key"),
4✔
3293
            "got: {err}"
3294
        );
3295
    }
3296

3297
    #[test]
3298
    fn alter_add_column_unique_rejected() {
3✔
3299
        let mut db = seed_users_table();
1✔
3300
        let err = process_command(
3301
            "ALTER TABLE users ADD COLUMN extra INTEGER UNIQUE;",
3302
            &mut db,
3303
        )
3304
        .unwrap_err();
3305
        assert!(
×
3306
            format!("{err}").to_lowercase().contains("unique"),
4✔
3307
            "got: {err}"
3308
        );
3309
    }
3310

3311
    #[test]
3312
    fn alter_add_column_existing_name_errors() {
3✔
3313
        let mut db = seed_users_table();
1✔
3314
        let err =
2✔
3315
            process_command("ALTER TABLE users ADD COLUMN age INTEGER;", &mut db).unwrap_err();
3316
        assert!(format!("{err}").contains("already exists"), "got: {err}");
2✔
3317
    }
3318

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

3324
    #[test]
3325
    fn alter_drop_column_basic() {
3✔
3326
        let mut db = seed_users_table();
1✔
3327
        process_command("ALTER TABLE users DROP COLUMN age;", &mut db).expect("drop column");
2✔
3328
        let users = db.get_table("users".to_string()).unwrap();
1✔
3329
        assert!(!users.contains_column("age".to_string()));
1✔
3330
        // Other columns and rowids still intact.
3331
        assert!(users.contains_column("name".to_string()));
2✔
3332
        assert_eq!(users.rowids().len(), 3);
1✔
3333
    }
3334

3335
    #[test]
3336
    fn alter_drop_column_drops_dependent_indexes() {
3✔
3337
        let mut db = seed_users_table();
1✔
3338
        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2✔
3339
        process_command("ALTER TABLE users DROP COLUMN age;", &mut db).unwrap();
1✔
3340
        let users = db.get_table("users".to_string()).unwrap();
1✔
3341
        assert!(users.index_by_name("users_age_idx").is_none());
1✔
3342
    }
3343

3344
    #[test]
3345
    fn alter_drop_column_pk_errors() {
3✔
3346
        let mut db = seed_users_table();
1✔
3347
        let err = process_command("ALTER TABLE users DROP COLUMN id;", &mut db).unwrap_err();
2✔
3348
        assert!(
×
3349
            format!("{err}").to_lowercase().contains("primary key"),
4✔
3350
            "got: {err}"
3351
        );
3352
    }
3353

3354
    #[test]
3355
    fn alter_drop_column_only_column_errors() {
3✔
3356
        let mut db = Database::new("t".to_string());
1✔
3357
        process_command("CREATE TABLE solo (only_col TEXT);", &mut db).unwrap();
2✔
3358
        let err = process_command("ALTER TABLE solo DROP COLUMN only_col;", &mut db).unwrap_err();
1✔
3359
        assert!(
×
3360
            format!("{err}").to_lowercase().contains("only column"),
4✔
3361
            "got: {err}"
3362
        );
3363
    }
3364

3365
    #[test]
3366
    fn alter_unknown_table_errors_without_if_exists() {
3✔
3367
        let mut db = Database::new("t".to_string());
1✔
3368
        let err = process_command("ALTER TABLE missing RENAME TO other;", &mut db).unwrap_err();
2✔
3369
        assert!(format!("{err}").contains("does not exist"), "got: {err}");
2✔
3370
    }
3371

3372
    #[test]
3373
    fn alter_unknown_table_if_exists_noop() {
3✔
3374
        let mut db = Database::new("t".to_string());
1✔
3375
        let response = process_command("ALTER TABLE IF EXISTS missing RENAME TO other;", &mut db)
1✔
3376
            .expect("IF EXISTS makes missing-table ALTER a no-op");
3377
        assert!(response.contains("no-op"));
2✔
3378
    }
3379

3380
    #[test]
3381
    fn drop_table_inside_transaction_rolls_back() {
3✔
3382
        // Exercises Database::deep_clone snapshot path with DROP TABLE.
3383
        // A wholesale tables-HashMap restore on ROLLBACK should resurrect
3384
        // the dropped table — including its data and dependent indexes.
3385
        let mut db = seed_users_table();
1✔
3386
        process_command("CREATE INDEX users_age_idx ON users (age);", &mut db).unwrap();
2✔
3387
        process_command("BEGIN;", &mut db).unwrap();
1✔
3388
        process_command("DROP TABLE users;", &mut db).unwrap();
1✔
3389
        assert!(!db.contains_table("users".to_string()));
1✔
3390
        process_command("ROLLBACK;", &mut db).unwrap();
2✔
3391
        assert!(db.contains_table("users".to_string()));
1✔
3392
        let users = db.get_table("users".to_string()).unwrap();
1✔
3393
        assert_eq!(users.rowids().len(), 3);
1✔
3394
        assert!(users.index_by_name("users_age_idx").is_some());
1✔
3395
    }
3396

3397
    #[test]
3398
    fn alter_inside_transaction_rolls_back() {
3✔
3399
        let mut db = seed_users_table();
1✔
3400
        process_command("BEGIN;", &mut db).unwrap();
2✔
3401
        process_command(
3402
            "ALTER TABLE users ADD COLUMN status TEXT DEFAULT 'active';",
3403
            &mut db,
3404
        )
3405
        .unwrap();
3406
        // Confirm in-flight visibility.
3407
        assert!(
×
3408
            db.get_table("users".to_string())
2✔
3409
                .unwrap()
1✔
3410
                .contains_column("status".to_string())
1✔
3411
        );
3412
        process_command("ROLLBACK;", &mut db).unwrap();
1✔
3413
        // Snapshot restore should erase the ALTER.
3414
        assert!(
×
3415
            !db.get_table("users".to_string())
2✔
3416
                .unwrap()
1✔
3417
                .contains_column("status".to_string())
1✔
3418
        );
3419
    }
3420

3421
    #[test]
3422
    fn alter_rejected_on_readonly_db() {
3✔
3423
        use crate::sql::pager::{open_database_read_only, save_database};
3424

3425
        let mut seed = Database::new("t".to_string());
1✔
3426
        process_command(
3427
            "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);",
3428
            &mut seed,
3429
        )
3430
        .unwrap();
3431
        let path = {
3432
            let mut p = std::env::temp_dir();
1✔
3433
            let pid = std::process::id();
2✔
3434
            let nanos = std::time::SystemTime::now()
2✔
3435
                .duration_since(std::time::UNIX_EPOCH)
1✔
3436
                .map(|d| d.as_nanos())
3✔
3437
                .unwrap_or(0);
3438
            p.push(format!("sqlrite-alter-ro-{pid}-{nanos}.sqlrite"));
1✔
3439
            p
1✔
3440
        };
3441
        save_database(&mut seed, &path).unwrap();
2✔
3442
        drop(seed);
1✔
3443

3444
        let mut ro = open_database_read_only(&path, "t".to_string()).unwrap();
1✔
3445
        for stmt in [
3✔
3446
            "ALTER TABLE notes RENAME TO n2;",
3447
            "ALTER TABLE notes RENAME COLUMN body TO b;",
3448
            "ALTER TABLE notes ADD COLUMN extra TEXT;",
3449
            "ALTER TABLE notes DROP COLUMN body;",
3450
        ] {
3451
            let err = process_command(stmt, &mut ro).unwrap_err();
2✔
3452
            assert!(
×
3453
                format!("{err}").contains("read-only"),
3✔
3454
                "{stmt:?} should surface read-only error, got: {err}"
3455
            );
3456
        }
3457

3458
        let _ = std::fs::remove_file(&path);
1✔
3459
        let mut wal = path.as_os_str().to_owned();
1✔
3460
        wal.push("-wal");
1✔
3461
        let _ = std::fs::remove_file(std::path::PathBuf::from(wal));
1✔
3462
    }
3463
}
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