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..c1a13ecd 100644 --- a/docs/config.toml +++ b/docs/config.toml @@ -5,7 +5,6 @@ ga = "UA-12613282-7" name = "Quickstart" pages = [ "/docs/quickstart/", - "/docs/quickstart-django/", ] [docs.walkthrough] @@ -17,3 +16,10 @@ ga = "UA-12613282-7" "/docs/basic-types/", "/docs/relay/", ] + +[docs.django] + name = "Django" + pages = [ + "/docs/django/tutorial/", + "/docs/django/filtering/", + ] diff --git a/docs/package.json b/docs/package.json index 33820334..694d0ff8 100644 --- a/docs/package.json +++ b/docs/package.json @@ -17,15 +17,17 @@ "copy-webpack-plugin": "^0.2.0", "es6-promise": "^3.0.2", "extract-text-webpack-plugin": "^0.9.1", - "gatsby": "^0.7.3", + "gatsby": "^0.7.7", "graphiql": "^0.4.2", "graphql": "^0.4.13", "jeet": "^6.1.2", "lodash": "^3.10.1", "nib": "^1.1.0", - "react": "^0.14.3", - "react-burger-menu": "^1.4.2", + "react": "^0.14.6", + "radium": "0.14.2", + "react-burger-menu": "^1.4.12", "react-document-title": "^2.0.1", + "react-dom": "^0.14.6", "react-router": "^0.13.5", "rupture": "^0.6.1", "stylus-loader": "^1.4.2", diff --git a/docs/pages/docs/basic-types.md b/docs/pages/docs/basic-types.md index b1acb2f6..defd78b5 100644 --- a/docs/pages/docs/basic-types.md +++ b/docs/pages/docs/basic-types.md @@ -82,3 +82,21 @@ graphene.Field(graphene.String(), to=graphene.String()) # Is equivalent to: graphene.Field(graphene.String(), to=graphene.Argument(graphene.String())) ``` + + +## Using custom object types as argument + +To use a custom object type as an argument, you need to inherit `graphene.InputObjectType`, not `graphene.ObjectType`. + +```python +class CustomArgumentObjectType(graphene.InputObjectType): + field1 = graphene.String() + field2 = graphene.String() + +``` + +Then, when defining this in an argument, you need to wrap it in an `Argument` object. + +```python +graphene.Field(graphene.String(), to=graphene.Argument(CustomArgumentObjectType)) +``` diff --git a/docs/pages/docs/django/filtering.md b/docs/pages/docs/django/filtering.md new file mode 100644 index 00000000..40cc7f1f --- /dev/null +++ b/docs/pages/docs/django/filtering.md @@ -0,0 +1,159 @@ +--- +title: Filtering +description: Details of how to perform filtering in Graphene Django +--- + +# Filtering + +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/master/examples/cookbook_django).** + +## 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(name_Icontains: "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/django/tutorial.md b/docs/pages/docs/django/tutorial.md new file mode 100644 index 00000000..76ee6ead --- /dev/null +++ b/docs/pages/docs/django/tutorial.md @@ -0,0 +1,293 @@ +--- +title: Quickstart +description: A Quick guide to Graphene in Django +--- + +# Django Tutorial + +Graphene has a number of additional features that are designed to make +working with Django *really simple*. + +**Note: The code in this quickstart is pulled from the +[cookbook example app](https://github.com/graphql-python/graphene/tree/master/examples/cookbook_django)**. + + +## Setup the Django project + +We will setup the project, create the following: + +* A Django project called `cookbook` +* An app within `cookbook` called `ingredients` + +```bash +# Create the project directory +mkdir cookbook +cd cookbook + +# Create a virtualenv to isolate our package dependencies locally +virtualenv env +source env/bin/activate # On Windows use `env\Scripts\activate` + +# Install Django and Graphene with Django support +pip install django +pip install graphene[django] +pip install django-graphiql + +# Set up a new project with a single application +django-admin.py startproject cookbook . # Note the trailing '.' character +django-admin.py startapp ingredients +``` + +Now sync your database for the first time: + +```bash +python manage.py migrate +``` + +Let's create a few simple models... + + +## Defining our models + +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 +``` + +## Schema + +GraphQL presents your objects to the world as a graph structure rather than a more +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. + +This graph also has a *root type* 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 specific user via `get_user`. + +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 + +import cookbook.ingredients.schema + + +class Query(cookbook.ingredients.schema.Query): + # This class will inherit from multiple Queries + # as we begin to add more apps to our project + pass + +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 + +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 = [ + ... + '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 GraphQL is accessed. +Requests to this URL are handled by Graphene's `GraphQLView` view. + +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 cookbook.schema import 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 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 + +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:8000/graphiql](http://localhost:8000/graphiql) and type your first query! + +```graphql +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(name_Icontains: "e", categoryName: "Meat") { + edges { + node { + name + } + } + } +} +``` diff --git a/docs/pages/docs/introspection-schema.md b/docs/pages/docs/introspection-schema.md new file mode 100644 index 00000000..d8c8187e --- /dev/null +++ b/docs/pages/docs/introspection-schema.md @@ -0,0 +1,50 @@ +--- +title: Introspection Schema +description: A guide to instrospection schema in Django +--- + +# Introspection Schema + +Relay uses [Babel Relay Plugin](https://facebook.github.io/relay/docs/guides-babel-plugin.html) +that requires you to provide your GraphQL schema data. + +Graphene comes with a management command for Django to dump your schema data to +`schema.json` that is compatible with babel-relay-plugin. + + +## Usage + +Include `graphene.django.contrib` to `INSTALLED_APPS` in you project settings: + +```python +INSTALLED_APPS += ('graphene.django.contrib') +``` + +Assuming your Graphene schema is at `tutorial.quickstart.schema`, run the command: + +```bash +./manage.py graphql_schema --schema tutorial.quickstart.schema --out schema.json +``` + +It dumps your full introspection schema to `schema.json` inside your project root +directory. Point `babel-relay-plugin` to this file and you're ready to use Relay +with Graphene GraphQL implementation. + + +## Advanced Usage + +To simplify the command to `./manage.py graphql_schema`, you can specify the +parameters in your settings.py: + +```python +GRAPHENE_SCHEMA = 'tutorial.quickstart.schema' +GRAPHENE_SCHEMA_OUTPUT = 'data/schema.json' # defaults to schema.json +``` + +Running `./manage.py graphql_schema` dumps your schema to +`/data/schema.json`. + + +## Help + +Run `./manage.py graphql_schema -h` for command usage. diff --git a/docs/pages/docs/mutations.md b/docs/pages/docs/mutations.md index 7967f62c..79c21da6 100644 --- a/docs/pages/docs/mutations.md +++ b/docs/pages/docs/mutations.md @@ -22,7 +22,7 @@ class CreatePerson(graphene.Mutation): person = graphene.Field('Person') @classmethod - def mutate(cls, args, info): + def mutate(cls, instance, args, info): person = Person(name=args.get('name')) ok = True return CreatePerson(person=person, ok=ok) diff --git a/docs/pages/docs/quickstart-django.md b/docs/pages/docs/quickstart-django.md deleted file mode 100644 index 7ae15113..00000000 --- a/docs/pages/docs/quickstart-django.md +++ /dev/null @@ -1,154 +0,0 @@ ---- -title: Django Tutorial -description: A Quick guide to Graphene in Django ---- - -# Django Tutorial - -In our previous quickstart page we created a very simple schema. - -Now we will adapt the schema to automatically map some Django models, -and expose this schema in a `/graphql` API endpoint. - -## Project setup - -```bash -# Create the project directory -mkdir tutorial -cd tutorial - -# Create a virtualenv to isolate our package dependencies locally -virtualenv env -source env/bin/activate # On Windows use `env\Scripts\activate` - -# Install Django and Graphene with Django support -pip install django -pip install graphene[django] -pip install django-graphiql - -# Set up a new project with a single application -django-admin.py startproject tutorial . # Note the trailing '.' character -django-admin.py startapp quickstart -``` - -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 -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`. - -Open `tutorial/quickstart/schema.py` and type the following: - -```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') - - -class GroupType(DjangoObjectType): - class Meta: - model = Group - only_fields = ('name', ) - - -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) -``` - - -## Adding GraphiQL - -For having the GraphiQL static assets we need to append `django_graphiql` in `INSTALLED_APPS` in `tutorial/settings.py`: - -```python -INSTALLED_APPS = [ - # The other installed apps - 'django_graphiql', -] -``` - -## Creating GraphQL and GraphiQL views - -Unlike a RESTful API, there is only a single URL from which a 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. - -```python -from django.conf.urls import url, include -from django.views.decorators.csrf import csrf_exempt -from graphene.contrib.django.views import GraphQLView -from quickstart.schema import schema - - -# Wire up our GraphQL schema to /graphql. -# Additionally, we include GraphiQL view for querying easily our schema. -urlpatterns = [ - url(r'^graphql', csrf_exempt(GraphQLView.as_view(schema=schema))), - url(r'^graphiql', include('django_graphiql.urls')), -] -``` - -## 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 -``` - -Go to [localhost:8080/graphiql](http://localhost:8080/graphiql) and type your first query! - -```graphql -myQuery { - getUser(id:"1") { - username - } -} -``` 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 0affcda1..1fd40372 100644 --- a/graphene/contrib/django/converter.py +++ b/graphene/contrib/django/converter.py @@ -1,15 +1,11 @@ from django.db import models -from singledispatch import singledispatch from ...core.types.scalars import ID, Boolean, Float, Int, String from .fields import DjangoField, 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 @@ -33,6 +29,7 @@ def fetch_field(f): @convert_django_field.register(models.EmailField) @convert_django_field.register(models.SlugField) @convert_django_field.register(models.URLField) +@convert_django_field.register(models.GenericIPAddressField) @convert_django_field.register(UUIDField) @fetch_field def convert_field_to_string(field): @@ -76,12 +73,22 @@ def convert_field_to_float(field): @convert_django_field.register(models.ManyToManyField) @convert_django_field.register(models.ManyToOneRel) +@convert_django_field.register(models.ManyToManyRel) def convert_field_to_list_or_connection(field): - model_field = DjangoModelField(field.related_model) + model_field = DjangoModelField(get_related_model(field)) + return ConnectionOrListField(model_field, _field=field) + + +# For Django 1.6 +@convert_django_field.register(RelatedObject) +def convert_relatedfield_to_djangomodel(field): + model_field = DjangoModelField(field.model) return ConnectionOrListField(model_field, _field=field) @convert_django_field.register(models.OneToOneField) @convert_django_field.register(models.ForeignKey) def convert_field_to_djangomodel(field): - return DjangoField(DjangoModelField(field.related_model), description=field.help_text, _field=field) + related_model = get_related_model(field) + return DjangoField(DjangoModelField(related_model), description=field.help_text, _field=field) + # return DjangoModelField(get_related_model(field), description=field.help_text, _field=field) 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 cf945a3e..8eee0736 100644 --- a/graphene/contrib/django/fields.py +++ b/graphene/contrib/django/fields.py @@ -1,12 +1,10 @@ -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 DJANGO_FILTER_INSTALLED, get_type_for_model, maybe_queryset class DjangoField(Field): @@ -16,29 +14,52 @@ class DjangoField(Field): return f def __init__(self, *args, **kwargs): - self.field = kwargs.pop('_field') + self.field = kwargs.pop('_field', None) return super(DjangoField, self).__init__(*args, **kwargs) class DjangoConnectionField(DjangoField, 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(DjangoField): def internal_type(self, schema): + if DJANGO_FILTER_INSTALLED: + 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 = DjangoConnectionField(field_object_type, _field=self.field) + if field_object_type._meta.filter_fields: + field = DjangoFilterConnectionField(field_object_type, _field=self.field) + else: + field = DjangoConnectionField(field_object_type, _field=self.field) else: field = DjangoField(List(field_object_type), _field=self.field) 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..4f8b0579 --- /dev/null +++ b/graphene/contrib/django/filter/__init__.py @@ -0,0 +1,14 @@ +import warnings +from graphene.contrib.django.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 .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..b618893d --- /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 graphql_relay.node.node import from_global_id + +from django_filters import Filter, MultipleChoiceFilter +from django_filters.filterset import FilterSet, FilterSetMetaclass +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..94c0dffe --- /dev/null +++ b/graphene/contrib/django/filter/tests/filters.py @@ -0,0 +1,30 @@ +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..5b2875b2 --- /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', 'headline_Icontains', + 'pubDate', 'pubDate_Gt', 'pubDate_Lt', + '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', 'headline_Icontains', + '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/__init__.py b/graphene/contrib/django/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/graphene/contrib/django/management/commands/__init__.py b/graphene/contrib/django/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/graphene/contrib/django/management/commands/graphql_schema.py b/graphene/contrib/django/management/commands/graphql_schema.py new file mode 100644 index 00000000..07b802d4 --- /dev/null +++ b/graphene/contrib/django/management/commands/graphql_schema.py @@ -0,0 +1,72 @@ +import importlib +import json +from distutils.version import StrictVersion +from optparse import make_option + +from django import get_version as get_django_version +from django.core.management.base import BaseCommand, CommandError + +LT_DJANGO_1_8 = StrictVersion(get_django_version()) < StrictVersion('1.8') + +if LT_DJANGO_1_8: + class CommandArguments(BaseCommand): + option_list = BaseCommand.option_list + ( + make_option( + '--schema', + type=str, + dest='schema', + default='', + help='Django app containing schema to dump, e.g. myproject.core.schema', + ), + make_option( + '--out', + type=str, + dest='out', + default='', + help='Output file (default: schema.json)' + ), + ) +else: + class CommandArguments(BaseCommand): + + def add_arguments(self, parser): + from django.conf import settings + parser.add_argument( + '--schema', + type=str, + dest='schema', + default=getattr(settings, 'GRAPHENE_SCHEMA', ''), + help='Django app containing schema to dump, e.g. myproject.core.schema') + + parser.add_argument( + '--out', + type=str, + dest='out', + default=getattr(settings, 'GRAPHENE_SCHEMA_OUTPUT', 'schema.json'), + help='Output file (default: schema.json)') + + +class Command(CommandArguments): + help = 'Dump Graphene schema JSON to file' + can_import_settings = True + + def save_file(self, out, schema_dict): + with open(out, 'w') as outfile: + json.dump(schema_dict, outfile) + + def handle(self, *args, **options): + from django.conf import settings + schema = options.get('schema') or getattr(settings, 'GRAPHENE_SCHEMA', '') + out = options.get('out') or getattr(settings, 'GRAPHENE_SCHEMA_OUTPUT', 'schema.json') + + if schema == '': + raise CommandError('Specify schema on GRAPHENE_SCHEMA setting or by using --schema') + i = importlib.import_module(schema) + + schema_dict = {'data': i.schema.introspect()} + self.save_file(out, schema_dict) + + style = getattr(self, 'style', None) + SUCCESS = getattr(style, 'SUCCESS', lambda x: x) + + self.stdout.write(SUCCESS('Successfully dumped GraphQL schema to %s' % out)) 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/models.py b/graphene/contrib/django/tests/models.py index a4ff3386..956d70af 100644 --- a/graphene/contrib/django/tests/models.py +++ b/graphene/contrib/django/tests/models.py @@ -7,6 +7,11 @@ class Pet(models.Model): name = models.CharField(max_length=30) +class Film(models.Model): + reporters = models.ManyToManyField('Reporter', + related_name='films') + + class Reporter(models.Model): first_name = models.CharField(max_length=30) last_name = models.CharField(max_length=30) diff --git a/graphene/contrib/django/tests/test_command.py b/graphene/contrib/django/tests/test_command.py new file mode 100644 index 00000000..e8033f6d --- /dev/null +++ b/graphene/contrib/django/tests/test_command.py @@ -0,0 +1,11 @@ +from django.core import management +from mock import patch +from six import StringIO + + +@patch('graphene.contrib.django.management.commands.graphql_schema.Command.save_file') +def test_generate_file_on_call_graphql_schema(savefile_mock, settings): + settings.GRAPHENE_SCHEMA = 'graphene.contrib.django.tests.test_urls' + out = StringIO() + management.call_command('graphql_schema', schema='', stdout=out) + assert "Successfully dumped GraphQL schema to schema.json" in out.getvalue() diff --git a/graphene/contrib/django/tests/test_converter.py b/graphene/contrib/django/tests/test_converter.py index 1228bc4a..5f221645 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) field = convert_django_field(field) graphene_type = field.type assert isinstance(graphene_type, graphene_field) @@ -48,8 +48,12 @@ def test_should_url_convert_string(): assert_conversion(models.URLField, graphene.String) +def test_should_ipaddress_convert_string(): + assert_conversion(models.GenericIPAddressField, graphene.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(): @@ -94,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..8296b3c9 100644 --- a/graphene/contrib/django/tests/test_schema.py +++ b/graphene/contrib/django/tests/test_schema.py @@ -29,7 +29,7 @@ def test_should_map_fields_correctly(): model = Reporter assert_equal_lists( ReporterType2._meta.fields_map.keys(), - ['articles', 'first_name', 'last_name', 'email', 'pets', 'id'] + ['articles', 'first_name', 'last_name', 'email', 'pets', 'id', 'films'] ) 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..4a16702b 100644 --- a/graphene/contrib/django/utils.py +++ b/graphene/contrib/django/utils.py @@ -4,6 +4,15 @@ 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, AttributeError): + # AtributeError raised if DjangoFilters installed with a incompatible Django Version + DJANGO_FILTER_INSTALLED = False + def get_type_for_model(schema, model): schema = schema @@ -17,8 +26,17 @@ 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 + elif isinstance(related, models.ManyToManyRel) and not related.symmetrical: yield related @@ -37,3 +55,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/types/field.py b/graphene/core/types/field.py index f6c55edb..2d96f1d3 100644 --- a/graphene/core/types/field.py +++ b/graphene/core/types/field.py @@ -8,8 +8,9 @@ from ..classtypes.base import FieldsClassType from ..classtypes.inputobjecttype import InputObjectType from ..classtypes.mutation import Mutation from ..exceptions import SkipField -from .argument import ArgumentsGroup, snake_case_args -from .base import GroupNamedType, LazyType, MountType, NamedType, OrderedType +from .argument import Argument, ArgumentsGroup, snake_case_args +from .base import (ArgumentType, GroupNamedType, LazyType, MountType, + NamedType, OrderedType) from .definitions import NonNull @@ -19,6 +20,9 @@ class Field(NamedType, OrderedType): self, type, description=None, args=None, name=None, resolver=None, required=False, default=None, *args_list, **kwargs): _creation_counter = kwargs.pop('_creation_counter', None) + if isinstance(name, (Argument, ArgumentType)): + kwargs['name'] = name + name = None super(Field, self).__init__(name=name, _creation_counter=_creation_counter) if isinstance(type, six.string_types): type = LazyType(type) @@ -47,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/core/types/tests/test_argument.py b/graphene/core/types/tests/test_argument.py index b2f5e239..0263496c 100644 --- a/graphene/core/types/tests/test_argument.py +++ b/graphene/core/types/tests/test_argument.py @@ -48,6 +48,7 @@ def test_to_arguments_wrong_type(): def test_snake_case_args(): - resolver = lambda instance, args, info: args['my_arg']['inner_arg'] + def resolver(instance, args, info): + return args['my_arg']['inner_arg'] r = snake_case_args(resolver) assert r(None, {'myArg': {'innerArg': 3}}, None) == 3 diff --git a/graphene/core/types/tests/test_base.py b/graphene/core/types/tests/test_base.py index 4af1eb65..be68e167 100644 --- a/graphene/core/types/tests/test_base.py +++ b/graphene/core/types/tests/test_base.py @@ -25,7 +25,9 @@ def test_orderedtype_different(): @patch('graphene.core.types.field.Field') def test_type_as_field_called(Field): - resolver = lambda x: x + def resolver(x): + return x + a = MountedType(2, description='A', resolver=resolver) a.as_field() Field.assert_called_with( @@ -45,7 +47,8 @@ def test_type_as_argument_called(Argument): def test_type_as_field(): - resolver = lambda x: x + def resolver(x): + return x class MyObjectType(ObjectType): t = MountedType(description='A', resolver=resolver) diff --git a/graphene/core/types/tests/test_field.py b/graphene/core/types/tests/test_field.py index dd30e556..9d224e0d 100644 --- a/graphene/core/types/tests/test_field.py +++ b/graphene/core/types/tests/test_field.py @@ -11,7 +11,8 @@ from ..scalars import String def test_field_internal_type(): - resolver = lambda *args: 'RESOLVED' + def resolver(*args): + return 'RESOLVED' field = Field(String(), description='My argument', resolver=resolver) @@ -104,6 +105,14 @@ def test_field_custom_arguments(): assert 'p' in schema.T(args) +def test_field_name_as_argument(): + field = Field(None, name=String()) + schema = Schema() + + args = field.arguments + assert 'name' in schema.T(args) + + def test_inputfield_internal_type(): field = InputField(String, description='My input field', default='3') @@ -121,3 +130,39 @@ def test_inputfield_internal_type(): assert isinstance(type, GraphQLInputObjectField) assert type.description == 'My input field' assert type.default_value == '3' + + +def test_field_resolve_argument(): + def resolver(instance, args, info): + return args.get('first_name') + + field = Field(String(), first_name=String(), description='My argument', resolver=resolver) + + class Query(ObjectType): + my_field = field + schema = Schema(query=Query) + + type = schema.T(field) + assert type.resolver(None, {'firstName': 'Peter'}, None) == 'Peter' + + +def test_field_resolve_vars(): + class Query(ObjectType): + hello = String(first_name=String()) + + def resolve_hello(self, args, info): + return 'Hello ' + args.get('first_name') + + schema = Schema(query=Query) + + result = schema.execute(""" + query foo($firstName:String) + { + hello(firstName:$firstName) + } + """, args={"firstName": "Serkan"}) + + expected = { + 'hello': 'Hello Serkan' + } + assert result.data == expected diff --git a/graphene/relay/fields.py b/graphene/relay/fields.py index dc8c4973..aac1f7df 100644 --- a/graphene/relay/fields.py +++ b/graphene/relay/fields.py @@ -1,3 +1,4 @@ +import six from graphql_relay.node.node import from_global_id from ..core.fields import Field @@ -23,15 +24,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): @@ -73,8 +74,11 @@ class NodeField(Field): return None _type, _id = resolved_global_id.type, resolved_global_id.id object_type = schema.get_type(_type) - if not is_node(object_type) or (self.field_object_type and - object_type != self.field_object_type): + if isinstance(self.field_object_type, six.string_types): + field_object_type = schema.get_type(self.field_object_type) + else: + field_object_type = self.field_object_type + if not is_node(object_type) or (self.field_object_type and object_type != field_object_type): return return object_type.get_node(_id, info) diff --git a/graphene/relay/tests/test_query.py b/graphene/relay/tests/test_query.py index cfe5502e..b49ac791 100644 --- a/graphene/relay/tests/test_query.py +++ b/graphene/relay/tests/test_query.py @@ -31,6 +31,7 @@ class SpecialNode(relay.Node): class Query(graphene.ObjectType): my_node = relay.NodeField(MyNode) + my_node_lazy = relay.NodeField('MyNode') special_node = relay.NodeField(SpecialNode) all_my_nodes = relay.ConnectionField( MyNode, connection_type=MyConnection, customArg=graphene.String()) @@ -117,3 +118,23 @@ def test_nodeidfield(): id_field_type = schema.T(id_field) assert isinstance(id_field_type.type, GraphQLNonNull) assert id_field_type.type.of_type == GraphQLID + + +def test_nodefield_lazy_query(): + query = ''' + query RebelsShipsQuery { + myNode(id:"TXlOb2RlOjE=") { + id + name + }, + myNodeLazy(id:"TXlOb2RlOjE=") { + id + name + }, + + } + ''' + result = schema.execute(query) + assert not result.errors + assert result.data['myNode'] == result.data['myNodeLazy'], \ + "NodeField with object_type direct reference and with object_type string name should not differ." 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/graphene/utils/str_converters.py b/graphene/utils/str_converters.py index c275a281..bb9b2885 100644 --- a/graphene/utils/str_converters.py +++ b/graphene/utils/str_converters.py @@ -7,7 +7,7 @@ def to_camel_case(snake_str): components = snake_str.split('_') # We capitalize the first letter of each component except the first one # with the 'title' method and join them together. - return components[0] + "".join(x.title() for x in components[1:]) + return components[0] + "".join(x.title() if x else '_' for x in components[1:]) # From this response in Stackoverflow diff --git a/graphene/utils/tests/test_resolve_only_args.py b/graphene/utils/tests/test_resolve_only_args.py index da0e3c8b..7866f32f 100644 --- a/graphene/utils/tests/test_resolve_only_args.py +++ b/graphene/utils/tests/test_resolve_only_args.py @@ -2,7 +2,11 @@ from ..resolve_only_args import resolve_only_args def test_resolve_only_args(): + + def resolver(*args, **kwargs): + return kwargs + my_data = {'one': 1, 'two': 2} - resolver = lambda *args, **kwargs: kwargs + wrapped = resolve_only_args(resolver) assert wrapped(None, my_data, None) == my_data diff --git a/graphene/utils/tests/test_str_converter.py b/graphene/utils/tests/test_str_converter.py index 9f2f9cdf..19f691af 100644 --- a/graphene/utils/tests/test_str_converter.py +++ b/graphene/utils/tests/test_str_converter.py @@ -4,11 +4,14 @@ from ..str_converters import to_camel_case, to_snake_case def test_snake_case(): assert to_snake_case('snakesOnAPlane') == 'snakes_on_a_plane' assert to_snake_case('SnakesOnAPlane') == 'snakes_on_a_plane' + assert to_snake_case('SnakesOnA_Plane') == 'snakes_on_a__plane' assert to_snake_case('snakes_on_a_plane') == 'snakes_on_a_plane' + assert to_snake_case('snakes_on_a__plane') == 'snakes_on_a__plane' assert to_snake_case('IPhoneHysteria') == 'i_phone_hysteria' assert to_snake_case('iPhoneHysteria') == 'i_phone_hysteria' def test_camel_case(): assert to_camel_case('snakes_on_a_plane') == 'snakesOnAPlane' + assert to_camel_case('snakes_on_a__plane') == 'snakesOnA_Plane' assert to_camel_case('i_phone_hysteria') == 'iPhoneHysteria' 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..b8b50fb6 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ class PyTest(TestCommand): setup( name='graphene', - version='0.5.0', + version='0.6.0', description='GraphQL Framework for Python', long_description=open('README.rst').read(), @@ -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', ], diff --git a/tests/django_settings.py b/tests/django_settings.py index 998f68ab..1cf5dd38 100644 --- a/tests/django_settings.py +++ b/tests/django_settings.py @@ -1,6 +1,7 @@ SECRET_KEY = 1 INSTALLED_APPS = [ + 'graphene.contrib.django', 'graphene.contrib.django.tests', 'examples.starwars_django', ]