commit 0434899b4ec52b1a6a122678eaa3d070d9ff4baa Author: Syrus Akbary Date: Sat Sep 17 16:29:00 2016 -0700 Isolated Graphene Django in a new package diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b755472 --- /dev/null +++ b/.gitignore @@ -0,0 +1,81 @@ +# Created by https://www.gitignore.io + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + + +/tests/django.sqlite + +/graphene/index.json +/graphene/meta.json + +/meta.json +/index.json + +/docs/playground/graphene-js/pypyjs-release-nojit/ +/docs/static/playground/lib + +/docs/static/playground + +# PyCharm +.idea + +# Databases +*.sqlite3 +.vscode diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..019cb6f --- /dev/null +++ b/.travis.yml @@ -0,0 +1,49 @@ +language: python +sudo: false +python: +- 2.7 +- 3.4 +- 3.5 +- pypy +before_install: + - | + if [ "$TRAVIS_PYTHON_VERSION" = "pypy" ]; then + export PYENV_ROOT="$HOME/.pyenv" + if [ -f "$PYENV_ROOT/bin/pyenv" ]; then + cd "$PYENV_ROOT" && git pull + else + rm -rf "$PYENV_ROOT" && git clone --depth 1 https://github.com/yyuu/pyenv.git "$PYENV_ROOT" + fi + export PYPY_VERSION="4.0.1" + "$PYENV_ROOT/bin/pyenv" install "pypy-$PYPY_VERSION" + virtualenv --python="$PYENV_ROOT/versions/pypy-$PYPY_VERSION/bin/python" "$HOME/virtualenvs/pypy-$PYPY_VERSION" + source "$HOME/virtualenvs/pypy-$PYPY_VERSION/bin/activate" + fi +install: +- | + if [ "$TEST_TYPE" = build ]; then + pip install pytest pytest-cov pytest-benchmark coveralls six pytest-django mock django-filter + pip install -e . + python setup.py develop + elif [ "$TEST_TYPE" = lint ]; then + pip install flake8 + fi +script: +- | + if [ "$TEST_TYPE" = lint ]; then + echo "Checking Python code lint." + flake8 graphene_django + exit + elif [ "$TEST_TYPE" = build ]; then + py.test --cov=graphene_django graphene_django examples + fi +after_success: +- | + if [ "$TEST_TYPE" = build ]; then + coveralls + fi +matrix: + fast_finish: true + include: + - python: '2.7' + env: TEST_TYPE=lint diff --git a/README.md b/README.md new file mode 100644 index 0000000..e1bc881 --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +You are in the `next` unreleased version of Graphene-Django (`1.0.dev`). +Please read [UPGRADE-v1.0.md](https://github.com/graphql-python/graphene/blob/master/UPGRADE-v1.0.md) to learn how to upgrade. + +--- + +# ![Graphene Logo](http://graphene-python.org/favicon.png) [Graphene-Django](http://graphene-python.org) [![Build Status](https://travis-ci.org/graphql-python/graphene-django.svg?branch=master)](https://travis-ci.org/graphql-python/graphene-django) [![PyPI version](https://badge.fury.io/py/graphene-django.svg)](https://badge.fury.io/py/graphene-django) [![Coverage Status](https://coveralls.io/repos/graphql-python/graphene-django/badge.svg?branch=master&service=github)](https://coveralls.io/github/graphql-python/graphene-django?branch=master) + + +[Graphene](http://graphene-python.org) is a Python library for building GraphQL schemas/types fast and easily. + +- **Easy to use:** Graphene helps you use GraphQL in Python without effort. +- **Relay:** Graphene has builtin support for Relay +- **Django:** Automatic *Django model* mapping to Graphene Types. Check a fully working [Django](http://github.com/graphql-python/swapi-graphene) implementation + +Graphene also supports *SQLAlchemy*! + +*What is supported in this Python version?* **Everything**: Interfaces, ObjectTypes, Scalars, Unions and Relay (Nodes, Connections), in addition to queries, mutations and subscriptions. + +**NEW**!: [Try graphene online](http://graphene-python.org/playground/) + +## Installation + +For instaling graphene, just run this command in your shell + +```bash +pip install "graphene-django>=1.0.dev" +``` + +## Examples + +Here is one example for get you started: + +```python +from django.db import models +from graphene_django import DjangoObjectType + +class UserModel(models.Model): + name = models.CharField(max_length=100) + last_name = models.CharField(max_length=100) + +class User(DjangoObjectType): + class Meta: + # This type will transform all the UserModel fields + # into Graphene fields automatically + model = UserModel + + # An extra field in the User Type + full_name = graphene.String() + + def resolve_full_name(self, args, context, info): + return "{} {}".format(self.name, self.last_name) +``` + +If you want to learn even more, you can also check the following [examples](examples/): + +* **Schema with Filtering**: [Cookbook example](examples/cookbook) +* **Relay Schema**: [Starwars Relay example](examples/starwars) + + +## Contributing + +After cloning this repo, ensure dependencies are installed by running: + +```sh +python setup.py install +``` + +After developing, the full test suite can be evaluated by running: + +```sh +python setup.py test # Use --pytest-args="-v -s" for verbose mode +``` diff --git a/bin/autolinter b/bin/autolinter new file mode 100755 index 0000000..8c39928 --- /dev/null +++ b/bin/autolinter @@ -0,0 +1,7 @@ +#!/bin/bash + +# Install the required scripts with +# pip install autoflake autopep8 isort +autoflake ./examples/ ./graphene_django/ -r --remove-unused-variables --remove-all-unused-imports --in-place +autopep8 ./examples/ ./graphene_django/ -r --in-place --experimental --aggressive --max-line-length 120 +isort -rc ./examples/ ./graphene_django/ diff --git a/bin/convert_documentation b/bin/convert_documentation new file mode 100755 index 0000000..b55d5da --- /dev/null +++ b/bin/convert_documentation @@ -0,0 +1,3 @@ +#!/bin/bash + +pandoc README.md --from markdown --to rst -s -o README.rst diff --git a/django_test_settings.py b/django_test_settings.py new file mode 100644 index 0000000..12efbf6 --- /dev/null +++ b/django_test_settings.py @@ -0,0 +1,18 @@ +import sys, os +ROOT_PATH = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, ROOT_PATH + '/examples/') + +SECRET_KEY = 1 + +INSTALLED_APPS = [ + 'graphene_django', + 'graphene_django.tests', + 'starwars', +] + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': 'django_test.sqlite', + } +} diff --git a/examples/cookbook/README.md b/examples/cookbook/README.md new file mode 100644 index 0000000..206d97c --- /dev/null +++ b/examples/cookbook/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/cookbook/__init__.py b/examples/cookbook/cookbook/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/cookbook/cookbook/ingredients/__init__.py b/examples/cookbook/cookbook/ingredients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/cookbook/cookbook/ingredients/admin.py b/examples/cookbook/cookbook/ingredients/admin.py new file mode 100644 index 0000000..766b23f --- /dev/null +++ b/examples/cookbook/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/cookbook/ingredients/apps.py b/examples/cookbook/cookbook/ingredients/apps.py new file mode 100644 index 0000000..21b4b08 --- /dev/null +++ b/examples/cookbook/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/cookbook/ingredients/fixtures/ingredients.json b/examples/cookbook/cookbook/ingredients/fixtures/ingredients.json new file mode 100644 index 0000000..8625d3c --- /dev/null +++ b/examples/cookbook/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/cookbook/ingredients/migrations/0001_initial.py b/examples/cookbook/cookbook/ingredients/migrations/0001_initial.py new file mode 100644 index 0000000..0494923 --- /dev/null +++ b/examples/cookbook/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/cookbook/ingredients/migrations/__init__.py b/examples/cookbook/cookbook/ingredients/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/cookbook/cookbook/ingredients/models.py b/examples/cookbook/cookbook/ingredients/models.py new file mode 100644 index 0000000..cffdf1e --- /dev/null +++ b/examples/cookbook/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/cookbook/ingredients/schema.py b/examples/cookbook/cookbook/ingredients/schema.py new file mode 100644 index 0000000..64353e3 --- /dev/null +++ b/examples/cookbook/cookbook/ingredients/schema.py @@ -0,0 +1,38 @@ +from cookbook.ingredients.models import Category, Ingredient +from graphene import ObjectType, Field, AbstractType, Node +from graphene_django.filter import DjangoFilterConnectionField +from graphene_django.types import DjangoObjectType + + +# Graphene will automatically map the Category model's fields onto the CategoryNode. +# This is configured in the CategoryNode's Meta class (as you can see below) +class CategoryNode(DjangoObjectType): + + class Meta: + model = Category + interfaces = (Node, ) + filter_fields = ['name', 'ingredients'] + filter_order_by = ['name'] + + +class IngredientNode(DjangoObjectType): + + class Meta: + model = Ingredient + # Allow for some more advanced filtering here + interfaces = (Node, ) + filter_fields = { + 'name': ['exact', 'icontains', 'istartswith'], + 'notes': ['exact', 'icontains'], + 'category': ['exact'], + 'category__name': ['exact'], + } + filter_order_by = ['name', 'category__name'] + + +class Query(AbstractType): + category = Field(CategoryNode) + all_categories = DjangoFilterConnectionField(CategoryNode) + + ingredient = Field(IngredientNode) + all_ingredients = DjangoFilterConnectionField(IngredientNode) diff --git a/examples/cookbook/cookbook/ingredients/tests.py b/examples/cookbook/cookbook/ingredients/tests.py new file mode 100644 index 0000000..4929020 --- /dev/null +++ b/examples/cookbook/cookbook/ingredients/tests.py @@ -0,0 +1,2 @@ + +# Create your tests here. diff --git a/examples/cookbook/cookbook/ingredients/views.py b/examples/cookbook/cookbook/ingredients/views.py new file mode 100644 index 0000000..b8e4ee0 --- /dev/null +++ b/examples/cookbook/cookbook/ingredients/views.py @@ -0,0 +1,2 @@ + +# Create your views here. diff --git a/examples/cookbook/cookbook/recipes/__init__.py b/examples/cookbook/cookbook/recipes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/cookbook/cookbook/recipes/admin.py b/examples/cookbook/cookbook/recipes/admin.py new file mode 100644 index 0000000..862dd4c --- /dev/null +++ b/examples/cookbook/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/cookbook/recipes/apps.py b/examples/cookbook/cookbook/recipes/apps.py new file mode 100644 index 0000000..1f24f13 --- /dev/null +++ b/examples/cookbook/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/cookbook/recipes/migrations/0001_initial.py b/examples/cookbook/cookbook/recipes/migrations/0001_initial.py new file mode 100644 index 0000000..338c71a --- /dev/null +++ b/examples/cookbook/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 + +import django.db.models.deletion +from django.db import migrations, models + + +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/cookbook/recipes/migrations/__init__.py b/examples/cookbook/cookbook/recipes/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/cookbook/cookbook/recipes/models.py b/examples/cookbook/cookbook/recipes/models.py new file mode 100644 index 0000000..a767dd2 --- /dev/null +++ b/examples/cookbook/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/cookbook/recipes/tests.py b/examples/cookbook/cookbook/recipes/tests.py new file mode 100644 index 0000000..4929020 --- /dev/null +++ b/examples/cookbook/cookbook/recipes/tests.py @@ -0,0 +1,2 @@ + +# Create your tests here. diff --git a/examples/cookbook/cookbook/recipes/views.py b/examples/cookbook/cookbook/recipes/views.py new file mode 100644 index 0000000..b8e4ee0 --- /dev/null +++ b/examples/cookbook/cookbook/recipes/views.py @@ -0,0 +1,2 @@ + +# Create your views here. diff --git a/examples/cookbook/cookbook/schema.py b/examples/cookbook/cookbook/schema.py new file mode 100644 index 0000000..0b89bcf --- /dev/null +++ b/examples/cookbook/cookbook/schema.py @@ -0,0 +1,9 @@ +import graphene +import cookbook.ingredients.schema + +# print cookbook.ingredients.schema.Query._meta.graphql_type.get_fields()['allIngredients'].args + +class Query(cookbook.ingredients.schema.Query, graphene.ObjectType): + pass + +schema = graphene.Schema(query=Query) diff --git a/examples/cookbook/cookbook/settings.py b/examples/cookbook/cookbook/settings.py new file mode 100644 index 0000000..bdc1f1c --- /dev/null +++ b/examples/cookbook/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/cookbook/urls.py b/examples/cookbook/cookbook/urls.py new file mode 100644 index 0000000..8e096e3 --- /dev/null +++ b/examples/cookbook/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_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/cookbook/wsgi.py b/examples/cookbook/cookbook/wsgi.py new file mode 100644 index 0000000..954b0a8 --- /dev/null +++ b/examples/cookbook/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/manage.py b/examples/cookbook/manage.py new file mode 100755 index 0000000..8d8a34d --- /dev/null +++ b/examples/cookbook/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/requirements.txt b/examples/cookbook/requirements.txt new file mode 100644 index 0000000..0fd3c2d --- /dev/null +++ b/examples/cookbook/requirements.txt @@ -0,0 +1,5 @@ +graphene[django] +django_graphiql +graphql-core +django==1.9 +django-filter==0.11.0 diff --git a/examples/starwars/__init__.py b/examples/starwars/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/starwars/data.py b/examples/starwars/data.py new file mode 100644 index 0000000..b464731 --- /dev/null +++ b/examples/starwars/data.py @@ -0,0 +1,114 @@ +from .models import Character, Faction, Ship + + +def initialize(): + human = Character( + name='Human' + ) + human.save() + + droid = Character( + name='Droid' + ) + droid.save() + + rebels = Faction( + id='1', + name='Alliance to Restore the Republic', + hero=human + ) + rebels.save() + + empire = Faction( + id='2', + name='Galactic Empire', + hero=droid + ) + empire.save() + + xwing = Ship( + id='1', + name='X-Wing', + faction=rebels, + ) + xwing.save() + + ywing = Ship( + id='2', + name='Y-Wing', + faction=rebels, + ) + ywing.save() + + awing = Ship( + id='3', + name='A-Wing', + faction=rebels, + ) + awing.save() + + # Yeah, technically it's Corellian. But it flew in the service of the rebels, + # so for the purposes of this demo it's a rebel ship. + falcon = Ship( + id='4', + name='Millenium Falcon', + faction=rebels, + ) + falcon.save() + + homeOne = Ship( + id='5', + name='Home One', + faction=rebels, + ) + homeOne.save() + + tieFighter = Ship( + id='6', + name='TIE Fighter', + faction=empire, + ) + tieFighter.save() + + tieInterceptor = Ship( + id='7', + name='TIE Interceptor', + faction=empire, + ) + tieInterceptor.save() + + executor = Ship( + id='8', + name='Executor', + faction=empire, + ) + executor.save() + + +def create_ship(ship_name, faction_id): + new_ship = Ship( + name=ship_name, + faction_id=faction_id + ) + new_ship.save() + return new_ship + + +def get_ship(_id): + return Ship.objects.get(id=_id) + + +def get_ships(): + return Ship.objects.all() + + +def get_faction(_id): + return Faction.objects.get(id=_id) + + +def get_rebels(): + return get_faction(1) + + +def get_empire(): + return get_faction(2) diff --git a/examples/starwars/models.py b/examples/starwars/models.py new file mode 100644 index 0000000..31d06fe --- /dev/null +++ b/examples/starwars/models.py @@ -0,0 +1,26 @@ +from __future__ import absolute_import + +from django.db import models + + +class Character(models.Model): + name = models.CharField(max_length=50) + + def __str__(self): + return self.name + + +class Faction(models.Model): + name = models.CharField(max_length=50) + hero = models.ForeignKey(Character) + + def __str__(self): + return self.name + + +class Ship(models.Model): + name = models.CharField(max_length=50) + faction = models.ForeignKey(Faction, related_name='ships') + + def __str__(self): + return self.name diff --git a/examples/starwars/schema.py b/examples/starwars/schema.py new file mode 100644 index 0000000..b401447 --- /dev/null +++ b/examples/starwars/schema.py @@ -0,0 +1,87 @@ +import graphene +from graphene import relay, resolve_only_args, Schema +from graphene_django import DjangoObjectType + +from .data import (create_ship, get_empire, get_faction, get_rebels, get_ship, + get_ships) +from .models import ( + Character as CharacterModel, + Faction as FactionModel, + Ship as ShipModel +) + + +class Ship(DjangoObjectType): + + class Meta: + model = ShipModel + interfaces = (relay.Node, ) + + @classmethod + def get_node(cls, id, context, info): + node = get_ship(id) + print(node) + return node + + +class Character(DjangoObjectType): + + class Meta: + model = CharacterModel + + +class Faction(DjangoObjectType): + + class Meta: + model = FactionModel + interfaces = (relay.Node, ) + + @classmethod + def get_node(cls, id, context, info): + return get_faction(id) + + +class IntroduceShip(relay.ClientIDMutation): + + class Input: + ship_name = graphene.String(required=True) + faction_id = graphene.String(required=True) + + ship = graphene.Field(Ship) + faction = graphene.Field(Faction) + + @classmethod + def mutate_and_get_payload(cls, input, context, info): + ship_name = input.get('ship_name') + faction_id = input.get('faction_id') + ship = create_ship(ship_name, faction_id) + faction = get_faction(faction_id) + return IntroduceShip(ship=ship, faction=faction) + + +class Query(graphene.ObjectType): + rebels = graphene.Field(Faction) + empire = graphene.Field(Faction) + node = relay.Node.Field() + ships = relay.ConnectionField(Ship, description='All the ships.') + + @resolve_only_args + def resolve_ships(self): + return get_ships() + + @resolve_only_args + def resolve_rebels(self): + return get_rebels() + + @resolve_only_args + def resolve_empire(self): + return get_empire() + + +class Mutation(graphene.ObjectType): + introduce_ship = IntroduceShip.Field() + + +# We register the Character Model because if not would be +# inaccessible for the schema +schema = Schema(query=Query, mutation=Mutation, types=[Ship, Character]) diff --git a/examples/starwars/tests/__init__.py b/examples/starwars/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/starwars/tests/test_connections.py b/examples/starwars/tests/test_connections.py new file mode 100644 index 0000000..fa760d4 --- /dev/null +++ b/examples/starwars/tests/test_connections.py @@ -0,0 +1,47 @@ +import pytest + +from ..data import initialize +from ..schema import schema + +pytestmark = pytest.mark.django_db + + +def test_correct_fetch_first_ship_rebels(): + initialize() + query = ''' + query RebelsShipsQuery { + rebels { + name, + hero { + name + } + ships(first: 1) { + edges { + node { + name + } + } + } + } + } + ''' + expected = { + 'rebels': { + 'name': 'Alliance to Restore the Republic', + 'hero': { + 'name': 'Human' + }, + 'ships': { + 'edges': [ + { + 'node': { + 'name': 'X-Wing' + } + } + ] + } + } + } + result = schema.execute(query) + assert not result.errors + assert result.data == expected diff --git a/examples/starwars/tests/test_mutation.py b/examples/starwars/tests/test_mutation.py new file mode 100644 index 0000000..aa312ff --- /dev/null +++ b/examples/starwars/tests/test_mutation.py @@ -0,0 +1,79 @@ +import pytest + +from ..data import initialize +from ..schema import schema + +pytestmark = pytest.mark.django_db + + +def test_mutations(): + initialize() + + query = ''' + mutation MyMutation { + introduceShip(input:{clientMutationId:"abc", shipName: "Peter", factionId: "1"}) { + ship { + id + name + } + faction { + name + ships { + edges { + node { + id + name + } + } + } + } + } + } + ''' + expected = { + 'introduceShip': { + 'ship': { + 'id': 'U2hpcDo5', + 'name': 'Peter' + }, + 'faction': { + 'name': 'Alliance to Restore the Republic', + 'ships': { + 'edges': [{ + 'node': { + 'id': 'U2hpcDox', + 'name': 'X-Wing' + } + }, { + 'node': { + 'id': 'U2hpcDoy', + 'name': 'Y-Wing' + } + }, { + 'node': { + 'id': 'U2hpcDoz', + 'name': 'A-Wing' + } + }, { + 'node': { + 'id': 'U2hpcDo0', + 'name': 'Millenium Falcon' + } + }, { + 'node': { + 'id': 'U2hpcDo1', + 'name': 'Home One' + } + }, { + 'node': { + 'id': 'U2hpcDo5', + 'name': 'Peter' + } + }] + }, + } + } + } + result = schema.execute(query) + assert not result.errors + assert result.data == expected diff --git a/examples/starwars/tests/test_objectidentification.py b/examples/starwars/tests/test_objectidentification.py new file mode 100644 index 0000000..fad1958 --- /dev/null +++ b/examples/starwars/tests/test_objectidentification.py @@ -0,0 +1,117 @@ +import pytest + +from ..data import initialize +from ..schema import schema + +pytestmark = pytest.mark.django_db + + +def test_correctly_fetches_id_name_rebels(): + initialize() + query = ''' + query RebelsQuery { + rebels { + id + name + } + } + ''' + expected = { + 'rebels': { + 'id': 'RmFjdGlvbjox', + 'name': 'Alliance to Restore the Republic' + } + } + result = schema.execute(query) + assert not result.errors + assert result.data == expected + + +def test_correctly_refetches_rebels(): + initialize() + query = ''' + query RebelsRefetchQuery { + node(id: "RmFjdGlvbjox") { + id + ... on Faction { + name + } + } + } + ''' + expected = { + 'node': { + 'id': 'RmFjdGlvbjox', + 'name': 'Alliance to Restore the Republic' + } + } + result = schema.execute(query) + assert not result.errors + assert result.data == expected + + +def test_correctly_fetches_id_name_empire(): + initialize() + query = ''' + query EmpireQuery { + empire { + id + name + } + } + ''' + expected = { + 'empire': { + 'id': 'RmFjdGlvbjoy', + 'name': 'Galactic Empire' + } + } + result = schema.execute(query) + assert not result.errors + assert result.data == expected + + +def test_correctly_refetches_empire(): + initialize() + query = ''' + query EmpireRefetchQuery { + node(id: "RmFjdGlvbjoy") { + id + ... on Faction { + name + } + } + } + ''' + expected = { + 'node': { + 'id': 'RmFjdGlvbjoy', + 'name': 'Galactic Empire' + } + } + result = schema.execute(query) + assert not result.errors + assert result.data == expected + + +def test_correctly_refetches_xwing(): + initialize() + query = ''' + query XWingRefetchQuery { + node(id: "U2hpcDox") { + id + ... on Ship { + name + } + } + } + ''' + expected = { + 'node': { + 'id': 'U2hpcDox', + 'name': 'X-Wing' + } + } + result = schema.execute(query) + assert not result.errors + assert result.data == expected diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py new file mode 100644 index 0000000..e999888 --- /dev/null +++ b/graphene_django/__init__.py @@ -0,0 +1,9 @@ +from .types import ( + DjangoObjectType, +) +from .fields import ( + DjangoConnectionField, +) + +__all__ = ['DjangoObjectType', + 'DjangoConnectionField'] diff --git a/graphene_django/compat.py b/graphene_django/compat.py new file mode 100644 index 0000000..4b1f55a --- /dev/null +++ b/graphene_django/compat.py @@ -0,0 +1,24 @@ +from django.db import models + + +class MissingType(object): + pass + +try: + UUIDField = models.UUIDField +except AttributeError: + # Improved compatibility for Django 1.6 + UUIDField = MissingType + +try: + from django.db.models.related import RelatedObject +except: + # Improved compatibility for Django 1.6 + RelatedObject = MissingType + + +try: + # Postgres fields are only available in Django 1.8+ + from django.contrib.postgres.fields import ArrayField, HStoreField, JSONField, RangeField +except ImportError: + ArrayField, HStoreField, JSONField, RangeField = (MissingType, ) * 4 diff --git a/graphene_django/converter.py b/graphene_django/converter.py new file mode 100644 index 0000000..dc1eb88 --- /dev/null +++ b/graphene_django/converter.py @@ -0,0 +1,189 @@ +from django.db import models +from django.utils.encoding import force_text + +from graphene import Enum, List, ID, Boolean, Float, Int, String, Field, NonNull, Field, Dynamic +from graphene.types.json import JSONString +from graphene.types.datetime import DateTime +from graphene.utils.str_converters import to_const +from graphene.relay import is_node + +from .compat import (ArrayField, HStoreField, JSONField, RangeField, + RelatedObject, UUIDField) +from .utils import get_related_model, import_single_dispatch +from .fields import get_connection_field + +singledispatch = import_single_dispatch() + + +def convert_choice_name(name): + return to_const(force_text(name)) + + +def get_choices(choices): + for value, help_text in choices: + if isinstance(help_text, (tuple, list)): + for choice in get_choices(help_text): + yield choice + else: + name = convert_choice_name(help_text) + description = help_text + yield name, value, description + + +def convert_django_field_with_choices(field, registry=None): + choices = getattr(field, 'choices', None) + if choices: + meta = field.model._meta + name = '{}{}'.format(meta.object_name, field.name.capitalize()) + choices = list(get_choices(choices)) + named_choices = [(c[0], c[1]) for c in choices] + named_choices_descriptions = {c[0]:c[2] for c in choices} + + class EnumWithDescriptionsType(object): + @property + def description(self): + return named_choices_descriptions[self.name] + + enum = Enum(name, list(named_choices), type=EnumWithDescriptionsType) + return enum(description=field.help_text) + return convert_django_field(field, registry) + + +@singledispatch +def convert_django_field(field, registry=None): + raise Exception( + "Don't know how to convert the Django field %s (%s)" % + (field, field.__class__)) + + +@convert_django_field.register(models.CharField) +@convert_django_field.register(models.TextField) +@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(models.FileField) +@convert_django_field.register(UUIDField) +def convert_field_to_string(field, registry=None): + return String(description=field.help_text) + + +@convert_django_field.register(models.AutoField) +def convert_field_to_id(field, registry=None): + return ID(description=field.help_text) + + +@convert_django_field.register(models.PositiveIntegerField) +@convert_django_field.register(models.PositiveSmallIntegerField) +@convert_django_field.register(models.SmallIntegerField) +@convert_django_field.register(models.BigIntegerField) +@convert_django_field.register(models.IntegerField) +def convert_field_to_int(field, registry=None): + return Int(description=field.help_text) + + +@convert_django_field.register(models.BooleanField) +def convert_field_to_boolean(field, registry=None): + return NonNull(Boolean, description=field.help_text) + + +@convert_django_field.register(models.NullBooleanField) +def convert_field_to_nullboolean(field, registry=None): + return Boolean(description=field.help_text) + + +@convert_django_field.register(models.DecimalField) +@convert_django_field.register(models.FloatField) +def convert_field_to_float(field, registry=None): + return Float(description=field.help_text) + + +@convert_django_field.register(models.DateField) +def convert_date_to_string(field, registry=None): + return DateTime(description=field.help_text) + + +@convert_django_field.register(models.OneToOneRel) +def convert_onetoone_field_to_djangomodel(field, registry=None): + model = get_related_model(field) + + def dynamic_type(): + _type = registry.get_type_for_model(model) + if not _type: + return + + return Field(_type) + + return Dynamic(dynamic_type) + + +@convert_django_field.register(models.ManyToManyField) +@convert_django_field.register(models.ManyToManyRel) +@convert_django_field.register(models.ManyToOneRel) +def convert_field_to_list_or_connection(field, registry=None): + model = get_related_model(field) + + def dynamic_type(): + _type = registry.get_type_for_model(model) + if not _type: + return + + if is_node(_type): + return get_connection_field(_type) + return Field(List(_type)) + + return Dynamic(dynamic_type) + + +# For Django 1.6 +@convert_django_field.register(RelatedObject) +def convert_relatedfield_to_djangomodel(field, registry=None): + model = field.model + + def dynamic_type(): + _type = registry.get_type_for_model(model) + if not _type: + return + + if is_node(_type): + return get_connection_field(_type) + return Field(List(_type)) + + return Dynamic(dynamic_type) + + +@convert_django_field.register(models.OneToOneField) +@convert_django_field.register(models.ForeignKey) +def convert_field_to_djangomodel(field, registry=None): + model = get_related_model(field) + + def dynamic_type(): + _type = registry.get_type_for_model(model) + if not _type: + return + + return Field(_type, description=field.help_text) + + return Dynamic(dynamic_type) + + +@convert_django_field.register(ArrayField) +def convert_postgres_array_to_list(field, registry=None): + base_type = convert_django_field(field.base_field) + if not isinstance(base_type, (List, NonNull)): + base_type = type(base_type) + return List(base_type, description=field.help_text) + + +@convert_django_field.register(HStoreField) +@convert_django_field.register(JSONField) +def convert_posgres_field_to_string(field, registry=None): + return JSONString(description=field.help_text) + + +@convert_django_field.register(RangeField) +def convert_posgres_range_to_string(field, registry=None): + inner_type = convert_django_field(field.base_field) + if not isinstance(inner_type, (List, NonNull)): + inner_type = type(inner_type) + return List(inner_type, description=field.help_text) diff --git a/graphene_django/debug/__init__.py b/graphene_django/debug/__init__.py new file mode 100644 index 0000000..cd5015e --- /dev/null +++ b/graphene_django/debug/__init__.py @@ -0,0 +1,4 @@ +from .middleware import DjangoDebugMiddleware +from .types import DjangoDebug + +__all__ = ['DjangoDebugMiddleware', 'DjangoDebug'] diff --git a/graphene_django/debug/middleware.py b/graphene_django/debug/middleware.py new file mode 100644 index 0000000..9479fd1 --- /dev/null +++ b/graphene_django/debug/middleware.py @@ -0,0 +1,56 @@ +from promise import Promise +from django.db import connections + +from .sql.tracking import unwrap_cursor, wrap_cursor +from .types import DjangoDebug + + +class DjangoDebugContext(object): + + def __init__(self): + self.debug_promise = None + self.promises = [] + self.enable_instrumentation() + self.object = DjangoDebug(sql=[]) + + def get_debug_promise(self): + if not self.debug_promise: + self.debug_promise = Promise.all(self.promises) + return self.debug_promise.then(self.on_resolve_all_promises) + + def on_resolve_all_promises(self, values): + self.disable_instrumentation() + return self.object + + def add_promise(self, promise): + if self.debug_promise and not self.debug_promise.is_fulfilled: + self.promises.append(promise) + + def enable_instrumentation(self): + # This is thread-safe because database connections are thread-local. + for connection in connections.all(): + wrap_cursor(connection, self) + + def disable_instrumentation(self): + for connection in connections.all(): + unwrap_cursor(connection) + + +class DjangoDebugMiddleware(object): + + def resolve(self, next, root, args, context, info): + django_debug = getattr(context, 'django_debug', None) + if not django_debug: + if context is None: + raise Exception('DjangoDebug cannot be executed in None contexts') + try: + context.django_debug = DjangoDebugContext() + except Exception: + raise Exception('DjangoDebug need the context to be writable, context received: {}.'.format( + context.__class__.__name__ + )) + if info.schema.get_type('DjangoDebug') == info.return_type: + return context.django_debug.get_debug_promise() + promise = next(root, args, context, info) + context.django_debug.add_promise(promise) + return promise diff --git a/graphene_django/debug/sql/__init__.py b/graphene_django/debug/sql/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/graphene_django/debug/sql/tracking.py b/graphene_django/debug/sql/tracking.py new file mode 100644 index 0000000..9d14e4b --- /dev/null +++ b/graphene_django/debug/sql/tracking.py @@ -0,0 +1,169 @@ +# Code obtained from django-debug-toolbar sql panel tracking +from __future__ import absolute_import, unicode_literals + +import json +from threading import local +from time import time + +from django.utils import six +from django.utils.encoding import force_text + +from .types import DjangoDebugSQL + + +class SQLQueryTriggered(Exception): + """Thrown when template panel triggers a query""" + + +class ThreadLocalState(local): + + def __init__(self): + self.enabled = True + + @property + def Wrapper(self): + if self.enabled: + return NormalCursorWrapper + return ExceptionCursorWrapper + + def recording(self, v): + self.enabled = v + + +state = ThreadLocalState() +recording = state.recording # export function + + +def wrap_cursor(connection, panel): + if not hasattr(connection, '_graphene_cursor'): + connection._graphene_cursor = connection.cursor + + def cursor(): + return state.Wrapper(connection._graphene_cursor(), connection, panel) + + connection.cursor = cursor + return cursor + + +def unwrap_cursor(connection): + if hasattr(connection, '_graphene_cursor'): + previous_cursor = connection._graphene_cursor + connection.cursor = previous_cursor + del connection._graphene_cursor + + +class ExceptionCursorWrapper(object): + """ + Wraps a cursor and raises an exception on any operation. + Used in Templates panel. + """ + + def __init__(self, cursor, db, logger): + pass + + def __getattr__(self, attr): + raise SQLQueryTriggered() + + +class NormalCursorWrapper(object): + """ + Wraps a cursor and logs queries. + """ + + def __init__(self, cursor, db, logger): + self.cursor = cursor + # Instance of a BaseDatabaseWrapper subclass + self.db = db + # logger must implement a ``record`` method + self.logger = logger + + def _quote_expr(self, element): + if isinstance(element, six.string_types): + return "'%s'" % force_text(element).replace("'", "''") + else: + return repr(element) + + def _quote_params(self, params): + if not params: + return params + if isinstance(params, dict): + return dict((key, self._quote_expr(value)) + for key, value in params.items()) + return list(map(self._quote_expr, params)) + + def _decode(self, param): + try: + return force_text(param, strings_only=True) + except UnicodeDecodeError: + return '(encoded string)' + + def _record(self, method, sql, params): + start_time = time() + try: + return method(sql, params) + finally: + stop_time = time() + duration = (stop_time - start_time) + _params = '' + try: + _params = json.dumps(list(map(self._decode, params))) + except Exception: + pass # object not JSON serializable + + alias = getattr(self.db, 'alias', 'default') + conn = self.db.connection + vendor = getattr(conn, 'vendor', 'unknown') + + params = { + 'vendor': vendor, + 'alias': alias, + 'sql': self.db.ops.last_executed_query( + self.cursor, sql, self._quote_params(params)), + 'duration': duration, + 'raw_sql': sql, + 'params': _params, + 'start_time': start_time, + 'stop_time': stop_time, + 'is_slow': duration > 10, + 'is_select': sql.lower().strip().startswith('select'), + } + + if vendor == 'postgresql': + # If an erroneous query was ran on the connection, it might + # be in a state where checking isolation_level raises an + # exception. + try: + iso_level = conn.isolation_level + except conn.InternalError: + iso_level = 'unknown' + params.update({ + 'trans_id': self.logger.get_transaction_id(alias), + 'trans_status': conn.get_transaction_status(), + 'iso_level': iso_level, + 'encoding': conn.encoding, + }) + + _sql = DjangoDebugSQL(**params) + # We keep `sql` to maintain backwards compatibility + self.logger.object.sql.append(_sql) + + def callproc(self, procname, params=()): + return self._record(self.cursor.callproc, procname, params) + + def execute(self, sql, params=()): + return self._record(self.cursor.execute, sql, params) + + def executemany(self, sql, param_list): + return self._record(self.cursor.executemany, sql, param_list) + + def __getattr__(self, attr): + return getattr(self.cursor, attr) + + def __iter__(self): + return iter(self.cursor) + + def __enter__(self): + return self + + def __exit__(self, type, value, traceback): + self.close() diff --git a/graphene_django/debug/sql/types.py b/graphene_django/debug/sql/types.py new file mode 100644 index 0000000..6ae4d31 --- /dev/null +++ b/graphene_django/debug/sql/types.py @@ -0,0 +1,20 @@ +from graphene import Boolean, Float, ObjectType, String + + +class DjangoDebugSQL(ObjectType): + vendor = String() + alias = String() + sql = String() + duration = Float() + raw_sql = String() + params = String() + start_time = Float() + stop_time = Float() + is_slow = Boolean() + is_select = Boolean() + + # Postgres + trans_id = String() + trans_status = String() + iso_level = String() + encoding = String() diff --git a/graphene_django/debug/tests/__init__.py b/graphene_django/debug/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/graphene_django/debug/tests/test_query.py b/graphene_django/debug/tests/test_query.py new file mode 100644 index 0000000..87e9628 --- /dev/null +++ b/graphene_django/debug/tests/test_query.py @@ -0,0 +1,225 @@ +import pytest + +import graphene +from graphene.relay import Node +from graphene_django import DjangoConnectionField, DjangoObjectType +from graphene_django.utils import DJANGO_FILTER_INSTALLED + +from ...tests.models import Reporter +from ..middleware import DjangoDebugMiddleware +from ..types import DjangoDebug + + +class context(object): + pass + +# from examples.starwars_django.models import Character + +pytestmark = pytest.mark.django_db + + +def test_should_query_field(): + r1 = Reporter(last_name='ABA') + r1.save() + r2 = Reporter(last_name='Griffin') + r2.save() + + class ReporterType(DjangoObjectType): + + class Meta: + model = Reporter + interfaces = (Node, ) + + class Query(graphene.ObjectType): + reporter = graphene.Field(ReporterType) + debug = graphene.Field(DjangoDebug, name='__debug') + + def resolve_reporter(self, *args, **kwargs): + return Reporter.objects.first() + + query = ''' + query ReporterQuery { + reporter { + lastName + } + __debug { + sql { + rawSql + } + } + } + ''' + expected = { + 'reporter': { + 'lastName': 'ABA', + }, + '__debug': { + 'sql': [{ + 'rawSql': str(Reporter.objects.order_by('pk')[:1].query) + }] + } + } + schema = graphene.Schema(query=Query, middlewares=[DjangoDebugMiddleware()]) + result = schema.execute(query, context_value=context()) + assert not result.errors + assert result.data == expected + + +def test_should_query_list(): + r1 = Reporter(last_name='ABA') + r1.save() + r2 = Reporter(last_name='Griffin') + r2.save() + + class ReporterType(DjangoObjectType): + + class Meta: + model = Reporter + interfaces = (Node, ) + + class Query(graphene.ObjectType): + all_reporters = graphene.List(ReporterType) + debug = graphene.Field(DjangoDebug, name='__debug') + + def resolve_all_reporters(self, *args, **kwargs): + return Reporter.objects.all() + + query = ''' + query ReporterQuery { + allReporters { + lastName + } + __debug { + sql { + rawSql + } + } + } + ''' + expected = { + 'allReporters': [{ + 'lastName': 'ABA', + }, { + 'lastName': 'Griffin', + }], + '__debug': { + 'sql': [{ + 'rawSql': str(Reporter.objects.all().query) + }] + } + } + schema = graphene.Schema(query=Query, middlewares=[DjangoDebugMiddleware()]) + result = schema.execute(query, context_value=context()) + assert not result.errors + assert result.data == expected + + +def test_should_query_connection(): + r1 = Reporter(last_name='ABA') + r1.save() + r2 = Reporter(last_name='Griffin') + r2.save() + + class ReporterType(DjangoObjectType): + + class Meta: + model = Reporter + interfaces = (Node, ) + + class Query(graphene.ObjectType): + all_reporters = DjangoConnectionField(ReporterType) + debug = graphene.Field(DjangoDebug, name='__debug') + + def resolve_all_reporters(self, *args, **kwargs): + return Reporter.objects.all() + + query = ''' + query ReporterQuery { + allReporters(first:1) { + edges { + node { + lastName + } + } + } + __debug { + sql { + rawSql + } + } + } + ''' + expected = { + 'allReporters': { + 'edges': [{ + 'node': { + 'lastName': 'ABA', + } + }] + }, + } + schema = graphene.Schema(query=Query, middlewares=[DjangoDebugMiddleware()]) + result = schema.execute(query, context_value=context()) + assert not result.errors + assert result.data['allReporters'] == expected['allReporters'] + assert 'COUNT' in result.data['__debug']['sql'][0]['rawSql'] + query = str(Reporter.objects.all()[:1].query) + assert result.data['__debug']['sql'][1]['rawSql'] == query + + +@pytest.mark.skipif(not DJANGO_FILTER_INSTALLED, + reason="requires django-filter") +def test_should_query_connectionfilter(): + from ...filter import DjangoFilterConnectionField + + r1 = Reporter(last_name='ABA') + r1.save() + r2 = Reporter(last_name='Griffin') + r2.save() + + class ReporterType(DjangoObjectType): + + class Meta: + model = Reporter + interfaces = (Node, ) + + class Query(graphene.ObjectType): + all_reporters = DjangoFilterConnectionField(ReporterType) + s = graphene.String(resolver=lambda *_: "S") + debug = graphene.Field(DjangoDebug, name='__debug') + + def resolve_all_reporters(self, *args, **kwargs): + return Reporter.objects.all() + + query = ''' + query ReporterQuery { + allReporters(first:1) { + edges { + node { + lastName + } + } + } + __debug { + sql { + rawSql + } + } + } + ''' + expected = { + 'allReporters': { + 'edges': [{ + 'node': { + 'lastName': 'ABA', + } + }] + }, + } + schema = graphene.Schema(query=Query, middlewares=[DjangoDebugMiddleware()]) + result = schema.execute(query, context_value=context()) + assert not result.errors + assert result.data['allReporters'] == expected['allReporters'] + assert 'COUNT' in result.data['__debug']['sql'][0]['rawSql'] + query = str(Reporter.objects.all()[:1].query) + assert result.data['__debug']['sql'][1]['rawSql'] == query diff --git a/graphene_django/debug/types.py b/graphene_django/debug/types.py new file mode 100644 index 0000000..891fe38 --- /dev/null +++ b/graphene_django/debug/types.py @@ -0,0 +1,6 @@ +from graphene import ObjectType, List +from .sql.types import DjangoDebugSQL + + +class DjangoDebug(ObjectType): + sql = List(DjangoDebugSQL) diff --git a/graphene_django/fields.py b/graphene_django/fields.py new file mode 100644 index 0000000..b23a4b6 --- /dev/null +++ b/graphene_django/fields.py @@ -0,0 +1,57 @@ +from functools import partial + +from django.db.models.query import QuerySet +from graphene.relay import ConnectionField, PageInfo +from graphql_relay.connection.arrayconnection import connection_from_list_slice +from .utils import maybe_queryset, DJANGO_FILTER_INSTALLED + + +class DjangoConnectionField(ConnectionField): + + def __init__(self, *args, **kwargs): + self.on = kwargs.pop('on', False) + return super(DjangoConnectionField, self).__init__(*args, **kwargs) + + @property + def model(self): + return self.type._meta.node._meta.model + + def get_manager(self): + if self.on: + return getattr(self.model, self.on) + else: + return self.model._default_manager + + @staticmethod + def connection_resolver(resolver, connection, default_manager, root, args, context, info): + iterable = resolver(root, args, context, info) + if iterable is None: + iterable = default_manager + iterable = maybe_queryset(iterable) + if isinstance(iterable, QuerySet): + _len = iterable.count() + else: + _len = len(iterable) + connection = connection_from_list_slice( + iterable, + args, + slice_start=0, + list_length=_len, + list_slice_length=_len, + connection_type=connection, + edge_type=connection.Edge, + pageinfo_type=PageInfo, + ) + connection.iterable = iterable + connection.length = _len + return connection + + def get_resolver(self, parent_resolver): + return partial(self.connection_resolver, parent_resolver, self.type, self.get_manager()) + + +def get_connection_field(*args, **kwargs): + if DJANGO_FILTER_INSTALLED: + from .filter.fields import DjangoFilterConnectionField + return DjangoFilterConnectionField(*args, **kwargs) + return ConnectionField(*args, **kwargs) diff --git a/graphene_django/filter/__init__.py b/graphene_django/filter/__init__.py new file mode 100644 index 0000000..71616b6 --- /dev/null +++ b/graphene_django/filter/__init__.py @@ -0,0 +1,14 @@ +import warnings +from ..utils import DJANGO_FILTER_INSTALLED + +if not DJANGO_FILTER_INSTALLED: + warnings.warn( + "Use of django filtering requires the django-filter package " + "be installed. You can do so using `pip install django-filter`", ImportWarning + ) +else: + from .fields import DjangoFilterConnectionField + from .filterset import GrapheneFilterSet, GlobalIDFilter, GlobalIDMultipleChoiceFilter + + __all__ = ['DjangoFilterConnectionField', 'GrapheneFilterSet', + 'GlobalIDFilter', 'GlobalIDMultipleChoiceFilter'] diff --git a/graphene_django/filter/fields.py b/graphene_django/filter/fields.py new file mode 100644 index 0000000..f4f84e2 --- /dev/null +++ b/graphene_django/filter/fields.py @@ -0,0 +1,39 @@ +from functools import partial +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) + + @staticmethod + def connection_resolver(resolver, connection, default_manager, filterset_class, filtering_args, + root, args, context, info): + filter_kwargs = {k: v for k, v in args.items() if k in filtering_args} + order = args.get('order_by', None) + qs = default_manager.get_queryset() + if order: + qs = qs.order_by(order) + qs = filterset_class(data=filter_kwargs, queryset=qs) + + return DjangoConnectionField.connection_resolver(resolver, connection, qs, root, args, context, info) + + def get_resolver(self, parent_resolver): + return partial(self.connection_resolver, parent_resolver, self.type, self.get_manager(), + self.filterset_class, self.filtering_args) diff --git a/graphene_django/filter/filterset.py b/graphene_django/filter/filterset.py new file mode 100644 index 0000000..7aa4031 --- /dev/null +++ b/graphene_django/filter/filterset.py @@ -0,0 +1,115 @@ +import six +from django.conf import settings +from django.db import models +from django.utils.text import capfirst +from django_filters import Filter, MultipleChoiceFilter +from django_filters.filterset import FilterSet, FilterSetMetaclass + +from ..forms import GlobalIDFormField, GlobalIDMultipleChoiceField +from graphql_relay.node.node import from_global_id + + +class GlobalIDFilter(Filter): + field_class = GlobalIDFormField + + def filter(self, qs, value): + _type, _id = from_global_id(value) + return super(GlobalIDFilter, self).filter(qs, _id) + + +class GlobalIDMultipleChoiceFilter(MultipleChoiceFilter): + field_class = GlobalIDMultipleChoiceField + + def filter(self, qs, value): + gids = [from_global_id(v)[1] for v in value] + return super(GlobalIDMultipleChoiceFilter, self).filter(qs, gids) + + +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_django/filter/tests/__init__.py b/graphene_django/filter/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/graphene_django/filter/tests/filters.py b/graphene_django/filter/tests/filters.py new file mode 100644 index 0000000..1a81669 --- /dev/null +++ b/graphene_django/filter/tests/filters.py @@ -0,0 +1,31 @@ +import django_filters + +from graphene_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_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py new file mode 100644 index 0000000..4735ee0 --- /dev/null +++ b/graphene_django/filter/tests/test_fields.py @@ -0,0 +1,339 @@ +from datetime import datetime + +import pytest + +from graphene import ObjectType, Schema, Field +from graphene.relay import Node +from graphene_django import DjangoObjectType +from graphene_django.forms import (GlobalIDFormField, + GlobalIDMultipleChoiceField) +from graphene_django.tests.models import Article, Pet, Reporter +from graphene_django.utils import DJANGO_FILTER_INSTALLED + +pytestmark = [] +if DJANGO_FILTER_INSTALLED: + import django_filters + from graphene_django.filter import (GlobalIDFilter, DjangoFilterConnectionField, + GlobalIDMultipleChoiceFilter) + from graphene_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(DjangoObjectType): + + class Meta: + model = Article + interfaces = (Node, ) + + +class ReporterNode(DjangoObjectType): + + class Meta: + model = Reporter + interfaces = (Node, ) + + +class PetNode(DjangoObjectType): + + class Meta: + model = Pet + interfaces = (Node, ) + +# schema = Schema() + + +def get_args(field): + if isinstance(field.args, list): + return {arg.name: arg for arg in field.args} + else: + return field.args + + +def assert_arguments(field, *arguments): + ignore = ('after', 'before', 'first', 'last', 'order_by') + args = get_args(field) + actual = [ + name + for name in args + 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): + args = get_args(field) + assert 'order_by' in args, \ + 'Field cannot be ordered' + + +def assert_not_orderable(field): + args = get_args(field) + assert 'order_by' not in args, \ + 'Field can be ordered' + + +def test_filter_explicit_filterset_arguments(): + field = DjangoFilterConnectionField(ArticleNode, filterset_class=ArticleFilter) + assert_arguments(field, + 'headline', 'headline__icontains', + 'pub_date', 'pub_date__gt', 'pub_date__lt', + 'reporter', + ) + + +def test_filter_shortcut_filterset_arguments_list(): + field = DjangoFilterConnectionField(ArticleNode, fields=['pub_date', 'reporter']) + assert_arguments(field, + 'pub_date', + '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(DjangoObjectType): + + class Meta: + model = Reporter + interfaces = (Node, ) + filter_fields = ['first_name', 'articles'] + filter_order_by = True + + field = DjangoFilterConnectionField(ReporterFilterNode) + assert_arguments(field, 'first_name', 'articles') + assert_orderable(field) + + +def test_filter_filterset_information_on_meta_related(): + class ReporterFilterNode(DjangoObjectType): + + class Meta: + model = Reporter + interfaces = (Node, ) + filter_fields = ['first_name', 'articles'] + filter_order_by = True + + class ArticleFilterNode(DjangoObjectType): + + class Meta: + model = Article + interfaces = (Node, ) + filter_fields = ['headline', 'reporter'] + filter_order_by = True + + class Query(ObjectType): + all_reporters = DjangoFilterConnectionField(ReporterFilterNode) + all_articles = DjangoFilterConnectionField(ArticleFilterNode) + reporter = Field(ReporterFilterNode) + article = Field(ArticleFilterNode) + + schema = Schema(query=Query) + articles_field = ReporterFilterNode._meta.fields['articles'].get_type() + assert_arguments(articles_field, 'headline', 'reporter') + assert_orderable(articles_field) + + +def test_filter_filterset_related_results(): + class ReporterFilterNode(DjangoObjectType): + + class Meta: + model = Reporter + interfaces = (Node, ) + filter_fields = ['first_name', 'articles'] + filter_order_by = True + + class ArticleFilterNode(DjangoObjectType): + + class Meta: + interfaces = (Node, ) + model = Article + filter_fields = ['headline', 'reporter'] + filter_order_by = True + + class Query(ObjectType): + all_reporters = DjangoFilterConnectionField(ReporterFilterNode) + all_articles = DjangoFilterConnectionField(ArticleFilterNode) + reporter = Field(ReporterFilterNode) + article = Field(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 + + +def test_filter_filterset_related_results(): + class ReporterFilterNode(DjangoObjectType): + + class Meta: + model = Reporter + interfaces = (Node, ) + filter_fields = { + 'first_name': ['icontains'] + } + + class Query(ObjectType): + all_reporters = DjangoFilterConnectionField(ReporterFilterNode) + + r1 = Reporter.objects.create(first_name='A test user', last_name='Last Name', email='test1@test.com') + r2 = Reporter.objects.create(first_name='Other test user', last_name='Other Last Name', email='test2@test.com') + r3 = Reporter.objects.create(first_name='Random', last_name='RandomLast', email='random@test.com') + + query = ''' + query { + allReporters(firstName_Icontains: "test") { + edges { + node { + id + } + } + } + } + ''' + schema = Schema(query=Query) + result = schema.execute(query) + assert not result.errors + # We should only get two reporters + assert len(result.data['allReporters']['edges']) == 2 diff --git a/graphene_django/filter/utils.py b/graphene_django/filter/utils.py new file mode 100644 index 0000000..86a34f2 --- /dev/null +++ b/graphene_django/filter/utils.py @@ -0,0 +1,31 @@ +import six + +from graphene 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 ..form_converter import convert_form_field + + args = {} + for name, filter_field in six.iteritems(filterset_class.base_filters): + field_type = 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] = 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_django/form_converter.py b/graphene_django/form_converter.py new file mode 100644 index 0000000..2ddb912 --- /dev/null +++ b/graphene_django/form_converter.py @@ -0,0 +1,70 @@ +from django import forms +from django.forms.fields import BaseTemporalField + +from graphene import ID, Boolean, Float, Int, String, List +from .forms import GlobalIDFormField, GlobalIDMultipleChoiceField +from .utils import import_single_dispatch + +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) +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_django/forms.py b/graphene_django/forms.py new file mode 100644 index 0000000..a54f0a5 --- /dev/null +++ b/graphene_django/forms.py @@ -0,0 +1,42 @@ +import binascii + +from django.core.exceptions import ValidationError +from django.forms import CharField, Field, 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: + _type, _id = from_global_id(value) + except (TypeError, ValueError, UnicodeDecodeError, binascii.Error): + raise ValidationError(self.error_messages['invalid']) + + try: + CharField().clean(_id) + CharField().clean(_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_django/management/__init__.py b/graphene_django/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/graphene_django/management/commands/__init__.py b/graphene_django/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/graphene_django/management/commands/graphql_schema.py b/graphene_django/management/commands/graphql_schema.py new file mode 100644 index 0000000..07b802d --- /dev/null +++ b/graphene_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_django/registry.py b/graphene_django/registry.py new file mode 100644 index 0000000..464e826 --- /dev/null +++ b/graphene_django/registry.py @@ -0,0 +1,29 @@ +class Registry(object): + def __init__(self): + self._registry = {} + self._registry_models = {} + + def register(self, cls): + from .types import DjangoObjectType + assert issubclass(cls, DjangoObjectType), 'Only DjangoObjectTypes can be registered, received "{}"'.format(cls.__name__) + assert cls._meta.registry == self, 'Registry for a Model have to match.' + # assert self.get_type_for_model(cls._meta.model) == cls, 'Multiple DjangoObjectTypes registered for "{}"'.format(cls._meta.model) + self._registry[cls._meta.model] = cls + + def get_type_for_model(self, model): + return self._registry.get(model) + + +registry = None + + +def get_global_registry(): + global registry + if not registry: + registry = Registry() + return registry + + +def reset_global_registry(): + global registry + registry = None diff --git a/graphene_django/tests/__init__.py b/graphene_django/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/graphene_django/tests/models.py b/graphene_django/tests/models.py new file mode 100644 index 0000000..a055912 --- /dev/null +++ b/graphene_django/tests/models.py @@ -0,0 +1,52 @@ +from __future__ import absolute_import + +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +CHOICES = ( + (1, 'this'), + (2, _('that')) +) + + +class Pet(models.Model): + name = models.CharField(max_length=30) + + +class FilmDetails(models.Model): + location = models.CharField(max_length=30) + film = models.OneToOneField('Film', related_name='details') + + +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) + email = models.EmailField() + pets = models.ManyToManyField('self') + a_choice = models.CharField(max_length=30, choices=CHOICES) + + def __str__(self): # __unicode__ on Python 2 + return "%s %s" % (self.first_name, self.last_name) + + +class Article(models.Model): + headline = models.CharField(max_length=100) + pub_date = models.DateField() + reporter = models.ForeignKey(Reporter, related_name='articles') + lang = models.CharField(max_length=2, help_text='Language', choices=[ + ('es', 'Spanish'), + ('en', 'English') + ], default='es') + importance = models.IntegerField('Importance', null=True, blank=True, + choices=[(1, u'Very important'), (2, u'Not as important')]) + + def __str__(self): # __unicode__ on Python 2 + return self.headline + + class Meta: + ordering = ('headline',) diff --git a/graphene_django/tests/schema.py b/graphene_django/tests/schema.py new file mode 100644 index 0000000..5584149 --- /dev/null +++ b/graphene_django/tests/schema.py @@ -0,0 +1,39 @@ +import graphene +from graphene import Schema, relay +from ..types import DjangoObjectType + +from .models import Article, Reporter + + +class Character(DjangoObjectType): + + class Meta: + model = Reporter + interfaces = (relay.Node, ) + + def get_node(self, id, context, info): + pass + + +class Human(DjangoObjectType): + raises = graphene.String() + + class Meta: + model = Article + interfaces = (relay.Node, ) + + def resolve_raises(self, *args): + raise Exception("This field should raise exception") + + def get_node(self, id): + pass + + +class Query(graphene.ObjectType): + human = graphene.Field(Human) + + def resolve_human(self, args, context, info): + return Human() + + +schema = Schema(query=Query) diff --git a/graphene_django/tests/test_command.py b/graphene_django/tests/test_command.py new file mode 100644 index 0000000..280a9d3 --- /dev/null +++ b/graphene_django/tests/test_command.py @@ -0,0 +1,11 @@ +from django.core import management +from mock import patch +from six import StringIO + + +@patch('graphene_django.management.commands.graphql_schema.Command.save_file') +def test_generate_file_on_call_graphql_schema(savefile_mock, settings): + settings.GRAPHENE_SCHEMA = 'graphene_django.tests.schema' + 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_django/tests/test_converter.py b/graphene_django/tests/test_converter.py new file mode 100644 index 0000000..ee1553c --- /dev/null +++ b/graphene_django/tests/test_converter.py @@ -0,0 +1,261 @@ +import pytest +from django.db import models +from django.utils.translation import ugettext_lazy as _ +from py.test import raises + +import graphene +from graphene.relay import Node, ConnectionField +from graphene.types.datetime import DateTime +from graphene.types.json import JSONString +# from graphene.core.types.custom_scalars import DateTime, JSONString + +from ..compat import (ArrayField, HStoreField, JSONField, MissingType, + RangeField) +from ..converter import convert_django_field, convert_django_field_with_choices +from ..registry import Registry +from .models import Article, Reporter, Film, FilmDetails, Pet +from ..types import DjangoObjectType + + +def assert_conversion(django_field, graphene_field, *args, **kwargs): + field = django_field(help_text='Custom Help Text', *args, **kwargs) + graphene_type = convert_django_field(field) + assert isinstance(graphene_type, graphene_field) + field = graphene_type.Field() + assert field.description == 'Custom Help Text' + return field + + +def test_should_unknown_django_field_raise_exception(): + with raises(Exception) as excinfo: + convert_django_field(None) + assert 'Don\'t know how to convert the Django field' in str(excinfo.value) + + +def test_should_date_convert_string(): + assert_conversion(models.DateField, DateTime) + + +def test_should_char_convert_string(): + assert_conversion(models.CharField, graphene.String) + + +def test_should_text_convert_string(): + assert_conversion(models.TextField, graphene.String) + + +def test_should_email_convert_string(): + assert_conversion(models.EmailField, graphene.String) + + +def test_should_slug_convert_string(): + assert_conversion(models.SlugField, graphene.String) + + +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_file_convert_string(): + assert_conversion(models.FileField, graphene.String) + + +def test_should_image_convert_string(): + assert_conversion(models.ImageField, graphene.String) + + +def test_should_auto_convert_id(): + assert_conversion(models.AutoField, graphene.ID, primary_key=True) + + +def test_should_positive_integer_convert_int(): + assert_conversion(models.PositiveIntegerField, graphene.Int) + + +def test_should_positive_small_convert_int(): + assert_conversion(models.PositiveSmallIntegerField, graphene.Int) + + +def test_should_small_integer_convert_int(): + assert_conversion(models.SmallIntegerField, graphene.Int) + + +def test_should_big_integer_convert_int(): + assert_conversion(models.BigIntegerField, graphene.Int) + + +def test_should_integer_convert_int(): + assert_conversion(models.IntegerField, graphene.Int) + + +def test_should_boolean_convert_boolean(): + field = assert_conversion(models.BooleanField, graphene.NonNull) + assert field.type.of_type == graphene.Boolean + + +def test_should_nullboolean_convert_boolean(): + assert_conversion(models.NullBooleanField, graphene.Boolean) + + +def test_field_with_choices_convert_enum(): + field = models.CharField(help_text='Language', choices=( + ('es', 'Spanish'), + ('en', 'English') + )) + + class TranslatedModel(models.Model): + language = field + + class Meta: + app_label = 'test' + + graphene_type = convert_django_field_with_choices(field) + assert isinstance(graphene_type, graphene.Enum) + assert graphene_type._meta.name == 'TranslatedModelLanguage' + assert graphene_type._meta.enum.__members__['SPANISH'].value == 'es' + assert graphene_type._meta.enum.__members__['SPANISH'].description == 'Spanish' + assert graphene_type._meta.enum.__members__['ENGLISH'].value == 'en' + assert graphene_type._meta.enum.__members__['ENGLISH'].description == 'English' + + +def test_field_with_grouped_choices(): + field = models.CharField(help_text='Language', choices=( + ('Europe', ( + ('es', 'Spanish'), + ('en', 'English'), + )), + )) + + class GroupedChoicesModel(models.Model): + language = field + + class Meta: + app_label = 'test' + + convert_django_field_with_choices(field) + + +def test_field_with_choices_gettext(): + field = models.CharField(help_text='Language', choices=( + ('es', _('Spanish')), + ('en', _('English')) + )) + + class TranslatedChoicesModel(models.Model): + language = field + + class Meta: + app_label = 'test' + + convert_django_field_with_choices(field) + + +def test_should_float_convert_float(): + assert_conversion(models.FloatField, graphene.Float) + + +def test_should_manytomany_convert_connectionorlist(): + registry = Registry() + dynamic_field = convert_django_field(Reporter._meta.local_many_to_many[0], registry) + assert not dynamic_field.get_type() + + +def test_should_manytomany_convert_connectionorlist_list(): + class A(DjangoObjectType): + class Meta: + model = Reporter + + graphene_field = convert_django_field(Reporter._meta.local_many_to_many[0], A._meta.registry) + assert isinstance(graphene_field, graphene.Dynamic) + dynamic_field = graphene_field.get_type() + assert isinstance(dynamic_field, graphene.Field) + assert isinstance(dynamic_field.type, graphene.List) + assert dynamic_field.type.of_type == A + + +def test_should_manytomany_convert_connectionorlist_connection(): + class A(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node, ) + + graphene_field = convert_django_field(Reporter._meta.local_many_to_many[0], A._meta.registry) + assert isinstance(graphene_field, graphene.Dynamic) + dynamic_field = graphene_field.get_type() + assert isinstance(dynamic_field, ConnectionField) + assert dynamic_field.type == A.Connection + + +def test_should_manytoone_convert_connectionorlist(): + # Django 1.9 uses 'rel', <1.9 uses 'related + related = getattr(Reporter.articles, 'rel', None) or \ + getattr(Reporter.articles, 'related') + + class A(DjangoObjectType): + class Meta: + model = Article + + graphene_field = convert_django_field(related, A._meta.registry) + assert isinstance(graphene_field, graphene.Dynamic) + dynamic_field = graphene_field.get_type() + assert isinstance(dynamic_field, graphene.Field) + assert isinstance(dynamic_field.type, graphene.List) + assert dynamic_field.type.of_type == A + + +def test_should_onetoone_reverse_convert_model(): + # Django 1.9 uses 'rel', <1.9 uses 'related + related = getattr(Film.details, 'rel', None) or \ + getattr(Film.details, 'related') + + class A(DjangoObjectType): + class Meta: + model = FilmDetails + + graphene_field = convert_django_field(related, A._meta.registry) + assert isinstance(graphene_field, graphene.Dynamic) + dynamic_field = graphene_field.get_type() + assert isinstance(dynamic_field, graphene.Field) + assert dynamic_field.type == A + + +@pytest.mark.skipif(ArrayField is MissingType, + reason="ArrayField should exist") +def test_should_postgres_array_convert_list(): + field = assert_conversion(ArrayField, graphene.List, models.CharField(max_length=100)) + assert isinstance(field.type, graphene.List) + assert field.type.of_type == graphene.String + + +@pytest.mark.skipif(ArrayField is MissingType, + reason="ArrayField should exist") +def test_should_postgres_array_multiple_convert_list(): + field = assert_conversion(ArrayField, graphene.List, ArrayField(models.CharField(max_length=100))) + assert isinstance(field.type, graphene.List) + assert isinstance(field.type.of_type, graphene.List) + assert field.type.of_type.of_type == graphene.String + + +@pytest.mark.skipif(HStoreField is MissingType, + reason="HStoreField should exist") +def test_should_postgres_hstore_convert_string(): + assert_conversion(HStoreField, JSONString) + + +@pytest.mark.skipif(JSONField is MissingType, + reason="JSONField should exist") +def test_should_postgres_json_convert_string(): + assert_conversion(JSONField, JSONString) + + +@pytest.mark.skipif(RangeField is MissingType, + reason="RangeField should exist") +def test_should_postgres_range_convert_list(): + from django.contrib.postgres.fields import IntegerRangeField + field = assert_conversion(IntegerRangeField, graphene.List) + assert isinstance(field.type, graphene.List) + assert field.type.of_type == graphene.Int diff --git a/graphene_django/tests/test_form_converter.py b/graphene_django/tests/test_form_converter.py new file mode 100644 index 0000000..661e73a --- /dev/null +++ b/graphene_django/tests/test_form_converter.py @@ -0,0 +1,103 @@ +from django import forms +from py.test import raises + +import graphene +from ..form_converter import convert_form_field +from graphene import ID, List, NonNull + +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.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 isinstance(field.type, NonNull) + + +def test_should_nullboolean_convert_boolean(): + field = assert_conversion(forms.NullBooleanField, graphene.Boolean) + assert not isinstance(field.type, NonNull) + + +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 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_django/tests/test_forms.py b/graphene_django/tests/test_forms.py new file mode 100644 index 0000000..ada9e8a --- /dev/null +++ b/graphene_django/tests/test_forms.py @@ -0,0 +1,29 @@ +from django.core.exceptions import ValidationError +from py.test import raises + +from ..forms import GlobalIDFormField + + +# 'TXlUeXBlOmFiYw==' -> 'MyType', 'abc' + + +def test_global_id_valid(): + field = GlobalIDFormField() + field.clean('TXlUeXBlOmFiYw==') + + +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) diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py new file mode 100644 index 0000000..80a45e9 --- /dev/null +++ b/graphene_django/tests/test_query.py @@ -0,0 +1,252 @@ +import datetime + +import pytest +from django.db import models +from py.test import raises + +import graphene +from graphene.relay import Node + +from ..compat import MissingType, RangeField +from ..types import DjangoObjectType +from ..fields import DjangoConnectionField +from ..registry import reset_global_registry, get_global_registry +from .models import Article, Reporter + +pytestmark = pytest.mark.django_db + + +def test_should_query_only_fields(): + with raises(Exception): + class ReporterType(DjangoObjectType): + + class Meta: + model = Reporter + only_fields = ('articles', ) + + schema = graphene.Schema(query=ReporterType) + query = ''' + query ReporterQuery { + articles + } + ''' + result = schema.execute(query) + assert not result.errors + + +def test_should_query_well(): + class ReporterType(DjangoObjectType): + + class Meta: + model = Reporter + + class Query(graphene.ObjectType): + reporter = graphene.Field(ReporterType) + + def resolve_reporter(self, *args, **kwargs): + return Reporter(first_name='ABA', last_name='X') + + query = ''' + query ReporterQuery { + reporter { + firstName, + lastName, + email + } + } + ''' + expected = { + 'reporter': { + 'firstName': 'ABA', + 'lastName': 'X', + 'email': '' + } + } + schema = graphene.Schema(query=Query) + result = schema.execute(query) + assert not result.errors + assert result.data == expected + + +@pytest.mark.skipif(RangeField is MissingType, + reason="RangeField should exist") +def test_should_query_postgres_fields(): + from django.contrib.postgres.fields import IntegerRangeField, ArrayField, JSONField, HStoreField + + class Event(models.Model): + ages = IntegerRangeField(help_text='The age ranges') + data = JSONField(help_text='Data') + store = HStoreField() + tags = ArrayField(models.CharField(max_length=50)) + + class EventType(DjangoObjectType): + + class Meta: + model = Event + + class Query(graphene.ObjectType): + event = graphene.Field(EventType) + + def resolve_event(self, *args, **kwargs): + return Event( + ages=(0, 10), + data={'angry_babies': True}, + store={'h': 'store'}, + tags=['child', 'angry', 'babies'] + ) + + schema = graphene.Schema(query=Query) + query = ''' + query myQuery { + event { + ages + tags + data + store + } + } + ''' + expected = { + 'event': { + 'ages': [0, 10], + 'tags': ['child', 'angry', 'babies'], + 'data': '{"angry_babies": true}', + 'store': '{"h": "store"}', + }, + } + result = schema.execute(query) + assert not result.errors + assert result.data == expected + + +def test_should_node(): + # reset_global_registry() + # Node._meta.registry = get_global_registry() + + class ReporterNode(DjangoObjectType): + + class Meta: + model = Reporter + interfaces = (Node, ) + + @classmethod + def get_node(cls, id, context, info): + return Reporter(id=2, first_name='Cookie Monster') + + def resolve_articles(self, *args, **kwargs): + return [Article(headline='Hi!')] + + class ArticleNode(DjangoObjectType): + + class Meta: + model = Article + interfaces = (Node, ) + + @classmethod + def get_node(cls, id, context, info): + return Article(id=1, headline='Article node', pub_date=datetime.date(2002, 3, 11)) + + class Query(graphene.ObjectType): + node = Node.Field() + reporter = graphene.Field(ReporterNode) + article = graphene.Field(ArticleNode) + + def resolve_reporter(self, *args, **kwargs): + return Reporter(id=1, first_name='ABA', last_name='X') + + query = ''' + query ReporterQuery { + reporter { + id, + firstName, + articles { + edges { + node { + headline + } + } + } + lastName, + email + } + myArticle: node(id:"QXJ0aWNsZU5vZGU6MQ==") { + id + ... on ReporterNode { + firstName + } + ... on ArticleNode { + headline + pubDate + } + } + } + ''' + expected = { + 'reporter': { + 'id': 'UmVwb3J0ZXJOb2RlOjE=', + 'firstName': 'ABA', + 'lastName': 'X', + 'email': '', + 'articles': { + 'edges': [{ + 'node': { + 'headline': 'Hi!' + } + }] + }, + }, + 'myArticle': { + 'id': 'QXJ0aWNsZU5vZGU6MQ==', + 'headline': 'Article node', + 'pubDate': '2002-03-11', + } + } + schema = graphene.Schema(query=Query) + result = schema.execute(query) + assert not result.errors + assert result.data == expected + + +def test_should_query_connectionfields(): + class ReporterType(DjangoObjectType): + + class Meta: + model = Reporter + interfaces = (Node, ) + only_fields = ('articles', ) + + class Query(graphene.ObjectType): + all_reporters = DjangoConnectionField(ReporterType) + + def resolve_all_reporters(self, args, context, info): + return [Reporter(id=1)] + + schema = graphene.Schema(query=Query) + query = ''' + query ReporterConnectionQuery { + allReporters { + pageInfo { + hasNextPage + } + edges { + node { + id + } + } + } + } + ''' + result = schema.execute(query) + assert not result.errors + assert result.data == { + 'allReporters': { + 'pageInfo': { + 'hasNextPage': False, + }, + 'edges': [{ + 'node': { + 'id': 'UmVwb3J0ZXJUeXBlOjE=' + } + }] + } + } diff --git a/graphene_django/tests/test_schema.py b/graphene_django/tests/test_schema.py new file mode 100644 index 0000000..d5af9c3 --- /dev/null +++ b/graphene_django/tests/test_schema.py @@ -0,0 +1,40 @@ +from py.test import raises + +from ..types import DjangoObjectType +from ..registry import Registry + +from .models import Reporter + + +def test_should_raise_if_no_model(): + with raises(Exception) as excinfo: + class Character1(DjangoObjectType): + pass + assert 'valid Django Model' in str(excinfo.value) + + +def test_should_raise_if_model_is_invalid(): + with raises(Exception) as excinfo: + class Character2(DjangoObjectType): + + class Meta: + model = 1 + assert 'valid Django Model' in str(excinfo.value) + + +def test_should_map_fields_correctly(): + class ReporterType2(DjangoObjectType): + class Meta: + model = Reporter + registry = Registry() + assert list(ReporterType2._meta.fields.keys()) == ['id', 'first_name', 'last_name', 'email', 'pets', 'a_choice', 'articles', 'films'] + + +def test_should_map_only_few_fields(): + class Reporter2(DjangoObjectType): + + class Meta: + model = Reporter + only_fields = ('id', 'email') + + assert list(Reporter2._meta.fields.keys()) == ['id', 'email'] diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py new file mode 100644 index 0000000..ecc2225 --- /dev/null +++ b/graphene_django/tests/test_types.py @@ -0,0 +1,123 @@ +from graphql.type import GraphQLObjectType +from mock import patch + +from graphene import ObjectType, Field, Int, ID, Schema, Interface +from graphene.relay import Node, ConnectionField +from ..types import DjangoObjectType + +from .models import Article as ArticleModel, Reporter as ReporterModel +from ..registry import reset_global_registry, Registry + +reset_global_registry() + + +class Reporter(DjangoObjectType): + '''Reporter description''' + class Meta: + model = ReporterModel + + +class Article(DjangoObjectType): + '''Article description''' + class Meta: + model = ArticleModel + interfaces = (Node, ) + + +class RootQuery(ObjectType): + node = Node.Field() + + +schema = Schema(query=RootQuery, types=[Article, Reporter]) + + +def test_django_interface(): + assert issubclass(Node, Interface) + assert issubclass(Node, Node) + + +@patch('graphene_django.tests.models.Article.objects.get', return_value=Article(id=1)) +def test_django_get_node(get): + article = Article.get_node(1, None, None) + get.assert_called_with(id=1) + assert article.id == 1 + + +def test_django_objecttype_map_correct_fields(): + fields = Reporter._meta.fields + assert list(fields.keys()) == ['id', 'first_name', 'last_name', 'email', 'pets', 'a_choice', 'articles', 'films'] + + +def test_django_objecttype_with_node_have_correct_fields(): + fields = Article._meta.fields + assert list(fields.keys()) == ['id', 'headline', 'pub_date', 'reporter', 'lang', 'importance'] + + +def test_schema_representation(): + expected = """ +schema { + query: RootQuery +} + +type Article implements Node { + id: ID! + headline: String + pubDate: DateTime + reporter: Reporter + lang: ArticleLang + importance: ArticleImportance +} + +type ArticleConnection { + pageInfo: PageInfo! + edges: [ArticleEdge] +} + +type ArticleEdge { + node: Article + cursor: String! +} + +enum ArticleImportance { + VERY_IMPORTANT + NOT_AS_IMPORTANT +} + +enum ArticleLang { + SPANISH + ENGLISH +} + +scalar DateTime + +interface Node { + id: ID! +} + +type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String +} + +type Reporter { + id: ID + firstName: String + lastName: String + email: String + pets: [Reporter] + aChoice: ReporterA_choice + articles(before: String, after: String, first: Int, last: Int): ArticleConnection +} + +enum ReporterA_choice { + THIS + THAT +} + +type RootQuery { + node(id: ID!): Node +} +""".lstrip() + assert str(schema) == expected diff --git a/graphene_django/tests/test_views.py b/graphene_django/tests/test_views.py new file mode 100644 index 0000000..efa0381 --- /dev/null +++ b/graphene_django/tests/test_views.py @@ -0,0 +1,57 @@ +import json + + +def format_response(response): + return json.loads(response.content.decode()) + + +def test_client_get_good_query(settings, client): + settings.ROOT_URLCONF = 'graphene_django.tests.urls' + response = client.get('/graphql', {'query': '{ human { headline } }'}) + json_response = format_response(response) + expected_json = { + 'data': { + 'human': { + 'headline': None + } + } + } + assert json_response == expected_json + + +def test_client_get_good_query_with_raise(settings, client): + settings.ROOT_URLCONF = 'graphene_django.tests.urls' + response = client.get('/graphql', {'query': '{ human { raises } }'}) + json_response = format_response(response) + assert json_response['errors'][0]['message'] == 'This field should raise exception' + assert json_response['data']['human']['raises'] is None + + +def test_client_post_good_query_json(settings, client): + settings.ROOT_URLCONF = 'graphene_django.tests.urls' + response = client.post( + '/graphql', json.dumps({'query': '{ human { headline } }'}), 'application/json') + json_response = format_response(response) + expected_json = { + 'data': { + 'human': { + 'headline': None + } + } + } + assert json_response == expected_json + + +def test_client_post_good_query_graphql(settings, client): + settings.ROOT_URLCONF = 'graphene_django.tests.urls' + response = client.post( + '/graphql', '{ human { headline } }', 'application/graphql') + json_response = format_response(response) + expected_json = { + 'data': { + 'human': { + 'headline': None + } + } + } + assert json_response == expected_json diff --git a/graphene_django/tests/urls.py b/graphene_django/tests/urls.py new file mode 100644 index 0000000..fd0eb4c --- /dev/null +++ b/graphene_django/tests/urls.py @@ -0,0 +1,8 @@ +from django.conf.urls import url + +from ..views import GraphQLView +from .schema import schema + +urlpatterns = [ + url(r'^graphql', GraphQLView.as_view(schema=schema)), +] diff --git a/graphene_django/types.py b/graphene_django/types.py new file mode 100644 index 0000000..91e7173 --- /dev/null +++ b/graphene_django/types.py @@ -0,0 +1,114 @@ +from collections import OrderedDict + +import six + +from graphene import ObjectType, Field +from graphene.types.objecttype import ObjectTypeMeta +from .converter import convert_django_field_with_choices +from graphene.types.options import Options +from .utils import get_model_fields, is_valid_django_model, DJANGO_FILTER_INSTALLED +from .registry import Registry, get_global_registry +from graphene.utils.is_base_type import is_base_type +from graphene.types.utils import yank_fields_from_attrs, merge + + +def construct_fields(options): + _model_fields = get_model_fields(options.model) + only_fields = options.only_fields + exclude_fields = options.exclude_fields + + fields = OrderedDict() + for field in _model_fields: + name = field.name + is_not_in_only = only_fields and name not in options.only_fields + is_already_created = name in options.fields + is_excluded = name in exclude_fields or is_already_created + if is_not_in_only or is_excluded: + # We skip this field if we specify only_fields and is not + # in there. Or when we exclude this field in exclude_fields + continue + converted = convert_django_field_with_choices(field, options.registry) + if not converted: + continue + fields[name] = converted + + return fields + + +class DjangoObjectTypeMeta(ObjectTypeMeta): + + @staticmethod + def __new__(cls, name, bases, attrs): + # Also ensure initialization is only performed for subclasses of + # DjangoObjectType + if not is_base_type(bases, DjangoObjectTypeMeta): + return type.__new__(cls, name, bases, attrs) + + defaults = dict( + name=name, + description=attrs.pop('__doc__', None), + model=None, + local_fields=None, + only_fields=(), + exclude_fields=(), + interfaces=(), + registry=None + ) + if DJANGO_FILTER_INSTALLED: + # In case Django filter is available, then + # we allow more attributes in Meta + defaults.update( + filter_fields=(), + filter_order_by=(), + ) + + options = Options( + attrs.pop('Meta', None), + **defaults + ) + if not options.registry: + options.registry = get_global_registry() + assert isinstance(options.registry, Registry), ( + 'The attribute registry in {}.Meta needs to be an instance of ' + 'Registry, received "{}".' + ).format(name, options.registry) + assert is_valid_django_model(options.model), ( + 'You need to pass a valid Django Model in {}.Meta, received "{}".' + ).format(name, options.model) + + cls = ObjectTypeMeta.__new__(cls, name, bases, dict(attrs, _meta=options)) + + options.registry.register(cls) + + options.django_fields = yank_fields_from_attrs( + construct_fields(options), + _as=Field, + ) + options.fields = merge( + options.interface_fields, + options.django_fields, + options.base_fields, + options.local_fields + ) + + return cls + + +class DjangoObjectType(six.with_metaclass(DjangoObjectTypeMeta, ObjectType)): + @classmethod + def is_type_of(cls, root, context, info): + if isinstance(root, cls): + return True + if not is_valid_django_model(type(root)): + raise Exception(( + 'Received incompatible instance "{}".' + ).format(root)) + model = root._meta.model + return model == cls._meta.model + + @classmethod + def get_node(cls, id, context, info): + try: + return cls._meta.model.objects.get(id=id) + except cls._meta.model.DoesNotExist: + return None diff --git a/graphene_django/utils.py b/graphene_django/utils.py new file mode 100644 index 0000000..a7c37b0 --- /dev/null +++ b/graphene_django/utils.py @@ -0,0 +1,81 @@ +import inspect +from django.db import models +from django.db.models.manager import Manager + +# from graphene.utils import LazyList +class LazyList(object): + pass + +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_reverse_fields(model): + for name, attr in model.__dict__.items(): + # 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 + + +def maybe_queryset(value): + if isinstance(value, Manager): + value = value.get_queryset() + return value + + +def get_model_fields(model): + reverse_fields = get_reverse_fields(model) + all_fields = sorted(list(model._meta.fields) + + list(model._meta.local_many_to_many)) + all_fields += list(reverse_fields) + + return all_fields + + +def get_related_model(field): + if hasattr(field, 'rel'): + # Django 1.6, 1.7 + return field.rel.to + return field.related_model + + +def is_valid_django_model(model): + return inspect.isclass(model) and issubclass(model, models.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_django/views.py b/graphene_django/views.py new file mode 100644 index 0000000..cc8b2b6 --- /dev/null +++ b/graphene_django/views.py @@ -0,0 +1,9 @@ +from graphql_django_view import GraphQLView as BaseGraphQLView + + +class GraphQLView(BaseGraphQLView): + def __init__(self, schema, **kwargs): + super(GraphQLView, self).__init__( + schema=schema, + **kwargs + ) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..83ffeec --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[tool:pytest] +DJANGO_SETTINGS_MODULE = django_test_settings diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..33dab3b --- /dev/null +++ b/setup.py @@ -0,0 +1,50 @@ +from setuptools import find_packages, setup + +setup( + name='graphene-django', + version='1.0.dev20160910000001', + + description='Graphene Django integration', + # long_description=open('README.rst').read(), + + url='https://github.com/graphql-python/graphene-django', + + author='Syrus Akbary', + author_email='me@syrusakbary.com', + + license='MIT', + + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Intended Audience :: Developers', + 'Topic :: Software Development :: Libraries', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: Implementation :: PyPy', + ], + + keywords='api graphql protocol rest relay graphene', + + packages=find_packages(exclude=['tests']), + + install_requires=[ + 'six>=1.10.0', + 'graphene>=1.0.dev', + 'Django>=1.6.0', + 'iso8601', + 'singledispatch>=3.4.0.3', + 'graphql-django-view>=1.3', + ], + tests_require=[ + 'django-filter>=0.10.0', + 'pytest>=3.0', + 'pytest-django', + 'mock', + # Required for Django postgres fields testing + 'psycopg2', + ], +)