Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion integration_test/cases/joins.exs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ defmodule Ecto.Integration.JoinsTest do
on: f1.num == f2.visits,
select: {f1, struct(f2, [:visits])}

assert {%Barebone{num: 1} = b1, %Post{visits: 1} = b2} = TestRepo.one(query)
assert {%Barebone{num: 1}, %Post{visits: 1}} = TestRepo.one(query)
end

## Associations joins
Expand Down
41 changes: 37 additions & 4 deletions lib/ecto/query/api.ex
Original file line number Diff line number Diff line change
Expand Up @@ -519,16 +519,49 @@ defmodule Ecto.Query.API do
@doc """
Allows a list argument to be spliced into a fragment.

Dynamic lists can be spliced into a query using interpolation

from p in Post, where: fragment("? in (?)", p.id, splice(^[1, 2, 3]))

The example above will be transformed at runtime into the following:
Note that each element of the list will be treated as a separate query parameter.
The example above will be transformed at runtime into the following

from p in Post, where: fragment("? in (?,?,?)", p.id, ^1, ^2, ^3)

You may only splice runtime values. For example, this would not work because
query bindings are compile-time constructs:
You may also splice compile-time lists. This allows you to combine query parameters
with literals and constructs like query bindings

sep = " "
from p in Post, select: fragment("concat(?)", splice([p.count, ^sep, "count"]))

The above example will be transformed into

sep = " "
from p in Post, select: fragment("concat(?,?,?)", p.count, ^sep, "count")

This is especially useful if you would like to create re-usable macros to inject
variadic database functions into queries. For example, you may create a macro for
the Postgres function `concat_ws` like below

defmacro concat_ws(sep, args) do
quote do
fragment("concat_ws(?,?)", unquote(sep), splice(unquote(args)))
end
end

Then you may call it from your application with argument lists of any size

from p in Post, select: concat_ws(":", [p.author, ^year, p.title])
from s in Sequences, select: concat_ws(".", ["public", s.relname])

You may nest others splices and fragment modifiers such as `identifier/1` and
`constant/1` inside of compile-time splices

from p in Post, where: fragment("? in (?)", p.id, splice([constant(^1), splice(^[2, 3])]))

This would be transformed into

from p in Post, where: fragment("concat(?)", splice(^[p.count, " ", "count"]))
from p in Post, where: fragment(? in (?,?,?), p.id, constant(1), ^2, ^3)
"""
def splice(list), do: doc!([list])

Expand Down
56 changes: 37 additions & 19 deletions lib/ecto/query/builder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ defmodule Ecto.Query.Builder do
end

{frags, params_acc} = Enum.map_reduce(frags, params_acc, &escape_fragment(&1, &2, vars, env))
{{:{}, [], [:fragment, [], merge_fragments(pieces, frags)]}, params_acc}
{{:{}, [], [:fragment, [], merge_fragments(pieces, frags, [])]}, params_acc}
end

# subqueries
Expand Down Expand Up @@ -638,7 +638,7 @@ defmodule Ecto.Query.Builder do
def fragment_pieces(frag, args) do
frag
|> split_fragment("")
|> merge_fragments(args)
|> merge_fragments(args, [])
end

defp escape_window_description([], params_acc, _vars, _env),
Expand Down Expand Up @@ -791,31 +791,49 @@ defmodule Ecto.Query.Builder do
end
end

defp escape_fragment({:splice, _meta, [splice]}, params_acc, vars, env) do
case splice do
{:^, _, [value]} = expr ->
checked = quote do: Ecto.Query.Builder.splice!(unquote(value))
length = quote do: length(unquote(checked))
{expr, params_acc} = escape(expr, {:splice, :any}, params_acc, vars, env)
escaped = {:{}, [], [:splice, [], [expr, length]]}
{escaped, params_acc}
defp escape_fragment({:splice, _meta, [{:^, _, [value]} = expr]}, params_acc, vars, env) do
checked = quote do: Ecto.Query.Builder.splice!(unquote(value))
length = quote do: length(unquote(checked))
{expr, params_acc} = escape(expr, {:splice, :any}, params_acc, vars, env)
escaped = {:{}, [], [:splice, [], [expr, length]]}
{escaped, params_acc}
end

_ ->
error!(
"splice/1 in fragment expects an interpolated value, such as splice(^value), got `#{Macro.to_string(splice)}`"
)
end
defp escape_fragment({:splice, _meta, [exprs]}, params_acc, vars, env) when is_list(exprs) do
{escaped, params_acc} =
Enum.map_reduce(exprs, params_acc, &escape_fragment(&1, &2, vars, env))

{{:splice, escaped}, params_acc}
end

defp escape_fragment({:splice, _meta, [other]}, _params_acc, _vars, _env) do
error!(
"splice/1 in fragment expects a compile-time list or interpolated value, got `#{Macro.to_string(other)}`"
)
end

defp escape_fragment(expr, params_acc, vars, env) do
escape(expr, :any, params_acc, vars, env)
end

defp merge_fragments([h1 | t1], [h2 | t2]),
do: [{:raw, h1}, {:expr, h2} | merge_fragments(t1, t2)]
defp merge_fragments([raw_h | raw_t], [{:splice, exprs} | expr_t], []),
do: [{:raw, raw_h} | merge_fragments(raw_t, expr_t, exprs)]

defp merge_fragments([raw_h | raw_t], [expr_h | expr_t], []),
do: [{:raw, raw_h}, {:expr, expr_h} | merge_fragments(raw_t, expr_t, [])]

defp merge_fragments([raw_h], [], []),
do: [{:raw, raw_h}]

defp merge_fragments(raw, expr, [{:splice, exprs} | splice_t]),
do: merge_fragments(raw, expr, exprs ++ splice_t)

defp merge_fragments(raw, expr, [splice_h]),
do: [{:expr, splice_h} | merge_fragments(raw, expr, [])]

defp merge_fragments(raw, expr, [splice_h | splice_t]),
do: [{:expr, splice_h}, {:raw, ","} | merge_fragments(raw, expr, splice_t)]

defp merge_fragments([h1], []),
do: [{:raw, h1}]

for {agg, arity} <- @dynamic_aggregates do
defp call_type(unquote(agg), unquote(arity)), do: {:any, :any}
Expand Down
29 changes: 29 additions & 0 deletions test/ecto/query/planner_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -1919,6 +1919,35 @@ defmodule Ecto.Query.PlannerTest do
assert length == 3
end

test "normalize: fragment with nested splicing" do
list = [3, 4]

{query, cast_params, dump_params, _} =
from(c in Comment)
|> where([c], c.id in fragment("(?, ?, ?)", ^1, splice([2, splice(^list)]), ^5))
|> normalize_with_params()

assert cast_params == [1, 3, 4, 5]
assert dump_params == [1, 3, 4, 5]

{:in, _, [_, {:fragment, _, parts}]} = hd(query.wheres).expr

assert [
_,
{:expr, {:^, _, [0]}},
_,
{:expr, 2},
_,
{:expr, {:splice, _, [{:^, _, [start_ix, length]}]}},
_,
{:expr, {:^, _, [3]}},
_
] = parts

assert start_ix == 1
assert length == 2
end

test "normalize: from values list" do
uuid = Ecto.UUID.generate()
values = [%{bid: uuid, num: 1}, %{bid: uuid, num: 2}]
Expand Down
107 changes: 106 additions & 1 deletion test/ecto/query_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -1068,6 +1068,12 @@ defmodule Ecto.QueryTest do
end

describe "fragment/1" do
defmacro concat_ws(sep, args) do
quote do
fragment("concat_ws(?,?)", unquote(sep), splice(unquote(args)))
end
end

test "raises at runtime when interpolation is not a keyword list" do
assert_raise ArgumentError,
~r/fragment\(...\) does not allow strings to be interpolated/s,
Expand Down Expand Up @@ -1123,7 +1129,7 @@ defmodule Ecto.QueryTest do
end
end

test "supports list splicing" do
test "supports interpolated list splicing" do
two = 2
three = 3

Expand All @@ -1147,6 +1153,105 @@ defmodule Ecto.QueryTest do
end
end

test "supports compile-time list splicing" do
query =
from p in "posts", where: p.id in fragment("(?,?,?)", ^1, splice([2, p.id, p.id + ^3]), ^5)

assert {:in, _, [_, {:fragment, _, parts}]} = hd(query.wheres).expr

assert [
raw: "(",
expr: {:^, _, [0]},
raw: ",",
expr: 2,
raw: ",",
expr: {{:., _, [{:&, _, [0]}, :id]}, _, _},
raw: ",",
expr: {:+, _, [{{:., _, [{:&, _, [0]}, :id]}, _, _}, {:^, _, [1]}]},
raw: ",",
expr: {:^, _, [2]},
raw: ")"
] = parts
end

test "supports compile-time list splicing with fragment modifiers" do
query =
from p in "posts", where: p.id in fragment("(?,?,?)", ^1, splice([2, constant(^3)]), ^5)

assert {:in, _, [_, {:fragment, _, parts}]} = hd(query.wheres).expr

assert [
raw: "(",
expr: {:^, _, [0]},
raw: ",",
expr: 2,
raw: ",",
expr: {:constant, _, [3]},
raw: ",",
expr: {:^, _, [1]},
raw: ")"
] = parts
end

test "supports compile-time list splicing with nested splicing" do
# nested runtime splice
list = [3, 4]

query =
from p in "posts", where: p.id in fragment("(?,?,?)", ^1, splice([2, splice(^list)]), ^5)

assert {:in, _, [_, {:fragment, _, parts}]} = hd(query.wheres).expr

assert [
raw: "(",
expr: {:^, _, [0]},
raw: ",",
expr: 2,
raw: ",",
expr: {:splice, _, [{:^, _, [1]}, 2]},
raw: ",",
expr: {:^, _, [2]},
raw: ")"
] = parts

# nested compile-time splice
query =
from p in "posts", where: p.id in fragment("(?,?,?)", ^1, splice([2, splice([3, 4]), 5]), ^6)

assert {:in, _, [_, {:fragment, _, parts}]} = hd(query.wheres).expr

assert [
raw: "(",
expr: {:^, _, [0]},
raw: ",",
expr: 2,
raw: ",",
expr: 3,
raw: ",",
expr: 4,
raw: ",",
expr: 5,
raw: ",",
expr: {:^, _, [1]},
raw: ")"
] = parts
end

test "supports compile-time splicing with macro" do
query = from p in "posts", select: concat_ws(":", [p.author, ^2000])
assert {:fragment, _, parts} = query.select.expr

assert [
raw: "concat_ws(",
expr: ":",
raw: ",",
expr: {{:., _, [{:&, _, [0]}, :author]}, _, _},
raw: ",",
expr: {:^, _, [0]},
raw: ")"
] = parts
end

test "keeps UTF-8 encoding" do
assert inspect(from p in "posts", where: fragment("héllò")) ==
~s[#Ecto.Query<from p0 in \"posts\", where: fragment("héllò")>]
Expand Down
Loading