diff --git a/README.md b/README.md index 9168252..a03c565 100644 --- a/README.md +++ b/README.md @@ -172,14 +172,15 @@ sqlrite> DELETE FROM users WHERE age < 30; | Statement | Features | |---|---| -| `CREATE TABLE` | `PRIMARY KEY`, `UNIQUE`, `NOT NULL`; duplicate-column detection; types `INTEGER`/`INT`/`BIGINT`/`SMALLINT`, `TEXT`/`VARCHAR`, `REAL`/`FLOAT`/`DOUBLE`/`DECIMAL`, `BOOLEAN`. Auto-creates `sqlrite_autoindex__` for every PK + UNIQUE column | +| `CREATE TABLE` | `PRIMARY KEY`, `UNIQUE`, `NOT NULL`; `IF NOT EXISTS` (idempotent re-create); duplicate-column detection; types `INTEGER`/`INT`/`BIGINT`/`SMALLINT`, `TEXT`/`VARCHAR`, `REAL`/`FLOAT`/`DOUBLE`/`DECIMAL`, `BOOLEAN`. Auto-creates `sqlrite_autoindex_
_` for every PK + UNIQUE column | | `CREATE [UNIQUE] INDEX` | Single-column, named indexes; `IF NOT EXISTS`; persists as a dedicated cell-based B-Tree. INTEGER + TEXT columns only | | `INSERT INTO` | Explicit column list required; auto-ROWID for `INTEGER PRIMARY KEY`; multi-row `VALUES (…), (…)`; UNIQUE enforcement; clean type errors (no panics); NULL padding for omitted columns | -| `SELECT` | `*` or column list with optional `AS alias`; `WHERE`; `DISTINCT`; `GROUP BY col[, col …]`; aggregate projections `COUNT(*)` / `COUNT([DISTINCT] col)` / `SUM` / `AVG` / `MIN` / `MAX`; `[INNER\|LEFT OUTER\|RIGHT OUTER\|FULL OUTER] JOIN ... ON ...` with table aliases and qualified `t.col` references; single-column `ORDER BY [ASC\|DESC]` (also resolves alias and aggregate display names); `LIMIT n`. `WHERE col = literal` probes an index when one exists | +| `SELECT` | `*` or column list with optional `AS alias`; `WHERE`; `DISTINCT`; `GROUP BY col[, col …]`; aggregate projections `COUNT(*)` / `COUNT([DISTINCT] col)` / `SUM` / `AVG` / `MIN` / `MAX`; `[INNER\|LEFT OUTER\|RIGHT OUTER\|FULL OUTER] JOIN ... ON ...` with table aliases and qualified `t.col` references; single-column `ORDER BY [ASC\|DESC]` (also resolves alias and aggregate display names); `LIMIT n`. `WHERE col = literal` probes an index when one exists. Catalog introspection via `SELECT … FROM sqlrite_master` | | `UPDATE` | Multi-column `SET`; `WHERE`; UNIQUE + type enforcement; arithmetic in assignments (`SET age = age + 1`) | | `DELETE` | `WHERE` predicate or full-table delete | | `BEGIN` / `COMMIT` / `ROLLBACK` | Real transactions, snapshot-based; WAL-backed commit; single-level (no savepoints); auto-rollback if `COMMIT`'s disk write fails | | `PRAGMA auto_vacuum` | Read (`PRAGMA auto_vacuum;`) returns the trigger threshold as a single-row result set; set (`PRAGMA auto_vacuum = 0.5;` / `= OFF;` / `= NONE;`) tunes or disables auto-VACUUM at the SQL layer for SDK / FFI / MCP consumers | +| `PRAGMA table_list` | Lists tables (`schema`, `name`, `type`, `ncol`, `wr`, `strict`) plus the `sqlrite_master` catalog — lightweight catalog introspection for SDK / FFI / MCP consumers | Expressions in `WHERE` and `UPDATE`'s `SET` RHS: diff --git a/docs/supported-sql.md b/docs/supported-sql.md index f46b147..8f1a9b5 100644 --- a/docs/supported-sql.md +++ b/docs/supported-sql.md @@ -8,7 +8,7 @@ If you're looking for _how_ to use SQLRite (REPL flow, meta-commands, history, e | Statement | Supported today | |---|---| -| [`CREATE TABLE`](#create-table) | Columns with `PRIMARY KEY` / `UNIQUE` / `NOT NULL` / `DEFAULT `; typed columns; auto-indexes on constrained columns | +| [`CREATE TABLE`](#create-table) | Columns with `PRIMARY KEY` / `UNIQUE` / `NOT NULL` / `DEFAULT `; typed columns; auto-indexes on constrained columns; `IF NOT EXISTS` | | [`CREATE [UNIQUE] INDEX`](#create-index) | Single-column named indexes, `IF NOT EXISTS`, persisted as cell-based B-Trees | | [`INSERT INTO`](#insert-into) | Auto-ROWID, UNIQUE/PK enforcement, clean type errors, NULL/DEFAULT padding | | [`SELECT`](#select) | `*` or column list, `WHERE`, single-column `ORDER BY`, `LIMIT`; index probing on `col = literal` | @@ -52,9 +52,11 @@ let rows = stmt ## `CREATE TABLE` ```sql -CREATE TABLE ( [column_constraint]* [, ...]); +CREATE TABLE [IF NOT EXISTS] ( [column_constraint]* [, ...]); ``` +`IF NOT EXISTS` (SQLR-10) makes a re-create a no-op when a table of that name already exists, instead of erroring — so "run my schema on every startup" migrations work against a populated database. The clause is honoured **by name only**: SQLRite does not diff the existing schema against the new column list (SQLite behaves the same). Without `IF NOT EXISTS`, re-creating an existing table still errors. + ### Column types | Keyword(s) | Storage class | Notes | @@ -80,7 +82,7 @@ CREATE TABLE ( [column_constraint]* [, ...]); ### Errors returned -- `Table 'foo' already exists.` — duplicate `CREATE TABLE`. +- `Cannot create, table already exists.` — duplicate `CREATE TABLE` (suppressed to a no-op when the statement uses `IF NOT EXISTS`). - `'sqlrite_master' is a reserved name used by the internal schema catalog` — you tried to shadow the catalog table. - `Column 'foo' appears more than once in the table definition` — duplicate column names. - `PRIMARY KEY column must be INTEGER` — PK on a non-integer column. @@ -108,7 +110,7 @@ Every `PRIMARY KEY` and every `UNIQUE` column gets an auto-index at `CREATE TABL sqlrite_autoindex_
_ ``` -These are full-citizen indexes — they're visible via `.tables`-adjacent catalog queries (once those land), persist across saves, and accelerate equality probes. You don't need to `CREATE INDEX` them yourself. +These are full-citizen indexes — they show up in [`sqlrite_master`](#querying-the-catalog-sqlrite_master) catalog queries, persist across saves, and accelerate equality probes. You don't need to `CREATE INDEX` them yourself. ### HNSW indexes (Phase 7d) @@ -267,6 +269,34 @@ The executor includes a tiny optimizer: if the `WHERE` is exactly ` Any of the above reaches the executor as a parsed AST node that execution doesn't handle, producing either `NotImplemented` or a more specific error (e.g., `joins are not supported`). +### Querying the catalog (`sqlrite_master`) + +SQLRite's schema catalog is exposed to SQL as a read-only table named `sqlrite_master` (SQLR-10), mirroring SQLite's `sqlite_master`. Embedders use it to introspect what's in a database — for example to discover existing tables before running migrations. + +```sql +SELECT name FROM sqlrite_master; -- every table + index +SELECT name FROM sqlrite_master WHERE type = 'table'; -- tables only +SELECT name FROM sqlrite_master WHERE type = 'index'; -- indexes only (incl. auto-indexes) +SELECT * FROM sqlrite_master WHERE name = 'users'; -- full row for one object +``` + +Columns (same schema the catalog persists with on disk): + +| Column | Type | Meaning | +|---|---|---| +| `type` | text | `'table'` or `'index'` | +| `name` | text | object name | +| `sql` | text | the `CREATE TABLE` / `CREATE INDEX` text that recreates the object | +| `rootpage` | integer | always `0` in this live view — page numbers are assigned at save time, not at query time. Kept for schema parity with the persisted catalog. | +| `last_rowid` | integer | a table's current auto-ROWID high-water mark (`0` for index rows) | + +Notes: + +- The catalog is synthesized on demand from the live database, so it reflects uncommitted in-memory state, not just what's been saved. +- It is **read-only**: `INSERT` / `UPDATE` / `DELETE` against `sqlrite_master` are rejected, as are `CREATE TABLE` / `DROP TABLE` / `ALTER TABLE` that target the reserved name. +- It works in the single-table `SELECT` path (`WHERE`, projections, `ORDER BY`, `LIMIT`). Joining `sqlrite_master` against another table is not supported. +- For a lighter-weight "what tables exist" check, [`PRAGMA table_list`](#pragma-table_list-sqlr-10) returns a column count per table without synthesizing SQL. + --- ## `UPDATE` @@ -579,6 +609,27 @@ Case-insensitive on both the pragma name and the value. Quoted values (`'mvcc'`) The setting is **per-database** — every `Connection::connect` sibling sees the same value. Reachable through the public API as `Connection::journal_mode() -> JournalMode`. +### `PRAGMA table_list` (SQLR-10) + +Lists the tables in the database — the quick "what's in here?" introspection that SDK / FFI / MCP consumers reach for when they can't run a full [`sqlrite_master`](#querying-the-catalog-sqlrite_master) query. Read-only; the write form (`PRAGMA table_list = …`) is rejected. + +```sql +PRAGMA table_list; -- one row per user table, plus sqlrite_master +``` + +Columns mirror SQLite's `PRAGMA table_list`: + +| Column | Meaning | +|---|---| +| `schema` | always `main` (SQLRite has a single schema) | +| `name` | table name | +| `type` | always `table` (SQLRite doesn't expose views) | +| `ncol` | number of declared columns | +| `wr` | always `0` (no WITHOUT ROWID tables) | +| `strict` | always `0` (no STRICT tables) | + +The synthetic catalog table `sqlrite_master` is listed last (SQLite lists `sqlite_schema` the same way). Indexes are **not** listed here — query [`sqlrite_master`](#querying-the-catalog-sqlrite_master) `WHERE type = 'index'` for those. + --- ## `BEGIN CONCURRENT` (Phase 11.4, SQLR-22) diff --git a/examples/go-collector/internal/store/store.go b/examples/go-collector/internal/store/store.go index 9a550fc..74d560c 100644 --- a/examples/go-collector/internal/store/store.go +++ b/examples/go-collector/internal/store/store.go @@ -20,9 +20,14 @@ // // - No parameter binding in the Go SDK → values are inlined via the // helpers in sqlquote.go. -// - `CREATE TABLE IF NOT EXISTS` is not honored and `sqlrite_master` -// isn't queryable → migrate() probes for the events table with a -// SELECT and only runs DDL on a fresh database. +// - migrate() probes for the events table with a SELECT and only runs +// DDL on a fresh database. NOTE: as of SQLR-10 the engine now honors +// `CREATE TABLE IF NOT EXISTS` and exposes a queryable `sqlrite_master` +// (and `PRAGMA table_list`), so the table-existence probe is no longer +// strictly required for table creation. We keep the fresh/reopen +// distinction because the `CREATE INDEX` below must NOT be re-issued on +// reopen (it's rejected once `journal_mode = mvcc`); the probe also +// keeps this example working against pre-SQLR-10 engine builds. // - `CREATE INDEX` is rejected once `journal_mode = mvcc` → all DDL, // including the optional secondary index, runs at migrate time // before MVCC is switched on. @@ -191,18 +196,18 @@ func (s *Store) Close() error { } // migrate creates the schema on a fresh database and is a no-op on -// reopen. Two engine constraints (both verified against the v0 engine) -// shape this: +// reopen. What shapes this: // -// - `CREATE TABLE IF NOT EXISTS` is NOT honored — a second create of -// an existing table errors "table already exists" — and the -// `sqlrite_master` catalog isn't queryable. So we detect a fresh -// database by probing for the events table with a cheap SELECT and -// only run DDL when it's absent. +// - We detect a fresh database by probing for the events table with a +// cheap SELECT and only run DDL when it's absent. As of SQLR-10 the +// engine honors `CREATE TABLE IF NOT EXISTS` and exposes a queryable +// `sqlrite_master`, so the tables alone wouldn't need the probe — but +// see the next point. // - `CREATE INDEX` is rejected once `journal_mode = mvcc`. All DDL // (tables + the optional index) therefore runs on the fresh path, // in WAL mode, *before* the MVCC switch. On reopen the index already -// exists, so we never re-issue it. +// exists, so we never re-issue it — which is why the fresh/reopen +// probe stays even though IF NOT EXISTS would cover the tables. func (s *Store) migrate(ctx context.Context) error { fresh := !s.tableExists(ctx, "events") @@ -260,10 +265,12 @@ func (s *Store) migrate(ctx context.Context) error { return nil } -// tableExists probes for a table with a zero-row SELECT. The engine has -// no queryable catalog and rejects `CREATE TABLE IF NOT EXISTS`, so this -// probe is how we tell a fresh database from a reopened one. A query -// error (the engine returns "Table '' not found") means absent. +// tableExists probes for a table with a zero-row SELECT. This is how we +// tell a fresh database from a reopened one so the MVCC-incompatible +// `CREATE INDEX` only runs once. A query error (the engine returns +// "Table '' not found") means absent. (As of SQLR-10 the engine +// also exposes `sqlrite_master` and `PRAGMA table_list` for catalog +// introspection — either could back this probe on a current engine.) func (s *Store) tableExists(ctx context.Context, name string) bool { rows, err := s.db.QueryContext(ctx, fmt.Sprintf("SELECT id FROM %s LIMIT 1", name)) if err != nil { diff --git a/src/sql/executor.rs b/src/sql/executor.rs index 181cd90..99ccac3 100644 --- a/src/sql/executor.rs +++ b/src/sql/executor.rs @@ -192,9 +192,23 @@ pub fn execute_select_rows(query: SelectQuery, db: &Database) -> Result` so // both the simple-row path and the aggregation path can iterate the diff --git a/src/sql/mod.rs b/src/sql/mod.rs index 5b05783..806663c 100644 --- a/src/sql/mod.rs +++ b/src/sql/mod.rs @@ -240,9 +240,23 @@ pub fn process_ast_with_render(query: Statement, db: &mut Database) -> Result { - return Err(SQLRiteError::Internal( - "Cannot create, table already exists.".to_string(), - )); + // SQLR-10: `CREATE TABLE IF NOT EXISTS` is a no-op + // when the table already exists, so idempotent + // "run my schema on every startup" migrations work + // against a populated DB. Matches the existing + // `CREATE INDEX IF NOT EXISTS` behaviour and SQLite. + // The clause is honoured by name only — we do NOT + // diff the existing schema against the new column + // list (SQLite doesn't either). + if payload.if_not_exists { + message = format!( + "CREATE TABLE Statement executed. (table '{table_name}' already exists, no-op)" + ); + } else { + return Err(SQLRiteError::Internal( + "Cannot create, table already exists.".to_string(), + )); + } } false => { let table = Table::new(payload); @@ -1906,6 +1920,169 @@ mod tests { assert_eq!(table.hnsw_indexes.len(), 1); } + // ----------------------------------------------------------------- + // SQLR-10 — CREATE TABLE IF NOT EXISTS + sqlrite_master introspection + // ----------------------------------------------------------------- + + #[test] + fn create_table_if_not_exists_is_idempotent() { + let mut db = Database::new("tempdb".to_string()); + process_command( + "CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER);", + &mut db, + ) + .unwrap(); + // Second CREATE with IF NOT EXISTS must succeed as a no-op + // (this is the bug: it used to error "table already exists"). + let msg = process_command( + "CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, v INTEGER);", + &mut db, + ) + .expect("CREATE TABLE IF NOT EXISTS should be a no-op on an existing table"); + assert!( + msg.to_lowercase().contains("no-op"), + "expected a no-op status; got: {msg}" + ); + // The original table is untouched — still exactly one table. + assert_eq!(db.tables.len(), 1); + } + + #[test] + fn create_table_without_if_not_exists_still_errors() { + let mut db = Database::new("tempdb".to_string()); + process_command("CREATE TABLE t (id INTEGER PRIMARY KEY);", &mut db).unwrap(); + let err = process_command("CREATE TABLE t (id INTEGER PRIMARY KEY);", &mut db).unwrap_err(); + assert!( + format!("{err}").to_lowercase().contains("already exists"), + "plain CREATE TABLE on an existing table must still error; got: {err}" + ); + } + + #[test] + fn create_table_if_not_exists_on_fresh_table_creates_it() { + let mut db = Database::new("tempdb".to_string()); + // IF NOT EXISTS on a brand-new name still creates the table. + process_command( + "CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, v INTEGER);", + &mut db, + ) + .unwrap(); + assert!(db.contains_table("t".to_string())); + } + + #[test] + fn select_from_sqlrite_master_lists_tables_and_indexes() { + use crate::sql::executor::execute_select_rows; + use crate::sql::parser::select::SelectQuery; + + let mut db = Database::new("tempdb".to_string()); + process_command( + "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE);", + &mut db, + ) + .unwrap(); + process_command("CREATE TABLE posts (id INTEGER PRIMARY KEY);", &mut db).unwrap(); + process_command("CREATE INDEX ix_email ON users (email);", &mut db).unwrap(); + + // Helper: run a SELECT and return structured rows. + let run = |sql: &str, db: &Database| -> Vec> { + let dialect = SqlriteDialect::new(); + let mut ast = Parser::parse_sql(&dialect, sql).unwrap(); + let sq = SelectQuery::new(&ast.pop().unwrap()).unwrap(); + execute_select_rows(sq, db).unwrap().rows + }; + + // The exact bug from the issue: SELECT name FROM sqlrite_master. + let names: Vec = run("SELECT name FROM sqlrite_master;", &db) + .into_iter() + .map(|r| match &r[0] { + Value::Text(s) => s.clone(), + other => panic!("expected Text name, got {other:?}"), + }) + .collect(); + assert!(names.contains(&"users".to_string())); + assert!(names.contains(&"posts".to_string())); + // The user's UNIQUE column produced an auto-index plus our explicit + // one — both should be visible by name. + assert!(names.contains(&"ix_email".to_string())); + + // type filtering works through the normal WHERE path. + let table_rows = run("SELECT name FROM sqlrite_master WHERE type = 'table';", &db); + assert_eq!(table_rows.len(), 2, "two user tables"); + + let index_rows = run("SELECT name FROM sqlrite_master WHERE type = 'index';", &db); + assert!( + !index_rows.is_empty(), + "at least the explicit ix_email index" + ); + + // SELECT * exposes the full catalog schema (type, name, sql, …). + let all = run("SELECT * FROM sqlrite_master WHERE name = 'users';", &db); + assert_eq!(all.len(), 1); + assert_eq!(all[0].len(), 5, "type, name, sql, rootpage, last_rowid"); + match &all[0][2] { + Value::Text(sql) => assert!( + sql.to_uppercase().contains("CREATE TABLE"), + "sql column should carry the CREATE TABLE text; got: {sql}" + ), + other => panic!("expected Text sql, got {other:?}"), + } + } + + #[test] + fn writes_to_sqlrite_master_are_rejected() { + let mut db = Database::new("tempdb".to_string()); + process_command("CREATE TABLE t (id INTEGER PRIMARY KEY);", &mut db).unwrap(); + // sqlrite_master is read-only: it never lands in db.tables, so an + // INSERT can't find it. (The reserved-name guard also blocks + // CREATE/DROP/ALTER against it.) + assert!( + process_command( + "INSERT INTO sqlrite_master (type, name) VALUES ('table', 'x');", + &mut db + ) + .is_err() + ); + } + + #[test] + fn select_from_sqlrite_master_survives_save_and_reopen() { + use crate::sql::executor::execute_select_rows; + use crate::sql::pager::{open_database, save_database}; + use crate::sql::parser::select::SelectQuery; + + let dir = std::env::temp_dir(); + let path = dir.join(format!("sqlr10_master_{}.sqlrite", std::process::id())); + let _ = std::fs::remove_file(&path); + + let mut db = Database::new("tempdb".to_string()); + process_command("CREATE TABLE alpha (id INTEGER PRIMARY KEY);", &mut db).unwrap(); + process_command("CREATE TABLE beta (id INTEGER PRIMARY KEY);", &mut db).unwrap(); + save_database(&mut db, &path).unwrap(); + + let reopened = open_database(&path, "tempdb".to_string()).unwrap(); + let dialect = SqlriteDialect::new(); + let mut ast = Parser::parse_sql( + &dialect, + "SELECT name FROM sqlrite_master WHERE type = 'table';", + ) + .unwrap(); + let sq = SelectQuery::new(&ast.pop().unwrap()).unwrap(); + let names: Vec = execute_select_rows(sq, &reopened) + .unwrap() + .rows + .into_iter() + .map(|r| match &r[0] { + Value::Text(s) => s.clone(), + other => panic!("expected Text, got {other:?}"), + }) + .collect(); + assert!(names.contains(&"alpha".to_string())); + assert!(names.contains(&"beta".to_string())); + + let _ = std::fs::remove_file(&path); + } + // ----------------------------------------------------------------- // Phase 8b — CREATE INDEX … USING fts end-to-end // ----------------------------------------------------------------- diff --git a/src/sql/pager/mod.rs b/src/sql/pager/mod.rs index 6701918..00c8162 100644 --- a/src/sql/pager/mod.rs +++ b/src/sql/pager/mod.rs @@ -618,6 +618,105 @@ fn build_empty_master_table() -> Table { build_empty_table(MASTER_TABLE_NAME, columns, 0) } +/// SQLR-10 — builds an in-memory, read-only snapshot of `sqlrite_master` +/// from the live `Database`, so `SELECT … FROM sqlrite_master` can +/// introspect the catalog without a save round-trip. One row per user +/// table and per index (B-Tree / HNSW / FTS), reusing the exact same SQL +/// synthesis the persistence path uses — so the `name` / `type` / `sql` +/// columns match byte-for-byte what a saved-then-dumped catalog shows. +/// +/// Rows are ordered deterministically: tables sorted by name, then +/// indexes sorted by `(owning_table, index_name)` — mirroring +/// [`save_database_with_mode`]'s staging order. +/// +/// The `rootpage` column is `0` for every row: page assignment only +/// happens at save time, so a live/in-memory view has no meaningful page +/// number. The column is kept for schema parity with the persisted +/// catalog (and SQLite's `sqlite_master`); callers needing introspection +/// use `type` / `name` / `sql`. `last_rowid` carries the table's current +/// auto-increment high-water mark (0 for index rows). +pub(crate) fn build_master_table_snapshot(db: &Database) -> Result
{ + let mut master = build_empty_master_table(); + + let mut entries: Vec = Vec::new(); + + // Tables, sorted by name. + let mut table_names: Vec<&String> = db.tables.keys().collect(); + table_names.sort(); + for name in &table_names { + let table = &db.tables[*name]; + entries.push(CatalogEntry { + kind: "table".into(), + name: (*name).clone(), + sql: table_to_create_sql(table), + rootpage: 0, + last_rowid: table.last_rowid, + }); + } + + // Indexes across all three families, sorted by (table, index name) — + // collected into one list so the final order matches how a reopened + // catalog enumerates them. + let mut index_entries: Vec<(String, String, String)> = Vec::new(); // (table, index_name, sql) + for table in db.tables.values() { + for idx in &table.secondary_indexes { + index_entries.push(( + table.tb_name.clone(), + idx.name.clone(), + idx.synthesized_sql(), + )); + } + for entry in &table.hnsw_indexes { + index_entries.push(( + table.tb_name.clone(), + entry.name.clone(), + synthesize_hnsw_create_index_sql( + &entry.name, + &table.tb_name, + &entry.column_name, + entry.metric, + ), + )); + } + for entry in &table.fts_indexes { + index_entries.push(( + table.tb_name.clone(), + entry.name.clone(), + format!( + "CREATE INDEX {} ON {} USING fts ({})", + entry.name, table.tb_name, entry.column_name + ), + )); + } + } + index_entries.sort_by(|(ta, ia, _), (tb, ib, _)| ta.cmp(tb).then(ia.cmp(ib))); + for (_table, name, sql) in index_entries { + entries.push(CatalogEntry { + kind: "index".into(), + name, + sql, + rootpage: 0, + last_rowid: 0, + }); + } + + for (i, entry) in entries.into_iter().enumerate() { + let rowid = (i as i64) + 1; + master.restore_row( + rowid, + vec![ + Some(Value::Text(entry.kind)), + Some(Value::Text(entry.name)), + Some(Value::Text(entry.sql)), + Some(Value::Integer(entry.rootpage as i64)), + Some(Value::Integer(entry.last_rowid)), + ], + )?; + } + + Ok(master) +} + /// Reads a required Text column from a known-good catalog row. fn take_text(table: &Table, col: &str, rowid: i64) -> Result { match table.get_value(col, rowid) { diff --git a/src/sql/parser/create.rs b/src/sql/parser/create.rs index ed0812e..ec78c81 100644 --- a/src/sql/parser/create.rs +++ b/src/sql/parser/create.rs @@ -74,6 +74,10 @@ pub struct CreateQuery { pub table_name: String, /// Vector of `ParsedColumn` type with column metadata information pub columns: Vec, + /// `true` when the statement was `CREATE TABLE IF NOT EXISTS …`. + /// When set, re-creating an existing table is a no-op rather than + /// an error — matching `CREATE INDEX IF NOT EXISTS` and SQLite. + pub if_not_exists: bool, } /// Parses a single sqlparser `ColumnDef` into our internal `ParsedColumn` @@ -282,6 +286,7 @@ impl CreateQuery { name, columns, constraints, + if_not_exists, .. }) => { let table_name = name; @@ -322,6 +327,7 @@ impl CreateQuery { Ok(CreateQuery { table_name: table_name.to_string(), columns: parsed_columns, + if_not_exists: *if_not_exists, }) } @@ -365,4 +371,26 @@ mod tests { } } } + + /// SQLR-10 — the `IF NOT EXISTS` clause must surface on `CreateQuery` + /// so the executor can treat a re-create as a no-op. + #[test] + fn create_query_captures_if_not_exists_flag() { + let dialect = SqlriteDialect::new(); + + // Without IF NOT EXISTS → flag is false. + let mut ast = + Parser::parse_sql(&dialect, "CREATE TABLE t (id INTEGER PRIMARY KEY);").unwrap(); + let q = ast.pop().unwrap(); + assert!(!CreateQuery::new(&q).unwrap().if_not_exists); + + // With IF NOT EXISTS → flag is true. + let mut ast = Parser::parse_sql( + &dialect, + "CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY);", + ) + .unwrap(); + let q = ast.pop().unwrap(); + assert!(CreateQuery::new(&q).unwrap().if_not_exists); + } } diff --git a/src/sql/pragma.rs b/src/sql/pragma.rs index 3a1b69d..addc6c7 100644 --- a/src/sql/pragma.rs +++ b/src/sql/pragma.rs @@ -195,6 +195,7 @@ pub fn execute_pragma(stmt: PragmaStatement, db: &mut Database) -> Result pragma_auto_vacuum(stmt.value, db), "journal_mode" => pragma_journal_mode(stmt.value, db), + "table_list" => pragma_table_list(stmt.value, db), other => Err(SQLRiteError::NotImplemented(format!( "PRAGMA '{other}' is not supported" ))), @@ -245,6 +246,66 @@ fn parse_journal_mode_target(value: &PragmaValue) -> Result { }) } +/// `PRAGMA table_list;` (SQLR-10) — lists the tables in the database so +/// embedding SDKs can introspect the catalog (discover existing tables +/// for idempotent migrations) without parsing a rendered `sqlrite_master` +/// query. Read-only: the write form is rejected. +/// +/// Columns mirror SQLite's `PRAGMA table_list`: `schema`, `name`, `type`, +/// `ncol`, `wr`, `strict`. SQLRite has a single schema (`main`), no +/// WITHOUT ROWID tables, and no STRICT tables, so `wr` and `strict` are +/// always `0`. The synthetic catalog table `sqlrite_master` is listed +/// last (matching SQLite, which lists `sqlite_schema`). +fn pragma_table_list(value: Option, db: &Database) -> Result { + if value.is_some() { + return Err(SQLRiteError::General( + "PRAGMA table_list does not take a value".to_string(), + )); + } + + let mut t = PrintTable::new(); + t.add_row(PrintRow::new(vec![ + PrintCell::new("schema"), + PrintCell::new("name"), + PrintCell::new("type"), + PrintCell::new("ncol"), + PrintCell::new("wr"), + PrintCell::new("strict"), + ])); + + let mut names: Vec<&String> = db.tables.keys().collect(); + names.sort(); + let mut row_count = 0usize; + for name in names { + let ncol = db.tables[name].columns.len(); + t.add_row(PrintRow::new(vec![ + PrintCell::new("main"), + PrintCell::new(name), + PrintCell::new("table"), + PrintCell::new(&ncol.to_string()), + PrintCell::new("0"), + PrintCell::new("0"), + ])); + row_count += 1; + } + + // The catalog table itself, listed last (SQLite lists sqlite_schema). + t.add_row(PrintRow::new(vec![ + PrintCell::new("main"), + PrintCell::new(crate::sql::pager::MASTER_TABLE_NAME), + PrintCell::new("table"), + PrintCell::new("5"), + PrintCell::new("0"), + PrintCell::new("0"), + ])); + row_count += 1; + + Ok(CommandOutput { + status: format!("PRAGMA table_list executed. {row_count} rows returned."), + rendered: Some(t.to_string()), + }) +} + /// `PRAGMA auto_vacuum;` (read) or `PRAGMA auto_vacuum = N | OFF | NONE;` /// (write). Reuses [`Database::set_auto_vacuum_threshold`] so the range /// validation lives in exactly one place. @@ -546,6 +607,53 @@ mod tests { assert_eq!(db.auto_vacuum_threshold(), Some(0.25)); } + #[test] + fn execute_pragma_table_list_lists_tables_and_catalog() { + use crate::sql::process_command; + + let mut db = Database::new("t".to_string()); + process_command( + "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT);", + &mut db, + ) + .unwrap(); + process_command("CREATE TABLE posts (id INTEGER PRIMARY KEY);", &mut db).unwrap(); + + let out = execute_pragma( + PragmaStatement { + name: "table_list".to_string(), + value: None, + }, + &mut db, + ) + .unwrap(); + let rendered = out.rendered.expect("table_list renders rows"); + assert!(rendered.contains("users"), "lists user table 'users'"); + assert!(rendered.contains("posts"), "lists user table 'posts'"); + assert!( + rendered.contains("sqlrite_master"), + "lists the catalog table" + ); + // Header columns present. + assert!(rendered.contains("ncol")); + // 2 user tables + sqlrite_master. + assert!(out.status.contains("3 rows"), "status: {}", out.status); + } + + #[test] + fn execute_pragma_table_list_rejects_value() { + let mut db = Database::new("t".to_string()); + let err = execute_pragma( + PragmaStatement { + name: "table_list".to_string(), + value: Some(PragmaValue::Identifier("x".to_string())), + }, + &mut db, + ) + .unwrap_err(); + assert!(format!("{err}").contains("does not take a value")); + } + #[test] fn execute_pragma_auto_vacuum_rejects_negative() { let mut db = Database::new("t".to_string());