Use virtual, dynamic and pattern-keyed fields

v0.1.4

Three kinds of field cover input that a plain field does not fit. A virtual_field is checked but never stored, a dynamic_field keeps a free-form map exactly as sent, and a field named with a regular expression describes a map with no fixed keys. This guide shows each one.

Before you begin

You need a guarded module. Keys that come from untrusted input are the reason these fields exist, and How the builder works explains how GuardedStruct treats them.

Check a value without storing it

A virtual_field takes the same options as a field except struct, structs and priority. Its derive string runs, but the key is left out of the struct. A common use is a password confirmation that the main validator compares and then drops.

elixir
defmodule Signup do
  use GuardedStruct

  guardedstruct do
    field :email, String.t(), enforce: true, derives: "validate(string, email_r)"
    field :password, String.t(), enforce: true, derives: "validate(string, min_len=8)"
    virtual_field :password_confirm, String.t(), derives: "validate(string)"
  end

  def main_validator(attrs) do
    if attrs[:password] == attrs[:password_confirm] do
      {:ok, attrs}
    else
      {:error, [%{field: :password_confirm, action: :match, message: "passwords don't match"}]}
    end
  end
end

Signup.builder(%{email: "[email protected]", password: "longpassword", password_confirm: "longpassword"})
# {:ok, %Signup{email: "[email protected]", password: "longpassword"}}

Signup.builder(%{email: "[email protected]", password: "longpassword", password_confirm: "other"})
# {:error, [%{field: :password_confirm, action: :match, message: "passwords don't match"}]}

The main validator sees the virtual value. The virtual field's own derive string runs after the main validator and only when the main validator passed. Signup.keys() returns [:email, :password], so the virtual key is not part of the struct.

Keep a map as it was sent

A dynamic_field holds a map whose keys you do not control. The builder returns the map exactly as it came in. String keys stay strings, atom keys stay atoms, and nested maps are not touched, so no atoms are created from user input. A dynamic_field inside a sub_field does the same, at any depth. The defaults are the type map(), the default value %{} and the derive string validate(map).

elixir
defmodule Doc do
  use GuardedStruct

  guardedstruct do
    field :id, String.t(), enforce: true
    dynamic_field :metadata
  end
end

Doc.builder(%{id: "x", metadata: %{"foo" => 1, :bar => 2, "baz" => %{"nested" => 3}}})
# {:ok, %Doc{id: "x", metadata: %{:bar => 2, "baz" => %{"nested" => 3}, "foo" => 1}}}

Doc.builder(%{id: "x"})
# {:ok, %Doc{id: "x", metadata: %{}}}

Doc.builder(%{id: "x", metadata: "no"})
# {:error, [%{field: :metadata, action: :map, message: "The metadata field must be map"}]}

Read the keys with the same form they were sent in. If you convert them to atoms later, do it for a fixed list of keys that you wrote in source, never for every key of the map.

Describe a map with no fixed keys

Name a field with a regular expression instead of an atom. The module then describes a map: every key that matches the pattern is accepted, and every value is built or checked the same way. builder/1 returns a plain map with string keys, not a struct.

elixir
defmodule Shard do
  use GuardedStruct

  guardedstruct do
    field :node, String.t(), enforce: true, derives: "sanitize(trim) validate(ipv4)"
  end
end

defmodule Shards do
  use GuardedStruct

  guardedstruct do
    field ~r/^shard_\d+$/, struct(), struct: Shard, derives: "validate(map, not_empty)"
  end
end

Shards.builder(%{"shard_1" => %{node: "10.0.0.1"}, "shard_2" => %{node: " 10.0.0.2 "}})
# {:ok, %{"shard_1" => %Shard{node: "10.0.0.1"}, "shard_2" => %Shard{node: "10.0.0.2"}}}

Shards.builder(%{"shard_1" => %{node: "10.0.0.1"}, "bad" => %{node: "10.0.0.2"}})
# {:error,
#  [%{field: :__map__, action: :key_pattern, key: "bad", message: "key \"bad\" does not match any declared pattern"}]}

Shards.builder(%{"shard_1" => %{node: "nope"}})
# {:error, [%{field: :node, action: :ipv4, key: "shard_1", message: "Invalid format in the node field"}]}

These rules apply to a pattern-keyed module:

  • You can declare several pattern fields, and a key matches the first pattern that fits.
  • The derive string of the first pattern field that has one runs on the whole map, so validate(map, not_empty) above rejects an empty map.
  • The value of a pattern field can be built with struct:, checked with validator:, or left as it is. The plain form field ~r/^X-[A-Z][A-Za-z0-9\-]*$/, String.t() accepts any value for a matching key. A validator: error has the field :__map__ and the key.
  • Mixing atom fields and regex fields in one guardedstruct is a compile error. Put the regex field in its own module and refer to it with struct:.
  • GuardedStruct.Info.pattern_keyed?/1 tells you which kind of module you have. Info.fields/1 lists its patterns, and Info.field_kind/2 returns :pattern_field for a name that a pattern matches.

Next steps