Formata.Schema.Native (formata v0.1.0)

Copy Markdown View Source

Native schemas: Formata.Backplane.Schema definitions used through Formata.

Creating schemas

A native schema is plain Elixir data in the Formata.Backplane.Schema vocabulary:

definition = %{
  name: {:required, {:string, min: 1}},
  age: {:integer, gte: 0},
  role: {:enum, ["admin", "member"]},
  tags: {:list, :string},
  address: %{city: {:required, :string}}
}

{:ok, schema} = Formata.Schema.native(definition)

Atom schema keys match atom or string data keys, so the definition above validates string-keyed documents decoded by Formata.

Reusable definitions

Because definitions are data, they can be bound to variables and composed (merge/2, select/2, except/2 delegate to Formata.Backplane.Schema). Named, reusable and recursive definitions use Formata.Backplane.Schema.defschema/3 and {:ref, {Module, name}}:

defmodule MyApp.Schemas do
  import Formata.Backplane.Schema

  defschema :address, %{city: {:required, :string}}
  defschema :person, %{
    name: {:required, :string},
    address: {:ref, :address},
    friends: {:list, {:ref, :person}}
  }
end

{:ok, schema} = Formata.Schema.native({:ref, {MyApp.Schemas, :person}})

Pure validation (Formata.validate/3)

Formata.Backplane.Schema.validate/3 both validates and transforms (defaults, coercion, transforms, and strict-mode stripping/renaming of keys). Formata's pure validation therefore runs a preflight over the definition, including every reachable {:ref, ...} target, and supports this non-transforming subset:

  • primitive types, {:literal, v}, {:enum, ...}, {:required, ...}, {:meta, ...}
  • string/integer/float constraints (min, max, regex, eq, neq, gt, gte, lt, lte, range, multiple_of)
  • nested maps, keyword schemas, {:list, ...}, {:map, ...}, {:tuple, ...}, {:schema, ...} (including :additional_keys)
  • {:either, ...}, {:oneof, ...} (first matching branch, native semantics), {:multi, ...}, {:cond, ...}, {:dependent, field, check, type}
  • {:custom, ...} validators that return :ok or {:error, ...}
  • {:ref, {Module, name}}, including recursive references

These constructs are rejected before anything runs, with an :unsupported / :pure_validation_unsupported error naming the construct and its schema path: {type, {:default, _}}, {type, {:transform, _}}, {type, {:encode, _}}, every {:coerce, ...} form, and the dynamic {:dependent, callback} forms (their schemas are only known at run time). Use Formata.cast/3 for those.

Custom validators run inside the native engine. If one returns anything other than :ok or an error during pure validation (for example {:ok, new_value}), validation fails with :unsupported / :non_pure_custom instead of accepting a modified value.

The input is never modified, and validate returns only :ok or errors.

Absent versus nil

The native engine treats an explicit nil like an absent field: an optional field accepts both, and {:required, type} rejects both.

Unknown fields

Optionvalidate/3cast/3
unknown: :allowdefault; ignoredkept in the result
unknown: :reject:unknown_field error per field:unknown_field error per field
unknown: :stripnot accepteddefault; removed from the result

Unknown-field rejection is enforced by the native engine (Formata adds an :additional_keys rule to each map schema). It is not available for keyword list schemas.

Casting (Formata.cast/3)

cast runs the full native engine (Formata.Backplane.Schema.validate/3) and returns the transformed data. Modifiers wrap types, and each field is processed from the outermost modifier inwards:

  1. {type, {:default, value}} replaces an absent or nil value with value; the default is then processed by type like any input;
  2. {:coerce, ...} converts the source representation (for example "42" to 42) and passes values that already have the target type;
  3. the type, its constraints, {:custom, ...} validators and nested schemas are checked on the (possibly defaulted/coerced) value;
  4. {type, {:transform, fun}} maps the validated value. Transforms only run for present, non-nil values, so write {{type, {:transform, fun}}, {:default, value}} to transform a default.

A default can never satisfy {:required, ...}: Formata.Backplane.Schema rejects that combination when the schema is compiled.

Unknown fields are then handled by the :unknown option. With :strip (the default) the result contains only declared fields, keyed by the schema's keys (an atom-keyed schema returns atom keys). With :allow, the result keeps the input keys: a declared field is written back under the key present in the data (an atom schema key matching a string data key keeps the string key), undeclared fields are kept unchanged, and a field added by a default uses the schema's key.

Coercion to :atom! is rejected (:unsafe_atom_coercion) because it would create atoms from input data.

Summary

Functions

compile(definition, opts \\ [])

@spec compile(term(), keyword()) ::
  {:ok, Formata.Schema.t()} | {:error, [Formata.Error.t(), ...]}

Checks and compiles a native definition.

Invalid definitions return :schema / :invalid_schema errors. A definition that is valid but outside the pure-validation subset still compiles; the limitation is reported when Formata.validate/3 is called.

except(schema, keys)

See Formata.Backplane.Schema.except/2.

merge(schema1, schema2)

See Formata.Backplane.Schema.merge/2.

select(schema, keys)

See Formata.Backplane.Schema.select/2.