defmodule Dataset do
@moduledoc """
Schema based data casting for prototyping.
"""
@type t :: %__MODULE__{
data: map(),
params: map(),
errors: list() | map(),
valid?: boolean()
}
defstruct data: %{}, params: %{}, errors: %{}, valid?: true
@basic_types [
:integer,
:string,
:float
]
defp new(%__MODULE__{} = dataset), do: dataset
defp new(params), do: %__MODULE__{params: params}
@doc """
Casts the given data according to a schema
## Supported types
Dataset supports three primitive types: :integer, :float and :string, as well as
lists, a list of possible types, maps and nested maps, with the following syntaxes:
- `:integer`
- `:float`
- `:string`
- `{:integer, opts}`
- `{:float, opts}`
- `{:string, opts}`
- `{:list, subtype}`
- `{:list, subtype, opts}`
- `[:integer, :float, other_subtype]`
- `{:choice`, subtypes}`
- `{:choice, subtypes, opts}`
- `%{key: type}`
- `{:map, %{key: type}}`
- `{:map, %{key: type}, opts}`
## Options
Each type can be accompanied by an options list as the list element of the type
tuple. The supported options are:
- `:required`: when `true`, an error is returned if the value is `nil` or not present.
- `:default`: if the value is not required and is not present in the data, this default
valued will be used instead.
"""
@spec cast(any, any) :: {:ok, any()} | {:error, t()}
def cast(params, schema) do
dataset =
params
|> new()
|> do_cast(schema)
if dataset.valid? do
{:ok, dataset.data}
else
{:error, dataset}
end
end
defp do_cast(dataset, schema) do
type = expand_type(schema)
case cast_fun(type, dataset.params) do
{:ok, data} ->
%{dataset | data: data}
{:error, reason} ->
%{dataset | valid?: false, errors: reason}
:no_value ->
if type.opts[:required] do
%{dataset | valid?: false, errors: ["must be present"]}
else
%{dataset | data: type.opts[:default]}
end
end
end
defp expand_type(schema) when is_map(schema) do
schema = Map.new(schema, fn {key, type} -> {key, expand_type(type)} end)
%{type: :schema, schema: schema, opts: []}
end
defp expand_type({:map, schema}) when is_map(schema), do: expand_type(schema)
defp expand_type({:map, schema, opts}) when is_map(schema) do
type = expand_type(schema)
%{type | opts: opts}
end
defp expand_type(type) when type in @basic_types, do: %{type: type, opts: []}
defp expand_type({type, opts}) when type in @basic_types, do: %{type: type, opts: opts}
defp expand_type({:list, type}), do: %{type: :list, subtype: expand_type(type), opts: []}
defp expand_type({:list, type, opts}),
do: %{type: :list, subtype: expand_type(type), opts: opts}
defp expand_type([_ | _] = choices),
do: %{type: :choice, choices: Enum.map(choices, &expand_type/1), opts: []}
defp expand_type({:choice, choices}), do: expand_type(choices)
defp expand_type({:choice, choices, opts}) do
type = expand_type(choices)
%{type | opts: opts}
end
defp expand_type({:const, value}) when is_binary(value) or is_number(value) do
%{type: :const, value: value, opts: []}
end
defp expand_type({:const, value, opts}) when is_binary(value) or is_number(value) do
%{type: :const, value: value, opts: opts}
end
defp cast_fun(_type, nil), do: :no_value
defp cast_fun(%{type: :integer} = type, data), do: cast_integer(type, data)
defp cast_fun(%{type: :float} = type, data), do: cast_float(type, data)
defp cast_fun(%{type: :string} = type, data), do: cast_string(type, data)
defp cast_fun(%{type: :list} = type, data), do: cast_list(type, data)
defp cast_fun(%{type: :schema, schema: schema}, data) when is_map(schema) do
cast_schema(schema, data)
end
defp cast_fun(%{type: :choice, choices: choices}, data) do
result =
Enum.reduce_while(choices, [], fn choice, errors ->
case cast_fun(choice, data) do
{:ok, value} -> {:halt, {:ok, value}}
{:error, reason} -> {:cont, [reason | errors]}
:no_value -> {:cont, errors}
end
end)
case result do
{:ok, value} -> {:ok, value}
errors when is_list(errors) -> {:error, errors}
end
end
defp cast_fun(%{type: :const, value: value}, data) do
if data == value do
{:ok, data}
else
{:error, "invalue, must be the constant #{inspect(value)}"}
end
end
defp cast_integer(_type, data) when is_integer(data), do: {:ok, data}
defp cast_integer(_type, data) when is_binary(data) do
case Integer.parse(data) do
{integer, ""} -> {:ok, integer}
_ -> {:error, ["must be a valid integer representation"]}
end
end
defp cast_integer(_type, _data),
do: {:error, ["must be a valid integer representation"]}
defp cast_float(_type, data) when is_float(data), do: {:ok, data}
defp cast_float(_type, data) when is_binary(data) do
case Float.parse(data) do
{float, ""} -> {:ok, float}
_ -> {:error, ["must be a valid float representation"]}
end
end
defp cast_float(_type, _data),
do: {:error, ["must be a valid float representation"]}
defp cast_string(_type, data) when is_binary(data), do: {:ok, data}
defp cast_string(_type, _), do: {:error, ["must be a string"]}
defp cast_list(%{subtype: type}, data) when is_list(data) do
casted =
for {item, index} <- Stream.with_index(data) do
case cast_fun(type, item) do
{:ok, value} -> value
{:error, reason} -> throw({:error, index, reason})
end
end
{:ok, casted}
catch
{:error, index, reason} ->
{:error, %{index => reason}}
end
defp cast_list(_type, _data), do: {:error, ["must be a list"]}
defp cast_schema(schema, params) when is_map(params) do
casted = for {key, type} <- schema, do: cast_schema_field(key, type, params)
case Enum.split_with(casted, &match?({:ok, _}, &1)) do
{casted, []} ->
casted = for {:ok, value} <- casted, into: %{}, do: value
{:ok, casted}
{_, errors} ->
errors = for {:error, error} <- errors, into: %{}, do: error
{:error, errors}
end
end
defp cast_schema(_schema, _params), do: {:error, ["must be a map"]}
defp cast_schema_field(key, type, params) do
case cast_fun(type, params[key] || params[to_string(key)]) do
{:ok, value} ->
{:ok, {key, value}}
{:error, reason} ->
{:error, {key, reason}}
:no_value ->
if type.opts[:required] do
{:error, {key, ["must be present"]}}
else
{:ok, {key, type.opts[:default]}}
end
end
end
end
{:module, Dataset, <<70, 79, 82, 49, 0, 0, 42, ...>>, {:cast_schema_field, 3}}
ExUnit.start()
defmodule DatasetTest do
use ExUnit.Case, async: true
describe "cast/2" do
test "casts strings" do
assert {:ok, "hello"} = Dataset.cast("hello", :string)
assert {:error, %Dataset{errors: ["must be a string"]}} = Dataset.cast(42, :string)
end
test "casts integers" do
assert {:ok, 42} = Dataset.cast(42, :integer)
assert {:ok, 42} = Dataset.cast("42", :integer)
assert {:error, %Dataset{errors: ["must be a valid integer representation"]}} =
Dataset.cast("hello", :integer)
end
test "casts floats" do
assert {:ok, 42.0} = Dataset.cast(42.0, :float)
assert {:ok, 42.0} = Dataset.cast("42.0", :float)
assert {:error, %Dataset{errors: ["must be a valid float representation"]}} =
Dataset.cast("hello", :float)
end
test "casts lists" do
assert {:ok, [42]} = Dataset.cast([42], {:list, :integer})
assert {:error, %Dataset{errors: ["must be a list"]}} =
Dataset.cast("hello", {:list, :integer})
end
test "casts a choice of values" do
assert {:ok, 42} = Dataset.cast("42", [:integer, :float])
assert {:ok, 42} = Dataset.cast("42", {:choice, [:integer, :float]})
assert {:error,
%Dataset{
errors: [
["must be a valid float representation"],
["must be a valid integer representation"]
]
}} = Dataset.cast("hello", [:integer, :float])
end
test "casts a map" do
assert {:ok, %{foo: 42, bar: "hello"}} =
Dataset.cast(%{"foo" => 42, :bar => "hello"}, %{foo: :integer, bar: :string})
assert {:ok, %{foo: 42, bar: nil}} =
Dataset.cast(%{"foo" => 42}, %{foo: :integer, bar: :string})
assert {:error,
%Dataset{
errors: %{
foo: ["must be a valid integer representation"],
bar: ["must be a string"]
}
}} = Dataset.cast(%{"foo" => 42.0, :bar => 42}, %{foo: :integer, bar: :string})
end
test "casts nested maps" do
assert {:ok, %{foo: 42, bar: %{baz: "hello"}}} =
Dataset.cast(%{"foo" => 42, :bar => %{baz: "hello"}}, %{
foo: :integer,
bar: %{baz: :string}
})
assert {:error, %Dataset{errors: %{bar: ["must be a map"]}}} =
Dataset.cast(%{"foo" => 42, :bar => 42}, %{foo: :integer, bar: %{baz: :string}})
assert {:error, %Dataset{errors: %{bar: %{baz: ["must be a string"]}}}} =
Dataset.cast(%{"foo" => 42, :bar => %{baz: 42}}, %{
foo: :integer,
bar: %{baz: :string}
})
end
test "validates required values" do
assert {:error, %Dataset{errors: ["must be present"]}} =
Dataset.cast(nil, {:choice, [:integer, :float], required: true})
end
test "default values" do
assert {:ok, 42} = Dataset.cast(nil, {:integer, default: 42})
end
end
end
ExUnit.run()
.........
Finished in 0.00 seconds (0.00s async, 0.00s sync)
9 tests, 0 failures
Randomized with seed 236959
%{excluded: 0, failures: 0, skipped: 0, total: 9}
schema = %{
foo: %{
bar: %{
baz: {:list, [:integer, :string, :float], required: true},
qux: :float,
quux: :integer,
quuz: {:string, default: "I'm a default!"}
}
}
}
data = %{
"foo" => %{
bar: %{
baz: [42, "3.1e4", "hello!"],
qux: "1.01a",
quux: "5e4"
}
}
}
Dataset.cast(data, schema)
{:error,
%Dataset{
data: %{},
params: %{"foo" => %{bar: %{baz: [42, "3.1e4", "hello!"], quux: "5e4", qux: "1.01a"}}},
errors: %{
foo: %{
bar: %{
quux: ["must be a valid integer representation"],
qux: ["must be a valid float representation"]
}
}
},
valid?: false
}}
data = %{
"foo" => %{
bar: %{
baz: [42, 3.14, "hello!"],
qux: "1.01",
quux: "54"
}
}
}
Dataset.cast(data, schema)
{:ok, %{foo: %{bar: %{baz: [42, 3.14, "hello!"], quux: 54, quuz: "I'm a default!", qux: 1.01}}}}