Production-grade SQLAlchemy-style ORM for EZ
ez install orm
| Package | Range |
|---|---|
| No dependencies. | |
A lightweight, SQLAlchemy-inspired Object-Relational Mapper for the EZ programming language. It gives you declarative model definitions, automatic table creation, a chainable query builder, and a Unit-of-Work style session for inserts/updates/deletes — backed by a native SQLite driver over FFI.
use "orm"
The ORM is distributed as an ezlib package named orm. Place it on your ezlib path (or alongside your project) and import it as a single package:
use "orm"
This pulls in every public piece of the ORM — models, the query engine, the session, the default SQLite driver, and the error hierarchy — through the package's main.ez entry point (as declared in package.ez).
Requirements:
sqlite3.dll (Windows) or libsqlite3-0.dll, discoverable on your system PATH or placed next to your project / in a dlls/ folder.use "orm"
# 1. Define a model
model User {
init() {
self.id = Column("integer", primaryKey=true, autoIncrement=true)
self.name = Column("string", nullable=false)
self.email = Column("string", unique=true, nullable=false)
self.age = Column("integer", default=18)
}
}
# 2. Wire up the engine and driver
engine = Engine(SQLiteDriver("app.sqlite3"))
engine.register(User, { "table": "users" })
engine.createAll()
# 3. Use a session to persist data
session = Session(engine)
u = User()
u.name = "Abdullah"
u.email = "abdullah@ez.org"
session.add(u)
session.commit()
out "New user ID: " + str(u.id)
# 4. Query
adults = session.query(User).filter("age", ">=", 18).orderBy("age", "DESC").all()
session.close()
engine.close()
| Concept | Role |
|---|---|
| Column | Describes a single field's schema: SQL type, constraints, defaults, and foreign keys. |
| Model | A plain EZ model whose init() assigns Column(...) to each mapped property. |
| Engine | Owns the driver connection, holds the schema registry (which models map to which tables), and can create/drop tables. |
| Session | A Unit-of-Work: stages add()/delete() calls and flushes them as INSERT/UPDATE/DELETE statements inside a transaction on commit(). |
| Query | A chainable builder for SELECT/DELETE statements, produced via session.query(ModelClass). |
| Driver | The low-level database adapter. Ships with SQLiteDriver; any object implementing the same interface can be substituted. |
Model definition rules:
init() (with no required arguments — the ORM introspects a model by instantiating it with no args).primaryKey=true.Column(...) assignment, or registration will fail.Column(type, primaryKey=false, autoIncrement=false, unique=false, nullable=true, default=nil, foreignKey=nil, index=false)
| Parameter | Type | Default | Description |
|---|---|---|---|
type | string | — | Logical column type. See type mapping below. An unrecognised type raises SchemaError. |
primaryKey | bool | false | Marks this column as the table's primary key. Exactly one column per model may set this. |
autoIncrement | bool | false | Adds AUTOINCREMENT. Only meaningful alongside primaryKey=true on an integer column. When set, the ORM populates the field automatically after INSERT. |
unique | bool | false | Adds a UNIQUE constraint. Ignored if primaryKey is also true (redundant). |
nullable | bool | true | If false, adds NOT NULL. Ignored if primaryKey is also true. |
default | any | nil | Adds a DEFAULT clause. String defaults are quoted and any embedded apostrophe is escaped; booleans render per dialect. |
foreignKey | string | nil | References another model's column, in "ModelName.column" format (e.g. "User.id"). A malformed reference raises SchemaError instead of being silently dropped. |
index | bool | false | Creates a secondary index on this column during createAll(). |
Type mapping (case-insensitive):
| You write | SQLite | MySQL | PostgreSQL |
|---|---|---|---|
"integer", "int" | INTEGER | INT | INTEGER |
"bigint", "long" | INTEGER | BIGINT | BIGINT |
"string", "text", "str" | TEXT | VARCHAR(255) | TEXT |
"float", "real", "double", "number" | REAL | DOUBLE | DOUBLE PRECISION |
"bool", "boolean" | INTEGER (0/1) | TINYINT(1) | BOOLEAN |
"blob", "bytes" | BLOB | BLOB | BYTEA |
"date" | TEXT | DATE | DATE |
"datetime", "timestamp" | TEXT | DATETIME | TIMESTAMP |
| (anything else) | raises SchemaError |
Values are coerced back to the declared type on read, so a bool column reads as true/false rather than SQLite's 0/1 or MySQL's "0"/"1".
The Engine owns the driver connection and the schema registry.
Engine(driver)
| Method | Signature | Description |
|---|---|---|
register | register(modelClass, options={}) | Introspects modelClass's Column definitions and registers it. options["table"] overrides the default table name (otherwise the class name). Throws SchemaError if there are no columns, no primary key, more than one primary key, or if a different model is already registered under the same name. Re-registering the same class is a no-op. |
createAll | createAll() | Runs CREATE TABLE IF NOT EXISTS for every registered model, plus any requested indexes. Tables are created in foreign-key dependency order. |
dropAll | dropAll() | Runs DROP TABLE IF EXISTS for every registered model, in reverse dependency order (children before parents). |
getMeta | getMeta(modelClass) | Returns the internal schema metadata dictionary (table, columns, pk, foreignKeys, indexes) for a registered model. Throws SchemaError if the model isn't registered. |
close | close() | Closes the underlying driver connection. |
Engine exposes the resolved SQL dialect as engine.dialect, read from the driver's required dialect property.
Registration order for foreign keys: register the referenced model before the model that references it — createAll() raises SchemaError if a foreign key names an unregistered model. Creation order is then sorted automatically by dependency, so you don't need to worry about which table gets created first.
The Session batches pending changes and flushes them together inside a transaction.
Session(engine)
| Method | Signature | Description |
|---|---|---|
query | query(modelClass) | Returns a new Query for the given model. |
get | get(modelClass, pkValue) | Fetches a single instance by primary key, or nil. |
add | add(instance) | Stages an instance for INSERT (if its primary key is unset) or UPDATE (if it's already set). Staging the same instance twice is a no-op. Nothing is written until commit(). |
addAll | addAll(instances) | Stages every instance in an array. |
delete | delete(instance) | Stages an instance for DELETE by its primary key. Throws IntegrityError if the instance has no primary key value. |
begin | begin() | Explicitly starts a transaction. Nesting is tracked, so begin() followed by commit() works correctly. |
commit | commit() | Flushes all staged adds/deletes, wrapped in a transaction (opening one only if none is already open). Rolls back and clears staged operations automatically if any statement throws. |
rollback | rollback() | Rolls back the current transaction and clears any staged operations. |
close | close() | Rolls back any open transaction and clears staged operations. Does not close the engine/driver — sessions don't own the connection. |
Batching: at commit(), pending inserts are grouped by model and by which columns are set, and rows sharing a column set go out as a single multi-row INSERT — rather than one round trip per object. Rows whose primary key is database-generated are inserted individually so each generated id can be read back.
Insert vs. update detection: add() doesn't immediately decide; the decision happens at commit() time, based on whether the instance's primary-key field currently holds a real value or is still an unset Column placeholder.
Produced via session.query(ModelClass). All builder methods except the terminal ones return self, so calls can be chained.
| Method | Signature | Returns | Description |
|---|---|---|---|
filter | filter(field, op, value) or filter(field, value) | Query | Adds a WHERE condition. Two-argument form defaults the operator to "=". Multiple filter() calls are combined with AND. field must name a column the model declares, and op must be one of =, !=, <>, <, <=, >, >=, LIKE, NOT LIKE, IN, NOT IN, IS, IS NOT — anything else raises SchemaError. IN/NOT IN take an array and expand to one bound placeholder per element. |
filterNull | filterNull(field, isNull=true) | Query | Adds field IS NULL (or IS NOT NULL). |
orderBy | orderBy(field, direction="ASC") | Query | Adds an ORDER BY clause. Call multiple times for multi-column sorts. direction must be ASC or DESC. |
limit | limit(n) | Query | Adds a LIMIT. |
offset | offset(n) | Query | Adds an OFFSET. |
clone | clone() | Query | Returns an independent copy, so a partially built query can be reused as a base for several queries. |
all | all() | array of instances | Executes the query and hydrates matching rows into model instances, coercing each value to its declared column type. |
first | first() | instance or nil | Returns the first match, or nil. Runs against a clone, so it does not leave LIMIT 1 attached to the query. |
exists | exists() | boolean | True when at least one row matches. |
count | count() | integer | Executes a COUNT(*) with the same filters. ORDER BY/LIMIT/OFFSET are deliberately not applied. |
update | update(values) | integer | Bulk UPDATE of the given {column: value} dict for matching rows. Requires at least one filter. |
delete | delete() | integer | Bulk DELETE matching the current filters; returns rows affected. Requires at least one filter — an unfiltered call raises IntegrityError. |
deleteAll | deleteAll() | integer | Deletes every row in the table. The explicit form of an unfiltered delete. |
Identifier safety. Field names, operators and sort directions are validated against the model's schema and a fixed keyword list rather than being concatenated into the SQL. Passing asortvalue straight from an HTTP query string is therefore safe: an unknown column raisesSchemaErrorinstead of injecting SQL. Values are always sent as bound parameters.
Example:
recent_adults = session.query(User)
.filter("age", ">=", 18)
.filter("name", "LIKE", "A%")
.orderBy("age", "DESC")
.limit(10)
.all()
The default driver, implemented over SQLite's C API via FFI.
SQLiteDriver(path, busyTimeoutMs=5000, cacheSize=64)
PRAGMA journal_mode=WAL and PRAGMA foreign_keys=ON on connect.sqlite3.dll, then libsqlite3-0.dll, then dlls\libsqlite3-0.dll, in that order.sqlite3_busy_timeout so a concurrent writer waits instead of failing immediately with SQLITE_BUSY.cacheSize prepared statements. Because the ORM issues the same handful of statements repeatedly, this avoids re-parsing and re-planning each one. Pass cacheSize=0 to disable.sqlite3_bind_*), not string interpolation.prepare_v2 would compile only the first statement and silently skip the rest, so passing "SELECT 1; SELECT 2" raises DriverError instead.
Statements are finalized in a finally, so a mid-iteration error cannot leak a statement handle (which under WAL would otherwise hold a read transaction open and block checkpointing).
| Method | Description |
|---|---|
execute(sql, params) | Runs a non-SELECT statement with positional ? parameters. |
query(sql, params) | Runs a SELECT and returns an array of row dictionaries. |
lastInsertId() | Returns the rowid of the last INSERT. |
changes() | Returns the number of rows affected by the last statement. |
begin() / commit() / rollback() | Transaction control. |
close() | Closes the connection handle. |
You can swap in your own driver for a different backend by implementing this same method set — Engine and Session only depend on this interface, not on SQLite specifically. A custom driver must also expose a dialect property ("sqlite", "mysql", or "postgres"); Engine raises DriverError without it.
MySQLDriver(host, user, password, dbname, port=3306, charset="utf8mb4")
PostgresDriver(connectionString)
MySQLDriver loads libmysql.dll or libmariadb.dll; PostgresDriver loads libpq.dll.
Both accept the same ? placeholder syntax as the SQLite driver. Internally they escape values with the vendor's own routine (mysql_real_escape_string, PQescapeLiteral) and splice them in, locating placeholders with a SQL-aware scanner that ignores any ? inside string literals, quoted identifiers, and comments. A mismatch between placeholder count and supplied parameters raises DriverError.
MySQLDriver pins the connection charset (default utf8mb4) before running any statement, because mysql_real_escape_string escapes according to the connection's charset and is not safe on an unknown one. It refuses to connect if the charset cannot be set.
ez orm/tests/run_tests.ez
The suite covers the SQL helpers and DDL generation with no database required, then runs an end-to-end SQLite section against an in-memory database (skipped automatically if sqlite3.dll is unavailable). It includes regression tests for identifier injection, placeholder scanning, transaction nesting, count()/first() isolation, batch inserts, and reserved-word column names.
All ORM exceptions extend the built-in Exception model, via a common ORMError base.
| Error | Thrown when |
|---|---|
ORMError | Base class for all ORM-specific errors. |
SchemaError | A model is registered without columns or without a primary key, or a query references an unregistered model. |
IntegrityError | An INSERT is missing a required (non-nullable, no-default) value. |
DriverError | A driver-level failure: DB open/prepare/step failure, or a missing SQLite library. |
try {
engine.register(BrokenModel)
} catch e {
out e.toString() # e.g. "SchemaError: Model 'BrokenModel' has no primary key..."
}
Foreign keys are declared on the referencing column and resolved by table name at createAll() time:
model Post {
init() {
self.id = Column("integer", primaryKey=true, autoIncrement=true)
self.title = Column("string", nullable=false)
self.user_id = Column("integer", foreignKey="User.id")
}
}
"User.id" refers to the model name and its column, not the physical table name — the ORM looks up User's registered table internally when generating the FOREIGN KEY clause. Register the referenced model (User) before the referencing model (Post) if you want the generated SQL to use the correct table name.
Declare relations in a static relations block and extend Model. Then read them
like any other property:
model User extends Model {
static relations = { "posts": hasMany("Post", "userId") }
init() {
self.id = Column("integer", primaryKey=true, autoIncrement=true)
self.name = Column("string", nullable=false)
}
}
model Post extends Model {
static relations = { "author": belongsTo("User", "userId") }
init() {
self.id = Column("integer", primaryKey=true, autoIncrement=true)
self.title = Column("string", nullable=false)
self.userId = Column("integer", foreignKey="User.id")
}
}
u = session.get(User, 1)
get p in u.posts { out p.title } # loaded on first touch, then cached
out post.author.name
| Declaration | Meaning | foreignKey names |
|---|---|---|
hasMany("Post", "userId") | one-to-many | the column on the target table pointing back here |
belongsTo("User", "userId") | many-to-one | the column on this table pointing at the target |
Targets are named as strings, so two models may refer to each other regardless of
which is registered first.
Relations must be declared static. A relation declared inside init() would be an
ordinary property on every loaded row, so reading it would hand back the declaration
instead of the related rows.
withRelated(name) — avoid N+1
Loading N users and then touching u.posts on each is N+1 queries. withRelated
makes it two, whatever N is:
users = session.query(User).withRelated("posts").all()
get u in users { out u.name + ": " + str(len(u.posts)) }
join(name) — filter by a related tableposts = session.query(Post).join("author").filter("author.name", "=", "ana").all()
Only the base model's rows come back — join is for narrowing, withRelated is for
loading. A relation.column field is validated exactly as strictly as a local one:
the relation must have been joined, and the column must exist on the target model.
A session returns one instance per row:
a = session.get(User, 1)
b = session.get(User, 1)
a == b # true — same object
Assignments to a loaded row are recorded and written on commit(), without staging
it again:
u = session.get(User, 1)
u.name = "new name"
session.commit() # UPDATE User SET name = ? WHERE id = ?
Only the columns you actually assigned appear in the SET clause. Both behaviours
come from extending Model; a model that does not extend it keeps the old semantics
(fetch returns a fresh instance, and changes are written only if you add() it).
session.begin()
# ... session.add(...) / session.delete(...) ...
session.commit()
commit() always flushes staged adds/deletes inside a transaction and clears them afterward; on any error it rolls back automatically and clears pending state before re-throwing. Prefer letting commit() manage the transaction implicitly (just call add()/delete() then commit()) unless you specifically need to group operations that aren't staged through the session (e.g. mixing raw driver calls with session operations).
Column definitions, so every parameter of a model's init() must be optional. A model written as init(name, age) cannot be registered — assign those fields after construction instead. Registering such a model raises a SchemaError explaining this rather than failing obscurely.hasMany/belongsTo cover the
common cases; there is no many-to-many (association table) support, and join()
is always an INNER JOIN — it exists to narrow results, so a LEFT JOIN would
defeat the purpose.
Model. Lazy access and change tracking
are inherited from it. A model that does not extend Model still works, but
u.posts is an error rather than a load, and a modified row is written only if
you add() it.
stays reachable until the session is dropped, which is what makes "one row, one
instance" hold. Use a short-lived session for a large scan.
createAll()/dropAll() are the only schema operations. createAll() uses CREATE TABLE IF NOT EXISTS, so changing a model does not alter an existing table — the schema silently drifts. Drop and recreate, or manage migrations yourself.Column per model may set primaryKey=true.mysql_real_escape_string on a charset-pinned connection, PQescapeLiteral) and placeholders are located with a SQL-aware scanner, so this is safe — but native mysql_stmt_* / PQexecParams binding would be better still. SQLite uses real parameter binding.
Do I need to call engine.register() before using a model?
Yes — the registry is what lets Session/Query know a model's table name, columns, and primary key. Register every model right after constructing the Engine, before createAll(), session.query(), or session.add().
Can I use multiple databases at once?
Yes — create separate Engine(SQLiteDriver(...)) instances, one per database file, and register the relevant models against each.
How do I inspect the generated SQL for a table?
engine.getMeta(ModelClass)["columns"] gives you each column's computed sqlDef string, and Column.toSQL() can be called directly on a standalone Column instance.
| Version | Size | Downloads | Published |
|---|---|---|---|
1.0.0 |
37.7 KB | 0 | 1 hour ago |
sha256 a803f1565c9bbcd57dee3b23eb0feff1774aece3429c54e39442dca357fc515b