diff --git a/lib/ecto/repo/queryable.ex b/lib/ecto/repo/queryable.ex index dc462a6a60..c4546e0d5b 100644 --- a/lib/ecto/repo/queryable.ex +++ b/lib/ecto/repo/queryable.ex @@ -39,40 +39,44 @@ defmodule Ecto.Repo.Queryable do {query, opts} = repo.prepare_query(:stream, query, opts) query = attach_prefix(query, opts) - query_cache? = Keyword.get(opts, :query_cache, true) - - {query_meta, prepared, cast_params, dump_params} = - Planner.query(query, :all, cache, adapter, 0, query_cache?) - - opts = [cast_params: cast_params] ++ opts - - case query_meta do - %{select: nil} -> - adapter_meta - |> adapter.stream(query_meta, prepared, dump_params, opts) - |> Stream.flat_map(fn {_, nil} -> [] end) - - %{select: select, preloads: preloads} -> - %{ - assocs: assocs, - preprocess: preprocess, - postprocess: postprocess, - take: take, - from: from - } = select - - if preloads != [] or assocs != [] do - raise Ecto.QueryError, query: query, message: "preloads are not supported on streams" - end - - preprocessor = preprocessor(from, preprocess, adapter) - stream = adapter.stream(adapter_meta, query_meta, prepared, dump_params, opts) - postprocessor = postprocessor(from, postprocess, take, adapter) - - stream - |> Stream.flat_map(fn {_, rows} -> rows end) - |> Stream.map(preprocessor) - |> Stream.map(postprocessor) + if unsatisfiable_wheres?(query) do + Stream.concat([]) + else + query_cache? = Keyword.get(opts, :query_cache, true) + + {query_meta, prepared, cast_params, dump_params} = + Planner.query(query, :all, cache, adapter, 0, query_cache?) + + opts = [cast_params: cast_params] ++ opts + + case query_meta do + %{select: nil} -> + adapter_meta + |> adapter.stream(query_meta, prepared, dump_params, opts) + |> Stream.flat_map(fn {_, nil} -> [] end) + + %{select: select, preloads: preloads} -> + %{ + assocs: assocs, + preprocess: preprocess, + postprocess: postprocess, + take: take, + from: from + } = select + + if preloads != [] or assocs != [] do + raise Ecto.QueryError, query: query, message: "preloads are not supported on streams" + end + + preprocessor = preprocessor(from, preprocess, adapter) + stream = adapter.stream(adapter_meta, query_meta, prepared, dump_params, opts) + postprocessor = postprocessor(from, postprocess, take, adapter) + + stream + |> Stream.flat_map(fn {_, rows} -> rows end) + |> Stream.map(preprocessor) + |> Stream.map(postprocessor) + end end end @@ -131,11 +135,23 @@ defmodule Ecto.Repo.Queryable do end def aggregate(name, queryable, aggregate, opts) do - one!(name, query_for_aggregate(queryable, aggregate), opts) + queryable = Queryable.to_query(queryable) + + if unsatisfiable_wheres?(queryable) do + empty_aggregate(aggregate) + else + one!(name, query_for_aggregate(queryable, aggregate), opts) + end end def aggregate(name, queryable, aggregate, field, opts) do - one!(name, query_for_aggregate(queryable, aggregate, field), opts) + queryable = Queryable.to_query(queryable) + + if unsatisfiable_wheres?(queryable) do + empty_aggregate(aggregate) + else + one!(name, query_for_aggregate(queryable, aggregate, field), opts) + end end def exists?(name, queryable, opts) do @@ -224,37 +240,102 @@ defmodule Ecto.Repo.Queryable do {query, opts} = repo.prepare_query(operation, query, opts) query = attach_prefix(query, opts) - query_cache? = Keyword.get(opts, :query_cache, true) + if unsatisfiable_wheres?(query) do + empty_execute_result(operation) + else + query_cache? = Keyword.get(opts, :query_cache, true) + + {query_meta, prepared, cast_params, dump_params} = + Planner.query(query, operation, cache, adapter, 0, query_cache?) + + opts = [cast_params: cast_params] ++ opts + + case query_meta do + %{select: nil} -> + adapter.execute(adapter_meta, query_meta, prepared, dump_params, opts) + + %{select: select, sources: sources, preloads: preloads} -> + %{ + preprocess: preprocess, + postprocess: postprocess, + take: take, + assocs: assocs, + from: from + } = select + + preprocessor = preprocessor(from, preprocess, adapter) + {count, rows} = adapter.execute(adapter_meta, query_meta, prepared, dump_params, opts) + postprocessor = postprocessor(from, postprocess, take, adapter) + + {count, + rows + |> Ecto.Repo.Assoc.query(assocs, sources, preprocessor) + |> Ecto.Repo.Preloader.query(name, preloads, take, assocs, postprocessor, tuplet)} + end + end + end + + defp empty_execute_result(:all), do: {0, []} + defp empty_execute_result(:update_all), do: {0, nil} + defp empty_execute_result(:delete_all), do: {0, nil} - {query_meta, prepared, cast_params, dump_params} = - Planner.query(query, operation, cache, adapter, 0, query_cache?) + defp empty_aggregate(:count), do: 0 + defp empty_aggregate(_aggregate), do: nil - opts = [cast_params: cast_params] ++ opts + # When a set of WHERE clauses can be proven to be unsatisfiable (e.g. `where: x in []`), + # we can avoid hitting the database entirely. + defp unsatisfiable_wheres?(%{combinations: [_ | _]}), do: false + defp unsatisfiable_wheres?(%{wheres: []}), do: false - case query_meta do - %{select: nil} -> - adapter.execute(adapter_meta, query_meta, prepared, dump_params, opts) + defp unsatisfiable_wheres?(%{wheres: [%Query.BooleanExpr{} = first | rest]}) do + Enum.reduce_while(rest, static_bool(first), fn %Query.BooleanExpr{op: op} = expr, acc -> + case combine_static(acc, op, static_bool(expr)) do + false -> {:cont, false} + other -> {:halt, other} + end + end) == false + end + + defp combine_static(l, :and, r) when l == false or r == false, do: false + defp combine_static(true, :and, other), do: other + defp combine_static(other, :and, true), do: other + defp combine_static(l, :or, r) when l == true or r == true, do: true + defp combine_static(false, :or, other), do: other + defp combine_static(other, :or, false), do: other + defp combine_static(l, _, r) when l == :unknown or r == :unknown, do: :unknown + + defp static_bool(%Query.BooleanExpr{expr: expr, params: params}), do: static_bool(expr, params) + defp static_bool(true, _params), do: true + defp static_bool(false, _params), do: false + + defp static_bool({:not, _, [inner]}, params) do + case static_bool(inner, params) do + true -> false + false -> true + :unknown -> :unknown + end + end - %{select: select, sources: sources, preloads: preloads} -> - %{ - preprocess: preprocess, - postprocess: postprocess, - take: take, - assocs: assocs, - from: from - } = select + defp static_bool({:and, _, [left, right]}, params) do + combine_static(static_bool(left, params), :and, static_bool(right, params)) + end - preprocessor = preprocessor(from, preprocess, adapter) - {count, rows} = adapter.execute(adapter_meta, query_meta, prepared, dump_params, opts) - postprocessor = postprocessor(from, postprocess, take, adapter) + defp static_bool({:or, _, [left, right]}, params) do + combine_static(static_bool(left, params), :or, static_bool(right, params)) + end - {count, - rows - |> Ecto.Repo.Assoc.query(assocs, sources, preprocessor) - |> Ecto.Repo.Preloader.query(name, preloads, take, assocs, postprocessor, tuplet)} + defp static_bool({:in, _, [_, {:^, _, [ix]}]}, params) do + case Enum.at(params, ix) do + {[], _type} -> false + _other -> :unknown end end + defp static_bool({:in, _, [_, %Query.Tagged{value: []}]}, _), do: false + defp static_bool({:in, _, [_, []]}, _), do: false + defp static_bool({:in, _, _}, _), do: :unknown + defp static_bool(_other, _params), do: :unknown + defp preprocessor({_, {:source, {source, schema}, prefix, types}}, preprocess, adapter) do struct = Ecto.Schema.Loader.load_struct(schema, prefix, source) diff --git a/test/ecto/repo_test.exs b/test/ecto/repo_test.exs index 97c01bfe4b..80c3255a7c 100644 --- a/test/ecto/repo_test.exs +++ b/test/ecto/repo_test.exs @@ -977,6 +977,83 @@ defmodule Ecto.RepoTest do end end + describe "short-circuiting unsatisfiable WHEREs" do + test "all does not hit the database when provably unsatisfiable" do + empty = [] + assert from(s in MySchema, where: s.x in ^empty) |> TestRepo.all() == [] + refute_received {:all, _} + + assert from(s in MySchema, where: s.x in ^[]) |> TestRepo.all() == [] + refute_received {:all, _} + + assert from(s in MySchema, where: s.x in []) |> TestRepo.all() == [] + refute_received {:all, _} + + assert from(s in MySchema, where: false) |> TestRepo.all() == [] + refute_received {:all, _} + + assert from(s in MySchema, where: true and false) |> TestRepo.all() == [] + refute_received {:all, _} + + assert from(s in MySchema, where: true, where: false) |> TestRepo.all() == [] + refute_received {:all, _} + end + + test "all does not hit the database when empty `in` is and-ed with other filters" do + assert from(s in MySchema, where: s.x in ^[] and s.x == "a") |> TestRepo.all() == [] + refute_received {:all, _} + end + + test "correctly queries the database when empty `in` is or-ed with other filters" do + assert [_] = from(s in MySchema, where: s.x in ^[] or s.x == "a") |> TestRepo.all() + assert_received {:all, _} + + assert [_] = from(s in MySchema, where: s.x in ^[]) |> or_where([s], s.x == "a") |> TestRepo.all() + assert_received {:all, _} + + assert [_] = from(s in MySchema, where: s.x in ^[]) |> or_where([_], true) |> TestRepo.all() + assert_received {:all, _} + end + + test "correctly queries the database for values not in the empty list" do + assert [_] = from(s in MySchema, where: s.x not in ^[]) |> TestRepo.all() + assert_received {:all, _} + end + + test "correctly queries the database for non-empty `in` list" do + assert [_] = from(s in MySchema, where: s.x in ^["a"]) |> TestRepo.all() + assert_received {:all, _} + end + + test "update_all does not hit the database for empty `in`" do + assert {0, nil} = from(s in MySchema, where: s.x in ^[]) |> TestRepo.update_all(set: [x: "b"]) + refute_received {:update_all, _} + end + + test "delete_all does not hit the database for empty `in`" do + assert {0, nil} = from(s in MySchema, where: s.x in ^[]) |> TestRepo.delete_all() + refute_received {:delete_all, _} + end + + test "exists? does not hit the database for empty `in`" do + refute from(s in MySchema, where: s.x in ^[]) |> TestRepo.exists?() + refute_received {:all, _} + end + + test "aggregate queries do not hit the database for empty `in`" do + assert from(s in MySchema, where: s.x in ^[]) |> TestRepo.aggregate(:count) == 0 + refute_received {:all, _} + + assert from(s in MySchema, where: s.id in ^[]) |> TestRepo.aggregate(:sum, :id) == nil + refute_received {:all, _} + end + + test "stream does not hit the database for empty `in`" do + assert from(s in MySchema, where: s.x in ^[]) |> TestRepo.stream() |> Enum.to_list() == [] + refute_received {:stream, _} + end + end + describe "update_all" do test "raises on bad input" do # Success