Skip to content

Commit ce836ac

Browse files
committed
feat: custom filters added
Introduced ability to convert params into custom queries and apply them to accumulator query using: * query builder functions at context level * custom query functions from schema
1 parent 9beaa71 commit ce836ac

File tree

7 files changed

+204
-14
lines changed

7 files changed

+204
-14
lines changed

lib/ecto_shorts/actions.ex

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ defmodule EctoShorts.Actions do
6565
@type schema_res :: {:ok, schema()} | {:error, any}
6666

6767
alias EctoShorts.{
68+
Actions,
6869
Actions.Error,
6970
CommonFilters,
7071
CommonSchemas,
@@ -124,9 +125,9 @@ defmodule EctoShorts.Actions do
124125
125126
### Filter Parameters
126127
127-
When the parameters is a keyword list the options `:repo` and `:replica` can be set.
128+
When the parameters is a keyword list the options `:repo`, `:replica` and `:query_builder` can be set.
128129
129-
See `EctoShorts.CommonFilters` for more information.
130+
See `EctoShorts.CommonFilters`, `EctoShorts.Actions.QueryBuilder` for more information.
130131
131132
### Options
132133
@@ -135,19 +136,24 @@ defmodule EctoShorts.Actions do
135136
136137
* `:repo` - A module that uses `Ecto.Repo`.
137138
139+
* `:query_builder` - A module that handles custom filters.
140+
138141
See [Ecto.Repo.all/2](https://hexdocs.pm/ecto/Ecto.Repo.html#c:all/2) for more options.
139142
140143
### Examples
141144
142145
iex> EctoSchemas.Actions.all(YourSchema, %{id: 1})
143146
iex> EctoSchemas.Actions.all(YourSchema, id: 1, repo: YourApp.Repo)
144147
iex> EctoSchemas.Actions.all(YourSchema, id: 1, replica: YourApp.Repo)
148+
iex> EctoSchemas.Actions.all(YourSchema, id: 1, custom_filter: val, query_builder: YourContext)
145149
iex> EctoSchemas.Actions.all({"source", YourSchema}, %{id: 1})
146150
iex> EctoSchemas.Actions.all({"source", YourSchema}, id: 1, repo: YourApp.Repo)
147151
iex> EctoSchemas.Actions.all({"source", YourSchema}, id: 1, replica: YourApp.Repo)
152+
iex> EctoSchemas.Actions.all({"source", YourSchema}, id: 1, custom_filter: val, query_builder: YourContext)
148153
iex> EctoSchemas.Actions.all(%Ecto.Query{}, %{id: 1})
149154
iex> EctoSchemas.Actions.all(%Ecto.Query{}, id: 1, repo: YourApp.Repo)
150155
iex> EctoSchemas.Actions.all(%Ecto.Query{}, id: 1, replica: YourApp.Repo)
156+
iex> EctoSchemas.Actions.all(%Ecto.Query{}, id: 1, custom_filter: val, query_builder: YourContext)
151157
"""
152158
@spec all(
153159
query :: query() | queryable() | source_queryable(),
@@ -158,24 +164,17 @@ defmodule EctoShorts.Actions do
158164
end
159165

160166
def all(query, opts) do
161-
query_params =
162-
opts
163-
|> Keyword.drop([:repo, :replica])
164-
|> Map.new()
167+
{opts, params} = Keyword.split(opts, [:repo, :replica, :query_builder])
165168

166-
if Enum.any?(query_params) do
167-
all(query, query_params, Keyword.take(opts, [:repo, :replica]))
168-
else
169-
all(query, %{}, Keyword.take(opts, [:repo, :replica]))
170-
end
169+
all(query, Map.new(params), opts)
171170
end
172171

173172
@doc """
174173
Fetches all records matching the given query.
175174
176175
### Filter Parameters
177176
178-
See `EctoShorts.CommonFilters` for more information.
177+
See `EctoShorts.CommonFilters`, `EctoShorts.Actions.QueryBuilder` for more information.
179178
180179
### Options
181180
@@ -184,21 +183,26 @@ defmodule EctoShorts.Actions do
184183
185184
* `:repo` - A module that uses `Ecto.Repo`.
186185
187-
* `:order_by` - Orders the fields based on one or more fields.
186+
* `:query_builder` - A module that handles custom filters.
187+
188+
* `:order_by` - Orders the records based on one or more fields.
188189
189190
See [Ecto.Repo.all/2](https://hexdocs.pm/ecto/Ecto.Repo.html#c:all/2) for more options.
190191
191-
## Examples
192+
### Examples
192193
193194
iex> EctoSchemas.Actions.all(YourSchema, %{id: 1}, prefix: "public")
194195
iex> EctoSchemas.Actions.all(YourSchema, %{id: 1}, repo: YourApp.Repo)
195196
iex> EctoSchemas.Actions.all(YourSchema, %{id: 1}, replica: YourApp.Repo)
197+
iex> EctoSchemas.Actions.all(YourSchema, %{id: 1, custom_filter: val}, query_builder: YourContext)
196198
iex> EctoSchemas.Actions.all({"source", YourSchema}, %{id: 1}, prefix: "public")
197199
iex> EctoSchemas.Actions.all({"source", YourSchema}, repo: YourApp.Repo)
198200
iex> EctoSchemas.Actions.all({"source", YourSchema}, replica: YourApp.Repo)
201+
iex> EctoSchemas.Actions.all({"source", YourSchema}, %{id: 1, custom_filter: val}, query_builder: YourContext)
199202
iex> EctoSchemas.Actions.all(%Ecto.Query{}, %{id: 1}, prefix: "public")
200203
iex> EctoSchemas.Actions.all(%Ecto.Query{}, repo: YourApp.Repo)
201204
iex> EctoSchemas.Actions.all(%Ecto.Query{}, replica: YourApp.Repo)
205+
iex> EctoSchemas.Actions.all(%Ecto.Query{}, %{id: 1, custom_filter: val}, query_builder: YourContext)
202206
"""
203207
@spec all(
204208
query :: query() | queryable() | source_queryable(),
@@ -212,6 +216,7 @@ defmodule EctoShorts.Actions do
212216

213217
query
214218
|> CommonFilters.convert_params_to_filter(params)
219+
|> Actions.Filters.convert_params_to_filter(params, Keyword.get(opts, :query_builder, nil))
215220
|> Config.replica!(opts).all(opts)
216221
end
217222

@@ -225,17 +230,22 @@ defmodule EctoShorts.Actions do
225230
226231
* `:repo` - A module that uses `Ecto.Repo`.
227232
233+
* `:query_builder` - A module that handles custom filters (see EctoShorts.Actions.QueryBuilder).
234+
228235
See [Ecto.Repo.all/2](https://hexdocs.pm/ecto/Ecto.Repo.html#c:one/2) for more options.
229236
230237
### Examples
231238
232239
iex> EctoSchemas.Actions.find(YourSchema, %{id: 1})
240+
iex> EctoSchemas.Actions.find(YourSchema, %{id: 1, custom_filter: val}, query_builder: YourContext)
233241
iex> EctoSchemas.Actions.find({"source", YourSchema}, %{id: 1})
234242
iex> EctoSchemas.Actions.find({"source", YourSchema}, %{id: 1}, repo: YourApp.Repo)
235243
iex> EctoSchemas.Actions.find({"source", YourSchema}, %{id: 1}, replica: YourApp.Repo)
244+
iex> EctoSchemas.Actions.find({"source", YourSchema}, %{id: 1, custom_filter: val}, query_builder: YourContext)
236245
iex> EctoSchemas.Actions.find(%Ecto.Query{}, %{id: 1})
237246
iex> EctoSchemas.Actions.find(%Ecto.Query{}, %{id: 1}, repo: YourApp.Repo)
238247
iex> EctoSchemas.Actions.find(%Ecto.Query{}, %{id: 1}, replica: YourApp.Repo)
248+
iex> EctoSchemas.Actions.find(%Ecto.Query{}, %{id: 1, custom_filter: val}, query_builder: YourContext)
239249
"""
240250
@spec find(
241251
query :: queryable() | source_queryable(),
@@ -262,6 +272,7 @@ defmodule EctoShorts.Actions do
262272

263273
query
264274
|> CommonFilters.convert_params_to_filter(params)
275+
|> Actions.Filters.convert_params_to_filter(params, Keyword.get(opts, :query_builder, nil))
265276
|> Config.replica!(opts).one(opts)
266277
|> case do
267278
nil ->
@@ -631,16 +642,20 @@ defmodule EctoShorts.Actions do
631642
632643
* `:repo` - A module that uses `Ecto.Repo`.
633644
645+
* `:query_builder` - A module that handles custom filters.
646+
634647
See [Ecto.Repo.stream/2](https://hexdocs.pm/ecto/Ecto.Repo.html#c:stream/2) for more options.
635648
636649
### Examples
637650
638651
iex> EctoSchemas.Actions.stream(YourSchema, %{id: 1})
639652
iex> EctoSchemas.Actions.stream(YourSchema, %{id: 1}, repo: YourApp.Repo)
640653
iex> EctoSchemas.Actions.stream(YourSchema, %{id: 1}, replica: YourApp.Repo)
654+
iex> EctoSchemas.Actions.stream(YourSchema, %{id: 1, custom_filter: val}, query_builder: YourContext)
641655
iex> EctoSchemas.Actions.stream({"source", YourSchema}, %{id: 1})
642656
iex> EctoSchemas.Actions.stream({"source", YourSchema}, %{id: 1}, repo: YourApp.Repo)
643657
iex> EctoSchemas.Actions.stream({"source", YourSchema}, %{id: 1}, replica: YourApp.Repo)
658+
iex> EctoSchemas.Actions.stream({"source", YourSchema}, %{id: 1, custom_filter: val}, query_builder: YourContext)
644659
"""
645660
@spec stream(
646661
query :: queryable() | source_queryable(),
@@ -655,6 +670,7 @@ defmodule EctoShorts.Actions do
655670
query
656671
|> CommonSchemas.get_schema_query()
657672
|> CommonFilters.convert_params_to_filter(params)
673+
|> Actions.Filters.convert_params_to_filter(params, Keyword.get(opts, :query_builder, nil))
658674
|> Config.replica!(opts).stream(opts)
659675
end
660676

@@ -669,16 +685,20 @@ defmodule EctoShorts.Actions do
669685
670686
* `:repo` - A module that uses `Ecto.Repo`.
671687
688+
* `:query_builder` - A module that handles custom filters.
689+
672690
See [Ecto.Repo.aggregate/4](https://hexdocs.pm/ecto/Ecto.Repo.html#c:aggregate/4) for more options.
673691
674692
### Examples
675693
676694
iex> EctoSchemas.Actions.aggregate(YourSchema, %{id: 1}, :count, :id)
677695
iex> EctoSchemas.Actions.aggregate(YourSchema, %{id: 1}, :count, :id, repo: YourApp.Repo)
678696
iex> EctoSchemas.Actions.aggregate(YourSchema, %{id: 1}, :count, :id, replica: YourApp.Repo)
697+
iex> EctoSchemas.Actions.aggregate(YourSchema, %{id: 1, custom_filter: val}, :count, :id, query_builder: YourContext)
679698
iex> EctoSchemas.Actions.aggregate({"source", YourSchema}, %{id: 1}, :count, :id)
680699
iex> EctoSchemas.Actions.aggregate({"source", YourSchema}, %{id: 1}, :count, :id, repo: YourApp.Repo)
681700
iex> EctoSchemas.Actions.aggregate({"source", YourSchema}, %{id: 1}, :count, :id, replica: YourApp.Repo)
701+
iex> EctoSchemas.Actions.aggregate({"source", YourSchema}, %{id: 1, custom_filter: val}, :count, :id, query_builder: YourContext)
682702
"""
683703
@spec aggregate(
684704
query :: query() | queryable() | source_queryable(),
@@ -697,6 +717,7 @@ defmodule EctoShorts.Actions do
697717
query
698718
|> CommonSchemas.get_schema_query()
699719
|> CommonFilters.convert_params_to_filter(params)
720+
|> Actions.Filters.convert_params_to_filter(params, Keyword.get(opts, :query_builder, nil))
700721
|> Config.replica!(opts).aggregate(aggregate, field, opts)
701722
end
702723

lib/ecto_shorts/actions/filters.ex

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
defmodule EctoShorts.Actions.Filters do
2+
@moduledoc """
3+
Converts parameters into filters and applies them to the query using the query builder.
4+
"""
5+
require Logger
6+
7+
@type query :: Ecto.Query.t()
8+
@type params :: map() | keyword()
9+
@type query_builder :: module()
10+
11+
@doc """
12+
Applies filters to the query based on the provided parameters.
13+
"""
14+
@spec convert_params_to_filter(query, params, query_builder) :: query
15+
def convert_params_to_filter(query, _params, nil), do: query
16+
17+
def convert_params_to_filter(query, params, _query_builder)
18+
when not (is_map(params) or is_list(params)),
19+
do: query
20+
21+
def convert_params_to_filter(query, params, _query_builder)
22+
when params === %{} or params === [],
23+
do: query
24+
25+
def convert_params_to_filter(query, params, query_builder) do
26+
if supports_query_building(query_builder) do
27+
schema = EctoShorts.QueryHelpers.get_queryable(query)
28+
29+
Enum.reduce(params, query, &reduce_filter(query_builder, schema, &1, &2))
30+
else
31+
query
32+
end
33+
end
34+
35+
defp reduce_filter(query_builder, schema, {filter_key, filter_value}, current_query) do
36+
if filter_key in query_builder.filters() do
37+
query_builder.build_query(schema, %{filter_key => filter_value}, current_query)
38+
else
39+
Logger.debug(
40+
"[EctoShorts] #{inspect(filter_key)} is not defined among filters in the #{inspect(query_builder)} context module"
41+
)
42+
43+
current_query
44+
end
45+
end
46+
47+
defp supports_query_building(query_builder) do
48+
Code.ensure_loaded?(query_builder) and
49+
function_exported?(query_builder, :build_query, 3) and
50+
function_exported?(query_builder, :filters, 0)
51+
end
52+
end
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
defmodule EctoShorts.Actions.QueryBuilder do
2+
@moduledoc """
3+
Behaviour for query building from a filter map.
4+
Allows calling custom query functions in schemas
5+
by defining the callback in a context.
6+
7+
In other words, the query builder decides how and when to apply
8+
a schema's query function.
9+
10+
Example of implementing the `build_query/3` callback in a context:
11+
```elixir
12+
defmodule YourApp.Context do
13+
@behaviour EctoShorts.Actions.QueryBuilder
14+
15+
@impl EctoShorts.Actions.QueryBuilder
16+
def build_query(YourApp.Context.Schema, {:custom_filter, val}, queryable) do
17+
YourApp.Context.Schema.by_custom_filter(queryable, val)
18+
end
19+
end
20+
```
21+
"""
22+
23+
@type filter :: %{(filter_key :: atom) => filter_value :: any}
24+
@type queryable :: Ecto.Queryable.t()
25+
@type schema :: module()
26+
27+
@doc "Adds condition to accumulator Ecto query by calling schema's function"
28+
@callback build_query(schema, filter, queryable) :: queryable
29+
@callback filters() :: list(atom)
30+
end
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
defmodule EctoShorts.Actions.FiltersTest do
2+
use ExUnit.Case, async: true
3+
doctest EctoShorts.Actions.Filters
4+
end

test/ecto_shorts/actions_test.exs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ defmodule EctoShorts.ActionsTest do
1111
Comment,
1212
Post
1313
}
14+
alias EctoShorts.Support.Contexts.Posts
1415

1516
test "raise when :repo not set in option and configuration" do
1617
assert_raise ArgumentError, ~r|EctoShorts repo not configured!|, fn ->
@@ -143,6 +144,13 @@ defmodule EctoShorts.ActionsTest do
143144
assert [^schema_data] = Actions.all(Comment, %{id: schema_data.id})
144145
end
145146

147+
test "returns data by map query parameters with custom filter and query builder in options" do
148+
assert {:ok, %Comment{id: id, body: body}} = Actions.create(Comment, %{body: "body"})
149+
150+
assert [^body] = Actions.all(Comment, %{id: id, select_body: true}, query_builder: Posts)
151+
assert [^body] = Actions.all({"comments", Comment}, %{id: id, select_body: true}, query_builder: Posts)
152+
end
153+
146154
test "returns records by keyword parameters" do
147155
assert {:ok, schema_data} = Actions.create(Comment, %{body: "body"})
148156

@@ -164,6 +172,12 @@ defmodule EctoShorts.ActionsTest do
164172
assert [^schema_data] = Actions.all(Comment, id: schema_data.id, repo: nil, replica: TestRepo)
165173
end)
166174
end
175+
176+
test "can use custom filters and query_builder in keyword parameters" do
177+
assert {:ok, %Comment{id: id, body: body}} = Actions.create(Comment, %{body: "body"})
178+
179+
assert [^body] = Actions.all(Comment, id: id, select_body: true, query_builder: Posts)
180+
end
167181
end
168182

169183
describe "find: " do
@@ -173,6 +187,12 @@ defmodule EctoShorts.ActionsTest do
173187
assert {:ok, ^schema_data} = Actions.find(Comment, %{id: schema_data.id})
174188
end
175189

190+
test "returns data with custom filters and query_builder in keyword parameters" do
191+
assert {:ok, %Comment{id: id, body: body}} = Actions.create(Comment, %{body: "body"})
192+
193+
assert {:ok, ^body} = Actions.find(Comment, %{id: id, select_body: true}, query_builder: Posts)
194+
end
195+
176196
test "returns error message with params and query" do
177197
assert {:ok, schema_data} = Actions.create(Comment, %{body: "body"})
178198

@@ -326,6 +346,20 @@ defmodule EctoShorts.ActionsTest do
326346

327347
assert created_schema_data.id === returned_schema_data.id
328348
end
349+
350+
test "returns data according to custom filter" do
351+
assert {:ok, created_schema_data} = Actions.create(Comment, %{body: "body"})
352+
353+
assert {:ok, [returned_schema_data]} =
354+
Repo.transaction(fn ->
355+
Comment
356+
|> Actions.stream(%{select_body: true}, query_builder: Posts)
357+
|> Enum.to_list()
358+
end)
359+
360+
assert created_schema_data.body === returned_schema_data
361+
end
362+
329363
end
330364

331365
describe "aggregate: " do
@@ -364,6 +398,17 @@ defmodule EctoShorts.ActionsTest do
364398

365399
assert 20 = Actions.aggregate(Comment, %{}, :max, :count)
366400
end
401+
402+
test "returns expected value for aggregate count using custom filter" do
403+
assert {:ok, post_schema_data_1} = Actions.create(Post, %{title: "title"})
404+
assert {:ok, post_schema_data_2} = Actions.create(Post, %{title: "title"})
405+
assert {:ok, _schema_data} = Actions.create(Comment, %{post_id: post_schema_data_1.id})
406+
assert {:ok, _schema_data} = Actions.create(Comment, %{post_id: post_schema_data_2.id})
407+
assert {:ok, _schema_data} = Actions.create(Comment, %{post_id: post_schema_data_2.id})
408+
409+
assert 1 =
410+
Actions.aggregate(Comment, %{post_id_with_comment_count_gte: 2}, :count, :post_id, query_builder: Posts)
411+
end
367412
end
368413

369414
describe "find_or_create_many: " do

0 commit comments

Comments
 (0)