Current section
Files
Jump to
Current section
Files
lib/json_api_query_builder.ex
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, select: [:article_id], distinct: true) |> Comment.Query.filter(params)
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, select: [:id]) |> User.Query.filter(params)
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, select_merge: [:author_id], 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, :id],
preload: [
articles: #Ecto.Query<
from a in Blog.Article,
select: [:id, :body, :description, :slug, :tag_list, :title, :user_id, :inserted_at, :updated_at],
preload: [
comments: #Ecto.Query<
from c in Blog.Comment,
select: [:id, :body, :user_id, :article_id, :inserted_at, :updated_at]
>
]
>
]
>
"""
@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, select: [:article_id], distinct: true) |> Comment.Query.filter(params)
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, select: [:id]) |> User.Query.filter(params)
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.
By default all fields are selected unless specified in the `"fields"` key of the request.
"""
@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`.
Any required foreign keys should be added to the query using `select_merge:` as required by the 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, select_merge: [:user_id], 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, schema: @schema)
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