Skip to content

Commit 63d297c

Browse files
authored
feat: support nested filtering in the query parser (#366)
* feat: support nested filtering Also renames a few private functions to clarify the difference between "validity" of a filter or include (represents a real option as determined by a view) vs. "allowed" (is specified in the allow-list passed to the QueryParser plug). * chore: add requested more deeply nested tests * fix: simplify and correct deep_merge conflict handling * chore: add test that string/string conflicts result in the second string being chosen
1 parent e86c2aa commit 63d297c

File tree

4 files changed

+78
-11
lines changed

4 files changed

+78
-11
lines changed

lib/jsonapi/plugs/query_parser.ex

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -119,12 +119,15 @@ defmodule JSONAPI.QueryParser do
119119
opts_filter = Keyword.get(opts, :filter, [])
120120

121121
Enum.reduce(filter, config, fn {key, val}, acc ->
122-
check_filter_validity!(opts_filter, key, config)
123-
%{acc | filter: Keyword.put(acc.filter, String.to_existing_atom(key), val)}
122+
check_filter_allowed!(opts_filter, key, config)
123+
124+
keys = key |> String.split(".") |> Enum.map(&String.to_existing_atom/1)
125+
filter = deep_merge(acc.filter, put_as_tree([], keys, val))
126+
%{acc | filter: filter}
124127
end)
125128
end
126129

127-
defp check_filter_validity!(filters, key, config) do
130+
defp check_filter_allowed!(filters, key, config) do
128131
unless key in filters do
129132
raise InvalidQuery, resource: config.view.type(), param: key, param_type: :filter
130133
end
@@ -215,7 +218,9 @@ defmodule JSONAPI.QueryParser do
215218
end
216219

217220
defp include_reducer(config, valid_includes, inc, acc) do
218-
check_include_validity!(inc, config)
221+
# if an explicit list of allowed includes was specified, check this include
222+
# against it:
223+
check_include_allowed!(inc, config)
219224

220225
if inc =~ ~r/\w+\.\w+/ do
221226
acc ++ handle_nested_include(inc, valid_includes, config)
@@ -236,25 +241,25 @@ defmodule JSONAPI.QueryParser do
236241
end
237242
end
238243

239-
defp check_include_validity!(key, %Config{opts: opts, view: view}) do
244+
defp check_include_allowed!(key, %Config{opts: opts, view: view}) do
240245
if opts do
241-
check_include_validity!(key, Keyword.get(opts, :include), view)
246+
check_include_allowed!(key, Keyword.get(opts, :include), view)
242247
end
243248
end
244249

245-
defp check_include_validity!(key, allowed_includes, view) when is_list(allowed_includes) do
250+
defp check_include_allowed!(key, allowed_includes, view) when is_list(allowed_includes) do
246251
unless key in allowed_includes do
247252
raise_invalid_include_query(key, view.type())
248253
end
249254
end
250255

251-
defp check_include_validity!(_key, nil, _view) do
256+
defp check_include_allowed!(_key, nil, _view) do
252257
# all includes are allowed if none are specified in input config
253258
end
254259

255260
@spec handle_nested_include(key :: String.t(), valid_include :: list(), config :: Config.t()) ::
256261
list() | no_return()
257-
def handle_nested_include(key, valid_include, config) do
262+
def handle_nested_include(key, valid_includes, config) do
258263
keys =
259264
try do
260265
key
@@ -267,7 +272,7 @@ defmodule JSONAPI.QueryParser do
267272
last = List.last(keys)
268273
path = Enum.slice(keys, 0, Enum.count(keys) - 1)
269274

270-
if member_of_tree?(keys, valid_include) do
275+
if member_of_tree?(keys, valid_includes) do
271276
put_as_tree([], path, last)
272277
else
273278
raise_invalid_include_query(key, config.view.type())

lib/jsonapi/utils/include_tree.ex

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,22 @@ defmodule JSONAPI.Utils.IncludeTree do
33
Internal utility for building trees of resource relationships
44
"""
55

6+
@spec deep_merge(Keyword.t(), Keyword.t()) :: Keyword.t()
7+
def deep_merge(acc, []), do: acc
8+
9+
def deep_merge(acc, [{key, val} | tail]) do
10+
acc
11+
|> Keyword.update(
12+
key,
13+
val,
14+
fn
15+
[_first | _rest] = old_val when is_list(val) -> deep_merge(old_val, val)
16+
_ -> val
17+
end
18+
)
19+
|> deep_merge(tail)
20+
end
21+
622
@spec put_as_tree(term(), term(), term()) :: term()
723
def put_as_tree(acc, items, val) do
824
[head | tail] = Enum.reverse(items)

test/jsonapi/plugs/query_parser_test.exs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,31 @@ defmodule JSONAPI.QueryParserTest do
6464
end
6565
end
6666

67-
test "parse_filter/2 turns filters key/val pairs" do
67+
test "parse_filter/2 returns filters key/val pairs" do
6868
config = struct(Config, opts: [filter: ~w(name)], view: MyView)
6969
filter = parse_filter(config, %{"name" => "jason"}).filter
7070
assert filter[:name] == "jason"
7171
end
7272

73+
test "parse_filter/2 handles nested filters" do
74+
config = struct(Config, opts: [filter: ~w(author.username)], view: MyView)
75+
filter = parse_filter(config, %{"author.username" => "jason"}).filter
76+
assert filter[:author][:username] == "jason"
77+
end
78+
79+
test "parse_filter/2 handles nested filters two deep" do
80+
config = struct(Config, opts: [filter: ~w(author.top_posts.text)], view: MyView)
81+
filter = parse_filter(config, %{"author.top_posts.text" => "some post"}).filter
82+
assert filter[:author][:top_posts][:text] == "some post"
83+
end
84+
85+
test "parse_filter/2 handles nested filters with overlap" do
86+
config = struct(Config, opts: [filter: ~w(author.username author.id)], view: MyView)
87+
filter = parse_filter(config, %{"author.username" => "jason", "author.id" => "123"}).filter
88+
assert filter[:author][:username] == "jason"
89+
assert filter[:author][:id] == "123"
90+
end
91+
7392
test "parse_filter/2 raises on invalid filters" do
7493
config = struct(Config, opts: [], view: MyView)
7594

@@ -84,11 +103,24 @@ defmodule JSONAPI.QueryParserTest do
84103
assert parse_include(config, "author").include == [:author]
85104
assert parse_include(config, "comments,author").include == [:comments, :author]
86105
assert parse_include(config, "comments.user").include == [comments: :user]
106+
assert parse_include(config, "comments.user.top_posts").include == [comments: [user: :top_posts]]
87107
assert parse_include(config, "best_friends").include == [:best_friends]
88108
assert parse_include(config, "author.top-posts").include == [author: :top_posts]
89109
assert parse_include(config, "").include == []
90110
end
91111

112+
test "parse_include/2 succeds given valid nested include specified in allowed list" do
113+
config = struct(Config, view: MyView, opts: [include: ~w(comments.user)])
114+
115+
assert parse_include(config, "comments.user").include == [comments: :user]
116+
end
117+
118+
test "parse_include/2 succeds given valid twice-nested include specified in allowed list" do
119+
config = struct(Config, view: MyView, opts: [include: ~w(comments.user.top_posts)])
120+
121+
assert parse_include(config, "comments.user.top_posts").include == [comments: [user: :top_posts]]
122+
end
123+
92124
test "parse_include/2 errors with invalid includes" do
93125
config = struct(Config, view: MyView)
94126

test/utils/include_tree_test.exs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,18 @@ defmodule JSONAPI.IncludeTreeTest do
66
items = [:test, :the, :path]
77
assert put_as_tree([], items, :boo) == [test: [the: [path: :boo]]]
88
end
9+
10+
test "deep_merge/2 handles string/keyword conflict by choosing second value" do
11+
# one direction
12+
assert [other: "thing", hi: [hello: "there"]] = deep_merge([other: "thing", hi: "there"], hi: [hello: "there"])
13+
# the other direction
14+
assert [hi: "there", other: "thing"] = deep_merge([hi: [hello: "there"]], other: "thing", hi: "there")
15+
end
16+
17+
test "deep_merge/2 handles string/string conflict by choosing second value" do
18+
# one direction
19+
assert [hi: "there"] = deep_merge([hi: "hello"], hi: "there")
20+
# the other direction
21+
assert [hi: "hello"] = deep_merge([hi: "there"], hi: "hello")
22+
end
923
end

0 commit comments

Comments
 (0)