SQL Executor

The akurai-sqlexec crate is the back half of the query engine. Where akurai-sql turns SQL text into an AST, the executor runs that AST over the akurai-storage copy-on-write B+tree. It is pure std with zero external dependencies, like every shipped crate.

use akurai_sqlexec::{Executor, Outcome};

let mut db = Executor::open("app.db").unwrap();
db.execute("CREATE TABLE users (id INTEGER NOT NULL, name TEXT, age INTEGER)").unwrap();
db.execute("INSERT INTO users (id, name, age) VALUES (1, 'ada', 36)").unwrap();

if let Outcome::Rows(rs) = db.execute(
    "SELECT name, age FROM users WHERE age >= 18 ORDER BY age DESC LIMIT 10"
).unwrap() {
    println!("{:?}", rs.columns); // ["name", "age"]
    println!("{:?}", rs.rows);
}

Aggregate queries use the same scan path:

if let Outcome::Rows(rs) = db.execute(
    "SELECT age, COUNT(*), AVG(score) FROM users WHERE active = 1 GROUP BY age ORDER BY age"
).unwrap() {
    println!("{:?}", rs.columns); // ["age", "COUNT(*)", "AVG(score)"]
    println!("{:?}", rs.rows);
}

Supported statements

The executor runs one statement per execute call and returns an Outcome:

| Statement | What it does | Outcome | |-----------|--------------|---------| | CREATE TABLE t (col TYPE [NOT NULL], …) | Registers a table schema. Rejects a duplicate table. | Created(table) | | INSERT INTO t (cols) VALUES (vals) | Validates against the schema, materializes one typed row, writes it. | Affected(1) | | UPDATE t SET col = val [, …] [WHERE …] | Scans, filters by WHERE, rewrites each matching row in place (same rowid), re-validating SET values against the column types. | Affected(n) | | DELETE FROM t [WHERE …] | Scans, filters by WHERE, removes each matching row. Without WHERE, clears the table. | Affected(n) | | SELECT <*|cols|aggregates> FROM t [WHERE …] [GROUP BY col] [ORDER BY col [ASC|DESC]] [LIMIT n] | Scans, filters, optionally groups and aggregates, sorts, limits, and projects. | Rows(ResultSet) |

A ResultSet carries the projected columns (names, left to right) and rows (each a Vec<Value>, one cell per projected column).

WHERE

WHERE supports the comparison operators =, != / <>, <, <=, >, >= between a column and a literal (either order), combined with AND, OR, NOT, and parentheses — AND binds tighter than OR. Comparisons use numeric semantics across INTEGER/REAL, and lexical order for TEXT. Any comparison that involves a NULL or two incomparable types evaluates to false rather than erroring, so a single odd row never aborts a scan.

ORDER BY and LIMIT

ORDER BY <column> [ASC|DESC] sorts the surviving rows; NULL sorts first. LIMIT n then truncates to the first n rows.

Aggregates and GROUP BY

SELECT supports COUNT(*), COUNT(col), SUM(col), AVG(col), MIN(col), and MAX(col). COUNT(*) counts all rows in the current input or group; COUNT(col) counts non-NULL cells. SUM and AVG ignore NULL cells and return NULL when there are no numeric inputs. SUM over integer input returns an integer unless the sum overflows, in which case it falls back to a float. AVG returns a float. MIN and MAX ignore NULL and use the executor's total value ordering.

GROUP BY is intentionally narrow: one column only. A grouped select may project the group column and aggregate calls. WHERE runs before grouping, and ORDER BY/LIMIT run on the grouped result set.

Row and key model

Each table owns three kinds of keys in the shared B+tree:

NOT NULL flags). The \x00 prefix is reserved; table names are SQL identifiers and never begin with it, so catalog and data keys can never collide.

rowid handed out. It is seeded at CREATE TABLE and bumped per insert, so rowids keep climbing across reopens.

rowid makes data keys sort in insertion order, and a SELECT scans the whole table with a single half-open range over the <table>: prefix.

A row is encoded as a self-describing sequence of cells: a length prefix, then for each column a one-byte type tag (NULL/INT/FLOAT/STR) followed by its payload. Integers and floats are fixed 8-byte big-endian; text is a length plus UTF-8 bytes.

Persistence

Every CREATE TABLE, INSERT, UPDATE, and DELETE ends with a storage-layer commit(), which fsyncs the new pages and atomically swaps the B+tree root. Reopening the database file therefore sees every committed table and row (and every committed update or delete), and the rowid sequence resumes where it left off.

Limitations (this slice)

This is a deliberately tight slice — CREATE / INSERT / UPDATE / DELETE / SELECT end to end. Not yet implemented:

scan.

no multi-statement BEGIN/COMMIT/ROLLBACK.