StatifierDatamodel.Compatibility (StatifierDatamodel v0.1.0)

Copy Markdown View Source

ADR-0001 decision 9: what a redefined declaration takes away from the one it replaces.

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 declaration might not hold under the new. The list is empty when the redefinition takes nothing away, and a name declared on neither side is an :error rather than an empty list: nothing was redefined, so there is nothing to answer.

The eight rows

The record's table, kept literally. Five ways a redefinition narrows:

ChangeVerdictBreak
a field removedbreaking{:field_removed, name}
a field's type changedbreaking{:type_changed, name}
a field optional -> requiredbreaking{:made_required, name}
a required field addedbreaking{:required_added, name}
a field's one_of value group addedbreaking{:group_added, name}

and three ways it does not:

ChangeVerdict
an optional field addedcompatible
a field required -> optionalcompatible
a field's one_of value group removedcompatible

The asymmetry is the whole point: this answers may a reader keep reading, not did anything change. Widening a declaration - more fields to have, fewer to supply, more values admitted - takes nothing away from a read written against the old one.

A value group compares by value, with order ignored

A field's one_of is compared as a set, so reordering one is no change. A group is added when the new declaration constrains where the old one did not, and also when the new group admits fewer values than the old: a value a document could carry before and cannot now is the same loss of a read either way, which is why the record calls shrinking a group breaking on the same reasoning as a type change. A group removed, or widened, admits everything it did before and is compatible.

What is not a break

A field's item_type is not compared, for the same reason the read check does not descend into it: list satisfies list, and narrowing that is a decision no record has taken. A changed label, a changed note and a changed kind are not among the record's five either - label and note carry no contract, and a name that changes kind is a redefinition this vocabulary has no word for. Nothing here raises: an input that is not a declaration is read as not declared on that side.

Deterministic order

Breaks are ordered by field name, then by reason in the order the record lists them, so two runs over the same pair produce the same list and a caller may compare two lists directly. One field can carry several breaks: a field that changed type and became required at once names both.

Summary

Types

One way a redefinition narrows what it replaces, naming the field it happened to.

Functions

Every way new narrows old, ordered by field name then by reason.

Types

break()

@type break() ::
  {:field_removed, String.t()}
  | {:type_changed, String.t()}
  | {:required_added, String.t()}
  | {:made_required, String.t()}
  | {:group_added, String.t()}

One way a redefinition narrows what it replaces, naming the field it happened to.

Functions

breaks(old, new)

Every way new narrows old, ordered by field name then by reason.

nil on a side means the name is not declared there: a declaration that went away removes all of its fields, and one that appeared adds all of its required ones. A name declared on neither side is :error.

iex> alias StatifierDatamodel.{Compatibility, Declarations}
iex> was = Declarations.from_document(%{"types" => [
...>   %{"name" => "cards.card", "kind" => "record", "label" => "Card", "fields" => [
...>     %{"name" => "brand", "type" => "string", "required?" => true},
...>     %{"name" => "last4", "type" => "string", "required?" => true}]}]})
iex> now = Declarations.from_document(%{"types" => [
...>   %{"name" => "cards.card", "kind" => "record", "label" => "Card", "fields" => [
...>     %{"name" => "brand", "type" => "string", "required?" => true},
...>     %{"name" => "expires_on", "type" => "date", "required?" => true}]}]})
iex> {:ok, old} = Declarations.fetch(was, "cards.card")
iex> {:ok, new} = Declarations.fetch(now, "cards.card")
iex> Compatibility.breaks(old, new)
[{:required_added, "expires_on"}, {:field_removed, "last4"}]

A redefinition that only widens takes nothing away:

iex> alias StatifierDatamodel.{Compatibility, Declarations}
iex> was = Declarations.from_document(%{"types" => [
...>   %{"name" => "cards.card", "kind" => "record", "fields" => [
...>     %{"name" => "brand", "type" => "string", "required?" => true}]}]})
iex> now = Declarations.from_document(%{"types" => [
...>   %{"name" => "cards.card", "kind" => "record", "fields" => [
...>     %{"name" => "brand", "type" => "string"},
...>     %{"name" => "last4", "type" => "string"}]}]})
iex> {:ok, old} = Declarations.fetch(was, "cards.card")
iex> {:ok, new} = Declarations.fetch(now, "cards.card")
iex> Compatibility.breaks(old, new)
[]

iex> StatifierDatamodel.Compatibility.breaks(nil, nil)
:error