All Articles

Keeping big Contexts small with metaprogramming in Elixir

At Novistore we’re building an awesome e-commerce API and we have a lot of schema’s in our project. To wrap all that up we use contexts as introduced in Phoenix. Contexts are great but they can get really grow in size. Devon wrote a great article of one way to solve it but after trying it out we decided to go another way.

This is a small example of how our contexts look like:

Catalogs Context

defmodule MyApp.Catalogs do
  use Context, repo: MyApp.Repo

  context MyApp.Catalogs.Product
  context MyApp.Catalogs.Collection, only: [create_collection: 1, delete_collection: 1]

  def list_products do
    # list all products
  end
end

Sales Context

defmodule MyApp.Sales do
  use Context, repo: MyApp.Repo

  context MyApp.Sales.Checkout
  context MyApp.Sales.Order, except: [delete_order: 1]

  def list_orders do
    # list all orders
  end
end

The context/1 and context/2 macros make sure that a lot of the standard functions to interact with the schema’s are created at compile time. This makes our contexts a bit smaller and easier to work with.

The line context MyApp.Catalogs.Product creates the following functions: get_product/1, get_product/1, get_product/1, get_product/1, find_product/1, find_product/1, create_product/1, update_product/2 , delete_product/1, create_product/1, update_product/2, delete_product/1. The context macro also supports options like only: [] and except: [] to make sure you don’t generate functions that you don’t need.

We hope you like it and will you benefit from it too. We decided not to package this into a library because some functions are not needed for your project or you might want to add some.

Below is the code to make it all work:

Context

defmodule Context do
  @moduledoc false

  defmacro __using__(opts) do
    repo = Keyword.fetch!(opts, :repo)

    quote do
      import Context, only: [context: 1, context: 2]

      Module.put_attribute(__MODULE__, :__repo__, unquote(repo))
    end
  end

  @doc false
  defmacro context(schema, opts \\ []) do
    name = Keyword.get(opts, :name, parse_name(schema, __CALLER__))
    funs = build_functions(name, opts)

    Enum.reduce(funs, [], fn {fun_name, arity}, acc ->
      fun =
        fun_name
        |> Atom.to_string()
        |> String.replace("_#{name}", "")
        |> String.to_existing_atom()

      [Kernel.apply(__MODULE__, :gen_fun, [{fun, arity}, name, schema]) | acc]
    end)
  end

  @spec parse_name(atom(), any()) :: String.t()
  defp parse_name(schema, caller) do
    schema
    |> Macro.expand_once(caller)
    |> Atom.to_string()
    |> String.split(".")
    |> List.last()
    |> Macro.underscore()
  end

  @spec build_functions(String.t(), keyword()) :: [{atom(), non_neg_integer()}]
  defp build_functions(name, opts) do
    only = Keyword.get(opts, :only, default_functions(name))
    except = Keyword.get(opts, :except, [])

    only -- except
  end

  @spec default_functions(String.t()) :: [{atom(), non_neg_integer()}]
  defp default_functions(name) do
    [
      {:"get_#{name}", 1},
      {:"get_#{name}!", 1},
      {:"get_#{name}_by", 1},
      {:"get_#{name}_by!", 1},
      {:"find_#{name}", 1},
      {:"find_#{name}_by", 1},
      {:"create_#{name}", 1},
      {:"update_#{name}", 2},
      {:"delete_#{name}", 1},
      {:"create_#{name}!", 1},
      {:"update_#{name}!", 2},
      {:"delete_#{name}!", 1}
    ]
  end

  @doc false
  @spec gen_fun(tuple(), String.t(), atom()) :: tuple()
  def gen_fun(fun_and_arity, name, schema)

  def gen_fun({:get, 1}, name, schema) do
    quote do
      @doc """
      Get a #{unquote(schema)} by ID

      ## Parameters

      - id: Valid ID

      ## Examples

        iex> #{__MODULE__}.get_#{unquote(name)}(1)
        %#{unquote(schema)}{}
      """
      @spec unquote(:"get_#{name}")(integer()) :: unquote(schema).t() | nil
      def unquote(:"get_#{name}")(id) do
        @__repo__.get(unquote(schema), id)
      end

      defoverridable [{unquote(:"get_#{name}"), 1}]
    end
  end

  def gen_fun({:get!, 1}, name, schema) do
    quote do
      @doc """
      Find a #{unquote(schema)} by ID

      ## Parameters

      - id: Valid ID

      ## Examples

        iex> #{__MODULE__}.get_#{unquote(name)}!(1)
        %#{unquote(schema)}{}
      """
      @spec unquote(:"get_#{name}!")(integer()) :: unquote(schema).t()
      def unquote(:"get_#{name}!")(id) do
        @__repo__.get!(unquote(schema), id)
      end

      defoverridable [{unquote(:"get_#{name}!"), 1}]
    end
  end

  def gen_fun({:get_by, 1}, name, schema) do
    quote do
      @doc """
      Find a #{unquote(schema)} by clauses

      ## Parameters

      - clauses: keyword list

      ## Examples

        iex> #{__MODULE__}.get_#{unquote(name)}_by(title: "title")
        %#{unquote(schema)}{}
      """
      @spec unquote(:"get_#{name}_by")(keyword()) :: unquote(schema).t() | nil
      def unquote(:"get_#{name}_by")(clauses) do
        @__repo__.get_by(unquote(schema), clauses)
      end

      defoverridable [{unquote(:"get_#{name}_by"), 1}]
    end
  end

  def gen_fun({:get_by!, 1}, name, schema) do
    quote do
      @doc """
      Find a #{unquote(schema)} by clauses

      ## Parameters

      - clauses: keyword list

      ## Examples

        iex> #{__MODULE__}.get_#{unquote(name)}_by!(title: "title")
        %#{unquote(schema)}{}
      """
      @spec unquote(:"get_#{name}_by!")(keyword()) :: unquote(schema).t()
      def unquote(:"get_#{name}_by!")(clauses) do
        @__repo__.get_by!(unquote(schema), clauses)
      end

      defoverridable [{unquote(:"get_#{name}_by!"), 1}]
    end
  end

  def gen_fun({:find, 1}, name, schema) do
    quote do
      @doc """
      Find a #{unquote(schema)} by ID

      ## Parameters

      - id: Valid ID

      ## Examples

        iex> #{__MODULE__}.find_#{unquote(name)}(1)
        {:ok, %#{unquote(schema)}{}}
      """
      @spec unquote(:"find_#{name}")(integer()) ::
              {:ok, unquote(schema).t()} | {:error, {unquote(schema), :not_found}}
      def unquote(:"find_#{name}")(id) do
        case @__repo__.get(unquote(schema), id) do
          nil -> {:error, {unquote(schema), :not_found}}
          term -> {:ok, term}
        end
      end

      defoverridable [{unquote(:"find_#{name}"), 1}]
    end
  end

  def gen_fun({:find_by, 1}, name, schema) do
    quote do
      @doc """
      Find a #{unquote(schema)} by clauses

      ## Parameters

      - clauses: keyword list

      ## Examples

        iex> #{__MODULE__}.find_#{unquote(name)}_by(title: "title")
        {:ok, %#{unquote(schema)}{}}
      """
      @spec unquote(:"find_#{name}_by")(keyword()) ::
              {:ok, unquote(schema).t()} | {:error, {unquote(schema), :not_found}}
      def unquote(:"find_#{name}_by")(clauses) do
        case @__repo__.get_by(unquote(schema), clauses) do
          nil -> {:error, {unquote(schema), :not_found}}
          term -> {:ok, term}
        end
      end

      defoverridable [{unquote(:"find_#{name}_by"), 1}]
    end
  end

  def gen_fun({:create, 1}, name, schema) do
    quote do
      @doc """
      Create a new #{unquote(schema)}

      ## Parameters

      - args: map

      ## Examples

        iex> #{__MODULE__}.create_#{unquote(name)}(%{})
        {:ok, %#{unquote(schema)}{}}
      """
      @spec unquote(:"create_#{name}")(map()) ::
              {:ok, unquote(schema).t()} | {:error, Ecto.Changeset.t()}
      def unquote(:"create_#{name}")(args) when is_map(args) do
        changeset = unquote(schema).changeset(%unquote(schema){}, args)
        @__repo__.insert(changeset)
      end

      defoverridable [{unquote(:"create_#{name}"), 1}]
    end
  end

  def gen_fun({:create!, 1}, name, schema) do
    quote do
      @doc """
      Create a new #{unquote(schema)}

      ## Parameters

      - args: map

      ## Examples

        iex> #{__MODULE__}.create_#{unquote(name)}(%{})
        %#{unquote(schema)}{}
      """
      @spec unquote(:"create_#{name}!")(map()) :: unquote(schema).t()
      def unquote(:"create_#{name}!")(args) when is_map(args) do
        changeset = unquote(schema).changeset(%unquote(schema){}, args)
        @__repo__.insert!(changeset)
      end

      defoverridable [{unquote(:"create_#{name}!"), 1}]
    end
  end

  def gen_fun({:update, 2}, name, schema) do
    quote do
      @doc """
      Update an existing #{unquote(schema)}

      ## Parameters

      - struct: %#{unquote(schema)}{}
      - args: map

      ## Examples

        iex> #{__MODULE__}.update_#{unquote(name)}!(%#{unquote(schema)}{}, %{})
        {:ok, %#{unquote(schema)}{}}
      """
      @spec unquote(:"update_#{name}")(unquote(schema).t(), map()) ::
              {:ok, unquote(schema).t()} | {:error, Ecto.Changeset.t()}
      def unquote(:"update_#{name}")(%unquote(schema){} = struct, args) when is_map(args) do
        changeset = unquote(schema).changeset(struct, args)
        @__repo__.update(changeset)
      end

      defoverridable [{unquote(:"update_#{name}"), 2}]
    end
  end

  def gen_fun({:update!, 2}, name, schema) do
    quote do
      @doc """
      Update an existing #{unquote(schema)}

      ## Parameters

      - struct: %#{unquote(schema)}{}
      - args: map

      ## Examples

        iex> #{__MODULE__}.update_#{unquote(name)}!(%#{unquote(schema)}{}, %{})
        %#{unquote(schema)}{}
      """
      @spec unquote(:"update_#{name}!")(unquote(schema).t(), map()) :: unquote(schema).t()
      def unquote(:"update_#{name}!")(%unquote(schema){} = struct, args) when is_map(args) do
        changeset = unquote(schema).changeset(struct, args)
        @__repo__.update!(changeset)
      end

      defoverridable [{unquote(:"update_#{name}!"), 2}]
    end
  end

  def gen_fun({:delete, 1}, name, schema) do
    quote do
      @doc """
      Delete an existing #{unquote(schema)}

      ## Parameters

      - struct: %#{unquote(schema)}{}

      ## Examples

        iex> #{__MODULE__}.delete_#{unquote(name)}(%#{unquote(name)}{})
        {:ok, %#{unquote(schema)}{}}
      """
      @spec unquote(:"delete_#{name}")(unquote(schema).t()) ::
              {:ok, unquote(schema).t()} | {:error, Ecto.Changeset.t()}
      def unquote(:"delete_#{name}")(%unquote(schema){} = struct) do
        @__repo__.delete(struct)
      end

      defoverridable [{unquote(:"delete_#{name}"), 1}]
    end
  end

  def gen_fun({:delete!, 1}, name, schema) do
    quote do
      @doc """
      Delete an existing #{unquote(schema)}

      ## Parameters

      - struct: %#{unquote(schema)}{}

      ## Examples

        iex> #{__MODULE__}.delete_#{unquote(name)}(%#{unquote(name)}{})
        {:ok, %#{unquote(schema)}{}}
      """
      @spec unquote(:"delete_#{name}!")(unquote(schema).t()) :: unquote(schema).t()
      def unquote(:"delete_#{name}!")(%unquote(schema){} = struct) do
        @__repo__.delete(struct)
      end

      defoverridable [{unquote(:"delete_#{name}!"), 1}]
    end
  end
end