A thin, type-safe SQL toolkit for MoonBit.
English | 日本語
Write one row type. A generator turns it into column handles, a projection, a
decoder and an encoder; you build typed queries and DML on top of those and run
them against any Driver. The row type and the domain entity are treated as
separate things, and the mapping between them is yours to write. The query
pipeline follows Acadia.
@aya.from(User::table())
|> @aya.Query::filter(u => u.age.gte(18) & u.deleted_at.is_none())
|> @aya.Query::map(u => @aya.sel(u.name))
|> @aya.Query::order_by(u => [u.name.asc()])
|> @aya.Query::limit(20)SELECT u."name"
FROM "users" AS u
WHERE u."age" >= ? AND u."deleted_at" IS NULL
ORDER BY u."name" ASC
LIMIT ?
-- parameters: [18, 20]Against a users table holding alice (30), bob (17), carol (42) and a
soft-deleted dave (25), that returns ["alice", "carol"] — an Array[String],
because the map narrowed the projection to one column. Every chapter below
works the same way: a table, a pipeline, the SQL it emits, and the rows that
come back.
- A query builder, not an ORM. No identity map, no lazy loading, no change tracking. A query is a value; running it is a separate step.
- Typed at the column.
Column[T]carries a phantomT, so aColumn[Int]will not accept a string and aColumn[String]cannot be summed. - Database-agnostic. aya produces SQL text plus an ordered parameter
list. Anything that can run that pair is a driver; SQLite, PostgreSQL and a
recording fake ship in
src/driver. - Generated, not reflected. The generator emits ordinary MoonBit source you can read and diff. Nothing is discovered at runtime.
- The schema comes from the same source.
aya-kitdiffs your entities against the last snapshot and writes the migration — no connection needed.
moon add Allianaab2m/aya// moon.pkg
import {
"Allianaab2m/aya",
"Allianaab2m/aya/driver/sqlite",
}The core library is the module's root package, so it is @aya without anyone
having to alias it: @aya.Column, @aya.Table, @aya.from, @aya.SqlValue.
Generated code is written against @aya too, so importing it under a different
alias will not line up with the output.
The whole dependency graph builds on the native target only: both SQL client
libraries are native FFI, and so is moonbitlang/async beneath them.
1. Declare the row type. The annotated struct is the flat shape of one row.
#aya.table(name="users", alias="u")
pub(all) struct User {
#aya.id
id : Int
name : String
age : Int
deleted_at : String?
} derive(Debug, Eq)2. Generate.
aya-kit codegenYou get UserCols, User::cols(), User::all(), User::binding(),
User::table(), User::table_of() and User::primary_key_name().
aya-kit generate turns the same annotations into the table itself — see
Schema and migrations.
3. Query, and run it.
async fn main {
@sqlite.with_connection("app.db", driver => {
let db = @aya.Tx::new(driver)
let adults = (@aya.from(User::table())
|> @aya.Query::filter(u => u.age.gte(18))).run(db)
println(adults.length())
})
}A handful of types carry everything. The rest of the API is combinators over them.
classDiagram
class Table~Cols,R~ {
cols : Cols
all : Selection~R~
write : Binding~R~
}
class Selection~Out~ {
exprs : Array~RawExpr~
read : Row to Out
}
class Binding~In~ {
columns : Array~String~
write : In to values
}
class Query~Cols,A~ {
cols : Cols
projection / wheres / group / order
decode : Row to A
}
class Reducer~Out~ {
aggregates only
}
class Column~T~ {
tbl : String
name : String
}
class Expr~T~ {
raw : RawExpr
}
class Tx~D~ {
db : D
depth : Int
}
class RawExpr {
Col / Lit / Bin
Unary / InList / Agg
}
Table --> Selection : all
Table --> Binding : write
Table --> Query : from
Query --> Reducer : reduce / group_by
Column --> Expr : expr
Expr --> RawExpr : raw
Selection --> RawExpr : exprs
Tx --> Query : run / one / first
| Type | Means | Chapter |
|---|---|---|
Table[Cols, R] |
a table, plus how to read and write one row of it | Table |
Selection[Out] |
which columns to read, and how to decode them | Table |
Binding[In] |
which columns to write, and how to encode them | Table |
Column[T] / Expr[T] |
a typed column reference / a typed expression | Query |
Query[Cols, A] |
a SELECT under construction, yielding A |
Query |
Insert / Update[Cols] / Delete[Cols] |
a write under construction | DML |
Nullable[C, R] |
the right-hand side of an outer join | JOIN |
Reducer[Out] |
a summary over many rows — aggregates only | Aggregation |
Executor / Driver / Tx[D] |
run a statement / bracket a transaction | Execution |
The T in Expr[T] and Column[T] is a phantom: it never reaches the SQL and
exists only to keep comparisons honest.
| 1. Table | defining tables, codegen, and the row-vs-domain seam |
| 2. Query | Query and the expression language |
| 3. DML | Insert, Update, Delete |
| 4. JOIN | inner and outer joins, and naming the joined shape |
| 5. Aggregation | Reducer, reduce, group_by |
| 6. Execution | Executor, Driver, Tx, transactions, drivers |
| 7. Repository | the pattern aya is designed to sit under |
| 8. Schema and migrations | DDL from the same annotations, and aya-kit |
| 9. Design notes | why the types are shaped this way, and what is missing |
src/*.mbt core library — expressions, projection, query, DML, emission
src/gen/ entity parsing — attributes to IR — and column-handle emission
src/ddl/ IR to snapshot, diff, and DDL
src/kit/ config and migration planning, all of it pure
src/kit/cmd/ the CLI (aya-kit)
src/driver/sqlite/ SQLite driver
src/driver/postgres/PostgreSQL driver
src/driver/fake/ recording fake, for testing repositories
src/example/ two worked entities: a plain one and a row-vs-domain one
migrations/ the migrations generated from src/example
moon check # type check
moon test # tests
moon fmt # format
moon info # refresh .mbtiThe code blocks in this repository's Markdown are not type-checked: checking
them would mean keeping each file as .mbt.md inside a package under src/,
and files outside the module's source = "src" are not covered.
Apache-2.0