moonmodel

    One model description for MoonBit, projected to JSON Schema, OpenAPI, SQL and GraphQL.

    schema
    model
    mapping
    serde
    pydantic
    moonbit
    Download zip
    Version
    0.1.1
    License
    Apache-2.0
    Last updated
    16 hours ago
    Downloads
    447

    #moonmodel

    One field description; JSON Schema, OpenAPI, SQL and GraphQL SDL are its projections.

    MoonBit already turns a struct into JSON and back — derive(ToJson), derive(FromJson), @json's JsonPath. That is not what this is for.

    The same concept is currently written four times over: moonapi's schema fields, moonorm's model columns, moongql's SDL fields, moonschema's documents. Annotate the struct once instead:

    #model.derive(doc="somebody with an account")
    pub struct User {
    #model.field(unique=true)
    id : Int64
    #model.field(longest="64")
    name : String
    age : Int32?
    home : Address
    }

    #model.derive
    pub struct Address {
    city : String
    }

    User::describe() is then the description, and every projection takes it:

    // JSON Schema 2020-12, an OpenAPI Schema Object, CREATE TABLE, type User { … }
    println(@json.of(User::describe()).stringify(indent=2))
    println(@open.of(User::describe(), dialect=OpenApi30).stringify(indent=2))
    println(@sql.table(User::describe(), dialect=Postgres))
    println(@sdl.document([User::describe(), Address::describe()][:]))

    #Annotations

    cmd/derive reads the annotations at build time. In moon.mod:

    rule(name: "model", command: "moonx moonbitstack/moonmodel/cmd/derive@0.1.1 -C \"$mod_dir\" \"$input\" -o \"$output\"")

    and in the moon.pkg of the package holding the structs:

    dev_build(rule: "model", input: "models.mbt", output: "models.g.mbt") formatter(ignore: [ "models.g.mbt" ]) import { "moonbitstack/moonmodel" }

    AnnotationGoes onMeans
    #model.derivea structgenerate T::describe(); name= and doc= set the model's name and text
    #model.derivean enuma field of this type is text limited to its constructors
    #model.fielda fieldeverything below
    #model.skipa fieldleave it out

    #model.field keyMeans
    name, docthe field's name in the model, and its text
    kindthe type, when the field's own says nothing: bool, int, big, real, text, bytes, stamp, any
    modelthe type is the model of this name
    required, nullableoverride what the type says
    defaulta default, read by the field's type, so "0" on a String is text
    least, most, over, under, stepnumeric bounds, as strings, since an annotation has no numbers
    shortest, longestthe length of text or a list
    pattern, formata text field's shape
    optionone allowed value, repeated
    distinct, uniquea list's items differ; no two records share the value

    A field with no annotation is named after itself and required. T? may be left out and may be null. A field with a default may be left out. Array[T] is a list, Map[String, T] a dictionary, an annotated struct a Ref, and @pkg.T a Ref to the model named T. A type that says nothing is refused at build time, with the file and line.

    #By hand

    The annotations generate calls to this, and a model built at run time is written the same way:

    let user = @moonmodel.Model::new(
    "User",
    [
    @moonmodel.Field::new("id", Big, check=@moonmodel.Check::new(unique=true)),
    @moonmodel.Field::new("name", Text, check=@moonmodel.Check::new(longest=64)),
    @moonmodel.Field::new("age", Int, required=false, nullable=true),
    @moonmodel.Field::new("home", Ref("Address")),
    ],
    doc="somebody with an account",
    )

    moon run src/examples/annotations holds an annotated model to the same model written by hand.

    moon run src/examples/tour runs all four on one description that uses every part of the vocabulary.

    #Install

    moon add moonbitstack/moonmodel

    In moon.pkg, import the root package and whichever projections you need:

    import { "moonbitstack/moonmodel", "moonbitstack/moonmodel/json", }

    #Packages

    PackageWritesCosts you
    moonmodelthe description itselfnothing — no dependencies
    moonmodel/jsonJSON Schema 2020-12the root package
    moonmodel/openan OpenAPI Schema Object, three dialectsthe root package
    moonmodel/sqlcolumns and CREATE TABLE, three dialectsthe root package
    moonmodel/sdlGraphQL SDLthe root package

    A program that wants OpenAPI does not link the SQL writer.

    #The description

    A Field carries a name, a Type, two separate flags, documentation, a default and a Check.

    TypeMeans
    Bool Int Big Real Text Bytes Stampthe scalars; Big is 64-bit, Stamp an instant
    List(Type) Dict(Type)a sequence, and a mapping with open keys
    Ref(String)another model, by name, so descriptions refer to each other without nesting
    Nest(Model)a model written where it stands, for the one with no name of its own
    Anyanything at all
    Raw(Json)a schema written out in full, for what this vocabulary cannot say

    required and nullable are two questions. The first asks whether the key has to be there, the second whether its value may be null. JSON Schema keeps them apart, SQL's NOT NULL is the second alone, and GraphQL's ! is both at once — folding them into one would leave at least one projection guessing.

    Check holds the bounds, under names that mean the same thing everywhere:

    FieldMeans
    least mostclosed bounds
    over underopen bounds — JSON Schema's exclusiveMinimum and exclusiveMaximum
    stepmultipleOf
    shortest longesta string's characters or a list's items
    pattern formata regular expression, and a named shape such as email
    optionsthe values allowed, if the set is closed — any Json, not only text
    distinctone list's items must differ
    uniqueno two records carry the same value — only SQL can state this

    min and max are avoided on purpose: they mean different things in the four targets.

    #What each projection cannot carry

    Every projection reports what it dropped rather than inventing a shape for it or failing. sql.of and sdl.of return the fields they skipped along with the reason; the JSON Schema and OpenAPI writers drop nothing but unique.

    Raw is the escape hatch, and it is the last thing to reach for: nothing reads inside it, so a projection can only pass it through or report that it cannot.

    Cannot carry
    JSON Schemaunique, which is about the collection and not the value
    OpenAPI 2.0nullable has no keyword — written as x-nullable, which is what its tooling reads
    OpenAPI 2.0 / 3.0an open bound is a boolean beside the closed one, not a number
    SQLDict, Nest and Ref need their own table; List is a column only in Postgres; Any and Raw have no type; pattern is spelled three different ways
    GraphQLDict, Nest, Any and Raw have no type; no bound of any kind reaches the schema

    GraphQL has no date and no byte string, so both are custom scalars — DateTime and Base64 by default, and document declares them once. A 64-bit integer is not GraphQL's Int, which the specification fixes at 32 bits, so it is carried as a String.

    #Configuration priority

    Every parameter has a public preset, so changing one is a parameter away: @sdl.stamp, @sdl.bytes, @json.defs, @moonmodel.check.

    #Where a description comes from

    Written by hand today. mctl generating one from a struct definition comes next, and moonast — annotations at the declaration, read at build time — after that. The description has to accept all three, so it is plain data with no builder hidden behind it.

    #Licence

    Apache-2.0. See LICENSE.

    Check

    pub(all) struct Check {
    least : Double?
    most : Double?
    over : Double?
    under : Double?
    step : Double?
    shortest : Int?
    longest : Int?
    pattern : String?
    format : String?
    options : Array[Json]
    distinct : Bool
    unique : Bool
    } derive(Eq,
    Debug
    )

    The bounds a value must satisfy.

    The names avoid min and max, which mean different things in the four targets: least and most are the closed bounds, under and over the open ones (JSON Schema's exclusiveMinimum and exclusiveMaximum). shortest and longest bound a string's characters and a list's items alike, which is how every one of the four spells it too.

    Check::equal

    fn Check::equal(Check, Check) -> Bool

    Check::is_empty

    fn Check::is_empty(self : Check) -> Bool

    Whether anything is bounded at all, which is how a projection decides to write nothing rather than an empty object.

    Check::new

    fn Check::new(least? : Double, most? : Double, over? : Double, under? : Double, step? : Double, shortest? : Int, longest? : Int, pattern? : String, format? : String, options? : Array[Json], distinct? : Bool, unique? : Bool) -> Check

    A Check by name, so a caller states the one bound it means.

    Check::not_equal

    fn Check::not_equal(x : Check, y : Check) -> Bool

    Check::to_repr

    Field

    pub(all) struct Field {
    name : String
    kind : Type
    required : Bool
    nullable : Bool
    doc : String
    default : Json?
    check : Check
    } derive(Eq,
    Debug
    )

    One field of a model.

    required and nullable are two questions, not one. The first asks whether the key has to be there, the second whether its value may be null. JSON Schema keeps them apart (required is a list on the object, null is a type), SQL's NOT NULL is the second alone, and GraphQL's ! is both at once. Folding them into one field would leave at least one projection guessing.

    Field::equal

    fn Field::equal(Field, Field) -> Bool

    Field::new

    fn Field::new(name : String, kind : Type, required? : Bool, nullable? : Bool, doc? : String, default? : Json, check? : Check) -> Field

    A field. Required by default, because a type whose fields are all optional describes almost nothing, and because every one of the four targets treats "present" as the ordinary case.

    Field::not_equal

    fn Field::not_equal(x : Field, y : Field) -> Bool

    Field::to_repr

    Model

    pub(all) struct Model {
    name : String
    doc : String
    fields : Array[Field]
    } derive(Eq,
    Debug
    )

    A named type and its fields.

    Model::equal

    fn Model::equal(Model, Model) -> Bool

    Model::field

    fn Model::field(self : Model, name : String) -> Field?

    One field by name, for a projection that needs to look one up.

    Model::mentions

    fn Model::mentions(self : Model) -> Array[String]

    Every model named by a Ref anywhere in this one, in the order met and without repeats.

    A projection that writes a document rather than a fragment needs this: it has to emit the models this one points at, and the ones those point at.

    Model::new

    fn Model::new(name : String, fields : Array[Field], doc? : String) -> Model

    Model::not_equal

    fn Model::not_equal(x : Model, y : Model) -> Bool

    Model::required

    fn Model::required(self : Model) -> Array[String]

    The names of the fields that must be present, in declaration order — JSON Schema's required array, and the test every projection applies.

    Model::to_repr

    Type

    pub(all) enum Type {
    Bool
    Int
    Big
    Real
    Text
    Bytes
    Stamp
    List(Type)
    Dict(Type)
    Ref(String)
    Nest(Model)
    Any
    Raw(Json)
    } derive(Eq,
    Debug
    )

    What a value is, in the smallest set all four projections recognise.

    Ref names another model rather than nesting it, so two models may point at each other and a projection decides for itself whether to inline or to emit a reference — $ref in JSON Schema, a foreign key in SQL, a named type in SDL.

    Type::equal

    fn Type::equal(Type, Type) -> Bool

    Type::not_equal

    fn Type::not_equal(x : Type, y : Type) -> Bool

    Type::to_repr

    check

    let check : Check

    No bound at all, which is what most fields carry.

    Source Files

    Powered by MoonBit

    Site sourceReport issueModulesSkillsBuild queueStatistics

    © 2026 mooncakes.io