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