defmodule JsonApiQueryBuilder do @moduledoc """ Behaviour and mixin for building an Ecto query from a JSON-API request. ## Example defmodule Article do use Ecto.Schema schema "articles" do field :body, :string field :description, :string field :slug, :string field :tag_list, {:array, :string} field :title, :string belongs_to :author, User, foreign_key: :user_id has_many :comments, Comment timestamps() end defmodule Query do use JsonApiQueryBuilder, schema: Article, type: "article", relationships: ["author", "comments"] @impl JsonApiQueryBuilder def filter(query, "tag", value), do: from(a in query, where: ^value in a.tag_list) def filter(query, "comments", params) do comment_query = from(Comment.Query.build(params), select: [:article_id], distinct: true) from a in query, join: c in ^subquery(comment_query), on: a.id == c.article_id end def filter(query, "author", params) do user_query = from(User.Query.build(params), select: [:id]) from a in query, join: u in ^subquery(user_query), on: a.user_id == u.id end @impl JsonApiQueryBuilder def include(query, "comments", comment_params) do from query, preload: [comments: ^Comment.Query.build(comment_params)] end def include(query, "author", author_params) do from query, preload: [author: ^User.Query.build(author_params)] end end end """ @typedoc """ A JSON-API request after parsing by Plug into a string keyed map. May contain `"filter"`, `"sort"`, `"fields"`, `"include"`, `"page"` keys. """ @type request :: %{String.t => any} @doc """ Builds an `Ecto.Queryable.t` from parsed JSON-API request parameters. An overridable default implementation is generated by the mixin. ## Example: User.Query.build(%{ "filter" => %{ "articles.tag" => "animals", "comments" => %{ "body" => "Boo" } }, "include" => "articles.comments", "fields" => %{"user" => "id,bio"} }) #Ecto.Query< from u in Blog.User, join: a in ^#Ecto.Query< from a in subquery( from a in Blog.Article, where: ^"animals" in a.tag_list, distinct: true, select: [:user_id] ) >, on: u.id == a.user_id, select: [:id, :bio], preload: [ articles: #Ecto.Query< from a in Blog.Article, preload: [comments: #Ecto.Query] > ] > """ @callback build(request) :: Ecto.Queryable.t @doc """ Applies filter conditions from a parsed JSON-API request to an `Ecto.Queryable.t` An overridable default implementation is generated by the mixin. """ @callback filter(query :: Ecto.Queryable.t, request) :: Ecto.Queryable.t @doc """ Callback responsible for adding a filter criteria to a query. Attribute filters will generally add a `where:` condition to the query. Relationship filters will generally add a `join:` based on a subquery. When applying a filter to a has-many relationship, take care to `select:` the foreign key with `distinct: true` to avoid duplicated results. For filtering a belongs-to relationships, selecting the primary key is all that is needed. ## Example @impl JsonApiQueryBuilder def filter(query, "tag", value), do: from(article in query, where: ^value in article.tag_list) def filter(query, "comments", params) do comment_query = from(Comment.Query.build(params), select: [:article_id], distinct: true) from article in query, join: comment in ^subquery(comment_query), on: article.id == comment.article_id end def filter(query, "author", params) do user_query = from(User.Query.build(params), select: [:id]) from article in query, join: user in ^subquery(user_query), on: article.user_id == user.id end """ @callback filter(query :: Ecto.Queryable.t, field :: String.t, value :: any) :: Ecto.Queryable.t @doc """ Applies sparse fieldset selection from a parsed JSON-API request to an `Ecto.Queryable.t` An overridable default implementation is generated by the mixin. """ @callback fields(query :: Ecto.Queryable.t, request) :: Ecto.Queryable.t @doc """ Optional callback responsible for mapping a JSON-API field string to an Ecto schema field. An overridable default implementation using `String.to_existing_atom/1` is generated by the mixin. ## Example @impl JsonApiQueryBuilder def field("username"), do: :name def field("price"), do: :unit_price def field(other), do: String.to_existing_atom(other) """ @callback field(api_field :: String.t) :: atom @doc """ Applies sorting from a parsed JSON-API request to an `Ecto.Queryable.t` An overridable default implementation is generated by the mixin. """ @callback sort(query :: Ecto.Queryable.t, request) :: Ecto.Queryable.t @doc """ Applies related resource inclusion from a parsed JSON-API request to an `Ecto.Queryable.t` as preloads. An overridable default implementation is generated by the mixin. """ @callback include(query :: Ecto.Queryable.t, request) :: Ecto.Queryable.t @doc """ Callback responsible for adding an included resource via `preload`. ## Example @impl JsonApiQueryBuilder def include(query, "comments", comment_params) do from query, preload: [comments: ^Comment.Query.build(comment_params)] end def include(query, "author", author_params) do from query, preload: [author: ^User.Query.build(author_params)] end """ @callback include(query :: Ecto.Queryable.t, relationship :: String.t, related_request :: request) :: Ecto.Queryable.t @doc false defmacro __using__(schema: schema, type: type, relationships: relationships) do quote do import Ecto.Query @behaviour JsonApiQueryBuilder @schema unquote(schema) @api_type unquote(type) @relationships unquote(relationships) @impl JsonApiQueryBuilder def build(params) do @schema |> from() |> filter(params) |> fields(params) |> sort(params) |> include(params) end @impl JsonApiQueryBuilder def filter(query, params) do JsonApiQueryBuilder.Filter.filter(query, params, &filter/3, relationships: @relationships) end @impl JsonApiQueryBuilder def fields(query, params) do JsonApiQueryBuilder.Fields.fields(query, params, &field/1, type: @api_type) end @impl JsonApiQueryBuilder def sort(query, params) do JsonApiQueryBuilder.Sort.sort(query, params, &field/1) end @impl JsonApiQueryBuilder def include(query, params) do JsonApiQueryBuilder.Include.include(query, params, &include/3) end @impl JsonApiQueryBuilder def field(str), do: String.to_existing_atom(str) defoverridable [build: 1, filter: 2, fields: 2, field: 1, sort: 2, include: 2] end end end