Accept more than one shape with conditional_field
v0.1.4
A conditional_field lets one field name accept more than one shape: a preset string or a detailed map, one value or a list of values. You declare each shape as a child, and the builder returns the first child that accepts the input. This guide declares a conditional field, reads its errors, and covers lists, priority and nesting.
Before you begin
You need a guarded module. Nested shapes use sub_field, which Nest structs with sub_field explains.
Declare a conditional field
Every child uses the same name as the conditional field. A child is a field, a sub_field or another conditional_field.
defmodule Check do
def string(field, value) when is_binary(value), do: {:ok, field, value}
def string(field, _), do: {:error, field, "It is not string"}
def map(field, value) when is_map(value), do: {:ok, field, value}
def map(field, _), do: {:error, field, "It is not map"}
def list(field, value) when is_list(value), do: {:ok, field, value}
def list(field, _), do: {:error, field, "It is not list"}
end
defmodule Account do
use GuardedStruct
guardedstruct do
field :name, String.t(), enforce: true
conditional_field :plan, any() do
field :plan, String.t(),
hint: "preset",
validator: {Check, :string},
derives: "validate(enum=String[free::pro::enterprise])"
sub_field :plan, struct(), hint: "detailed", validator: {Check, :map} do
field :tier, String.t(), enforce: true
field :seats, integer(), derives: "validate(integer, min_len=1)"
end
end
end
endThe builder tries the children in the order you wrote them. A child accepts the value when all of these hold:
- Its
validator:, if it has one, returns{:ok, field, value}. - Its
derivespass. - For a
sub_fieldor astruct:child, the nested struct builds.
Account.builder(%{name: "Acme", plan: "pro"})
# {:ok, %Account{name: "Acme", plan: "pro"}}
Account.builder(%{name: "Acme", plan: %{tier: "custom", seats: 50}})
# {:ok, %Account{name: "Acme", plan: %Account.Plan1{tier: "custom", seats: 50}}}GuardedStruct numbers the modules of sub_field children in the order they appear, so the first one is Account.Plan1.
Tell the children apart with validators
Each child above has a validator: that checks the shape of the value. A child without a validator: passes the validator step for any value. A sub_field child with no validator then fails to build a string and reports bad_parameters, which hides the real reason. A validator that checks the shape makes the error say what was wrong. The validator is a {Module, :function} tuple that the builder calls with the field name and the value.
Read the errors
When no child accepts the value, you get one error with the action conditionals. Its errors list holds the errors of each child in order. A hint: on a child is copied into its errors as __hint__, so you can tell which child produced which error.
Account.builder(%{name: "Acme", plan: "gold"})
# {:error,
# [
# %{
# action: :conditionals,
# field: :plan,
# errors: [
# %{
# field: :plan,
# action: :enum,
# message: "Your sent data form plan field is not in the allowed list",
# __hint__: "preset"
# },
# %{field: :plan, action: :validator, message: "It is not map", __hint__: "detailed"}
# ]
# }
# ]}Accept a list
Add structs: true to the conditional field. Each item of a list goes through the children on its own, and the result is a list. A value that is not a list is still tried against the children as a single value.
defmodule Contact do
use GuardedStruct
guardedstruct do
conditional_field :contact, any(), structs: true do
field :contact, String.t(),
validator: {Check, :string},
hint: "email",
derives: "validate(email_r)"
sub_field :contact, struct(), validator: {Check, :map}, hint: "phone" do
field :country, String.t(), enforce: true
field :number, String.t(), enforce: true
end
end
end
end
Contact.builder(%{contact: ["[email protected]", %{country: "NL", number: "123"}]})
# {:ok, %Contact{contact: ["[email protected]", %Contact.Contact1{country: "NL", number: "123"}]}}When items fail, the errors of every failing item are gathered into one conditionals error with duplicates removed.
Report only the first error
Add priority: true to the conditional field to keep only the first error of the list. The aggregated error then names one child instead of all of them.
conditional_field :value, any(), priority: true do
field :value, String.t(), validator: {Check, :string}, hint: "text"
sub_field :value, struct(), validator: {Check, :map}, hint: "map" do
field :a, String.t(), enforce: true
end
end
# builder(%{value: 5}) returns one inner error, the one hinted "text"Nest conditional fields
A conditional field can sit inside a sub_field that is itself the child of another conditional field, to any depth. The error of the inner one is nested inside the error of the outer one, with the outer child's hint on it.
defmodule Deep do
use GuardedStruct
guardedstruct do
conditional_field :plan, any() do
field :plan, String.t(), hint: "preset", validator: {Check, :string}
sub_field :plan, struct(), hint: "detailed", validator: {Check, :map} do
field :tier, String.t(), enforce: true
conditional_field :notes, any() do
field :notes, String.t(), hint: "single", validator: {Check, :string}
field :notes, list(), hint: "many", validator: {Check, :list}
end
end
end
end
end
Deep.builder(%{plan: %{tier: "pro", notes: ["x", "y"]}})
# {:ok, %Deep{plan: %Deep.Plan1{tier: "pro", notes: ["x", "y"]}}}Next steps
- Error shapes and actions: the
conditionalserror and every other action. - Write a field validator and a main validator: the return values a child validator can use.
- DSL reference: every option of
conditional_field.