Query language

A typed SQL subset
with explicit semantics.

The query language is a limited native subset. It includes a parser, nominal type checking, three-valued logic, and WAL-protected DML. It is not a complete SQL dialect.

AreaSupported nowNot supported
SELECTQualified / unqualified columns, wildcard projection, LIMITArbitrary expression projection, output aliases, DISTINCT
FROM / JOINAS and shorthand aliases, chained INNER JOIN … ON, self joinsOuter joins, USING, join reordering, hash / merge join
PredicatesAND / OR / NOT, comparisons, IS NULL, parenthesesIN / BETWEEN / LIKE, subqueries
DMLSingle-row INSERT with an explicit column list, UPDATE, DELETE, optional WHEREDefaults, RETURNING, UPSERT, multi-table writes
ORDER BYMulti source-column keys, ASC / DESC, NULLS FIRST / LASTAliases, ordinals, arbitrary sort expressions
AggregatesCOUNT(*) / COUNT / SUM / MIN / MAX, source-column GROUP BYHAVING, DISTINCT aggregates, grouping expressions, ROLLUP

Nominal types

Schema columns keep both a physical representation and an optional nominal semantic type. HIR requires nominal compatibility in comparisons, so contextual NULL typing cannot make UserId = TeamId legal. Self joins distinguish two occurrences of the same TableId with query-local RelationBindingId values.

physical: UINT64
semantic: UserId

UserId ≠ TeamId   even when both are u64

NULL is a database value

Database NULL is an explicit ScalarValue::Null. Rust Option remains reserved for absent clauses or metadata. Comparisons with NULL yield UNKNOWN; IS NULL / IS NOT NULL are the explicit tests. AND / OR / NOT use SQL three-valued logic. WHERE and JOIN ON keep only TRUE; FALSE and UNKNOWN are rejected.

Bool(true)  → TRUE
Bool(false) → FALSE
NULL        → UNKNOWN

NULL = NULL     → UNKNOWN
NULL IS NULL    → TRUE

JOIN

An alias hides the underlying table name. Qualified columns resolve through the exposed relation name; unqualified columns are accepted only when exactly one visible relation provides the name. Each ON can see the complete left subtree and its current right relation, but not later joins. Nested-loop execution preserves duplicates in deterministic left-major, right-minor order.

SELECT e.name, m.name
FROM employees e
JOIN employees m ON e.manager_id = m.id
WHERE e.active IS NOT NULL
ORDER BY e.name ASC NULLS LAST
LIMIT 20

DML

Typed DML uses the same compiler, transaction, full-page WAL, rollback, and recovery path as heap writes. Database::execute returns query rows or an explicit AffectedRows(u64); query rejects mutating statements. Omitted nullable INSERT columns become NULL; omitted non-nullable columns are rejected. UPDATE evaluates every right-hand side against the original row, so SET a = b, b = a swaps.

INSERT INTO users (id, name) VALUES (1, 'Ada');
UPDATE users SET name = 'Ada Lovelace' WHERE id = 1;
DELETE FROM users WHERE name IS NULL;

Sort and aggregates

The ordinary plan is Scan/Join → Filter → Sort → Project → Limit. The aggregate plan is Scan/Join → Filter → Aggregate → Limit. Keys resolve against the complete FROM / JOIN scope before projection, so a query may sort by a column it does not return.

COUNT(*) counts rows; COUNT(column) ignores NULL. Numeric SUM uses checked arithmetic and strips nominal meaning. MIN / MAX preserve the input SemanticType. NULLs at a grouping key share one group, unlike expression NULL = NULL, which remains UNKNOWN. Grouped queries currently reject ORDER BY.

SELECT team_id, COUNT(*), SUM(score), MAX(score)
FROM scores
GROUP BY team_id

Indexes and ANALYZE

create_index registers a non-unique single-column index after a transactional backfill. Subsequent heap and SQL DML maintains registered indexes. Eligible equality and IS NULL predicates can select a point IndexScan; analyzed two-sided Int64/UInt64 bounds can select a range IndexScan. ANALYZE is explicit and is not maintained by DML. SQL index DDL is not available.

create_index(table, column)
    → transactional backfill
    → register in IndexCatalog
    → DML maintains the index
    → ANALYZE writes a cost snapshot
    → planner may choose IndexScan

Multi-table writes are still unsupported

The core composes unchanged one-table heap files with create_tables / open_tables. JOIN did not change the page, WAL, recovery, or transaction format. Cross-table write transactions remain unsupported.