moonorm — an ORM / SQL toolkit for MoonBit (← SQLAlchemy / SQLModel): a parameterized, injection-safe query builder (select / insert / update / delete).
Dependencies
Imports. Bound-value constructors (Int, Text, Null, …) are moondb's — the Value type is re-exported by moonorm, but you construct values as @moondb.Int / @moondb.Text (add moon add Lfan-ke/moondb). The SQLite driver's package name is hyphenated, so import it under an alias in moon.pkg.json ({"path": "Lfan-ke/moon-sqlite", "alias": "sqlite"}) and reach it as @sqlite.
let (sql, params) = @moonorm.select("users")
.column("id").column("name")
.eq("age", @moondb.Int(18))
.where_("name", "LIKE", @moondb.Text("bob%"))
.order_by("name", @moonorm.Asc)
.limit(10)
.build()
// sql = "SELECT id, name FROM users WHERE age = ? AND name LIKE ? ORDER BY name ASC LIMIT 10"
// params = [Int(18), Text("bob%")]
let (isql, ivals) = @moonorm.insert("users")
.set("name", @moondb.Text("bob")).set("age", @moondb.Int(30)).build()
// "INSERT INTO users (name, age) VALUES (?, ?)"
@moonorm.update("users").set("age", @moondb.Int(31)).where_("id", "=", @moondb.Int(1)).build()
@moonorm.delete("users").where_("id", "=", @moondb.Int(9)).build()
// JOIN + GROUP BY + HAVING, with aggregates and a Table descriptor:
let orders : @moonorm.Table = { name: "orders", columns: [] }
let (jsql, jparams) = orders.select()
.raw("users.name").count()
.join("users", "users.id = orders.user_id")
.eq("orders.status", @moondb.Text("paid"))
.group_by("users.name")
.having("COUNT(*)", ">", @moondb.Int(3))
.build()
// "SELECT users.name, COUNT(*) FROM orders JOIN users ON users.id = orders.user_id
// WHERE orders.status = ? GROUP BY users.name HAVING COUNT(*) > ?"
// params = [Text("paid"), Int(3)]// native target only — moon-sqlite links the vendored amalgamation.
fn demo() -> Unit raise @moondb.DbError {
let sess = @moonorm.Session::new(@sqlite.SqliteDriver::open(":memory:"))
sess.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)", []) |> ignore
// INSERT through the builder — values are bound, never spliced.
sess.add(@moonorm.insert("users").set("name", @moondb.Text("alice")).set("age", @moondb.Int(30)))
|> ignore
// SELECT through the builder — real rows come back, typed.
let rows = sess.fetch(
@moonorm.select("users").column("name").column("age").where_("age", ">", @moondb.Int(18)),
)
let _ = rows[0].text(0) // "alice" (raises TypeError if the column isn't Text)
let _ = rows[0].int(1) // 30
sess.close()
}// A parent model and a child model with a foreign key back to it.
let teams : @moonorm.Model[Team] = @moonorm.Model::new(
"teams",
[@moonorm.column("id", @moonorm.IntType, primary_key=true),
@moonorm.column("name", @moonorm.TextType, nullable=false)],
(r) => { id: r.int(0), name: r.text(1) },
(t) => [("id", @moondb.Int(t.id)), ("name", @moondb.Text(t.name))],
)
let heroes : @moonorm.Model[Hero] = @moonorm.Model::new(
"heroes",
[@moonorm.column("id", @moonorm.IntType, primary_key=true),
@moonorm.column("name", @moonorm.TextType, nullable=false),
@moonorm.column("team_id", @moonorm.IntType, references=Some(("teams", "id")))],
(r) => { id: r.int(0), name: r.text(1), team_id: r.int(2) },
(h) => [("id", @moondb.Int(h.id)), ("name", @moondb.Text(h.name)),
("team_id", @moondb.Int(h.team_id))],
)
sess.create_table(teams) |> ignore // CREATE TABLE teams (id INTEGER PRIMARY KEY, name TEXT NOT NULL)
sess.create_table(heroes) |> ignore
sess.insert_record(teams, { id: 1, name: "avengers" }) |> ignore
sess.insert_record(heroes, { id: 10, name: "iron-man", team_id: 1 }) |> ignore
// 1:N — a team's heroes, eager-loaded as mapped records.
let children = @moonorm.has_many(heroes, "team_id", (t : Team) => @moondb.Int(t.id))
let kids = sess.load({ id: 1, name: "avengers" }, children) // [Hero{...}, ...]
// N:1 — a hero's team.
let parent = @moonorm.belongs_to(teams, "id", (h : Hero) => @moondb.Int(h.team_id))
let team = sess.load_one({ id: 10, name: "iron-man", team_id: 1 }, parent) // Some(Team{...})let heroes : @moonorm.Model[Hero] = @moonorm.Model::from_fields(
"heroes",
[
@moonorm.field("id", @moonorm.IntType, (h : Hero) => @moondb.Int(h.id), primary_key=true),
@moonorm.field("name", @moonorm.TextType, (h : Hero) => @moondb.Text(h.name), nullable=false),
@moonorm.field("team_id", @moonorm.IntType, (h : Hero) => @moondb.Int(h.team_id),
references=Some(("teams", "id"))),
],
(r) => { id: r.int(0), name: r.text(1), team_id: r.int(2) }, // the one closure that stays
)let children = @moonorm.has_many(heroes, "team_id", (t : Team) => @moondb.Int(t.id))
let teams : Array[Team] = [{ id: 1, name: "avengers" }, { id: 2, name: "x-men" }]
let grouped = sess.load_batch(teams, children) // one query; grouped[0] = avengers' heroes, …let sp = sess.begin_nested() // SAVEPOINT moonorm_sp_1; sess.savepoint_depth() == 1
sess.add(insert_stmt) |> ignore
sp.rollback() // undo just this savepoint's work; depth back to 0
sess.begin_with({ isolation: Some(@moonorm.Serializable), read_only: false }, dialect=@moonorm.Postgres)
// BEGIN; SET TRANSACTION ISOLATION LEVEL SERIALIZABLElet big = @moonorm.select("orders").column("user_id").where_("total", ">", @moondb.Int(100))
let (sql, params) = @moonorm.select("users")
.column("id").column("name")
.with_cte("big_spenders", big)
.where_("active", "=", @moondb.Bool(true))
.where_in("id", @moonorm.select("big_spenders").column("user_id"))
.build()
// "WITH big_spenders AS (SELECT user_id FROM orders WHERE total > ?)
// SELECT id, name FROM users WHERE active = ? AND id IN (SELECT user_id FROM big_spenders)"
// params = [Int(100), Bool(true)] ← CTE value first, then the WHERE valuelet stmt = @moonorm.update("account")
.set("balance", @moondb.Int(50))
.set("version", @moondb.Int(current + 1))
.where_("id", "=", @moondb.Int(1))
.where_("version", "=", @moondb.Int(current)) // the guard
sess.modify_versioned(stmt, what="account") |> ignore // raises LostUpdate on a stale versionlet migrations : Array[@moonorm.Migration] = [
{ version: 1, name: "create_users",
up: ["CREATE TABLE users (id INTEGER PRIMARY KEY)"], down: ["DROP TABLE users"] },
{ version: 2, name: "add_email",
up: ["ALTER TABLE users ADD COLUMN email TEXT"], down: ["ALTER TABLE users DROP COLUMN email"] },
]
let m = @moonorm.Migrator::new()
m.up(sess, migrations) |> ignore // applies 1 then 2; returns how many ran
let _ = m.current_version(sess) // 2
m.down_to(sess, migrations, 1) |> ignore // rolls back 2, leaving 1let evil = "'; DROP TABLE users; --"
let (sql, params) = @moonorm.select("users").eq("name", @moondb.Text(evil)).build()
// sql = "SELECT * FROM users WHERE name = ?" ← the attack string is NOT in the SQL
// params = [Text("'; DROP TABLE users; --")] ← it's a bound parameterpub(all) suberror LostUpdate {
LostUpdate(String)
}impl Show for LostUpdatepub(all) struct Column {
name : String
col_type : ColumnType
primary_key : Bool
autoincrement : Bool
nullable : Bool
unique : Bool
default : String?
checks : Array[String]
references : (String, String)?
on_delete : String?
on_update : String?
}pub(all) enum ColumnDiff {
Added(name~ : String, col_type~ : ColumnType)
Removed(String)
TypeChanged(name~ : String, from~ : ColumnType, to~ : ColumnType)
} derive(Eq)pub(all) enum ColumnType {
IntType
TextType
RealType
BlobType
BoolType
VarcharType(Int)
NumericType
DateTimeType
DateType
TimeType
UuidType
JsonType
} derive(Eq)type Conflicttype Ctetype InCondpub(all) enum IsolationLevel {
ReadUncommitted
ReadCommitted
RepeatableRead
Serializable
} derive(Eq)type JoinClausepub struct Migrator {
table : String
}Model::from_fields(
"hero",
[
field("id", IntType, primary_key=true, h => Int(h.id)),
field("name", TextType, nullable=false, h => Text(h.name)),
],
r => { id: r.int(0), name: r.text(1) },
)pub struct Select {
table : String
ctes : Array[Cte]
cols : Array[String]
joins : Array[JoinClause]
conds : Array[Cond]
in_conds : Array[InCond]
sub_conds : Array[SubCond]
preds : Array[Predicate]
groups : Array[String]
havings : Array[Cond]
orders : Array[(String, Order)]
set_ops : Array[(String, Select)]
distinct_ : Bool
limit_ : Int?
offset_ : Int?
}fn[T] Session::create_table(self : Session, model : Model[T], if_not_exists? : Bool) -> ExecResult raise DbErrorfn[T] Session::insert_record(self : Session, model : Model[T], record : T) -> ExecResult raise DbErrorfn[S, T] Session::load_many(self : Session, source : S, rel : ManyToMany[S, T]) -> Array[T] raise DbErrortype SubCondfn column(name : String, col_type : ColumnType, primary_key? : Bool, autoincrement? : Bool, nullable? : Bool, unique? : Bool, default? : String?, checks? : Array[String], references? : (String, String)?, on_delete? : String?, on_update? : String?) -> Columnfn[T] field(name : String, col_type : ColumnType, encode : (T) -> Value, primary_key? : Bool, autoincrement? : Bool, nullable? : Bool, unique? : Bool, default? : String?, checks? : Array[String], references? : (String, String)?, on_delete? : String?, on_update? : String?) -> Field[T]fn[S, T] many_to_many(target : Model[T], junction~ : String, source_fk~ : String, target_fk~ : String, target_key~ : String, source_key~ : (S) -> Value) -> ManyToMany[S, T]moonorm — an ORM / SQL toolkit for MoonBit (← SQLAlchemy / SQLModel): a parameterized, injection-safe query builder (select / insert / update / delete).
Dependencies