Normandy.Type behaviour
(normandy v0.6.0)
View Source
Defines functions and the Normandy.Type behaviour for implementing
basic custom types.
Normandy provides two types of custom types: basic types and parameterized types. Basic types are simple, requiring only four callbacks to be implemented, and are enough for most occasions. Parameterized types can be customized on the field definition and provide a wide variety of callbacks.
The definition of basic custom types and all of their callbacks are
available in this module. You can learn more about parameterized
types in Normandy.ParameterizedType. If in doubt, prefer to use
basic custom types and rely on parameterized types if you need
the extra functionality.
External vs internal vs database representation
The core functionality of a custom type is the mapping between external, internal and database representations of a value belonging to the type.
External data comes from user input (forms, APIs, etc.), internal data is the application's runtime representation, and database data is how values are persisted or serialized.
stateDiagram-v2
external: External Data
internal: Internal Data
database: Database Data
external --> internal: cast/1
external --> database: dump/1
internal --> database: dump/1
database --> internal: load/1Example
Imagine you want to store a URI struct as part of a schema. A custom type is needed to handle the conversion between string input and the URI struct at runtime.
The custom type needs to handle the conversion from external data to
runtime data (cast/1) as well as transforming that runtime data
into a serializable format and back (dump/1 and c:load/1).
defmodule URIType do
use Normandy.Type
def type, do: :map
# Provide custom casting rules.
# Cast strings into the URI struct to be used at runtime
def cast(uri) when is_binary(uri) do
{:ok, URI.parse(uri)}
end
# Accept casting of URI structs as well
def cast(%URI{} = uri), do: {:ok, uri}
# Everything else is a failure though
def cast(_), do: :error
# When loading data, as long as it's a map,
# we just put the data back into a URI struct.
def load(data) when is_map(data) do
data =
for {key, val} <- data do
{String.to_existing_atom(key), val}
end
{:ok, struct!(URI, data)}
end
# When dumping data, we *expect* a URI struct
# but any value could be inserted into the schema struct at runtime,
# so we need to guard against them.
def dump(%URI{} = uri), do: {:ok, Map.from_struct(uri)}
def dump(_), do: :error
endNow we can use our new field type in our schemas:
defmodule UrlSchema do
use Normandy.Schema
schema do
field :original_url, URIType
end
endNote: nil values are always bypassed and cannot be handled by
custom types.
use Normandy.Type
When you use Normandy.Type, it will set @behaviour Normandy.Type and define
default, overridable implementations for embed_as/1 and equal?/2.
Custom types with validation
Custom types can also include validation logic. For example, an encoded ID type that handles base64 encoding:
defmodule EncodedId do
use Normandy.Type
def type, do: :string
def cast(id) when is_integer(id) do
{:ok, encode_id(id)}
end
def cast(id) when is_binary(id) do
case Base.decode64(id) do
{:ok, _} -> {:ok, id}
:error -> :error
end
end
def cast(_), do: :error
def dump(id) when is_binary(id) do
case Base.decode64(id) do
{:ok, decoded} -> {:ok, String.to_integer(decoded)}
:error -> :error
end
end
def load(id) when is_integer(id) do
{:ok, encode_id(id)}
end
defp encode_id(id) do
id
|> Integer.to_string()
|> Base.encode64()
end
endNow you can use this custom type in your schemas:
defmodule ContentSchema do
use Normandy.Schema
schema do
field :id, EncodedId
field :content, :string
end
end
Summary
Types
Custom types are represented by user-defined modules.
Primitive Ecto types (handled by Ecto).
An Ecto type, primitive or custom.
Callbacks
Generates a loaded version of the data.
Casts the given input to the custom type.
Dumps the given term into an Ecto native type.
Dictates how the type should be treated inside embeds.
Checks if two terms are semantically equal.
Returns the underlying schema type for the custom type.
Functions
Checks if the given atom can be used as base type.
Casts a value to the given type.
Casts a value to the given type or raises an error.
Checks if the given atom can be used as composite type.
Dumps a value to the given type.
Gets how the type is treated inside embeds for the given format.
Dumps the value for type considering it will be embedded in format.
Loads the value for type considering it was embedded in format.
Checks if two terms are equal.
Format type for error messaging and logs.
Checks if collection includes a term.
Loads a value with the given type.
Checks if a given type matches with a primitive type that can be found in queries.
Checks if we have a primitive type.
Retrieves the underlying schema type for the given, possibly custom, type.
Types
@type base() ::
:integer
| :float
| :boolean
| :string
| :bitstring
| :map
| :binary
| :date
| :time
| :binary
| :any
| :struct
Custom types are represented by user-defined modules.
Primitive Ecto types (handled by Ecto).
An Ecto type, primitive or custom.
Callbacks
@callback autogenerate() :: term()
Generates a loaded version of the data.
This is callback is invoked when a custom type is given
to field with the :autogenerate flag.
Casts the given input to the custom type.
This callback is called on external input and can return any type,
as long as the dump/1 function is able to convert the returned
value into an Ecto native type. There are two situations where
this callback is called:
- When casting values by
Ecto.Changeset - When passing arguments to
Ecto.Query
You can return :error if the given term cannot be cast.
A default error message of "is invalid" will be added to the
changeset.
You may also return {:error, keyword()} to customize the
changeset error message and its metadata. Passing a :message
key, will override the default message. It is not possible to
override the :type key.
For {:array, CustomType} or {:map, CustomType} the returned
keyword list will be erased and the default error will be shown.
Dumps the given term into an Ecto native type.
This callback is called with any term that was stored in the struct and it needs to validate them and convert it to an Ecto native type.
@callback embed_as(format :: atom()) :: :self | :dump
Dictates how the type should be treated inside embeds.
By default, the type is sent as itself, without calling
dumping to keep the higher level representation. But
it can be set to :dump so that it is dumped before
being encoded.
Checks if two terms are semantically equal.
This callback is used for determining equality of types in
Ecto.Changeset.
By default the terms are compared with the equal operator ==/2.
@callback type() :: t()
Returns the underlying schema type for the custom type.
For example, if you want to provide your own date
structures, the type function should return :date.
Note this function is not required to return Ecto primitive types, the type is only required to be known by the adapter.
Functions
Checks if the given atom can be used as base type.
iex> base?(:string)
true
iex> base?(:array)
false
iex> base?(Custom)
false
Casts a value to the given type.
cast/2 is used by the finder queries and changesets to cast outside values to
specific types.
Note that nil can be cast to all primitive types as data stores allow nil to be set on any column.
NaN and infinite decimals are not supported, use custom types instead.
iex> cast(:any, "whatever")
{:ok, "whatever"}
iex> cast(:any, nil)
{:ok, nil}
iex> cast(:string, nil)
{:ok, nil}
iex> cast(:integer, 1)
{:ok, 1}
iex> cast(:integer, "1")
{:ok, 1}
iex> cast(:integer, "1.0")
:error
iex> cast(:id, 1)
{:ok, 1}
iex> cast(:id, "1")
{:ok, 1}
iex> cast(:id, "1.0")
:error
iex> cast(:float, 1.0)
{:ok, 1.0}
iex> cast(:float, 1)
{:ok, 1.0}
iex> cast(:float, "1")
{:ok, 1.0}
iex> cast(:float, "1.0")
{:ok, 1.0}
iex> cast(:float, "1-foo")
:error
iex> cast(:boolean, true)
{:ok, true}
iex> cast(:boolean, false)
{:ok, false}
iex> cast(:boolean, "1")
{:ok, true}
iex> cast(:boolean, "0")
{:ok, false}
iex> cast(:boolean, "whatever")
:error
iex> cast(:string, "beef")
{:ok, "beef"}
iex> cast(:binary, "beef")
{:ok, "beef"}
iex> cast({:array, :integer}, [1, 2, 3])
{:ok, [1, 2, 3]}
iex> cast({:array, :integer}, ["1", "2", "3"])
{:ok, [1, 2, 3]}
iex> cast({:array, :string}, [1, 2, 3])
:error
iex> cast(:string, [1, 2, 3])
:error
iex> cast(:utc_datetime, "2014-04-17T14:00:00Z")
{:ok, ~U[2014-04-17 14:00:00Z]}
iex> cast(:utc_datetime, "2014-04-17T14:00:00.030Z")
{:ok, ~U[2014-04-17 14:00:00Z]}
iex> cast(:utc_datetime, "2014-04-17T12:00:00-02:00")
{:ok, ~U[2014-04-17 14:00:00Z]}
Casts a value to the given type or raises an error.
See cast/2 for more information.
Examples
iex> Normandy.Type.cast!(:integer, "1")
1
iex> Normandy.Type.cast!(:integer, 1)
1
iex> Normandy.Type.cast!(:integer, nil)
nil
iex> Normandy.Type.cast!(:integer, 1.0)
** (Normandy.CastError) cannot cast 1.0 to :integer
Checks if the given atom can be used as composite type.
iex> composite?(:array)
true
iex> composite?(:string)
false
Dumps a value to the given type.
Opposite to casting, dumping requires the returned value to be a valid Ecto type, as it will be sent to the underlying data store.
iex> dump(:string, nil)
{:ok, nil}
iex> dump(:string, "foo")
{:ok, "foo"}
iex> dump(:integer, 1)
{:ok, 1}
iex> dump(:integer, "10")
:error
iex> dump(:binary, "foo")
{:ok, "foo"}
iex> dump(:binary, 1)
:error
iex> dump({:array, :integer}, [1, 2, 3])
{:ok, [1, 2, 3]}
iex> dump({:array, :integer}, [1, "2", 3])
:error
iex> dump({:array, :binary}, ["1", "2", "3"])
{:ok, ["1", "2", "3"]}
Gets how the type is treated inside embeds for the given format.
See embed_as/1.
Dumps the value for type considering it will be embedded in format.
Examples
iex> Normandy.Type.embedded_dump(:string, "1", :json)
{:ok, "1"}
Loads the value for type considering it was embedded in format.
Examples
iex> Normandy.Type.embedded_load(:string, "1", :json)
{:ok, "1"}
Checks if two terms are equal.
Depending on the given type performs a structural or semantical comparison.
Examples
iex> equal?(:integer, 1, 1)
true
Format type for error messaging and logs.
Checks if collection includes a term.
Depending on the given type performs a structural or semantical comparison.
Examples
iex> include?(:integer, 1, 1..3)
true
Loads a value with the given type.
iex> load(:string, nil)
{:ok, nil}
iex> load(:string, "foo")
{:ok, "foo"}
iex> load(:integer, 1)
{:ok, 1}
iex> load(:integer, "10")
:error
Checks if a given type matches with a primitive type that can be found in queries.
iex> match?(:string, :any)
true
iex> match?(:any, :string)
true
iex> match?(:string, :string)
true
iex> match?({:array, :string}, {:array, :any})
true
Checks if we have a primitive type.
iex> primitive?(:string)
true
iex> primitive?(Another)
false
iex> primitive?({:array, :string})
true
iex> primitive?({:array, Another})
true
Retrieves the underlying schema type for the given, possibly custom, type.
iex> type({:array, :string})
{:array, :string}