Skip to content

Commit 6012035

Browse files
Allow source fields in json_extract_path (#4604)
1 parent f971230 commit 6012035

File tree

7 files changed

+116
-11
lines changed

7 files changed

+116
-11
lines changed

Diff for: integration_test/cases/type.exs

+35
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,41 @@ defmodule Ecto.Integration.TypeTest do
431431
assert TestRepo.one(from o in Order, where: o.metadata["tags"][0]["name"] == "red", select: o.id) == order.id
432432
end
433433

434+
@tag :map_type
435+
@tag :json_extract_path_with_field
436+
@tag :json_extract_path
437+
test "json_extract_path with fields in path" do
438+
order = %Order{id: 1, label: "tags", metadata: %{tags: [%{name: "red"}, %{name: "green"}]}}
439+
order = TestRepo.insert!(order)
440+
441+
assert TestRepo.one(from o in Order, select: o.metadata[o.label][1]["name"]) == "green"
442+
assert TestRepo.one(from o in Order, select: o.metadata["tags"][o.id]["name"]) == "green"
443+
444+
assert TestRepo.one(from o in Order, select: o.metadata["tags"][field(o, ^:id)]["name"]) ==
445+
"green"
446+
447+
squery = from o in Order, select: o.metadata["tags"][parent_as(:o).id]["name"]
448+
assert TestRepo.one(from o in Order, as: :o, where: subquery(squery) == ^"green")
449+
450+
squery = from o in Order, select: o.metadata["tags"][field(parent_as(:o), ^:id)]["name"]
451+
assert TestRepo.one(from o in Order, as: :o, where: subquery(squery) == ^"green")
452+
453+
assert TestRepo.one(
454+
from(o in Order,
455+
where: o.metadata["tags"][o.id]["name"] == "green",
456+
select: o.id)
457+
) == order.id
458+
459+
assert TestRepo.one(
460+
from(o in Order,
461+
where: o.metadata["tags"][field(o, ^:id)]["name"] == "green",
462+
select: o.id)
463+
) == order.id
464+
465+
squery = from o in Order, where: o.metadata["tags"][parent_as(:o).id]["name"] == "green"
466+
assert TestRepo.one(from o in Order, as: :o, where: exists(subquery(squery)))
467+
end
468+
434469
@tag :map_type
435470
@tag :map_type_schemaless
436471
test "embeds one with custom type" do

Diff for: integration_test/support/schemas.exs

+1
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ defmodule Ecto.Integration.Order do
281281
use Ecto.Integration.Schema
282282

283283
schema "orders" do
284+
field :label, :string
284285
field :metadata, :map, source: :meta
285286
embeds_one :item, Ecto.Integration.Item
286287
embeds_many :items, Ecto.Integration.Item

Diff for: lib/ecto/query/api.ex

+8
Original file line numberDiff line numberDiff line change
@@ -765,11 +765,19 @@ defmodule Ecto.Query.API do
765765
766766
from(post in Post, select: post.meta["tags"][0]["name"])
767767
768+
Some adapters allow path elements to be references to query source fields
769+
770+
from(post in Post, select: post.meta[p.title])
771+
from(p in Post, join: u in User, on: p.user_id == u.id, select: p.meta[u.name])
772+
768773
Any element of the path can be dynamic:
769774
770775
field = "name"
771776
from(post in Post, select: post.meta["author"][^field])
772777
778+
source_field = :source_column
779+
from(post in Post, select: post.meta["author"][field(p, ^source_field)])
780+
773781
## Warning: indexes on PostgreSQL
774782
775783
PostgreSQL supports indexing on jsonb columns via GIN indexes.

Diff for: lib/ecto/query/builder.ex

+18-10
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ defmodule Ecto.Query.Builder do
269269
def escape({:json_extract_path, _, [field, path]}, type, params_acc, vars, env) do
270270
validate_json_field!(field)
271271

272-
path = escape_json_path(path)
272+
path = escape_json_path(path, vars)
273273
{field, params_acc} = escape(field, type, params_acc, vars, env)
274274
{{:{}, [], [:json_extract_path, [], [field, path]]}, params_acc}
275275
end
@@ -1194,36 +1194,44 @@ defmodule Ecto.Query.Builder do
11941194
end
11951195
end
11961196

1197-
defp escape_json_path(path) when is_list(path) do
1198-
Enum.map(path, &quoted_json_path_element!/1)
1197+
defp escape_json_path(path, vars) when is_list(path) do
1198+
Enum.map(path, &quoted_json_path_element!(&1, vars))
11991199
end
12001200

1201-
defp escape_json_path({:^, _, [path]}) do
1201+
defp escape_json_path({:^, _, [path]}, _vars) do
12021202
quote do
12031203
path = Ecto.Query.Builder.json_path!(unquote(path))
12041204
Enum.map(path, &Ecto.Query.Builder.json_path_element!/1)
12051205
end
12061206
end
12071207

1208-
defp escape_json_path(other) do
1208+
defp escape_json_path(other, _vars) do
12091209
error!(
12101210
"expected JSON path to be a literal list or interpolated value, got: `#{Macro.to_string(other)}`"
12111211
)
12121212
end
12131213

1214-
defp quoted_json_path_element!({:^, _, [expr]}),
1214+
defp quoted_json_path_element!({:^, _, [expr]}, _vars),
12151215
do: quote(do: Ecto.Query.Builder.json_path_element!(unquote(expr)))
12161216

1217-
defp quoted_json_path_element!(binary) when is_binary(binary),
1217+
defp quoted_json_path_element!(binary, _vars) when is_binary(binary),
12181218
do: binary
12191219

1220-
defp quoted_json_path_element!(integer) when is_integer(integer),
1220+
defp quoted_json_path_element!(integer, _vars) when is_integer(integer),
12211221
do: integer
12221222

1223-
defp quoted_json_path_element!(other),
1223+
defp quoted_json_path_element!({{:., _, [callee, field]}, _, []}, vars) do
1224+
escape_field!(callee, field, vars)
1225+
end
1226+
1227+
defp quoted_json_path_element!({:field, _, [callee, field]}, vars) do
1228+
escape_field!(callee, field, vars)
1229+
end
1230+
1231+
defp quoted_json_path_element!(other, _vars),
12241232
do:
12251233
error!(
1226-
"expected JSON path to contain literal strings, literal integers, or interpolated values, got: " <>
1234+
"expected JSON path to contain literal strings, literal integers, fields, or interpolated values, got: " <>
12271235
"`#{Macro.to_string(other)}`"
12281236
)
12291237

Diff for: lib/ecto/query/planner.ex

+22-1
Original file line numberDiff line numberDiff line change
@@ -1441,7 +1441,8 @@ defmodule Ecto.Query.Planner do
14411441
{Enum.reverse(combinations), counter}
14421442
end
14431443

1444-
defp validate_json_path!([path_field | rest], field, {:parameterized, {Ecto.Embedded, embed}}) do
1444+
defp validate_json_path!([path_field | rest], field, {:parameterized, {Ecto.Embedded, embed}})
1445+
when is_binary(path_field) or is_integer(path_field) do
14451446
case embed do
14461447
%{related: related, cardinality: :one} ->
14471448
unless Enum.any?(related.__schema__(:fields), &(Atom.to_string(&1) == path_field)) do
@@ -1464,6 +1465,26 @@ defmodule Ecto.Query.Planner do
14641465
end
14651466
end
14661467

1468+
defp validate_json_path!([path_field | rest], field, {:parameterized, {Ecto.Embedded, embed}}) do
1469+
case embed do
1470+
%{related: _, cardinality: :one} ->
1471+
# A source field cannot be used to validate whether the next step in the
1472+
# path exists in the embedded schema, so we stop here. If there is an error
1473+
# later in the path it will be caught by the driver.
1474+
:ok
1475+
1476+
%{related: _, cardinality: :many} ->
1477+
# The source field may not be an integer but for the sake of validating
1478+
# the rest of the path, we assume it is. The error will be caught later
1479+
# by the driver if it is not.
1480+
updated_embed = %{embed | cardinality: :one}
1481+
validate_json_path!(rest, path_field, {:parameterized, {Ecto.Embedded, updated_embed}})
1482+
1483+
other ->
1484+
raise "expected field `#{field}` to be of type embed, got: `#{inspect(other)}`"
1485+
end
1486+
end
1487+
14671488
defp validate_json_path!([_path_field | _rest] = path, field, other_type) do
14681489
case Ecto.Type.type(other_type) do
14691490
:any ->

Diff for: test/ecto/query/builder_test.exs

+13
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,19 @@ defmodule Ecto.Query.BuilderTest do
279279

280280
assert actual == expected
281281

282+
expected = {Macro.escape(quote do: json_extract_path(&0.y(), [0, &0.z(), "a"])), []}
283+
284+
actual =
285+
escape(
286+
quote do
287+
x.y[0][x.z]["a"]
288+
end,
289+
[x: 0],
290+
__ENV__
291+
)
292+
293+
assert actual == expected
294+
282295
assert_raise Ecto.Query.CompileError, "`x` is not a valid json field", fn ->
283296
escape(
284297
quote do

Diff for: test/ecto/query/planner_test.exs

+19
Original file line numberDiff line numberDiff line change
@@ -1663,6 +1663,18 @@ defmodule Ecto.Query.PlannerTest do
16631663
query = from(Post, []) |> select([p], p.metas[0]["slug"])
16641664
normalize(query)
16651665

1666+
query = from(Post, []) |> select([p], p.meta[p.title])
1667+
normalize(query)
1668+
1669+
query = from(Post, []) |> select([p], p.meta[p.title]["author"])
1670+
normalize(query)
1671+
1672+
query = from(Post, []) |> select([p], p.meta["author"][p.title])
1673+
normalize(query)
1674+
1675+
query = from(Post, []) |> select([p], p.metas[p.visits]["slug"])
1676+
normalize(query)
1677+
16661678
query = from(Post, []) |> select([p], p.payload["unknown_field"])
16671679
normalize(query)
16681680

@@ -1715,6 +1727,13 @@ defmodule Ecto.Query.PlannerTest do
17151727
normalize(query)
17161728
end
17171729

1730+
assert_raise RuntimeError,
1731+
"field `unknown_field` does not exist in Ecto.Query.PlannerTest.PostMeta",
1732+
fn ->
1733+
query = from(Post, []) |> select([p], p.metas[p.visits]["unknown_field"])
1734+
normalize(query)
1735+
end
1736+
17181737
assert_raise RuntimeError,
17191738
"field `0` does not exist in Ecto.Query.PlannerTest.PostMeta",
17201739
fn ->

0 commit comments

Comments
 (0)