lib/virtual.ex

defmodule Needle.Virtual do
  @moduledoc """
  Sets up an Ecto Schema for a Virtual Pointable

  Virtual Pointables (or just `virtuals`) are like pointables with no
  additional columns, except instead of being backed by a table they
  are backed by a view. This is more efficient of resources but only
  works when there are no additional columns to add.

  If you need to add columns to the schema, you should use a pointable.

  ## Sample Usage

  ```
  use Needle.Virtual,
    otp_app: :my_app,   # your OTP application's name
    source: "my_table", # default name of view in database
    table_id: "01EBTVSZJ6X02J01R1XWWPWGZW" # valid ULID to identify virtual

  virtual_schema do
    # ... `has_one`, `has_many`, or *virtual* fields ONLY go here.
  end
  ```

  ## Overriding with configuration

  During `use` (i.e. compilation time), we will attempt to load
  configuration from the provided `:otp_app` under the key of the
  current module. Any values provided here will override the defaults
  provided to `use`. This allows you to configure them after the fact.

  Additionally, pointables use `Exto`'s `flex_schema()`, so you can
  provide additional configuration for those in the same place. Unlike
  a regular pointable, you should not add additional
  (non-virtual) fields, but it is permitted to add `has_one` /
  `has_many` associations.

  I shall say it again because it's important: This happens at
  *compile time*. You must rebuild the app containing the pointable
  whenever the configuration changes.

  ## Introspection

  Defines a function `__pointers__/1` to introspect data. Recognised
  parameters:

  `:role` - `:virtual`.
  `:table_id` - retrieves the ULID id of the virtual.
  `:otp_app` - retrieves the OTP application to which this belongs.
  """

  alias Needle.Util

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

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

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

  defp using(module, options) do
    # raise early if not present
    get_table_id(options)
    Util.get_source(options)
    app = Util.get_otp_app(options)
    Module.put_attribute(module, __MODULE__, options)
    config = Application.get_env(app, module, [])
    pointers = emit_pointers(config ++ options)

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

      # 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(app), unquote(module))

      unquote_splicing(pointers)
    end
  end

  @bad_table_id "You must provide a ULID-formatted binary :table_id option."
  @must_use "You must use Needle.Virtual before calling virtual_schema/1."

  defp get_table_id(opts), do: check_table_id(Keyword.get(opts, :table_id))

  defp check_table_id(x) when is_binary(x),
    do: check_table_id_valid(x, Needle.ULID.cast(x))

  defp check_table_id(_), do: raise(ArgumentError, message: @bad_table_id)

  defp check_table_id_valid(x, {:ok, x}), do: x

  defp check_table_id_valid(_, _),
    do: raise(ArgumentError, message: @bad_table_id)

  defmacro virtual_schema(body)

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

  # verifies that the module was `use`d and generates a new schema
  defp schema_check_attr(options, module, body) when is_list(options) do
    otp_app = Keyword.fetch!(options, :otp_app)
    config = Application.get_env(otp_app, module, [])
    source = Util.get_source(config ++ options)

    quote do
      unquote(Util.schema_primary_key(module, options))
      unquote(Util.schema_foreign_key_type(module))

      schema unquote(source) do
        Exto.flex_schema(unquote(otp_app))
        unquote(body)
      end
    end
  end

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

  # defines __pointers__
  defp emit_pointers(config) do
    table_id = get_table_id(config)
    otp_app = Util.get_otp_app(config)

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