From 04b5e9a83ec02ea2a54d93f7288e25ee19aaccdf Mon Sep 17 00:00:00 2001 From: Tyler Young Date: Mon, 10 Aug 2026 07:07:03 -0500 Subject: [PATCH] perf: Avoid hitting the database when queries can be statically analyzed as never returning any results I often find myself writing code like this: ``` def get_users_by_ids(ids) def get_users_by_ids([]), do: [] def get_users_by_ids(ids) do from(u in User, where: u.id in ^ids) |> Repo.all() end ``` Today, if I left off that first function head, Ecto would still hit the database with a query like `SELECT * FROM users WHERE false`. Obviously that's not a heavy query, but to the extent that I can avoid putting trivial load on my database (not to mention the round-trip query time), I would prefer to do so. To that end, this PR adds a gate to `Ecto.Repo.Queryable`'s `stream/3`, `aggregate/{4,5}`, and `execute/4` functions to first check whether the WHERE clauses can be statically shown to be unsatisfiable, and if so, return the appropriate empty result. (Note that the diff looks quite large, but the delta to those existing functions is largely whitespace.) --- lib/ecto/repo/queryable.ex | 197 ++++++++++++++++++++++++++----------- test/ecto/repo_test.exs | 77 +++++++++++++++ 2 files changed, 216 insertions(+), 58 deletions(-) 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