StatifierDatamodel.Conformance (StatifierDatamodel v0.7.0)

Copy Markdown View Source

ADR-0004 decision 7: whether a value conforms to a type expression.

conforms/3 is given the declarations, a type expression and a value - a payload a host decoded from JSON or built in Elixir - and answers :ok, or {:error, mismatches} naming every place the value does not conform. It checks against a declared record, a declared shape or an inline shape alike: a payload is checked against what its event declares it carries.

StatifierDatamodel.Types.satisfies/3 decides a type against a type, and StatifierDatamodel.Coverage.missing/3 decides which required fields of a shape a map leaves unfilled. This module is the third question beside them, a value against a type, and neither of the other two changes any answer it gives.

A mismatch: a path and a reason

Each mismatch is {path, reason}. path is a list of segments, a string for a field name and an integer for a list index; the value itself is at []. reason is one of two:

  • :missing - a required field is absent or nil, which is StatifierDatamodel.Coverage.missing/3's fill rule;
  • {:expected, type} - the value at path does not conform to type, the type expression it failed.

Mismatches come in the type's field order, depth first, and a list's items in index order.

What each type accepts

TypeA value conforms when it is
:stringa binary
:integeran integer
:decimalan integer, a float, a Decimal struct, or a string
:booleana boolean
:datean ISO 8601 date string, or a Date
:datetimean ISO 8601 date-time string carrying an offset, or a DateTime
:durationan ISO 8601 duration string, an expression-language duration string, or a Duration
:objecta map
:lista proper list, whose items conform to the field's item_type when it is known
a declared record or shape, or an inline shapea map whose fields conform, below
:unknown, {:opaque, _}, a declared name the declarations do not hold, or nilanything

A Decimal struct is recognised by its module name; the package takes no dependency on it. The string spellings of :decimal and :duration are ADR-0001 decision 4's, accepted beside the ones a decoded JSON value spells. Decision 4 says a decimal "is a string" and states no grammar for it, so any string conforms. An expression-language duration string is one or more <digits>(.<digits>)?<unit> pairs, the unit one of y, mo, w, d, h, m, s and ms, with no whitespace and no sign, and a fraction that comes to a whole number of milliseconds (30s, 1h30m, 1.5s): the rule the expression language's own duration parse applies to a whole string, followed here without depending on it.

A NaiveDateTime carries no offset and does not conform to :datetime, and neither does a date-time string without one. A field whose type, or a list field whose item_type, named nothing the document declares is read as unknown, as an undeclared name is everywhere in this package.

A record, a shape or an inline shape

The value must be a map, read with the strings the declaration spells, as StatifierDatamodel.Coverage.missing/3 reads one: a key spelled as an atom is not the field's key. Then each field, in the type's order:

  • absent or nil - a :missing mismatch when the field is required, and nothing when it is optional;
  • present - checked against the field's type, and a list field's items against its item_type, with the field's name, and an item's index, added to the path.

A key the type does not name is ignored, because every object is open, and a field's one_of is a hint and is not checked. A record and a shape are checked the same way: the difference between them is what the read check decides, not what a value carries.

Every answer is a fact. Nothing raises, nothing is enforced at run time, and whether a mismatch stops a host's pipeline is the host's to decide (ADR-0001 decision 12).

Summary

Types

One place a value does not conform to its type.

Where a mismatch is: a field name or a list index per segment, [] the value itself.

Why the value at a path does not conform.

Functions

Whether value conforms to type: :ok, or every mismatch in order.

Types

mismatch()

@type mismatch() :: {path(), reason()}

One place a value does not conform to its type.

path()

@type path() :: [String.t() | non_neg_integer()]

Where a mismatch is: a field name or a list index per segment, [] the value itself.

reason()

@type reason() :: :missing | {:expected, StatifierDatamodel.Types.t()}

Why the value at a path does not conform.

Functions

conforms(declarations, type, value)

@spec conforms(
  StatifierDatamodel.Declarations.t(),
  StatifierDatamodel.Types.t(),
  term()
) ::
  :ok | {:error, [mismatch(), ...]}

Whether value conforms to type: :ok, or every mismatch in order.

The library loan. A renewal is a record whose holds are a list of another record:

iex> alias StatifierDatamodel.{Conformance, Declarations}
iex> declarations = Declarations.from_document(%{"types" => [
...>   %{"name" => "loans.renewal", "kind" => "record", "label" => "Renewal", "fields" => [
...>     %{"name" => "copy_id", "type" => "string", "required?" => true},
...>     %{"name" => "due_on", "type" => "date", "required?" => true},
...>     %{"name" => "renewals", "type" => "integer", "required?" => true},
...>     %{"name" => "holds", "type" => "list", "item_type" => "loans.hold"}]},
...>   %{"name" => "loans.hold", "kind" => "record", "label" => "Hold", "fields" => [
...>     %{"name" => "patron_id", "type" => "string", "required?" => true}]}]})
iex> Conformance.conforms(declarations, {:declared, "loans.renewal"},
...>   %{"copy_id" => "c-0412", "due_on" => "2026-11-02", "renewals" => 1})
:ok
iex> Conformance.conforms(declarations, {:declared, "loans.renewal"},
...>   %{"copy_id" => "c-0412", "due_on" => ~D[2026-11-02], "renewals" => 1})
:ok
iex> Conformance.conforms(declarations, {:declared, "loans.renewal"},
...>   %{"copy_id" => "c-0412", "due_on" => "next week", "renewals" => nil,
...>     "holds" => [%{"patron_id" => "p-77"}, %{}], "shelf" => "B3"})
{:error, [{["due_on"], {:expected, :date}},
          {["renewals"], :missing},
          {["holds", 1, "patron_id"], :missing}]}

An inline shape is checked the same way, and a scalar on its own is the value at []:

iex> alias StatifierDatamodel.Conformance
iex> Conformance.conforms(%{}, {:shape, [%{name: "patron_id", type: :string, required?: true}]},
...>   %{"patron_id" => 77})
{:error, [{["patron_id"], {:expected, :string}}]}
iex> Conformance.conforms(%{}, :duration, "1h30m")
:ok
iex> Conformance.conforms(%{}, :datetime, ~N[2026-11-02 10:00:00])
{:error, [{[], {:expected, :datetime}}]}
iex> Conformance.conforms(%{}, :unknown, {:anything, "at all"})
:ok