From d909ae995b2e7c7639c588e4f859287f1c378ba0 Mon Sep 17 00:00:00 2001 From: David Vogt Date: Fri, 11 Oct 2024 16:22:08 +0200 Subject: [PATCH] feat(visibilities): make visibilities suppressable on n:1 relationships Visibilities may incur a heavy tax on DB load, but are not always needed. This will allow you to define, in your visibility classes, a list of lookups that won't use the visibility layer, and thus improve performance. --- caluma/caluma_core/visibilities.py | 27 +++++++++++ caluma/caluma_form/schema.py | 34 ++++++++++++++ caluma/caluma_workflow/schema.py | 33 ++++++++++--- caluma/utils.py | 74 ++++++++++++++++++++++++++++++ 4 files changed, 162 insertions(+), 6 deletions(-) diff --git a/caluma/caluma_core/visibilities.py b/caluma/caluma_core/visibilities.py index 6bfb47306..eaf71e2b5 100644 --- a/caluma/caluma_core/visibilities.py +++ b/caluma/caluma_core/visibilities.py @@ -37,8 +37,18 @@ class BaseVisibility(object): ... @filter_queryset_for(Form) ... def filter_queryset_for_form(self, node, queryset, info): ... return queryset.exclude(slug='protected-form') + ... + ... # Do not trigger visibility when looking up case from workitem + ... suppress_visibilities = [ + ... "WorkItem.case", + ... "WorkItem.child_case", + ... ] """ + # Used by the @suppressable_visibility decorator to store + # the *allowed* values for the `suppress_visibilities` property + _suppressable_visibilities = [] + def __init__(self): queryset_fns = inspect.getmembers(self, lambda m: hasattr(m, "_visibilities")) @@ -56,6 +66,20 @@ def __init__(self): node: fn for _, fn in queryset_fns for node in fn._visibilities } + self._validate_suppress_visibility() + + def _validate_suppress_visibility(self): + requested_suppressors = set(self.suppress_visibilities) + available_suppressors = set(type(self)._suppressable_visibilities) + + invalid = requested_suppressors - available_suppressors + if invalid: + invalid_str_list = ", ".join(f"`{x}`" for x in (sorted(invalid))) + raise ImproperlyConfigured( + f"`{type(self).__name__}` contains invalid `suppress_visibilities`: " + f"{invalid_str_list}" + ) + def filter_queryset(self, node, queryset, info): for cls in node.mro(): if cls in self._filter_querysets_for: @@ -63,6 +87,9 @@ def filter_queryset(self, node, queryset, info): return queryset + # Default: suppress no visibilities + suppress_visibilities = [] + class Any(BaseVisibility): """No restrictions, all nodes are exposed.""" diff --git a/caluma/caluma_form/schema.py b/caluma/caluma_form/schema.py index 37c6adb02..bea477010 100644 --- a/caluma/caluma_form/schema.py +++ b/caluma/caluma_form/schema.py @@ -4,6 +4,8 @@ from graphene.types import ObjectType, generic from graphene_django.rest_framework import serializer_converter +from caluma.utils import suppressable_visibility + from ..caluma_core.filters import ( CollectionFilterSetFactory, DjangoFilterConnectionField, @@ -147,6 +149,10 @@ class Question(Node, graphene.Interface): ) source = graphene.Field("caluma.caluma_form.schema.Question") + @suppressable_visibility("Question.source") + def resolve_source(self, *args, **kwargs): + return getattr(self, "source", None) + @classmethod def get_queryset(cls, queryset, info): queryset = super().get_queryset(queryset, info) @@ -166,6 +172,10 @@ def resolve_type(cls, instance, info): class Option(FormDjangoObjectType): meta = generic.GenericScalar() + @suppressable_visibility("Option.source") + def resolve_source(self, *args, **kwargs): + return getattr(self, "source", None) + class Meta: model = models.Option interfaces = (relay.Node,) @@ -638,6 +648,10 @@ class Form(FormDjangoObjectType): ) meta = generic.GenericScalar() + @suppressable_visibility("Form.source") + def resolve_source(self, *args, **kwargs): + return getattr(self, "source", None) + class Meta: model = models.Form interfaces = (relay.Node,) @@ -809,6 +823,10 @@ class Answer(Node, graphene.Interface): question = graphene.Field(Question, required=True) meta = generic.GenericScalar(required=True) + @suppressable_visibility("Answer.question") + def resolve_question(self, *args, **kwargs): + return getattr(self, "question", None) + @classmethod def resolve_type(cls, instance, info): return resolve_answer(instance) @@ -911,6 +929,22 @@ class Document(FormDjangoObjectType): modified_content_by_user = graphene.String() modified_content_by_group = graphene.String() + @suppressable_visibility("Document.form") + def resolve_form(self, *args, **kwargs): + return getattr(self, "form", None) + + @suppressable_visibility("Document.source") + def resolve_source(self, *args, **kwargs): + return getattr(self, "source", None) + + @suppressable_visibility("Document.case") + def resolve_case(self, *args, **kwargs): + return getattr(self, "case", None) + + @suppressable_visibility("Document.work_item") + def resolve_work_item(self, *args, **kwargs): + return getattr(self, "work_item", None) + class Meta: model = models.Document exclude = ("family", "dynamicoption_set") diff --git a/caluma/caluma_workflow/schema.py b/caluma/caluma_workflow/schema.py index dbf4d87cd..54f7f05e0 100644 --- a/caluma/caluma_workflow/schema.py +++ b/caluma/caluma_workflow/schema.py @@ -1,12 +1,13 @@ import itertools import graphene -from graphene_django import bypass_get_queryset from django.db.models import Q from graphene import relay from graphene.types import generic from graphene_django.rest_framework import serializer_converter +from caluma.utils import suppressable_visibility + from ..caluma_core.filters import ( CollectionFilterSetFactory, DjangoFilterConnectionField, @@ -99,6 +100,10 @@ def resolve_type(cls, instance, info): return TASK_TYPE[instance.type] + @suppressable_visibility("Task.form") + def resolve_form(self, *args, **kwargs): + return getattr(self, "form", None) + Meta = InterfaceMetaFactory() @@ -207,14 +212,26 @@ class WorkItem(DjangoObjectType): ) ) - @bypass_get_queryset + @suppressable_visibility("WorkItem.case") def resolve_case(self, *args, **kwargs): return getattr(self, "case", None) - @bypass_get_queryset + @suppressable_visibility("WorkItem.child_case") def resolve_child_case(self, *args, **kwargs): return getattr(self, "child_case", None) + @suppressable_visibility("WorkItem.task") + def resolve_task(self, *args, **kwargs): + return getattr(self, "task", None) + + @suppressable_visibility("WorkItem.document") + def resolve_document(self, *args, **kwargs): + return getattr(self, "document", None) + + @suppressable_visibility("WorkItem.previous_work_item") + def resolve_previous_work_item(self, *args, **kwargs): + return getattr(self, "previous_work_item", None) + def resolve_is_redoable(self, *args, **kwargs): return ( self.status != models.WorkItem.STATUS_READY @@ -246,18 +263,22 @@ class Case(DjangoObjectType): meta = generic.GenericScalar() status = CaseStatus(required=True) - @bypass_get_queryset + @suppressable_visibility("Case.document") def resolve_document(self, *args, **kwargs): return getattr(self, "document", None) - @bypass_get_queryset + @suppressable_visibility("Case.parent_work_item") def resolve_parent_work_item(self, *args, **kwargs): return getattr(self, "parent_work_item", None) - @bypass_get_queryset + @suppressable_visibility("Case.family") def resolve_family(self, *args, **kwargs): return getattr(self, "family", None) + @suppressable_visibility("Case.workflow") + def resolve_workflow(self, *args, **kwargs): + return getattr(self, "workflow", None) + def resolve_family_work_items(self, info, **args): return models.WorkItem.objects.filter(case__family=self.family) diff --git a/caluma/utils.py b/caluma/utils.py index a3846516c..4343e417a 100644 --- a/caluma/utils.py +++ b/caluma/utils.py @@ -1,8 +1,11 @@ from functools import wraps from logging import getLogger +from django.conf import ImproperlyConfigured from django.db.models import Model +from caluma.caluma_core.types import Node + log = getLogger(__name__) @@ -102,3 +105,74 @@ def wrapper(*args, **kwargs): signal_handler(*args, **kwargs) return wrapper + + +def suppressable_visibility(attr_spec): + """Make resolver able to suppress the visibility layer. + + The visibility layer may cause additional load on the database, so + in cases where it's not needed, the visibility classes can choose to + skip filtering where it's not absolutely needed. + + For example, if the visibility of a child case is always dependent + on the associated work item, you can disable enforcing visibility + on that relationship. + + Example in the GraphQL schema: + + >>> class Form(FormDjangoObjectType): + >>> ... + >>> @suppressable_visibility("WorkItem.child_case") + >>> def resolve_child_case(self, *args, **kwargs): + >>> return getattr(self, "child_case", None) + + Example in the visibility class: + + >>> class MyCustomVisibility(BaseVisibility): + >>> @filter_queryset_for(Case) + >>> def filter_queryset_for_case(self, node, queryset, info): + >>> # do the filtering as usual + >>> ... + >>> ... + >>> suppress_visibilities = [ + >>> "WorkItem.child_case", + >>> ... + >>> ] + + With the above setting, when looking up child cases from work items, + the visibility layer would not be run, and the child case would always + be shown (if the workitem can be shown, of course). + + You may also define it as a `@property` on the visibility class + """ + + # Avoid circular imports + from caluma.caluma_core.visibilities import BaseVisibility + + if attr_spec in BaseVisibility._suppressable_visibilities: + raise ImproperlyConfigured( + f"@suppressable_visibility('{attr_spec}') " + "is used twice, possible configuration error" + ) + + BaseVisibility._suppressable_visibilities.append(attr_spec) + + def visibility_aware_resolver(resolver): + # TODO proper docstring / explanation / docs + + class dynamic_resolver: + def __call__(self, *args, **kwargs): + return resolver(*args, **kwargs) + + @property + def _bypass_get_queryset(self): + return any( + attr_spec in vis_class.suppress_visibilities + for vis_class in Node.visibility_classes + ) + breakpoint() + pass + + return dynamic_resolver() + + return visibility_aware_resolver