Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions lib/flop.ex
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ defmodule Flop do
alias Flop.Cursor
alias Flop.CustomTypes.ExistingAtom
alias Flop.Filter
alias Flop.Combinator
alias Flop.Meta
alias Flop.NimbleSchemas

Expand Down Expand Up @@ -518,16 +519,18 @@ defmodule Flop do
- `order_directions`: List of order directions applied to the fields defined
in `order_by`. If empty or the list is shorter than the `order_by` list,
`:asc` will be used as a default for each missing order direction.
- `filters`: List of filters, see `t:Flop.Filter.t/0`. Each `Filter.t()`
represents a filter operation on a specific field.
- `filters`: List of filters or combinators, see `t:Flop.Filter.t/0` and
`t:Flop.Combinator.t/0`. Each `Filter.t()` represents a filter operation
on a specific field, while each `Combinator.t()` represents a boolean
combination of filters.

Note: Pagination fields are mutually exclusive.
"""
@type t :: %__MODULE__{
after: String.t() | nil,
before: String.t() | nil,
decoded_cursor: map | nil,
filters: [Filter.t()] | nil,
filters: [Filter.t() | Combinator.t()] | nil,
first: pos_integer | nil,
last: pos_integer | nil,
limit: pos_integer | nil,
Expand Down Expand Up @@ -562,7 +565,7 @@ defmodule Flop do
field :page_size, :integer
field :decoded_cursor, :map

embeds_many :filters, Filter
field :filters, {:array, :map}
end

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

use Ecto.Schema

import Ecto.Changeset

alias Ecto.Changeset
alias Flop.FilterOrCombinator

@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 | nil,
filters: [Flop.Filter.t() | t()] | nil
}

@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
field :filters, {:array, :map}
end

@doc false
@spec changeset(__MODULE__.t(), map) :: Changeset.t()
def changeset(combinator, %{} = params) do
combinator
|> cast(params, [:type])
|> FilterOrCombinator.cast(params)
|> validate_required([:type])
|> 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
%Ecto.Changeset{data: %__MODULE__{}} ->
changeset

%__MODULE__{} ->
changeset

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

true ->
changeset
end
end
end
107 changes: 107 additions & 0 deletions lib/flop/filter_or_combinator.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
defmodule Flop.FilterOrCombinator do
@moduledoc """
Helper module for handling polymorphic casting of Filter and Combinator types.

This module provides functionality to cast parameters into either Filter or
Combinator structs with proper validation, eliminating code duplication across
the codebase.
"""

alias Flop.Combinator
alias Flop.Filter

@doc """
Casts polymorphic filters in a changeset.

Handles both string and atom key parameters for the :filters field.
"""
@spec cast(Ecto.Changeset.t(), map(), keyword()) :: Ecto.Changeset.t()
def cast(changeset, params, opts \\ [])

def cast(changeset, params, opts) do
filters = params["filters"] || params[:filters]

case filters do
filters when is_list(filters) ->
{:ok, cast_filters} = cast_filters_list(filters, opts)

changeset_with_filters =
Ecto.Changeset.put_change(changeset, :filters, cast_filters)

has_invalid_filters =
Enum.any?(cast_filters, fn
%Ecto.Changeset{valid?: false} -> true
_ -> false
end)

if has_invalid_filters do
Ecto.Changeset.add_error(
changeset_with_filters,
:filters,
"contains invalid filters or combinators"
)
else
changeset_with_filters
end

_ ->
changeset
end
end

defp cast_filters_list(filters, opts) when is_list(filters) do
changesets = Enum.map(filters, &cast_single(&1, opts))
{:ok, changesets}
end

defp cast_single(%{"field" => _, "value" => _} = params, opts) do
Filter.changeset(%Filter{}, params, opts)
end

defp cast_single(%{"type" => _, "filters" => _} = params, _opts) do
Combinator.changeset(%Combinator{filters: []}, params)
end

defp cast_single(%{field: _, op: _} = params, opts) when is_map(params) do
cast_single(stringify_keys(params), opts)
end

defp cast_single(%{type: _, filters: _} = params, opts) when is_map(params) do
cast_single(stringify_keys(params), opts)
end

defp cast_single(%struct{} = item, _opts)
when struct in [Filter, Combinator] do
item
end

defp cast_single(%{"field" => _} = params, opts) do
Filter.changeset(%Filter{}, params, opts)
end

defp cast_single(%{field: _} = params, opts) do
cast_single(stringify_keys(params), opts)
end

defp cast_single(%{"type" => _} = params, _opts) do
Combinator.changeset(%Combinator{filters: []}, params)
end

defp cast_single(%{type: _} = params, opts) do
cast_single(stringify_keys(params), opts)
end

defp cast_single(params, opts) when is_map(params) do
Filter.changeset(%Filter{}, stringify_keys(params), opts)
end

defp cast_single(_params, _opts) do
%Filter{}
|> Ecto.Changeset.change()
|> Ecto.Changeset.add_error(:base, "must be a map")
end

defp stringify_keys(map) do
Map.new(map, fn {k, v} -> {to_string(k), v} end)
end
end
6 changes: 2 additions & 4 deletions lib/flop/validation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ defmodule Flop.Validation do

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

@spec changeset(map, [Flop.option()]) :: Changeset.t()
def changeset(%{} = params, opts) do
Expand Down Expand Up @@ -79,9 +79,7 @@ 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)
)
FilterOrCombinator.cast(changeset, changeset.params, opts)
else
changeset
end
Expand Down
116 changes: 116 additions & 0 deletions test/base/flop/combinator_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
defmodule Flop.CombinatorTest do
use ExUnit.Case, async: true

import Flop.TestUtil
import Ecto.Changeset

alias Flop.Combinator

defp validate(params) do
%Combinator{}
|> Combinator.changeset(params)
|> 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__ == Ecto.Changeset
assert filter.data.__struct__ == Flop.Filter
assert nested_combinator.__struct__ == Ecto.Changeset
assert nested_combinator.data.__struct__ == Combinator
assert Ecto.Changeset.get_field(nested_combinator, :type) == :or
assert length(Ecto.Changeset.get_field(nested_combinator, :filters)) == 2
end
end
end
20 changes: 20 additions & 0 deletions test/base/flop/validation_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -1076,4 +1076,24 @@ defmodule Flop.ValidationTest do
assert [%{value: ["is invalid"]}] = errors_on(changeset)[:filters]
end
end

describe "filter parameters using combinators" do
test "validates filter containing combinators" do
params = %{
filters: [
%{
filters: [
%{field: :age, op: :>, value: 1},
%{field: :species, op: :==, value: "C. lupus"}
]
}
]
}

assert {:error, changeset} = validate(params, for: Pet)

assert changeset.errors[:filters] ==
{"contains invalid filters or combinators", []}
end
end
end