Skip to content

Commit

Permalink
feat: custom filters added
Browse files Browse the repository at this point in the history
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
  • Loading branch information
subaru9 committed Oct 21, 2024
1 parent 9beaa71 commit ce836ac
Show file tree
Hide file tree
Showing 7 changed files with 204 additions and 14 deletions.
49 changes: 35 additions & 14 deletions lib/ecto_shorts/actions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ defmodule EctoShorts.Actions do
@type schema_res :: {:ok, schema()} | {:error, any}

alias EctoShorts.{
Actions,
Actions.Error,
CommonFilters,
CommonSchemas,
Expand Down Expand Up @@ -124,9 +125,9 @@ defmodule EctoShorts.Actions do
### Filter Parameters
When the parameters is a keyword list the options `:repo` and `:replica` can be set.
When the parameters is a keyword list the options `:repo`, `:replica` and `:query_builder` can be set.
See `EctoShorts.CommonFilters` for more information.
See `EctoShorts.CommonFilters`, `EctoShorts.Actions.QueryBuilder` for more information.
### Options
Expand All @@ -135,19 +136,24 @@ defmodule EctoShorts.Actions do
* `:repo` - A module that uses `Ecto.Repo`.
* `:query_builder` - A module that handles custom filters.
See [Ecto.Repo.all/2](https://hexdocs.pm/ecto/Ecto.Repo.html#c:all/2) for more options.
### Examples
iex> EctoSchemas.Actions.all(YourSchema, %{id: 1})
iex> EctoSchemas.Actions.all(YourSchema, id: 1, repo: YourApp.Repo)
iex> EctoSchemas.Actions.all(YourSchema, id: 1, replica: YourApp.Repo)
iex> EctoSchemas.Actions.all(YourSchema, id: 1, custom_filter: val, query_builder: YourContext)
iex> EctoSchemas.Actions.all({"source", YourSchema}, %{id: 1})
iex> EctoSchemas.Actions.all({"source", YourSchema}, id: 1, repo: YourApp.Repo)
iex> EctoSchemas.Actions.all({"source", YourSchema}, id: 1, replica: YourApp.Repo)
iex> EctoSchemas.Actions.all({"source", YourSchema}, id: 1, custom_filter: val, query_builder: YourContext)
iex> EctoSchemas.Actions.all(%Ecto.Query{}, %{id: 1})
iex> EctoSchemas.Actions.all(%Ecto.Query{}, id: 1, repo: YourApp.Repo)
iex> EctoSchemas.Actions.all(%Ecto.Query{}, id: 1, replica: YourApp.Repo)
iex> EctoSchemas.Actions.all(%Ecto.Query{}, id: 1, custom_filter: val, query_builder: YourContext)
"""
@spec all(
query :: query() | queryable() | source_queryable(),
Expand All @@ -158,24 +164,17 @@ defmodule EctoShorts.Actions do
end

def all(query, opts) do
query_params =
opts
|> Keyword.drop([:repo, :replica])
|> Map.new()
{opts, params} = Keyword.split(opts, [:repo, :replica, :query_builder])

if Enum.any?(query_params) do
all(query, query_params, Keyword.take(opts, [:repo, :replica]))
else
all(query, %{}, Keyword.take(opts, [:repo, :replica]))
end
all(query, Map.new(params), opts)
end

@doc """
Fetches all records matching the given query.
### Filter Parameters
See `EctoShorts.CommonFilters` for more information.
See `EctoShorts.CommonFilters`, `EctoShorts.Actions.QueryBuilder` for more information.
### Options
Expand All @@ -184,21 +183,26 @@ defmodule EctoShorts.Actions do
* `:repo` - A module that uses `Ecto.Repo`.
* `:order_by` - Orders the fields based on one or more fields.
* `:query_builder` - A module that handles custom filters.
* `:order_by` - Orders the records based on one or more fields.
See [Ecto.Repo.all/2](https://hexdocs.pm/ecto/Ecto.Repo.html#c:all/2) for more options.
## Examples
### Examples
iex> EctoSchemas.Actions.all(YourSchema, %{id: 1}, prefix: "public")
iex> EctoSchemas.Actions.all(YourSchema, %{id: 1}, repo: YourApp.Repo)
iex> EctoSchemas.Actions.all(YourSchema, %{id: 1}, replica: YourApp.Repo)
iex> EctoSchemas.Actions.all(YourSchema, %{id: 1, custom_filter: val}, query_builder: YourContext)
iex> EctoSchemas.Actions.all({"source", YourSchema}, %{id: 1}, prefix: "public")
iex> EctoSchemas.Actions.all({"source", YourSchema}, repo: YourApp.Repo)
iex> EctoSchemas.Actions.all({"source", YourSchema}, replica: YourApp.Repo)
iex> EctoSchemas.Actions.all({"source", YourSchema}, %{id: 1, custom_filter: val}, query_builder: YourContext)
iex> EctoSchemas.Actions.all(%Ecto.Query{}, %{id: 1}, prefix: "public")
iex> EctoSchemas.Actions.all(%Ecto.Query{}, repo: YourApp.Repo)
iex> EctoSchemas.Actions.all(%Ecto.Query{}, replica: YourApp.Repo)
iex> EctoSchemas.Actions.all(%Ecto.Query{}, %{id: 1, custom_filter: val}, query_builder: YourContext)
"""
@spec all(
query :: query() | queryable() | source_queryable(),
Expand All @@ -212,6 +216,7 @@ defmodule EctoShorts.Actions do

query
|> CommonFilters.convert_params_to_filter(params)
|> Actions.Filters.convert_params_to_filter(params, Keyword.get(opts, :query_builder, nil))
|> Config.replica!(opts).all(opts)
end

Expand All @@ -225,17 +230,22 @@ defmodule EctoShorts.Actions do
* `:repo` - A module that uses `Ecto.Repo`.
* `:query_builder` - A module that handles custom filters (see EctoShorts.Actions.QueryBuilder).
See [Ecto.Repo.all/2](https://hexdocs.pm/ecto/Ecto.Repo.html#c:one/2) for more options.
### Examples
iex> EctoSchemas.Actions.find(YourSchema, %{id: 1})
iex> EctoSchemas.Actions.find(YourSchema, %{id: 1, custom_filter: val}, query_builder: YourContext)
iex> EctoSchemas.Actions.find({"source", YourSchema}, %{id: 1})
iex> EctoSchemas.Actions.find({"source", YourSchema}, %{id: 1}, repo: YourApp.Repo)
iex> EctoSchemas.Actions.find({"source", YourSchema}, %{id: 1}, replica: YourApp.Repo)
iex> EctoSchemas.Actions.find({"source", YourSchema}, %{id: 1, custom_filter: val}, query_builder: YourContext)
iex> EctoSchemas.Actions.find(%Ecto.Query{}, %{id: 1})
iex> EctoSchemas.Actions.find(%Ecto.Query{}, %{id: 1}, repo: YourApp.Repo)
iex> EctoSchemas.Actions.find(%Ecto.Query{}, %{id: 1}, replica: YourApp.Repo)
iex> EctoSchemas.Actions.find(%Ecto.Query{}, %{id: 1, custom_filter: val}, query_builder: YourContext)
"""
@spec find(
query :: queryable() | source_queryable(),
Expand All @@ -262,6 +272,7 @@ defmodule EctoShorts.Actions do

query
|> CommonFilters.convert_params_to_filter(params)
|> Actions.Filters.convert_params_to_filter(params, Keyword.get(opts, :query_builder, nil))
|> Config.replica!(opts).one(opts)
|> case do
nil ->
Expand Down Expand Up @@ -631,16 +642,20 @@ defmodule EctoShorts.Actions do
* `:repo` - A module that uses `Ecto.Repo`.
* `:query_builder` - A module that handles custom filters.
See [Ecto.Repo.stream/2](https://hexdocs.pm/ecto/Ecto.Repo.html#c:stream/2) for more options.
### Examples
iex> EctoSchemas.Actions.stream(YourSchema, %{id: 1})
iex> EctoSchemas.Actions.stream(YourSchema, %{id: 1}, repo: YourApp.Repo)
iex> EctoSchemas.Actions.stream(YourSchema, %{id: 1}, replica: YourApp.Repo)
iex> EctoSchemas.Actions.stream(YourSchema, %{id: 1, custom_filter: val}, query_builder: YourContext)
iex> EctoSchemas.Actions.stream({"source", YourSchema}, %{id: 1})
iex> EctoSchemas.Actions.stream({"source", YourSchema}, %{id: 1}, repo: YourApp.Repo)
iex> EctoSchemas.Actions.stream({"source", YourSchema}, %{id: 1}, replica: YourApp.Repo)
iex> EctoSchemas.Actions.stream({"source", YourSchema}, %{id: 1, custom_filter: val}, query_builder: YourContext)
"""
@spec stream(
query :: queryable() | source_queryable(),
Expand All @@ -655,6 +670,7 @@ defmodule EctoShorts.Actions do
query
|> CommonSchemas.get_schema_query()
|> CommonFilters.convert_params_to_filter(params)
|> Actions.Filters.convert_params_to_filter(params, Keyword.get(opts, :query_builder, nil))
|> Config.replica!(opts).stream(opts)
end

Expand All @@ -669,16 +685,20 @@ defmodule EctoShorts.Actions do
* `:repo` - A module that uses `Ecto.Repo`.
* `:query_builder` - A module that handles custom filters.
See [Ecto.Repo.aggregate/4](https://hexdocs.pm/ecto/Ecto.Repo.html#c:aggregate/4) for more options.
### Examples
iex> EctoSchemas.Actions.aggregate(YourSchema, %{id: 1}, :count, :id)
iex> EctoSchemas.Actions.aggregate(YourSchema, %{id: 1}, :count, :id, repo: YourApp.Repo)
iex> EctoSchemas.Actions.aggregate(YourSchema, %{id: 1}, :count, :id, replica: YourApp.Repo)
iex> EctoSchemas.Actions.aggregate(YourSchema, %{id: 1, custom_filter: val}, :count, :id, query_builder: YourContext)
iex> EctoSchemas.Actions.aggregate({"source", YourSchema}, %{id: 1}, :count, :id)
iex> EctoSchemas.Actions.aggregate({"source", YourSchema}, %{id: 1}, :count, :id, repo: YourApp.Repo)
iex> EctoSchemas.Actions.aggregate({"source", YourSchema}, %{id: 1}, :count, :id, replica: YourApp.Repo)
iex> EctoSchemas.Actions.aggregate({"source", YourSchema}, %{id: 1, custom_filter: val}, :count, :id, query_builder: YourContext)
"""
@spec aggregate(
query :: query() | queryable() | source_queryable(),
Expand All @@ -697,6 +717,7 @@ defmodule EctoShorts.Actions do
query
|> CommonSchemas.get_schema_query()
|> CommonFilters.convert_params_to_filter(params)
|> Actions.Filters.convert_params_to_filter(params, Keyword.get(opts, :query_builder, nil))
|> Config.replica!(opts).aggregate(aggregate, field, opts)
end

Expand Down
52 changes: 52 additions & 0 deletions lib/ecto_shorts/actions/filters.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
defmodule EctoShorts.Actions.Filters do
@moduledoc """
Converts parameters into filters and applies them to the query using the query builder.
"""
require Logger

@type query :: Ecto.Query.t()
@type params :: map() | keyword()
@type query_builder :: module()

@doc """
Applies filters to the query based on the provided parameters.
"""
@spec convert_params_to_filter(query, params, query_builder) :: query
def convert_params_to_filter(query, _params, nil), do: query

def convert_params_to_filter(query, params, _query_builder)
when not (is_map(params) or is_list(params)),
do: query

def convert_params_to_filter(query, params, _query_builder)
when params === %{} or params === [],
do: query

def convert_params_to_filter(query, params, query_builder) do
if supports_query_building(query_builder) do
schema = EctoShorts.QueryHelpers.get_queryable(query)

Enum.reduce(params, query, &reduce_filter(query_builder, schema, &1, &2))
else
query
end
end

defp reduce_filter(query_builder, schema, {filter_key, filter_value}, current_query) do
if filter_key in query_builder.filters() do
query_builder.build_query(schema, %{filter_key => filter_value}, current_query)
else
Logger.debug(
"[EctoShorts] #{inspect(filter_key)} is not defined among filters in the #{inspect(query_builder)} context module"
)

current_query
end
end

defp supports_query_building(query_builder) do
Code.ensure_loaded?(query_builder) and
function_exported?(query_builder, :build_query, 3) and
function_exported?(query_builder, :filters, 0)
end
end
30 changes: 30 additions & 0 deletions lib/ecto_shorts/actions/query_builder.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
defmodule EctoShorts.Actions.QueryBuilder do
@moduledoc """
Behaviour for query building from a filter map.
Allows calling custom query functions in schemas
by defining the callback in a context.
In other words, the query builder decides how and when to apply
a schema's query function.
Example of implementing the `build_query/3` callback in a context:
```elixir
defmodule YourApp.Context do
@behaviour EctoShorts.Actions.QueryBuilder
@impl EctoShorts.Actions.QueryBuilder
def build_query(YourApp.Context.Schema, {:custom_filter, val}, queryable) do
YourApp.Context.Schema.by_custom_filter(queryable, val)
end
end
```
"""

@type filter :: %{(filter_key :: atom) => filter_value :: any}
@type queryable :: Ecto.Queryable.t()
@type schema :: module()

@doc "Adds condition to accumulator Ecto query by calling schema's function"
@callback build_query(schema, filter, queryable) :: queryable
@callback filters() :: list(atom)
end
4 changes: 4 additions & 0 deletions test/ecto_shorts/actions/filters_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
defmodule EctoShorts.Actions.FiltersTest do
use ExUnit.Case, async: true
doctest EctoShorts.Actions.Filters
end
45 changes: 45 additions & 0 deletions test/ecto_shorts/actions_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ defmodule EctoShorts.ActionsTest do
Comment,
Post
}
alias EctoShorts.Support.Contexts.Posts

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

test "returns data by map query parameters with custom filter and query builder in options" do
assert {:ok, %Comment{id: id, body: body}} = Actions.create(Comment, %{body: "body"})

assert [^body] = Actions.all(Comment, %{id: id, select_body: true}, query_builder: Posts)
assert [^body] = Actions.all({"comments", Comment}, %{id: id, select_body: true}, query_builder: Posts)
end

test "returns records by keyword parameters" do
assert {:ok, schema_data} = Actions.create(Comment, %{body: "body"})

Expand All @@ -164,6 +172,12 @@ defmodule EctoShorts.ActionsTest do
assert [^schema_data] = Actions.all(Comment, id: schema_data.id, repo: nil, replica: TestRepo)
end)
end

test "can use custom filters and query_builder in keyword parameters" do
assert {:ok, %Comment{id: id, body: body}} = Actions.create(Comment, %{body: "body"})

assert [^body] = Actions.all(Comment, id: id, select_body: true, query_builder: Posts)
end
end

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

test "returns data with custom filters and query_builder in keyword parameters" do
assert {:ok, %Comment{id: id, body: body}} = Actions.create(Comment, %{body: "body"})

assert {:ok, ^body} = Actions.find(Comment, %{id: id, select_body: true}, query_builder: Posts)
end

test "returns error message with params and query" do
assert {:ok, schema_data} = Actions.create(Comment, %{body: "body"})

Expand Down Expand Up @@ -326,6 +346,20 @@ defmodule EctoShorts.ActionsTest do

assert created_schema_data.id === returned_schema_data.id
end

test "returns data according to custom filter" do
assert {:ok, created_schema_data} = Actions.create(Comment, %{body: "body"})

assert {:ok, [returned_schema_data]} =
Repo.transaction(fn ->
Comment
|> Actions.stream(%{select_body: true}, query_builder: Posts)
|> Enum.to_list()
end)

assert created_schema_data.body === returned_schema_data
end

end

describe "aggregate: " do
Expand Down Expand Up @@ -364,6 +398,17 @@ defmodule EctoShorts.ActionsTest do

assert 20 = Actions.aggregate(Comment, %{}, :max, :count)
end

test "returns expected value for aggregate count using custom filter" do
assert {:ok, post_schema_data_1} = Actions.create(Post, %{title: "title"})
assert {:ok, post_schema_data_2} = Actions.create(Post, %{title: "title"})
assert {:ok, _schema_data} = Actions.create(Comment, %{post_id: post_schema_data_1.id})
assert {:ok, _schema_data} = Actions.create(Comment, %{post_id: post_schema_data_2.id})
assert {:ok, _schema_data} = Actions.create(Comment, %{post_id: post_schema_data_2.id})

assert 1 =
Actions.aggregate(Comment, %{post_id_with_comment_count_gte: 2}, :count, :post_id, query_builder: Posts)
end
end

describe "find_or_create_many: " do
Expand Down
Loading

0 comments on commit ce836ac

Please sign in to comment.