From 9d564a0d4541baa9f3c6824955a84a8ca0eff327 Mon Sep 17 00:00:00 2001 From: Thomas Leonard Date: Wed, 10 Feb 2021 10:05:45 +0100 Subject: [PATCH 1/2] Issue #1111: foreign key should also call get_queryset method --- graphene_django/converter.py | 19 +- graphene_django/tests/models.py | 3 + graphene_django/tests/test_get_queryset.py | 355 +++++++++++++++++++++ graphene_django/tests/test_query.py | 70 +++- 4 files changed, 443 insertions(+), 4 deletions(-) create mode 100644 graphene_django/tests/test_get_queryset.py diff --git a/graphene_django/converter.py b/graphene_django/converter.py index c243e8227..005bf854f 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -301,7 +301,24 @@ def dynamic_type(): if not _type: return - return Field( + class CustomField(Field): + def wrap_resolve(self, parent_resolver): + """ + Implements a custom resolver which go through the `get_node` method to insure that + it goes through the `get_queryset` method of the DjangoObjectType. + """ + resolver = super().wrap_resolve(parent_resolver) + + def custom_resolver(root, info, **args): + fk_obj = resolver(root, info, **args) + if fk_obj is None: + return None + else: + return _type.get_node(info, fk_obj.pk) + + return custom_resolver + + return CustomField( _type, description=get_django_field_description(field), required=not field.null, diff --git a/graphene_django/tests/models.py b/graphene_django/tests/models.py index 7b76cd378..c26a6d8d5 100644 --- a/graphene_django/tests/models.py +++ b/graphene_django/tests/models.py @@ -13,6 +13,9 @@ class Person(models.Model): class Pet(models.Model): name = models.CharField(max_length=30) age = models.PositiveIntegerField() + owner = models.ForeignKey( + "Person", on_delete=models.CASCADE, null=True, blank=True, related_name="pets" + ) class FilmDetails(models.Model): diff --git a/graphene_django/tests/test_get_queryset.py b/graphene_django/tests/test_get_queryset.py new file mode 100644 index 000000000..b2647c341 --- /dev/null +++ b/graphene_django/tests/test_get_queryset.py @@ -0,0 +1,355 @@ +import pytest + +import graphene +from graphene.relay import Node + +from graphql_relay import to_global_id + +from ..fields import DjangoConnectionField +from ..types import DjangoObjectType + +from .models import Article, Reporter + + +class TestShouldCallGetQuerySetOnForeignKey: + """ + Check that the get_queryset method is called in both forward and reversed direction + of a foreignkey on types. + (see issue #1111) + """ + + @pytest.fixture(autouse=True) + def setup_schema(self): + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + + @classmethod + def get_queryset(cls, queryset, info): + if info.context and info.context.get("admin"): + return queryset + raise Exception("Not authorized to access reporters.") + + class ArticleType(DjangoObjectType): + class Meta: + model = Article + + @classmethod + def get_queryset(cls, queryset, info): + return queryset.exclude(headline__startswith="Draft") + + class Query(graphene.ObjectType): + reporter = graphene.Field(ReporterType, id=graphene.ID(required=True)) + article = graphene.Field(ArticleType, id=graphene.ID(required=True)) + + def resolve_reporter(self, info, id): + return ( + ReporterType.get_queryset(Reporter.objects, info) + .filter(id=id) + .last() + ) + + def resolve_article(self, info, id): + return ( + ArticleType.get_queryset(Article.objects, info).filter(id=id).last() + ) + + self.schema = graphene.Schema(query=Query) + + self.reporter = Reporter.objects.create(first_name="Jane", last_name="Doe") + + self.articles = [ + Article.objects.create( + headline="A fantastic article", + reporter=self.reporter, + editor=self.reporter, + ), + Article.objects.create( + headline="Draft: My next best seller", + reporter=self.reporter, + editor=self.reporter, + ), + ] + + def test_get_queryset_called_on_field(self): + # If a user tries to access an article it is fine as long as it's not a draft one + query = """ + query getArticle($id: ID!) { + article(id: $id) { + headline + } + } + """ + # Non-draft + result = self.schema.execute(query, variables={"id": self.articles[0].id}) + assert not result.errors + assert result.data["article"] == { + "headline": "A fantastic article", + } + # Draft + result = self.schema.execute(query, variables={"id": self.articles[1].id}) + assert not result.errors + assert result.data["article"] is None + + # If a non admin user tries to access a reporter they should get our authorization error + query = """ + query getReporter($id: ID!) { + reporter(id: $id) { + firstName + } + } + """ + + result = self.schema.execute(query, variables={"id": self.reporter.id}) + assert len(result.errors) == 1 + assert result.errors[0].message == "Not authorized to access reporters." + + # An admin user should be able to get reporters + query = """ + query getReporter($id: ID!) { + reporter(id: $id) { + firstName + } + } + """ + + result = self.schema.execute( + query, variables={"id": self.reporter.id}, context_value={"admin": True}, + ) + assert not result.errors + assert result.data == {"reporter": {"firstName": "Jane"}} + + def test_get_queryset_called_on_foreignkey(self): + # If a user tries to access a reporter through an article they should get our authorization error + query = """ + query getArticle($id: ID!) { + article(id: $id) { + headline + reporter { + firstName + } + } + } + """ + + result = self.schema.execute(query, variables={"id": self.articles[0].id}) + assert len(result.errors) == 1 + assert result.errors[0].message == "Not authorized to access reporters." + + # An admin user should be able to get reporters through an article + query = """ + query getArticle($id: ID!) { + article(id: $id) { + headline + reporter { + firstName + } + } + } + """ + + result = self.schema.execute( + query, variables={"id": self.articles[0].id}, context_value={"admin": True}, + ) + assert not result.errors + assert result.data["article"] == { + "headline": "A fantastic article", + "reporter": {"firstName": "Jane"}, + } + + # An admin user should not be able to access draft article through a reporter + query = """ + query getReporter($id: ID!) { + reporter(id: $id) { + firstName + articles { + headline + } + } + } + """ + + result = self.schema.execute( + query, variables={"id": self.reporter.id}, context_value={"admin": True}, + ) + assert not result.errors + assert result.data["reporter"] == { + "firstName": "Jane", + "articles": [{"headline": "A fantastic article"}], + } + + +class TestShouldCallGetQuerySetOnForeignKeyNode: + """ + Check that the get_queryset method is called in both forward and reversed direction + of a foreignkey on types using a node interface. + (see issue #1111) + """ + + @pytest.fixture(autouse=True) + def setup_schema(self): + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + + @classmethod + def get_queryset(cls, queryset, info): + if info.context and info.context.get("admin"): + return queryset + raise Exception("Not authorized to access reporters.") + + class ArticleType(DjangoObjectType): + class Meta: + model = Article + interfaces = (Node,) + + @classmethod + def get_queryset(cls, queryset, info): + return queryset.exclude(headline__startswith="Draft") + + class Query(graphene.ObjectType): + reporter = Node.Field(ReporterType) + article = Node.Field(ArticleType) + + self.schema = graphene.Schema(query=Query) + + self.reporter = Reporter.objects.create(first_name="Jane", last_name="Doe") + + self.articles = [ + Article.objects.create( + headline="A fantastic article", + reporter=self.reporter, + editor=self.reporter, + ), + Article.objects.create( + headline="Draft: My next best seller", + reporter=self.reporter, + editor=self.reporter, + ), + ] + + def test_get_queryset_called_on_node(self): + # If a user tries to access an article it is fine as long as it's not a draft one + query = """ + query getArticle($id: ID!) { + article(id: $id) { + headline + } + } + """ + # Non-draft + result = self.schema.execute( + query, variables={"id": to_global_id("ArticleType", self.articles[0].id)} + ) + assert not result.errors + assert result.data["article"] == { + "headline": "A fantastic article", + } + # Draft + result = self.schema.execute( + query, variables={"id": to_global_id("ArticleType", self.articles[1].id)} + ) + assert not result.errors + assert result.data["article"] is None + + # If a non admin user tries to access a reporter they should get our authorization error + query = """ + query getReporter($id: ID!) { + reporter(id: $id) { + firstName + } + } + """ + + result = self.schema.execute( + query, variables={"id": to_global_id("ReporterType", self.reporter.id)} + ) + assert len(result.errors) == 1 + assert result.errors[0].message == "Not authorized to access reporters." + + # An admin user should be able to get reporters + query = """ + query getReporter($id: ID!) { + reporter(id: $id) { + firstName + } + } + """ + + result = self.schema.execute( + query, + variables={"id": to_global_id("ReporterType", self.reporter.id)}, + context_value={"admin": True}, + ) + assert not result.errors + assert result.data == {"reporter": {"firstName": "Jane"}} + + def test_get_queryset_called_on_foreignkey(self): + # If a user tries to access a reporter through an article they should get our authorization error + query = """ + query getArticle($id: ID!) { + article(id: $id) { + headline + reporter { + firstName + } + } + } + """ + + result = self.schema.execute( + query, variables={"id": to_global_id("ArticleType", self.articles[0].id)} + ) + assert len(result.errors) == 1 + assert result.errors[0].message == "Not authorized to access reporters." + + # An admin user should be able to get reporters through an article + query = """ + query getArticle($id: ID!) { + article(id: $id) { + headline + reporter { + firstName + } + } + } + """ + + result = self.schema.execute( + query, + variables={"id": to_global_id("ArticleType", self.articles[0].id)}, + context_value={"admin": True}, + ) + assert not result.errors + assert result.data["article"] == { + "headline": "A fantastic article", + "reporter": {"firstName": "Jane"}, + } + + # An admin user should not be able to access draft article through a reporter + query = """ + query getReporter($id: ID!) { + reporter(id: $id) { + firstName + articles { + edges { + node { + headline + } + } + } + } + } + """ + + result = self.schema.execute( + query, + variables={"id": to_global_id("ReporterType", self.reporter.id)}, + context_value={"admin": True}, + ) + assert not result.errors + assert result.data["reporter"] == { + "firstName": "Jane", + "articles": {"edges": [{"node": {"headline": "A fantastic article"}}]}, + } diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index aabe19ceb..dfde6d004 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -15,7 +15,7 @@ from ..fields import DjangoConnectionField from ..types import DjangoObjectType from ..utils import DJANGO_FILTER_INSTALLED -from .models import Article, CNNReporter, Film, FilmDetails, Reporter +from .models import Article, CNNReporter, Film, FilmDetails, Person, Pet, Reporter def test_should_query_only_fields(): @@ -251,8 +251,8 @@ def resolve_reporter(self, info): def test_should_query_onetoone_fields(): - film = Film(id=1) - film_details = FilmDetails(id=1, film=film) + film = Film.objects.create(id=1) + film_details = FilmDetails.objects.create(id=1, film=film) class FilmNode(DjangoObjectType): class Meta: @@ -1593,3 +1593,67 @@ class Query(graphene.ObjectType): "allReporters": {"edges": [{"node": {"firstName": "Jane", "lastName": "Roe"}},]} } assert result.data == expected + + +def test_should_query_nullable_foreign_key(): + class PetType(DjangoObjectType): + class Meta: + model = Pet + + class PersonType(DjangoObjectType): + class Meta: + model = Person + + class Query(graphene.ObjectType): + pet = graphene.Field(PetType, name=graphene.String(required=True)) + person = graphene.Field(PersonType, name=graphene.String(required=True)) + + def resolve_pet(self, info, name): + return Pet.objects.filter(name=name).first() + + def resolve_person(self, info, name): + return Person.objects.filter(name=name).first() + + schema = graphene.Schema(query=Query) + + person = Person.objects.create(name="Jane") + pets = [ + Pet.objects.create(name="Stray dog", age=1), + Pet.objects.create(name="Jane's dog", owner=person, age=1), + ] + + query_pet = """ + query getPet($name: String!) { + pet(name: $name) { + owner { + name + } + } + } + """ + result = schema.execute(query_pet, variables={"name": "Stray dog"}) + assert not result.errors + assert result.data["pet"] == { + "owner": None, + } + + result = schema.execute(query_pet, variables={"name": "Jane's dog"}) + assert not result.errors + assert result.data["pet"] == { + "owner": {"name": "Jane"}, + } + + query_owner = """ + query getOwner($name: String!) { + person(name: $name) { + pets { + name + } + } + } + """ + result = schema.execute(query_owner, variables={"name": "Jane"}) + assert not result.errors + assert result.data["person"] == { + "pets": [{"name": "Jane's dog"}], + } From ca555293a4334c26cf9a390dd1e3d0bd4c819a17 Mon Sep 17 00:00:00 2001 From: Thomas Leonard Date: Sun, 20 Mar 2022 22:48:54 +0100 Subject: [PATCH 2/2] fix: test for graphene PR https://github.com/graphql-python/graphene/pull/1412 --- .../filter/tests/test_array_field_exact_filter.py | 5 +---- graphene_django/filter/tests/test_enum_filtering.py | 5 +---- graphene_django/filter/tests/test_fields.py | 4 ++-- graphene_django/filter/tests/test_typed_filter.py | 2 +- graphene_django/tests/test_types.py | 2 +- setup.py | 3 ++- 6 files changed, 8 insertions(+), 13 deletions(-) diff --git a/graphene_django/filter/tests/test_array_field_exact_filter.py b/graphene_django/filter/tests/test_array_field_exact_filter.py index cd72868c9..10e32ef73 100644 --- a/graphene_django/filter/tests/test_array_field_exact_filter.py +++ b/graphene_django/filter/tests/test_array_field_exact_filter.py @@ -120,10 +120,7 @@ def test_array_field_filter_schema_type(Query): "randomField": "[Boolean!]", } filters_str = ", ".join( - [ - f"{filter_field}: {gql_type} = null" - for filter_field, gql_type in filters.items() - ] + [f"{filter_field}: {gql_type}" for filter_field, gql_type in filters.items()] ) assert ( f"type Query {{\n events({filters_str}): EventTypeConnection\n}}" in schema_str diff --git a/graphene_django/filter/tests/test_enum_filtering.py b/graphene_django/filter/tests/test_enum_filtering.py index 09c69b393..4fe7dddb4 100644 --- a/graphene_django/filter/tests/test_enum_filtering.py +++ b/graphene_django/filter/tests/test_enum_filtering.py @@ -152,9 +152,6 @@ def test_filter_enum_field_schema_type(schema): "reporter_AChoice_In": "[TestsReporterAChoiceChoices]", } filters_str = ", ".join( - [ - f"{filter_field}: {gql_type} = null" - for filter_field, gql_type in filters.items() - ] + [f"{filter_field}: {gql_type}" for filter_field, gql_type in filters.items()] ) assert f" allArticles({filters_str}): ArticleTypeConnection\n" in schema_str diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index 7d440f4a1..68d6e3276 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -1008,7 +1008,7 @@ class Query(ObjectType): assert str(schema) == dedent( """\ type Query { - pets(offset: Int = null, before: String = null, after: String = null, first: Int = null, last: Int = null, age: Int = null): PetTypeConnection + pets(offset: Int, before: String, after: String, first: Int, last: Int, age: Int): PetTypeConnection } type PetTypeConnection { @@ -1077,7 +1077,7 @@ class Query(ObjectType): assert str(schema) == dedent( """\ type Query { - pets(offset: Int = null, before: String = null, after: String = null, first: Int = null, last: Int = null, age: Int = null, age_Isnull: Boolean = null, age_Lt: Int = null): PetTypeConnection + pets(offset: Int, before: String, after: String, first: Int, last: Int, age: Int, age_Isnull: Boolean, age_Lt: Int): PetTypeConnection } type PetTypeConnection { diff --git a/graphene_django/filter/tests/test_typed_filter.py b/graphene_django/filter/tests/test_typed_filter.py index b903b590f..cc0bafe25 100644 --- a/graphene_django/filter/tests/test_typed_filter.py +++ b/graphene_django/filter/tests/test_typed_filter.py @@ -98,7 +98,7 @@ def test_typed_filter_schema(schema): ) for filter_field, gql_type in filters.items(): - assert "{}: {} = null".format(filter_field, gql_type) in all_articles_filters + assert "{}: {}".format(filter_field, gql_type) in all_articles_filters def test_typed_filters_work(schema): diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index bde72c7a6..b6f2e5e92 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -183,7 +183,7 @@ def test_schema_representation(): pets: [Reporter!]! aChoice: TestsReporterAChoiceChoices reporterType: TestsReporterReporterTypeChoices - articles(offset: Int = null, before: String = null, after: String = null, first: Int = null, last: Int = null): ArticleConnection! + articles(offset: Int, before: String, after: String, first: Int, last: Int): ArticleConnection! } \"""An enumeration.\""" diff --git a/setup.py b/setup.py index 176276065..13a57e8e6 100644 --- a/setup.py +++ b/setup.py @@ -60,7 +60,8 @@ keywords="api graphql protocol rest relay graphene", packages=find_packages(exclude=["tests", "examples", "examples.*"]), install_requires=[ - "graphene>=3.0,<4", + # "graphene>=3.0,<4", + "graphene @ git+https://github.com/loft-orbital/graphene.git@loft-v3-1.0#egg=graphene", "graphql-core>=3.1.0,<4", "graphql-relay>=3.1.1,<4", "Django>=2.2",