Current section
Files
Jump to
Current section
Files
README.md
# StruktStrukt 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 todayto define schema'd types in Ecto, can now be used to define structs for general purposeusage.The functionality provided by the `defstruct` macro in this module is strictly a supersetof the functionality provided both by `Kernel.defstruct/1`, as well as `Ecto.Schema`. Ifyou 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.## Installation``` elixirdef deps do [ {:strukt, "~> 0.3"} ]end```## ExampleThe following is an example of using `defstruct/1` to define a struct with types, autogeneratedprimary key, and inline validation rules.``` elixirdefmodule 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() endend```And an example of how you would create and use this struct:``` elixir# Creating from params, with autogeneration of fieldsiex> {: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```## ValidationThere 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, butif for some reason you prefer a more manual approach, overriding the `validate/1` callback is anoption 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 yourimplementation, none of the inline or module/function validators will be run. It is expected thatif you are overriding the implementation, you are either intentionally disabling that functionality,or are intending to delegate to it only in certain circumstances.### Module ValidatorsThis is the primary method of implementing reusable validation rules:There are two callbacks, `init/1` and `validate/2`. You can choose to omit theimplementation of `init/1` and a default implementation will be provided for you.The default implementation returns whatever it is given as input. Whatever is returnedby `init/1` is given as the second argument to `validate/2`. The `validate/2` callbackis required.```elixirdefmodule 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) endend```### Function ValidatorsThese are useful for ad-hoc validators that are specific to a single struct and aren't likelyto be useful in other contexts. The function is expected to received two arguments, the firstis the changeset to be validated, the second any options passed to the `validation/2` macro:```elixirdefmodule 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 # ... endend```As with module validators, the function should always return an `Ecto.Changeset`.### Conditional RulesYou 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 wheneither of those fields are changed:```elixir # 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.## Custom FieldsUsing the `:source` option allows you to express that a given field may be provided as a parameterusing a different naming scheme than is used in idiomatic Elixir code (i.e. snake case):``` elixirdefmodule 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() endend# in iexiex> {: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](https://hexdocs.pm/strukt/usage.html)