Skip to content

Commit

Permalink
Add support for subqueries operators (#3465)
Browse files Browse the repository at this point in the history
  • Loading branch information
GabrielAlacchi committed Nov 2, 2020
1 parent 6aea34e commit 49fd4d5
Show file tree
Hide file tree
Showing 5 changed files with 152 additions and 2 deletions.
55 changes: 54 additions & 1 deletion lib/ecto/query/api.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ defmodule Ecto.Query.API do
* Arithmetic operators: `+`, `-`, `*`, `/`
* Boolean operators: `and`, `or`, `not`
* Inclusion operator: `in/2`
* Subquery operators: `any`, `all` and `exists`
* Search functions: `like/2` and `ilike/2`
* Null check functions: `is_nil/1`
* Aggregates: `count/0`, `count/1`, `avg/1`, `sum/1`, `min/1`, `max/1`
Expand Down Expand Up @@ -118,9 +119,61 @@ defmodule Ecto.Query.API do
or even a column in the database with array type:
from p in Post, where: "elixir" in p.tags
Additionally, the right side may also be a subquery:
from c in Comment, where: c.post_id in subquery(
from(p in Post, where: p.created_at > ^since)
)
"""
def left in right, do: doc! [left, right]

@doc """
Evaluates to true if the provided subquery returns 1 or more rows.
from p in Post, as: :post, where: exists(from(c in Comment, where: parent_as(:post).id == c.post_id and c.replies_count > 5, select: 1))
This is best used in conjunction with `parent_as` to correlate the subquery with the parent query to test
some condition on related rows in a different table. In the above example the query returns posts which
have at least one comment that has more than 5 replies.
"""
def exists(subquery), do: doc! [subquery]

@doc """
Tests whether one or more values returned from the provided subquery match in a comparison operation.
from p in Product, where: p.id = any(
from(li in LineItem, select: [li.product_id], where: li.created_at > ^since and li.qty >= 10)
)
A product matches in the above example if a line item was created since the provided date where the customer purchased
at least 10 units.
Both `any` and `all` must be given a subquery as an argument, and theyu must be used on the right hand side of a comparison.
Both can be used with every comparison operator: `==`, `!=`, `>`, `>=`, `<`, `<=`.
"""
def any(subquery), do: doc! [subquery]

@doc """
Evaluates whether all values returned from the provided subquery match in a comparison operation.
from p in Post, where: p.visits >= all(
from(p in Post, select: avg(p.visits), group_by: [p.category_id])
)
For a post to match in the above example it must be visited at least as much as the average post in all categories.
from p in Post, where: p.visits = all(
from(p in Post, select: max(p.visits))
)
The above example matches all the posts which are tied for being the most visited.
Both `any` and `all` must be given a subquery as an argument, and theyu must be used on the right hand side of a comparison.
Both can be used with every comparison operator: `==`, `!=`, `>`, `>=`, `<`, `<=`.
"""
def all(subquery), do: doc! [subquery]

@doc """
Searches for `search` in `string`.
Expand Down Expand Up @@ -485,7 +538,7 @@ defmodule Ecto.Query.API do
from(post in Post, select: post.meta["author"][^field])
## Warning
The underlying data in the JSON column is returned without any
additional decoding. This means "null" JSON values are not the
same as SQL's "null". For example, the `Repo.all` operation below
Expand Down
5 changes: 5 additions & 0 deletions lib/ecto/query/builder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,11 @@ defmodule Ecto.Query.Builder do
{{:{}, [], [:over, [], [aggregate, window]]}, params_acc}
end

def escape({quantifier, meta, [subquery]}, type, params_acc, vars, env) when quantifier in [:all, :any, :exists] do
{subquery, params_acc} = escape_subquery({:subquery, meta, [subquery]}, type, params_acc, vars, env)
{{:{}, [], [quantifier, [], [subquery]]}, params_acc}
end

def escape({:=, _, _} = expr, _type, _params_acc, _vars, _env) do
error! "`#{Macro.to_string(expr)}` is not a valid query expression. " <>
"The match operator is not supported: `=`. " <>
Expand Down
21 changes: 21 additions & 0 deletions lib/ecto/query/planner.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1117,6 +1117,27 @@ defmodule Ecto.Query.Planner do
{{:in, in_meta, [left, right]}, acc}
end

defp prewalk({quantifier, meta, [{:subquery, i}]}, kind, query, expr, acc, adapter) when quantifier in [:exists, :any, :all] do
subquery = Enum.fetch!(expr.subqueries, i)
{subquery, acc} = prewalk_source(subquery, kind, query, expr, acc, adapter)

case {quantifier, subquery.query.select.fields} do
{:exists, _} ->
:ok

{_, [_]} ->
:ok

_ ->
error!(
query,
"subquery must return a single field in order to be used with #{quantifier}"
)
end

{{quantifier, meta, [subquery]}, acc}
end

defp prewalk({{:., dot_meta, [left, field]}, meta, []},
kind, query, expr, acc, _adapter) do
{ix, ix_expr, ix_query} = get_ix!(left, query)
Expand Down
30 changes: 29 additions & 1 deletion test/ecto/query/builder/filter_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,35 @@ defmodule Ecto.Query.Builder.FilterTest do
[{:subquery, 0}]
end

test "supports exists subquery expressions" do
s = from(p in "posts", select: 1)

%{wheres: [where]} = from(p in "posts", where: exists(s))

assert Macro.to_string(where.expr) ==
"exists({:subquery, 0})"
assert where.params ==
[{:subquery, 0}]
end

test "supports comparison with subqueries with all and any quantifiers" do
s = from(p in "posts", select: p.rating, order_by: [desc: p.created_at], limit: 10)

assert_quantified_subquery = fn %{wheres: [where]}, expected_quantifier ->
assert Macro.to_string(where.expr) ==
"&0.rating() >= #{expected_quantifier}({:subquery, 0})"

assert where.params ==
[{:subquery, 0}]
end

all_query = from(p in "posts", where: p.rating >= all(s))
any_query = from(p in "posts", where: p.rating >= any(s))

assert_quantified_subquery.(all_query, :all)
assert_quantified_subquery.(any_query, :any)
end

test "raises on invalid keywords" do
assert_raise ArgumentError, fn ->
where(from(p in "posts"), [p], ^[{1, 2}])
Expand All @@ -86,6 +115,5 @@ defmodule Ecto.Query.Builder.FilterTest do
where(from(p in "posts"), [p], ^[foo: nil])
end
end

end
end
43 changes: 43 additions & 0 deletions test/ecto/query/planner_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -1275,4 +1275,47 @@ defmodule Ecto.Query.PlannerTest do
from(p in Post, order_by: p.title) |> normalize(:delete_all)
end
end

describe "normalize: subqueries in boolean expressions" do
test "replaces {:subquery, index} with an Ecto.SubQuery struct" do
subquery = from(p in Post, select: p.visits)

%{wheres: [where]} =
from(p in Post, where: p.visits in subquery(subquery))
|> normalize()

assert {:in, _, [_, %Ecto.SubQuery{}] } = where.expr

%{wheres: [where]} =
from(p in Post, where: p.visits >= all(subquery))
|> normalize()

assert {:>=, _, [_, {:all, _, [%Ecto.SubQuery{}] }]} = where.expr

%{wheres: [where]} =
from(p in Post, where: exists(subquery))
|> normalize()

assert {:exists, _, [%Ecto.SubQuery{}]} = where.expr
end

test "raises a runtime error if more than 1 field is selected" do
s = from(p in Post, select: [p.visits, p.id])

assert_raise Ecto.QueryError, fn ->
from(p in Post, where: p.id in subquery(s))
|> normalize()
end

assert_raise Ecto.QueryError, fn ->
from(p in Post, where: p.id > any(s))
|> normalize()
end

assert_raise Ecto.QueryError, fn ->
from(p in Post, where: p.id > all(s))
|> normalize()
end
end
end
end

0 comments on commit 49fd4d5

Please sign in to comment.