Sign in

    This version of the module is deprecated: renamed to Rz-coder8848/moon-typed-orm

    moonorm

    A typed, injection-safe SQL query builder with a pluggable connection boundary and a dependency-free in-memory backend.

    orm
    sql
    query-builder
    database
    Download zip
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    last month
    Downloads
    8

    No dependencies

    #MoonORM

    A typed, injection-safe SQL query builder for MoonBit, with a pluggable connection boundary and two backends: a dependency-free in-memory store and a native SQLite driver.

    SQL is built through a small DSL of typed columns instead of string concatenation. Every value is bound as a positional parameter, so there is no path for SQL injection through user input. The builder renders to a Statement { sql, params } pair that any SQL driver can execute.

    ///|
    let age : Column[Int] = Column::new("users", "age")

    ///|
    let stmt = Select::from("users")
    .where_(age.gte(18))
    .order_by(age.desc())
    .limit(10)
    .build()
    // stmt.to_sql() == SELECT * FROM "users" WHERE "users"."age" >= ? ORDER BY "users"."age" DESC LIMIT 10
    // stmt.to_params() == [Value::int(18)]

    #What's here

    • ast.mbt — the DSL: Column, Condition, Order, Assignment.
    • query.mbt — Select / Insert / Update / Delete builders and Statement, plus aggregates (COUNT / SUM / AVG / MIN / MAX), GROUP BY / HAVING, INNER / LEFT / RIGHT / FULL JOIN, and subqueries (IN / EXISTS, scalar comparison, and correlated).
    • conn.mbt — Row, ExecResult, and the Connection / Transactional traits.
    • memory.mbt — an in-memory Connection implementation (no FFI, no deps).
    • error.mbt — OrmError.
    • sqlite/ — a native SQLite Connection over a vendored SQLite amalgamation.
    • cmd/main / cmd/sqlite_demo — runnable demos of the same builders against the in-memory backend and against SQLite.

    #Running

    moon test # 48 tests on wasm-gc: SQL rendering, errors, CRUD, joins, subqueries moon test --target native # + 3 SQLite tests (CRUD, null/float, transactions) moon run cmd/main # in-memory demo: build -> execute -> rows moon run cmd/sqlite_demo --target native # the same demo against SQLite

    The core library has no external dependencies beyond the MoonBit core library. The in-memory backend runs on the wasm-gc target, so the demo needs no database and no C compiler. The SQLite driver targets the native backend, needs a C toolchain, and vendors the SQLite amalgamation so there is no system sqlite3 to install.

    #The DSL

    Columns carry a compile-time type so comparisons stay type-checked:

    ///|
    let id : Column[Int] = Column::new("users", "id")

    ///|
    let email : Column[String] = Column::new("users", "email")

    // where id = 1 AND (email LIKE '%@example.com' OR email IS NULL)

    ///|
    let cond = id.eq(1).and_(email.like("%@example.com").or_(email.is_null()))

    Conditions combine with and_ / or_ / not_ (the bare and / or / not are MoonBit keywords). Condition::raw(sql) is an escape hatch for operators the DSL doesn't model; the in-memory backend rejects it.

    Aggregation and joins are first-class too:

    ///|
    let stmt = Select::from("orders")
    .group_by(Column::new("orders", "status"))
    .count()
    .build()
    // SELECT "orders"."status", COUNT(*) FROM "orders" GROUP BY "orders"."status"

    ///|
    let user_id : Column[Int] = Column::new("orders", "user_id")

    ///|
    let stmt = Select::from("orders")
    .left_join("users", user_id.eq(Column::new("users", "id")))
    .build()
    // SELECT * FROM "orders" LEFT JOIN "users" ON "orders"."user_id" = "users"."id"
    // `.join` is the inner join; `.left_join`, `.right_join`, and `.full_join`
    // are the outer joins.

    Subqueries plug into WHERE. They may reference the outer query's columns — a correlated subquery — or be compared to a single value (a scalar subquery):

    ///|
    let user_id : Column[Int] = Column::new("orders", "user_id")

    ///|
    let stmt = Select::from("users")
    .where_(
    Column::new("users", "id").in_select(
    Select::from("orders").columns([user_id.to_ref()]),
    ),
    )
    .build()
    // SELECT * FROM "users" WHERE "users"."id" IN (SELECT "orders"."user_id" FROM "orders")
    // `Condition::exists(sub)` gives EXISTS; wrap in `.not_()` for NOT EXISTS.

    ///|
    let uid : Column[Int] = Column::new("users", "id")

    ///|
    let ouid : Column[Int] = Column::new("orders", "user_id")

    ///|
    let stmt = Select::from("users")
    .where_(Condition::exists(Select::from("orders").where_(ouid.eq_col(uid))))
    .build()
    // SELECT * FROM "users" WHERE EXISTS (SELECT * FROM "orders" WHERE "orders"."user_id" = "users"."id")

    ///|
    let age : Column[Int] = Column::new("users", "age")
    // users whose age is above the average:

    ///|
    let stmt2 = Select::from("users")
    .where_(age.gt_sub(Select::from("users").avg(age)))
    .build()
    // SELECT * FROM "users" WHERE "users"."age" > (SELECT AVG("users"."age") FROM "users")

    #The Connection boundary

    Builders hand structured queries to a backend, not SQL strings. A SQL driver renders with build() and binds parameters; the in-memory backend evaluates the structure directly. The trait is the replaceable boundary — same call sites, different backend:

    ///|
    pub(open) trait Connection {
    fn select(Self, Select) -> Array[Row] raise OrmError
    fn insert(Self, Insert) -> ExecResult raise OrmError
    fn update(Self, Update) -> ExecResult raise OrmError
    fn delete(Self, Delete) -> ExecResult raise OrmError
    }

    Memory implements it:

    let db = Memory::new()
    db.create_table("users", ["id", "name", "age"])

    db.insert(Insert::into("users").set(id.set(1)).set(name.set("alice")).set(age.set(30)))

    let rows = db.select(Select::from("users").where_(age.gte(18)))

    Sqlite (native target) implements the same trait:

    let db = Sqlite::open(":memory:")
    db.exec("CREATE TABLE users (id INTEGER, name TEXT, age INTEGER)")

    db.insert(Insert::into("users").set(id.set(1)).set(name.set("alice")).set(age.set(30)))

    let rows = db.select(Select::from("users").where_(age.gte(18)))

    Transactions are an optional capability, not part of Connection. A backend implements Transactional only if it can run multi-statement transactions:

    ///|
    pub(open) trait Transactional {
    fn begin(Self) -> Unit raise OrmError
    fn commit(Self) -> Unit raise OrmError
    fn rollback(Self) -> Unit raise OrmError
    }

    Sqlite implements it (BEGIN / COMMIT / ROLLBACK); Memory does not. The two demos show the boundary side by side: cmd/main runs on Memory, while cmd/sqlite_demo runs the same builders on Sqlite and wraps seeding in a transaction.

    #Design notes

    • Parameterized SQL. Values are never interpolated into the text. The builder emits ? placeholders and a parallel params array.
    • Phantom type columns. Column[T]'s type parameter never appears in the SQL; it exists only so Column[Int]::eq takes an Int. This is a compile-time marker, not a runtime value.
    • No string SQL in the hot path. Connection takes the builder types, so an in-memory backend doesn't have to parse SQL back.
    • Value smart constructors. Enum variants are read-only outside the package (a MoonBit interface rule), so construct results with Value::int(1), Value::text("x"), etc.

    #Limits

    • One join per SELECT (INNER / LEFT / RIGHT / FULL); the in-memory backend evaluates all four join kinds, padding the missing side with nulls.
    • Subqueries support IN, EXISTS, and scalar comparison, and may be correlated — they can reference the outer row. Correlation is single-level: a subquery sees its immediate outer query's columns, not a grand-outer.
    • Transactions are opt-in via Transactional: Sqlite implements it, Memory does not.
    • Condition::raw works for SQL rendering but is rejected by Memory.
    • The SQLite driver is native-only (not available on wasm-gc).

    #License

    Apache-2.0.

    The native backend vendors the SQLite amalgamation (sqlite/sqlite3.c and sqlite3.h, version 3.53.4). SQLite is in the public domain, so it carries no license terms and imposes no restrictions on use or redistribution.

    Connection

    pub(open) trait Connection {
    fn select(Self, Select) -> Array[Row] raise OrmError
    fn insert(Self, Insert) -> ExecResult raise OrmError
    fn update(Self, Update) -> ExecResult raise OrmError
    fn delete(Self, Delete) -> ExecResult raise OrmError
    }

    A database backend. Builders are handed over as structured queries: a SQL driver renders them with [Select::build] and binds parameters, while the in-memory backend evaluates them directly. This trait is the replaceable boundary — swap [Memory] for a SQLite or Postgres driver without touching call sites.

    ToValue

    pub trait ToValue {
    fn to_value(Self) -> Value
    }

    Types that can be turned into a [Value] for query binding.
    impl ToValue for Bool
    impl ToValue for Int
    impl ToValue for Double
    impl ToValue for String
    impl ToValue for Bytes

    Transactional

    pub(open) trait Transactional {
    fn begin(Self) -> Unit raise OrmError
    fn commit(Self) -> Unit raise OrmError
    fn rollback(Self) -> Unit raise OrmError
    }

    A connection that can run explicit transactions. Backends that don't support multi-statement transactions (for example a single-statement in-memory store) simply don't implement this trait — capabilities are opt-in, and [Connection] stays the narrow boundary every backend shares.

    OrmError

    pub suberror OrmError {
    InvalidIdentifier(String)
    InvalidQuery(String)
    Backend(String)
    }

    Errors raised while building or running queries.

    OrmError::backend

    fn OrmError::backend(why : String) -> OrmError

    Smart constructor for backend errors. Like [Value], OrmError's variants are read-only outside this package; drivers raise backend failures through this instead of building the variant directly.

    OrmError::message

    fn OrmError::message(self : OrmError) -> String

    Human-readable message, used for error reporting.

    Aggregate

    pub enum Aggregate {
    CountAll
    Count(ColumnRef)
    Sum(ColumnRef)
    Avg(ColumnRef)
    Min(ColumnRef)
    Max(ColumnRef)
    } derive(Eq,
    Debug
    )

    An aggregate function over a column, or COUNT(*) over all rows.

    Assignment

    pub struct Assignment {
    column : ColumnRef
    value : Value
    } derive(Eq,
    Debug
    )

    A column assignment used by INSERT and UPDATE.

    Column

    pub struct Column[T] {
    reference : ColumnRef
    }

    A typed reference to a column.

    The type parameter T is a compile-time marker that pins the column to a MoonBit type so comparisons stay type-checked; it never appears in the SQL text. Build one with [Column::new], for example:

    let email : Column[String] = Column::new("users", "email")

    Column::asc

    fn[T] Column::asc(self : Column[T]) -> Order

    Column::desc

    fn[T] Column::desc(self : Column[T]) -> Order

    Column::eq

    fn[T : ToValue] Column::eq(self : Column[T], value : T) -> Condition

    Column::eq_col

    fn[T] Column::eq_col(self : Column[T], other : Column[T]) -> Condition

    Compare this column against another column (for example the ON condition of a join). Both columns must share the same type.

    Column::eq_sub

    fn[T] Column::eq_sub(self : Column[T], sub : Select) -> Condition

    Compare this column against a scalar subquery (col = (SELECT ...)).

    Column::gt

    fn[T : ToValue] Column::gt(self : Column[T], value : T) -> Condition

    Column::gt_sub

    fn[T] Column::gt_sub(self : Column[T], sub : Select) -> Condition

    Compare this column against a scalar subquery (col > (SELECT ...)).

    Column::gte

    fn[T : ToValue] Column::gte(self : Column[T], value : T) -> Condition

    Column::gte_sub

    fn[T] Column::gte_sub(self : Column[T], sub : Select) -> Condition

    Compare this column against a scalar subquery (col >= (SELECT ...)).

    Column::in_

    fn[T : ToValue] Column::in_(self : Column[T], values : Array[T]) -> Condition

    Column::in_select

    fn[T] Column::in_select(self : Column[T], sub : Select) -> Condition

    Match this column against a single-column subquery (col IN (SELECT ...)). The subquery selects one column; the in-memory backend uses its first. The subquery may reference the outer query's columns (a correlated subquery).

    Column::is_not_null

    fn[T] Column::is_not_null(self : Column[T]) -> Condition

    Column::is_null

    fn[T] Column::is_null(self : Column[T]) -> Condition

    Column::like

    fn[T : ToValue] Column::like(self : Column[T], value : T) -> Condition

    Column::lt

    fn[T : ToValue] Column::lt(self : Column[T], value : T) -> Condition

    Column::lt_sub

    fn[T] Column::lt_sub(self : Column[T], sub : Select) -> Condition

    Compare this column against a scalar subquery (col < (SELECT ...)).

    Column::lte

    fn[T : ToValue] Column::lte(self : Column[T], value : T) -> Condition

    Column::lte_sub

    fn[T] Column::lte_sub(self : Column[T], sub : Select) -> Condition

    Compare this column against a scalar subquery (col <= (SELECT ...)).

    Column::name

    fn[T] Column::name(self : Column[T]) -> String

    Column::ne

    fn[T : ToValue] Column::ne(self : Column[T], value : T) -> Condition

    Column::ne_sub

    fn[T] Column::ne_sub(self : Column[T], sub : Select) -> Condition

    Compare this column against a scalar subquery (col <> (SELECT ...)).

    Column::new

    fn[T] Column::new(table : String, name : String) -> Column[T]

    Create a typed column reference.

    Column::set

    fn[T : ToValue] Column::set(self : Column[T], value : T) -> Assignment

    Column::table

    fn[T] Column::table(self : Column[T]) -> String

    Column::to_ref

    fn[T] Column::to_ref(self : Column[T]) -> ColumnRef

    The underlying untyped column reference.

    ColumnRef

    pub struct ColumnRef {
    table : String
    name : String
    } derive(Eq,
    Debug
    )

    Identifies a column by its qualified table and name.

    CompareOp

    pub enum CompareOp {
    Eq
    Ne
    Gt
    Gte
    Lt
    Lte
    Like
    } derive(Eq,
    Debug
    )

    Binary comparison operators.

    Condition

    pub enum Condition {
    Compare(ColumnRef, CompareOp, Value)
    CompareCol(ColumnRef, CompareOp, ColumnRef)
    CompareSub(ColumnRef, CompareOp, Select)
    In(ColumnRef, Array[Value])
    InSelect(ColumnRef, Select)
    IsNull(ColumnRef)
    IsNotNull(ColumnRef)
    Exists(Select)
    And(Condition, Condition)
    Or(Condition, Condition)
    Not(Condition)
    Raw(String)
    } derive(Eq,
    Debug
    )

    A boolean expression in a query.

    Condition::and_

    fn Condition::and_(self : Condition, other : Condition) -> Condition

    Combine two conditions with AND.

    Condition::exists

    fn Condition::exists(sub : Select) -> Condition

    EXISTS (SELECT ...) — true when the subquery returns at least one row. Combine with [Condition::not_] for NOT EXISTS. The subquery may reference the outer query's columns (a correlated subquery): a qualified column that names a table outside the subquery resolves against the outer row.

    Condition::not_

    fn Condition::not_(self : Condition) -> Condition

    Negate a condition.

    Condition::or_

    fn Condition::or_(self : Condition, other : Condition) -> Condition

    Combine two conditions with OR.

    Condition::raw

    fn Condition::raw(sql : String) -> Condition

    Escape hatch: a raw SQL fragment spliced into the WHERE clause as-is. Prefer the typed helpers; use this only for operators the DSL doesn't model.

    Delete

    pub struct Delete {
    table : String
    condition : Condition?
    }

    Build a DELETE.

    Delete::build

    fn Delete::build(self : Delete) -> Statement raise OrmError

    Delete::from

    fn Delete::from(table : String) -> Delete

    Delete::where_

    fn Delete::where_(self : Delete, condition : Condition) -> Delete

    ExecResult

    pub struct ExecResult {
    rows_affected : Int
    last_insert_id : Int?
    } derive(Eq,
    Debug
    )

    Outcome of a statement that mutates data.

    ExecResult::new

    fn ExecResult::new(rows_affected : Int, last_insert_id? : Int) -> ExecResult

    Insert

    pub struct Insert {
    table : String
    assignments : Array[Assignment]
    }

    Build an INSERT.

    Insert::build

    fn Insert::build(self : Insert) -> Statement raise OrmError

    Insert::into

    fn Insert::into(table : String) -> Insert

    Insert::set

    fn Insert::set(self : Insert, assignment : Assignment) -> Insert

    Join

    A join: a right-hand table matched against the left on on, of a given [JoinKind].

    JoinKind

    pub enum JoinKind {
    Inner
    Left
    Right
    Full
    } derive(Eq,
    Debug
    )

    The kind of a join clause.

    Memory

    pub struct Memory {
    tables :
    HashMap
    [String, MemoryTable]
    }

    In-memory backend: a dependency-free table store for tests, demos and CI.

    Implements [Connection] by evaluating the structured builders directly, so query code runs unchanged against this backend or a future SQL driver. Supports a single inner join (nested-loop) in addition to single-table queries; raw SQL conditions are rejected with [OrmError::Backend].

    Memory::create_table

    fn Memory::create_table(self : Memory, name : String, columns : Array[String]) -> Unit raise OrmError

    Define a table before inserting into it.

    Memory::delete

    fn Memory::delete(self : Memory, query : Delete) -> ExecResult raise OrmError

    Make the backend methods callable directly as db.select(...), db.insert(...), and so on.

    Memory::insert

    fn Memory::insert(self : Memory, query : Insert) -> ExecResult raise OrmError

    Make the backend methods callable directly as db.select(...), db.insert(...), and so on.

    Memory::new

    fn Memory::new() -> Memory

    Memory::select

    fn Memory::select(self : Memory, query : Select) -> Array[Row] raise OrmError

    Make the backend methods callable directly as db.select(...), db.insert(...), and so on.

    Memory::update

    fn Memory::update(self : Memory, query : Update) -> ExecResult raise OrmError

    Make the backend methods callable directly as db.select(...), db.insert(...), and so on.

    MemoryTable

    type MemoryTable

    Order

    pub enum Order {
    Asc(ColumnRef)
    Desc(ColumnRef)
    } derive(Eq,
    Debug
    )

    Sort direction.

    Row

    pub struct Row {
    columns : Array[String]
    values : Array[Value]
    } derive(Eq,
    Debug
    )

    A single result row: ordered column names and their values.

    Row::get

    fn Row::get(self : Row, column : String) -> Value?

    Row::get_at

    fn Row::get_at(self : Row, index : Int) -> Value?

    Row::new

    fn Row::new(columns : Array[String], values : Array[Value]) -> Row

    Select

    pub struct Select {
    table : String
    columns : Array[ColumnRef]
    aggregate : Aggregate?
    condition : Condition?
    group_by : Array[ColumnRef]
    having : Condition?
    join : Join?
    orders : Array[Order]
    limit : Int?
    offset : Int?
    } derive(Eq,
    Debug
    )

    Build a SELECT.

    Select::avg

    fn[T] Select::avg(self : Select, column : Column[T]) -> Select

    Average a numeric column.

    Select::build

    fn Select::build(self : Select) -> Statement raise OrmError

    Select::columns

    fn Select::columns(self : Select, columns : Array[ColumnRef]) -> Select

    Restrict to specific columns; omit to select all (*).

    Select::count

    fn Select::count(self : Select) -> Select

    Aggregate all rows with COUNT(*).

    Select::count_of

    fn[T] Select::count_of(self : Select, column : Column[T]) -> Select

    Count the non-null values of a column.

    Select::from

    fn Select::from(table : String) -> Select

    Select::full_join

    fn Select::full_join(self : Select, table : String, on : Condition) -> Select

    Full-join another table on a condition. Rows from either side are kept even when no row on the other side matches (the missing side becomes null).

    Select::group_by

    fn[T] Select::group_by(self : Select, column : Column[T]) -> Select

    Group rows by a column. Repeated calls add more grouping columns; the selected columns become the grouping columns (plus an aggregate, if any).

    Select::having

    fn Select::having(self : Select, condition : Condition) -> Select

    Filter groups after aggregation. The in-memory backend can only address grouping columns here; a SQL driver can also use [Condition::raw] to reference an aggregate (for example Condition::raw("COUNT(*) > 1")).

    Select::join

    fn Select::join(self : Select, table : String, on : Condition) -> Select

    Inner-join another table on a condition. Columns from either table stay qualified, so an on condition can reference both (for example order.user_id.eq(user.id)).

    Select::left_join

    fn Select::left_join(self : Select, table : String, on : Condition) -> Select

    Left-join another table on a condition. Rows from the left table are kept even when no row on the right matches (right-hand columns become null).

    Select::limit

    fn Select::limit(self : Select, limit : Int) -> Select

    Select::max

    fn[T] Select::max(self : Select, column : Column[T]) -> Select

    Maximum value of a column.

    Select::min

    fn[T] Select::min(self : Select, column : Column[T]) -> Select

    Minimum value of a column.

    Select::offset

    fn Select::offset(self : Select, offset : Int) -> Select

    Select::order_by

    fn Select::order_by(self : Select, order : Order) -> Select

    Select::right_join

    fn Select::right_join(self : Select, table : String, on : Condition) -> Select

    Right-join another table on a condition. Rows from the right table are kept even when no row on the left matches (left-hand columns become null).

    Select::sum

    fn[T] Select::sum(self : Select, column : Column[T]) -> Select

    Sum a numeric column.

    Select::where_

    fn Select::where_(self : Select, condition : Condition) -> Select

    Statement

    pub struct Statement {
    sql : String
    params : Array[Value]
    } derive(Eq,
    Debug
    )

    A rendered SQL statement: text with ? placeholders plus bound parameters.

    Statement::to_params

    fn Statement::to_params(self : Statement) -> Array[Value]

    The bound parameters, in placeholder order.

    Statement::to_sql

    fn Statement::to_sql(self : Statement) -> String

    The rendered SQL text.

    Update

    pub struct Update {
    table : String
    assignments : Array[Assignment]
    condition : Condition?
    }

    Build an UPDATE.

    Update::build

    fn Update::build(self : Update) -> Statement raise OrmError

    Update::set

    fn Update::set(self : Update, assignment : Assignment) -> Update

    Update::table

    fn Update::table(table : String) -> Update

    Update::where_

    fn Update::where_(self : Update, condition : Condition) -> Update

    Value

    pub enum Value {
    Null
    Bool(Bool)
    Int(Int)
    Float(Double)
    Text(String)
    Blob(Bytes)
    } derive(Eq,
    Debug
    )

    A single SQL value. Backends translate these onto their own binding types (SQLite sqlite3_bind_*, Postgres wire types, etc.).
    impl ToValue for Value

    Value::blob

    fn Value::blob(b : Bytes) -> Value

    Value::bool

    fn Value::bool(b : Bool) -> Value

    Value::float

    fn Value::float(f : Double) -> Value

    Value::int

    fn Value::int(n : Int) -> Value

    Value::null

    fn Value::null() -> Value

    Smart constructors. The variants are read-only outside this package — they can be matched but not built — so construct values through these.

    Value::text

    fn Value::text(s : String) -> Value

    Value::type_name

    fn Value::type_name(self : Value) -> String

    Short type tag, used in error messages when a value mismatches a column.