PhoenixBricks (phoenix_bricks v0.3.0) View Source

An opinable set of proposed patters that helps to write reusable and no repetitive code for Contexts.

Motivation

After several years in Ruby on Rails developing I've got used to structure code folllowing the Single Responsibility Principle.

Phoenix comes with the Context concept, a module that cares about expose an API of an application section to other sections.

In a Context we usually have at least 6 actions for each defined schema (list_records/0, get_record!/1, create_record/1, update_record/2, delete_record/1, change_record/2).

If we consider that all Business Logic could go inside the Context, it's possibile to have a module with hundreds of lines of code, making code mainteinance very hard to be guardanteed.

The idea is to highlight common portion of code that can be extacted and moved into a separated module with only one responsibility and that could be reused in different contexts.

List records

The method list_* has a default implementation that returns the list of associated record:

def list_records do
  MyApp.Context.RecordSchema
  |> MyApp.Repo.all()
end

Let's add now to the context the capability of filtering the collection according to an arbitrary set of scopes, calling the function in this way:

iex> Context.list_records(title_matches: "value")

A possible solution could be to delegate the query building into a separated RecordQuery module

defmodule RecordQuery do
  def scope(list_of_filters) do
    RecordSchema
    |> improve_query_with_filters(list_of_filters)
  end

  defp improve_query_with_filters(start_query, list_of_filters) do
    list_of_filters
    |> Enum.reduce(start_query, fn scope, query -> apply_scope(query, scope) end)
  end

  defp apply_scope(query, {:title_matches, "value"}) do
    query
    |> where([q], ...)
  end

  defp apply_scope(query, {:price_lte, value}) do
    query
    |> where([q], ...)
  end
end

and use it into the Context

def list_records(scopes \\ []) do
  RecordQuery.scope(scopes)
  |> Repo.all()
end

iex> Context.list_records(title_matches: "value", price_lte: 42)

PhoenixBricks.Scopes

Using PhoenixBricks.Scopes it's possible to extend a module with all scope behaviours:

defmodule RecordQuery do
  use PhoenixBricks.Scopes, schema: RecordSchema

  defp apply_scope(query, {:title_matches, "value"}) do
    query
    |> where([q], ...)
  end
end

Filter

Another common feature is to filter records according to params provided through url params (for example after a submit in a search form).

def index(conn, params)
  filters = Map.get(params, "filters", %{})

  colletion = Context.list_records_based_on_filters(filters)

  conn
  |> assign(:collection, collection)
  ...
end

ensuring to allow only specified filters

A possible implementation could be:

defmodule RecordFilter do
  @search_filters ["title_matches", "price_lte"]

  def convert_filters_to_scopes(filters) do
    filters
    |> Enum.map(fn {name, value} ->
      convert_filter_to_scope(name, value)
    end)
  end

  def convert_filter_to_scope(name, value) when name in @search_fields do
    {String.to_atom(name), value}
  end
end

This way parameters are filtered and converted to a Keyword that is the common format for the RecordQuery described above.

iex> RecordFilter.convert_filters_to_scopes(%{"title_matches" => "value", "invalid_scope" => "value"})
iex> [title_matches: "value"]

and we can rewrite the previous action emphasizing the params convertion and the collection filter

def index(conn, params) do
  filters = Map.get(params, "filters", %{})

  collection =
    filters
    |> RecordFilter.convert_filters_to_scopes()
    |> Context.list_records()

  conn
  |> assign(:collection, collection)
  ....
end

The last part is to build a search form. In order to achieve this, we can add schema functionality to RecordFilter module:

defmodule RecordFilter do
  use Ecto.Schema

  embedded_schema do
    field :title_matches, :string
  end

  def changeset(filter, params) do
    filter
    |> cast(params, [:title_matches])
  end
end

def index(conn, params) do
  filters = Map.get(params, "filters", %{})
  filter_changeset = RecordFilter.changeset(%RecordFilter{}, filters)

  collection =
    filters
    |> RecordFilter.convert_filters_to_scopes()
    |> Context.list_records()

  conn
  |> assign(:collection, collection)
  |> assign(:filter_changeset, filter_changeset)
end
  <%= f = form_for @filter_changeset, .... %>
    <%= label f, :title_matches %>
    <%= text_input f, :title_matches %>

    <%= submit "Filter results" %>
  <% end %>

PhoenixBricks.Filter

Using PhoenixBricks.Filter module it's possible to extend a module with all filtering behaviours (define a changeset and the filter convertion)

defmodule RecordFilter do
  use PhoenixBricks.Filter,
      filters: [
        title_matches: :string
      ]
end

making available changeset/2 defined above and convert_filters_to_scopes/1