defmodule Ash.Type.Map do @constraints [ fields: [ type: :keyword_list, keys: [ *: [ type: :keyword_list, keys: [ type: [ type: Ash.OptionsHelpers.ash_type(), required: true ], allow_nil?: [ type: :boolean, default: true ], constraints: [ type: :keyword_list, default: [] ] ] ] ], doc: """ The types of the fields in the map, and their constraints. If constraints are specified, only those fields will be in the casted map. For example: fields: [ amount: [ type: :integer, constraints: [ max: 10 ] ], currency: [ type: :string, allow_nil?: false, constraints: [ max_length: 3 ] ] ] allow_nil? is true by default """ ] ] def field_types(value) do {:ok, value} end @moduledoc """ Represents a map stored in the database. In postgres, for example, this represents binary encoded json A builtin type that can be referenced via `:map` #{Spark.OptionsHelpers.docs(@constraints)} """ use Ash.Type @impl true def constraints, do: @constraints @impl true def storage_type, do: :map @impl true def cast_input("", _), do: {:ok, nil} def cast_input(nil, _), do: {:ok, nil} def cast_input(value, constraints) when is_binary(value) do case Jason.decode(value) do {:ok, value} -> cast_input(value, constraints) _ -> :error end end def cast_input(value, _) when is_map(value), do: {:ok, value} def cast_input(_, _), do: :error @impl true def cast_stored(nil, _), do: {:ok, nil} def cast_stored(value, _) when is_map(value), do: {:ok, value} def cast_stored(_, _), do: :error @impl true def dump_to_native(nil, _), do: {:ok, nil} def dump_to_native(value, _) when is_map(value), do: {:ok, value} def dump_to_native(_, _), do: :error @impl true def apply_constraints(value, constraints) do Enum.reduce(constraints, {:ok, value}, fn {:fields, fields}, {:ok, value} -> check_fields(value, fields) {_, _}, {:error, errors} -> {:error, errors} end) end defp check_fields(value, fields) do Enum.reduce(fields, {:ok, %{}}, fn {field, field_constraints}, {:ok, checked_value} -> case fetch_field(value, field) do {:ok, field_value} -> check_field(checked_value, field, field_value, field_constraints) :error -> if field_constraints[:allow_nil?] == false do {:error, [[message: "field must be present", field: field]]} else {:ok, checked_value} end end {_, _}, {:error, errors} -> {:error, errors} end) end defp check_field(result, field, field_value, field_constraints) do case Ash.Type.cast_input( field_constraints[:type], field_value, field_constraints[:type] ) do {:ok, field_value} -> case Ash.Type.apply_constraints( field_constraints[:type], field_value, field_constraints[:constraints] ) do {:ok, nil} -> if field_constraints[:allow_nil?] == false do {:error, [[message: "value must not be nil", field: field]]} else {:ok, Map.put(result, field, nil)} end {:ok, field_value} -> {:ok, Map.put(result, field, field_value)} {:error, errors} -> {:error, Enum.map(errors, fn error -> [field: field, message: error[:message]] end)} end :error -> {:error, [[message: "invalid value", field: field]]} end end defp fetch_field(map, atom) when is_atom(atom) do case Map.fetch(map, atom) do {:ok, value} -> {:ok, value} :error -> fetch_field(map, to_string(atom)) end end defp fetch_field(map, key), do: Map.fetch(map, key) end