lib/schema_extractor.ex

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

  @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

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

    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

  defp list_associations(module, association) do
    relationship = module.__schema__(:association, association)

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

  defp map_association(module, visited, {:direct, association}) do
    relationship = module.__schema__(:association, association)

    associated_module = relationship.related

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

  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
end