lib/archeometer/schema.ex

defmodule Archeometer.Schema do
  @moduledoc """
  Specify data base table information in Elixir modules. The information must
  be specified inside a `defschema/1` macro.

  An schema has `field` properties. Foreign keys are specied with a `belongs_to`
  property. And the opposite is spcified with a `has` property.

  ## Example

      defmodule ModuleA do
        use Archeometer.Schema

        defschema do
          field :first_module, primary_key: true
          belongs_to ModuleB
          has ModuleB, as: :other_b, key: :recurse_id
        end
      end
  """

  defmacro __using__(_opts) do
    quote do
      import Archeometer.Schema
    end
  end

  @doc """
  Specify that the current schema has fields. The first argument is the name of
  the field, and as an optional argument you can specify if the current field is
  a primary key.

  For example

      defmodule A do
        use Archeometer.Schema

        defschema :a do
          field :id, primary_key: true
          field :name
        end
      end
  """
  defmacro field(name, opts \\ []) do
    Module.put_attribute(__CALLER__.module, :schema_fields, name)
    maybe_register_primary_key(__CALLER__.module, name, opts)
  end

  defp maybe_register_primary_key(caller, name, opts) do
    if {:primary_key, true} in opts do
      Module.put_attribute(caller, :schema_primary_key, name)
    end
  end

  defp default_key_atom(module) do
    module
    |> Archeometer.Util.Code.snakefy()
    |> Archeometer.Util.Code.atom_concat(:_id)
  end

  defp register_assoc(module, name, keys, type) do
    quote do
      Module.put_attribute(
        __MODULE__,
        :schema_assocs,
        %Archeometer.Schema.Assoc{
          name: unquote(name),
          module: unquote(module),
          type: unquote(type),
          keys: unquote(keys)
        }
      )
    end
  end

  @doc """
  This property denotes that there is a foreign key in other table that makes
  reference to the current table. This is usually to specify the `one` entity
  in a `many-to-one` relation.

  Using the name of the reference table, a field will be generated. This
  generated name will be a queryable field in the schema. The name can be
  specified with the `as: name` optional parameter.

  The name of the foreign key in the other table will be inferred from the name
  of the current table. This can be overriden with the `key: key_name` optional
  paramenter.

  For example

      defmodule A do
        use Archeometer.Schema

        defschema :schema do
          field :id, primary_key: true
          has B # with infererd name `:b` and foreign key `:a_id`
          has B, as: :other_b, key: other_a
        end
      end

  Where `B` is defined as follows

      defmodule B do
        use Archeometer.Schema

        defschema :b do
          field :id, primary_key: true
          belongs_to A # with inferred name `:a` and key `:a_id`
          belongs_to A, as: :other_a, key: :other_a_id
        end
      end

  """
  defmacro has(module, opts \\ []) do
    name = Keyword.get(opts, :as, Archeometer.Util.Code.snakefy(module))
    key = Keyword.get(opts, :key, default_key_atom(__CALLER__.module))
    register_assoc(module, name, key, :remote)
  end

  @doc """
  This property marks a foreign key. This is usually to specify the `many`
  entity in a `many-to-one` relation.

  Using the name of the reference table, a field will be generated. This
  generated name will be a queryable field in the schema. The name can be
  specified with the `as: name` optional parameter.

  The name of the foreign key will be inferred from the name of the referenced
  table. This can be overriden with the `key: key_name` optional paramenter.

  For example

      defmodule B do
        use Archeometer.Schema

        defschema :b do
          field :id, primary_key: true
          belongs_to A # with inferred name `:a` and key `:a_id`
          belongs_to A, as: :other_a, key: :other_a_id
        end
      end

  Where `A` is defined as follows

      defmodule A do
        use Archeometer.Schema

        defschema :schema do
          field :id, primary_key: true
          has B # with infererd name `:b` and foreign key `:a_id`
          has B, as: :other_b, key: other_a
        end
      end

  """
  defmacro belongs_to(module, opts \\ []) do
    name = Keyword.get(opts, :as, Archeometer.Util.Code.snakefy(module))
    key = Keyword.get(opts, :key, default_key_atom(name))
    maybe_register_primary_key(__CALLER__.module, key, opts)
    register_assoc(module, name, key, :local)
  end

  @doc """
  Starts the definition of a new schema. This definition maps database columns
  into Elixir data. The `field`, `has` and `belongs_to` macros are available to
  specify how to map the data base table to Elixir terms.

  See their individual documentation of each for the list of properties they
  can specify.
  """
  defmacro defschema(name, do: block) when is_atom(name) do
    Module.register_attribute(__CALLER__.module, :schema_fields, accumulate: true)
    Module.register_attribute(__CALLER__.module, :schema_assocs, accumulate: true)
    Module.register_attribute(__CALLER__.module, :schema_primary_key, accumulate: true)

    quote do
      unquote(block)

      def __archeometer_name__() do
        unquote(name)
      end

      @schema_fields_set MapSet.new(@schema_fields)
      def __archeometer_fields__(), do: @schema_fields_set

      @schema_assocs_map Map.new(
                           @schema_assocs,
                           fn %Archeometer.Schema.Assoc{name: n} = a ->
                             {n, a}
                           end
                         )
      def __archeometer_assocs__() do
        @schema_assocs_map
      end

      case @schema_primary_key do
        [] ->
          raise ArgumentError, "Schema doesn't define a primary key"

        [_] ->
          @schema_single_key hd(@schema_primary_key)
          def __archeometer_keys__() do
            @schema_single_key
          end

        _ ->
          def __archeometer_keys__() do
            @schema_primary_key
          end
      end

      defstruct @schema_fields ++ Enum.map(@schema_assocs, & &1.name)
    end
  end

  defmodule Assoc do
    @moduledoc false
    defstruct [:name, :type, :module, :keys]
  end
end