Skip to content

Commit

Permalink
feat(visibilities): make visibilities suppressable on n:1 relationships
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
winged committed Oct 11, 2024
1 parent e0a0faf commit d909ae9
Show file tree
Hide file tree
Showing 4 changed files with 162 additions and 6 deletions.
27 changes: 27 additions & 0 deletions caluma/caluma_core/visibilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))

Expand All @@ -56,13 +66,30 @@ 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:
return self._filter_querysets_for[cls](node, queryset, info)

return queryset

# Default: suppress no visibilities
suppress_visibilities = []


class Any(BaseVisibility):
"""No restrictions, all nodes are exposed."""
Expand Down
34 changes: 34 additions & 0 deletions caluma/caluma_form/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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,)
Expand Down Expand Up @@ -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,)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down
33 changes: 27 additions & 6 deletions caluma/caluma_workflow/schema.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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()


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
74 changes: 74 additions & 0 deletions caluma/utils.py
Original file line number Diff line number Diff line change
@@ -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__)


Expand Down Expand Up @@ -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

0 comments on commit d909ae9

Please sign in to comment.