Merge branch 'v2' into master

This commit is contained in:
Firas K 2023-03-01 11:24:35 +03:00 committed by GitHub
commit abdd30c66e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
64 changed files with 2231 additions and 343 deletions

View File

@ -10,11 +10,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v3
- name: Set up Python 3.8 - name: Set up Python 3.10
uses: actions/setup-python@v1 uses: actions/setup-python@v4
with: with:
python-version: 3.8 python-version: "3.10"
- name: Build wheel and source tarball - name: Build wheel and source tarball
run: | run: |
pip install wheel pip install wheel

View File

@ -7,16 +7,16 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v3
- name: Set up Python 3.8 - name: Set up Python 3.10
uses: actions/setup-python@v1 uses: actions/setup-python@v4
with: with:
python-version: 3.8 python-version: "3.10"
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install tox pip install tox
- name: Run lint 💅 - name: Run pre-commit 💅
run: tox run: tox
env: env:
TOXENV: flake8 TOXENV: pre-commit

View File

@ -8,16 +8,33 @@ jobs:
strategy: strategy:
max-parallel: 4 max-parallel: 4
matrix: matrix:
django: ["1.11", "2.2", "3.0", "3.1"] django: ["2.2", "3.0", "3.1", "3.2", "4.0"]
python-version: ["3.6", "3.7", "3.8"] python-version: ["3.8", "3.9"]
include: include:
- django: "1.11" - django: "2.2"
python-version: "2.7" python-version: "3.6"
- django: "2.2"
python-version: "3.7"
- django: "3.0"
python-version: "3.6"
- django: "3.0"
python-version: "3.7"
- django: "3.1"
python-version: "3.6"
- django: "3.1"
python-version: "3.7"
- django: "3.2"
python-version: "3.6"
- django: "3.2"
python-version: "3.7"
- django: "3.2"
python-version: "3.10"
- django: "4.0"
python-version: "3.10"
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1 uses: actions/setup-python@v4
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install dependencies - name: Install dependencies

14
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,14 @@
default_language_version:
python: python3.10
repos:
- repo: https://github.com/PyCQA/flake8
rev: 5.0.4
hooks:
- id: flake8
additional_dependencies: [flake8-bugbear==22.7.1]
- repo: https://github.com/psf/black
rev: 22.6.0
hooks:
- id: black

View File

@ -14,11 +14,7 @@ test: tests # Alias test -> tests
.PHONY: format .PHONY: format
format: format:
black --exclude "/migrations/" graphene_django examples setup.py pre-commit run --all-files
.PHONY: lint
lint:
flake8 graphene_django examples
.PHONY: docs ## Generate docs .PHONY: docs ## Generate docs
docs: dev-setup docs: dev-setup

View File

@ -60,18 +60,18 @@ source_suffix = ".rst"
master_doc = "index" master_doc = "index"
# General information about the project. # General information about the project.
project = u"Graphene Django" project = "Graphene Django"
copyright = u"Graphene 2017" copyright = "Graphene 2017"
author = u"Syrus Akbary" author = "Syrus Akbary"
# The version info for the project you're documenting, acts as replacement for # The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the # |version| and |release|, also used in various other places throughout the
# built documents. # built documents.
# #
# The short X.Y version. # The short X.Y version.
version = u"1.0" version = "1.0"
# The full version, including alpha/beta/rc tags. # The full version, including alpha/beta/rc tags.
release = u"1.0.dev" release = "1.0.dev"
# The language for content autogenerated by Sphinx. Refer to documentation # The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages. # for a list of supported languages.
@ -276,7 +276,7 @@ latex_elements = {
# (source start file, target name, title, # (source start file, target name, title,
# author, documentclass [howto, manual, or own class]). # author, documentclass [howto, manual, or own class]).
latex_documents = [ latex_documents = [
(master_doc, "Graphene.tex", u"Graphene Documentation", u"Syrus Akbary", "manual") (master_doc, "Graphene.tex", "Graphene Documentation", "Syrus Akbary", "manual")
] ]
# The name of an image file (relative to this directory) to place at the top of # The name of an image file (relative to this directory) to place at the top of
@ -317,7 +317,7 @@ latex_documents = [
# One entry per manual page. List of tuples # One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section). # (source start file, name, description, authors, manual section).
man_pages = [ man_pages = [
(master_doc, "graphene_django", u"Graphene Django Documentation", [author], 1) (master_doc, "graphene_django", "Graphene Django Documentation", [author], 1)
] ]
# If true, show URL addresses after external links. # If true, show URL addresses after external links.
@ -334,7 +334,7 @@ texinfo_documents = [
( (
master_doc, master_doc,
"Graphene-Django", "Graphene-Django",
u"Graphene Django Documentation", "Graphene Django Documentation",
author, author,
"Graphene Django", "Graphene Django",
"One line description of project.", "One line description of project.",

View File

@ -3,7 +3,7 @@ Django Debug Middleware
You can debug your GraphQL queries in a similar way to You can debug your GraphQL queries in a similar way to
`django-debug-toolbar <https://django-debug-toolbar.readthedocs.org/>`__, `django-debug-toolbar <https://django-debug-toolbar.readthedocs.org/>`__,
but outputing in the results in GraphQL response as fields, instead of but outputting in the results in GraphQL response as fields, instead of
the graphical HTML interface. the graphical HTML interface.
For that, you will need to add the plugin in your graphene schema. For that, you will need to add the plugin in your graphene schema.
@ -43,7 +43,7 @@ And in your ``settings.py``:
Querying Querying
-------- --------
You can query it for outputing all the sql transactions that happened in You can query it for outputting all the sql transactions that happened in
the GraphQL request, like: the GraphQL request, like:
.. code:: .. code::

View File

@ -2,9 +2,9 @@ Filtering
========= =========
Graphene-Django integrates with Graphene-Django integrates with
`django-filter <https://django-filter.readthedocs.io/en/master/>`__ (2.x for `django-filter <https://django-filter.readthedocs.io/en/main/>`__ (2.x for
Python 3 or 1.x for Python 2) to provide filtering of results. See the `usage Python 3 or 1.x for Python 2) to provide filtering of results. See the `usage
documentation <https://django-filter.readthedocs.io/en/master/guide/usage.html#the-filter>`__ documentation <https://django-filter.readthedocs.io/en/main/guide/usage.html#the-filter>`__
for details on the format for ``filter_fields``. for details on the format for ``filter_fields``.
This filtering is automatically available when implementing a ``relay.Node``. This filtering is automatically available when implementing a ``relay.Node``.
@ -27,7 +27,7 @@ After installing ``django-filter`` you'll need to add the application in the ``s
] ]
Note: The techniques below are demoed in the `cookbook example Note: The techniques below are demoed in the `cookbook example
app <https://github.com/graphql-python/graphene-django/tree/master/examples/cookbook>`__. app <https://github.com/graphql-python/graphene-django/tree/v2/examples/cookbook>`__.
Filterable fields Filterable fields
----------------- -----------------
@ -35,7 +35,7 @@ Filterable fields
The ``filter_fields`` parameter is used to specify the fields which can The ``filter_fields`` parameter is used to specify the fields which can
be filtered upon. The value specified here is passed directly to be filtered upon. The value specified here is passed directly to
``django-filter``, so see the `filtering ``django-filter``, so see the `filtering
documentation <https://django-filter.readthedocs.io/en/master/guide/usage.html#the-filter>`__ documentation <https://django-filter.readthedocs.io/en/main/guide/usage.html#the-filter>`__
for full details on the range of options available. for full details on the range of options available.
For example: For example:
@ -163,7 +163,7 @@ in unison with the ``filter_fields`` parameter:
animal = relay.Node.Field(AnimalNode) animal = relay.Node.Field(AnimalNode)
all_animals = DjangoFilterConnectionField(AnimalNode) all_animals = DjangoFilterConnectionField(AnimalNode)
The context argument is passed on as the `request argument <http://django-filter.readthedocs.io/en/master/guide/usage.html#request-based-filtering>`__ The context argument is passed on as the `request argument <http://django-filter.readthedocs.io/en/main/guide/usage.html#request-based-filtering>`__
in a ``django_filters.FilterSet`` instance. You can use this to customize your in a ``django_filters.FilterSet`` instance. You can use this to customize your
filters to be context-dependent. We could modify the ``AnimalFilter`` above to filters to be context-dependent. We could modify the ``AnimalFilter`` above to
pre-filter animals owned by the authenticated user (set in ``context.user``). pre-filter animals owned by the authenticated user (set in ``context.user``).
@ -228,3 +228,84 @@ with this set up, you can now order the users under group:
} }
} }
} }
PostgreSQL `ArrayField`
-----------------------
Graphene provides an easy to implement filters on `ArrayField` as they are not natively supported by django_filters:
.. code:: python
from django.db import models
from django_filters import FilterSet, OrderingFilter
from graphene_django.filter import ArrayFilter
class Event(models.Model):
name = models.CharField(max_length=50)
tags = ArrayField(models.CharField(max_length=50))
class EventFilterSet(FilterSet):
class Meta:
model = Event
fields = {
"name": ["exact", "contains"],
}
tags__contains = ArrayFilter(field_name="tags", lookup_expr="contains")
tags__overlap = ArrayFilter(field_name="tags", lookup_expr="overlap")
tags = ArrayFilter(field_name="tags", lookup_expr="exact")
class EventType(DjangoObjectType):
class Meta:
model = Event
interfaces = (Node,)
filterset_class = EventFilterSet
with this set up, you can now filter events by tags:
.. code::
query {
events(tags_Overlap: ["concert", "festival"]) {
name
}
}
`TypedFilter`
-------------
Sometimes the automatic detection of the filter input type is not satisfactory for what you are trying to achieve.
You can then explicitly specify the input type you want for your filter by using a `TypedFilter`:
.. code:: python
from django.db import models
from django_filters import FilterSet, OrderingFilter
import graphene
from graphene_django.filter import TypedFilter
class Event(models.Model):
name = models.CharField(max_length=50)
class EventFilterSet(FilterSet):
class Meta:
model = Event
fields = {
"name": ["exact", "contains"],
}
only_first = TypedFilter(input_type=graphene.Boolean, method="only_first_filter")
def only_first_filter(self, queryset, _name, value):
if value:
return queryset[:1]
else:
return queryset
class EventType(DjangoObjectType):
class Meta:
model = Event
interfaces = (Node,)
filterset_class = EventFilterSet

View File

@ -287,7 +287,7 @@ Where "foo" is the name of the field declared in the ``Query`` object.
class Query(graphene.ObjectType): class Query(graphene.ObjectType):
foo = graphene.List(QuestionType) foo = graphene.List(QuestionType)
def resolve_foo(root, info): def resolve_foo(root, info, **kwargs):
id = kwargs.get("id") id = kwargs.get("id")
return Question.objects.get(id) return Question.objects.get(id)

View File

@ -1,58 +1,55 @@
import graphene import graphene
from graphene_django.types import DjangoObjectType from graphene_django.types import DjangoObjectType
from cookbook.ingredients.models import Category, Ingredient from cookbook.ingredients.models import Category, Ingredient
class CategoryType(DjangoObjectType): class CategoryType(DjangoObjectType):
class Meta: class Meta:
model = Category model = Category
class IngredientType(DjangoObjectType): class IngredientType(DjangoObjectType):
class Meta: class Meta:
model = Ingredient model = Ingredient
class Query(object): class Query(object):
category = graphene.Field(CategoryType, category = graphene.Field(CategoryType, id=graphene.Int(), name=graphene.String())
id=graphene.Int(), all_categories = graphene.List(CategoryType)
name=graphene.String())
all_categories = graphene.List(CategoryType)
ingredient = graphene.Field(
IngredientType, id=graphene.Int(), name=graphene.String()
)
all_ingredients = graphene.List(IngredientType)
ingredient = graphene.Field(IngredientType, def resolve_all_categories(self, info, **kwargs):
id=graphene.Int(), return Category.objects.all()
name=graphene.String())
all_ingredients = graphene.List(IngredientType)
def resolve_all_categories(self, info, **kwargs): def resolve_all_ingredients(self, info, **kwargs):
return Category.objects.all() return Ingredient.objects.all()
def resolve_all_ingredients(self, info, **kwargs): def resolve_category(self, info, **kwargs):
return Ingredient.objects.all() id = kwargs.get("id")
name = kwargs.get("name")
def resolve_category(self, info, **kwargs): if id is not None:
id = kwargs.get('id') return Category.objects.get(pk=id)
name = kwargs.get('name')
if id is not None: if name is not None:
return Category.objects.get(pk=id) return Category.objects.get(name=name)
if name is not None: return None
return Category.objects.get(name=name)
return None def resolve_ingredient(self, info, **kwargs):
id = kwargs.get("id")
name = kwargs.get("name")
def resolve_ingredient(self, info, **kwargs): if id is not None:
id = kwargs.get('id') return Ingredient.objects.get(pk=id)
name = kwargs.get('name')
if id is not None: if name is not None:
return Ingredient.objects.get(pk=id) return Ingredient.objects.get(name=name)
if name is not None: return None
return Ingredient.objects.get(name=name)
return None

View File

@ -10,24 +10,46 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = []
]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Category', name="Category",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('name', models.CharField(max_length=100)), "id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100)),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='Ingredient', name="Ingredient",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('name', models.CharField(max_length=100)), "id",
('notes', models.TextField()), models.AutoField(
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ingredients', to='ingredients.Category')), auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100)),
("notes", models.TextField()),
(
"category",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ingredients",
to="ingredients.Category",
),
),
], ],
), ),
] ]

View File

@ -8,13 +8,13 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('ingredients', '0001_initial'), ("ingredients", "0001_initial"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='ingredient', model_name="ingredient",
name='notes', name="notes",
field=models.TextField(blank=True, null=True), field=models.TextField(blank=True, null=True),
), ),
] ]

View File

@ -6,12 +6,12 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('ingredients', '0002_auto_20161104_0050'), ("ingredients", "0002_auto_20161104_0050"),
] ]
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='category', name="category",
options={'verbose_name_plural': 'Categories'}, options={"verbose_name_plural": "Categories"},
), ),
] ]

View File

@ -11,26 +11,62 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('ingredients', '0001_initial'), ("ingredients", "0001_initial"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Recipe', name="Recipe",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('title', models.CharField(max_length=100)), "id",
('instructions', models.TextField()), models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(max_length=100)),
("instructions", models.TextField()),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='RecipeIngredient', name="RecipeIngredient",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('amount', models.FloatField()), "id",
('unit', models.CharField(choices=[('kg', 'Kilograms'), ('l', 'Litres'), ('', 'Units')], max_length=20)), models.AutoField(
('ingredient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='used_by', to='ingredients.Ingredient')), auto_created=True,
('recipes', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='amounts', to='recipes.Recipe')), primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("amount", models.FloatField()),
(
"unit",
models.CharField(
choices=[("kg", "Kilograms"), ("l", "Litres"), ("", "Units")],
max_length=20,
),
),
(
"ingredient",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="used_by",
to="ingredients.Ingredient",
),
),
(
"recipes",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="amounts",
to="recipes.Recipe",
),
),
], ],
), ),
] ]

View File

@ -8,18 +8,26 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('recipes', '0001_initial'), ("recipes", "0001_initial"),
] ]
operations = [ operations = [
migrations.RenameField( migrations.RenameField(
model_name='recipeingredient', model_name="recipeingredient",
old_name='recipes', old_name="recipes",
new_name='recipe', new_name="recipe",
), ),
migrations.AlterField( migrations.AlterField(
model_name='recipeingredient', model_name="recipeingredient",
name='unit', name="unit",
field=models.CharField(choices=[(b'unit', b'Units'), (b'kg', b'Kilograms'), (b'l', b'Litres'), (b'st', b'Shots')], max_length=20), field=models.CharField(
choices=[
(b"unit", b"Units"),
(b"kg", b"Kilograms"),
(b"l", b"Litres"),
(b"st", b"Shots"),
],
max_length=20,
),
), ),
] ]

View File

@ -6,13 +6,21 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('recipes', '0002_auto_20161104_0106'), ("recipes", "0002_auto_20161104_0106"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='recipeingredient', model_name="recipeingredient",
name='unit', name="unit",
field=models.CharField(choices=[('unit', 'Units'), ('kg', 'Kilograms'), ('l', 'Litres'), ('st', 'Shots')], max_length=20), field=models.CharField(
choices=[
("unit", "Units"),
("kg", "Kilograms"),
("l", "Litres"),
("st", "Shots"),
],
max_length=20,
),
), ),
] ]

View File

@ -10,24 +10,46 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = []
]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Category', name="Category",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('name', models.CharField(max_length=100)), "id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100)),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='Ingredient', name="Ingredient",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('name', models.CharField(max_length=100)), "id",
('notes', models.TextField()), models.AutoField(
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ingredients', to='ingredients.Category')), auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100)),
("notes", models.TextField()),
(
"category",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ingredients",
to="ingredients.Category",
),
),
], ],
), ),
] ]

View File

@ -8,13 +8,13 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('ingredients', '0001_initial'), ("ingredients", "0001_initial"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='ingredient', model_name="ingredient",
name='notes', name="notes",
field=models.TextField(blank=True, null=True), field=models.TextField(blank=True, null=True),
), ),
] ]

View File

@ -11,26 +11,62 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('ingredients', '0001_initial'), ("ingredients", "0001_initial"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Recipe', name="Recipe",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('title', models.CharField(max_length=100)), "id",
('instructions', models.TextField()), models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(max_length=100)),
("instructions", models.TextField()),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='RecipeIngredient', name="RecipeIngredient",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('amount', models.FloatField()), "id",
('unit', models.CharField(choices=[('kg', 'Kilograms'), ('l', 'Litres'), ('', 'Units')], max_length=20)), models.AutoField(
('ingredient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='used_by', to='ingredients.Ingredient')), auto_created=True,
('recipes', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='amounts', to='recipes.Recipe')), primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("amount", models.FloatField()),
(
"unit",
models.CharField(
choices=[("kg", "Kilograms"), ("l", "Litres"), ("", "Units")],
max_length=20,
),
),
(
"ingredient",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="used_by",
to="ingredients.Ingredient",
),
),
(
"recipes",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="amounts",
to="recipes.Recipe",
),
),
], ],
), ),
] ]

View File

@ -8,18 +8,26 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('recipes', '0001_initial'), ("recipes", "0001_initial"),
] ]
operations = [ operations = [
migrations.RenameField( migrations.RenameField(
model_name='recipeingredient', model_name="recipeingredient",
old_name='recipes', old_name="recipes",
new_name='recipe', new_name="recipe",
), ),
migrations.AlterField( migrations.AlterField(
model_name='recipeingredient', model_name="recipeingredient",
name='unit', name="unit",
field=models.CharField(choices=[(b'unit', b'Units'), (b'kg', b'Kilograms'), (b'l', b'Litres'), (b'st', b'Shots')], max_length=20), field=models.CharField(
choices=[
(b"unit", b"Units"),
(b"kg", b"Kilograms"),
(b"l", b"Litres"),
(b"st", b"Shots"),
],
max_length=20,
),
), ),
] ]

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__ = "2.14.0" __version__ = "2.15.0"
__all__ = [ __all__ = [
"__version__", "__version__",

View File

@ -6,13 +6,16 @@ try:
# Postgres fields are only available in Django with psycopg2 installed # Postgres fields are only available in Django with psycopg2 installed
# and we cannot have psycopg2 on PyPy # and we cannot have psycopg2 on PyPy
from django.contrib.postgres.fields import ( from django.contrib.postgres.fields import (
IntegerRangeField,
ArrayField, ArrayField,
HStoreField, HStoreField,
JSONField as PGJSONField, JSONField as PGJSONField,
RangeField, RangeField,
) )
except ImportError: except ImportError:
ArrayField, HStoreField, PGJSONField, RangeField = (MissingType,) * 4 IntegerRangeField, ArrayField, HStoreField, PGJSONField, RangeField = (
MissingType,
) * 5
try: try:
# JSONField is only available from Django 3.1 # JSONField is only available from Django 3.1

View File

@ -18,6 +18,7 @@ from graphene import (
DateTime, DateTime,
Date, Date,
Time, Time,
Decimal,
) )
from graphene.types.json import JSONString from graphene.types.json import JSONString
from graphene.utils.str_converters import to_camel_case from graphene.utils.str_converters import to_camel_case
@ -68,7 +69,11 @@ def convert_choices_to_named_enum_with_descriptions(name, choices):
def description(self): def description(self):
return named_choices_descriptions[self.name] return named_choices_descriptions[self.name]
return Enum(name, list(named_choices), type=EnumWithDescriptionsType) if named_choices == []:
# Python 2.7 doesn't handle enums with lists with zero entries, but works okay with empty sets
named_choices = set()
return Enum(name, named_choices, type=EnumWithDescriptionsType)
def generate_enum_name(django_model_meta, field): def generate_enum_name(django_model_meta, field):
@ -160,6 +165,10 @@ 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):
return Decimal(description=field.help_text, required=not field.null)
@convert_django_field.register(models.FloatField) @convert_django_field.register(models.FloatField)
@convert_django_field.register(models.DurationField) @convert_django_field.register(models.DurationField)
def convert_field_to_float(field, registry=None): def convert_field_to_float(field, registry=None):

View File

@ -66,7 +66,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, parent_resolver, self.get_manager(), self.list_resolver,
django_object_type,
parent_resolver,
self.get_manager(),
) )

View File

@ -9,10 +9,21 @@ if not DJANGO_FILTER_INSTALLED:
) )
else: else:
from .fields import DjangoFilterConnectionField from .fields import DjangoFilterConnectionField
from .filterset import GlobalIDFilter, GlobalIDMultipleChoiceFilter from .filters import (
ArrayFilter,
GlobalIDFilter,
GlobalIDMultipleChoiceFilter,
ListFilter,
RangeFilter,
TypedFilter,
)
__all__ = [ __all__ = [
"DjangoFilterConnectionField", "DjangoFilterConnectionField",
"GlobalIDFilter", "GlobalIDFilter",
"GlobalIDMultipleChoiceFilter", "GlobalIDMultipleChoiceFilter",
"ArrayFilter",
"ListFilter",
"RangeFilter",
"TypedFilter",
] ]

View File

@ -43,8 +43,8 @@ class DjangoFilterConnectionField(DjangoConnectionField):
if self._extra_filter_meta: if self._extra_filter_meta:
meta.update(self._extra_filter_meta) meta.update(self._extra_filter_meta)
filterset_class = self._provided_filterset_class or ( filterset_class = (
self.node_type._meta.filterset_class self._provided_filterset_class or self.node_type._meta.filterset_class
) )
self._filterset_class = get_filterset_class(filterset_class, **meta) self._filterset_class = get_filterset_class(filterset_class, **meta)

View File

@ -0,0 +1,25 @@
import warnings
from ...utils import DJANGO_FILTER_INSTALLED
if not DJANGO_FILTER_INSTALLED:
warnings.warn(
"Use of django filtering requires the django-filter package "
"be installed. You can do so using `pip install django-filter`",
ImportWarning,
)
else:
from .array_filter import ArrayFilter
from .global_id_filter import GlobalIDFilter, GlobalIDMultipleChoiceFilter
from .list_filter import ListFilter
from .range_filter import RangeFilter
from .typed_filter import TypedFilter
__all__ = [
"DjangoFilterConnectionField",
"GlobalIDFilter",
"GlobalIDMultipleChoiceFilter",
"ArrayFilter",
"ListFilter",
"RangeFilter",
"TypedFilter",
]

View File

@ -0,0 +1,27 @@
from django_filters.constants import EMPTY_VALUES
from .typed_filter import TypedFilter
class ArrayFilter(TypedFilter):
"""
Filter made for PostgreSQL ArrayField.
"""
def filter(self, qs, value):
"""
Override the default filter class to check first whether the list is
empty or not.
This needs to be done as in this case we expect to get the filter applied with
an empty list since it's a valid value but django_filter consider an empty list
to be an empty input value (see `EMPTY_VALUES`) meaning that
the filter does not need to be applied (hence returning the original
queryset).
"""
if value in EMPTY_VALUES and value != []:
return qs
if self.distinct:
qs = qs.distinct()
lookup = "%s__%s" % (self.field_name, self.lookup_expr)
qs = self.get_method(qs)(**{lookup: value})
return qs

View File

@ -0,0 +1,28 @@
from django_filters import Filter, MultipleChoiceFilter
from graphql_relay.node.node import from_global_id
from ...forms import GlobalIDFormField, GlobalIDMultipleChoiceField
class GlobalIDFilter(Filter):
"""
Filter for Relay global ID.
"""
field_class = GlobalIDFormField
def filter(self, qs, value):
"""Convert the filter value to a primary key before filtering"""
_id = None
if value is not None:
_, _id = from_global_id(value)
return super(GlobalIDFilter, self).filter(qs, _id)
class GlobalIDMultipleChoiceFilter(MultipleChoiceFilter):
field_class = GlobalIDMultipleChoiceField
def filter(self, qs, value):
gids = [from_global_id(v)[1] for v in value]
return super(GlobalIDMultipleChoiceFilter, self).filter(qs, gids)

View File

@ -0,0 +1,26 @@
from .typed_filter import TypedFilter
class ListFilter(TypedFilter):
"""
Filter that takes a list of value as input.
It is for example used for `__in` filters.
"""
def filter(self, qs, value):
"""
Override the default filter class to check first whether the list is
empty or not.
This needs to be done as in this case we expect to get an empty output
(if not an exclude filter) but django_filter consider an empty list
to be an empty input value (see `EMPTY_VALUES`) meaning that
the filter does not need to be applied (hence returning the original
queryset).
"""
if value is not None and len(value) == 0:
if self.exclude:
return qs
else:
return qs.none()
else:
return super(ListFilter, self).filter(qs, value)

View File

@ -0,0 +1,24 @@
from django.core.exceptions import ValidationError
from django.forms import Field
from .typed_filter import TypedFilter
def validate_range(value):
"""
Validator for range filter input: the list of value must be of length 2.
Note that validators are only run if the value is not empty.
"""
if len(value) != 2:
raise ValidationError(
"Invalid range specified: it needs to contain 2 values.", code="invalid"
)
class RangeField(Field):
default_validators = [validate_range]
empty_values = [None]
class RangeFilter(TypedFilter):
field_class = RangeField

View File

@ -0,0 +1,27 @@
from django_filters import Filter
from graphene.types.utils import get_type
class TypedFilter(Filter):
"""
Filter class for which the input GraphQL type can explicitly be provided.
If it is not provided, when building the schema, it will try to guess
it from the field.
"""
def __init__(self, input_type=None, *args, **kwargs):
self._input_type = input_type
super(TypedFilter, self).__init__(*args, **kwargs)
@property
def input_type(self):
input_type = get_type(self._input_type)
if input_type is not None:
if not callable(getattr(input_type, "get_type", None)):
raise ValueError(
"Wrong `input_type` for {}: it only accepts graphene types, got {}".format(
self.__class__.__name__, input_type
)
)
return input_type

View File

@ -1,32 +1,11 @@
import itertools import itertools
from django.db import models from django.db import models
from django_filters import Filter, MultipleChoiceFilter, VERSION from django_filters import VERSION
from django_filters.filterset import BaseFilterSet, FilterSet from django_filters.filterset import BaseFilterSet, FilterSet
from django_filters.filterset import FILTER_FOR_DBFIELD_DEFAULTS from django_filters.filterset import FILTER_FOR_DBFIELD_DEFAULTS
from graphql_relay.node.node import from_global_id from .filters import GlobalIDFilter, GlobalIDMultipleChoiceFilter
from ..forms import GlobalIDFormField, GlobalIDMultipleChoiceField
class GlobalIDFilter(Filter):
field_class = GlobalIDFormField
def filter(self, qs, value):
""" Convert the filter value to a primary key before filtering """
_id = None
if value is not None:
_, _id = from_global_id(value)
return super(GlobalIDFilter, self).filter(qs, _id)
class GlobalIDMultipleChoiceFilter(MultipleChoiceFilter):
field_class = GlobalIDMultipleChoiceField
def filter(self, qs, value):
gids = [from_global_id(v)[1] for v in value]
return super(GlobalIDMultipleChoiceFilter, self).filter(qs, gids)
GRAPHENE_FILTER_SET_OVERRIDES = { GRAPHENE_FILTER_SET_OVERRIDES = {
@ -40,8 +19,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(
@ -81,8 +60,7 @@ if VERSION[0] < 2:
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),
@ -91,8 +69,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

@ -0,0 +1,166 @@
from mock import MagicMock
import pytest
from django.db import models
from django.db.models.query import QuerySet
from django_filters import filters
from django_filters import FilterSet
import graphene
from graphene.relay import Node
from graphene_django import DjangoObjectType
from graphene_django.utils import DJANGO_FILTER_INSTALLED
from graphene_django.filter import ArrayFilter, ListFilter
from ...compat import ArrayField
pytestmark = []
if DJANGO_FILTER_INSTALLED:
from graphene_django.filter import DjangoFilterConnectionField
else:
pytestmark.append(
pytest.mark.skipif(
True, reason="django_filters not installed or not compatible"
)
)
STORE = {"events": []}
@pytest.fixture
def Event():
class Event(models.Model):
name = models.CharField(max_length=50)
tags = ArrayField(models.CharField(max_length=50))
tag_ids = ArrayField(models.IntegerField())
random_field = ArrayField(models.BooleanField())
return Event
@pytest.fixture
def EventFilterSet(Event):
class EventFilterSet(FilterSet):
class Meta:
model = Event
fields = {
"name": ["exact", "contains"],
}
# Those are actually usable with our Query fixture bellow
tags__contains = ArrayFilter(field_name="tags", lookup_expr="contains")
tags__overlap = ArrayFilter(field_name="tags", lookup_expr="overlap")
tags = ArrayFilter(field_name="tags", lookup_expr="exact")
# Those are actually not usable and only to check type declarations
tags_ids__contains = ArrayFilter(field_name="tag_ids", lookup_expr="contains")
tags_ids__overlap = ArrayFilter(field_name="tag_ids", lookup_expr="overlap")
tags_ids = ArrayFilter(field_name="tag_ids", lookup_expr="exact")
random_field__contains = ArrayFilter(
field_name="random_field", lookup_expr="contains"
)
random_field__overlap = ArrayFilter(
field_name="random_field", lookup_expr="overlap"
)
random_field = ArrayFilter(field_name="random_field", lookup_expr="exact")
return EventFilterSet
@pytest.fixture
def EventType(Event, EventFilterSet):
class EventType(DjangoObjectType):
class Meta:
model = Event
interfaces = (Node,)
filterset_class = EventFilterSet
return EventType
@pytest.fixture
def Query(Event, EventType):
"""
Note that we have to use a custom resolver to replicate the arrayfield filter behavior as
we are running unit tests in sqlite which does not have ArrayFields.
"""
class Query(graphene.ObjectType):
events = DjangoFilterConnectionField(EventType)
def resolve_events(self, info, **kwargs):
events = [
Event(
name="Live Show",
tags=["concert", "music", "rock"],
),
Event(
name="Musical",
tags=["movie", "music"],
),
Event(
name="Ballet",
tags=["concert", "dance"],
),
Event(
name="Speech",
tags=[],
),
]
STORE["events"] = events
m_queryset = MagicMock(spec=QuerySet)
m_queryset.model = Event
def filter_events(**kwargs):
if "tags__contains" in kwargs:
STORE["events"] = list(
filter(
lambda e: set(kwargs["tags__contains"]).issubset(
set(e.tags)
),
STORE["events"],
)
)
if "tags__overlap" in kwargs:
STORE["events"] = list(
filter(
lambda e: not set(kwargs["tags__overlap"]).isdisjoint(
set(e.tags)
),
STORE["events"],
)
)
if "tags__exact" in kwargs:
STORE["events"] = list(
filter(
lambda e: set(kwargs["tags__exact"]) == set(e.tags),
STORE["events"],
)
)
def mock_queryset_filter(*args, **kwargs):
filter_events(**kwargs)
return m_queryset
def mock_queryset_none(*args, **kwargs):
STORE["events"] = []
return m_queryset
def mock_queryset_count(*args, **kwargs):
return len(STORE["events"])
m_queryset.all.return_value = m_queryset
m_queryset.filter.side_effect = mock_queryset_filter
m_queryset.none.side_effect = mock_queryset_none
m_queryset.count.side_effect = mock_queryset_count
m_queryset.__getitem__.side_effect = lambda index: STORE[
"events"
].__getitem__(index)
return m_queryset
return Query

View File

@ -10,7 +10,7 @@ class ArticleFilter(django_filters.FilterSet):
fields = { fields = {
"headline": ["exact", "icontains"], "headline": ["exact", "icontains"],
"pub_date": ["gt", "lt", "exact"], "pub_date": ["gt", "lt", "exact"],
"reporter": ["exact"], "reporter": ["exact", "in"],
} }
order_by = OrderingFilter(fields=("pub_date",)) order_by = OrderingFilter(fields=("pub_date",))

View File

@ -0,0 +1,87 @@
import pytest
from graphene import Schema
from ...compat import ArrayField, MissingType
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_contains_multiple(Query):
"""
Test contains filter on a array field of string.
"""
schema = Schema(query=Query)
query = """
query {
events (tags_Contains: ["concert", "music"]) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["events"]["edges"] == [
{"node": {"name": "Live Show"}},
]
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_contains_one(Query):
"""
Test contains filter on a array field of string.
"""
schema = Schema(query=Query)
query = """
query {
events (tags_Contains: ["music"]) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["events"]["edges"] == [
{"node": {"name": "Live Show"}},
{"node": {"name": "Musical"}},
]
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_contains_empty_list(Query):
"""
Test contains filter on a array field of string.
"""
schema = Schema(query=Query)
query = """
query {
events (tags_Contains: []) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["events"]["edges"] == [
{"node": {"name": "Live Show"}},
{"node": {"name": "Musical"}},
{"node": {"name": "Ballet"}},
{"node": {"name": "Speech"}},
]

View File

@ -0,0 +1,107 @@
import pytest
from graphene import Schema
from ...compat import ArrayField, MissingType
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_exact_no_match(Query):
"""
Test exact filter on a array field of string.
"""
schema = Schema(query=Query)
query = """
query {
events (tags: ["concert", "music"]) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["events"]["edges"] == []
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_exact_match(Query):
"""
Test exact filter on a array field of string.
"""
schema = Schema(query=Query)
query = """
query {
events (tags: ["movie", "music"]) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["events"]["edges"] == [
{"node": {"name": "Musical"}},
]
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_exact_empty_list(Query):
"""
Test exact filter on a array field of string.
"""
schema = Schema(query=Query)
query = """
query {
events (tags: []) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["events"]["edges"] == [
{"node": {"name": "Speech"}},
]
def test_array_field_filter_schema_type(Query):
"""
Check that the type in the filter is an array field like on the object type.
"""
schema = Schema(query=Query)
schema_str = str(schema)
assert (
"""type EventType implements Node {
id: ID!
name: String!
tags: [String!]!
tagIds: [Int!]!
randomField: [Boolean!]!
}"""
in schema_str
)
assert (
"""type Query {
events(offset: Int, before: String, after: String, first: Int, last: Int, name: String, name_Contains: String, tags_Contains: [String!], tags_Overlap: [String!], tags: [String!], tagsIds_Contains: [Int!], tagsIds_Overlap: [Int!], tagsIds: [Int!], randomField_Contains: [Boolean!], randomField_Overlap: [Boolean!], randomField: [Boolean!]): EventTypeConnection
}"""
in schema_str
)

View File

@ -0,0 +1,84 @@
import pytest
from graphene import Schema
from ...compat import ArrayField, MissingType
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_overlap_multiple(Query):
"""
Test overlap filter on a array field of string.
"""
schema = Schema(query=Query)
query = """
query {
events (tags_Overlap: ["concert", "music"]) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["events"]["edges"] == [
{"node": {"name": "Live Show"}},
{"node": {"name": "Musical"}},
{"node": {"name": "Ballet"}},
]
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_overlap_one(Query):
"""
Test overlap filter on a array field of string.
"""
schema = Schema(query=Query)
query = """
query {
events (tags_Overlap: ["music"]) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["events"]["edges"] == [
{"node": {"name": "Live Show"}},
{"node": {"name": "Musical"}},
]
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_overlap_empty_list(Query):
"""
Test overlap filter on a array field of string.
"""
schema = Schema(query=Query)
query = """
query {
events (tags_Overlap: []) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["events"]["edges"] == []

View File

@ -0,0 +1,171 @@
import pytest
import graphene
from graphene.relay import Node
from graphene_django import DjangoObjectType, DjangoConnectionField
from graphene_django.tests.models import Article, Reporter
from graphene_django.utils import DJANGO_FILTER_INSTALLED
pytestmark = []
if DJANGO_FILTER_INSTALLED:
from graphene_django.filter import DjangoFilterConnectionField
else:
pytestmark.append(
pytest.mark.skipif(
True, reason="django_filters not installed or not compatible"
)
)
@pytest.fixture
def schema():
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
interfaces = (Node,)
class ArticleType(DjangoObjectType):
class Meta:
model = Article
interfaces = (Node,)
filter_fields = {
"lang": ["exact", "in"],
"reporter__a_choice": ["exact", "in"],
}
class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType)
all_articles = DjangoFilterConnectionField(ArticleType)
schema = graphene.Schema(query=Query)
return schema
@pytest.fixture
def reporter_article_data():
john = Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
jane = Reporter.objects.create(
first_name="Jane", last_name="Doe", email="janedoe@example.com", a_choice=2
)
Article.objects.create(
headline="Article Node 1",
reporter=john,
editor=john,
lang="es",
)
Article.objects.create(
headline="Article Node 2",
reporter=john,
editor=john,
lang="en",
)
Article.objects.create(
headline="Article Node 3",
reporter=jane,
editor=jane,
lang="en",
)
def test_filter_enum_on_connection(schema, reporter_article_data):
"""
Check that we can filter with enums on a connection.
"""
query = """
query {
allArticles(lang: ES) {
edges {
node {
headline
}
}
}
}
"""
expected = {
"allArticles": {
"edges": [
{"node": {"headline": "Article Node 1"}},
]
}
}
result = schema.execute(query)
assert not result.errors
assert result.data == expected
def test_filter_on_foreign_key_enum_field(schema, reporter_article_data):
"""
Check that we can filter with enums on a field from a foreign key.
"""
query = """
query {
allArticles(reporter_AChoice: A_1) {
edges {
node {
headline
}
}
}
}
"""
expected = {
"allArticles": {
"edges": [
{"node": {"headline": "Article Node 1"}},
{"node": {"headline": "Article Node 2"}},
]
}
}
result = schema.execute(query)
assert not result.errors
assert result.data == expected
def test_filter_enum_field_schema_type(schema):
"""
Check that the type in the filter is an enum like on the object type.
"""
schema_str = str(schema)
assert (
"""type ArticleType implements Node {
id: ID!
headline: String!
pubDate: Date!
pubDateTime: DateTime!
reporter: ReporterType!
editor: ReporterType!
lang: ArticleLang!
importance: ArticleImportance
}"""
in schema_str
)
filters = {
"offset": "Int",
"before": "String",
"after": "String",
"first": "Int",
"last": "Int",
"lang": "ArticleLang",
"lang_In": "[ArticleLang]",
"reporter_AChoice": "ReporterAChoice",
"reporter_AChoice_In": "[ReporterAChoice]",
}
all_articles_filters = (
schema_str.split(" allArticles(")[1]
.split("): ArticleTypeConnection\n")[0]
.split(", ")
)
for filter_field, gql_type in filters.items():
assert "{}: {}".format(filter_field, gql_type) in all_articles_filters

View File

@ -5,18 +5,18 @@ import pytest
from django.db.models import TextField, Value from django.db.models import TextField, Value
from django.db.models.functions import Concat from django.db.models.functions import Concat
from graphene import Argument, Boolean, Field, Float, ObjectType, Schema, String from graphene import Argument, Boolean, Decimal, Field, ObjectType, Schema, String
from graphene.relay import Node from graphene.relay import Node
from graphene_django import DjangoObjectType from graphene_django import DjangoObjectType
from graphene_django.forms import GlobalIDFormField, GlobalIDMultipleChoiceField from graphene_django.forms import GlobalIDFormField, GlobalIDMultipleChoiceField
from graphene_django.tests.models import Article, Pet, Reporter from graphene_django.tests.models import Article, Person, Pet, Reporter
from graphene_django.utils import DJANGO_FILTER_INSTALLED from graphene_django.utils import DJANGO_FILTER_INSTALLED
pytestmark = [] pytestmark = []
if DJANGO_FILTER_INSTALLED: if DJANGO_FILTER_INSTALLED:
import django_filters import django_filters
from django_filters import FilterSet, NumberFilter from django_filters import FilterSet, NumberFilter, OrderingFilter
from graphene_django.filter import ( from graphene_django.filter import (
GlobalIDFilter, GlobalIDFilter,
@ -87,6 +87,7 @@ def test_filter_explicit_filterset_arguments():
"pub_date__gt", "pub_date__gt",
"pub_date__lt", "pub_date__lt",
"reporter", "reporter",
"reporter__in",
) )
@ -388,7 +389,7 @@ def test_filterset_descriptions():
field = DjangoFilterConnectionField(ArticleNode, filterset_class=ArticleIdFilter) field = DjangoFilterConnectionField(ArticleNode, filterset_class=ArticleIdFilter)
max_time = field.args["max_time"] max_time = field.args["max_time"]
assert isinstance(max_time, Argument) assert isinstance(max_time, Argument)
assert max_time.type == Float assert max_time.type == Decimal
assert max_time.description == "The maximum time" assert max_time.description == "The maximum time"
@ -671,12 +672,12 @@ def test_should_query_filter_node_limit():
schema = Schema(query=Query) schema = Schema(query=Query)
query = """ query = """
query NodeFilteringQuery { query NodeFilteringQuery {
allReporters(limit: 1) { allReporters(limit: "1") {
edges { edges {
node { node {
id id
firstName firstName
articles(lang: "es") { articles(lang: ES) {
edges { edges {
node { node {
id id
@ -1085,7 +1086,7 @@ def test_filter_filterset_based_on_mixin():
return filters return filters
def filter_email_in(cls, queryset, name, value): def filter_email_in(self, queryset, name, value):
return queryset.filter(**{name: [value]}) return queryset.filter(**{name: [value]})
class NewArticleFilter(ArticleFilterMixin, ArticleFilter): class NewArticleFilter(ArticleFilterMixin, ArticleFilter):
@ -1171,3 +1172,76 @@ def test_filter_filterset_based_on_mixin():
assert not result.errors assert not result.errors
assert result.data == expected assert result.data == expected
def test_filter_string_contains():
class PersonType(DjangoObjectType):
class Meta:
model = Person
interfaces = (Node,)
filter_fields = {"name": ["exact", "in", "contains", "icontains"]}
class Query(ObjectType):
people = DjangoFilterConnectionField(PersonType)
schema = Schema(query=Query)
Person.objects.bulk_create(
[
Person(name="Jack"),
Person(name="Joe"),
Person(name="Jane"),
Person(name="Peter"),
Person(name="Bob"),
]
)
query = """query nameContain($filter: String) {
people(name_Contains: $filter) {
edges {
node {
name
}
}
}
}"""
result = schema.execute(query, variables={"filter": "Ja"})
assert not result.errors
assert result.data == {
"people": {
"edges": [
{"node": {"name": "Jack"}},
{"node": {"name": "Jane"}},
]
}
}
result = schema.execute(query, variables={"filter": "o"})
assert not result.errors
assert result.data == {
"people": {
"edges": [
{"node": {"name": "Joe"}},
{"node": {"name": "Bob"}},
]
}
}
def test_only_custom_filters():
class ReporterFilter(FilterSet):
class Meta:
model = Reporter
fields = []
some_filter = OrderingFilter(fields=("name",))
class ReporterFilterNode(DjangoObjectType):
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
filterset_class = ReporterFilter
field = DjangoFilterConnectionField(ReporterFilterNode)
assert_arguments(field, "some_filter")

View File

@ -1,9 +1,14 @@
from datetime import datetime
import pytest import pytest
from django_filters import FilterSet
from django_filters import rest_framework as filters
from graphene import ObjectType, Schema from graphene import ObjectType, Schema
from graphene.relay import Node from graphene.relay import Node
from graphene_django import DjangoObjectType from graphene_django import DjangoObjectType
from graphene_django.tests.models import Pet from graphene_django.tests.models import Pet, Person, Reporter, Article, Film
from graphene_django.filter.tests.filters import ArticleFilter
from graphene_django.utils import DJANGO_FILTER_INSTALLED from graphene_django.utils import DJANGO_FILTER_INSTALLED
pytestmark = [] pytestmark = []
@ -18,21 +23,72 @@ else:
) )
class PetNode(DjangoObjectType): @pytest.fixture
class Meta: def query():
model = Pet class PetNode(DjangoObjectType):
interfaces = (Node,) class Meta:
filter_fields = { model = Pet
"name": ["exact", "in"], interfaces = (Node,)
"age": ["exact", "in", "range"], filter_fields = {
} "id": ["exact", "in"],
"name": ["exact", "in"],
"age": ["exact", "in", "range"],
}
class ReporterNode(DjangoObjectType):
class Meta:
model = Reporter
interfaces = (Node,)
# choice filter using enum
filter_fields = {"reporter_type": ["exact", "in"]}
class ArticleNode(DjangoObjectType):
class Meta:
model = Article
interfaces = (Node,)
filterset_class = ArticleFilter
class FilmNode(DjangoObjectType):
class Meta:
model = Film
interfaces = (Node,)
# choice filter not using enum
filter_fields = {
"genre": ["exact", "in"],
}
convert_choices_to_enum = False
class PersonFilterSet(FilterSet):
class Meta:
model = Person
fields = {"name": ["in"]}
names = filters.BaseInFilter(method="filter_names")
def filter_names(self, qs, name, value):
"""
This custom filter take a string as input with comma separated values.
Note that the value here is already a list as it has been transformed by the BaseInFilter class.
"""
return qs.filter(name__in=value)
class PersonNode(DjangoObjectType):
class Meta:
model = Person
interfaces = (Node,)
filterset_class = PersonFilterSet
class Query(ObjectType):
pets = DjangoFilterConnectionField(PetNode)
people = DjangoFilterConnectionField(PersonNode)
articles = DjangoFilterConnectionField(ArticleNode)
films = DjangoFilterConnectionField(FilmNode)
reporters = DjangoFilterConnectionField(ReporterNode)
return Query
class Query(ObjectType): def test_string_in_filter(query):
pets = DjangoFilterConnectionField(PetNode)
def test_string_in_filter():
""" """
Test in filter on a string field. Test in filter on a string field.
""" """
@ -40,7 +96,7 @@ def test_string_in_filter():
Pet.objects.create(name="Mimi", age=3) Pet.objects.create(name="Mimi", age=3)
Pet.objects.create(name="Jojo, the rabbit", age=3) Pet.objects.create(name="Jojo, the rabbit", age=3)
schema = Schema(query=Query) schema = Schema(query=query)
query = """ query = """
query { query {
@ -61,7 +117,65 @@ def test_string_in_filter():
] ]
def test_int_in_filter(): def test_string_in_filter_with_otjer_filter(query):
"""
Test in filter on a string field which has also a custom filter doing a similar operation.
"""
Person.objects.create(name="John")
Person.objects.create(name="Michael")
Person.objects.create(name="Angela")
schema = Schema(query=query)
query = """
query {
people (name_In: ["John", "Michael"]) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["people"]["edges"] == [
{"node": {"name": "John"}},
{"node": {"name": "Michael"}},
]
def test_string_in_filter_with_declared_filter(query):
"""
Test in filter on a string field with a custom filterset class.
"""
Person.objects.create(name="John")
Person.objects.create(name="Michael")
Person.objects.create(name="Angela")
schema = Schema(query=query)
query = """
query {
people (names: "John,Michael") {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["people"]["edges"] == [
{"node": {"name": "John"}},
{"node": {"name": "Michael"}},
]
def test_int_in_filter(query):
""" """
Test in filter on an integer field. Test in filter on an integer field.
""" """
@ -69,7 +183,7 @@ def test_int_in_filter():
Pet.objects.create(name="Mimi", age=3) Pet.objects.create(name="Mimi", age=3)
Pet.objects.create(name="Jojo, the rabbit", age=3) Pet.objects.create(name="Jojo, the rabbit", age=3)
schema = Schema(query=Query) schema = Schema(query=query)
query = """ query = """
query { query {
@ -109,20 +223,19 @@ def test_int_in_filter():
] ]
def test_int_range_filter(): def test_in_filter_with_empty_list(query):
""" """
Test in filter on an integer field. Check that using a in filter with an empty list provided as input returns no objects.
""" """
Pet.objects.create(name="Brutus", age=12) Pet.objects.create(name="Brutus", age=12)
Pet.objects.create(name="Mimi", age=8) Pet.objects.create(name="Mimi", age=8)
Pet.objects.create(name="Jojo, the rabbit", age=3)
Pet.objects.create(name="Picotin", age=5) Pet.objects.create(name="Picotin", age=5)
schema = Schema(query=Query) schema = Schema(query=query)
query = """ query = """
query { query {
pets (age_Range: [4, 9]) { pets (name_In: []) {
edges { edges {
node { node {
name name
@ -133,7 +246,210 @@ def test_int_range_filter():
""" """
result = schema.execute(query) result = schema.execute(query)
assert not result.errors assert not result.errors
assert result.data["pets"]["edges"] == [ assert len(result.data["pets"]["edges"]) == 0
{"node": {"name": "Mimi"}},
{"node": {"name": "Picotin"}},
def test_choice_in_filter_without_enum(query):
"""
Test in filter o an choice field not using an enum (Film.genre).
"""
john_doe = Reporter.objects.create(
first_name="John", last_name="Doe", email="john@doe.com"
)
jean_bon = Reporter.objects.create(
first_name="Jean", last_name="Bon", email="jean@bon.com"
)
documentary_film = Film.objects.create(genre="do")
documentary_film.reporters.add(john_doe)
action_film = Film.objects.create(genre="ac")
action_film.reporters.add(john_doe)
other_film = Film.objects.create(genre="ot")
other_film.reporters.add(john_doe)
other_film.reporters.add(jean_bon)
schema = Schema(query=query)
query = """
query {
films (genre_In: ["do", "ac"]) {
edges {
node {
genre
reporters {
edges {
node {
lastName
}
}
}
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["films"]["edges"] == [
{
"node": {
"genre": "do",
"reporters": {"edges": [{"node": {"lastName": "Doe"}}]},
}
},
{
"node": {
"genre": "ac",
"reporters": {"edges": [{"node": {"lastName": "Doe"}}]},
}
},
]
def test_fk_id_in_filter(query):
"""
Test in filter on an foreign key relationship.
"""
john_doe = Reporter.objects.create(
first_name="John", last_name="Doe", email="john@doe.com"
)
jean_bon = Reporter.objects.create(
first_name="Jean", last_name="Bon", email="jean@bon.com"
)
sara_croche = Reporter.objects.create(
first_name="Sara", last_name="Croche", email="sara@croche.com"
)
Article.objects.create(
headline="A",
pub_date=datetime.now(),
pub_date_time=datetime.now(),
reporter=john_doe,
editor=john_doe,
)
Article.objects.create(
headline="B",
pub_date=datetime.now(),
pub_date_time=datetime.now(),
reporter=jean_bon,
editor=jean_bon,
)
Article.objects.create(
headline="C",
pub_date=datetime.now(),
pub_date_time=datetime.now(),
reporter=sara_croche,
editor=sara_croche,
)
schema = Schema(query=query)
query = """
query {
articles (reporter_In: [%s, %s]) {
edges {
node {
headline
reporter {
lastName
}
}
}
}
}
""" % (
john_doe.id,
jean_bon.id,
)
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == [
{"node": {"headline": "A", "reporter": {"lastName": "Doe"}}},
{"node": {"headline": "B", "reporter": {"lastName": "Bon"}}},
]
def test_enum_in_filter(query):
"""
Test in filter on a choice field using an enum (Reporter.reporter_type).
"""
Reporter.objects.create(
first_name="John",
last_name="Doe",
email="john@doe.com",
reporter_type=1,
)
Reporter.objects.create(
first_name="Jean",
last_name="Bon",
email="jean@bon.com",
reporter_type=2,
)
Reporter.objects.create(
first_name="Jane",
last_name="Doe",
email="jane@doe.com",
reporter_type=2,
)
Reporter.objects.create(
first_name="Jack",
last_name="Black",
email="jack@black.com",
reporter_type=None,
)
schema = Schema(query=query)
query = """
query {
reporters (reporterType_In: [A_1]) {
edges {
node {
email
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["reporters"]["edges"] == [
{"node": {"email": "john@doe.com"}},
]
query = """
query {
reporters (reporterType_In: [A_2]) {
edges {
node {
email
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["reporters"]["edges"] == [
{"node": {"email": "jean@bon.com"}},
{"node": {"email": "jane@doe.com"}},
]
query = """
query {
reporters (reporterType_In: [A_2, A_1]) {
edges {
node {
email
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["reporters"]["edges"] == [
{"node": {"email": "john@doe.com"}},
{"node": {"email": "jean@bon.com"}},
{"node": {"email": "jane@doe.com"}},
] ]

View File

@ -0,0 +1,115 @@
import ast
import json
import pytest
from django_filters import FilterSet
from django_filters import rest_framework as filters
from graphene import ObjectType, Schema
from graphene.relay import Node
from graphene_django import DjangoObjectType
from graphene_django.tests.models import Pet
from graphene_django.utils import DJANGO_FILTER_INSTALLED
pytestmark = []
if DJANGO_FILTER_INSTALLED:
from graphene_django.filter import DjangoFilterConnectionField
else:
pytestmark.append(
pytest.mark.skipif(
True, reason="django_filters not installed or not compatible"
)
)
class PetNode(DjangoObjectType):
class Meta:
model = Pet
interfaces = (Node,)
filter_fields = {
"name": ["exact", "in"],
"age": ["exact", "in", "range"],
}
class Query(ObjectType):
pets = DjangoFilterConnectionField(PetNode)
def test_int_range_filter():
"""
Test range filter on an integer field.
"""
Pet.objects.create(name="Brutus", age=12)
Pet.objects.create(name="Mimi", age=8)
Pet.objects.create(name="Jojo, the rabbit", age=3)
Pet.objects.create(name="Picotin", age=5)
schema = Schema(query=Query)
query = """
query {
pets (age_Range: [4, 9]) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["pets"]["edges"] == [
{"node": {"name": "Mimi"}},
{"node": {"name": "Picotin"}},
]
def test_range_filter_with_invalid_input():
"""
Test range filter used with invalid inputs raise an error.
"""
Pet.objects.create(name="Brutus", age=12)
Pet.objects.create(name="Mimi", age=8)
Pet.objects.create(name="Jojo, the rabbit", age=3)
Pet.objects.create(name="Picotin", age=5)
schema = Schema(query=Query)
query = """
query ($rangeValue: [Int]) {
pets (age_Range: $rangeValue) {
edges {
node {
name
}
}
}
}
"""
expected_error = json.dumps(
{
"age__range": [
{
"message": "Invalid range specified: it needs to contain 2 values.",
"code": "invalid",
}
]
}
)
# Empty list
result = schema.execute(query, variables={"rangeValue": []})
assert len(result.errors) == 1
assert ast.literal_eval(result.errors[0].message)[0] == expected_error
# Only one item in the list
result = schema.execute(query, variables={"rangeValue": [1]})
assert len(result.errors) == 1
assert ast.literal_eval(result.errors[0].message)[0] == expected_error
# More than 2 items in the list
result = schema.execute(query, variables={"rangeValue": [1, 2, 3]})
assert len(result.errors) == 1
assert ast.literal_eval(result.errors[0].message)[0] == expected_error

View File

@ -0,0 +1,165 @@
import pytest
from django_filters import FilterSet
import graphene
from graphene.relay import Node
from graphene_django import DjangoObjectType
from graphene_django.tests.models import Article, Reporter
from graphene_django.utils import DJANGO_FILTER_INSTALLED
pytestmark = []
if DJANGO_FILTER_INSTALLED:
from graphene_django.filter import (
DjangoFilterConnectionField,
TypedFilter,
ListFilter,
)
else:
pytestmark.append(
pytest.mark.skipif(
True, reason="django_filters not installed or not compatible"
)
)
@pytest.fixture
def schema():
class ArticleFilterSet(FilterSet):
class Meta:
model = Article
fields = {
"lang": ["exact", "in"],
}
lang__contains = TypedFilter(
field_name="lang", lookup_expr="icontains", input_type=graphene.String
)
lang__in_str = ListFilter(
field_name="lang",
lookup_expr="in",
input_type=graphene.List(graphene.String),
)
first_n = TypedFilter(input_type=graphene.Int, method="first_n_filter")
only_first = TypedFilter(
input_type=graphene.Boolean, method="only_first_filter"
)
def first_n_filter(self, queryset, _name, value):
return queryset[:value]
def only_first_filter(self, queryset, _name, value):
if value:
return queryset[:1]
else:
return queryset
class ArticleType(DjangoObjectType):
class Meta:
model = Article
interfaces = (Node,)
filterset_class = ArticleFilterSet
class Query(graphene.ObjectType):
articles = DjangoFilterConnectionField(ArticleType)
schema = graphene.Schema(query=Query)
return schema
def test_typed_filter_schema(schema):
"""
Check that the type provided in the filter is reflected in the schema.
"""
schema_str = str(schema)
filters = {
"offset": "Int",
"before": "String",
"after": "String",
"first": "Int",
"last": "Int",
"lang": "ArticleLang",
"lang_In": "[ArticleLang]",
"lang_Contains": "String",
"lang_InStr": "[String]",
"firstN": "Int",
"onlyFirst": "Boolean",
}
all_articles_filters = (
schema_str.split(" articles(")[1]
.split("): ArticleTypeConnection\n")[0]
.split(", ")
)
for filter_field, gql_type in filters.items():
assert "{}: {}".format(filter_field, gql_type) in all_articles_filters
def test_typed_filters_work(schema):
reporter = Reporter.objects.create(first_name="John", last_name="Doe", email="")
Article.objects.create(
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",
)
query = "query { articles (lang_In: [ES]) { edges { node { headline } } } }"
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == [
{"node": {"headline": "A"}},
{"node": {"headline": "B"}},
]
query = 'query { articles (lang_InStr: ["es"]) { edges { node { headline } } } }'
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == [
{"node": {"headline": "A"}},
{"node": {"headline": "B"}},
]
query = 'query { articles (lang_Contains: "n") { edges { node { headline } } } }'
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == [
{"node": {"headline": "C"}},
]
query = "query { articles (firstN: 2) { edges { node { headline } } } }"
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == [
{"node": {"headline": "A"}},
{"node": {"headline": "B"}},
]
query = "query { articles (onlyFirst: true) { edges { node { headline } } } }"
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == [
{"node": {"headline": "A"}},
]

View File

@ -1,53 +1,108 @@
import six import six
from graphene import List import graphene
from django_filters.utils import get_model_field from django import forms
from django_filters.utils import get_model_field, get_field_parts
from django_filters.filters import Filter, BaseCSVFilter from django_filters.filters import Filter, BaseCSVFilter
from .filterset import custom_filterset_factory, setup_filterset from .filterset import custom_filterset_factory, setup_filterset
from .filters import ArrayFilter, ListFilter, RangeFilter, TypedFilter
from ..forms import GlobalIDFormField, GlobalIDMultipleChoiceField
def get_field_type(registry, model, field_name):
"""
Try to get a model field corresponding Graphql type from the DjangoObjectType.
"""
object_type = registry.get_type_for_model(model)
if object_type:
object_type_field = object_type._meta.fields.get(field_name)
if object_type_field:
field_type = object_type_field.type
if isinstance(field_type, graphene.NonNull):
field_type = field_type.of_type
return field_type
return None
def get_filtering_args_from_filterset(filterset_class, type): def get_filtering_args_from_filterset(filterset_class, type):
""" Inspect a FilterSet and produce the arguments to pass to """
a Graphene Field. These arguments will be available to Inspect a FilterSet and produce the arguments to pass to a Graphene Field.
filter against in the GraphQL These arguments will be available to filter against in the GraphQL API.
""" """
from ..forms.converter import convert_form_field from ..forms.converter import convert_form_field
args = {} args = {}
model = filterset_class._meta.model model = filterset_class._meta.model
registry = type._meta.registry
for name, filter_field in six.iteritems(filterset_class.base_filters): for name, filter_field in six.iteritems(filterset_class.base_filters):
filter_type = filter_field.lookup_expr
required = filter_field.extra.get("required", False)
field_type = None
form_field = None form_field = None
if name in filterset_class.declared_filters: if (
# Get the filter field from the explicitly declared filter isinstance(filter_field, TypedFilter)
form_field = filter_field.field and filter_field.input_type is not None
field = convert_form_field(form_field) ):
# First check if the filter input type has been explicitely given
field_type = filter_field.input_type
else: else:
# Get the filter field with no explicit type declaration if name not in filterset_class.declared_filters or isinstance(
model_field = get_model_field(model, filter_field.field_name) filter_field, TypedFilter
filter_type = filter_field.lookup_expr ):
if filter_type != "isnull" and hasattr(model_field, "formfield"): # Get the filter field for filters that are no explicitly declared.
form_field = model_field.formfield( if filter_type == "isnull":
required=filter_field.extra.get("required", False) field = graphene.Boolean(required=required)
) else:
model_field = get_model_field(model, filter_field.field_name)
# Fallback to field defined on filter if we can't get it from the # Get the form field either from:
# model field # 1. the formfield corresponding to the model field
if not form_field: # 2. the field defined on filter
form_field = filter_field.field if hasattr(model_field, "formfield"):
form_field = model_field.formfield(required=required)
if not form_field:
form_field = filter_field.field
field = convert_form_field(form_field) # First try to get the matching field type from the GraphQL DjangoObjectType
if model_field:
if (
isinstance(form_field, forms.ModelChoiceField)
or isinstance(form_field, forms.ModelMultipleChoiceField)
or isinstance(form_field, GlobalIDMultipleChoiceField)
or isinstance(form_field, GlobalIDFormField)
):
# Foreign key have dynamic types and filtering on a foreign key actually means filtering on its ID.
field_type = get_field_type(
registry, model_field.related_model, "id"
)
else:
field_type = get_field_type(
registry, model_field.model, model_field.name
)
if filter_type in ["in", "range"]: if not field_type:
# Replace CSV filters (`in`, `range`) argument type to be a list of the same type as the field. # Fallback on converting the form field either because:
# See comments in `replace_csv_filters` method for more details. # - it's an explicitly declared filters
field = List(field.get_type()) # - we did not manage to get the type from the model type
form_field = form_field or filter_field.field
field_type = convert_form_field(form_field).get_type()
field_type = field.Argument() if isinstance(filter_field, ListFilter) or isinstance(
field_type.description = filter_field.label filter_field, RangeFilter
args[name] = field_type ):
# Replace InFilter/RangeFilter filters (`in`, `range`) argument type to be a list of
# the same type as the field. See comments in `replace_csv_filters` method for more details.
field_type = graphene.List(field_type)
args[name] = graphene.Argument(
type=field_type,
description=filter_field.label,
required=required,
)
return args return args
@ -69,22 +124,35 @@ def get_filterset_class(filterset_class, **meta):
def replace_csv_filters(filterset_class): def replace_csv_filters(filterset_class):
""" """
Replace the "in" and "range" filters (that are not explicitly declared) to not be BaseCSVFilter (BaseInFilter, BaseRangeFilter) objects anymore Replace the "in" and "range" filters (that are not explicitly declared)
but regular Filter objects that simply use the input value as filter argument on the queryset. to not be BaseCSVFilter (BaseInFilter, BaseRangeFilter) objects anymore
but our custom InFilter/RangeFilter filter class that use the input
value as filter argument on the queryset.
This is because those BaseCSVFilter are expecting a string as input with comma separated value but with GraphQl we This is because those BaseCSVFilter are expecting a string as input with
can actually have a list as input and have a proper type verification of each value in the list. comma separated values.
But with GraphQl we can actually have a list as input and have a proper
type verification of each value in the list.
See issue https://github.com/graphql-python/graphene-django/issues/1068. See issue https://github.com/graphql-python/graphene-django/issues/1068.
""" """
for name, filter_field in six.iteritems(filterset_class.base_filters): for name, filter_field in six.iteritems(filterset_class.base_filters):
# Do not touch any declared filters
if name in filterset_class.declared_filters:
continue
filter_type = filter_field.lookup_expr filter_type = filter_field.lookup_expr
if ( if filter_type == "in":
filter_type in ["in", "range"] filterset_class.base_filters[name] = ListFilter(
and name not in filterset_class.declared_filters field_name=filter_field.field_name,
): lookup_expr=filter_field.lookup_expr,
assert isinstance(filter_field, BaseCSVFilter) label=filter_field.label,
filterset_class.base_filters[name] = Filter( method=filter_field.method,
exclude=filter_field.exclude,
**filter_field.extra
)
elif filter_type == "range":
filterset_class.base_filters[name] = RangeFilter(
field_name=filter_field.field_name, field_name=filter_field.field_name,
lookup_expr=filter_field.lookup_expr, lookup_expr=filter_field.lookup_expr,
label=filter_field.label, label=filter_field.label,

View File

@ -1,12 +1,23 @@
from django import forms from django import forms
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from graphene import ID, Boolean, Float, Int, List, String, UUID, Date, DateTime, Time from graphene import (
Boolean,
Date,
DateTime,
Decimal,
Float,
ID,
Int,
List,
String,
Time,
UUID,
)
from .forms import GlobalIDFormField, GlobalIDMultipleChoiceField from .forms import GlobalIDFormField, GlobalIDMultipleChoiceField
from ..utils import import_single_dispatch from ..utils import import_single_dispatch
singledispatch = import_single_dispatch() singledispatch = import_single_dispatch()
@ -52,6 +63,10 @@ def convert_form_field_to_nullboolean(field):
@convert_form_field.register(forms.DecimalField) @convert_form_field.register(forms.DecimalField)
def convert_field_to_decimal(field):
return Decimal(description=field.help_text, required=field.required)
@convert_form_field.register(forms.FloatField) @convert_form_field.register(forms.FloatField)
def convert_form_field_to_float(field): def convert_form_field_to_float(field):
return Float(description=field.help_text, required=field.required) return Float(description=field.help_text, required=field.required)

View File

@ -1,19 +1,19 @@
from django import forms from django import forms
from py.test import raises from py.test import raises
import graphene
from graphene import ( from graphene import (
String,
Int,
Boolean, Boolean,
Date,
DateTime,
Decimal,
Float, Float,
ID, ID,
UUID, Int,
List, List,
NonNull, NonNull,
DateTime, String,
Date,
Time, Time,
UUID,
) )
from ..converter import convert_form_field from ..converter import convert_form_field
@ -97,8 +97,8 @@ def test_should_float_convert_float():
assert_conversion(forms.FloatField, Float) assert_conversion(forms.FloatField, Float)
def test_should_decimal_convert_float(): def test_should_decimal_convert_decimal():
assert_conversion(forms.DecimalField, Float) assert_conversion(forms.DecimalField, Decimal)
def test_should_multiple_choice_convert_list(): def test_should_multiple_choice_convert_list():

View File

@ -18,6 +18,7 @@ class SerializerMutationOptions(MutationOptions):
model_class = None model_class = None
model_operations = ["create", "update"] model_operations = ["create", "update"]
serializer_class = None serializer_class = None
optional_fields = ()
def fields_for_serializer( def fields_for_serializer(
@ -27,6 +28,7 @@ def fields_for_serializer(
is_input=False, is_input=False,
convert_choices_to_enum=True, convert_choices_to_enum=True,
lookup_field=None, lookup_field=None,
optional_fields=(),
): ):
fields = OrderedDict() fields = OrderedDict()
for name, field in serializer.fields.items(): for name, field in serializer.fields.items():
@ -44,9 +46,13 @@ def fields_for_serializer(
if is_not_in_only or is_excluded: if is_not_in_only or is_excluded:
continue continue
is_optional = name in optional_fields
fields[name] = convert_serializer_field( fields[name] = convert_serializer_field(
field, is_input=is_input, convert_choices_to_enum=convert_choices_to_enum field,
is_input=is_input,
convert_choices_to_enum=convert_choices_to_enum,
force_optional=is_optional,
) )
return fields return fields
@ -70,6 +76,7 @@ class SerializerMutation(ClientIDMutation):
exclude_fields=(), exclude_fields=(),
convert_choices_to_enum=True, convert_choices_to_enum=True,
_meta=None, _meta=None,
optional_fields=(),
**options **options
): ):
@ -95,6 +102,7 @@ class SerializerMutation(ClientIDMutation):
is_input=True, is_input=True,
convert_choices_to_enum=convert_choices_to_enum, convert_choices_to_enum=convert_choices_to_enum,
lookup_field=lookup_field, lookup_field=lookup_field,
optional_fields=optional_fields,
) )
output_fields = fields_for_serializer( output_fields = fields_for_serializer(
serializer, serializer,

View File

@ -19,7 +19,9 @@ def get_graphene_type_from_serializer_field(field):
) )
def convert_serializer_field(field, is_input=True, convert_choices_to_enum=True): def convert_serializer_field(
field, is_input=True, convert_choices_to_enum=True, force_optional=False
):
""" """
Converts a django rest frameworks field to a graphql field Converts a django rest frameworks field to a graphql field
and marks the field as required if we are creating an input type and marks the field as required if we are creating an input type
@ -32,7 +34,10 @@ def convert_serializer_field(field, is_input=True, convert_choices_to_enum=True)
graphql_type = get_graphene_type_from_serializer_field(field) graphql_type = get_graphene_type_from_serializer_field(field)
args = [] args = []
kwargs = {"description": field.help_text, "required": is_input and field.required} kwargs = {
"description": field.help_text,
"required": is_input and field.required and not force_optional,
}
# if it is a tuple or a list it means that we are returning # if it is a tuple or a list it means that we are returning
# the graphql type and the child type # the graphql type and the child type
@ -110,8 +115,12 @@ def convert_serializer_field_to_bool(field):
return graphene.Boolean return graphene.Boolean
@get_graphene_type_from_serializer_field.register(serializers.FloatField)
@get_graphene_type_from_serializer_field.register(serializers.DecimalField) @get_graphene_type_from_serializer_field.register(serializers.DecimalField)
def convert_serializer_field_to_decimal(field):
return graphene.Decimal
@get_graphene_type_from_serializer_field.register(serializers.FloatField)
def convert_serializer_field_to_float(field): def convert_serializer_field_to_float(field):
return graphene.Float return graphene.Float

View File

@ -133,9 +133,9 @@ def test_should_float_convert_float():
assert_conversion(serializers.FloatField, graphene.Float) assert_conversion(serializers.FloatField, graphene.Float)
def test_should_decimal_convert_float(): def test_should_decimal_convert_decimal():
assert_conversion( assert_conversion(
serializers.DecimalField, graphene.Float, max_digits=4, decimal_places=2 serializers.DecimalField, graphene.Decimal, max_digits=4, decimal_places=2
) )

View File

@ -3,7 +3,7 @@ import datetime
from py.test import raises from py.test import raises
from rest_framework import serializers from rest_framework import serializers
from graphene import Field, ResolveInfo from graphene import Field, ResolveInfo, NonNull, String
from graphene.types.inputobjecttype import InputObjectType from graphene.types.inputobjecttype import InputObjectType
from ...types import DjangoObjectType from ...types import DjangoObjectType
@ -98,6 +98,25 @@ def test_exclude_fields():
assert "created" not in MyMutation.Input._meta.fields assert "created" not in MyMutation.Input._meta.fields
def test_model_serializer_required_fields():
class MyMutation(SerializerMutation):
class Meta:
serializer_class = MyModelSerializer
assert "cool_name" in MyMutation.Input._meta.fields
assert MyMutation.Input._meta.fields["cool_name"].type == NonNull(String)
def test_model_serializer_optional_fields():
class MyMutation(SerializerMutation):
class Meta:
serializer_class = MyModelSerializer
optional_fields = ("cool_name",)
assert "cool_name" in MyMutation.Input._meta.fields
assert MyMutation.Input._meta.fields["cool_name"].type == String
def test_write_only_field(): def test_write_only_field():
class WriteOnlyFieldModelSerializer(serializers.ModelSerializer): class WriteOnlyFieldModelSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True) password = serializers.CharField(write_only=True)

View File

@ -1,11 +1,15 @@
from __future__ import absolute_import from __future__ import absolute_import
from django.db import models from django.db import models
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
CHOICES = ((1, "this"), (2, _("that"))) CHOICES = ((1, "this"), (2, _("that")))
class Person(models.Model):
name = models.CharField(max_length=30)
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()
@ -22,7 +26,7 @@ class Film(models.Model):
genre = models.CharField( genre = models.CharField(
max_length=2, max_length=2,
help_text="Genre", help_text="Genre",
choices=[("do", "Documentary"), ("ot", "Other")], choices=[("do", "Documentary"), ("ac", "Action"), ("ot", "Other")],
default="ot", default="ot",
) )
reporters = models.ManyToManyField("Reporter", related_name="films") reporters = models.ManyToManyField("Reporter", related_name="films")
@ -46,7 +50,7 @@ class Reporter(models.Model):
"Reporter Type", "Reporter Type",
null=True, null=True,
blank=True, blank=True,
choices=[(1, u"Regular"), (2, u"CNN Reporter")], choices=[(1, "Regular"), (2, "CNN Reporter")],
) )
def __str__(self): # __unicode__ on Python 2 def __str__(self): # __unicode__ on Python 2
@ -87,8 +91,8 @@ class CNNReporter(Reporter):
class Article(models.Model): class Article(models.Model):
headline = models.CharField(max_length=100) headline = models.CharField(max_length=100)
pub_date = models.DateField() pub_date = models.DateField(auto_now_add=True)
pub_date_time = models.DateTimeField() pub_date_time = models.DateTimeField(auto_now_add=True)
reporter = models.ForeignKey( reporter = models.ForeignKey(
Reporter, on_delete=models.CASCADE, related_name="articles" Reporter, on_delete=models.CASCADE, related_name="articles"
) )
@ -105,7 +109,7 @@ class Article(models.Model):
"Importance", "Importance",
null=True, null=True,
blank=True, blank=True,
choices=[(1, u"Very important"), (2, u"Not as important")], choices=[(1, "Very important"), (2, "Not as important")],
) )
def __str__(self): # __unicode__ on Python 2 def __str__(self): # __unicode__ on Python 2

View File

@ -2,7 +2,7 @@ from collections import namedtuple
import pytest import pytest
from django.db import models from django.db import models
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from py.test import raises from py.test import raises
import graphene import graphene
@ -242,6 +242,10 @@ def test_should_float_convert_float():
assert_conversion(models.FloatField, graphene.Float) assert_conversion(models.FloatField, graphene.Float)
def test_should_decimal_convert_decimal():
assert_conversion(models.DecimalField, graphene.Decimal)
def test_should_manytomany_convert_connectionorlist(): def test_should_manytomany_convert_connectionorlist():
registry = Registry() registry = Registry()
dynamic_field = convert_django_field(Reporter._meta.local_many_to_many[0], registry) dynamic_field = convert_django_field(Reporter._meta.local_many_to_many[0], registry)

View File

@ -11,7 +11,7 @@ from py.test import raises
import graphene import graphene
from graphene.relay import Node from graphene.relay import Node
from ..compat import JSONField, MissingType 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
@ -113,7 +113,7 @@ def test_should_query_well():
assert result.data == expected assert result.data == expected
@pytest.mark.skipif(JSONField is MissingType, reason="RangeField should exist") @pytest.mark.skipif(IntegerRangeField is MissingType, reason="RangeField should exist")
def test_should_query_postgres_fields(): def test_should_query_postgres_fields():
from django.contrib.postgres.fields import ( from django.contrib.postgres.fields import (
IntegerRangeField, IntegerRangeField,
@ -412,6 +412,7 @@ def test_should_query_node_filtering():
model = Article model = Article
interfaces = (Node,) interfaces = (Node,)
filter_fields = ("lang",) filter_fields = ("lang",)
convert_choices_to_enum = False
class Query(graphene.ObjectType): class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType) all_reporters = DjangoConnectionField(ReporterType)
@ -534,6 +535,7 @@ def test_should_query_node_multiple_filtering():
model = Article model = Article
interfaces = (Node,) interfaces = (Node,)
filter_fields = ("lang", "headline") filter_fields = ("lang", "headline")
convert_choices_to_enum = False
class Query(graphene.ObjectType): class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType) all_reporters = DjangoConnectionField(ReporterType)
@ -1442,7 +1444,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
@ -1482,7 +1488,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
@ -1549,6 +1557,10 @@ 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

View File

@ -51,7 +51,9 @@ def test_graphql_test_case_op_name(post_mock):
pass pass
tc = TestClass() tc = TestClass()
tc._pre_setup()
tc.setUpClass() tc.setUpClass()
tc.query("query { }", op_name="QueryName") tc.query("query { }", op_name="QueryName")
body = json.loads(post_mock.call_args.args[1]) body = json.loads(post_mock.call_args.args[1])
# `operationName` field from https://graphql.org/learn/serving-over-http/#post-request # `operationName` field from https://graphql.org/learn/serving-over-http/#post-request

View File

@ -1,8 +1,8 @@
from django.conf.urls import url from django.urls import re_path
from ..views import GraphQLView from ..views import GraphQLView
urlpatterns = [ urlpatterns = [
url(r"^graphql/batch", GraphQLView.as_view(batch=True)), re_path(r"^graphql/batch", GraphQLView.as_view(batch=True)),
url(r"^graphql", GraphQLView.as_view(graphiql=True)), re_path(r"^graphql", GraphQLView.as_view(graphiql=True)),
] ]

View File

@ -1,4 +1,4 @@
from django.conf.urls import url from django.urls import re_path
from ..views import GraphQLView from ..views import GraphQLView
from .schema_view import schema from .schema_view import schema
@ -10,4 +10,4 @@ class CustomGraphQLView(GraphQLView):
pretty = True pretty = True
urlpatterns = [url(r"^graphql/inherited/$", CustomGraphQLView.as_view())] urlpatterns = [re_path(r"^graphql/inherited/$", CustomGraphQLView.as_view())]

View File

@ -1,6 +1,6 @@
from django.conf.urls import url from django.urls import re_path
from ..views import GraphQLView from ..views import GraphQLView
from .schema_view import schema from .schema_view import schema
urlpatterns = [url(r"^graphql", GraphQLView.as_view(schema=schema, pretty=True))] urlpatterns = [re_path(r"^graphql", GraphQLView.as_view(schema=schema, pretty=True))]

View File

@ -1,6 +1,7 @@
import json import json
import warnings
from django.test import TestCase, Client from django.test import Client, TestCase
DEFAULT_GRAPHQL_URL = "/graphql/" DEFAULT_GRAPHQL_URL = "/graphql/"
@ -68,12 +69,6 @@ class GraphQLTestCase(TestCase):
# URL to graphql endpoint # URL to graphql endpoint
GRAPHQL_URL = DEFAULT_GRAPHQL_URL GRAPHQL_URL = DEFAULT_GRAPHQL_URL
@classmethod
def setUpClass(cls):
super(GraphQLTestCase, cls).setUpClass()
cls._client = Client()
def query(self, query, op_name=None, input_data=None, variables=None, headers=None): def query(self, query, op_name=None, input_data=None, variables=None, headers=None):
""" """
Args: Args:
@ -99,10 +94,32 @@ class GraphQLTestCase(TestCase):
input_data=input_data, input_data=input_data,
variables=variables, variables=variables,
headers=headers, headers=headers,
client=self._client, client=self.client,
graphql_url=self.GRAPHQL_URL, graphql_url=self.GRAPHQL_URL,
) )
@property
def _client(self):
pass
@_client.getter
def _client(self):
warnings.warn(
"Using `_client` is deprecated in favour of `client`.",
PendingDeprecationWarning,
stacklevel=2,
)
return self.client
@_client.setter
def _client(self, client):
warnings.warn(
"Using `_client` is deprecated in favour of `client`.",
PendingDeprecationWarning,
stacklevel=2,
)
self.client = client
def assertResponseNoErrors(self, resp, msg=None): def assertResponseNoErrors(self, resp, msg=None):
""" """
Assert that the call went through correctly. 200 means the syntax is ok, if there are no `errors`, Assert that the call went through correctly. 200 means the syntax is ok, if there are no `errors`,

View File

@ -7,4 +7,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

@ -0,0 +1,45 @@
import pytest
from .. import GraphQLTestCase
from ...tests.test_types import with_local_registry
from django.test import Client
@with_local_registry
def test_graphql_test_case_deprecated_client_getter():
"""
`GraphQLTestCase._client`' getter should raise pending deprecation warning.
"""
class TestClass(GraphQLTestCase):
GRAPHQL_SCHEMA = True
def runTest(self):
pass
tc = TestClass()
tc._pre_setup()
tc.setUpClass()
with pytest.warns(PendingDeprecationWarning):
tc._client
@with_local_registry
def test_graphql_test_case_deprecated_client_setter():
"""
`GraphQLTestCase._client`' setter should raise pending deprecation warning.
"""
class TestClass(GraphQLTestCase):
GRAPHQL_SCHEMA = True
def runTest(self):
pass
tc = TestClass()
tc._pre_setup()
tc.setUpClass()
with pytest.warns(PendingDeprecationWarning):
tc._client = Client()

View File

@ -3,7 +3,7 @@ import inspect
import six import six
from django.db import connection, models, transaction from django.db import connection, models, transaction
from django.db.models.manager import Manager from django.db.models.manager import Manager
from django.utils.encoding import force_text from django.utils.encoding import force_str
from django.utils.functional import Promise from django.utils.functional import Promise
from graphene.utils.str_converters import to_camel_case from graphene.utils.str_converters import to_camel_case
@ -26,7 +26,7 @@ def isiterable(value):
def _camelize_django_str(s): def _camelize_django_str(s):
if isinstance(s, Promise): if isinstance(s, Promise):
s = force_text(s) s = force_str(s)
return to_camel_case(s) if isinstance(s, six.string_types) else s return to_camel_case(s) if isinstance(s, six.string_types) else s

View File

@ -59,23 +59,23 @@ class GraphQLView(View):
graphiql_template = "graphene/graphiql.html" graphiql_template = "graphene/graphiql.html"
# Polyfill for window.fetch. # Polyfill for window.fetch.
whatwg_fetch_version = "3.2.0" whatwg_fetch_version = "3.6.2"
whatwg_fetch_sri = "sha256-l6HCB9TT2v89oWbDdo2Z3j+PSVypKNLA/nqfzSbM8mo=" whatwg_fetch_sri = "sha256-+pQdxwAcHJdQ3e/9S4RK6g8ZkwdMgFQuHvLuN5uyk5c="
# React and ReactDOM. # React and ReactDOM.
react_version = "16.13.1" react_version = "17.0.2"
react_sri = "sha256-yUhvEmYVhZ/GGshIQKArLvySDSh6cdmdcIx0spR3UP4=" react_sri = "sha256-Ipu/TQ50iCCVZBUsZyNJfxrDk0E2yhaEIz0vqI+kFG8="
react_dom_sri = "sha256-vFt3l+illeNlwThbDUdoPTqF81M8WNSZZZt3HEjsbSU=" react_dom_sri = "sha256-nbMykgB6tsOFJ7OdVmPpdqMFVk4ZsqWocT6issAPUF0="
# The GraphiQL React app. # The GraphiQL React app.
graphiql_version = "1.0.3" graphiql_version = "1.4.1"
graphiql_sri = "sha256-VR4buIDY9ZXSyCNFHFNik6uSe0MhigCzgN4u7moCOTk=" graphiql_sri = "sha256-JUMkXBQWZMfJ7fGEsTXalxVA10lzKOS9loXdLjwZKi4="
graphiql_css_sri = "sha256-LwqxjyZgqXDYbpxQJ5zLQeNcf7WVNSJ+r8yp2rnWE/E=" graphiql_css_sri = "sha256-Md3vdR7PDzWyo/aGfsFVF4tvS5/eAUWuIsg9QHUusCY="
# The websocket transport library for subscriptions. # The websocket transport library for subscriptions.
subscriptions_transport_ws_version = "0.9.17" subscriptions_transport_ws_version = "0.9.18"
subscriptions_transport_ws_sri = ( subscriptions_transport_ws_sri = (
"sha256-kCDzver8iRaIQ/SVlfrIwxaBQ/avXf9GQFJRLlErBnk=" "sha256-i0hAXd4PdJ/cHX3/8tIy/Q/qKiWr5WSTxMFuL9tACkw="
) )
schema = None schema = None

View File

@ -19,17 +19,16 @@ tests_require = [
"coveralls", "coveralls",
"mock", "mock",
"pytz", "pytz",
"django-filter<2;python_version<'3'", "django-filter>=2",
"django-filter>=2;python_version>='3'",
"pytest-django>=3.3.2", "pytest-django>=3.3.2",
] + rest_framework_require ] + rest_framework_require
dev_requires = [ dev_requires = [
"black==19.10b0", "black==22.6.0",
"flake8==3.7.9", "flake8>=5,<6",
"flake8-black==0.1.1", "flake8-black==0.3.3",
"flake8-bugbear==20.1.4", "flake8-bugbear==22.7.1",
] + tests_require ] + tests_require
setup( setup(
@ -45,25 +44,26 @@ setup(
"Development Status :: 3 - Alpha", "Development Status :: 3 - Alpha",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries",
"Programming Language :: Python :: 2",
"Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.6", "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.10",
"Programming Language :: Python :: Implementation :: PyPy", "Programming Language :: Python :: Implementation :: PyPy",
"Framework :: Django", "Framework :: Django",
"Framework :: Django :: 1.11",
"Framework :: Django :: 2.2", "Framework :: Django :: 2.2",
"Framework :: Django :: 3.0", "Framework :: Django :: 3.0",
"Framework :: Django :: 3.1",
"Framework :: Django :: 3.2",
"Framework :: Django :: 4.0",
], ],
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=[
"six>=1.10.0",
"graphene>=2.1.7,<3", "graphene>=2.1.7,<3",
"graphql-core>=2.1.0,<3", "graphql-core>=2.1.0,<3",
"Django>=1.11", "Django>=2.2",
"singledispatch>=3.4.0.3", "singledispatch>=3.4.0.3",
"promise>=2.1", "promise>=2.1",
"text-unidecode", "text-unidecode",

37
tox.ini
View File

@ -1,24 +1,26 @@
[tox] [tox]
envlist = envlist =
py{27,35,36,37,38}-django{111,20,21,22,master}, py{36,37,38,39}-django22,
py{36,37,38}-django{30,31}, py{36,37,38,39}-django{30,31},
py{36,37,38,39,310}-django32,
py{38,39,310}-django{40,master},
black,flake8 black,flake8
[gh-actions] [gh-actions]
python = python =
2.7: py27
3.6: py36 3.6: py36
3.7: py37 3.7: py37
3.8: py38 3.8: py38
3.9: py39
3.10: py310
[gh-actions:env] [gh-actions:env]
DJANGO = DJANGO =
1.11: django111
2.0: django20
2.1: django21
2.2: django22 2.2: django22
3.0: django30 3.0: django30
3.1: django31 3.1: django31
3.2: django32
4.0: django40
master: djangomaster master: djangomaster
[testenv] [testenv]
@ -29,24 +31,17 @@ setenv =
deps = deps =
-e.[test] -e.[test]
psycopg2-binary psycopg2-binary
django111: Django>=1.11,<2.0
django111: djangorestframework<3.12
django20: Django>=2.0,<2.1
django21: Django>=2.1,<2.2
django22: Django>=2.2,<3.0 django22: Django>=2.2,<3.0
django30: Django>=3.0a1,<3.1 django30: Django>=3.0,<3.1
django31: Django>=3.1,<3.2 django31: Django>=3.1,<3.2
django32: Django>=3.2,<4.0
django40: Django>=4.0,<4.1
djangomaster: https://github.com/django/django/archive/master.zip djangomaster: https://github.com/django/django/archive/master.zip
commands = {posargs:py.test --cov=graphene_django graphene_django examples} commands = {posargs:py.test --cov=graphene_django graphene_django examples}
[testenv:black] [testenv:pre-commit]
basepython = python3.8 basepython = python3.10
deps = -e.[dev] skip_install = true
commands = deps = pre-commit
black --exclude "/migrations/" graphene_django examples setup.py --check
[testenv:flake8]
basepython = python3.8
deps = -e.[dev]
commands = commands =
flake8 graphene_django examples setup.py pre-commit run --all-files --show-diff-on-failure