graphile-engine icon indicating copy to clipboard operation
graphile-engine copied to clipboard

Ordering by nullable column can lead to invalid cursor pagination

Open benjie opened this issue 4 years ago • 5 comments

e.g.

create table p.foo (
  id int primary key,
  name text
);

insert into p.foo (id) values
  (1),
  (2),
  (3),
  (4);
{
  page1: allFoos(first: 2, orderBy: NAME_ASC) {
    nodes {
      id
    }
    pageInfo {
      endCursor
      hasNextPage
    }
    totalCount
  }
  page2: allFoos(
    first: 2
    orderBy: NAME_ASC
    after: "WyJuYW1lX2FzYyIsW251bGwsMl1d"
  ) {
    nodes {
      id
    }
    pageInfo {
      endCursor
      hasNextPage
    }
    totalCount
  }
}
{
  "data": {
    "page1": {
      "nodes": [
        {
          "id": 1
        },
        {
          "id": 2
        }
      ],
      "pageInfo": {
        "endCursor": "WyJuYW1lX2FzYyIsW251bGwsMl1d",
        "hasNextPage": true
      },
      "totalCount": 4
    },
    "page2": {
      "nodes": [],
      "pageInfo": {
        "endCursor": null,
        "hasNextPage": false
      },
      "totalCount": 4
    }
  }
}
base64Decode("WyJuYW1lX2FzYyIsW251bGwsMl1d") === '["name_asc",[null,2]]'

The null causes the cursor comparison (a = b OR a > b) to fail since b is null so it is null, so it gets excluded. Need to explicitly handle nulls here, e.g. ((a is null AND a is not distinct from b) OR a = b OR a > b) or something. Will have to think it through carefully, especially how it relates to NULLS FIRST and NULLS LAST.

Originally reported here: https://github.com/graphile-contrib/pg-order-by-related/issues/19

Reproduction thanks to @mattbretl

benjie avatar Aug 12 '19 09:08 benjie

Pass an isNullable boolean as the third entry in the sort tuple for optimisation

benjie avatar Aug 15 '19 08:08 benjie

Hey Benjie, what's the current way to work around this? Fall back to offset-based pagination? Thanks for your work!

dargmuesli avatar Nov 15 '21 15:11 dargmuesli

Yes :+1:

benjie avatar Nov 15 '21 18:11 benjie

(Or just don't order by nullable fields :wink: )

benjie avatar Nov 15 '21 18:11 benjie

I ran into this exact issue, and I can verify that the query on Postgresql performs an equality (=) operator, where for null values, this will not be a correct check (has to be is null rather than (=)).

nbonavia avatar Feb 09 '22 12:02 nbonavia

Instead of passing an isNullable boolean as the third entry in the sort tuple for optimisation, couldn't it just generate a different SQL query if the value in the sort tuple is null? That way the performance hit would only be for pages that start with the null value?

We're running into this problem at Logixboard, and we're looking into forking the plugin to support this. Ordering by nullable fields is a requirement for us 🙂. Could you point us in the right direction where the logic for using the cursor lives? Found this but it doesn't seem to be the place where the sql is actually generated

jcgsville avatar Dec 08 '22 19:12 jcgsville

Start here: https://github.com/graphile/graphile-engine/blob/1bc8cfefdab7a61fd7ad287bcdff66298352e308/packages/graphile-build-pg/src/queryFromResolveDataFactory.js#L122

Hopefully this leads you down the right path; it’s been years since I’ve touched that code and I remember it being Not Fun to get all the edge cases right. Fortunately there’s a lot of integration tests for it so you should know quickly if you break something 👍

benjie avatar Dec 09 '22 07:12 benjie

This is probably the bit you are most interested in: https://github.com/graphile/graphile-engine/blob/1bc8cfefdab7a61fd7ad287bcdff66298352e308/packages/graphile-build-pg/src/queryFromResolveDataFactory.js#L387

benjie avatar Dec 09 '22 07:12 benjie

I believe this is fixed in V5.

benjie avatar Jul 23 '23 11:07 benjie

Basic testing indicates that this is fixed in V5.

benjie avatar Sep 27 '23 17:09 benjie