Skip to content

Instantly share code, notes, and snippets.

@doorgan
Last active January 12, 2023 19:09
Show Gist options
  • Save doorgan/5085bb3530a4ca9aaa252ef3614fc681 to your computer and use it in GitHub Desktop.
Save doorgan/5085bb3530a4ca9aaa252ef3614fc681 to your computer and use it in GitHub Desktop.

Mini validations lib

Section

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}}}}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment