diff --git a/frappe_graphql/__init__.py b/frappe_graphql/__init__.py index e32f8a9..89aa625 100644 --- a/frappe_graphql/__init__.py +++ b/frappe_graphql/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -from .utils.loader import get_schema # noqa 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..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.resolver import default_field_resolver @frappe.whitelist(allow_guest=True) @@ -12,9 +13,9 @@ 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() + context_value=frappe._dict(), + execution_context_class=DeferredExecutionContext ) output = frappe._dict() for k in ("data", "errors"): diff --git a/frappe_graphql/utils/cursor_pagination.py b/frappe_graphql/utils/cursor_pagination.py index 5044c25..75270ad 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=["name", 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 + 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/execution/__init__.py b/frappe_graphql/utils/execution/__init__.py new file mode 100644 index 0000000..e69de29 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/permissions.py b/frappe_graphql/utils/permissions.py new file mode 100644 index 0000000..baaad9f --- /dev/null +++ b/frappe_graphql/utils/permissions.py @@ -0,0 +1,102 @@ +from typing import List +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 + """ + _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") + + 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) + + _set_allowed_fieldnames_to_locals( + allowed_fields=fieldnames, + doctype=doctype, + parent_doctype=parent_doctype + ) + + 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] + + 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 + + +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 diff --git a/frappe_graphql/utils/resolver/__init__.py b/frappe_graphql/utils/resolver/__init__.py index e981d15..e93d66c 100644 --- a/frappe_graphql/utils/resolver/__init__.py +++ b/frappe_graphql/utils/resolver/__init__.py @@ -1,69 +1,99 @@ -from typing import Any -from graphql import GraphQLObjectType, GraphQLResolveInfo +from graphql import GraphQLSchema, GraphQLType, GraphQLResolveInfo, GraphQLNonNull import frappe -from frappe.model.document import Document -from frappe.model.meta import is_single - -from frappe_graphql import CursorPaginator -from .document_resolver import document_resolver -from .utils import get_singular_doctype, get_plural_doctype - - -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 +from frappe.model.meta import Meta + +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 + + +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) + if not dt: + continue + + 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) + 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) + + +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 + """ + 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 + + 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 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/child_tables.py b/frappe_graphql/utils/resolver/child_tables.py new file mode 100644 index 0000000..0872203 --- /dev/null +++ b/frappe_graphql/utils/resolver/child_tables.py @@ -0,0 +1,32 @@ +from graphql import GraphQLType, GraphQLResolveInfo + +from frappe.model.meta import Meta + +from .dataloaders import get_child_table_loader +from .utils import get_frappe_df_from_resolve_info + + +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 + + gql_field = gql_type.fields[df.fieldname] + gql_field.resolve = _child_table_resolver + + +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 = get_frappe_df_from_resolve_info(info) + 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..1033122 --- /dev/null +++ b/frappe_graphql/utils/resolver/dataloaders/__init__.py @@ -0,0 +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 new file mode 100644 index 0000000..aa17d33 --- /dev/null +++ b/frappe_graphql/utils/resolver/dataloaders/child_table_loader.py @@ -0,0 +1,54 @@ +from collections import OrderedDict + +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) \ + -> FrappeDataloader: + locals_key = (child_doctype, parent_doctype, parentfield) + loader = get_loader_from_locals(locals_key) + if loader: + return loader + + loader = FrappeDataloader(_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): + fieldnames = get_allowed_fieldnames_for_doctype( + doctype=child_doctype, + parent_doctype=parent_doctype + ) + + 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: + _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..a307d50 --- /dev/null +++ b/frappe_graphql/utils/resolver/dataloaders/doctype_loader.py @@ -0,0 +1,41 @@ +from typing import List +import frappe +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) -> FrappeDataloader: + loader = get_loader_from_locals(doctype) + if loader: + return loader + + loader = FrappeDataloader(_get_document_loader_fn(doctype=doctype)) + set_loader_in_locals(doctype, loader) + return loader + + +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=fieldnames, + 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/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 new file mode 100644 index 0000000..7c31b54 --- /dev/null +++ b/frappe_graphql/utils/resolver/dataloaders/locals.py @@ -0,0 +1,24 @@ +from typing import Union, Tuple + +import frappe +from .frappe_dataloader import FrappeDataloader + + +def get_loader_from_locals(key: Union[str, Tuple[str, ...]]) -> Union[FrappeDataloader, None]: + 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: Union[str, Tuple[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() diff --git a/frappe_graphql/utils/resolver/document_resolver.py b/frappe_graphql/utils/resolver/document_resolver.py deleted file mode 100644 index 4356661..0000000 --- a/frappe_graphql/utils/resolver/document_resolver.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 diff --git a/frappe_graphql/utils/resolver/link_field.py b/frappe_graphql/utils/resolver/link_field.py new file mode 100644 index 0000000..9ace2e9 --- /dev/null +++ b/frappe_graphql/utils/resolver/link_field.py @@ -0,0 +1,79 @@ +from graphql import GraphQLResolveInfo, GraphQLType, is_scalar_type + +from frappe.model.meta import Meta + +from .dataloaders import get_doctype_dataloader +from .utils import get_frappe_df_from_resolve_info + + +def setup_link_field_resolvers(meta: Meta, gql_type: GraphQLType): + """ + This will set up Link fields on DocTypes to resolve target docs + """ + 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 or is_scalar_type( + gql_type.fields[df.fieldname].type): + 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 + + gql_type.fields[_name_df].resolve = _resolve_link_name_field + + +def _resolve_link_field(obj, info: GraphQLResolveInfo, **kwargs): + df = get_frappe_df_from_resolve_info(info) + if not df: + return None + + dt = df.options + dn = obj.get(info.field_name) + + 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) + + +def _resolve_dynamic_link_field(obj, info: GraphQLResolveInfo, **kwargs): + df = get_frappe_df_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 + + # Permission check is done within get_doctype_dataloader via get_list + 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_default_field_links(): + from .utils import get_default_fields_docfield + + return [ + x for x in get_default_fields_docfield() + if x.fieldtype in ["Link", "Dynamic Link"] + ] diff --git a/frappe_graphql/utils/resolver/root_query.py b/frappe_graphql/utils/resolver/root_query.py new file mode 100644 index 0000000..4bc698a --- /dev/null +++ b/frappe_graphql/utils/resolver/root_query.py @@ -0,0 +1,53 @@ +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 + + dn = kwargs["name"] + if not frappe.has_permission(doctype=dt, doc=dn): + raise frappe.PermissionError(frappe._("No permission for {0}").format(dt + " " + dn)) + + doc = frappe.get_doc(dt, dn) + doc.apply_fieldlevel_read_permissions() + return doc + + +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/resolver/select_fields.py b/frappe_graphql/utils/resolver/select_fields.py new file mode 100644 index 0000000..b827fc4 --- /dev/null +++ b/frappe_graphql/utils/resolver/select_fields.py @@ -0,0 +1,36 @@ +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 + + 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(value).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/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) diff --git a/frappe_graphql/utils/resolver/translate.py b/frappe_graphql/utils/resolver/translate.py new file mode 100644 index 0000000..185af55 --- /dev/null +++ b/frappe_graphql/utils/resolver/translate.py @@ -0,0 +1,25 @@ +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] + + if gql_field.resolve: + continue + + 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 diff --git a/frappe_graphql/utils/resolver/utils.py b/frappe_graphql/utils/resolver/utils.py index 3dbb957..7ebfe37 100644 --- a/frappe_graphql/utils/resolver/utils.py +++ b/frappe_graphql/utils/resolver/utils.py @@ -1,5 +1,10 @@ +from graphql import GraphQLResolveInfo + 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" @@ -42,3 +47,78 @@ 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) + + +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 + 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 + ] 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) diff --git a/frappe_graphql/utils/tests/test_permissions.py b/frappe_graphql/utils/tests/test_permissions.py new file mode 100644 index 0000000..759d49b --- /dev/null +++ b/frappe_graphql/utils/tests/test_permissions.py @@ -0,0 +1,106 @@ +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: + # Clear caches + frappe.local.meta_cache = frappe._dict() + frappe.local.permlevel_fields = {} + + 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"] + ) + + def test_perm_level_on_guest(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.maxDiff = None + self.assertCountEqual( + fieldnames, + [x.fieldname for x in user_meta.fields + if x.permlevel == 0 and x.fieldtype not in no_value_fields] + + [x for x in default_fields if x != "doctype"] + ) + + 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") + 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"] + ) + + 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") + 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=0 + )) + + meta.get_field("full_name").permlevel = 1 + + return meta diff --git a/requirements.txt b/requirements.txt index de00f41..4f83e74 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ frappe -graphql-core==3.1.3 +graphql-core==3.2.1 inflect==5.3.0 +graphql-sync-dataloaders==0.1.1 \ No newline at end of file