Advanced Usage View Source

Suppose you have a users schema that is defined as below:

defmodule Accounts.User do
  schema "users" do
    field :email, :string
    field :password_hash, :string

    field :name, :string
    field :is_active, :boolean

    field :signed_up_at, :naive_datetime
    field :last_logged_in_at, :naive_datetime
  end
end

Then a basic filtering and ordering query builder can be defined as:

defmodule Accounts.User.QueryBuilder do
  use QueryElf,
    schema: Accounts.User,
    searchable_fields: ~w[email name is_active signed_up_at]a,
    sortable_fields: ~w[email name signed_up_at]a
end

Where filtering is possible on email, name, is_active, and signed_up_at, and sorting is possible on email, name, and signed_up_at. If this is confusing for you, it would be a good idea to read through the basic usage documentation.

Defining custom filters

Suppose you wish to implement a custom filter to identify recently active users defined as users that logged in in the last n days. You can define and use such a filter as below:

defmodule Accounts.User.QueryBuilder do
  use QueryElf,
    schema: Accounts.User,
    searchable_fields: ~w[email name is_active signed_up_at]a,
    sortable_fields: ~w[email name signed_up_at]a

  def filter(:active_in_last_days, days, _query) do
    earliest_logged_in_at =
      NaiveDateTime.add(
        NaiveDateTime.utc_now(),
        days * 24 * 3600 * -1,
        :second
      )

    dynamic([u], u.last_logged_in_at > ^earliest_logged_in_at)
  end
end

Accounts.User.QueryBuilder.build_query(active_in_last_days: 30)
|> Repo.all()

Suppose you also have a notification preferences schema and a foriegn key from users schema to the preferences schema and you want to implement custom filters to identify recently active users that are happy to receive newsletters, then that can be achieved as below:

defmodule Accounts.User do
  schema "users" do
    field :email, :string
    field :password_hash, :string

    field :name, :string
    field :is_active, :boolean

    field :signed_up_at, :naive_datetime
    field :last_logged_in_at, :naive_datetime

    has_one :notification_preferences, NotificationPreferences
  end
end

defmodule Accounts.NotificationPreferences do
  schema "notification_preferences" do
    field :send_product_updates, :boolean
    field :send_newsletters, :boolean
  end
end

defmodule Accounts.User.QueryBuilder do
  use QueryElf,
    schema: Accounts.User,
    searchable_fields: ~w[email name is_active signed_up_at]a,
    sortable_fields: ~w[email name signed_up_at]a

  def filter(:active_in_last_days, days, _query) do
    earliest_logged_in_at =
      NaiveDateTime.add(
        NaiveDateTime.utc_now(),
        days * 24 * 3600 * -1,
        :second
      )

    dynamic([u], u.last_logged_in_at > ^earliest_logged_in_at)
  end

  def filter(:accepts_newsletters, value, query) do
    {
      # Use reusable_join instead of join as explained in the next section
      join(query, assoc(r, :notification_preferences), as: :preferences),
      dynamic([preferences: p], p.send_newsletters == ^value)
    }
  end
end

Accounts.User.QueryBuilder.build_query(active_in_last_days: 30, accepts_newsletters: true)
|> Repo.all()

Using reusable joins

Given the module definition of User.QueryBuilder above, if you wrote the following query:

Accounts.User.QueryBuilder.build_query(
  _or: [
    accepts_newsletters: true,
    _and: [active_in_last_days: 5, accepts_newsletters: false]
  ]
)

Then ecto with throw an error as it would result in two named joins having the same name. This is because both accepts_newsletters: true and accepts_newsletters: false add a join each between users and notification_preferences with the name preferences and ecto doesn't allow two joins with the same name.

What you really want is that a single join is added to the query as they both are essentially the same join. This is achieved by the reusable_join macro.

import QueryElf

defmodule Accounts.User.QueryBuilder do
  use QueryElf,
    schema: Accounts.User,
    searchable_fields: ~w[email name is_active signed_up_at]a,
    sortable_fields: ~w[email name signed_up_at]a

  def filter(:accepts_newsletters, value, query) do
    {
      reusable_join(query, assoc(r, :notification_preferences), as: :preferences),
      dynamic([preferences: p], p.send_newsletters == ^value)
    }
  end
end

Internally, the reusable_join macro checks if a join with the same name has already been added to the query and if so it skips adding the join. Thus, if you have two custom filters that should be using the same join, you can ensure that happens by giving the joins in the filters the same name.

For example, suppose you want to implement custom filters to allow fetching users that are happy to receive newsletters and product updates. In this case, you would need to build two different filters so that they can be used independently and also be combined. But you want to ensure that when combined, the filters use a single join instead of multiple joins on users and notification_preferences. This is achieved by using reusable_join with the same join name in both the custom filters.

defmodule Accounts.User.QueryBuilder do
  use QueryElf,
    schema: Accounts.User,
    searchable_fields: ~w[email name is_active signed_up_at]a,
    sortable_fields: ~w[email name signed_up_at]a

  def filter(:accepts_newsletters, value, query) do
    {
      reusable_join(query, assoc(r, :notification_preferences), as: :preferences),
      dynamic([preferences: p], p.send_newsletters == ^value)
    }
  end

  def filter(:accepts_product_updates, value, query) do
    {
      reusable_join(query, assoc(r, :notification_preferences), as: :preferences),
      dynamic([preferences: p], p.send_product_updates == ^value)
    }
  end
end

Accounts.User.QueryBuilder.build_query(accepts_newsletters: true)
|> Repo.all()

Accounts.User.QueryBuilder.build_query(accepts_product_updates: true)
|> Repo.all()

Accounts.User.QueryBuilder.build_query(accepts_newsletters: true, accepts_product_updates: true)
|> Repo.all()