lib/exandra/set.ex

defmodule Exandra.Set do
  opts_schema = [
    type: [
      type: :atom,
      required: true,
      doc: "The type of the elements in the set."
    ],
    field: [
      type: :atom,
      doc: false
    ],
    schema: [
      type: :atom,
      doc: false
    ]
  ]

  @moduledoc """
  `Ecto.ParameterizedType` for sets.

  ## Options

  #{NimbleOptions.docs(opts_schema)}

  ## Examples

      schema "users" do
        field :email, :string
        field :roles, Exandra.Set, type: :string
      end

  """

  use Ecto.ParameterizedType

  @type t() :: MapSet.t()

  @opts_schema NimbleOptions.new!(opts_schema)

  # Made public for testing.
  @doc false
  def params(embed), do: %{embed: embed}

  @impl Ecto.ParameterizedType
  def type(_opts), do: :exandra_set

  @impl Ecto.ParameterizedType
  def init(opts) do
    opts
    |> NimbleOptions.validate!(@opts_schema)
    |> Map.new()
  end

  @impl Ecto.ParameterizedType
  def cast(nil, _), do: {:ok, MapSet.new()}

  def cast({op, val}, opts) when op in [:add, :remove] do
    case cast(val, opts) do
      {:ok, casted} -> {:ok, {op, casted}}
      other -> other
    end
  end

  def cast(%MapSet{} = set, opts), do: set |> MapSet.to_list() |> cast(opts)

  def cast(list, %{type: type}) when is_list(list) do
    casted =
      Enum.reduce_while(list, [], fn elem, acc ->
        case Ecto.Type.cast(type, elem) do
          {:ok, casted} -> {:cont, [casted | acc]}
          err -> {:halt, err}
        end
      end)

    if is_list(casted), do: {:ok, MapSet.new(casted)}, else: casted
  end

  def cast(val, %{type: type}) do
    case Ecto.Type.cast(type, val) do
      {:ok, casted} -> {:ok, MapSet.new([casted])}
      err -> err
    end
  end

  def cast(_key, _val), do: :error

  @impl Ecto.ParameterizedType
  def load(%MapSet{} = mapset, _loader, %{type: type}) do
    loaded =
      Enum.reduce_while(mapset, [], fn elem, acc ->
        case Ecto.Type.cast(type, elem) do
          {:ok, loaded} -> {:cont, [loaded | acc]}
          err -> {:halt, err}
        end
      end)

    if is_list(loaded), do: {:ok, MapSet.new(loaded)}, else: :error
  end

  def load(nil, _, _), do: {:ok, %MapSet{}}

  def load(_field_name, loader, field) do
    load(%MapSet{}, loader, field)
  end

  @impl Ecto.ParameterizedType
  def dump(mapset, _dumper, _opts), do: {:ok, mapset}

  @impl Ecto.ParameterizedType
  def equal?({_, _}, _, _), do: false
  def equal?(_, {_, _}, _), do: false
  def equal?(nil, nil, _), do: true
  def equal?(nil, data, _), do: Enum.empty?(data)
  def equal?(data, nil, _), do: Enum.empty?(data)
  def equal?(%MapSet{} = a, %MapSet{} = b, _), do: MapSet.equal?(a, b)
  def equal?(_, _, _), do: false

  # From Ecto.Type
  @doc false
  def embed_as(_format), do: :self

  defimpl Jason.Encoder, for: MapSet do
    def encode(set, opts) do
      Jason.Encode.list(MapSet.to_list(set), opts)
    end
  end
end