lib/exto.ex

defmodule Exto do
  @moduledoc """
  Configuration-driven Ecto Schemata.
  """

  @doc """
  Adds additional associations dynamically from app config.

  Reads config for the given OTP application, under the name of the
  current module. Each key maps to an Ecto.Schema function:

  * `belongs_to`
  * `field`
  * `has_many`
  * `has_one`
  * `many_to_many`

  Each of these keys should map to a keyword list where the key is the
  name of the field or association and the value is one of:

  * A type
  * A tuple of type and options (keyword list)

  Example Schema:

  ```
  defmodule My.Schema do
    use Ecto.Schema
    import Exto, only: [flex_schema: 1]

    schema "my_table" do
      field :name, :string # just normal schema things
      flex_schema(:my_app) # boom! give me the stuff
    end
  end
  ```

  Example configuration:

  ```
  config :my_app, My.Schema,
    belongs_to: [
      foo: Foo,                   # belongs_to :foo, Foo
      bar: {Bar, type: :integer}, # belongs_to :bar, Bar, type: :integer
    ],
    field: [
      foo: :string,                # field :foo, :string
      bar: {:integer, default: 4}, # field :foo, :integer, default: 4
    ],
    has_one: [
      foo: Foo,                             # has_one :foo, Foo
      bar: {Bar, foreign_key: :the_bar_id}, # has_one :bar, Bar, foreign_key: :the_bar_id
    ]
    has_many: [
      foo: Foo,                             # has_many :foo, Foo
      bar: {Bar, foreign_key: :the_bar_id}, # has_many :bar, Bar, foreign_key: :the_bar_id
    ]
    many_to_many: [
      foo: Foo,                         # many_to_many :foo, Foo
      bar: {Bar, join_through: FooBar}, # many_to_many :bar, Bar, :join_through: FooBar
    ]
  ```

  This one won't work very well because we define `foo` and `bar` 5
  times each, but I think you get the point.

  Reading of configuration is done during compile time. The relations
  will be baked in during compilation, thus:

  * Do not expect this to work in runtime config.
  * You will need to rebuild all dependencies which use this macro
    when you change their configuration.
  """
  defmacro flex_schema(otp_app) when is_atom(otp_app) do
    module = __CALLER__.module
    config = Application.get_env(otp_app, module, [])
    code = Enum.flat_map(config, &flex_category/1)

    quote do
      (unquote_splicing(code))
    end
  end

  # flex_schema impl

  @cats [:belongs_to, :field, :has_one, :has_many, :many_to_many]

  defp flex_category({:code, code}), do: [code]

  defp flex_category({cat, items}) when cat in @cats and is_list(items),
    do: Enum.map(items, &flex_association(cat, &1))

  # skip over anything else, they might use it!
  defp flex_category(_), do: []

  defp flex_association(rel, {name, type})
       when is_atom(name) and is_atom(type),
       do: flex_association(rel, name, type, [])

  defp flex_association(rel, {name, opts})
       when is_atom(name) and is_list(opts),
       do: flex_association(rel, name, opts)

  defp flex_association(rel, {name, {type, opts}})
       when is_atom(name) and is_atom(type) and is_list(opts),
       do: flex_association(rel, name, type, opts)

  defp flex_association(rel, {name, {opts}})
       when is_atom(name) and is_list(opts),
       do: flex_association(rel, name, opts)

  defp flex_association(rel, name, opts) do
    quote do
      unquote(rel)(unquote(name), unquote(opts))
    end
  end

  defp flex_association(rel, name, type, opts) do
    quote do
      unquote(rel)(unquote(name), unquote(type), unquote(opts))
    end
  end
end