///|
/// A foreign key declared on a column, e.g.
/// `#aya.column(references="accounts.id", on_delete="cascade")`.
pub struct RefSpec {
/// Referenced table name (the database name, not the MoonBit type).
table : String
/// Referenced column name.
column : String
/// Referential action, already normalised to upper case, e.g. `CASCADE`.
on_delete : String?
on_update : String?
} derive(Debug, Eq)
///|
/// One column of an entity, in the order it was declared.
///
/// Declaration order is load-bearing: it fixes both the projection order and
/// the positional decoder, which is exactly the pairing a hand-written
/// `Selection` can get wrong.
pub struct FieldSpec {
/// MoonBit field name, e.g. `deleted_at`.
name : String
/// MoonBit type rendered back to source, e.g. `String?`.
ty : String
/// Database column name. Defaults to `name`.
column : String
is_primary : Bool
/// `true` when the declared MoonBit type is an `Option`.
///
/// Nullability is read off the type rather than off an attribute: the type
/// is what the decoder already believes, so letting an attribute contradict
/// it would let the schema and the row type disagree silently.
nullable : Bool
/// SQL type text from `#aya.column(type="...")`.
///
/// `None` means "infer from the MoonBit type", which only works for the
/// types `SqlEncode` covers. A wrapper type has to say what it stores.
sql_type : String?
/// Raw SQL expression from `#aya.column(default="...")`, emitted verbatim
/// after `DEFAULT`.
default_sql : String?
unique : Bool
autoincrement : Bool
references : RefSpec?
} derive(Debug, Eq)
///|
/// A secondary index declared on the entity with `#aya.index(...)`.
pub struct IndexSpec {
name : String
/// Database column names, in index order.
columns : Array[String]
unique : Bool
} derive(Debug, Eq)
///|
/// A struct carrying `#aya.table`, lowered into everything the emitter needs.
///
/// This describes the *row type*: the flat shape of one database row. A domain
/// type is a separate thing that the programmer maps to and from.
pub struct EntitySpec {
/// MoonBit type name, e.g. `User`.
type_name : String
/// Table name in the database.
table : String
/// Table alias used when emitting SQL.
tbl : String
/// Name of the generated column-handle struct.
cols_name : String
fields : Array[FieldSpec]
indexes : Array[IndexSpec]
} derive(Debug, Eq)
///|
pub(all) suberror EntityError {
/// A `#aya.table` was found on something that is not a record struct.
NotARecord(type_name~ : String)
/// `#aya.table` needs a table name and no default is safe to invent.
MissingTable(type_name~ : String)
/// More than one field claimed `#aya.id`.
DuplicatePrimaryKey(type_name~ : String)
/// An attribute argument was present but not a value this generator accepts.
InvalidArg(type_name~ : String, key~ : String, value~ : String)
/// `#aya.index` named a field the struct does not have.
UnknownIndexColumn(type_name~ : String, column~ : String)
/// `#aya.index` without any column to index.
EmptyIndex(type_name~ : String)
/// An argument name the MoonBit toolchain cannot round-trip.
ReservedArg(type_name~ : String, key~ : String, use_instead~ : String)
} derive(Debug)
///|
/// Collect every `#aya.table` declaration in a parsed file.
///
/// Declarations without the attribute are skipped, so an entity file may hold
/// ordinary MoonBit code alongside entities.
pub fn collect_entities(
impls : @list.List[@syntax.Impl],
) -> Array[EntitySpec] raise Error {
let specs = []
for im in impls {
if im is TopTypeDef(decl) {
if entity_attr(decl.attrs) is Some(attr) {
specs.push(lower_entity(decl, attr))
}
}
}
specs
}
///|
/// Find the `#aya.table` attribute among a declaration's attributes.
fn entity_attr(
attrs : @list.List[@attribute.Attribute],
) -> Attr? raise AttrError {
for a in attrs {
if parse_attr(a.raw) is Some(parsed) && parsed.name == "table" {
return Some(parsed)
}
}
None
}
///|
/// Lower one annotated struct into an `EntitySpec`.
fn lower_entity(decl : @syntax.TypeDecl, attr : Attr) -> EntitySpec raise Error {
let type_name = decl.tycon
guard decl.components is Record(fields~) else { raise NotARecord(type_name~) }
let table = match attr.arg("name") {
Some(t) => t
None => raise MissingTable(type_name~)
}
// The alias is what shows up in `FROM "users" AS u`, so a short default
// beats a generated one that changes whenever the type is renamed.
let tbl = attr.arg("alias").unwrap_or(default_alias(table))
// Derived by suffix rather than by stripping anything from the type name:
// guessing that `OrderRow` "means" `Order` would be magic. Override with
// `cols="..."` when the default reads badly.
let cols_name = attr.arg("cols").unwrap_or(type_name + "Cols")
let specs = []
let mut seen_primary = false
for f in fields {
let name = f.name.label
let mut column = name
let mut is_primary = false
let mut sql_type = None
let mut default_sql = None
let mut unique = false
let mut autoincrement = false
let mut references = None
for a in f.attrs {
if parse_attr(a.raw) is Some(parsed) {
match parsed.name {
"id" => {
if seen_primary {
raise DuplicatePrimaryKey(type_name~)
}
seen_primary = true
is_primary = true
}
"column" => {
if parsed.arg("name") is Some(n) {
column = n
}
// Not `type=`: `moon fmt` rewrites `#aya.column(type="...")`
// into `##aya.column(...)`, which then fails to lex. Rejecting it
// here costs one error message; accepting it costs a file that
// stops compiling the next time someone formats it.
if parsed.arg("type") is Some(_) {
raise ReservedArg(type_name~, key="type", use_instead="sql_type")
}
sql_type = parsed.arg("sql_type")
default_sql = parsed.arg("default")
unique = attr_bool(parsed, "unique", type_name)
autoincrement = attr_bool(parsed, "autoincrement", type_name)
references = lower_ref(parsed, type_name)
}
// Unknown names are ignored on purpose so that adding an attribute
// does not break older generators.
_ => ()
}
}
}
specs.push({
name,
ty: render_type(f.ty),
column,
is_primary,
nullable: f.ty is Option(_),
sql_type,
default_sql,
unique,
autoincrement,
references,
})
}
{
type_name,
table,
tbl,
cols_name,
fields: specs,
indexes: lower_indexes(decl.attrs, type_name, table, specs),
}
}
///|
/// Read an attribute argument that must be `"true"` or `"false"`.
///
/// A typo like `unique="yes"` is rejected rather than read as `false`: a
/// silently dropped constraint is exactly the kind of drift a migration tool
/// exists to prevent.
fn attr_bool(
attr : Attr,
key : String,
type_name : String,
) -> Bool raise EntityError {
match attr.arg(key) {
None => false
Some(v) =>
if v == "true" {
true
} else if v == "false" {
false
} else {
raise InvalidArg(type_name~, key~, value=v)
}
}
}
///|
/// Lower `references="accounts.id"` plus its optional referential actions.
fn lower_ref(attr : Attr, type_name : String) -> RefSpec? raise EntityError {
guard attr.arg("references") is Some(target) else { return None }
guard target.split_once(".") is Some((table, column)) else {
raise InvalidArg(type_name~, key="references", value=target)
}
guard !table.is_empty() && !column.is_empty() else {
raise InvalidArg(type_name~, key="references", value=target)
}
Some({
table: table.to_owned(),
column: column.to_owned(),
on_delete: ref_action(attr, "on_delete", type_name),
on_update: ref_action(attr, "on_update", type_name),
})
}
///|
/// Normalise a referential action, rejecting anything SQL does not define.
fn ref_action(
attr : Attr,
key : String,
type_name : String,
) -> String? raise EntityError {
guard attr.arg(key) is Some(raw) else { return None }
let action = raw.trim().to_upper().to_owned()
let known = ["CASCADE", "RESTRICT", "SET NULL", "SET DEFAULT", "NO ACTION"]
if known.contains(action) {
Some(action)
} else {
raise InvalidArg(type_name~, key~, value=raw)
}
}
///|
/// Lower every `#aya.index(...)` on the declaration.
///
/// Columns are named by MoonBit field, not by database column, so renaming a
/// column with `#aya.column(name=...)` does not silently break its index.
fn lower_indexes(
attrs : @list.List[@attribute.Attribute],
type_name : String,
table : String,
fields : Array[FieldSpec],
) -> Array[IndexSpec] raise Error {
let indexes = []
for a in attrs {
guard parse_attr(a.raw) is Some(parsed) && parsed.name == "index" else {
continue
}
let columns = []
for part in parsed.arg("columns").unwrap_or("").split(",") {
let field = part.trim().to_owned()
if field.is_empty() {
continue
}
match fields.search_by(f => f.name == field) {
Some(i) => columns.push(fields[i].column)
None => raise UnknownIndexColumn(type_name~, column=field)
}
}
if columns.is_empty() {
raise EmptyIndex(type_name~)
}
indexes.push({
name: parsed.arg("name").unwrap_or(default_index_name(table, columns)),
columns,
unique: attr_bool(parsed, "unique", type_name),
})
}
indexes
}
///|
/// Derive an index name from what it indexes: `idx_users_email`.
fn default_index_name(table : String, columns : Array[String]) -> String {
"idx_" + table + "_" + columns.join("_")
}
///|
/// Derive a table alias from a table name: `users` -> `u`.
fn default_alias(table : String) -> String {
if table.is_empty() {
"t"
} else {
table[:1].to_owned()
}
}
///|
/// Render a parsed type back to MoonBit source text.
///
/// The emitter needs the type twice, once inside `Column[_]` and once as the
/// entity field type, so the round trip has to be faithful for the types aya
/// can actually store.
pub fn render_type(t : @syntax.Type) -> String {
match t {
Name(constr_id~, tys~, ..) => {
let base = match constr_id.id {
Ident(name~) => name
Dot(pkg~, id~) => "@\{pkg}.\{id}"
}
if tys.is_empty() {
base
} else {
base + "[" + tys.map(render_type).to_array().join(", ") + "]"
}
}
Option(ty~, ..) => render_type(ty) + "?"
Tuple(tys~, ..) => "(" + tys.map(render_type).to_array().join(", ") + ")"
// Arrows, object types and `_` cannot be stored in a column, so leaving
// them unrendered surfaces as a compile error in the generated file rather
// than as a silently wrong column type.
_ => "_"
}
}
///|
pub impl Show for EntityError with fn output(self, logger) {
logger.write_string(
match self {
NotARecord(type_name~) =>
"`\{type_name}`: #aya.table only goes on a record struct"
MissingTable(type_name~) =>
"`\{type_name}`: #aya.table needs `name=\"...\"`"
DuplicatePrimaryKey(type_name~) =>
"`\{type_name}`: more than one field is marked #aya.id"
InvalidArg(type_name~, key~, value~) =>
"`\{type_name}`: `\{key}=\"\{value}\"` is not a value aya accepts"
UnknownIndexColumn(type_name~, column~) =>
"`\{type_name}`: #aya.index names `\{column}`, which is not a field"
EmptyIndex(type_name~) => "`\{type_name}`: #aya.index has no columns"
ReservedArg(type_name~, key~, use_instead~) =>
"`\{type_name}`: `\{key}=` cannot be used in an attribute, because `moon fmt` rewrites it into something that will not lex; write `\{use_instead}=` instead"
},
)
}