orm

Production-grade SQLAlchemy-style ORM for EZ

0 downloads owners: imabd645

Install

ez install orm

Dependencies (1.0.0)

PackageRange
No dependencies.

Readme

EZ ORM

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"

Table of Contents


Installation

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:


Quick Start

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()

Core Concepts

ConceptRole
ColumnDescribes a single field's schema: SQL type, constraints, defaults, and foreign keys.
ModelA plain EZ model whose init() assigns Column(...) to each mapped property.
EngineOwns the driver connection, holds the schema registry (which models map to which tables), and can create/drop tables.
SessionA Unit-of-Work: stages add()/delete() calls and flushes them as INSERT/UPDATE/DELETE statements inside a transaction on commit().
QueryA chainable builder for SELECT/DELETE statements, produced via session.query(ModelClass).
DriverThe low-level database adapter. Ships with SQLiteDriver; any object implementing the same interface can be substituted.

Model definition rules:


API Reference

Column

Column(type, primaryKey=false, autoIncrement=false, unique=false, nullable=true, default=nil, foreignKey=nil, index=false)
ParameterTypeDefaultDescription
typestringLogical column type. See type mapping below. An unrecognised type raises SchemaError.
primaryKeyboolfalseMarks this column as the table's primary key. Exactly one column per model may set this.
autoIncrementboolfalseAdds AUTOINCREMENT. Only meaningful alongside primaryKey=true on an integer column. When set, the ORM populates the field automatically after INSERT.
uniqueboolfalseAdds a UNIQUE constraint. Ignored if primaryKey is also true (redundant).
nullablebooltrueIf false, adds NOT NULL. Ignored if primaryKey is also true.
defaultanynilAdds a DEFAULT clause. String defaults are quoted and any embedded apostrophe is escaped; booleans render per dialect.
foreignKeystringnilReferences another model's column, in "ModelName.column" format (e.g. "User.id"). A malformed reference raises SchemaError instead of being silently dropped.
indexboolfalseCreates a secondary index on this column during createAll().

Type mapping (case-insensitive):

You writeSQLiteMySQLPostgreSQL
"integer", "int"INTEGERINTINTEGER
"bigint", "long"INTEGERBIGINTBIGINT
"string", "text", "str"TEXTVARCHAR(255)TEXT
"float", "real", "double", "number"REALDOUBLEDOUBLE PRECISION
"bool", "boolean"INTEGER (0/1)TINYINT(1)BOOLEAN
"blob", "bytes"BLOBBLOBBYTEA
"date"TEXTDATEDATE
"datetime", "timestamp"TEXTDATETIMETIMESTAMP
(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".


Engine

The Engine owns the driver connection and the schema registry.

Engine(driver)
MethodSignatureDescription
registerregister(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.
createAllcreateAll()Runs CREATE TABLE IF NOT EXISTS for every registered model, plus any requested indexes. Tables are created in foreign-key dependency order.
dropAlldropAll()Runs DROP TABLE IF EXISTS for every registered model, in reverse dependency order (children before parents).
getMetagetMeta(modelClass)Returns the internal schema metadata dictionary (table, columns, pk, foreignKeys, indexes) for a registered model. Throws SchemaError if the model isn't registered.
closeclose()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.


Session

The Session batches pending changes and flushes them together inside a transaction.

Session(engine)
MethodSignatureDescription
queryquery(modelClass)Returns a new Query for the given model.
getget(modelClass, pkValue)Fetches a single instance by primary key, or nil.
addadd(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().
addAlladdAll(instances)Stages every instance in an array.
deletedelete(instance)Stages an instance for DELETE by its primary key. Throws IntegrityError if the instance has no primary key value.
beginbegin()Explicitly starts a transaction. Nesting is tracked, so begin() followed by commit() works correctly.
commitcommit()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.
rollbackrollback()Rolls back the current transaction and clears any staged operations.
closeclose()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.


Query

Produced via session.query(ModelClass). All builder methods except the terminal ones return self, so calls can be chained.

MethodSignatureReturnsDescription
filterfilter(field, op, value) or filter(field, value)QueryAdds 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.
filterNullfilterNull(field, isNull=true)QueryAdds field IS NULL (or IS NOT NULL).
orderByorderBy(field, direction="ASC")QueryAdds an ORDER BY clause. Call multiple times for multi-column sorts. direction must be ASC or DESC.
limitlimit(n)QueryAdds a LIMIT.
offsetoffset(n)QueryAdds an OFFSET.
cloneclone()QueryReturns an independent copy, so a partially built query can be reused as a base for several queries.
allall()array of instancesExecutes the query and hydrates matching rows into model instances, coercing each value to its declared column type.
firstfirst()instance or nilReturns the first match, or nil. Runs against a clone, so it does not leave LIMIT 1 attached to the query.
existsexists()booleanTrue when at least one row matches.
countcount()integerExecutes a COUNT(*) with the same filters. ORDER BY/LIMIT/OFFSET are deliberately not applied.
updateupdate(values)integerBulk UPDATE of the given {column: value} dict for matching rows. Requires at least one filter.
deletedelete()integerBulk DELETE matching the current filters; returns rows affected. Requires at least one filter — an unfiltered call raises IntegrityError.
deleteAlldeleteAll()integerDeletes 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 a sort value straight from an HTTP query string is therefore safe: an unknown column raises SchemaError instead 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()

SQLiteDriver

The default driver, implemented over SQLite's C API via FFI.

SQLiteDriver(path, busyTimeoutMs=5000, cacheSize=64)

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).

MethodDescription
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 / PostgresDriver

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.


Testing

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.


Errors

All ORM exceptions extend the built-in Exception model, via a common ORMError base.

ErrorThrown when
ORMErrorBase class for all ORM-specific errors.
SchemaErrorA model is registered without columns or without a primary key, or a query references an unregistered model.
IntegrityErrorAn INSERT is missing a required (non-nullable, no-default) value.
DriverErrorA 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..."
}

Defining Relationships

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.

Navigating relations

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
DeclarationMeaningforeignKey names
hasMany("Post", "userId")one-to-manythe column on the target table pointing back here
belongsTo("User", "userId")many-to-onethe 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 table

posts = 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.


Identity Map and Change Tracking

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).


Transactions

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).


Known Limitations

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.

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.


FAQ

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.

Versions

VersionSizeDownloadsPublished
1.0.0 37.7 KB 0 1 hour ago

Integrity

sha256  a803f1565c9bbcd57dee3b23eb0feff1774aece3429c54e39442dca357fc515b