mirror of
https://github.com/graphql-python/graphene-django.git
synced 2025-07-07 13:54:21 +03:00
Merge branch 'v2' into master
This commit is contained in:
commit
abdd30c66e
8
.github/workflows/deploy.yml
vendored
8
.github/workflows/deploy.yml
vendored
|
@ -10,11 +10,11 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v3
|
||||||
- name: Set up Python 3.8
|
- name: Set up Python 3.10
|
||||||
uses: actions/setup-python@v1
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: 3.8
|
python-version: "3.10"
|
||||||
- name: Build wheel and source tarball
|
- name: Build wheel and source tarball
|
||||||
run: |
|
run: |
|
||||||
pip install wheel
|
pip install wheel
|
||||||
|
|
12
.github/workflows/lint.yml
vendored
12
.github/workflows/lint.yml
vendored
|
@ -7,16 +7,16 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v3
|
||||||
- name: Set up Python 3.8
|
- name: Set up Python 3.10
|
||||||
uses: actions/setup-python@v1
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: 3.8
|
python-version: "3.10"
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install tox
|
pip install tox
|
||||||
- name: Run lint 💅
|
- name: Run pre-commit 💅
|
||||||
run: tox
|
run: tox
|
||||||
env:
|
env:
|
||||||
TOXENV: flake8
|
TOXENV: pre-commit
|
||||||
|
|
31
.github/workflows/tests.yml
vendored
31
.github/workflows/tests.yml
vendored
|
@ -8,16 +8,33 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
max-parallel: 4
|
max-parallel: 4
|
||||||
matrix:
|
matrix:
|
||||||
django: ["1.11", "2.2", "3.0", "3.1"]
|
django: ["2.2", "3.0", "3.1", "3.2", "4.0"]
|
||||||
python-version: ["3.6", "3.7", "3.8"]
|
python-version: ["3.8", "3.9"]
|
||||||
include:
|
include:
|
||||||
- django: "1.11"
|
- django: "2.2"
|
||||||
python-version: "2.7"
|
python-version: "3.6"
|
||||||
|
- django: "2.2"
|
||||||
|
python-version: "3.7"
|
||||||
|
- django: "3.0"
|
||||||
|
python-version: "3.6"
|
||||||
|
- django: "3.0"
|
||||||
|
python-version: "3.7"
|
||||||
|
- django: "3.1"
|
||||||
|
python-version: "3.6"
|
||||||
|
- django: "3.1"
|
||||||
|
python-version: "3.7"
|
||||||
|
- django: "3.2"
|
||||||
|
python-version: "3.6"
|
||||||
|
- django: "3.2"
|
||||||
|
python-version: "3.7"
|
||||||
|
- django: "3.2"
|
||||||
|
python-version: "3.10"
|
||||||
|
- django: "4.0"
|
||||||
|
python-version: "3.10"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v3
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v1
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|
14
.pre-commit-config.yaml
Normal file
14
.pre-commit-config.yaml
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
default_language_version:
|
||||||
|
python: python3.10
|
||||||
|
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/PyCQA/flake8
|
||||||
|
rev: 5.0.4
|
||||||
|
hooks:
|
||||||
|
- id: flake8
|
||||||
|
additional_dependencies: [flake8-bugbear==22.7.1]
|
||||||
|
|
||||||
|
- repo: https://github.com/psf/black
|
||||||
|
rev: 22.6.0
|
||||||
|
hooks:
|
||||||
|
- id: black
|
6
Makefile
6
Makefile
|
@ -14,11 +14,7 @@ test: tests # Alias test -> tests
|
||||||
|
|
||||||
.PHONY: format
|
.PHONY: format
|
||||||
format:
|
format:
|
||||||
black --exclude "/migrations/" graphene_django examples setup.py
|
pre-commit run --all-files
|
||||||
|
|
||||||
.PHONY: lint
|
|
||||||
lint:
|
|
||||||
flake8 graphene_django examples
|
|
||||||
|
|
||||||
.PHONY: docs ## Generate docs
|
.PHONY: docs ## Generate docs
|
||||||
docs: dev-setup
|
docs: dev-setup
|
||||||
|
|
16
docs/conf.py
16
docs/conf.py
|
@ -60,18 +60,18 @@ source_suffix = ".rst"
|
||||||
master_doc = "index"
|
master_doc = "index"
|
||||||
|
|
||||||
# General information about the project.
|
# General information about the project.
|
||||||
project = u"Graphene Django"
|
project = "Graphene Django"
|
||||||
copyright = u"Graphene 2017"
|
copyright = "Graphene 2017"
|
||||||
author = u"Syrus Akbary"
|
author = "Syrus Akbary"
|
||||||
|
|
||||||
# The version info for the project you're documenting, acts as replacement for
|
# The version info for the project you're documenting, acts as replacement for
|
||||||
# |version| and |release|, also used in various other places throughout the
|
# |version| and |release|, also used in various other places throughout the
|
||||||
# built documents.
|
# built documents.
|
||||||
#
|
#
|
||||||
# The short X.Y version.
|
# The short X.Y version.
|
||||||
version = u"1.0"
|
version = "1.0"
|
||||||
# The full version, including alpha/beta/rc tags.
|
# The full version, including alpha/beta/rc tags.
|
||||||
release = u"1.0.dev"
|
release = "1.0.dev"
|
||||||
|
|
||||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||||
# for a list of supported languages.
|
# for a list of supported languages.
|
||||||
|
@ -276,7 +276,7 @@ latex_elements = {
|
||||||
# (source start file, target name, title,
|
# (source start file, target name, title,
|
||||||
# author, documentclass [howto, manual, or own class]).
|
# author, documentclass [howto, manual, or own class]).
|
||||||
latex_documents = [
|
latex_documents = [
|
||||||
(master_doc, "Graphene.tex", u"Graphene Documentation", u"Syrus Akbary", "manual")
|
(master_doc, "Graphene.tex", "Graphene Documentation", "Syrus Akbary", "manual")
|
||||||
]
|
]
|
||||||
|
|
||||||
# The name of an image file (relative to this directory) to place at the top of
|
# The name of an image file (relative to this directory) to place at the top of
|
||||||
|
@ -317,7 +317,7 @@ latex_documents = [
|
||||||
# One entry per manual page. List of tuples
|
# One entry per manual page. List of tuples
|
||||||
# (source start file, name, description, authors, manual section).
|
# (source start file, name, description, authors, manual section).
|
||||||
man_pages = [
|
man_pages = [
|
||||||
(master_doc, "graphene_django", u"Graphene Django Documentation", [author], 1)
|
(master_doc, "graphene_django", "Graphene Django Documentation", [author], 1)
|
||||||
]
|
]
|
||||||
|
|
||||||
# If true, show URL addresses after external links.
|
# If true, show URL addresses after external links.
|
||||||
|
@ -334,7 +334,7 @@ texinfo_documents = [
|
||||||
(
|
(
|
||||||
master_doc,
|
master_doc,
|
||||||
"Graphene-Django",
|
"Graphene-Django",
|
||||||
u"Graphene Django Documentation",
|
"Graphene Django Documentation",
|
||||||
author,
|
author,
|
||||||
"Graphene Django",
|
"Graphene Django",
|
||||||
"One line description of project.",
|
"One line description of project.",
|
||||||
|
|
|
@ -3,7 +3,7 @@ Django Debug Middleware
|
||||||
|
|
||||||
You can debug your GraphQL queries in a similar way to
|
You can debug your GraphQL queries in a similar way to
|
||||||
`django-debug-toolbar <https://django-debug-toolbar.readthedocs.org/>`__,
|
`django-debug-toolbar <https://django-debug-toolbar.readthedocs.org/>`__,
|
||||||
but outputing in the results in GraphQL response as fields, instead of
|
but outputting in the results in GraphQL response as fields, instead of
|
||||||
the graphical HTML interface.
|
the graphical HTML interface.
|
||||||
|
|
||||||
For that, you will need to add the plugin in your graphene schema.
|
For that, you will need to add the plugin in your graphene schema.
|
||||||
|
@ -43,7 +43,7 @@ And in your ``settings.py``:
|
||||||
Querying
|
Querying
|
||||||
--------
|
--------
|
||||||
|
|
||||||
You can query it for outputing all the sql transactions that happened in
|
You can query it for outputting all the sql transactions that happened in
|
||||||
the GraphQL request, like:
|
the GraphQL request, like:
|
||||||
|
|
||||||
.. code::
|
.. code::
|
||||||
|
|
|
@ -2,9 +2,9 @@ Filtering
|
||||||
=========
|
=========
|
||||||
|
|
||||||
Graphene-Django integrates with
|
Graphene-Django integrates with
|
||||||
`django-filter <https://django-filter.readthedocs.io/en/master/>`__ (2.x for
|
`django-filter <https://django-filter.readthedocs.io/en/main/>`__ (2.x for
|
||||||
Python 3 or 1.x for Python 2) to provide filtering of results. See the `usage
|
Python 3 or 1.x for Python 2) to provide filtering of results. See the `usage
|
||||||
documentation <https://django-filter.readthedocs.io/en/master/guide/usage.html#the-filter>`__
|
documentation <https://django-filter.readthedocs.io/en/main/guide/usage.html#the-filter>`__
|
||||||
for details on the format for ``filter_fields``.
|
for details on the format for ``filter_fields``.
|
||||||
|
|
||||||
This filtering is automatically available when implementing a ``relay.Node``.
|
This filtering is automatically available when implementing a ``relay.Node``.
|
||||||
|
@ -27,7 +27,7 @@ After installing ``django-filter`` you'll need to add the application in the ``s
|
||||||
]
|
]
|
||||||
|
|
||||||
Note: The techniques below are demoed in the `cookbook example
|
Note: The techniques below are demoed in the `cookbook example
|
||||||
app <https://github.com/graphql-python/graphene-django/tree/master/examples/cookbook>`__.
|
app <https://github.com/graphql-python/graphene-django/tree/v2/examples/cookbook>`__.
|
||||||
|
|
||||||
Filterable fields
|
Filterable fields
|
||||||
-----------------
|
-----------------
|
||||||
|
@ -35,7 +35,7 @@ Filterable fields
|
||||||
The ``filter_fields`` parameter is used to specify the fields which can
|
The ``filter_fields`` parameter is used to specify the fields which can
|
||||||
be filtered upon. The value specified here is passed directly to
|
be filtered upon. The value specified here is passed directly to
|
||||||
``django-filter``, so see the `filtering
|
``django-filter``, so see the `filtering
|
||||||
documentation <https://django-filter.readthedocs.io/en/master/guide/usage.html#the-filter>`__
|
documentation <https://django-filter.readthedocs.io/en/main/guide/usage.html#the-filter>`__
|
||||||
for full details on the range of options available.
|
for full details on the range of options available.
|
||||||
|
|
||||||
For example:
|
For example:
|
||||||
|
@ -163,7 +163,7 @@ in unison with the ``filter_fields`` parameter:
|
||||||
animal = relay.Node.Field(AnimalNode)
|
animal = relay.Node.Field(AnimalNode)
|
||||||
all_animals = DjangoFilterConnectionField(AnimalNode)
|
all_animals = DjangoFilterConnectionField(AnimalNode)
|
||||||
|
|
||||||
The context argument is passed on as the `request argument <http://django-filter.readthedocs.io/en/master/guide/usage.html#request-based-filtering>`__
|
The context argument is passed on as the `request argument <http://django-filter.readthedocs.io/en/main/guide/usage.html#request-based-filtering>`__
|
||||||
in a ``django_filters.FilterSet`` instance. You can use this to customize your
|
in a ``django_filters.FilterSet`` instance. You can use this to customize your
|
||||||
filters to be context-dependent. We could modify the ``AnimalFilter`` above to
|
filters to be context-dependent. We could modify the ``AnimalFilter`` above to
|
||||||
pre-filter animals owned by the authenticated user (set in ``context.user``).
|
pre-filter animals owned by the authenticated user (set in ``context.user``).
|
||||||
|
@ -228,3 +228,84 @@ with this set up, you can now order the users under group:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
PostgreSQL `ArrayField`
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
Graphene provides an easy to implement filters on `ArrayField` as they are not natively supported by django_filters:
|
||||||
|
|
||||||
|
.. code:: python
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django_filters import FilterSet, OrderingFilter
|
||||||
|
from graphene_django.filter import ArrayFilter
|
||||||
|
|
||||||
|
class Event(models.Model):
|
||||||
|
name = models.CharField(max_length=50)
|
||||||
|
tags = ArrayField(models.CharField(max_length=50))
|
||||||
|
|
||||||
|
class EventFilterSet(FilterSet):
|
||||||
|
class Meta:
|
||||||
|
model = Event
|
||||||
|
fields = {
|
||||||
|
"name": ["exact", "contains"],
|
||||||
|
}
|
||||||
|
|
||||||
|
tags__contains = ArrayFilter(field_name="tags", lookup_expr="contains")
|
||||||
|
tags__overlap = ArrayFilter(field_name="tags", lookup_expr="overlap")
|
||||||
|
tags = ArrayFilter(field_name="tags", lookup_expr="exact")
|
||||||
|
|
||||||
|
class EventType(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Event
|
||||||
|
interfaces = (Node,)
|
||||||
|
filterset_class = EventFilterSet
|
||||||
|
|
||||||
|
with this set up, you can now filter events by tags:
|
||||||
|
|
||||||
|
.. code::
|
||||||
|
|
||||||
|
query {
|
||||||
|
events(tags_Overlap: ["concert", "festival"]) {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
`TypedFilter`
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Sometimes the automatic detection of the filter input type is not satisfactory for what you are trying to achieve.
|
||||||
|
You can then explicitly specify the input type you want for your filter by using a `TypedFilter`:
|
||||||
|
|
||||||
|
.. code:: python
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django_filters import FilterSet, OrderingFilter
|
||||||
|
import graphene
|
||||||
|
from graphene_django.filter import TypedFilter
|
||||||
|
|
||||||
|
class Event(models.Model):
|
||||||
|
name = models.CharField(max_length=50)
|
||||||
|
|
||||||
|
class EventFilterSet(FilterSet):
|
||||||
|
class Meta:
|
||||||
|
model = Event
|
||||||
|
fields = {
|
||||||
|
"name": ["exact", "contains"],
|
||||||
|
}
|
||||||
|
|
||||||
|
only_first = TypedFilter(input_type=graphene.Boolean, method="only_first_filter")
|
||||||
|
|
||||||
|
def only_first_filter(self, queryset, _name, value):
|
||||||
|
if value:
|
||||||
|
return queryset[:1]
|
||||||
|
else:
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
class EventType(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Event
|
||||||
|
interfaces = (Node,)
|
||||||
|
filterset_class = EventFilterSet
|
||||||
|
|
|
@ -287,7 +287,7 @@ Where "foo" is the name of the field declared in the ``Query`` object.
|
||||||
class Query(graphene.ObjectType):
|
class Query(graphene.ObjectType):
|
||||||
foo = graphene.List(QuestionType)
|
foo = graphene.List(QuestionType)
|
||||||
|
|
||||||
def resolve_foo(root, info):
|
def resolve_foo(root, info, **kwargs):
|
||||||
id = kwargs.get("id")
|
id = kwargs.get("id")
|
||||||
return Question.objects.get(id)
|
return Question.objects.get(id)
|
||||||
|
|
||||||
|
|
|
@ -1,58 +1,55 @@
|
||||||
import graphene
|
import graphene
|
||||||
|
|
||||||
from graphene_django.types import DjangoObjectType
|
from graphene_django.types import DjangoObjectType
|
||||||
|
|
||||||
from cookbook.ingredients.models import Category, Ingredient
|
from cookbook.ingredients.models import Category, Ingredient
|
||||||
|
|
||||||
|
|
||||||
class CategoryType(DjangoObjectType):
|
class CategoryType(DjangoObjectType):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Category
|
model = Category
|
||||||
|
|
||||||
|
|
||||||
class IngredientType(DjangoObjectType):
|
class IngredientType(DjangoObjectType):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Ingredient
|
model = Ingredient
|
||||||
|
|
||||||
|
|
||||||
class Query(object):
|
class Query(object):
|
||||||
category = graphene.Field(CategoryType,
|
category = graphene.Field(CategoryType, id=graphene.Int(), name=graphene.String())
|
||||||
id=graphene.Int(),
|
all_categories = graphene.List(CategoryType)
|
||||||
name=graphene.String())
|
|
||||||
all_categories = graphene.List(CategoryType)
|
|
||||||
|
|
||||||
|
ingredient = graphene.Field(
|
||||||
|
IngredientType, id=graphene.Int(), name=graphene.String()
|
||||||
|
)
|
||||||
|
all_ingredients = graphene.List(IngredientType)
|
||||||
|
|
||||||
ingredient = graphene.Field(IngredientType,
|
def resolve_all_categories(self, info, **kwargs):
|
||||||
id=graphene.Int(),
|
return Category.objects.all()
|
||||||
name=graphene.String())
|
|
||||||
all_ingredients = graphene.List(IngredientType)
|
|
||||||
|
|
||||||
def resolve_all_categories(self, info, **kwargs):
|
def resolve_all_ingredients(self, info, **kwargs):
|
||||||
return Category.objects.all()
|
return Ingredient.objects.all()
|
||||||
|
|
||||||
def resolve_all_ingredients(self, info, **kwargs):
|
def resolve_category(self, info, **kwargs):
|
||||||
return Ingredient.objects.all()
|
id = kwargs.get("id")
|
||||||
|
name = kwargs.get("name")
|
||||||
|
|
||||||
def resolve_category(self, info, **kwargs):
|
if id is not None:
|
||||||
id = kwargs.get('id')
|
return Category.objects.get(pk=id)
|
||||||
name = kwargs.get('name')
|
|
||||||
|
|
||||||
if id is not None:
|
if name is not None:
|
||||||
return Category.objects.get(pk=id)
|
return Category.objects.get(name=name)
|
||||||
|
|
||||||
if name is not None:
|
return None
|
||||||
return Category.objects.get(name=name)
|
|
||||||
|
|
||||||
return None
|
def resolve_ingredient(self, info, **kwargs):
|
||||||
|
id = kwargs.get("id")
|
||||||
|
name = kwargs.get("name")
|
||||||
|
|
||||||
def resolve_ingredient(self, info, **kwargs):
|
if id is not None:
|
||||||
id = kwargs.get('id')
|
return Ingredient.objects.get(pk=id)
|
||||||
name = kwargs.get('name')
|
|
||||||
|
|
||||||
if id is not None:
|
if name is not None:
|
||||||
return Ingredient.objects.get(pk=id)
|
return Ingredient.objects.get(name=name)
|
||||||
|
|
||||||
if name is not None:
|
return None
|
||||||
return Ingredient.objects.get(name=name)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
|
@ -10,24 +10,46 @@ class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = []
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Category',
|
name="Category",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('name', models.CharField(max_length=100)),
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=100)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Ingredient',
|
name="Ingredient",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('name', models.CharField(max_length=100)),
|
"id",
|
||||||
('notes', models.TextField()),
|
models.AutoField(
|
||||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ingredients', to='ingredients.Category')),
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=100)),
|
||||||
|
("notes", models.TextField()),
|
||||||
|
(
|
||||||
|
"category",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="ingredients",
|
||||||
|
to="ingredients.Category",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -8,13 +8,13 @@ from django.db import migrations, models
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('ingredients', '0001_initial'),
|
("ingredients", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='ingredient',
|
model_name="ingredient",
|
||||||
name='notes',
|
name="notes",
|
||||||
field=models.TextField(blank=True, null=True),
|
field=models.TextField(blank=True, null=True),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -6,12 +6,12 @@ from django.db import migrations
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('ingredients', '0002_auto_20161104_0050'),
|
("ingredients", "0002_auto_20161104_0050"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterModelOptions(
|
migrations.AlterModelOptions(
|
||||||
name='category',
|
name="category",
|
||||||
options={'verbose_name_plural': 'Categories'},
|
options={"verbose_name_plural": "Categories"},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -11,26 +11,62 @@ class Migration(migrations.Migration):
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('ingredients', '0001_initial'),
|
("ingredients", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Recipe',
|
name="Recipe",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('title', models.CharField(max_length=100)),
|
"id",
|
||||||
('instructions', models.TextField()),
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("title", models.CharField(max_length=100)),
|
||||||
|
("instructions", models.TextField()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='RecipeIngredient',
|
name="RecipeIngredient",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('amount', models.FloatField()),
|
"id",
|
||||||
('unit', models.CharField(choices=[('kg', 'Kilograms'), ('l', 'Litres'), ('', 'Units')], max_length=20)),
|
models.AutoField(
|
||||||
('ingredient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='used_by', to='ingredients.Ingredient')),
|
auto_created=True,
|
||||||
('recipes', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='amounts', to='recipes.Recipe')),
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("amount", models.FloatField()),
|
||||||
|
(
|
||||||
|
"unit",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("kg", "Kilograms"), ("l", "Litres"), ("", "Units")],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ingredient",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="used_by",
|
||||||
|
to="ingredients.Ingredient",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"recipes",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="amounts",
|
||||||
|
to="recipes.Recipe",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -8,18 +8,26 @@ from django.db import migrations, models
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('recipes', '0001_initial'),
|
("recipes", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.RenameField(
|
migrations.RenameField(
|
||||||
model_name='recipeingredient',
|
model_name="recipeingredient",
|
||||||
old_name='recipes',
|
old_name="recipes",
|
||||||
new_name='recipe',
|
new_name="recipe",
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='recipeingredient',
|
model_name="recipeingredient",
|
||||||
name='unit',
|
name="unit",
|
||||||
field=models.CharField(choices=[(b'unit', b'Units'), (b'kg', b'Kilograms'), (b'l', b'Litres'), (b'st', b'Shots')], max_length=20),
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
(b"unit", b"Units"),
|
||||||
|
(b"kg", b"Kilograms"),
|
||||||
|
(b"l", b"Litres"),
|
||||||
|
(b"st", b"Shots"),
|
||||||
|
],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -6,13 +6,21 @@ from django.db import migrations, models
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('recipes', '0002_auto_20161104_0106'),
|
("recipes", "0002_auto_20161104_0106"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='recipeingredient',
|
model_name="recipeingredient",
|
||||||
name='unit',
|
name="unit",
|
||||||
field=models.CharField(choices=[('unit', 'Units'), ('kg', 'Kilograms'), ('l', 'Litres'), ('st', 'Shots')], max_length=20),
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("unit", "Units"),
|
||||||
|
("kg", "Kilograms"),
|
||||||
|
("l", "Litres"),
|
||||||
|
("st", "Shots"),
|
||||||
|
],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -10,24 +10,46 @@ class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = []
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Category',
|
name="Category",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('name', models.CharField(max_length=100)),
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=100)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Ingredient',
|
name="Ingredient",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('name', models.CharField(max_length=100)),
|
"id",
|
||||||
('notes', models.TextField()),
|
models.AutoField(
|
||||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ingredients', to='ingredients.Category')),
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=100)),
|
||||||
|
("notes", models.TextField()),
|
||||||
|
(
|
||||||
|
"category",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="ingredients",
|
||||||
|
to="ingredients.Category",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -8,13 +8,13 @@ from django.db import migrations, models
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('ingredients', '0001_initial'),
|
("ingredients", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='ingredient',
|
model_name="ingredient",
|
||||||
name='notes',
|
name="notes",
|
||||||
field=models.TextField(blank=True, null=True),
|
field=models.TextField(blank=True, null=True),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -11,26 +11,62 @@ class Migration(migrations.Migration):
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('ingredients', '0001_initial'),
|
("ingredients", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Recipe',
|
name="Recipe",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('title', models.CharField(max_length=100)),
|
"id",
|
||||||
('instructions', models.TextField()),
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("title", models.CharField(max_length=100)),
|
||||||
|
("instructions", models.TextField()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='RecipeIngredient',
|
name="RecipeIngredient",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('amount', models.FloatField()),
|
"id",
|
||||||
('unit', models.CharField(choices=[('kg', 'Kilograms'), ('l', 'Litres'), ('', 'Units')], max_length=20)),
|
models.AutoField(
|
||||||
('ingredient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='used_by', to='ingredients.Ingredient')),
|
auto_created=True,
|
||||||
('recipes', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='amounts', to='recipes.Recipe')),
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("amount", models.FloatField()),
|
||||||
|
(
|
||||||
|
"unit",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("kg", "Kilograms"), ("l", "Litres"), ("", "Units")],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ingredient",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="used_by",
|
||||||
|
to="ingredients.Ingredient",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"recipes",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="amounts",
|
||||||
|
to="recipes.Recipe",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -8,18 +8,26 @@ from django.db import migrations, models
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('recipes', '0001_initial'),
|
("recipes", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.RenameField(
|
migrations.RenameField(
|
||||||
model_name='recipeingredient',
|
model_name="recipeingredient",
|
||||||
old_name='recipes',
|
old_name="recipes",
|
||||||
new_name='recipe',
|
new_name="recipe",
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='recipeingredient',
|
model_name="recipeingredient",
|
||||||
name='unit',
|
name="unit",
|
||||||
field=models.CharField(choices=[(b'unit', b'Units'), (b'kg', b'Kilograms'), (b'l', b'Litres'), (b'st', b'Shots')], max_length=20),
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
(b"unit", b"Units"),
|
||||||
|
(b"kg", b"Kilograms"),
|
||||||
|
(b"l", b"Litres"),
|
||||||
|
(b"st", b"Shots"),
|
||||||
|
],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
from .fields import DjangoConnectionField, DjangoListField
|
from .fields import DjangoConnectionField, DjangoListField
|
||||||
from .types import DjangoObjectType
|
from .types import DjangoObjectType
|
||||||
|
|
||||||
__version__ = "2.14.0"
|
__version__ = "2.15.0"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"__version__",
|
"__version__",
|
||||||
|
|
|
@ -6,13 +6,16 @@ try:
|
||||||
# Postgres fields are only available in Django with psycopg2 installed
|
# Postgres fields are only available in Django with psycopg2 installed
|
||||||
# and we cannot have psycopg2 on PyPy
|
# and we cannot have psycopg2 on PyPy
|
||||||
from django.contrib.postgres.fields import (
|
from django.contrib.postgres.fields import (
|
||||||
|
IntegerRangeField,
|
||||||
ArrayField,
|
ArrayField,
|
||||||
HStoreField,
|
HStoreField,
|
||||||
JSONField as PGJSONField,
|
JSONField as PGJSONField,
|
||||||
RangeField,
|
RangeField,
|
||||||
)
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
ArrayField, HStoreField, PGJSONField, RangeField = (MissingType,) * 4
|
IntegerRangeField, ArrayField, HStoreField, PGJSONField, RangeField = (
|
||||||
|
MissingType,
|
||||||
|
) * 5
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# JSONField is only available from Django 3.1
|
# JSONField is only available from Django 3.1
|
||||||
|
|
|
@ -18,6 +18,7 @@ from graphene import (
|
||||||
DateTime,
|
DateTime,
|
||||||
Date,
|
Date,
|
||||||
Time,
|
Time,
|
||||||
|
Decimal,
|
||||||
)
|
)
|
||||||
from graphene.types.json import JSONString
|
from graphene.types.json import JSONString
|
||||||
from graphene.utils.str_converters import to_camel_case
|
from graphene.utils.str_converters import to_camel_case
|
||||||
|
@ -68,7 +69,11 @@ def convert_choices_to_named_enum_with_descriptions(name, choices):
|
||||||
def description(self):
|
def description(self):
|
||||||
return named_choices_descriptions[self.name]
|
return named_choices_descriptions[self.name]
|
||||||
|
|
||||||
return Enum(name, list(named_choices), type=EnumWithDescriptionsType)
|
if named_choices == []:
|
||||||
|
# Python 2.7 doesn't handle enums with lists with zero entries, but works okay with empty sets
|
||||||
|
named_choices = set()
|
||||||
|
|
||||||
|
return Enum(name, named_choices, type=EnumWithDescriptionsType)
|
||||||
|
|
||||||
|
|
||||||
def generate_enum_name(django_model_meta, field):
|
def generate_enum_name(django_model_meta, field):
|
||||||
|
@ -160,6 +165,10 @@ def convert_field_to_boolean(field, registry=None):
|
||||||
|
|
||||||
|
|
||||||
@convert_django_field.register(models.DecimalField)
|
@convert_django_field.register(models.DecimalField)
|
||||||
|
def convert_field_to_decimal(field, registry=None):
|
||||||
|
return Decimal(description=field.help_text, required=not field.null)
|
||||||
|
|
||||||
|
|
||||||
@convert_django_field.register(models.FloatField)
|
@convert_django_field.register(models.FloatField)
|
||||||
@convert_django_field.register(models.DurationField)
|
@convert_django_field.register(models.DurationField)
|
||||||
def convert_field_to_float(field, registry=None):
|
def convert_field_to_float(field, registry=None):
|
||||||
|
|
|
@ -66,7 +66,10 @@ class DjangoListField(Field):
|
||||||
_type = _type.of_type
|
_type = _type.of_type
|
||||||
django_object_type = _type.of_type.of_type
|
django_object_type = _type.of_type.of_type
|
||||||
return partial(
|
return partial(
|
||||||
self.list_resolver, django_object_type, parent_resolver, self.get_manager(),
|
self.list_resolver,
|
||||||
|
django_object_type,
|
||||||
|
parent_resolver,
|
||||||
|
self.get_manager(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -9,10 +9,21 @@ if not DJANGO_FILTER_INSTALLED:
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
from .fields import DjangoFilterConnectionField
|
from .fields import DjangoFilterConnectionField
|
||||||
from .filterset import GlobalIDFilter, GlobalIDMultipleChoiceFilter
|
from .filters import (
|
||||||
|
ArrayFilter,
|
||||||
|
GlobalIDFilter,
|
||||||
|
GlobalIDMultipleChoiceFilter,
|
||||||
|
ListFilter,
|
||||||
|
RangeFilter,
|
||||||
|
TypedFilter,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DjangoFilterConnectionField",
|
"DjangoFilterConnectionField",
|
||||||
"GlobalIDFilter",
|
"GlobalIDFilter",
|
||||||
"GlobalIDMultipleChoiceFilter",
|
"GlobalIDMultipleChoiceFilter",
|
||||||
|
"ArrayFilter",
|
||||||
|
"ListFilter",
|
||||||
|
"RangeFilter",
|
||||||
|
"TypedFilter",
|
||||||
]
|
]
|
||||||
|
|
|
@ -43,8 +43,8 @@ class DjangoFilterConnectionField(DjangoConnectionField):
|
||||||
if self._extra_filter_meta:
|
if self._extra_filter_meta:
|
||||||
meta.update(self._extra_filter_meta)
|
meta.update(self._extra_filter_meta)
|
||||||
|
|
||||||
filterset_class = self._provided_filterset_class or (
|
filterset_class = (
|
||||||
self.node_type._meta.filterset_class
|
self._provided_filterset_class or self.node_type._meta.filterset_class
|
||||||
)
|
)
|
||||||
self._filterset_class = get_filterset_class(filterset_class, **meta)
|
self._filterset_class = get_filterset_class(filterset_class, **meta)
|
||||||
|
|
||||||
|
|
25
graphene_django/filter/filters/__init__.py
Normal file
25
graphene_django/filter/filters/__init__.py
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import warnings
|
||||||
|
from ...utils import DJANGO_FILTER_INSTALLED
|
||||||
|
|
||||||
|
if not DJANGO_FILTER_INSTALLED:
|
||||||
|
warnings.warn(
|
||||||
|
"Use of django filtering requires the django-filter package "
|
||||||
|
"be installed. You can do so using `pip install django-filter`",
|
||||||
|
ImportWarning,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
from .array_filter import ArrayFilter
|
||||||
|
from .global_id_filter import GlobalIDFilter, GlobalIDMultipleChoiceFilter
|
||||||
|
from .list_filter import ListFilter
|
||||||
|
from .range_filter import RangeFilter
|
||||||
|
from .typed_filter import TypedFilter
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DjangoFilterConnectionField",
|
||||||
|
"GlobalIDFilter",
|
||||||
|
"GlobalIDMultipleChoiceFilter",
|
||||||
|
"ArrayFilter",
|
||||||
|
"ListFilter",
|
||||||
|
"RangeFilter",
|
||||||
|
"TypedFilter",
|
||||||
|
]
|
27
graphene_django/filter/filters/array_filter.py
Normal file
27
graphene_django/filter/filters/array_filter.py
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
from django_filters.constants import EMPTY_VALUES
|
||||||
|
|
||||||
|
from .typed_filter import TypedFilter
|
||||||
|
|
||||||
|
|
||||||
|
class ArrayFilter(TypedFilter):
|
||||||
|
"""
|
||||||
|
Filter made for PostgreSQL ArrayField.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def filter(self, qs, value):
|
||||||
|
"""
|
||||||
|
Override the default filter class to check first whether the list is
|
||||||
|
empty or not.
|
||||||
|
This needs to be done as in this case we expect to get the filter applied with
|
||||||
|
an empty list since it's a valid value but django_filter consider an empty list
|
||||||
|
to be an empty input value (see `EMPTY_VALUES`) meaning that
|
||||||
|
the filter does not need to be applied (hence returning the original
|
||||||
|
queryset).
|
||||||
|
"""
|
||||||
|
if value in EMPTY_VALUES and value != []:
|
||||||
|
return qs
|
||||||
|
if self.distinct:
|
||||||
|
qs = qs.distinct()
|
||||||
|
lookup = "%s__%s" % (self.field_name, self.lookup_expr)
|
||||||
|
qs = self.get_method(qs)(**{lookup: value})
|
||||||
|
return qs
|
28
graphene_django/filter/filters/global_id_filter.py
Normal file
28
graphene_django/filter/filters/global_id_filter.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
from django_filters import Filter, MultipleChoiceFilter
|
||||||
|
|
||||||
|
from graphql_relay.node.node import from_global_id
|
||||||
|
|
||||||
|
from ...forms import GlobalIDFormField, GlobalIDMultipleChoiceField
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalIDFilter(Filter):
|
||||||
|
"""
|
||||||
|
Filter for Relay global ID.
|
||||||
|
"""
|
||||||
|
|
||||||
|
field_class = GlobalIDFormField
|
||||||
|
|
||||||
|
def filter(self, qs, value):
|
||||||
|
"""Convert the filter value to a primary key before filtering"""
|
||||||
|
_id = None
|
||||||
|
if value is not None:
|
||||||
|
_, _id = from_global_id(value)
|
||||||
|
return super(GlobalIDFilter, self).filter(qs, _id)
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalIDMultipleChoiceFilter(MultipleChoiceFilter):
|
||||||
|
field_class = GlobalIDMultipleChoiceField
|
||||||
|
|
||||||
|
def filter(self, qs, value):
|
||||||
|
gids = [from_global_id(v)[1] for v in value]
|
||||||
|
return super(GlobalIDMultipleChoiceFilter, self).filter(qs, gids)
|
26
graphene_django/filter/filters/list_filter.py
Normal file
26
graphene_django/filter/filters/list_filter.py
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
from .typed_filter import TypedFilter
|
||||||
|
|
||||||
|
|
||||||
|
class ListFilter(TypedFilter):
|
||||||
|
"""
|
||||||
|
Filter that takes a list of value as input.
|
||||||
|
It is for example used for `__in` filters.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def filter(self, qs, value):
|
||||||
|
"""
|
||||||
|
Override the default filter class to check first whether the list is
|
||||||
|
empty or not.
|
||||||
|
This needs to be done as in this case we expect to get an empty output
|
||||||
|
(if not an exclude filter) but django_filter consider an empty list
|
||||||
|
to be an empty input value (see `EMPTY_VALUES`) meaning that
|
||||||
|
the filter does not need to be applied (hence returning the original
|
||||||
|
queryset).
|
||||||
|
"""
|
||||||
|
if value is not None and len(value) == 0:
|
||||||
|
if self.exclude:
|
||||||
|
return qs
|
||||||
|
else:
|
||||||
|
return qs.none()
|
||||||
|
else:
|
||||||
|
return super(ListFilter, self).filter(qs, value)
|
24
graphene_django/filter/filters/range_filter.py
Normal file
24
graphene_django/filter/filters/range_filter.py
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.forms import Field
|
||||||
|
|
||||||
|
from .typed_filter import TypedFilter
|
||||||
|
|
||||||
|
|
||||||
|
def validate_range(value):
|
||||||
|
"""
|
||||||
|
Validator for range filter input: the list of value must be of length 2.
|
||||||
|
Note that validators are only run if the value is not empty.
|
||||||
|
"""
|
||||||
|
if len(value) != 2:
|
||||||
|
raise ValidationError(
|
||||||
|
"Invalid range specified: it needs to contain 2 values.", code="invalid"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RangeField(Field):
|
||||||
|
default_validators = [validate_range]
|
||||||
|
empty_values = [None]
|
||||||
|
|
||||||
|
|
||||||
|
class RangeFilter(TypedFilter):
|
||||||
|
field_class = RangeField
|
27
graphene_django/filter/filters/typed_filter.py
Normal file
27
graphene_django/filter/filters/typed_filter.py
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
from django_filters import Filter
|
||||||
|
|
||||||
|
from graphene.types.utils import get_type
|
||||||
|
|
||||||
|
|
||||||
|
class TypedFilter(Filter):
|
||||||
|
"""
|
||||||
|
Filter class for which the input GraphQL type can explicitly be provided.
|
||||||
|
If it is not provided, when building the schema, it will try to guess
|
||||||
|
it from the field.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, input_type=None, *args, **kwargs):
|
||||||
|
self._input_type = input_type
|
||||||
|
super(TypedFilter, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def input_type(self):
|
||||||
|
input_type = get_type(self._input_type)
|
||||||
|
if input_type is not None:
|
||||||
|
if not callable(getattr(input_type, "get_type", None)):
|
||||||
|
raise ValueError(
|
||||||
|
"Wrong `input_type` for {}: it only accepts graphene types, got {}".format(
|
||||||
|
self.__class__.__name__, input_type
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return input_type
|
|
@ -1,32 +1,11 @@
|
||||||
import itertools
|
import itertools
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django_filters import Filter, MultipleChoiceFilter, VERSION
|
from django_filters import VERSION
|
||||||
from django_filters.filterset import BaseFilterSet, FilterSet
|
from django_filters.filterset import BaseFilterSet, FilterSet
|
||||||
from django_filters.filterset import FILTER_FOR_DBFIELD_DEFAULTS
|
from django_filters.filterset import FILTER_FOR_DBFIELD_DEFAULTS
|
||||||
|
|
||||||
from graphql_relay.node.node import from_global_id
|
from .filters import GlobalIDFilter, GlobalIDMultipleChoiceFilter
|
||||||
|
|
||||||
from ..forms import GlobalIDFormField, GlobalIDMultipleChoiceField
|
|
||||||
|
|
||||||
|
|
||||||
class GlobalIDFilter(Filter):
|
|
||||||
field_class = GlobalIDFormField
|
|
||||||
|
|
||||||
def filter(self, qs, value):
|
|
||||||
""" Convert the filter value to a primary key before filtering """
|
|
||||||
_id = None
|
|
||||||
if value is not None:
|
|
||||||
_, _id = from_global_id(value)
|
|
||||||
return super(GlobalIDFilter, self).filter(qs, _id)
|
|
||||||
|
|
||||||
|
|
||||||
class GlobalIDMultipleChoiceFilter(MultipleChoiceFilter):
|
|
||||||
field_class = GlobalIDMultipleChoiceField
|
|
||||||
|
|
||||||
def filter(self, qs, value):
|
|
||||||
gids = [from_global_id(v)[1] for v in value]
|
|
||||||
return super(GlobalIDMultipleChoiceFilter, self).filter(qs, gids)
|
|
||||||
|
|
||||||
|
|
||||||
GRAPHENE_FILTER_SET_OVERRIDES = {
|
GRAPHENE_FILTER_SET_OVERRIDES = {
|
||||||
|
@ -40,8 +19,8 @@ GRAPHENE_FILTER_SET_OVERRIDES = {
|
||||||
|
|
||||||
|
|
||||||
class GrapheneFilterSetMixin(BaseFilterSet):
|
class GrapheneFilterSetMixin(BaseFilterSet):
|
||||||
""" A django_filters.filterset.BaseFilterSet with default filter overrides
|
"""A django_filters.filterset.BaseFilterSet with default filter overrides
|
||||||
to handle global IDs """
|
to handle global IDs"""
|
||||||
|
|
||||||
FILTER_DEFAULTS = dict(
|
FILTER_DEFAULTS = dict(
|
||||||
itertools.chain(
|
itertools.chain(
|
||||||
|
@ -81,8 +60,7 @@ if VERSION[0] < 2:
|
||||||
|
|
||||||
|
|
||||||
def setup_filterset(filterset_class):
|
def setup_filterset(filterset_class):
|
||||||
""" Wrap a provided filterset in Graphene-specific functionality
|
"""Wrap a provided filterset in Graphene-specific functionality"""
|
||||||
"""
|
|
||||||
return type(
|
return type(
|
||||||
"Graphene{}".format(filterset_class.__name__),
|
"Graphene{}".format(filterset_class.__name__),
|
||||||
(filterset_class, GrapheneFilterSetMixin),
|
(filterset_class, GrapheneFilterSetMixin),
|
||||||
|
@ -91,8 +69,7 @@ def setup_filterset(filterset_class):
|
||||||
|
|
||||||
|
|
||||||
def custom_filterset_factory(model, filterset_base_class=FilterSet, **meta):
|
def custom_filterset_factory(model, filterset_base_class=FilterSet, **meta):
|
||||||
""" Create a filterset for the given model using the provided meta data
|
"""Create a filterset for the given model using the provided meta data"""
|
||||||
"""
|
|
||||||
meta.update({"model": model})
|
meta.update({"model": model})
|
||||||
meta_class = type(str("Meta"), (object,), meta)
|
meta_class = type(str("Meta"), (object,), meta)
|
||||||
filterset = type(
|
filterset = type(
|
||||||
|
|
166
graphene_django/filter/tests/conftest.py
Normal file
166
graphene_django/filter/tests/conftest.py
Normal file
|
@ -0,0 +1,166 @@
|
||||||
|
from mock import MagicMock
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models.query import QuerySet
|
||||||
|
from django_filters import filters
|
||||||
|
from django_filters import FilterSet
|
||||||
|
import graphene
|
||||||
|
from graphene.relay import Node
|
||||||
|
from graphene_django import DjangoObjectType
|
||||||
|
from graphene_django.utils import DJANGO_FILTER_INSTALLED
|
||||||
|
from graphene_django.filter import ArrayFilter, ListFilter
|
||||||
|
|
||||||
|
from ...compat import ArrayField
|
||||||
|
|
||||||
|
pytestmark = []
|
||||||
|
|
||||||
|
if DJANGO_FILTER_INSTALLED:
|
||||||
|
from graphene_django.filter import DjangoFilterConnectionField
|
||||||
|
else:
|
||||||
|
pytestmark.append(
|
||||||
|
pytest.mark.skipif(
|
||||||
|
True, reason="django_filters not installed or not compatible"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
STORE = {"events": []}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def Event():
|
||||||
|
class Event(models.Model):
|
||||||
|
name = models.CharField(max_length=50)
|
||||||
|
tags = ArrayField(models.CharField(max_length=50))
|
||||||
|
tag_ids = ArrayField(models.IntegerField())
|
||||||
|
random_field = ArrayField(models.BooleanField())
|
||||||
|
|
||||||
|
return Event
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def EventFilterSet(Event):
|
||||||
|
class EventFilterSet(FilterSet):
|
||||||
|
class Meta:
|
||||||
|
model = Event
|
||||||
|
fields = {
|
||||||
|
"name": ["exact", "contains"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Those are actually usable with our Query fixture bellow
|
||||||
|
tags__contains = ArrayFilter(field_name="tags", lookup_expr="contains")
|
||||||
|
tags__overlap = ArrayFilter(field_name="tags", lookup_expr="overlap")
|
||||||
|
tags = ArrayFilter(field_name="tags", lookup_expr="exact")
|
||||||
|
|
||||||
|
# Those are actually not usable and only to check type declarations
|
||||||
|
tags_ids__contains = ArrayFilter(field_name="tag_ids", lookup_expr="contains")
|
||||||
|
tags_ids__overlap = ArrayFilter(field_name="tag_ids", lookup_expr="overlap")
|
||||||
|
tags_ids = ArrayFilter(field_name="tag_ids", lookup_expr="exact")
|
||||||
|
random_field__contains = ArrayFilter(
|
||||||
|
field_name="random_field", lookup_expr="contains"
|
||||||
|
)
|
||||||
|
random_field__overlap = ArrayFilter(
|
||||||
|
field_name="random_field", lookup_expr="overlap"
|
||||||
|
)
|
||||||
|
random_field = ArrayFilter(field_name="random_field", lookup_expr="exact")
|
||||||
|
|
||||||
|
return EventFilterSet
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def EventType(Event, EventFilterSet):
|
||||||
|
class EventType(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Event
|
||||||
|
interfaces = (Node,)
|
||||||
|
filterset_class = EventFilterSet
|
||||||
|
|
||||||
|
return EventType
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def Query(Event, EventType):
|
||||||
|
"""
|
||||||
|
Note that we have to use a custom resolver to replicate the arrayfield filter behavior as
|
||||||
|
we are running unit tests in sqlite which does not have ArrayFields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Query(graphene.ObjectType):
|
||||||
|
events = DjangoFilterConnectionField(EventType)
|
||||||
|
|
||||||
|
def resolve_events(self, info, **kwargs):
|
||||||
|
|
||||||
|
events = [
|
||||||
|
Event(
|
||||||
|
name="Live Show",
|
||||||
|
tags=["concert", "music", "rock"],
|
||||||
|
),
|
||||||
|
Event(
|
||||||
|
name="Musical",
|
||||||
|
tags=["movie", "music"],
|
||||||
|
),
|
||||||
|
Event(
|
||||||
|
name="Ballet",
|
||||||
|
tags=["concert", "dance"],
|
||||||
|
),
|
||||||
|
Event(
|
||||||
|
name="Speech",
|
||||||
|
tags=[],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
STORE["events"] = events
|
||||||
|
|
||||||
|
m_queryset = MagicMock(spec=QuerySet)
|
||||||
|
m_queryset.model = Event
|
||||||
|
|
||||||
|
def filter_events(**kwargs):
|
||||||
|
if "tags__contains" in kwargs:
|
||||||
|
STORE["events"] = list(
|
||||||
|
filter(
|
||||||
|
lambda e: set(kwargs["tags__contains"]).issubset(
|
||||||
|
set(e.tags)
|
||||||
|
),
|
||||||
|
STORE["events"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if "tags__overlap" in kwargs:
|
||||||
|
STORE["events"] = list(
|
||||||
|
filter(
|
||||||
|
lambda e: not set(kwargs["tags__overlap"]).isdisjoint(
|
||||||
|
set(e.tags)
|
||||||
|
),
|
||||||
|
STORE["events"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if "tags__exact" in kwargs:
|
||||||
|
STORE["events"] = list(
|
||||||
|
filter(
|
||||||
|
lambda e: set(kwargs["tags__exact"]) == set(e.tags),
|
||||||
|
STORE["events"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def mock_queryset_filter(*args, **kwargs):
|
||||||
|
filter_events(**kwargs)
|
||||||
|
return m_queryset
|
||||||
|
|
||||||
|
def mock_queryset_none(*args, **kwargs):
|
||||||
|
STORE["events"] = []
|
||||||
|
return m_queryset
|
||||||
|
|
||||||
|
def mock_queryset_count(*args, **kwargs):
|
||||||
|
return len(STORE["events"])
|
||||||
|
|
||||||
|
m_queryset.all.return_value = m_queryset
|
||||||
|
m_queryset.filter.side_effect = mock_queryset_filter
|
||||||
|
m_queryset.none.side_effect = mock_queryset_none
|
||||||
|
m_queryset.count.side_effect = mock_queryset_count
|
||||||
|
m_queryset.__getitem__.side_effect = lambda index: STORE[
|
||||||
|
"events"
|
||||||
|
].__getitem__(index)
|
||||||
|
|
||||||
|
return m_queryset
|
||||||
|
|
||||||
|
return Query
|
|
@ -10,7 +10,7 @@ class ArticleFilter(django_filters.FilterSet):
|
||||||
fields = {
|
fields = {
|
||||||
"headline": ["exact", "icontains"],
|
"headline": ["exact", "icontains"],
|
||||||
"pub_date": ["gt", "lt", "exact"],
|
"pub_date": ["gt", "lt", "exact"],
|
||||||
"reporter": ["exact"],
|
"reporter": ["exact", "in"],
|
||||||
}
|
}
|
||||||
|
|
||||||
order_by = OrderingFilter(fields=("pub_date",))
|
order_by = OrderingFilter(fields=("pub_date",))
|
||||||
|
|
|
@ -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"}},
|
||||||
|
]
|
107
graphene_django/filter/tests/test_array_field_exact_filter.py
Normal file
107
graphene_django/filter/tests/test_array_field_exact_filter.py
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from graphene import Schema
|
||||||
|
|
||||||
|
from ...compat import ArrayField, MissingType
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||||
|
def test_array_field_exact_no_match(Query):
|
||||||
|
"""
|
||||||
|
Test exact filter on a array field of string.
|
||||||
|
"""
|
||||||
|
|
||||||
|
schema = Schema(query=Query)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
events (tags: ["concert", "music"]) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["events"]["edges"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||||
|
def test_array_field_exact_match(Query):
|
||||||
|
"""
|
||||||
|
Test exact filter on a array field of string.
|
||||||
|
"""
|
||||||
|
|
||||||
|
schema = Schema(query=Query)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
events (tags: ["movie", "music"]) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["events"]["edges"] == [
|
||||||
|
{"node": {"name": "Musical"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||||
|
def test_array_field_exact_empty_list(Query):
|
||||||
|
"""
|
||||||
|
Test exact filter on a array field of string.
|
||||||
|
"""
|
||||||
|
|
||||||
|
schema = Schema(query=Query)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
events (tags: []) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["events"]["edges"] == [
|
||||||
|
{"node": {"name": "Speech"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_array_field_filter_schema_type(Query):
|
||||||
|
"""
|
||||||
|
Check that the type in the filter is an array field like on the object type.
|
||||||
|
"""
|
||||||
|
schema = Schema(query=Query)
|
||||||
|
schema_str = str(schema)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"""type EventType implements Node {
|
||||||
|
id: ID!
|
||||||
|
name: String!
|
||||||
|
tags: [String!]!
|
||||||
|
tagIds: [Int!]!
|
||||||
|
randomField: [Boolean!]!
|
||||||
|
}"""
|
||||||
|
in schema_str
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"""type Query {
|
||||||
|
events(offset: Int, before: String, after: String, first: Int, last: Int, name: String, name_Contains: String, tags_Contains: [String!], tags_Overlap: [String!], tags: [String!], tagsIds_Contains: [Int!], tagsIds_Overlap: [Int!], tagsIds: [Int!], randomField_Contains: [Boolean!], randomField_Overlap: [Boolean!], randomField: [Boolean!]): EventTypeConnection
|
||||||
|
}"""
|
||||||
|
in schema_str
|
||||||
|
)
|
|
@ -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"] == []
|
171
graphene_django/filter/tests/test_enum_filtering.py
Normal file
171
graphene_django/filter/tests/test_enum_filtering.py
Normal file
|
@ -0,0 +1,171 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import graphene
|
||||||
|
from graphene.relay import Node
|
||||||
|
|
||||||
|
from graphene_django import DjangoObjectType, DjangoConnectionField
|
||||||
|
from graphene_django.tests.models import Article, Reporter
|
||||||
|
from graphene_django.utils import DJANGO_FILTER_INSTALLED
|
||||||
|
|
||||||
|
pytestmark = []
|
||||||
|
|
||||||
|
if DJANGO_FILTER_INSTALLED:
|
||||||
|
from graphene_django.filter import DjangoFilterConnectionField
|
||||||
|
else:
|
||||||
|
pytestmark.append(
|
||||||
|
pytest.mark.skipif(
|
||||||
|
True, reason="django_filters not installed or not compatible"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def schema():
|
||||||
|
class ReporterType(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Reporter
|
||||||
|
interfaces = (Node,)
|
||||||
|
|
||||||
|
class ArticleType(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Article
|
||||||
|
interfaces = (Node,)
|
||||||
|
filter_fields = {
|
||||||
|
"lang": ["exact", "in"],
|
||||||
|
"reporter__a_choice": ["exact", "in"],
|
||||||
|
}
|
||||||
|
|
||||||
|
class Query(graphene.ObjectType):
|
||||||
|
all_reporters = DjangoConnectionField(ReporterType)
|
||||||
|
all_articles = DjangoFilterConnectionField(ArticleType)
|
||||||
|
|
||||||
|
schema = graphene.Schema(query=Query)
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def reporter_article_data():
|
||||||
|
john = Reporter.objects.create(
|
||||||
|
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
|
||||||
|
)
|
||||||
|
jane = Reporter.objects.create(
|
||||||
|
first_name="Jane", last_name="Doe", email="janedoe@example.com", a_choice=2
|
||||||
|
)
|
||||||
|
Article.objects.create(
|
||||||
|
headline="Article Node 1",
|
||||||
|
reporter=john,
|
||||||
|
editor=john,
|
||||||
|
lang="es",
|
||||||
|
)
|
||||||
|
Article.objects.create(
|
||||||
|
headline="Article Node 2",
|
||||||
|
reporter=john,
|
||||||
|
editor=john,
|
||||||
|
lang="en",
|
||||||
|
)
|
||||||
|
Article.objects.create(
|
||||||
|
headline="Article Node 3",
|
||||||
|
reporter=jane,
|
||||||
|
editor=jane,
|
||||||
|
lang="en",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_enum_on_connection(schema, reporter_article_data):
|
||||||
|
"""
|
||||||
|
Check that we can filter with enums on a connection.
|
||||||
|
"""
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
allArticles(lang: ES) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
headline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
"allArticles": {
|
||||||
|
"edges": [
|
||||||
|
{"node": {"headline": "Article Node 1"}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_on_foreign_key_enum_field(schema, reporter_article_data):
|
||||||
|
"""
|
||||||
|
Check that we can filter with enums on a field from a foreign key.
|
||||||
|
"""
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
allArticles(reporter_AChoice: A_1) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
headline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
"allArticles": {
|
||||||
|
"edges": [
|
||||||
|
{"node": {"headline": "Article Node 1"}},
|
||||||
|
{"node": {"headline": "Article Node 2"}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_enum_field_schema_type(schema):
|
||||||
|
"""
|
||||||
|
Check that the type in the filter is an enum like on the object type.
|
||||||
|
"""
|
||||||
|
schema_str = str(schema)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"""type ArticleType implements Node {
|
||||||
|
id: ID!
|
||||||
|
headline: String!
|
||||||
|
pubDate: Date!
|
||||||
|
pubDateTime: DateTime!
|
||||||
|
reporter: ReporterType!
|
||||||
|
editor: ReporterType!
|
||||||
|
lang: ArticleLang!
|
||||||
|
importance: ArticleImportance
|
||||||
|
}"""
|
||||||
|
in schema_str
|
||||||
|
)
|
||||||
|
|
||||||
|
filters = {
|
||||||
|
"offset": "Int",
|
||||||
|
"before": "String",
|
||||||
|
"after": "String",
|
||||||
|
"first": "Int",
|
||||||
|
"last": "Int",
|
||||||
|
"lang": "ArticleLang",
|
||||||
|
"lang_In": "[ArticleLang]",
|
||||||
|
"reporter_AChoice": "ReporterAChoice",
|
||||||
|
"reporter_AChoice_In": "[ReporterAChoice]",
|
||||||
|
}
|
||||||
|
|
||||||
|
all_articles_filters = (
|
||||||
|
schema_str.split(" allArticles(")[1]
|
||||||
|
.split("): ArticleTypeConnection\n")[0]
|
||||||
|
.split(", ")
|
||||||
|
)
|
||||||
|
for filter_field, gql_type in filters.items():
|
||||||
|
assert "{}: {}".format(filter_field, gql_type) in all_articles_filters
|
|
@ -5,18 +5,18 @@ import pytest
|
||||||
from django.db.models import TextField, Value
|
from django.db.models import TextField, Value
|
||||||
from django.db.models.functions import Concat
|
from django.db.models.functions import Concat
|
||||||
|
|
||||||
from graphene import Argument, Boolean, Field, Float, ObjectType, Schema, String
|
from graphene import Argument, Boolean, Decimal, Field, ObjectType, Schema, String
|
||||||
from graphene.relay import Node
|
from graphene.relay import Node
|
||||||
from graphene_django import DjangoObjectType
|
from graphene_django import DjangoObjectType
|
||||||
from graphene_django.forms import GlobalIDFormField, GlobalIDMultipleChoiceField
|
from graphene_django.forms import GlobalIDFormField, GlobalIDMultipleChoiceField
|
||||||
from graphene_django.tests.models import Article, Pet, Reporter
|
from graphene_django.tests.models import Article, Person, Pet, Reporter
|
||||||
from graphene_django.utils import DJANGO_FILTER_INSTALLED
|
from graphene_django.utils import DJANGO_FILTER_INSTALLED
|
||||||
|
|
||||||
pytestmark = []
|
pytestmark = []
|
||||||
|
|
||||||
if DJANGO_FILTER_INSTALLED:
|
if DJANGO_FILTER_INSTALLED:
|
||||||
import django_filters
|
import django_filters
|
||||||
from django_filters import FilterSet, NumberFilter
|
from django_filters import FilterSet, NumberFilter, OrderingFilter
|
||||||
|
|
||||||
from graphene_django.filter import (
|
from graphene_django.filter import (
|
||||||
GlobalIDFilter,
|
GlobalIDFilter,
|
||||||
|
@ -87,6 +87,7 @@ def test_filter_explicit_filterset_arguments():
|
||||||
"pub_date__gt",
|
"pub_date__gt",
|
||||||
"pub_date__lt",
|
"pub_date__lt",
|
||||||
"reporter",
|
"reporter",
|
||||||
|
"reporter__in",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -388,7 +389,7 @@ def test_filterset_descriptions():
|
||||||
field = DjangoFilterConnectionField(ArticleNode, filterset_class=ArticleIdFilter)
|
field = DjangoFilterConnectionField(ArticleNode, filterset_class=ArticleIdFilter)
|
||||||
max_time = field.args["max_time"]
|
max_time = field.args["max_time"]
|
||||||
assert isinstance(max_time, Argument)
|
assert isinstance(max_time, Argument)
|
||||||
assert max_time.type == Float
|
assert max_time.type == Decimal
|
||||||
assert max_time.description == "The maximum time"
|
assert max_time.description == "The maximum time"
|
||||||
|
|
||||||
|
|
||||||
|
@ -671,12 +672,12 @@ def test_should_query_filter_node_limit():
|
||||||
schema = Schema(query=Query)
|
schema = Schema(query=Query)
|
||||||
query = """
|
query = """
|
||||||
query NodeFilteringQuery {
|
query NodeFilteringQuery {
|
||||||
allReporters(limit: 1) {
|
allReporters(limit: "1") {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
firstName
|
firstName
|
||||||
articles(lang: "es") {
|
articles(lang: ES) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
|
@ -1085,7 +1086,7 @@ def test_filter_filterset_based_on_mixin():
|
||||||
|
|
||||||
return filters
|
return filters
|
||||||
|
|
||||||
def filter_email_in(cls, queryset, name, value):
|
def filter_email_in(self, queryset, name, value):
|
||||||
return queryset.filter(**{name: [value]})
|
return queryset.filter(**{name: [value]})
|
||||||
|
|
||||||
class NewArticleFilter(ArticleFilterMixin, ArticleFilter):
|
class NewArticleFilter(ArticleFilterMixin, ArticleFilter):
|
||||||
|
@ -1171,3 +1172,76 @@ def test_filter_filterset_based_on_mixin():
|
||||||
|
|
||||||
assert not result.errors
|
assert not result.errors
|
||||||
assert result.data == expected
|
assert result.data == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_string_contains():
|
||||||
|
class PersonType(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Person
|
||||||
|
interfaces = (Node,)
|
||||||
|
filter_fields = {"name": ["exact", "in", "contains", "icontains"]}
|
||||||
|
|
||||||
|
class Query(ObjectType):
|
||||||
|
people = DjangoFilterConnectionField(PersonType)
|
||||||
|
|
||||||
|
schema = Schema(query=Query)
|
||||||
|
|
||||||
|
Person.objects.bulk_create(
|
||||||
|
[
|
||||||
|
Person(name="Jack"),
|
||||||
|
Person(name="Joe"),
|
||||||
|
Person(name="Jane"),
|
||||||
|
Person(name="Peter"),
|
||||||
|
Person(name="Bob"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
query = """query nameContain($filter: String) {
|
||||||
|
people(name_Contains: $filter) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
|
||||||
|
result = schema.execute(query, variables={"filter": "Ja"})
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data == {
|
||||||
|
"people": {
|
||||||
|
"edges": [
|
||||||
|
{"node": {"name": "Jack"}},
|
||||||
|
{"node": {"name": "Jane"}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = schema.execute(query, variables={"filter": "o"})
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data == {
|
||||||
|
"people": {
|
||||||
|
"edges": [
|
||||||
|
{"node": {"name": "Joe"}},
|
||||||
|
{"node": {"name": "Bob"}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_custom_filters():
|
||||||
|
class ReporterFilter(FilterSet):
|
||||||
|
class Meta:
|
||||||
|
model = Reporter
|
||||||
|
fields = []
|
||||||
|
|
||||||
|
some_filter = OrderingFilter(fields=("name",))
|
||||||
|
|
||||||
|
class ReporterFilterNode(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Reporter
|
||||||
|
interfaces = (Node,)
|
||||||
|
fields = "__all__"
|
||||||
|
filterset_class = ReporterFilter
|
||||||
|
|
||||||
|
field = DjangoFilterConnectionField(ReporterFilterNode)
|
||||||
|
assert_arguments(field, "some_filter")
|
||||||
|
|
|
@ -1,9 +1,14 @@
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from django_filters import FilterSet
|
||||||
|
from django_filters import rest_framework as filters
|
||||||
from graphene import ObjectType, Schema
|
from graphene import ObjectType, Schema
|
||||||
from graphene.relay import Node
|
from graphene.relay import Node
|
||||||
from graphene_django import DjangoObjectType
|
from graphene_django import DjangoObjectType
|
||||||
from graphene_django.tests.models import Pet
|
from graphene_django.tests.models import Pet, Person, Reporter, Article, Film
|
||||||
|
from graphene_django.filter.tests.filters import ArticleFilter
|
||||||
from graphene_django.utils import DJANGO_FILTER_INSTALLED
|
from graphene_django.utils import DJANGO_FILTER_INSTALLED
|
||||||
|
|
||||||
pytestmark = []
|
pytestmark = []
|
||||||
|
@ -18,21 +23,72 @@ else:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class PetNode(DjangoObjectType):
|
@pytest.fixture
|
||||||
class Meta:
|
def query():
|
||||||
model = Pet
|
class PetNode(DjangoObjectType):
|
||||||
interfaces = (Node,)
|
class Meta:
|
||||||
filter_fields = {
|
model = Pet
|
||||||
"name": ["exact", "in"],
|
interfaces = (Node,)
|
||||||
"age": ["exact", "in", "range"],
|
filter_fields = {
|
||||||
}
|
"id": ["exact", "in"],
|
||||||
|
"name": ["exact", "in"],
|
||||||
|
"age": ["exact", "in", "range"],
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReporterNode(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Reporter
|
||||||
|
interfaces = (Node,)
|
||||||
|
# choice filter using enum
|
||||||
|
filter_fields = {"reporter_type": ["exact", "in"]}
|
||||||
|
|
||||||
|
class ArticleNode(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Article
|
||||||
|
interfaces = (Node,)
|
||||||
|
filterset_class = ArticleFilter
|
||||||
|
|
||||||
|
class FilmNode(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Film
|
||||||
|
interfaces = (Node,)
|
||||||
|
# choice filter not using enum
|
||||||
|
filter_fields = {
|
||||||
|
"genre": ["exact", "in"],
|
||||||
|
}
|
||||||
|
convert_choices_to_enum = False
|
||||||
|
|
||||||
|
class PersonFilterSet(FilterSet):
|
||||||
|
class Meta:
|
||||||
|
model = Person
|
||||||
|
fields = {"name": ["in"]}
|
||||||
|
|
||||||
|
names = filters.BaseInFilter(method="filter_names")
|
||||||
|
|
||||||
|
def filter_names(self, qs, name, value):
|
||||||
|
"""
|
||||||
|
This custom filter take a string as input with comma separated values.
|
||||||
|
Note that the value here is already a list as it has been transformed by the BaseInFilter class.
|
||||||
|
"""
|
||||||
|
return qs.filter(name__in=value)
|
||||||
|
|
||||||
|
class PersonNode(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Person
|
||||||
|
interfaces = (Node,)
|
||||||
|
filterset_class = PersonFilterSet
|
||||||
|
|
||||||
|
class Query(ObjectType):
|
||||||
|
pets = DjangoFilterConnectionField(PetNode)
|
||||||
|
people = DjangoFilterConnectionField(PersonNode)
|
||||||
|
articles = DjangoFilterConnectionField(ArticleNode)
|
||||||
|
films = DjangoFilterConnectionField(FilmNode)
|
||||||
|
reporters = DjangoFilterConnectionField(ReporterNode)
|
||||||
|
|
||||||
|
return Query
|
||||||
|
|
||||||
|
|
||||||
class Query(ObjectType):
|
def test_string_in_filter(query):
|
||||||
pets = DjangoFilterConnectionField(PetNode)
|
|
||||||
|
|
||||||
|
|
||||||
def test_string_in_filter():
|
|
||||||
"""
|
"""
|
||||||
Test in filter on a string field.
|
Test in filter on a string field.
|
||||||
"""
|
"""
|
||||||
|
@ -40,7 +96,7 @@ def test_string_in_filter():
|
||||||
Pet.objects.create(name="Mimi", age=3)
|
Pet.objects.create(name="Mimi", age=3)
|
||||||
Pet.objects.create(name="Jojo, the rabbit", age=3)
|
Pet.objects.create(name="Jojo, the rabbit", age=3)
|
||||||
|
|
||||||
schema = Schema(query=Query)
|
schema = Schema(query=query)
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
query {
|
query {
|
||||||
|
@ -61,7 +117,65 @@ def test_string_in_filter():
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_int_in_filter():
|
def test_string_in_filter_with_otjer_filter(query):
|
||||||
|
"""
|
||||||
|
Test in filter on a string field which has also a custom filter doing a similar operation.
|
||||||
|
"""
|
||||||
|
Person.objects.create(name="John")
|
||||||
|
Person.objects.create(name="Michael")
|
||||||
|
Person.objects.create(name="Angela")
|
||||||
|
|
||||||
|
schema = Schema(query=query)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
people (name_In: ["John", "Michael"]) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["people"]["edges"] == [
|
||||||
|
{"node": {"name": "John"}},
|
||||||
|
{"node": {"name": "Michael"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_string_in_filter_with_declared_filter(query):
|
||||||
|
"""
|
||||||
|
Test in filter on a string field with a custom filterset class.
|
||||||
|
"""
|
||||||
|
Person.objects.create(name="John")
|
||||||
|
Person.objects.create(name="Michael")
|
||||||
|
Person.objects.create(name="Angela")
|
||||||
|
|
||||||
|
schema = Schema(query=query)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
people (names: "John,Michael") {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["people"]["edges"] == [
|
||||||
|
{"node": {"name": "John"}},
|
||||||
|
{"node": {"name": "Michael"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_int_in_filter(query):
|
||||||
"""
|
"""
|
||||||
Test in filter on an integer field.
|
Test in filter on an integer field.
|
||||||
"""
|
"""
|
||||||
|
@ -69,7 +183,7 @@ def test_int_in_filter():
|
||||||
Pet.objects.create(name="Mimi", age=3)
|
Pet.objects.create(name="Mimi", age=3)
|
||||||
Pet.objects.create(name="Jojo, the rabbit", age=3)
|
Pet.objects.create(name="Jojo, the rabbit", age=3)
|
||||||
|
|
||||||
schema = Schema(query=Query)
|
schema = Schema(query=query)
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
query {
|
query {
|
||||||
|
@ -109,20 +223,19 @@ def test_int_in_filter():
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_int_range_filter():
|
def test_in_filter_with_empty_list(query):
|
||||||
"""
|
"""
|
||||||
Test in filter on an integer field.
|
Check that using a in filter with an empty list provided as input returns no objects.
|
||||||
"""
|
"""
|
||||||
Pet.objects.create(name="Brutus", age=12)
|
Pet.objects.create(name="Brutus", age=12)
|
||||||
Pet.objects.create(name="Mimi", age=8)
|
Pet.objects.create(name="Mimi", age=8)
|
||||||
Pet.objects.create(name="Jojo, the rabbit", age=3)
|
|
||||||
Pet.objects.create(name="Picotin", age=5)
|
Pet.objects.create(name="Picotin", age=5)
|
||||||
|
|
||||||
schema = Schema(query=Query)
|
schema = Schema(query=query)
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
query {
|
query {
|
||||||
pets (age_Range: [4, 9]) {
|
pets (name_In: []) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
name
|
name
|
||||||
|
@ -133,7 +246,210 @@ def test_int_range_filter():
|
||||||
"""
|
"""
|
||||||
result = schema.execute(query)
|
result = schema.execute(query)
|
||||||
assert not result.errors
|
assert not result.errors
|
||||||
assert result.data["pets"]["edges"] == [
|
assert len(result.data["pets"]["edges"]) == 0
|
||||||
{"node": {"name": "Mimi"}},
|
|
||||||
{"node": {"name": "Picotin"}},
|
|
||||||
|
def test_choice_in_filter_without_enum(query):
|
||||||
|
"""
|
||||||
|
Test in filter o an choice field not using an enum (Film.genre).
|
||||||
|
"""
|
||||||
|
|
||||||
|
john_doe = Reporter.objects.create(
|
||||||
|
first_name="John", last_name="Doe", email="john@doe.com"
|
||||||
|
)
|
||||||
|
jean_bon = Reporter.objects.create(
|
||||||
|
first_name="Jean", last_name="Bon", email="jean@bon.com"
|
||||||
|
)
|
||||||
|
documentary_film = Film.objects.create(genre="do")
|
||||||
|
documentary_film.reporters.add(john_doe)
|
||||||
|
action_film = Film.objects.create(genre="ac")
|
||||||
|
action_film.reporters.add(john_doe)
|
||||||
|
other_film = Film.objects.create(genre="ot")
|
||||||
|
other_film.reporters.add(john_doe)
|
||||||
|
other_film.reporters.add(jean_bon)
|
||||||
|
|
||||||
|
schema = Schema(query=query)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
films (genre_In: ["do", "ac"]) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
genre
|
||||||
|
reporters {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
lastName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["films"]["edges"] == [
|
||||||
|
{
|
||||||
|
"node": {
|
||||||
|
"genre": "do",
|
||||||
|
"reporters": {"edges": [{"node": {"lastName": "Doe"}}]},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": {
|
||||||
|
"genre": "ac",
|
||||||
|
"reporters": {"edges": [{"node": {"lastName": "Doe"}}]},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fk_id_in_filter(query):
|
||||||
|
"""
|
||||||
|
Test in filter on an foreign key relationship.
|
||||||
|
"""
|
||||||
|
john_doe = Reporter.objects.create(
|
||||||
|
first_name="John", last_name="Doe", email="john@doe.com"
|
||||||
|
)
|
||||||
|
jean_bon = Reporter.objects.create(
|
||||||
|
first_name="Jean", last_name="Bon", email="jean@bon.com"
|
||||||
|
)
|
||||||
|
sara_croche = Reporter.objects.create(
|
||||||
|
first_name="Sara", last_name="Croche", email="sara@croche.com"
|
||||||
|
)
|
||||||
|
Article.objects.create(
|
||||||
|
headline="A",
|
||||||
|
pub_date=datetime.now(),
|
||||||
|
pub_date_time=datetime.now(),
|
||||||
|
reporter=john_doe,
|
||||||
|
editor=john_doe,
|
||||||
|
)
|
||||||
|
Article.objects.create(
|
||||||
|
headline="B",
|
||||||
|
pub_date=datetime.now(),
|
||||||
|
pub_date_time=datetime.now(),
|
||||||
|
reporter=jean_bon,
|
||||||
|
editor=jean_bon,
|
||||||
|
)
|
||||||
|
Article.objects.create(
|
||||||
|
headline="C",
|
||||||
|
pub_date=datetime.now(),
|
||||||
|
pub_date_time=datetime.now(),
|
||||||
|
reporter=sara_croche,
|
||||||
|
editor=sara_croche,
|
||||||
|
)
|
||||||
|
|
||||||
|
schema = Schema(query=query)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
articles (reporter_In: [%s, %s]) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
headline
|
||||||
|
reporter {
|
||||||
|
lastName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""" % (
|
||||||
|
john_doe.id,
|
||||||
|
jean_bon.id,
|
||||||
|
)
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["articles"]["edges"] == [
|
||||||
|
{"node": {"headline": "A", "reporter": {"lastName": "Doe"}}},
|
||||||
|
{"node": {"headline": "B", "reporter": {"lastName": "Bon"}}},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_enum_in_filter(query):
|
||||||
|
"""
|
||||||
|
Test in filter on a choice field using an enum (Reporter.reporter_type).
|
||||||
|
"""
|
||||||
|
|
||||||
|
Reporter.objects.create(
|
||||||
|
first_name="John",
|
||||||
|
last_name="Doe",
|
||||||
|
email="john@doe.com",
|
||||||
|
reporter_type=1,
|
||||||
|
)
|
||||||
|
Reporter.objects.create(
|
||||||
|
first_name="Jean",
|
||||||
|
last_name="Bon",
|
||||||
|
email="jean@bon.com",
|
||||||
|
reporter_type=2,
|
||||||
|
)
|
||||||
|
Reporter.objects.create(
|
||||||
|
first_name="Jane",
|
||||||
|
last_name="Doe",
|
||||||
|
email="jane@doe.com",
|
||||||
|
reporter_type=2,
|
||||||
|
)
|
||||||
|
Reporter.objects.create(
|
||||||
|
first_name="Jack",
|
||||||
|
last_name="Black",
|
||||||
|
email="jack@black.com",
|
||||||
|
reporter_type=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
schema = Schema(query=query)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
reporters (reporterType_In: [A_1]) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["reporters"]["edges"] == [
|
||||||
|
{"node": {"email": "john@doe.com"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
reporters (reporterType_In: [A_2]) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["reporters"]["edges"] == [
|
||||||
|
{"node": {"email": "jean@bon.com"}},
|
||||||
|
{"node": {"email": "jane@doe.com"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
reporters (reporterType_In: [A_2, A_1]) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["reporters"]["edges"] == [
|
||||||
|
{"node": {"email": "john@doe.com"}},
|
||||||
|
{"node": {"email": "jean@bon.com"}},
|
||||||
|
{"node": {"email": "jane@doe.com"}},
|
||||||
]
|
]
|
||||||
|
|
115
graphene_django/filter/tests/test_range_filter.py
Normal file
115
graphene_django/filter/tests/test_range_filter.py
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from django_filters import FilterSet
|
||||||
|
from django_filters import rest_framework as filters
|
||||||
|
from graphene import ObjectType, Schema
|
||||||
|
from graphene.relay import Node
|
||||||
|
from graphene_django import DjangoObjectType
|
||||||
|
from graphene_django.tests.models import Pet
|
||||||
|
from graphene_django.utils import DJANGO_FILTER_INSTALLED
|
||||||
|
|
||||||
|
pytestmark = []
|
||||||
|
|
||||||
|
if DJANGO_FILTER_INSTALLED:
|
||||||
|
from graphene_django.filter import DjangoFilterConnectionField
|
||||||
|
else:
|
||||||
|
pytestmark.append(
|
||||||
|
pytest.mark.skipif(
|
||||||
|
True, reason="django_filters not installed or not compatible"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PetNode(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Pet
|
||||||
|
interfaces = (Node,)
|
||||||
|
filter_fields = {
|
||||||
|
"name": ["exact", "in"],
|
||||||
|
"age": ["exact", "in", "range"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Query(ObjectType):
|
||||||
|
pets = DjangoFilterConnectionField(PetNode)
|
||||||
|
|
||||||
|
|
||||||
|
def test_int_range_filter():
|
||||||
|
"""
|
||||||
|
Test range filter on an integer field.
|
||||||
|
"""
|
||||||
|
Pet.objects.create(name="Brutus", age=12)
|
||||||
|
Pet.objects.create(name="Mimi", age=8)
|
||||||
|
Pet.objects.create(name="Jojo, the rabbit", age=3)
|
||||||
|
Pet.objects.create(name="Picotin", age=5)
|
||||||
|
|
||||||
|
schema = Schema(query=Query)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
pets (age_Range: [4, 9]) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["pets"]["edges"] == [
|
||||||
|
{"node": {"name": "Mimi"}},
|
||||||
|
{"node": {"name": "Picotin"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_range_filter_with_invalid_input():
|
||||||
|
"""
|
||||||
|
Test range filter used with invalid inputs raise an error.
|
||||||
|
"""
|
||||||
|
Pet.objects.create(name="Brutus", age=12)
|
||||||
|
Pet.objects.create(name="Mimi", age=8)
|
||||||
|
Pet.objects.create(name="Jojo, the rabbit", age=3)
|
||||||
|
Pet.objects.create(name="Picotin", age=5)
|
||||||
|
|
||||||
|
schema = Schema(query=Query)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query ($rangeValue: [Int]) {
|
||||||
|
pets (age_Range: $rangeValue) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
expected_error = json.dumps(
|
||||||
|
{
|
||||||
|
"age__range": [
|
||||||
|
{
|
||||||
|
"message": "Invalid range specified: it needs to contain 2 values.",
|
||||||
|
"code": "invalid",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Empty list
|
||||||
|
result = schema.execute(query, variables={"rangeValue": []})
|
||||||
|
assert len(result.errors) == 1
|
||||||
|
assert ast.literal_eval(result.errors[0].message)[0] == expected_error
|
||||||
|
|
||||||
|
# Only one item in the list
|
||||||
|
result = schema.execute(query, variables={"rangeValue": [1]})
|
||||||
|
assert len(result.errors) == 1
|
||||||
|
assert ast.literal_eval(result.errors[0].message)[0] == expected_error
|
||||||
|
|
||||||
|
# More than 2 items in the list
|
||||||
|
result = schema.execute(query, variables={"rangeValue": [1, 2, 3]})
|
||||||
|
assert len(result.errors) == 1
|
||||||
|
assert ast.literal_eval(result.errors[0].message)[0] == expected_error
|
165
graphene_django/filter/tests/test_typed_filter.py
Normal file
165
graphene_django/filter/tests/test_typed_filter.py
Normal file
|
@ -0,0 +1,165 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from django_filters import FilterSet
|
||||||
|
|
||||||
|
import graphene
|
||||||
|
from graphene.relay import Node
|
||||||
|
|
||||||
|
from graphene_django import DjangoObjectType
|
||||||
|
from graphene_django.tests.models import Article, Reporter
|
||||||
|
from graphene_django.utils import DJANGO_FILTER_INSTALLED
|
||||||
|
|
||||||
|
pytestmark = []
|
||||||
|
|
||||||
|
if DJANGO_FILTER_INSTALLED:
|
||||||
|
from graphene_django.filter import (
|
||||||
|
DjangoFilterConnectionField,
|
||||||
|
TypedFilter,
|
||||||
|
ListFilter,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
pytestmark.append(
|
||||||
|
pytest.mark.skipif(
|
||||||
|
True, reason="django_filters not installed or not compatible"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def schema():
|
||||||
|
class ArticleFilterSet(FilterSet):
|
||||||
|
class Meta:
|
||||||
|
model = Article
|
||||||
|
fields = {
|
||||||
|
"lang": ["exact", "in"],
|
||||||
|
}
|
||||||
|
|
||||||
|
lang__contains = TypedFilter(
|
||||||
|
field_name="lang", lookup_expr="icontains", input_type=graphene.String
|
||||||
|
)
|
||||||
|
lang__in_str = ListFilter(
|
||||||
|
field_name="lang",
|
||||||
|
lookup_expr="in",
|
||||||
|
input_type=graphene.List(graphene.String),
|
||||||
|
)
|
||||||
|
first_n = TypedFilter(input_type=graphene.Int, method="first_n_filter")
|
||||||
|
only_first = TypedFilter(
|
||||||
|
input_type=graphene.Boolean, method="only_first_filter"
|
||||||
|
)
|
||||||
|
|
||||||
|
def first_n_filter(self, queryset, _name, value):
|
||||||
|
return queryset[:value]
|
||||||
|
|
||||||
|
def only_first_filter(self, queryset, _name, value):
|
||||||
|
if value:
|
||||||
|
return queryset[:1]
|
||||||
|
else:
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
class ArticleType(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Article
|
||||||
|
interfaces = (Node,)
|
||||||
|
filterset_class = ArticleFilterSet
|
||||||
|
|
||||||
|
class Query(graphene.ObjectType):
|
||||||
|
articles = DjangoFilterConnectionField(ArticleType)
|
||||||
|
|
||||||
|
schema = graphene.Schema(query=Query)
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
def test_typed_filter_schema(schema):
|
||||||
|
"""
|
||||||
|
Check that the type provided in the filter is reflected in the schema.
|
||||||
|
"""
|
||||||
|
|
||||||
|
schema_str = str(schema)
|
||||||
|
|
||||||
|
filters = {
|
||||||
|
"offset": "Int",
|
||||||
|
"before": "String",
|
||||||
|
"after": "String",
|
||||||
|
"first": "Int",
|
||||||
|
"last": "Int",
|
||||||
|
"lang": "ArticleLang",
|
||||||
|
"lang_In": "[ArticleLang]",
|
||||||
|
"lang_Contains": "String",
|
||||||
|
"lang_InStr": "[String]",
|
||||||
|
"firstN": "Int",
|
||||||
|
"onlyFirst": "Boolean",
|
||||||
|
}
|
||||||
|
|
||||||
|
all_articles_filters = (
|
||||||
|
schema_str.split(" articles(")[1]
|
||||||
|
.split("): ArticleTypeConnection\n")[0]
|
||||||
|
.split(", ")
|
||||||
|
)
|
||||||
|
|
||||||
|
for filter_field, gql_type in filters.items():
|
||||||
|
assert "{}: {}".format(filter_field, gql_type) in all_articles_filters
|
||||||
|
|
||||||
|
|
||||||
|
def test_typed_filters_work(schema):
|
||||||
|
reporter = Reporter.objects.create(first_name="John", last_name="Doe", email="")
|
||||||
|
Article.objects.create(
|
||||||
|
headline="A",
|
||||||
|
reporter=reporter,
|
||||||
|
editor=reporter,
|
||||||
|
lang="es",
|
||||||
|
)
|
||||||
|
Article.objects.create(
|
||||||
|
headline="B",
|
||||||
|
reporter=reporter,
|
||||||
|
editor=reporter,
|
||||||
|
lang="es",
|
||||||
|
)
|
||||||
|
Article.objects.create(
|
||||||
|
headline="C",
|
||||||
|
reporter=reporter,
|
||||||
|
editor=reporter,
|
||||||
|
lang="en",
|
||||||
|
)
|
||||||
|
|
||||||
|
query = "query { articles (lang_In: [ES]) { edges { node { headline } } } }"
|
||||||
|
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["articles"]["edges"] == [
|
||||||
|
{"node": {"headline": "A"}},
|
||||||
|
{"node": {"headline": "B"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
query = 'query { articles (lang_InStr: ["es"]) { edges { node { headline } } } }'
|
||||||
|
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["articles"]["edges"] == [
|
||||||
|
{"node": {"headline": "A"}},
|
||||||
|
{"node": {"headline": "B"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
query = 'query { articles (lang_Contains: "n") { edges { node { headline } } } }'
|
||||||
|
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["articles"]["edges"] == [
|
||||||
|
{"node": {"headline": "C"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
query = "query { articles (firstN: 2) { edges { node { headline } } } }"
|
||||||
|
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["articles"]["edges"] == [
|
||||||
|
{"node": {"headline": "A"}},
|
||||||
|
{"node": {"headline": "B"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
query = "query { articles (onlyFirst: true) { edges { node { headline } } } }"
|
||||||
|
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["articles"]["edges"] == [
|
||||||
|
{"node": {"headline": "A"}},
|
||||||
|
]
|
|
@ -1,53 +1,108 @@
|
||||||
import six
|
import six
|
||||||
|
|
||||||
from graphene import List
|
import graphene
|
||||||
|
|
||||||
from django_filters.utils import get_model_field
|
from django import forms
|
||||||
|
|
||||||
|
from django_filters.utils import get_model_field, get_field_parts
|
||||||
from django_filters.filters import Filter, BaseCSVFilter
|
from django_filters.filters import Filter, BaseCSVFilter
|
||||||
|
|
||||||
from .filterset import custom_filterset_factory, setup_filterset
|
from .filterset import custom_filterset_factory, setup_filterset
|
||||||
|
from .filters import ArrayFilter, ListFilter, RangeFilter, TypedFilter
|
||||||
|
from ..forms import GlobalIDFormField, GlobalIDMultipleChoiceField
|
||||||
|
|
||||||
|
|
||||||
|
def get_field_type(registry, model, field_name):
|
||||||
|
"""
|
||||||
|
Try to get a model field corresponding Graphql type from the DjangoObjectType.
|
||||||
|
"""
|
||||||
|
object_type = registry.get_type_for_model(model)
|
||||||
|
if object_type:
|
||||||
|
object_type_field = object_type._meta.fields.get(field_name)
|
||||||
|
if object_type_field:
|
||||||
|
field_type = object_type_field.type
|
||||||
|
if isinstance(field_type, graphene.NonNull):
|
||||||
|
field_type = field_type.of_type
|
||||||
|
return field_type
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_filtering_args_from_filterset(filterset_class, type):
|
def get_filtering_args_from_filterset(filterset_class, type):
|
||||||
""" Inspect a FilterSet and produce the arguments to pass to
|
"""
|
||||||
a Graphene Field. These arguments will be available to
|
Inspect a FilterSet and produce the arguments to pass to a Graphene Field.
|
||||||
filter against in the GraphQL
|
These arguments will be available to filter against in the GraphQL API.
|
||||||
"""
|
"""
|
||||||
from ..forms.converter import convert_form_field
|
from ..forms.converter import convert_form_field
|
||||||
|
|
||||||
args = {}
|
args = {}
|
||||||
model = filterset_class._meta.model
|
model = filterset_class._meta.model
|
||||||
|
registry = type._meta.registry
|
||||||
for name, filter_field in six.iteritems(filterset_class.base_filters):
|
for name, filter_field in six.iteritems(filterset_class.base_filters):
|
||||||
|
filter_type = filter_field.lookup_expr
|
||||||
|
required = filter_field.extra.get("required", False)
|
||||||
|
field_type = None
|
||||||
form_field = None
|
form_field = None
|
||||||
|
|
||||||
if name in filterset_class.declared_filters:
|
if (
|
||||||
# Get the filter field from the explicitly declared filter
|
isinstance(filter_field, TypedFilter)
|
||||||
form_field = filter_field.field
|
and filter_field.input_type is not None
|
||||||
field = convert_form_field(form_field)
|
):
|
||||||
|
# First check if the filter input type has been explicitely given
|
||||||
|
field_type = filter_field.input_type
|
||||||
else:
|
else:
|
||||||
# Get the filter field with no explicit type declaration
|
if name not in filterset_class.declared_filters or isinstance(
|
||||||
model_field = get_model_field(model, filter_field.field_name)
|
filter_field, TypedFilter
|
||||||
filter_type = filter_field.lookup_expr
|
):
|
||||||
if filter_type != "isnull" and hasattr(model_field, "formfield"):
|
# Get the filter field for filters that are no explicitly declared.
|
||||||
form_field = model_field.formfield(
|
if filter_type == "isnull":
|
||||||
required=filter_field.extra.get("required", False)
|
field = graphene.Boolean(required=required)
|
||||||
)
|
else:
|
||||||
|
model_field = get_model_field(model, filter_field.field_name)
|
||||||
|
|
||||||
# Fallback to field defined on filter if we can't get it from the
|
# Get the form field either from:
|
||||||
# model field
|
# 1. the formfield corresponding to the model field
|
||||||
if not form_field:
|
# 2. the field defined on filter
|
||||||
form_field = filter_field.field
|
if hasattr(model_field, "formfield"):
|
||||||
|
form_field = model_field.formfield(required=required)
|
||||||
|
if not form_field:
|
||||||
|
form_field = filter_field.field
|
||||||
|
|
||||||
field = convert_form_field(form_field)
|
# First try to get the matching field type from the GraphQL DjangoObjectType
|
||||||
|
if model_field:
|
||||||
|
if (
|
||||||
|
isinstance(form_field, forms.ModelChoiceField)
|
||||||
|
or isinstance(form_field, forms.ModelMultipleChoiceField)
|
||||||
|
or isinstance(form_field, GlobalIDMultipleChoiceField)
|
||||||
|
or isinstance(form_field, GlobalIDFormField)
|
||||||
|
):
|
||||||
|
# Foreign key have dynamic types and filtering on a foreign key actually means filtering on its ID.
|
||||||
|
field_type = get_field_type(
|
||||||
|
registry, model_field.related_model, "id"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
field_type = get_field_type(
|
||||||
|
registry, model_field.model, model_field.name
|
||||||
|
)
|
||||||
|
|
||||||
if filter_type in ["in", "range"]:
|
if not field_type:
|
||||||
# Replace CSV filters (`in`, `range`) argument type to be a list of the same type as the field.
|
# Fallback on converting the form field either because:
|
||||||
# See comments in `replace_csv_filters` method for more details.
|
# - it's an explicitly declared filters
|
||||||
field = List(field.get_type())
|
# - we did not manage to get the type from the model type
|
||||||
|
form_field = form_field or filter_field.field
|
||||||
|
field_type = convert_form_field(form_field).get_type()
|
||||||
|
|
||||||
field_type = field.Argument()
|
if isinstance(filter_field, ListFilter) or isinstance(
|
||||||
field_type.description = filter_field.label
|
filter_field, RangeFilter
|
||||||
args[name] = field_type
|
):
|
||||||
|
# Replace InFilter/RangeFilter filters (`in`, `range`) argument type to be a list of
|
||||||
|
# the same type as the field. See comments in `replace_csv_filters` method for more details.
|
||||||
|
field_type = graphene.List(field_type)
|
||||||
|
|
||||||
|
args[name] = graphene.Argument(
|
||||||
|
type=field_type,
|
||||||
|
description=filter_field.label,
|
||||||
|
required=required,
|
||||||
|
)
|
||||||
|
|
||||||
return args
|
return args
|
||||||
|
|
||||||
|
@ -69,22 +124,35 @@ def get_filterset_class(filterset_class, **meta):
|
||||||
|
|
||||||
def replace_csv_filters(filterset_class):
|
def replace_csv_filters(filterset_class):
|
||||||
"""
|
"""
|
||||||
Replace the "in" and "range" filters (that are not explicitly declared) to not be BaseCSVFilter (BaseInFilter, BaseRangeFilter) objects anymore
|
Replace the "in" and "range" filters (that are not explicitly declared)
|
||||||
but regular Filter objects that simply use the input value as filter argument on the queryset.
|
to not be BaseCSVFilter (BaseInFilter, BaseRangeFilter) objects anymore
|
||||||
|
but our custom InFilter/RangeFilter filter class that use the input
|
||||||
|
value as filter argument on the queryset.
|
||||||
|
|
||||||
This is because those BaseCSVFilter are expecting a string as input with comma separated value but with GraphQl we
|
This is because those BaseCSVFilter are expecting a string as input with
|
||||||
can actually have a list as input and have a proper type verification of each value in the list.
|
comma separated values.
|
||||||
|
But with GraphQl we can actually have a list as input and have a proper
|
||||||
|
type verification of each value in the list.
|
||||||
|
|
||||||
See issue https://github.com/graphql-python/graphene-django/issues/1068.
|
See issue https://github.com/graphql-python/graphene-django/issues/1068.
|
||||||
"""
|
"""
|
||||||
for name, filter_field in six.iteritems(filterset_class.base_filters):
|
for name, filter_field in six.iteritems(filterset_class.base_filters):
|
||||||
|
# Do not touch any declared filters
|
||||||
|
if name in filterset_class.declared_filters:
|
||||||
|
continue
|
||||||
|
|
||||||
filter_type = filter_field.lookup_expr
|
filter_type = filter_field.lookup_expr
|
||||||
if (
|
if filter_type == "in":
|
||||||
filter_type in ["in", "range"]
|
filterset_class.base_filters[name] = ListFilter(
|
||||||
and name not in filterset_class.declared_filters
|
field_name=filter_field.field_name,
|
||||||
):
|
lookup_expr=filter_field.lookup_expr,
|
||||||
assert isinstance(filter_field, BaseCSVFilter)
|
label=filter_field.label,
|
||||||
filterset_class.base_filters[name] = Filter(
|
method=filter_field.method,
|
||||||
|
exclude=filter_field.exclude,
|
||||||
|
**filter_field.extra
|
||||||
|
)
|
||||||
|
elif filter_type == "range":
|
||||||
|
filterset_class.base_filters[name] = RangeFilter(
|
||||||
field_name=filter_field.field_name,
|
field_name=filter_field.field_name,
|
||||||
lookup_expr=filter_field.lookup_expr,
|
lookup_expr=filter_field.lookup_expr,
|
||||||
label=filter_field.label,
|
label=filter_field.label,
|
||||||
|
|
|
@ -1,12 +1,23 @@
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
|
||||||
from graphene import ID, Boolean, Float, Int, List, String, UUID, Date, DateTime, Time
|
from graphene import (
|
||||||
|
Boolean,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
Decimal,
|
||||||
|
Float,
|
||||||
|
ID,
|
||||||
|
Int,
|
||||||
|
List,
|
||||||
|
String,
|
||||||
|
Time,
|
||||||
|
UUID,
|
||||||
|
)
|
||||||
|
|
||||||
from .forms import GlobalIDFormField, GlobalIDMultipleChoiceField
|
from .forms import GlobalIDFormField, GlobalIDMultipleChoiceField
|
||||||
from ..utils import import_single_dispatch
|
from ..utils import import_single_dispatch
|
||||||
|
|
||||||
|
|
||||||
singledispatch = import_single_dispatch()
|
singledispatch = import_single_dispatch()
|
||||||
|
|
||||||
|
|
||||||
|
@ -52,6 +63,10 @@ def convert_form_field_to_nullboolean(field):
|
||||||
|
|
||||||
|
|
||||||
@convert_form_field.register(forms.DecimalField)
|
@convert_form_field.register(forms.DecimalField)
|
||||||
|
def convert_field_to_decimal(field):
|
||||||
|
return Decimal(description=field.help_text, required=field.required)
|
||||||
|
|
||||||
|
|
||||||
@convert_form_field.register(forms.FloatField)
|
@convert_form_field.register(forms.FloatField)
|
||||||
def convert_form_field_to_float(field):
|
def convert_form_field_to_float(field):
|
||||||
return Float(description=field.help_text, required=field.required)
|
return Float(description=field.help_text, required=field.required)
|
||||||
|
|
|
@ -1,19 +1,19 @@
|
||||||
from django import forms
|
from django import forms
|
||||||
from py.test import raises
|
from py.test import raises
|
||||||
|
|
||||||
import graphene
|
|
||||||
from graphene import (
|
from graphene import (
|
||||||
String,
|
|
||||||
Int,
|
|
||||||
Boolean,
|
Boolean,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
Decimal,
|
||||||
Float,
|
Float,
|
||||||
ID,
|
ID,
|
||||||
UUID,
|
Int,
|
||||||
List,
|
List,
|
||||||
NonNull,
|
NonNull,
|
||||||
DateTime,
|
String,
|
||||||
Date,
|
|
||||||
Time,
|
Time,
|
||||||
|
UUID,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..converter import convert_form_field
|
from ..converter import convert_form_field
|
||||||
|
@ -97,8 +97,8 @@ def test_should_float_convert_float():
|
||||||
assert_conversion(forms.FloatField, Float)
|
assert_conversion(forms.FloatField, Float)
|
||||||
|
|
||||||
|
|
||||||
def test_should_decimal_convert_float():
|
def test_should_decimal_convert_decimal():
|
||||||
assert_conversion(forms.DecimalField, Float)
|
assert_conversion(forms.DecimalField, Decimal)
|
||||||
|
|
||||||
|
|
||||||
def test_should_multiple_choice_convert_list():
|
def test_should_multiple_choice_convert_list():
|
||||||
|
|
|
@ -18,6 +18,7 @@ class SerializerMutationOptions(MutationOptions):
|
||||||
model_class = None
|
model_class = None
|
||||||
model_operations = ["create", "update"]
|
model_operations = ["create", "update"]
|
||||||
serializer_class = None
|
serializer_class = None
|
||||||
|
optional_fields = ()
|
||||||
|
|
||||||
|
|
||||||
def fields_for_serializer(
|
def fields_for_serializer(
|
||||||
|
@ -27,6 +28,7 @@ def fields_for_serializer(
|
||||||
is_input=False,
|
is_input=False,
|
||||||
convert_choices_to_enum=True,
|
convert_choices_to_enum=True,
|
||||||
lookup_field=None,
|
lookup_field=None,
|
||||||
|
optional_fields=(),
|
||||||
):
|
):
|
||||||
fields = OrderedDict()
|
fields = OrderedDict()
|
||||||
for name, field in serializer.fields.items():
|
for name, field in serializer.fields.items():
|
||||||
|
@ -44,9 +46,13 @@ def fields_for_serializer(
|
||||||
|
|
||||||
if is_not_in_only or is_excluded:
|
if is_not_in_only or is_excluded:
|
||||||
continue
|
continue
|
||||||
|
is_optional = name in optional_fields
|
||||||
|
|
||||||
fields[name] = convert_serializer_field(
|
fields[name] = convert_serializer_field(
|
||||||
field, is_input=is_input, convert_choices_to_enum=convert_choices_to_enum
|
field,
|
||||||
|
is_input=is_input,
|
||||||
|
convert_choices_to_enum=convert_choices_to_enum,
|
||||||
|
force_optional=is_optional,
|
||||||
)
|
)
|
||||||
return fields
|
return fields
|
||||||
|
|
||||||
|
@ -70,6 +76,7 @@ class SerializerMutation(ClientIDMutation):
|
||||||
exclude_fields=(),
|
exclude_fields=(),
|
||||||
convert_choices_to_enum=True,
|
convert_choices_to_enum=True,
|
||||||
_meta=None,
|
_meta=None,
|
||||||
|
optional_fields=(),
|
||||||
**options
|
**options
|
||||||
):
|
):
|
||||||
|
|
||||||
|
@ -95,6 +102,7 @@ class SerializerMutation(ClientIDMutation):
|
||||||
is_input=True,
|
is_input=True,
|
||||||
convert_choices_to_enum=convert_choices_to_enum,
|
convert_choices_to_enum=convert_choices_to_enum,
|
||||||
lookup_field=lookup_field,
|
lookup_field=lookup_field,
|
||||||
|
optional_fields=optional_fields,
|
||||||
)
|
)
|
||||||
output_fields = fields_for_serializer(
|
output_fields = fields_for_serializer(
|
||||||
serializer,
|
serializer,
|
||||||
|
|
|
@ -19,7 +19,9 @@ def get_graphene_type_from_serializer_field(field):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def convert_serializer_field(field, is_input=True, convert_choices_to_enum=True):
|
def convert_serializer_field(
|
||||||
|
field, is_input=True, convert_choices_to_enum=True, force_optional=False
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Converts a django rest frameworks field to a graphql field
|
Converts a django rest frameworks field to a graphql field
|
||||||
and marks the field as required if we are creating an input type
|
and marks the field as required if we are creating an input type
|
||||||
|
@ -32,7 +34,10 @@ def convert_serializer_field(field, is_input=True, convert_choices_to_enum=True)
|
||||||
graphql_type = get_graphene_type_from_serializer_field(field)
|
graphql_type = get_graphene_type_from_serializer_field(field)
|
||||||
|
|
||||||
args = []
|
args = []
|
||||||
kwargs = {"description": field.help_text, "required": is_input and field.required}
|
kwargs = {
|
||||||
|
"description": field.help_text,
|
||||||
|
"required": is_input and field.required and not force_optional,
|
||||||
|
}
|
||||||
|
|
||||||
# if it is a tuple or a list it means that we are returning
|
# if it is a tuple or a list it means that we are returning
|
||||||
# the graphql type and the child type
|
# the graphql type and the child type
|
||||||
|
@ -110,8 +115,12 @@ def convert_serializer_field_to_bool(field):
|
||||||
return graphene.Boolean
|
return graphene.Boolean
|
||||||
|
|
||||||
|
|
||||||
@get_graphene_type_from_serializer_field.register(serializers.FloatField)
|
|
||||||
@get_graphene_type_from_serializer_field.register(serializers.DecimalField)
|
@get_graphene_type_from_serializer_field.register(serializers.DecimalField)
|
||||||
|
def convert_serializer_field_to_decimal(field):
|
||||||
|
return graphene.Decimal
|
||||||
|
|
||||||
|
|
||||||
|
@get_graphene_type_from_serializer_field.register(serializers.FloatField)
|
||||||
def convert_serializer_field_to_float(field):
|
def convert_serializer_field_to_float(field):
|
||||||
return graphene.Float
|
return graphene.Float
|
||||||
|
|
||||||
|
|
|
@ -133,9 +133,9 @@ def test_should_float_convert_float():
|
||||||
assert_conversion(serializers.FloatField, graphene.Float)
|
assert_conversion(serializers.FloatField, graphene.Float)
|
||||||
|
|
||||||
|
|
||||||
def test_should_decimal_convert_float():
|
def test_should_decimal_convert_decimal():
|
||||||
assert_conversion(
|
assert_conversion(
|
||||||
serializers.DecimalField, graphene.Float, max_digits=4, decimal_places=2
|
serializers.DecimalField, graphene.Decimal, max_digits=4, decimal_places=2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ import datetime
|
||||||
from py.test import raises
|
from py.test import raises
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from graphene import Field, ResolveInfo
|
from graphene import Field, ResolveInfo, NonNull, String
|
||||||
from graphene.types.inputobjecttype import InputObjectType
|
from graphene.types.inputobjecttype import InputObjectType
|
||||||
|
|
||||||
from ...types import DjangoObjectType
|
from ...types import DjangoObjectType
|
||||||
|
@ -98,6 +98,25 @@ def test_exclude_fields():
|
||||||
assert "created" not in MyMutation.Input._meta.fields
|
assert "created" not in MyMutation.Input._meta.fields
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_serializer_required_fields():
|
||||||
|
class MyMutation(SerializerMutation):
|
||||||
|
class Meta:
|
||||||
|
serializer_class = MyModelSerializer
|
||||||
|
|
||||||
|
assert "cool_name" in MyMutation.Input._meta.fields
|
||||||
|
assert MyMutation.Input._meta.fields["cool_name"].type == NonNull(String)
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_serializer_optional_fields():
|
||||||
|
class MyMutation(SerializerMutation):
|
||||||
|
class Meta:
|
||||||
|
serializer_class = MyModelSerializer
|
||||||
|
optional_fields = ("cool_name",)
|
||||||
|
|
||||||
|
assert "cool_name" in MyMutation.Input._meta.fields
|
||||||
|
assert MyMutation.Input._meta.fields["cool_name"].type == String
|
||||||
|
|
||||||
|
|
||||||
def test_write_only_field():
|
def test_write_only_field():
|
||||||
class WriteOnlyFieldModelSerializer(serializers.ModelSerializer):
|
class WriteOnlyFieldModelSerializer(serializers.ModelSerializer):
|
||||||
password = serializers.CharField(write_only=True)
|
password = serializers.CharField(write_only=True)
|
||||||
|
|
|
@ -1,11 +1,15 @@
|
||||||
from __future__ import absolute_import
|
from __future__ import absolute_import
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
CHOICES = ((1, "this"), (2, _("that")))
|
CHOICES = ((1, "this"), (2, _("that")))
|
||||||
|
|
||||||
|
|
||||||
|
class Person(models.Model):
|
||||||
|
name = models.CharField(max_length=30)
|
||||||
|
|
||||||
|
|
||||||
class Pet(models.Model):
|
class Pet(models.Model):
|
||||||
name = models.CharField(max_length=30)
|
name = models.CharField(max_length=30)
|
||||||
age = models.PositiveIntegerField()
|
age = models.PositiveIntegerField()
|
||||||
|
@ -22,7 +26,7 @@ class Film(models.Model):
|
||||||
genre = models.CharField(
|
genre = models.CharField(
|
||||||
max_length=2,
|
max_length=2,
|
||||||
help_text="Genre",
|
help_text="Genre",
|
||||||
choices=[("do", "Documentary"), ("ot", "Other")],
|
choices=[("do", "Documentary"), ("ac", "Action"), ("ot", "Other")],
|
||||||
default="ot",
|
default="ot",
|
||||||
)
|
)
|
||||||
reporters = models.ManyToManyField("Reporter", related_name="films")
|
reporters = models.ManyToManyField("Reporter", related_name="films")
|
||||||
|
@ -46,7 +50,7 @@ class Reporter(models.Model):
|
||||||
"Reporter Type",
|
"Reporter Type",
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
choices=[(1, u"Regular"), (2, u"CNN Reporter")],
|
choices=[(1, "Regular"), (2, "CNN Reporter")],
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self): # __unicode__ on Python 2
|
def __str__(self): # __unicode__ on Python 2
|
||||||
|
@ -87,8 +91,8 @@ class CNNReporter(Reporter):
|
||||||
|
|
||||||
class Article(models.Model):
|
class Article(models.Model):
|
||||||
headline = models.CharField(max_length=100)
|
headline = models.CharField(max_length=100)
|
||||||
pub_date = models.DateField()
|
pub_date = models.DateField(auto_now_add=True)
|
||||||
pub_date_time = models.DateTimeField()
|
pub_date_time = models.DateTimeField(auto_now_add=True)
|
||||||
reporter = models.ForeignKey(
|
reporter = models.ForeignKey(
|
||||||
Reporter, on_delete=models.CASCADE, related_name="articles"
|
Reporter, on_delete=models.CASCADE, related_name="articles"
|
||||||
)
|
)
|
||||||
|
@ -105,7 +109,7 @@ class Article(models.Model):
|
||||||
"Importance",
|
"Importance",
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
choices=[(1, u"Very important"), (2, u"Not as important")],
|
choices=[(1, "Very important"), (2, "Not as important")],
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self): # __unicode__ on Python 2
|
def __str__(self): # __unicode__ on Python 2
|
||||||
|
|
|
@ -2,7 +2,7 @@ from collections import namedtuple
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from py.test import raises
|
from py.test import raises
|
||||||
|
|
||||||
import graphene
|
import graphene
|
||||||
|
@ -242,6 +242,10 @@ def test_should_float_convert_float():
|
||||||
assert_conversion(models.FloatField, graphene.Float)
|
assert_conversion(models.FloatField, graphene.Float)
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_decimal_convert_decimal():
|
||||||
|
assert_conversion(models.DecimalField, graphene.Decimal)
|
||||||
|
|
||||||
|
|
||||||
def test_should_manytomany_convert_connectionorlist():
|
def test_should_manytomany_convert_connectionorlist():
|
||||||
registry = Registry()
|
registry = Registry()
|
||||||
dynamic_field = convert_django_field(Reporter._meta.local_many_to_many[0], registry)
|
dynamic_field = convert_django_field(Reporter._meta.local_many_to_many[0], registry)
|
||||||
|
|
|
@ -11,7 +11,7 @@ from py.test import raises
|
||||||
import graphene
|
import graphene
|
||||||
from graphene.relay import Node
|
from graphene.relay import Node
|
||||||
|
|
||||||
from ..compat import JSONField, MissingType
|
from ..compat import IntegerRangeField, MissingType
|
||||||
from ..fields import DjangoConnectionField
|
from ..fields import DjangoConnectionField
|
||||||
from ..types import DjangoObjectType
|
from ..types import DjangoObjectType
|
||||||
from ..utils import DJANGO_FILTER_INSTALLED
|
from ..utils import DJANGO_FILTER_INSTALLED
|
||||||
|
@ -113,7 +113,7 @@ def test_should_query_well():
|
||||||
assert result.data == expected
|
assert result.data == expected
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(JSONField is MissingType, reason="RangeField should exist")
|
@pytest.mark.skipif(IntegerRangeField is MissingType, reason="RangeField should exist")
|
||||||
def test_should_query_postgres_fields():
|
def test_should_query_postgres_fields():
|
||||||
from django.contrib.postgres.fields import (
|
from django.contrib.postgres.fields import (
|
||||||
IntegerRangeField,
|
IntegerRangeField,
|
||||||
|
@ -412,6 +412,7 @@ def test_should_query_node_filtering():
|
||||||
model = Article
|
model = Article
|
||||||
interfaces = (Node,)
|
interfaces = (Node,)
|
||||||
filter_fields = ("lang",)
|
filter_fields = ("lang",)
|
||||||
|
convert_choices_to_enum = False
|
||||||
|
|
||||||
class Query(graphene.ObjectType):
|
class Query(graphene.ObjectType):
|
||||||
all_reporters = DjangoConnectionField(ReporterType)
|
all_reporters = DjangoConnectionField(ReporterType)
|
||||||
|
@ -534,6 +535,7 @@ def test_should_query_node_multiple_filtering():
|
||||||
model = Article
|
model = Article
|
||||||
interfaces = (Node,)
|
interfaces = (Node,)
|
||||||
filter_fields = ("lang", "headline")
|
filter_fields = ("lang", "headline")
|
||||||
|
convert_choices_to_enum = False
|
||||||
|
|
||||||
class Query(graphene.ObjectType):
|
class Query(graphene.ObjectType):
|
||||||
all_reporters = DjangoConnectionField(ReporterType)
|
all_reporters = DjangoConnectionField(ReporterType)
|
||||||
|
@ -1442,7 +1444,11 @@ def test_connection_should_enable_offset_filtering():
|
||||||
result = schema.execute(query)
|
result = schema.execute(query)
|
||||||
assert not result.errors
|
assert not result.errors
|
||||||
expected = {
|
expected = {
|
||||||
"allReporters": {"edges": [{"node": {"firstName": "Some", "lastName": "Guy"}},]}
|
"allReporters": {
|
||||||
|
"edges": [
|
||||||
|
{"node": {"firstName": "Some", "lastName": "Guy"}},
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
assert result.data == expected
|
assert result.data == expected
|
||||||
|
|
||||||
|
@ -1482,7 +1488,9 @@ def test_connection_should_enable_offset_filtering_higher_than_max_limit(
|
||||||
assert not result.errors
|
assert not result.errors
|
||||||
expected = {
|
expected = {
|
||||||
"allReporters": {
|
"allReporters": {
|
||||||
"edges": [{"node": {"firstName": "Some", "lastName": "Lady"}},]
|
"edges": [
|
||||||
|
{"node": {"firstName": "Some", "lastName": "Lady"}},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert result.data == expected
|
assert result.data == expected
|
||||||
|
@ -1549,6 +1557,10 @@ def test_connection_should_allow_offset_filtering_with_after():
|
||||||
result = schema.execute(query, variable_values=dict(after=after))
|
result = schema.execute(query, variable_values=dict(after=after))
|
||||||
assert not result.errors
|
assert not result.errors
|
||||||
expected = {
|
expected = {
|
||||||
"allReporters": {"edges": [{"node": {"firstName": "Jane", "lastName": "Roe"}},]}
|
"allReporters": {
|
||||||
|
"edges": [
|
||||||
|
{"node": {"firstName": "Jane", "lastName": "Roe"}},
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
assert result.data == expected
|
assert result.data == expected
|
||||||
|
|
|
@ -51,7 +51,9 @@ def test_graphql_test_case_op_name(post_mock):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
tc = TestClass()
|
tc = TestClass()
|
||||||
|
tc._pre_setup()
|
||||||
tc.setUpClass()
|
tc.setUpClass()
|
||||||
|
|
||||||
tc.query("query { }", op_name="QueryName")
|
tc.query("query { }", op_name="QueryName")
|
||||||
body = json.loads(post_mock.call_args.args[1])
|
body = json.loads(post_mock.call_args.args[1])
|
||||||
# `operationName` field from https://graphql.org/learn/serving-over-http/#post-request
|
# `operationName` field from https://graphql.org/learn/serving-over-http/#post-request
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
from django.conf.urls import url
|
from django.urls import re_path
|
||||||
|
|
||||||
from ..views import GraphQLView
|
from ..views import GraphQLView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(r"^graphql/batch", GraphQLView.as_view(batch=True)),
|
re_path(r"^graphql/batch", GraphQLView.as_view(batch=True)),
|
||||||
url(r"^graphql", GraphQLView.as_view(graphiql=True)),
|
re_path(r"^graphql", GraphQLView.as_view(graphiql=True)),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
from django.conf.urls import url
|
from django.urls import re_path
|
||||||
|
|
||||||
from ..views import GraphQLView
|
from ..views import GraphQLView
|
||||||
from .schema_view import schema
|
from .schema_view import schema
|
||||||
|
@ -10,4 +10,4 @@ class CustomGraphQLView(GraphQLView):
|
||||||
pretty = True
|
pretty = True
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [url(r"^graphql/inherited/$", CustomGraphQLView.as_view())]
|
urlpatterns = [re_path(r"^graphql/inherited/$", CustomGraphQLView.as_view())]
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
from django.conf.urls import url
|
from django.urls import re_path
|
||||||
|
|
||||||
from ..views import GraphQLView
|
from ..views import GraphQLView
|
||||||
from .schema_view import schema
|
from .schema_view import schema
|
||||||
|
|
||||||
urlpatterns = [url(r"^graphql", GraphQLView.as_view(schema=schema, pretty=True))]
|
urlpatterns = [re_path(r"^graphql", GraphQLView.as_view(schema=schema, pretty=True))]
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import json
|
import json
|
||||||
|
import warnings
|
||||||
|
|
||||||
from django.test import TestCase, Client
|
from django.test import Client, TestCase
|
||||||
|
|
||||||
DEFAULT_GRAPHQL_URL = "/graphql/"
|
DEFAULT_GRAPHQL_URL = "/graphql/"
|
||||||
|
|
||||||
|
@ -68,12 +69,6 @@ class GraphQLTestCase(TestCase):
|
||||||
# URL to graphql endpoint
|
# URL to graphql endpoint
|
||||||
GRAPHQL_URL = DEFAULT_GRAPHQL_URL
|
GRAPHQL_URL = DEFAULT_GRAPHQL_URL
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def setUpClass(cls):
|
|
||||||
super(GraphQLTestCase, cls).setUpClass()
|
|
||||||
|
|
||||||
cls._client = Client()
|
|
||||||
|
|
||||||
def query(self, query, op_name=None, input_data=None, variables=None, headers=None):
|
def query(self, query, op_name=None, input_data=None, variables=None, headers=None):
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
|
@ -99,10 +94,32 @@ class GraphQLTestCase(TestCase):
|
||||||
input_data=input_data,
|
input_data=input_data,
|
||||||
variables=variables,
|
variables=variables,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
client=self._client,
|
client=self.client,
|
||||||
graphql_url=self.GRAPHQL_URL,
|
graphql_url=self.GRAPHQL_URL,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _client(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@_client.getter
|
||||||
|
def _client(self):
|
||||||
|
warnings.warn(
|
||||||
|
"Using `_client` is deprecated in favour of `client`.",
|
||||||
|
PendingDeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
return self.client
|
||||||
|
|
||||||
|
@_client.setter
|
||||||
|
def _client(self, client):
|
||||||
|
warnings.warn(
|
||||||
|
"Using `_client` is deprecated in favour of `client`.",
|
||||||
|
PendingDeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
self.client = client
|
||||||
|
|
||||||
def assertResponseNoErrors(self, resp, msg=None):
|
def assertResponseNoErrors(self, resp, msg=None):
|
||||||
"""
|
"""
|
||||||
Assert that the call went through correctly. 200 means the syntax is ok, if there are no `errors`,
|
Assert that the call went through correctly. 200 means the syntax is ok, if there are no `errors`,
|
||||||
|
|
|
@ -7,4 +7,4 @@ def test_to_const():
|
||||||
|
|
||||||
|
|
||||||
def test_to_const_unicode():
|
def test_to_const_unicode():
|
||||||
assert to_const(u"Skoða þetta unicode stöff") == "SKODA_THETTA_UNICODE_STOFF"
|
assert to_const("Skoða þetta unicode stöff") == "SKODA_THETTA_UNICODE_STOFF"
|
||||||
|
|
45
graphene_django/utils/tests/test_testing.py
Normal file
45
graphene_django/utils/tests/test_testing.py
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from .. import GraphQLTestCase
|
||||||
|
from ...tests.test_types import with_local_registry
|
||||||
|
from django.test import Client
|
||||||
|
|
||||||
|
|
||||||
|
@with_local_registry
|
||||||
|
def test_graphql_test_case_deprecated_client_getter():
|
||||||
|
"""
|
||||||
|
`GraphQLTestCase._client`' getter should raise pending deprecation warning.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class TestClass(GraphQLTestCase):
|
||||||
|
GRAPHQL_SCHEMA = True
|
||||||
|
|
||||||
|
def runTest(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
tc = TestClass()
|
||||||
|
tc._pre_setup()
|
||||||
|
tc.setUpClass()
|
||||||
|
|
||||||
|
with pytest.warns(PendingDeprecationWarning):
|
||||||
|
tc._client
|
||||||
|
|
||||||
|
|
||||||
|
@with_local_registry
|
||||||
|
def test_graphql_test_case_deprecated_client_setter():
|
||||||
|
"""
|
||||||
|
`GraphQLTestCase._client`' setter should raise pending deprecation warning.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class TestClass(GraphQLTestCase):
|
||||||
|
GRAPHQL_SCHEMA = True
|
||||||
|
|
||||||
|
def runTest(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
tc = TestClass()
|
||||||
|
tc._pre_setup()
|
||||||
|
tc.setUpClass()
|
||||||
|
|
||||||
|
with pytest.warns(PendingDeprecationWarning):
|
||||||
|
tc._client = Client()
|
|
@ -3,7 +3,7 @@ import inspect
|
||||||
import six
|
import six
|
||||||
from django.db import connection, models, transaction
|
from django.db import connection, models, transaction
|
||||||
from django.db.models.manager import Manager
|
from django.db.models.manager import Manager
|
||||||
from django.utils.encoding import force_text
|
from django.utils.encoding import force_str
|
||||||
from django.utils.functional import Promise
|
from django.utils.functional import Promise
|
||||||
|
|
||||||
from graphene.utils.str_converters import to_camel_case
|
from graphene.utils.str_converters import to_camel_case
|
||||||
|
@ -26,7 +26,7 @@ def isiterable(value):
|
||||||
|
|
||||||
def _camelize_django_str(s):
|
def _camelize_django_str(s):
|
||||||
if isinstance(s, Promise):
|
if isinstance(s, Promise):
|
||||||
s = force_text(s)
|
s = force_str(s)
|
||||||
return to_camel_case(s) if isinstance(s, six.string_types) else s
|
return to_camel_case(s) if isinstance(s, six.string_types) else s
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -59,23 +59,23 @@ class GraphQLView(View):
|
||||||
graphiql_template = "graphene/graphiql.html"
|
graphiql_template = "graphene/graphiql.html"
|
||||||
|
|
||||||
# Polyfill for window.fetch.
|
# Polyfill for window.fetch.
|
||||||
whatwg_fetch_version = "3.2.0"
|
whatwg_fetch_version = "3.6.2"
|
||||||
whatwg_fetch_sri = "sha256-l6HCB9TT2v89oWbDdo2Z3j+PSVypKNLA/nqfzSbM8mo="
|
whatwg_fetch_sri = "sha256-+pQdxwAcHJdQ3e/9S4RK6g8ZkwdMgFQuHvLuN5uyk5c="
|
||||||
|
|
||||||
# React and ReactDOM.
|
# React and ReactDOM.
|
||||||
react_version = "16.13.1"
|
react_version = "17.0.2"
|
||||||
react_sri = "sha256-yUhvEmYVhZ/GGshIQKArLvySDSh6cdmdcIx0spR3UP4="
|
react_sri = "sha256-Ipu/TQ50iCCVZBUsZyNJfxrDk0E2yhaEIz0vqI+kFG8="
|
||||||
react_dom_sri = "sha256-vFt3l+illeNlwThbDUdoPTqF81M8WNSZZZt3HEjsbSU="
|
react_dom_sri = "sha256-nbMykgB6tsOFJ7OdVmPpdqMFVk4ZsqWocT6issAPUF0="
|
||||||
|
|
||||||
# The GraphiQL React app.
|
# The GraphiQL React app.
|
||||||
graphiql_version = "1.0.3"
|
graphiql_version = "1.4.1"
|
||||||
graphiql_sri = "sha256-VR4buIDY9ZXSyCNFHFNik6uSe0MhigCzgN4u7moCOTk="
|
graphiql_sri = "sha256-JUMkXBQWZMfJ7fGEsTXalxVA10lzKOS9loXdLjwZKi4="
|
||||||
graphiql_css_sri = "sha256-LwqxjyZgqXDYbpxQJ5zLQeNcf7WVNSJ+r8yp2rnWE/E="
|
graphiql_css_sri = "sha256-Md3vdR7PDzWyo/aGfsFVF4tvS5/eAUWuIsg9QHUusCY="
|
||||||
|
|
||||||
# The websocket transport library for subscriptions.
|
# The websocket transport library for subscriptions.
|
||||||
subscriptions_transport_ws_version = "0.9.17"
|
subscriptions_transport_ws_version = "0.9.18"
|
||||||
subscriptions_transport_ws_sri = (
|
subscriptions_transport_ws_sri = (
|
||||||
"sha256-kCDzver8iRaIQ/SVlfrIwxaBQ/avXf9GQFJRLlErBnk="
|
"sha256-i0hAXd4PdJ/cHX3/8tIy/Q/qKiWr5WSTxMFuL9tACkw="
|
||||||
)
|
)
|
||||||
|
|
||||||
schema = None
|
schema = None
|
||||||
|
|
22
setup.py
22
setup.py
|
@ -19,17 +19,16 @@ tests_require = [
|
||||||
"coveralls",
|
"coveralls",
|
||||||
"mock",
|
"mock",
|
||||||
"pytz",
|
"pytz",
|
||||||
"django-filter<2;python_version<'3'",
|
"django-filter>=2",
|
||||||
"django-filter>=2;python_version>='3'",
|
|
||||||
"pytest-django>=3.3.2",
|
"pytest-django>=3.3.2",
|
||||||
] + rest_framework_require
|
] + rest_framework_require
|
||||||
|
|
||||||
|
|
||||||
dev_requires = [
|
dev_requires = [
|
||||||
"black==19.10b0",
|
"black==22.6.0",
|
||||||
"flake8==3.7.9",
|
"flake8>=5,<6",
|
||||||
"flake8-black==0.1.1",
|
"flake8-black==0.3.3",
|
||||||
"flake8-bugbear==20.1.4",
|
"flake8-bugbear==22.7.1",
|
||||||
] + tests_require
|
] + tests_require
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
|
@ -45,25 +44,26 @@ setup(
|
||||||
"Development Status :: 3 - Alpha",
|
"Development Status :: 3 - Alpha",
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
"Topic :: Software Development :: Libraries",
|
"Topic :: Software Development :: Libraries",
|
||||||
"Programming Language :: Python :: 2",
|
|
||||||
"Programming Language :: Python :: 2.7",
|
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: 3.6",
|
"Programming Language :: Python :: 3.6",
|
||||||
"Programming Language :: Python :: 3.7",
|
"Programming Language :: Python :: 3.7",
|
||||||
"Programming Language :: Python :: 3.8",
|
"Programming Language :: Python :: 3.8",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
"Programming Language :: Python :: Implementation :: PyPy",
|
"Programming Language :: Python :: Implementation :: PyPy",
|
||||||
"Framework :: Django",
|
"Framework :: Django",
|
||||||
"Framework :: Django :: 1.11",
|
|
||||||
"Framework :: Django :: 2.2",
|
"Framework :: Django :: 2.2",
|
||||||
"Framework :: Django :: 3.0",
|
"Framework :: Django :: 3.0",
|
||||||
|
"Framework :: Django :: 3.1",
|
||||||
|
"Framework :: Django :: 3.2",
|
||||||
|
"Framework :: Django :: 4.0",
|
||||||
],
|
],
|
||||||
keywords="api graphql protocol rest relay graphene",
|
keywords="api graphql protocol rest relay graphene",
|
||||||
packages=find_packages(exclude=["tests", "examples", "examples.*"]),
|
packages=find_packages(exclude=["tests", "examples", "examples.*"]),
|
||||||
install_requires=[
|
install_requires=[
|
||||||
"six>=1.10.0",
|
|
||||||
"graphene>=2.1.7,<3",
|
"graphene>=2.1.7,<3",
|
||||||
"graphql-core>=2.1.0,<3",
|
"graphql-core>=2.1.0,<3",
|
||||||
"Django>=1.11",
|
"Django>=2.2",
|
||||||
"singledispatch>=3.4.0.3",
|
"singledispatch>=3.4.0.3",
|
||||||
"promise>=2.1",
|
"promise>=2.1",
|
||||||
"text-unidecode",
|
"text-unidecode",
|
||||||
|
|
37
tox.ini
37
tox.ini
|
@ -1,24 +1,26 @@
|
||||||
[tox]
|
[tox]
|
||||||
envlist =
|
envlist =
|
||||||
py{27,35,36,37,38}-django{111,20,21,22,master},
|
py{36,37,38,39}-django22,
|
||||||
py{36,37,38}-django{30,31},
|
py{36,37,38,39}-django{30,31},
|
||||||
|
py{36,37,38,39,310}-django32,
|
||||||
|
py{38,39,310}-django{40,master},
|
||||||
black,flake8
|
black,flake8
|
||||||
|
|
||||||
[gh-actions]
|
[gh-actions]
|
||||||
python =
|
python =
|
||||||
2.7: py27
|
|
||||||
3.6: py36
|
3.6: py36
|
||||||
3.7: py37
|
3.7: py37
|
||||||
3.8: py38
|
3.8: py38
|
||||||
|
3.9: py39
|
||||||
|
3.10: py310
|
||||||
|
|
||||||
[gh-actions:env]
|
[gh-actions:env]
|
||||||
DJANGO =
|
DJANGO =
|
||||||
1.11: django111
|
|
||||||
2.0: django20
|
|
||||||
2.1: django21
|
|
||||||
2.2: django22
|
2.2: django22
|
||||||
3.0: django30
|
3.0: django30
|
||||||
3.1: django31
|
3.1: django31
|
||||||
|
3.2: django32
|
||||||
|
4.0: django40
|
||||||
master: djangomaster
|
master: djangomaster
|
||||||
|
|
||||||
[testenv]
|
[testenv]
|
||||||
|
@ -29,24 +31,17 @@ setenv =
|
||||||
deps =
|
deps =
|
||||||
-e.[test]
|
-e.[test]
|
||||||
psycopg2-binary
|
psycopg2-binary
|
||||||
django111: Django>=1.11,<2.0
|
|
||||||
django111: djangorestframework<3.12
|
|
||||||
django20: Django>=2.0,<2.1
|
|
||||||
django21: Django>=2.1,<2.2
|
|
||||||
django22: Django>=2.2,<3.0
|
django22: Django>=2.2,<3.0
|
||||||
django30: Django>=3.0a1,<3.1
|
django30: Django>=3.0,<3.1
|
||||||
django31: Django>=3.1,<3.2
|
django31: Django>=3.1,<3.2
|
||||||
|
django32: Django>=3.2,<4.0
|
||||||
|
django40: Django>=4.0,<4.1
|
||||||
djangomaster: https://github.com/django/django/archive/master.zip
|
djangomaster: https://github.com/django/django/archive/master.zip
|
||||||
commands = {posargs:py.test --cov=graphene_django graphene_django examples}
|
commands = {posargs:py.test --cov=graphene_django graphene_django examples}
|
||||||
|
|
||||||
[testenv:black]
|
[testenv:pre-commit]
|
||||||
basepython = python3.8
|
basepython = python3.10
|
||||||
deps = -e.[dev]
|
skip_install = true
|
||||||
commands =
|
deps = pre-commit
|
||||||
black --exclude "/migrations/" graphene_django examples setup.py --check
|
|
||||||
|
|
||||||
[testenv:flake8]
|
|
||||||
basepython = python3.8
|
|
||||||
deps = -e.[dev]
|
|
||||||
commands =
|
commands =
|
||||||
flake8 graphene_django examples setup.py
|
pre-commit run --all-files --show-diff-on-failure
|
||||||
|
|
Loading…
Reference in New Issue
Block a user