Data & Enterprise
Data Layer
Zornux has a built-in ORM — no external library, no SQL
strings. You declare a database, map your
classes to tables, and read and write with
plain-English statements. Queries are parameterized and injection-safe by
construction.
Declaring a database
A database … end block names a provider and maps one or more
classes to tables with table T from Class:
class User
has id
has name as text
has email as text required # NOT NULL
has balance as money # exact minor units, lossless
end
database AppDb
provider memory
table Users from User
end
provider memory is an in-memory store — perfect for tests and prototyping. provider sqlite persists to a file, and provider postgres backs production, multi-instance deployments. All three ship in the standard runtime and work out of the box — PostgreSQL needs no special build or flag (its managed driver is bundled into every official release; it is omitted only from a browser-wasm build). The ORM syntax is identical across all three, so the same code moves from memory in tests to postgres in production. Connection details (and secrets) are supplied through configuration, never hard-coded.
A field may declare a column type — text, number, whole, truth, date, datetime, money — and required for NOT NULL (enforced pre-save, ZX2422 on violation). A typed column round-trips as that exact kind on every provider, where an untyped truth/date may read back as a number or string on a SQL store. as money persists a MoneyValue losslessly (integer minor units, currency preserved) on both SQLite and PostgreSQL. Untyped has name still works — a type is opt-in.
Save, find, delete
save item into Table inserts a new item (assigning a fresh
id) or updates an existing one, matched by its id.
find queries; delete removes.
create alice from User
alice.name = "Alice"
alice.email = "[email protected]"
save alice into AppDb.Users
# Read every row, or the first match of a condition.
show "Users: " + text(length(find all from AppDb.Users))
create found = find one from AppDb.Users where email is "[email protected]"
show "Found: " + found.name
# Update: change a field and save the same item again.
found.name = "Alice Jr."
save found into AppDb.Users
# Delete.
delete found from AppDb.Users
| Statement | Does |
|---|---|
save item into Db.Table | Insert (new id) or update (by id). |
find all from Db.Table | Every row, as a list. |
find one from Db.Table where field is value | The first match, or nothing. |
delete item from Db.Table | Remove an item. |
save and delete can report the affected-row count with giving: save item into Db.Table giving written (0 or 1) and delete item from Db.Table giving removed. update … in Db.Table where … applies an atomic compare-and-set and gives the number of rows it changed.
Rich queries
A where clause is a tree of comparisons joined by
and / or. The is … operators reuse
the language's own comparison words, so a query reads like any other
condition:
find all from Db.People where age is greater than 18
find all from Db.People where city is "London" and age is greater than 30
find all from Db.People where name starts with "A" or name ends with "z"
| Operator | Matches |
|---|---|
is / is not | Equality / inequality. |
is greater/less than [or equal to] | Ordering (numbers or text). |
contains / starts with / ends with | Text matching. |
Queries run over parameterized rows, never string-concatenated SQL. An unknown field is a diagnostic, and untrusted input in a condition is rejected — the guard checks the whole and/or tree, so nothing slips in through a nested branch.
Ordering & pagination
Sort with sorted by field [descending] (more keys with
then by, a stable sort), and page with skip N
(offset) and first N (limit):
find all from Db.People sorted by age descending
find all from Db.People sorted by city then by age
find all from Db.People sorted by created descending skip 20 first 10
Aggregates & existence
Ask a question instead of fetching rows: find count,
find any, and the numeric aggregates
sum / average / minimum /
maximum of field from:
find count from Db.People where city is "London"
find any from Db.People where age is greater than 100
sum of age from Db.People
average of age from Db.People where city is "London"
Every query word is contextual — sorted, skip, first, count, sum, and the rest — so create first = 1 and create sum = 2 still bind ordinary variables. An empty sum is 0; an empty average / minimum / maximum is nothing.
On a SQL provider, a find — its where, ordering, paging, find count/find any, and the aggregates — is compiled to the provider's own SQL and executed in the store, not fetched-then-filtered in process. The result is byte-identical to running it in-process, so the same query is correct on memory, SQLite, and PostgreSQL.
Relationships — has one / has many
A class declares a relationship with has one / has many
Target by column — it maps to no column. Load it with
find … with, which batch-loads related rows one query per level
(WHERE … IN (…)), so there is no N+1. SQL joins are intentionally
not exposed.
class Customer
has id
has email as text required
has many Order by customer_id
end
class Order
has id
has customer_id as whole
has total as money
end
# Load customers and their orders in one extra query — not one per row.
create rows = find all from Shop.Customers with orders
Transactions
A transaction is all-or-nothing: it commits when its body
finishes cleanly and rolls the whole batch back if a
recoverable error occurs — the same catch error recovery
as error handling.
transaction on Bank
save ada into Bank.Accounts
save grace into Bank.Accounts
show item_at([], 3) # fails — the whole batch rolls back
catch error
show "rolled back (" + error.code + ")"
end
Injection-safe by design
Untrusted input can't flow straight into a query. A value from
read_text or a web request is UntrustedText
until you sanitize or validate it — passing raw
untrusted text to the database raises ZX2411. Queries are
parameterized under the hood, so there is no string-concatenation attack
surface.
Connection strings and credentials come from configuration secrets; a failed connection error never prints them.
Schema migrations
As classes change, the database has to keep up. Zornux uses a
hybrid model: the automatic diff adds any new tables and
columns the classes declare, while migration … end blocks
handle the rest — renames and removals — ordered and recorded so each runs
once. The diff never removes anything, so a deploy can't
lose data by surprise.
migration CreateProducts
create table Products from Product
end
migration AddSku
add field sku to Products
end
migrate Store # apply pending migrations, then the additive diff
rollback Store # reverse the most recently applied migration
| Step | Effect | Reverse |
|---|---|---|
create table T from Class | create the table | drop the table |
add field f to T | add a column | drop the column |
rename field a to b in T | rename a column, keeping data | rename it back |
remove field f from T | drop a column | irreversible (ZX3505) |
migrate applies every pending migration and the additive diff in one transaction — a failure part-way rolls the whole thing back. A database can also migrate on open. From the CLI, zornux db status lists applied/pending, zornux db check gates a deploy by flagging drift (a column the classes no longer declare, ZX3504), and zornux db migrate / rollback drive it. migration, migrate, and rollback are all contextual — no new reserved words.
A production worker can run with --no-migrate so it opens the database without migrate on open or lazy table creation — the runtime login then needs no DDL rights at all. Schema is applied ahead of it by a separate, privileged zornux db migrate step in the deploy pipeline.
Async in components
In a real backend, data access lives in a repository
whose methods are async functions — a service calls them with
wait for:
repository UserRepository
async function all
give back find all from AppDb.Users
end
end
Diagnostics are ZX2400–ZX2499. Next: layering services and injection on top — Application Layer.