Skip to content

Commit 73bd66d

Browse files
feat: add native_in/2 function for SQL IN (...) syntax (#728)
Adds AshPostgres.Functions.NativeIn as an escape hatch for cases where PostgreSQL's query planner produces suboptimal plans with = ANY(...) array syntax. The native_in/2 function generates IN ($1, $2, ...) with individually typed parameters instead. Usage: filter(query, native_in(field, [^v1, ^v2, ^v3])) Closes ash-project/ash#2605
1 parent ba1d148 commit 73bd66d

File tree

4 files changed

+197
-1
lines changed

4 files changed

+197
-1
lines changed

lib/data_layer.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -891,7 +891,8 @@ defmodule AshPostgres.DataLayer do
891891
functions = [
892892
AshPostgres.Functions.Like,
893893
AshPostgres.Functions.ILike,
894-
AshPostgres.Functions.Binding
894+
AshPostgres.Functions.Binding,
895+
AshPostgres.Functions.PostgresIn
895896
]
896897

897898
functions = [Ash.Query.Function.RequiredError | functions]

lib/functions/postgres_in.ex

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# SPDX-FileCopyrightText: 2019 ash_postgres contributors <https://github.com/ash-project/ash_postgres/graphs/contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule AshPostgres.Functions.PostgresIn do
6+
@moduledoc """
7+
Generates a native SQL `IN (...)` clause instead of the default `= ANY(...)` array syntax.
8+
9+
PostgreSQL's query planner may choose different (sometimes suboptimal) indexes when using
10+
`= ANY('{...}'::type[])` compared to `IN ($1, $2, ...)`. This function provides an escape
11+
hatch for cases where the native `IN` syntax produces better query plans.
12+
13+
## Example
14+
15+
filter(query, postgres_in(id, [^id1, ^id2, ^id3]))
16+
17+
Generates:
18+
19+
WHERE id IN ($1, $2, $3)
20+
21+
Instead of the default:
22+
23+
WHERE id = ANY($1::uuid[])
24+
25+
See: https://github.com/ash-project/ash/issues/2605
26+
"""
27+
28+
use Ash.Query.Function, name: :postgres_in, predicate?: true
29+
30+
def args, do: [[:any, {:array, :any}]]
31+
end

lib/sql_implementation.ex

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,91 @@ defmodule AshPostgres.SqlImplementation do
177177
{:ok, Ecto.Query.dynamic(fragment("(? <-> ?)", ^arg1, ^arg2)), acc}
178178
end
179179

180+
def expr(
181+
query,
182+
%AshPostgres.Functions.PostgresIn{
183+
arguments: [left, right],
184+
embedded?: pred_embedded?
185+
},
186+
bindings,
187+
embedded?,
188+
acc,
189+
_type
190+
) do
191+
context_embedded? = pred_embedded? || embedded?
192+
193+
# Determine the Ecto type from the left-hand side for proper value encoding
194+
left_type =
195+
case left do
196+
%Ash.Query.Ref{attribute: %{type: type, constraints: constraints}} ->
197+
AshPostgres.SqlImplementation.parameterized_type(type, constraints)
198+
199+
_ ->
200+
:any
201+
end
202+
203+
{left_expr, acc} =
204+
AshSql.Expr.dynamic_expr(query, left, bindings, context_embedded?, :any, acc)
205+
206+
case right do
207+
%Ash.Query.Ref{} ->
208+
# If right side is a reference (i.e. an array column), fall back to = ANY(...)
209+
{right_expr, acc} =
210+
AshSql.Expr.dynamic_expr(query, right, bindings, context_embedded?, :any, acc)
211+
212+
{:ok, Ecto.Query.dynamic(^left_expr in ^right_expr), acc}
213+
214+
_ ->
215+
values =
216+
case right do
217+
%Ash.Query.Function.Type{arguments: [value | _]} -> value
218+
value -> value
219+
end
220+
221+
values = if is_list(values), do: values, else: [values]
222+
223+
# Build params and fragment_data in forward order (not reversed)
224+
# Param index 0 = left_expr, indices 1..N = values
225+
params = [{left_expr, :any}]
226+
227+
{params, value_fragment_parts, _count, acc} =
228+
Enum.reduce(values, {params, [], 1, acc}, fn value, {params, parts, count, acc} ->
229+
{value_expr, acc} =
230+
AshSql.Expr.dynamic_expr(query, value, bindings, context_embedded?, :any, acc)
231+
232+
separator =
233+
if count == 1, do: "", else: ", "
234+
235+
typed_value =
236+
if left_type != :any do
237+
Ecto.Query.dynamic(type(^value_expr, ^left_type))
238+
else
239+
value_expr
240+
end
241+
242+
new_parts = [{:raw, separator}, {:expr, {:^, [], [count]}}]
243+
{params ++ [{typed_value, :any}], parts ++ new_parts, count + 1, acc}
244+
end)
245+
246+
# Build complete fragment: "" left_expr " IN (" v1 ", " v2 ... ")"
247+
fragment_data =
248+
[{:raw, ""}, {:expr, {:^, [], [0]}}, {:raw, " IN ("}] ++
249+
value_fragment_parts ++
250+
[{:raw, ")"}]
251+
252+
dynamic = %Ecto.Query.DynamicExpr{
253+
fun: fn _query ->
254+
{{:fragment, [], fragment_data}, params, [], %{}}
255+
end,
256+
binding: [],
257+
file: __ENV__.file,
258+
line: __ENV__.line
259+
}
260+
261+
{:ok, dynamic, acc}
262+
end
263+
end
264+
180265
def expr(
181266
query,
182267
%Ash.Query.Ref{

test/postgres_in_test.exs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# SPDX-FileCopyrightText: 2019 ash_postgres contributors <https://github.com/ash-project/ash_postgres/graphs/contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule AshPostgres.PostgresInTest do
6+
use AshPostgres.RepoCase, async: false
7+
8+
alias AshPostgres.Test.Post
9+
10+
require Ash.Query
11+
12+
describe "postgres_in/2" do
13+
test "generates SQL IN (...) syntax instead of = ANY(...)" do
14+
{query, _vars} =
15+
Post
16+
|> Ash.Query.filter(postgres_in(id, [^Ash.UUID.generate(), ^Ash.UUID.generate()]))
17+
|> Ash.data_layer_query!()
18+
|> Map.get(:query)
19+
|> then(&AshPostgres.TestRepo.to_sql(:all, &1))
20+
21+
assert query =~ " IN ("
22+
refute query =~ "ANY("
23+
end
24+
25+
test "returns matching records" do
26+
post1 =
27+
Post
28+
|> Ash.Changeset.for_create(:create, %{title: "first"})
29+
|> Ash.create!()
30+
31+
post2 =
32+
Post
33+
|> Ash.Changeset.for_create(:create, %{title: "second"})
34+
|> Ash.create!()
35+
36+
_post3 =
37+
Post
38+
|> Ash.Changeset.for_create(:create, %{title: "third"})
39+
|> Ash.create!()
40+
41+
results =
42+
Post
43+
|> Ash.Query.filter(postgres_in(id, [^post1.id, ^post2.id]))
44+
|> Ash.read!()
45+
|> Enum.sort_by(& &1.title)
46+
47+
assert length(results) == 2
48+
assert [%{title: "first"}, %{title: "second"}] = results
49+
end
50+
51+
test "returns empty list when no values match" do
52+
Post
53+
|> Ash.Changeset.for_create(:create, %{title: "existing"})
54+
|> Ash.create!()
55+
56+
results =
57+
Post
58+
|> Ash.Query.filter(postgres_in(id, [^Ash.UUID.generate()]))
59+
|> Ash.read!()
60+
61+
assert results == []
62+
end
63+
64+
test "works with a single value" do
65+
post =
66+
Post
67+
|> Ash.Changeset.for_create(:create, %{title: "only"})
68+
|> Ash.create!()
69+
70+
results =
71+
Post
72+
|> Ash.Query.filter(postgres_in(id, [^post.id]))
73+
|> Ash.read!()
74+
75+
assert length(results) == 1
76+
assert [%{title: "only"}] = results
77+
end
78+
end
79+
end

0 commit comments

Comments
 (0)