├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── config └── config.exs ├── docs └── all.json ├── lib ├── json_api_query_builder.ex └── json_api_query_builder │ ├── fields.ex │ ├── filter.ex │ ├── include.ex │ └── sort.ex ├── mix.exs ├── mix.lock └── test ├── json_api_query_builder_test.exs └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # IDE files 23 | .elixir_ls/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: '1.10' 3 | otp_release: '21.3' 4 | 5 | cache: 6 | directories: 7 | - _build 8 | - deps 9 | 10 | install: 11 | - mix local.hex --force 12 | - mix local.rebar --force 13 | - mix deps.get 14 | 15 | before_script: 16 | - mix clean 17 | 18 | script: 19 | - mix test 20 | - mix credo 21 | - mix dialyzer --halt-exit-status 22 | 23 | after_script: 24 | - mix inch.report -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Mike Buhot 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://img.shields.io/travis/mbuhot/json_api_query_builder.svg?branch=master)](https://travis-ci.org/mbuhot/json_api_query_builder) 2 | [![Hex.pm](https://img.shields.io/hexpm/v/json_api_query_builder.svg)](https://hex.pm/packages/json_api_query_builder) 3 | [![HexDocs](https://img.shields.io/badge/api-docs-yellow.svg)](https://hexdocs.pm/json_api_query_builder/) 4 | [![Inch CI](http://inch-ci.org/github/mbuhot/json_api_query_builder.svg)](http://inch-ci.org/github/mbuhot/json_api_query_builder) 5 | [![License](https://img.shields.io/hexpm/l/json_api_query_builder.svg)](https://github.com/mbuhot/json_api_query_builder/blob/master/LICENSE) 6 | 7 | # JSON-API Query Builder 8 | 9 | Build Ecto queries from JSON-API requests. 10 | 11 | Docs can be found at [https://hexdocs.pm/json_api_query_builder](https://hexdocs.pm/json_api_query_builder). 12 | 13 | ## Installation 14 | 15 | The package can be installed by adding `json_api_query_builder` to your list of dependencies in `mix.exs`: 16 | 17 | ```elixir 18 | def deps do 19 | [{:json_api_query_builder, "~> 1.0"}] 20 | end 21 | ``` 22 | 23 | ## Features 24 | 25 | JSON-API Query Builder can be used to construct an efficient Ecto query to handle the following kinds of requests, in arbitrary combinations. 26 | 27 | ### Sparse Fieldsets 28 | 29 | Get all articles, including only the `title` and `description` fields 30 | 31 | `/blog/articles/?fields[article]=title,description` 32 | 33 | ### Sorting 34 | 35 | Get all articles, sorted by `category` ascending and `published` descending 36 | 37 | `/blog/articles/?sort=category,-published` 38 | 39 | ### Included Resources 40 | 41 | Get all articles, including related author, comments and comments user 42 | 43 | `/blog/articles/?include=author,comments,comments.user` 44 | 45 | ### Attribute Filters 46 | 47 | Get all articles with the animals `tag` 48 | 49 | `/blog/articles/?filter[tag]=animals` 50 | 51 | ### Filter by related resource 52 | 53 | Get all users who have an article with the animals `tag` 54 | 55 | `/blog/users?filter[article.tag]=animals` 56 | 57 | ### Filter included resources 58 | 59 | Get all users, including related articles that have the animals `tag` 60 | 61 | `/blog/users?include=articles&filter[article][tag]=animals` 62 | 63 | ### Pagination 64 | 65 | TODO 66 | 67 | ## Usage 68 | 69 | For each Ecto schema, create a related query builder module: 70 | 71 | ```elixir 72 | defmodule Article do 73 | use Ecto.Schema 74 | 75 | schema "articles" do 76 | field :body, :string 77 | field :description, :string 78 | field :slug, :string 79 | field :tag_list, {:array, :string} 80 | field :title, :string 81 | belongs_to :author, User, foreign_key: :user_id 82 | has_many :comments, Comment 83 | timestamps() 84 | end 85 | 86 | defmodule Query do 87 | use JsonApiQueryBuilder, 88 | schema: Article, 89 | type: "article", 90 | relationships: ["author", "comments"] 91 | 92 | @impl JsonApiQueryBuilder 93 | def filter(query, "tag", value), do: from(a in query, where: ^value in a.tag_list) 94 | def filter(query, "comments", params) do 95 | comment_query = from(Comment, select: [:article_id], distinct: true) |> Comment.Query.filter(params) 96 | from a in query, join: c in ^subquery(comment_query), on: a.id == c.article_id 97 | end 98 | def filter(query, "author", params) do 99 | user_query = from(User, select: [:id]) |> User.Query.filter(params) 100 | from a in query, join: u in ^subquery(user_query), on: a.user_id == u.id 101 | end 102 | 103 | @impl JsonApiQueryBuilder 104 | def include(query, "comments", comment_params) do 105 | from query, preload: [comments: ^Comment.Query.build(comment_params)] 106 | end 107 | def include(query, "author", author_params) do 108 | from query, select_merge: [:author_id], preload: [author: ^User.Query.build(author_params)] 109 | end 110 | end 111 | end 112 | ``` 113 | 114 | Then in an API request handler, use the query builder: 115 | 116 | ```elixir 117 | defmodule ArticleController do 118 | use MyAppWeb, :controller 119 | 120 | def index(conn, params) do 121 | articles = 122 | params 123 | |> Article.Query.build() 124 | |> MyApp.Repo.all() 125 | 126 | # pass data and opts as expected by `ja_serializer` 127 | render("index.json-api", data: articles, opts: [ 128 | fields: params["fields"], 129 | include: params["include"] 130 | ]) 131 | end 132 | end 133 | ``` 134 | 135 | ## Generated Queries 136 | 137 | Using `join:` queries for filtering based on relationships, `preload:` queries for included resources and `select:` lists for sparse fieldsets, the generated queries are as efficient as what you would write by hand. 138 | 139 | Eg the following index request: 140 | 141 | ```elixir 142 | params = %{ 143 | "fields" => %{ 144 | "article" => "description", 145 | "comment" => "body", 146 | "user" => "email,username" 147 | }, 148 | "filter" => %{ 149 | "articles.tag" => "animals" 150 | }, 151 | "include" => "articles,articles.comments,articles.comments.user" 152 | } 153 | Blog.Repo.all(Blog.User.Query.build(params)) 154 | ``` 155 | 156 | Produces one join query for filtering, and 3 preload queries 157 | 158 | ``` 159 | [debug] QUERY OK source="users" db=3.8ms decode=0.1ms queue=0.1ms 160 | SELECT u0."email", u0."username", u0."id" 161 | FROM "users" AS u0 162 | INNER JOIN ( 163 | SELECT DISTINCT a0."user_id" AS "user_id" 164 | FROM "articles" AS a0 165 | WHERE ($1 = ANY(a0."tag_list")) 166 | ) AS s1 167 | ON u0."id" = s1."user_id" ["animals"] 168 | 169 | [debug] QUERY OK source="articles" db=1.9ms 170 | SELECT a0."description", a0."id", a0."user_id" 171 | FROM "articles" AS a0 172 | WHERE (a0."user_id" = ANY($1)) 173 | ORDER BY a0."user_id" [[2, 1]] 174 | 175 | [debug] QUERY OK source="comments" db=1.7ms 176 | SELECT c0."body", c0."id", c0."user_id", c0."article_id" 177 | FROM "comments" AS c0 178 | WHERE (c0."article_id" = ANY($1)) 179 | ORDER BY c0."article_id" [[4, 3, 2, 1]] 180 | 181 | [debug] QUERY OK source="users" db=1.3ms 182 | SELECT u0."email", u0."username", u0."id", u0."id" 183 | FROM "users" AS u0 184 | WHERE (u0."id" = $1) [2] 185 | ``` 186 | 187 | ## License 188 | 189 | MIT 190 | 191 | ## Contributing 192 | 193 | GitHub issues and pull requests welcome. 194 | 195 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure your application as: 12 | # 13 | # config :json_api_query_builder, key: :value 14 | # 15 | # and access this configuration in your application as: 16 | # 17 | # Application.get_env(:json_api_query_builder, :key) 18 | # 19 | # You can also configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /docs/all.json: -------------------------------------------------------------------------------- 1 | {"shell":true,"revision":"397da4418f91e08eabd781903d39efef50d5a93c","objects":[{"type":null,"source":"lib/json_api_query_builder.ex:2","object_type":"ModuleObject","moduledoc":"Build Ecto queries from JSON-API requests.\n","module":"Elixir.JsonApiQueryBuilder","id":"JsonApiQueryBuilder"}],"language":"elixir","git_repo_url":"https://github.com/mbuhot/json_api_query_builder.git","client_version":"0.5.6","client_name":"inch_ex","branch_name":"test","args":[]} -------------------------------------------------------------------------------- /lib/json_api_query_builder.ex: -------------------------------------------------------------------------------- 1 | defmodule JsonApiQueryBuilder do 2 | @moduledoc """ 3 | Behaviour and mixin for building an Ecto query from a JSON-API request. 4 | 5 | ## Example 6 | 7 | defmodule Article do 8 | use Ecto.Schema 9 | 10 | schema "articles" do 11 | field :body, :string 12 | field :description, :string 13 | field :slug, :string 14 | field :tag_list, {:array, :string} 15 | field :title, :string 16 | belongs_to :author, User, foreign_key: :user_id 17 | has_many :comments, Comment 18 | timestamps() 19 | end 20 | 21 | defmodule Query do 22 | use JsonApiQueryBuilder, 23 | schema: Article, 24 | type: "article", 25 | relationships: ["author", "comments"] 26 | 27 | @impl JsonApiQueryBuilder 28 | def filter(query, "tag", value), do: from(a in query, where: ^value in a.tag_list) 29 | def filter(query, "comments", params) do 30 | comment_query = from(Comment, select: [:article_id], distinct: true) |> Comment.Query.filter(params) 31 | from a in query, join: c in ^subquery(comment_query), on: a.id == c.article_id 32 | end 33 | def filter(query, "author", params) do 34 | user_query = from(User, select: [:id]) |> User.Query.filter(params) 35 | from a in query, join: u in ^subquery(user_query), on: a.user_id == u.id 36 | end 37 | 38 | @impl JsonApiQueryBuilder 39 | def include(query, "comments", comment_params) do 40 | from query, preload: [comments: ^Comment.Query.build(comment_params)] 41 | end 42 | def include(query, "author", author_params) do 43 | from query, select_merge: [:author_id], preload: [author: ^User.Query.build(author_params)] 44 | end 45 | end 46 | end 47 | """ 48 | 49 | @typedoc """ 50 | A JSON-API request after parsing by Plug into a string keyed map. 51 | 52 | May contain `"filter"`, `"sort"`, `"fields"`, `"include"`, `"page"` keys. 53 | """ 54 | @type request :: %{String.t => any} 55 | 56 | @doc """ 57 | Builds an `Ecto.Queryable.t` from parsed JSON-API request parameters. 58 | 59 | An overridable default implementation is generated by the mixin. 60 | 61 | ## Example: 62 | 63 | User.Query.build(%{ 64 | "filter" => %{ 65 | "articles.tag" => "animals", 66 | "comments" => %{ 67 | "body" => "Boo" 68 | } 69 | }, 70 | "include" => "articles.comments", 71 | "fields" => %{"user" => "id,bio"} 72 | }) 73 | 74 | #Ecto.Query< 75 | from u in Blog.User, 76 | join: a in ^#Ecto.Query< 77 | from a in subquery( 78 | from a in Blog.Article, 79 | where: ^"animals" in a.tag_list, 80 | distinct: true, 81 | select: [:user_id] 82 | ) 83 | >, 84 | on: u.id == a.user_id, 85 | select: [:id, :bio, :id], 86 | preload: [ 87 | articles: #Ecto.Query< 88 | from a in Blog.Article, 89 | select: [:id, :body, :description, :slug, :tag_list, :title, :user_id, :inserted_at, :updated_at], 90 | preload: [ 91 | comments: #Ecto.Query< 92 | from c in Blog.Comment, 93 | select: [:id, :body, :user_id, :article_id, :inserted_at, :updated_at] 94 | > 95 | ] 96 | > 97 | ] 98 | > 99 | """ 100 | @callback build(request) :: Ecto.Queryable.t 101 | 102 | @doc """ 103 | Applies filter conditions from a parsed JSON-API request to an `Ecto.Queryable.t` 104 | 105 | An overridable default implementation is generated by the mixin. 106 | """ 107 | @callback filter(query :: Ecto.Queryable.t, request) :: Ecto.Queryable.t 108 | 109 | 110 | @doc """ 111 | Callback responsible for adding a filter criteria to a query. 112 | 113 | Attribute filters will generally add a `where:` condition to the query. 114 | 115 | Relationship filters will generally add a `join:` based on a subquery. 116 | 117 | When applying a filter to a has-many relationship, take care to `select:` the foreign key with `distinct: true` to avoid duplicated results. 118 | For filtering a belongs-to relationships, selecting the primary key is all that is needed. 119 | 120 | ## Example 121 | 122 | @impl JsonApiQueryBuilder 123 | def filter(query, "tag", value), do: from(article in query, where: ^value in article.tag_list) 124 | def filter(query, "comments", params) do 125 | comment_query = from(Comment, select: [:article_id], distinct: true) |> Comment.Query.filter(params) 126 | from article in query, join: comment in ^subquery(comment_query), on: article.id == comment.article_id 127 | end 128 | def filter(query, "author", params) do 129 | user_query = from(User, select: [:id]) |> User.Query.filter(params) 130 | from article in query, join: user in ^subquery(user_query), on: article.user_id == user.id 131 | end 132 | """ 133 | @callback filter(query :: Ecto.Queryable.t, field :: String.t, value :: any) :: Ecto.Queryable.t 134 | 135 | 136 | @doc """ 137 | Applies sparse fieldset selection from a parsed JSON-API request to an `Ecto.Queryable.t` 138 | 139 | An overridable default implementation is generated by the mixin. 140 | By default all fields are selected unless specified in the `"fields"` key of the request. 141 | """ 142 | @callback fields(query :: Ecto.Queryable.t, request) :: Ecto.Queryable.t 143 | 144 | @doc """ 145 | Optional callback responsible for mapping a JSON-API field string to an Ecto schema field. 146 | 147 | An overridable default implementation using `String.to_existing_atom/1` is generated by the mixin. 148 | 149 | ## Example 150 | 151 | @impl JsonApiQueryBuilder 152 | def field("username"), do: :name 153 | def field("price"), do: :unit_price 154 | def field(other), do: String.to_existing_atom(other) 155 | """ 156 | @callback field(api_field :: String.t) :: atom 157 | 158 | @doc """ 159 | Applies sorting from a parsed JSON-API request to an `Ecto.Queryable.t` 160 | 161 | An overridable default implementation is generated by the mixin. 162 | """ 163 | @callback sort(query :: Ecto.Queryable.t, request) :: Ecto.Queryable.t 164 | 165 | @doc """ 166 | Applies related resource inclusion from a parsed JSON-API request to an `Ecto.Queryable.t` as preloads. 167 | 168 | An overridable default implementation is generated by the mixin. 169 | """ 170 | @callback include(query :: Ecto.Queryable.t, request) :: Ecto.Queryable.t 171 | 172 | @doc """ 173 | Callback responsible for adding an included resource via `preload`. 174 | 175 | Any required foreign keys should be added to the query using `select_merge:` as required by the preload. 176 | 177 | ## Example 178 | 179 | @impl JsonApiQueryBuilder 180 | def include(query, "comments", comment_params) do 181 | from query, preload: [comments: ^Comment.Query.build(comment_params)] 182 | end 183 | def include(query, "author", author_params) do 184 | from query, select_merge: [:user_id], preload: [author: ^User.Query.build(author_params)] 185 | end 186 | """ 187 | @callback include(query :: Ecto.Queryable.t, relationship :: String.t, related_request :: request) :: Ecto.Queryable.t 188 | 189 | 190 | @doc false 191 | defmacro __using__(schema: schema, type: type, relationships: relationships) do 192 | quote do 193 | import Ecto.Query 194 | 195 | @behaviour JsonApiQueryBuilder 196 | 197 | @schema unquote(schema) 198 | @api_type unquote(type) 199 | @relationships unquote(relationships) 200 | 201 | @impl JsonApiQueryBuilder 202 | def build(params) do 203 | @schema 204 | |> from() 205 | |> filter(params) 206 | |> fields(params) 207 | |> sort(params) 208 | |> include(params) 209 | end 210 | 211 | @impl JsonApiQueryBuilder 212 | def filter(query, params) do 213 | JsonApiQueryBuilder.Filter.filter(query, params, &filter/3, relationships: @relationships) 214 | end 215 | 216 | @impl JsonApiQueryBuilder 217 | def fields(query, params) do 218 | JsonApiQueryBuilder.Fields.fields(query, params, &field/1, type: @api_type, schema: @schema) 219 | end 220 | 221 | @impl JsonApiQueryBuilder 222 | def sort(query, params) do 223 | JsonApiQueryBuilder.Sort.sort(query, params, &field/1) 224 | end 225 | 226 | @impl JsonApiQueryBuilder 227 | def include(query, params) do 228 | JsonApiQueryBuilder.Include.include(query, params, &include/3) 229 | end 230 | 231 | @impl JsonApiQueryBuilder 232 | def field(str), do: String.to_existing_atom(str) 233 | 234 | defoverridable [build: 1, filter: 2, fields: 2, field: 1, sort: 2, include: 2] 235 | end 236 | end 237 | end 238 | -------------------------------------------------------------------------------- /lib/json_api_query_builder/fields.ex: -------------------------------------------------------------------------------- 1 | defmodule JsonApiQueryBuilder.Fields do 2 | @moduledoc """ 3 | Sparse fieldset operations for JsonApiQueryBuilder 4 | """ 5 | 6 | import Ecto.Query 7 | 8 | @doc """ 9 | Applies sparse fieldset selection from a parsed JSON-API request to an `Ecto.Queryable.t` 10 | 11 | The given callback will be used to map from API field name strings to Ecto schema atoms. 12 | """ 13 | @spec fields(Ecto.Queryable.t, map, function, [type: String.t]) :: Ecto.Queryable.t 14 | def fields(query, params, callback, type: type, schema: schema) do 15 | db_fields = 16 | params 17 | |> get_in(["fields", type]) 18 | |> fields_string_to_list(callback, schema) 19 | 20 | from(x in query, select: ^db_fields) 21 | end 22 | 23 | defp fields_string_to_list(nil, _callback, schema) do 24 | # HACK! must explicitly select all fields, otherwise `select_merge:` may clobber it later. 25 | schema.__schema__(:fields) 26 | end 27 | defp fields_string_to_list(fields, callback, _schema) do 28 | fields 29 | |> String.split(",", trim: true) 30 | |> Kernel.++(["id"]) 31 | |> Enum.map(callback) 32 | end 33 | end -------------------------------------------------------------------------------- /lib/json_api_query_builder/filter.ex: -------------------------------------------------------------------------------- 1 | defmodule JsonApiQueryBuilder.Filter do 2 | @moduledoc """ 3 | Filter operations for JsonApiQueryBuilder. 4 | """ 5 | 6 | @doc """ 7 | Applies filter conditions from a parsed JSON-API request to an `Ecto.Queryable.t`. 8 | 9 | Each filter condition will be cause the given callback to be invoked with the query, attribute and value. 10 | 11 | ## Relationship Filters 12 | 13 | There are two types of relationship filters: join filters and preload filters. 14 | 15 | Join filters use a dotted notation, eg: `"comments.author.articles"`, and will be used to filter the primary data using a SQL inner join. 16 | 17 | Eg: `%{"filter" => %{"author.has_bio" => 1}}` can be used to find all articles where the related author has a bio in the database. 18 | 19 | 20 | Preload filters use a nested notation, eg: `%{"author" => %{"has_bio" => 1}}`, and will only be used to filter the relationships specified in the `include` parameter. 21 | 22 | Eg: `%{"include" => "author", "filter" => %{"author" => %{"has_bio" => 1}}}` can be used to find all articles, and include the related authors if they have a bio. 23 | 24 | ## Example 25 | 26 | JsonApiQueryBuilder.Filter.filter( 27 | Article, 28 | %{"filter" => %{"tag" => "animals", "author.has_bio" => "1", "author.has_image" => "1", "comments" => %{"body" => "Great"}}}, 29 | &apply_filter/3, 30 | relationships: ["author", "comments"] 31 | ) 32 | 33 | The example above will cause the `apply_filter/3` callback to be invoked twice: 34 | - `apply_filter(query, "tag", "animals")` 35 | - `apply_filter(query, "author", %{"filter" => %{"has_bio" => "1", "has_image" => "1"}})` 36 | 37 | Where `apply_filter` would be implemented like: 38 | 39 | ``` 40 | def apply_filter(query, "tag", val), do: from(article in query, where: ^val in article.tag_list) 41 | def apply_filter(query, "author", params) do 42 | user_query = from(User, select: [:id]) |> User.Query.filter(params) 43 | from(article in query, join: user in ^subquery(user_query), on: article.user_id == user.id) 44 | end 45 | ``` 46 | """ 47 | @spec filter(Ecto.Queryable.t, map, function, [relationships: [String.t]]) :: Ecto.Queryable.t 48 | def filter(query, params, callback, relationships: relationships) do 49 | query 50 | |> apply_attribute_filters(params, callback, relationships) 51 | |> apply_join_filters(params, callback, relationships) 52 | end 53 | 54 | @spec apply_attribute_filters(Ecto.Queryable.t, map, function, [String.t]) :: Ecto.Queryable.t 55 | defp apply_attribute_filters(query, params, callback, relationships) do 56 | params 57 | |> Map.get("filter", %{}) 58 | |> Enum.filter(fn {k, _v} -> not is_relationship_filter?(relationships, k) end) 59 | |> Enum.reduce(query, fn {k, v}, query -> callback.(query, k, v) end) 60 | end 61 | 62 | @spec apply_join_filters(Ecto.Queryable.t, map, function, [String.t]) :: Ecto.Queryable.t 63 | defp apply_join_filters(query, params, callback, relationships) do 64 | params 65 | |> Map.get("filter", %{}) 66 | |> Enum.filter(fn {k, _v} -> is_join_filter?(relationships, k) end) 67 | |> Enum.group_by(fn {k, _v} -> first_relationship_segment(k) end) 68 | |> Enum.map(fn {relation, rel_filters} -> trim_leading_relationship_from_keys(relation, rel_filters) end) 69 | |> Enum.reduce(query, fn {relation, params}, query -> callback.(query, relation, %{"filter" => params}) end) 70 | end 71 | 72 | @doc """ 73 | Tests if the given string is either a join filter or a preload filter 74 | 75 | ## Example 76 | 77 | iex> JsonApiQueryBuilder.Filter.is_relationship_filter?(["articles", "comments"], "articles.comments.user") 78 | true 79 | iex> JsonApiQueryBuilder.Filter.is_relationship_filter?(["articles", "comments"], "comments") 80 | true 81 | iex> JsonApiQueryBuilder.Filter.is_relationship_filter?(["articles", "comments"], "email") 82 | false 83 | """ 84 | @spec is_relationship_filter?([String.t], String.t) :: boolean 85 | def is_relationship_filter?(relationships, filter) do 86 | is_join_filter?(relationships, filter) || is_preload_filter?(relationships, filter) 87 | end 88 | 89 | @doc """ 90 | Tests if the given string is a join filter. 91 | 92 | ## Example 93 | 94 | iex> JsonApiQueryBuilder.Filter.is_join_filter?(["articles", "comments"], "articles.comments.user") 95 | true 96 | iex> JsonApiQueryBuilder.Filter.is_join_filter?(["articles", "comments"], "comments") 97 | false 98 | iex> JsonApiQueryBuilder.Filter.is_join_filter?(["articles", "comments"], "email") 99 | false 100 | """ 101 | @spec is_join_filter?([String.t], String.t) :: boolean 102 | def is_join_filter?(relationships, filter) do 103 | Enum.any?(relationships, fn relationship -> 104 | String.starts_with?(filter, relationship <> ".") 105 | end) 106 | end 107 | 108 | @doc """ 109 | Tests if the given string is a join preload filter. 110 | 111 | ## Example 112 | 113 | iex> JsonApiQueryBuilder.Filter.is_preload_filter?(["articles", "comments"], "articles.comments.user") 114 | false 115 | iex> JsonApiQueryBuilder.Filter.is_preload_filter?(["articles", "comments"], "comments") 116 | true 117 | iex> JsonApiQueryBuilder.Filter.is_preload_filter?(["articles", "comments"], "email") 118 | false 119 | """ 120 | @spec is_preload_filter?([String.t], String.t) :: boolean 121 | def is_preload_filter?(relationships, filter) do 122 | filter in relationships 123 | end 124 | 125 | @doc """ 126 | Extract the first segment of a dotted relationship path. 127 | 128 | ## Example 129 | 130 | iex> JsonApiQueryBuilder.Filter.first_relationship_segment("a.b.c") 131 | "a" 132 | """ 133 | @spec first_relationship_segment(String.t) :: String.t 134 | def first_relationship_segment(path) do 135 | path 136 | |> String.split(".", parts: 2) 137 | |> hd() 138 | end 139 | 140 | @doc """ 141 | Removes the leading path segment from map keys after grouping has been applied. 142 | 143 | 144 | ## Example 145 | 146 | iex> JsonApiQueryBuilder.Filter.trim_leading_relationship_from_keys("article", [{"article.tag", "animals"}, {"article.comments.user.name", "joe"}]) 147 | {"article", %{"comments.user.name" => "joe", "tag" => "animals"}} 148 | """ 149 | @spec trim_leading_relationship_from_keys(String.t, list) :: {String.t, map} 150 | def trim_leading_relationship_from_keys(relation, rel_filters) do 151 | { 152 | relation, 153 | rel_filters 154 | |> Enum.map(fn {k, v} -> {String.trim_leading(k, relation <> "."), v} end) 155 | |> Enum.into(%{}) 156 | } 157 | end 158 | end -------------------------------------------------------------------------------- /lib/json_api_query_builder/include.ex: -------------------------------------------------------------------------------- 1 | defmodule JsonApiQueryBuilder.Include do 2 | @moduledoc """ 3 | Related resource include operations for JsonApiQueryBuilder 4 | """ 5 | 6 | @doc """ 7 | Applies related resource inclusion from a parsed JSON-API request to an `Ecto.Queryable.t` as preloads. 8 | 9 | The given callback will be invoked for each included relationship with a new JSON-API style request. 10 | """ 11 | @spec include(Ecto.Queryable.t, map, function) :: Ecto.Queryable.t 12 | def include(query, params = %{"include" => include}, callback) do 13 | includes = group_includes(include) 14 | 15 | Enum.reduce(includes, query, fn 16 | {relationship, related_includes}, query -> 17 | related_params = %{ 18 | "include" => related_includes, 19 | "filter" => (get_in(params, ["filter", relationship]) || %{}), 20 | "fields" => params["fields"] 21 | } 22 | callback.(query, relationship, related_params) 23 | end) 24 | end 25 | def include(query, _params, _callback), do: query 26 | 27 | @doc """ 28 | Groups the `include` string by leading path segment. 29 | 30 | ## Example 31 | 32 | iex> JsonApiQueryBuilder.Include.group_includes("a,a.b,a.b.c,a.d,e") 33 | [{"a", "b,b.c,d"}, {"e", ""}] 34 | """ 35 | @spec group_includes(String.t) :: [{String.t, String.t}] 36 | def group_includes(includes) do 37 | includes 38 | |> String.split(",", trim: true) 39 | |> Enum.map(&String.split(&1, ".", parts: 2)) 40 | |> Enum.group_by(&hd/1, &Enum.drop(&1, 1)) 41 | |> Enum.map(fn {k, v} -> {k, Enum.join(List.flatten(v), ",")} end) 42 | end 43 | end -------------------------------------------------------------------------------- /lib/json_api_query_builder/sort.ex: -------------------------------------------------------------------------------- 1 | defmodule JsonApiQueryBuilder.Sort do 2 | @moduledoc """ 3 | Sort operations for JsonApiQueryBuilder 4 | """ 5 | 6 | import Ecto.Query 7 | 8 | @doc """ 9 | Applies sorting from a parsed JSON-API request to an `Ecto.Queryable.t` 10 | 11 | The given callback will be used to map from API field name strings to Ecto schema atoms. 12 | """ 13 | def sort(query, %{"sort" => sort_string}, callback) do 14 | sort = 15 | sort_string 16 | |> String.split(",", trim: true) 17 | |> Enum.map(fn 18 | "-" <> field -> {:desc, callback.(field)} 19 | field -> {:asc, callback.(field)} 20 | end) 21 | from(query, order_by: ^sort) 22 | end 23 | def sort(query, _params, _callback), do: query 24 | end -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule JsonApiQueryBuilder.Mixfile do 2 | use Mix.Project 3 | 4 | @version "1.0.2" 5 | 6 | def project do 7 | [ 8 | app: :json_api_query_builder, 9 | version: @version, 10 | elixir: "~> 1.10", 11 | start_permanent: Mix.env == :prod, 12 | deps: deps(), 13 | description: "Build Ecto queries from JSON-API requests", 14 | package: package(), 15 | 16 | #Docs 17 | source_url: "https://github.com/mbuhot/json_api_query_builder", 18 | homepage_url: "https://github.com/mbuhot/json_api_query_builder", 19 | docs: [extras: ["README.md"], main: "readme", source_ref: "v#{@version}"] 20 | ] 21 | end 22 | 23 | defp package do 24 | [maintainers: ["Michael Buhot"], 25 | licenses: ["MIT"], 26 | links: %{"Github" => "https://github.com/mbuhot/json_api_query_builder"}] 27 | end 28 | 29 | # Run "mix help compile.app" to learn about applications. 30 | def application do 31 | [ 32 | extra_applications: [:logger] 33 | ] 34 | end 35 | 36 | # Run "mix help deps" to learn about dependencies. 37 | defp deps do 38 | [ 39 | {:ecto, "~> 3.5"}, 40 | {:dialyxir, "~> 1.0", only: :dev, runtime: false}, 41 | {:ex_doc, "~> 0.22", only: :dev, runtime: false}, 42 | {:inch_ex, "~> 2.0", only: :dev, runtime: false}, 43 | {:credo, "~> 1.4", only: :dev, runtime: false} 44 | ] 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 3 | "credo": {:hex, :credo, "1.4.1", "16392f1edd2cdb1de9fe4004f5ab0ae612c92e230433968eab00aafd976282fc", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "155f8a2989ad77504de5d8291fa0d41320fdcaa6a1030472e9967f285f8c7692"}, 4 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, 5 | "dialyxir": {:hex, :dialyxir, "1.0.0", "6a1fa629f7881a9f5aaf3a78f094b2a51a0357c843871b8bc98824e7342d00a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "aeb06588145fac14ca08d8061a142d52753dbc2cf7f0d00fc1013f53f8654654"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.10", "6603d7a603b9c18d3d20db69921527f82ef09990885ed7525003c7fe7dc86c56", [:mix], [], "hexpm", "8e2d5370b732385db2c9b22215c3f59c84ac7dda7ed7e544d7c459496ae519c0"}, 7 | "ecto": {:hex, :ecto, "3.5.1", "c2c8ababbb36f12b2c5660ee2de538b58730557a2164b8907d1adff9b0b89991", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b5228da2678155f44abf01be27a59d9cf33709884d0c9f7858cf93e9460b0428"}, 8 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 9 | "ex_doc": {:hex, :ex_doc, "0.22.6", "0fb1e09a3e8b69af0ae94c8b4e4df36995d8c88d5ec7dbd35617929144b62c00", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "1e0aceda15faf71f1b0983165e6e7313be628a460e22a031e32913b98edbd638"}, 10 | "inch_ex": {:hex, :inch_ex, "2.0.0", "24268a9284a1751f2ceda569cd978e1fa394c977c45c331bb52a405de544f4de", [:mix], [{:bunt, "~> 0.2", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "96d0ec5ecac8cf63142d02f16b7ab7152cf0f0f1a185a80161b758383c9399a8"}, 11 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 12 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 13 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.0", "98312c9f0d3730fde4049985a1105da5155bfe5c11e47bdc7406d88e01e4219b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "75ffa34ab1056b7e24844c90bfc62aaf6f3a37a15faa76b07bc5eba27e4a8b4a"}, 14 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 15 | "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, 16 | } 17 | -------------------------------------------------------------------------------- /test/json_api_query_builder_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JsonApiQueryBuilderTest do 2 | use ExUnit.Case 3 | doctest JsonApiQueryBuilder 4 | doctest JsonApiQueryBuilder.Include 5 | doctest JsonApiQueryBuilder.Filter 6 | doctest JsonApiQueryBuilder.Sort 7 | doctest JsonApiQueryBuilder.Fields 8 | 9 | end 10 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------