From 897a94bb6a4e9f962814a72c04e055840be6e99e Mon Sep 17 00:00:00 2001 From: Greg Rychlewski Date: Fri, 9 Jan 2026 23:46:17 -0500 Subject: [PATCH] perform runtime splicing in ecto --- lib/ecto/query/builder.ex | 49 +++++++++++++++++++------------- lib/ecto/query/planner.ex | 12 -------- test/ecto/query/planner_test.exs | 24 +++++++++++----- test/ecto/query_test.exs | 16 +++++++---- 4 files changed, 57 insertions(+), 44 deletions(-) diff --git a/lib/ecto/query/builder.ex b/lib/ecto/query/builder.ex index 4b53b51c4f..f720b41bd0 100644 --- a/lib/ecto/query/builder.ex +++ b/lib/ecto/query/builder.ex @@ -204,7 +204,7 @@ defmodule Ecto.Query.Builder do {{:{}, [], [:fragment, [], [expr]]}, params_acc} end - def escape({:fragment, _, [query | frags]}, _type, params_acc, vars, env) do + def escape({:fragment, _, [query | frags]}, _type, {params, acc}, vars, env) do pieces = expand_and_split_fragment(query, env) if length(pieces) != length(frags) + 1 do @@ -214,8 +214,17 @@ 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} + {frags, {params, acc, compile_merge?}} = + Enum.map_reduce(frags, {params, acc, true}, &escape_fragment(&1, &2, vars, env)) + + merged = + if compile_merge? do + merge_fragments(pieces, frags, []) + else + quote do: Ecto.Query.Builder.merge_fragments(unquote(pieces), unquote(frags), []) + end + + {{:{}, [], [:fragment, [], merged]}, {params, acc}} end # subqueries @@ -790,12 +799,10 @@ defmodule Ecto.Query.Builder do end end - 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} + defp escape_fragment({:splice, _meta, [{:^, _, [value]}]}, {params, acc, _}, _vars, _env) do + checked = quote do: Ecto.Query.Builder.splice!(unquote(value), length(unquote(params))) + param = {value, {:splice, :any}} + {{:splice, checked}, {[param | params], acc, false}} end defp escape_fragment({:splice, _meta, [exprs]}, params_acc, vars, env) when is_list(exprs) do @@ -811,29 +818,31 @@ defmodule Ecto.Query.Builder do ) end - defp escape_fragment(expr, params_acc, vars, env) do - escape(expr, :any, params_acc, vars, env) + defp escape_fragment(expr, {params, acc, compile_merge?}, vars, env) do + {expr, {params, acc}} = + escape(expr, :any, {params, acc}, vars, env) + + {expr, {params, acc, compile_merge?}} end - defp merge_fragments([raw_h | raw_t], [{:splice, exprs} | expr_t], []), + def 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], []), + def 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], [], []), + def merge_fragments([raw_h], [], []), do: [{:raw, raw_h}] - defp merge_fragments(raw, expr, [{:splice, exprs} | splice_t]), + def merge_fragments(raw, expr, [{:splice, exprs} | splice_t]), do: merge_fragments(raw, expr, exprs ++ splice_t) - defp merge_fragments(raw, expr, [splice_h]), + def merge_fragments(raw, expr, [splice_h]), do: [{:expr, splice_h} | merge_fragments(raw, expr, [])] - defp merge_fragments(raw, expr, [splice_h | splice_t]), + def merge_fragments(raw, expr, [splice_h | splice_t]), do: [{:expr, splice_h}, {:raw, ","} | merge_fragments(raw, expr, splice_t)] - for {agg, arity} <- @dynamic_aggregates do defp call_type(unquote(agg), unquote(arity)), do: {:any, :any} end @@ -1336,9 +1345,9 @@ defmodule Ecto.Query.Builder do @doc """ Called by escaper at runtime to verify splice in fragments. """ - def splice!(value) do + def splice!(value, param_num) do if is_list(value) do - value + Enum.map(value, fn _ -> {:^, [], [param_num]} end) else raise ArgumentError, "splice(^value) expects `value` to be a list, got `#{inspect(value)}`" diff --git a/lib/ecto/query/planner.ex b/lib/ecto/query/planner.ex index fa1b8be024..fd7cd6b79f 100644 --- a/lib/ecto/query/planner.ex +++ b/lib/ecto/query/planner.ex @@ -1642,18 +1642,6 @@ defmodule Ecto.Query.Planner do {{quantifier, meta, [subquery]}, acc} end - defp prewalk( - {:splice, splice_meta, [{:^, meta, [_]}, length]}, - _kind, - _query, - _expr, - acc, - _adapter - ) do - param = {:^, meta, [acc, length]} - {{:splice, splice_meta, [param]}, acc + length} - end - defp prewalk({{:., dot_meta, [left, field]}, meta, []}, kind, query, expr, acc, _adapter) do {ix, ix_expr, ix_query} = get_ix!(left, kind, query) extra = if kind == :select, do: [type: type!(kind, ix_query, expr, ix, field)], else: [] diff --git a/test/ecto/query/planner_test.exs b/test/ecto/query/planner_test.exs index e6cd3ab28c..d42a812266 100644 --- a/test/ecto/query/planner_test.exs +++ b/test/ecto/query/planner_test.exs @@ -1914,9 +1914,20 @@ defmodule Ecto.Query.PlannerTest do assert dump_params == [1, 2, 3, 4, 5] {:in, _, [_, {:fragment, _, parts}]} = hd(query.wheres).expr - assert [_, _, _, {:expr, {:splice, _, [{:^, _, [start_ix, length]}]}}, _, _, _] = parts - assert start_ix == 1 - assert length == 3 + + assert [ + _, + {:expr, {:^, _, [0]}}, + _, + {:expr, {:^, _, [1]}}, + _, + {:expr, {:^, _, [2]}}, + _, + {:expr, {:^, _, [3]}}, + _, + {:expr, {:^, _, [4]}}, + _ + ] = parts end test "normalize: fragment with nested splicing" do @@ -1938,14 +1949,13 @@ defmodule Ecto.Query.PlannerTest do _, {:expr, 2}, _, - {:expr, {:splice, _, [{:^, _, [start_ix, length]}]}}, + {:expr, {:^, _, [1]}}, + _, + {:expr, {:^, _, [2]}}, _, {:expr, {:^, _, [3]}}, _ ] = parts - - assert start_ix == 1 - assert length == 2 end test "normalize: from values list" do diff --git a/test/ecto/query_test.exs b/test/ecto/query_test.exs index b30b8c66e3..e5bac67ce1 100644 --- a/test/ecto/query_test.exs +++ b/test/ecto/query_test.exs @@ -1134,16 +1134,20 @@ defmodule Ecto.QueryTest do three = 3 query = - from p in "posts", where: p.id in fragment("(?, ?, ?)", ^1, splice(^[two, three, 4]), ^5) + from p in "posts", where: p.id in fragment("(?,?,?)", ^1, splice(^[two, three, 4]), ^5) assert {:in, _, [_, {:fragment, _, parts}]} = hd(query.wheres).expr assert [ raw: "(", expr: {:^, _, [0]}, - raw: ", ", - expr: {:splice, _, [{:^, _, [1]}, 3]}, - raw: ", ", + raw: ",", + expr: {:^, _, [1]}, + raw: ",", + expr: {:^, _, [1]}, + raw: ",", + expr: {:^, _, [1]}, + raw: ",", expr: {:^, _, [2]}, raw: ")" ] = parts @@ -1208,7 +1212,9 @@ defmodule Ecto.QueryTest do raw: ",", expr: 2, raw: ",", - expr: {:splice, _, [{:^, _, [1]}, 2]}, + expr: {:^, _, [1]}, + raw: ",", + expr: {:^, _, [1]}, raw: ",", expr: {:^, _, [2]}, raw: ")"