Skip to content

Commit 5db377c

Browse files
joaoh82claude
andauthored
feat(sql): IS NULL / IS NOT NULL + typed Option<Value> INSERT pipeline (SQLR-7) (#95)
Two related fixes from SQLR-2 fallout: 1) `WHERE col IS NULL` / `IS NOT NULL` now work — two new arms in `eval_expr`. NULLs aren't stored in secondary / HNSW / FTS indexes, so these predicates correctly fall through to a full scan via the existing `select_rowids` logic. 2) INSERT no longer carries values as `Vec<Vec<String>>` with the string sentinel `"Null"` standing in for SQL NULL. `InsertQuery.rows` is now `Vec<Vec<Option<Value>>>`; the parser emits `None` for NULL and typed `Value::*` for everything else. `Table::insert_row` and `Table::validate_unique_constraint` dispatch on `Option<Value>`, leaving the per-column BTreeMap entry absent for NULL (reads come back as `Value::Null` via the existing missing-rowid path). Fixes: - `INSERT INTO t (n) VALUES (NULL)` for INTEGER / REAL / BOOLEAN / VECTOR columns no longer errors via `"Null".parse::<T>()` - `INSERT INTO t (s) VALUES (NULL)` for TEXT no longer stores the literal string `"Null"` - `Table::restore_row` accepts NULL for non-text column types so persisted NULLs reopen cleanly (without this, a saved DB containing any NULL outside TEXT failed to reload) - SQL UNIQUE allows multiple NULLs (standard SQL three-valued logic) - `DEFAULT NULL` collapses to "no default" — explicit NULL in INSERT is preserved over a column DEFAULT, matching SQLite Tests: 8 new INSERT-NULL tests across all column types; 5 IS NULL / IS NOT NULL executor tests (non-indexed, indexed, omitted-column, combined-with-AND); restored the SQLR-2 `default_does_not_override_ explicit_null` test that had to be dropped because of this bug; new file-backed `null_values_round_trip_through_disk`. The previous `process_command_insert_missing_integer_returns_error_test` codified the bug as a graceful error and is replaced with a positive "omitted INTEGER stores NULL" assertion. Docs: removed "not yet supported" notes for IS NULL / IS NOT NULL from README, supported-sql.md, usage.md, roadmap.md. Out of scope (deferred): - Removing the legacy `Row::Text "Null"` sentinel-strip on read (kept as back-compat for pre-fix saved DBs; revisit at next file-format bump) - Negative numeric literals in INSERT VALUES (pre-existing gap) - IS NULL index optimization (full scan is fine for now) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 99c353c commit 5db377c

8 files changed

Lines changed: 603 additions & 189 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,12 +165,13 @@ sqlrite> DELETE FROM users WHERE age < 30;
165165
Expressions in `WHERE` and `UPDATE`'s `SET` RHS:
166166

167167
- Comparisons — `=`, `<>`, `<`, `<=`, `>`, `>=`
168+
- Null tests — `IS NULL`, `IS NOT NULL`
168169
- Logical — `AND`, `OR`, `NOT` (SQL three-valued logic; NULL-as-false in `WHERE`)
169170
- Arithmetic — `+`, `-`, `*`, `/`, `%` (integer ops stay integer; any `REAL` promotes to `f64`; divide/modulo by zero is a clean error)
170171
- String concat — `||`
171172
- Literals — integer + real numbers, `'single-quoted strings'`, `TRUE` / `FALSE`, `NULL`; parentheses for grouping
172173

173-
**Not yet supported** (common ones): joins, subqueries, CTEs, `GROUP BY` / aggregates, `DISTINCT`, `LIKE` / `IN` / `IS NULL`, expressions in the projection list, column aliases, `OFFSET`, multi-column `ORDER BY`, savepoints, `ALTER TABLE`, `DROP TABLE`, `DROP INDEX`. The [full list with context](docs/supported-sql.md#not-yet-supported) lives in the reference.
174+
**Not yet supported** (common ones): joins, subqueries, CTEs, `GROUP BY` / aggregates, `DISTINCT`, `LIKE` / `IN`, expressions in the projection list, column aliases, `OFFSET`, multi-column `ORDER BY`, savepoints, `ALTER TABLE`, `DROP TABLE`, `DROP INDEX`. The [full list with context](docs/supported-sql.md#not-yet-supported) lives in the reference.
174175

175176
#### Meta commands
176177

@@ -309,7 +310,7 @@ Lockstep versioning — one dispatch bumps every product to the same `vX.Y.Z`. T
309310

310311
**Possible extras** *(no committed phase)*
311312
- Joins (`INNER`, `LEFT OUTER`, `CROSS` — SQLite does not support `RIGHT`/`FULL OUTER`)
312-
- `GROUP BY`, aggregates (`COUNT`, `SUM`, `AVG`, ...), `DISTINCT`, `LIKE`, `IN`, `IS NULL`
313+
- `GROUP BY`, aggregates (`COUNT`, `SUM`, `AVG`, ...), `DISTINCT`, `LIKE`, `IN`
313314
- Composite and expression indexes (with cost analysis)
314315
- Alternate storage engines — LSM/SSTable for write-heavy workloads alongside the B-Tree
315316
- Benchmarks against SQLite

docs/roadmap.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -520,7 +520,7 @@ Final docs pass — canonical [`fts.md`](fts.md) reference (mirrors `ask.md`'s s
520520
## "Possible extras" not pinned to a phase
521521

522522
- Joins (`INNER`, `LEFT OUTER`, `CROSS`)
523-
- `GROUP BY`, aggregates (`COUNT`, `SUM`, `AVG`, ...), `DISTINCT`, `LIKE`, `IN`, `IS NULL`
523+
- `GROUP BY`, aggregates (`COUNT`, `SUM`, `AVG`, ...), `DISTINCT`, `LIKE`, `IN`
524524
- Composite and expression indexes
525525
- Alternate storage engines (LSM/SSTable for write-heavy workloads)
526526
- Benchmarks against SQLite

docs/supported-sql.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ FROM <table>
158158
### What works
159159

160160
- **Projection**: `*` (all columns in declaration order) or a bare column list. Columns not declared on the table are rejected.
161-
- **`WHERE`**: any [expression](#expressions). Evaluated per row; NULL-as-false in WHERE context (three-valued logic collapsed to two-valued for filtering).
161+
- **`WHERE`**: any [expression](#expressions). Evaluated per row; NULL-as-false in WHERE context (three-valued logic collapsed to two-valued for filtering). Includes **`IS NULL`** / **`IS NOT NULL`** for explicit null tests.
162162
- **`ORDER BY`**: single sort key, `ASC` (default) or `DESC`. The sort key can be a bare column reference OR any expression — including function calls — so KNN queries like `ORDER BY vec_distance_l2(embedding, [...]) LIMIT k` work end-to-end *(Phase 7b)*. Sort key types must match; mixing `INTEGER` and `TEXT` across rows under a single `ORDER BY` is a runtime error.
163163
- **`LIMIT`**: non-negative integer literal. `LIMIT 0` is valid (returns zero rows).
164164

@@ -172,7 +172,7 @@ The executor includes a tiny optimizer: if the `WHERE` is exactly `<indexed_col>
172172
- **Subqueries**, CTEs (`WITH`), views
173173
- **`GROUP BY`**, aggregate functions (`COUNT`, `SUM`, `AVG`, `MIN`, `MAX`), `HAVING`
174174
- **`DISTINCT`**
175-
- **`LIKE`**, **`IN`**, **`IS NULL`** / **`IS NOT NULL`**, `BETWEEN`
175+
- **`LIKE`**, **`IN`**, `BETWEEN`
176176
- **Expressions in the projection list** (`SELECT age + 1 FROM users`) — projection is bare column references only
177177
- **Multi-column `ORDER BY`**, `NULLS FIRST/LAST` (single sort key only; the sort key itself can be an expression as of Phase 7b)
178178
- **`OFFSET`**
@@ -382,7 +382,7 @@ See [`docs/fts.md`](fts.md) for the canonical FTS reference (tokenizer rules, BM
382382

383383
SQLRite follows standard SQL three-valued logic:
384384

385-
- **Comparisons involving NULL** (`NULL = 1`, `1 < NULL`) evaluate to unknown, which behaves as `false` inside `WHERE`. Neither the NULL = NULL equality nor the NULL <> NULL inequality is true — use `IS NULL` / `IS NOT NULL` for explicit null tests (both **not yet supported**).
385+
- **Comparisons involving NULL** (`NULL = 1`, `1 < NULL`) evaluate to unknown, which behaves as `false` inside `WHERE`. Neither the NULL = NULL equality nor the NULL <> NULL inequality is true — use **`IS NULL`** / **`IS NOT NULL`** for explicit null tests (`SELECT … WHERE col IS NULL`). NULLs are not stored in secondary, HNSW, or FTS indexes, so `IS NULL` always falls through to a full scan; that's correct, just not as fast as an indexed equality probe.
386386
- **Logical operators with NULL**: `NULL AND false``false`, `NULL AND true``NULL`, `NULL OR true``true`, `NOT NULL``NULL`. The short-circuit rules prevent NULL from propagating when one operand already decides the result.
387387
- **Arithmetic with NULL**: any operand NULL → result NULL. `NULL + 1``NULL`.
388388
- **String concat with NULL**: `'foo' || NULL``NULL` (same propagation as arithmetic).
@@ -503,7 +503,6 @@ For context when you hit `NotImplemented`. See [Roadmap](roadmap.md) for when th
503503
### Predicate & expression
504504
- `LIKE`, `GLOB`, `REGEXP`
505505
- `IN (...)`, `NOT IN`, `BETWEEN`
506-
- `IS NULL`, `IS NOT NULL` (pending — use `col = NULL` is NOT a workaround since it's always false; the only current way to select NULL rows is to rely on the NULL-as-false-in-WHERE behavior being absent when the column isn't referenced)
507506
- `CASE WHEN ... THEN ... END`
508507
- Expressions in the `SELECT` projection list
509508
- Column aliases (`AS`)

docs/usage.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@ Quick hits worth knowing when you're working at the REPL:
4242
- **One statement per call.** The REPL / `Connection::execute` expects a single statement; multi-statement strings return `Expected a single query statement, but there are N`. For batch execution use the SDKs' `executescript` / `execute_batch`.
4343
- **Transactions are real.** `BEGIN` / `COMMIT` / `ROLLBACK` land as expected; auto-save is suppressed inside a transaction and everything flushes in one WAL commit frame on `COMMIT`. No nested transactions yet.
4444
- **Arithmetic stays honest.** Integer-only operations stay integer; any `REAL` operand promotes to `f64`; divide-by-zero is a typed runtime error, never a panic.
45-
- **NULL follows three-valued logic.** `NULL = NULL` is unknown (not true) — treated as false in `WHERE`. Use `IS NULL` / `IS NOT NULL` — oh wait, [those aren't supported yet](supported-sql.md#not-yet-supported). Track via Roadmap.
45+
- **NULL follows three-valued logic.** `NULL = NULL` is unknown (not true) — treated as false in `WHERE`. Use `IS NULL` / `IS NOT NULL` for explicit null tests, e.g. `SELECT id FROM t WHERE qty IS NULL;`.
4646
- **Identifiers are case-sensitive** (table / column names; no normalization), but keywords aren't. String literals preserve case.
47-
- **Not yet supported**: joins, subqueries, `GROUP BY` / aggregates, `DISTINCT`, `LIKE` / `IN` / `IS NULL`, projection expressions, column aliases, `OFFSET`, multi-column `ORDER BY`, savepoints, `ALTER TABLE`, `DROP TABLE`, `DROP INDEX`. See the [full list in the reference](supported-sql.md#not-yet-supported).
47+
- **Not yet supported**: joins, subqueries, `GROUP BY` / aggregates, `DISTINCT`, `LIKE` / `IN`, projection expressions, column aliases, `OFFSET`, multi-column `ORDER BY`, savepoints, `ALTER TABLE`, `DROP TABLE`, `DROP INDEX`. See the [full list in the reference](supported-sql.md#not-yet-supported).
4848

4949
## History
5050

0 commit comments

Comments
 (0)