Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: Remove Default Resolver + GQL Dataloaders #64

Closed
wants to merge 29 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
27aa7c8
[WIP] feat: Experiments
fahimalizain Aug 10, 2022
be6913e
feat: DataLoader
fahimalizain Aug 11, 2022
0fad669
fix: CursorPaginator Select Fields
fahimalizain Aug 11, 2022
f91d362
fix: deferred_list callback list
fahimalizain Aug 11, 2022
4c784c4
fix: support parent link fields
fahimalizain Aug 11, 2022
47a3f59
fix: remove deprecated code
fahimalizain Aug 12, 2022
bd2a4a1
fix: removed unused code
fahimalizain Aug 13, 2022
a8419fb
feat: Basic Perms for new Resolvers
fahimalizain Aug 16, 2022
f506eb0
feat: Field Level Perms (#66)
fahimalizain Aug 19, 2022
5152e94
fix: DeferredValue support for Mutations (#67)
fahimalizain Aug 19, 2022
6e6ab2c
fix: Reduced no. of iterations in default schema binding
fahimalizain Aug 20, 2022
b7e206b
feat: Introduce hook 'doctype_resolver_processors'
fahimalizain Aug 22, 2022
ad0e8df
Merge branch 'master' into ROMMAN-T-289-kick-default-resolver
fahimalizain Aug 22, 2022
974538c
feat: Translations Support
fahimalizain Aug 22, 2022
8518a89
fix: Setup GQLType.doctype resolver manually
fahimalizain Aug 24, 2022
f354001
feat: implement select field resolver
Abadulrehman Aug 25, 2022
4a04f0a
refactor: check if return type is scalar before link field binded (#70)
e-lobo Aug 25, 2022
de30ebd
fix: get_allowed_fieldnames_for_doctype on plain child-doctype support
fahimalizain Aug 26, 2022
54e0715
fix: default_fields link fields like owner
fahimalizain Aug 26, 2022
ea01ae7
fix: cache get_allowed_fieldnames_for_doctype at the request level
fahimalizain Aug 26, 2022
040d21b
feat: pre load schema's utility (#78)
e-lobo Sep 8, 2022
800bd26
[ROMMAN-T-521] GQL Dataloader: Raise Perm Error on GQLNonNull Permlev…
fahimalizain Sep 9, 2022
f0dc655
Merge branch 'master' into ROMMAN-T-289-kick-default-resolver
fahimalizain Sep 16, 2022
b97b675
use new graphql-sync-dataloaders package (#81)
e-lobo Oct 4, 2022
09dc1a9
fix: Clear dataloader cache post each batch load
fahimalizain Jan 11, 2023
2aad208
chore: types
fahimalizain Jan 11, 2023
4facc15
fix: Replace db.sql with get_all in child_table_loader
fahimalizain Jan 12, 2023
04493ad
test: fix TestGetAllowedFieldNameForDocType
fahimalizain Jan 12, 2023
5ce74de
ROMMAN-T-433 | tests: Update DocumentResolver Tests
fahimalizain Jan 14, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frappe_graphql/__init__.py
Original file line number Diff line number Diff line change
@@ -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, \
Expand Down
2 changes: 1 addition & 1 deletion frappe_graphql/frappe_graphql/subscription/doc_events.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
7 changes: 4 additions & 3 deletions frappe_graphql/graphql.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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"):
Expand Down
8 changes: 7 additions & 1 deletion frappe_graphql/utils/cursor_pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

import frappe

from frappe_graphql.utils.permissions import get_allowed_fieldnames_for_doctype


class CursorPaginator(object):
def __init__(
Expand Down Expand Up @@ -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"
Expand Down
Empty file.
2 changes: 2 additions & 0 deletions frappe_graphql/utils/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand All @@ -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
Expand Down
102 changes: 102 additions & 0 deletions frappe_graphql/utils/permissions.py
Original file line number Diff line number Diff line change
@@ -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
164 changes: 97 additions & 67 deletions frappe_graphql/utils/resolver/__init__.py
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions frappe_graphql/utils/resolver/child_tables.py
Original file line number Diff line number Diff line change
@@ -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"))
3 changes: 3 additions & 0 deletions frappe_graphql/utils/resolver/dataloaders/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Loading