lib/mixin.ex

defmodule Needle.Mixin do
  @moduledoc """
  If a Pointer represents an object, mixins represent data about the object. Mixins collate optional
  additional information about an object. Different types of object will typically make use of
  different mixins. You can see these as aspects of the data if you like.

  A mixin table starts with an `id` column which references `Pointer` and forms the default primary
  key. It is up to the user to choose which other fields go in the table, and thus what the mixin is for.

  Use of a mixin is typically through `has_one`:

  ```
  has_one :my_mixin, MyMixin, foreign_key: :id, references: :id
  ```

  Sometimes, the user may wish to add fields to the primary key by using the `primary_key: true`
  option to `add` in their migrations. This is permitted and in such case we call the resulting
  mixin a `multimixin`. Use becomes `has_many`:

  ```
  has_many :my_mixin, MyMixin, foreign_key: :id, references: :id
  ```

  Thus the choice of single or multi comes down to how many times you want to store that data for
  the object. A user's profile naturally lends itself to a regular `single` mixin, whereas an
  object's appearance in a feed would naturally lend itself to being a multimixin since the object
  may appear in many feeds.

  ### Declaring a mixin table type

  ```
  defmodule My.Mixin do

    use Needle.Mixin,
      otp_app: :my_app,
      source: "postgres_table_name"

    mixin_schema do
      field :is_awesome, :boolean
    end
  end
  ```
  """

  # alias Ecto.Changeset
  alias Needle.{ULID, Util}

  defmacro __using__(options), do: using(__CALLER__.module, options)

  @must_be_in_module "Needle.Mixin may only be used inside a defmodule!"

  def using(nil, _options),
    do: raise(RuntimeError, description: @must_be_in_module)

  def using(module, options) do
    otp_app = Util.get_otp_app(options)
    Util.get_source(options)
    config = Application.get_env(otp_app, module, [])
    Module.put_attribute(module, __MODULE__, options)
    pointers = emit_pointers(config ++ options)

    quote do
      use Ecto.Schema
      require Needle.Changesets
      import Exto
      import Needle.Mixin

      # this is an attempt to help mix notice that we are using the configuration at compile
      # time. In exto, for reasons, we already had to use Application.get_env
      _dummy_compile_env = Application.compile_env(unquote(otp_app), unquote(module))

      unquote_splicing(pointers)
    end
  end

  @must_use "You must use Needle.Mixin before calling mixin_schema/1"

  defmacro mixin_schema(do: body) do
    module = __CALLER__.module
    schema_check_attr(Module.get_attribute(module, __MODULE__), module, body)
  end

  @timestamps_opts [type: :utc_datetime_usec]
  @foreign_key_type ULID

  defp schema_check_attr(options, module, body) when is_list(options) do
    otp_app = Util.get_otp_app(options)
    config = Application.get_env(otp_app, module, [])
    source = Util.get_source(config ++ options)

    foreign_key = Module.get_attribute(module, :foreign_key_type, @foreign_key_type)

    timestamps_opts = Module.get_attribute(module, :timestamps_opts, @timestamps_opts)

    quote do
      @primary_key false
      @foreign_key_type unquote(foreign_key)
      @timestamps_opts unquote(timestamps_opts)
      schema(unquote(source)) do
        belongs_to(:pointer, Needle.Pointer,
          foreign_key: :id,
          on_replace: :update,
          primary_key: true,
          type: Needle.ULID
        )

        unquote(body)
        Exto.flex_schema(unquote(otp_app))
      end
    end
  end

  defp schema_check_attr(_, _, _), do: raise(ArgumentError, message: @must_use)

  # defines __pointers__
  defp emit_pointers(config) do
    otp_app = Keyword.fetch!(config, :otp_app)

    [
      Util.pointers_clause(:role, :mixin),
      Util.pointers_clause(:otp_app, otp_app)
    ]
  end
end