Current section
Files
Jump to
Current section
Files
lib/pipeline.ex
defmodule Flint.Pipeline do
@moduledoc """
The core pipeline of Flint. `Flint.Pipeline` uses the module attributes that are collected when using the `Flint.Schema`
macros to perform transformations and validations.
See the `Pipeline` section of the `Flint` documentation for more information.
"""
import Ecto.Changeset
@doc """
Applies transformations to each field according to the `:derive` options passed in the schema specification.
These transformations are applied after casting, but before validations when used within the default `Flint.Pipeline.changeset` implementation.
Accepts optional bindings which are passed to evaluated code.
"""
def apply_pre_transforms(changeset, bindings \\ []) do
module = changeset.data.__struct__
env = Module.concat(module, Env) |> apply(:env, [])
all_pre_transforms = module.__schema__(:pre_transforms)
for {field, pre_transforms} <- all_pre_transforms, reduce: changeset do
changeset ->
{derived_expression, _pre_transforms} = Keyword.pop(pre_transforms, :derive)
bindings = bindings ++ Enum.into(changeset.changes, [])
if derived_expression do
{derived_value, _bindings} = Code.eval_quoted(derived_expression, bindings, env)
derived_value =
if is_function(derived_value) do
case Function.info(derived_value, :arity) do
{:arity, 0} ->
apply(derived_value, [])
{:arity, 1} when not is_nil(field) ->
apply(derived_value, [
fetch_change!(changeset, field)
])
_ ->
raise ArgumentError,
"Anonymous functions provided to `:derive` must be either 0-arity or an input value for the field must be provided."
end
else
derived_value
end
put_change(changeset, field, derived_value)
else
changeset
end
end
end
@doc """
Applies transformations to each field according to the `:map` options passed in the schema specification.
These transformations are applied after validations when used within the default `Flint.Pipeline.changeset` implementation.
Accepts optional bindings which are passed to evaluated code.
"""
def apply_post_transforms(changeset, bindings \\ []) do
module = changeset.data.__struct__
env = Module.concat(module, Env) |> apply(:env, [])
all_post_transforms = module.__schema__(:post_transforms)
for {field, post_transforms} <- all_post_transforms, reduce: changeset do
changeset ->
{map_expression, _post_transforms} = Keyword.pop(post_transforms, :map)
bindings = bindings ++ Enum.into(changeset.changes, [])
if is_nil(map_expression) do
changeset
else
{mapped, _bindings} = Code.eval_quoted(map_expression, bindings, env)
mapped =
if is_function(mapped) do
case Function.info(mapped, :arity) do
{:arity, 1} when not is_nil(field) ->
apply(mapped, [fetch_change!(changeset, field)])
{:arity, 1} when is_nil(field) ->
nil
_ ->
raise ArgumentError,
":map option only accepts arity-1 anonymous function"
end
else
mapped
end
put_change(changeset, field, mapped)
end
end
end
@doc """
Applies validations to each field according to the options passed in the schema specification.
See the `Field Validations` section of the README for more information on validation details.
"""
def apply_validations(changeset, bindings \\ []) do
module = changeset.data.__struct__
env = Module.concat(module, Env) |> apply(:env, [])
all_validations = module.__schema__(:validations)
for {field, validations} <- all_validations, reduce: changeset do
changeset ->
{block, validations} = Keyword.pop(validations, :block, [])
{when_condition, validations} = Keyword.pop(validations, :when)
bindings = bindings ++ Enum.into(changeset.changes, [])
validations =
validations
|> Enum.map(fn
{k, v} ->
{result, _bindings} = Code.eval_quoted(v, bindings, env)
{k, result}
end)
{validate_length_args, validations} =
Keyword.split(validations, [:is, :min, :max, :count])
{validate_number_args, validations} =
Keyword.split(validations, [
:less_than,
:greater_than,
:less_than_or_equal_to,
:greater_than_or_equal_to,
:equal_to,
:not_equal_to
])
{validate_subset_arg, validations} = Keyword.pop(validations, :subset_of)
{validate_inclusion_arg, validations} = Keyword.pop(validations, :in)
{validate_exclusion_arg, validations} = Keyword.pop(validations, :not_in)
{validate_format_arg, _validations} = Keyword.pop(validations, :format)
validation_args = [
validate_inclusion: validate_inclusion_arg,
validate_exclusion: validate_exclusion_arg,
validate_number: validate_number_args,
validate_length: validate_length_args,
validate_format: validate_format_arg,
validate_subset: validate_subset_arg
]
changeset =
Enum.reduce(validation_args, changeset, fn
{_func, nil}, chngset ->
chngset
{_func, []}, chngset ->
chngset
{func, arg}, chngset ->
apply(Ecto.Changeset, func, [chngset, field, arg])
end)
{validate_when_condition, _bindings} =
try do
Code.eval_quoted(
when_condition,
bindings,
env
)
rescue
_ ->
{false, nil}
end
if validate_when_condition do
changeset
else
add_error(changeset, field, "Failed `:when` validation")
end
block
|> Enum.with_index()
|> Enum.reduce(changeset, fn
{{quoted_condition, quoted_err}, index}, chngset ->
try do
{invalid?, _bindings} = Code.eval_quoted(quoted_condition, bindings, env)
invalid? =
if is_function(invalid?) do
case Function.info(invalid?, :arity) do
{:arity, 0} ->
apply(invalid?, [])
{:arity, 1} when not is_nil(field) ->
apply(invalid?, [fetch_change!(changeset, field)])
_ ->
raise ArgumentError,
"Anonymous functions in validation clause must be either 0-arity or an input value for the field must be provided."
end
else
invalid?
end
{err_msg, _bindings} = Code.eval_quoted(quoted_err, bindings, env)
if invalid? do
add_error(chngset, field, err_msg,
validation: :block,
clause: index + 1
)
else
chngset
end
rescue
_ ->
add_error(
chngset,
field,
"Error evaluating expression in Clause ##{index + 1} of `do:` block"
)
end
end)
end
end
@doc """
Given a `Flint` (or `Ecto`) schema and params (can be a map, struct of the given schema, or an existing changeset),
applies all steps of the `Flint.Pipeline` to generate a new changeset.
This function casts all fields (recursively casting all embeds using this same function),
validates required fields (specified using the bang (`!`) macros exposed by `Flint`),
applies pre-transformations, validations, and post-transformations, before
outputting the resulting `Ecto.Changeset`.
See the `Pipeline` section of the `README` for more details.
"""
def changeset(schema, params \\ %{}, bindings \\ []) do
module = schema.__struct__
fields = module.__schema__(:fields) |> MapSet.new()
embedded_fields = module.__schema__(:embeds) |> MapSet.new()
params =
case params do
%Ecto.Changeset{params: params} -> params
s when is_struct(s) -> Map.from_struct(params)
_ -> params
end
required = module.__schema__(:required)
fields = fields |> MapSet.difference(embedded_fields)
required_embeds = Enum.filter(required, &(&1 in embedded_fields))
required_fields = Enum.filter(required, &(&1 in fields))
changeset =
schema
|> cast(params, fields |> MapSet.to_list())
changeset =
for field <- embedded_fields, reduce: changeset do
changeset ->
changeset
|> cast_embed(field,
required: field in required_embeds,
with: &changeset(&1, &2, bindings)
)
end
changeset
|> validate_required(required_fields)
|> apply_pre_transforms(bindings)
|> apply_validations(bindings)
|> apply_post_transforms(bindings)
end
end