# SPDX-License-Identifier: Apache-2.0
defmodule BitcrowdEcto.Changeset do
@moduledoc """
Extensions for Ecto changesets.
"""
@moduledoc since: "0.1.0"
import Ecto.Changeset
alias Ecto.Changeset
@doc """
Validates that a field has changed in a defined way.
## Examples
validate_transition(changeset, field, [{"foo", "bar"}, {"foo", "yolo"}])
This marks the changeset invalid unless the value of `:field` is currently `"foo"` and is
changed to `"bar"` or `"yolo"`. If the field is not changed, a `{state, state}` transition
has to be present in the list of transitions.
"""
@doc since: "0.1.0"
@spec validate_transition(Changeset.t(), atom, [{any, any}]) :: Changeset.t()
def validate_transition(changeset, field, transitions) do
from = Map.fetch!(changeset.data, field)
to = Map.get(changeset.changes, field, from)
if {from, to} in transitions do
changeset
else
add_error(
changeset,
field,
"%{field} cannot transition from %{from} to %{to}",
from: from,
to: to,
validation: :transition
)
end
end
@doc """
Validates that a field that has been changed.
"""
@doc since: "0.1.0"
@spec validate_changed(Changeset.t(), atom) :: Changeset.t()
def validate_changed(changeset, field) do
if Map.has_key?(changeset.changes, field) do
changeset
else
add_error(changeset, field, "did not change", validation: :changed)
end
end
@doc """
Validates that a field is not changed from its current value, unless the current value is nil.
"""
@doc since: "0.1.0"
@spec validate_immutable(Changeset.t(), atom) :: Changeset.t()
def validate_immutable(changeset, field) do
if is_nil(Map.fetch!(changeset.data, field)) || !Map.has_key?(changeset.changes, field) do
changeset
else
add_error(changeset, field, "cannot be changed", validation: :immutable)
end
end
@valid_email_re ~r/^[\w.!#$%&’*+\-\/=?\^`{|}~]+@[a-z0-9-]+(\.[a-z0-9-]+)*$/i
@valid_email_re_only_web ~r/^[\w.!#$%&’*+\-\/=?\^`{|}~]+@[a-z0-9-]+(\.[a-z0-9-]+)+$/i
@type validate_email_option :: {:max_length, non_neg_integer} | {:only_web, boolean}
@doc """
Validates that an email has valid format.
* Ignores nil values.
## Compliance
For a good list of valid/invalid emails, see https://gist.github.com/cjaoude/fd9910626629b53c4d25
The regex used in this validator doesn't understand half of the inputs, but we don't really care
for now. Validating super strange emails is not a sport we want to compete in.
## Options
* `:max_length` - restricts the maximum length of the input, defaults to 320
* `:only_web` - requires a dot in the domain part, e.g. `domain.tld`, defaults to true
"""
@doc since: "0.1.0"
@spec validate_email(Changeset.t(), atom, [validate_email_option]) :: Changeset.t()
def validate_email(changeset, field, opts \\ []) do
max_length = Keyword.get(opts, :max_length, 320)
re =
if Keyword.get(opts, :only_web, true) do
@valid_email_re_only_web
else
@valid_email_re
end
changeset
|> validate_format(field, re)
|> validate_length(field, max: max_length)
end
@doc """
Validates a field url to be qualified url
"""
@doc since: "0.1.0"
@spec validate_url(Changeset.t(), atom) :: Changeset.t()
def validate_url(changeset, field) do
get_field(changeset, field) |> do_validate_url(changeset, field)
end
defp do_validate_url(nil, changeset, _field), do: changeset
defp do_validate_url(url, changeset, field) do
uri = URI.parse(url)
if !is_nil(uri.scheme) && uri.host =~ "." do
changeset
else
add_error(changeset, field, "is not a valid url", validation: :format)
end
end
@doc """
Validates a field timestamp to be in the past, if present
"""
@doc since: "0.6.0"
@spec validate_past_datetime(Changeset.t(), atom, DateTime.t()) :: Changeset.t()
def validate_past_datetime(changeset, field, now \\ DateTime.utc_now()) do
datetime = get_change(changeset, field)
if datetime && DateTime.compare(now, datetime) == :lt do
add_error(changeset, field, "must be in the past", validation: :date_in_past)
else
changeset
end
end
@doc """
Validates a field timestamp to be in the future, if present
"""
@doc since: "0.6.0"
@spec validate_future_datetime(Changeset.t(), atom, DateTime.t()) :: Changeset.t()
def validate_future_datetime(changeset, field, now \\ DateTime.utc_now()) do
datetime = get_change(changeset, field)
if datetime && DateTime.compare(now, datetime) != :lt do
add_error(changeset, field, "must be in the future", validation: :date_in_future)
else
changeset
end
end
@doc """
Validates a field timestamp to be after the given one
"""
@doc since: "0.6.0"
@spec validate_datetime_after(Changeset.t(), atom, DateTime.t(), [{:formatter, fun}]) ::
Changeset.t()
def validate_datetime_after(changeset, field, reference_datetime, opts \\ []) do
formatter = Keyword.get(opts, :formatter, &DateTime.to_string/1)
datetime = get_change(changeset, field)
if datetime && DateTime.compare(reference_datetime, datetime) != :lt do
reference = formatter.(reference_datetime)
add_error(
changeset,
field,
"must be after %{reference}",
reference: reference,
validation: :datetime_after
)
else
changeset
end
end
@doc """
Validates two date fields to be a date range, so if both are set the first field has to be
before the second field. The error is placed on the later field.
## Examples
validate_date_order(changeset, :from, :to)
validate_date_order(changeset, :from, :to, [valid_orders: :lt])
validate_date_order(changeset, :from, :to, [formatter: &Date.day_of_week/1])
"""
@doc since: "0.6.0"
@spec validate_date_order(Changeset.t(), atom, atom, [
{:formatter, fun},
{:valid_orders, list(atom)}
]) ::
Changeset.t()
def validate_date_order(changeset, from_field, until_field, opts \\ []) do
formatter = Keyword.get(opts, :formatter, &Date.to_string/1)
valid_orders = Keyword.get(opts, :valid_orders, [:lt, :eq])
validate_order(
changeset,
from_field,
until_field,
:date_order,
compare_fun: &Date.compare/2,
valid_orders: valid_orders,
formatter: formatter
)
end
@doc """
Validates two datetime fields to be a time range, so if both are set the first has to be before
the second field. The error is placed on the later field.
## Examples
validate_datetime_order(changeset, :from, :to)
validate_datetime_order(changeset, :from, :to, [valid_orders: :lt])
validate_datetime_order(changeset, :from, :to, [formatter: &DateTime.to_time/1])
"""
@doc since: "0.6.0"
@spec validate_datetime_order(Changeset.t(), atom, atom, [
{:formatter, fun},
{:valid_orders, list(atom)}
]) ::
Changeset.t()
def validate_datetime_order(changeset, from_field, until_field, opts \\ []) do
formatter = Keyword.get(opts, :formatter, &DateTime.to_string/1)
valid_orders = Keyword.get(opts, :valid_orders, [:lt, :eq])
validate_order(
changeset,
from_field,
until_field,
:datetime_order,
compare_fun: &DateTime.compare/2,
valid_orders: valid_orders,
formatter: formatter
)
end
@doc """
Validates two fields to be a range, so if both are set the first has to be before
the second field. The error is placed on the second field.
## Examples
validate_order(changeset, :from, :to, :to_is_after_from)
validate_order(changeset, :from, :to, :to_is_after_from, [compare_fun: fn a, b -> String.length(a) > String.length(b) end])
validate_order(changeset, :from, :to, :to_is_after_from, [formatter: &String.length/1])
"""
@doc since: "0.6.0"
@spec validate_order(Changeset.t(), atom, atom, atom, [
{:formatter, fun},
{:compare_fun, fun},
{:valid_orders, list(atom)}
]) ::
Changeset.t()
def validate_order(changeset, from_field, until_field, validation_key, opts \\ []) do
formatter = Keyword.get(opts, :formatter, &Kernel.to_string/1)
compare_fun = Keyword.get(opts, :compare_fun, &Kernel.</2)
valid_orders = Keyword.get(opts, :valid_orders, [true])
from = get_field(changeset, from_field)
until = get_field(changeset, until_field)
if from && until && !(compare_fun.(from, until) in List.wrap(valid_orders)) do
stringified_value = formatter.(from)
message = "must be after '%{stringified_value}'"
add_error(changeset, until_field, message,
validation: validation_key,
stringified_value: stringified_value
)
else
changeset
end
end
@hex_color_regex ~r/^#[0-9A-Fa-f]{6}$/
@doc """
Validates a changeset field with hexadecimal color format
"""
@doc since: "0.11.0"
@spec validate_hex_color(Changeset.t(), atom) :: Changeset.t()
def validate_hex_color(changeset, hex_color_field) do
validate_format(changeset, hex_color_field, @hex_color_regex)
end
@doc """
Validates a date field in the changeset is after the given reference date.
"""
@doc since: "0.11.0"
@spec validate_date_after(Changeset.t(), atom, Date.t(), [{:formatter, fun}]) ::
Changeset.t()
def validate_date_after(changeset, date_field, ref_date, opts \\ []) do
date = get_field(changeset, date_field)
if date do
if Date.compare(date, ref_date) in [:eq, :gt] do
changeset
else
formatter = Keyword.get(opts, :formatter, &Date.to_string/1)
reference = formatter.(ref_date)
add_error(changeset, date_field, "must be after or equal to #{reference}",
validation: :date_after
)
end
else
changeset
end
end
if Code.ensure_loaded?(Money) do
@type validate_money_option ::
{:equal_to, Money.t()}
| {:more_than, Money.t()}
| {:less_than, Money.t()}
| {:more_than_or_equal_to, Money.t()}
| {:less_than_or_equal_to, Money.t()}
| {:currency, atom}
@money_validations [
:equal_to,
:more_than,
:less_than,
:more_than_or_equal_to,
:less_than_or_equal_to,
:currency
]
@doc """
Validates a `t:Money.t` value according to the given options.
## Examples
validate_money(changeset, :amount, less_than: Money.new("100.00", :USD))
validate_money(changeset, :amount, greater_than_or_equal_to: Money.new("100.00", :USD))
validate_money(changeset, :amount, currency: :USD)
"""
@doc since: "0.13.0"
@spec validate_money(Changeset.t(), atom(), [validate_money_option]) ::
Changeset.t() | no_return()
def validate_money(changeset, field, opts) do
validate_change(changeset, field, fn _, %Money{} = value ->
opts
|> Keyword.take(@money_validations)
|> Enum.flat_map(&do_validate_money(field, value, &1))
end)
end
defp do_validate_money(field, value, {:equal_to, target}) do
if Money.compare(value, target) != :eq do
[
{field,
{"must be equal to %{money}", [money: target, validation: :money, kind: :equal_to]}}
]
else
[]
end
end
defp do_validate_money(field, value, {:less_than, target}) do
if Money.compare(value, target) in [:eq, :gt] do
[
{field,
{"must be less than %{money}", [money: target, validation: :money, kind: :less_than]}}
]
else
[]
end
end
defp do_validate_money(field, value, {:more_than, target}) do
if Money.compare(value, target) in [:eq, :lt] do
[
{field,
{"must be more than %{money}", [money: target, validation: :money, kind: :more_than]}}
]
else
[]
end
end
defp do_validate_money(field, value, {:less_than_or_equal_to, target}) do
if Money.compare(value, target) == :gt do
[
{field,
{"must be less than or equal to %{money}",
[money: target, validation: :money, kind: :less_than_or_equal_to]}}
]
else
[]
end
end
defp do_validate_money(field, value, {:more_than_or_equal_to, target}) do
if Money.compare(value, target) == :lt do
[
{field,
{"must be more than or equal to %{money}",
[money: target, validation: :money, kind: :more_than_or_equal_to]}}
]
else
[]
end
end
defp do_validate_money(field, value, {:currency, target}) do
if value.currency != target do
[
{field,
{"currency must be %{currency}",
[currency: target, validation: :money, kind: :currency]}}
]
else
[]
end
end
end
@type cast_all_option :: {:action, atom}
@doc """
Introspects a schema and casts all defined fields from a params map.
- Accepts a schema module or structs or changesets.
- Can deal with embeds.
## Options
- `required` list of required field names
- `optional` list of optional field names (= inverse set is required)
`required` and `optional` options must not be present at the same time.
## Examples
iex> changeset = cast_all(TestEmbeddedSchema, %{some_field: 4})
iex> changeset.valid?
true
iex> changeset = cast_all(%TestEmbeddedSchema{}, %{some_field: 4})
iex> changeset.valid?
true
iex> changeset = cast_all(change(%TestEmbeddedSchema{}), %{some_field: 4})
iex> changeset.valid?
true
iex> changeset = cast_all(TestEmbeddedSchema, %{}, required: [:some_field])
iex> changeset.errors
[some_field: {"can't be blank", [validation: :required]}]
iex> changeset = cast_all(TestEmbeddedSchema, %{}, optional: [:some_other_field])
iex> changeset.errors
[some_field: {"can't be blank", [validation: :required]}]
"""
@doc since: "0.17.0"
@spec cast_all(module | struct, map) :: Ecto.Changeset.t()
@spec cast_all(module | struct, map, [cast_all_option]) :: Ecto.Changeset.t()
def cast_all(schema_or_struct_or_changeset, params, opts \\ [])
def cast_all(%Ecto.Changeset{} = changeset, params, opts) do
do_cast_all(changeset.data.__struct__, changeset, params, opts)
end
def cast_all(schema, params, opts) when is_atom(schema) do
do_cast_all(schema, struct!(schema), params, opts)
end
def cast_all(struct, params, opts) when is_struct(struct) do
do_cast_all(struct.__struct__, struct, params, opts)
end
defp do_cast_all(schema, struct_or_changeset, params, opts) do
required = required_fields(schema, opts)
%{scalars: scalars, embeds: embeds} = grouped_fields(schema)
struct_or_changeset
|> cast_scalars(params, scalars, required)
|> cast_embeds(embeds, required)
end
defp required_fields(schema, opts) do
required = Keyword.get(opts, :required)
optional = Keyword.get(opts, :optional)
cond do
required && optional ->
raise ArgumentError, ":required and :optional options are mutually exclusive"
required ->
required
optional ->
schema.__schema__(:fields) -- optional
true ->
[]
end
end
defp grouped_fields(schema) do
:fields
|> schema.__schema__()
|> Enum.group_by(fn field ->
case schema.__schema__(:type, field) do
{:parameterized, Ecto.Embedded, _} ->
:embeds
# Simplification, can be extended as needed.
_other ->
:scalars
end
end)
|> Map.put_new(:embeds, [])
|> Map.put_new(:scalars, [])
end
defp cast_scalars(schema_struct, params, scalars, required) do
# Don't be confused, `--` is right-associative.
# https://hexdocs.pm/elixir/1.15.7/operators.html#operator-precedence-and-associativity
required = scalars -- scalars -- required
schema_struct
|> Ecto.Changeset.cast(params, scalars)
|> Ecto.Changeset.validate_required(required)
end
defp cast_embeds(changeset, embeds, required) do
Enum.reduce(embeds, changeset, fn embed, cs ->
Ecto.Changeset.cast_embed(cs, embed, required: embed in required)
end)
end
end