diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..5ebeb47b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# http://editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{py,rst,ini}] +indent_style = space +indent_size = 4 + diff --git a/.gitignore b/.gitignore index e6447ce7..5b7b0767 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,9 @@ target/ /docs/static/playground/lib /docs/static/playground + +# PyCharm +.idea + +# Databases +*.sqlite3 diff --git a/.travis.yml b/.travis.yml index 6e8e5f31..b6996d24 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,6 @@ language: python sudo: false python: - 2.7 -- 3.3 - 3.4 - 3.5 - pypy @@ -24,8 +23,9 @@ before_install: install: - | if [ "$TEST_TYPE" = build ]; then - pip install --download-cache $HOME/.cache/pip/ pytest pytest-cov coveralls six pytest-django + pip install --download-cache $HOME/.cache/pip/ pytest pytest-cov coveralls six pytest-django django-filter pip install --download-cache $HOME/.cache/pip/ -e .[django] + pip install django==$DJANGO_VERSION python setup.py develop elif [ "$TEST_TYPE" = build_website ]; then pip install --download-cache $HOME/.cache/pip/ -e . @@ -79,6 +79,14 @@ env: matrix: fast_finish: true include: + - python: '2.7' + env: TEST_TYPE=build DJANGO_VERSION=1.6 + - python: '2.7' + env: TEST_TYPE=build DJANGO_VERSION=1.7 + - python: '2.7' + env: TEST_TYPE=build DJANGO_VERSION=1.8 + - python: '2.7' + env: TEST_TYPE=build DJANGO_VERSION=1.9 - python: '2.7' env: TEST_TYPE=build_website - python: '2.7' diff --git a/bin/autolinter b/bin/autolinter index 7f749242..0fc3ccae 100755 --- a/bin/autolinter +++ b/bin/autolinter @@ -1,5 +1,7 @@ #!/bin/bash +# Install the required scripts with +# pip install autoflake autopep8 isort autoflake ./examples/ ./graphene/ -r --remove-unused-variables --remove-all-unused-imports --in-place autopep8 ./examples/ ./graphene/ -r --in-place --experimental --aggressive --max-line-length 120 isort -rc ./examples/ ./graphene/ diff --git a/docs/config.toml b/docs/config.toml index 569605d9..5e39bed6 100644 --- a/docs/config.toml +++ b/docs/config.toml @@ -16,4 +16,5 @@ ga = "UA-12613282-7" "/docs/mutations/", "/docs/basic-types/", "/docs/relay/", + "/docs/filtering/", ] diff --git a/docs/pages/docs/filtering.md b/docs/pages/docs/filtering.md new file mode 100644 index 00000000..95521dd2 --- /dev/null +++ b/docs/pages/docs/filtering.md @@ -0,0 +1,150 @@ +--- +title: Filtering (Django) +description: Details of how to perform filtering +--- + +# Filtering (Django) + +Graphene integrates with [django-filter](https://django-filter.readthedocs.org) +to provide filtering of results. See the +[usage documentation](https://django-filter.readthedocs.org/en/latest/usage.html#the-filter) +for details on the format for `filter_fields`. + +This filtering is only available when using the Django integrations +(i.e. nodes which extend `DjangoNode`). Additionally `django-filter` +is an optional dependency of Graphene. You will need to +install it manually, which can be done as follows: + +```bash +# You'll need to django-filter +pip install django-filter +``` + +**Note: The techniques below are demoed in the +[cookbook example app](https://github.com/graphql-python/graphene/tree/feature/django/examples/cookbook).** + +## Filterable fields + +The `filter_fields` parameter is used to specify the fields which can be filtered upon. +The value specified here is passed directly to `django-filter`, so see the +[filtering documentation](https://django-filter.readthedocs.org/en/latest/usage.html#the-filter) +for full details on the range of options available. + +For example: + +```python +class AnimalNode(DjangoNode): + class Meta: + # Assume you have an Animal model defined with the following fields + model = Animal + filter_fields = ['name', 'genus', 'is_domesticated'] + +class Query(ObjectType): + animal = relay.NodeField(AnimalNode) + all_animals = DjangoFilterConnectionField(AnimalNode) +``` + +You could then perform a query such as: + +```graphql +query { + # Note that fields names become camelcased + allAnimals(genus: "cat", isDomesticated: true) { + edges { + node { + id, + name +}}}} +``` + +You can also make more complex lookup types available: + +```python +class AnimalNode(DjangoNode): + class Meta: + model = Animal + # Provide more complex lookup types + filter_fields = { + 'name': ['exact', 'icontains', 'istartswith'], + 'genus': ['exact'], + 'is_domesticated': ['exact'], + } +``` + +Which you could query as follows: + +```graphql +query { + # Note that fields names become camelcased + allAnimals(nameIcontains: "lion") { + edges { + node { + id, + name +}}}} +``` + +## Orderable fields + +Ordering can also be specified using `filter_order_by`. Like `filter_fields`, +this value is also passed directly to `django-filter` as the `order_by` field. +For full details see the +[order_by documentation](https://django-filter.readthedocs.org/en/latest/usage.html#ordering-using-order-by). + +For example: + +```python +class AnimalNode(DjangoNode): + class Meta: + model = Animal + filter_fields = ['name', 'genus', 'is_domesticated'] + # Either a tuple/list of fields upon which ordering is allowed, or + # True to allow filtering on all fields specified in filter_fields + order_by_fields = True +``` + +You can then control the ordering via the `orderBy` argument: + +```graphql +query { + allAnimals(orderBy: "name") { + edges { + node { + id, + name +}}}} +``` + +## Custom Filtersets + +By default Graphene provides easy access to the most commonly used +features of `django-filter`. This is done by transparently creating a +`django_filters.FilterSet` class for you and passing in the values for +`filter_fields` and `order_by_fields`. + +However, you may find this to be insufficient. In these cases you can +create your own `Filterset` as follows: + +```python +class AnimalNode(DjangoNode): + class Meta: + # Assume you have an Animal model defined with the following fields + model = Animal + filter_fields = ['name', 'genus', 'is_domesticated'] + + +class AnimalFilter(django_filters.FilterSet): + # Do case-insensitive lookups on 'name' + name = django_filters.CharFilter(lookup_type='iexact') + + class Meta: + model = Animal + fields = ['name', 'genus', 'is_domesticated'] + + +class Query(ObjectType): + animal = relay.NodeField(AnimalNode) + # We specify our custom AnimalFilter using the filterset_class param + all_animals = DjangoFilterConnectionField(AnimalNode, + filterset_class=AnimalFilter) +``` diff --git a/docs/pages/docs/quickstart-django.md b/docs/pages/docs/quickstart-django.md index 7ae15113..51167d70 100644 --- a/docs/pages/docs/quickstart-django.md +++ b/docs/pages/docs/quickstart-django.md @@ -1,154 +1,259 @@ --- -title: Django Tutorial +title: Django Quickstart description: A Quick guide to Graphene in Django --- # Django Tutorial -In our previous quickstart page we created a very simple schema. +Graphene has a number of additional features that are designed to make +working with Django simple. -Now we will adapt the schema to automatically map some Django models, -and expose this schema in a `/graphql` API endpoint. +If you need help getting started with django then head over to +Django's getting started page. -## Project setup +First let's create a few simple models... -```bash -# Create the project directory -mkdir tutorial -cd tutorial +**Note: The code in this quickstart is pulled from the +[cookbook example app](https://github.com/graphql-python/graphene/tree/feature/django/examples/cookbook)**. -# Create a virtualenv to isolate our package dependencies locally -virtualenv env -source env/bin/activate # On Windows use `env\Scripts\activate` +## Defining our models -# Install Django and Graphene with Django support -pip install django -pip install graphene[django] -pip install django-graphiql +Before continuing, create the following: -# Set up a new project with a single application -django-admin.py startproject tutorial . # Note the trailing '.' character -django-admin.py startapp quickstart +* A Django project called `cookbook` +* An app within `cookbook` called `ingredients` + +Let's get started with these models: + +```python +# cookbook/ingredients/models.py +from django.db import models + + +class Category(models.Model): + name = models.CharField(max_length=100) + + def __str__(self): + return self.name + + +class Ingredient(models.Model): + name = models.CharField(max_length=100) + notes = models.TextField() + category = models.ForeignKey(Category, related_name='ingredients') + + def __str__(self): + return self.name ``` -Now sync your database for the first time: - -```bash -python manage.py migrate -``` - -We'll also create an initial user named `admin` with a password of `password`. - -```bash -python manage.py createsuperuser -``` - -Once you've set up a database and initial user created and ready to go, open up the app's directory and we'll get coding... - - - ## Schema GraphQL presents your objects to the world as a graph structure rather than a more -heiricarcal structure to which you may be acustomed. In order to create this +hierarchical structure to which you may be accustomed. In order to create this representation, Graphene needs to know about each *type* of object which will appear in the graph. Below we define these as the `UserType` and `GroupType` classes. This graph also has a 'root' through which all access begins. This is the `Query` class below. In this example, we provide the ability to list all users via `all_users`, and the -ability to obtain a single user via `get_user`. +ability to obtain a specific user via `get_user`. -Open `tutorial/quickstart/schema.py` and type the following: +Create `cookbook/ingredients/schema.py` and type the following: + +```python +# cookbook/ingredients/schema.py +from graphene import relay, ObjectType +from graphene.contrib.django.filter import DjangoFilterConnectionField +from graphene.contrib.django.types import DjangoNode + +from cookbook.ingredients.models import Category, Ingredient + + +# Graphene will automatically map the User model's fields onto the UserType. +# This is configured in the UserType's Meta class (as you can see below) +class CategoryNode(DjangoNode): + class Meta: + model = Category + filter_fields = ['name', 'ingredients'] + filter_order_by = ['name'] + + +class IngredientNode(DjangoNode): + class Meta: + model = Ingredient + # Allow for some more advanced filtering here + filter_fields = { + 'name': ['exact', 'icontains', 'istartswith'], + 'notes': ['exact', 'icontains'], + 'category': ['exact'], + 'category__name': ['exact'], + } + filter_order_by = ['name', 'category__name'] + + +class Query(ObjectType): + category = relay.NodeField(CategoryNode) + all_categories = DjangoFilterConnectionField(CategoryNode) + + ingredient = relay.NodeField(IngredientNode) + all_ingredients = DjangoFilterConnectionField(IngredientNode) + + class Meta: + abstract = True +``` + +The filtering functionality is provided by +[django-filter](https://django-filter.readthedocs.org). See the +[usage documentation](https://django-filter.readthedocs.org/en/latest/usage.html#the-filter) +for details on the format for `filter_fields`. + +Note that the above `Query` class is marked as 'abstract'. This is because we +want will now create a project-level query which will combine all our app-level +queries. + +Create the parent project-level `cookbook/schema.py`: ```python import graphene -from graphene.contrib.django import DjangoObjectType -from django.contrib.auth.models import User, Group - -# Graphene will automatically map the User model's fields onto the UserType. -# This is configured in the UserType's Meta class -class UserType(DjangoObjectType): - class Meta: - model = User - only_fields = ('username', 'email', 'groups') +import cookbook.ingredients.schema -class GroupType(DjangoObjectType): - class Meta: - model = Group - only_fields = ('name', ) +class Query(cookbook.ingredients.schema.Query): + # This class will inherit from multiple Queries + # as we begin to add more apps to our project + pass - -class Query(graphene.ObjectType): - all_users = graphene.List(UserType) - get_user = graphene.Field(UserType, - id=graphene.String().NonNull) - get_group = graphene.Field(GroupType, - id=graphene.String().NonNull) - - def resolve_all_users(self, args, info): - return User.objects.all() - - def resolve_get_user(self, args, info): - return User.objects.get(id=args.get('id')) - - def resolve_get_group(self, args, info): - return Group.objects.get(id=args.get('id')) - -schema = graphene.Schema(query=Query) +schema = graphene.Schema(name='Cookbook Schema') +schema.query = Query ``` +You can think of this as being something like your top-level `urls.py` +file (although it currently lacks any namespacing). ## Adding GraphiQL -For having the GraphiQL static assets we need to append `django_graphiql` in `INSTALLED_APPS` in `tutorial/settings.py`: +GraphiQL is a web-based integrated development environment to assist in the +writing and executing of GraphQL queries. It will provide us with a simple +and easy way of testing our cookbook project. + +Add `django_graphiql` to `INSTALLED_APPS` in `cookbook/settings.py`: ```python INSTALLED_APPS = [ - # The other installed apps + ... 'django_graphiql', + + # This will also make the `graphql_schema` management command available + 'graphene.contrib.django', ] ``` ## Creating GraphQL and GraphiQL views -Unlike a RESTful API, there is only a single URL from which a GraphQL is accessed. +Unlike a RESTful API, there is only a single URL from which GraphQL is accessed. Requests to this URL are handled by Graphene's `GraphQLView` view. -Additionally, an interface for navigating this API will be very useful. Graphene -includes the [graphiql](https://github.com/graphql/graphiql) in-browser IDE -which assists in exploring and querying your new API. We’ll add a URL for this too. +Additionally, we'll add a URL for aforementioned GraphiQL, and for the Django admin +interface (the latter can be useful for creating test data). ```python from django.conf.urls import url, include +from django.contrib import admin from django.views.decorators.csrf import csrf_exempt + from graphene.contrib.django.views import GraphQLView -from quickstart.schema import schema +from cookbook.schema import schema -# Wire up our GraphQL schema to /graphql. -# Additionally, we include GraphiQL view for querying easily our schema. urlpatterns = [ + url(r'^admin/', admin.site.urls), url(r'^graphql', csrf_exempt(GraphQLView.as_view(schema=schema))), url(r'^graphiql', include('django_graphiql.urls')), ] ``` +## Load some test data + +Now is a good time to load up some test data. The easiest option will be to +[download the ingredients.json](https://raw.githubusercontent.com/graphql-python/graphene/feature/django/examples/cookbook/cookbook/ingredients/fixtures/ingredients.json) +fixture and place it in +`cookbook/ingredients/fixtures/ingredients.json`. You can then run the following: + +``` +$ python ./manage.py loaddata ingredients + +Installed 6 object(s) from 1 fixture(s) +``` + +Alternatively you can use the Django admin interface to create some data youself. +You'll need to run the development server (see below), and probably create a login +for yourself too (`./manage.py createsuperuser`). + ## Testing our GraphQL schema We're now ready to test the API we've built. Let's fire up the server from the command line. ```bash -python ./manage.py runserver +$ python ./manage.py runserver + +Performing system checks... +Django version 1.9, using settings 'cookbook.settings' +Starting development server at http://127.0.0.1:8000/ +Quit the server with CONTROL-C. ``` -Go to [localhost:8080/graphiql](http://localhost:8080/graphiql) and type your first query! +Go to [localhost:8000/graphiql](http://localhost:8000/graphiql) and type your first query! ```graphql -myQuery { - getUser(id:"1") { - username +query { + allIngredients { + edges { + node { + id, + name + } } + } } ``` + +The above will return the names & IDs for all ingredients. But perhaps you want +a specific ingredient: + +```graphql +query { + # Graphene creates globally unique IDs for all objects. + # You may need to copy this value from the results of the first query + ingredient(id: "SW5ncmVkaWVudE5vZGU6MQ==") { + name + } +} +``` + +You can also get each ingredient for each category: + +```graphql +query { + allCategories { + edges { + node { + name, + + ingredients { + edges { + node { + name +}}}}}}} +``` + +Or you can get only 'meat' ingredients containing the letter 'e': + +```graphql +query { + # You can also use `category: "CATEGORY GLOBAL ID"` + allIngredients(nameIcontains: "e", categoryName: "Meat") { + edges { + node { + name +}}}} +``` diff --git a/examples/cookbook_django/README.md b/examples/cookbook_django/README.md new file mode 100644 index 00000000..206d97c3 --- /dev/null +++ b/examples/cookbook_django/README.md @@ -0,0 +1,64 @@ +Cookbook Example Django Project +=============================== + +This example project demos integration between Graphene and Django. +The project contains two apps, one named `ingredients` and another +named `recepies`. + +Getting started +--------------- + +First you'll need to get the source of the project. Do this by cloning the +whole Graphene repository: + +```bash +# Get the example project code +git clone https://github.com/graphql-python/graphene.git +cd graphene/examples/cookbook +``` + +It is good idea (but not required) to create a virtual environment +for this project. We'll do this using +[virtualenv](http://docs.python-guide.org/en/latest/dev/virtualenvs/) +to keep things simple, +but you may also find something like +[virtualenvwrapper](https://virtualenvwrapper.readthedocs.org/en/latest/) +to be useful: + +```bash +# Create a virtualenv in which we can install the dependencies +virtualenv env +source env/bin/activate +``` + +Now we can install our dependencies: + +```bash +pip install -r requirements.txt +``` + +Now setup our database: + +```bash +# Setup the database +./manage.py migrate + +# Load some example data +./manage.py loaddata ingredients + +# Create an admin user (useful for logging into the admin UI +# at http://127.0.0.1:8000/admin) +./manage.py createsuperuser +``` + +Now you should be ready to start the server: + +```bash +./manage.py runserver +``` + +Now head on over to +[http://127.0.0.1:8000/graphiql](http://127.0.0.1:8000/graphiql) +and run some queries! +(See the [Django quickstart guide](http://graphene-python.org/docs/quickstart-django/) +for some example queries) diff --git a/examples/cookbook_django/cookbook/__init__.py b/examples/cookbook_django/cookbook/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/cookbook_django/cookbook/ingredients/__init__.py b/examples/cookbook_django/cookbook/ingredients/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/cookbook_django/cookbook/ingredients/admin.py b/examples/cookbook_django/cookbook/ingredients/admin.py new file mode 100644 index 00000000..766b23fb --- /dev/null +++ b/examples/cookbook_django/cookbook/ingredients/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin + +from cookbook.ingredients.models import Category, Ingredient + +admin.site.register(Ingredient) +admin.site.register(Category) diff --git a/examples/cookbook_django/cookbook/ingredients/apps.py b/examples/cookbook_django/cookbook/ingredients/apps.py new file mode 100644 index 00000000..21b4b08a --- /dev/null +++ b/examples/cookbook_django/cookbook/ingredients/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class IngredientsConfig(AppConfig): + name = 'cookbook.ingredients' + label = 'ingredients' + verbose_name = 'Ingredients' diff --git a/examples/cookbook_django/cookbook/ingredients/fixtures/ingredients.json b/examples/cookbook_django/cookbook/ingredients/fixtures/ingredients.json new file mode 100644 index 00000000..8625d3c7 --- /dev/null +++ b/examples/cookbook_django/cookbook/ingredients/fixtures/ingredients.json @@ -0,0 +1 @@ +[{"model": "ingredients.category", "pk": 1, "fields": {"name": "Dairy"}}, {"model": "ingredients.category", "pk": 2, "fields": {"name": "Meat"}}, {"model": "ingredients.ingredient", "pk": 1, "fields": {"name": "Eggs", "notes": "Good old eggs", "category": 1}}, {"model": "ingredients.ingredient", "pk": 2, "fields": {"name": "Milk", "notes": "Comes from a cow", "category": 1}}, {"model": "ingredients.ingredient", "pk": 3, "fields": {"name": "Beef", "notes": "Much like milk, this comes from a cow", "category": 2}}, {"model": "ingredients.ingredient", "pk": 4, "fields": {"name": "Chicken", "notes": "Definitely doesn't come from a cow", "category": 2}}] \ No newline at end of file diff --git a/examples/cookbook_django/cookbook/ingredients/migrations/0001_initial.py b/examples/cookbook_django/cookbook/ingredients/migrations/0001_initial.py new file mode 100644 index 00000000..04949239 --- /dev/null +++ b/examples/cookbook_django/cookbook/ingredients/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2015-12-04 18:15 +from __future__ import unicode_literals + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ], + ), + migrations.CreateModel( + name='Ingredient', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('notes', models.TextField()), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ingredients', to='ingredients.Category')), + ], + ), + ] diff --git a/examples/cookbook_django/cookbook/ingredients/migrations/__init__.py b/examples/cookbook_django/cookbook/ingredients/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/cookbook_django/cookbook/ingredients/models.py b/examples/cookbook_django/cookbook/ingredients/models.py new file mode 100644 index 00000000..cffdf1ea --- /dev/null +++ b/examples/cookbook_django/cookbook/ingredients/models.py @@ -0,0 +1,17 @@ +from django.db import models + + +class Category(models.Model): + name = models.CharField(max_length=100) + + def __str__(self): + return self.name + + +class Ingredient(models.Model): + name = models.CharField(max_length=100) + notes = models.TextField() + category = models.ForeignKey(Category, related_name='ingredients') + + def __str__(self): + return self.name diff --git a/examples/cookbook_django/cookbook/ingredients/schema.py b/examples/cookbook_django/cookbook/ingredients/schema.py new file mode 100644 index 00000000..13825267 --- /dev/null +++ b/examples/cookbook_django/cookbook/ingredients/schema.py @@ -0,0 +1,39 @@ +from cookbook.ingredients.models import Category, Ingredient +from graphene import ObjectType, relay +from graphene.contrib.django.filter import DjangoFilterConnectionField +from graphene.contrib.django.types import DjangoNode + + +# Graphene will automatically map the User model's fields onto the UserType. +# This is configured in the UserType's Meta class (as you can see below) +class CategoryNode(DjangoNode): + + class Meta: + model = Category + filter_fields = ['name', 'ingredients'] + filter_order_by = ['name'] + + +class IngredientNode(DjangoNode): + + class Meta: + model = Ingredient + # Allow for some more advanced filtering here + filter_fields = { + 'name': ['exact', 'icontains', 'istartswith'], + 'notes': ['exact', 'icontains'], + 'category': ['exact'], + 'category__name': ['exact'], + } + filter_order_by = ['name', 'category__name'] + + +class Query(ObjectType): + category = relay.NodeField(CategoryNode) + all_categories = DjangoFilterConnectionField(CategoryNode) + + ingredient = relay.NodeField(IngredientNode) + all_ingredients = DjangoFilterConnectionField(IngredientNode) + + class Meta: + abstract = True diff --git a/examples/cookbook_django/cookbook/ingredients/tests.py b/examples/cookbook_django/cookbook/ingredients/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/examples/cookbook_django/cookbook/ingredients/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/examples/cookbook_django/cookbook/ingredients/views.py b/examples/cookbook_django/cookbook/ingredients/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/examples/cookbook_django/cookbook/ingredients/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/examples/cookbook_django/cookbook/recipes/__init__.py b/examples/cookbook_django/cookbook/recipes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/cookbook_django/cookbook/recipes/admin.py b/examples/cookbook_django/cookbook/recipes/admin.py new file mode 100644 index 00000000..862dd4cb --- /dev/null +++ b/examples/cookbook_django/cookbook/recipes/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin + +from cookbook.recipes.models import Recipe, RecipeIngredient + +admin.site.register(Recipe) +admin.site.register(RecipeIngredient) diff --git a/examples/cookbook_django/cookbook/recipes/apps.py b/examples/cookbook_django/cookbook/recipes/apps.py new file mode 100644 index 00000000..1f24f13e --- /dev/null +++ b/examples/cookbook_django/cookbook/recipes/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class RecipesConfig(AppConfig): + name = 'cookbook.recipes' + label = 'recipes' + verbose_name = 'Recipes' diff --git a/examples/cookbook_django/cookbook/recipes/migrations/0001_initial.py b/examples/cookbook_django/cookbook/recipes/migrations/0001_initial.py new file mode 100644 index 00000000..2071afc5 --- /dev/null +++ b/examples/cookbook_django/cookbook/recipes/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2015-12-04 18:20 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('ingredients', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Recipe', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=100)), + ('instructions', models.TextField()), + ], + ), + migrations.CreateModel( + name='RecipeIngredient', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount', models.FloatField()), + ('unit', models.CharField(choices=[('kg', 'Kilograms'), ('l', 'Litres'), ('', 'Units')], max_length=20)), + ('ingredient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='used_by', to='ingredients.Ingredient')), + ('recipes', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='amounts', to='recipes.Recipe')), + ], + ), + ] diff --git a/examples/cookbook_django/cookbook/recipes/migrations/__init__.py b/examples/cookbook_django/cookbook/recipes/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/cookbook_django/cookbook/recipes/models.py b/examples/cookbook_django/cookbook/recipes/models.py new file mode 100644 index 00000000..a767dd23 --- /dev/null +++ b/examples/cookbook_django/cookbook/recipes/models.py @@ -0,0 +1,19 @@ +from django.db import models + +from cookbook.ingredients.models import Ingredient + + +class Recipe(models.Model): + title = models.CharField(max_length=100) + instructions = models.TextField() + + +class RecipeIngredient(models.Model): + recipes = models.ForeignKey(Recipe, related_name='amounts') + ingredient = models.ForeignKey(Ingredient, related_name='used_by') + amount = models.FloatField() + unit = models.CharField(max_length=20, choices=( + ('kg', 'Kilograms'), + ('l', 'Litres'), + ('', 'Units'), + )) diff --git a/examples/cookbook_django/cookbook/recipes/tests.py b/examples/cookbook_django/cookbook/recipes/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/examples/cookbook_django/cookbook/recipes/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/examples/cookbook_django/cookbook/recipes/views.py b/examples/cookbook_django/cookbook/recipes/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/examples/cookbook_django/cookbook/recipes/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/examples/cookbook_django/cookbook/schema.py b/examples/cookbook_django/cookbook/schema.py new file mode 100644 index 00000000..acb53666 --- /dev/null +++ b/examples/cookbook_django/cookbook/schema.py @@ -0,0 +1,9 @@ +import cookbook.ingredients.schema +import graphene + + +class Query(cookbook.ingredients.schema.Query): + pass + +schema = graphene.Schema(name='Cookbook Schema') +schema.query = Query diff --git a/examples/cookbook_django/cookbook/settings.py b/examples/cookbook_django/cookbook/settings.py new file mode 100644 index 00000000..bdc1f1c5 --- /dev/null +++ b/examples/cookbook_django/cookbook/settings.py @@ -0,0 +1,125 @@ +""" +Django settings for cookbook project. + +Generated by 'django-admin startproject' using Django 1.9. + +For more information on this file, see +https://docs.djangoproject.com/en/1.9/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.9/ref/settings/ +""" + +import os + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = '_$=$%eqxk$8ss4n7mtgarw^5$8^d5+c83!vwatr@i_81myb=e4' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django_graphiql', + + 'cookbook.ingredients.apps.IngredientsConfig', + 'cookbook.recipes.apps.RecipesConfig', +] + +MIDDLEWARE_CLASSES = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'cookbook.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'cookbook.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/1.9/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/1.9/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.9/howto/static-files/ + +STATIC_URL = '/static/' diff --git a/examples/cookbook_django/cookbook/urls.py b/examples/cookbook_django/cookbook/urls.py new file mode 100644 index 00000000..e8bc0aa5 --- /dev/null +++ b/examples/cookbook_django/cookbook/urls.py @@ -0,0 +1,12 @@ +from django.conf.urls import include, url +from django.contrib import admin +from django.views.decorators.csrf import csrf_exempt + +from cookbook.schema import schema +from graphene.contrib.django.views import GraphQLView + +urlpatterns = [ + url(r'^admin/', admin.site.urls), + url(r'^graphql', csrf_exempt(GraphQLView.as_view(schema=schema))), + url(r'^graphiql', include('django_graphiql.urls')), +] diff --git a/examples/cookbook_django/cookbook/wsgi.py b/examples/cookbook_django/cookbook/wsgi.py new file mode 100644 index 00000000..954b0a80 --- /dev/null +++ b/examples/cookbook_django/cookbook/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for cookbook project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.9/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cookbook.settings") + +application = get_wsgi_application() diff --git a/examples/cookbook_django/manage.py b/examples/cookbook_django/manage.py new file mode 100755 index 00000000..8d8a34d6 --- /dev/null +++ b/examples/cookbook_django/manage.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cookbook.settings") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/examples/cookbook_django/requirements.txt b/examples/cookbook_django/requirements.txt new file mode 100644 index 00000000..0fd3c2da --- /dev/null +++ b/examples/cookbook_django/requirements.txt @@ -0,0 +1,5 @@ +graphene[django] +django_graphiql +graphql-core +django==1.9 +django-filter==0.11.0 diff --git a/graphene/contrib/django/__init__.py b/graphene/contrib/django/__init__.py index 11720f9f..047fe0a3 100644 --- a/graphene/contrib/django/__init__.py +++ b/graphene/contrib/django/__init__.py @@ -9,4 +9,4 @@ from graphene.contrib.django.fields import ( ) __all__ = ['DjangoObjectType', 'DjangoNode', 'DjangoConnection', - 'DjangoConnectionField', 'DjangoModelField'] + 'DjangoModelField', 'DjangoConnectionField'] diff --git a/graphene/contrib/django/compat.py b/graphene/contrib/django/compat.py new file mode 100644 index 00000000..a5b444c7 --- /dev/null +++ b/graphene/contrib/django/compat.py @@ -0,0 +1,15 @@ +from django.db import models + +try: + UUIDField = models.UUIDField +except AttributeError: + # Improved compatibility for Django 1.6 + class UUIDField(object): + pass + +try: + from django.db.models.related import RelatedObject +except: + # Improved compatibility for Django 1.6 + class RelatedObject(object): + pass diff --git a/graphene/contrib/django/converter.py b/graphene/contrib/django/converter.py index adabdece..ffdcfc5a 100644 --- a/graphene/contrib/django/converter.py +++ b/graphene/contrib/django/converter.py @@ -1,15 +1,10 @@ from django.db import models -from singledispatch import singledispatch from ...core.types.scalars import ID, Boolean, Float, Int, String -from .fields import ConnectionOrListField, DjangoModelField +from .compat import RelatedObject, UUIDField +from .utils import get_related_model, import_single_dispatch -try: - UUIDField = models.UUIDField -except AttributeError: - # Improved compatibility for Django 1.6 - class UUIDField(object): - pass +singledispatch = import_single_dispatch() @singledispatch @@ -64,11 +59,21 @@ def convert_field_to_float(field): @convert_django_field.register(models.ManyToManyField) @convert_django_field.register(models.ManyToOneRel) def convert_field_to_list_or_connection(field): - model_field = DjangoModelField(field.related_model) + from .fields import DjangoModelField, ConnectionOrListField + model_field = DjangoModelField(get_related_model(field)) + return ConnectionOrListField(model_field) + + +# For Django 1.6 +@convert_django_field.register(RelatedObject) +def convert_relatedfield_to_djangomodel(field): + from .fields import DjangoModelField, ConnectionOrListField + model_field = DjangoModelField(field.model) return ConnectionOrListField(model_field) @convert_django_field.register(models.OneToOneField) @convert_django_field.register(models.ForeignKey) def convert_field_to_djangomodel(field): - return DjangoModelField(field.related_model, description=field.help_text) + from .fields import DjangoModelField + return DjangoModelField(get_related_model(field), description=field.help_text) diff --git a/graphene/contrib/django/debug/plugin.py b/graphene/contrib/django/debug/plugin.py index 86f8da58..70cd6741 100644 --- a/graphene/contrib/django/debug/plugin.py +++ b/graphene/contrib/django/debug/plugin.py @@ -2,8 +2,8 @@ from contextlib import contextmanager from django.db import connections -from ....core.types import Field from ....core.schema import GraphQLSchema +from ....core.types import Field from ....plugins import Plugin from .sql.tracking import unwrap_cursor, wrap_cursor from .sql.types import DjangoDebugSQL diff --git a/graphene/contrib/django/debug/sql/types.py b/graphene/contrib/django/debug/sql/types.py index 5df5e9d8..995aeaa2 100644 --- a/graphene/contrib/django/debug/sql/types.py +++ b/graphene/contrib/django/debug/sql/types.py @@ -1,4 +1,4 @@ -from .....core import Float, ObjectType, String, Boolean +from .....core import Boolean, Float, ObjectType, String class DjangoDebugSQL(ObjectType): diff --git a/graphene/contrib/django/fields.py b/graphene/contrib/django/fields.py index e12fab3a..5c4f034e 100644 --- a/graphene/contrib/django/fields.py +++ b/graphene/contrib/django/fields.py @@ -1,33 +1,53 @@ -import warnings - from ...core.exceptions import SkipField from ...core.fields import Field from ...core.types.base import FieldType from ...core.types.definitions import List from ...relay import ConnectionField from ...relay.utils import is_node -from .utils import get_type_for_model +from .utils import get_type_for_model, maybe_queryset class DjangoConnectionField(ConnectionField): def __init__(self, *args, **kwargs): - cls = self.__class__ - warnings.warn("Using {} will be not longer supported." - " Use relay.ConnectionField instead".format(cls.__name__), - FutureWarning) + self.on = kwargs.pop('on', False) return super(DjangoConnectionField, self).__init__(*args, **kwargs) + @property + def model(self): + return self.type._meta.model + + def get_manager(self): + if self.on: + return getattr(self.model, self.on) + else: + return self.model._default_manager + + def get_queryset(self, resolved_qs, args, info): + return resolved_qs + + def from_list(self, connection_type, resolved, args, info): + if not resolved: + resolved = self.get_manager() + resolved_qs = maybe_queryset(resolved) + qs = self.get_queryset(resolved_qs, args, info) + return super(DjangoConnectionField, self).from_list(connection_type, qs, args, info) + class ConnectionOrListField(Field): def internal_type(self, schema): + from .filter.fields import DjangoFilterConnectionField + model_field = self.type field_object_type = model_field.get_object_type(schema) if not field_object_type: raise SkipField() if is_node(field_object_type): - field = ConnectionField(field_object_type) + if field_object_type._meta.filter_fields: + field = DjangoFilterConnectionField(field_object_type) + else: + field = DjangoConnectionField(field_object_type) else: field = Field(List(field_object_type)) field.contribute_to_class(self.object_type, self.attname) diff --git a/graphene/contrib/django/filter/__init__.py b/graphene/contrib/django/filter/__init__.py new file mode 100644 index 00000000..51a04b7e --- /dev/null +++ b/graphene/contrib/django/filter/__init__.py @@ -0,0 +1,13 @@ +from graphene.contrib.django.utils import DJANGO_FILTER_INSTALLED + +if not DJANGO_FILTER_INSTALLED: + raise Exception( + "Use of django filtering requires the django-filter package " + "be installed. You can do so using `pip install django-filter`" + ) + +from .fields import DjangoFilterConnectionField +from .filterset import GrapheneFilterSet, GlobalIDFilter, GlobalIDMultipleChoiceFilter + +__all__ = ['DjangoFilterConnectionField', 'GrapheneFilterSet', + 'GlobalIDFilter', 'GlobalIDMultipleChoiceFilter'] diff --git a/graphene/contrib/django/filter/fields.py b/graphene/contrib/django/filter/fields.py new file mode 100644 index 00000000..d8457fa8 --- /dev/null +++ b/graphene/contrib/django/filter/fields.py @@ -0,0 +1,36 @@ +from ..fields import DjangoConnectionField +from .utils import get_filtering_args_from_filterset, get_filterset_class + + +class DjangoFilterConnectionField(DjangoConnectionField): + + def __init__(self, type, fields=None, order_by=None, + extra_filter_meta=None, filterset_class=None, + *args, **kwargs): + + self.order_by = order_by or type._meta.filter_order_by + self.fields = fields or type._meta.filter_fields + meta = dict(model=type._meta.model, + fields=self.fields, + order_by=self.order_by) + if extra_filter_meta: + meta.update(extra_filter_meta) + self.filterset_class = get_filterset_class(filterset_class, **meta) + self.filtering_args = get_filtering_args_from_filterset(self.filterset_class, type) + kwargs.setdefault('args', {}) + kwargs['args'].update(**self.filtering_args) + super(DjangoFilterConnectionField, self).__init__(type, *args, **kwargs) + + def get_queryset(self, qs, args, info): + filterset_class = self.filterset_class + filter_kwargs = self.get_filter_kwargs(args) + order = self.get_order(args) + if order: + qs = qs.order_by(order) + return filterset_class(data=filter_kwargs, queryset=qs) + + def get_filter_kwargs(self, args): + return {k: v for k, v in args.items() if k in self.filtering_args} + + def get_order(self, args): + return args.get('order_by', None) diff --git a/graphene/contrib/django/filter/filterset.py b/graphene/contrib/django/filter/filterset.py new file mode 100644 index 00000000..70f776be --- /dev/null +++ b/graphene/contrib/django/filter/filterset.py @@ -0,0 +1,116 @@ +import six +from django.conf import settings +from django.db import models +from django.utils.text import capfirst +from django_filters import Filter, MultipleChoiceFilter +from django_filters.filterset import FilterSet, FilterSetMetaclass +from graphql_relay.node.node import from_global_id + +from graphene.contrib.django.forms import (GlobalIDFormField, + GlobalIDMultipleChoiceField) + + +class GlobalIDFilter(Filter): + field_class = GlobalIDFormField + + def filter(self, qs, value): + gid = from_global_id(value) + return super(GlobalIDFilter, self).filter(qs, gid.id) + + +class GlobalIDMultipleChoiceFilter(MultipleChoiceFilter): + field_class = GlobalIDMultipleChoiceField + + def filter(self, qs, value): + gids = [from_global_id(v).id for v in value] + return super(GlobalIDMultipleChoiceFilter, self).filter(qs, gids) + + +ORDER_BY_FIELD = getattr(settings, 'GRAPHENE_ORDER_BY_FIELD', 'order_by') + + +GRAPHENE_FILTER_SET_OVERRIDES = { + models.AutoField: { + 'filter_class': GlobalIDFilter, + }, + models.OneToOneField: { + 'filter_class': GlobalIDFilter, + }, + models.ForeignKey: { + 'filter_class': GlobalIDFilter, + }, + models.ManyToManyField: { + 'filter_class': GlobalIDMultipleChoiceFilter, + } +} + + +class GrapheneFilterSetMetaclass(FilterSetMetaclass): + + def __new__(cls, name, bases, attrs): + new_class = super(GrapheneFilterSetMetaclass, cls).__new__(cls, name, bases, attrs) + # Customise the filter_overrides for Graphene + for k, v in GRAPHENE_FILTER_SET_OVERRIDES.items(): + new_class.filter_overrides.setdefault(k, v) + return new_class + + +class GrapheneFilterSetMixin(object): + order_by_field = ORDER_BY_FIELD + + @classmethod + def filter_for_reverse_field(cls, f, name): + """Handles retrieving filters for reverse relationships + + We override the default implementation so that we can handle + Global IDs (the default implementation expects database + primary keys) + """ + rel = f.field.rel + default = { + 'name': name, + 'label': capfirst(rel.related_name) + } + if rel.multiple: + # For to-many relationships + return GlobalIDMultipleChoiceFilter(**default) + else: + # For to-one relationships + return GlobalIDFilter(**default) + + +class GrapheneFilterSet(six.with_metaclass(GrapheneFilterSetMetaclass, GrapheneFilterSetMixin, FilterSet)): + """ Base class for FilterSets used by Graphene + + You shouldn't usually need to use this class. The + DjangoFilterConnectionField will wrap FilterSets with this class as + necessary + """ + + +def setup_filterset(filterset_class): + """ Wrap a provided filterset in Graphene-specific functionality + """ + return type( + 'Graphene{}'.format(filterset_class.__name__), + (six.with_metaclass(GrapheneFilterSetMetaclass, GrapheneFilterSetMixin, filterset_class),), + {}, + ) + + +def custom_filterset_factory(model, filterset_base_class=GrapheneFilterSet, + **meta): + """ Create a filterset for the given model using the provided meta data + """ + meta.update({ + 'model': model, + }) + meta_class = type(str('Meta'), (object,), meta) + filterset = type( + str('%sFilterSet' % model._meta.object_name), + (filterset_base_class,), + { + 'Meta': meta_class + } + ) + return filterset diff --git a/graphene/contrib/django/filter/tests/__init__.py b/graphene/contrib/django/filter/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/graphene/contrib/django/filter/tests/filters.py b/graphene/contrib/django/filter/tests/filters.py new file mode 100644 index 00000000..bccd72d5 --- /dev/null +++ b/graphene/contrib/django/filter/tests/filters.py @@ -0,0 +1,31 @@ +import django_filters + +from graphene.contrib.django.tests.models import Article, Pet, Reporter + + +class ArticleFilter(django_filters.FilterSet): + + class Meta: + model = Article + fields = { + 'headline': ['exact', 'icontains'], + 'pub_date': ['gt', 'lt', 'exact'], + 'reporter': ['exact'], + } + order_by = True + + +class ReporterFilter(django_filters.FilterSet): + + class Meta: + model = Reporter + fields = ['first_name', 'last_name', 'email', 'pets'] + order_by = False + + +class PetFilter(django_filters.FilterSet): + + class Meta: + model = Pet + fields = ['name'] + order_by = False diff --git a/graphene/contrib/django/filter/tests/test_fields.py b/graphene/contrib/django/filter/tests/test_fields.py new file mode 100644 index 00000000..56d69dc8 --- /dev/null +++ b/graphene/contrib/django/filter/tests/test_fields.py @@ -0,0 +1,287 @@ +from datetime import datetime + +import pytest + +from graphene import ObjectType, Schema +from graphene.contrib.django import DjangoNode +from graphene.contrib.django.forms import (GlobalIDFormField, + GlobalIDMultipleChoiceField) +from graphene.contrib.django.tests.models import Article, Pet, Reporter +from graphene.contrib.django.utils import DJANGO_FILTER_INSTALLED +from graphene.relay import NodeField + +pytestmark = [] +if DJANGO_FILTER_INSTALLED: + import django_filters + from graphene.contrib.django.filter import (GlobalIDFilter, DjangoFilterConnectionField, + GlobalIDMultipleChoiceFilter) + from graphene.contrib.django.filter.tests.filters import ArticleFilter, PetFilter +else: + pytestmark.append(pytest.mark.skipif(True, reason='django_filters not installed')) + +pytestmark.append(pytest.mark.django_db) + + +class ArticleNode(DjangoNode): + + class Meta: + model = Article + + +class ReporterNode(DjangoNode): + + class Meta: + model = Reporter + + +class PetNode(DjangoNode): + + class Meta: + model = Pet + +schema = Schema() + + +def assert_arguments(field, *arguments): + ignore = ('after', 'before', 'first', 'last', 'orderBy') + actual = [ + name + for name in schema.T(field.arguments) + if name not in ignore and not name.startswith('_') + ] + assert set(arguments) == set(actual), \ + 'Expected arguments ({}) did not match actual ({})'.format( + arguments, + actual + ) + + +def assert_orderable(field): + assert 'orderBy' in schema.T(field.arguments), \ + 'Field cannot be ordered' + + +def assert_not_orderable(field): + assert 'orderBy' not in schema.T(field.arguments), \ + 'Field can be ordered' + + +def test_filter_explicit_filterset_arguments(): + field = DjangoFilterConnectionField(ArticleNode, filterset_class=ArticleFilter) + assert_arguments(field, + 'headline', 'headlineIcontains', + 'pubDate', 'pubDateGt', 'pubDateLt', + 'reporter', + ) + + +def test_filter_shortcut_filterset_arguments_list(): + field = DjangoFilterConnectionField(ArticleNode, fields=['pub_date', 'reporter']) + assert_arguments(field, + 'pubDate', + 'reporter', + ) + + +def test_filter_shortcut_filterset_arguments_dict(): + field = DjangoFilterConnectionField(ArticleNode, fields={ + 'headline': ['exact', 'icontains'], + 'reporter': ['exact'], + }) + assert_arguments(field, + 'headline', 'headlineIcontains', + 'reporter', + ) + + +def test_filter_explicit_filterset_orderable(): + field = DjangoFilterConnectionField(ArticleNode, filterset_class=ArticleFilter) + assert_orderable(field) + + +def test_filter_shortcut_filterset_orderable_true(): + field = DjangoFilterConnectionField(ArticleNode, order_by=True) + assert_orderable(field) + + +def test_filter_shortcut_filterset_orderable_headline(): + field = DjangoFilterConnectionField(ArticleNode, order_by=['headline']) + assert_orderable(field) + + +def test_filter_explicit_filterset_not_orderable(): + field = DjangoFilterConnectionField(PetNode, filterset_class=PetFilter) + assert_not_orderable(field) + + +def test_filter_shortcut_filterset_extra_meta(): + field = DjangoFilterConnectionField(ArticleNode, extra_filter_meta={ + 'order_by': True + }) + assert_orderable(field) + + +def test_filter_filterset_information_on_meta(): + class ReporterFilterNode(DjangoNode): + + class Meta: + model = Reporter + filter_fields = ['first_name', 'articles'] + filter_order_by = True + + field = DjangoFilterConnectionField(ReporterFilterNode) + assert_arguments(field, 'firstName', 'articles') + assert_orderable(field) + + +def test_filter_filterset_information_on_meta_related(): + class ReporterFilterNode(DjangoNode): + + class Meta: + model = Reporter + filter_fields = ['first_name', 'articles'] + filter_order_by = True + + class ArticleFilterNode(DjangoNode): + + class Meta: + model = Article + filter_fields = ['headline', 'reporter'] + filter_order_by = True + + class Query(ObjectType): + all_reporters = DjangoFilterConnectionField(ReporterFilterNode) + all_articles = DjangoFilterConnectionField(ArticleFilterNode) + reporter = NodeField(ReporterFilterNode) + article = NodeField(ArticleFilterNode) + + schema = Schema(query=Query) + schema.schema # Trigger the schema loading + articles_field = schema.get_type('ReporterFilterNode')._meta.fields_map['articles'] + assert_arguments(articles_field, 'headline', 'reporter') + assert_orderable(articles_field) + + +def test_filter_filterset_related_results(): + class ReporterFilterNode(DjangoNode): + + class Meta: + model = Reporter + filter_fields = ['first_name', 'articles'] + filter_order_by = True + + class ArticleFilterNode(DjangoNode): + + class Meta: + model = Article + filter_fields = ['headline', 'reporter'] + filter_order_by = True + + class Query(ObjectType): + all_reporters = DjangoFilterConnectionField(ReporterFilterNode) + all_articles = DjangoFilterConnectionField(ArticleFilterNode) + reporter = NodeField(ReporterFilterNode) + article = NodeField(ArticleFilterNode) + + r1 = Reporter.objects.create(first_name='r1', last_name='r1', email='r1@test.com') + r2 = Reporter.objects.create(first_name='r2', last_name='r2', email='r2@test.com') + Article.objects.create(headline='a1', pub_date=datetime.now(), reporter=r1) + Article.objects.create(headline='a2', pub_date=datetime.now(), reporter=r2) + + query = ''' + query { + allReporters { + edges { + node { + articles { + edges { + node { + headline + } + } + } + } + } + } + } + ''' + schema = Schema(query=Query) + result = schema.execute(query) + assert not result.errors + # We should only get back a single article for each reporter + assert len(result.data['allReporters']['edges'][0]['node']['articles']['edges']) == 1 + assert len(result.data['allReporters']['edges'][1]['node']['articles']['edges']) == 1 + + +def test_global_id_field_implicit(): + field = DjangoFilterConnectionField(ArticleNode, fields=['id']) + filterset_class = field.filterset_class + id_filter = filterset_class.base_filters['id'] + assert isinstance(id_filter, GlobalIDFilter) + assert id_filter.field_class == GlobalIDFormField + + +def test_global_id_field_explicit(): + class ArticleIdFilter(django_filters.FilterSet): + + class Meta: + model = Article + fields = ['id'] + + field = DjangoFilterConnectionField(ArticleNode, filterset_class=ArticleIdFilter) + filterset_class = field.filterset_class + id_filter = filterset_class.base_filters['id'] + assert isinstance(id_filter, GlobalIDFilter) + assert id_filter.field_class == GlobalIDFormField + + +def test_global_id_field_relation(): + field = DjangoFilterConnectionField(ArticleNode, fields=['reporter']) + filterset_class = field.filterset_class + id_filter = filterset_class.base_filters['reporter'] + assert isinstance(id_filter, GlobalIDFilter) + assert id_filter.field_class == GlobalIDFormField + + +def test_global_id_multiple_field_implicit(): + field = DjangoFilterConnectionField(ReporterNode, fields=['pets']) + filterset_class = field.filterset_class + multiple_filter = filterset_class.base_filters['pets'] + assert isinstance(multiple_filter, GlobalIDMultipleChoiceFilter) + assert multiple_filter.field_class == GlobalIDMultipleChoiceField + + +def test_global_id_multiple_field_explicit(): + class ReporterPetsFilter(django_filters.FilterSet): + + class Meta: + model = Reporter + fields = ['pets'] + + field = DjangoFilterConnectionField(ReporterNode, filterset_class=ReporterPetsFilter) + filterset_class = field.filterset_class + multiple_filter = filterset_class.base_filters['pets'] + assert isinstance(multiple_filter, GlobalIDMultipleChoiceFilter) + assert multiple_filter.field_class == GlobalIDMultipleChoiceField + + +def test_global_id_multiple_field_implicit_reverse(): + field = DjangoFilterConnectionField(ReporterNode, fields=['articles']) + filterset_class = field.filterset_class + multiple_filter = filterset_class.base_filters['articles'] + assert isinstance(multiple_filter, GlobalIDMultipleChoiceFilter) + assert multiple_filter.field_class == GlobalIDMultipleChoiceField + + +def test_global_id_multiple_field_explicit_reverse(): + class ReporterPetsFilter(django_filters.FilterSet): + + class Meta: + model = Reporter + fields = ['articles'] + + field = DjangoFilterConnectionField(ReporterNode, filterset_class=ReporterPetsFilter) + filterset_class = field.filterset_class + multiple_filter = filterset_class.base_filters['articles'] + assert isinstance(multiple_filter, GlobalIDMultipleChoiceFilter) + assert multiple_filter.field_class == GlobalIDMultipleChoiceField diff --git a/graphene/contrib/django/filter/utils.py b/graphene/contrib/django/filter/utils.py new file mode 100644 index 00000000..5071ddc4 --- /dev/null +++ b/graphene/contrib/django/filter/utils.py @@ -0,0 +1,31 @@ +import six + +from ....core.types import Argument, String +from .filterset import custom_filterset_factory, setup_filterset + + +def get_filtering_args_from_filterset(filterset_class, type): + """ Inspect a FilterSet and produce the arguments to pass to + a Graphene Field. These arguments will be available to + filter against in the GraphQL + """ + from graphene.contrib.django.form_converter import convert_form_field + + args = {} + for name, filter_field in six.iteritems(filterset_class.base_filters): + field_type = Argument(convert_form_field(filter_field.field)) + args[name] = field_type + + # Also add the 'order_by' field + if filterset_class._meta.order_by: + args[filterset_class.order_by_field] = Argument(String()) + return args + + +def get_filterset_class(filterset_class, **meta): + """Get the class to be used as the FilterSet""" + if filterset_class: + # If were given a FilterSet class, then set it up and + # return it + return setup_filterset(filterset_class) + return custom_filterset_factory(**meta) diff --git a/graphene/contrib/django/form_converter.py b/graphene/contrib/django/form_converter.py new file mode 100644 index 00000000..de2a40d8 --- /dev/null +++ b/graphene/contrib/django/form_converter.py @@ -0,0 +1,73 @@ +from django import forms +from django.forms.fields import BaseTemporalField + +from graphene import ID, Boolean, Float, Int, String +from graphene.contrib.django.forms import (GlobalIDFormField, + GlobalIDMultipleChoiceField) +from graphene.contrib.django.utils import import_single_dispatch +from graphene.core.types.definitions import List + +singledispatch = import_single_dispatch() + +try: + UUIDField = forms.UUIDField +except AttributeError: + class UUIDField(object): + pass + + +@singledispatch +def convert_form_field(field): + raise Exception( + "Don't know how to convert the Django form field %s (%s) " + "to Graphene type" % + (field, field.__class__) + ) + + +@convert_form_field.register(BaseTemporalField) +@convert_form_field.register(forms.CharField) +@convert_form_field.register(forms.EmailField) +@convert_form_field.register(forms.SlugField) +@convert_form_field.register(forms.URLField) +@convert_form_field.register(forms.ChoiceField) +@convert_form_field.register(forms.RegexField) +@convert_form_field.register(forms.Field) +@convert_form_field.register(UUIDField) +def convert_form_field_to_string(field): + return String(description=field.help_text) + + +@convert_form_field.register(forms.IntegerField) +@convert_form_field.register(forms.NumberInput) +def convert_form_field_to_int(field): + return Int(description=field.help_text) + + +@convert_form_field.register(forms.BooleanField) +@convert_form_field.register(forms.NullBooleanField) +def convert_form_field_to_boolean(field): + return Boolean(description=field.help_text, required=True) + + +@convert_form_field.register(forms.NullBooleanField) +def convert_form_field_to_nullboolean(field): + return Boolean(description=field.help_text) + + +@convert_form_field.register(forms.DecimalField) +@convert_form_field.register(forms.FloatField) +def convert_form_field_to_float(field): + return Float(description=field.help_text) + + +@convert_form_field.register(forms.ModelMultipleChoiceField) +@convert_form_field.register(GlobalIDMultipleChoiceField) +def convert_form_field_to_list(field): + return List(ID()) + + +@convert_form_field.register(forms.ModelChoiceField) +@convert_form_field.register(GlobalIDFormField) +def convert_form_field_to_id(field): + return ID() diff --git a/graphene/contrib/django/forms.py b/graphene/contrib/django/forms.py new file mode 100644 index 00000000..d8062e39 --- /dev/null +++ b/graphene/contrib/django/forms.py @@ -0,0 +1,41 @@ +import binascii + +from django.core.exceptions import ValidationError +from django.forms import CharField, Field, IntegerField, MultipleChoiceField +from django.utils.translation import ugettext_lazy as _ +from graphql_relay import from_global_id + + +class GlobalIDFormField(Field): + default_error_messages = { + 'invalid': _('Invalid ID specified.'), + } + + def clean(self, value): + if not value and not self.required: + return None + + try: + gid = from_global_id(value) + except (TypeError, ValueError, UnicodeDecodeError, binascii.Error): + raise ValidationError(self.error_messages['invalid']) + + try: + IntegerField().clean(gid.id) + CharField().clean(gid.type) + except ValidationError: + raise ValidationError(self.error_messages['invalid']) + + return value + + +class GlobalIDMultipleChoiceField(MultipleChoiceField): + default_error_messages = { + 'invalid_choice': _('One of the specified IDs was invalid (%(value)s).'), + 'invalid_list': _('Enter a list of values.'), + } + + def valid_value(self, value): + # Clean will raise a validation error if there is a problem + GlobalIDFormField().clean(value) + return True diff --git a/graphene/contrib/django/management/commands/graphql_schema.py b/graphene/contrib/django/management/commands/graphql_schema.py index 35eb2772..57174e01 100644 --- a/graphene/contrib/django/management/commands/graphql_schema.py +++ b/graphene/contrib/django/management/commands/graphql_schema.py @@ -1,8 +1,8 @@ -from django.core.management.base import BaseCommand, CommandError - import importlib import json +from django.core.management.base import BaseCommand, CommandError + class Command(BaseCommand): help = 'Dump Graphene schema JSON to file' diff --git a/graphene/contrib/django/options.py b/graphene/contrib/django/options.py index 61dd37a3..dbd88aca 100644 --- a/graphene/contrib/django/options.py +++ b/graphene/contrib/django/options.py @@ -1,9 +1,13 @@ from ...core.classtypes.objecttype import ObjectTypeOptions from ...relay.types import Node from ...relay.utils import is_node +from .utils import DJANGO_FILTER_INSTALLED VALID_ATTRS = ('model', 'only_fields', 'exclude_fields') +if DJANGO_FILTER_INSTALLED: + VALID_ATTRS += ('filter_fields', 'filter_order_by') + class DjangoOptions(ObjectTypeOptions): @@ -13,6 +17,8 @@ class DjangoOptions(ObjectTypeOptions): self.valid_attrs += VALID_ATTRS self.only_fields = None self.exclude_fields = [] + self.filter_fields = None + self.filter_order_by = None def contribute_to_class(self, cls, name): super(DjangoOptions, self).contribute_to_class(cls, name) diff --git a/graphene/contrib/django/tests/test_converter.py b/graphene/contrib/django/tests/test_converter.py index b7a1e198..59f3aa29 100644 --- a/graphene/contrib/django/tests/test_converter.py +++ b/graphene/contrib/django/tests/test_converter.py @@ -9,8 +9,8 @@ from graphene.contrib.django.fields import (ConnectionOrListField, from .models import Article, Reporter -def assert_conversion(django_field, graphene_field, *args): - field = django_field(*args, help_text='Custom Help Text') +def assert_conversion(django_field, graphene_field, *args, **kwargs): + field = django_field(help_text='Custom Help Text', *args, **kwargs) graphene_type = convert_django_field(field) assert isinstance(graphene_type, graphene_field) field = graphene_type.as_field() @@ -53,7 +53,7 @@ def test_should_ipaddress_convert_string(): def test_should_auto_convert_id(): - assert_conversion(models.AutoField, graphene.ID) + assert_conversion(models.AutoField, graphene.ID, primary_key=True) def test_should_positive_integer_convert_int(): @@ -98,7 +98,10 @@ def test_should_manytomany_convert_connectionorlist(): def test_should_manytoone_convert_connectionorlist(): - graphene_type = convert_django_field(Reporter.articles.related) + # Django 1.9 uses 'rel', <1.9 uses 'related + related = getattr(Reporter.articles, 'rel', None) or \ + getattr(Reporter.articles, 'related') + graphene_type = convert_django_field(related) assert isinstance(graphene_type, ConnectionOrListField) assert isinstance(graphene_type.type, DjangoModelField) assert graphene_type.type.model == Article diff --git a/graphene/contrib/django/tests/test_form_converter.py b/graphene/contrib/django/tests/test_form_converter.py new file mode 100644 index 00000000..44d9bec3 --- /dev/null +++ b/graphene/contrib/django/tests/test_form_converter.py @@ -0,0 +1,103 @@ +from django import forms +from py.test import raises + +import graphene +from graphene.contrib.django.form_converter import convert_form_field +from graphene.core.types import ID, List + +from .models import Reporter + + +def assert_conversion(django_field, graphene_field, *args): + field = django_field(*args, help_text='Custom Help Text') + graphene_type = convert_form_field(field) + assert isinstance(graphene_type, graphene_field) + field = graphene_type.as_field() + assert field.description == 'Custom Help Text' + return field + + +def test_should_unknown_django_field_raise_exception(): + with raises(Exception) as excinfo: + convert_form_field(None) + assert 'Don\'t know how to convert the Django form field' in str(excinfo.value) + + +def test_should_date_convert_string(): + assert_conversion(forms.DateField, graphene.String) + + +def test_should_time_convert_string(): + assert_conversion(forms.TimeField, graphene.String) + + +def test_should_date_time_convert_string(): + assert_conversion(forms.DateTimeField, graphene.String) + + +def test_should_char_convert_string(): + assert_conversion(forms.CharField, graphene.String) + + +def test_should_email_convert_string(): + assert_conversion(forms.EmailField, graphene.String) + + +def test_should_slug_convert_string(): + assert_conversion(forms.SlugField, graphene.String) + + +def test_should_url_convert_string(): + assert_conversion(forms.URLField, graphene.String) + + +def test_should_choice_convert_string(): + assert_conversion(forms.ChoiceField, graphene.String) + + +def test_should_base_field_convert_string(): + assert_conversion(forms.Field, graphene.String) + + +def test_should_regex_convert_string(): + assert_conversion(forms.RegexField, graphene.String, '[0-9]+') + + +def test_should_uuid_convert_string(): + if hasattr(forms, 'UUIDField'): + assert_conversion(forms.UUIDField, graphene.String) + + +def test_should_integer_convert_int(): + assert_conversion(forms.IntegerField, graphene.Int) + + +def test_should_boolean_convert_boolean(): + field = assert_conversion(forms.BooleanField, graphene.Boolean) + assert field.required is True + + +def test_should_nullboolean_convert_boolean(): + field = assert_conversion(forms.NullBooleanField, graphene.Boolean) + assert field.required is False + + +def test_should_float_convert_float(): + assert_conversion(forms.FloatField, graphene.Float) + + +def test_should_decimal_convert_float(): + assert_conversion(forms.DecimalField, graphene.Float) + + +def test_should_multiple_choice_convert_connectionorlist(): + field = forms.ModelMultipleChoiceField(Reporter.objects.all()) + graphene_type = convert_form_field(field) + assert isinstance(graphene_type, List) + assert isinstance(graphene_type.of_type, ID) + + +def test_should_manytoone_convert_connectionorlist(): + field = forms.ModelChoiceField(Reporter.objects.all()) + graphene_type = convert_form_field(field) + assert isinstance(graphene_type, graphene.ID) diff --git a/graphene/contrib/django/tests/test_forms.py b/graphene/contrib/django/tests/test_forms.py new file mode 100644 index 00000000..c499728a --- /dev/null +++ b/graphene/contrib/django/tests/test_forms.py @@ -0,0 +1,36 @@ +from django.core.exceptions import ValidationError +from py.test import raises + +from graphene.contrib.django.forms import GlobalIDFormField + + +# 'TXlUeXBlOjEwMA==' -> 'MyType', 100 +# 'TXlUeXBlOmFiYw==' -> 'MyType', 'abc' + + +def test_global_id_valid(): + field = GlobalIDFormField() + field.clean('TXlUeXBlOjEwMA==') + + +def test_global_id_invalid(): + field = GlobalIDFormField() + with raises(ValidationError): + field.clean('badvalue') + + +def test_global_id_none(): + field = GlobalIDFormField() + with raises(ValidationError): + field.clean(None) + + +def test_global_id_none_optional(): + field = GlobalIDFormField(required=False) + field.clean(None) + + +def test_global_id_bad_int(): + field = GlobalIDFormField() + with raises(ValidationError): + field.clean('TXlUeXBlOmFiYw==') diff --git a/graphene/contrib/django/tests/test_query.py b/graphene/contrib/django/tests/test_query.py index 090c8695..460c8e22 100644 --- a/graphene/contrib/django/tests/test_query.py +++ b/graphene/contrib/django/tests/test_query.py @@ -1,3 +1,4 @@ +import pytest from py.test import raises import graphene @@ -6,6 +7,8 @@ from graphene.contrib.django import DjangoNode, DjangoObjectType from .models import Article, Reporter +pytestmark = pytest.mark.django_db + def test_should_query_only_fields(): with raises(Exception): diff --git a/graphene/contrib/django/tests/test_schema.py b/graphene/contrib/django/tests/test_schema.py index e474121f..07a9a84b 100644 --- a/graphene/contrib/django/tests/test_schema.py +++ b/graphene/contrib/django/tests/test_schema.py @@ -1,7 +1,7 @@ from py.test import raises +from tests.utils import assert_equal_lists from graphene.contrib.django import DjangoObjectType -from tests.utils import assert_equal_lists from .models import Reporter diff --git a/graphene/contrib/django/tests/test_types.py b/graphene/contrib/django/tests/test_types.py index 42028a5e..c3583fe6 100644 --- a/graphene/contrib/django/tests/test_types.py +++ b/graphene/contrib/django/tests/test_types.py @@ -1,12 +1,12 @@ from graphql.core.type import GraphQLObjectType from mock import patch +from tests.utils import assert_equal_lists from graphene import Schema from graphene.contrib.django.types import DjangoNode, DjangoObjectType from graphene.core.fields import Field from graphene.core.types.scalars import Int from graphene.relay.fields import GlobalIDField -from tests.utils import assert_equal_lists from .models import Article, Reporter diff --git a/graphene/contrib/django/types.py b/graphene/contrib/django/types.py index 5b68ebbb..0c3bf69f 100644 --- a/graphene/contrib/django/types.py +++ b/graphene/contrib/django/types.py @@ -7,7 +7,7 @@ from ...core.classtypes.objecttype import ObjectType, ObjectTypeMeta from ...relay.types import Connection, Node, NodeMeta from .converter import convert_django_field from .options import DjangoOptions -from .utils import get_reverse_fields, maybe_queryset +from .utils import get_reverse_fields class DjangoObjectTypeMeta(ObjectTypeMeta): @@ -82,11 +82,7 @@ class DjangoObjectType(six.with_metaclass( class DjangoConnection(Connection): - - @classmethod - def from_list(cls, iterable, *args, **kwargs): - iterable = maybe_queryset(iterable) - return super(DjangoConnection, cls).from_list(iterable, *args, **kwargs) + pass class DjangoNodeMeta(DjangoObjectTypeMeta, NodeMeta): @@ -112,5 +108,3 @@ class DjangoNode(six.with_metaclass( return cls(instance) except cls._meta.model.DoesNotExist: return None - - connection_type = DjangoConnection diff --git a/graphene/contrib/django/utils.py b/graphene/contrib/django/utils.py index 3c72a0e1..b03c2fc8 100644 --- a/graphene/contrib/django/utils.py +++ b/graphene/contrib/django/utils.py @@ -4,6 +4,14 @@ from django.db.models.query import QuerySet from graphene.utils import LazyList +from .compat import RelatedObject + +try: + import django_filters # noqa + DJANGO_FILTER_INSTALLED = True +except ImportError: + DJANGO_FILTER_INSTALLED = False + def get_type_for_model(schema, model): schema = schema @@ -17,8 +25,15 @@ def get_type_for_model(schema, model): def get_reverse_fields(model): for name, attr in model.__dict__.items(): - related = getattr(attr, 'related', None) - if isinstance(related, models.ManyToOneRel): + # Django =>1.9 uses 'rel', django <1.9 uses 'related' + related = getattr(attr, 'rel', None) or \ + getattr(attr, 'related', None) + if isinstance(related, RelatedObject): + # Hack for making it compatible with Django 1.6 + new_related = RelatedObject(related.parent_model, related.model, related.field) + new_related.name = name + yield new_related + elif isinstance(related, models.ManyToOneRel): yield related @@ -37,3 +52,33 @@ def maybe_queryset(value): if isinstance(value, QuerySet): return WrappedQueryset(value) return value + + +def get_related_model(field): + if hasattr(field, 'rel'): + # Django 1.6, 1.7 + return field.rel.to + return field.related_model + + +def import_single_dispatch(): + try: + from functools import singledispatch + except ImportError: + singledispatch = None + + if not singledispatch: + try: + from singledispatch import singledispatch + except ImportError: + pass + + if not singledispatch: + raise Exception( + "It seems your python version does not include " + "functools.singledispatch. Please install the 'singledispatch' " + "package. More information here: " + "https://pypi.python.org/pypi/singledispatch" + ) + + return singledispatch diff --git a/graphene/core/tests/test_schema.py b/graphene/core/tests/test_schema.py index 189111a1..8fb7e836 100644 --- a/graphene/core/tests/test_schema.py +++ b/graphene/core/tests/test_schema.py @@ -1,10 +1,10 @@ from graphql.core import graphql from py.test import raises +from tests.utils import assert_equal_lists from graphene import Interface, List, ObjectType, Schema, String from graphene.core.fields import Field from graphene.core.types.base import LazyType -from tests.utils import assert_equal_lists schema = Schema(name='My own schema') diff --git a/graphene/core/types/field.py b/graphene/core/types/field.py index 17fc9fa2..2d96f1d3 100644 --- a/graphene/core/types/field.py +++ b/graphene/core/types/field.py @@ -9,7 +9,8 @@ from ..classtypes.inputobjecttype import InputObjectType from ..classtypes.mutation import Mutation from ..exceptions import SkipField from .argument import Argument, ArgumentsGroup, snake_case_args -from .base import GroupNamedType, LazyType, MountType, NamedType, ArgumentType, OrderedType +from .base import (ArgumentType, GroupNamedType, LazyType, MountType, + NamedType, OrderedType) from .definitions import NonNull @@ -50,6 +51,16 @@ class Field(NamedType, OrderedType): def resolver(self): return self.resolver_fn or self.get_resolver_fn() + @property + def default(self): + if callable(self._default): + return self._default() + return self._default + + @default.setter + def default(self, value): + self._default = value + def get_resolver_fn(self): resolve_fn_name = 'resolve_%s' % self.attname if hasattr(self.object_type, resolve_fn_name): diff --git a/graphene/relay/fields.py b/graphene/relay/fields.py index dc8c4973..aa446083 100644 --- a/graphene/relay/fields.py +++ b/graphene/relay/fields.py @@ -23,15 +23,15 @@ class ConnectionField(Field): self.connection_type = connection_type self.edge_type = edge_type - def wrap_resolved(self, value, instance, args, info): - return value - def resolver(self, instance, args, info): schema = info.schema.graphene_schema connection_type = self.get_type(schema) resolved = super(ConnectionField, self).resolver(instance, args, info) if isinstance(resolved, connection_type): return resolved + return self.from_list(connection_type, resolved, args, info) + + def from_list(self, connection_type, resolved, args, info): return connection_type.from_list(resolved, args, info) def get_connection_type(self, node): diff --git a/graphene/relay/types.py b/graphene/relay/types.py index 425e3038..672042e7 100644 --- a/graphene/relay/types.py +++ b/graphene/relay/types.py @@ -4,7 +4,6 @@ from collections import Iterable from functools import wraps import six - from graphql_relay.connection.arrayconnection import connection_from_list from graphql_relay.node.node import to_global_id diff --git a/setup.cfg b/setup.cfg index d9e6b06f..2cd61786 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [flake8] -exclude = setup.py,docs/* +exclude = setup.py,docs/*,examples/cookbook_django/* max-line-length = 120 [coverage:run] diff --git a/setup.py b/setup.py index b7a3e7c5..90388c63 100644 --- a/setup.py +++ b/setup.py @@ -55,17 +55,18 @@ setup( install_requires=[ 'six>=1.10.0', - 'graphql-core==0.4.9', - 'graphql-relay==0.3.3' + 'graphql-core>=0.4.9', + 'graphql-relay==0.3.3', ], tests_require=[ + 'django-filter>=0.10.0', 'pytest>=2.7.2', 'pytest-django', 'mock', ], extras_require={ 'django': [ - 'Django>=1.6.0,<1.9', + 'Django>=1.6.0', 'singledispatch>=3.4.0.3', 'graphql-django-view>=1.1.0', ],