From ce836ac886dd9a53f7196e5500cc0b18e0563a04 Mon Sep 17 00:00:00 2001 From: Dmytro Date: Fri, 4 Oct 2024 16:22:31 +0300 Subject: [PATCH] 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 --- lib/ecto_shorts/actions.ex | 49 +++++++++++++++------ lib/ecto_shorts/actions/filters.ex | 52 +++++++++++++++++++++++ lib/ecto_shorts/actions/query_builder.ex | 30 +++++++++++++ test/ecto_shorts/actions/filters_test.exs | 4 ++ test/ecto_shorts/actions_test.exs | 45 ++++++++++++++++++++ test/support/contexts/posts.ex | 25 +++++++++++ test/support/schemas/comment.ex | 13 ++++++ 7 files changed, 204 insertions(+), 14 deletions(-) create mode 100644 lib/ecto_shorts/actions/filters.ex create mode 100644 lib/ecto_shorts/actions/query_builder.ex create mode 100644 test/ecto_shorts/actions/filters_test.exs create mode 100644 test/support/contexts/posts.ex diff --git a/lib/ecto_shorts/actions.ex b/lib/ecto_shorts/actions.ex index df414aa..6ee0dcc 100644 --- a/lib/ecto_shorts/actions.ex +++ b/lib/ecto_shorts/actions.ex @@ -65,6 +65,7 @@ defmodule EctoShorts.Actions do @type schema_res :: {:ok, schema()} | {:error, any} alias EctoShorts.{ + Actions, Actions.Error, CommonFilters, CommonSchemas, @@ -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 @@ -135,6 +136,8 @@ 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 @@ -142,12 +145,15 @@ defmodule EctoShorts.Actions do 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(), @@ -158,16 +164,9 @@ 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 """ @@ -175,7 +174,7 @@ defmodule EctoShorts.Actions do ### Filter Parameters - See `EctoShorts.CommonFilters` for more information. + See `EctoShorts.CommonFilters`, `EctoShorts.Actions.QueryBuilder` for more information. ### Options @@ -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(), @@ -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 @@ -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(), @@ -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 -> @@ -631,6 +642,8 @@ 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 @@ -638,9 +651,11 @@ defmodule EctoShorts.Actions do 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(), @@ -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 @@ -669,6 +685,8 @@ 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 @@ -676,9 +694,11 @@ defmodule EctoShorts.Actions do 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(), @@ -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 diff --git a/lib/ecto_shorts/actions/filters.ex b/lib/ecto_shorts/actions/filters.ex new file mode 100644 index 0000000..a607089 --- /dev/null +++ b/lib/ecto_shorts/actions/filters.ex @@ -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 diff --git a/lib/ecto_shorts/actions/query_builder.ex b/lib/ecto_shorts/actions/query_builder.ex new file mode 100644 index 0000000..6dac345 --- /dev/null +++ b/lib/ecto_shorts/actions/query_builder.ex @@ -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 diff --git a/test/ecto_shorts/actions/filters_test.exs b/test/ecto_shorts/actions/filters_test.exs new file mode 100644 index 0000000..807a7a7 --- /dev/null +++ b/test/ecto_shorts/actions/filters_test.exs @@ -0,0 +1,4 @@ +defmodule EctoShorts.Actions.FiltersTest do + use ExUnit.Case, async: true + doctest EctoShorts.Actions.Filters +end diff --git a/test/ecto_shorts/actions_test.exs b/test/ecto_shorts/actions_test.exs index 34a2b7f..20e4be5 100644 --- a/test/ecto_shorts/actions_test.exs +++ b/test/ecto_shorts/actions_test.exs @@ -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 -> @@ -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"}) @@ -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 @@ -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"}) @@ -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 @@ -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 diff --git a/test/support/contexts/posts.ex b/test/support/contexts/posts.ex new file mode 100644 index 0000000..b7cb10a --- /dev/null +++ b/test/support/contexts/posts.ex @@ -0,0 +1,25 @@ +defmodule EctoShorts.Support.Contexts.Posts do + @moduledoc """ + Highlights query building using: + * query builder functions at context level + * custom query functions from schema + """ + alias EctoShorts.Actions.QueryBuilder + alias EctoShorts.Support.Schemas.Comment + + @behaviour QueryBuilder + + @impl QueryBuilder + def filters, do: [:select_body, :post_id_with_comment_count_gte] + + @impl QueryBuilder + def build_query(Comment, %{select_body: true}, query), + do: Comment.select_body(query) + + def build_query(Comment, %{select_body: _}, query), + do: query + + @impl QueryBuilder + def build_query(Comment, %{post_id_with_comment_count_gte: val}, query), + do: Comment.post_id_with_comment_count_gte(query, val) +end diff --git a/test/support/schemas/comment.ex b/test/support/schemas/comment.ex index 27cb5de..a1fc36c 100644 --- a/test/support/schemas/comment.ex +++ b/test/support/schemas/comment.ex @@ -3,6 +3,8 @@ defmodule EctoShorts.Support.Schemas.Comment do use Ecto.Schema import Ecto.Changeset + require Ecto.Query + schema "comments" do field :body, :string field :count, :integer @@ -22,4 +24,15 @@ defmodule EctoShorts.Support.Schemas.Comment do |> cast(attrs, @available_fields) |> validate_length(:body, min: 3) end + + def select_body(queryable \\ __MODULE__), + do: Ecto.Query.select(queryable, [c], c.body) + + def post_id_with_comment_count_gte(queryable \\ __MODULE__, count) do + queryable + |> Ecto.Query.group_by([c], c.post_id) + |> Ecto.Query.select([c], c.post_id) + |> Ecto.Query.having([c], count(c.post_id) >= ^count) + |> Ecto.Query.subquery() + end end