diff --git a/lib/peri.ex b/lib/peri.ex index 3e7dbd8..203f705 100644 --- a/lib/peri.ex +++ b/lib/peri.ex @@ -155,8 +155,53 @@ defmodule Peri do end end - defguardp is_enumerable(data) when is_map(data) or is_list(data) + @doc """ + Checks if the given data is an enumerable, specifically a map or a list. + + ## Parameters + + - `data`: The data to check. + + ## Examples + + iex> is_enumerable(%{}) + true + + iex> is_enumerable([]) + true + + iex> is_enumerable(123) + false + + iex> is_enumerable("string") + false + """ + defguard is_enumerable(data) when is_map(data) or is_list(data) + + @doc """ + Checks if the given data conforms to the specified schema. + + ## Parameters + - `schema`: The schema definition to validate against. + - `data`: The data to be validated. + + ## Returns + + - `true` if the data conforms to the schema. + - `false` if the data does not conform to the schema. + + ## Examples + + iex> schema = %{name: :string, age: :integer} + iex> data = %{name: "Alice", age: 30} + iex> Peri.conforms?(schema, data) + true + + iex> invalid_data = %{name: "Alice", age: "thirty"} + iex> Peri.conforms?(schema, invalid_data) + false + """ def conforms?(schema, data) do case validate(schema, data) do {:ok, _} -> true @@ -164,6 +209,42 @@ defmodule Peri do end end + if Code.ensure_loaded?(StreamData) do + @doc """ + Generates sample data based on the given schema definition using `StreamData`. + + This function validates the schema first, and if the schema is valid, it uses the + `Peri.Generatable.gen/1` function to generate data according to the schema. + + Note that this function returns a `Stream`, so you traverse easily the data generations. + + ## Parameters + + - `schema`: The schema definition to generate data for. + + ## Returns + + - `{:ok, stream}` if the data is successfully generated. + - `{:error, errors}` if there are validation errors in the schema. + + ## Examples + + iex> schema = %{name: :string, age: {:integer, {:range, {18, 65}}}} + iex> {:ok, stream} = Peri.generate(schema) + iex> [data] = Enum.take(stream, 1) + iex> is_map(data) + true + iex> data[:age] in 18..65 + true + + """ + def generate(schema) do + with {:ok, schema} <- validate_schema(schema) do + {:ok, Peri.Generatable.gen(schema)} + end + end + end + @doc """ Validates a given data map against a schema. @@ -279,8 +360,54 @@ defmodule Peri do end end - defguardp is_numeric(n) when is_integer(n) or is_float(n) - defguardp is_numeric_type(t) when t in [:integer, :float] + @doc """ + Checks if the given data is a numeric value, specifically a integer or a float. + + ## Parameters + + - `data`: The data to check. + + ## Examples + + iex> is_numeric(123) + true + + iex> is_numeric(0xFF) + true + + iex> is_numeric(12.12) + true + + iex> is_numeric("string") + false + + iex> is_numeric(%{}) + false + """ + defguard is_numeric(n) when is_integer(n) or is_float(n) + + @doc """ + Checks if the given type as an atom is a numeric (integer or float). + + ## Parameters + + - `data`: The data to check. + + ## Examples + + iex> is_numeric(:integer) + true + + iex> is_numeric(:float) + true + + iex> is_numeric(:list) + false + + iex> is_numeric({:enum, _}) + false + """ + defguard is_numeric_type(t) when t in [:integer, :float] @doc false defp validate_field(nil, nil, _data), do: :ok diff --git a/lib/peri/generatable.ex b/lib/peri/generatable.ex new file mode 100644 index 0000000..181dd90 --- /dev/null +++ b/lib/peri/generatable.ex @@ -0,0 +1,141 @@ +if Code.ensure_loaded?(StreamData) do + defmodule Peri.Generatable do + require Peri + + def gen(:atom), do: StreamData.atom(:alphanumeric) + def gen(:string), do: StreamData.string(:alphanumeric) + def gen(:integer), do: StreamData.integer() + def gen(:float), do: StreamData.float() + def gen(:boolean), do: StreamData.boolean() + + def gen({:enum, choices}) do + choices + |> Enum.map(&StreamData.constant/1) + |> StreamData.one_of() + end + + def gen({:list, type}) do + type + |> gen() + |> StreamData.list_of() + end + + def gen({:tuple, types}) do + types + |> Enum.map(&gen/1) + |> List.to_tuple() + |> StreamData.tuple() + end + + def gen({type, {:eq, eq}}) when Peri.is_numeric_type(type) do + StreamData.constant(eq) + end + + def gen({type, {:neq, neq}}) when Peri.is_numeric_type(type) do + stream = gen(type) + StreamData.filter(stream, &(&1 != neq)) + end + + def gen({type, {:gt, gt}}) when Peri.is_numeric_type(type) do + stream = gen(type) + StreamData.filter(stream, &(&1 > gt)) + end + + def gen({type, {:gte, gte}}) when Peri.is_numeric_type(type) do + stream = gen(type) + StreamData.filter(stream, &(&1 >= gte)) + end + + def gen({type, {:lt, lt}}) when Peri.is_numeric_type(type) do + stream = gen(type) + StreamData.filter(stream, &(&1 < lt)) + end + + def gen({type, {:lte, lte}}) when Peri.is_numeric_type(type) do + stream = gen(type) + StreamData.filter(stream, &(&1 <= lte)) + end + + def gen({type, {:range, {min, max}}}) when Peri.is_numeric_type(type) do + stream = gen(type) + StreamData.filter(stream, &(&1 in min..max)) + end + + def gen({:string, {:regex, regex}}) do + stream = gen(:string) + StreamData.filter(stream, &Regex.match?(regex, &1)) + end + + def gen({:string, {:eq, eq}}) do + StreamData.constant(eq) + end + + def gen({:string, {:min, min}}) do + stream = gen(:string) + StreamData.filter(stream, &(String.length(&1) >= min)) + end + + def gen({:string, {:max, max}}) do + stream = gen(:string) + StreamData.filter(stream, &(String.length(&1) <= max)) + end + + def gen({type, {:default, _}}), do: gen(type) + def gen({type, {:dependent, _, _, _}}), do: gen(type) + + def gen({type, {:transform, mapper}}) do + stream = gen(type) + StreamData.map(stream, mapper) + end + + def gen({:either, {type_1, type_2}}) do + stream_1 = gen(type_1) + stream_2 = gen(type_2) + StreamData.one_of([stream_1, stream_2]) + end + + def gen({:oneof, types}) do + types + |> Enum.map(&gen/1) + |> StreamData.one_of() + end + + def gen({:custom, {mod, fun}}) do + Stream.filter(StreamData.term(), fn val -> + case apply(mod, fun, [val]) do + :ok -> true + {:ok, _} -> true + {:error, _reason, _info} -> false + end + end) + end + + def gen({:custom, {mod, fun, args}}) do + Stream.filter(StreamData.term(), fn val -> + case apply(mod, fun, [val | args]) do + :ok -> true + {:ok, _} -> true + {:error, _reason, _info} -> false + end + end) + end + + def gen({:custom, cb}) do + Stream.filter(StreamData.term(), fn val -> + case cb.(val) do + :ok -> true + {:ok, _} -> true + {:error, _reason, _info} -> false + end + end) + end + + def gen(schema) when Peri.is_enumerable(schema) do + for {k, v} <- schema do + stream = gen(v) + {k, stream} + end + |> StreamData.fixed_map() + end + end +end diff --git a/mix.exs b/mix.exs index 15a94f3..99f9a77 100644 --- a/mix.exs +++ b/mix.exs @@ -26,6 +26,7 @@ defmodule Peri.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ + {:stream_data, "~> 1.1", optional: true}, {:credo, "~> 1.7", only: :dev, runtime: false}, {:ex_doc, "~> 0.14", only: :dev, runtime: false} ]