lib/inspecto.ex

defmodule Inspecto do
  @moduledoc """
  `Inspecto` is a utility for inspecting Ecto schemas to view their field names,
  data types, and default values.

  Ecto schema modules do not contain full information about your database
  schemas: they only contain enough information to act as a viable intermediary
  for the Elixir layer.  You cannot, for example, know character length limits or
  input constraints by merely inspecting Ecto schemas.  Although Ecto _migrations_
  contain a lot more of this information, they too aren't great for the purpose
  because migrations are additive with changes spread out over time, and importantly,
  there's not requirement that a database be defined via migrations.

  ## Usage

  The expected usage of this package is to call `Inspecto.summarize/2` from within
  a `@moduledoc` tag somewhere inside your app, wherever you wish a summary of
  your Ecto schemas to appear, e.g. something like this:

  ```
  defmodule MyApp.MyModel do
    @moduledoc \"\"\"
    Here is a summary of my Ecto schemas:

    \#\{ MyApp.MyModel |> Inspecto.modules() |> Inspecto.summarize(format: :html)\}
    \"\"\"
  end
  ```

  This will render a series of HTML tables at compile-time.

  > #### Warning {: .warning}
  >
  > When you call a function from inside a `@moduledoc` tag, it is evaluated
  > at *compile-time*. `Inspecto` _should_ filter out problems and avoid raising
  > errors, but if it generates a problem for any reason, be aware that you may
  > need to remove any calls to its functions from your `@moduledoc` tags.
  """

  alias Inspecto.Schema

  require EEx

  @local_path Application.app_dir(:inspecto, ["priv/resources"])

  @doc """
  Summarizes the given Ecto schema modules using the format provided.

  It is necessary to supply a list of modules.

  ## Options

  - `:format` controls the format of the output. Supported values: `:html`, `:raw` (default).
  - `:h` controls the heading level used for each schema (`3` corresponds to an `h3` tag).
    This is only relevant when `:format` is set to `:html`. Default: `2`

  The `:raw` format returns information as a list of `Inspecto.Schema` structs.

  The `:html` format returns information as an HTML table. This is suitable for
  use inside a `@moduledoc` attribute.

  Other formats may be supported in the future (e.g. Mermaid JS, but it's not yet
  compatible with how documentation generation strips out newlines).

  ## Examples

      iex> MyApp.Schemas |> Inspecto.modules() |> Inspecto.summarize(format: :raw)
      [
        %Inspecto.Schema{
          fields: [
            %Inspecto.Schema.Field{default: nil, name: :id, type: :binary_id},
            %Inspecto.Schema.Field{default: nil, name: :meta, type: :map},
            %Inspecto.Schema.Field{default: 30, name: :net, type: :integer},
            # ...
          ],
          module:MyApp.Schemas.SomeSchema,
          primary_key: [:id],
          source: "some_table"
        }
      ],
      #  ...
  """
  @spec summarize(modules :: [module()], opts :: Keyword.t()) :: String.t() | [Schema.t()]
  def summarize(modules, opts \\ []) when is_list(modules) do
    format = Keyword.get(opts, :format, :raw)
    heading_level = Keyword.get(opts, :h, 2)

    modules
    |> Enum.reduce([], fn m, acc ->
      case Schema.inspect(m) do
        {:ok, schema} -> [schema | acc]
        {:invalid_module, _} -> acc
      end
    end)
    |> Enum.reverse()
    |> apply_format(format, heading_level)
  end

  defp apply_format(schemas, :raw, _), do: schemas

  defp apply_format(schemas, :html, heading_level), do: table_wrapper(schemas, heading_level)

  EEx.function_from_file(
    :defp,
    :table_wrapper,
    "#{@local_path}/table_wrapper.eex",
    [:schemas, :heading_level]
  )

  EEx.function_from_file(:defp, :table, "#{@local_path}/table.eex", [:schema, :heading_level])

  defp stringify(module), do: String.trim_leading("#{module}", "Elixir.")

  @doc """
  This is a convenience function which retrieves a list of modules in the given
  "namespace". This is a useful way of gathering up modules that define Ecto
  schemas.

  ## Examples

      iex> Inspecto.modules(MyApp)
      [MyApp.Foo, MyApp.Bar, ...]
  """
  def modules(namespace) when is_atom(namespace) do
    :code.all_available()
    |> Enum.filter(fn {name, _file, _loaded} ->
      String.starts_with?(to_string(name), to_string(namespace))
    end)
    |> Enum.map(fn {name, _file, _loaded} ->
      name |> List.to_atom()
    end)
  end
end