aya

A thin, type-safe SQL toolkit for MoonBit

moon add Allianaab2m/aya@0.3.0
Download zip
Version
0.3.0
License
Apache-2.0
Last updated
12 hours ago
Downloads
5
README

#aya

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.

#What it is

  • 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 phantom T, so a Column[Int] will not accept a string and a Column[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-kit diffs your entities against the last snapshot and writes the migration — no connection needed.

#Install

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.

#Quickstart

1. Declare the row type. The annotated struct is the flat shape of one row.

#ayatable(name="users", alias="u")
pub(all) struct User {
#ayaid
id : Int
name : String
age : Int
deleted_at : String?
} derive(Debug, Eq)

2. Generate.

aya-kit codegen

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

#The vocabulary

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

TypeMeansChapter
Table[Cols, R]a table, plus how to read and write one row of itTable
Selection[Out]which columns to read, and how to decode themTable
Binding[In]which columns to write, and how to encode themTable
Column[T] / Expr[T]a typed column reference / a typed expressionQuery
Query[Cols, A]a SELECT under construction, yielding AQuery
Insert / Update[Cols] / Delete[Cols]a write under constructionDML
Nullable[C, R]the right-hand side of an outer joinJOIN
Reducer[Out]a summary over many rows — aggregates onlyAggregation
Executor / Driver / Tx[D]run a statement / bracket a transactionExecution

The T in Expr[T] and Column[T] is a phantom: it never reaches the SQL and exists only to keep comparisons honest.

#Documentation

1. Tabledefining tables, codegen, and the row-vs-domain seam
2. QueryQuery and the expression language
3. DMLInsert, Update, Delete
4. JOINinner and outer joins, and naming the joined shape
5. AggregationReducer, reduce, group_by
6. ExecutionExecutor, Driver, Tx, transactions, drivers
7. Repositorythe pattern aya is designed to sit under
8. Schema and migrationsDDL from the same annotations, and aya-kit
9. Design noteswhy the types are shaped this way, and what is missing

#Package layout

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/aya-kit/ 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

#Development

moon check # type check moon test # tests moon fmt # format moon info # refresh .mbti

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

#License

Apache-2.0

#
Row

type Row = ArrayView[SqlValue]

One result row, as the driver handed it back: the projected values in projection order, and nothing else. Columns are found by position, never by name.

#
Driver

pub(open) trait Driver : Executor {
async fn begin(Self) -> Unit raise DbError
async fn commit(Self) -> Unit raise DbError
async fn rollback(Self) -> Unit raise DbError
}

What aya needs from a database binding: an Executor that can also bracket a transaction.

Everything above this trait is dialect-agnostic: aya builds SQL text and an ordered parameter list, and a driver is whatever can run that pair. A driver is also the connection — pooling, if wanted, belongs outside.

The three transaction statements are separated out from Executor because they are a capability, not a detail: Tx is the only thing in aya that sends them, and nothing aya hands to user code implements this trait.

#
Executor

pub(open) trait Executor {
async fn query(Self, String, Array[SqlValue], columns~ : Int) -> Array[Array[SqlValue]] raise DbError
async fn execute(Self, String, Array[SqlValue]) -> Int raise DbError
fn dialect(Self) -> Dialect
}

What running a statement needs: text, parameters, and the dialect they were built for. No transaction control.

This is deliberately the smaller half of what a database connection can do. Everything aya hands to the body of a transaction is an Executor and nothing more, so a repository holding one cannot commit or roll back the transaction it is running inside.

#
SqlDecode

pub(open) trait SqlDecode {
fn decode(SqlValue, String) -> Self raise DecodeError
}

How a column is read back into a MoonBit value.

The second argument is the column's name, carried only so that a failure can say which column it came from.
impl SqlDecode for Bool
impl SqlDecode for Int
impl SqlDecode for Double
impl SqlDecode for String
impl SqlDecode for Option[T]

#
SqlEncode

pub(open) trait SqlEncode {
fn to_sql_value(Self) -> SqlValue
}

How a MoonBit value is written to a column.
impl SqlEncode for Bool
impl SqlEncode for Int
impl SqlEncode for Int64
impl SqlEncode for Double
impl SqlEncode for String
impl SqlEncode for Option[T]

#
SqlNum

pub(open) trait SqlNum {
}

Marker trait for types SQL will do arithmetic on.

Narrower than SqlOrd on purpose: sum and avg need a number, not merely something orderable, so a Column[String] cannot be summed.
impl SqlNum for Int
impl SqlNum for Int64
impl SqlNum for Double

#
SqlOrd

pub(open) trait SqlOrd {
}

Marker trait for types SQL will order: gt, gte, lt, lte, asc, desc. No methods — it is a constraint, not an interface.
impl SqlOrd for Int
impl SqlOrd for Int64
impl SqlOrd for Double
impl SqlOrd for String
impl SqlOrd for Option[T]

#
DbError

pub(all) suberror DbError {
ConnectionFailed(String)
QueryFailed(sql~ : String, message~ : String)
NotFound(sql~ : String)
TooManyRows(sql~ : String, got~ : Int)
RollbackOnly(cause~ : Error)
} derive(
Debug
)

Failures that come from the database rather than from aya.

#
DecodeError

pub(all) suberror DecodeError {
MissingColumn(String)
TypeMismatch(column~ : String, expected~ : String)
Malformed(String)
} derive(
Debug
)

A stored value did not fit the type the entity declared for it.

#
StatementError

pub(all) suberror StatementError {
EmptyInsert(table~ : String)
ArityMismatch(table~ : String, expected~ : Int, got~ : Int)
EmptyUpdate(table~ : String)
DuplicateAlias(tbl~ : String)
} derive(
Debug
)

A statement that cannot be written as valid SQL.

Every to_sql shares this: the failures are about the statement being malformed, not about anything the database said.

#
BinOp

pub enum BinOp {
Eq
Ne
Gt
Gte
Lt
Lte
And
Or
} derive(Eq,
Debug
)

impl Show for BinOp

#
Binding

pub struct Binding[In] {
columns : Array[String]
write : (In) -> Array[SqlValue]
}

The write-side counterpart of Selection.

Selection says which columns to read and how to turn a result row into a value; Binding says which columns to write and how to turn a value back into a row. Keeping the pair symmetric means a generator can emit both from the same field list, so the two can never disagree about column order.

#
Binding::contramap

fn[A, B] Binding::contramap(self : Binding[A], f : (B) -> A) -> Binding[B]

Rewrite a binding to accept a different input type.

The contravariant counterpart of Selection::map. Together they form the seam between the row type and a domain type: Selection::map turns rows into domain values on the way out, Binding::contramap turns domain values back into rows on the way in.

f is total, unlike the reading direction, because a domain value is the more constrained of the two: flattening it into a row cannot fail.

#
Binding::new

fn[In] Binding::new(columns : Array[String], write : (In) -> Array[SqlValue]) -> Binding[In]

Build a binding from column names and an encoder.

write must return one value per entry of columns, in the same order.

#
Binding::without

fn[In] Binding::without(self : Binding[In], names : Array[String]) -> Binding[In]

Drop columns from a binding, keeping declaration order.

The usual reason is an auto-increment primary key: the entity carries an id field, but the INSERT must let the database assign it.

#
Column

pub struct Column[T] {
tbl : String
name : String
}

A reference to one column of one table.

Separate from Expr[T] because a column also has a name, which sel, Binding and Update::set need and a general expression cannot supply.

#
Column::asc

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

#
Column::desc

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

#
Column::eq

fn[T : SqlEncode] Column::eq(self : Column[T], v : T) -> Expr[Bool]

#
Column::eq_col

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

Compare two columns, as in a join condition.

#
Column::expr

fn[T] Column::expr(self : Column[T]) -> Expr[T]

#
Column::gt

fn[T : SqlEncode + SqlOrd] Column::gt(self : Column[T], v : T) -> Expr[Bool]

#
Column::gte

fn[T : SqlEncode + SqlOrd] Column::gte(self : Column[T], v : T) -> Expr[Bool]

#
Column::in_

fn[T : SqlEncode] Column::in_(self : Column[T], vs : Array[T]) -> Expr[Bool]

#
Column::is_none

fn[T] Column::is_none(self : Column[T?]) -> Expr[Bool]

#
Column::is_some

fn[T] Column::is_some(self : Column[T?]) -> Expr[Bool]

#
Column::lt

fn[T : SqlEncode + SqlOrd] Column::lt(self : Column[T], v : T) -> Expr[Bool]

#
Column::lte

fn[T : SqlEncode + SqlOrd] Column::lte(self : Column[T], v : T) -> Expr[Bool]

#
Column::ne

fn[T : SqlEncode] Column::ne(self : Column[T], v : T) -> Expr[Bool]

#
Column::new

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

#
Column::nullable

fn[T] Column::nullable(self : Column[T]) -> Column[T?]

Reinterpret a column as nullable.

The right-hand side of a LEFT JOIN can be absent, so its columns decode as T? even though the entity declares them as T.

#
Column::raw

fn[T] Column::raw(self : Column[T]) -> RawExpr

#
Delete

pub struct Delete[Cols] {
table_name : String
tbl : String
cols : Cols
wheres : Array[RawExpr]
}

A DELETE statement over one table.

#
Delete::filter

fn[C] Delete::filter(self : Delete[C], f : (C) -> Expr[Bool]) -> Delete[C]

Narrow a delete further. Conditions are ANDed together.

#
Delete::run

async fn[E : Executor, C] Delete::run(self : Delete[C], db : E) -> Int

Run the delete, returning how many rows were removed.

#
Delete::to_sql

fn[C] Delete::to_sql(self : Delete[C], dialect? : Dialect) -> (String, Array[SqlValue])

#
Dialect

pub(all) enum Dialect {
Sqlite
Postgres
} derive(Eq,
Debug
)

SQL dialect. The two differ in how a placeholder is spelled.

#
Dir

pub(all) enum Dir {
Asc
Desc
} derive(Eq,
Debug
)

impl Show for Dir

#
Emitter

pub struct Emitter {
dialect : Dialect
params : Array[SqlValue]
}

Renders expressions to SQL text while collecting their literals.

Rendering and parameter collection are deliberately one pass: a literal becomes a placeholder at the moment its text is written, so the parameter list can never fall out of step with the placeholders that consume it.

#
Emitter::expr

fn Emitter::expr(self : Emitter, e : RawExpr, parent? : Int) -> String

Render an expression, adding brackets only where precedence demands them.

#
Emitter::new

fn Emitter::new(dialect : Dialect) -> Emitter

#
Expr

pub struct Expr[T](RawExpr)

A typed expression. T is what the expression evaluates to in SQL terms; nothing at runtime carries it.
impl BitAnd for Expr[Bool]
impl BitOr for Expr[Bool]

#
Expr::asc

fn[T : SqlOrd] Expr::asc(self : Expr[T]) -> OrderKey

#
Expr::desc

fn[T : SqlOrd] Expr::desc(self : Expr[T]) -> OrderKey

#
Expr::eq

fn[T : SqlEncode] Expr::eq(self : Expr[T], v : T) -> Expr[Bool]

#
Expr::eq_col

fn[T] Expr::eq_col(self : Expr[T], other : Expr[T]) -> Expr[Bool]

Compare two expressions, as in a join condition.

#
Expr::gt

fn[T : SqlEncode + SqlOrd] Expr::gt(self : Expr[T], v : T) -> Expr[Bool]

#
Expr::gte

fn[T : SqlEncode + SqlOrd] Expr::gte(self : Expr[T], v : T) -> Expr[Bool]

#
Expr::in_

fn[T : SqlEncode] Expr::in_(self : Expr[T], vs : Array[T]) -> Expr[Bool]

#
Expr::is_none

fn[T] Expr::is_none(self : Expr[T?]) -> Expr[Bool]

#
Expr::is_some

fn[T] Expr::is_some(self : Expr[T?]) -> Expr[Bool]

#
Expr::lt

fn[T : SqlEncode + SqlOrd] Expr::lt(self : Expr[T], v : T) -> Expr[Bool]

#
Expr::lte

fn[T : SqlEncode + SqlOrd] Expr::lte(self : Expr[T], v : T) -> Expr[Bool]

#
Expr::ne

fn[T : SqlEncode] Expr::ne(self : Expr[T], v : T) -> Expr[Bool]

#
Expr::raw

fn[T] Expr::raw(self : Expr[T]) -> RawExpr

#
Insert

pub struct Insert {
table_name : String
columns : Array[String]
rows : Array[Array[SqlValue]]
}

An INSERT statement over one table.

#
Insert::run

async fn[E : Executor] Insert::run(self : Insert, db : E) -> Int

Run the insert, returning how many rows were added.

#
Insert::to_sql

fn Insert::to_sql(self : Insert, dialect? : Dialect) -> (String, Array[SqlValue]) raise StatementError

#
Join

pub struct Join {
kind : JoinKind
table_name : String
tbl : String
on : RawExpr
}

One table joined onto a query, with the condition that attaches it.

#
JoinKind

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

impl Show for JoinKind

#
Nullable

type Nullable[C, R]

The right-hand side of a LEFT JOIN.

Wrapping the joined table's column handles rather than exposing them keeps the nullability from being lost: there is no way to reach a Column[T] of an outer-joined table, only a Column[T?] through col, or the whole row as Selection[R?] through row.

#
Nullable::col

fn[C, R, T] Nullable::col(self : Nullable[C, R], f : (C) -> Column[T]) -> Column[T?]

Take one column of the outer-joined table, as a nullable column.

#
Nullable::row

fn[C, R] Nullable::row(self : Nullable[C, R]) -> Selection[R?]

Take the whole outer-joined row, absent when nothing matched.

This is usually what a LEFT JOIN means: not that each column independently might be NULL, but that the row on the right either exists or does not. Inside Some, every field keeps the type the table declared.

#
OrderKey

pub struct OrderKey {
expr : RawExpr
dir : Dir
}

One key of an ORDER BY clause.

#
Query

pub struct Query[Cols, A] {
source : String
source_tbl : String
joins : Array[Join]
cols : Cols
projection : Array[RawExpr]
wheres : Array[RawExpr]
group : Array[RawExpr]
order : Array[OrderKey]
limit_n : Int?
decode : (ArrayView[SqlValue]) -> A raise DecodeError
}

A SELECT under construction, together with what its rows decode to.

Cols is the column handles the combinators are written against — one table's Cols struct to begin with, a tuple once joins are added. A is what a row becomes, which is the table's entity until map says otherwise.

#
Query::filter

fn[C, A] Query::filter(self : Query[C, A], f : (C) -> Expr[Bool]) -> Query[C, A]

Narrow the result. Conditions accumulate and are ANDed together.

#
Query::first

async fn[E : Executor, C, A] Query::first(self : Query[C, A], db : E) -> A?

Run the query and decode the first row, if there is one.

#
Query::group_by

fn[C, A, K : SqlDecode, S] Query::group_by(self : Query[C, A], key : (C) -> Column[K], f : (C) -> Reducer[S]) -> Query[C, (K, S)]

Summarise one group per distinct key.

The grouping column is the only non-aggregate the projection can contain, and it is exactly the one being grouped by, so the result is always a legal aggregate query.

#
Query::join

fn[C1, C2, R2, A] Query::join(self : Query[C1, A], t : Table[C2, R2], on : (C1, C2) -> Expr[Bool]) -> Query[(C1, C2), A]

Join another table, pairing the two tables' column handles.

Cols becomes a pair. Chaining nests to the left — ((C1, C2), C3) — which Query::map_cols collapses into names once the joins are done.

#
Query::left_join

fn[C1, C2, R2, A] Query::left_join(self : Query[C1, A], t : Table[C2, R2], on : (C1, C2) -> Expr[Bool]) -> Query[(C1, Nullable[C2, R2]), A]

Outer-join another table, pairing the column handles. on sees the joined table's raw columns, because a join condition is evaluated before the outer-join padding and nullability does not apply to it. What the query carries afterwards is the Nullable wrapper.

#
Query::limit

fn[C, A] Query::limit(self : Query[C, A], n : Int) -> Query[C, A]

Cap how many rows come back.

#
Query::map

fn[C, R, O] Query::map(self : Query[C, R], f : (C) -> Selection[O]) -> Query[C, O]

Replace the projection, and with it what a row decodes to.

#
Query::map_cols

fn[C, D, A] Query::map_cols(self : Query[C, A], f : (C) -> D) -> Query[D, A]

Replace the column handles the query carries.

Chained joins nest their columns to the left — ((C1, C2), C3) — and indexing through that nesting at every later step reads badly. Collapsing it once, into a flat tuple or a struct with names, keeps the rest of the pipeline legible. Only the handles change; the SQL built so far does not.

#
Query::one

async fn[E : Executor, C, A] Query::one(self : Query[C, A], db : E) -> A

Run the query and decode the single row it must return.

#
Query::order_by

fn[C, A] Query::order_by(self : Query[C, A], f : (C) -> Array[OrderKey]) -> Query[C, A]

Sort the result. The last call wins; keys are not accumulated.

#
Query::reduce

fn[C, A, S] Query::reduce(self : Query[C, A], f : (C) -> Reducer[S]) -> Query[C, S]

Collapse the whole result into one summary row.

The projection becomes the aggregates and nothing else, which is why a Reducer rather than a Selection is asked for here: it cannot smuggle in a bare column that SQL would then refuse to project.

#
Query::run

async fn[E : Executor, C, A] Query::run(self : Query[C, A], db : E) -> Array[A]

Run the query and decode every row.

#
Query::to_sql

fn[C, A] Query::to_sql(self : Query[C, A], dialect? : Dialect) -> (String, Array[SqlValue]) raise StatementError

Build the statement text and its parameters, in placeholder order.

#
RawExpr

pub enum RawExpr {
Col(tbl~ : String, name~ : String)
Lit(SqlValue)
Bin(BinOp, RawExpr, RawExpr)
Unary(String, RawExpr)
InList(RawExpr, Array[SqlValue])
Agg(String, RawExpr?)
} derive(
Debug
)

Untyped expression tree. Whatever type the wrappers above it carried has been erased by the time an expression reaches here.

#
Reducer

type Reducer[Out]

A summary computed across many rows, and how to read it back.

Structurally this is a Selection — the same expressions-plus-positional- reader pair — but the type is deliberately separate and opaque: a Reducer may only hold aggregate expressions, and there is no constructor that could put anything else in one. That is what stops a bare column being projected alongside an aggregate without being grouped by. SQL rejects such a query, and here it cannot be written.

#
Reducer::map

fn[A, B] Reducer::map(self : Reducer[A], f : (A) -> B raise DecodeError) -> Reducer[B]

Reshape a summary once it has been read.

#
Reducer::zip

fn[A, B] Reducer::zip(self : Reducer[A], other : Reducer[B]) -> Reducer[(A, B)]

Compute two summaries in the same pass.

Acadia spells this map2 through map9; zip plus map covers the same ground without an arity ladder.

#
Selection

pub struct Selection[Out] {
exprs : Array[RawExpr]
read : (ArrayView[SqlValue]) -> Out raise DecodeError
}

Which columns to read, and how to turn the values back into one MoonBit value.

Kept as one type rather than two because apart they break the usual way: a column is added to the projection and the decoder is not updated to match. read is positional, which is fragile by hand — but a generator emits both halves from one pass over the same field list, so they cannot drift.

#
Selection::into2

fn[A, B, R] Selection::into2(self : Selection[(A, B)], f : (A, B) -> R) -> Selection[R]

Collapse a pair into a value of your own.

#
Selection::into3

fn[A, B, C, R] Selection::into3(self : Selection[(A, B, C)], f : (A, B, C) -> R) -> Selection[R]

Collapse a triple into a value of your own.

#
Selection::map

fn[A, B] Selection::map(self : Selection[A], f : (A) -> B raise DecodeError) -> Selection[B]

Reshape what a projection decodes to, without touching which columns it reads.

#
Selection::new

fn[Out] Selection::new(exprs : Array[RawExpr], read : (ArrayView[SqlValue]) -> Out raise DecodeError) -> Selection[Out]

Build a projection from raw expressions and a positional decoder.

Intended for generated code: a generator emits exprs and read from the same entity definition, so their order cannot drift apart.

#
Selection::optional

fn[Out] Selection::optional(self : Selection[Out]) -> Selection[Out?]

Read the projection as absent when the row did not match.

A LEFT JOIN reports "no match" by making every column of the right table NULL, so an all-NULL row decodes to None rather than being forced through a decoder that would reject it.

This is safe for any projection that includes a column the database never leaves NULL — a primary key, for instance, which Table::all always covers. For a hand-built projection of entirely nullable columns, say which column to test with optional_on.

#
Selection::optional_on

fn[Out] Selection::optional_on(self : Selection[Out], key~ : Int) -> Selection[Out?]

Read the projection as absent when one chosen column is NULL.

key indexes into this projection's own columns, counting from zero.

#
Selection::zip

fn[A, B] Selection::zip(self : Selection[A], other : Selection[B]) -> Selection[(A, B)]

Concatenate two projections, self's columns first.

#
SqlValue

pub(all) enum SqlValue {
VNull
VInt(Int64)
VDouble(Double)
VText(String)
VBool(Bool)
VBytes(Bytes)
} derive(Eq,
Debug
)

A value as it travels to or from the database.

#
Table

pub struct Table[Cols, R] {
table_name : String
tbl : String
cols : Cols
all : Selection[R]
write : Binding[R]
}

A table, with everything needed to read from it and write to it.

Cols is the struct of Column[T] handles the query combinators are written against; R is what one row is, which may be the generated row type or a domain type mapped onto it by table_of.

tbl is the alias the emitted SQL uses (FROM "users" AS u). It is fixed per table rather than assigned per query, which is why a self-join is not expressible yet: both sides would claim the same alias, and Query::to_sql raises DuplicateAlias rather than emit ambiguous column references.

#
Table::new

fn[C, R] Table::new(table_name~ : String, tbl~ : String, cols~ : C, all~ : Selection[R], write~ : Binding[R]) -> Table[C, R]

Assemble a table from its parts. Normally emitted by aya-kit rather than written by hand.
pub struct Tx[D] {
db : D
depth : Int
failure : Error?
}

A driver plus how deep into nested transactions it currently is.

Build one per connection, at wiring time, and hand it to every repository rather than handing them the driver. That shared depth is the whole point: a repository that opens a transaction of its own joins the enclosing one instead of asking the database to BEGIN twice.

let db = @aya.Tx::new(driver)
let tickets = SqlTickets::new(db)
let users = SqlUsers::new(db)
@aya.transaction(db, _ => {
tickets.save(a) // both saves land in one transaction
users.save(b)
})

A Tx is not safe to share between concurrently running tasks: the depth it counts is a property of one connection's position in one call stack.
impl Executor for Tx[D]

#
Tx::driver

fn[D] Tx::driver(self : Tx[D]) -> D

The driver underneath, for the things aya does not model.

Reaching for this inside a transaction body means talking to the same connection the transaction is open on, which is usually what is wanted — and also means commit and rollback are in reach again, which is not.

#
Tx::is_open

fn[D] Tx::is_open(self : Tx[D]) -> Bool

Whether a transaction is currently open on this connection.

#
Tx::new

fn[D] Tx::new(db : D) -> Tx[D]

Wrap a driver so transactions over it can nest.

#
Update

pub struct Update[Cols] {
table_name : String
tbl : String
cols : Cols
sets : Array[(String, RawExpr)]
wheres : Array[RawExpr]
}

An UPDATE statement over one table.

Cols is carried so that assignments and predicates are written against the same typed column handles the query builder uses.

#
Update::filter

fn[C] Update::filter(self : Update[C], f : (C) -> Expr[Bool]) -> Update[C]

Narrow an update further. Conditions are ANDed together.

#
Update::run

async fn[E : Executor, C] Update::run(self : Update[C], db : E) -> Int

Run the update, returning how many rows were changed.

#
Update::set

fn[C, T : SqlEncode] Update::set(self : Update[C], f : (C) -> Column[T], v : T) -> Update[C]

Assign a value to one column.

The column is selected from the table's column handles, so the value type has to match the column type.

#
Update::to_sql

fn[C] Update::to_sql(self : Update[C], dialect? : Dialect) -> (String, Array[SqlValue]) raise StatementError

#
avg

fn[T : SqlNum] avg(c : Column[T]) -> Reducer[Double?]

The mean, or nothing when there are no rows.

The result is a Double whatever the column's type, because an average of integers is not generally an integer.

#
count

fn count() -> Reducer[Int]

How many rows there are.

Unlike the others this never comes back empty: COUNT(*) over no rows is zero, where MIN over no rows is NULL.

#
count_of

fn[T] count_of(c : Column[T]) -> Reducer[Int]

How many rows have a value in this column. NULLs are not counted.

#
delete

fn[C, R] delete(t : Table[C, R], pred : (C) -> Expr[Bool]) -> Delete[C]

Delete the rows matching pred.

As with update, the predicate is required so that a table-wide delete cannot happen by omission.

#
delete_all

fn[C, R] delete_all(t : Table[C, R]) -> Delete[C]

Delete every row in the table.

#
from

fn[C, R] from(t : Table[C, R]) -> Query[C, R]

Start a query from a table, selecting every column it declares.

#
insert

fn[C, R] insert(t : Table[C, R], row : R) -> Insert

Insert one row, writing every column the table's binding covers.

#
insert_except

fn[C, R] insert_except(t : Table[C, R], row : R, omit~ : Array[String]) -> Insert

Insert every column except the named ones.

Typically used to let the database assign an auto-increment primary key.

#
insert_many

fn[C, R] insert_many(t : Table[C, R], rows : Array[R]) -> Insert

Insert several rows in a single statement.

Emitting one statement rather than N keeps the parameters in a single ordered list, which is what a driver needs to bind them.

#
max

fn[T : SqlDecode + SqlOrd] max(c : Column[T]) -> Reducer[T?]

The largest value, or nothing when there are no rows.

#
min

fn[T : SqlDecode + SqlOrd] min(c : Column[T]) -> Reducer[T?]

The smallest value, or nothing when there are no rows.

#
sel

fn[T : SqlDecode] sel(c : Column[T]) -> Selection[T]

Read one column, decoded as T.

#
sel2

fn[A : SqlDecode, B : SqlDecode] sel2(x : Column[A], y : Column[B]) -> Selection[(A, B)]

Read two columns as a pair.

#
sel3

fn[A : SqlDecode, B : SqlDecode, C : SqlDecode] sel3(x : Column[A], y : Column[B], z : Column[C]) -> Selection[(A, B, C)]

Read three columns as a triple.

#
split2

fn[A, B, R] split2(f : (A, B) -> R) -> (((A, B)) -> R)

Take a two-table join apart into its two column sets.

|> Query::filter(split2((u, p) => u.age.gte(18) & p.title.ne("draft")))

Deliberately stops at two. With three tables the arguments start needing positional _ placeholders, at which point naming the shape with Query::map_cols reads better than destructuring it.

#
sum

fn[T : SqlDecode + SqlNum] sum(c : Column[T]) -> Reducer[T?]

The total, or nothing when there are no rows.

#
transaction

async fn[D : Driver + Executor, A] transaction(tx : Tx[D], body : async (Tx[D]) -> A) -> A

Run body inside a transaction, committing on success and rolling back on any failure.

Written as a combinator rather than exposed as begin/commit/rollback so that an early raise in the middle of body cannot leave a transaction open. MoonBit's error effect does the work a transaction monad would do elsewhere: body is an ordinary function that may raise.

Calls nest. Only the outermost scope sends BEGIN and COMMIT; an inner one just runs its body, so A.save() and B.save() land in one transaction when something wrapped them in another. What an inner scope cannot do is fail by itself: with no savepoint there is no way to undo only its writes, so its failure marks the whole transaction rollback-only and the outermost scope raises RollbackOnly rather than commit half the work.

#
update

fn[C, R] update(t : Table[C, R], pred : (C) -> Expr[Bool]) -> Update[C]

Update the rows matching pred.

The predicate is a required argument rather than a chained step, so an UPDATE that touches the whole table cannot be written by forgetting one.

#
update_all

fn[C, R] update_all(t : Table[C, R]) -> Update[C]

Update every row in the table.

Named separately from update so that an unfiltered write is visible at the call site instead of being an omission.