Skip to content

Commit

Permalink
Access data fields on transformation functions (#14)
Browse files Browse the repository at this point in the history
* feat: access data fields on transformation funs

- minor fix of `:transform` directive not accepting
MFAs on schema definition validation
- passing root data to the `mapper` function
as second argument

* increment docs and add tests cases for transform with MFA
  • Loading branch information
zoedsoupe authored Nov 26, 2024
1 parent 7d37e84 commit 6520de5
Show file tree
Hide file tree
Showing 3 changed files with 173 additions and 4 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 38 additions & 2 deletions lib/peri.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]}
Expand Down
132 changes: 132 additions & 0 deletions test/peri_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand All @@ -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}}}}
})
Expand Down

0 comments on commit 6520de5

Please sign in to comment.