// `mctl model mongo` (← goctl's `model mongo`): a document model per `type`
// block, over a collection port instead of a driver.
//
// A Mongo driver is a dependency the generated code should not have to carry: what
// a repository needs from one is five calls — insert one, find one, find many,
// replace one, delete one — and every driver spells those differently. So the port
// here is `Collection.run`: an operation, a filter, a document in, a document out.
// A driver binds it in a dozen lines, and everything above it is generated and
// typed.
//
// The document itself is a `struct` with `derive(FromJson, ToJson)`, which is what
// makes a value round-trip through the store without a hand-written field walker
// per type. Its keys are the MoonBit field names — the spec's names in
// `snake_case` — so a `json:"…"` tag renames the wire form, not the stored one.
///|
/// Every type block that has a field becomes a document. A block with no field is a
/// placeholder rather than a shape, and a collection of empty documents stores
/// nothing.
fn mongo_document(t : @api.TypeDef, types : Array[@api.TypeDef]) -> String? {
if @api.flat_fields(t, types).length() == 0 {
return None
}
Some(t.name)
}
///|
/// The field a document is looked up by: the one named `id`, or — when the spec has
/// no such field — the first one that holds a single value, since a filter needs a
/// value a driver can compare, not a whole document. `None` when every field is a
/// list or a map, which leaves a repository that can only insert and list.
fn key_field(fields : Array[@api.Field]) -> @api.Field? {
for f in fields {
if f.json_name() == "id" || f.mbt_name() == "id" {
return Some(f)
}
}
for f in fields {
let t = @text.trim(f.type_)
if @text.starts_with(t, "Array[") == false &&
@text.starts_with(t, "Map[") == false {
return Some(f)
}
}
None
}
///|
/// The index of the `", "` between a `Map[K, V]`'s two halves, or `-1`.
fn map_comma(s : String) -> Int32 {
let mut depth = 0
for i = 0; i < s.length(); i = i + 1 {
let c = s[i]
if c == '[' {
depth = depth + 1
} else if c == ']' {
depth = depth - 1
} else if c == ',' && depth == 0 {
return i
}
}
-1
}
///|
/// A field's type as the document carries it: a type the spec also models becomes
/// that type's `Doc`, so a document that nests one nests a document — an `owner :
/// User` field is stored as an `owner : UserDoc`. Lists and maps are rewritten
/// element by element; anything the spec does not model is carried across as it is,
/// which leaves the typechecker to reject a spec that names a type it never
/// declared.
fn doc_type(t : String, names : Array[String]) -> String {
let s = @text.trim(t)
if s == "" {
return s
}
if @text.ends_with(s, "?") && s.length() > 1 {
return doc_type(s[0:s.length() - 1].to_owned(), names) + "?"
}
if @text.starts_with(s, "Array[") && @text.ends_with(s, "]") {
return "Array[" + doc_type(s[6:s.length() - 1].to_owned(), names) + "]"
}
if @text.starts_with(s, "Map[") && @text.ends_with(s, "]") {
let body = s[4:s.length() - 1].to_owned()
let comma = map_comma(body)
if comma >= 0 {
return "Map[" +
doc_type(body[0:comma].to_owned(), names) +
", " +
doc_type(body[comma + 1:].to_owned(), names) +
"]"
}
}
for n in names {
if n == s {
return n + "Doc"
}
}
s
}
///|
/// The field as the document declares it: its type rewritten to the document's own
/// types, and made nullable when its tag says `optional` — a document key that may
/// be absent has to hold `null`, which is what `T?` is.
fn doc_field(f : @api.Field, names : Array[String]) -> String {
let t = doc_type(f.type_, names)
if f.optional() && @text.ends_with(t, "?") == false {
t + "?"
} else {
t
}
}
///|
/// The prelude every document model shares: the error a store failure becomes, the
/// operations a repository asks for, the port a driver fills, and the two document
/// helpers the generated methods use.
fn mongo_prelude() -> String {
"// Code generated by moonctl. DO NOT EDIT.\n" +
"//\n" +
"// `mctl model mongo`: one document model and one typed repository per `.api`\n" +
"// `type` block, over the `Collection` port below. A driver binds\n" +
"// `Collection.run` to its own insert-one / find-one / find / replace-one /\n" +
"// delete-one, and every repository is typed from there.\n" +
"//\n" +
"// This file needs `moonbitlang/core/json` imported as `@json`, which is where\n" +
"// `from_json` and `JsonDecodeError` live (`Json` and `ToJson` are core itself).\n" +
"\n" +
"///|\n/// A document store failure: the key was not there, the reply could not be read\n/// back, or the driver refused the call.\npub(all) suberror MongoError {\n MongoMissing(collection~ : String, key~ : String)\n MongoBadDocument(collection~ : String, why~ : String)\n MongoFailed(collection~ : String, why~ : String)\n}\n" +
"\n" +
"///|\n/// The five calls a repository makes of a store, one name each, so a driver binds\n/// `Insert` to `insertOne`, `FindOne` to `findOne`, `Find` to `find`, `Replace` to\n/// `replaceOne` and `Delete` to `deleteOne`.\npub(all) enum MongoOp {\n Insert\n FindOne\n Find\n Replace\n Delete\n}\n" +
"\n" +
"///|\n/// A collection: its name, and the one call a driver supplies. `filter` selects the\n/// documents an operation applies to (the empty document means all of them), `doc`\n/// is what a write carries (empty for a read), and the reply is whatever the driver\n/// returned — a stored document, an array of them, or `true` for a delete.\npub(all) struct Collection {\n name : String\n run : (MongoOp, Json, Json) -> Json raise MongoError\n}\n" +
"\n" +
"///|\n/// The empty document: the filter that selects everything, and the document a read\n/// carries.\nfn empty_doc() -> Json {\n let m : Map[String, Json] = Map([])\n @json.to_json(m)\n}\n" +
"\n" +
"///|\n/// The filter matching one key against one value: `filter_of(\"id\", 7)` is\n/// `{ \"id\": 7 }`.\nfn filter_of(key : String, value : Json) -> Json {\n let m : Map[String, Json] = Map([])\n m[key] = value\n @json.to_json(m)\n}\n\n"
}
///|
/// One document type: the struct the collection stores, its two conversions, and the
/// repository over it. `fields` are already flattened, so an embedded type
/// contributes its fields rather than a second struct.
fn emit_document(
name : String,
fields : Array[@api.Field],
collection : String,
names : Array[String],
) -> String {
let doc = name + "Doc"
let repo = name + "Repo"
let mut out = "///|\n/// `" +
name +
"` as the `" +
collection +
"` collection stores it.\npub(all) struct " +
doc +
" {\n"
for f in fields {
out = out + " " + f.mbt_name() + " : " + doc_field(f, names) + "\n"
}
out = out +
"} derive(FromJson, ToJson)\n\n" +
"///|\n/// `" +
doc +
"` reads (and writes) itself as a document; the explicit `extend` is what keeps\n/// the derived method from being promoted onto the struct as well.\npub extend " +
doc +
" with @json.FromJson::{from_json}\n\n" +
"///|\npub extend " +
doc +
" with ToJson::{to_json}\n\n" +
"///|\n/// The `" +
collection +
"` collection as a typed repository.\npub(all) struct " +
repo +
" {\n coll : Collection\n}\n\n" +
"///|\n/// A repository over `coll`, the driver-bound `" +
collection +
"` collection.\npub fn " +
repo +
"::new(coll : Collection) -> " +
repo +
" {\n { coll, }\n}\n\n" +
"///|\n/// Insert `doc`.\npub fn " +
repo +
"::insert(\n self : " +
repo +
",\n doc : " +
doc +
"\n) -> Unit raise MongoError {\n ignore((self.coll.run)(Insert, empty_doc(), @json.to_json(doc)))\n}\n\n" +
"///|\n/// Every document in the collection.\npub fn " +
repo +
"::all(self : " +
repo +
") -> Array[" +
doc +
"] raise MongoError {\n let reply = (self.coll.run)(Find, empty_doc(), empty_doc())\n @json.from_json(reply) catch {\n e =>\n raise MongoBadDocument(collection=self.coll.name, why=e.to_string())\n }\n}\n\n"
match key_field(fields) {
None => ()
Some(key) => {
let label = key.mbt_name()
let field = @text.quote(key.mbt_name())
out = out +
"///|\n/// The document whose `" +
label +
"` is `" +
label +
"`, or `MongoMissing` when there is none.\npub fn " +
repo +
"::find(\n self : " +
repo +
",\n " +
label +
" : " +
doc_type(key.type_, names) +
"\n) -> " +
doc +
" raise MongoError {\n let reply = (self.coll.run)(FindOne, filter_of(" +
field +
", @json.to_json(" +
label +
")), empty_doc())\n match reply {\n Null =>\n raise MongoMissing(collection=self.coll.name, key=" +
label +
".to_string())\n _ =>\n @json.from_json(reply) catch {\n e =>\n raise MongoBadDocument(collection=self.coll.name, why=e.to_string())\n }\n }\n}\n\n" +
"///|\n/// Replace the stored document with `doc`, matched by `" +
label +
"`.\npub fn " +
repo +
"::replace(\n self : " +
repo +
",\n doc : " +
doc +
"\n) -> Unit raise MongoError {\n let key = filter_of(" +
field +
", @json.to_json(doc." +
label +
"))\n ignore((self.coll.run)(Replace, key, @json.to_json(doc)))\n}\n\n" +
"///|\n/// Delete the document whose `" +
label +
"` is `" +
label +
"`; `false` when there was none.\npub fn " +
repo +
"::delete(\n self : " +
repo +
",\n " +
label +
" : " +
doc_type(key.type_, names) +
"\n) -> Bool raise MongoError {\n let reply = (self.coll.run)(Delete, filter_of(" +
field +
", @json.to_json(" +
label +
")), empty_doc())\n @json.from_json(reply) catch {\n e =>\n raise MongoBadDocument(collection=self.coll.name, why=e.to_string())\n }\n}\n\n"
}
}
out
}
///|
/// Generate a Mongo document model for every `type` block of `spec`: a `Doc` struct
/// that is the document as stored, and a `Repo` over the `Collection` port with
/// `insert`, `all`, and — when the spec has a key field — `find`, `replace` and
/// `delete`.
///
/// The output compiles against MoonBit core: `Json` and `ToJson` are builtin, and
/// the file imports `moonbitlang/core/json` as `@json` for `from_json`. A driver
/// supplies the port; without one, nothing here needs a database to typecheck.
pub fn generate_mongo(spec : @api.Spec) -> String {
let mut out = mongo_prelude()
let names = spec.types.map(t => t.name)
let mut wrote = false
for t in spec.types {
match mongo_document(t, spec.types) {
None => ()
Some(name) => {
out = out +
emit_document(
name,
@api.flat_fields(t, spec.types),
table_name(name),
names,
)
wrote = true
}
}
}
if wrote == false {
out = out +
"// The spec declares no `type` block with a field, so there is no document\n// to model.\n"
}
out
}
///|
/// The file a document model lands in: `greet` → `greet_mongo.mbt`, named after the
/// service so two specs generated into one directory do not collide.
pub fn mongo_file_name(service : String) -> String {
(if service == "" { "model" } else { @text.to_snake(service) }) + "_mongo.mbt"
}