Skip to content

Cursor pagination with nullable fields #560

@balysv

Description

@balysv

Is your feature request related to a problem? Please describe.
I have a DB schema with nullable TEXT / VARCHAR fields I'd like to sort and paginate on using cursors. However, as cursor pagination is currently implemented in the Ecto adapter (source), it uses the > or < operators which would always remove null values from the result set (at least in PostgreSQL).

Describe the solution you'd like
Enhance the sorting capabilities to allow for sorting data with null values. This can, of course, only work if an additional sorting column like id is provided. The generated query would look something like:

SELECT * FROM users
WHERE (
  name > :last_seen_val OR
  (name = :last_seen_val AND id > :last_seen_id) OR
  (name IS NULL AND :last_seen_val IS NULL AND id > :last_seen_id)
)
ORDER BY name ASC NULLS LAST, id ASC
LIMIT 100;

Describe alternatives you've considered
I've looked into custom fields, compound fields (not supported for cursors) or simply ditching the library and rewriting the pagination logic myself (which I'd like not to).

Additional context
I am not 100% sure if I should instead file a bug report but after investigating the code I see that this functionality not supported by the library so opting for a request. If there's a config I'm missing in how to do this, let me know!

I also pushed a repo with tests reproducing the issue: https://github.com/balysv/flop-cursor-pagination. The test case is:

defmodule FlopTest.User do
  use Ecto.Schema
  import Ecto.Changeset

  @derive {
    Flop.Schema,
    filterable: [],
    sortable: [:id, :name],
    default_order: %{
      order_by: [:id],
      order_directions: [:asc]
    },
    default_pagination_type: :offset,
    pagination_types: [:first, :last, :offset]
  }

  schema "users" do
    field(:name, :string)

    timestamps()
  end

  def changeset(user, attrs) do
    user
    |> cast(attrs, [:name])
    |> validate_required([])
  end
end
defmodule FlopTest.UserTest do
  use FlopTest.DataCase
  import ExUnit.Case

  alias FlopTest.User

  setup do
    [nil, nil, "a", "b", "c", "d", nil, nil]
    |> Enum.each(fn name ->
      Repo.insert(%User{name: name})
    end)
  end

  test "page ends in `nil`" do
    query = from(u in User)

    {:ok, {results, meta}} =
      Flop.validate_and_run(
        query,
        %{first: 5, order_by: [:name, :id], order_directions: [:asc]},
        for: User
      )


    # Results: [{"a", 3}, {"b", 4}, {"c", 5}, {"d", 6}, {nil, 7}]
    assert Enum.map(results, & &1.name) == ["a", "b", "c", "d", nil]

    {:ok, {results, meta}} =
      Flop.validate_and_run(
        query,
        %{first: 5, after: meta.end_cursor, order_by: [:name, :id], order_directions: [:asc]},
        for: User
      )

    # Results: [{"a", 3}, {"b", 4}, {"c", 5}, {"d", 6}, {nil, 7}]
    # but instead it should be: [{nil, 7}, {nil, 8}]
    assert Enum.map(results, & &1.name) == [nil, nil]
  end

  test "page ends in value, `nil`s to follow" do
    query = from(u in User)

    {:ok, {results, meta}} =
      Flop.validate_and_run(
        query,
        %{first: 3, order_by: [:name, :id], order_directions: [:asc]},
        for: User
      )

    # Results: page1: [{"a", 3}, {"b", 4}, {"c", 5}]
    assert Enum.map(results, & &1.name) == ["a", "b", "c"]

    {:ok, {results, meta}} =
      Flop.validate_and_run(
        query,
        %{first: 100, after: meta.end_cursor, order_by: [:name, :id], order_directions: [:asc]},
        for: User
      )

    # Results: page2: [{"d", 6}] - all `nils` are filtered out
    assert Enum.map(results, & &1.name) == ["d", nil, nil]
  end
end

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions