Sign in

    moonmodel

    moonmodel — one field description, many projections (← Pydantic / serde / DAO): JSON Schema, OpenAPI, SQL columns and GraphQL SDL from the same declaration.

    schema
    model
    mapping
    serde
    pydantic
    moonbit
    Download zip
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    2 days ago
    Downloads
    111

    #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. Write it once instead:

    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),
    @moonmodel.Field::new("home", Ref("Address")),
    ],
    doc="somebody with an account",
    )

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

    moon run 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