lib/exandra/embedded_type.ex

defmodule Exandra.EmbeddedType do
  opts_schema = [
    cardinality: [
      type: :atom,
      required: false,
      doc:
        "`:one` for a regular UDT column, or `:many` for a frozen list of UDTs. default is `:one`"
    ],
    using: [
      type: :atom,
      required: true,
      doc: "The Ecto.Schema to use for the UDT."
    ],
    field: [
      type: :atom,
      doc: false
    ],
    schema: [
      type: :atom,
      doc: false
    ]
  ]

  @moduledoc """
  `Ecto.ParameterizedType` for **User-Defined Types** (UDTs) with Ecto.Schemas.

  ## Options

  #{NimbleOptions.docs(opts_schema)}

  ## Examples

    defmodule PageView do
      use Ecto.Schema
      use Exandra.Embedded

      schema "page_views" do
        field :url, :string
        embedded_type :view_meta, ViewMeta
      end

      def changeset(entity, params) do
        entity
        |> cast(params, [:url, :view_meta])
      end
    end

  """
  use Ecto.ParameterizedType

  defstruct [
    :cardinality,
    :field,
    :using
  ]

  @type t :: any()

  @opts_schema NimbleOptions.new!(opts_schema)

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

  @impl Ecto.ParameterizedType
  def init(opts) do
    NimbleOptions.validate!(opts, @opts_schema)

    cardinality = Keyword.get(opts, :cardinality, :one)
    opts = Keyword.put(opts, :cardinality, cardinality)
    struct(__MODULE__, opts)
  end

  @impl Ecto.ParameterizedType
  def type(_), do: :exandra_embedded_type

  @impl Ecto.ParameterizedType
  def cast(nil, %{cardinality: :one}), do: {:ok, nil}

  def cast(nil, %{cardinality: :many}), do: {:ok, []}

  def cast(%struct{} = data, %{cardinality: :one, using: struct}), do: {:ok, data}

  def cast(data, %{cardinality: :one, using: struct}) do
    autogenerated_fields =
      (struct.__schema__(:primary_key) ++ struct.__schema__(:autogenerate_fields))
      |> Enum.into(%{}, fn field ->
        type = struct.__schema__(:type, field)
        {field, Exandra.autogenerate(type)}
      end)

    struct
    |> struct(%{})
    |> struct.changeset(data)
    |> Ecto.Changeset.apply_action(:insert)
    |> case do
      {:ok, casted} ->
        {:ok, Map.merge(casted, autogenerated_fields)}

      {:error, _changeset} ->
        :error
    end
  end

  def cast(data, %{cardinality: :many, using: struct}) do
    data
    |> Enum.reduce_while([], fn datum, acc ->
      case cast(datum, %{cardinality: :one, using: struct}) do
        {:ok, casted} -> {:cont, [casted | acc]}
        :error -> {:halt, :error}
      end
    end)
    |> case do
      :error -> :error
      casted_list -> {:ok, Enum.reverse(casted_list)}
    end
  end

  @impl Ecto.ParameterizedType
  def load(nil, _loader, %{cardinality: :one}), do: {:ok, nil}
  def load(nil, _loader, %{cardinality: :many}), do: {:ok, []}

  def load(value, loader, %{cardinality: :one, using: struct}) do
    {:ok, Ecto.Schema.Loader.unsafe_load(struct, value, loader)}
  end

  def load(value, loader, %{cardinality: :many, using: struct}) do
    {:ok, Enum.map(value, &Ecto.Schema.Loader.unsafe_load(struct, &1, loader))}
  end

  def load(_data, _loader, _opts), do: :error

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

  def dump(%struct{} = data, dumper, %{cardinality: :one, using: struct}) do
    {:ok, dump_field(data, struct.__schema__(:dump), dumper)}
  end

  def dump(data, dumper, %{cardinality: :many, using: struct}) do
    {:ok, Enum.map(data, &dump_field(&1, struct.__schema__(:dump), dumper))}
  end

  def dump(_data, _dumper, _opts), do: :error

  defp dump_field(data, types, dumper) do
    data
    |> Ecto.Schema.Loader.safe_dump(types, dumper)
    |> Map.new(fn {field, dumped} -> {Atom.to_string(field), dumped} end)
  end

  # From Ecto.Type.
  @doc false
  @impl Ecto.ParameterizedType
  def embed_as(_format, _params), do: :dump
end