Skip to content

Commit

Permalink
feat: add support for all asc/desc sorting order combinations (duffel…
Browse files Browse the repository at this point in the history
…hq#136)

* feat: add support for all asc/desc sorting order combinations

This patch adds support for all supported sorting orders when the
field is nullable. It is now possible to specify the following extra
sort orders in the cursor_fields:

  * asc_nulls_last, asc_nulls_first
  * desc_nulls_last, desc_nulls_first

As the number of different combinations is large, we refactored the
code that builds the dynamic filter expressions to make it a bit more
manageable. Each sorting order is now a separate module.

There is at least one problem with the current implementation
though. The `:asc/:desc` default order, w.r.t. to null values may vary
from vendor to vendor. For instance, we are following Postgres
defaults. It means that `:asc = :asc_nulls_last` and `:desc =
:desc_nulls_first`. However, if you are using Mysql instead, you get
`:asc = :asc_nulls_first` and `:desc = :desc_nulls_last`. Somehow we
need to factor in the adapter. However, I was not sure the best way to
achieve that, I opted for lefting it out for now and instead asks for
directions on that regard.

* chore: remove function.identity and rebase against main

* fix: dialyzer
  • Loading branch information
dgvncsz0f authored Feb 7, 2022
1 parent b87282f commit 9e5b1d2
Show file tree
Hide file tree
Showing 8 changed files with 456 additions and 58 deletions.
9 changes: 8 additions & 1 deletion lib/paginator/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,14 @@ defmodule Paginator.Config do
@minimum_limit 1
@maximum_limit 500
@default_total_count_limit 10_000
@order_directions [:asc, :desc]
@order_directions [
:asc,
:asc_nulls_last,
:asc_nulls_first,
:desc,
:desc_nulls_first,
:desc_nulls_last
]

def new(opts \\ []) do
%__MODULE__{
Expand Down
94 changes: 38 additions & 56 deletions lib/paginator/ecto/query.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ defmodule Paginator.Ecto.Query do
import Ecto.Query

alias Paginator.Config
alias Paginator.Ecto.Query.DynamicFilterBuilder

def paginate(queryable, config \\ [])

Expand All @@ -18,74 +19,51 @@ defmodule Paginator.Ecto.Query do
paginate(queryable, config)
end

defp get_operator(:asc, :before), do: :lt
defp get_operator(:desc, :before), do: :gt
defp get_operator(:asc, :after), do: :gt
defp get_operator(:desc, :after), do: :lt

defp get_operator(direction, _),
do: raise("Invalid sorting value :#{direction}, please use either :asc or :desc")

defp get_operator_for_field(cursor_fields, key, direction) do
{_, order} =
cursor_fields
|> Enum.find(fn {field_key, _order} ->
field_key == key
end)

get_operator(order, direction)
end

# This clause is responsible for transforming legacy list cursors into map cursors
defp filter_values(query, fields, values, cursor_direction) when is_list(values) do
new_values =
fields
|> Keyword.keys()
|> Enum.map(&elem(&1, 0))
|> Enum.zip(values)
|> Map.new()

filter_values(query, fields, new_values, cursor_direction)
end

defp filter_values(query, fields, values, cursor_direction) when is_map(values) do
sorts =
fields
|> Enum.map(fn {column, _order} -> {column, Map.get(values, column)} end)
|> Enum.reject(fn val -> match?({_column, nil}, val) end)

dynamic_sorts =
sorts
|> Enum.with_index()
|> Enum.reduce(true, fn {{bound_column, value}, i}, dynamic_sorts ->
{position, column} = column_position(query, bound_column)

dynamic = true

dynamic =
case get_operator_for_field(fields, bound_column, cursor_direction) do
:lt ->
dynamic([{q, position}], field(q, ^column) < ^value and ^dynamic)

:gt ->
dynamic([{q, position}], field(q, ^column) > ^value and ^dynamic)
end

dynamic =
sorts
|> Enum.take(i)
|> Enum.reduce(dynamic, fn {prev_column, prev_value}, dynamic ->
{position, prev_column} = column_position(query, prev_column)
dynamic([{q, position}], field(q, ^prev_column) == ^prev_value and ^dynamic)
end)

if i == 0 do
dynamic([{q, position}], ^dynamic and ^dynamic_sorts)
else
dynamic([{q, position}], ^dynamic or ^dynamic_sorts)
end
end)
filters = build_where_expression(query, fields, values, cursor_direction)

where(query, [{q, 0}], ^filters)
end

defp build_where_expression(query, [{column, order}], values, cursor_direction) do
value = Map.get(values, column)
{q_position, q_binding} = column_position(query, column)

DynamicFilterBuilder.build!(%{
sort_order: order,
direction: cursor_direction,
value: value,
entity_position: q_position,
column: q_binding,
next_filters: true
})
end

defp build_where_expression(query, [{column, order} | fields], values, cursor_direction) do
value = Map.get(values, column)
{q_position, q_binding} = column_position(query, column)

filters = build_where_expression(query, fields, values, cursor_direction)

where(query, [{q, 0}], ^dynamic_sorts)
DynamicFilterBuilder.build!(%{
sort_order: order,
direction: cursor_direction,
value: value,
entity_position: q_position,
column: q_binding,
next_filters: filters
})
end

defp maybe_where(query, %Config{
Expand Down Expand Up @@ -160,7 +138,11 @@ defmodule Paginator.Ecto.Query do
| expr:
Enum.map(expr, fn
{:desc, ast} -> {:asc, ast}
{:desc_nulls_first, ast} -> {:asc_nulls_last, ast}
{:desc_nulls_last, ast} -> {:asc_nulls_first, ast}
{:asc, ast} -> {:desc, ast}
{:asc_nulls_last, ast} -> {:desc_nulls_first, ast}
{:asc_nulls_first, ast} -> {:desc_nulls_last, ast}
end)
}
end
Expand Down
60 changes: 60 additions & 0 deletions lib/paginator/ecto/query/asc_nulls_first.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
defmodule Paginator.Ecto.Query.AscNullsFirst do
@behaviour Paginator.Ecto.Query.DynamicFilterBuilder

import Ecto.Query

@impl Paginator.Ecto.Query.DynamicFilterBuilder
def build_dynamic_filter(%{direction: :after, value: nil, next_filters: true}) do
raise("unstable sort order: nullable columns can't be used as the last term")
end

def build_dynamic_filter(args = %{direction: :after, value: nil}) do
dynamic(
[{query, args.entity_position}],
(is_nil(field(query, ^args.column)) and ^args.next_filters) or
not is_nil(field(query, ^args.column))
)
end

def build_dynamic_filter(args = %{direction: :after, next_filters: true}) do
dynamic(
[{query, args.entity_position}],
field(query, ^args.column) > ^args.value
)
end

def build_dynamic_filter(args = %{direction: :after}) do
dynamic(
[{query, args.entity_position}],
(field(query, ^args.column) == ^args.value and ^args.next_filters) or
field(query, ^args.column) > ^args.value
)
end

def build_dynamic_filter(%{direction: :before, value: nil, next_filters: true}) do
raise("unstable sort order: nullable columns can't be used as the last term")
end

def build_dynamic_filter(args = %{direction: :before, value: nil}) do
dynamic(
[{query, args.entity_position}],
is_nil(field(query, ^args.column)) and ^args.next_filters
)
end

def build_dynamic_filter(args = %{direction: :before, next_filters: true}) do
dynamic(
[{query, args.entity_position}],
field(query, ^args.column) < ^args.value or is_nil(field(query, ^args.column))
)
end

def build_dynamic_filter(args = %{direction: :before}) do
dynamic(
[{query, args.entity_position}],
(field(query, ^args.column) == ^args.value and ^args.next_filters) or
field(query, ^args.column) < ^args.value or
is_nil(field(query, ^args.column))
)
end
end
57 changes: 57 additions & 0 deletions lib/paginator/ecto/query/asc_nulls_last.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
defmodule Paginator.Ecto.Query.AscNullsLast do
@behaviour Paginator.Ecto.Query.DynamicFilterBuilder

import Ecto.Query

@impl Paginator.Ecto.Query.DynamicFilterBuilder
def build_dynamic_filter(%{direction: :after, value: nil, next_filters: true}) do
raise("unstable sort order: nullable columns can't be used as the last term")
end

def build_dynamic_filter(args = %{direction: :after, value: nil}) do
dynamic(
[{query, args.entity_position}],
is_nil(field(query, ^args.column)) and ^args.next_filters
)
end

def build_dynamic_filter(args = %{direction: :after, next_filters: true}) do
dynamic(
[{query, args.entity_position}],
field(query, ^args.column) > ^args.value or is_nil(field(query, ^args.column))
)
end

def build_dynamic_filter(args = %{direction: :after}) do
dynamic(
[{query, args.entity_position}],
(field(query, ^args.column) == ^args.value and ^args.next_filters) or
field(query, ^args.column) > ^args.value or
is_nil(field(query, ^args.column))
)
end

def build_dynamic_filter(%{direction: :before, value: nil, next_filters: true}) do
raise("unstable sort order: nullable columns can't be used as the last term")
end

def build_dynamic_filter(args = %{direction: :before, value: nil}) do
dynamic(
[{query, args.entity_position}],
(is_nil(field(query, ^args.column)) and ^args.next_filters) or
not is_nil(field(query, ^args.column))
)
end

def build_dynamic_filter(args = %{direction: :before, next_filters: true}) do
dynamic([{query, args.entity_position}], field(query, ^args.column) < ^args.value)
end

def build_dynamic_filter(args = %{direction: :before}) do
dynamic(
[{query, args.entity_position}],
(field(query, ^args.column) == ^args.value and ^args.next_filters) or
field(query, ^args.column) < ^args.value
)
end
end
57 changes: 57 additions & 0 deletions lib/paginator/ecto/query/desc_nulls_first.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
defmodule Paginator.Ecto.Query.DescNullsFirst do
@behaviour Paginator.Ecto.Query.DynamicFilterBuilder

import Ecto.Query

@impl Paginator.Ecto.Query.DynamicFilterBuilder
def build_dynamic_filter(%{direction: :before, value: nil, next_filters: true}) do
raise("unstable sort order: nullable columns can't be used as the last term")
end

def build_dynamic_filter(args = %{direction: :before, value: nil}) do
dynamic(
[{query, args.entity_position}],
is_nil(field(query, ^args.column)) and ^args.next_filters
)
end

def build_dynamic_filter(args = %{direction: :before, next_filters: true}) do
dynamic(
[{query, args.entity_position}],
field(query, ^args.column) > ^args.value or is_nil(field(query, ^args.column))
)
end

def build_dynamic_filter(args = %{direction: :before}) do
dynamic(
[{query, args.entity_position}],
(field(query, ^args.column) == ^args.value and ^args.next_filters) or
field(query, ^args.column) > ^args.value or
is_nil(field(query, ^args.column))
)
end

def build_dynamic_filter(%{direction: :after, value: nil, next_filters: true}) do
raise("unstable sort order: nullable columns can't be used as the last term")
end

def build_dynamic_filter(args = %{direction: :after, value: nil}) do
dynamic(
[{query, args.entity_position}],
(is_nil(field(query, ^args.column)) and ^args.next_filters) or
not is_nil(field(query, ^args.column))
)
end

def build_dynamic_filter(args = %{direction: :after, next_filters: true}) do
dynamic([{query, args.entity_position}], field(query, ^args.column) < ^args.value)
end

def build_dynamic_filter(args = %{direction: :after}) do
dynamic(
[{query, args.entity_position}],
(field(query, ^args.column) == ^args.value and ^args.next_filters) or
field(query, ^args.column) < ^args.value
)
end
end
60 changes: 60 additions & 0 deletions lib/paginator/ecto/query/desc_nulls_last.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
defmodule Paginator.Ecto.Query.DescNullsLast do
@behaviour Paginator.Ecto.Query.DynamicFilterBuilder

import Ecto.Query

@impl Paginator.Ecto.Query.DynamicFilterBuilder
def build_dynamic_filter(%{direction: :before, value: nil, next_filters: true}) do
raise("unstable sort order: nullable columns can't be used as the last term")
end

def build_dynamic_filter(args = %{direction: :before, value: nil}) do
dynamic(
[{query, args.entity_position}],
(is_nil(field(query, ^args.column)) and ^args.next_filters) or
not is_nil(field(query, ^args.column))
)
end

def build_dynamic_filter(args = %{direction: :before, next_filters: true}) do
dynamic(
[{query, args.entity_position}],
field(query, ^args.column) > ^args.value
)
end

def build_dynamic_filter(args = %{direction: :before}) do
dynamic(
[{query, args.entity_position}],
(field(query, ^args.column) == ^args.value and ^args.next_filters) or
field(query, ^args.column) > ^args.value
)
end

def build_dynamic_filter(%{direction: :after, value: nil, next_filters: true}) do
raise("unstable sort order: nullable columns can't be used as the last term")
end

def build_dynamic_filter(args = %{direction: :after, value: nil}) do
dynamic(
[{query, args.entity_position}],
is_nil(field(query, ^args.column)) and ^args.next_filters
)
end

def build_dynamic_filter(args = %{direction: :after, next_filters: true}) do
dynamic(
[{query, args.entity_position}],
field(query, ^args.column) < ^args.value or is_nil(field(query, ^args.column))
)
end

def build_dynamic_filter(args = %{direction: :after}) do
dynamic(
[{query, args.entity_position}],
(field(query, ^args.column) == ^args.value and ^args.next_filters) or
field(query, ^args.column) < ^args.value or
is_nil(field(query, ^args.column))
)
end
end
Loading

0 comments on commit 9e5b1d2

Please sign in to comment.