moonmodel

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

    model
    schema
    data-model
    validation
    validator
    serialization
    serde
    json
    json-schema
    openapi
    sql
    ddl
    graphql
    sdl
    derive
    annotations
    codegen
    binary
    byte-order
    endianness
    fixed-width
    layout
    pydantic
    mapping
    moonbit
    Download zip
    Version
    0.1.3
    License
    Apache-2.0
    Last updated
    14 hours ago
    Downloads
    628

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

    A service describes the same field four times over: once to check a request, once to document it, once to store it, once to serve it. Describe it 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.2 -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; or a width — int8, int16, uint16, uint64, float32 — with -be after it for a big-endian number and -ref for one written by reference
    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, and so is a kind= word the vocabulary does not have.

    #Without annotations

    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",
    )

    A third way — the type carrying its own description — is what the annotation generates: User::describe() beside the struct. There is nothing else to implement, because a description is data and the method is the whole of it.

    moon run src/examples/annotations holds an annotated model to the same model written by hand, and moon run src/examples/tour runs all four projections and a check on one description that uses every part of the vocabulary.

    moon run src/cmd/sheet prints one description as a single JSON document — every field, four times over, with what each projection could not carry — which is what the project's front page is typeset from.

    #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 and the JSON writer
    moonmodel/sqlcolumns and CREATE TABLE, three dialectsthe root package
    moonmodel/sdlGraphQL SDLthe root package
    moonmodel/verifya verdict on an instancethe JSON writer and a validator

    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
    Wire(Layout)a value with the exact layout a byte format writes: a width, a sign, a byte order and a reference mark
    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.

    #Values a byte format writes

    A document says what a value is. A format that writes bytes also fixes how wide it is, whether it carries a sign, which byte comes first, and whether it is written where it stands or as an offset to somewhere else. Layout carries those four, so one description serves a codec as well as a schema:

    @moonmodel.Field::new(
    "count",
    Wire(@moonmodel.Layout::new(Int, bits=16, unsigned=true)),
    )

    PartMeans
    kindthe value's own type: Bool, Int, Big, Real, Text, Bytes, Stamp
    bitsthe width a format fixes — 8, 16, 32 or 64 — or absent when the format's own table decides
    unsignedthere is no sign bit
    orderOrder::Little or Order::Big: which byte of a number comes first
    referencethe value is an offset into the buffer rather than written in place

    A layout also has a word, and the word reads back: uint16, int32-be, text-ref. Type::word writes it and Type::of_word reads it, which is the same vocabulary #model.field(kind=…) takes — so an annotation and a description state a layout in exactly one way.

    The width is the one part a document can state, so JSON Schema and OpenAPI write it as format — int8, uint16, float32 — beside the type. SQL narrows the column where the dialect has a narrower type, widens it where the sign does not fit, and SQLite keeps its one integer type. GraphQL's Int is signed and 32 bits, so a wider or unsigned number is carried as a String, the way a 64-bit integer already is. A byte order and a reference mark are left to the format that writes the bytes: no document has a keyword for them.

    #Checking an instance

    The description is what an instance is checked against. It is written out as JSON Schema and read by a validator that knows the specification, so the schema a reader sees and the schema a request is held to are the same one:

    fn conforms(user : Json) -> Bool {
    let checker = @verify.schema(User::describe(), known=[Address::describe()]) catch {
    _ => return false
    }
    checker.valid(user)
    }

    CallAnswers
    schema(model, known=…)the model as a Schema, with every model it refers to
    valid(model, instance, known=…)whether the instance conforms, reading the description on the way
    faults(model, instance, known=…)every reason it does not, each with at — where in the instance — and the keyword that refused it

    schema reads the description once, and the Schema it gives answers for as many instances as there are. A Ref to a model the call did not hand over is refused by name: a validator asked about a $ref that points nowhere can only answer no to everything, which would blame the instance.

    #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, which is about the collection rather than the value.

    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; a layout's byte order and reference mark
    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; a byte order and a reference mark are not a column
    GraphQLDict, Nest, Any and Raw have no type; no bound of any kind reaches the schema; a 64-bit or unsigned-wide number becomes a String

    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

    A description is plain data with no builder hidden behind it, so it comes from wherever it is easiest to keep right: the annotations at the declaration, Model::new at run time, or a generator that writes those calls out. The three meet at Model, and nothing downstream can tell which one a value came from.

    #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

    Layout

    pub(all) struct Layout {
    kind : Type
    bits : Int?
    unsigned : Bool
    order : Order
    reference : Bool
    } derive(Eq,
    Debug
    )

    A value as a byte format lays it out.

    A document says what a value is. A format that writes bytes also fixes how wide it is, whether it carries a sign, which byte comes first, and whether it is written where it stands or as an offset to somewhere else. The four belong to the type rather than to the value: every value of the type is laid out the same way, and a reader has to know before it looks at one.

    A projection that writes a document reads kind and says nothing about the rest, because a document has no width and no offset. The width alone is still worth writing as a format, which is how the JSON family already distinguishes int32 from int64.

    Layout::equal

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

    Layout::format

    fn Layout::format(self : Layout) -> String?

    The format this layout asks for: int8, uint16, float32.

    None when the layout fixes no width of its own, or when the kind is not a number — a width on anything else is not a width, and writing one would make a reader think the format had said something it did not.

    Layout::new

    fn Layout::new(kind : Type, bits? : Int, unsigned? : Bool, order? : Order, reference? : Bool) -> Layout

    A layout: the kind, and how a format writes it.

    Every part but the kind has the common answer as its default, so a format whose numbers are little-endian and written in place says only the width.

    Layout::not_equal

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

    Layout::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

    Order

    pub(all) enum Order {
    Little
    Big
    } derive(Eq,
    Debug
    )

    Which end of a number comes first.

    Order::equal

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

    Order::not_equal

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

    Order::to_repr

    Type

    pub(all) enum Type {
    Bool
    Int
    Big
    Real
    Text
    Bytes
    Stamp
    List(Type)
    Dict(Type)
    Ref(String)
    Nest(Model)
    Wire(Layout)
    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::of_word

    fn Type::of_word(word : String) -> Type?

    The type a word stands for, when it is one of the words word writes.

    Read the suffixes from the right first: uint16-be-ref is a uint16 in big-endian order, written by reference. A word fixes what the kind and the width agree on, so a layout that contradicts its own kind — an Int at 64 bits — is written at the width it states and read back as the kind that width belongs to.

    Type::to_repr

    Type::word

    fn Type::word(self : Type) -> String?

    The word a scalar type is written with: what #model.field(kind=…) takes, and what a report calls it.

    None for a type with parts inside — a list, a mapping, a model, a schema written out — because one word cannot carry what is in it. A layout's word adds -be for a big-endian number and -ref for one written by reference; both are read back by of_word, so a word and a type round-trip.

    check

    let check : Check

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

    Source Files