lib/schema_extractor.ex

defmodule Mecto.SchemaExtractor do
  @moduledoc """
  Converts an Ecto schema into a map, for use in `Mecto.SchemaValidator`.
  """

  @type cardinality :: :one | :many
  @type association :: {:direct, atom()} | {:indirect, atom()}

  @spec convert_from(module()) :: map() | {:error, atom()}
  def convert_from(module) do
    case Code.ensure_compiled(module) do
      {:module, module} ->
        if function_exported?(module, :__schema__, 1) do
          convert_schema_to_map(module, [module])
        else
          {:error, :missing_schema}
        end

      error ->
        error
    end
  end

  @spec convert_schema_to_map(module(), list()) :: map()
  defp convert_schema_to_map(module, visited) do
    fields =
      :fields
      |> module.__schema__()
      |> Enum.map(fn field -> {field, module.__schema__(:type, field)} end)

    {embedded_associations, fields} =
      Enum.split_with(fields, fn
        {_, {:parameterized, {Ecto.Embedded, _}}} -> true
        _field -> false
      end)

    fields =
      fields
      |> Enum.map(&map_custom_type/1)
      |> Enum.into(%{})

    fields =
      embedded_associations
      |> Enum.map(fn {field, _} -> field end)
      |> Enum.map(&list_associations(module, &1))
      |> Enum.group_by(fn {type, _} -> type end)
      |> Map.get(:direct, [])
      |> Enum.map(&map_association(module, visited, &1))
      |> Enum.into(fields)

    associations =
      :associations
      |> module.__schema__()
      |> Enum.map(&list_associations(module, &1))
      |> Enum.group_by(fn {type, _} -> type end)

    associations
    |> Map.get(:direct, [])
    |> Enum.map(&map_association(module, visited, &1))
    |> Enum.reject(&is_nil/1)
    |> Enum.into(fields)
    |> link_indirect_associations(module, Map.get(associations, :indirect))
  end

  @spec list_associations(module(), atom()) :: association()
  defp list_associations(module, association) do
    relationship = get_relationship(module, association)

    case Map.get(relationship || %{}, :related) do
      nil -> {:indirect, association}
      _otherwise -> {:direct, association}
    end
  end

  @spec map_association(module(), list(), {:direct, atom()}) ::
          {atom(), {cardinality(), map()}} | nil
  defp map_association(module, visited, {:direct, association}) do
    relationship = get_relationship(module, association) || %{}
    associated_module = Map.get(relationship, :related)

    if Code.ensure_loaded?(associated_module) do
      if Enum.member?(visited, associated_module) do
        nil
      else
        {
          association,
          {
            relationship.cardinality,
            convert_schema_to_map(associated_module, [associated_module | visited])
          }
        }
      end
    end
  end

  @spec link_indirect_associations(map(), module(), [association()]) :: map()
  defp link_indirect_associations(schema, _module, nil), do: schema

  defp link_indirect_associations(schema, module, associations) do
    Enum.reduce(associations, schema, fn {:indirect, association}, schema ->
      relationship = module.__schema__(:association, association)

      path =
        relationship.through
        |> Enum.flat_map(&[&1, Access.elem(1)])
        |> Enum.drop(-1)

      case get_in(schema, path) do
        nil -> schema
        association_to_insert -> Map.put(schema, association, association_to_insert)
      end
    end)
  end

  defp map_custom_type({label, {:parameterized, {type, data}}}) do
    {label, Mecto.CustomSchema.type(type, data)}
  end

  defp map_custom_type(type), do: type

  @spec get_relationship(module(), atom()) :: struct() | nil
  defp get_relationship(module, association) do
    association =
      module.__schema__(:association, association) || module.__schema__(:type, association)

    case association do
      {:parameterized, {_module, relationship}} -> relationship
      relationship -> relationship
    end
  end
end