From 848eea72b1717345d32ffce69790381621c5337c Mon Sep 17 00:00:00 2001 From: Zoey de Souza Pessanha Date: Fri, 21 Jun 2024 14:09:45 -0300 Subject: [PATCH] feat: transform type --- CHANGELOG.md | 4 ++ README.md | 1 + lib/peri.ex | 30 ++++++-- mix.exs | 2 +- test/peri_test.exs | 170 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 201 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a03ec18..fdde020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to this project will be documented in this file. +## [0.2.4] - 2024-06-21 +- Implemented new type `{type, {:default, default}}`. [a569ecf, 821935f] +- Implemented new type `{type, {:transform, mapper}}`. [785179d] + ## [0.2.3] - 2024-06-18 ### Added diff --git a/README.md b/README.md index 8e85deb..f82ed4a 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Peri supports a variety of types to ensure your data is validated accurately. Be | `{:cond, condition, true_type, else_type}` | Conditionally validates a field based on the result of a condition function. | | `{:dependent, field, condition, type}` | Validates a field based on the value of another field. | | `{type, {:default, default}}` | Validates a field exists based on `type`, if not, return the `default` value | +| `{type, {:transform, mapper}}` | Validates a field have valid `type`, if yes, return the return of the `mapper/1` function passing the value | These types provide flexibility and control over how data is validated, enabling robust and precise schema definitions. diff --git a/lib/peri.ex b/lib/peri.ex index 059577d..8fa750a 100644 --- a/lib/peri.ex +++ b/lib/peri.ex @@ -317,6 +317,13 @@ defmodule Peri do defp validate_field(nil, _, _data), do: :ok + defp validate_field(val, {type, {:transform, mapper}}, data) + when is_function(mapper, 1) do + with :ok <- validate_field(val, type, data) do + {:ok, mapper.(val)} + end + end + defp validate_field(val, {:custom, callback}, _data) when is_function(callback, 1) do callback.(val) end @@ -361,6 +368,7 @@ defmodule Peri do |> Enum.reduce_while(:error, fn type, :error -> case validate_field(val, type, data) do :ok -> {:halt, :ok} + {:ok, val} -> {:halt, {:ok, val}} {:error, _reason, _info} -> {:cont, :error} end end) @@ -368,8 +376,11 @@ defmodule Peri do :ok -> :ok + {:ok, val} -> + {:ok, val} + :error -> - expected = Enum.map_join(types, " or ", &to_string/1) + expected = Enum.map_join(types, " or ", &inspect/1) info = [oneof: expected, actual: inspect(val)] template = "expected one of %{oneof}, got: %{actual}" @@ -417,20 +428,26 @@ defmodule Peri do end defp validate_field(data, {:list, type}, source) when is_list(data) do - Enum.reduce_while(data, {:ok, nil}, fn el, {:ok, val} -> + Enum.reduce_while(data, {:ok, []}, fn el, {:ok, vals} -> case validate_field(el, type, source) do - :ok -> {:cont, {:ok, val}} - {:ok, val} -> {:cont, {:ok, val}} + :ok -> {:cont, {:ok, vals}} + {:ok, val} -> {:cont, {:ok, [val | vals]}} {:error, errors} -> {:halt, {:error, errors}} {:error, reason, info} -> {:halt, {:error, reason, info}} end end) |> then(fn - {:ok, _} -> :ok + {:ok, []} -> :ok + {:ok, val} -> {:ok, Enum.reverse(val)} err -> err end) end + defp validate_field(data, schema, _data) + when is_enumerable(data) and not is_enumerable(schema) do + {:error, "expected a nested schema but received schema: %{type}", [type: schema]} + end + defp validate_field(data, schema, _data) when is_enumerable(data) do case traverse_schema(schema, Peri.Parser.new(data)) do %Peri.Parser{errors: []} = parser -> {:ok, parser.data} @@ -543,6 +560,9 @@ defmodule Peri do defp validate_type({type, {:default, _val}}, p), do: validate_type(type, p) defp validate_type({:enum, choices}, _) when is_list(choices), do: :ok + defp validate_type({type, {:transform, mapper}}, p) when is_function(mapper, 1), + 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/mix.exs b/mix.exs index 76a7df4..fcf4738 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Peri.MixProject do use Mix.Project - @version "0.2.3" + @version "0.2.4" @source_url "https://github.com/zoedsoupe/peri" def project do diff --git a/test/peri_test.exs b/test/peri_test.exs index 3943bc0..08f64d0 100644 --- a/test/peri_test.exs +++ b/test/peri_test.exs @@ -1342,4 +1342,174 @@ defmodule PeriTest do assert {:ok, ^data} = simple_tuple(data) end end + + defp double(x), do: x * 2 + defp upcase(str), do: String.upcase(str) + + defschema(:basic_transform, %{ + number: {:integer, {:transform, &double/1}}, + name: {:string, {:transform, &upcase/1}} + }) + + defschema(:nested_transform, %{ + user: %{ + age: {:integer, {:transform, &double/1}}, + profile: %{ + nickname: {:string, {:transform, &upcase/1}} + } + } + }) + + defschema(:list_transform, %{ + scores: {:list, {:integer, {:transform, &double/1}}} + }) + + describe "basic transform schema" do + test "applies transform function correctly" do + data = %{number: 5, name: "john"} + expected = %{number: 10, name: "JOHN"} + assert {:ok, ^expected} = basic_transform(data) + end + end + + describe "nested transform schema" do + test "applies transform function correctly in nested schema" do + data = %{user: %{age: 5, profile: %{nickname: "john"}}} + expected = %{user: %{age: 10, profile: %{nickname: "JOHN"}}} + assert {:ok, ^expected} = nested_transform(data) + end + end + + describe "list transform schema" do + test "applies transform function correctly in list schema" do + data = %{scores: [1, 2, 3]} + expected = %{scores: [2, 4, 6]} + assert {:ok, ^expected} = list_transform(data) + end + end + + defschema(:either_transform, %{ + value: {:either, {{:integer, {:transform, &double/1}}, {:string, {:transform, &upcase/1}}}} + }) + + defschema(:either_transform_mixed, %{ + value: {:either, {{:integer, {:transform, &double/1}}, :string}} + }) + + defschema(:oneof_transform, %{ + value: {:oneof, [{:integer, {:transform, &double/1}}, {:string, {:transform, &upcase/1}}]} + }) + + describe "either transform schema" do + test "applies transform function correctly for integer type" do + data = %{value: 5} + expected = %{value: 10} + assert {:ok, ^expected} = either_transform(data) + end + + test "applies transform function correctly for string type" do + data = %{value: "john"} + expected = %{value: "JOHN"} + assert {:ok, ^expected} = either_transform(data) + end + end + + describe "either transform mixed schema" do + test "applies transform function correctly for integer type" do + data = %{value: 5} + expected = %{value: 10} + assert {:ok, ^expected} = either_transform_mixed(data) + end + + test "applies transform function correctly for string type" do + data = %{value: "john"} + assert {:ok, ^data} = either_transform_mixed(data) + end + end + + describe "oneof transform schema" do + test "applies transform function correctly for integer type" do + data = %{value: 5} + expected = %{value: 10} + assert {:ok, ^expected} = oneof_transform(data) + end + + test "applies transform function correctly for string type" do + data = %{value: "john"} + expected = %{value: "JOHN"} + assert {:ok, ^expected} = oneof_transform(data) + end + end + + defschema(:mixed, %{ + id: {:integer, {:transform, &(&1 * 2)}}, + name: + {:either, + {{:string, {:transform, &String.upcase/1}}, {:atom, {:transform, &Atom.to_string/1}}}}, + tags: {:list, :string}, + info: + {:oneof, + [ + {:string, {:transform, &String.upcase/1}}, + %{ + age: {:integer, {:transform, &(&1 + 1)}}, + address: %{ + street: :string, + number: :integer + } + } + ]} + }) + + describe "massive mixed schema validation" do + test "validates and transforms mixed schema with integer id and string info" do + data = %{id: 5, name: :john, tags: ["elixir", "programming"], info: "some info"} + + expected = %{ + id: 10, + name: "john", + tags: ["elixir", "programming"], + info: "SOME INFO" + } + + assert {:ok, ^expected} = mixed(data) + end + + test "validates and transforms mixed schema with integer id and map info" do + data = %{ + id: 7, + name: "jane", + tags: ["elixir", "programming"], + info: %{age: 25, address: %{street: "Main St", number: 123}} + } + + expected = %{ + id: 14, + name: "JANE", + tags: ["elixir", "programming"], + info: %{age: 26, address: %{street: "Main St", number: 123}} + } + + assert {:ok, ^expected} = mixed(data) + end + + test "validates and transforms mixed schema with atom name" do + data = %{id: 8, name: :doe, tags: ["elixir"], info: "details"} + + expected = %{ + id: 16, + name: "doe", + tags: ["elixir"], + info: "DETAILS" + } + + assert {:ok, ^expected} = mixed(data) + end + + test "returns error for invalid info type in mixed schema" do + data = %{id: 8, name: "doe", tags: ["elixir"], info: 123} + assert {:error, errors} = mixed(data) + assert [%Peri.Error{path: [:info], message: _}] = errors + end + end end