lib/archeometer/collect/ecto.ex

defmodule Archeometer.Collect.Ecto do
  @moduledoc """
  Module for collecting information about Ecto schemas and their associations between them.
  """
  alias Archeometer.Repo
  alias Exqlite.Sqlite3, as: DB

  def ecto_schemas(extra_deps \\ []) do
    Archeometer.Collect.Project.local_modules(extra_deps)
    |> Enum.reduce([], fn module, acc ->
      if is_ecto_schema?(module) do
        embedded_schemas = get_embedded_schemas(module)
        embedded_schemas ++ [module | acc]
      else
        acc
      end
    end)
  end

  def get_associations(modules) do
    Enum.reduce(modules, [], fn module, acc ->
      Enum.map(
        module.__schema__(:associations),
        &%{
          caller: module,
          callee: get_related_module(module, &1),
          type: "ecto_#{get_assoc_type(module, &1)}",
          line: nil
        }
      ) ++ acc
    end)
  end

  defp get_related_module(module, association) do
    assoc_data = module.__schema__(:association, association)

    case assoc_data.__struct__ do
      Ecto.Association.HasThrough ->
        process_has_through(module, assoc_data.through)

      _ ->
        assoc_data.related
    end
  end

  defp process_has_through(module, [assoc]) do
    reflection = module.__schema__(:association, assoc)

    if Map.has_key?(reflection, :related) do
      reflection.related
    else
      process_has_through(module, reflection.through)
    end
  end

  defp process_has_through(module, [assoc | rest]) do
    reflection = module.__schema__(:association, assoc)

    if Map.has_key?(reflection, :related) do
      process_has_through(reflection.related, rest)
    else
      process_has_through(module, reflection.through)
    end
  end

  defp get_assoc_type(module, association) do
    assoc = module.__schema__(:association, association)

    relation =
      assoc.__struct__
      |> Module.split()
      |> List.last()
      |> Macro.underscore()

    case relation do
      "belongs_to" ->
        relation

      "many_to_many" ->
        relation

      _ ->
        cardinality = Atom.to_string(assoc.cardinality)

        relation <> "_" <> cardinality
    end
  end

  defp is_ecto_schema?(nil), do: false

  defp is_ecto_schema?(module) do
    if Keyword.has_key?(module.__info__(:functions), :__struct__) do
      case Map.get(module.__struct__(), :__meta__, :no_meta) do
        %{__struct__: Ecto.Schema.Metadata} -> true
        _ -> false
      end
    end
  end

  def clear_references(db_name \\ Repo.default_db_name()) do
    {:ok, conn} = DB.open(db_name)
    :ok = DB.execute(conn, "DELETE FROM xrefs WHERE type LIKE 'ecto%';")
    DB.close(conn)
  end

  defp get_embedded_schemas(module) do
    module.__schema__(:embeds)
    |> Enum.map(&module.__schema__(:embed, &1))
    |> Enum.map(&Map.get(&1, :related))
  end
end