The datamodel document and what can be decided from it, with no dependency on the block editor, the compiler, or the UI.
A datamodel document is a host's typed description of the data universe an
author writes conditions against. It has three scopes - global, local,
event - each carrying entries with a name, an absolute dotted path, a
type and a label; and a types key of named record and shape
declarations, each with ordered fields carrying name, type and
required?. This package is the reader of that document, and the home of
every question that can be answered from the document alone.
The contract is ADR-0001; the document shape and the index are re-homed here from statifier_blocks' ADR-0006, and ADR-0001 is the record of the re-homing.
Installation
def deps do
[
{:statifier_datamodel, "~> 0.4"}
]
endWhat it answers
Every example below is a doctest: test/readme_test.exs runs this file, so a
snippet that stops being true fails the suite. The worked domain is
credit-card processing, as it is everywhere in this family.
The index
StatifierDatamodel.Index.index/1 admits a decoded document and returns the
path/type index over it. Every entry contributes its own path at every
nesting depth, in document order, and nothing else does. Admission is a total
normalizer: a map carrying a list under "scopes" is a document, and
anything else is nil - so not a document stays distinguishable from a
document declaring nothing.
iex> alias StatifierDatamodel.Index
iex> document = %{
...> "version" => 1,
...> "scopes" => [
...> %{"scope" => "local", "label" => "Chart-local", "entries" => [
...> %{"name" => "amount_cents", "path" => "amount_cents",
...> "type" => "integer", "label" => "Amount (minor units)"},
...> %{"name" => "card", "path" => "card", "type" => "object",
...> "label" => "Card", "fields" => [
...> %{"name" => "brand", "path" => "card.brand", "type" => "string",
...> "label" => "Brand",
...> "one_of" => ["visa", "mastercard", "amex"]},
...> %{"name" => "expires_on", "path" => "card.expires_on",
...> "type" => "date", "label" => "Expires on"}]}]}]}
iex> index = Index.index(document)
iex> index.order
["amount_cents", "card", "card.brand", "card.expires_on"]
iex> Index.type(index, "card.expires_on")
:date
iex> Index.declared?(index, "card.cvv")
false
iex> Index.index(["amount_cents"])
nilThe declared paths
StatifierDatamodel.Document is the shorthand for a consumer that holds a
document rather than an index: it admits its argument through the index and
answers totally either way. declared_paths/1 is the projection an editor's
undeclared-path advisory reads; an empty document projects to an empty set,
and only a non-document is nil.
iex> alias StatifierDatamodel.Document
iex> document = %{"scopes" => [%{"scope" => "local", "entries" => [
...> %{"name" => "card", "path" => "card", "type" => "object", "fields" => [
...> %{"name" => "last4", "path" => "card.last4", "type" => "string"},
...> %{"name" => "brand", "path" => "card.brand", "type" => "string",
...> "one_of" => ["visa", "mastercard", "amex"]}]}]}]}
iex> Document.declared_paths(document)
MapSet.new(["card", "card.brand", "card.last4"])
iex> Document.candidates_under(document, "card")
["card.last4", "card.brand"]
iex> Document.declared_values(document)
%{"card.brand" => ["visa", "mastercard", "amex"]}
iex> Document.declared_paths(%{"scopes" => []})
MapSet.new([])
iex> Document.declared_paths(["card"])
nilThe declared types and the read check
A record is a fact about what a write puts at a path; a shape is a
constraint a read places on one. StatifierDatamodel.Declarations indexes
the document's types key to name -> declaration, and
StatifierDatamodel.Types decides the read check over it: unknown is
permissive both ways, then identity, then a record read as a shape is
admitted when the record's fields cover the shape's required set. Covering a
required shape field takes a record field of the same name that the record
also declares required: an optional field has not promised the value, so it
does not cover. Identity is nominal - there is no structural widening between
two records. satisfies/3 returns the reason a consumer renders;
satisfies?/3 is the same check as a boolean.
iex> alias StatifierDatamodel.{Declarations, Types}
iex> declarations = Declarations.from_document(%{"types" => [
...> %{"name" => "cards.credit_txn", "kind" => "record",
...> "label" => "Credit transaction", "fields" => [
...> %{"name" => "amount_cents", "type" => "integer", "required?" => true},
...> %{"name" => "currency", "type" => "string", "required?" => true},
...> %{"name" => "authorized_at", "type" => "datetime", "required?" => true}]},
...> %{"name" => "Settleable", "kind" => "shape", "label" => "Settleable",
...> "fields" => [
...> %{"name" => "amount_cents", "type" => "integer", "required?" => true},
...> %{"name" => "currency", "type" => "string", "required?" => true},
...> %{"name" => "authorized_at", "type" => "datetime", "required?" => true}]},
...> %{"name" => "Refundable", "kind" => "shape", "label" => "Refundable",
...> "fields" => [
...> %{"name" => "amount_cents", "type" => "integer", "required?" => true},
...> %{"name" => "settled_at", "type" => "datetime", "required?" => true}]}]})
iex> Types.satisfies(declarations, {:declared, "cards.credit_txn"}, {:declared, "Settleable"})
:covers
iex> Types.satisfies(declarations, {:declared, "cards.credit_txn"}, {:declared, "Refundable"})
{:missing, ["settled_at"]}
iex> Types.satisfies?(declarations, {:declared, "cards.credit_txn"}, {:declared, "Refundable"})
false
iex> Types.satisfies(declarations, :date, :date)
:identicalDrop the required? on the record's authorized_at and the same read stops
being satisfied, naming that field the way an absent one is named. The fix in
the document is one key: mark the field required where the record does
promise the value.
iex> alias StatifierDatamodel.{Declarations, Types}
iex> loose = Declarations.from_document(%{"types" => [
...> %{"name" => "cards.credit_txn", "kind" => "record",
...> "label" => "Credit transaction", "fields" => [
...> %{"name" => "amount_cents", "type" => "integer", "required?" => true},
...> %{"name" => "currency", "type" => "string", "required?" => true},
...> %{"name" => "authorized_at", "type" => "datetime"}]},
...> %{"name" => "Settleable", "kind" => "shape", "label" => "Settleable",
...> "fields" => [
...> %{"name" => "amount_cents", "type" => "integer", "required?" => true},
...> %{"name" => "currency", "type" => "string", "required?" => true},
...> %{"name" => "authorized_at", "type" => "datetime", "required?" => true}]}]})
iex> Types.satisfies(loose, {:declared, "cards.credit_txn"}, {:declared, "Settleable"})
{:missing, ["authorized_at"]}An inline, unnamed shape
A consumer holding a value the host never declared - a fan-out's collected
envelope, a block's computed summary - says what it holds with
{:shape, members}, where each member carries name, type and
required?. It is read the same way a declaration is: a record covers it
member-wise, it covers a declared shape, and two inline shapes compare
structurally. It never satisfies a declared record, whose identity is
nominal, and a declared shape held covers nothing but itself. Identity for
the arm is member-set-wise, so member order is a rendering decision and not
a difference. There is no document spelling for one: parse/2 reads only
strings, and an inline shape enters as an argument to the read check.
iex> alias StatifierDatamodel.{Declarations, Types}
iex> declarations = Declarations.from_document(%{"types" => [
...> %{"name" => "ChunkSummary", "kind" => "shape", "label" => "Chunk summary",
...> "fields" => [
...> %{"name" => "authorized_count", "type" => "integer", "required?" => true},
...> %{"name" => "declined_count", "type" => "integer", "required?" => true}]}]})
iex> summary = {:shape, [
...> %{name: "authorized_count", type: :integer, required?: true},
...> %{name: "declined_count", type: :integer, required?: true}]}
iex> envelope = {:shape, [
...> %{name: "index", type: :integer, required?: true},
...> %{name: "status", type: :string, required?: true},
...> %{name: "donedata", type: summary, required?: false}]}
iex> Types.satisfies(declarations, summary, {:declared, "ChunkSummary"})
:covers
iex> Types.satisfies(declarations, envelope, {:declared, "ChunkSummary"})
{:missing, ["authorized_count", "declined_count"]}
iex> Types.to_string(summary)
"{authorized_count: integer, declined_count: integer}"The envelope's own members are index, status and donedata: the summary
is one member down, and this package widens nothing to find it.
An entry typed by a declaration
An entry's type, and a list entry's item_type, may name a declaration
instead of one of the nine types. The reference is nominal - the index
carries {:declared, name}, not a copy of the fields - and the entry then
contributes the declaration's fields beneath its own path, exactly as an
inlined object entry contributes its fields. A name the types key does
not declare stays unknown, as it always has.
iex> alias StatifierDatamodel.Index
iex> index = Index.index(%{"scopes" => [
...> %{"scope" => "local", "entries" => [
...> %{"name" => "txn", "path" => "txn", "type" => "cards.credit_txn",
...> "label" => "Transaction"},
...> %{"name" => "note", "path" => "note", "type" => "cards.nothing"}]}],
...> "types" => [
...> %{"name" => "cards.credit_txn", "kind" => "record",
...> "label" => "Credit transaction", "fields" => [
...> %{"name" => "amount_cents", "type" => "integer", "required?" => true},
...> %{"name" => "card", "type" => "cards.card", "required?" => true}]},
...> %{"name" => "cards.card", "kind" => "record", "label" => "Card",
...> "fields" => [
...> %{"name" => "brand", "type" => "string",
...> "one_of" => ["visa", "mastercard", "amex"]}]}]})
iex> index.order
["txn", "txn.amount_cents", "txn.card", "txn.card.brand", "note"]
iex> Index.type(index, "txn.card")
{:declared, "cards.card"}
iex> Index.type(index, "note")
nil
iex> Index.path_types(index)
%{
"txn.amount_cents" => :number,
"txn.card.brand" => {:one_of, ["visa", "mastercard", "amex"]}
}txn and txn.card are absent from the value kinds for the same reason an
object entry is: neither is a value the expression language has a kind
for. A cycle between declarations discharges rather than recurring, so
index/1 stays total over any document a host can write.
Compatibility of a redefined declaration
StatifierDatamodel.Compatibility.breaks/2 is given the declaration a name
had and the declaration replacing it, and lists every way the new one narrows
the old one - every reason a read that held under the old might not hold
under the new - ordered by field name. The list is empty when the
redefinition takes nothing away; a name declared on neither side is :error,
never an empty list.
iex> alias StatifierDatamodel.{Compatibility, Declarations}
iex> was = Declarations.from_document(%{"types" => [
...> %{"name" => "cards.credit_txn", "kind" => "record",
...> "label" => "Credit transaction", "fields" => [
...> %{"name" => "amount_cents", "type" => "integer", "required?" => true},
...> %{"name" => "currency", "type" => "string", "required?" => true},
...> %{"name" => "authorized_at", "type" => "datetime", "required?" => true}]}]})
iex> now = Declarations.from_document(%{"types" => [
...> %{"name" => "cards.credit_txn", "kind" => "record",
...> "label" => "Credit transaction", "fields" => [
...> %{"name" => "amount_cents", "type" => "integer", "required?" => true},
...> %{"name" => "currency", "type" => "string", "required?" => false}]}]})
iex> {:ok, old} = Declarations.fetch(was, "cards.credit_txn")
iex> {:ok, new} = Declarations.fetch(now, "cards.credit_txn")
iex> Compatibility.breaks(old, new)
[{:field_removed, "authorized_at"}, {:made_optional, "currency"}]
iex> Compatibility.breaks(old, old)
[]
iex> Compatibility.breaks(nil, nil)
:errorEach break names its kind first and the field second. The kind is the row of
the record's table the break came from - :field_removed, :type_changed,
:made_required, :required_added or :made_optional, one per breaking row
and no others - so a host words a warning by matching on it rather than by
comparing the two declarations again.
Both changes break: dropping authorized_at takes the field away, and
relaxing currency takes away the promise that the value is there, which
the read check has read as not covered since decision 8 was amended. What
does not break is a widening - an optional field added - and a one_of,
which is a completion hint and not a constraint however it is edited.
iex> alias StatifierDatamodel.{Compatibility, Declarations}
iex> hinted = fn one_of -> Declarations.from_document(%{"types" => [
...> %{"name" => "cards.card", "kind" => "record", "label" => "Card",
...> "fields" => [
...> %{"name" => "brand", "type" => "string", "required?" => true,
...> "one_of" => one_of}]}]}) end
iex> {:ok, old} = Declarations.fetch(hinted.(["visa", "mastercard", "amex"]), "cards.card")
iex> {:ok, new} = Declarations.fetch(hinted.(["visa"]), "cards.card")
iex> Compatibility.breaks(old, new)
[]This answers may a reader keep reading, not did anything change.
Coverage of a map against a shape
StatifierDatamodel.Coverage.missing/3 returns the name of every required
field of a shape that a map does not fill, in declaration order. "Fill" is
presence of the key with a non-nil value; the value's type is the
expression language's question, not this one's. A name that is not declared,
or that is a record rather than a shape, is :error.
iex> alias StatifierDatamodel.{Coverage, Declarations}
iex> declarations = Declarations.from_document(%{"types" => [
...> %{"name" => "Settleable", "kind" => "shape", "label" => "Settleable",
...> "fields" => [
...> %{"name" => "amount_cents", "type" => "integer", "required?" => true},
...> %{"name" => "currency", "type" => "string", "required?" => true},
...> %{"name" => "authorized_at", "type" => "datetime", "required?" => true}]},
...> %{"name" => "cards.card", "kind" => "record", "label" => "Card",
...> "fields" => [
...> %{"name" => "last4", "type" => "string", "required?" => true}]}]})
iex> Coverage.missing(declarations, "Settleable", %{"amount_cents" => 4200, "currency" => "USD"})
{:ok, ["authorized_at"]}
iex> Coverage.missing(declarations, "Settleable", %{"currency" => nil})
{:ok, ["amount_cents", "currency", "authorized_at"]}
iex> Coverage.missing(declarations, "cards.card", %{})
:errorValue kinds for an expression editor
StatifierDatamodel.Index.path_types/1 projects the index to
%{path => kind | {:list, kind} | {:one_of, values}}, in the expression
language's own vocabulary: integer and decimal are both :number, a
list with a scalar item_type is {:list, kind}, and a drawable one_of
wins over the kind. An object, a list with no usable item_type and an
unknown type are absent from the map - absence means unknown, not wrong,
and an editor handed the map treats a path it does not contain exactly as it
treats every path today.
iex> alias StatifierDatamodel.Index
iex> %{"scopes" => [%{"scope" => "local", "entries" => [
...> %{"path" => "amount_cents", "type" => "integer"},
...> %{"path" => "risk_reasons", "type" => "list", "item_type" => "string"},
...> %{"path" => "card", "type" => "object", "fields" => [
...> %{"path" => "card.brand", "type" => "string",
...> "one_of" => ["visa", "mastercard", "amex"]},
...> %{"path" => "card.expires_on", "type" => "date"}]}]}]}
...> |> Index.index()
...> |> Index.path_types()
%{
"amount_cents" => :number,
"risk_reasons" => {:list, :string},
"card.brand" => {:one_of, ["visa", "mastercard", "amex"]},
"card.expires_on" => :date
}What is not here
Every function is pure and total over an admitted document, and returns a fact - a set, an index, a boolean, a list of names, a map. Nothing here produces a finding, a severity or a verdict: an undeclared path is unknown, not wrong, and an unsatisfied read is something a consumer assigns a severity to in its own record.
What is deliberately elsewhere: the environment walk over a block document, which needs the block tree and stays in statifier_blocks; anything that renders, which is statifier_ui's; and any runtime enforcement, which no record in the family has asked for.
Who takes this
statifier_blocks reads the document through this package today, for the typed
environment's read check. statifier_ui takes path_types/1's map as the
expression editor's assign in a later release. Neither takes the other for
the document, and this package depends on nothing in the family - that
property is the whole reason it exists, and a runtime dependency added here
is a decision to record.
License
MIT - see LICENSE.