loading...
Novistore

Phoenix without Contexts

hl profile image Henricus Louwhoff ・4 min read

When you learn about the Phoenix framework you will come across contexts. While contexts are great, they are not always needed. Your app might only have a couple of schemas or, like in our case, it's just easier and faster (to develop the app) to use something else instead of Phoenix contexts. The main issue we at Novistore had was, that it took too long to figure out how to structure our contexts.

In the below code I'm going to show a way to dynamically build MyApp.<Schema>.find/1, MyApp.<Schema>.create/1 and MyApp.<Schema>.update/2 for all your schemas.

Schema

We use a separate Schema module to define some helper functions on our schemas so we can use it in a more simpler way.

First we create a straightforward schema that we're going to use in our schemas:

defmodule MyApp.Schema do
  @moduledoc """
  Define commonly used imports for all `Ecto.Schema`s here.
  """

  defmacro __using__(_) do
    quote do
      use Ecto.Schema

      import Ecto.Changeset
    end
  end
end

User schema

Next up, a simple user schema:

defmodule MyApp.User do
  use MyApp.Schema

  schema "users" do
    field :name, :string
    timestamps()
  end
end

Once our schema is done we would like to have functions like MyApp.User.find/1

Example

iex> MyApp.User.find(1)
%User{id: 1, name: "Jane"}

Let's add .find/1 to our schema:

defmodule MyApp.Schema do
  @moduledoc """
  Define commonly used imports for all `Ecto.Schema`s here.
  """

  defmacro __using__(_) do
    quote do
      use Ecto.Schema

      import Ecto.Changeset

      alias MyApp.Repo

      @type id :: non_neg_integer

      @spec find(id) :: {:ok, __MODULE__.t()} | {:error, {__MODULE__, :not_found}}
      def find(id) do
        case Repo.get(__MODULE__, id) do
          nil -> {:error, {__MODULE__, :not_found}}
          struct -> {:ok, struct}
        end
      end
    end
  end
end

The next function is create/1.

Example

iex> User.insert(%{name: "Jane"})
{:ok, %User{id: 1, name: "Jane"}}

Let's add .create/1 to our schema:

defmodule MyApp.Schema do
  @moduledoc """
  Define commonly used imports for all `Ecto.Schema`s here.
  """

  defmacro __using__(_) do
    quote do
      use Ecto.Schema

      import Ecto.Changeset

      alias MyApp.Repo

      @type id :: non_neg_integer

      @spec find(id) :: {:ok, __MODULE__.t()} | {:error, {__MODULE__, :not_found}}
      def find(id) do
        case Repo.get(__MODULE__, id) do
          nil -> {:error, {__MODULE__, :not_found}}
          struct -> {:ok, struct}
        end
      end

      @spec insert(map, keyword) :: {:ok, __MODULE__.t()} | {:error, Ecto.Changeset.t()}
      def insert(params, opts \\ []) when is_map(params) do
        changeset = changeset(params)

        Repo.insert(changeset, opts)
      end
    end
  end
end

Our User schema needs a .changeset/1 so let's add one:

defmodule MyApp.User do
  use MyApp.Schema

  schema "users" do
    field :name, :string
    timestamps()
  end

  @required [:name]
  optional = []
  @fields optional ++ @required
  @spec changeset(t, map) :: Ecto.Changeset.t()
  def changeset(struct \\ %__MODULE__{}, params) when is_map(params) do
    struct
    |> cast(params, @fields)
    |> validate_required(@required)
  end
end

The last one that we need is .update/2

defmodule MyApp.Schema do
  @moduledoc """
  Define commonly used imports for all `Ecto.Schema`s here.
  """

  defmacro __using__(_) do
    quote do
      use Ecto.Schema

      import Ecto.Changeset

      alias MyApp.Repo

      @type id :: non_neg_integer

      @spec find(id) :: {:ok, __MODULE__.t()} | {:error, {__MODULE__, :not_found}}
      def find(id) do
        case Repo.get(__MODULE__, id) do
          nil -> {:error, {__MODULE__, :not_found}}
          struct -> {:ok, struct}
        end
      end

      @spec insert(map, keyword) :: {:ok, __MODULE__.t()} | {:error, Ecto.Changeset.t()}
      def insert(params, opts \\ []) when is_map(params) do
        changeset = changeset(params)

        Repo.insert(changeset, opts)
      end

      @spec update(struct, map, keyword) :: {:ok, __MODULE__.t()} | {:error, Ecto.Changeset.t()}
      def update(%{__struct__: __MODULE__} = struct, params, opts \\ []) when is_map(params) do
        changeset = changeset(struct, params)

        Repo.update(changeset, opts)
      end
    end
  end
end

This way, every schema that uses use MyApp.Schema, has all the functions defined that we need to retrieve, create and update our schemas.

Bonus

You can also expand your schema module to import commonly used changeset functions like .validate_email/3 or add queries helpers like .filter_by/2:

defmodule MyApp.Schema do
  @moduledoc """
  Define commonly used imports for all `Ecto.Schema`s here.
  """

  defmacro __using__(_) do
    quote do
      use Ecto.Schema

      import Ecto.Changeset
      import MyApp.Schema

      alias MyApp.Repo

      @type id :: non_neg_integer

      @spec find(id) :: {:ok, __MODULE__.t()} | {:error, {__MODULE__, :not_found}}
      def find(id) do
        case Repo.get(__MODULE__, id) do
          nil -> {:error, {__MODULE__, :not_found}}
          struct -> {:ok, struct}
        end
      end

      @spec insert(map, keyword) :: {:ok, __MODULE__.t()} | {:error, Ecto.Changeset.t()}
      def insert(params, opts \\ []) when is_map(params) do
        changeset = changeset(params)

        Repo.insert(changeset, opts)
      end

      @spec update(struct, map, keyword) :: {:ok, __MODULE__.t()} | {:error, Ecto.Changeset.t()}
      def update(%{__struct__: __MODULE__} = struct, params, opts \\ []) when is_map(params) do
        changeset = changeset(struct, params)

        Repo.update(changeset, opts)
      end

      @spec filter_by(Ecto.Queryable.t(), keyword) :: Ecto.Queryable.t()
      def filter_by(queryable \\ __MODULE__, clauses) do
        Enum.reduce(clauses, queryable, fn {k, v}, query ->
          from q in query, where: field(q, ^k) == ^v
        end)
      end
    end
  end

  import Ecto.Changeset

  @spec validate_email(Ecto.Changeset.t(), atom) :: Ecto.Changeset.t()
  def validate_email(%{valid?: true} = changeset, key) do
    case fetch_change(changeset, key) do
      {:ok, _email} -> validate_format(changeset, key, ~r@)
      :error -> changeset
    end
  end
end

Posted on Nov 19 '19 by:

hl profile

Henricus Louwhoff

@hl

A dark wizard of mystery and code, whose code is exceeded only by his mystery. The second best engineer @novistore but by far the most bearded one.

Novistore

Hi, we're Novistore. We are building a platform for companies that want to build e-commerce apps without having to worry about the infrastructure.

Discussion

markdown guide