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:
| Change | Verdict | Break |
|---|---|---|
| a field removed | breaking | {:field_removed, name} |
a field's type changed | breaking | {:type_changed, name} |
| a field optional -> required | breaking | {:made_required, name} |
| a required field added | breaking | {:required_added, name} |
a field's one_of value group added | breaking | {:group_added, name} |
and three ways it does not:
| Change | Verdict |
|---|---|
| an optional field added | compatible |
| a field required -> optional | compatible |
a field's one_of value group removed | compatible |
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
Functions
Every way new narrows old, ordered by field name then by reason.
Types
Functions
@spec breaks( StatifierDatamodel.Declarations.declaration() | nil, StatifierDatamodel.Declarations.declaration() | nil ) :: [break()] | :error
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