From 39eff4c63c41a50e9831f0b1e741c79186a3d93c Mon Sep 17 00:00:00 2001 From: M Aswin Kishore <60577077+mak626@users.noreply.github.com> Date: Mon, 18 Dec 2023 18:52:50 +0530 Subject: [PATCH] feat: experimental offset-pagination Introduced AsyncMongoenginePaginationObjectType AsyncMongoenginePaginationField --- graphene_mongo/__init__.py | 11 +- graphene_mongo/experimental/__init__.py | 0 .../experimental/pagination/__init__.py | 7 + .../pagination/fields_pagination_async.py | 426 ++++++++++++++++++ .../pagination/graphene_/__init__.py | 3 + .../pagination/graphene_/relay_/__init__.py | 3 + .../pagination/graphene_/relay_/connection.py | 68 +++ .../pagination/types_pagination_async.py | 214 +++++++++ .../experimental/pagination/utils.py | 109 +++++ graphene_mongo/registry.py | 6 +- 10 files changed, 844 insertions(+), 3 deletions(-) create mode 100644 graphene_mongo/experimental/__init__.py create mode 100644 graphene_mongo/experimental/pagination/__init__.py create mode 100644 graphene_mongo/experimental/pagination/fields_pagination_async.py create mode 100644 graphene_mongo/experimental/pagination/graphene_/__init__.py create mode 100644 graphene_mongo/experimental/pagination/graphene_/relay_/__init__.py create mode 100644 graphene_mongo/experimental/pagination/graphene_/relay_/connection.py create mode 100644 graphene_mongo/experimental/pagination/types_pagination_async.py create mode 100644 graphene_mongo/experimental/pagination/utils.py diff --git a/graphene_mongo/__init__.py b/graphene_mongo/__init__.py index e1e64801..a66da746 100644 --- a/graphene_mongo/__init__.py +++ b/graphene_mongo/__init__.py @@ -1,9 +1,14 @@ from .fields import MongoengineConnectionField from .fields_async import AsyncMongoengineConnectionField - -from .types import MongoengineObjectType, MongoengineInputType, MongoengineInterfaceType +from .types import MongoengineInputType, MongoengineInterfaceType, MongoengineObjectType from .types_async import AsyncMongoengineObjectType +# Do not sort import +from .experimental.pagination import ( + AsyncMongoenginePaginationField, + AsyncMongoenginePaginationObjectType, +) + __version__ = "0.1.1" __all__ = [ @@ -14,4 +19,6 @@ "MongoengineInterfaceType", "MongoengineConnectionField", "AsyncMongoengineConnectionField", + "AsyncMongoenginePaginationObjectType", + "AsyncMongoenginePaginationField", ] diff --git a/graphene_mongo/experimental/__init__.py b/graphene_mongo/experimental/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/graphene_mongo/experimental/pagination/__init__.py b/graphene_mongo/experimental/pagination/__init__.py new file mode 100644 index 00000000..67edd965 --- /dev/null +++ b/graphene_mongo/experimental/pagination/__init__.py @@ -0,0 +1,7 @@ +from .fields_pagination_async import AsyncMongoenginePaginationField +from .types_pagination_async import AsyncMongoenginePaginationObjectType + +__all__ = [ + "AsyncMongoenginePaginationField", + "AsyncMongoenginePaginationObjectType", +] diff --git a/graphene_mongo/experimental/pagination/fields_pagination_async.py b/graphene_mongo/experimental/pagination/fields_pagination_async.py new file mode 100644 index 00000000..cd4e8ad1 --- /dev/null +++ b/graphene_mongo/experimental/pagination/fields_pagination_async.py @@ -0,0 +1,426 @@ +from __future__ import absolute_import + +from itertools import filterfalse +from typing import Coroutine + +import bson +import graphene +import mongoengine +import pymongo +from bson import DBRef, ObjectId +from graphene import ConnectionField, Context, Int +from graphene.utils.str_converters import to_snake_case +from graphql import GraphQLResolveInfo +from graphql_relay import cursor_to_offset, from_global_id +from mongoengine import QuerySet +from promise import Promise +from pymongo.errors import OperationFailure + +from .utils import connection_from_iterables, find_skip_and_limit, has_page_count +from ...fields_async import ( + AsyncMongoengineConnectionField, +) +from ...utils import get_query_fields, has_page_info, sync_to_async + +PYMONGO_VERSION = tuple(pymongo.version_tuple[:2]) + + +class AsyncMongoenginePaginationField(AsyncMongoengineConnectionField): + def __init__(self, type, *args, **kwargs): + kwargs.setdefault("offset", Int()) + kwargs.setdefault("limit", Int()) + super(AsyncMongoenginePaginationField, self).__init__(type, *args, **kwargs) + + @property + def type(self): + from .types_pagination_async import AsyncMongoenginePaginationObjectType + + _type = super(ConnectionField, self).type + assert issubclass( + _type, AsyncMongoenginePaginationObjectType + ), "AsyncMongoenginePaginationField only accepts AsyncMongoenginePaginationObjectType types" + assert _type._meta.connection, "The type {} doesn't have a connection".format( + _type.__name__ + ) + return _type._meta.connection + + async def default_resolver(self, _root, info, required_fields=None, resolved=None, **args): + if required_fields is None: + required_fields = list() + args = args or {} + for key, value in dict(args).items(): + if value is None: + del args[key] + if _root is not None and not resolved: + field_name = to_snake_case(info.field_name) + if not hasattr(_root, "_fields_ordered"): + if isinstance(getattr(_root, field_name, []), list): + args["pk__in"] = [r.id for r in getattr(_root, field_name, [])] + elif field_name in _root._fields_ordered and not ( + isinstance(_root._fields[field_name].field, mongoengine.EmbeddedDocumentField) + or isinstance( + _root._fields[field_name].field, + mongoengine.GenericEmbeddedDocumentField, + ) + ): + if getattr(_root, field_name, []) is not None: + args["pk__in"] = [r.id for r in getattr(_root, field_name, [])] + + _id = args.pop("id", None) + + if _id is not None: + args["pk"] = from_global_id(_id)[-1] + iterables = [] + list_length = 0 + skip = 0 + count = 0 + limit = None + reverse = False + first = args.pop("first", None) + after = args.pop("after", None) + if after: + after = cursor_to_offset(after) + last = args.pop("last", None) + before = args.pop("before", None) + if before: + before = cursor_to_offset(before) + requires_page_info = has_page_info(info) + has_next_page = False + + # Pagination Logic + page_offset = args.pop("offset", None) + page_limit = args.pop("limit", None) + + page_count = None + requires_page_count = has_page_count(info) + + if requires_page_count and page_offset is None: + raise ValueError("Page count requires offset pagination") + + if page_offset is not None: + if after or before: + raise ValueError("Offset pagination does not support cursor based paging") + if first: + raise ValueError("first argument not allowed in offset pagination") + if last: + raise ValueError("last argument not allowed in offset pagination") + if page_limit is None: + raise ValueError("limit argument is required in offset pagination") + # End of Pagination Logic + + if resolved is not None: + items = resolved + + if isinstance(items, QuerySet): + try: + if ( + last is not None and after is not None + ) or requires_page_count: # Pagination Logic + count = await sync_to_async(items.count)(with_limit_and_skip=False) + else: + count = None + except OperationFailure: + count = await sync_to_async(len)(items) + else: + count = len(items) + + skip, limit, reverse = find_skip_and_limit( + first=first, + last=last, + after=after, + before=before, + page_offset=page_offset, + page_limit=page_limit, + count=count, + ) + + if isinstance(items, QuerySet): + if limit: + _base_query: QuerySet = ( + await sync_to_async(items.order_by("-pk").skip)(skip) + if reverse + else await sync_to_async(items.skip)(skip) + ) + items = await sync_to_async(_base_query.limit)(limit) + has_next_page = ( + (await sync_to_async(len)(_base_query.skip(limit).only("id").limit(1)) != 0) + if requires_page_info + else False + ) + elif skip: + items = await sync_to_async(items.skip)(skip) + else: + if limit: + if reverse: + _base_query = items[::-1] + items = _base_query[skip : skip + limit] + else: + _base_query = items + items = items[skip : skip + limit] + has_next_page = ( + (skip + limit) < len(_base_query) if requires_page_info else False + ) + elif skip: + items = items[skip:] + iterables = await sync_to_async(list)(items) + list_length = len(iterables) + + elif callable(getattr(self.model, "objects", None)): + if ( + _root is None + or args + or isinstance(getattr(_root, field_name, []), AsyncMongoengineConnectionField) + ): + args_copy = args.copy() + for key in args.copy(): + if key not in self.model._fields_ordered: + args_copy.pop(key) + elif ( + isinstance(getattr(self.model, key), mongoengine.fields.ReferenceField) + or isinstance( + getattr(self.model, key), + mongoengine.fields.GenericReferenceField, + ) + or isinstance( + getattr(self.model, key), + mongoengine.fields.LazyReferenceField, + ) + or isinstance( + getattr(self.model, key), + mongoengine.fields.CachedReferenceField, + ) + ): + if not isinstance(args_copy[key], ObjectId): + _from_global_id = from_global_id(args_copy[key])[1] + if bson.objectid.ObjectId.is_valid(_from_global_id): + args_copy[key] = ObjectId(_from_global_id) + else: + args_copy[key] = _from_global_id + elif isinstance(getattr(self.model, key), mongoengine.fields.EnumField): + if getattr(args_copy[key], "value", None): + args_copy[key] = args_copy[key].value + + if PYMONGO_VERSION >= (3, 7): + count = await sync_to_async( + (mongoengine.get_db()[self.model._get_collection_name()]).count_documents + )(args_copy) + else: + count = await sync_to_async(self.model.objects(args_copy).count)() + if count != 0: + skip, limit, reverse = find_skip_and_limit( + first=first, + after=after, + last=last, + before=before, + page_offset=page_offset, + page_limit=page_limit, + count=count, + ) + iterables = self.get_queryset( + self.model, info, required_fields, skip, limit, reverse, **args + ) + iterables = await sync_to_async(list)(iterables) + list_length = len(iterables) + if isinstance(info, GraphQLResolveInfo): + if not info.context: + info = info._replace(context=Context()) + info.context.queryset = self.get_queryset( + self.model, info, required_fields, **args + ) + + elif "pk__in" in args and args["pk__in"]: + count = len(args["pk__in"]) + skip, limit, reverse = find_skip_and_limit( + first=first, + last=last, + after=after, + before=before, + page_offset=page_offset, + page_limit=page_limit, + count=count, + ) + if limit: + if reverse: + args["pk__in"] = args["pk__in"][::-1][skip : skip + limit] + else: + args["pk__in"] = args["pk__in"][skip : skip + limit] + elif skip: + args["pk__in"] = args["pk__in"][skip:] + iterables = self.get_queryset(self.model, info, required_fields, **args) + iterables = await sync_to_async(list)(iterables) + list_length = len(iterables) + if isinstance(info, GraphQLResolveInfo): + if not info.context: + info = info._replace(context=Context()) + info.context.queryset = self.get_queryset( + self.model, info, required_fields, **args + ) + + elif _root is not None: + field_name = to_snake_case(info.field_name) + items = getattr(_root, field_name, []) + count = len(items) + skip, limit, reverse = find_skip_and_limit( + first=first, + last=last, + after=after, + before=before, + page_offset=page_offset, + page_limit=page_limit, + count=count, + ) + if limit: + if reverse: + _base_query = items[::-1] + items = _base_query[skip : skip + limit] + else: + _base_query = items + items = items[skip : skip + limit] + has_next_page = (skip + limit) < len(_base_query) if requires_page_info else False + elif skip: + items = items[skip:] + iterables = items + iterables = await sync_to_async(list)(iterables) + list_length = len(iterables) + + if requires_page_info and count: + has_next_page = ( + True + if (0 if limit is None else limit) + (0 if skip is None else skip) < count + else False + ) + has_previous_page = True if requires_page_info and skip else False + + # Pagination Logic + if requires_page_count: + page_count = (count if count is not None else 0) // page_limit + # Pagination Logic End + + if reverse: + iterables = await sync_to_async(list)(iterables) + iterables.reverse() + skip = limit + + connection = connection_from_iterables( + edges=iterables, + start_offset=skip, + has_previous_page=has_previous_page, + has_next_page=has_next_page, + connection_type=self.type, + edge_type=self.type.Edge, + pageinfo_type=graphene.PageInfo, + page_count=page_count, # Pagination Logic + ) + connection.iterable = iterables + connection.list_length = list_length + return connection + + async def chained_resolver(self, resolver, is_partial, root, info, **args): + for key, value in dict(args).items(): + if value is None: + del args[key] + + required_fields = list() + + for field in self.required_fields: + if field in self.model._fields_ordered: + required_fields.append(field) + + for field in get_query_fields(info): + if to_snake_case(field) in self.model._fields_ordered: + required_fields.append(to_snake_case(field)) + + args_copy = args.copy() + + if not bool(args) or not is_partial: + if isinstance(self.model, mongoengine.Document) or isinstance( + self.model, mongoengine.base.metaclasses.TopLevelDocumentMetaclass + ): + connection_fields = [ + field + for field in self.fields + if isinstance( + self.fields[field], + AsyncMongoenginePaginationField, # Pagination Logic + ) + ] + + def filter_connection(x): + return any( + [ + connection_fields.__contains__(x), + self._type._meta.non_filter_fields.__contains__(x), + ] + ) + + filterable_args = tuple( + filterfalse(filter_connection, list(self.model._fields_ordered)) + ) + for arg_name, arg in args.copy().items(): + if arg_name not in filterable_args + tuple(self.filter_args.keys()): + args_copy.pop(arg_name) + if isinstance(info, GraphQLResolveInfo): + if not info.context: + info = info._replace(context=Context()) + info.context.queryset = self.get_queryset( + self.model, info, required_fields, **args_copy + ) + + # XXX: Filter nested args + resolved = resolver(root, info, **args) + if isinstance(resolved, Coroutine): + resolved = await resolved + if resolved is not None: + # if isinstance(resolved, Coroutine): + # resolved = await resolved + if isinstance(resolved, list): + if resolved == list(): + return resolved + elif not isinstance(resolved[0], DBRef): + return resolved + else: + return await self.default_resolver(root, info, required_fields, **args_copy) + elif isinstance(resolved, QuerySet): + args.update(resolved._query) + args_copy = args.copy() + for arg_name, arg in args.copy().items(): + if "." in arg_name or arg_name not in self.model._fields_ordered + ( + "first", + "last", + "before", + "after", + # Pagination Logic + "offset", + "limit", + # Pagination Logic End + ) + tuple(self.filter_args.keys()): + args_copy.pop(arg_name) + if arg_name == "_id" and isinstance(arg, dict): + operation = list(arg.keys())[0] + args_copy["pk" + operation.replace("$", "__")] = arg[operation] + if not isinstance(arg, ObjectId) and "." in arg_name: + if isinstance(arg, dict): + operation = list(arg.keys())[0] + args_copy[ + arg_name.replace(".", "__") + operation.replace("$", "__") + ] = arg[operation] + else: + args_copy[arg_name.replace(".", "__")] = arg + elif "." in arg_name and isinstance(arg, ObjectId): + args_copy[arg_name.replace(".", "__")] = arg + else: + operations = ["$lte", "$gte", "$ne", "$in"] + if isinstance(arg, dict) and any(op in arg for op in operations): + operation = list(arg.keys())[0] + args_copy[arg_name + operation.replace("$", "__")] = arg[operation] + del args_copy[arg_name] + + return await self.default_resolver( + root, info, required_fields, resolved=resolved, **args_copy + ) + elif isinstance(resolved, Promise): + return resolved.value + else: + return await resolved + + return await self.default_resolver(root, info, required_fields, **args) diff --git a/graphene_mongo/experimental/pagination/graphene_/__init__.py b/graphene_mongo/experimental/pagination/graphene_/__init__.py new file mode 100644 index 00000000..f6c49636 --- /dev/null +++ b/graphene_mongo/experimental/pagination/graphene_/__init__.py @@ -0,0 +1,3 @@ +from .relay_ import PageConnection + +__all__ = ["PageConnection"] diff --git a/graphene_mongo/experimental/pagination/graphene_/relay_/__init__.py b/graphene_mongo/experimental/pagination/graphene_/relay_/__init__.py new file mode 100644 index 00000000..7eb2d6b6 --- /dev/null +++ b/graphene_mongo/experimental/pagination/graphene_/relay_/__init__.py @@ -0,0 +1,3 @@ +from .connection import PageConnection + +__all__ = ["PageConnection"] diff --git a/graphene_mongo/experimental/pagination/graphene_/relay_/connection.py b/graphene_mongo/experimental/pagination/graphene_/relay_/connection.py new file mode 100644 index 00000000..52bddde5 --- /dev/null +++ b/graphene_mongo/experimental/pagination/graphene_/relay_/connection.py @@ -0,0 +1,68 @@ +import re + +from graphene.relay.connection import ( + ConnectionOptions, + Enum, + Field, + Int, + Interface, + List, + NonNull, + ObjectType, + PageInfo, + Scalar, + Union, + get_edge_class, +) + + +class PageConnection(ObjectType): + class Meta: + abstract = True + + @classmethod + def __init_subclass_with_meta__( + cls, node=None, name=None, strict_types=False, _meta=None, **options + ): + if not _meta: + _meta = ConnectionOptions(cls) + assert node, f"You have to provide a node in {cls.__name__}.Meta" + assert isinstance(node, NonNull) or issubclass( + node, (Scalar, Enum, ObjectType, Interface, Union, NonNull) + ), f'Received incompatible node "{node}" for Connection {cls.__name__}.' + + base_name = re.sub("Connection$", "", name or cls.__name__) or node._meta.name + if not name: + name = f"{base_name}Connection" + + options["name"] = name + + _meta.node = node + + if not _meta.fields: + _meta.fields = {} + + if "page_info" not in _meta.fields: + _meta.fields["page_info"] = Field( + PageInfo, + name="pageInfo", + required=True, + description="Pagination data for this connection.", + ) + + if "page_count" not in _meta.fields: + _meta.fields["page_count"] = Field( + Int, + required=True, + description="Page count data for this connection. This is a heavy computation, always call once only", + ) + + if "edges" not in _meta.fields: + edge_class = get_edge_class(cls, node, base_name, strict_types) # type: ignore + cls.Edge = edge_class + _meta.fields["edges"] = Field( + NonNull(List(NonNull(edge_class) if strict_types else edge_class)), + description="Contains the nodes in this connection.", + ) + + return super(PageConnection, cls).__init_subclass_with_meta__(_meta=_meta, **options) diff --git a/graphene_mongo/experimental/pagination/types_pagination_async.py b/graphene_mongo/experimental/pagination/types_pagination_async.py new file mode 100644 index 00000000..150bc936 --- /dev/null +++ b/graphene_mongo/experimental/pagination/types_pagination_async.py @@ -0,0 +1,214 @@ +import graphene +import mongoengine +from graphene import InputObjectType +from graphene.relay import Node +from graphene.types.interface import Interface, InterfaceOptions +from graphene.types.objecttype import ObjectType, ObjectTypeOptions +from graphene.types.utils import yank_fields_from_attrs +from graphene.utils.str_converters import to_snake_case + +from graphene_mongo.experimental.pagination.graphene_ import PageConnection +from graphene_mongo.registry import Registry, get_global_async_registry, get_inputs_async_registry +from graphene_mongo.types import construct_fields, construct_self_referenced_fields +from graphene_mongo.utils import ( + ExecutorEnum, + get_query_fields, + is_valid_mongoengine_model, + sync_to_async, +) +from .fields_pagination_async import AsyncMongoenginePaginationField + + +def create_graphene_generic_class_async(object_type, option_type): + class AsyncMongoenginePaginationGenericObjectTypeOptions(option_type): + model = None + registry = None # type: Registry + connection = None + filter_fields = () + non_required_fields = () + order_by = None + + class AsyncGrapheneMongoenginePaginationGenericType(object_type): + @classmethod + def __init_subclass_with_meta__( + cls, + model=None, + registry=None, + skip_registry=False, + only_fields=(), + required_fields=(), + exclude_fields=(), + non_required_fields=(), + filter_fields=None, + non_filter_fields=(), + connection=None, + connection_class=None, + use_connection=None, + connection_field_class=None, + interfaces=(), + _meta=None, + order_by=None, + **options, + ): + assert is_valid_mongoengine_model(model), ( + "The attribute model in {}.Meta must be a valid Mongoengine Model. " + 'Received "{}" instead.' + ).format(cls.__name__, type(model)) + + if not registry: + # input objects shall be registred in a separated registry + if issubclass(cls, InputObjectType): + registry = get_inputs_async_registry() + else: + registry = get_global_async_registry() + + assert isinstance(registry, Registry), ( + "The attribute registry in {}.Meta needs to be an instance of " + 'Registry({}), received "{}".' + ).format(object_type, cls.__name__, registry) + converted_fields, self_referenced = construct_fields( + model, + registry, + only_fields, + exclude_fields, + non_required_fields, + ExecutorEnum.ASYNC, + ) + mongoengine_fields = yank_fields_from_attrs(converted_fields, _as=graphene.Field) + if use_connection is None and interfaces: + use_connection = any((issubclass(interface, Node) for interface in interfaces)) + + if use_connection and not connection: + # We create the connection automatically + if not connection_class: + connection_class = PageConnection + + connection = connection_class.create_type( + "{}Connection".format(options.get("name") or cls.__name__), node=cls + ) + + if connection is not None: + assert issubclass(connection, PageConnection), ( + "The attribute connection in {}.Meta must be of type PageConnection. " + 'Received "{}" instead.' + ).format(cls.__name__, type(connection)) + + if connection_field_class is not None: + assert issubclass(connection_field_class, graphene.ConnectionField), ( + "The attribute connection_field_class in {}.Meta must be of type graphene.ConnectionField. " + 'Received "{}" instead.' + ).format(cls.__name__, type(connection_field_class)) + else: + connection_field_class = AsyncMongoenginePaginationField + + if _meta: + assert isinstance(_meta, AsyncMongoenginePaginationGenericObjectTypeOptions), ( + "_meta must be an instance of AsyncMongoenginePaginationGenericObjectTypeOptions, " + "received {}" + ).format(_meta.__class__) + else: + _meta = AsyncMongoenginePaginationGenericObjectTypeOptions(option_type) + + _meta.model = model + _meta.registry = registry + _meta.fields = mongoengine_fields + _meta.filter_fields = filter_fields + _meta.non_filter_fields = non_filter_fields + _meta.connection = connection + _meta.connection_field_class = connection_field_class + # Save them for later + _meta.only_fields = only_fields + _meta.required_fields = required_fields + _meta.exclude_fields = exclude_fields + _meta.non_required_fields = non_required_fields + _meta.order_by = order_by + + super(AsyncGrapheneMongoenginePaginationGenericType, cls).__init_subclass_with_meta__( + _meta=_meta, interfaces=interfaces, **options + ) + + if not skip_registry: + registry.register(cls) + # Notes: Take care list of self-reference fields. + converted_fields = construct_self_referenced_fields( + self_referenced, registry, ExecutorEnum.ASYNC + ) + if converted_fields: + mongoengine_fields = yank_fields_from_attrs( + converted_fields, _as=graphene.Field + ) + cls._meta.fields.update(mongoengine_fields) + registry.register(cls) + + @classmethod + def rescan_fields(cls): + """Attempts to rescan fields and will insert any not converted initially""" + + converted_fields, self_referenced = construct_fields( + cls._meta.model, + cls._meta.registry, + cls._meta.only_fields, + cls._meta.exclude_fields, + cls._meta.non_required_fields, + ExecutorEnum.ASYNC, + ) + + mongoengine_fields = yank_fields_from_attrs(converted_fields, _as=graphene.Field) + + # The initial scan should take precedence + for field in mongoengine_fields: + if field not in cls._meta.fields: + cls._meta.fields.update({field: mongoengine_fields[field]}) + # Self-referenced fields can't change between scans! + + @classmethod + def is_type_of(cls, root, info): + if isinstance(root, cls): + return True + # XXX: Take care FileField + if isinstance(root, mongoengine.GridFSProxy): + return True + if not is_valid_mongoengine_model(type(root)): + raise Exception(('Received incompatible instance "{}".').format(root)) + return isinstance(root, cls._meta.model) + + @classmethod + async def get_node(cls, info, id): + required_fields = list() + for field in cls._meta.required_fields: + if field in cls._meta.model._fields_ordered: + required_fields.append(field) + queried_fields = get_query_fields(info) + if cls._meta.name in queried_fields: + queried_fields = queried_fields[cls._meta.name] + for field in queried_fields: + if to_snake_case(field) in cls._meta.model._fields_ordered: + required_fields.append(to_snake_case(field)) + required_fields = list(set(required_fields)) + return await sync_to_async( + cls._meta.model.objects.no_dereference().only(*required_fields).get + )(pk=id) + + def resolve_id(self, info): + return str(self.id) + + return ( + AsyncGrapheneMongoenginePaginationGenericType, + AsyncMongoenginePaginationGenericObjectTypeOptions, + ) + + +( + AsyncMongoenginePaginationObjectType, + AsyncMongoenginePaginationObjectTypeOptions, +) = create_graphene_generic_class_async(ObjectType, ObjectTypeOptions) + +( + AsyncMongoenginePaginationInterfaceType, + MongoengineInterfacePaginationTypeOptions, +) = create_graphene_generic_class_async(Interface, InterfaceOptions) + +AsyncGrapheneMongoenginePaginationObjectTypes = ( + AsyncMongoenginePaginationObjectType, + AsyncMongoenginePaginationInterfaceType, +) diff --git a/graphene_mongo/experimental/pagination/utils.py b/graphene_mongo/experimental/pagination/utils.py new file mode 100644 index 00000000..c36bb12c --- /dev/null +++ b/graphene_mongo/experimental/pagination/utils.py @@ -0,0 +1,109 @@ +from graphql_relay import offset_to_cursor + +from ...utils import ast_to_dict, collect_query_fields + + +def find_skip_and_limit(first, last, after, before, page_offset, page_limit, count=None): + reverse = False + skip = 0 + limit = None + + # Pagination Logic + if page_offset is not None and page_limit is not None: + skip = page_offset * page_limit + limit = page_limit + return skip, limit, reverse + # End of Pagination Logic + + if first is not None and after is not None: + skip = after + 1 + limit = first + elif first is not None and before is not None: + if first >= before: + limit = before - 1 + else: + limit = first + elif first is not None: + skip = 0 + limit = first + elif last is not None and before is not None: + reverse = False + if last >= before: + limit = before + else: + limit = last + skip = before - last + elif last is not None and after is not None: + if not count: + raise ValueError("Count Missing") + reverse = True + if last + after < count: + limit = last + else: + limit = count - after - 1 + elif last is not None: + skip = 0 + limit = last + reverse = True + elif after is not None: + skip = after + 1 + elif before is not None: + limit = before + return skip, limit, reverse + + +def connection_from_iterables( + edges, + start_offset, + has_previous_page, + has_next_page, + connection_type, + edge_type, + pageinfo_type, + page_count, +): + edges_items = [ + edge_type( + node=node, + cursor=offset_to_cursor((0 if start_offset is None else start_offset) + i), + ) + for i, node in enumerate(edges) + ] + + first_edge_cursor = edges_items[0].cursor if edges_items else None + last_edge_cursor = edges_items[-1].cursor if edges_items else None + + return connection_type( + edges=edges_items, + page_info=pageinfo_type( + start_cursor=first_edge_cursor, + end_cursor=last_edge_cursor, + has_previous_page=has_previous_page, + has_next_page=has_next_page, + ), + page_count=page_count, + ) + + +def has_page_count(info): + """A convenience function to call collect_query_fields with info + for retrieving if page_count details are required + + Args: + info (ResolveInfo) + + Returns: + bool: True if it received pageCount + """ + + fragments = {} + if not info: + return True # Returning True if invalid info is provided + node = ast_to_dict(info.field_nodes[0]) + variables = info.variable_values + + for name, value in info.fragments.items(): + fragments[name] = ast_to_dict(value) + + query = collect_query_fields(node, fragments, variables) + return next((True for x in query.keys() if x.lower() == "pagecount"), False) diff --git a/graphene_mongo/registry.py b/graphene_mongo/registry.py index 70e88480..f8d627e0 100644 --- a/graphene_mongo/registry.py +++ b/graphene_mongo/registry.py @@ -14,11 +14,15 @@ def __init__(self): def register(self, cls): from .types import GrapheneMongoengineObjectTypes from .types_async import AsyncGrapheneMongoengineObjectTypes + from .experimental.pagination.types_pagination_async import ( + AsyncGrapheneMongoenginePaginationObjectTypes, + ) assert ( issubclass(cls, GrapheneMongoengineObjectTypes) or issubclass(cls, AsyncGrapheneMongoengineObjectTypes) - ), 'Only Mongoengine/Async Mongoengine object types can be registered, received "{}"'.format( + or issubclass(cls, AsyncGrapheneMongoenginePaginationObjectTypes) + ), 'Only Mongoengine/Async Mongoengine/Async Mongoengine Pagination object types can be registered, received "{}"'.format( cls.__name__ ) assert cls._meta.registry == self, "Registry for a Model have to match."