| outline | deep |
|---|
morm is a lightweight ORM toolkit for MoonBit. It focuses on three things:
- describe schema from normal MoonBit types
- generate boilerplate code instead of hiding behavior at runtime
- keep SQL execution engine-specific and explicit
morm is not a "full magic" ORM. You still decide what SQL runs, what your engine does, and when to bypass the helper layer.
With morm, you can:
- define entities with
#morm.*attributes - generate
table()metadata from those entities - build parameterized
select,insert,update,delete, andupsertqueries - generate mapper implementations from annotated traits
- support multiple database engines behind one query model
- pass typed time values such as
PlainDateTimeandZonedDateTime
Add oboard/morm to your app's moon.mod.json:
{
"bin-deps": {
"oboard/morm": "latest"
}
}If you use generated code, configure your package build to run morm-gen before compilation.
In your package moon.pkg, import morm and generated-code dependencies with
the aliases emitted by mormgen:
import {
"oboard/morm",
"oboard/morm/engine" @morm/engine,
}Add concrete engine packages, such as "oboard/morm/engine/sqlite3", when your
hand-written code uses them.
This is a breaking generated-code alias change. Regenerated .g.mbt files use
@morm and @morm/engine, so update package aliases before regenerating.
Then add pre-build rules so source files generate their companion .g.mbt
files:
options(
"pre-build": [
{
"command": "$mod_dir/.mooncakes/oboard/morm/morm-gen $input -o $output && moonfmt -w $output",
"input": "entities.mbt",
"output": "entities.g.mbt",
},
{
"command": "$mod_dir/.mooncakes/oboard/morm/morm-gen $input -o $output && moonfmt -w $output",
"input": "mapper.mbt",
"output": "mapper.g.mbt",
},
],
)The generated files are ordinary MoonBit code. You can read them, diff them, and reason about them directly.
Create entities.mbt and describe your models as normal structs.
///|
using @time {type PlainDateTime, type ZonedDateTime}
///|
#morm.entity
pub(all) struct Class {
#morm.id
#morm.default(autoincrement())
id : Int64
#morm.varchar(length="255")
name : String
#morm.foreign_key(references="teacher.id", on_delete="CASCADE")
teacher_id : Int
created_at : PlainDateTime
updated_at : PlainDateTime
} derive(ToJson, FromJson)
///|
#morm.entity
pub(all) struct Student {
#morm.id
#morm.default(autoincrement())
id : Int64
#morm.varchar(length="255")
name : String
age : Int
birth_date : ZonedDateTime
} derive(ToJson, FromJson)After generation, each entity gets:
impl @morm.EntityType::table() -> @morm.Table
That metadata is what migrations, query builders, and generated mappers use.
The same example/entities.mbt also includes relation and JSON-storage patterns:
///|
#morm.entity
pub(all) struct Enrollment {
#morm.id
#morm.default(autoincrement())
id : Int64
#morm.many_to_one(references="student.id", fk="student_id", on_delete="CASCADE")
student : Student
#morm.many_to_one(table="class", column="id", fk="class_id")
klass : Class
#morm.varchar(length="255")
note : String
} derive(ToJson, FromJson)
///|
#morm.entity
pub(all) struct StudentAggregate {
#morm.id
id : Int64
#morm.one_to_many(mapped_by="student")
enrollments : FixedArray[Int]
} derive(ToJson, FromJson)
///|
#morm.entity
pub(all) struct JsonDocument {
#morm.id
id : Int64
#morm.json(storage="blob")
payload : Json
} derive(ToJson, FromJson)morm keeps nullability simple and type-driven:
Tmeans non-null columnT?means nullable column- primary keys are treated as non-null
Example:
///|
#morm.entity
pub(all) struct Teacher {
#morm.id
#morm.default(autoincrement())
id : Int64
#morm.varchar(length="255")
name : String
birth_date : @time.ZonedDateTime?
} derive(ToJson, FromJson)Here birth_date becomes nullable because the field type is ZonedDateTime?.
The default type mapping covers common MoonBit types and several time types:
- integer types map to integer column types
Stringmaps to string/text columns unless overridden by attributesBoolmaps to booleanJsoncan map toJson/JsonB@time.PlainDatemaps toDate@time.PlainTimemaps toTime@time.PlainDateTimemaps toDateTime@time.ZonedDateTimemaps toTimestamp
For time fields:
- use
PlainDateTimefor application-level created/updated timestamps when you want stable cross-engine behavior - use
ZonedDateTimewhen you need an explicit offset preserved in your application data
These are the attributes you will use most often:
| Attribute | Purpose |
|---|---|
#morm.entity |
Marks a struct as a managed entity |
#morm.id |
Marks a field as the primary key |
#morm.default(autoincrement()) |
Marks a field as auto-increment |
#morm.varchar(length="255") |
Sets a varchar column |
#morm.char(length="1") |
Sets a fixed char column |
#morm.text / #morm.mediumtext / #morm.longtext |
Sets text-like columns |
#morm.decimal(precision="10", scale="2") |
Sets a decimal column |
#morm.boolean |
Forces boolean column type |
#morm.json / #morm.jsonb |
Uses JSON-capable columns |
#morm.date / #morm.time / #morm.datetime / #morm.timestamp |
Forces temporal column type |
#morm.foreign_key(...) |
Adds a foreign key constraint |
#morm.many_to_one(...) |
Models a relation that materializes as a foreign key column |
#morm.one_to_many(...) |
Models a collection side that is not emitted as a physical column |
#morm.transient |
Keeps a field on the entity only (no physical column, excluded from from(entity) writes) |
#morm.auto_create_time |
Opts a field into generated create timestamp behavior |
#morm.auto_update_time |
Opts a field into generated update timestamp behavior |
Inside this repository, the direct commands are:
moon run mormgen -- example/entities.mbt -o example/entities.g.mbt
moon run mormgen -- example/mapper.mbt -o example/mapper.g.mbtInside a consuming project, the pre-build hook usually handles this for you via the packaged morm-gen binary.
The generated file contains the fully expanded @morm.Table literal for each entity.
Create mapper.mbt and describe mapper traits:
///|
#morm.mapper(table="class")
pub trait ClassMapper {
async save(Self, entity : Class) -> Class
}
///|
#morm.mapper(table="student")
pub trait StudentMapper {
async find_student_by_id(Self, id : Int) -> Student?
async find_student_by_name(Self, name : String) -> Student?
async find_students_by_age(Self, age : Int) -> FixedArray[Student]
async find_students_by_age_and_name(Self, age : Int, name : String) -> FixedArray[
Student,
]
async count_students_by_age(Self, age : Int) -> Int?
async find_all(Self) -> FixedArray[Student]
async all(Self) -> FixedArray[Student]
}
///|
#morm.mapper(table="enrollment")
pub trait EnrollmentMapper {
async find_enrollments_by_student_id(Self, student_id : Int) -> FixedArray[
Enrollment,
]
#morm.query("SELECT id, student_id, class_id, note FROM enrollment WHERE class_id = ?")
async find_by_class_raw(Self, class_id : Int) -> FixedArray[Enrollment]
#morm.fetch_graph(join="LEFT JOIN student ON student.id = enrollment.student_id")
async find_with_student_by_id(Self, id : Int) -> Enrollment?
#morm.load_graph(join="LEFT JOIN class ON class.id = enrollment.class_id")
async find_with_class_by_id(Self, id : Int) -> Enrollment?
}You can bind a mapper by:
table="student"and letmormgeninfer the entity from package entitiesentity="Student"explicitly- old-style positional mapper annotation supported by the parser
mormgen generates:
StudentMapperImplStudentMapperImpl::new(engine)impl StudentMapper for StudentMapperImpl
If you omit #morm.query, mormgen derives queries from method names for common patterns:
find_student_by_idfind_student_by_namefind_students_by_age_and_namecount_students_by_agefind_allall
This keeps simple read methods concise while staying explicit in generated code.
When naming rules are not enough, use #morm.query:
///|
#morm.mapper(table="enrollment")
pub trait EnrollmentMapper {
#morm.query("SELECT id, student_id, class_id, note FROM enrollment WHERE class_id = ?")
async find_by_class_raw(Self, class_id : Int) -> FixedArray[Enrollment]
}This gives you exact SQL control while still keeping typed params and typed result decoding.
Mapper methods can also attach joins with:
#morm.fetch_graph(join="...")#morm.load_graph(join="...")
These are currently implemented as query builder .join(...) appends. They do not add a separate object graph identity map; they simply extend the SQL used for the generated method.
Generated mappers recognize special methods:
create(Self, entity : T)save(Self, entity : T)delete(Self, entity : T)
create is implemented with @morm.insert_into(...).from(entity). Insert
builders skip auto_increment columns, so this path is suitable for database
generated ids.
save is implemented with @morm.upsert_into(...).from(entity).
That means:
- on engines that support conflict updates, it behaves like an upsert
- the conflict target comes from the entity primary key or a unique index
- behavior is still visible in generated code, not hidden in runtime reflection
Generated save methods support automatic timestamp filling.
By default:
- a field named
created_atis treated as auto create time - a field named
updated_atis treated as auto update time
If you want custom field names, add:
#morm.auto_create_time#morm.auto_update_time
Supported generated values:
PlainDateTimegets@morm.current_plain_date_time_utc()ZonedDateTimegets@morm.current_timestamp_utc()
Example:
///|
#morm.entity
pub(all) struct AuditLog {
#morm.id
id : Int64
#morm.auto_create_time
inserted_on : @time.PlainDateTime
#morm.auto_update_time
touched_on : @time.PlainDateTime
} derive(ToJson, FromJson)Current behavior is straightforward: generated save rewrites the entity before upsert, so both auto fields are assigned before the statement is built. If you need stricter semantics such as "never rewrite created_at during update", define a custom mapper method or explicit SQL path for that case.
You can use morm without generated mappers.
let q = @morm.select_from("student")
.where_eq("id", 1)
.where_eq("name", "Alice")
.limit(1)Available builders:
@morm.select_from(table)@morm.insert_into(table)@morm.upsert_into(table)@morm.update(table)@morm.delete_from(table)
These build @morm/engine.Statement values and defer SQL rendering to the engine layer.
Parameters are converted through the @morm/engine.ToParam trait.
Out of the box, morm supports:
- numeric primitives
BoolStringBytesJson@time.PlainDate@time.PlainTime@time.PlainDateTime@time.ZonedDateTime
Example:
let params : FixedArray[@morm/engine.Param] = [
@morm/engine.to_param(1),
@morm/engine.to_param("Alice"),
@morm/engine.to_param(@morm.current_plain_date_time_utc()),
]morm does not hide database connectivity behind runtime magic. Execution happens through @morm/engine.Engine.
An engine implementation is responsible for:
- rendering engine-specific SQL
- binding params
- executing statements
- returning rows as JSON-compatible values
- performing migration SQL for
auto_migrate
This repository contains concrete engines for:
- MySQL
- PostgreSQL
- SQLite
- SQL Server
- Oracle
If your engine implements migration support, you can apply table metadata directly:
@morm.auto_migrate(engine, [
Class::table(),
Student::table(),
])auto_migrate is intentionally thin. It forwards the schema JSON to the engine and lets the engine decide how to diff or apply changes.
For a typical project:
- define entities in
entities.mbt - define mapper traits in
mapper.mbt - generate
.g.mbtfiles during pre-build - implement or choose an engine
- call generated mappers for routine CRUD and use query builders for custom flows
- inspect generated code whenever behavior matters
morm is intentionally easy to bypass. Prefer direct SQL or direct query builders when:
- you need complex reporting SQL
- you need engine-specific locking or hints
- you want exact conflict-update semantics
- you are doing bulk import/export work
That is part of the design, not a failure mode.
- Architecture explains the runtime boundaries
- API Examples shows concrete builders, params, entities, mappers, and migration usage