Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix hasPreviousPage #481

Merged
merged 1 commit into from
Jun 3, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion caluma/core/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@

from .forms import GlobalIDFormField, GlobalIDMultipleChoiceField
from .relay import extract_global_id
from .types import DjangoConnectionField


class GlobalIDFilter(Filter):
Expand Down Expand Up @@ -414,7 +415,9 @@ class MetaFilterSet(FilterSet):
meta_value = MetaValueFilter(field_name="meta")


class DjangoFilterConnectionField(filter.DjangoFilterConnectionField):
class DjangoFilterConnectionField(
filter.DjangoFilterConnectionField, DjangoConnectionField
):
"""
Django connection filter field with object type get_queryset support.
Expand Down
94 changes: 94 additions & 0 deletions caluma/core/pagination.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
from graphql_relay.connection.arrayconnection import (
get_offset_with_default,
offset_to_cursor,
)
from graphql_relay.connection.connectiontypes import Connection, Edge, PageInfo


def connection_from_list(data, args=None, **kwargs):
"""
Replace graphql_relay.connection.arrayconnection.connection_from_list.
This can be removed, when (or better if)
https://github.com/graphql-python/graphql-relay-py/issues/12
is resolved.
A simple function that accepts an array and connection arguments, and returns
a connection object for use in GraphQL. It uses array offsets as pagination,
so pagination will only work if the array is static.
"""
_len = len(data)
return connection_from_list_slice(
data, args, slice_start=0, list_length=_len, list_slice_length=_len, **kwargs
)


def connection_from_list_slice(
list_slice,
args=None,
connection_type=None,
edge_type=None,
pageinfo_type=None,
slice_start=0,
list_length=0,
list_slice_length=None,
):
"""
Replace graphql_relay.connection.arrayconnection.connection_from_list_slice.
This can be removed, when (or better if)
https://github.com/graphql-python/graphql-relay-py/issues/12
is resolved.
Given a slice (subset) of an array, returns a connection object for use in
GraphQL.
This function is similar to `connectionFromArray`, but is intended for use
cases where you know the cardinality of the connection, consider it too large
to materialize the entire array, and instead wish pass in a slice of the
total result large enough to cover the range specified in `args`.
"""
connection_type = connection_type or Connection
edge_type = edge_type or Edge
pageinfo_type = pageinfo_type or PageInfo

args = args or {}

before = args.get("before")
after = args.get("after")
first = args.get("first")
last = args.get("last")
if list_slice_length is None: # pragma: no cover
list_slice_length = len(list_slice)
slice_end = slice_start + list_slice_length
before_offset = get_offset_with_default(before, list_length)
after_offset = get_offset_with_default(after, -1)

start_offset = max(slice_start - 1, after_offset, -1) + 1
end_offset = min(slice_end, before_offset, list_length)
if isinstance(first, int):
end_offset = min(end_offset, start_offset + first)
if isinstance(last, int):
start_offset = max(start_offset, end_offset - last)

# If supplied slice is too large, trim it down before mapping over it.
_slice = list_slice[
max(start_offset - slice_start, 0) : list_slice_length
- (slice_end - end_offset)
]
edges = [
edge_type(node=node, cursor=offset_to_cursor(start_offset + i))
for i, node in enumerate(_slice)
]

first_edge_cursor = edges[0].cursor if edges else None
last_edge_cursor = edges[-1].cursor if edges else None

return connection_type(
edges=edges,
page_info=pageinfo_type(
start_cursor=first_edge_cursor,
end_cursor=last_edge_cursor,
has_previous_page=start_offset > 0,
has_next_page=end_offset < list_length,
),
)
51 changes: 51 additions & 0 deletions caluma/core/tests/test_pagination.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import pytest


def test_offset_pagination(db, schema_executor, document_factory):
document_factory(meta={"position": 0})
document_factory(meta={"position": 1})
Expand Down Expand Up @@ -26,3 +29,51 @@ def test_offset_pagination(db, schema_executor, document_factory):
assert result.data["allDocuments"]["totalCount"] == 2
assert result.data["allDocuments"]["edges"][0]["node"]["meta"]["position"] == 2
assert result.data["allDocuments"]["edges"][1]["node"]["meta"]["position"] == 3


@pytest.mark.parametrize(
"first,last,before,after,has_next,has_previous",
[
(1, None, None, None, True, False),
(None, 1, None, None, False, True),
(None, None, None, None, False, False),
(None, None, None, "YXJyYXljb25uZWN0aW9uOjI=", False, True),
(None, None, "YXJyYXljb25uZWN0aW9uOjI=", None, True, False),
],
)
def test_has_next_previous(
db,
first,
last,
before,
after,
has_next,
has_previous,
schema_executor,
document_factory,
):
document_factory.create_batch(5)

query = """
query AllDocumentsQuery ($first: Int, $last: Int, $before: String, $after: String) {
allDocuments(first: $first, last: $last, before: $before, after: $after) {
pageInfo {
hasNextPage
hasPreviousPage
}
edges {
node {
id
}
}
}
}
"""

inp = {"first": first, "last": last, "before": before, "after": after}

result = schema_executor(query, variables=inp)

assert not result.errors
assert result.data["allDocuments"]["pageInfo"]["hasNextPage"] == has_next
assert result.data["allDocuments"]["pageInfo"]["hasPreviousPage"] == has_previous
73 changes: 73 additions & 0 deletions caluma/core/types.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
from collections import Iterable

import graphene
from django.core.exceptions import ImproperlyConfigured
from django.db.models.query import QuerySet
from graphene.relay import PageInfo
from graphene.relay.connection import ConnectionField
from graphene_django import types
from graphene_django.fields import DjangoConnectionField
from graphene_django.utils import maybe_queryset

from .pagination import connection_from_list, connection_from_list_slice


class Node(object):
Expand Down Expand Up @@ -54,3 +62,68 @@ def resolve_total_count(self, info, **kwargs):
if isinstance(self.iterable, QuerySet):
return self.iterable.count()
return len(self.iterable)


class DjangoConnectionField(DjangoConnectionField):
"""
Custom DjangoConnectionField with fix for hasNextPage/hasPreviousPage.

This can be removed, when (or better if)
https://github.com/graphql-python/graphql-relay-py/issues/12
is resolved.
"""

@classmethod
def resolve_connection(cls, connection, default_manager, args, iterable):
if iterable is None:
iterable = default_manager
iterable = maybe_queryset(iterable)
if isinstance(iterable, QuerySet):
if iterable is not default_manager:
default_queryset = maybe_queryset(default_manager)
iterable = cls.merge_querysets(default_queryset, iterable)
_len = iterable.count()
else: # pragma: no cover
_len = len(iterable)
connection = connection_from_list_slice(
iterable,
args,
slice_start=0,
list_length=_len,
list_slice_length=_len,
connection_type=connection,
edge_type=connection.Edge,
pageinfo_type=PageInfo,
)
connection.iterable = iterable
connection.length = _len
return connection


class ConnectionField(ConnectionField):
"""
Custom ConnectionField with fix for hasNextPage/hasPreviousPage.

This can be removed, when (or better if)
https://github.com/graphql-python/graphql-relay-py/issues/12
is resolved.
"""

@classmethod
def resolve_connection(cls, connection_type, args, resolved):
if isinstance(resolved, connection_type): # pragma: no cover
return resolved

assert isinstance(resolved, Iterable), (
"Resolved value from the connection field have to be iterable or instance of {0}. "
'Received "{1}"'
).format(connection_type, resolved)
connection = connection_from_list(
resolved,
args,
connection_type=connection_type,
edge_type=connection_type.Edge,
pageinfo_type=PageInfo,
)
connection.iterable = resolved
return connection
13 changes: 9 additions & 4 deletions caluma/form/schema.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import graphene
from graphene import ConnectionField, relay
from graphene import relay
from graphene.types import ObjectType, generic
from graphene_django.rest_framework import serializer_converter

from ..core.filters import DjangoFilterConnectionField, DjangoFilterSetConnectionField
from ..core.mutation import Mutation, UserDefinedPrimaryKeyMixin
from ..core.relay import extract_global_id
from ..core.types import CountableConnectionBase, DjangoObjectType, Node
from ..core.types import (
ConnectionField,
CountableConnectionBase,
DjangoObjectType,
Node,
)
from ..data_source.data_source_handlers import get_data_source_data
from ..data_source.schema import DataSourceDataConnection
from . import filters, models, serializers
Expand Down Expand Up @@ -168,7 +173,7 @@ class Meta:
class TextQuestion(QuestionQuerysetMixin, FormDjangoObjectType):
max_length = graphene.Int()
placeholder = graphene.String()
format_validators = graphene.ConnectionField(FormatValidatorConnection)
format_validators = ConnectionField(FormatValidatorConnection)

def resolve_format_validators(self, info):
return get_format_validators(include=self.format_validators)
Expand All @@ -192,7 +197,7 @@ class Meta:
class TextareaQuestion(QuestionQuerysetMixin, FormDjangoObjectType):
max_length = graphene.Int()
placeholder = graphene.String()
format_validators = graphene.ConnectionField(FormatValidatorConnection)
format_validators = ConnectionField(FormatValidatorConnection)

def resolve_format_validators(self, info):
return get_format_validators(include=self.format_validators)
Expand Down