Skip to content

Commit

Permalink
Added support for json_extract_path() in select queries
Browse files Browse the repository at this point in the history
- Also refined support for where/in
  • Loading branch information
heywhy authored and evadne committed Jun 28, 2022
1 parent 371d3dc commit 2af8316
Show file tree
Hide file tree
Showing 4 changed files with 129 additions and 25 deletions.
75 changes: 51 additions & 24 deletions lib/etso/ets/match_specification.ex
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
defmodule Etso.ETS.MatchSpecification do
@moduledoc """
The ETS Match Specifications module contains various functions which convert Ecto queries to
ETS Match Specifications in order to execute the given queries.
[ETS Match Specifications](https://www.erlang.org/doc/apps/erts/match_spec.html) in order to
execute the given queries with ETS with as much pushed down to ETS as possible.
The basic shape of the match head is `[$1, $2, $3, …]` where each field is a named variable, the
ordering of the fields is determined by `Etso.ETS.TableStructure`.
Conditions are compiled according to the wheres in the underlying Ecto query, while the body is
compiled based on the selected fields in the underlying Ecto query.
"""

def build(query, params) do
{_, schema} = query.from.source
field_names = Etso.ETS.TableStructure.field_names(schema)

match_head = build_head(field_names)
match_conditions = build_conditions(field_names, params, query.wheres)
match_body = [build_body(field_names, query.select.fields)]
Expand Down Expand Up @@ -45,56 +51,77 @@ defmodule Etso.ETS.MatchSpecification do
end
end

defp build_condition(field_names, params, {:in, [], [field, value]}) do
field_name = resolve_field_name(field)
field_index = get_field_index(field_names, field_name)
defp build_condition(field_names, params, {:in, [], [field, values]}) do
field_target = resolve_field_target(field_names, field)

case resolve_field_values(params, value) do
case resolve_param_values(params, values) do
[] -> []
values -> List.to_tuple([:orelse | Enum.map(values, &{:==, :"$#{field_index}", &1})])
values -> List.to_tuple([:orelse | Enum.map(values, &{:==, field_target, &1})])
end
end

defp build_condition(field_names, params, {:is_nil, [], [field]}) do
{:==, build_condition(field_names, params, field), nil}
end

defp build_condition(field_names, _, {{:., [], [{:&, [], [0]}, field_name]}, [], []}) do
:"$#{get_field_index(field_names, field_name)}"
end

defp build_condition(_, params, {:^, [], [index]}) do
Enum.at(params, index)
end

defp build_condition(_, _, value) when not is_tuple(value) do
defp build_condition(field_names, _params, field) when is_tuple(field) do
resolve_field_target(field_names, field)
end

defp build_condition(_, _, value) do
value
end

defp build_body(field_names, query_select_fields) do
for select_field <- query_select_fields do
field_name = resolve_field_name(select_field)
field_index = get_field_index(field_names, field_name)
:"$#{field_index}"
defp build_body(field_names, fields) do
for field <- fields do
resolve_field_target(field_names, field)
end
end

defp resolve_field_name(field) do
{{:., _, [{:&, [], [0]}, field_name]}, [], []} = field
field_name
defp resolve_field_target(field_names, {:json_extract_path, [], [field, path]}) do
field_target = resolve_field_target(field_names, field)
resolve_field_target_path(field_target, path)
end

defp resolve_field_target(field_names, {{:., _, [{:&, [], [0]}, field_name]}, [], []}) do
field_index = 1 + Enum.find_index(field_names, fn x -> x == field_name end)
:"$#{field_index}"
end

defp resolve_field_target_path(field_target, path) do
# - If the path component is a key, return {:map_get, key, target}
# - If the path component is a number, return {:hd, target} outside as many {:tl, _} around
# as required. For example, [:metadata, 0] would be {:hd, {:map_get, :metadata, field}},
# while [:metadata, 1] would be {:hd, {:tl, {:map_get, :metadata, field}}} (with one tl).

at = fn self ->
fn
condition, 0 -> {:hd, condition}
condition, index -> self.(self).({:tl, condition}, index - 1)
end
end

Enum.reduce(path, field_target, fn
key, condition when is_atom(key) or is_binary(key) -> {:map_get, key, condition}
index, condition when is_integer(index) -> at.(at).(condition, index)
end)
end

defp resolve_field_values(params, {:^, [], [index, count]}) do
defp resolve_param_values(params, {:^, [], [index, count]}) do
for index <- index..(index + count - 1) do
Enum.at(params, index)
end
end

defp resolve_field_values(params, {:^, [], [index]}) do
defp resolve_param_values(params, {:^, [], [index]}) do
Enum.at(params, index)
end

defp get_field_index(field_names, field_name) do
1 + Enum.find_index(field_names, fn x -> x == field_name end)
defp resolve_param_values(_params, values) when is_list(values) do
values
end
end
18 changes: 17 additions & 1 deletion priv/northwind/employees.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,23 @@
1581
],
"title": "Vice President Sales",
"titleOfCourtesy": "Dr."
"titleOfCourtesy": "Dr.",
"metadata": {
"twitter": "@andrew_fuller",
"photos": [
{
"storage": "a",
"url": "https://example.com/a"
},
{
"storage": "b",
"url": "https://example.com/b"
}
],
"documents": {
"passport": "verified"
}
}
},
{
"address": {
Expand Down
60 changes: 60 additions & 0 deletions test/northwind/repo_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,64 @@ defmodule Northwind.RepoTest do

assert sorted_etso == sorted_code
end

describe "json_extract_path" do
test "Support json_extract_path expression" do
Model.Employee
|> where([e], e.metadata["twitter"] == "@andrew_fuller")
|> Repo.one!()
end

test "Support nested json_extract_path expression" do
Model.Employee
|> where([e], e.metadata["documents"]["passport"] == "verified")
|> Repo.one!()
end

test "Support variable pinning in nested json_extract_path expression" do
field = "passport"

Model.Employee
|> where([e], e.metadata["documents"][^field] == "verified")
|> Repo.one!()

Model.Employee
|> select([e], json_extract_path(e.metadata, ["documents", "passport"]))
|> Repo.all()
|> Enum.any?(&(&1 == "verified"))
|> assert()
end

test "Support accessing JSON arrays in json_extract_path expression" do
Model.Employee
|> select([e], json_extract_path(e.metadata, ["photos", 0, "url"]))
|> where([e], e.metadata["documents"]["passport"] == "verified")
|> Repo.one!()
|> (&(&1 == "https://example.com/a")).()
|> assert()

Model.Employee
|> where([e], e.metadata["documents"]["passport"] == "verified")
|> select([e], e.metadata["photos"][0]["url"])
|> Repo.one!()
|> (&(&1 == "https://example.com/a")).()
|> assert()

Model.Employee
|> select([e], e.metadata["photos"][1]["url"])
|> where([e], e.metadata["documents"]["passport"] == "verified")
|> Repo.one!()
|> (&(&1 == "https://example.com/b")).()
|> assert()
end

test "Support where/in" do
Model.Employee
|> where([e], e.metadata["documents"]["passport"] in ~w(verified))
|> select([e], e.metadata["photos"][1]["url"])
|> Repo.one!()
|> (&(&1 == "https://example.com/b")).()
|> assert()
end
end
end
1 change: 1 addition & 0 deletions test/support/northwind/model/employee.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ defmodule Northwind.Model.Employee do
field :hire_date, :date
field :notes, :string
field :territory_ids, {:array, :integer}
field :metadata, :map, default: %{}

embeds_one :address, Model.Address

Expand Down

0 comments on commit 2af8316

Please sign in to comment.