Skip to content
13 changes: 12 additions & 1 deletion lib/flop.ex
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,8 @@ defmodule Flop do
"""
use Ecto.Schema

import PolymorphicEmbed

alias Ecto.Changeset
alias Ecto.Queryable
alias Flop.Adapter
Expand Down Expand Up @@ -562,7 +564,16 @@ defmodule Flop do
field :page_size, :integer
field :decoded_cursor, :map

embeds_many :filters, Filter
polymorphic_embeds_many(:filters,
types: [
filter: [module: Flop.Filter, identify_by_fields: [:field, :value]],
combinator: [
module: Flop.Combinator,
identify_by_fields: [:type, :filters]
]
],
on_replace: :delete
)
end

@doc """
Expand Down
105 changes: 105 additions & 0 deletions lib/flop/combinator.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
defmodule Flop.Combinator do
@moduledoc """
Defines a combinator for boolean logic on filters.
"""

use Ecto.Schema

import PolymorphicEmbed
import Ecto.Changeset

alias Ecto.Changeset

@typedoc """
Represents a combinator for applying boolean logic to filters.

### Fields

- `type`: The boolean operator to apply to the filters (`:and` or `:or`).
- `filters`: A list of filters or nested combinators to combine.
"""
@type t :: %__MODULE__{
type: combinator_type,
filters: [Flop.Filter.t() | t()]
}

@typedoc """
Represents valid combinator types.

| Type | Description |
| :---- | :------------------------------- |
| `:and`| Combines filters with AND logic |
| `:or` | Combines filters with OR logic |
"""
@type combinator_type :: :and | :or

@combinator_types [:and, :or]

@primary_key false
embedded_schema do
field :type, Ecto.Enum, values: @combinator_types

polymorphic_embeds_many(:filters,
types: [
filter: [module: Flop.Filter, identify_by_fields: [:field, :value]],
combinator: [module: __MODULE__, identify_by_fields: [:type, :filters]]
],
on_replace: :delete
)
end

@doc false
@spec filter_or_combinator(keyword) :: [
{:filter | :combinator, (t(), map() -> Changeset.t())}
]
def filter_or_combinator(opts) do
[
filter: &Flop.Filter.changeset/3,
combinator: &changeset/3
]
|> Enum.map(fn {type, changeset_fn} ->
{type, fn struct, params -> changeset_fn.(struct, params, opts) end}
end)
end

@doc false
@spec changeset(__MODULE__.t(), map, keyword) :: Changeset.t()
def changeset(combinator, %{} = params, opts \\ []) do
combinator
|> cast(params, [:type])
|> validate_required([:type])
|> cast_polymorphic_embed(:filters, with: filter_or_combinator(opts))
|> validate_filters_not_empty()
end

defp validate_filters_not_empty(changeset) do
filters = get_field(changeset, :filters)
is_list = is_list(filters)
length = if is_list, do: length(filters), else: 0

cond do
is_list and length == 0 ->
add_error(
changeset,
:filters,
"must have at least two filters or one combinator"
)

is_list and length == 1 ->
case List.first(filters) do
%__MODULE__{} ->
changeset

_ ->
add_error(
changeset,
:filters,
"must have at least two filters or one combinator"
)
end

true ->
changeset
end
end
end
43 changes: 38 additions & 5 deletions lib/flop/validation.ex
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
defmodule Flop.Validation do
@moduledoc false

import PolymorphicEmbed

alias Ecto.Changeset
alias Flop.Cursor
alias Flop.Filter

@spec changeset(map, [Flop.option()]) :: Changeset.t()
def changeset(%{} = params, opts) do
Expand Down Expand Up @@ -48,8 +49,13 @@ defmodule Flop.Validation do
nil ->
nil

changesets when is_list(changesets) ->
Enum.filter(changesets, fn %Changeset{valid?: valid?} -> valid? end)
structs when is_list(structs) ->
Enum.filter(structs, fn
%Changeset{valid?: valid?} -> valid?
%Flop.Filter{} -> true
%Flop.Combinator{} -> true
_ -> false
end)
end)
end

Expand Down Expand Up @@ -79,14 +85,41 @@ defmodule Flop.Validation do

defp cast_filters(changeset, opts) do
if Flop.get_option(:filtering, opts, true) do
Changeset.cast_embed(changeset, :filters,
with: &Filter.changeset(&1, &2, opts)
changeset
|> maybe_preprocess_malformed_filters(opts)
|> cast_polymorphic_embed(:filters,
with: Flop.Combinator.filter_or_combinator(opts)
)
else
changeset
end
end

defp maybe_preprocess_malformed_filters(changeset, opts) do
if Keyword.get(opts, :replace_invalid_params, false) do
params = changeset.params || %{}

{filters, key} =
if params[:filters] do
{params[:filters], :filters}
else
{params["filters"], "filters"}
end

if is_list(filters) do
processed_filters = Enum.filter(filters, &is_map/1)

updated_params = %{params | key => processed_filters}

%{changeset | params: updated_params}
else
changeset
end
else
changeset
end
end

# Takes a list of field groups and validates that no fields from multiple
# groups are set.
@spec validate_exclusive(Changeset.t(), [[atom]], keyword) :: Changeset.t()
Expand Down
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ defmodule Flop.MixProject do
{:excoveralls, "== 0.18.5", only: :test},
{:myxql, "== 0.7.1", only: :test},
{:nimble_options, "~> 1.0"},
{:polymorphic_embed, "~> 5.0"},
{:postgrex, "== 0.20.0", only: :test},
{:ecto_sqlite3, "== 0.19.0", only: :test},
{:stream_data, "== 1.2.0", only: [:dev, :test]}
Expand Down
2 changes: 2 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
%{
"attrs": {:hex, :attrs, "0.6.0", "25d738b47829f964a786ef73897d2550b66f3e7d1d7c49a83bc8fd81c71bed93", [:mix], [], "hexpm", "9c30ac15255c2ba8399263db55ba32c2f4e5ec267b654ce23df99168b405c82e"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"},
"credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"},
Expand All @@ -24,6 +25,7 @@
"myxql": {:hex, :myxql, "0.7.1", "7c7b75aa82227cd2bc9b7fbd4de774fb19a1cdb309c219f411f82ca8860f8e01", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:geo, "~> 3.4", [hex: :geo, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a491cdff53353a09b5850ac2d472816ebe19f76c30b0d36a43317a67c9004936"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"polymorphic_embed": {:hex, :polymorphic_embed, "5.0.3", "37444e0af941026a2c29b0539b6471bdd6737a6492a19264bf2bb0118e3ac242", [:mix], [{:attrs, "~> 0.6", [hex: :attrs, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "2fed44f57abf0a0fc7642e0eb0807a55b65de1562712cc0620772cbbb80e49c1"},
"postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"},
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
Expand Down
114 changes: 114 additions & 0 deletions test/base/flop/combinator_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
defmodule Flop.CombinatorTest do
use ExUnit.Case, async: true

import Flop.TestUtil
import Ecto.Changeset

alias Flop.Combinator

defp validate(params, opts \\ []) do
%Combinator{}
|> Combinator.changeset(params, opts)
|> apply_action(:validate)
end

describe "changeset/3" do
test "combinator type must be a valid type" do
params = %{
type: :invalid,
filters: [
%{field: :name, op: :==, value: "Harry"}
]
}

{:error, changeset} = validate(params)

assert errors_on(changeset)[:type] == ["is invalid"]
end

test "combinator with empty filters is invalid" do
params = %{
type: :or,
filters: []
}

{:error, changeset} = validate(params)

assert errors_on(changeset)[:filters] == [
"must have at least two filters or one combinator"
]
end

test "combinator with single filter is invalid" do
params = %{
type: :or,
filters: [
%{field: :name, op: :==, value: "Harry"}
]
}

{:error, changeset} = validate(params)

assert errors_on(changeset)[:filters] == [
"must have at least two filters or one combinator"
]
end

test "validates simple combinator with OR type" do
params = %{
type: :or,
filters: [
%{field: :name, op: :==, value: "Harry"},
%{field: :name, op: :==, value: "Maggie"}
]
}

{:ok, combinator} = validate(params)

assert combinator.type == :or
assert length(combinator.filters) == 2
end

test "validates simple combinator with AND type" do
params = %{
type: :and,
filters: [
%{field: :age, op: :>, value: 1},
%{field: :species, op: :==, value: "C. lupus"}
]
}

{:ok, combinator} = validate(params)

assert combinator.type == :and
assert length(combinator.filters) == 2
end

test "validates nested combinators" do
params = %{
type: :and,
filters: [
%{field: :age, op: :>, value: 1},
%{
type: :or,
filters: [
%{field: :name, op: :==, value: "Harry"},
%{field: :name, op: :==, value: "Maggie"}
]
}
]
}

{:ok, combinator} = validate(params)

assert combinator.type == :and
assert length(combinator.filters) == 2

[filter, nested_combinator] = combinator.filters
assert filter.__struct__ == Flop.Filter
assert nested_combinator.__struct__ == Combinator
assert nested_combinator.type == :or
assert length(nested_combinator.filters) == 2
end
end
end
Loading