diff --git a/lib/flop.ex b/lib/flop.ex index c64c4d2..aa9a55f 100644 --- a/lib/flop.ex +++ b/lib/flop.ex @@ -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 @@ -518,8 +519,10 @@ 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. """ @@ -527,7 +530,7 @@ defmodule Flop do 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, @@ -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 """ diff --git a/lib/flop/combinator.ex b/lib/flop/combinator.ex new file mode 100644 index 0000000..202c3c5 --- /dev/null +++ b/lib/flop/combinator.ex @@ -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 diff --git a/lib/flop/filter_or_combinator.ex b/lib/flop/filter_or_combinator.ex new file mode 100644 index 0000000..6d0111e --- /dev/null +++ b/lib/flop/filter_or_combinator.ex @@ -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 diff --git a/lib/flop/validation.ex b/lib/flop/validation.ex index 0556d55..0adffaa 100644 --- a/lib/flop/validation.ex +++ b/lib/flop/validation.ex @@ -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 @@ -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 diff --git a/test/base/flop/combinator_test.exs b/test/base/flop/combinator_test.exs new file mode 100644 index 0000000..ef8357d --- /dev/null +++ b/test/base/flop/combinator_test.exs @@ -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 diff --git a/test/base/flop/validation_test.exs b/test/base/flop/validation_test.exs index 3417714..026ffd9 100644 --- a/test/base/flop/validation_test.exs +++ b/test/base/flop/validation_test.exs @@ -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