Skip to content

Commit 05fd59c

Browse files
joaoh82claude
andauthored
SQLR-10 — honor CREATE TABLE IF NOT EXISTS + make sqlrite_master queryable (#151)
Two engine ergonomics gaps that every embedding SDK consumer hits when writing idempotent "run my schema on startup" migrations: 1. `CREATE TABLE IF NOT EXISTS` was parsed but ignored — a second create of an existing table errored "table already exists", unlike `CREATE INDEX IF NOT EXISTS`. Now `CreateQuery` carries the `if_not_exists` flag and the executor treats a re-create as a no-op (by name only; no schema diff, matching SQLite). 2. The schema catalog wasn't reachable from SQL. Added: - `SELECT … FROM sqlrite_master` — synthesizes a read-only in-memory catalog table on demand (type/name/sql/rootpage/last_rowid), reusing the same SQL synthesis the persistence path uses. Works through the normal single-table SELECT path (WHERE/projection/ORDER BY/LIMIT); reflects live in-memory state. Writes remain rejected; joins against it are out of scope. - `PRAGMA table_list` — SQLite-canonical lightweight introspection (schema/name/type/ncol/wr/strict) listing tables + sqlrite_master. Covers the REPL and every SDK (all route through execute_select_rows / process_command). Docs (supported-sql.md, README) updated; the go-collector example's now-stale workaround comments refreshed. Tests: parser flag capture; IF NOT EXISTS idempotency + still-errors without it + fresh-create; sqlrite_master listing/filtering/SELECT */ write-rejection/save-reopen; PRAGMA table_list listing + value-rejection. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent aa40fc8 commit 05fd59c

8 files changed

Lines changed: 512 additions & 27 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,14 +172,15 @@ sqlrite> DELETE FROM users WHERE age < 30;
172172

173173
| Statement | Features |
174174
|---|---|
175-
| `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_<table>_<col>` for every PK + UNIQUE column |
175+
| `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_<table>_<col>` for every PK + UNIQUE column |
176176
| `CREATE [UNIQUE] INDEX` | Single-column, named indexes; `IF NOT EXISTS`; persists as a dedicated cell-based B-Tree. INTEGER + TEXT columns only |
177177
| `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 |
178-
| `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 |
178+
| `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` |
179179
| `UPDATE` | Multi-column `SET`; `WHERE`; UNIQUE + type enforcement; arithmetic in assignments (`SET age = age + 1`) |
180180
| `DELETE` | `WHERE` predicate or full-table delete |
181181
| `BEGIN` / `COMMIT` / `ROLLBACK` | Real transactions, snapshot-based; WAL-backed commit; single-level (no savepoints); auto-rollback if `COMMIT`'s disk write fails |
182182
| `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 |
183+
| `PRAGMA table_list` | Lists tables (`schema`, `name`, `type`, `ncol`, `wr`, `strict`) plus the `sqlrite_master` catalog — lightweight catalog introspection for SDK / FFI / MCP consumers |
183184

184185
Expressions in `WHERE` and `UPDATE`'s `SET` RHS:
185186

docs/supported-sql.md

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ If you're looking for _how_ to use SQLRite (REPL flow, meta-commands, history, e
88

99
| Statement | Supported today |
1010
|---|---|
11-
| [`CREATE TABLE`](#create-table) | Columns with `PRIMARY KEY` / `UNIQUE` / `NOT NULL` / `DEFAULT <literal>`; typed columns; auto-indexes on constrained columns |
11+
| [`CREATE TABLE`](#create-table) | Columns with `PRIMARY KEY` / `UNIQUE` / `NOT NULL` / `DEFAULT <literal>`; typed columns; auto-indexes on constrained columns; `IF NOT EXISTS` |
1212
| [`CREATE [UNIQUE] INDEX`](#create-index) | Single-column named indexes, `IF NOT EXISTS`, persisted as cell-based B-Trees |
1313
| [`INSERT INTO`](#insert-into) | Auto-ROWID, UNIQUE/PK enforcement, clean type errors, NULL/DEFAULT padding |
1414
| [`SELECT`](#select) | `*` or column list, `WHERE`, single-column `ORDER BY`, `LIMIT`; index probing on `col = literal` |
@@ -52,9 +52,11 @@ let rows = stmt
5252
## `CREATE TABLE`
5353

5454
```sql
55-
CREATE TABLE <name> (<col> <type> [column_constraint]* [, ...]);
55+
CREATE TABLE [IF NOT EXISTS] <name> (<col> <type> [column_constraint]* [, ...]);
5656
```
5757

58+
`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.
59+
5860
### Column types
5961

6062
| Keyword(s) | Storage class | Notes |
@@ -80,7 +82,7 @@ CREATE TABLE <name> (<col> <type> [column_constraint]* [, ...]);
8082

8183
### Errors returned
8284

83-
- `Table 'foo' already exists.` — duplicate `CREATE TABLE`.
85+
- `Cannot create, table already exists.` — duplicate `CREATE TABLE` (suppressed to a no-op when the statement uses `IF NOT EXISTS`).
8486
- `'sqlrite_master' is a reserved name used by the internal schema catalog` — you tried to shadow the catalog table.
8587
- `Column 'foo' appears more than once in the table definition` — duplicate column names.
8688
- `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
108110
sqlrite_autoindex_<table>_<column>
109111
```
110112

111-
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.
113+
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.
112114

113115
### HNSW indexes (Phase 7d)
114116

@@ -267,6 +269,34 @@ The executor includes a tiny optimizer: if the `WHERE` is exactly `<indexed_col>
267269

268270
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`).
269271

272+
### Querying the catalog (`sqlrite_master`)
273+
274+
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.
275+
276+
```sql
277+
SELECT name FROM sqlrite_master; -- every table + index
278+
SELECT name FROM sqlrite_master WHERE type = 'table'; -- tables only
279+
SELECT name FROM sqlrite_master WHERE type = 'index'; -- indexes only (incl. auto-indexes)
280+
SELECT * FROM sqlrite_master WHERE name = 'users'; -- full row for one object
281+
```
282+
283+
Columns (same schema the catalog persists with on disk):
284+
285+
| Column | Type | Meaning |
286+
|---|---|---|
287+
| `type` | text | `'table'` or `'index'` |
288+
| `name` | text | object name |
289+
| `sql` | text | the `CREATE TABLE` / `CREATE INDEX` text that recreates the object |
290+
| `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. |
291+
| `last_rowid` | integer | a table's current auto-ROWID high-water mark (`0` for index rows) |
292+
293+
Notes:
294+
295+
- The catalog is synthesized on demand from the live database, so it reflects uncommitted in-memory state, not just what's been saved.
296+
- 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.
297+
- It works in the single-table `SELECT` path (`WHERE`, projections, `ORDER BY`, `LIMIT`). Joining `sqlrite_master` against another table is not supported.
298+
- 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.
299+
270300
---
271301

272302
## `UPDATE`
@@ -579,6 +609,27 @@ Case-insensitive on both the pragma name and the value. Quoted values (`'mvcc'`)
579609

580610
The setting is **per-database** — every `Connection::connect` sibling sees the same value. Reachable through the public API as `Connection::journal_mode() -> JournalMode`.
581611

612+
### `PRAGMA table_list` (SQLR-10)
613+
614+
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.
615+
616+
```sql
617+
PRAGMA table_list; -- one row per user table, plus sqlrite_master
618+
```
619+
620+
Columns mirror SQLite's `PRAGMA table_list`:
621+
622+
| Column | Meaning |
623+
|---|---|
624+
| `schema` | always `main` (SQLRite has a single schema) |
625+
| `name` | table name |
626+
| `type` | always `table` (SQLRite doesn't expose views) |
627+
| `ncol` | number of declared columns |
628+
| `wr` | always `0` (no WITHOUT ROWID tables) |
629+
| `strict` | always `0` (no STRICT tables) |
630+
631+
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.
632+
582633
---
583634

584635
## `BEGIN CONCURRENT` (Phase 11.4, SQLR-22)

examples/go-collector/internal/store/store.go

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,14 @@
2020
//
2121
// - No parameter binding in the Go SDK → values are inlined via the
2222
// helpers in sqlquote.go.
23-
// - `CREATE TABLE IF NOT EXISTS` is not honored and `sqlrite_master`
24-
// isn't queryable → migrate() probes for the events table with a
25-
// SELECT and only runs DDL on a fresh database.
23+
// - migrate() probes for the events table with a SELECT and only runs
24+
// DDL on a fresh database. NOTE: as of SQLR-10 the engine now honors
25+
// `CREATE TABLE IF NOT EXISTS` and exposes a queryable `sqlrite_master`
26+
// (and `PRAGMA table_list`), so the table-existence probe is no longer
27+
// strictly required for table creation. We keep the fresh/reopen
28+
// distinction because the `CREATE INDEX` below must NOT be re-issued on
29+
// reopen (it's rejected once `journal_mode = mvcc`); the probe also
30+
// keeps this example working against pre-SQLR-10 engine builds.
2631
// - `CREATE INDEX` is rejected once `journal_mode = mvcc` → all DDL,
2732
// including the optional secondary index, runs at migrate time
2833
// before MVCC is switched on.
@@ -191,18 +196,18 @@ func (s *Store) Close() error {
191196
}
192197

193198
// migrate creates the schema on a fresh database and is a no-op on
194-
// reopen. Two engine constraints (both verified against the v0 engine)
195-
// shape this:
199+
// reopen. What shapes this:
196200
//
197-
// - `CREATE TABLE IF NOT EXISTS` is NOT honored — a second create of
198-
// an existing table errors "table already exists" — and the
199-
// `sqlrite_master` catalog isn't queryable. So we detect a fresh
200-
// database by probing for the events table with a cheap SELECT and
201-
// only run DDL when it's absent.
201+
// - We detect a fresh database by probing for the events table with a
202+
// cheap SELECT and only run DDL when it's absent. As of SQLR-10 the
203+
// engine honors `CREATE TABLE IF NOT EXISTS` and exposes a queryable
204+
// `sqlrite_master`, so the tables alone wouldn't need the probe — but
205+
// see the next point.
202206
// - `CREATE INDEX` is rejected once `journal_mode = mvcc`. All DDL
203207
// (tables + the optional index) therefore runs on the fresh path,
204208
// in WAL mode, *before* the MVCC switch. On reopen the index already
205-
// exists, so we never re-issue it.
209+
// exists, so we never re-issue it — which is why the fresh/reopen
210+
// probe stays even though IF NOT EXISTS would cover the tables.
206211
func (s *Store) migrate(ctx context.Context) error {
207212
fresh := !s.tableExists(ctx, "events")
208213

@@ -260,10 +265,12 @@ func (s *Store) migrate(ctx context.Context) error {
260265
return nil
261266
}
262267

263-
// tableExists probes for a table with a zero-row SELECT. The engine has
264-
// no queryable catalog and rejects `CREATE TABLE IF NOT EXISTS`, so this
265-
// probe is how we tell a fresh database from a reopened one. A query
266-
// error (the engine returns "Table '<name>' not found") means absent.
268+
// tableExists probes for a table with a zero-row SELECT. This is how we
269+
// tell a fresh database from a reopened one so the MVCC-incompatible
270+
// `CREATE INDEX` only runs once. A query error (the engine returns
271+
// "Table '<name>' not found") means absent. (As of SQLR-10 the engine
272+
// also exposes `sqlrite_master` and `PRAGMA table_list` for catalog
273+
// introspection — either could back this probe on a current engine.)
267274
func (s *Store) tableExists(ctx context.Context, name string) bool {
268275
rows, err := s.db.QueryContext(ctx, fmt.Sprintf("SELECT id FROM %s LIMIT 1", name))
269276
if err != nil {

src/sql/executor.rs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,9 +192,23 @@ pub fn execute_select_rows(query: SelectQuery, db: &Database) -> Result<SelectRe
192192
return execute_select_rows_joined(query, db);
193193
}
194194

195-
let table = db
196-
.get_table(query.table_name.clone())
197-
.map_err(|_| SQLRiteError::Internal(format!("Table '{}' not found", query.table_name)))?;
195+
// SQLR-10 — `SELECT … FROM sqlrite_master` introspects the catalog.
196+
// The catalog isn't a live entry in `db.tables` (it's materialized at
197+
// save time), so we synthesize a read-only in-memory snapshot on
198+
// demand and run the normal single-table path against it. WHERE /
199+
// projections / ORDER BY / LIMIT all work unchanged. Writes against
200+
// sqlrite_master remain rejected (it never lands in `db.tables`), and
201+
// joins against it are not supported (the joined path doesn't
202+
// synthesize it).
203+
let master_snapshot;
204+
let table: &Table = if query.table_name == crate::sql::pager::MASTER_TABLE_NAME {
205+
master_snapshot = crate::sql::pager::build_master_table_snapshot(db)?;
206+
&master_snapshot
207+
} else {
208+
db.get_table(query.table_name.clone()).map_err(|_| {
209+
SQLRiteError::Internal(format!("Table '{}' not found", query.table_name))
210+
})?
211+
};
198212

199213
// SQLR-3: Materialize the projection as `Vec<ProjectionItem>` so
200214
// both the simple-row path and the aggregation path can iterate the

0 commit comments

Comments
 (0)