Skip to content

Commit e1dfc6f

Browse files
committed
Fix hasPreviousPage
This commit implements a fix for graphql-python/graphql-relay-py#12 The project `graphql-relay-py` seems unmaintained, so there is little hope that [this PR](graphql-python/graphql-relay-py#14) gets merged any time soon. Closes projectcaluma#469
1 parent cb24090 commit e1dfc6f

File tree

5 files changed

+231
-5
lines changed

5 files changed

+231
-5
lines changed

caluma/core/filters.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636

3737
from .forms import GlobalIDFormField, GlobalIDMultipleChoiceField
3838
from .relay import extract_global_id
39+
from .types import DjangoConnectionField
3940

4041

4142
class GlobalIDFilter(Filter):
@@ -414,7 +415,9 @@ class MetaFilterSet(FilterSet):
414415
meta_value = MetaValueFilter(field_name="meta")
415416

416417

417-
class DjangoFilterConnectionField(filter.DjangoFilterConnectionField):
418+
class DjangoFilterConnectionField(
419+
filter.DjangoFilterConnectionField, DjangoConnectionField
420+
):
418421
"""
419422
Django connection filter field with object type get_queryset support.
420423

caluma/core/pagination.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
from graphql_relay.connection.arrayconnection import (
2+
get_offset_with_default,
3+
offset_to_cursor,
4+
)
5+
from graphql_relay.connection.connectiontypes import Connection, Edge, PageInfo
6+
7+
8+
def connection_from_list(data, args=None, **kwargs):
9+
"""
10+
Replace graphql_relay.connection.arrayconnection.connection_from_list.
11+
12+
This can be removed, when (or better if)
13+
https://github.com/graphql-python/graphql-relay-py/issues/12
14+
is resolved.
15+
16+
A simple function that accepts an array and connection arguments, and returns
17+
a connection object for use in GraphQL. It uses array offsets as pagination,
18+
so pagination will only work if the array is static.
19+
"""
20+
_len = len(data)
21+
return connection_from_list_slice(
22+
data, args, slice_start=0, list_length=_len, list_slice_length=_len, **kwargs
23+
)
24+
25+
26+
def connection_from_list_slice(
27+
list_slice,
28+
args=None,
29+
connection_type=None,
30+
edge_type=None,
31+
pageinfo_type=None,
32+
slice_start=0,
33+
list_length=0,
34+
list_slice_length=None,
35+
):
36+
"""
37+
Replace graphql_relay.connection.arrayconnection.connection_from_list_slice.
38+
39+
This can be removed, when (or better if)
40+
https://github.com/graphql-python/graphql-relay-py/issues/12
41+
is resolved.
42+
43+
Given a slice (subset) of an array, returns a connection object for use in
44+
GraphQL.
45+
This function is similar to `connectionFromArray`, but is intended for use
46+
cases where you know the cardinality of the connection, consider it too large
47+
to materialize the entire array, and instead wish pass in a slice of the
48+
total result large enough to cover the range specified in `args`.
49+
"""
50+
connection_type = connection_type or Connection
51+
edge_type = edge_type or Edge
52+
pageinfo_type = pageinfo_type or PageInfo
53+
54+
args = args or {}
55+
56+
before = args.get("before")
57+
after = args.get("after")
58+
first = args.get("first")
59+
last = args.get("last")
60+
if list_slice_length is None: # pragma: no cover
61+
list_slice_length = len(list_slice)
62+
slice_end = slice_start + list_slice_length
63+
before_offset = get_offset_with_default(before, list_length)
64+
after_offset = get_offset_with_default(after, -1)
65+
66+
start_offset = max(slice_start - 1, after_offset, -1) + 1
67+
end_offset = min(slice_end, before_offset, list_length)
68+
if isinstance(first, int):
69+
end_offset = min(end_offset, start_offset + first)
70+
if isinstance(last, int):
71+
start_offset = max(start_offset, end_offset - last)
72+
73+
# If supplied slice is too large, trim it down before mapping over it.
74+
_slice = list_slice[
75+
max(start_offset - slice_start, 0) : list_slice_length
76+
- (slice_end - end_offset)
77+
]
78+
edges = [
79+
edge_type(node=node, cursor=offset_to_cursor(start_offset + i))
80+
for i, node in enumerate(_slice)
81+
]
82+
83+
first_edge_cursor = edges[0].cursor if edges else None
84+
last_edge_cursor = edges[-1].cursor if edges else None
85+
86+
return connection_type(
87+
edges=edges,
88+
page_info=pageinfo_type(
89+
start_cursor=first_edge_cursor,
90+
end_cursor=last_edge_cursor,
91+
has_previous_page=start_offset > 0,
92+
has_next_page=end_offset < list_length,
93+
),
94+
)

caluma/core/tests/test_pagination.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import pytest
2+
3+
14
def test_offset_pagination(db, schema_executor, document_factory):
25
document_factory(meta={"position": 0})
36
document_factory(meta={"position": 1})
@@ -26,3 +29,51 @@ def test_offset_pagination(db, schema_executor, document_factory):
2629
assert result.data["allDocuments"]["totalCount"] == 2
2730
assert result.data["allDocuments"]["edges"][0]["node"]["meta"]["position"] == 2
2831
assert result.data["allDocuments"]["edges"][1]["node"]["meta"]["position"] == 3
32+
33+
34+
@pytest.mark.parametrize(
35+
"first,last,before,after,has_next,has_previous",
36+
[
37+
(1, None, None, None, True, False),
38+
(None, 1, None, None, False, True),
39+
(None, None, None, None, False, False),
40+
(None, None, None, "YXJyYXljb25uZWN0aW9uOjI=", False, True),
41+
(None, None, "YXJyYXljb25uZWN0aW9uOjI=", None, True, False),
42+
],
43+
)
44+
def test_has_next_previous(
45+
db,
46+
first,
47+
last,
48+
before,
49+
after,
50+
has_next,
51+
has_previous,
52+
schema_executor,
53+
document_factory,
54+
):
55+
document_factory.create_batch(5)
56+
57+
query = """
58+
query AllDocumentsQuery ($first: Int, $last: Int, $before: String, $after: String) {
59+
allDocuments(first: $first, last: $last, before: $before, after: $after) {
60+
pageInfo {
61+
hasNextPage
62+
hasPreviousPage
63+
}
64+
edges {
65+
node {
66+
id
67+
}
68+
}
69+
}
70+
}
71+
"""
72+
73+
inp = {"first": first, "last": last, "before": before, "after": after}
74+
75+
result = schema_executor(query, variables=inp)
76+
77+
assert not result.errors
78+
assert result.data["allDocuments"]["pageInfo"]["hasNextPage"] == has_next
79+
assert result.data["allDocuments"]["pageInfo"]["hasPreviousPage"] == has_previous

caluma/core/types.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
1+
from collections import Iterable
2+
13
import graphene
24
from django.core.exceptions import ImproperlyConfigured
35
from django.db.models.query import QuerySet
6+
from graphene.relay import PageInfo
7+
from graphene.relay.connection import ConnectionField
48
from graphene_django import types
9+
from graphene_django.fields import DjangoConnectionField
10+
from graphene_django.utils import maybe_queryset
11+
12+
from .pagination import connection_from_list, connection_from_list_slice
513

614

715
class Node(object):
@@ -54,3 +62,68 @@ def resolve_total_count(self, info, **kwargs):
5462
if isinstance(self.iterable, QuerySet):
5563
return self.iterable.count()
5664
return len(self.iterable)
65+
66+
67+
class DjangoConnectionField(DjangoConnectionField):
68+
"""
69+
Custom DjangoConnectionField with fix for hasNextPage/hasPreviousPage.
70+
71+
This can be removed, when (or better if)
72+
https://github.com/graphql-python/graphql-relay-py/issues/12
73+
is resolved.
74+
"""
75+
76+
@classmethod
77+
def resolve_connection(cls, connection, default_manager, args, iterable):
78+
if iterable is None:
79+
iterable = default_manager
80+
iterable = maybe_queryset(iterable)
81+
if isinstance(iterable, QuerySet):
82+
if iterable is not default_manager:
83+
default_queryset = maybe_queryset(default_manager)
84+
iterable = cls.merge_querysets(default_queryset, iterable)
85+
_len = iterable.count()
86+
else: # pragma: no cover
87+
_len = len(iterable)
88+
connection = connection_from_list_slice(
89+
iterable,
90+
args,
91+
slice_start=0,
92+
list_length=_len,
93+
list_slice_length=_len,
94+
connection_type=connection,
95+
edge_type=connection.Edge,
96+
pageinfo_type=PageInfo,
97+
)
98+
connection.iterable = iterable
99+
connection.length = _len
100+
return connection
101+
102+
103+
class ConnectionField(ConnectionField):
104+
"""
105+
Custom ConnectionField with fix for hasNextPage/hasPreviousPage.
106+
107+
This can be removed, when (or better if)
108+
https://github.com/graphql-python/graphql-relay-py/issues/12
109+
is resolved.
110+
"""
111+
112+
@classmethod
113+
def resolve_connection(cls, connection_type, args, resolved):
114+
if isinstance(resolved, connection_type): # pragma: no cover
115+
return resolved
116+
117+
assert isinstance(resolved, Iterable), (
118+
"Resolved value from the connection field have to be iterable or instance of {0}. "
119+
'Received "{1}"'
120+
).format(connection_type, resolved)
121+
connection = connection_from_list(
122+
resolved,
123+
args,
124+
connection_type=connection_type,
125+
edge_type=connection_type.Edge,
126+
pageinfo_type=PageInfo,
127+
)
128+
connection.iterable = resolved
129+
return connection

caluma/form/schema.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import graphene
2-
from graphene import ConnectionField, relay
2+
from graphene import relay
33
from graphene.types import ObjectType, generic
44
from graphene_django.rest_framework import serializer_converter
55

66
from ..core.filters import DjangoFilterConnectionField, DjangoFilterSetConnectionField
77
from ..core.mutation import Mutation, UserDefinedPrimaryKeyMixin
88
from ..core.relay import extract_global_id
9-
from ..core.types import CountableConnectionBase, DjangoObjectType, Node
9+
from ..core.types import (
10+
ConnectionField,
11+
CountableConnectionBase,
12+
DjangoObjectType,
13+
Node,
14+
)
1015
from ..data_source.data_source_handlers import get_data_source_data
1116
from ..data_source.schema import DataSourceDataConnection
1217
from . import filters, models, serializers
@@ -168,7 +173,7 @@ class Meta:
168173
class TextQuestion(QuestionQuerysetMixin, FormDjangoObjectType):
169174
max_length = graphene.Int()
170175
placeholder = graphene.String()
171-
format_validators = graphene.ConnectionField(FormatValidatorConnection)
176+
format_validators = ConnectionField(FormatValidatorConnection)
172177

173178
def resolve_format_validators(self, info):
174179
return get_format_validators(include=self.format_validators)
@@ -192,7 +197,7 @@ class Meta:
192197
class TextareaQuestion(QuestionQuerysetMixin, FormDjangoObjectType):
193198
max_length = graphene.Int()
194199
placeholder = graphene.String()
195-
format_validators = graphene.ConnectionField(FormatValidatorConnection)
200+
format_validators = ConnectionField(FormatValidatorConnection)
196201

197202
def resolve_format_validators(self, info):
198203
return get_format_validators(include=self.format_validators)

0 commit comments

Comments
 (0)