Strukt provides an extended defstruct
macro which builds on top of Ecto.Schema
and Ecto.Changeset
to remove the boilerplate of defining type specifications,
implementing validations, generating changesets from parameters, JSON serialization,
and support for autogenerated fields.
This builds on top of Ecto embedded schemas, so the same familiar syntax you use today to define schema'd types in Ecto, can now be used to define structs for general purpose usage.
The functionality provided by the defstruct
macro in this module is strictly a superset
of the functionality provided both by Kernel.defstruct/1
, as well as Ecto.Schema
. If
you import it in a scope where you use Kernel.defstruct/1
already, it will not interfere.
Likewise, the support for defining validation rules inline with usage of field/3
, embeds_one/3
,
etc., is strictly additive, and those additions are stripped from the AST before field/3
and friends ever see it.
def deps do
[
{:strukt, "~> 0.3"}
]
end
The following is an example of using defstruct/1
to define a struct with types, autogenerated
primary key, and inline validation rules.
defmodule Person do
use Strukt
@derives [Jason.Encoder]
@primary_key {:uuid, Ecto.UUID, autogenerate: true}
@timestamps_opts [autogenerate: {NaiveDateTime, :utc_now, []}]
defstruct do
field :name, :string, required: true
field :email, :string, format: ~r/^.+@.+$/
timestamps()
end
end
And an example of how you would create and use this struct:
# Creating from params, with autogeneration of fields
iex> {:ok, person} = Person.new(name: "Paul", email: "bitwalker@example.com")
...> person
%Person{
uuid: "d420aa8a-9294-4977-8b00-bacf3789c702",
name: "Paul",
email: "bitwalker@example.com",
inserted_at: ~N[2021-06-08 22:21:23.490554],
updated_at: ~N[2021-06-08 22:21:23.490554]
}
# Validation (Create)
iex> {:error, %Ecto.Changeset{valid?: false, errors: errors}} = Person.new(email: "bitwalker@example.com")
...> errors
[name: {"can't be blank", [validation: :required]}]
# Validation (Update)
iex> {:ok, person} = Person.new(name: "Paul", email: "bitwalker@example.com")
...> {:error, %Ecto.Changeset{valid?: false, errors: errors}} = Person.change(person, email: "foo")
...> errors
[email: {"has invalid format", [validation: :format]}]
# JSON Serialization/Deserialization
...> person == person |> Jason.encode!() |> Person.from_json()
true
There are a few different ways to express and customize validation rules for a struct.
- Inline (as shown above, these consist of the common validations provided by
Ecto.Changeset
) - Validators (with module and function variants, as shown below)
- Custom (by overriding the
validate/1
callback)
The first two are the preferred method of expressing and controlling validation of a struct, but
if for some reason you prefer a more manual approach, overriding the validate/1
callback is an
option available to you and allows you to completely control validation of the struct.
NOTE: Be aware that if you override validate/1
without calling super/1
at some point in your
implementation, none of the inline or module/function validators will be run. It is expected that
if you are overriding the implementation, you are either intentionally disabling that functionality,
or are intending to delegate to it only in certain circumstances.
This is the primary method of implementing reusable validation rules:
There are two callbacks, init/1
and validate/2
. You can choose to omit the
implementation of init/1
and a default implementation will be provided for you.
The default implementation returns whatever it is given as input. Whatever is returned
by init/1
is given as the second argument to validate/2
. The validate/2
callback
is required.
defmodule MyValidator.ValidPhoneNumber do
use Strukt.Validator
@pattern ~r/^(\+1 )[0-9]{3}-[0-9]{3}-[0-9]{4}$/
@impl true
def init(opts), do: Enum.into(opts, %{})
@impl true
def validate(changeset, %{fields: fields}) do
Enum.reduce(fields, changeset, fn field, cs ->
case fetch_change(cs, field) do
:error ->
cs
{:ok, value} when value in [nil, ""] ->
add_error(cs, field, "phone number cannot be empty")
{:ok, value} when is_binary(value) ->
if value =~ @pattern do
cs
else
add_error(cs, field, "invalid phone number")
end
{:ok, _} ->
add_error(cs, field, "expected phone number to be a string")
end
end)
end
end
These are useful for ad-hoc validators that are specific to a single struct and aren't likely
to be useful in other contexts. The function is expected to received two arguments, the first
is the changeset to be validated, the second any options passed to the validation/2
macro:
defmodule File do
use Strukt
@allowed_content_types []
defstruct do
field :filename, :string, required: true
field :content_type, :string
field :content, :binary
end
validation :validate_filename_matches_content_type, @allowed_content_types
defp validate_filename_matches_content_type(changeset, allowed) do
# ...
end
end
As with module validators, the function should always return an Ecto.Changeset
.
You may express validation rules that apply only conditionally using guard clauses. For example, extending the example above, we could validate that the filename and content type match only when either of those fields are changed:
# With options
validation :validate_filename_matches_content_type, @allowed_content_types
when is_map_key(changeset.changes, :filename) or is_map_key(changeset.changes, :content_type)
# Without options
validation :validate_filename_matches_content_type
when is_map_key(changeset.changes, :filename) or is_map_key(changeset.changes, :content_type)
By default validation rules have an implicit guard of when true
if one is not explicitly provided.
Using the :source
option allows you to express that a given field may be provided as a parameter
using a different naming scheme than is used in idiomatic Elixir code (i.e. snake case):
defmodule Person do
use Strukt
@derives [Jason.Encoder]
@primary_key {:uuid, Ecto.UUID, autogenerate: true}
@timestamps_opts [autogenerate: {NaiveDateTime, :utc_now, []}]
defstruct do
field :name, :string, required: true, source: :NAME
field :email, :string, format: ~r/^.+@.+$/
timestamps()
end
end
# in iex
iex> {:ok, person} = Person.new(%{NAME: "Ivan", email: "ivan@example.com"})
...> person
%Person{
uuid: "f8736f15-bfdc-49bd-ac78-9da514208464",
name: "Ivan",
email: "ivan@example.com",
inserted_at: ~N[2021-06-08 22:21:23.490554],
updated_at: ~N[2021-06-08 22:21:23.490554]
}
NOTE: This does not affect serialization/deserialization via Jason.Encoder
when derived.
For more, see the usage docs