From 5fa726a1905f53b2a22da9d0c7302b7f7c948539 Mon Sep 17 00:00:00 2001 From: dataflow-solutions-sk Date: Sun, 16 Aug 2026 23:42:11 +0200 Subject: [PATCH] Fix hasPreviousPage/hasNextPage ignoring after/before cursors connection_from_array_slice gated hasPreviousPage entirely on whether last was supplied and hasNextPage entirely on whether irst was supplied, ignoring the after/before cursors which are, per the Relay Cursor Connections Specification, an equally valid (and in this offset-based implementation, efficiently determinable) signal that a previous/next page exists. Now, when last is not given, hasPreviousPage is true whenever a valid fter cursor (pointing at an existing element) was supplied. Symmetrically, when irst is not given, hasNextPage is true whenever a valid efore cursor was supplied. This matches the algorithm in graphql-relay-js PR #400, which proposed the same fix upstream (still unmerged as of this writing). Updates existing test expectations that encoded the old, incorrect behavior, and adds regression tests for the two scenarios from the bug report: hasPreviousPage on a second page reached via first+after, and hasNextPage on a page reached via last+before. --- .../connection/array_connection.py | 8 +- tests/connection/test_array_connection.py | 92 +++++++++++++++---- 2 files changed, 79 insertions(+), 21 deletions(-) diff --git a/src/graphql_relay/connection/array_connection.py b/src/graphql_relay/connection/array_connection.py index f25cdce..413eb19 100644 --- a/src/graphql_relay/connection/array_connection.py +++ b/src/graphql_relay/connection/array_connection.py @@ -149,8 +149,12 @@ def connection_from_array_slice( pageInfo=page_info_type( startCursor=first_edge_cursor, endCursor=last_edge_cursor, - hasPreviousPage=isinstance(last, int) and start_offset > lower_bound, - hasNextPage=isinstance(first, int) and end_offset < upper_bound, + hasPreviousPage=start_offset > lower_bound + if isinstance(last, int) + else (0 <= after_offset < array_length if after is not None else False), + hasNextPage=end_offset < upper_bound + if isinstance(first, int) + else (0 <= before_offset < array_length if before is not None else False), ), ) diff --git a/tests/connection/test_array_connection.py b/tests/connection/test_array_connection.py index 33c89ee..db6e5f6 100644 --- a/tests/connection/test_array_connection.py +++ b/tests/connection/test_array_connection.py @@ -111,7 +111,8 @@ def respects_first_and_after(): pageInfo=PageInfo( startCursor=cursor_c, endCursor=cursor_d, - hasPreviousPage=False, + # `after` was given, so a previous page necessarily exists. + hasPreviousPage=True, hasNextPage=True, ), ) @@ -123,7 +124,8 @@ def respects_first_and_after_with_long_first(): pageInfo=PageInfo( startCursor=cursor_c, endCursor=cursor_e, - hasPreviousPage=False, + # `after` was given, so a previous page necessarily exists. + hasPreviousPage=True, hasNextPage=False, ), ) @@ -136,7 +138,8 @@ def respects_last_and_before(): startCursor=cursor_b, endCursor=cursor_c, hasPreviousPage=True, - hasNextPage=False, + # `before` was given, so a next page necessarily exists. + hasNextPage=True, ), ) @@ -148,7 +151,8 @@ def respects_last_and_before_with_long_last(): startCursor=cursor_a, endCursor=cursor_c, hasPreviousPage=False, - hasNextPage=False, + # `before` was given, so a next page necessarily exists. + hasNextPage=True, ), ) @@ -162,7 +166,8 @@ def respects_first_and_after_and_before_too_few(): pageInfo=PageInfo( startCursor=cursor_b, endCursor=cursor_c, - hasPreviousPage=False, + # `after` was given, so a previous page necessarily exists. + hasPreviousPage=True, hasNextPage=True, ), ) @@ -177,7 +182,8 @@ def respects_first_and_after_and_before_too_many(): pageInfo=PageInfo( startCursor=cursor_b, endCursor=cursor_d, - hasPreviousPage=False, + # `after` was given, so a previous page necessarily exists. + hasPreviousPage=True, hasNextPage=False, ), ) @@ -192,7 +198,8 @@ def respects_first_and_after_and_before_exactly_right(): pageInfo=PageInfo( startCursor=cursor_b, endCursor=cursor_d, - hasPreviousPage=False, + # `after` was given, so a previous page necessarily exists. + hasPreviousPage=True, hasNextPage=False, ), ) @@ -208,7 +215,8 @@ def respects_last_and_after_and_before_too_few(): startCursor=cursor_c, endCursor=cursor_d, hasPreviousPage=True, - hasNextPage=False, + # `before` was given, so a next page necessarily exists. + hasNextPage=True, ), ) @@ -223,7 +231,8 @@ def respects_last_and_after_and_before_too_many(): startCursor=cursor_b, endCursor=cursor_d, hasPreviousPage=False, - hasNextPage=False, + # `before` was given, so a next page necessarily exists. + hasNextPage=True, ), ) @@ -238,10 +247,49 @@ def respects_last_and_after_and_before_exactly_right(): startCursor=cursor_b, endCursor=cursor_d, hasPreviousPage=False, - hasNextPage=False, + # `before` was given, so a next page necessarily exists. + hasNextPage=True, ), ) + def describe_has_previous_next_page_regression(): + """Regression tests for pageInfo.hasPreviousPage/hasNextPage being + gated solely on `last`/`first` and ignoring `after`/`before`.""" + + array_0_to_19 = list(range(20)) + + def has_previous_page_true_on_second_page_of_forward_pagination(): + first_page = connection_from_array(array_0_to_19, dict(first=5)) + assert first_page.pageInfo.hasPreviousPage is False + + second_page = connection_from_array( + array_0_to_19, + dict(first=5, after=first_page.pageInfo.endCursor), + ) + # An `after` cursor was explicitly given, guaranteeing a + # previous page exists, even though `last` was never passed. + assert second_page.pageInfo.hasPreviousPage is True + + def has_previous_page_false_on_first_page_of_forward_pagination(): + first_page = connection_from_array(array_0_to_19, dict(first=5)) + assert first_page.pageInfo.hasPreviousPage is False + + def has_next_page_true_on_penultimate_page_of_backward_pagination(): + last_page = connection_from_array(array_0_to_19, dict(last=5)) + assert last_page.pageInfo.hasNextPage is False + + previous_page = connection_from_array( + array_0_to_19, + dict(last=5, before=last_page.pageInfo.startCursor), + ) + # A `before` cursor was explicitly given, guaranteeing a next + # page exists, even though `first` was never passed. + assert previous_page.pageInfo.hasNextPage is True + + def has_next_page_false_on_last_page_of_backward_pagination(): + last_page = connection_from_array(array_0_to_19, dict(last=5)) + assert last_page.pageInfo.hasNextPage is False + def describe_cursor_edge_cases(): def throws_an_error_if_first_smaller_than_zero(): with raises(ValueError) as exc_info: @@ -317,8 +365,12 @@ def returns_no_elements_if_cursors_cross(): pageInfo=PageInfo( startCursor=None, endCursor=None, - hasPreviousPage=False, - hasNextPage=False, + # Both `after` and `before` point to valid, existing + # elements, so elements exist both before `after` and + # after `before`, even though the crossed cursors leave + # no edges in the returned slice itself. + hasPreviousPage=True, + hasNextPage=True, ), ) @@ -483,7 +535,9 @@ def works_with_a_just_right_array_slice(): pageInfo=PageInfo( startCursor=cursor_b, endCursor=cursor_c, - hasPreviousPage=False, + # `after` points at an existing element (A), so a previous + # page necessarily exists. + hasPreviousPage=True, hasNextPage=True, ), ) @@ -500,7 +554,7 @@ def works_with_an_oversized_array_slice_left_side(): pageInfo=PageInfo( startCursor=cursor_b, endCursor=cursor_c, - hasPreviousPage=False, + hasPreviousPage=True, hasNextPage=True, ), ) @@ -517,7 +571,7 @@ def works_with_an_oversized_array_slice_right_side(): pageInfo=PageInfo( startCursor=cursor_c, endCursor=cursor_c, - hasPreviousPage=False, + hasPreviousPage=True, hasNextPage=True, ), ) @@ -534,7 +588,7 @@ def works_with_an_oversized_array_slice_both_sides(): pageInfo=PageInfo( startCursor=cursor_c, endCursor=cursor_c, - hasPreviousPage=False, + hasPreviousPage=True, hasNextPage=True, ), ) @@ -551,7 +605,7 @@ def works_with_an_undersized_array_slice_left_side(): pageInfo=PageInfo( startCursor=cursor_d, endCursor=cursor_e, - hasPreviousPage=False, + hasPreviousPage=True, hasNextPage=False, ), ) @@ -568,7 +622,7 @@ def works_with_an_undersized_array_slice_right_side(): pageInfo=PageInfo( startCursor=cursor_c, endCursor=cursor_d, - hasPreviousPage=False, + hasPreviousPage=True, hasNextPage=True, ), ) @@ -585,7 +639,7 @@ def works_with_an_undersized_array_slice_both_sides(): pageInfo=PageInfo( startCursor=cursor_d, endCursor=cursor_d, - hasPreviousPage=False, + hasPreviousPage=True, hasNextPage=True, ), )