Skip to content

Commit

Permalink
Add code documentation with small refactorings
Browse files Browse the repository at this point in the history
  • Loading branch information
mathieuprog committed Dec 20, 2019
1 parent f7fe3ab commit 4823e4f
Show file tree
Hide file tree
Showing 8 changed files with 177 additions and 40 deletions.
8 changes: 8 additions & 0 deletions lib/join_maker.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ defmodule QueryBuilder.JoinMaker do

require Ecto.Query

@doc ~S"""
Options may be:
* `:mode`: if set to `:if_preferable`, schemas are joined only if it is better
performance-wise; this happens only for one case: when the association has a
one-to-one cardinality, it is better to join and include the association's result
in the result set of the query, rather than emitting a new DB query.
* `:type`: see `Ecto.Query.join/5`'s qualifier argument for possible values.
"""
def make_joins(query, token, options \\ []) do
_make_joins(query, token, bindings(token), options, [])
end
Expand Down
33 changes: 0 additions & 33 deletions lib/query/helper.ex

This file was deleted.

4 changes: 2 additions & 2 deletions lib/query/order_by.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ defmodule QueryBuilder.Query.OrderBy do
@moduledoc false

require Ecto.Query
import QueryBuilder.Query.Helper
import QueryBuilder.Utils

def order_by(query, assoc_fields, value) do
token = QueryBuilder.Token.token(query, assoc_fields)
Expand All @@ -20,7 +20,7 @@ defmodule QueryBuilder.Query.OrderBy do
end

defp apply_order(query, token, {field, direction}) do
{field, binding} = field_and_binding(query, token, field)
{field, binding} = find_field_and_binding_from_token(query, token, field)

Ecto.Query.order_by(query, [{^binding, x}], [{^direction, field(x, ^field)}])
end
Expand Down
17 changes: 17 additions & 0 deletions lib/query/preload.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ defmodule QueryBuilder.Query.Preload do

def preload(query, value) do
token = QueryBuilder.Token.token(query, value)

# Join one-to-one associations as it is more advantageous to include those into
# the result set rather than emitting a new DB query.
{query, token} = QueryBuilder.JoinMaker.make_joins(query, token, mode: :if_preferable)

do_preload(query, token)
Expand All @@ -13,14 +16,25 @@ defmodule QueryBuilder.Query.Preload do
defp do_preload(query, token) do
flattened_assoc_data = flatten_assoc_data(token)

# Firstly, give `Ecto.Query.preload/3` the list of associations that have been
# joined, such as:
# `Ecto.Query.preload.(query, [articles: a, user: u, role: r], [articles: {a, [user: {u, [role: r]}]}])`
query =
flattened_assoc_data
# Filter only the associations that have been joined
|> Enum.map(fn assoc_data_list ->
Enum.flat_map(assoc_data_list, fn
%{has_joined: false} -> []
assoc_data -> [{assoc_data.assoc_binding, assoc_data.assoc_field}]
end)
end)
# Get rid of the associations' lists that are redundant;
# for example for the 4 lists below:
# `[{:binding1, :field1}]`
# `[{:binding1, :field1}, {:binding2, :field2}]`
# `[{:binding1, :field1}, {:binding2, :field2}]`
# `[{:binding1, :field1}, {:binding2, :field2}, {:binding3, :field3}]`
# only the last list should be preserved.
|> Enum.uniq()
|> (fn lists ->
Enum.filter(
Expand All @@ -38,6 +52,9 @@ defmodule QueryBuilder.Query.Preload do
do_preload_with_bindings(query, list)
end)

# Secondly, give `Ecto.Query.preload/3` the list of associations that have not
# been joined, such as:
# `Ecto.Query.preload.(query, [articles: [comments: :comment_likes]])`
query =
flattened_assoc_data
|> Enum.map(fn assoc_data_list ->
Expand Down
8 changes: 4 additions & 4 deletions lib/query/where.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ defmodule QueryBuilder.Query.Where do
@moduledoc false

require Ecto.Query
import QueryBuilder.Query.Helper
import QueryBuilder.Utils

def where(query, assoc_fields, filters) do
token = QueryBuilder.Token.token(query, assoc_fields)
Expand All @@ -24,14 +24,14 @@ defmodule QueryBuilder.Query.Where do
end

defp apply_filter(query, token, {field1, operator, field2}) when is_atom(field2) do
{field1, binding_field1} = field_and_binding(query, token, field1)
{field2, binding_field2} = field_and_binding(query, token, field2)
{field1, binding_field1} = find_field_and_binding_from_token(query, token, field1)
{field2, binding_field2} = find_field_and_binding_from_token(query, token, field2)

do_where(query, binding_field1, binding_field2, {field1, operator, field2})
end

defp apply_filter(query, token, {field, operator, value}) do
{field, binding} = field_and_binding(query, token, field)
{field, binding} = find_field_and_binding_from_token(query, token, field)

do_where(query, binding, {field, operator, value})
end
Expand Down
69 changes: 69 additions & 0 deletions lib/query_builder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,34 +9,103 @@ defmodule QueryBuilder do
end
end

@doc ~S"""
Preloads the associations.
Bindings are automatically set if joins have been made, or if it is preferable to
join (i.e. one-to-one associations are preferable to include into the query result
rather than emitting separate DB queries).
Example:
```
QueryBuilder.preload(query, [role: :permissions, articles: [:stars, comments: :user]])
```
"""
def preload(query, assoc_fields) do
ensure_query_has_binding(query)
|> QueryBuilder.Query.Preload.preload(assoc_fields)
end

@doc ~S"""
An AND where query expression.
Example:
```
QueryBuilder.where(query, firstname: "John")
```
"""
def where(query, filters) do
where(query, [], filters)
end

@doc ~S"""
An AND where query expression.
Associations are passed in second argument; fields from these associations can then
be referenced by writing the field name, followed by the "@" character and the
association name, as an atom. For example: `:name@users`.
Example:
```
QueryBuilder.where(query, [role: :permissions], name@permissions: :write)
```
"""
def where(query, assoc_fields, filters) do
ensure_query_has_binding(query)
|> QueryBuilder.Query.Where.where(assoc_fields, filters)
end

@doc ~S"""
An order by query expression.
Example:
```
QueryBuilder.order_by(query, lastname: :asc, firstname: :asc)
```
"""
def order_by(query, value) do
order_by(query, [], value)
end

@doc ~S"""
An order by query expression.
For more about the second argument, see `where/3`.
Example:
```
QueryBuilder.order_by(query, :articles, title@articles: :asc)
```
"""
def order_by(query, assoc_fields, value) do
ensure_query_has_binding(query)
|> QueryBuilder.Query.OrderBy.order_by(assoc_fields, value)
end

@doc ~S"""
A join query expression.
Example:
```
QueryBuilder.join(query, :articles, :left)
```
"""
def join(query, assoc_fields, type) do
ensure_query_has_binding(query)
|> QueryBuilder.Query.Join.join(assoc_fields, type)
end

@doc ~S"""
Allows to pass a list of operations through a keyword list.
Example:
```
QueryBuilder.from_list(query, [
where: [name: "John", city: "Anytown"],
preload: [articles: :comments]
])
```
"""
def from_list(query, []), do: query

def from_list(query, [{operation, arguments} | tail]) do
Expand Down
46 changes: 46 additions & 0 deletions lib/token.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,61 @@ defmodule QueryBuilder.Token do
@moduledoc false

defmodule State do
@moduledoc false
# @moduledoc """
# The `token` function below received way too many arguments (which made the code
# harder to read and led `mix format` to split the arguments over multiple lines).
#
# The purpose of this struct is to reduce the number of arguments and to maintain
# state between `token`'s recursive calls, hence its name.
# """

defstruct source_binding: nil,
source_schema: nil,
# `bindings` allows to keep track of all the binding names in order to
# detect a binding name that is going to be used twice when joining
# associations; in such case, the `token` function raises an error.
bindings: []
end

@doc ~S"""
The purpose of the `token/2` function is to generate a data structure containing
information about given association tree.
It receives a query and a list (with nested lists) of association fields (atoms).
For example:
```
[
{:authored_articles,
[
:article_likes,
:article_stars,
{:comments, [:comment_stars, comment_likes: :user]}
]},
:published_articles
]
```
For each association field, a map will be created with the following keys and values:
* `:assoc_binding`: *named binding* to be used (atom)
* `:assoc_field`: field name (atom)
* `:assoc_schema`: module name of the schema (atom)
* `:cardinality`: cardinality (atom `:one` or `:many`)
* `:has_joined`: indicating whether the association has already been joined or not
with Ecto query (boolean)
* `:nested_assocs`: the nested associations (list)
* `:source_binding`: *named binding* of the source schema (atom)
* `:source_schema`: module name of the source schema (atom)
This information allows the exposed functions such as `QueryBuilder.where/3` to join
associations, refer to associations, etc.
"""
def token(query, value) do
source_schema = QueryBuilder.Utils.root_schema(query)

state = %State{
# the name of the binding of the query's root schema is the schema itself
source_binding: source_schema,
source_schema: source_schema
}
Expand Down
32 changes: 31 additions & 1 deletion lib/utils.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,35 @@ defmodule QueryBuilder.Utils do
context
end

def to_string(query), do: Inspect.Ecto.Query.to_string(query)
def query_to_string(query), do: Inspect.Ecto.Query.to_string(query)

def find_field_and_binding_from_token(query, token, field) do
split_field = String.split(to_string(field), "@")
[field, assoc_field] = [Enum.at(split_field, 0), Enum.at(split_field, 1)]

field = String.to_existing_atom(field)
assoc_field = String.to_existing_atom(assoc_field || "nil")

_find_field_and_binding_from_token(query, token, [field, assoc_field])
end

defp _find_field_and_binding_from_token(query, _token, [field, nil]) do
{field, QueryBuilder.Utils.root_schema(query)}
end

defp _find_field_and_binding_from_token(_query, token, [field, assoc_field]) do
{:ok, binding} = find_binding_from_token(token, assoc_field)
{field, binding}
end

defp find_binding_from_token([], _field), do: {:error, :not_found}

defp find_binding_from_token([assoc_data | tail], field) do
if field == Map.fetch!(assoc_data, :assoc_field) do
{:ok, assoc_data.assoc_binding}
else
find_binding_from_token(assoc_data.nested_assocs, field) ||
find_binding_from_token(tail, field)
end
end
end

0 comments on commit 4823e4f

Please sign in to comment.