Add offset pagination (#1013)

* Add offset filtering

* Formatting

Co-authored-by: Thomas Leonard <thomas@loftorbital.com>
This commit is contained in:
Thomas Leonard 2020-10-26 17:09:21 +01:00 committed by GitHub
parent 8408c51bf9
commit 2140be5e6a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 165 additions and 5 deletions

View File

@ -4,11 +4,13 @@ import six
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from graphql_relay.connection.arrayconnection import ( from graphql_relay.connection.arrayconnection import (
connection_from_list_slice, connection_from_list_slice,
cursor_to_offset,
get_offset_with_default, get_offset_with_default,
offset_to_cursor,
) )
from promise import Promise from promise import Promise
from graphene import NonNull from graphene import Int, NonNull
from graphene.relay import ConnectionField, PageInfo from graphene.relay import ConnectionField, PageInfo
from graphene.types import Field, List from graphene.types import Field, List
@ -81,6 +83,7 @@ class DjangoConnectionField(ConnectionField):
"enforce_first_or_last", "enforce_first_or_last",
graphene_settings.RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST, graphene_settings.RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST,
) )
kwargs.setdefault("offset", Int())
super(DjangoConnectionField, self).__init__(*args, **kwargs) super(DjangoConnectionField, self).__init__(*args, **kwargs)
@property @property
@ -131,6 +134,15 @@ class DjangoConnectionField(ConnectionField):
@classmethod @classmethod
def resolve_connection(cls, connection, args, iterable, max_limit=None): def resolve_connection(cls, connection, args, iterable, max_limit=None):
# Remove the offset parameter and convert it to an after cursor.
offset = args.pop("offset", None)
after = args.get("after")
if offset:
if after:
offset += cursor_to_offset(after) + 1
# input offset starts at 1 while the graphene offset starts at 0
args["after"] = offset_to_cursor(offset - 1)
iterable = maybe_queryset(iterable) iterable = maybe_queryset(iterable)
if isinstance(iterable, QuerySet): if isinstance(iterable, QuerySet):
@ -181,6 +193,8 @@ class DjangoConnectionField(ConnectionField):
): ):
first = args.get("first") first = args.get("first")
last = args.get("last") last = args.get("last")
offset = args.get("offset")
before = args.get("before")
if enforce_first_or_last: if enforce_first_or_last:
assert first or last, ( assert first or last, (
@ -200,6 +214,11 @@ class DjangoConnectionField(ConnectionField):
).format(last, info.field_name, max_limit) ).format(last, info.field_name, max_limit)
args["last"] = min(last, max_limit) args["last"] = min(last, max_limit)
if offset is not None:
assert before is None, (
"You can't provide a `before` value at the same time as an `offset` value to properly paginate the `{}` connection."
).format(info.field_name)
# eventually leads to DjangoObjectType's get_queryset (accepts queryset) # eventually leads to DjangoObjectType's get_queryset (accepts queryset)
# or a resolve_foo (does not accept queryset) # or a resolve_foo (does not accept queryset)
iterable = resolver(root, info, **args) iterable = resolver(root, info, **args)

View File

@ -59,7 +59,7 @@ def get_args(field):
def assert_arguments(field, *arguments): def assert_arguments(field, *arguments):
ignore = ("after", "before", "first", "last", "order_by") ignore = ("offset", "after", "before", "first", "last", "order_by")
args = get_args(field) args = get_args(field)
actual = [name for name in args if name not in ignore and not name.startswith("_")] actual = [name for name in args if name not in ignore and not name.startswith("_")]
assert set(arguments) == set( assert set(arguments) == set(
@ -945,7 +945,7 @@ def test_integer_field_filter_type():
} }
type Query { type Query {
pets(before: String, after: String, first: Int, last: Int, age: Int): PetTypeConnection pets(offset: Int, before: String, after: String, first: Int, last: Int, age: Int): PetTypeConnection
} }
""" """
) )
@ -997,7 +997,7 @@ def test_other_filter_types():
} }
type Query { type Query {
pets(before: String, after: String, first: Int, last: Int, age: Int, age_Isnull: Boolean, age_Lt: Int): PetTypeConnection pets(offset: Int, before: String, after: String, first: Int, last: Int, age: Int, age_Isnull: Boolean, age_Lt: Int): PetTypeConnection
} }
""" """
) )

View File

@ -1314,3 +1314,144 @@ def test_should_preserve_annotations():
} }
} }
assert result.data == expected, str(result.data) assert result.data == expected, str(result.data)
def test_connection_should_enable_offset_filtering():
Reporter.objects.create(first_name="John", last_name="Doe")
Reporter.objects.create(first_name="Some", last_name="Guy")
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
interfaces = (Node,)
class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType)
schema = graphene.Schema(query=Query)
query = """
query {
allReporters(first: 1, offset: 1) {
edges {
node {
firstName
lastName
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
expected = {
"allReporters": {"edges": [{"node": {"firstName": "Some", "lastName": "Guy"}},]}
}
assert result.data == expected
def test_connection_should_enable_offset_filtering_higher_than_max_limit(
graphene_settings,
):
graphene_settings.RELAY_CONNECTION_MAX_LIMIT = 2
Reporter.objects.create(first_name="John", last_name="Doe")
Reporter.objects.create(first_name="Some", last_name="Guy")
Reporter.objects.create(first_name="Jane", last_name="Roe")
Reporter.objects.create(first_name="Some", last_name="Lady")
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
interfaces = (Node,)
class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType)
schema = graphene.Schema(query=Query)
query = """
query {
allReporters(first: 1, offset: 3) {
edges {
node {
firstName
lastName
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
expected = {
"allReporters": {
"edges": [{"node": {"firstName": "Some", "lastName": "Lady"}},]
}
}
assert result.data == expected
def test_connection_should_forbid_offset_filtering_with_before():
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
interfaces = (Node,)
class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType)
schema = graphene.Schema(query=Query)
query = """
query ReporterPromiseConnectionQuery ($before: String) {
allReporters(first: 1, before: $before, offset: 1) {
edges {
node {
firstName
lastName
}
}
}
}
"""
before = base64.b64encode(b"arrayconnection:2").decode()
result = schema.execute(query, variable_values=dict(before=before))
expected_error = "You can't provide a `before` value at the same time as an `offset` value to properly paginate the `allReporters` connection."
assert len(result.errors) == 1
assert result.errors[0].message == expected_error
def test_connection_should_allow_offset_filtering_with_after():
Reporter.objects.create(first_name="John", last_name="Doe")
Reporter.objects.create(first_name="Some", last_name="Guy")
Reporter.objects.create(first_name="Jane", last_name="Roe")
Reporter.objects.create(first_name="Some", last_name="Lady")
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
interfaces = (Node,)
class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType)
schema = graphene.Schema(query=Query)
query = """
query ReporterPromiseConnectionQuery ($after: String) {
allReporters(first: 1, after: $after, offset: 1) {
edges {
node {
firstName
lastName
}
}
}
}
"""
after = base64.b64encode(b"arrayconnection:0").decode()
result = schema.execute(query, variable_values=dict(after=after))
assert not result.errors
expected = {
"allReporters": {"edges": [{"node": {"firstName": "Jane", "lastName": "Roe"}},]}
}
assert result.data == expected

View File

@ -172,7 +172,7 @@ type Reporter {
pets: [Reporter!]! pets: [Reporter!]!
aChoice: ReporterAChoice aChoice: ReporterAChoice
reporterType: ReporterReporterType reporterType: ReporterReporterType
articles(before: String, after: String, first: Int, last: Int): ArticleConnection! articles(offset: Int, before: String, after: String, first: Int, last: Int): ArticleConnection!
} }
enum ReporterAChoice { enum ReporterAChoice {