Skip to content

Commit

Permalink
fix: disable data loaders for DjangoConnectionField
Browse files Browse the repository at this point in the history
Data loaders that exist are not fully compatible with new versions of
graphene and graphene-django. DjangoConnectionField doesn't seem to handle
loaders correctly and instead return errors like:

"Cannot return null for non-nullable field EmailNodeConnection.edges."

So for now, data loaders will be disabled for this field type.

Use graphql-sync-dataloaders to make other types of fields work with
data loaders.

Some GitHub issues for reference:
- graphql-python/graphene-django#1394
- graphql-python/graphene-django#1263
- graphql-python/graphene-django#1425

Refs: HP-2082
  • Loading branch information
charn committed Feb 2, 2024
1 parent a4bb163 commit 077b6de
Show file tree
Hide file tree
Showing 10 changed files with 86 additions and 64 deletions.
30 changes: 15 additions & 15 deletions open_city_profile/graphene.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,16 @@
from graphene_django import DjangoObjectType
from graphene_django.forms.converter import convert_form_field
from graphene_django.types import ALL_FIELDS
from graphql_sync_dataloaders import SyncDataLoader
from parler.models import TranslatableModel

from profiles.loaders import (
AddressesByProfileIdLoader,
EmailsByProfileIdLoader,
PhonesByProfileIdLoader,
PrimaryAddressForProfileLoader,
PrimaryEmailForProfileLoader,
PrimaryPhoneForProfileLoader,
addresses_by_profile_id_loader,
emails_by_profile_id_loader,
phones_by_profile_id_loader,
primary_address_for_profile_loader,
primary_email_for_profile_loader,
primary_phone_for_profile_loader,
)


Expand Down Expand Up @@ -52,12 +53,12 @@ class UUIDMultipleChoiceFilter(MultipleChoiceFilter):


_LOADERS = {
"addresses_by_profile_id_loader": AddressesByProfileIdLoader,
"emails_by_profile_id_loader": EmailsByProfileIdLoader,
"phones_by_profile_id_loader": PhonesByProfileIdLoader,
"primary_address_for_profile_loader": PrimaryAddressForProfileLoader,
"primary_email_for_profile_loader": PrimaryEmailForProfileLoader,
"primary_phone_for_profile_loader": PrimaryPhoneForProfileLoader,
"addresses_by_profile_id_loader": addresses_by_profile_id_loader,
"emails_by_profile_id_loader": emails_by_profile_id_loader,
"phones_by_profile_id_loader": phones_by_profile_id_loader,
"primary_address_for_profile_loader": primary_address_for_profile_loader,
"primary_email_for_profile_loader": primary_email_for_profile_loader,
"primary_phone_for_profile_loader": primary_phone_for_profile_loader,
}


Expand All @@ -69,9 +70,8 @@ def resolve(self, next, root, info, **kwargs):
context = info.context

if not self.cached_loaders:
for loader_name, loader_class in _LOADERS.items():
setattr(context, loader_name, loader_class())

for loader_name, loader_function in _LOADERS.items():
setattr(context, loader_name, SyncDataLoader(loader_function))
self.cached_loaders = True

return next(root, info, **kwargs)
Expand Down
11 changes: 9 additions & 2 deletions open_city_profile/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from graphene_django.settings import graphene_settings
from graphene_django.views import instantiate_middleware
from graphql import build_client_schema, get_introspection_query
from graphql_sync_dataloaders import DeferredExecutionContext
from helusers.authz import UserAuthorization

from open_city_profile.schema import schema
Expand Down Expand Up @@ -127,6 +128,11 @@ def keycloak_setup(settings):
settings.KEYCLOAK_CLIENT_SECRET = "test-keycloak-client-secret"


@pytest.fixture
def execution_context_class():
return DeferredExecutionContext


@pytest.fixture
def user():
return UserFactory()
Expand Down Expand Up @@ -178,9 +184,10 @@ def superuser_gql_client():


@pytest.fixture
def gql_schema(anon_user_gql_client):
def gql_schema(anon_user_gql_client, execution_context_class):
introspection = anon_user_gql_client.execute(
get_introspection_query(descriptions=False)
get_introspection_query(descriptions=False),
execution_context_class=execution_context_class,
)
return build_client_schema(introspection["data"])

Expand Down
6 changes: 5 additions & 1 deletion open_city_profile/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import TemplateView
from graphql_sync_dataloaders import DeferredExecutionContext

from open_city_profile.views import GraphQLView

Expand All @@ -14,7 +15,10 @@
path(
"graphql/",
csrf_exempt(
GraphQLView.as_view(graphiql=settings.ENABLE_GRAPHIQL or settings.DEBUG)
GraphQLView.as_view(
graphiql=settings.ENABLE_GRAPHIQL or settings.DEBUG,
execution_context_class=DeferredExecutionContext,
)
),
),
path("auth/", include("helusers.urls")),
Expand Down
68 changes: 31 additions & 37 deletions profiles/loaders.py
Original file line number Diff line number Diff line change
@@ -1,54 +1,48 @@
import uuid
from collections import defaultdict

from promise import Promise
from promise.dataloader import DataLoader
from typing import Callable, List

from profiles.models import Address, Email, Phone


def loader_for_profile(model):
class BaseByProfileIdLoader(DataLoader):
def batch_load_fn(self, profile_ids):
items_by_profile_ids = defaultdict(list)
for item in model.objects.filter(profile_id__in=profile_ids).iterator():
items_by_profile_ids[item.profile_id].append(item)
return Promise.resolve(
[items_by_profile_ids.get(profile_id, []) for profile_id in profile_ids]
)
def loader_for_profile(model) -> Callable:
def batch_load_fn(profile_ids: List[uuid.UUID]) -> List[List[model]]:
items_by_profile_ids = defaultdict(list)
for item in model.objects.filter(profile_id__in=profile_ids).iterator():
items_by_profile_ids[item.profile_id].append(item)

return [items_by_profile_ids[profile_id] for profile_id in profile_ids]

return BaseByProfileIdLoader
return batch_load_fn


def loader_for_profile_primary(model):
class BaseByProfileIdPrimaryLoader(DataLoader):
def batch_load_fn(self, profile_ids):
items_by_profile_ids = defaultdict()
for item in model.objects.filter(
profile_id__in=profile_ids, primary=True
).iterator():
items_by_profile_ids[item.profile_id] = item
def loader_for_profile_primary(model) -> Callable:
def batch_load_fn(profile_ids: List[uuid.UUID]) -> List[model]:
items_by_profile_ids = {}
for item in model.objects.filter(
profile_id__in=profile_ids, primary=True
).iterator():
items_by_profile_ids[item.profile_id] = item

return Promise.resolve(
[items_by_profile_ids.get(profile_id) for profile_id in profile_ids]
)
return [items_by_profile_ids.get(profile_id) for profile_id in profile_ids]

return BaseByProfileIdPrimaryLoader
return batch_load_fn


EmailsByProfileIdLoader = loader_for_profile(Email)
PhonesByProfileIdLoader = loader_for_profile(Phone)
AddressesByProfileIdLoader = loader_for_profile(Address)
addresses_by_profile_id_loader = loader_for_profile(Address)
emails_by_profile_id_loader = loader_for_profile(Email)
phones_by_profile_id_loader = loader_for_profile(Phone)

PrimaryEmailForProfileLoader = loader_for_profile_primary(Email)
PrimaryPhoneForProfileLoader = loader_for_profile_primary(Phone)
PrimaryAddressForProfileLoader = loader_for_profile_primary(Address)
primary_address_for_profile_loader = loader_for_profile_primary(Address)
primary_email_for_profile_loader = loader_for_profile_primary(Email)
primary_phone_for_profile_loader = loader_for_profile_primary(Phone)


__all__ = [
"AddressesByProfileIdLoader",
"EmailsByProfileIdLoader",
"PhonesByProfileIdLoader",
"PrimaryAddressForProfileLoader",
"PrimaryEmailForProfileLoader",
"PrimaryPhoneForProfileLoader",
"addresses_by_profile_id_loader",
"emails_by_profile_id_loader",
"phones_by_profile_id_loader",
"primary_address_for_profile_loader",
"primary_email_for_profile_loader",
"primary_phone_for_profile_loader",
]
6 changes: 3 additions & 3 deletions profiles/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,13 +547,13 @@ def resolve_primary_address(self: Profile, info, **kwargs):
return info.context.primary_address_for_profile_loader.load(self.id)

def resolve_emails(self: Profile, info, **kwargs):
return info.context.emails_by_profile_id_loader.load(self.id)
return self.emails.all()

def resolve_phones(self: Profile, info, **kwargs):
return info.context.phones_by_profile_id_loader.load(self.id)
return self.phones.all()

def resolve_addresses(self: Profile, info, **kwargs):
return info.context.addresses_by_profile_id_loader.load(self.id)
return self.addresses.all()


@key(fields="id")
Expand Down
10 changes: 8 additions & 2 deletions profiles/tests/test_gql_claim_profile_mutation.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,9 @@ def test_can_not_delete_primary_email(user_gql_client):
assert_match_error_code(executed, "PROFILE_MUST_HAVE_PRIMARY_EMAIL")


def test_changing_an_email_address_marks_it_unverified(user_gql_client):
def test_changing_an_email_address_marks_it_unverified(
user_gql_client, execution_context_class
):
profile = ProfileFactory(user=None)
email = EmailFactory(profile=profile, verified=True)
claim_token = ClaimTokenFactory(profile=profile)
Expand Down Expand Up @@ -163,7 +165,11 @@ def test_changing_an_email_address_marks_it_unverified(user_gql_client):
},
}

executed = user_gql_client.execute(CLAIM_PROFILE_MUTATION, variables=variables)
executed = user_gql_client.execute(
CLAIM_PROFILE_MUTATION,
variables=variables,
execution_context_class=execution_context_class,
)
assert "errors" not in executed
assert executed["data"] == expected_data

Expand Down
8 changes: 6 additions & 2 deletions profiles/tests/test_gql_my_profile_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,9 @@ def test_normal_user_can_query_addresses(user_gql_client):
assert dict(executed["data"]) == expected_data


def test_normal_user_can_query_primary_contact_details(user_gql_client):
def test_normal_user_can_query_primary_contact_details(
user_gql_client, execution_context_class
):
profile = ProfileFactory(user=user_gql_client.user)
phone = PhoneFactory(profile=profile, primary=True)
email = EmailFactory(profile=profile, primary=True)
Expand Down Expand Up @@ -179,7 +181,9 @@ def test_normal_user_can_query_primary_contact_details(user_gql_client):
},
}
}
executed = user_gql_client.execute(query)
executed = user_gql_client.execute(
query, execution_context_class=execution_context_class
)
assert dict(executed["data"]) == expected_data


Expand Down
7 changes: 5 additions & 2 deletions profiles/tests/test_gql_update_my_profile_mutation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1061,7 +1061,7 @@ def test_can_not_remove_address_of_another_profile(user_gql_client):


def test_change_primary_contact_details(
user_gql_client, email_data, phone_data, address_data
user_gql_client, email_data, phone_data, address_data, execution_context_class
):
profile = ProfileFactory(user=user_gql_client.user)
PhoneFactory(profile=profile, primary=True)
Expand Down Expand Up @@ -1159,7 +1159,10 @@ def test_change_primary_contact_details(
address_type=address_data["address_type"],
primary="true",
)
executed = user_gql_client.execute(mutation)
executed = user_gql_client.execute(
mutation, execution_context_class=execution_context_class
)
assert "errors" not in executed
assert dict(executed["data"]) == expected_data


Expand Down
1 change: 1 addition & 0 deletions requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ django-sanitized-dump
django-searchable-encrypted-fields
graphene-django
graphene-federation
graphql-sync-dataloaders
git+https://github.com/City-of-Helsinki/graphene-validator.git@graphene3
ipython
iso3166
Expand Down
3 changes: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,13 @@ graphql-core==3.2.3
# graphene-django
# graphene-federation
# graphql-relay
# graphql-sync-dataloaders
graphql-relay==3.2.0
# via
# graphene
# graphene-django
graphql-sync-dataloaders==0.1.1
# via -r requirements.in
idna==3.6
# via requests
ipython==8.21.0
Expand Down

0 comments on commit 077b6de

Please sign in to comment.