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