lib/bylaw/ecto/schema.ex

defmodule Bylaw.Ecto.Schema do
  @moduledoc """
  Reflection helpers for compiled Ecto schema modules.

  These helpers intentionally inspect compiled modules instead of source code.
  Projects commonly wrap `use Ecto.Schema` in their own macros, so source-based
  detection would miss valid schemas.
  """

  @typedoc """
  Compiled schema metadata needed by database checks.
  """
  @type info :: %{
          module: module(),
          source: String.t(),
          prefix: String.t() | nil,
          fields: list(atom()),
          associations: list(atom()),
          field_sources: %{String.t() => atom()}
        }

  @doc """
  Returns compiled Ecto schema modules for an OTP application.
  """
  @spec modules(otp_app :: atom()) :: list(module())
  def modules(otp_app) when is_atom(otp_app) do
    otp_app
    |> application_modules()
    |> Enum.filter(&ecto_schema?/1)
    |> Enum.sort()
  end

  @doc """
  Returns true when `module` exports Ecto schema reflection functions.
  """
  @spec ecto_schema?(module :: module()) :: boolean()
  def ecto_schema?(module) when is_atom(module) and not is_nil(module) do
    Code.ensure_loaded?(module) and
      function_exported?(module, :__schema__, 1) and
      function_exported?(module, :__schema__, 2) and
      function_exported?(module, :__changeset__, 0)
  end

  @doc """
  Returns compiled schema metadata.
  """
  @spec info(module :: module()) :: info()
  def info(module) when is_atom(module) do
    fields = module.__schema__(:fields)

    %{
      module: module,
      source: module.__schema__(:source),
      prefix: module.__schema__(:prefix),
      fields: fields,
      associations: module.__schema__(:associations),
      field_sources: field_sources(module, fields)
    }
  end

  defp application_modules(otp_app) do
    case :application.get_key(otp_app, :modules) do
      {:ok, modules} -> modules
      :undefined -> []
    end
  end

  defp field_sources(module, fields) do
    Map.new(fields, fn field ->
      {field_source(module.__schema__(:field_source, field)), field}
    end)
  end

  defp field_source(source) when is_atom(source), do: Atom.to_string(source)
  defp field_source(source), do: source
end