lib/paper_trail/serializer.ex

defmodule PaperTrail.Serializer do
  import Ecto.Query

  alias PaperTrail.RepoClient
  alias PaperTrail.Version

  @type options :: PaperTrail.options()

  @default_ignored_ecto_types [Ecto.UUID, :binary_id, :binary]

  def make_version_struct(%{event: "insert"}, model, options) do
    originator = RepoClient.originator()
    originator_ref = options[originator[:name]] || options[:originator]

    %Version{
      event: "insert",
      item_type: get_item_type(model),
      item_id: get_model_id(model),
      item_changes: serialize(model, options),
      originator_id:
        case originator_ref do
          nil -> nil
          _ -> originator_ref |> Map.get(:id)
        end,
      origin: options[:origin],
      meta: options[:meta]
    }
    |> add_prefix(options[:prefix])
  end

  def make_version_struct(%{event: "update"}, changeset, options) do
    originator = RepoClient.originator()
    originator_ref = options[originator[:name]] || options[:originator]

    %Version{
      event: "update",
      item_type: get_item_type(changeset),
      item_id: get_model_id(changeset),
      item_changes: serialize(changeset, options, "update"),
      originator_id:
        case originator_ref do
          nil -> nil
          _ -> originator_ref |> Map.get(:id)
        end,
      origin: options[:origin],
      meta: options[:meta]
    }
    |> add_prefix(options[:prefix])
  end

  def make_version_struct(%{event: "delete"}, model_or_changeset, options) do
    originator = RepoClient.originator()
    originator_ref = options[originator[:name]] || options[:originator]

    %Version{
      event: "delete",
      item_type: get_item_type(model_or_changeset),
      item_id: get_model_id(model_or_changeset),
      item_changes: serialize(model_or_changeset, options),
      originator_id:
        case originator_ref do
          nil -> nil
          _ -> originator_ref |> Map.get(:id)
        end,
      origin: options[:origin],
      meta: options[:meta]
    }
    |> add_prefix(options[:prefix])
  end

  @spec make_version_structs(map, PaperTrail.queryable(), Keyword.t() | map, PaperTrail.options()) ::
          [map]
  def make_version_structs(%{event: "update"}, queryable, changes, options) do
    {_table, schema} = queryable.from.source
    item_type = schema |> struct() |> get_item_type()
    [primary_key] = schema.__schema__(:primary_key)
    changes_map = Map.new(changes)
    originator = RepoClient.originator()
    originator_ref = options[originator[:name]] || options[:originator]
    originator_id = if(originator_ref, do: originator_ref.id, else: nil)
    origin = options[:origin]
    meta = options[:meta]
    repo = RepoClient.repo(options)

    repo.all(
      from(q in queryable,
        select: %{
          event: type(^"update", :string),
          item_type: type(^item_type, :string),
          item_id: field(q, ^primary_key),
          item_changes: type(^changes_map, :map),
          originator_id: type(^originator_id, :string),
          origin: type(^origin, :string),
          meta: type(^meta, :map),
          inserted_at: type(fragment("CURRENT_TIMESTAMP"), :naive_datetime)
        }
      )
    )
  end

  def get_sequence_from_model(changeset, options \\ []) do
    table_name =
      case Map.get(changeset, :data) do
        nil -> changeset.__struct__.__schema__(:source)
        _ -> changeset.data.__struct__.__schema__(:source)
      end

    get_sequence_id(table_name, options)
  end

  def get_sequence_id(table_name, options) do
    Ecto.Adapters.SQL.query!(
      RepoClient.repo(options),
      "select last_value FROM #{table_name}_id_seq"
    ).rows
    |> List.first()
    |> List.first()
  end

  @spec serialize(
          nil | Ecto.Changeset.t() | struct() | [Ecto.Changeset.t() | struct()],
          options(),
          String.t()
        ) :: nil | map() | [map()]
  def serialize(model, options, event \\ "insert")

  def serialize(nil, _options, _event), do: nil

  def serialize(list, options, event) when is_list(list) do
    Enum.map(list, &serialize(&1, options, event))
  end

  def serialize(
        %Ecto.Changeset{data: %schema{}, changes: changes},
        options,
        "update"
      ) do
    changes
    |> schema.__struct__()
    |> do_serialize(options, "update", Map.keys(changes))
  end

  def serialize(%Ecto.Changeset{data: data}, options, event) do
    do_serialize(data, options, event)
  end

  def serialize(%_schema{} = model, options, event), do: do_serialize(model, options, event)

  @spec do_serialize(struct, options, String.t(), [atom] | nil) :: map
  def do_serialize(%schema{} = model, options, event, changed_fields \\ nil) do
    fields = changed_fields || schema.__schema__(:fields)
    repo = RepoClient.repo(options)
    {adapter, _adapter_meta} = Ecto.Repo.Registry.lookup(repo.get_dynamic_repo())
    changes = model |> Map.from_struct() |> Map.take(fields)
    associations = serialize_associations(model, options, event)

    changes
    |> Map.take(schema.__schema__(:fields))
    |> Enum.map(&dump_field!(&1, schema, adapter, options, event))
    |> Map.new()
    |> Map.merge(associations)
  end

  @spec serialize_associations(struct, options, String.t()) :: map
  defp serialize_associations(%schema{} = model, options, event) do
    association_fields = schema.__schema__(:associations)

    model
    |> Map.take(association_fields)
    |> Enum.filter(fn {_field, value} -> Ecto.assoc_loaded?(value) end)
    |> Enum.map(fn {field, value} -> {field, serialize(value, options, event)} end)
    |> Map.new()
  end

  @spec dump_field!({atom, any}, module, module, options, String.t()) :: {atom, any}
  defp dump_field!({field, %Ecto.Changeset{} = value}, _schema, _adapter, options, event) do
    {field, serialize(value, options, event)}
  end

  defp dump_field!({field, value}, schema, adapter, _options, _event) do
    dumper = schema.__schema__(:dump)
    {alias, type} = Map.fetch!(dumper, field)

    dumped_value =
      if(
        type in ignored_ecto_types(),
        do: serialize_binary(value),
        else: do_dump_field!(schema, field, type, value, adapter)
      )

    {alias, dumped_value}
  end

  @spec do_dump_field!(module, atom, atom, any, module) :: any
  defp do_dump_field!(schema, field, type, value, adapter) do
    case Ecto.Type.adapter_dump(adapter, type, value) do
      {:ok, value} ->
        value

      :error ->
        raise Ecto.ChangeError,
              "value `#{inspect(value)}` for `#{inspect(schema)}.#{field}` " <>
                "does not match type #{inspect(type)}"
    end
  end

  def add_prefix(changeset, nil), do: changeset
  def add_prefix(changeset, prefix), do: Ecto.put_meta(changeset, prefix: prefix)

  def get_item_type(%Ecto.Changeset{data: data}), do: get_item_type(data)
  def get_item_type(%schema{}), do: schema |> Module.split() |> List.last()

  def get_model_id(%Ecto.Changeset{data: data}), do: get_model_id(data)

  def get_model_id(model) do
    {_, model_id} = List.first(Ecto.primary_key(model))

    case PaperTrail.Version.__schema__(:type, :item_id) do
      :integer ->
        model_id

      _ ->
        "#{model_id}"
    end
  end

  @spec ignored_ecto_types :: [atom]
  defp ignored_ecto_types do
    :not_dumped_ecto_types
    |> get_env([])
    |> Kernel.++(@default_ignored_ecto_types)
    |> Enum.uniq()
  end

  @spec get_env(atom, any) :: any
  defp get_env(key, default), do: Application.get_env(:paper_trail, key, default)

  @spec serialize_binary(binary()) :: String.t() | [integer()]
  defp serialize_binary(binary) when is_binary(binary) do
    if String.valid?(binary) do
      binary
    else
      :binary.bin_to_list(binary)
    end
  end

  defp serialize_binary(value), do: value
end