Merge branch 'main' into chore/pre-commit

This commit is contained in:
Nikolai R Kristiansen 2022-09-23 11:33:39 +02:00
commit e1e046e307
28 changed files with 692 additions and 156 deletions

View File

@ -8,15 +8,11 @@ jobs:
strategy: strategy:
max-parallel: 4 max-parallel: 4
matrix: matrix:
django: ["2.2", "3.0", "3.1", "3.2"] django: ["3.2", "4.0", "4.1"]
python-version: ["3.6", "3.7", "3.8", "3.9"] python-version: ["3.8", "3.9", "3.10"]
include: include:
- django: "3.2" - django: "3.2"
python-version: "3.10" python-version: "3.7"
- django: "4.0"
python-version: "3.10"
- django: "main"
python-version: "3.10"
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}

View File

@ -1,22 +1,21 @@
.PHONY: help
help:
@echo "Please use \`make <target>' where <target> is one of"
@grep -E '^\.PHONY: [a-zA-Z_-]+ .*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = "(: |##)"}; {printf "\033[36m%-30s\033[0m %s\n", $$2, $$3}'
.PHONY: dev-setup ## Install development dependencies .PHONY: dev-setup ## Install development dependencies
dev-setup: dev-setup:
pip install -e ".[dev]" pip install -e ".[dev]"
.PHONY: install-dev .PHONY: tests ## Run unit tests
install-dev: dev-setup # Alias install-dev -> dev-setup
.PHONY: tests
tests: tests:
py.test graphene_django --cov=graphene_django -vv py.test graphene_django --cov=graphene_django -vv
.PHONY: test .PHONY: format ## Format code
test: tests # Alias test -> tests
.PHONY: format
format: format:
black graphene_django examples setup.py black graphene_django examples setup.py
.PHONY: lint .PHONY: lint ## Lint code
lint: lint:
flake8 graphene_django examples flake8 graphene_django examples

View File

@ -151,7 +151,7 @@ Create ``cookbook/ingredients/schema.py`` and type the following:
interfaces = (relay.Node, ) interfaces = (relay.Node, )
class Query(graphene.ObjectType): class Query(ObjectType):
category = relay.Node.Field(CategoryNode) category = relay.Node.Field(CategoryNode)
all_categories = DjangoFilterConnectionField(CategoryNode) all_categories = DjangoFilterConnectionField(CategoryNode)

View File

@ -1,7 +1,7 @@
from .fields import DjangoConnectionField, DjangoListField from .fields import DjangoConnectionField, DjangoListField
from .types import DjangoObjectType from .types import DjangoObjectType
__version__ = "3.0.0b7" __version__ = "3.0.0b8"
__all__ = [ __all__ = [
"__version__", "__version__",

View File

@ -24,6 +24,7 @@ from graphene import (
Decimal, Decimal,
) )
from graphene.types.json import JSONString from graphene.types.json import JSONString
from graphene.types.scalars import BigInt
from graphene.utils.str_converters import to_camel_case from graphene.utils.str_converters import to_camel_case
from graphql import GraphQLError, assert_valid_name from graphql import GraphQLError, assert_valid_name
from graphql.pyutils import register_description from graphql.pyutils import register_description
@ -186,10 +187,14 @@ def convert_field_to_uuid(field, registry=None):
) )
@convert_django_field.register(models.BigIntegerField)
def convert_big_int_field(field, registry=None):
return BigInt(description=field.help_text, required=not field.null)
@convert_django_field.register(models.PositiveIntegerField) @convert_django_field.register(models.PositiveIntegerField)
@convert_django_field.register(models.PositiveSmallIntegerField) @convert_django_field.register(models.PositiveSmallIntegerField)
@convert_django_field.register(models.SmallIntegerField) @convert_django_field.register(models.SmallIntegerField)
@convert_django_field.register(models.BigIntegerField)
@convert_django_field.register(models.IntegerField) @convert_django_field.register(models.IntegerField)
def convert_field_to_int(field, registry=None): def convert_field_to_int(field, registry=None):
return Int(description=get_django_field_description(field), required=not field.null) return Int(description=get_django_field_description(field), required=not field.null)
@ -205,7 +210,9 @@ def convert_field_to_boolean(field, registry=None):
@convert_django_field.register(models.DecimalField) @convert_django_field.register(models.DecimalField)
def convert_field_to_decimal(field, registry=None): def convert_field_to_decimal(field, registry=None):
return Decimal(description=field.help_text, required=not field.null) return Decimal(
description=get_django_field_description(field), required=not field.null
)
@convert_django_field.register(models.FloatField) @convert_django_field.register(models.FloatField)
@ -301,7 +308,24 @@ def convert_field_to_djangomodel(field, registry=None):
if not _type: if not _type:
return 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, _type,
description=get_django_field_description(field), description=get_django_field_description(field),
required=not field.null, required=not field.null,

View File

@ -69,7 +69,10 @@ class DjangoListField(Field):
_type = _type.of_type _type = _type.of_type
django_object_type = _type.of_type.of_type django_object_type = _type.of_type.of_type
return partial( return partial(
self.list_resolver, django_object_type, resolver, self.get_manager(), self.list_resolver,
django_object_type,
resolver,
self.get_manager(),
) )
@ -146,36 +149,40 @@ class DjangoConnectionField(ConnectionField):
iterable = maybe_queryset(iterable) iterable = maybe_queryset(iterable)
if isinstance(iterable, QuerySet): if isinstance(iterable, QuerySet):
list_length = iterable.count() array_length = iterable.count()
else: else:
list_length = len(iterable) array_length = len(iterable)
list_slice_length = (
min(max_limit, list_length) if max_limit is not None else list_length
)
# If after is higher than list_length, connection_from_list_slice # If after is higher than array_length, connection_from_array_slice
# would try to do a negative slicing which makes django throw an # would try to do a negative slicing which makes django throw an
# AssertionError # AssertionError
after = min(get_offset_with_default(args.get("after"), -1) + 1, list_length) slice_start = min(
get_offset_with_default(args.get("after"), -1) + 1,
array_length,
)
array_slice_length = array_length - slice_start
if max_limit is not None and args.get("first", None) is None: # Impose the maximum limit via the `first` field if neither first or last are already provided
if args.get("last", None) is not None: # (note that if any of them is provided they must be under max_limit otherwise an error is raised).
after = list_length - args["last"] if (
else: max_limit is not None
args["first"] = max_limit and args.get("first", None) is None
and args.get("last", None) is None
):
args["first"] = max_limit
connection = connection_from_array_slice( connection = connection_from_array_slice(
iterable[after:], iterable[slice_start:],
args, args,
slice_start=after, slice_start=slice_start,
array_length=list_length, array_length=array_length,
array_slice_length=list_slice_length, array_slice_length=array_slice_length,
connection_type=partial(connection_adapter, connection), connection_type=partial(connection_adapter, connection),
edge_type=connection.Edge, edge_type=connection.Edge,
page_info_type=page_info_adapter, page_info_type=page_info_adapter,
) )
connection.iterable = iterable connection.iterable = iterable
connection.length = list_length connection.length = array_length
return connection return connection
@classmethod @classmethod

View File

@ -30,7 +30,7 @@ def convert_enum(data):
class DjangoFilterConnectionField(DjangoConnectionField): class DjangoFilterConnectionField(DjangoConnectionField):
def __init__( def __init__(
self, self,
type, type_,
fields=None, fields=None,
order_by=None, order_by=None,
extra_filter_meta=None, extra_filter_meta=None,
@ -44,7 +44,7 @@ class DjangoFilterConnectionField(DjangoConnectionField):
self._filtering_args = None self._filtering_args = None
self._extra_filter_meta = extra_filter_meta self._extra_filter_meta = extra_filter_meta
self._base_args = None self._base_args = None
super(DjangoFilterConnectionField, self).__init__(type, *args, **kwargs) super(DjangoFilterConnectionField, self).__init__(type_, *args, **kwargs)
@property @property
def args(self): def args(self):

View File

@ -13,7 +13,7 @@ class GlobalIDFilter(Filter):
field_class = GlobalIDFormField field_class = GlobalIDFormField
def filter(self, qs, value): def filter(self, qs, value):
""" Convert the filter value to a primary key before filtering """ """Convert the filter value to a primary key before filtering"""
_id = None _id = None
if value is not None: if value is not None:
_, _id = from_global_id(value) _, _id = from_global_id(value)

View File

@ -18,8 +18,8 @@ GRAPHENE_FILTER_SET_OVERRIDES = {
class GrapheneFilterSetMixin(BaseFilterSet): class GrapheneFilterSetMixin(BaseFilterSet):
""" A django_filters.filterset.BaseFilterSet with default filter overrides """A django_filters.filterset.BaseFilterSet with default filter overrides
to handle global IDs """ to handle global IDs"""
FILTER_DEFAULTS = dict( FILTER_DEFAULTS = dict(
itertools.chain( itertools.chain(
@ -29,8 +29,7 @@ class GrapheneFilterSetMixin(BaseFilterSet):
def setup_filterset(filterset_class): def setup_filterset(filterset_class):
""" Wrap a provided filterset in Graphene-specific functionality """Wrap a provided filterset in Graphene-specific functionality"""
"""
return type( return type(
"Graphene{}".format(filterset_class.__name__), "Graphene{}".format(filterset_class.__name__),
(filterset_class, GrapheneFilterSetMixin), (filterset_class, GrapheneFilterSetMixin),
@ -39,8 +38,7 @@ def setup_filterset(filterset_class):
def custom_filterset_factory(model, filterset_base_class=FilterSet, **meta): def custom_filterset_factory(model, filterset_base_class=FilterSet, **meta):
""" Create a filterset for the given model using the provided meta data """Create a filterset for the given model using the provided meta data"""
"""
meta.update({"model": model}) meta.update({"model": model})
meta_class = type(str("Meta"), (object,), meta) meta_class = type(str("Meta"), (object,), meta)
filterset = type( filterset = type(

View File

@ -89,10 +89,10 @@ def Query(EventType):
def resolve_events(self, info, **kwargs): def resolve_events(self, info, **kwargs):
events = [ events = [
Event(name="Live Show", tags=["concert", "music", "rock"],), Event(name="Live Show", tags=["concert", "music", "rock"]),
Event(name="Musical", tags=["movie", "music"],), Event(name="Musical", tags=["movie", "music"]),
Event(name="Ballet", tags=["concert", "dance"],), Event(name="Ballet", tags=["concert", "dance"]),
Event(name="Speech", tags=[],), Event(name="Speech", tags=[]),
] ]
STORE["events"] = events STORE["events"] = events

View File

@ -120,10 +120,7 @@ def test_array_field_filter_schema_type(Query):
"randomField": "[Boolean!]", "randomField": "[Boolean!]",
} }
filters_str = ", ".join( filters_str = ", ".join(
[ [f"{filter_field}: {gql_type}" for filter_field, gql_type in filters.items()]
f"{filter_field}: {gql_type} = null"
for filter_field, gql_type in filters.items()
]
) )
assert ( assert (
f"type Query {{\n events({filters_str}): EventTypeConnection\n}}" in schema_str f"type Query {{\n events({filters_str}): EventTypeConnection\n}}" in schema_str

View File

@ -54,13 +54,13 @@ def reporter_article_data():
first_name="Jane", last_name="Doe", email="janedoe@example.com", a_choice=2 first_name="Jane", last_name="Doe", email="janedoe@example.com", a_choice=2
) )
Article.objects.create( Article.objects.create(
headline="Article Node 1", reporter=john, editor=john, lang="es", headline="Article Node 1", reporter=john, editor=john, lang="es"
) )
Article.objects.create( Article.objects.create(
headline="Article Node 2", reporter=john, editor=john, lang="en", headline="Article Node 2", reporter=john, editor=john, lang="en"
) )
Article.objects.create( Article.objects.create(
headline="Article Node 3", reporter=jane, editor=jane, lang="en", headline="Article Node 3", reporter=jane, editor=jane, lang="en"
) )
@ -80,7 +80,13 @@ def test_filter_enum_on_connection(schema, reporter_article_data):
} }
""" """
expected = {"allArticles": {"edges": [{"node": {"headline": "Article Node 1"}},]}} expected = {
"allArticles": {
"edges": [
{"node": {"headline": "Article Node 1"}},
]
}
}
result = schema.execute(query) result = schema.execute(query)
assert not result.errors assert not result.errors
@ -152,9 +158,6 @@ def test_filter_enum_field_schema_type(schema):
"reporter_AChoice_In": "[TestsReporterAChoiceChoices]", "reporter_AChoice_In": "[TestsReporterAChoiceChoices]",
} }
filters_str = ", ".join( filters_str = ", ".join(
[ [f"{filter_field}: {gql_type}" for filter_field, gql_type in filters.items()]
f"{filter_field}: {gql_type} = null"
for filter_field, gql_type in filters.items()
]
) )
assert f" allArticles({filters_str}): ArticleTypeConnection\n" in schema_str assert f" allArticles({filters_str}): ArticleTypeConnection\n" in schema_str

View File

@ -1008,7 +1008,7 @@ def test_integer_field_filter_type():
assert str(schema) == dedent( assert str(schema) == dedent(
"""\ """\
type Query { 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 { type PetTypeConnection {
@ -1056,8 +1056,7 @@ def test_integer_field_filter_type():
interface Node { interface Node {
\"""The ID of the object\""" \"""The ID of the object\"""
id: ID! id: ID!
} }"""
"""
) )
@ -1077,7 +1076,7 @@ def test_other_filter_types():
assert str(schema) == dedent( assert str(schema) == dedent(
"""\ """\
type Query { 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 { type PetTypeConnection {
@ -1125,8 +1124,7 @@ def test_other_filter_types():
interface Node { interface Node {
\"""The ID of the object\""" \"""The ID of the object\"""
id: ID! id: ID!
} }"""
"""
) )
@ -1226,7 +1224,7 @@ def test_filter_filterset_based_on_mixin():
} }
} }
result = schema.execute(query, variable_values={"email": reporter_1.email},) result = schema.execute(query, variable_values={"email": reporter_1.email})
assert not result.errors assert not result.errors
assert result.data == expected assert result.data == expected
@ -1267,13 +1265,23 @@ def test_filter_string_contains():
result = schema.execute(query, variables={"filter": "Ja"}) result = schema.execute(query, variables={"filter": "Ja"})
assert not result.errors assert not result.errors
assert result.data == { assert result.data == {
"people": {"edges": [{"node": {"name": "Jack"}}, {"node": {"name": "Jane"}},]} "people": {
"edges": [
{"node": {"name": "Jack"}},
{"node": {"name": "Jane"}},
]
}
} }
result = schema.execute(query, variables={"filter": "o"}) result = schema.execute(query, variables={"filter": "o"})
assert not result.errors assert not result.errors
assert result.data == { assert result.data == {
"people": {"edges": [{"node": {"name": "Joe"}}, {"node": {"name": "Bob"}},]} "people": {
"edges": [
{"node": {"name": "Joe"}},
{"node": {"name": "Bob"}},
]
}
} }

View File

@ -98,20 +98,14 @@ def test_typed_filter_schema(schema):
) )
for filter_field, gql_type in filters.items(): 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): def test_typed_filters_work(schema):
reporter = Reporter.objects.create(first_name="John", last_name="Doe", email="") reporter = Reporter.objects.create(first_name="John", last_name="Doe", email="")
Article.objects.create( Article.objects.create(headline="A", reporter=reporter, editor=reporter, lang="es")
headline="A", reporter=reporter, editor=reporter, lang="es", Article.objects.create(headline="B", reporter=reporter, editor=reporter, lang="es")
) Article.objects.create(headline="C", reporter=reporter, editor=reporter, lang="en")
Article.objects.create(
headline="B", reporter=reporter, editor=reporter, lang="es",
)
Article.objects.create(
headline="C", reporter=reporter, editor=reporter, lang="en",
)
query = "query { articles (lang_In: [ES]) { edges { node { headline } } } }" query = "query { articles (lang_In: [ES]) { edges { node { headline } } } }"

View File

@ -94,7 +94,9 @@ def get_filtering_args_from_filterset(filterset_class, type):
field_type = graphene.List(field_type) field_type = graphene.List(field_type)
args[name] = graphene.Argument( args[name] = graphene.Argument(
field_type, description=filter_field.label, required=required, field_type,
description=filter_field.label,
required=required,
) )
return args return args

View File

@ -117,7 +117,7 @@ class DjangoModelFormMutation(BaseDjangoFormMutation):
class Meta: class Meta:
abstract = True abstract = True
errors = graphene.List(ErrorType) errors = graphene.List(graphene.NonNull(ErrorType), required=True)
@classmethod @classmethod
def __init_subclass_with_meta__( def __init_subclass_with_meta__(

View File

@ -13,6 +13,9 @@ class Person(models.Model):
class Pet(models.Model): class Pet(models.Model):
name = models.CharField(max_length=30) name = models.CharField(max_length=30)
age = models.PositiveIntegerField() age = models.PositiveIntegerField()
owner = models.ForeignKey(
"Person", on_delete=models.CASCADE, null=True, blank=True, related_name="pets"
)
class FilmDetails(models.Model): class FilmDetails(models.Model):

View File

@ -53,6 +53,5 @@ def test_generate_graphql_file_on_call_graphql_schema():
"""\ """\
type Query { type Query {
hi: String hi: String
} }"""
"""
) )

View File

@ -10,6 +10,7 @@ from graphene import NonNull
from graphene.relay import ConnectionField, Node from graphene.relay import ConnectionField, Node
from graphene.types.datetime import Date, DateTime, Time from graphene.types.datetime import Date, DateTime, Time
from graphene.types.json import JSONString from graphene.types.json import JSONString
from graphene.types.scalars import BigInt
from ..compat import ( from ..compat import (
ArrayField, ArrayField,
@ -140,8 +141,8 @@ def test_should_small_integer_convert_int():
assert_conversion(models.SmallIntegerField, graphene.Int) assert_conversion(models.SmallIntegerField, graphene.Int)
def test_should_big_integer_convert_int(): def test_should_big_integer_convert_big_int():
assert_conversion(models.BigIntegerField, graphene.Int) assert_conversion(models.BigIntegerField, BigInt)
def test_should_integer_convert_int(): def test_should_integer_convert_int():

View File

@ -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"}}]},
}

View File

@ -15,7 +15,7 @@ from ..compat import IntegerRangeField, MissingType
from ..fields import DjangoConnectionField from ..fields import DjangoConnectionField
from ..types import DjangoObjectType from ..types import DjangoObjectType
from ..utils import DJANGO_FILTER_INSTALLED 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(): def test_should_query_only_fields():
@ -251,8 +251,8 @@ def test_should_node():
def test_should_query_onetoone_fields(): def test_should_query_onetoone_fields():
film = Film(id=1) film = Film.objects.create(id=1)
film_details = FilmDetails(id=1, film=film) film_details = FilmDetails.objects.create(id=1, film=film)
class FilmNode(DjangoObjectType): class FilmNode(DjangoObjectType):
class Meta: class Meta:
@ -1243,6 +1243,7 @@ def test_should_have_next_page(graphene_settings):
} }
@pytest.mark.parametrize("max_limit", [100, 4])
class TestBackwardPagination: class TestBackwardPagination:
def setup_schema(self, graphene_settings, max_limit): def setup_schema(self, graphene_settings, max_limit):
graphene_settings.RELAY_CONNECTION_MAX_LIMIT = max_limit graphene_settings.RELAY_CONNECTION_MAX_LIMIT = max_limit
@ -1261,8 +1262,8 @@ class TestBackwardPagination:
schema = graphene.Schema(query=Query) schema = graphene.Schema(query=Query)
return schema return schema
def do_queries(self, schema): def test_query_last(self, graphene_settings, max_limit):
# Simply last 3 schema = self.setup_schema(graphene_settings, max_limit=max_limit)
query_last = """ query_last = """
query { query {
allReporters(last: 3) { allReporters(last: 3) {
@ -1282,7 +1283,8 @@ class TestBackwardPagination:
e["node"]["firstName"] for e in result.data["allReporters"]["edges"] e["node"]["firstName"] for e in result.data["allReporters"]["edges"]
] == ["First 3", "First 4", "First 5"] ] == ["First 3", "First 4", "First 5"]
# Use a combination of first and last def test_query_first_and_last(self, graphene_settings, max_limit):
schema = self.setup_schema(graphene_settings, max_limit=max_limit)
query_first_and_last = """ query_first_and_last = """
query { query {
allReporters(first: 4, last: 3) { allReporters(first: 4, last: 3) {
@ -1302,7 +1304,8 @@ class TestBackwardPagination:
e["node"]["firstName"] for e in result.data["allReporters"]["edges"] e["node"]["firstName"] for e in result.data["allReporters"]["edges"]
] == ["First 1", "First 2", "First 3"] ] == ["First 1", "First 2", "First 3"]
# Use a combination of first and last and after def test_query_first_last_and_after(self, graphene_settings, max_limit):
schema = self.setup_schema(graphene_settings, max_limit=max_limit)
query_first_last_and_after = """ query_first_last_and_after = """
query queryAfter($after: String) { query queryAfter($after: String) {
allReporters(first: 4, last: 3, after: $after) { allReporters(first: 4, last: 3, after: $after) {
@ -1317,7 +1320,8 @@ class TestBackwardPagination:
after = base64.b64encode(b"arrayconnection:0").decode() after = base64.b64encode(b"arrayconnection:0").decode()
result = schema.execute( result = schema.execute(
query_first_last_and_after, variable_values=dict(after=after) query_first_last_and_after,
variable_values=dict(after=after),
) )
assert not result.errors assert not result.errors
assert len(result.data["allReporters"]["edges"]) == 3 assert len(result.data["allReporters"]["edges"]) == 3
@ -1325,20 +1329,35 @@ class TestBackwardPagination:
e["node"]["firstName"] for e in result.data["allReporters"]["edges"] e["node"]["firstName"] for e in result.data["allReporters"]["edges"]
] == ["First 2", "First 3", "First 4"] ] == ["First 2", "First 3", "First 4"]
def test_should_query(self, graphene_settings): def test_query_last_and_before(self, graphene_settings, max_limit):
schema = self.setup_schema(graphene_settings, max_limit=max_limit)
query_first_last_and_after = """
query queryAfter($before: String) {
allReporters(last: 1, before: $before) {
edges {
node {
firstName
}
}
}
}
""" """
Backward pagination should work as expected
"""
schema = self.setup_schema(graphene_settings, max_limit=100)
self.do_queries(schema)
def test_should_query_with_low_max_limit(self, graphene_settings): result = schema.execute(
""" query_first_last_and_after,
When doing backward pagination (using last) in combination with a max limit higher than the number of objects )
we should really retrieve the last ones. assert not result.errors
""" assert len(result.data["allReporters"]["edges"]) == 1
schema = self.setup_schema(graphene_settings, max_limit=4) assert result.data["allReporters"]["edges"][0]["node"]["firstName"] == "First 5"
self.do_queries(schema)
before = base64.b64encode(b"arrayconnection:5").decode()
result = schema.execute(
query_first_last_and_after,
variable_values=dict(before=before),
)
assert not result.errors
assert len(result.data["allReporters"]["edges"]) == 1
assert result.data["allReporters"]["edges"][0]["node"]["firstName"] == "First 4"
def test_should_preserve_prefetch_related(django_assert_num_queries): def test_should_preserve_prefetch_related(django_assert_num_queries):
@ -1480,7 +1499,11 @@ def test_connection_should_enable_offset_filtering():
result = schema.execute(query) result = schema.execute(query)
assert not result.errors assert not result.errors
expected = { expected = {
"allReporters": {"edges": [{"node": {"firstName": "Some", "lastName": "Guy"}},]} "allReporters": {
"edges": [
{"node": {"firstName": "Some", "lastName": "Guy"}},
]
}
} }
assert result.data == expected assert result.data == expected
@ -1521,7 +1544,9 @@ def test_connection_should_enable_offset_filtering_higher_than_max_limit(
assert not result.errors assert not result.errors
expected = { expected = {
"allReporters": { "allReporters": {
"edges": [{"node": {"firstName": "Some", "lastName": "Lady"}},] "edges": [
{"node": {"firstName": "Some", "lastName": "Lady"}},
]
} }
} }
assert result.data == expected assert result.data == expected
@ -1590,6 +1615,149 @@ def test_connection_should_allow_offset_filtering_with_after():
result = schema.execute(query, variable_values=dict(after=after)) result = schema.execute(query, variable_values=dict(after=after))
assert not result.errors assert not result.errors
expected = { expected = {
"allReporters": {"edges": [{"node": {"firstName": "Jane", "lastName": "Roe"}},]} "allReporters": {
"edges": [
{"node": {"firstName": "Jane", "lastName": "Roe"}},
]
}
} }
assert result.data == expected assert result.data == expected
def test_connection_should_succeed_if_last_higher_than_number_of_objects():
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType)
schema = graphene.Schema(query=Query)
query = """
query ReporterPromiseConnectionQuery ($last: Int) {
allReporters(last: $last) {
edges {
node {
firstName
lastName
}
}
}
}
"""
result = schema.execute(query, variable_values=dict(last=2))
assert not result.errors
expected = {"allReporters": {"edges": []}}
assert result.data == expected
Reporter.objects.create(first_name="John", last_name="Doe")
Reporter.objects.create(first_name="Some", last_name="Guy")
Reporter.objects.create(first_name="Jane", last_name="Roe")
Reporter.objects.create(first_name="Some", last_name="Lady")
result = schema.execute(query, variable_values=dict(last=2))
assert not result.errors
expected = {
"allReporters": {
"edges": [
{"node": {"firstName": "Jane", "lastName": "Roe"}},
{"node": {"firstName": "Some", "lastName": "Lady"}},
]
}
}
assert result.data == expected
result = schema.execute(query, variable_values=dict(last=4))
assert not result.errors
expected = {
"allReporters": {
"edges": [
{"node": {"firstName": "John", "lastName": "Doe"}},
{"node": {"firstName": "Some", "lastName": "Guy"}},
{"node": {"firstName": "Jane", "lastName": "Roe"}},
{"node": {"firstName": "Some", "lastName": "Lady"}},
]
}
}
assert result.data == expected
result = schema.execute(query, variable_values=dict(last=20))
assert not result.errors
expected = {
"allReporters": {
"edges": [
{"node": {"firstName": "John", "lastName": "Doe"}},
{"node": {"firstName": "Some", "lastName": "Guy"}},
{"node": {"firstName": "Jane", "lastName": "Roe"}},
{"node": {"firstName": "Some", "lastName": "Lady"}},
]
}
}
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"}],
}

View File

@ -183,7 +183,7 @@ def test_schema_representation():
pets: [Reporter!]! pets: [Reporter!]!
aChoice: TestsReporterAChoiceChoices aChoice: TestsReporterAChoiceChoices
reporterType: TestsReporterReporterTypeChoices 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.\""" \"""An enumeration.\"""
@ -244,8 +244,7 @@ def test_schema_representation():
\"""The ID of the object\""" \"""The ID of the object\"""
id: ID! id: ID!
): Node ): Node
} }"""
"""
) )
assert str(schema) == expected assert str(schema) == expected
@ -525,8 +524,7 @@ class TestDjangoObjectType:
id: ID! id: ID!
kind: String! kind: String!
cuteness: Int! cuteness: Int!
} }"""
"""
) )
def test_django_objecttype_convert_choices_enum_list(self, PetModel): def test_django_objecttype_convert_choices_enum_list(self, PetModel):
@ -560,8 +558,7 @@ class TestDjangoObjectType:
\"""Dog\""" \"""Dog\"""
DOG DOG
} }"""
"""
) )
def test_django_objecttype_convert_choices_enum_empty_list(self, PetModel): def test_django_objecttype_convert_choices_enum_empty_list(self, PetModel):
@ -586,8 +583,7 @@ class TestDjangoObjectType:
id: ID! id: ID!
kind: String! kind: String!
cuteness: Int! cuteness: Int!
} }"""
"""
) )
def test_django_objecttype_convert_choices_enum_naming_collisions( def test_django_objecttype_convert_choices_enum_naming_collisions(
@ -621,8 +617,7 @@ class TestDjangoObjectType:
\"""Dog\""" \"""Dog\"""
DOG DOG
} }"""
"""
) )
def test_django_objecttype_choices_custom_enum_name( def test_django_objecttype_choices_custom_enum_name(
@ -660,8 +655,7 @@ class TestDjangoObjectType:
\"""Dog\""" \"""Dog\"""
DOG DOG
} }"""
"""
) )

View File

@ -109,12 +109,10 @@ def test_reports_validation_errors(client):
{ {
"message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.",
"locations": [{"line": 1, "column": 9}], "locations": [{"line": 1, "column": 9}],
"path": None,
}, },
{ {
"message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.", "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.",
"locations": [{"line": 1, "column": 21}], "locations": [{"line": 1, "column": 21}],
"path": None,
}, },
] ]
} }
@ -135,8 +133,6 @@ def test_errors_when_missing_operation_name(client):
"errors": [ "errors": [
{ {
"message": "Must provide operation name if query contains multiple operations.", "message": "Must provide operation name if query contains multiple operations.",
"locations": None,
"path": None,
} }
] ]
} }
@ -477,7 +473,6 @@ def test_handles_syntax_errors_caught_by_graphql(client):
{ {
"locations": [{"column": 1, "line": 1}], "locations": [{"column": 1, "line": 1}],
"message": "Syntax Error: Unexpected Name 'syntaxerror'.", "message": "Syntax Error: Unexpected Name 'syntaxerror'.",
"path": None,
} }
] ]
} }

View File

@ -216,7 +216,7 @@ class DjangoObjectType(ObjectType):
"Creating a DjangoObjectType without either the `fields` " "Creating a DjangoObjectType without either the `fields` "
"or the `exclude` option is deprecated. Add an explicit `fields " "or the `exclude` option is deprecated. Add an explicit `fields "
"= '__all__'` option on DjangoObjectType {class_name} to use all " "= '__all__'` option on DjangoObjectType {class_name} to use all "
"fields".format(class_name=cls.__name__,), "fields".format(class_name=cls.__name__),
DeprecationWarning, DeprecationWarning,
stacklevel=2, stacklevel=2,
) )

View File

@ -6,4 +6,4 @@ def test_to_const():
def test_to_const_unicode(): def test_to_const_unicode():
assert to_const(u"Skoða þetta unicode stöff") == "SKODA_THETTA_UNICODE_STOFF" assert to_const("Skoða þetta unicode stöff") == "SKODA_THETTA_UNICODE_STOFF"

View File

@ -11,7 +11,6 @@ from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.generic import View from django.views.generic import View
from graphql import OperationType, get_operation_ast, parse, validate from graphql import OperationType, get_operation_ast, parse, validate
from graphql.error import GraphQLError from graphql.error import GraphQLError
from graphql.error import format_error as format_graphql_error
from graphql.execution import ExecutionResult from graphql.execution import ExecutionResult
from graphene import Schema from graphene import Schema
@ -387,7 +386,7 @@ class GraphQLView(View):
@staticmethod @staticmethod
def format_error(error): def format_error(error):
if isinstance(error, GraphQLError): if isinstance(error, GraphQLError):
return format_graphql_error(error) return error.formatted
return {"message": str(error)} return {"message": str(error)}

View File

@ -14,22 +14,22 @@ rest_framework_require = ["djangorestframework>=3.6.3"]
tests_require = [ tests_require = [
"pytest>=3.6.3", "pytest>=7.1.3",
"pytest-cov", "pytest-cov",
"pytest-random-order", "pytest-random-order",
"coveralls", "coveralls",
"mock", "mock",
"pytz", "pytz",
"django-filter>=2", "django-filter>=22.1",
"pytest-django>=3.3.2", "pytest-django>=4.5.2",
] + rest_framework_require ] + rest_framework_require
dev_requires = [ dev_requires = [
"black==22.3.0", "black==22.8.0",
"flake8>=4,<5", "flake8==5.0.4",
"flake8-black==0.3.3", "flake8-black==0.3.3",
"flake8-bugbear==20.1.4", "flake8-bugbear==22.9.11",
] + tests_require ] + tests_require
setup( setup(
@ -46,24 +46,24 @@ setup(
"Intended Audience :: Developers", "Intended Audience :: Developers",
"Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: Implementation :: PyPy", "Programming Language :: Python :: Implementation :: PyPy",
"Framework :: Django", "Framework :: Django",
"Framework :: Django :: 2.2",
"Framework :: Django :: 3.0",
"Framework :: Django :: 3.1",
"Framework :: Django :: 3.2", "Framework :: Django :: 3.2",
"Framework :: Django :: 4.0",
"Framework :: Django :: 4.1",
], ],
keywords="api graphql protocol rest relay graphene", keywords="api graphql protocol rest relay graphene",
packages=find_packages(exclude=["tests", "examples", "examples.*"]), packages=find_packages(exclude=["tests", "examples", "examples.*"]),
install_requires=[ 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-core>=3.1.0,<4",
"graphql-relay>=3.1.1,<4", "graphql-relay>=3.1.1,<4",
"Django>=2.2", "Django>=3.2",
"promise>=2.1", "promise>=2.1",
"text-unidecode", "text-unidecode",
], ],

14
tox.ini
View File

@ -1,13 +1,11 @@
[tox] [tox]
envlist = envlist =
py{36,37,38,39}-django{22,30,31}, py{37,38,39,310}-django32,
py{36,37,38,39,310}-django32, py{38,39,310}-django{40,41,main},
py{38,39,310}-django{40,main},
pre-commit pre-commit
[gh-actions] [gh-actions]
python = python =
3.6: py36
3.7: py37 3.7: py37
3.8: py38 3.8: py38
3.9: py39 3.9: py39
@ -15,11 +13,9 @@ python =
[gh-actions:env] [gh-actions:env]
DJANGO = DJANGO =
2.2: django22
3.0: django30
3.1: django31
3.2: django32 3.2: django32
4.0: django40 4.0: django40
4.1: django41
main: djangomain main: djangomain
[testenv] [testenv]
@ -30,11 +26,9 @@ setenv =
deps = deps =
-e.[test] -e.[test]
psycopg2-binary psycopg2-binary
django22: Django>=2.2,<3.0
django30: Django>=3.0,<3.1
django31: Django>=3.1,<3.2
django32: Django>=3.2,<4.0 django32: Django>=3.2,<4.0
django40: Django>=4.0,<4.1 django40: Django>=4.0,<4.1
django41: Django>=4.1,<4.2
djangomain: https://github.com/django/django/archive/main.zip djangomain: https://github.com/django/django/archive/main.zip
commands = {posargs:py.test --cov=graphene_django graphene_django examples} commands = {posargs:py.test --cov=graphene_django graphene_django examples}