Skip to content

Commit

Permalink
Support JSON extract path expression
Browse files Browse the repository at this point in the history
  • Loading branch information
heywhy committed Jun 29, 2022
1 parent 8e0b164 commit 8c58b1a
Show file tree
Hide file tree
Showing 7 changed files with 220 additions and 112 deletions.
15 changes: 1 addition & 14 deletions lib/etso/adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ defmodule Etso.Adapter do
end
"""

alias Etso.Adapter.TableRegistry

@behaviour Ecto.Adapter
@behaviour Ecto.Adapter.Schema
@behaviour Ecto.Adapter.Queryable
Expand All @@ -32,7 +30,7 @@ defmodule Etso.Adapter do
{:ok, repo} = Keyword.fetch(config, :repo)
child_spec = __MODULE__.Supervisor.child_spec(repo)
adapter_meta = %__MODULE__.Meta{repo: repo}
{:ok, child_spec, Map.from_struct(adapter_meta)}
{:ok, child_spec, adapter_meta}
end

@doc false
Expand All @@ -53,19 +51,8 @@ defmodule Etso.Adapter do
@impl Ecto.Adapter
def dumpers(:binary_id, type), do: [type, Ecto.UUID]
def dumpers(:embed_id, type), do: [type, Ecto.UUID]
def dumpers(:map, type), do: [type, Etso.Ecto.MapType]
def dumpers(_, type), do: [type]

@doc """
Delete all data from the tables.
"""
@spec flush_tables(module()) :: :ok
def flush_tables(repo) do
repo
|> TableRegistry.active_tables()
|> Enum.each(& :ets.delete_all_objects(&1) == true)
end

for {implementation_module, behaviour_module} <- [
{__MODULE__.Behaviour.Schema, Ecto.Adapter.Schema},
{__MODULE__.Behaviour.Queryable, Ecto.Adapter.Queryable}
Expand Down
46 changes: 40 additions & 6 deletions lib/etso/adapter/behaviour/queryable.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,55 @@ defmodule Etso.Adapter.Behaviour.Queryable do
alias Etso.ETS.ObjectsSorter

@impl Ecto.Adapter.Queryable
def prepare(:all, query) do
{:nocache, query}
def prepare(:all, %Ecto.Query{} = query) do
{:nocache, {:select, query}}
end

@impl Ecto.Adapter.Queryable
def execute(%{repo: repo}, _, {:nocache, query}, params, _) do
def prepare(:delete_all, %Ecto.Query{wheres: []} = query) do
{:nocache, {:delete_all_objects, query}}
end

@impl Ecto.Adapter.Queryable
def prepare(:delete_all, %Ecto.Query{wheres: _} = query) do
{:nocache, {:match_delete, query}}
end

@impl Ecto.Adapter.Queryable
def execute(%{repo: repo}, _, {:nocache, {:select, query}}, params, _) do
{_, schema} = query.from.source
{:ok, ets_table} = TableRegistry.get_table(repo, schema)
ets_match = MatchSpecification.build(query, params)
ets_objects = :ets.select(ets_table, [ets_match])
ets_count = length(ets_objects)
{ets_count, ObjectsSorter.sort(ets_objects, query)}
end

@impl Ecto.Adapter.Queryable
def execute(%{repo: repo}, _, {:nocache, {:delete_all_objects, query}}, params, _) do
{_, schema} = query.from.source
{:ok, ets_table} = TableRegistry.get_table(repo, schema)
ets_match = MatchSpecification.build(query, params)
ets_objects = query.select && ObjectsSorter.sort(:ets.select(ets_table, [ets_match]), query)
ets_count = :ets.info(ets_table, :size)
true = :ets.delete_all_objects(ets_table)
{ets_count, ets_objects || nil}
end

@impl Ecto.Adapter.Queryable
def execute(%{repo: repo}, _, {:nocache, {:match_delete, query}}, params, _) do
{_, schema} = query.from.source
{:ok, ets_table} = TableRegistry.get_table(repo, schema)
ets_match = MatchSpecification.build(query, params)
ets_objects = :ets.select(ets_table, [ets_match]) |> ObjectsSorter.sort(query)
{length(ets_objects), ets_objects}
ets_objects = query.select && ObjectsSorter.sort(:ets.select(ets_table, [ets_match]), query)
{ets_match_head, ets_match_body, _} = ets_match
ets_match = {ets_match_head, ets_match_body, [true]}
ets_count = :ets.select_delete(ets_table, [ets_match])
{ets_count, ets_objects || nil}
end

@impl Ecto.Adapter.Queryable
def stream(%{repo: repo}, _, {:nocache, query}, params, options) do
def stream(%{repo: repo}, _, {:nocache, {:select, query}}, params, options) do
{_, schema} = query.from.source
{:ok, ets_table} = TableRegistry.get_table(repo, schema)
ets_match = MatchSpecification.build(query, params)
Expand Down
46 changes: 0 additions & 46 deletions lib/etso/adapter/types/map.ex

This file was deleted.

100 changes: 64 additions & 36 deletions lib/etso/ets/match_specification.ex
Original file line number Diff line number Diff line change
@@ -1,25 +1,31 @@
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)]
match_conditions = build_conditions(field_names, params, query)
match_body = [build_body(field_names, query)]
{match_head, match_conditions, match_body}
end

defp build_head(field_names) do
List.to_tuple(Enum.map(1..length(field_names), fn x -> :"$#{x}" end))
end

defp build_conditions(field_names, params, query_wheres) do
Enum.reduce(query_wheres, [], fn %Ecto.Query.BooleanExpr{expr: expression}, acc ->
defp build_conditions(field_names, params, %Ecto.Query{wheres: wheres}) do
Enum.reduce(wheres, [], fn %Ecto.Query.BooleanExpr{expr: expression}, acc ->
[build_condition(field_names, params, expression) | acc]
end)
end
Expand All @@ -45,65 +51,87 @@ 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, _, {:json_extract_path, [], paths}) do
[parent, fields] = paths
[field | remaining] = fields
acc = {:map_get, field, build_condition(field_names, nil, parent)}
Enum.reduce(remaining, acc, fn field, acc ->
{:map_get, field, acc}
end)
defp build_condition(_, params, {:^, [], [index]}) do
Enum.at(params, index)
end

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

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

defp build_condition(_, _, value) when not is_tuple(value) do
value
defp build_body(_, %Ecto.Query{select: nil}) do
[]
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, %Ecto.Query{select: %{fields: 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_values(params, {:^, [], [index, count]}) do
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_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

def list_spec(0, acc), do: {:hd, acc}

def list_spec(index, acc) do
list_spec(index - 1, {:tl, acc})
end
end
2 changes: 1 addition & 1 deletion mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
"dialyxir": {:hex, :dialyxir, "1.0.0-rc.6", "78e97d9c0ff1b5521dd68041193891aebebce52fc3b93463c0a6806874557d7d", [:mix], [{:erlex, "~> 0.2.1", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "49496d63267bc1a4614ffd5f67c45d9fc3ea62701a6797975bc98bc156d2763f"},
"earmark": {:hex, :earmark, "1.3.6", "ce1d0675e10a5bb46b007549362bd3f5f08908843957687d8484fe7f37466b19", [:mix], [], "hexpm", "1476378df80982302d5a7857b6a11dd0230865057dec6d16544afecc6bc6b4c2"},
"ecto": {:hex, :ecto, "3.8.3", "5e681d35bc2cbb46dcca1e2675837c7d666316e5ada14eca6c9c609b6232817c", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "af92dd7815967bcaea0daaaccf31c3b23165432b1c7a475d84144efbc703d105"},
"ecto": {:hex, :ecto, "3.8.4", "e06b8b87e62b27fea17fd2ff6041572ddd10339fd16cdf58446e402c6c90a74b", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f9244288b8d42db40515463a008cf3f4e0e564bb9c249fe87bf28a6d79fe82d4"},
"erlex": {:hex, :erlex, "0.2.4", "23791959df45fe8f01f388c6f7eb733cc361668cbeedd801bf491c55a029917b", [:mix], [], "hexpm", "4a12ebc7cd8f24f2d0fce93d279fa34eb5068e0e885bb841d558c4d83c52c439"},
"ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f1155337ae17ff7a1255217b4c1ceefcd1860b7ceb1a1874031e7a861b052e39"},
"jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"},
Expand Down
19 changes: 17 additions & 2 deletions priv/northwind/employees.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,22 @@
],
"title": "Vice President Sales",
"titleOfCourtesy": "Dr.",
"metadata": {"twitter": "@andrew_fuller"}
"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 Expand Up @@ -238,4 +253,4 @@
"title": "Sales Representative",
"titleOfCourtesy": "Ms."
}
]
]
Loading

0 comments on commit 8c58b1a

Please sign in to comment.