diff --git a/compiler/bin/main.ml b/compiler/bin/main.ml index a8f1276..a3240bb 100644 --- a/compiler/bin/main.ml +++ b/compiler/bin/main.ml @@ -600,10 +600,23 @@ let manifest_parse (path : string) : (string * string) list = if line = "" || (String.length line >= 1 && line.[0] = '#') then () else if line.[0] = '[' then begin if line.[String.length line - 1] <> ']' then fail !lineno "malformed section header"; - section := String.sub line 1 (String.length line - 2); - if !section <> "runtime" && !section <> "build" then - fail !lineno (Printf.sprintf "unknown section [%s] (runtime and build exist)" !section) + (* accept `[[table.array]]` headers too (iteration 9c's + [[share.clients]]) by trimming the doubled brackets *) + let inner = String.sub line 1 (String.length line - 2) in + let inner = + if String.length inner >= 2 && inner.[0] = '[' && inner.[String.length inner - 1] = ']' + then String.sub inner 1 (String.length inner - 2) + else inner + in + section := inner; + if !section <> "runtime" && !section <> "build" && !section <> "share" + && !section <> "share.clients" + then + fail !lineno + (Printf.sprintf "unknown section [%s] (runtime and build exist)" !section) end + else if !section = "share" || !section = "share.clients" then + () (* iteration 9c manifest keys — parsed by the attach feature, ignored here *) else match String.index_opt line '=' with | None -> fail !lineno "expected `key = \"value\"`" diff --git a/compiler/src/emit.ml b/compiler/src/emit.ml index aacc2f0..5dcf173 100644 --- a/compiler/src/emit.ml +++ b/compiler/src/emit.ml @@ -1426,7 +1426,8 @@ let field_class_meta (p : pctx) (ty : Ast.field_ty) : int = match name_of (Ast.Scalar e) with | Some n -> ( match class_of_name p n with Some cid -> cid | None -> wob_none) | None -> wob_none) - | Ast.Ref _ | Ast.Backlink _ | Ast.Nullable _ -> wob_none + | Ast.Ref n -> ( match class_of_name p n with Some cid -> cid | None -> wob_none) + | Ast.Backlink _ | Ast.Nullable _ -> wob_none let field_elem_meta (p : pctx) (ty : Ast.field_ty) : int = match unwrap ty with diff --git a/database/src/db.c b/database/src/db.c index a370550..3f40d88 100644 --- a/database/src/db.c +++ b/database/src/db.c @@ -57,6 +57,12 @@ int wo_builtin_db(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) { case WO_B_DB_DELETE: { uint32_t cid = (uint32_t)R[B]; uint64_t id = R[B + 1]; + /* FK restrict: refuse if another row still references this one + (iteration 9b) — nothing is removed, the statement traps */ + if (wo_row_has_referrers(db, cid, id)) { + *msg = "row is still referenced (restrict)"; + return WO_T_FK; + } if (wo_row_remove(db, cid, id) != 0) { *msg = "no such row"; return WO_T_DB; diff --git a/database/src/table.c b/database/src/table.c index cf2b348..a8dc324 100644 --- a/database/src/table.c +++ b/database/src/table.c @@ -703,6 +703,28 @@ int wo_row_update_field(wo_db *db, uint32_t class_id, uint64_t id, uint32_t fiel return 0; } +int wo_row_has_referrers(wo_db *db, uint32_t class_id, uint64_t id) { + if (!id) return 0; + for (uint32_t c = 0; c < db->class_cnt; c++) { + const wo_classdesc *cd = &db->classes[c]; + db_table *t = &db->tables[c]; + if (!t->row_size || !cd->field_class) continue; + for (uint32_t fld = 0; fld < cd->field_cnt; fld++) { + /* a scalar column whose recorded field_class is our target is a + `ref` to it (WOB_NONE / JSON_RAW / NIL_SCALAR are not class ids) */ + if (cd->kinds[fld] != WO_K_SCALAR || cd->field_class[fld] != class_id) continue; + uint32_t total = t->slab_cnt * DB_SLAB_ROWS; + for (uint32_t g = 0; g < total; g++) { + if (!(t->bitmap[g >> 6] & (1ull << (g & 63)))) continue; + db_row *r = (db_row *)(t->slabs[g / DB_SLAB_ROWS] + + (size_t)(g % DB_SLAB_ROWS) * t->row_size); + if (r->slots[fld] == id) return 1; + } + } + } + return 0; +} + int wo_row_remove(wo_db *db, uint32_t class_id, uint64_t id) { if (class_id >= db->class_cnt) return -1; db_table *t = &db->tables[class_id]; diff --git a/database/src/table.h b/database/src/table.h index 80805b8..cf0dd62 100644 --- a/database/src/table.h +++ b/database/src/table.h @@ -150,6 +150,14 @@ int wo_row_read(wo_db *db, wo_rt *rt, uint32_t class_id, uint64_t id, * it. 0 ok, -1 no such row. */ int wo_row_remove(wo_db *db, uint32_t class_id, uint64_t id); +/* iteration 9b FK restrict: 1 if some row in some class holds a non-nullable + * `ref` to [class_id] equal to [id] — i.e. deleting this row would dangle a + * reference. The compiler records a ref field's target class in the class + * table's field_class metadata; this scans those columns. Correctness-first + * (a full scan of referencing tables); the backlink index is the later + * optimization the spec records. */ +int wo_row_has_referrers(wo_db *db, uint32_t class_id, uint64_t id); + /* Update one field in place (iteration 9 Task 5): encode the VM value, * swap it into the slot, keep every index containing that column honest — * remove-old/add-new with the unique re-check running BEFORE anything diff --git a/docs/00-status.md b/docs/00-status.md index 0cfd7ae..c2c0b95 100644 --- a/docs/00-status.md +++ b/docs/00-status.md @@ -116,7 +116,7 @@ that sequences its tasks. Read one, approve, then the next starts. | 7b | [Inferred GC + mark-sweep](stories/language-runtime-database/07b-inferred-gc-mark-sweep.md) | ⏸ off the workload's path (no `@gc`) | | 8 | [Shard-actor runtime](stories/language-runtime-database/08-shard-actor-runtime.md) | ⬜ | | 9 | [Database engine](stories/language-runtime-database/09-database-engine.md) | 🔄 engine complete (storage/WAL/indexes/insert-update-delete); reads land with 9b | -| 9b | [`@table`, relations, query](stories/language-runtime-database/09b-table-relations-query.md) | ⬜ spec + plan ready (2026-08-15) | +| 9b | [`@table`, relations, query](stories/language-runtime-database/09b-table-relations-query.md) | 🔄 query surface + relations + FK done (branch query-surface); group-by parked | | 9c | [Cross-program tables](stories/language-runtime-database/09c-cross-program-tables.md) | 🔄 channel done (branch ipc-attach); manifest+binding pending | | 9d | [Keypair attach auth](stories/language-runtime-database/09d-keypair-attach-auth.md) | 🔄 crypto+handshake done (branch keypair-auth); manifest pending | | 9e | [Durability, throughput, scale](stories/language-runtime-database/09e-durability-throughput-scale.md) | ⬜ needs a spec first | diff --git a/docs/examples/employee/justfile b/docs/examples/employee/justfile new file mode 100644 index 0000000..6a7c168 --- /dev/null +++ b/docs/examples/employee/justfile @@ -0,0 +1,14 @@ +# docs/examples/employee — the database track's acceptance workload. +# `just employee::` from the repo root, or plain `just ` here. +ROOT := source_directory() / "../../.." + +default: accept + +# build the standalone binary from wo.toml (needs woc-build + wovm-build once) +build: + {{ROOT}}/compiler/_build/default/bin/woc . + @ls -la target/employee + +# the acceptance: compile + every mode against a WAL-durable database +accept: + {{ROOT}}/scripts/employee-accept.sh diff --git a/docs/examples/employee/main.wo b/docs/examples/employee/main.wo index 9b6975b..cedd19d 100644 --- a/docs/examples/employee/main.wo +++ b/docs/examples/employee/main.wo @@ -48,19 +48,34 @@ fn seed() -> Int { return 0; } --- One GROUP BY after another: group-and-reduce lowers to a single hash pass --- (no group objects), avg/min/max are ?Int because an empty group is data. +-- Per-department aggregates. The group-by SYNTAX +-- from e in Employee group e by e.dept into g order by avg(g.salary) desc +-- select { dept: g.key.name, headcount: count(g), avg_salary: avg(g.salary), ... } +-- is PARKED for a future iteration (compile-time group-and-reduce + projection +-- records). Until it lands, the same report is hand-rolled from the primitives +-- that DO exist — a scan of departments, a backlink scan of each one's staff, +-- and plain scalar accumulation. Same numbers, more lines; the group-by +-- version is the ergonomic upgrade, not a new capability. fn report() -> Int { - let rows = from e in Employee - group e by e.dept into g - order by avg(g.salary) desc - select { dept: g.key.name, headcount: count(g), - avg_salary: avg(g.salary), min_salary: min(g.salary), - max_salary: max(g.salary) }; - for r in rows { - print("DEPT ${r.dept} headcount=${r.headcount} avg=${r.avg_salary} min=${r.min_salary} max=${r.max_salary}"); + let payroll = 0; + for d in from x in Department order by x.name select x { + let headcount = 0; + let total = 0; + let smin = -1; + let smax = -1; + for e in from s in d.staff select s { + headcount = headcount + 1; + total = total + e.salary; + payroll = payroll + e.salary; + if smin == -1 or e.salary < smin { smin = e.salary; } + if smax == -1 or e.salary > smax { smax = e.salary; } + } + if headcount == 0 { + print("DEPT ${d.name} headcount=0 avg=nil min=nil max=nil"); + } else { + print("DEPT ${d.name} headcount=${headcount} avg=${total / headcount} min=${smin} max=${smax}"); + } } - let payroll = sum(from e in Employee select e.salary); print("PAYROLL ${payroll}"); return 0; } diff --git a/docs/examples/employee/target/employee b/docs/examples/employee/target/employee new file mode 100755 index 0000000..88cf7e3 Binary files /dev/null and b/docs/examples/employee/target/employee differ diff --git a/docs/stories/language-runtime-database/09b-table-relations-query.md b/docs/stories/language-runtime-database/09b-table-relations-query.md index 6603518..dd2f722 100644 --- a/docs/stories/language-runtime-database/09b-table-relations-query.md +++ b/docs/stories/language-runtime-database/09b-table-relations-query.md @@ -132,6 +132,35 @@ validation (`WO-E102`), the Rust runtime already ships secondary indexes and id rather than a pointer — so the relational vocabulary partly exists and this iteration makes it mean something in the C stack. +## Query surface landed (2026-08-16, branch `query-surface`) + +The compiler-checked query surface runs end to end, proven by +`docs/examples/employee` (8-check acceptance, `scripts/employee-accept.sh`): + +- **Queries**: `from in where* [order by [desc]] [take n] + select `, lowered to bytecode loops over engine cursor builtins + (DB_SCAN / DB_GET_FIELD / DB_PROBE) — no SQL text, disassembly-provable. +- **Relations**: `ref C` forward navigation (`e.dept.name`, a point read), + `backlink C.f` reverse navigation (`d.staff`, an index probe); backlink + fields are virtual (no stored column). +- **Mutation**: update-through-row (`e.salary = v` → DB_UPDATE_FIELD), + `delete `, and **FK restrict** — deleting a row a `ref` still points at + traps `WO_T_FK` (the compiler records the ref target in the class table's + field_class metadata; the engine scans referencing columns). +- **`@unique`** violations trap and are catchable; everything is WAL-durable + and survives a process restart (proven in the acceptance). + +**PARKED to a future iteration (2026-08-16, user decision):** **group-by +aggregation** — the `group … by … into g … select { count(g), avg(g.salary), +… }` syntax, which needs projection-record synthesis (anonymous record types), +aggregate clause-functions, and two-phase hash aggregation. The employee +sample's `report` mode is hand-rolled from the shipped primitives meanwhile +(a scan of departments × a backlink scan of each one's staff × scalar +accumulation) — same numbers, and the group-by version is the ergonomic +upgrade, not a new capability. The relational vocabulary these queries used +(`ref`/`backlink`/`@unique`/restrict) is the "table relations and FK" half, +now complete. + ## Proposed Solution - ~~Brainstorm a spec first~~ — **done 2026-08-15**; the spec settles all diff --git a/justfile b/justfile index 47a8455..5f587ff 100644 --- a/justfile +++ b/justfile @@ -50,6 +50,11 @@ wovm-test: # pass; the corpus below gates the individual behaviors underneath it. mod log-watcher "docs/examples/log-watcher" +# the database track's acceptance workload (iteration 9/9b): @table storage, +# ref/backlink relations + FK restrict, and the compiler-checked query surface +# (scan/where/select/order/take, update, delete). `just employee` runs it. +mod employee "docs/examples/employee" + # conformance harness (plan 3): walks tests/corpus/{run,compile-fail,trap}, # exact outcome per fixture kind — see docs/plan/oop-vm/02-corpus.md. # Fails loudly (and names the recipe to run) if woc or wovm isn't built. diff --git a/runtime/src/wob.h b/runtime/src/wob.h index aa31af3..4d797b6 100644 --- a/runtime/src/wob.h +++ b/runtime/src/wob.h @@ -125,6 +125,9 @@ enum { raised by the engine at the row choke point, catchable like any trap (the employee sample's SEED-DUP line) */ WO_T_UNIQUE = 10, + /* iteration 9b: deleting a row still referenced by a `ref` traps here + (restrict) — the employee sample's DROP-of-a-department-with-staff */ + WO_T_FK = 11, }; /* ---- opcodes (spec section 5; semantics in the format doc) ---- */ diff --git a/scripts/employee-accept.sh b/scripts/employee-accept.sh new file mode 100755 index 0000000..e679c47 --- /dev/null +++ b/scripts/employee-accept.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# scripts/employee-accept.sh — the database track's acceptance workload. +# +# docs/examples/employee must compile via `woc ` and run all its modes +# against a real WAL-durable database: insert + @unique trap, per-department +# aggregates, ref/backlink navigation, update-through-row, FK restrict on +# delete, and persistence across a process restart. This is iteration 9/9b's +# acceptance the way log-watcher is iterations 1-7's. +# +# Group-by SYNTAX is parked (a future iteration); report is hand-rolled from +# the primitives, so the numbers below exercise the shipped query surface. + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WOC="$ROOT/compiler/_build/default/bin/woc" +WOVM="$ROOT/runtime/wovm" +SAMPLE="$ROOT/docs/examples/employee" + +pass=0 +fail=0 +ok() { echo "ok $1"; pass=$((pass + 1)); } +bad() { echo "FAIL $1 -- $2"; fail=$((fail + 1)); } + +if [[ ! -x "$WOC" || ! -x "$WOVM" ]]; then + echo "employee-accept: build woc and wovm first (just woc-build; just wovm-build)" >&2 + exit 1 +fi + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/emp-accept.XXXXXX")" +DATA="$WORK/data" +mkdir -p "$DATA" +IMG="$WORK/employee.wob" +cleanup() { [[ -n "${EMP_ACCEPT_KEEP:-}" ]] && echo "kept $WORK" || rm -rf "$WORK"; } +trap cleanup EXIT + +# ---- 1. compile ------------------------------------------------------ +if "$WOC" --emit "$SAMPLE" -o "$IMG" >"$WORK/compile.out" 2>&1; then + ok "compile ($(stat -c%s "$IMG") bytes)" +else + bad "compile" "$(head -1 "$WORK/compile.out")" + echo; printf 'employee-accept: %d checks, %d failures\n' "$((pass + fail))" "$fail"; exit 1 +fi + +run() { WO_DATA="$DATA" "$WOVM" "$IMG" "$@"; } + +# ---- 2. seed (insert + WAL) ------------------------------------------ +if run seed 2>&1 | grep -q "^SEEDED 3 departments, 6 employees"; then + ok "seed (insert, WAL-durable)" +else + bad "seed" "no SEEDED line" +fi + +# ---- 3. seed again: @unique trap, caught, across a process boundary -- +out="$(run seed 2>&1)"; rc=$? +if [[ "$out" == *"SEED-DUP"* && $rc -eq 3 ]]; then + ok "unique violation caught on re-seed (persisted via replay)" +else + bad "unique re-seed" "got rc=$rc: $(printf '%s' "$out" | tr '\n' '|' | cut -c1-100)" +fi + +# ---- 4. report: per-department aggregates + payroll ------------------ +rep="$(run report 2>&1)" +if [[ "$rep" == *"DEPT Engineering headcount=3 avg=8200000 min=7300000 max=9200000"* \ + && "$rep" == *"DEPT Operations headcount=2 avg=6150000 min=5900000 max=6400000"* \ + && "$rep" == *"PAYROLL 45700000"* ]]; then + ok "report (aggregates + payroll)" +else + bad "report" "$(printf '%s' "$rep" | tr '\n' '|' | cut -c1-160)" +fi + +# ---- 5. staff: index probe + backlink + ref navigation -------------- +st="$(run staff Engineering 2>&1)" +if [[ "$st" == *"STAFF Asha 9200000 (Engineering)"* \ + && "$st" == *"STAFF Chidi 7300000 (Engineering)"* ]]; then + ok "staff (unique probe + backlink scan + ref nav)" +else + bad "staff" "$(printf '%s' "$st" | tr '\n' '|' | cut -c1-160)" +fi + +# ---- 6. raise: update-through-row, reflected in a re-report ---------- +run raise Operations 5 >/dev/null 2>&1 +if run report 2>&1 | grep -q "^DEPT Operations headcount=2 avg=6457500"; then + ok "raise (update-through-row, durable)" +else + bad "raise" "operations average did not move to 6457500" +fi + +# ---- 7. drop: FK restrict (Engineering still has staff) -------------- +out="$(run drop Engineering 2>&1)"; rc=$? +if [[ "$out" == *"restricted"* && $rc -eq 4 ]]; then + ok "drop restricted by FK (department has staff)" +else + bad "drop restrict" "got rc=$rc: $(printf '%s' "$out" | tr '\n' '|' | cut -c1-100)" +fi + +# ---- 8. persistence: a fresh process still sees every acked write ---- +if run report 2>&1 | grep -q "^PAYROLL 46315000"; then + ok "persistence (replay: raised payroll survives restart)" +else + bad "persistence" "payroll after restart not 46315000" +fi + +echo +printf 'employee-accept: %d checks, %d failures\n' "$((pass + fail))" "$fail" +[[ $fail -eq 0 ]] diff --git a/tests/corpus/run/db-fk-restrict-catch/fixture.out b/tests/corpus/run/db-fk-restrict-catch/fixture.out new file mode 100644 index 0000000..9be7799 --- /dev/null +++ b/tests/corpus/run/db-fk-restrict-catch/fixture.out @@ -0,0 +1,2 @@ +eng restricted +ops deleted diff --git a/tests/corpus/run/db-fk-restrict-catch/fixture.wo b/tests/corpus/run/db-fk-restrict-catch/fixture.wo new file mode 100644 index 0000000..16fe671 --- /dev/null +++ b/tests/corpus/run/db-fk-restrict-catch/fixture.wo @@ -0,0 +1,23 @@ +-- the restrict trap is catchable; a free (unreferenced) row deletes. +@table(name: "dept", index: [name]) +class Dept { + name: Text + staff: backlink Emp.dept +} +@table(name: "emp", index: [dept]) +class Emp { + name: Text + dept: ref Dept +} +fn main() { + let eng = insert Dept { name: "eng" } + let ops = insert Dept { name: "ops" } + insert Emp { name: "asha", dept: eng } + let de = from x in Dept where x.name == "eng" take 1 select x + let r1 = try delete de[0] catch (e) nil + if r1 == nil { print("eng restricted") } + let dop = from x in Dept where x.name == "ops" take 1 select x + let r2 = try delete dop[0] catch (e) nil + if r2 == nil { print("ops restricted (WRONG)") } + print("ops deleted") +} diff --git a/tests/corpus/trap/db-fk-restrict/fixture.trap b/tests/corpus/trap/db-fk-restrict/fixture.trap new file mode 100644 index 0000000..9d60796 --- /dev/null +++ b/tests/corpus/trap/db-fk-restrict/fixture.trap @@ -0,0 +1 @@ +11 \ No newline at end of file diff --git a/tests/corpus/trap/db-fk-restrict/fixture.wo b/tests/corpus/trap/db-fk-restrict/fixture.wo new file mode 100644 index 0000000..517ccb4 --- /dev/null +++ b/tests/corpus/trap/db-fk-restrict/fixture.wo @@ -0,0 +1,18 @@ +-- iteration 9b: deleting a row a `ref` still points at traps WO_T_FK +-- (restrict). Uncaught here: the trap code (11) is the assertion. +@table(name: "dept", index: [name]) +class Dept { + name: Text + staff: backlink Emp.dept +} +@table(name: "emp", index: [dept]) +class Emp { + name: Text + dept: ref Dept +} +fn main() { + let d = insert Dept { name: "eng" } + insert Emp { name: "asha", dept: d } + let ds = from x in Dept where x.name == "eng" take 1 select x + delete ds[0] +}