diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 1cd1011..6cce61d 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -10,11 +10,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v1
- - name: Set up Python 3.8
- uses: actions/setup-python@v1
+ - uses: actions/checkout@v3
+ - name: Set up Python 3.10
+ uses: actions/setup-python@v4
with:
- python-version: 3.8
+ python-version: "3.10"
- name: Build wheel and source tarball
run: |
pip install wheel
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 20cf7fb..a458cd1 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -7,16 +7,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v1
- - name: Set up Python 3.8
- uses: actions/setup-python@v1
+ - uses: actions/checkout@v3
+ - name: Set up Python 3.10
+ uses: actions/setup-python@v4
with:
- python-version: 3.8
+ python-version: "3.10"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install tox
- - name: Run lint 💅
+ - name: Run pre-commit 💅
run: tox
env:
- TOXENV: flake8
+ TOXENV: pre-commit
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index b9e57b5..045d73f 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -8,16 +8,33 @@ jobs:
strategy:
max-parallel: 4
matrix:
- django: ["1.11", "2.2", "3.0", "3.1"]
- python-version: ["3.6", "3.7", "3.8"]
+ django: ["2.2", "3.0", "3.1", "3.2", "4.0"]
+ python-version: ["3.8", "3.9"]
include:
- - django: "1.11"
- python-version: "2.7"
-
+ - django: "2.2"
+ 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:
- - uses: actions/checkout@v1
+ - uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v1
+ uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..021d38b
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -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
diff --git a/Makefile b/Makefile
index b850ae8..8f7ea0d 100644
--- a/Makefile
+++ b/Makefile
@@ -14,11 +14,7 @@ test: tests # Alias test -> tests
.PHONY: format
format:
- black --exclude "/migrations/" graphene_django examples setup.py
-
-.PHONY: lint
-lint:
- flake8 graphene_django examples
+ pre-commit run --all-files
.PHONY: docs ## Generate docs
docs: dev-setup
diff --git a/docs/conf.py b/docs/conf.py
index a485d5b..b83e0f0 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -60,18 +60,18 @@ source_suffix = ".rst"
master_doc = "index"
# General information about the project.
-project = u"Graphene Django"
-copyright = u"Graphene 2017"
-author = u"Syrus Akbary"
+project = "Graphene Django"
+copyright = "Graphene 2017"
+author = "Syrus Akbary"
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
-version = u"1.0"
+version = "1.0"
# 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
# for a list of supported languages.
@@ -276,7 +276,7 @@ latex_elements = {
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
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
@@ -317,7 +317,7 @@ latex_documents = [
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
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.
@@ -334,7 +334,7 @@ texinfo_documents = [
(
master_doc,
"Graphene-Django",
- u"Graphene Django Documentation",
+ "Graphene Django Documentation",
author,
"Graphene Django",
"One line description of project.",
diff --git a/docs/debug.rst b/docs/debug.rst
index d1cbb21..2286519 100644
--- a/docs/debug.rst
+++ b/docs/debug.rst
@@ -3,7 +3,7 @@ Django Debug Middleware
You can debug your GraphQL queries in a similar way to
`django-debug-toolbar `__,
-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.
For that, you will need to add the plugin in your graphene schema.
@@ -43,7 +43,7 @@ And in your ``settings.py``:
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:
.. code::
diff --git a/docs/filtering.rst b/docs/filtering.rst
index e366fe2..a131b30 100644
--- a/docs/filtering.rst
+++ b/docs/filtering.rst
@@ -2,9 +2,9 @@ Filtering
=========
Graphene-Django integrates with
-`django-filter `__ (2.x for
+`django-filter `__ (2.x for
Python 3 or 1.x for Python 2) to provide filtering of results. See the `usage
-documentation `__
+documentation `__
for details on the format for ``filter_fields``.
This filtering is automatically available when implementing a ``relay.Node``.
@@ -16,7 +16,7 @@ You will need to install it manually, which can be done as follows:
# You'll need to install django-filter
pip install django-filter>=2
-
+
After installing ``django-filter`` you'll need to add the application in the ``settings.py`` file:
.. code:: python
@@ -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
-app `__.
+app `__.
Filterable fields
-----------------
@@ -35,7 +35,7 @@ Filterable fields
The ``filter_fields`` parameter is used to specify the fields which can
be filtered upon. The value specified here is passed directly to
``django-filter``, so see the `filtering
-documentation `__
+documentation `__
for full details on the range of options available.
For example:
@@ -163,7 +163,7 @@ in unison with the ``filter_fields`` parameter:
animal = relay.Node.Field(AnimalNode)
all_animals = DjangoFilterConnectionField(AnimalNode)
-The context argument is passed on as the `request argument `__
+The context argument is passed on as the `request argument `__
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
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
diff --git a/docs/queries.rst b/docs/queries.rst
index 02a2bf2..d2da781 100644
--- a/docs/queries.rst
+++ b/docs/queries.rst
@@ -287,7 +287,7 @@ Where "foo" is the name of the field declared in the ``Query`` object.
class Query(graphene.ObjectType):
foo = graphene.List(QuestionType)
- def resolve_foo(root, info):
+ def resolve_foo(root, info, **kwargs):
id = kwargs.get("id")
return Question.objects.get(id)
diff --git a/docs/schema.py b/docs/schema.py
index 3d9b2fa..914b656 100644
--- a/docs/schema.py
+++ b/docs/schema.py
@@ -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 Meta:
- model = Category
+class CategoryType(DjangoObjectType):
+ class Meta:
+ model = Category
- class IngredientType(DjangoObjectType):
- class Meta:
- model = Ingredient
+class IngredientType(DjangoObjectType):
+ class Meta:
+ model = Ingredient
- class Query(object):
- category = graphene.Field(CategoryType,
- id=graphene.Int(),
- name=graphene.String())
- all_categories = graphene.List(CategoryType)
+class Query(object):
+ category = graphene.Field(CategoryType, id=graphene.Int(), 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,
- id=graphene.Int(),
- name=graphene.String())
- all_ingredients = graphene.List(IngredientType)
+ def resolve_all_categories(self, info, **kwargs):
+ return Category.objects.all()
- def resolve_all_categories(self, info, **kwargs):
- return Category.objects.all()
+ def resolve_all_ingredients(self, info, **kwargs):
+ return Ingredient.objects.all()
- def resolve_all_ingredients(self, info, **kwargs):
- return Ingredient.objects.all()
+ def resolve_category(self, info, **kwargs):
+ id = kwargs.get("id")
+ name = kwargs.get("name")
- def resolve_category(self, info, **kwargs):
- id = kwargs.get('id')
- name = kwargs.get('name')
+ if id is not None:
+ return Category.objects.get(pk=id)
- if id is not None:
- return Category.objects.get(pk=id)
+ if name is not None:
+ return Category.objects.get(name=name)
- if name is not None:
- return Category.objects.get(name=name)
+ return None
- return None
+ def resolve_ingredient(self, info, **kwargs):
+ id = kwargs.get("id")
+ name = kwargs.get("name")
- def resolve_ingredient(self, info, **kwargs):
- id = kwargs.get('id')
- name = kwargs.get('name')
+ if id is not None:
+ return Ingredient.objects.get(pk=id)
- if id is not None:
- return Ingredient.objects.get(pk=id)
+ if name is not None:
+ return Ingredient.objects.get(name=name)
- if name is not None:
- return Ingredient.objects.get(name=name)
-
- return None
\ No newline at end of file
+ return None
diff --git a/examples/cookbook-plain/cookbook/ingredients/migrations/0001_initial.py b/examples/cookbook-plain/cookbook/ingredients/migrations/0001_initial.py
index 0494923..ee8cadd 100644
--- a/examples/cookbook-plain/cookbook/ingredients/migrations/0001_initial.py
+++ b/examples/cookbook-plain/cookbook/ingredients/migrations/0001_initial.py
@@ -10,24 +10,46 @@ class Migration(migrations.Migration):
initial = True
- dependencies = [
- ]
+ dependencies = []
operations = [
migrations.CreateModel(
- name='Category',
+ name="Category",
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(
- name='Ingredient',
+ name="Ingredient",
fields=[
- ('id', models.AutoField(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')),
+ (
+ "id",
+ models.AutoField(
+ 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",
+ ),
+ ),
],
),
]
diff --git a/examples/cookbook-plain/cookbook/ingredients/migrations/0002_auto_20161104_0050.py b/examples/cookbook-plain/cookbook/ingredients/migrations/0002_auto_20161104_0050.py
index 359d4fc..0f3cab5 100644
--- a/examples/cookbook-plain/cookbook/ingredients/migrations/0002_auto_20161104_0050.py
+++ b/examples/cookbook-plain/cookbook/ingredients/migrations/0002_auto_20161104_0050.py
@@ -8,13 +8,13 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('ingredients', '0001_initial'),
+ ("ingredients", "0001_initial"),
]
operations = [
migrations.AlterField(
- model_name='ingredient',
- name='notes',
+ model_name="ingredient",
+ name="notes",
field=models.TextField(blank=True, null=True),
),
]
diff --git a/examples/cookbook-plain/cookbook/ingredients/migrations/0003_auto_20181018_1746.py b/examples/cookbook-plain/cookbook/ingredients/migrations/0003_auto_20181018_1746.py
index 184e79e..8015d1f 100644
--- a/examples/cookbook-plain/cookbook/ingredients/migrations/0003_auto_20181018_1746.py
+++ b/examples/cookbook-plain/cookbook/ingredients/migrations/0003_auto_20181018_1746.py
@@ -6,12 +6,12 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
- ('ingredients', '0002_auto_20161104_0050'),
+ ("ingredients", "0002_auto_20161104_0050"),
]
operations = [
migrations.AlterModelOptions(
- name='category',
- options={'verbose_name_plural': 'Categories'},
+ name="category",
+ options={"verbose_name_plural": "Categories"},
),
]
diff --git a/examples/cookbook-plain/cookbook/recipes/migrations/0001_initial.py b/examples/cookbook-plain/cookbook/recipes/migrations/0001_initial.py
index 338c71a..a43fa7d 100644
--- a/examples/cookbook-plain/cookbook/recipes/migrations/0001_initial.py
+++ b/examples/cookbook-plain/cookbook/recipes/migrations/0001_initial.py
@@ -11,26 +11,62 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
- ('ingredients', '0001_initial'),
+ ("ingredients", "0001_initial"),
]
operations = [
migrations.CreateModel(
- name='Recipe',
+ name="Recipe",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('title', models.CharField(max_length=100)),
- ('instructions', models.TextField()),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("title", models.CharField(max_length=100)),
+ ("instructions", models.TextField()),
],
),
migrations.CreateModel(
- name='RecipeIngredient',
+ name="RecipeIngredient",
fields=[
- ('id', models.AutoField(auto_created=True, 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')),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ 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",
+ ),
+ ),
],
),
]
diff --git a/examples/cookbook-plain/cookbook/recipes/migrations/0002_auto_20161104_0106.py b/examples/cookbook-plain/cookbook/recipes/migrations/0002_auto_20161104_0106.py
index f135392..6a8d1bf 100644
--- a/examples/cookbook-plain/cookbook/recipes/migrations/0002_auto_20161104_0106.py
+++ b/examples/cookbook-plain/cookbook/recipes/migrations/0002_auto_20161104_0106.py
@@ -8,18 +8,26 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('recipes', '0001_initial'),
+ ("recipes", "0001_initial"),
]
operations = [
migrations.RenameField(
- model_name='recipeingredient',
- old_name='recipes',
- new_name='recipe',
+ model_name="recipeingredient",
+ old_name="recipes",
+ new_name="recipe",
),
migrations.AlterField(
- model_name='recipeingredient',
- 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),
+ model_name="recipeingredient",
+ 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,
+ ),
),
]
diff --git a/examples/cookbook-plain/cookbook/recipes/migrations/0003_auto_20181018_1728.py b/examples/cookbook-plain/cookbook/recipes/migrations/0003_auto_20181018_1728.py
index 7a8df49..c54855b 100644
--- a/examples/cookbook-plain/cookbook/recipes/migrations/0003_auto_20181018_1728.py
+++ b/examples/cookbook-plain/cookbook/recipes/migrations/0003_auto_20181018_1728.py
@@ -6,13 +6,21 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('recipes', '0002_auto_20161104_0106'),
+ ("recipes", "0002_auto_20161104_0106"),
]
operations = [
migrations.AlterField(
- model_name='recipeingredient',
- name='unit',
- field=models.CharField(choices=[('unit', 'Units'), ('kg', 'Kilograms'), ('l', 'Litres'), ('st', 'Shots')], max_length=20),
+ model_name="recipeingredient",
+ name="unit",
+ field=models.CharField(
+ choices=[
+ ("unit", "Units"),
+ ("kg", "Kilograms"),
+ ("l", "Litres"),
+ ("st", "Shots"),
+ ],
+ max_length=20,
+ ),
),
]
diff --git a/examples/cookbook/cookbook/ingredients/migrations/0001_initial.py b/examples/cookbook/cookbook/ingredients/migrations/0001_initial.py
index 0494923..ee8cadd 100644
--- a/examples/cookbook/cookbook/ingredients/migrations/0001_initial.py
+++ b/examples/cookbook/cookbook/ingredients/migrations/0001_initial.py
@@ -10,24 +10,46 @@ class Migration(migrations.Migration):
initial = True
- dependencies = [
- ]
+ dependencies = []
operations = [
migrations.CreateModel(
- name='Category',
+ name="Category",
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(
- name='Ingredient',
+ name="Ingredient",
fields=[
- ('id', models.AutoField(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')),
+ (
+ "id",
+ models.AutoField(
+ 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",
+ ),
+ ),
],
),
]
diff --git a/examples/cookbook/cookbook/ingredients/migrations/0002_auto_20161104_0050.py b/examples/cookbook/cookbook/ingredients/migrations/0002_auto_20161104_0050.py
index 359d4fc..0f3cab5 100644
--- a/examples/cookbook/cookbook/ingredients/migrations/0002_auto_20161104_0050.py
+++ b/examples/cookbook/cookbook/ingredients/migrations/0002_auto_20161104_0050.py
@@ -8,13 +8,13 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('ingredients', '0001_initial'),
+ ("ingredients", "0001_initial"),
]
operations = [
migrations.AlterField(
- model_name='ingredient',
- name='notes',
+ model_name="ingredient",
+ name="notes",
field=models.TextField(blank=True, null=True),
),
]
diff --git a/examples/cookbook/cookbook/recipes/migrations/0001_initial.py b/examples/cookbook/cookbook/recipes/migrations/0001_initial.py
index 338c71a..a43fa7d 100644
--- a/examples/cookbook/cookbook/recipes/migrations/0001_initial.py
+++ b/examples/cookbook/cookbook/recipes/migrations/0001_initial.py
@@ -11,26 +11,62 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
- ('ingredients', '0001_initial'),
+ ("ingredients", "0001_initial"),
]
operations = [
migrations.CreateModel(
- name='Recipe',
+ name="Recipe",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('title', models.CharField(max_length=100)),
- ('instructions', models.TextField()),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("title", models.CharField(max_length=100)),
+ ("instructions", models.TextField()),
],
),
migrations.CreateModel(
- name='RecipeIngredient',
+ name="RecipeIngredient",
fields=[
- ('id', models.AutoField(auto_created=True, 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')),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ 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",
+ ),
+ ),
],
),
]
diff --git a/examples/cookbook/cookbook/recipes/migrations/0002_auto_20161104_0106.py b/examples/cookbook/cookbook/recipes/migrations/0002_auto_20161104_0106.py
index f135392..6a8d1bf 100644
--- a/examples/cookbook/cookbook/recipes/migrations/0002_auto_20161104_0106.py
+++ b/examples/cookbook/cookbook/recipes/migrations/0002_auto_20161104_0106.py
@@ -8,18 +8,26 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('recipes', '0001_initial'),
+ ("recipes", "0001_initial"),
]
operations = [
migrations.RenameField(
- model_name='recipeingredient',
- old_name='recipes',
- new_name='recipe',
+ model_name="recipeingredient",
+ old_name="recipes",
+ new_name="recipe",
),
migrations.AlterField(
- model_name='recipeingredient',
- 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),
+ model_name="recipeingredient",
+ 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,
+ ),
),
]
diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py
index 792f7be..7472a06 100644
--- a/graphene_django/__init__.py
+++ b/graphene_django/__init__.py
@@ -1,7 +1,7 @@
from .fields import DjangoConnectionField, DjangoListField
from .types import DjangoObjectType
-__version__ = "2.14.0"
+__version__ = "2.15.0"
__all__ = [
"__version__",
diff --git a/graphene_django/compat.py b/graphene_django/compat.py
index 8a2b933..537fd1d 100644
--- a/graphene_django/compat.py
+++ b/graphene_django/compat.py
@@ -6,13 +6,16 @@ try:
# Postgres fields are only available in Django with psycopg2 installed
# and we cannot have psycopg2 on PyPy
from django.contrib.postgres.fields import (
+ IntegerRangeField,
ArrayField,
HStoreField,
JSONField as PGJSONField,
RangeField,
)
except ImportError:
- ArrayField, HStoreField, PGJSONField, RangeField = (MissingType,) * 4
+ IntegerRangeField, ArrayField, HStoreField, PGJSONField, RangeField = (
+ MissingType,
+ ) * 5
try:
# JSONField is only available from Django 3.1
diff --git a/graphene_django/converter.py b/graphene_django/converter.py
index 0de6964..b744e51 100644
--- a/graphene_django/converter.py
+++ b/graphene_django/converter.py
@@ -18,6 +18,7 @@ from graphene import (
DateTime,
Date,
Time,
+ Decimal,
)
from graphene.types.json import JSONString
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):
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):
@@ -160,6 +165,10 @@ def convert_field_to_boolean(field, registry=None):
@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.DurationField)
def convert_field_to_float(field, registry=None):
diff --git a/graphene_django/fields.py b/graphene_django/fields.py
index fdf95aa..eead5b3 100644
--- a/graphene_django/fields.py
+++ b/graphene_django/fields.py
@@ -66,7 +66,10 @@ class DjangoListField(Field):
_type = _type.of_type
django_object_type = _type.of_type.of_type
return partial(
- self.list_resolver, django_object_type, parent_resolver, self.get_manager(),
+ self.list_resolver,
+ django_object_type,
+ parent_resolver,
+ self.get_manager(),
)
diff --git a/graphene_django/filter/__init__.py b/graphene_django/filter/__init__.py
index daafe56..f02fc6b 100644
--- a/graphene_django/filter/__init__.py
+++ b/graphene_django/filter/__init__.py
@@ -9,10 +9,21 @@ if not DJANGO_FILTER_INSTALLED:
)
else:
from .fields import DjangoFilterConnectionField
- from .filterset import GlobalIDFilter, GlobalIDMultipleChoiceFilter
+ from .filters import (
+ ArrayFilter,
+ GlobalIDFilter,
+ GlobalIDMultipleChoiceFilter,
+ ListFilter,
+ RangeFilter,
+ TypedFilter,
+ )
__all__ = [
"DjangoFilterConnectionField",
"GlobalIDFilter",
"GlobalIDMultipleChoiceFilter",
+ "ArrayFilter",
+ "ListFilter",
+ "RangeFilter",
+ "TypedFilter",
]
diff --git a/graphene_django/filter/fields.py b/graphene_django/filter/fields.py
index 7d8d2d8..9a4cf36 100644
--- a/graphene_django/filter/fields.py
+++ b/graphene_django/filter/fields.py
@@ -43,8 +43,8 @@ class DjangoFilterConnectionField(DjangoConnectionField):
if self._extra_filter_meta:
meta.update(self._extra_filter_meta)
- filterset_class = self._provided_filterset_class or (
- self.node_type._meta.filterset_class
+ filterset_class = (
+ self._provided_filterset_class or self.node_type._meta.filterset_class
)
self._filterset_class = get_filterset_class(filterset_class, **meta)
diff --git a/graphene_django/filter/filters/__init__.py b/graphene_django/filter/filters/__init__.py
new file mode 100644
index 0000000..fcf75af
--- /dev/null
+++ b/graphene_django/filter/filters/__init__.py
@@ -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",
+ ]
diff --git a/graphene_django/filter/filters/array_filter.py b/graphene_django/filter/filters/array_filter.py
new file mode 100644
index 0000000..e886cff
--- /dev/null
+++ b/graphene_django/filter/filters/array_filter.py
@@ -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
diff --git a/graphene_django/filter/filters/global_id_filter.py b/graphene_django/filter/filters/global_id_filter.py
new file mode 100644
index 0000000..da16585
--- /dev/null
+++ b/graphene_django/filter/filters/global_id_filter.py
@@ -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)
diff --git a/graphene_django/filter/filters/list_filter.py b/graphene_django/filter/filters/list_filter.py
new file mode 100644
index 0000000..9689be3
--- /dev/null
+++ b/graphene_django/filter/filters/list_filter.py
@@ -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)
diff --git a/graphene_django/filter/filters/range_filter.py b/graphene_django/filter/filters/range_filter.py
new file mode 100644
index 0000000..c2faddb
--- /dev/null
+++ b/graphene_django/filter/filters/range_filter.py
@@ -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
diff --git a/graphene_django/filter/filters/typed_filter.py b/graphene_django/filter/filters/typed_filter.py
new file mode 100644
index 0000000..2c813e4
--- /dev/null
+++ b/graphene_django/filter/filters/typed_filter.py
@@ -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
diff --git a/graphene_django/filter/filterset.py b/graphene_django/filter/filterset.py
index 7676ea8..8ffb0b5 100644
--- a/graphene_django/filter/filterset.py
+++ b/graphene_django/filter/filterset.py
@@ -1,32 +1,11 @@
import itertools
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 FILTER_FOR_DBFIELD_DEFAULTS
-from graphql_relay.node.node import from_global_id
-
-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)
+from .filters import GlobalIDFilter, GlobalIDMultipleChoiceFilter
GRAPHENE_FILTER_SET_OVERRIDES = {
@@ -40,8 +19,8 @@ GRAPHENE_FILTER_SET_OVERRIDES = {
class GrapheneFilterSetMixin(BaseFilterSet):
- """ A django_filters.filterset.BaseFilterSet with default filter overrides
- to handle global IDs """
+ """A django_filters.filterset.BaseFilterSet with default filter overrides
+ to handle global IDs"""
FILTER_DEFAULTS = dict(
itertools.chain(
@@ -81,8 +60,7 @@ if VERSION[0] < 2:
def setup_filterset(filterset_class):
- """ Wrap a provided filterset in Graphene-specific functionality
- """
+ """Wrap a provided filterset in Graphene-specific functionality"""
return type(
"Graphene{}".format(filterset_class.__name__),
(filterset_class, GrapheneFilterSetMixin),
@@ -91,8 +69,7 @@ def setup_filterset(filterset_class):
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_class = type(str("Meta"), (object,), meta)
filterset = type(
diff --git a/graphene_django/filter/tests/conftest.py b/graphene_django/filter/tests/conftest.py
new file mode 100644
index 0000000..4d5b810
--- /dev/null
+++ b/graphene_django/filter/tests/conftest.py
@@ -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
diff --git a/graphene_django/filter/tests/filters.py b/graphene_django/filter/tests/filters.py
index 43b6a87..a7443c0 100644
--- a/graphene_django/filter/tests/filters.py
+++ b/graphene_django/filter/tests/filters.py
@@ -10,7 +10,7 @@ class ArticleFilter(django_filters.FilterSet):
fields = {
"headline": ["exact", "icontains"],
"pub_date": ["gt", "lt", "exact"],
- "reporter": ["exact"],
+ "reporter": ["exact", "in"],
}
order_by = OrderingFilter(fields=("pub_date",))
diff --git a/graphene_django/filter/tests/test_array_field_contains_filter.py b/graphene_django/filter/tests/test_array_field_contains_filter.py
new file mode 100644
index 0000000..4144614
--- /dev/null
+++ b/graphene_django/filter/tests/test_array_field_contains_filter.py
@@ -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"}},
+ ]
diff --git a/graphene_django/filter/tests/test_array_field_exact_filter.py b/graphene_django/filter/tests/test_array_field_exact_filter.py
new file mode 100644
index 0000000..814fd33
--- /dev/null
+++ b/graphene_django/filter/tests/test_array_field_exact_filter.py
@@ -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
+ )
diff --git a/graphene_django/filter/tests/test_array_field_overlap_filter.py b/graphene_django/filter/tests/test_array_field_overlap_filter.py
new file mode 100644
index 0000000..5ce1576
--- /dev/null
+++ b/graphene_django/filter/tests/test_array_field_overlap_filter.py
@@ -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"] == []
diff --git a/graphene_django/filter/tests/test_enum_filtering.py b/graphene_django/filter/tests/test_enum_filtering.py
new file mode 100644
index 0000000..73b628b
--- /dev/null
+++ b/graphene_django/filter/tests/test_enum_filtering.py
@@ -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
diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py
index 18e7f0c..61e6548 100644
--- a/graphene_django/filter/tests/test_fields.py
+++ b/graphene_django/filter/tests/test_fields.py
@@ -5,18 +5,18 @@ import pytest
from django.db.models import TextField, Value
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_django import DjangoObjectType
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
pytestmark = []
if DJANGO_FILTER_INSTALLED:
import django_filters
- from django_filters import FilterSet, NumberFilter
+ from django_filters import FilterSet, NumberFilter, OrderingFilter
from graphene_django.filter import (
GlobalIDFilter,
@@ -87,6 +87,7 @@ def test_filter_explicit_filterset_arguments():
"pub_date__gt",
"pub_date__lt",
"reporter",
+ "reporter__in",
)
@@ -388,7 +389,7 @@ def test_filterset_descriptions():
field = DjangoFilterConnectionField(ArticleNode, filterset_class=ArticleIdFilter)
max_time = field.args["max_time"]
assert isinstance(max_time, Argument)
- assert max_time.type == Float
+ assert max_time.type == Decimal
assert max_time.description == "The maximum time"
@@ -671,12 +672,12 @@ def test_should_query_filter_node_limit():
schema = Schema(query=Query)
query = """
query NodeFilteringQuery {
- allReporters(limit: 1) {
+ allReporters(limit: "1") {
edges {
node {
id
firstName
- articles(lang: "es") {
+ articles(lang: ES) {
edges {
node {
id
@@ -1085,7 +1086,7 @@ def test_filter_filterset_based_on_mixin():
return filters
- def filter_email_in(cls, queryset, name, value):
+ def filter_email_in(self, queryset, name, value):
return queryset.filter(**{name: [value]})
class NewArticleFilter(ArticleFilterMixin, ArticleFilter):
@@ -1171,3 +1172,76 @@ def test_filter_filterset_based_on_mixin():
assert not result.errors
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")
diff --git a/graphene_django/filter/tests/test_in_filter.py b/graphene_django/filter/tests/test_in_filter.py
index 3d4034e..f022aa0 100644
--- a/graphene_django/filter/tests/test_in_filter.py
+++ b/graphene_django/filter/tests/test_in_filter.py
@@ -1,9 +1,14 @@
+from datetime import datetime
+
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.tests.models import Pet, Person, Reporter, Article, Film
+from graphene_django.filter.tests.filters import ArticleFilter
from graphene_django.utils import DJANGO_FILTER_INSTALLED
pytestmark = []
@@ -18,21 +23,72 @@ else:
)
-class PetNode(DjangoObjectType):
- class Meta:
- model = Pet
- interfaces = (Node,)
- filter_fields = {
- "name": ["exact", "in"],
- "age": ["exact", "in", "range"],
- }
+@pytest.fixture
+def query():
+ class PetNode(DjangoObjectType):
+ class Meta:
+ model = Pet
+ interfaces = (Node,)
+ 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):
- pets = DjangoFilterConnectionField(PetNode)
-
-
-def test_string_in_filter():
+def test_string_in_filter(query):
"""
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="Jojo, the rabbit", age=3)
- schema = Schema(query=Query)
+ schema = Schema(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.
"""
@@ -69,7 +183,7 @@ def test_int_in_filter():
Pet.objects.create(name="Mimi", age=3)
Pet.objects.create(name="Jojo, the rabbit", age=3)
- schema = Schema(query=Query)
+ schema = Schema(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="Mimi", age=8)
- Pet.objects.create(name="Jojo, the rabbit", age=3)
Pet.objects.create(name="Picotin", age=5)
- schema = Schema(query=Query)
+ schema = Schema(query=query)
query = """
query {
- pets (age_Range: [4, 9]) {
+ pets (name_In: []) {
edges {
node {
name
@@ -133,7 +246,210 @@ def test_int_range_filter():
"""
result = schema.execute(query)
assert not result.errors
- assert result.data["pets"]["edges"] == [
- {"node": {"name": "Mimi"}},
- {"node": {"name": "Picotin"}},
+ assert len(result.data["pets"]["edges"]) == 0
+
+
+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"}},
]
diff --git a/graphene_django/filter/tests/test_range_filter.py b/graphene_django/filter/tests/test_range_filter.py
new file mode 100644
index 0000000..4d8db4f
--- /dev/null
+++ b/graphene_django/filter/tests/test_range_filter.py
@@ -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
diff --git a/graphene_django/filter/tests/test_typed_filter.py b/graphene_django/filter/tests/test_typed_filter.py
new file mode 100644
index 0000000..051144d
--- /dev/null
+++ b/graphene_django/filter/tests/test_typed_filter.py
@@ -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"}},
+ ]
diff --git a/graphene_django/filter/utils.py b/graphene_django/filter/utils.py
index becd5f5..0743773 100644
--- a/graphene_django/filter/utils.py
+++ b/graphene_django/filter/utils.py
@@ -1,53 +1,108 @@
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 .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):
- """ Inspect a FilterSet and produce the arguments to pass to
- a Graphene Field. These arguments will be available to
- filter against in the GraphQL
+ """
+ Inspect a FilterSet and produce the arguments to pass to a Graphene Field.
+ These arguments will be available to filter against in the GraphQL API.
"""
from ..forms.converter import convert_form_field
args = {}
model = filterset_class._meta.model
+ registry = type._meta.registry
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
- if name in filterset_class.declared_filters:
- # Get the filter field from the explicitly declared filter
- form_field = filter_field.field
- field = convert_form_field(form_field)
+ if (
+ isinstance(filter_field, TypedFilter)
+ and filter_field.input_type is not None
+ ):
+ # First check if the filter input type has been explicitely given
+ field_type = filter_field.input_type
else:
- # Get the filter field with no explicit type declaration
- model_field = get_model_field(model, filter_field.field_name)
- filter_type = filter_field.lookup_expr
- if filter_type != "isnull" and hasattr(model_field, "formfield"):
- form_field = model_field.formfield(
- required=filter_field.extra.get("required", False)
- )
+ if name not in filterset_class.declared_filters or isinstance(
+ filter_field, TypedFilter
+ ):
+ # Get the filter field for filters that are no explicitly declared.
+ if filter_type == "isnull":
+ 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
- # model field
- if not form_field:
- form_field = filter_field.field
+ # Get the form field either from:
+ # 1. the formfield corresponding to the model field
+ # 2. the field defined on filter
+ 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"]:
- # Replace CSV 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 = List(field.get_type())
+ if not field_type:
+ # Fallback on converting the form field either because:
+ # - it's an explicitly declared filters
+ # - 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()
- field_type.description = filter_field.label
- args[name] = field_type
+ if isinstance(filter_field, ListFilter) or isinstance(
+ filter_field, RangeFilter
+ ):
+ # 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
@@ -69,22 +124,35 @@ def get_filterset_class(filterset_class, **meta):
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
- but regular Filter objects that simply use the input value as filter argument on the queryset.
+ Replace the "in" and "range" filters (that are not explicitly declared)
+ 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
- can actually have a list as input and have a proper type verification of each value in the list.
+ This is because those BaseCSVFilter are expecting a string as input with
+ 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.
"""
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
- if (
- filter_type in ["in", "range"]
- and name not in filterset_class.declared_filters
- ):
- assert isinstance(filter_field, BaseCSVFilter)
- filterset_class.base_filters[name] = Filter(
+ if filter_type == "in":
+ filterset_class.base_filters[name] = ListFilter(
+ field_name=filter_field.field_name,
+ lookup_expr=filter_field.lookup_expr,
+ label=filter_field.label,
+ 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,
lookup_expr=filter_field.lookup_expr,
label=filter_field.label,
diff --git a/graphene_django/forms/converter.py b/graphene_django/forms/converter.py
index 5d17680..9db0a77 100644
--- a/graphene_django/forms/converter.py
+++ b/graphene_django/forms/converter.py
@@ -1,12 +1,23 @@
from django import forms
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 ..utils import import_single_dispatch
-
singledispatch = import_single_dispatch()
@@ -52,6 +63,10 @@ def convert_form_field_to_nullboolean(field):
@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)
def convert_form_field_to_float(field):
return Float(description=field.help_text, required=field.required)
diff --git a/graphene_django/forms/tests/test_converter.py b/graphene_django/forms/tests/test_converter.py
index ccf630f..78b315c 100644
--- a/graphene_django/forms/tests/test_converter.py
+++ b/graphene_django/forms/tests/test_converter.py
@@ -1,19 +1,19 @@
from django import forms
from py.test import raises
-import graphene
from graphene import (
- String,
- Int,
Boolean,
+ Date,
+ DateTime,
+ Decimal,
Float,
ID,
- UUID,
+ Int,
List,
NonNull,
- DateTime,
- Date,
+ String,
Time,
+ UUID,
)
from ..converter import convert_form_field
@@ -97,8 +97,8 @@ def test_should_float_convert_float():
assert_conversion(forms.FloatField, Float)
-def test_should_decimal_convert_float():
- assert_conversion(forms.DecimalField, Float)
+def test_should_decimal_convert_decimal():
+ assert_conversion(forms.DecimalField, Decimal)
def test_should_multiple_choice_convert_list():
diff --git a/graphene_django/rest_framework/mutation.py b/graphene_django/rest_framework/mutation.py
index 000b21e..9e2ae12 100644
--- a/graphene_django/rest_framework/mutation.py
+++ b/graphene_django/rest_framework/mutation.py
@@ -18,6 +18,7 @@ class SerializerMutationOptions(MutationOptions):
model_class = None
model_operations = ["create", "update"]
serializer_class = None
+ optional_fields = ()
def fields_for_serializer(
@@ -27,6 +28,7 @@ def fields_for_serializer(
is_input=False,
convert_choices_to_enum=True,
lookup_field=None,
+ optional_fields=(),
):
fields = OrderedDict()
for name, field in serializer.fields.items():
@@ -44,9 +46,13 @@ def fields_for_serializer(
if is_not_in_only or is_excluded:
continue
+ is_optional = name in optional_fields
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
@@ -70,6 +76,7 @@ class SerializerMutation(ClientIDMutation):
exclude_fields=(),
convert_choices_to_enum=True,
_meta=None,
+ optional_fields=(),
**options
):
@@ -95,6 +102,7 @@ class SerializerMutation(ClientIDMutation):
is_input=True,
convert_choices_to_enum=convert_choices_to_enum,
lookup_field=lookup_field,
+ optional_fields=optional_fields,
)
output_fields = fields_for_serializer(
serializer,
diff --git a/graphene_django/rest_framework/serializer_converter.py b/graphene_django/rest_framework/serializer_converter.py
index 82a113a..2535fe7 100644
--- a/graphene_django/rest_framework/serializer_converter.py
+++ b/graphene_django/rest_framework/serializer_converter.py
@@ -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
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)
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
# the graphql type and the child type
@@ -110,8 +115,12 @@ def convert_serializer_field_to_bool(field):
return graphene.Boolean
-@get_graphene_type_from_serializer_field.register(serializers.FloatField)
@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):
return graphene.Float
diff --git a/graphene_django/rest_framework/tests/test_field_converter.py b/graphene_django/rest_framework/tests/test_field_converter.py
index daa8349..4858365 100644
--- a/graphene_django/rest_framework/tests/test_field_converter.py
+++ b/graphene_django/rest_framework/tests/test_field_converter.py
@@ -133,9 +133,9 @@ def test_should_float_convert_float():
assert_conversion(serializers.FloatField, graphene.Float)
-def test_should_decimal_convert_float():
+def test_should_decimal_convert_decimal():
assert_conversion(
- serializers.DecimalField, graphene.Float, max_digits=4, decimal_places=2
+ serializers.DecimalField, graphene.Decimal, max_digits=4, decimal_places=2
)
diff --git a/graphene_django/rest_framework/tests/test_mutation.py b/graphene_django/rest_framework/tests/test_mutation.py
index ffbc4b5..5c2518d 100644
--- a/graphene_django/rest_framework/tests/test_mutation.py
+++ b/graphene_django/rest_framework/tests/test_mutation.py
@@ -3,7 +3,7 @@ import datetime
from py.test import raises
from rest_framework import serializers
-from graphene import Field, ResolveInfo
+from graphene import Field, ResolveInfo, NonNull, String
from graphene.types.inputobjecttype import InputObjectType
from ...types import DjangoObjectType
@@ -98,6 +98,25 @@ def test_exclude_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():
class WriteOnlyFieldModelSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True)
diff --git a/graphene_django/tests/models.py b/graphene_django/tests/models.py
index 44a5d8a..7b76cd3 100644
--- a/graphene_django/tests/models.py
+++ b/graphene_django/tests/models.py
@@ -1,11 +1,15 @@
from __future__ import absolute_import
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")))
+class Person(models.Model):
+ name = models.CharField(max_length=30)
+
+
class Pet(models.Model):
name = models.CharField(max_length=30)
age = models.PositiveIntegerField()
@@ -22,7 +26,7 @@ class Film(models.Model):
genre = models.CharField(
max_length=2,
help_text="Genre",
- choices=[("do", "Documentary"), ("ot", "Other")],
+ choices=[("do", "Documentary"), ("ac", "Action"), ("ot", "Other")],
default="ot",
)
reporters = models.ManyToManyField("Reporter", related_name="films")
@@ -46,7 +50,7 @@ class Reporter(models.Model):
"Reporter Type",
null=True,
blank=True,
- choices=[(1, u"Regular"), (2, u"CNN Reporter")],
+ choices=[(1, "Regular"), (2, "CNN Reporter")],
)
def __str__(self): # __unicode__ on Python 2
@@ -87,8 +91,8 @@ class CNNReporter(Reporter):
class Article(models.Model):
headline = models.CharField(max_length=100)
- pub_date = models.DateField()
- pub_date_time = models.DateTimeField()
+ pub_date = models.DateField(auto_now_add=True)
+ pub_date_time = models.DateTimeField(auto_now_add=True)
reporter = models.ForeignKey(
Reporter, on_delete=models.CASCADE, related_name="articles"
)
@@ -105,7 +109,7 @@ class Article(models.Model):
"Importance",
null=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
diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py
index 7d8e669..7b38a45 100644
--- a/graphene_django/tests/test_converter.py
+++ b/graphene_django/tests/test_converter.py
@@ -2,7 +2,7 @@ from collections import namedtuple
import pytest
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
import graphene
@@ -242,6 +242,10 @@ def test_should_float_convert_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():
registry = Registry()
dynamic_field = convert_django_field(Reporter._meta.local_many_to_many[0], registry)
diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py
index a2d8373..fd43fb0 100644
--- a/graphene_django/tests/test_query.py
+++ b/graphene_django/tests/test_query.py
@@ -11,7 +11,7 @@ from py.test import raises
import graphene
from graphene.relay import Node
-from ..compat import JSONField, MissingType
+from ..compat import IntegerRangeField, MissingType
from ..fields import DjangoConnectionField
from ..types import DjangoObjectType
from ..utils import DJANGO_FILTER_INSTALLED
@@ -113,7 +113,7 @@ def test_should_query_well():
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():
from django.contrib.postgres.fields import (
IntegerRangeField,
@@ -412,6 +412,7 @@ def test_should_query_node_filtering():
model = Article
interfaces = (Node,)
filter_fields = ("lang",)
+ convert_choices_to_enum = False
class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType)
@@ -534,6 +535,7 @@ def test_should_query_node_multiple_filtering():
model = Article
interfaces = (Node,)
filter_fields = ("lang", "headline")
+ convert_choices_to_enum = False
class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType)
@@ -1442,7 +1444,11 @@ def test_connection_should_enable_offset_filtering():
result = schema.execute(query)
assert not result.errors
expected = {
- "allReporters": {"edges": [{"node": {"firstName": "Some", "lastName": "Guy"}},]}
+ "allReporters": {
+ "edges": [
+ {"node": {"firstName": "Some", "lastName": "Guy"}},
+ ]
+ }
}
assert result.data == expected
@@ -1482,7 +1488,9 @@ def test_connection_should_enable_offset_filtering_higher_than_max_limit(
assert not result.errors
expected = {
"allReporters": {
- "edges": [{"node": {"firstName": "Some", "lastName": "Lady"}},]
+ "edges": [
+ {"node": {"firstName": "Some", "lastName": "Lady"}},
+ ]
}
}
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))
assert not result.errors
expected = {
- "allReporters": {"edges": [{"node": {"firstName": "Jane", "lastName": "Roe"}},]}
+ "allReporters": {
+ "edges": [
+ {"node": {"firstName": "Jane", "lastName": "Roe"}},
+ ]
+ }
}
assert result.data == expected
diff --git a/graphene_django/tests/test_utils.py b/graphene_django/tests/test_utils.py
index f5a8b05..e7aa027 100644
--- a/graphene_django/tests/test_utils.py
+++ b/graphene_django/tests/test_utils.py
@@ -51,7 +51,9 @@ def test_graphql_test_case_op_name(post_mock):
pass
tc = TestClass()
+ tc._pre_setup()
tc.setUpClass()
+
tc.query("query { }", op_name="QueryName")
body = json.loads(post_mock.call_args.args[1])
# `operationName` field from https://graphql.org/learn/serving-over-http/#post-request
diff --git a/graphene_django/tests/urls.py b/graphene_django/tests/urls.py
index 66b3fc4..f2faae2 100644
--- a/graphene_django/tests/urls.py
+++ b/graphene_django/tests/urls.py
@@ -1,8 +1,8 @@
-from django.conf.urls import url
+from django.urls import re_path
from ..views import GraphQLView
urlpatterns = [
- url(r"^graphql/batch", GraphQLView.as_view(batch=True)),
- url(r"^graphql", GraphQLView.as_view(graphiql=True)),
+ re_path(r"^graphql/batch", GraphQLView.as_view(batch=True)),
+ re_path(r"^graphql", GraphQLView.as_view(graphiql=True)),
]
diff --git a/graphene_django/tests/urls_inherited.py b/graphene_django/tests/urls_inherited.py
index 6fa8019..815d04d 100644
--- a/graphene_django/tests/urls_inherited.py
+++ b/graphene_django/tests/urls_inherited.py
@@ -1,4 +1,4 @@
-from django.conf.urls import url
+from django.urls import re_path
from ..views import GraphQLView
from .schema_view import schema
@@ -10,4 +10,4 @@ class CustomGraphQLView(GraphQLView):
pretty = True
-urlpatterns = [url(r"^graphql/inherited/$", CustomGraphQLView.as_view())]
+urlpatterns = [re_path(r"^graphql/inherited/$", CustomGraphQLView.as_view())]
diff --git a/graphene_django/tests/urls_pretty.py b/graphene_django/tests/urls_pretty.py
index 1133c87..635d4f3 100644
--- a/graphene_django/tests/urls_pretty.py
+++ b/graphene_django/tests/urls_pretty.py
@@ -1,6 +1,6 @@
-from django.conf.urls import url
+from django.urls import re_path
from ..views import GraphQLView
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))]
diff --git a/graphene_django/utils/testing.py b/graphene_django/utils/testing.py
index 871c440..afe83c2 100644
--- a/graphene_django/utils/testing.py
+++ b/graphene_django/utils/testing.py
@@ -1,6 +1,7 @@
import json
+import warnings
-from django.test import TestCase, Client
+from django.test import Client, TestCase
DEFAULT_GRAPHQL_URL = "/graphql/"
@@ -68,12 +69,6 @@ class GraphQLTestCase(TestCase):
# URL to graphql endpoint
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):
"""
Args:
@@ -99,10 +94,32 @@ class GraphQLTestCase(TestCase):
input_data=input_data,
variables=variables,
headers=headers,
- client=self._client,
+ client=self.client,
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):
"""
Assert that the call went through correctly. 200 means the syntax is ok, if there are no `errors`,
diff --git a/graphene_django/utils/tests/test_str_converters.py b/graphene_django/utils/tests/test_str_converters.py
index 24064b2..fc466f6 100644
--- a/graphene_django/utils/tests/test_str_converters.py
+++ b/graphene_django/utils/tests/test_str_converters.py
@@ -7,4 +7,4 @@ def test_to_const():
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"
diff --git a/graphene_django/utils/tests/test_testing.py b/graphene_django/utils/tests/test_testing.py
new file mode 100644
index 0000000..2ef78f9
--- /dev/null
+++ b/graphene_django/utils/tests/test_testing.py
@@ -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()
diff --git a/graphene_django/utils/utils.py b/graphene_django/utils/utils.py
index b1c9a7d..ff3b7f3 100644
--- a/graphene_django/utils/utils.py
+++ b/graphene_django/utils/utils.py
@@ -3,7 +3,7 @@ import inspect
import six
from django.db import connection, models, transaction
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 graphene.utils.str_converters import to_camel_case
@@ -26,7 +26,7 @@ def isiterable(value):
def _camelize_django_str(s):
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
diff --git a/graphene_django/views.py b/graphene_django/views.py
index e81f760..9908e70 100644
--- a/graphene_django/views.py
+++ b/graphene_django/views.py
@@ -59,23 +59,23 @@ class GraphQLView(View):
graphiql_template = "graphene/graphiql.html"
# Polyfill for window.fetch.
- whatwg_fetch_version = "3.2.0"
- whatwg_fetch_sri = "sha256-l6HCB9TT2v89oWbDdo2Z3j+PSVypKNLA/nqfzSbM8mo="
+ whatwg_fetch_version = "3.6.2"
+ whatwg_fetch_sri = "sha256-+pQdxwAcHJdQ3e/9S4RK6g8ZkwdMgFQuHvLuN5uyk5c="
# React and ReactDOM.
- react_version = "16.13.1"
- react_sri = "sha256-yUhvEmYVhZ/GGshIQKArLvySDSh6cdmdcIx0spR3UP4="
- react_dom_sri = "sha256-vFt3l+illeNlwThbDUdoPTqF81M8WNSZZZt3HEjsbSU="
+ react_version = "17.0.2"
+ react_sri = "sha256-Ipu/TQ50iCCVZBUsZyNJfxrDk0E2yhaEIz0vqI+kFG8="
+ react_dom_sri = "sha256-nbMykgB6tsOFJ7OdVmPpdqMFVk4ZsqWocT6issAPUF0="
# The GraphiQL React app.
- graphiql_version = "1.0.3"
- graphiql_sri = "sha256-VR4buIDY9ZXSyCNFHFNik6uSe0MhigCzgN4u7moCOTk="
- graphiql_css_sri = "sha256-LwqxjyZgqXDYbpxQJ5zLQeNcf7WVNSJ+r8yp2rnWE/E="
+ graphiql_version = "1.4.1"
+ graphiql_sri = "sha256-JUMkXBQWZMfJ7fGEsTXalxVA10lzKOS9loXdLjwZKi4="
+ graphiql_css_sri = "sha256-Md3vdR7PDzWyo/aGfsFVF4tvS5/eAUWuIsg9QHUusCY="
# The websocket transport library for subscriptions.
- subscriptions_transport_ws_version = "0.9.17"
+ subscriptions_transport_ws_version = "0.9.18"
subscriptions_transport_ws_sri = (
- "sha256-kCDzver8iRaIQ/SVlfrIwxaBQ/avXf9GQFJRLlErBnk="
+ "sha256-i0hAXd4PdJ/cHX3/8tIy/Q/qKiWr5WSTxMFuL9tACkw="
)
schema = None
diff --git a/setup.py b/setup.py
index e6615b8..0ac0d91 100644
--- a/setup.py
+++ b/setup.py
@@ -19,17 +19,16 @@ tests_require = [
"coveralls",
"mock",
"pytz",
- "django-filter<2;python_version<'3'",
- "django-filter>=2;python_version>='3'",
+ "django-filter>=2",
"pytest-django>=3.3.2",
] + rest_framework_require
dev_requires = [
- "black==19.10b0",
- "flake8==3.7.9",
- "flake8-black==0.1.1",
- "flake8-bugbear==20.1.4",
+ "black==22.6.0",
+ "flake8>=5,<6",
+ "flake8-black==0.3.3",
+ "flake8-bugbear==22.7.1",
] + tests_require
setup(
@@ -45,25 +44,26 @@ setup(
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Topic :: Software Development :: Libraries",
- "Programming Language :: Python :: 2",
- "Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
+ "Programming Language :: Python :: 3.9",
+ "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: Implementation :: PyPy",
"Framework :: Django",
- "Framework :: Django :: 1.11",
"Framework :: Django :: 2.2",
"Framework :: Django :: 3.0",
+ "Framework :: Django :: 3.1",
+ "Framework :: Django :: 3.2",
+ "Framework :: Django :: 4.0",
],
keywords="api graphql protocol rest relay graphene",
packages=find_packages(exclude=["tests", "examples", "examples.*"]),
install_requires=[
- "six>=1.10.0",
"graphene>=2.1.7,<3",
"graphql-core>=2.1.0,<3",
- "Django>=1.11",
+ "Django>=2.2",
"singledispatch>=3.4.0.3",
"promise>=2.1",
"text-unidecode",
diff --git a/tox.ini b/tox.ini
index d2d3065..952ba68 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,24 +1,26 @@
[tox]
envlist =
- py{27,35,36,37,38}-django{111,20,21,22,master},
- py{36,37,38}-django{30,31},
+ py{36,37,38,39}-django22,
+ py{36,37,38,39}-django{30,31},
+ py{36,37,38,39,310}-django32,
+ py{38,39,310}-django{40,master},
black,flake8
[gh-actions]
python =
- 2.7: py27
3.6: py36
3.7: py37
3.8: py38
+ 3.9: py39
+ 3.10: py310
[gh-actions:env]
DJANGO =
- 1.11: django111
- 2.0: django20
- 2.1: django21
2.2: django22
3.0: django30
3.1: django31
+ 3.2: django32
+ 4.0: django40
master: djangomaster
[testenv]
@@ -29,24 +31,17 @@ setenv =
deps =
-e.[test]
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
- django30: Django>=3.0a1,<3.1
+ django30: Django>=3.0,<3.1
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
commands = {posargs:py.test --cov=graphene_django graphene_django examples}
-[testenv:black]
-basepython = python3.8
-deps = -e.[dev]
-commands =
- black --exclude "/migrations/" graphene_django examples setup.py --check
-
-[testenv:flake8]
-basepython = python3.8
-deps = -e.[dev]
+[testenv:pre-commit]
+basepython = python3.10
+skip_install = true
+deps = pre-commit
commands =
- flake8 graphene_django examples setup.py
+ pre-commit run --all-files --show-diff-on-failure