diff --git a/README.md b/README.md index 7b2b071..125d09b 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,9 @@ end - `{type, {:default, default}}` - Provides a default value if the field is missing or `nil`. - `{type, {:default, &some_fun/0}}` - The default values is retrieved from callinf `some_fun/0` if the field is missing. - `{type, {:default, {mod, fun}}}` - The default values is retrieved from callinf `mod.fun/0` if the field is missing. -- `{type, {:transform, mapper}}` - Transforms the field value using the specified mapper function. - - `{type, {:transform, {mod, fun}}}` - Transforms the field value using the specified `mod.fun/1` function. +- `{type, {:transform, mapper}}` - Transforms the field value using the specified mapper function. It can be a 1 or 2 arity function: when is a single arity the mapper function will only receive the defined field value, while with 2 arity will receive the current defined field value and the whole data as the second argument. + - `{type, {:transform, {mod, fun}}}` - Transforms the field value using the specified `mod.fun/1` function. Notice that `fun` can be a 2 arity so it can receive the whole data being validated, in case on dependent fields transformations. + - `{type, {:transform, {mod, fun, args}}}` - Transforms the field value using the specified MFA. Notice that `fun` will be at least a 2 arity one so it can receive the whole data being validated, in case on dependent fields transformations and the maximum arity allowed will be 2 + `length(args)`. - `{:either, {type1, type2}}` - Validates that the field is either of the two specified types. - `{:oneof, types}` - Validates that the field is one of the specified types. - `{:custom, callback}` - Validates that the field passes the custom validation function. diff --git a/lib/peri.ex b/lib/peri.ex index 4192d90..9cdb99e 100644 --- a/lib/peri.ex +++ b/lib/peri.ex @@ -665,17 +665,44 @@ defmodule Peri do end end + defp validate_field(val, {type, {:transform, mapper}}, data) + when is_function(mapper, 2) do + with :ok <- validate_field(val, type, data) do + {:ok, mapper.(val, maybe_get_root_data(data))} + end + end + defp validate_field(val, {type, {:transform, {mod, fun}}}, data) when is_atom(mod) and is_atom(fun) do with :ok <- validate_field(val, type, data) do - {:ok, apply(mod, fun, [val])} + cond do + function_exported?(mod, fun, 1) -> + {:ok, apply(mod, fun, [val])} + + function_exported?(mod, fun, 2) -> + {:ok, apply(mod, fun, [val, maybe_get_root_data(data)])} + + true -> + template = "expected %{mod} to export %{fun}/1 or %{fun}/2" + {:error, template, mod: mod, fun: fun} + end end end defp validate_field(val, {type, {:transform, {mod, fun, args}}}, data) when is_atom(mod) and is_atom(fun) and is_list(args) do with :ok <- validate_field(val, type, data) do - {:ok, apply(mod, fun, [val | args])} + cond do + function_exported?(mod, fun, length(args) + 2) -> + {:ok, apply(mod, fun, [val, maybe_get_root_data(data) | args])} + + function_exported?(mod, fun, length(args) + 1) -> + {:ok, apply(mod, fun, [val | args])} + + true -> + template = "expected %{mod} to export %{fun} with arity from %{base} to %{arity}" + {:error, template, mod: mod, fun: fun, arity: length(args), base: length(args) + 1} + end end end @@ -947,6 +974,15 @@ defmodule Peri do defp validate_type({type, {:transform, mapper}}, p) when is_function(mapper, 1), do: validate_type(type, p) + defp validate_type({type, {:transform, mapper}}, p) when is_function(mapper, 2), + do: validate_type(type, p) + + defp validate_type({type, {:transform, {_mod, _fun}}}, p), + do: validate_type(type, p) + + defp validate_type({type, {:transform, {_mod, _fun, args}}}, p) when is_list(args), + do: validate_type(type, p) + defp validate_type({:required, {type, {:default, val}}}, _) do template = "cannot set default value of %{value} for required field of type %{type}" {:error, template, [value: val, type: type]} diff --git a/test/peri_test.exs b/test/peri_test.exs index ea3c948..91bf203 100644 --- a/test/peri_test.exs +++ b/test/peri_test.exs @@ -1383,6 +1383,32 @@ defmodule PeriTest do scores: {:list, {:integer, {:transform, &double/1}}} }) + defschema(:dependent_transform, %{ + id: {:required, :string}, + name: + {:string, + {:transform, + fn + name, data -> (data[:id] && name <> "-#{data[:id]}") || name + end}} + }) + + defschema(:nested_dependent_transform, %{ + user: %{ + birth_year: {:required, :integer}, + age: {:integer, {:transform, fn _, %{user: %{birth_year: y}} -> 2024 - y end}}, + profile: %{ + nickname: + {:string, + {:transform, + fn nick, data -> + year = get_in(data, [:user, :birth_year]) + if year > 2006, do: nick, else: "doomed" + end}} + } + } + }) + describe "basic transform schema" do test "applies transform function correctly" do data = %{number: 5, name: "john"} @@ -1407,6 +1433,112 @@ defmodule PeriTest do end end + describe "dependent fields transform" do + test "applies transform function correctly with dependent fields" do + data = %{id: "123", name: "john"} + expected = %{id: "123", name: "john-123"} + assert {:ok, ^expected} = dependent_transform(data) + + # how about keyword lists? + data = [id: "123", name: "maria"] + s = Map.to_list(get_schema(:dependent_transform)) + assert {:ok, valid} = Peri.validate(s, data) + assert valid[:id] == "123" + assert valid[:name] == "maria-123" + + # order shouldn't matter too + data = [name: "maria", id: "123"] + s = Map.to_list(get_schema(:dependent_transform)) + assert {:ok, valid} = Peri.validate(s, data) + assert valid[:id] == "123" + assert valid[:name] == "maria-123" + end + + test "it should return an error if the dependent field is invalid" do + data = %{id: 123, name: "john"} + + assert { + :error, + [ + %Peri.Error{ + path: [:id], + key: :id, + content: %{actual: "123", expected: :string}, + message: "expected type of :string received 123 value", + errors: nil + } + ] + } = dependent_transform(data) + + # map order shouldn't matter + data = %{name: "john", id: 123} + + assert {:error, + [ + %Peri.Error{ + path: [:id], + key: :id, + content: %{actual: "123", expected: :string}, + message: "expected type of :string received 123 value", + errors: nil + } + ]} = dependent_transform(data) + end + + test "it should support nested dependent transformations too" do + data = %{user: %{birth_year: 2007, age: 5, profile: %{nickname: "john"}}} + expected = %{user: %{birth_year: 2007, age: 17, profile: %{nickname: "john"}}} + assert {:ok, ^expected} = nested_dependent_transform(data) + end + end + + describe "transform with MFA" do + test "it should apply the mapper function without additional argument" do + s = {:string, {:transform, {String, :to_integer}}} + assert {:ok, 10} = Peri.validate(s, "10") + end + + test "it should apply the mapper function without additional argument but with dependent field" do + s = %{id: {:string, {:transform, {__MODULE__, :integer_by_name}}}, name: :string} + data = %{id: "10", name: "john"} + assert {:ok, %{id: 20, name: "john"}} = Peri.validate(s, data) + + data = %{id: "10", name: "maria"} + assert {:ok, %{id: 10, name: "maria"}} = Peri.validate(s, data) + end + + test "it should apply mapper function with additional arguments" do + s = {:string, {:transform, {String, :split, [~r/\D/, [trim: true]]}}} + assert {:ok, ["10"]} = Peri.validate(s, "omw 10") + end + + test "it should apply mapper function with additional arguments with dependent field" do + s = %{ + id: {:string, {:transform, {__MODULE__, :integer_by_name, [[make_sense?: false]]}}}, + name: :string + } + + data = %{id: "10", name: "john"} + assert {:ok, %{id: 10, name: "john"}} = Peri.validate(s, data) + end + end + + def integer_by_name(id, %{name: name}) do + if name != "john" do + String.to_integer(id) + else + String.to_integer(id) + 10 + end + end + + def integer_by_name(id, %{name: name}, make_sense?: sense) do + cond do + sense && name != "john" -> String.to_integer(id) - 10 + not sense && name == "john" -> String.to_integer(id) + true -> 42 + end + end + defschema(:either_transform, %{ value: {:either, {{:integer, {:transform, &double/1}}, {:string, {:transform, &upcase/1}}}} })