From 27aa7c893c40677846ed5d3e330d68359f2f8e1d Mon Sep 17 00:00:00 2001 From: Fahim Ali Zain Date: Wed, 10 Aug 2022 19:04:49 +0530 Subject: [PATCH 01/27] [WIP] feat: Experiments --- frappe_graphql/__init__.py | 21 +++++- .../frappe_graphql/subscription/doc_events.py | 2 +- frappe_graphql/graphql.py | 2 - frappe_graphql/utils/loader.py | 2 + frappe_graphql/utils/resolver/__init__.py | 75 ++++--------------- frappe_graphql/utils/resolver/__init__old.py | 70 +++++++++++++++++ ...t_resolver.py => document_resolver_old.py} | 0 frappe_graphql/utils/resolver/link_field.py | 43 +++++++++++ frappe_graphql/utils/resolver/root_query.py | 46 ++++++++++++ frappe_graphql/utils/subscriptions.py | 2 - 10 files changed, 195 insertions(+), 68 deletions(-) create mode 100644 frappe_graphql/utils/resolver/__init__old.py rename frappe_graphql/utils/resolver/{document_resolver.py => document_resolver_old.py} (100%) create mode 100644 frappe_graphql/utils/resolver/link_field.py create mode 100644 frappe_graphql/utils/resolver/root_query.py diff --git a/frappe_graphql/__init__.py b/frappe_graphql/__init__.py index e32f8a9..fb1e52c 100644 --- a/frappe_graphql/__init__.py +++ b/frappe_graphql/__init__.py @@ -1,7 +1,26 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -from .utils.loader import get_schema # noqa +import time + + +def profile_fn(fn): + from graphql import GraphQLResolveInfo + + def _inner(*args, **kwargs): + _v = fn.__name__ + if len(args) > 1 and isinstance(args[1], GraphQLResolveInfo): + _v += f" df: {args[1].field_name}" + + t = time.perf_counter() + v = fn(*args, **kwargs) + print(_v, f"{(time.perf_counter() - t) * 1000}ms") + return v + + return _inner + + from .utils.cursor_pagination import CursorPaginator # noqa +from .utils.loader import get_schema # noqa from .utils.exceptions import ERROR_CODED_EXCEPTIONS, GQLExecutionUserError, GQLExecutionUserErrorMultiple # noqa from .utils.roles import REQUIRE_ROLES # noqa from .utils.subscriptions import setup_subscription, get_consumers, notify_consumer, \ diff --git a/frappe_graphql/frappe_graphql/subscription/doc_events.py b/frappe_graphql/frappe_graphql/subscription/doc_events.py index 8758984..f9bf34c 100644 --- a/frappe_graphql/frappe_graphql/subscription/doc_events.py +++ b/frappe_graphql/frappe_graphql/subscription/doc_events.py @@ -1,7 +1,7 @@ import frappe from graphql import GraphQLSchema, GraphQLResolveInfo from frappe_graphql import setup_subscription, get_consumers, notify_consumers, get_schema -from frappe_graphql.utils.resolver import get_singular_doctype +from frappe_graphql.utils.resolver.utils import get_singular_doctype def bind(schema: GraphQLSchema): diff --git a/frappe_graphql/graphql.py b/frappe_graphql/graphql.py index f849e07..c308371 100644 --- a/frappe_graphql/graphql.py +++ b/frappe_graphql/graphql.py @@ -2,7 +2,6 @@ import graphql from frappe_graphql.utils.loader import get_schema -from frappe_graphql.utils.resolver import default_field_resolver @frappe.whitelist(allow_guest=True) @@ -12,7 +11,6 @@ def execute(query=None, variables=None, operation_name=None): source=query, variable_values=variables, operation_name=operation_name, - field_resolver=default_field_resolver, middleware=[frappe.get_attr(cmd) for cmd in frappe.get_hooks("graphql_middlewares")], context_value=frappe._dict() ) diff --git a/frappe_graphql/utils/loader.py b/frappe_graphql/utils/loader.py index b787247..1b4c1b0 100644 --- a/frappe_graphql/utils/loader.py +++ b/frappe_graphql/utils/loader.py @@ -6,6 +6,7 @@ from graphql import parse from graphql.error import GraphQLSyntaxError +from .resolver import setup_default_resolvers from .exceptions import GraphQLFileSyntaxError graphql_schemas = {} @@ -18,6 +19,7 @@ def get_schema(): return graphql_schemas.get(frappe.local.site) schema = graphql.build_schema(get_typedefs()) + setup_default_resolvers(schema=schema) execute_schema_processors(schema=schema) graphql_schemas[frappe.local.site] = schema diff --git a/frappe_graphql/utils/resolver/__init__.py b/frappe_graphql/utils/resolver/__init__.py index e981d15..c9ec6a6 100644 --- a/frappe_graphql/utils/resolver/__init__.py +++ b/frappe_graphql/utils/resolver/__init__.py @@ -1,69 +1,20 @@ -from typing import Any -from graphql import GraphQLObjectType, GraphQLResolveInfo +from graphql import GraphQLSchema -import frappe -from frappe.model.document import Document -from frappe.model.meta import is_single +from .root_query import setup_root_query_resolvers +from .link_field import setup_link_field_resolvers, setup_dynamic_link_field_resolvers -from frappe_graphql import CursorPaginator -from .document_resolver import document_resolver -from .utils import get_singular_doctype, get_plural_doctype +def setup_default_resolvers(schema: GraphQLSchema): + setup_root_query_resolvers(schema=schema) + setup_link_field_resolvers(schema=schema) + setup_dynamic_link_field_resolvers(schema=schema) + setup_select_field_resolvers(schema=schema) + setup_child_table_resolvers(schema=schema) -def default_field_resolver(obj: Any, info: GraphQLResolveInfo, **kwargs): - parent_type: GraphQLObjectType = info.parent_type - if not isinstance(info.parent_type, GraphQLObjectType): - frappe.throw("Invalid GraphQL") +def setup_select_field_resolvers(schema: GraphQLSchema): + pass - if parent_type.name == "Query": - # This section is executed on root query type fields - dt = get_singular_doctype(info.field_name) - if dt: - if is_single(dt): - kwargs["name"] = dt - elif not frappe.db.exists(dt, kwargs.get("name")): - raise frappe.DoesNotExistError( - frappe._("{0} {1} not found").format(frappe._(dt), kwargs.get("name"))) - return frappe._dict( - doctype=dt, - name=kwargs.get("name") - ) - plural_doctype = get_plural_doctype(info.field_name) - if plural_doctype: - frappe.has_permission(doctype=plural_doctype, throw=True) - return CursorPaginator(doctype=plural_doctype).resolve(obj, info, **kwargs) - - if not isinstance(obj, (dict, Document)): - return None - - should_resolve_from_doc = not not (obj.get("name") and ( - obj.get("doctype") or get_singular_doctype(parent_type.name))) - - # check if requested field can be resolved - # - default resolver for simple objects - # - these form the resolvers for - # "SET_VALUE_TYPE", "SAVE_DOC_TYPE", "DELETE_DOC_TYPE" mutations - if obj.get(info.field_name) is not None: - value = obj.get(info.field_name) - if isinstance(value, CursorPaginator): - return value.resolve(obj, info, **kwargs) - - if not should_resolve_from_doc: - return value - - if should_resolve_from_doc: - # this section is executed for Fields on DocType object types. - hooks_cmd = frappe.get_hooks("gql_default_document_resolver") - resolver = document_resolver - if len(hooks_cmd): - resolver = frappe.get_attr(hooks_cmd[-1]) - - return resolver( - obj=obj, - info=info, - **kwargs - ) - - return None +def setup_child_table_resolvers(schema: GraphQLSchema): + pass diff --git a/frappe_graphql/utils/resolver/__init__old.py b/frappe_graphql/utils/resolver/__init__old.py new file mode 100644 index 0000000..8cd1248 --- /dev/null +++ b/frappe_graphql/utils/resolver/__init__old.py @@ -0,0 +1,70 @@ +from typing import Any +from graphql import GraphQLObjectType, GraphQLResolveInfo + +import frappe +from frappe.model.document import Document +from frappe.model.meta import is_single + +from frappe_graphql import CursorPaginator, profile_fn +from .document_resolver_old import document_resolver +from .utils import get_singular_doctype, get_plural_doctype + + +@profile_fn +def default_field_resolver(obj: Any, info: GraphQLResolveInfo, **kwargs): + + parent_type: GraphQLObjectType = info.parent_type + if not isinstance(info.parent_type, GraphQLObjectType): + frappe.throw("Invalid GraphQL") + + if parent_type.name == "Query": + # This section is executed on root query type fields + dt = get_singular_doctype(info.field_name) + if dt: + if is_single(dt): + kwargs["name"] = dt + elif not frappe.db.exists(dt, kwargs.get("name")): + raise frappe.DoesNotExistError( + frappe._("{0} {1} not found").format(frappe._(dt), kwargs.get("name"))) + return frappe._dict( + doctype=dt, + name=kwargs.get("name") + ) + + plural_doctype = get_plural_doctype(info.field_name) + if plural_doctype: + frappe.has_permission(doctype=plural_doctype, throw=True) + return CursorPaginator(doctype=plural_doctype).resolve(obj, info, **kwargs) + + if not isinstance(obj, (dict, Document)): + return None + + should_resolve_from_doc = not not (obj.get("name") and ( + obj.get("doctype") or get_singular_doctype(parent_type.name))) + + # check if requested field can be resolved + # - default resolver for simple objects + # - these form the resolvers for + # "SET_VALUE_TYPE", "SAVE_DOC_TYPE", "DELETE_DOC_TYPE" mutations + if obj.get(info.field_name) is not None: + value = obj.get(info.field_name) + if isinstance(value, CursorPaginator): + return value.resolve(obj, info, **kwargs) + + if not should_resolve_from_doc: + return value + + if should_resolve_from_doc: + # this section is executed for Fields on DocType object types. + hooks_cmd = frappe.get_hooks("gql_default_document_resolver") + resolver = document_resolver + if len(hooks_cmd): + resolver = frappe.get_attr(hooks_cmd[-1]) + + return resolver( + obj=obj, + info=info, + **kwargs + ) + + return None diff --git a/frappe_graphql/utils/resolver/document_resolver.py b/frappe_graphql/utils/resolver/document_resolver_old.py similarity index 100% rename from frappe_graphql/utils/resolver/document_resolver.py rename to frappe_graphql/utils/resolver/document_resolver_old.py diff --git a/frappe_graphql/utils/resolver/link_field.py b/frappe_graphql/utils/resolver/link_field.py new file mode 100644 index 0000000..e824a8c --- /dev/null +++ b/frappe_graphql/utils/resolver/link_field.py @@ -0,0 +1,43 @@ +from graphql import GraphQLSchema, GraphQLResolveInfo + +import frappe + +from .utils import get_singular_doctype + + +def setup_link_field_resolvers(schema: GraphQLSchema): + """ + This will set up Link fields on DocTypes to resolve target docs + """ + for type_name, gql_type in schema.type_map.items(): + dt = get_singular_doctype(type_name) + if not dt: + continue + + meta = frappe.get_meta(dt) + for df in meta.get_link_fields() + meta.get_dynamic_link_fields(): + if df.fieldname not in gql_type.fields: + continue + gql_type.fields[df.fieldname].resolve = None + + _name_df = f"{df.fieldname}__name" + if _name_df not in gql_type.fields: + continue + + gql_type.fields[_name_df].resolve = _resolve_link_name_field + + +def setup_dynamic_link_field_resolvers(schema: GraphQLSchema): + """ + This will set up Link fields on DocTypes to resolve target docs + """ + pass + + +def _resolve_link_field(obj, info: GraphQLResolveInfo, **kwargs): + pass + + +def _resolve_link_name_field(obj, info: GraphQLResolveInfo, **kwargs): + df = info.field_name.split("__name")[0] + return obj.get(df) diff --git a/frappe_graphql/utils/resolver/root_query.py b/frappe_graphql/utils/resolver/root_query.py new file mode 100644 index 0000000..74deb4c --- /dev/null +++ b/frappe_graphql/utils/resolver/root_query.py @@ -0,0 +1,46 @@ +from graphql import GraphQLSchema, GraphQLResolveInfo + +import frappe +from frappe.model.meta import is_single + +from frappe_graphql import CursorPaginator +from .utils import get_singular_doctype, get_plural_doctype + + +def setup_root_query_resolvers(schema: GraphQLSchema): + """ + This will handle DocType Query at the root. + + Query { + User(name: ID): User! + Users(**args: CursorArgs): UserCountableConnection! + } + """ + + for fieldname, field in schema.query_type.fields.items(): + dt = get_singular_doctype(fieldname) + if dt: + field.resolve = _get_doc_resolver + continue + + dt = get_plural_doctype(fieldname) + if dt: + field.resolve = _doc_cursor_resolver + + +def _get_doc_resolver(obj, info: GraphQLResolveInfo, **kwargs): + dt = get_singular_doctype(info.field_name) + if is_single(dt): + kwargs["name"] = dt + elif not frappe.db.exists(dt, kwargs.get("name")): + raise frappe.DoesNotExistError( + frappe._("{0} {1} not found").format(frappe._(dt), kwargs.get("name"))) + + return frappe.get_doc(dt, kwargs["name"]) + + +def _doc_cursor_resolver(obj, info: GraphQLResolveInfo, **kwargs): + plural_doctype = get_plural_doctype(info.field_name) + frappe.has_permission(doctype=plural_doctype, throw=True) + + return CursorPaginator(doctype=plural_doctype).resolve(obj, info, **kwargs) diff --git a/frappe_graphql/utils/subscriptions.py b/frappe_graphql/utils/subscriptions.py index 8970071..ba1b175 100644 --- a/frappe_graphql/utils/subscriptions.py +++ b/frappe_graphql/utils/subscriptions.py @@ -7,7 +7,6 @@ from frappe.utils import now_datetime, get_datetime from frappe_graphql import get_schema -from frappe_graphql.utils.resolver import default_field_resolver """ Implemented similar to @@ -185,7 +184,6 @@ def gql_transform(subscription, selection_set, obj): exc_ctx = ExecutionContext.build( schema=schema, document=document, - field_resolver=default_field_resolver ) data = exc_ctx.execute_operation(exc_ctx.operation, frappe._dict(__subscription__=obj)) result = frappe._dict(exc_ctx.build_response(data).formatted) From be6913e47b3d7369d68610d258bcacd093cb9057 Mon Sep 17 00:00:00 2001 From: Fahim Ali Zain Date: Thu, 11 Aug 2022 12:27:20 +0530 Subject: [PATCH 02/27] feat: DataLoader --- frappe_graphql/graphql.py | 4 +- frappe_graphql/utils/execution/__init__.py | 2 + frappe_graphql/utils/execution/dataloader.py | 31 ++ .../utils/execution/deferred_value.py | 210 ++++++++++ .../utils/execution/execution_context.py | 367 ++++++++++++++++++ frappe_graphql/utils/resolver/__init__.py | 8 +- frappe_graphql/utils/resolver/child_tables.py | 34 ++ .../utils/resolver/dataloaders/__init__.py | 2 + .../dataloaders/child_table_loader.py | 50 +++ .../resolver/dataloaders/doctype_loader.py | 41 ++ .../utils/resolver/dataloaders/locals.py | 17 + frappe_graphql/utils/resolver/link_field.py | 48 ++- frappe_graphql/utils/resolver/root_query.py | 7 +- requirements.txt | 2 +- 14 files changed, 803 insertions(+), 20 deletions(-) create mode 100644 frappe_graphql/utils/execution/__init__.py create mode 100644 frappe_graphql/utils/execution/dataloader.py create mode 100644 frappe_graphql/utils/execution/deferred_value.py create mode 100644 frappe_graphql/utils/execution/execution_context.py create mode 100644 frappe_graphql/utils/resolver/child_tables.py create mode 100644 frappe_graphql/utils/resolver/dataloaders/__init__.py create mode 100644 frappe_graphql/utils/resolver/dataloaders/child_table_loader.py create mode 100644 frappe_graphql/utils/resolver/dataloaders/doctype_loader.py create mode 100644 frappe_graphql/utils/resolver/dataloaders/locals.py diff --git a/frappe_graphql/graphql.py b/frappe_graphql/graphql.py index c308371..65120a8 100644 --- a/frappe_graphql/graphql.py +++ b/frappe_graphql/graphql.py @@ -2,6 +2,7 @@ import graphql from frappe_graphql.utils.loader import get_schema +from frappe_graphql.utils.execution import DeferredExecutionContext @frappe.whitelist(allow_guest=True) @@ -12,7 +13,8 @@ def execute(query=None, variables=None, operation_name=None): variable_values=variables, operation_name=operation_name, middleware=[frappe.get_attr(cmd) for cmd in frappe.get_hooks("graphql_middlewares")], - context_value=frappe._dict() + context_value=frappe._dict(), + execution_context_class=DeferredExecutionContext ) output = frappe._dict() for k in ("data", "errors"): diff --git a/frappe_graphql/utils/execution/__init__.py b/frappe_graphql/utils/execution/__init__.py new file mode 100644 index 0000000..09c67b1 --- /dev/null +++ b/frappe_graphql/utils/execution/__init__.py @@ -0,0 +1,2 @@ +from .dataloader import DataLoader # noqa: F401 +from .execution_context import DeferredExecutionContext # noqa: F401 diff --git a/frappe_graphql/utils/execution/dataloader.py b/frappe_graphql/utils/execution/dataloader.py new file mode 100644 index 0000000..a6718c5 --- /dev/null +++ b/frappe_graphql/utils/execution/dataloader.py @@ -0,0 +1,31 @@ +class DataLoader: + class LazyValue: + def __init__(self, key, dataloader): + self.key = key + self.dataloader = dataloader + + def get(self): + return self.dataloader.get(self.key) + + def __init__(self, load_fn): + self.load_fn = load_fn + self.pending_ids = set() + self.loaded_ids = {} + + def load(self, key): + lazy_value = DataLoader.LazyValue(key, self) + self.pending_ids.add(key) + + return lazy_value + + def get(self, key): + if key in self.loaded_ids: + return self.loaded_ids.get(key) + + keys = self.pending_ids + values = self.load_fn(keys) + for k, value in zip(keys, values): + self.loaded_ids[k] = value + + self.pending_ids.clear() + return self.loaded_ids[key] diff --git a/frappe_graphql/utils/execution/deferred_value.py b/frappe_graphql/utils/execution/deferred_value.py new file mode 100644 index 0000000..ef5dcbb --- /dev/null +++ b/frappe_graphql/utils/execution/deferred_value.py @@ -0,0 +1,210 @@ +from typing import Any, Optional, List, Callable, cast, Dict + + +OnSuccessCallback = Callable[[Any], None] +OnErrorCallback = Callable[[Exception], None] + + +class DeferredValue: + PENDING = -1 + REJECTED = 0 + RESOLVED = 1 + + _value: Optional[Any] + _reason: Optional[Exception] + _callbacks: List[OnSuccessCallback] + _errbacks: List[OnErrorCallback] + + def __init__( + self, + on_complete: Optional[OnSuccessCallback] = None, + on_error: Optional[OnErrorCallback] = None, + ): + self._state = self.PENDING + self._value = None + self._reason = None + if on_complete: + self._callbacks = [on_complete] + else: + self._callbacks = [] + if on_error: + self._errbacks = [on_error] + else: + self._errbacks = [] + + def resolve(self, value: Any) -> None: + if self._state != DeferredValue.PENDING: + return + + if isinstance(value, DeferredValue): + value.add_callback(self.resolve) + value.add_errback(self.reject) + return + + self._value = value + self._state = self.RESOLVED + + callbacks = self._callbacks + self._callbacks = [] + for callback in callbacks: + try: + callback(value) + except Exception: + # Ignore errors in callbacks + pass + + def reject(self, reason: Exception) -> None: + if self._state != DeferredValue.PENDING: + return + + self._reason = reason + self._state = self.REJECTED + + errbacks = self._errbacks + self._errbacks = [] + for errback in errbacks: + try: + errback(reason) + except Exception: + # Ignore errors in errback + pass + + def then( + self, + on_complete: Optional[OnSuccessCallback] = None, + on_error: Optional[OnErrorCallback] = None, + ) -> "DeferredValue": + ret = DeferredValue() + + def call_and_resolve(v: Any) -> None: + try: + if on_complete: + ret.resolve(on_complete(v)) + else: + ret.resolve(v) + except Exception as e: + ret.reject(e) + + def call_and_reject(r: Exception) -> None: + try: + if on_error: + ret.resolve(on_error(r)) + else: + ret.reject(r) + except Exception as e: + ret.reject(e) + + self.add_callback(call_and_resolve) + self.add_errback(call_and_resolve) + + return ret + + def add_callback(self, callback: OnSuccessCallback) -> None: + if self._state == self.PENDING: + self._callbacks.append(callback) + return + + if self._state == self.RESOLVED: + callback(self._value) + + def add_errback(self, callback: OnErrorCallback) -> None: + if self._state == self.PENDING: + self._errbacks.append(callback) + return + + if self._state == self.REJECTED: + callback(cast(Exception, self._reason)) + + @property + def is_resolved(self) -> bool: + return self._state == self.RESOLVED + + @property + def is_rejected(self) -> bool: + return self._state == self.REJECTED + + @property + def value(self) -> Any: + return self._value + + @property + def reason(self) -> Optional[Exception]: + return self._reason + + +def deferred_dict(m: Dict[str, Any]) -> DeferredValue: + """ + A special function that takes a dictionary of deferred values + and turns them into a deferred value that will ultimately resolve + into a dictionary of values. + """ + if len(m) == 0: + raise TypeError("Empty dict") + + ret = DeferredValue() + + plain_values = { + key: value for key, value in m.items() if not isinstance(value, DeferredValue) + } + deferred_values = { + key: value for key, value in m.items() if isinstance(value, DeferredValue) + } + + count = len(deferred_values) + + def handle_success(_: Any) -> None: + nonlocal count + count -= 1 + if count == 0: + value = plain_values + + for k, p in deferred_values.items(): + value[k] = p.value + + ret.resolve(value) + + for p in deferred_values.values(): + p.add_callback(handle_success) + p.add_errback(ret.reject) + + return ret + + +def deferred_list(_list: List[Any]) -> DeferredValue: + """ + A special function that takes a list of deferred values + and turns them into a deferred value for a list of values. + """ + if len(_list) == 0: + raise TypeError("Empty list") + + ret = DeferredValue() + + plain_values = {} + deferred_values = {} + for index, value in enumerate(_list): + if isinstance(value, DeferredValue): + deferred_values[index] = value + else: + plain_values[index] = value + + count = len(deferred_values) + + def handle_success(_: Any) -> None: + nonlocal count + count -= 1 + if count == 0: + values = [] + + for k in sorted(list(plain_values.keys()) + list(deferred_values.keys())): + value = plain_values.get(k, None) + if not value: + value = deferred_values[k].value + values.append(value) + ret.resolve(values) + + for p in _list: + p.add_callback(handle_success) + p.add_errback(ret.reject) + + return ret diff --git a/frappe_graphql/utils/execution/execution_context.py b/frappe_graphql/utils/execution/execution_context.py new file mode 100644 index 0000000..f322bd3 --- /dev/null +++ b/frappe_graphql/utils/execution/execution_context.py @@ -0,0 +1,367 @@ +from typing import ( + Any, + AsyncIterable, + Dict, + Iterable, + List, + Optional, + Union, + Tuple, + cast, +) + +from graphql.execution.execute import ExecutionContext +from graphql.execution.collect_fields import collect_fields +from graphql.error import GraphQLError, located_error +from graphql.type import ( + GraphQLAbstractType, + GraphQLLeafType, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLOutputType, + GraphQLResolveInfo, + is_abstract_type, + is_leaf_type, + is_list_type, + is_non_null_type, + is_object_type, +) +from graphql.language import ( + FieldNode, + OperationDefinitionNode, + OperationType, +) +from graphql.pyutils import ( + inspect, + is_iterable, + AwaitableOrValue, + Path, + Undefined, +) + +from .deferred_value import DeferredValue, deferred_dict, deferred_list +from .dataloader import DataLoader + + +class DeferredExecutionContext(ExecutionContext): + def __init__(self, *args, **kwargs): + self._deferred_values: List[Tuple[DeferredValue, Any]] = [] + + return super().__init__(*args, **kwargs) + + def is_lazy(self, value: Any) -> bool: + return isinstance(value, DataLoader.LazyValue) + + def execute_operation( + self, operation: OperationDefinitionNode, root_value: Any + ) -> Optional[AwaitableOrValue[Any]]: + """Execute an operation. + + Implements the "Executing operations" section of the spec. + """ + root_type = self.schema.get_root_type(operation.operation) + if root_type is None: + raise GraphQLError( + "Schema is not configured to execute" + f" {operation.operation.value} operation.", + operation, + ) + + root_fields = collect_fields( + self.schema, + self.fragments, + self.variable_values, + root_type, + operation.selection_set, + ) + + path = None + + result = ( + self.execute_fields_serially + if operation.operation == OperationType.MUTATION + else self.execute_fields + )(root_type, root_value, path, root_fields) + + while len(self._deferred_values) > 0: + for d in list(self._deferred_values): + self._deferred_values.remove(d) + res = d[1].get() + d[0].resolve(res) + + if isinstance(result, DeferredValue): + if result.is_rejected: + raise cast(Exception, result.reason) + return result.value + + return result + + def execute_fields( + self, + parent_type: GraphQLObjectType, + source_value: Any, + path: Optional[Path], + fields: Dict[str, List[FieldNode]], + ) -> AwaitableOrValue[Dict[str, Any]]: + """Execute the given fields concurrently. + + Implements the "Executing selection sets" section of the spec + for fields that may be executed in parallel. + """ + results = {} + is_awaitable = self.is_awaitable + awaitable_fields: List[str] = [] + append_awaitable = awaitable_fields.append + contains_deferred = False + for response_name, field_nodes in fields.items(): + field_path = Path(path, response_name, parent_type.name) + result = self.execute_field( + parent_type, source_value, field_nodes, field_path + ) + if result is not Undefined: + results[response_name] = result + if is_awaitable(result): + append_awaitable(response_name) + if isinstance(result, DeferredValue): + contains_deferred = True + + if contains_deferred: + return deferred_dict(results) + + # If there are no coroutines, we can just return the object + if not awaitable_fields: + return results + + # Otherwise, results is a map from field name to the result of resolving that + # field, which is possibly a coroutine object. Return a coroutine object that + # will yield this same map, but with any coroutines awaited in parallel and + # replaced with the values they yielded. + async def get_results() -> Dict[str, Any]: + from asyncio import gather + results.update( + zip( + awaitable_fields, + await gather(*(results[field] for field in awaitable_fields)), + ) + ) + return results + + return get_results() + + def complete_value( + self, + return_type: GraphQLOutputType, + field_nodes: List[FieldNode], + info: GraphQLResolveInfo, + path: Path, + result: Any, + ) -> AwaitableOrValue[Any]: + """Complete a value. + + Implements the instructions for completeValue as defined in the + "Value completion" section of the spec. + + If the field type is Non-Null, then this recursively completes the value + for the inner type. It throws a field error if that completion returns null, + as per the "Nullability" section of the spec. + + If the field type is a List, then this recursively completes the value + for the inner type on each item in the list. + + If the field type is a Scalar or Enum, ensures the completed value is a legal + value of the type by calling the ``serialize`` method of GraphQL type + definition. + + If the field is an abstract type, determine the runtime type of the value and + then complete based on that type. + + Otherwise, the field type expects a sub-selection set, and will complete the + value by evaluating all sub-selections. + """ + # If result is an Exception, throw a located error. + if isinstance(result, Exception): + raise result + + # If field type is NonNull, complete for inner type, and throw field error if + # result is null. + if is_non_null_type(return_type): + completed = self.complete_value( + cast(GraphQLNonNull, return_type).of_type, + field_nodes, + info, + path, + result, + ) + if completed is None: + raise TypeError( + "Cannot return null for non-nullable field" + f" {info.parent_type.name}.{info.field_name}." + ) + return completed + + # If result value is null or undefined then return null. + if result is None or result is Undefined: + return None + + if self.is_lazy(result): + def handle_resolve(resolved: Any) -> Any: + return self.complete_value( + return_type, field_nodes, info, path, resolved + ) + + def handle_error(raw_error: Exception) -> None: + raise raw_error + + deferred = DeferredValue() + self._deferred_values.append(( + deferred, result + )) + + completed = deferred.then(handle_resolve, handle_error) + return completed + + # If field type is List, complete each item in the list with inner type + if is_list_type(return_type): + return self.complete_list_value( + cast(GraphQLList, return_type), field_nodes, info, path, result + ) + + # If field type is a leaf type, Scalar or Enum, serialize to a valid value, + # returning null if serialization is not possible. + if is_leaf_type(return_type): + return self.complete_leaf_value(cast(GraphQLLeafType, return_type), result) + + # If field type is an abstract type, Interface or Union, determine the runtime + # Object type and complete for that type. + if is_abstract_type(return_type): + return self.complete_abstract_value( + cast(GraphQLAbstractType, return_type), field_nodes, info, path, result + ) + + # If field type is Object, execute and complete all sub-selections. + if is_object_type(return_type): + return self.complete_object_value( + cast(GraphQLObjectType, return_type), field_nodes, info, path, result + ) + + # Not reachable. All possible output types have been considered. + raise TypeError( # pragma: no cover + "Cannot complete value of unexpected output type:" + f" '{inspect(return_type)}'." + ) + + def complete_list_value( + self, + return_type: GraphQLList[GraphQLOutputType], + field_nodes: List[FieldNode], + info: GraphQLResolveInfo, + path: Path, + result: Union[AsyncIterable[Any], Iterable[Any]], + ) -> AwaitableOrValue[List[Any]]: + """Complete a list value. + + Complete a list value by completing each item in the list with the inner type. + """ + if not is_iterable(result): + # experimental: allow async iterables + if isinstance(result, AsyncIterable): + # noinspection PyShadowingNames + async def async_iterable_to_list( + async_result: AsyncIterable[Any], + ) -> Any: + sync_result = [item async for item in async_result] + return self.complete_list_value( + return_type, field_nodes, info, path, sync_result + ) + + return async_iterable_to_list(result) + + raise GraphQLError( + "Expected Iterable, but did not find one for field" + f" '{info.parent_type.name}.{info.field_name}'." + ) + result = cast(Iterable[Any], result) + + # This is specified as a simple map, however we're optimizing the path where + # the list contains no coroutine objects by avoiding creating another coroutine + # object. + item_type = return_type.of_type + is_awaitable = self.is_awaitable + awaitable_indices: List[int] = [] + append_awaitable = awaitable_indices.append + completed_results: List[Any] = [] + append_result = completed_results.append + contains_deferred = False + for index, item in enumerate(result): + # No need to modify the info object containing the path, since from here on + # it is not ever accessed by resolver functions. + item_path = path.add_key(index, None) + completed_item: AwaitableOrValue[Any] + if is_awaitable(item): + # noinspection PyShadowingNames + async def await_completed(item: Any, item_path: Path) -> Any: + try: + completed = self.complete_value( + item_type, field_nodes, info, item_path, await item + ) + if is_awaitable(completed): + return await completed + return completed + except Exception as raw_error: + error = located_error( + raw_error, field_nodes, item_path.as_list() + ) + self.handle_field_error(error, item_type) + return None + + completed_item = await_completed(item, item_path) + else: + try: + completed_item = self.complete_value( + item_type, field_nodes, info, item_path, item + ) + if is_awaitable(completed_item): + # noinspection PyShadowingNames + async def await_completed(item: Any, item_path: Path) -> Any: + try: + return await item + except Exception as raw_error: + error = located_error( + raw_error, field_nodes, item_path.as_list() + ) + self.handle_field_error(error, item_type) + return None + + completed_item = await_completed(completed_item, item_path) + if isinstance(completed_item, DeferredValue): + contains_deferred = True + + except Exception as raw_error: + error = located_error(raw_error, field_nodes, item_path.as_list()) + self.handle_field_error(error, item_type) + completed_item = None + + if is_awaitable(completed_item): + append_awaitable(index) + append_result(completed_item) + + if contains_deferred is True: + return deferred_list(completed_results) + + if not awaitable_indices: + return completed_results + + # noinspection PyShadowingNames + async def get_completed_results() -> List[Any]: + from asyncio import gather + for index, result in zip( + awaitable_indices, + await gather( + *(completed_results[index] for index in awaitable_indices) + ), + ): + completed_results[index] = result + return completed_results + + return get_completed_results() diff --git a/frappe_graphql/utils/resolver/__init__.py b/frappe_graphql/utils/resolver/__init__.py index c9ec6a6..c5c205a 100644 --- a/frappe_graphql/utils/resolver/__init__.py +++ b/frappe_graphql/utils/resolver/__init__.py @@ -1,20 +1,16 @@ from graphql import GraphQLSchema from .root_query import setup_root_query_resolvers -from .link_field import setup_link_field_resolvers, setup_dynamic_link_field_resolvers +from .link_field import setup_link_field_resolvers +from .child_tables import setup_child_table_resolvers def setup_default_resolvers(schema: GraphQLSchema): setup_root_query_resolvers(schema=schema) setup_link_field_resolvers(schema=schema) - setup_dynamic_link_field_resolvers(schema=schema) setup_select_field_resolvers(schema=schema) setup_child_table_resolvers(schema=schema) def setup_select_field_resolvers(schema: GraphQLSchema): pass - - -def setup_child_table_resolvers(schema: GraphQLSchema): - pass diff --git a/frappe_graphql/utils/resolver/child_tables.py b/frappe_graphql/utils/resolver/child_tables.py new file mode 100644 index 0000000..aa34527 --- /dev/null +++ b/frappe_graphql/utils/resolver/child_tables.py @@ -0,0 +1,34 @@ +from graphql import GraphQLSchema, GraphQLResolveInfo + +import frappe + +from .dataloaders import get_child_table_loader +from .utils import get_singular_doctype + + +def setup_child_table_resolvers(schema: GraphQLSchema): + for type_name, gql_type in schema.type_map.items(): + dt = get_singular_doctype(type_name) + if not dt: + continue + + meta = frappe.get_meta(dt) + for df in meta.get_table_fields(): + if df.fieldname not in gql_type.fields: + continue + + gql_field = gql_type.fields[df.fieldname] + gql_field.frappe_docfield = df + gql_field.resolve = _child_table_resolver + + +def _child_table_resolver(obj, info: GraphQLResolveInfo, **kwargs): + df = getattr(info.parent_type.fields[info.field_name], "frappe_docfield", None) + if not df: + return [] + + return get_child_table_loader( + child_doctype=df.options, + parent_doctype=df.parent, + parentfield=df.fieldname + ).load(obj.get("name")) diff --git a/frappe_graphql/utils/resolver/dataloaders/__init__.py b/frappe_graphql/utils/resolver/dataloaders/__init__.py new file mode 100644 index 0000000..2c3fc51 --- /dev/null +++ b/frappe_graphql/utils/resolver/dataloaders/__init__.py @@ -0,0 +1,2 @@ +from .doctype_loader import get_doctype_dataloader # noqa: F401 +from .child_table_loader import get_child_table_loader # noqa: F401 diff --git a/frappe_graphql/utils/resolver/dataloaders/child_table_loader.py b/frappe_graphql/utils/resolver/dataloaders/child_table_loader.py new file mode 100644 index 0000000..4ad5a37 --- /dev/null +++ b/frappe_graphql/utils/resolver/dataloaders/child_table_loader.py @@ -0,0 +1,50 @@ +import frappe + +from frappe_graphql.utils.execution import DataLoader +from .locals import get_loader_from_locals, set_loader_in_locals + +from collections import OrderedDict + + +def get_child_table_loader(child_doctype: str, parent_doctype: str, parentfield: str) -> DataLoader: + locals_key = (child_doctype, parent_doctype, parentfield) + loader = get_loader_from_locals(locals_key) + if loader: + return loader + + loader = DataLoader(_get_child_table_loader_fn( + child_doctype=child_doctype, + parent_doctype=parent_doctype, + parentfield=parentfield, + )) + set_loader_in_locals(locals_key, loader) + return loader + + +def _get_child_table_loader_fn(child_doctype: str, parent_doctype: str, parentfield: str): + def _inner(keys): + rows = frappe.db.sql(f""" + SELECT * FROM `tab{child_doctype}` + WHERE + parent IN %(parent_keys)s + AND parenttype = %(parenttype)s + AND parentfield = %(parentfield)s + ORDER BY idx + """, dict( + parent_keys=keys, + parenttype=parent_doctype, + parentfield=parentfield, + ), as_dict=1) + + _results = OrderedDict() + for k in keys: + _results[k] = [] + + for row in rows: + if row.parent not in _results: + continue + _results.get(row.parent).append(row) + + return _results.values() + + return _inner diff --git a/frappe_graphql/utils/resolver/dataloaders/doctype_loader.py b/frappe_graphql/utils/resolver/dataloaders/doctype_loader.py new file mode 100644 index 0000000..a0ac0f6 --- /dev/null +++ b/frappe_graphql/utils/resolver/dataloaders/doctype_loader.py @@ -0,0 +1,41 @@ +from typing import List + +import frappe + +from frappe_graphql.utils.execution import DataLoader +from .locals import get_loader_from_locals, set_loader_in_locals + + +def get_doctype_dataloader(doctype: str) -> DataLoader: + loader = get_loader_from_locals(doctype) + if loader: + return loader + + loader = DataLoader(load_fn=_get_document_loader_fn(doctype=doctype)) + set_loader_in_locals(doctype, loader) + return loader + + +def _get_document_loader_fn(doctype: str): + + def _load_documents(keys: List[str]): + docs = frappe.get_all( + doctype=doctype, + filters=[["name", "IN", keys]], + fields=["*"], + limit_page_length=len(keys) + 1 + ) + + sorted_docs = [] + for k in keys: + doc = [x for x in docs if x.name == k] + if not len(doc): + sorted_docs.append(None) + continue + + sorted_docs.append(doc[0]) + docs.remove(doc[0]) + + return sorted_docs + + return _load_documents diff --git a/frappe_graphql/utils/resolver/dataloaders/locals.py b/frappe_graphql/utils/resolver/dataloaders/locals.py new file mode 100644 index 0000000..6548125 --- /dev/null +++ b/frappe_graphql/utils/resolver/dataloaders/locals.py @@ -0,0 +1,17 @@ +import frappe +from frappe_graphql.utils.execution import DataLoader + + +def get_loader_from_locals(key: str): + if not hasattr(frappe.local, "dataloaders"): + frappe.local.dataloaders = frappe._dict() + + if key in frappe.local.dataloaders: + return frappe.local.dataloaders.get(key) + + +def set_loader_in_locals(key: str, loader: DataLoader): + if not hasattr(frappe.local, "dataloaders"): + frappe.local.dataloaders = frappe._dict() + + frappe.local.dataloaders[key] = loader diff --git a/frappe_graphql/utils/resolver/link_field.py b/frappe_graphql/utils/resolver/link_field.py index e824a8c..4fcb28d 100644 --- a/frappe_graphql/utils/resolver/link_field.py +++ b/frappe_graphql/utils/resolver/link_field.py @@ -2,6 +2,7 @@ import frappe +from .dataloaders import get_doctype_dataloader from .utils import get_singular_doctype @@ -18,7 +19,15 @@ def setup_link_field_resolvers(schema: GraphQLSchema): for df in meta.get_link_fields() + meta.get_dynamic_link_fields(): if df.fieldname not in gql_type.fields: continue - gql_type.fields[df.fieldname].resolve = None + + gql_field = gql_type.fields[df.fieldname] + gql_field.frappe_docfield = df + if df.fieldtype == "Link": + gql_field.resolve = _resolve_link_field + elif df.fieldtype == "Dynamic Link": + gql_field.resolve = _resolve_dynamic_link_field + else: + continue _name_df = f"{df.fieldname}__name" if _name_df not in gql_type.fields: @@ -27,17 +36,40 @@ def setup_link_field_resolvers(schema: GraphQLSchema): gql_type.fields[_name_df].resolve = _resolve_link_name_field -def setup_dynamic_link_field_resolvers(schema: GraphQLSchema): - """ - This will set up Link fields on DocTypes to resolve target docs - """ - pass +def _resolve_link_field(obj, info: GraphQLResolveInfo, **kwargs): + df = _get_frappe_docfield_from_resolve_info(info) + if not df: + return None + dt = df.options + dn = obj.get(info.field_name) -def _resolve_link_field(obj, info: GraphQLResolveInfo, **kwargs): - pass + if not (dt and dn): + return None + + return get_doctype_dataloader(dt).load(dn) + + +def _resolve_dynamic_link_field(obj, info: GraphQLResolveInfo, **kwargs): + df = _get_frappe_docfield_from_resolve_info(info) + if not df: + return None + + dt = obj.get(df.options) + if not dt: + return None + + dn = obj.get(info.field_name) + if not dn: + return None + + return get_doctype_dataloader(dt).load(dn) def _resolve_link_name_field(obj, info: GraphQLResolveInfo, **kwargs): df = info.field_name.split("__name")[0] return obj.get(df) + + +def _get_frappe_docfield_from_resolve_info(info: GraphQLResolveInfo): + return getattr(info.parent_type.fields[info.field_name], "frappe_docfield", None) diff --git a/frappe_graphql/utils/resolver/root_query.py b/frappe_graphql/utils/resolver/root_query.py index 74deb4c..a729cc8 100644 --- a/frappe_graphql/utils/resolver/root_query.py +++ b/frappe_graphql/utils/resolver/root_query.py @@ -4,6 +4,8 @@ from frappe.model.meta import is_single from frappe_graphql import CursorPaginator + +from .dataloaders import get_doctype_dataloader from .utils import get_singular_doctype, get_plural_doctype @@ -32,11 +34,8 @@ def _get_doc_resolver(obj, info: GraphQLResolveInfo, **kwargs): dt = get_singular_doctype(info.field_name) if is_single(dt): kwargs["name"] = dt - elif not frappe.db.exists(dt, kwargs.get("name")): - raise frappe.DoesNotExistError( - frappe._("{0} {1} not found").format(frappe._(dt), kwargs.get("name"))) - return frappe.get_doc(dt, kwargs["name"]) + return get_doctype_dataloader(dt).load(kwargs["name"]) def _doc_cursor_resolver(obj, info: GraphQLResolveInfo, **kwargs): diff --git a/requirements.txt b/requirements.txt index de00f41..52581eb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ frappe -graphql-core==3.1.3 +graphql-core==3.2.1 inflect==5.3.0 From 0fad669a2f583f7fb9a423794ecc34169dc87190 Mon Sep 17 00:00:00 2001 From: Fahim Ali Zain Date: Thu, 11 Aug 2022 12:36:57 +0530 Subject: [PATCH 03/27] fix: CursorPaginator Select Fields --- frappe_graphql/utils/cursor_pagination.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe_graphql/utils/cursor_pagination.py b/frappe_graphql/utils/cursor_pagination.py index 5044c25..e44de4d 100644 --- a/frappe_graphql/utils/cursor_pagination.py +++ b/frappe_graphql/utils/cursor_pagination.py @@ -142,7 +142,7 @@ def get_data(self, doctype, filters, sorting_fields, sort_dir, limit): return frappe.get_list( doctype, - fields=["name", f"SUBSTR(\".{doctype}\", 2) as doctype"] + sorting_fields, + fields=["*", f"SUBSTR(\".{doctype}\", 2) as doctype"] + sorting_fields, filters=filters, order_by=f"{', '.join([f'{x} {sort_dir}' for x in sorting_fields])}", limit_page_length=limit From f91d36200d21f26e70d37d5f026759c4bb04259a Mon Sep 17 00:00:00 2001 From: Fahim Ali Zain Date: Thu, 11 Aug 2022 17:47:16 +0530 Subject: [PATCH 04/27] fix: deferred_list callback list --- frappe_graphql/utils/execution/deferred_value.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe_graphql/utils/execution/deferred_value.py b/frappe_graphql/utils/execution/deferred_value.py index ef5dcbb..31194ad 100644 --- a/frappe_graphql/utils/execution/deferred_value.py +++ b/frappe_graphql/utils/execution/deferred_value.py @@ -203,7 +203,7 @@ def handle_success(_: Any) -> None: values.append(value) ret.resolve(values) - for p in _list: + for p in deferred_values.values(): p.add_callback(handle_success) p.add_errback(ret.reject) From 4c784c46d01602d3cac4846eab937c0df1f114d2 Mon Sep 17 00:00:00 2001 From: Fahim Ali Zain Date: Thu, 11 Aug 2022 18:24:21 +0530 Subject: [PATCH 05/27] fix: support parent link fields --- .../dataloaders/child_table_loader.py | 5 +++- .../resolver/dataloaders/doctype_loader.py | 2 +- frappe_graphql/utils/resolver/link_field.py | 29 ++++++++++++++++++- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/frappe_graphql/utils/resolver/dataloaders/child_table_loader.py b/frappe_graphql/utils/resolver/dataloaders/child_table_loader.py index 4ad5a37..69f2e07 100644 --- a/frappe_graphql/utils/resolver/dataloaders/child_table_loader.py +++ b/frappe_graphql/utils/resolver/dataloaders/child_table_loader.py @@ -24,7 +24,10 @@ def get_child_table_loader(child_doctype: str, parent_doctype: str, parentfield: def _get_child_table_loader_fn(child_doctype: str, parent_doctype: str, parentfield: str): def _inner(keys): rows = frappe.db.sql(f""" - SELECT * FROM `tab{child_doctype}` + SELECT + *, + "{child_doctype}" as doctype + FROM `tab{child_doctype}` WHERE parent IN %(parent_keys)s AND parenttype = %(parenttype)s diff --git a/frappe_graphql/utils/resolver/dataloaders/doctype_loader.py b/frappe_graphql/utils/resolver/dataloaders/doctype_loader.py index a0ac0f6..b6caa03 100644 --- a/frappe_graphql/utils/resolver/dataloaders/doctype_loader.py +++ b/frappe_graphql/utils/resolver/dataloaders/doctype_loader.py @@ -22,7 +22,7 @@ def _load_documents(keys: List[str]): docs = frappe.get_all( doctype=doctype, filters=[["name", "IN", keys]], - fields=["*"], + fields=["*", f"'{doctype}' as doctype"], limit_page_length=len(keys) + 1 ) diff --git a/frappe_graphql/utils/resolver/link_field.py b/frappe_graphql/utils/resolver/link_field.py index 4fcb28d..2833ddc 100644 --- a/frappe_graphql/utils/resolver/link_field.py +++ b/frappe_graphql/utils/resolver/link_field.py @@ -16,7 +16,10 @@ def setup_link_field_resolvers(schema: GraphQLSchema): continue meta = frappe.get_meta(dt) - for df in meta.get_link_fields() + meta.get_dynamic_link_fields(): + link_dfs = meta.get_link_fields() + meta.get_dynamic_link_fields() + \ + _get_default_field_links() + + for df in link_dfs: if df.fieldname not in gql_type.fields: continue @@ -73,3 +76,27 @@ def _resolve_link_name_field(obj, info: GraphQLResolveInfo, **kwargs): def _get_frappe_docfield_from_resolve_info(info: GraphQLResolveInfo): return getattr(info.parent_type.fields[info.field_name], "frappe_docfield", None) + + +def _get_default_field_links(): + + def _get_default_field_df(fieldname): + df = frappe._dict( + fieldname=fieldname, + fieldtype="Data" + ) + if fieldname in ("owner", "modified_by"): + df.fieldtype = "Link" + df.options = "User" + + if fieldname == "parent": + df.fieldtype = "Dynamic Link" + df.options = "parenttype" + + return df + + return [ + _get_default_field_df(x) for x in [ + "owner", "modified_by", "parent" + ] + ] From 47a3f5971e850a7c96a6ab9df8b5e02bfa593a6e Mon Sep 17 00:00:00 2001 From: Fahim Ali Zain Date: Fri, 12 Aug 2022 18:55:43 +0530 Subject: [PATCH 06/27] fix: remove deprecated code --- frappe_graphql/utils/resolver/__init__old.py | 70 ---------- .../utils/resolver/document_resolver_old.py | 123 ------------------ 2 files changed, 193 deletions(-) delete mode 100644 frappe_graphql/utils/resolver/__init__old.py delete mode 100644 frappe_graphql/utils/resolver/document_resolver_old.py diff --git a/frappe_graphql/utils/resolver/__init__old.py b/frappe_graphql/utils/resolver/__init__old.py deleted file mode 100644 index 8cd1248..0000000 --- a/frappe_graphql/utils/resolver/__init__old.py +++ /dev/null @@ -1,70 +0,0 @@ -from typing import Any -from graphql import GraphQLObjectType, GraphQLResolveInfo - -import frappe -from frappe.model.document import Document -from frappe.model.meta import is_single - -from frappe_graphql import CursorPaginator, profile_fn -from .document_resolver_old import document_resolver -from .utils import get_singular_doctype, get_plural_doctype - - -@profile_fn -def default_field_resolver(obj: Any, info: GraphQLResolveInfo, **kwargs): - - parent_type: GraphQLObjectType = info.parent_type - if not isinstance(info.parent_type, GraphQLObjectType): - frappe.throw("Invalid GraphQL") - - if parent_type.name == "Query": - # This section is executed on root query type fields - dt = get_singular_doctype(info.field_name) - if dt: - if is_single(dt): - kwargs["name"] = dt - elif not frappe.db.exists(dt, kwargs.get("name")): - raise frappe.DoesNotExistError( - frappe._("{0} {1} not found").format(frappe._(dt), kwargs.get("name"))) - return frappe._dict( - doctype=dt, - name=kwargs.get("name") - ) - - plural_doctype = get_plural_doctype(info.field_name) - if plural_doctype: - frappe.has_permission(doctype=plural_doctype, throw=True) - return CursorPaginator(doctype=plural_doctype).resolve(obj, info, **kwargs) - - if not isinstance(obj, (dict, Document)): - return None - - should_resolve_from_doc = not not (obj.get("name") and ( - obj.get("doctype") or get_singular_doctype(parent_type.name))) - - # check if requested field can be resolved - # - default resolver for simple objects - # - these form the resolvers for - # "SET_VALUE_TYPE", "SAVE_DOC_TYPE", "DELETE_DOC_TYPE" mutations - if obj.get(info.field_name) is not None: - value = obj.get(info.field_name) - if isinstance(value, CursorPaginator): - return value.resolve(obj, info, **kwargs) - - if not should_resolve_from_doc: - return value - - if should_resolve_from_doc: - # this section is executed for Fields on DocType object types. - hooks_cmd = frappe.get_hooks("gql_default_document_resolver") - resolver = document_resolver - if len(hooks_cmd): - resolver = frappe.get_attr(hooks_cmd[-1]) - - return resolver( - obj=obj, - info=info, - **kwargs - ) - - return None diff --git a/frappe_graphql/utils/resolver/document_resolver_old.py b/frappe_graphql/utils/resolver/document_resolver_old.py deleted file mode 100644 index 4356661..0000000 --- a/frappe_graphql/utils/resolver/document_resolver_old.py +++ /dev/null @@ -1,123 +0,0 @@ -from graphql import GraphQLResolveInfo, GraphQLEnumType, GraphQLNonNull - -import frappe -from frappe.utils import cint -from frappe.model import default_fields -from frappe.model.document import BaseDocument - -from .utils import get_singular_doctype - - -def document_resolver(obj, info: GraphQLResolveInfo, **kwargs): - doctype = obj.get('doctype') or get_singular_doctype(info.parent_type.name) - if not doctype: - return None - - cached_doc = obj - __ignore_perms = cint(obj.get("__ignore_perms", 0) == 1) - - if not isinstance(cached_doc, BaseDocument) and info.field_name not in cached_doc: - try: - cached_doc = frappe.get_cached_doc(doctype, obj.get("name")) - except BaseException: - pass - - if frappe.is_table(doctype=doctype) and isinstance(cached_doc, BaseDocument): - # Saves a lot of frappe.get_cached_doc calls - # - We do not want to check perms for child tables - # - We load child doc only if doc is not an instance of BaseDocument - pass - elif __ignore_perms: - pass - else: - try: - # Permission check after the document is confirmed to exist - # verbose check of is_owner of doc - # In the case when object signature lead into document resolver - # But the document no longer exists in database - if not isinstance(cached_doc, BaseDocument): - cached_doc = frappe.get_cached_doc(doctype, obj.get("name")) - - frappe.has_permission(doctype=doctype, doc=cached_doc, throw=True) - role_permissions = frappe.permissions.get_role_permissions(doctype) - if role_permissions.get("if_owner", {}).get("read"): - if cached_doc.get("owner") != frappe.session.user: - frappe.throw( - frappe._("No permission for {0}").format( - doctype + " " + obj.get("name"))) - # apply field level read perms - cached_doc.apply_fieldlevel_read_permissions() - - except frappe.DoesNotExistError: - pass - - meta = frappe.get_meta(doctype) - - df = meta.get_field(info.field_name) - if not df: - if info.field_name in default_fields: - df = get_default_field_df(info.field_name) - - def _get_value(fieldname, ignore_translation=False): - # Preference to fetch from obj first, cached_doc later - if obj.get(fieldname) is not None: - value = obj.get(fieldname) - else: - value = cached_doc.get(fieldname) - - # ignore_doc_resolver_translation might be helpful for overriding document_resolver - # which might be a simple wrapper around this function (document_resolver) - _df = meta.get_field(info.field_name) - if not ignore_translation and isinstance( - value, str) and not frappe.flags.ignore_doc_resolver_translation and _df and cint( - _df.get("translatable")): - return frappe._(value) - - if __ignore_perms: - if isinstance(value, list): - for item in value: - item.update({"__ignore_perms": __ignore_perms}) - elif isinstance(value, (BaseDocument, dict)): - value.update({"__ignore_perms": __ignore_perms}) - - return value - - if info.field_name.endswith("__name"): - fieldname = info.field_name.split("__name")[0] - return _get_value(fieldname, ignore_translation=True) - elif df: - if df.fieldtype in ("Link", "Dynamic Link"): - if not _get_value(df.fieldname): - return None - link_dt = df.options if df.fieldtype == "Link" else \ - _get_value(df.options, ignore_translation=True) - return frappe._dict( - name=_get_value(df.fieldname, ignore_translation=True), - doctype=link_dt, - __ignore_perms=__ignore_perms) - elif df.fieldtype == "Select": - # We allow Select fields whose returnType is just Strings - return_type = info.return_type - if isinstance(return_type, GraphQLNonNull): - return_type = return_type.of_type - if isinstance(return_type, GraphQLEnumType): - value = _get_value(df.fieldname, ignore_translation=True) or "" - return frappe.scrub(value).upper() - - return _get_value(info.field_name) - - -def get_default_field_df(fieldname): - df = frappe._dict( - fieldname=fieldname, - fieldtype="Data" - ) - if fieldname in ("owner", "modified_by"): - df.fieldtype = "Link" - df.options = "User" - - if fieldname == "parent": - df.fieldtype = "Dynamic Link" - df.options = "parenttype" - - return df From bd2a4a138587607f08757e710a6898f0fd116463 Mon Sep 17 00:00:00 2001 From: Fahim Ali Zain Date: Sat, 13 Aug 2022 14:26:02 +0530 Subject: [PATCH 07/27] fix: removed unused code --- frappe_graphql/__init__.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/frappe_graphql/__init__.py b/frappe_graphql/__init__.py index fb1e52c..89aa625 100644 --- a/frappe_graphql/__init__.py +++ b/frappe_graphql/__init__.py @@ -1,24 +1,5 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -import time - - -def profile_fn(fn): - from graphql import GraphQLResolveInfo - - def _inner(*args, **kwargs): - _v = fn.__name__ - if len(args) > 1 and isinstance(args[1], GraphQLResolveInfo): - _v += f" df: {args[1].field_name}" - - t = time.perf_counter() - v = fn(*args, **kwargs) - print(_v, f"{(time.perf_counter() - t) * 1000}ms") - return v - - return _inner - - from .utils.cursor_pagination import CursorPaginator # noqa from .utils.loader import get_schema # noqa from .utils.exceptions import ERROR_CODED_EXCEPTIONS, GQLExecutionUserError, GQLExecutionUserErrorMultiple # noqa From a8419fb760a3a429c2f9758e4e7e21fa40abc1de Mon Sep 17 00:00:00 2001 From: Fahim Ali Zain Date: Tue, 16 Aug 2022 10:21:38 +0000 Subject: [PATCH 08/27] feat: Basic Perms for new Resolvers fix: Use get_list in doc-dataloader feat: Basic Perms for new resolvers Co-authored-by: Fahim Ali Zain Merge-request: ROMMAN-MR-126 Merged-by: Fahim Ali Zain --- .../utils/resolver/dataloaders/doctype_loader.py | 2 +- frappe_graphql/utils/resolver/link_field.py | 2 ++ frappe_graphql/utils/resolver/root_query.py | 11 +++++++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/frappe_graphql/utils/resolver/dataloaders/doctype_loader.py b/frappe_graphql/utils/resolver/dataloaders/doctype_loader.py index b6caa03..7a8de10 100644 --- a/frappe_graphql/utils/resolver/dataloaders/doctype_loader.py +++ b/frappe_graphql/utils/resolver/dataloaders/doctype_loader.py @@ -19,7 +19,7 @@ def get_doctype_dataloader(doctype: str) -> DataLoader: def _get_document_loader_fn(doctype: str): def _load_documents(keys: List[str]): - docs = frappe.get_all( + docs = frappe.get_list( doctype=doctype, filters=[["name", "IN", keys]], fields=["*", f"'{doctype}' as doctype"], diff --git a/frappe_graphql/utils/resolver/link_field.py b/frappe_graphql/utils/resolver/link_field.py index 2833ddc..5aece5e 100644 --- a/frappe_graphql/utils/resolver/link_field.py +++ b/frappe_graphql/utils/resolver/link_field.py @@ -50,6 +50,7 @@ def _resolve_link_field(obj, info: GraphQLResolveInfo, **kwargs): if not (dt and dn): return None + # Permission check is done within get_doctype_dataloader via get_list return get_doctype_dataloader(dt).load(dn) @@ -66,6 +67,7 @@ def _resolve_dynamic_link_field(obj, info: GraphQLResolveInfo, **kwargs): if not dn: return None + # Permission check is done within get_doctype_dataloader via get_list return get_doctype_dataloader(dt).load(dn) diff --git a/frappe_graphql/utils/resolver/root_query.py b/frappe_graphql/utils/resolver/root_query.py index a729cc8..1fa12c6 100644 --- a/frappe_graphql/utils/resolver/root_query.py +++ b/frappe_graphql/utils/resolver/root_query.py @@ -35,11 +35,18 @@ def _get_doc_resolver(obj, info: GraphQLResolveInfo, **kwargs): if is_single(dt): kwargs["name"] = dt - return get_doctype_dataloader(dt).load(kwargs["name"]) + dn = kwargs["name"] + if not frappe.has_permission(doctype=dt, doc=dn): + raise frappe.PermissionError(frappe._("No permission for {0}").format(dt + " " + dn)) + + return get_doctype_dataloader(dt).load(dn) def _doc_cursor_resolver(obj, info: GraphQLResolveInfo, **kwargs): plural_doctype = get_plural_doctype(info.field_name) - frappe.has_permission(doctype=plural_doctype, throw=True) + + frappe.has_permission( + doctype=plural_doctype, + throw=True) return CursorPaginator(doctype=plural_doctype).resolve(obj, info, **kwargs) From f506eb08b2372251010e8d2bda9f1881c281aa2b Mon Sep 17 00:00:00 2001 From: Fahim Ali Zain Date: Fri, 19 Aug 2022 14:00:27 +0530 Subject: [PATCH 09/27] feat: Field Level Perms (#66) * feat: Field Level Perms * fix: keywords in field names --- frappe_graphql/utils/cursor_pagination.py | 8 +- frappe_graphql/utils/permissions.py | 44 +++++++++++ frappe_graphql/utils/resolver/child_tables.py | 5 ++ .../dataloaders/child_table_loader.py | 17 +++-- .../resolver/dataloaders/doctype_loader.py | 4 +- frappe_graphql/utils/resolver/root_query.py | 5 +- .../utils/tests/test_permissions.py | 75 +++++++++++++++++++ 7 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 frappe_graphql/utils/permissions.py create mode 100644 frappe_graphql/utils/tests/test_permissions.py diff --git a/frappe_graphql/utils/cursor_pagination.py b/frappe_graphql/utils/cursor_pagination.py index e44de4d..3554ab4 100644 --- a/frappe_graphql/utils/cursor_pagination.py +++ b/frappe_graphql/utils/cursor_pagination.py @@ -3,6 +3,8 @@ import frappe +from frappe_graphql.utils.permissions import get_allowed_fieldnames_for_doctype + class CursorPaginator(object): def __init__( @@ -142,12 +144,16 @@ def get_data(self, doctype, filters, sorting_fields, sort_dir, limit): return frappe.get_list( doctype, - fields=["*", f"SUBSTR(\".{doctype}\", 2) as doctype"] + sorting_fields, + fields=self.get_fields_to_fetch(doctype, filters, sorting_fields), filters=filters, order_by=f"{', '.join([f'{x} {sort_dir}' for x in sorting_fields])}", limit_page_length=limit ) + def get_fields_to_fetch(self, doctype, filters, sorting_fields): + fieldnames = get_allowed_fieldnames_for_doctype(doctype) + return list(set(fieldnames + [f"SUBSTR(\".{doctype}\", 2) as doctype"] + sorting_fields)) + def get_sort_args(self, sorting_input=None): sort_dir = self.default_sorting_direction if self.default_sorting_direction in ( "asc", "desc") else "desc" diff --git a/frappe_graphql/utils/permissions.py b/frappe_graphql/utils/permissions.py new file mode 100644 index 0000000..d73352a --- /dev/null +++ b/frappe_graphql/utils/permissions.py @@ -0,0 +1,44 @@ +import frappe +from frappe.model import default_fields, no_value_fields +from frappe.model.meta import Meta + + +def get_allowed_fieldnames_for_doctype(doctype: str, parent_doctype: str = None): + """ + Gets a list of fieldnames that's allowed for the current User to + read on the specified doctype. This includes default_fields + """ + fieldnames = list(default_fields) + ["\"{}\" as `doctype`".format(doctype)] + fieldnames.remove("doctype") + + meta = frappe.get_meta(doctype) + has_access_to = _get_permlevel_read_access(meta=frappe.get_meta(parent_doctype or doctype)) + if not has_access_to: + return [] + + for df in meta.fields: + if df.fieldtype in no_value_fields: + continue + + if df.permlevel is not None and df.permlevel not in has_access_to: + continue + + fieldnames.append(df.fieldname) + + return fieldnames + + +def _get_permlevel_read_access(meta: Meta): + ptype = "read" + _has_access_to = [] + roles = frappe.get_roles() + for perm in meta.permissions: + if perm.get("role") not in roles or not perm.get(ptype): + continue + + if perm.get("permlevel") in _has_access_to: + continue + + _has_access_to.append(perm.get("permlevel")) + + return _has_access_to diff --git a/frappe_graphql/utils/resolver/child_tables.py b/frappe_graphql/utils/resolver/child_tables.py index aa34527..83133ac 100644 --- a/frappe_graphql/utils/resolver/child_tables.py +++ b/frappe_graphql/utils/resolver/child_tables.py @@ -23,6 +23,11 @@ def setup_child_table_resolvers(schema: GraphQLSchema): def _child_table_resolver(obj, info: GraphQLResolveInfo, **kwargs): + # If the obj already has a non None value, we can return it. + # This happens when the resolver returns a full doc + if obj.get(info.field_name) is not None: + return obj.get(info.field_name) + df = getattr(info.parent_type.fields[info.field_name], "frappe_docfield", None) if not df: return [] diff --git a/frappe_graphql/utils/resolver/dataloaders/child_table_loader.py b/frappe_graphql/utils/resolver/dataloaders/child_table_loader.py index 69f2e07..1d861d1 100644 --- a/frappe_graphql/utils/resolver/dataloaders/child_table_loader.py +++ b/frappe_graphql/utils/resolver/dataloaders/child_table_loader.py @@ -1,10 +1,11 @@ +from collections import OrderedDict + import frappe from frappe_graphql.utils.execution import DataLoader +from frappe_graphql.utils.permissions import get_allowed_fieldnames_for_doctype from .locals import get_loader_from_locals, set_loader_in_locals -from collections import OrderedDict - def get_child_table_loader(child_doctype: str, parent_doctype: str, parentfield: str) -> DataLoader: locals_key = (child_doctype, parent_doctype, parentfield) @@ -23,10 +24,16 @@ def get_child_table_loader(child_doctype: str, parent_doctype: str, parentfield: def _get_child_table_loader_fn(child_doctype: str, parent_doctype: str, parentfield: str): def _inner(keys): + fieldnames = get_allowed_fieldnames_for_doctype( + doctype=child_doctype, + parent_doctype=parent_doctype + ) + + select_fields = ", ".join([f"`{x}`" if "`" not in x else x for x in fieldnames]) + rows = frappe.db.sql(f""" - SELECT - *, - "{child_doctype}" as doctype + SELECT + {select_fields} FROM `tab{child_doctype}` WHERE parent IN %(parent_keys)s diff --git a/frappe_graphql/utils/resolver/dataloaders/doctype_loader.py b/frappe_graphql/utils/resolver/dataloaders/doctype_loader.py index 7a8de10..1af49e2 100644 --- a/frappe_graphql/utils/resolver/dataloaders/doctype_loader.py +++ b/frappe_graphql/utils/resolver/dataloaders/doctype_loader.py @@ -3,6 +3,7 @@ import frappe from frappe_graphql.utils.execution import DataLoader +from frappe_graphql.utils.permissions import get_allowed_fieldnames_for_doctype from .locals import get_loader_from_locals, set_loader_in_locals @@ -17,12 +18,13 @@ def get_doctype_dataloader(doctype: str) -> DataLoader: def _get_document_loader_fn(doctype: str): + fieldnames = get_allowed_fieldnames_for_doctype(doctype) def _load_documents(keys: List[str]): docs = frappe.get_list( doctype=doctype, filters=[["name", "IN", keys]], - fields=["*", f"'{doctype}' as doctype"], + fields=fieldnames, limit_page_length=len(keys) + 1 ) diff --git a/frappe_graphql/utils/resolver/root_query.py b/frappe_graphql/utils/resolver/root_query.py index 1fa12c6..4bc698a 100644 --- a/frappe_graphql/utils/resolver/root_query.py +++ b/frappe_graphql/utils/resolver/root_query.py @@ -5,7 +5,6 @@ from frappe_graphql import CursorPaginator -from .dataloaders import get_doctype_dataloader from .utils import get_singular_doctype, get_plural_doctype @@ -39,7 +38,9 @@ def _get_doc_resolver(obj, info: GraphQLResolveInfo, **kwargs): if not frappe.has_permission(doctype=dt, doc=dn): raise frappe.PermissionError(frappe._("No permission for {0}").format(dt + " " + dn)) - return get_doctype_dataloader(dt).load(dn) + doc = frappe.get_doc(dt, dn) + doc.apply_fieldlevel_read_permissions() + return doc def _doc_cursor_resolver(obj, info: GraphQLResolveInfo, **kwargs): diff --git a/frappe_graphql/utils/tests/test_permissions.py b/frappe_graphql/utils/tests/test_permissions.py new file mode 100644 index 0000000..e82a43a --- /dev/null +++ b/frappe_graphql/utils/tests/test_permissions.py @@ -0,0 +1,75 @@ +from unittest import TestCase +from unittest.mock import patch + +import frappe +from frappe.model import no_value_fields, default_fields + +from ..permissions import get_allowed_fieldnames_for_doctype + + +class TestGetAllowedFieldNameForDocType(TestCase): + def setUp(self) -> None: + pass + + def tearDown(self) -> None: + frappe.set_user("Administrator") + + def test_admin_on_user(self): + """ + Administrator on User doctype + """ + meta = frappe.get_meta("User") + fieldnames = get_allowed_fieldnames_for_doctype("User") + self.assertCountEqual( + fieldnames, + [x.fieldname for x in meta.fields if x.fieldtype not in no_value_fields] + + [x for x in default_fields if x != "doctype"] + + ["\"{}\" as doctype".format(meta.name)] + ) + + def test_permlevels_on_user(self): + frappe.set_user("Guest") + + # Guest is given permlevel=0 access on User DocType + user_meta = self._get_custom_user_meta() + + with patch("frappe.get_meta") as get_meta_mock: + get_meta_mock.return_value = user_meta + fieldnames = get_allowed_fieldnames_for_doctype(user_meta.name) + + self.assertCountEqual( + fieldnames, + [x.fieldname for x in user_meta.fields + if x.permlevel == 1 and x.fieldtype not in no_value_fields] + + [x for x in default_fields if x != "doctype"] + + ["\"{}\" as doctype".format(user_meta.name)] + ) + + # Clear meta_cache for User doctype + del frappe.local.meta_cache["User"] + + def test_on_child_doctype(self): + fieldnames = get_allowed_fieldnames_for_doctype("Has Role", parent_doctype="User") + meta = frappe.get_meta("Has Role") + self.assertCountEqual( + fieldnames, + [x.fieldname for x in meta.fields if x.fieldtype not in no_value_fields] + + [x for x in default_fields if x != "doctype"] + + ["\"{}\" as doctype".format(meta.name)] + ) + + def test_on_child_doctype_with_no_parent_doctype(self): + fieldnames = get_allowed_fieldnames_for_doctype("Has Role") + self.assertEqual(fieldnames, []) + + def _get_custom_user_meta(self): + meta = frappe.get_meta("User") + meta.permissions.append(dict( + role="Guest", + read=1, + permlevel=1 + )) + + meta.get_field("full_name").permlevel = 1 + + return meta From 5152e947f9387a7ad52537dde348272f5b9977ec Mon Sep 17 00:00:00 2001 From: Fahim Ali Zain Date: Fri, 19 Aug 2022 16:03:35 +0530 Subject: [PATCH 10/27] fix: DeferredValue support for Mutations (#67) --- .../utils/execution/execution_context.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/frappe_graphql/utils/execution/execution_context.py b/frappe_graphql/utils/execution/execution_context.py index f322bd3..c07b730 100644 --- a/frappe_graphql/utils/execution/execution_context.py +++ b/frappe_graphql/utils/execution/execution_context.py @@ -149,6 +149,20 @@ async def get_results() -> Dict[str, Any]: return get_results() + def execute_fields_serially(self, *args, **kwargs): + result = super().execute_fields_serially(*args, **kwargs) + contains_deferred = False + + for v in result.values(): + if isinstance(v, DeferredValue): + contains_deferred = True + break + + if contains_deferred: + return deferred_dict(result) + + return result + def complete_value( self, return_type: GraphQLOutputType, From 6e6ab2c1fbc12de07d4f451d51cb9d57a43a4d0e Mon Sep 17 00:00:00 2001 From: Fahim Ali Zain Date: Sat, 20 Aug 2022 08:06:50 +0530 Subject: [PATCH 11/27] fix: Reduced no. of iterations in default schema binding --- frappe_graphql/utils/resolver/__init__.py | 39 ++++++++++++-- frappe_graphql/utils/resolver/child_tables.py | 25 ++++----- frappe_graphql/utils/resolver/link_field.py | 54 ++++++++----------- frappe_graphql/utils/resolver/utils.py | 6 +++ 4 files changed, 71 insertions(+), 53 deletions(-) diff --git a/frappe_graphql/utils/resolver/__init__.py b/frappe_graphql/utils/resolver/__init__.py index c5c205a..af2c0e7 100644 --- a/frappe_graphql/utils/resolver/__init__.py +++ b/frappe_graphql/utils/resolver/__init__.py @@ -1,16 +1,45 @@ -from graphql import GraphQLSchema +from graphql import GraphQLSchema, GraphQLType + +import frappe +from frappe.model.meta import Meta from .root_query import setup_root_query_resolvers from .link_field import setup_link_field_resolvers from .child_tables import setup_child_table_resolvers +from .utils import get_singular_doctype def setup_default_resolvers(schema: GraphQLSchema): setup_root_query_resolvers(schema=schema) - setup_link_field_resolvers(schema=schema) - setup_select_field_resolvers(schema=schema) - setup_child_table_resolvers(schema=schema) + + # Setup custom resolvers for DocTypes + for type_name, gql_type in schema.type_map.items(): + dt = get_singular_doctype(type_name) + if not dt: + continue + + meta = frappe.get_meta(dt) + + setup_frappe_df(meta, gql_type) + setup_link_field_resolvers(meta, gql_type) + setup_select_field_resolvers(meta, gql_type) + setup_child_table_resolvers(meta, gql_type) + + +def setup_frappe_df(meta: Meta, gql_type: GraphQLType): + """ + Sets up frappe-DocField on the GraphQLFields as `frappe_df`. + This is useful when resolving: + - Link / Dynamic Link Fields + - Child Tables + - Checking if the leaf-node is translatable + """ + for df in meta.fields: + if df.fieldname not in gql_type.fields: + continue + + gql_type.fields[df.fieldname].frappe_df = df -def setup_select_field_resolvers(schema: GraphQLSchema): +def setup_select_field_resolvers(meta: Meta, gql_type: GraphQLType): pass diff --git a/frappe_graphql/utils/resolver/child_tables.py b/frappe_graphql/utils/resolver/child_tables.py index 83133ac..0872203 100644 --- a/frappe_graphql/utils/resolver/child_tables.py +++ b/frappe_graphql/utils/resolver/child_tables.py @@ -1,25 +1,18 @@ -from graphql import GraphQLSchema, GraphQLResolveInfo +from graphql import GraphQLType, GraphQLResolveInfo -import frappe +from frappe.model.meta import Meta from .dataloaders import get_child_table_loader -from .utils import get_singular_doctype +from .utils import get_frappe_df_from_resolve_info -def setup_child_table_resolvers(schema: GraphQLSchema): - for type_name, gql_type in schema.type_map.items(): - dt = get_singular_doctype(type_name) - if not dt: +def setup_child_table_resolvers(meta: Meta, gql_type: GraphQLType): + for df in meta.get_table_fields(): + if df.fieldname not in gql_type.fields: continue - meta = frappe.get_meta(dt) - for df in meta.get_table_fields(): - if df.fieldname not in gql_type.fields: - continue - - gql_field = gql_type.fields[df.fieldname] - gql_field.frappe_docfield = df - gql_field.resolve = _child_table_resolver + gql_field = gql_type.fields[df.fieldname] + gql_field.resolve = _child_table_resolver def _child_table_resolver(obj, info: GraphQLResolveInfo, **kwargs): @@ -28,7 +21,7 @@ def _child_table_resolver(obj, info: GraphQLResolveInfo, **kwargs): if obj.get(info.field_name) is not None: return obj.get(info.field_name) - df = getattr(info.parent_type.fields[info.field_name], "frappe_docfield", None) + df = get_frappe_df_from_resolve_info(info) if not df: return [] diff --git a/frappe_graphql/utils/resolver/link_field.py b/frappe_graphql/utils/resolver/link_field.py index 5aece5e..530b082 100644 --- a/frappe_graphql/utils/resolver/link_field.py +++ b/frappe_graphql/utils/resolver/link_field.py @@ -1,46 +1,40 @@ -from graphql import GraphQLSchema, GraphQLResolveInfo +from graphql import GraphQLResolveInfo, GraphQLType import frappe +from frappe.model.meta import Meta from .dataloaders import get_doctype_dataloader -from .utils import get_singular_doctype +from .utils import get_frappe_df_from_resolve_info -def setup_link_field_resolvers(schema: GraphQLSchema): +def setup_link_field_resolvers(meta: Meta, gql_type: GraphQLType): """ This will set up Link fields on DocTypes to resolve target docs """ - for type_name, gql_type in schema.type_map.items(): - dt = get_singular_doctype(type_name) - if not dt: - continue - - meta = frappe.get_meta(dt) - link_dfs = meta.get_link_fields() + meta.get_dynamic_link_fields() + \ - _get_default_field_links() + link_dfs = meta.get_link_fields() + meta.get_dynamic_link_fields() + \ + _get_default_field_links() - for df in link_dfs: - if df.fieldname not in gql_type.fields: - continue + for df in link_dfs: + if df.fieldname not in gql_type.fields: + continue - gql_field = gql_type.fields[df.fieldname] - gql_field.frappe_docfield = df - if df.fieldtype == "Link": - gql_field.resolve = _resolve_link_field - elif df.fieldtype == "Dynamic Link": - gql_field.resolve = _resolve_dynamic_link_field - else: - continue + gql_field = gql_type.fields[df.fieldname] + if df.fieldtype == "Link": + gql_field.resolve = _resolve_link_field + elif df.fieldtype == "Dynamic Link": + gql_field.resolve = _resolve_dynamic_link_field + else: + continue - _name_df = f"{df.fieldname}__name" - if _name_df not in gql_type.fields: - continue + _name_df = f"{df.fieldname}__name" + if _name_df not in gql_type.fields: + continue - gql_type.fields[_name_df].resolve = _resolve_link_name_field + gql_type.fields[_name_df].resolve = _resolve_link_name_field def _resolve_link_field(obj, info: GraphQLResolveInfo, **kwargs): - df = _get_frappe_docfield_from_resolve_info(info) + df = get_frappe_df_from_resolve_info(info) if not df: return None @@ -55,7 +49,7 @@ def _resolve_link_field(obj, info: GraphQLResolveInfo, **kwargs): def _resolve_dynamic_link_field(obj, info: GraphQLResolveInfo, **kwargs): - df = _get_frappe_docfield_from_resolve_info(info) + df = get_frappe_df_from_resolve_info(info) if not df: return None @@ -76,10 +70,6 @@ def _resolve_link_name_field(obj, info: GraphQLResolveInfo, **kwargs): return obj.get(df) -def _get_frappe_docfield_from_resolve_info(info: GraphQLResolveInfo): - return getattr(info.parent_type.fields[info.field_name], "frappe_docfield", None) - - def _get_default_field_links(): def _get_default_field_df(fieldname): diff --git a/frappe_graphql/utils/resolver/utils.py b/frappe_graphql/utils/resolver/utils.py index 3dbb957..395ea64 100644 --- a/frappe_graphql/utils/resolver/utils.py +++ b/frappe_graphql/utils/resolver/utils.py @@ -1,3 +1,5 @@ +from graphql import GraphQLResolveInfo + import frappe SINGULAR_DOCTYPE_MAP_REDIS_KEY = "singular_doctype_graphql_map" @@ -42,3 +44,7 @@ def get_plural_doctype(name): frappe.cache().set_value(PLURAL_DOCTYPE_MAP_REDIS_KEY, plural_map) return plural_map.get(name, None) + + +def get_frappe_df_from_resolve_info(info: GraphQLResolveInfo): + return getattr(info.parent_type.fields[info.field_name], "frappe_df", None) From b7e206b3245a647c38cab07f4f9adeb54e55d3a4 Mon Sep 17 00:00:00 2001 From: Fahim Ali Zain Date: Mon, 22 Aug 2022 02:40:20 +0000 Subject: [PATCH 12/27] feat: Introduce hook 'doctype_resolver_processors' Co-authored-by: Fahim Ali Zain Merge-request: ROMMAN-MR-178 Merged-by: Fahim Ali Zain --- frappe_graphql/utils/resolver/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frappe_graphql/utils/resolver/__init__.py b/frappe_graphql/utils/resolver/__init__.py index af2c0e7..bf9810c 100644 --- a/frappe_graphql/utils/resolver/__init__.py +++ b/frappe_graphql/utils/resolver/__init__.py @@ -12,6 +12,8 @@ def setup_default_resolvers(schema: GraphQLSchema): setup_root_query_resolvers(schema=schema) + doctype_resolver_processors = frappe.get_hooks("doctype_resolver_processors") + # Setup custom resolvers for DocTypes for type_name, gql_type in schema.type_map.items(): dt = get_singular_doctype(type_name) @@ -25,6 +27,9 @@ def setup_default_resolvers(schema: GraphQLSchema): setup_select_field_resolvers(meta, gql_type) setup_child_table_resolvers(meta, gql_type) + for cmd in doctype_resolver_processors: + frappe.get_attr(cmd)(meta=meta, gql_type=gql_type) + def setup_frappe_df(meta: Meta, gql_type: GraphQLType): """ From 974538c751fab3a9776e6f75aed8a80bce8bcd2a Mon Sep 17 00:00:00 2001 From: Fahim Ali Zain Date: Mon, 22 Aug 2022 07:39:29 +0000 Subject: [PATCH 13/27] feat: Translations Support fix: remove redundant resolver check Merge branch 'ROMMAN-T-289-kick-default-resolver' into ROMMAN-T-481-translations feat: Translations Support Co-authored-by: Fahim Ali Zain Merge-request: ROMMAN-MR-177 Merged-by: Fahim Ali Zain --- frappe_graphql/utils/resolver/__init__.py | 2 ++ frappe_graphql/utils/resolver/translate.py | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 frappe_graphql/utils/resolver/translate.py diff --git a/frappe_graphql/utils/resolver/__init__.py b/frappe_graphql/utils/resolver/__init__.py index bf9810c..c937b51 100644 --- a/frappe_graphql/utils/resolver/__init__.py +++ b/frappe_graphql/utils/resolver/__init__.py @@ -6,6 +6,7 @@ from .root_query import setup_root_query_resolvers from .link_field import setup_link_field_resolvers from .child_tables import setup_child_table_resolvers +from .translate import setup_translatable_resolvers from .utils import get_singular_doctype @@ -26,6 +27,7 @@ def setup_default_resolvers(schema: GraphQLSchema): setup_link_field_resolvers(meta, gql_type) setup_select_field_resolvers(meta, gql_type) setup_child_table_resolvers(meta, gql_type) + setup_translatable_resolvers(meta, gql_type) for cmd in doctype_resolver_processors: frappe.get_attr(cmd)(meta=meta, gql_type=gql_type) diff --git a/frappe_graphql/utils/resolver/translate.py b/frappe_graphql/utils/resolver/translate.py new file mode 100644 index 0000000..e183f3e --- /dev/null +++ b/frappe_graphql/utils/resolver/translate.py @@ -0,0 +1,21 @@ +from graphql import GraphQLResolveInfo, GraphQLType + +import frappe +from frappe.model.meta import Meta + + +def setup_translatable_resolvers(meta: Meta, gql_type: GraphQLType): + for df_fieldname in meta.get_translatable_fields(): + if df_fieldname not in gql_type.fields: + continue + + gql_field = gql_type.fields[df_fieldname] + gql_field.resolve = _translatable_resolver + + +def _translatable_resolver(obj, info: GraphQLResolveInfo, **kwargs): + value = obj.get(info.field_name) + if isinstance(value, str) and value: + value = frappe._(value) + + return value From 8518a8923037821b597c7b86676f04d94b567e9b Mon Sep 17 00:00:00 2001 From: Fahim Ali Zain Date: Wed, 24 Aug 2022 15:19:00 +0530 Subject: [PATCH 14/27] fix: Setup GQLType.doctype resolver manually --- frappe_graphql/utils/cursor_pagination.py | 2 +- frappe_graphql/utils/permissions.py | 2 +- frappe_graphql/utils/resolver/__init__.py | 18 +++++++++++++++++- frappe_graphql/utils/tests/test_permissions.py | 3 --- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/frappe_graphql/utils/cursor_pagination.py b/frappe_graphql/utils/cursor_pagination.py index 3554ab4..75270ad 100644 --- a/frappe_graphql/utils/cursor_pagination.py +++ b/frappe_graphql/utils/cursor_pagination.py @@ -152,7 +152,7 @@ def get_data(self, doctype, filters, sorting_fields, sort_dir, limit): def get_fields_to_fetch(self, doctype, filters, sorting_fields): fieldnames = get_allowed_fieldnames_for_doctype(doctype) - return list(set(fieldnames + [f"SUBSTR(\".{doctype}\", 2) as doctype"] + sorting_fields)) + return list(set(fieldnames + sorting_fields)) def get_sort_args(self, sorting_input=None): sort_dir = self.default_sorting_direction if self.default_sorting_direction in ( diff --git a/frappe_graphql/utils/permissions.py b/frappe_graphql/utils/permissions.py index d73352a..e88310c 100644 --- a/frappe_graphql/utils/permissions.py +++ b/frappe_graphql/utils/permissions.py @@ -8,7 +8,7 @@ def get_allowed_fieldnames_for_doctype(doctype: str, parent_doctype: str = None) Gets a list of fieldnames that's allowed for the current User to read on the specified doctype. This includes default_fields """ - fieldnames = list(default_fields) + ["\"{}\" as `doctype`".format(doctype)] + fieldnames = list(default_fields) fieldnames.remove("doctype") meta = frappe.get_meta(doctype) diff --git a/frappe_graphql/utils/resolver/__init__.py b/frappe_graphql/utils/resolver/__init__.py index c937b51..ea391f3 100644 --- a/frappe_graphql/utils/resolver/__init__.py +++ b/frappe_graphql/utils/resolver/__init__.py @@ -1,4 +1,4 @@ -from graphql import GraphQLSchema, GraphQLType +from graphql import GraphQLSchema, GraphQLType, GraphQLResolveInfo import frappe from frappe.model.meta import Meta @@ -24,6 +24,7 @@ def setup_default_resolvers(schema: GraphQLSchema): meta = frappe.get_meta(dt) setup_frappe_df(meta, gql_type) + setup_doctype_resolver(meta, gql_type) setup_link_field_resolvers(meta, gql_type) setup_select_field_resolvers(meta, gql_type) setup_child_table_resolvers(meta, gql_type) @@ -48,5 +49,20 @@ def setup_frappe_df(meta: Meta, gql_type: GraphQLType): gql_type.fields[df.fieldname].frappe_df = df +def setup_doctype_resolver(meta: Meta, gql_type: GraphQLType): + """ + Sets custom resolver to BaseDocument.doctype field + """ + if "doctype" not in gql_type.fields: + return + + gql_type.fields["doctype"].resolve = _doctype_resolver + + +def _doctype_resolver(obj, info: GraphQLResolveInfo, **kwargs): + dt = get_singular_doctype(info.parent_type.name) + return dt + + def setup_select_field_resolvers(meta: Meta, gql_type: GraphQLType): pass diff --git a/frappe_graphql/utils/tests/test_permissions.py b/frappe_graphql/utils/tests/test_permissions.py index e82a43a..f73e4cd 100644 --- a/frappe_graphql/utils/tests/test_permissions.py +++ b/frappe_graphql/utils/tests/test_permissions.py @@ -24,7 +24,6 @@ def test_admin_on_user(self): fieldnames, [x.fieldname for x in meta.fields if x.fieldtype not in no_value_fields] + [x for x in default_fields if x != "doctype"] - + ["\"{}\" as doctype".format(meta.name)] ) def test_permlevels_on_user(self): @@ -42,7 +41,6 @@ def test_permlevels_on_user(self): [x.fieldname for x in user_meta.fields if x.permlevel == 1 and x.fieldtype not in no_value_fields] + [x for x in default_fields if x != "doctype"] - + ["\"{}\" as doctype".format(user_meta.name)] ) # Clear meta_cache for User doctype @@ -55,7 +53,6 @@ def test_on_child_doctype(self): fieldnames, [x.fieldname for x in meta.fields if x.fieldtype not in no_value_fields] + [x for x in default_fields if x != "doctype"] - + ["\"{}\" as doctype".format(meta.name)] ) def test_on_child_doctype_with_no_parent_doctype(self): From f354001e498fd290c91aeebc07cca02aa064d4de Mon Sep 17 00:00:00 2001 From: Abadulrehman Date: Thu, 25 Aug 2022 03:45:37 +0000 Subject: [PATCH 15/27] feat: implement select field resolver Co-authored-by: Abadulrehman Merge-request: ROMMAN-MR-196 Merged-by: Fahim Ali Zain --- frappe_graphql/utils/resolver/__init__.py | 5 +-- .../utils/resolver/select_fields.py | 35 +++++++++++++++++++ frappe_graphql/utils/resolver/translate.py | 4 +++ 3 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 frappe_graphql/utils/resolver/select_fields.py diff --git a/frappe_graphql/utils/resolver/__init__.py b/frappe_graphql/utils/resolver/__init__.py index ea391f3..8e694cb 100644 --- a/frappe_graphql/utils/resolver/__init__.py +++ b/frappe_graphql/utils/resolver/__init__.py @@ -5,6 +5,7 @@ from .root_query import setup_root_query_resolvers from .link_field import setup_link_field_resolvers +from .select_fields import setup_select_field_resolvers from .child_tables import setup_child_table_resolvers from .translate import setup_translatable_resolvers from .utils import get_singular_doctype @@ -62,7 +63,3 @@ def setup_doctype_resolver(meta: Meta, gql_type: GraphQLType): def _doctype_resolver(obj, info: GraphQLResolveInfo, **kwargs): dt = get_singular_doctype(info.parent_type.name) return dt - - -def setup_select_field_resolvers(meta: Meta, gql_type: GraphQLType): - pass diff --git a/frappe_graphql/utils/resolver/select_fields.py b/frappe_graphql/utils/resolver/select_fields.py new file mode 100644 index 0000000..5099abf --- /dev/null +++ b/frappe_graphql/utils/resolver/select_fields.py @@ -0,0 +1,35 @@ +from graphql import GraphQLType, GraphQLResolveInfo, GraphQLNonNull, GraphQLEnumType + +import frappe +from frappe.model.meta import Meta + +from .translate import _translatable_resolver +from .utils import get_frappe_df_from_resolve_info + + +def setup_select_field_resolvers(meta: Meta, gql_type: GraphQLType): + + for df in meta.get_select_fields(): + + if df.fieldname not in gql_type.fields: + continue + + gql_field = gql_type.fields[df.fieldname] + gql_field.resolve = _select_field_resolver + + +def _select_field_resolver(obj, info: GraphQLResolveInfo, **kwargs): + + df = get_frappe_df_from_resolve_info(info) + return_type = info.return_type + + if isinstance(return_type, GraphQLNonNull): + return_type = return_type.of_type + + if isinstance(return_type, GraphQLEnumType): + return frappe.scrub(obj.get(info.field_name)).upper() + + if df and df.translatable: + return _translatable_resolver(obj, info, **kwargs) + + return obj.get(info.field_name) diff --git a/frappe_graphql/utils/resolver/translate.py b/frappe_graphql/utils/resolver/translate.py index e183f3e..185af55 100644 --- a/frappe_graphql/utils/resolver/translate.py +++ b/frappe_graphql/utils/resolver/translate.py @@ -10,6 +10,10 @@ def setup_translatable_resolvers(meta: Meta, gql_type: GraphQLType): continue gql_field = gql_type.fields[df_fieldname] + + if gql_field.resolve: + continue + gql_field.resolve = _translatable_resolver From 4a04f0af9a7b23fa277e58ae54afaf5b356b624d Mon Sep 17 00:00:00 2001 From: Elton Lobo <41537985+e-lobo@users.noreply.github.com> Date: Thu, 25 Aug 2022 07:54:42 +0400 Subject: [PATCH 16/27] refactor: check if return type is scalar before link field binded (#70) --- frappe_graphql/utils/resolver/link_field.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frappe_graphql/utils/resolver/link_field.py b/frappe_graphql/utils/resolver/link_field.py index 530b082..4599dbc 100644 --- a/frappe_graphql/utils/resolver/link_field.py +++ b/frappe_graphql/utils/resolver/link_field.py @@ -1,4 +1,4 @@ -from graphql import GraphQLResolveInfo, GraphQLType +from graphql import GraphQLResolveInfo, GraphQLType, is_scalar_type import frappe from frappe.model.meta import Meta @@ -15,7 +15,8 @@ def setup_link_field_resolvers(meta: Meta, gql_type: GraphQLType): _get_default_field_links() for df in link_dfs: - if df.fieldname not in gql_type.fields: + if df.fieldname not in gql_type.fields or is_scalar_type( + gql_type.fields[df.fieldname].type): continue gql_field = gql_type.fields[df.fieldname] @@ -71,7 +72,6 @@ def _resolve_link_name_field(obj, info: GraphQLResolveInfo, **kwargs): def _get_default_field_links(): - def _get_default_field_df(fieldname): df = frappe._dict( fieldname=fieldname, From de30ebd43f139cd913130564f9ab14017af41d80 Mon Sep 17 00:00:00 2001 From: Fahim Ali Zain Date: Fri, 26 Aug 2022 10:04:45 +0530 Subject: [PATCH 17/27] fix: get_allowed_fieldnames_for_doctype on plain child-doctype support --- frappe_graphql/utils/permissions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frappe_graphql/utils/permissions.py b/frappe_graphql/utils/permissions.py index e88310c..7b79682 100644 --- a/frappe_graphql/utils/permissions.py +++ b/frappe_graphql/utils/permissions.py @@ -29,6 +29,9 @@ def get_allowed_fieldnames_for_doctype(doctype: str, parent_doctype: str = None) def _get_permlevel_read_access(meta: Meta): + if meta.istable: + return [0] + ptype = "read" _has_access_to = [] roles = frappe.get_roles() From 54e0715f97d3c1afb41ee3d93d6e9d30c1b372a4 Mon Sep 17 00:00:00 2001 From: Fahim Ali Zain Date: Fri, 26 Aug 2022 16:21:09 +0530 Subject: [PATCH 18/27] fix: default_fields link fields like owner --- frappe_graphql/utils/resolver/__init__.py | 4 ++- frappe_graphql/utils/resolver/link_field.py | 20 +++----------- frappe_graphql/utils/resolver/utils.py | 30 +++++++++++++++++++++ 3 files changed, 36 insertions(+), 18 deletions(-) diff --git a/frappe_graphql/utils/resolver/__init__.py b/frappe_graphql/utils/resolver/__init__.py index 8e694cb..17c98c4 100644 --- a/frappe_graphql/utils/resolver/__init__.py +++ b/frappe_graphql/utils/resolver/__init__.py @@ -43,7 +43,9 @@ def setup_frappe_df(meta: Meta, gql_type: GraphQLType): - Child Tables - Checking if the leaf-node is translatable """ - for df in meta.fields: + from .utils import get_default_fields_docfield + fields = meta.fields + get_default_fields_docfield() + for df in fields: if df.fieldname not in gql_type.fields: continue diff --git a/frappe_graphql/utils/resolver/link_field.py b/frappe_graphql/utils/resolver/link_field.py index 4599dbc..ce6c3c5 100644 --- a/frappe_graphql/utils/resolver/link_field.py +++ b/frappe_graphql/utils/resolver/link_field.py @@ -72,23 +72,9 @@ def _resolve_link_name_field(obj, info: GraphQLResolveInfo, **kwargs): def _get_default_field_links(): - def _get_default_field_df(fieldname): - df = frappe._dict( - fieldname=fieldname, - fieldtype="Data" - ) - if fieldname in ("owner", "modified_by"): - df.fieldtype = "Link" - df.options = "User" - - if fieldname == "parent": - df.fieldtype = "Dynamic Link" - df.options = "parenttype" - - return df + from .utils import get_default_fields_docfield return [ - _get_default_field_df(x) for x in [ - "owner", "modified_by", "parent" - ] + x for x in get_default_fields_docfield() + if x.fieldtype in ["Link", "Dynamic Link"] ] diff --git a/frappe_graphql/utils/resolver/utils.py b/frappe_graphql/utils/resolver/utils.py index 395ea64..9b35bbe 100644 --- a/frappe_graphql/utils/resolver/utils.py +++ b/frappe_graphql/utils/resolver/utils.py @@ -48,3 +48,33 @@ def get_plural_doctype(name): def get_frappe_df_from_resolve_info(info: GraphQLResolveInfo): return getattr(info.parent_type.fields[info.field_name], "frappe_df", None) + + +def get_default_fields_docfield(): + """ + from frappe.model import default_fields are included on all DocTypes + But, DocMeta do not include them in the fields + """ + from frappe.model import default_fields + + def _get_default_field_df(fieldname): + df = frappe._dict( + fieldname=fieldname, + fieldtype="Data" + ) + if fieldname in ("owner", "modified_by"): + df.fieldtype = "Link" + df.options = "User" + + if fieldname == "parent": + df.fieldtype = "Dynamic Link" + df.options = "parenttype" + + if fieldname in ["docstatus", "idx"]: + df.fieldtype = "Int" + + return df + + return [ + _get_default_field_df(x) for x in default_fields + ] From ea01ae7bc499a6955265ff168ae179324d5f057c Mon Sep 17 00:00:00 2001 From: Fahim Ali Zain Date: Fri, 26 Aug 2022 16:51:46 +0530 Subject: [PATCH 19/27] fix: cache get_allowed_fieldnames_for_doctype at the request level --- frappe_graphql/utils/permissions.py | 38 +++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/frappe_graphql/utils/permissions.py b/frappe_graphql/utils/permissions.py index 7b79682..4f202b6 100644 --- a/frappe_graphql/utils/permissions.py +++ b/frappe_graphql/utils/permissions.py @@ -1,3 +1,4 @@ +from typing import List import frappe from frappe.model import default_fields, no_value_fields from frappe.model.meta import Meta @@ -8,6 +9,10 @@ def get_allowed_fieldnames_for_doctype(doctype: str, parent_doctype: str = None) Gets a list of fieldnames that's allowed for the current User to read on the specified doctype. This includes default_fields """ + _from_locals = _get_allowed_fieldnames_from_locals(doctype, parent_doctype) + if _from_locals is not None: + return _from_locals + fieldnames = list(default_fields) fieldnames.remove("doctype") @@ -25,6 +30,12 @@ def get_allowed_fieldnames_for_doctype(doctype: str, parent_doctype: str = None) fieldnames.append(df.fieldname) + _set_allowed_fieldnames_to_locals( + allowed_fields=fieldnames, + doctype=doctype, + parent_doctype=parent_doctype + ) + return fieldnames @@ -45,3 +56,30 @@ def _get_permlevel_read_access(meta: Meta): _has_access_to.append(perm.get("permlevel")) return _has_access_to + + +def _get_allowed_fieldnames_from_locals(doctype: str, parent_doctype: str = None): + + if not hasattr(frappe.local, "permlevel_fields"): + frappe.local.permlevel_fields = dict() + + k = doctype + if parent_doctype: + k = (doctype, parent_doctype) + + return frappe.local.permlevel_fields.get(k) + + +def _set_allowed_fieldnames_to_locals( + allowed_fields: List[str], + doctype: str, + parent_doctype: str = None): + + if not hasattr(frappe.local, "permlevel_fields"): + frappe.local.permlevel_fields = dict() + + k = doctype + if parent_doctype: + k = (doctype, parent_doctype) + + frappe.local.permlevel_fields[k] = allowed_fields From 040d21b0ede282875be96ddbc048d7f3968b275e Mon Sep 17 00:00:00 2001 From: Elton Lobo <41537985+e-lobo@users.noreply.github.com> Date: Thu, 8 Sep 2022 11:52:16 +0400 Subject: [PATCH 20/27] feat: pre load schema's utility (#78) --- frappe_graphql/utils/pre_load_schemas.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 frappe_graphql/utils/pre_load_schemas.py diff --git a/frappe_graphql/utils/pre_load_schemas.py b/frappe_graphql/utils/pre_load_schemas.py new file mode 100644 index 0000000..e4bc5b7 --- /dev/null +++ b/frappe_graphql/utils/pre_load_schemas.py @@ -0,0 +1,24 @@ +def pre_load_schemas(): + """ + Can be called in https://docs.gunicorn.org/en/stable/settings.html#pre-fork + to pre-load the all sites schema's on all workers. + """ + from frappe.utils import get_sites + from frappe import init_site, init, connect, get_installed_apps, destroy + with init_site(): + sites = get_sites() + + for site in sites: + import frappe + frappe.local.initialised = False + init(site=site) + connect(site) + if "frappe_graphql" not in get_installed_apps(): + continue + try: + from frappe_graphql import get_schema + get_schema() + except Exception: + print(f"Failed to build schema for site {site}") + finally: + destroy() From 800bd262d029be5ebec3d6f1ab1950235e655539 Mon Sep 17 00:00:00 2001 From: Fahim Ali Zain Date: Fri, 9 Sep 2022 08:54:08 +0000 Subject: [PATCH 21/27] [ROMMAN-T-521] GQL Dataloader: Raise Perm Error on GQLNonNull Permlevel Restricted Fields fix: check for GraphQLNonNull fix: use default_field_resolver from graphql fix: refactored perm checks fix: Raise Perm Error on GQLNonNull Permlevel Restricted Fields Co-authored-by: Fahim Ali Zain Merge-request: ROMMAN-MR-214 Merged-by: Fahim Ali Zain --- frappe_graphql/utils/permissions.py | 17 +++++++ frappe_graphql/utils/resolver/__init__.py | 34 +++++++++++++- frappe_graphql/utils/resolver/link_field.py | 1 - .../utils/resolver/select_fields.py | 3 +- frappe_graphql/utils/resolver/utils.py | 44 +++++++++++++++++++ 5 files changed, 96 insertions(+), 3 deletions(-) diff --git a/frappe_graphql/utils/permissions.py b/frappe_graphql/utils/permissions.py index 4f202b6..baaad9f 100644 --- a/frappe_graphql/utils/permissions.py +++ b/frappe_graphql/utils/permissions.py @@ -39,6 +39,23 @@ def get_allowed_fieldnames_for_doctype(doctype: str, parent_doctype: str = None) return fieldnames +def is_field_permlevel_restricted_for_doctype( + fieldname: str, doctype: str, parent_doctype: str = None): + """ + Returns a boolean when the given field is restricted for the current User under permlevel + """ + meta = frappe.get_meta(doctype) + if meta.get_field(fieldname) is None: + return False + + allowed_fieldnames = get_allowed_fieldnames_for_doctype( + doctype=doctype, parent_doctype=parent_doctype) + if fieldname not in allowed_fieldnames: + return True + + return False + + def _get_permlevel_read_access(meta: Meta): if meta.istable: return [0] diff --git a/frappe_graphql/utils/resolver/__init__.py b/frappe_graphql/utils/resolver/__init__.py index 17c98c4..e93d66c 100644 --- a/frappe_graphql/utils/resolver/__init__.py +++ b/frappe_graphql/utils/resolver/__init__.py @@ -1,4 +1,4 @@ -from graphql import GraphQLSchema, GraphQLType, GraphQLResolveInfo +from graphql import GraphQLSchema, GraphQLType, GraphQLResolveInfo, GraphQLNonNull import frappe from frappe.model.meta import Meta @@ -31,6 +31,9 @@ def setup_default_resolvers(schema: GraphQLSchema): setup_child_table_resolvers(meta, gql_type) setup_translatable_resolvers(meta, gql_type) + # Wrap all the resolvers set above with a mandatory-checker + setup_mandatory_resolver(meta, gql_type) + for cmd in doctype_resolver_processors: frappe.get_attr(cmd)(meta=meta, gql_type=gql_type) @@ -62,6 +65,35 @@ def setup_doctype_resolver(meta: Meta, gql_type: GraphQLType): gql_type.fields["doctype"].resolve = _doctype_resolver +def setup_mandatory_resolver(meta: Meta, gql_type: GraphQLType): + """ + When mandatory fields return None, it might be due to restricted permlevel access + So when we find a Null value being returned and the field requested is restricted to + the current User, we raise Permission Error instead of: + + "Cannot return null for non-nullable field ..." + + """ + from graphql.execution.execute import default_field_resolver + from .utils import field_permlevel_check + + for df in meta.fields: + if not df.reqd: + continue + + if df.fieldname not in gql_type.fields: + continue + + gql_field = gql_type.fields[df.fieldname] + if not isinstance(gql_field.type, GraphQLNonNull): + continue + + if gql_field.resolve: + gql_field.resolve = field_permlevel_check(gql_field.resolve) + else: + gql_field.resolve = field_permlevel_check(default_field_resolver) + + def _doctype_resolver(obj, info: GraphQLResolveInfo, **kwargs): dt = get_singular_doctype(info.parent_type.name) return dt diff --git a/frappe_graphql/utils/resolver/link_field.py b/frappe_graphql/utils/resolver/link_field.py index ce6c3c5..9ace2e9 100644 --- a/frappe_graphql/utils/resolver/link_field.py +++ b/frappe_graphql/utils/resolver/link_field.py @@ -1,6 +1,5 @@ from graphql import GraphQLResolveInfo, GraphQLType, is_scalar_type -import frappe from frappe.model.meta import Meta from .dataloaders import get_doctype_dataloader diff --git a/frappe_graphql/utils/resolver/select_fields.py b/frappe_graphql/utils/resolver/select_fields.py index 5099abf..b827fc4 100644 --- a/frappe_graphql/utils/resolver/select_fields.py +++ b/frappe_graphql/utils/resolver/select_fields.py @@ -23,11 +23,12 @@ def _select_field_resolver(obj, info: GraphQLResolveInfo, **kwargs): df = get_frappe_df_from_resolve_info(info) return_type = info.return_type + value = obj.get(info.field_name) if isinstance(return_type, GraphQLNonNull): return_type = return_type.of_type if isinstance(return_type, GraphQLEnumType): - return frappe.scrub(obj.get(info.field_name)).upper() + return frappe.scrub(value).upper() if df and df.translatable: return _translatable_resolver(obj, info, **kwargs) diff --git a/frappe_graphql/utils/resolver/utils.py b/frappe_graphql/utils/resolver/utils.py index 9b35bbe..7ebfe37 100644 --- a/frappe_graphql/utils/resolver/utils.py +++ b/frappe_graphql/utils/resolver/utils.py @@ -2,6 +2,9 @@ import frappe +from frappe_graphql.utils.permissions import is_field_permlevel_restricted_for_doctype + + SINGULAR_DOCTYPE_MAP_REDIS_KEY = "singular_doctype_graphql_map" PLURAL_DOCTYPE_MAP_REDIS_KEY = "plural_doctype_graphql_map" @@ -50,6 +53,47 @@ def get_frappe_df_from_resolve_info(info: GraphQLResolveInfo): return getattr(info.parent_type.fields[info.field_name], "frappe_df", None) +def field_permlevel_check(resolver): + """ + A helper function when wrapped will check if the field + being resolved is permlevel restricted & GQLNonNullField + + If permlevel restriction is applied on the field, None is returned. + This will raise 'You cannot return Null on a NonNull field' error. + This helper function will change it to a permission error. + """ + import functools + + @functools.wraps(resolver) + def _inner(obj, info: GraphQLResolveInfo, **kwargs): + value = obj.get(info.field_name) + if value is not None: + return resolver(obj, info, **kwargs) + + # Ok, so value is None, and this field is Non-Null + df = get_frappe_df_from_resolve_info(info) + if not df or not df.parent: + return + + dt = df.parent + parent_dt = obj.get("parenttype") + + is_permlevel_restricted = is_field_permlevel_restricted_for_doctype( + fieldname=info.field_name, doctype=dt, parent_doctype=parent_dt) + + if is_permlevel_restricted: + raise frappe.PermissionError(frappe._( + "You do not have read permission on field '{0}' in DocType '{1}'" + ).format( + info.field_name, + "{} ({})".format(dt, parent_dt) if parent_dt else dt + )) + + return resolver(obj, info, **kwargs) + + return _inner + + def get_default_fields_docfield(): """ from frappe.model import default_fields are included on all DocTypes From b97b6750bd50d5cbff5327ba69ae4d07c6b41e7a Mon Sep 17 00:00:00 2001 From: Elton Lobo <41537985+e-lobo@users.noreply.github.com> Date: Tue, 4 Oct 2022 07:58:57 +0400 Subject: [PATCH 22/27] use new graphql-sync-dataloaders package (#81) * refactor: use graphql-sync-dataloader package * refactor: update package graphql-sync-dataloader --- frappe_graphql/graphql.py | 3 +- frappe_graphql/utils/execution/__init__.py | 2 - frappe_graphql/utils/execution/dataloader.py | 31 -- .../utils/execution/deferred_value.py | 210 ---------- .../utils/execution/execution_context.py | 381 ------------------ .../dataloaders/child_table_loader.py | 8 +- .../resolver/dataloaders/doctype_loader.py | 8 +- .../utils/resolver/dataloaders/locals.py | 4 +- requirements.txt | 1 + 9 files changed, 13 insertions(+), 635 deletions(-) delete mode 100644 frappe_graphql/utils/execution/dataloader.py delete mode 100644 frappe_graphql/utils/execution/deferred_value.py delete mode 100644 frappe_graphql/utils/execution/execution_context.py diff --git a/frappe_graphql/graphql.py b/frappe_graphql/graphql.py index 65120a8..47a89e7 100644 --- a/frappe_graphql/graphql.py +++ b/frappe_graphql/graphql.py @@ -1,8 +1,9 @@ +from graphql_sync_dataloaders import DeferredExecutionContext + import frappe import graphql from frappe_graphql.utils.loader import get_schema -from frappe_graphql.utils.execution import DeferredExecutionContext @frappe.whitelist(allow_guest=True) diff --git a/frappe_graphql/utils/execution/__init__.py b/frappe_graphql/utils/execution/__init__.py index 09c67b1..e69de29 100644 --- a/frappe_graphql/utils/execution/__init__.py +++ b/frappe_graphql/utils/execution/__init__.py @@ -1,2 +0,0 @@ -from .dataloader import DataLoader # noqa: F401 -from .execution_context import DeferredExecutionContext # noqa: F401 diff --git a/frappe_graphql/utils/execution/dataloader.py b/frappe_graphql/utils/execution/dataloader.py deleted file mode 100644 index a6718c5..0000000 --- a/frappe_graphql/utils/execution/dataloader.py +++ /dev/null @@ -1,31 +0,0 @@ -class DataLoader: - class LazyValue: - def __init__(self, key, dataloader): - self.key = key - self.dataloader = dataloader - - def get(self): - return self.dataloader.get(self.key) - - def __init__(self, load_fn): - self.load_fn = load_fn - self.pending_ids = set() - self.loaded_ids = {} - - def load(self, key): - lazy_value = DataLoader.LazyValue(key, self) - self.pending_ids.add(key) - - return lazy_value - - def get(self, key): - if key in self.loaded_ids: - return self.loaded_ids.get(key) - - keys = self.pending_ids - values = self.load_fn(keys) - for k, value in zip(keys, values): - self.loaded_ids[k] = value - - self.pending_ids.clear() - return self.loaded_ids[key] diff --git a/frappe_graphql/utils/execution/deferred_value.py b/frappe_graphql/utils/execution/deferred_value.py deleted file mode 100644 index 31194ad..0000000 --- a/frappe_graphql/utils/execution/deferred_value.py +++ /dev/null @@ -1,210 +0,0 @@ -from typing import Any, Optional, List, Callable, cast, Dict - - -OnSuccessCallback = Callable[[Any], None] -OnErrorCallback = Callable[[Exception], None] - - -class DeferredValue: - PENDING = -1 - REJECTED = 0 - RESOLVED = 1 - - _value: Optional[Any] - _reason: Optional[Exception] - _callbacks: List[OnSuccessCallback] - _errbacks: List[OnErrorCallback] - - def __init__( - self, - on_complete: Optional[OnSuccessCallback] = None, - on_error: Optional[OnErrorCallback] = None, - ): - self._state = self.PENDING - self._value = None - self._reason = None - if on_complete: - self._callbacks = [on_complete] - else: - self._callbacks = [] - if on_error: - self._errbacks = [on_error] - else: - self._errbacks = [] - - def resolve(self, value: Any) -> None: - if self._state != DeferredValue.PENDING: - return - - if isinstance(value, DeferredValue): - value.add_callback(self.resolve) - value.add_errback(self.reject) - return - - self._value = value - self._state = self.RESOLVED - - callbacks = self._callbacks - self._callbacks = [] - for callback in callbacks: - try: - callback(value) - except Exception: - # Ignore errors in callbacks - pass - - def reject(self, reason: Exception) -> None: - if self._state != DeferredValue.PENDING: - return - - self._reason = reason - self._state = self.REJECTED - - errbacks = self._errbacks - self._errbacks = [] - for errback in errbacks: - try: - errback(reason) - except Exception: - # Ignore errors in errback - pass - - def then( - self, - on_complete: Optional[OnSuccessCallback] = None, - on_error: Optional[OnErrorCallback] = None, - ) -> "DeferredValue": - ret = DeferredValue() - - def call_and_resolve(v: Any) -> None: - try: - if on_complete: - ret.resolve(on_complete(v)) - else: - ret.resolve(v) - except Exception as e: - ret.reject(e) - - def call_and_reject(r: Exception) -> None: - try: - if on_error: - ret.resolve(on_error(r)) - else: - ret.reject(r) - except Exception as e: - ret.reject(e) - - self.add_callback(call_and_resolve) - self.add_errback(call_and_resolve) - - return ret - - def add_callback(self, callback: OnSuccessCallback) -> None: - if self._state == self.PENDING: - self._callbacks.append(callback) - return - - if self._state == self.RESOLVED: - callback(self._value) - - def add_errback(self, callback: OnErrorCallback) -> None: - if self._state == self.PENDING: - self._errbacks.append(callback) - return - - if self._state == self.REJECTED: - callback(cast(Exception, self._reason)) - - @property - def is_resolved(self) -> bool: - return self._state == self.RESOLVED - - @property - def is_rejected(self) -> bool: - return self._state == self.REJECTED - - @property - def value(self) -> Any: - return self._value - - @property - def reason(self) -> Optional[Exception]: - return self._reason - - -def deferred_dict(m: Dict[str, Any]) -> DeferredValue: - """ - A special function that takes a dictionary of deferred values - and turns them into a deferred value that will ultimately resolve - into a dictionary of values. - """ - if len(m) == 0: - raise TypeError("Empty dict") - - ret = DeferredValue() - - plain_values = { - key: value for key, value in m.items() if not isinstance(value, DeferredValue) - } - deferred_values = { - key: value for key, value in m.items() if isinstance(value, DeferredValue) - } - - count = len(deferred_values) - - def handle_success(_: Any) -> None: - nonlocal count - count -= 1 - if count == 0: - value = plain_values - - for k, p in deferred_values.items(): - value[k] = p.value - - ret.resolve(value) - - for p in deferred_values.values(): - p.add_callback(handle_success) - p.add_errback(ret.reject) - - return ret - - -def deferred_list(_list: List[Any]) -> DeferredValue: - """ - A special function that takes a list of deferred values - and turns them into a deferred value for a list of values. - """ - if len(_list) == 0: - raise TypeError("Empty list") - - ret = DeferredValue() - - plain_values = {} - deferred_values = {} - for index, value in enumerate(_list): - if isinstance(value, DeferredValue): - deferred_values[index] = value - else: - plain_values[index] = value - - count = len(deferred_values) - - def handle_success(_: Any) -> None: - nonlocal count - count -= 1 - if count == 0: - values = [] - - for k in sorted(list(plain_values.keys()) + list(deferred_values.keys())): - value = plain_values.get(k, None) - if not value: - value = deferred_values[k].value - values.append(value) - ret.resolve(values) - - for p in deferred_values.values(): - p.add_callback(handle_success) - p.add_errback(ret.reject) - - return ret diff --git a/frappe_graphql/utils/execution/execution_context.py b/frappe_graphql/utils/execution/execution_context.py deleted file mode 100644 index c07b730..0000000 --- a/frappe_graphql/utils/execution/execution_context.py +++ /dev/null @@ -1,381 +0,0 @@ -from typing import ( - Any, - AsyncIterable, - Dict, - Iterable, - List, - Optional, - Union, - Tuple, - cast, -) - -from graphql.execution.execute import ExecutionContext -from graphql.execution.collect_fields import collect_fields -from graphql.error import GraphQLError, located_error -from graphql.type import ( - GraphQLAbstractType, - GraphQLLeafType, - GraphQLList, - GraphQLNonNull, - GraphQLObjectType, - GraphQLOutputType, - GraphQLResolveInfo, - is_abstract_type, - is_leaf_type, - is_list_type, - is_non_null_type, - is_object_type, -) -from graphql.language import ( - FieldNode, - OperationDefinitionNode, - OperationType, -) -from graphql.pyutils import ( - inspect, - is_iterable, - AwaitableOrValue, - Path, - Undefined, -) - -from .deferred_value import DeferredValue, deferred_dict, deferred_list -from .dataloader import DataLoader - - -class DeferredExecutionContext(ExecutionContext): - def __init__(self, *args, **kwargs): - self._deferred_values: List[Tuple[DeferredValue, Any]] = [] - - return super().__init__(*args, **kwargs) - - def is_lazy(self, value: Any) -> bool: - return isinstance(value, DataLoader.LazyValue) - - def execute_operation( - self, operation: OperationDefinitionNode, root_value: Any - ) -> Optional[AwaitableOrValue[Any]]: - """Execute an operation. - - Implements the "Executing operations" section of the spec. - """ - root_type = self.schema.get_root_type(operation.operation) - if root_type is None: - raise GraphQLError( - "Schema is not configured to execute" - f" {operation.operation.value} operation.", - operation, - ) - - root_fields = collect_fields( - self.schema, - self.fragments, - self.variable_values, - root_type, - operation.selection_set, - ) - - path = None - - result = ( - self.execute_fields_serially - if operation.operation == OperationType.MUTATION - else self.execute_fields - )(root_type, root_value, path, root_fields) - - while len(self._deferred_values) > 0: - for d in list(self._deferred_values): - self._deferred_values.remove(d) - res = d[1].get() - d[0].resolve(res) - - if isinstance(result, DeferredValue): - if result.is_rejected: - raise cast(Exception, result.reason) - return result.value - - return result - - def execute_fields( - self, - parent_type: GraphQLObjectType, - source_value: Any, - path: Optional[Path], - fields: Dict[str, List[FieldNode]], - ) -> AwaitableOrValue[Dict[str, Any]]: - """Execute the given fields concurrently. - - Implements the "Executing selection sets" section of the spec - for fields that may be executed in parallel. - """ - results = {} - is_awaitable = self.is_awaitable - awaitable_fields: List[str] = [] - append_awaitable = awaitable_fields.append - contains_deferred = False - for response_name, field_nodes in fields.items(): - field_path = Path(path, response_name, parent_type.name) - result = self.execute_field( - parent_type, source_value, field_nodes, field_path - ) - if result is not Undefined: - results[response_name] = result - if is_awaitable(result): - append_awaitable(response_name) - if isinstance(result, DeferredValue): - contains_deferred = True - - if contains_deferred: - return deferred_dict(results) - - # If there are no coroutines, we can just return the object - if not awaitable_fields: - return results - - # Otherwise, results is a map from field name to the result of resolving that - # field, which is possibly a coroutine object. Return a coroutine object that - # will yield this same map, but with any coroutines awaited in parallel and - # replaced with the values they yielded. - async def get_results() -> Dict[str, Any]: - from asyncio import gather - results.update( - zip( - awaitable_fields, - await gather(*(results[field] for field in awaitable_fields)), - ) - ) - return results - - return get_results() - - def execute_fields_serially(self, *args, **kwargs): - result = super().execute_fields_serially(*args, **kwargs) - contains_deferred = False - - for v in result.values(): - if isinstance(v, DeferredValue): - contains_deferred = True - break - - if contains_deferred: - return deferred_dict(result) - - return result - - def complete_value( - self, - return_type: GraphQLOutputType, - field_nodes: List[FieldNode], - info: GraphQLResolveInfo, - path: Path, - result: Any, - ) -> AwaitableOrValue[Any]: - """Complete a value. - - Implements the instructions for completeValue as defined in the - "Value completion" section of the spec. - - If the field type is Non-Null, then this recursively completes the value - for the inner type. It throws a field error if that completion returns null, - as per the "Nullability" section of the spec. - - If the field type is a List, then this recursively completes the value - for the inner type on each item in the list. - - If the field type is a Scalar or Enum, ensures the completed value is a legal - value of the type by calling the ``serialize`` method of GraphQL type - definition. - - If the field is an abstract type, determine the runtime type of the value and - then complete based on that type. - - Otherwise, the field type expects a sub-selection set, and will complete the - value by evaluating all sub-selections. - """ - # If result is an Exception, throw a located error. - if isinstance(result, Exception): - raise result - - # If field type is NonNull, complete for inner type, and throw field error if - # result is null. - if is_non_null_type(return_type): - completed = self.complete_value( - cast(GraphQLNonNull, return_type).of_type, - field_nodes, - info, - path, - result, - ) - if completed is None: - raise TypeError( - "Cannot return null for non-nullable field" - f" {info.parent_type.name}.{info.field_name}." - ) - return completed - - # If result value is null or undefined then return null. - if result is None or result is Undefined: - return None - - if self.is_lazy(result): - def handle_resolve(resolved: Any) -> Any: - return self.complete_value( - return_type, field_nodes, info, path, resolved - ) - - def handle_error(raw_error: Exception) -> None: - raise raw_error - - deferred = DeferredValue() - self._deferred_values.append(( - deferred, result - )) - - completed = deferred.then(handle_resolve, handle_error) - return completed - - # If field type is List, complete each item in the list with inner type - if is_list_type(return_type): - return self.complete_list_value( - cast(GraphQLList, return_type), field_nodes, info, path, result - ) - - # If field type is a leaf type, Scalar or Enum, serialize to a valid value, - # returning null if serialization is not possible. - if is_leaf_type(return_type): - return self.complete_leaf_value(cast(GraphQLLeafType, return_type), result) - - # If field type is an abstract type, Interface or Union, determine the runtime - # Object type and complete for that type. - if is_abstract_type(return_type): - return self.complete_abstract_value( - cast(GraphQLAbstractType, return_type), field_nodes, info, path, result - ) - - # If field type is Object, execute and complete all sub-selections. - if is_object_type(return_type): - return self.complete_object_value( - cast(GraphQLObjectType, return_type), field_nodes, info, path, result - ) - - # Not reachable. All possible output types have been considered. - raise TypeError( # pragma: no cover - "Cannot complete value of unexpected output type:" - f" '{inspect(return_type)}'." - ) - - def complete_list_value( - self, - return_type: GraphQLList[GraphQLOutputType], - field_nodes: List[FieldNode], - info: GraphQLResolveInfo, - path: Path, - result: Union[AsyncIterable[Any], Iterable[Any]], - ) -> AwaitableOrValue[List[Any]]: - """Complete a list value. - - Complete a list value by completing each item in the list with the inner type. - """ - if not is_iterable(result): - # experimental: allow async iterables - if isinstance(result, AsyncIterable): - # noinspection PyShadowingNames - async def async_iterable_to_list( - async_result: AsyncIterable[Any], - ) -> Any: - sync_result = [item async for item in async_result] - return self.complete_list_value( - return_type, field_nodes, info, path, sync_result - ) - - return async_iterable_to_list(result) - - raise GraphQLError( - "Expected Iterable, but did not find one for field" - f" '{info.parent_type.name}.{info.field_name}'." - ) - result = cast(Iterable[Any], result) - - # This is specified as a simple map, however we're optimizing the path where - # the list contains no coroutine objects by avoiding creating another coroutine - # object. - item_type = return_type.of_type - is_awaitable = self.is_awaitable - awaitable_indices: List[int] = [] - append_awaitable = awaitable_indices.append - completed_results: List[Any] = [] - append_result = completed_results.append - contains_deferred = False - for index, item in enumerate(result): - # No need to modify the info object containing the path, since from here on - # it is not ever accessed by resolver functions. - item_path = path.add_key(index, None) - completed_item: AwaitableOrValue[Any] - if is_awaitable(item): - # noinspection PyShadowingNames - async def await_completed(item: Any, item_path: Path) -> Any: - try: - completed = self.complete_value( - item_type, field_nodes, info, item_path, await item - ) - if is_awaitable(completed): - return await completed - return completed - except Exception as raw_error: - error = located_error( - raw_error, field_nodes, item_path.as_list() - ) - self.handle_field_error(error, item_type) - return None - - completed_item = await_completed(item, item_path) - else: - try: - completed_item = self.complete_value( - item_type, field_nodes, info, item_path, item - ) - if is_awaitable(completed_item): - # noinspection PyShadowingNames - async def await_completed(item: Any, item_path: Path) -> Any: - try: - return await item - except Exception as raw_error: - error = located_error( - raw_error, field_nodes, item_path.as_list() - ) - self.handle_field_error(error, item_type) - return None - - completed_item = await_completed(completed_item, item_path) - if isinstance(completed_item, DeferredValue): - contains_deferred = True - - except Exception as raw_error: - error = located_error(raw_error, field_nodes, item_path.as_list()) - self.handle_field_error(error, item_type) - completed_item = None - - if is_awaitable(completed_item): - append_awaitable(index) - append_result(completed_item) - - if contains_deferred is True: - return deferred_list(completed_results) - - if not awaitable_indices: - return completed_results - - # noinspection PyShadowingNames - async def get_completed_results() -> List[Any]: - from asyncio import gather - for index, result in zip( - awaitable_indices, - await gather( - *(completed_results[index] for index in awaitable_indices) - ), - ): - completed_results[index] = result - return completed_results - - return get_completed_results() diff --git a/frappe_graphql/utils/resolver/dataloaders/child_table_loader.py b/frappe_graphql/utils/resolver/dataloaders/child_table_loader.py index 1d861d1..77027a3 100644 --- a/frappe_graphql/utils/resolver/dataloaders/child_table_loader.py +++ b/frappe_graphql/utils/resolver/dataloaders/child_table_loader.py @@ -1,19 +1,21 @@ from collections import OrderedDict +from graphql_sync_dataloaders import SyncDataLoader + import frappe -from frappe_graphql.utils.execution import DataLoader from frappe_graphql.utils.permissions import get_allowed_fieldnames_for_doctype from .locals import get_loader_from_locals, set_loader_in_locals -def get_child_table_loader(child_doctype: str, parent_doctype: str, parentfield: str) -> DataLoader: +def get_child_table_loader(child_doctype: str, parent_doctype: str, parentfield: str) \ + -> SyncDataLoader: locals_key = (child_doctype, parent_doctype, parentfield) loader = get_loader_from_locals(locals_key) if loader: return loader - loader = DataLoader(_get_child_table_loader_fn( + loader = SyncDataLoader(_get_child_table_loader_fn( child_doctype=child_doctype, parent_doctype=parent_doctype, parentfield=parentfield, diff --git a/frappe_graphql/utils/resolver/dataloaders/doctype_loader.py b/frappe_graphql/utils/resolver/dataloaders/doctype_loader.py index 1af49e2..f01d3fe 100644 --- a/frappe_graphql/utils/resolver/dataloaders/doctype_loader.py +++ b/frappe_graphql/utils/resolver/dataloaders/doctype_loader.py @@ -1,18 +1,16 @@ from typing import List - import frappe - -from frappe_graphql.utils.execution import DataLoader +from graphql_sync_dataloaders import SyncDataLoader from frappe_graphql.utils.permissions import get_allowed_fieldnames_for_doctype from .locals import get_loader_from_locals, set_loader_in_locals -def get_doctype_dataloader(doctype: str) -> DataLoader: +def get_doctype_dataloader(doctype: str) -> SyncDataLoader: loader = get_loader_from_locals(doctype) if loader: return loader - loader = DataLoader(load_fn=_get_document_loader_fn(doctype=doctype)) + loader = SyncDataLoader(_get_document_loader_fn(doctype=doctype)) set_loader_in_locals(doctype, loader) return loader diff --git a/frappe_graphql/utils/resolver/dataloaders/locals.py b/frappe_graphql/utils/resolver/dataloaders/locals.py index 6548125..0520536 100644 --- a/frappe_graphql/utils/resolver/dataloaders/locals.py +++ b/frappe_graphql/utils/resolver/dataloaders/locals.py @@ -1,5 +1,5 @@ import frappe -from frappe_graphql.utils.execution import DataLoader +from graphql_sync_dataloaders import SyncDataLoader def get_loader_from_locals(key: str): @@ -10,7 +10,7 @@ def get_loader_from_locals(key: str): return frappe.local.dataloaders.get(key) -def set_loader_in_locals(key: str, loader: DataLoader): +def set_loader_in_locals(key: str, loader: SyncDataLoader): if not hasattr(frappe.local, "dataloaders"): frappe.local.dataloaders = frappe._dict() diff --git a/requirements.txt b/requirements.txt index 52581eb..4f83e74 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ frappe graphql-core==3.2.1 inflect==5.3.0 +graphql-sync-dataloaders==0.1.1 \ No newline at end of file From 09dc1a9a7b622eb025ecc4521ece9c7565c9571c Mon Sep 17 00:00:00 2001 From: Fahim Ali Zain Date: Wed, 11 Jan 2023 18:05:52 +0530 Subject: [PATCH 23/27] fix: Clear dataloader cache post each batch load --- .../utils/resolver/dataloaders/__init__.py | 1 + .../dataloaders/child_table_loader.py | 7 +++---- .../resolver/dataloaders/doctype_loader.py | 6 +++--- .../resolver/dataloaders/frappe_dataloader.py | 19 +++++++++++++++++++ .../utils/resolver/dataloaders/locals.py | 9 +++++++-- 5 files changed, 33 insertions(+), 9 deletions(-) create mode 100644 frappe_graphql/utils/resolver/dataloaders/frappe_dataloader.py diff --git a/frappe_graphql/utils/resolver/dataloaders/__init__.py b/frappe_graphql/utils/resolver/dataloaders/__init__.py index 2c3fc51..1033122 100644 --- a/frappe_graphql/utils/resolver/dataloaders/__init__.py +++ b/frappe_graphql/utils/resolver/dataloaders/__init__.py @@ -1,2 +1,3 @@ +from .frappe_dataloader import FrappeDataloader # noqa: F401 from .doctype_loader import get_doctype_dataloader # noqa: F401 from .child_table_loader import get_child_table_loader # noqa: F401 diff --git a/frappe_graphql/utils/resolver/dataloaders/child_table_loader.py b/frappe_graphql/utils/resolver/dataloaders/child_table_loader.py index 77027a3..5489c7a 100644 --- a/frappe_graphql/utils/resolver/dataloaders/child_table_loader.py +++ b/frappe_graphql/utils/resolver/dataloaders/child_table_loader.py @@ -1,21 +1,20 @@ from collections import OrderedDict -from graphql_sync_dataloaders import SyncDataLoader - import frappe from frappe_graphql.utils.permissions import get_allowed_fieldnames_for_doctype +from .frappe_dataloader import FrappeDataloader from .locals import get_loader_from_locals, set_loader_in_locals def get_child_table_loader(child_doctype: str, parent_doctype: str, parentfield: str) \ - -> SyncDataLoader: + -> FrappeDataloader: locals_key = (child_doctype, parent_doctype, parentfield) loader = get_loader_from_locals(locals_key) if loader: return loader - loader = SyncDataLoader(_get_child_table_loader_fn( + loader = FrappeDataloader(_get_child_table_loader_fn( child_doctype=child_doctype, parent_doctype=parent_doctype, parentfield=parentfield, diff --git a/frappe_graphql/utils/resolver/dataloaders/doctype_loader.py b/frappe_graphql/utils/resolver/dataloaders/doctype_loader.py index f01d3fe..a307d50 100644 --- a/frappe_graphql/utils/resolver/dataloaders/doctype_loader.py +++ b/frappe_graphql/utils/resolver/dataloaders/doctype_loader.py @@ -1,16 +1,16 @@ from typing import List import frappe -from graphql_sync_dataloaders import SyncDataLoader +from .frappe_dataloader import FrappeDataloader from frappe_graphql.utils.permissions import get_allowed_fieldnames_for_doctype from .locals import get_loader_from_locals, set_loader_in_locals -def get_doctype_dataloader(doctype: str) -> SyncDataLoader: +def get_doctype_dataloader(doctype: str) -> FrappeDataloader: loader = get_loader_from_locals(doctype) if loader: return loader - loader = SyncDataLoader(_get_document_loader_fn(doctype=doctype)) + loader = FrappeDataloader(_get_document_loader_fn(doctype=doctype)) set_loader_in_locals(doctype, loader) return loader diff --git a/frappe_graphql/utils/resolver/dataloaders/frappe_dataloader.py b/frappe_graphql/utils/resolver/dataloaders/frappe_dataloader.py new file mode 100644 index 0000000..909add4 --- /dev/null +++ b/frappe_graphql/utils/resolver/dataloaders/frappe_dataloader.py @@ -0,0 +1,19 @@ +from graphql_sync_dataloaders import SyncDataLoader + + +class FrappeDataloader(SyncDataLoader): + def dispatch_queue(self): + """ + We hope to clear the cache after each batch load + This is helpful when we ask for the same Document consecutively + with Updates in between in a single request + + Eg: + - get_doctype_dataloader("User").load("Administrator") + - frappe.db.set_value("User", "Administrator", "first_name", "New Name") + - get_doctype_dataloader("User").load("Administrator") + + If we do not clear the cache, the second load will return the old value + """ + super().dispatch_queue() + self._cache = {} diff --git a/frappe_graphql/utils/resolver/dataloaders/locals.py b/frappe_graphql/utils/resolver/dataloaders/locals.py index 0520536..463d12c 100644 --- a/frappe_graphql/utils/resolver/dataloaders/locals.py +++ b/frappe_graphql/utils/resolver/dataloaders/locals.py @@ -1,5 +1,5 @@ import frappe -from graphql_sync_dataloaders import SyncDataLoader +from .frappe_dataloader import FrappeDataloader def get_loader_from_locals(key: str): @@ -10,8 +10,13 @@ def get_loader_from_locals(key: str): return frappe.local.dataloaders.get(key) -def set_loader_in_locals(key: str, loader: SyncDataLoader): +def set_loader_in_locals(key: str, loader: FrappeDataloader): if not hasattr(frappe.local, "dataloaders"): frappe.local.dataloaders = frappe._dict() frappe.local.dataloaders[key] = loader + + +def clear_all_loaders(): + if hasattr(frappe.local, "dataloaders"): + frappe.local.dataloaders = frappe._dict() From 2aad208c9dd8a8c2aa5695e01cb8d0a6913a821b Mon Sep 17 00:00:00 2001 From: Fahim Ali Zain Date: Wed, 11 Jan 2023 18:40:52 +0530 Subject: [PATCH 24/27] chore: types --- frappe_graphql/utils/resolver/dataloaders/locals.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frappe_graphql/utils/resolver/dataloaders/locals.py b/frappe_graphql/utils/resolver/dataloaders/locals.py index 463d12c..7c31b54 100644 --- a/frappe_graphql/utils/resolver/dataloaders/locals.py +++ b/frappe_graphql/utils/resolver/dataloaders/locals.py @@ -1,8 +1,10 @@ +from typing import Union, Tuple + import frappe from .frappe_dataloader import FrappeDataloader -def get_loader_from_locals(key: str): +def get_loader_from_locals(key: Union[str, Tuple[str, ...]]) -> Union[FrappeDataloader, None]: if not hasattr(frappe.local, "dataloaders"): frappe.local.dataloaders = frappe._dict() @@ -10,7 +12,7 @@ def get_loader_from_locals(key: str): return frappe.local.dataloaders.get(key) -def set_loader_in_locals(key: str, loader: FrappeDataloader): +def set_loader_in_locals(key: Union[str, Tuple[str, ...]], loader: FrappeDataloader): if not hasattr(frappe.local, "dataloaders"): frappe.local.dataloaders = frappe._dict() From 4facc15d926494174948aa6bbbee4367aec8bc09 Mon Sep 17 00:00:00 2001 From: Fahim Ali Zain Date: Thu, 12 Jan 2023 11:56:36 +0530 Subject: [PATCH 25/27] fix: Replace db.sql with get_all in child_table_loader --- .../dataloaders/child_table_loader.py | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/frappe_graphql/utils/resolver/dataloaders/child_table_loader.py b/frappe_graphql/utils/resolver/dataloaders/child_table_loader.py index 5489c7a..aa17d33 100644 --- a/frappe_graphql/utils/resolver/dataloaders/child_table_loader.py +++ b/frappe_graphql/utils/resolver/dataloaders/child_table_loader.py @@ -30,22 +30,15 @@ def _inner(keys): parent_doctype=parent_doctype ) - select_fields = ", ".join([f"`{x}`" if "`" not in x else x for x in fieldnames]) - - rows = frappe.db.sql(f""" - SELECT - {select_fields} - FROM `tab{child_doctype}` - WHERE - parent IN %(parent_keys)s - AND parenttype = %(parenttype)s - AND parentfield = %(parentfield)s - ORDER BY idx - """, dict( - parent_keys=keys, - parenttype=parent_doctype, - parentfield=parentfield, - ), as_dict=1) + rows = frappe.get_all( + doctype=child_doctype, + fields=fieldnames, + filters=dict( + parenttype=parent_doctype, + parentfield=parentfield, + parent=("in", keys), + ), + order_by="idx asc") _results = OrderedDict() for k in keys: From 04493ada087c567e986920a8e009a2fabdc39acf Mon Sep 17 00:00:00 2001 From: Fahim Ali Zain Date: Thu, 12 Jan 2023 14:12:50 +0530 Subject: [PATCH 26/27] test: fix TestGetAllowedFieldNameForDocType --- .../utils/tests/test_permissions.py | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/frappe_graphql/utils/tests/test_permissions.py b/frappe_graphql/utils/tests/test_permissions.py index f73e4cd..759d49b 100644 --- a/frappe_graphql/utils/tests/test_permissions.py +++ b/frappe_graphql/utils/tests/test_permissions.py @@ -12,6 +12,10 @@ def setUp(self) -> None: pass def tearDown(self) -> None: + # Clear caches + frappe.local.meta_cache = frappe._dict() + frappe.local.permlevel_fields = {} + frappe.set_user("Administrator") def test_admin_on_user(self): @@ -26,7 +30,7 @@ def test_admin_on_user(self): + [x for x in default_fields if x != "doctype"] ) - def test_permlevels_on_user(self): + def test_perm_level_on_guest(self): frappe.set_user("Guest") # Guest is given permlevel=0 access on User DocType @@ -36,15 +40,36 @@ def test_permlevels_on_user(self): get_meta_mock.return_value = user_meta fieldnames = get_allowed_fieldnames_for_doctype(user_meta.name) + self.maxDiff = None self.assertCountEqual( fieldnames, [x.fieldname for x in user_meta.fields - if x.permlevel == 1 and x.fieldtype not in no_value_fields] + if x.permlevel == 0 and x.fieldtype not in no_value_fields] + [x for x in default_fields if x != "doctype"] ) - # Clear meta_cache for User doctype - del frappe.local.meta_cache["User"] + def test_perm_level_on_guest_1(self): + frappe.set_user("Guest") + + # Guest is given permlevel=1 access on User DocType + user_meta = self._get_custom_user_meta() + user_meta.permissions.append(dict( + role="Guest", + read=1, + permlevel=1 + )) + + with patch("frappe.get_meta") as get_meta_mock: + get_meta_mock.return_value = user_meta + fieldnames = get_allowed_fieldnames_for_doctype(user_meta.name) + + self.maxDiff = None + self.assertCountEqual( + fieldnames, + [x.fieldname for x in user_meta.fields + if x.permlevel in (0, 1) and x.fieldtype not in no_value_fields] + + [x for x in default_fields if x != "doctype"] + ) def test_on_child_doctype(self): fieldnames = get_allowed_fieldnames_for_doctype("Has Role", parent_doctype="User") @@ -56,15 +81,24 @@ def test_on_child_doctype(self): ) def test_on_child_doctype_with_no_parent_doctype(self): + """ + It should return all fields of the Child DocType with permlevel=0 + """ fieldnames = get_allowed_fieldnames_for_doctype("Has Role") - self.assertEqual(fieldnames, []) + meta = frappe.get_meta("Has Role") + self.assertCountEqual( + fieldnames, + [x.fieldname for x in meta.fields + if x.permlevel == 0 and x.fieldtype not in no_value_fields] + + [x for x in default_fields if x != "doctype"] + ) def _get_custom_user_meta(self): meta = frappe.get_meta("User") meta.permissions.append(dict( role="Guest", read=1, - permlevel=1 + permlevel=0 )) meta.get_field("full_name").permlevel = 1 From 5ce74de844c44fb02143c8c7ec5232a211ba3785 Mon Sep 17 00:00:00 2001 From: Fahim Ali Zain Date: Sat, 14 Jan 2023 11:15:20 +0000 Subject: [PATCH 27/27] ROMMAN-T-433 | tests: Update DocumentResolver Tests tests: fix test_deleted_doc_resolution Merge branch 'ROMMAN-T-289-kick-default-resolver' into ROMMAN-T-433-document-resolver-tests Merge branch 'ROMMAN-T-289-kick-default-resolver' into ROMMAN-T-433-document-resolver-tests tests: Update DocumentResolver Tests Co-authored-by: Fahim Ali Zain Merge-request: ROMMAN-MR-368 Merged-by: Fahim Ali Zain --- .../resolver/tests/test_document_resolver.py | 139 ++++-------------- 1 file changed, 29 insertions(+), 110 deletions(-) diff --git a/frappe_graphql/utils/resolver/tests/test_document_resolver.py b/frappe_graphql/utils/resolver/tests/test_document_resolver.py index 7123c54..01673cf 100644 --- a/frappe_graphql/utils/resolver/tests/test_document_resolver.py +++ b/frappe_graphql/utils/resolver/tests/test_document_resolver.py @@ -57,7 +57,7 @@ def test_get_administrator(self): admin = r.get("data").get("User") self.assertEqual(admin.get("doctype"), "User") - self.assertEqual(admin.get("name"), "administrator") + self.assertEqual(admin.get("name"), "Administrator") self.assertEqual(admin.get("full_name"), "Administrator") """ @@ -184,6 +184,14 @@ def test_child_table(self): """ def test_simple_select(self): + # Make sure the field is a String field + schema = get_schema() + user_type = schema.type_map.get("User") + original_type = None + if not isinstance(user_type.fields.get("desk_theme").type, GraphQLScalarType): + original_type = user_type.fields.get("desk_theme").type + user_type.fields.get("desk_theme").type = GraphQLString + r = execute( query=""" query FetchAdmin($user: String!) { @@ -203,6 +211,10 @@ def test_simple_select(self): self.assertIn(admin.get("desk_theme"), ["Light", "Dark"]) + # Set back the original type + if original_type is not None: + user_type.fields.get("desk_theme").type = original_type + def test_enum_select(self): """ Update SDL.User.desk_theme return type to be an Enum @@ -244,142 +256,49 @@ def test_enum_select(self): if original_type is not None: user_type.fields.get("desk_theme").type = original_type - """ - IGNORE_PERMS_TESTS - """ - - def test_ignore_perms(self): - administrator = frappe.get_doc("User", "administrator") - frappe.set_user("Guest") - schema = get_schema() - schema.query_type.fields["GetAdmin"] = GraphQLField( - type_=schema.type_map["User"], - resolve=lambda obj, info, **kwargs: dict( - doctype="User", name="Administrator", __ignore_perms=True) - ) - - r = execute( - query=""" - { - GetAdmin { - email - full_name - desk_theme - roles { - role__name - } - } - } - """ - ) - - self.assertIsNone(r.get("errors")) - admin = frappe._dict(r.get("data").get("GetAdmin")) - - self.assertEqual(admin.email, administrator.email) - self.assertEqual(len(admin.roles), len(administrator.roles)) - - def test_ignore_perms_child_doc_and_link_field(self): - """ - Has Role { - __ignore_perms: 1 - role__name - role { - should be readable without perm errors - } - } - """ - frappe.set_user("Guest") - has_role_name = frappe.db.get_value("Has Role", {}) - - schema = get_schema() - schema.query_type.fields["GetHasRole"] = GraphQLField( - type_=schema.type_map["HasRole"], - args=dict( - name=GraphQLString - ), - resolve=lambda obj, info, **kwargs: dict( - doctype="Has Role", name=kwargs.get("name"), __ignore_perms=True) - ) - - r = execute( - query=""" - query GetHasRole($name: String!) { - GetHasRole(name: $name) { - name - doctype - role__name - role { - name - } - } - } - """, - variables={ - "name": has_role_name - } - ) - self.assertIsNone(r.get("errors")) - - has_role = frappe._dict(r.get("data").get("GetHasRole")) - self.assertEqual(has_role.name, has_role_name) - self.assertEqual(has_role.role__name, has_role.role.get("name")) - """ DB_DELETED_DOC_TESTS """ def test_deleted_doc_resolution(self): d = frappe.get_doc(dict( - doctype="User", - first_name="Example A", - email="example_a@test.com", - send_welcome_email=0, - roles=[{ - "role": "System Manager" - }] + doctype="Role", + role_name="Example A", )).insert() d.delete() - # We cannot call Query.User(name: d.name) now since its deleted + # We cannot call Query.Role(name: d.name) now since its deleted schema = get_schema() - schema.type_map["UserDocInput"] = GraphQLScalarType( - name="UserDocInput" + schema.type_map["RoleDocInput"] = GraphQLScalarType( + name="RoleDocInput" ) - schema.query_type.fields["EchoUser"] = GraphQLField( - type_=schema.type_map["User"], + schema.query_type.fields["EchoRole"] = GraphQLField( + type_=schema.type_map["Role"], args=dict( - user=GraphQLArgument( - type_=schema.type_map["UserDocInput"] + role=GraphQLArgument( + type_=schema.type_map["RoleDocInput"] ) ), - resolve=lambda obj, info, **kwargs: kwargs.get("user") + resolve=lambda obj, info, **kwargs: kwargs.get("role") ) r = execute( query=""" - query EchoUser($user: UserDocInput!) { - EchoUser(user: $user) { + query EchoRole($role: RoleDocInput!) { + EchoRole(role: $role) { doctype name - email - full_name - roles { - role__name - } + role_name } } """, variables={ - "user": d + "role": d } ) - resolved_doc = frappe._dict(r.get("data").get("EchoUser")) + resolved_doc = frappe._dict(r.get("data").get("EchoRole")) self.assertEqual(resolved_doc.doctype, d.doctype) self.assertEqual(resolved_doc.name, d.name) - self.assertEqual(resolved_doc.email, d.email) - self.assertEqual(resolved_doc.full_name, d.full_name) - self.assertEqual(len(resolved_doc.roles), 1) - self.assertEqual(resolved_doc.roles[0].get("role__name"), "System Manager") + self.assertEqual(resolved_doc.role_name, d.role_name)