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());