///|
/// The `@moonorm.ColumnType` for a field type, or `None` when the type has no
/// single-column storage class (`Array`, `Map`, a nested `type`, `Float`, unsigned
/// ints — none map onto one `@moondb.Value`). Only a type in this set gets a
/// declared column, a `from_row` read, and a `to_columns` bind, so a model is only
/// generated when every field is storable.
fn storage_class(type_ : String) -> String? {
match type_ {
"Int" => Some("IntType")
"Int64" => Some("IntType")
"String" => Some("TextType")
"Double" => Some("RealType")
"Bool" => Some("BoolType")
"Bytes" => Some("BlobType")
_ => None
}
}
///|
/// The typed `@moondb.Row` accessor that reads column `idx` as `type_`. The
/// accessors raise `@moondb.DbError` on a type or index mismatch, so a decode bug
/// surfaces at the call site rather than reading a silent zero.
fn row_read(type_ : String, idx : Int) -> String {
let i = idx.to_string()
match type_ {
"Int" => "row.int(" + i + ")"
"Int64" => "row.int64(" + i + ")"
"String" => "row.text(" + i + ")"
"Double" => "row.double(" + i + ")"
"Bool" => "row.bool(" + i + ")"
"Bytes" => "row.blob(" + i + ")"
_ => "row.get(" + i + ")"
}
}
///|
/// The `@moondb.Value` constructor that boxes `expr` (a `r.field` access) for a
/// column of `type_`, e.g. `Int64` → `@moondb.Int64(r.id)`.
fn value_ctor(type_ : String, expr : String) -> String {
match type_ {
"Int" => "@moondb.Int(" + expr + ")"
"Int64" => "@moondb.Int64(" + expr + ")"
"String" => "@moondb.Text(" + expr + ")"
"Double" => "@moondb.Double(" + expr + ")"
"Bool" => "@moondb.Bool(" + expr + ")"
"Bytes" => "@moondb.Blob(" + expr + ")"
_ => "@moondb.Null"
}
}
///|
/// Whether every field of a `type` block has a storage class, so a full
/// `@moonorm.Model` (with `from_row` / `to_columns`) can be generated for it.
fn all_storable(fields : Array[Field]) -> Bool {
for f in fields {
if storage_class(f.type_) is None {
return false
}
}
true
}
///|
/// Generate a moonorm data layer for every `type` block in `spec`. A block whose
/// fields are all storable yields: the model `struct`; a
/// `_model : @moonorm.Model[T]` built with `@moonorm.Model::new` — declared
/// columns (an `id` column is the primary key), a `from_row` decoder, and the
/// `to_columns` projection an INSERT binds; a `_table : @moonorm.Table`
/// descriptor; and a `_up` / `_down` migration pair (create the table
/// idempotently / drop it). This is the explicit MoonBit stand-in for a SQLAlchemy
/// declarative class plus an Alembic revision, which reflection would otherwise
/// synthesise. A block with a non-storable field (a slice, map, or nested message)
/// still gets its struct and a plain `@moonorm.Table` descriptor.
///
/// The output compiles against `Lfan-ke/moonorm` + `Lfan-ke/moondb`; a consuming
/// package imports both.
pub fn generate_model(spec : Spec) -> String {
let mut out = "// Code generated by moonctl. DO NOT EDIT.\n\n"
for t in spec.types {
out = out +
"///|\n/// `" +
t.name +
"` model (generated from the `.api` `type` block).\npub(all) struct " +
t.name +
" {\n"
for f in t.fields {
out = out + " " + f.name + " : " + f.type_ + "\n"
}
out = out + "}\n\n"
let table = to_snake(t.name)
if all_storable(t.fields) {
out = out + emit_model(t, table) + emit_migration(table)
} else {
let cols : Array[String] = []
for f in t.fields {
cols.push(quote(to_snake(f.name)))
}
out = out +
"///|\n/// Table metadata for `" +
t.name +
"` (a non-storable field — slice, map, or nested message — means no row\n/// mapper, so only the descriptor is generated).\npub let " +
table +
"_table : @moonorm.Table = {\n name: " +
quote(table) +
",\n columns: [" +
commas(cols) +
"],\n}\n\n"
}
}
out
}
///|
/// Emit the `@moonorm.Model[T]` value and the `@moonorm.Table` descriptor for a
/// fully storable `type` block.
fn emit_model(t : TypeDef, table : String) -> String {
let col_lines : Array[String] = []
for f in t.fields {
let cname = to_snake(f.name)
let cls = match storage_class(f.type_) {
Some(c) => c
None => "TextType"
}
let pk = if cname == "id" { ", primary_key=true" } else { "" }
col_lines.push(
" @moonorm.column(" + quote(cname) + ", @moonorm." + cls + pk + "),",
)
}
let from_fields : Array[String] = []
for i = 0; i < t.fields.length(); i = i + 1 {
let f = t.fields[i]
from_fields.push(" " + f.name + ": " + row_read(f.type_, i) + ",")
}
let to_pairs : Array[String] = []
for f in t.fields {
to_pairs.push(
" (" +
quote(to_snake(f.name)) +
", " +
value_ctor(f.type_, "r." + f.name) +
"),",
)
}
"///|\n/// Declarative model for `" +
t.name +
"`: declared columns, a `@moondb.Row` decoder, and the column projection an\n/// INSERT binds. The explicit stand-in for a SQLAlchemy declarative class.\npub let " +
table +
"_model : @moonorm.Model[" +
t.name +
"] = @moonorm.Model::new(\n " +
quote(table) +
",\n [\n" +
join_lines(col_lines) +
"\n ],\n row => {\n" +
join_lines(from_fields) +
"\n },\n r => [\n" +
join_lines(to_pairs) +
"\n ],\n)\n\n" +
"///|\n/// Table metadata for `" +
t.name +
"` (name + column list), for the plain builder API.\npub let " +
table +
"_table : @moonorm.Table = " +
table +
"_model.table_descriptor()\n\n"
}
///|
/// Emit the `_up` / `_down` migration pair for a storable model:
/// create the table idempotently, drop it. This is the explicit equivalent of an
/// Alembic `upgrade()` / `downgrade()` revision.
fn emit_migration(table : String) -> String {
"///|\n/// Migration up: create the `" +
table +
"` table (idempotent).\npub fn " +
table +
"_up(sess : @moonorm.Session) -> @moondb.ExecResult raise @moondb.DbError {\n sess.create_table(" +
table +
"_model, if_not_exists=true)\n}\n\n" +
"///|\n/// Migration down: drop the `" +
table +
"` table.\npub fn " +
table +
"_down(sess : @moonorm.Session) -> @moondb.ExecResult raise @moondb.DbError {\n sess.execute(" +
quote("DROP TABLE IF EXISTS " + table) +
", [])\n}\n\n"
}
///|
/// Join lines with `\n` (no trailing newline) — the codegen counterpart of
/// `commas` for multi-line blocks.
fn join_lines(parts : Array[String]) -> String {
let mut out = ""
for i = 0; i < parts.length(); i = i + 1 {
if i > 0 {
out = out + "\n"
}
out = out + parts[i]
}
out
}