diff --git a/.travis.yml b/.travis.yml index b6996d24..ae23cd02 100644 --- a/.travis.yml +++ b/.travis.yml @@ -25,6 +25,7 @@ install: if [ "$TEST_TYPE" = build ]; then pip install --download-cache $HOME/.cache/pip/ pytest pytest-cov coveralls six pytest-django django-filter pip install --download-cache $HOME/.cache/pip/ -e .[django] + pip install --download-cache $HOME/.cache/pip/ -e .[sqlalchemy] pip install django==$DJANGO_VERSION python setup.py develop elif [ "$TEST_TYPE" = build_website ]; then diff --git a/README.md b/README.md index e36ac257..52ab5635 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ - **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. @@ -18,8 +19,10 @@ For instaling graphene, just run this command in your shell ```bash pip install graphene -# Or in case of need Django model support +# In case of need Django model support pip install graphene[django] +# Or in case of need SQLAlchemy support +pip install graphene[sqlalchemy] ``` @@ -59,6 +62,7 @@ If you want to learn even more, you can also check the following [examples](exam * **Basic Schema**: [Starwars example](examples/starwars) * **Relay Schema**: [Starwars Relay example](examples/starwars_relay) * **Django model mapping**: [Starwars Django example](examples/starwars_django) +* **SQLAlchemy model mapping**: [Flask SQLAlchemy example](examples/flask_sqlalchemy) ## Contributing diff --git a/README.rst b/README.rst index 1d427010..a3b8703a 100644 --- a/README.rst +++ b/README.rst @@ -12,6 +12,8 @@ building GraphQL schemas/types fast and easily. `Django `__ 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. @@ -27,8 +29,10 @@ For instaling graphene, just run this command in your shell .. code:: bash pip install graphene - # Or in case of need Django model support + # In case of need Django model support pip install graphene[django] + # Or in case of need SQLAlchemy support + pip install graphene[sqlalchemy] Examples -------- @@ -70,6 +74,8 @@ If you want to learn even more, you can also check the following example `__ - **Django model mapping**: `Starwars Django example `__ +- **SQLAlchemy model mapping**: `Flask SQLAlchemy + example `__ Contributing ------------ diff --git a/docs/config.toml b/docs/config.toml index c1a13ecd..6a858b3c 100644 --- a/docs/config.toml +++ b/docs/config.toml @@ -23,3 +23,10 @@ ga = "UA-12613282-7" "/docs/django/tutorial/", "/docs/django/filtering/", ] + +[docs.sqlalchemy] + name = "SQLAlchemy" + pages = [ + "/docs/sqlalchemy/tutorial/", + "/docs/sqlalchemy/tips/", + ] diff --git a/docs/css/graphiql.css b/docs/css/graphiql.css index 5cc95b66..926e7c23 100644 --- a/docs/css/graphiql.css +++ b/docs/css/graphiql.css @@ -97,6 +97,7 @@ } #graphiql-container .resultWrap { + position: relative; display: -webkit-flex; display: flex; -webkit-flex-direction: column; @@ -1010,6 +1011,52 @@ span.CodeMirror-selectedtext { background: none; } background-position: right bottom; width: 100%; height: 100%; } +#graphiql-container .spinner-container { + position: absolute; + top: 50%; + height: 36px; + width: 36px; + left: 50%; + transform: translate(-50%, -50%); + z-index: 10; +} + +#graphiql-container .spinner { + vertical-align: middle; + display: inline-block; + height: 24px; + width: 24px; + position: absolute; + -webkit-animation: rotation .6s infinite linear; + -moz-animation: rotation .6s infinite linear; + -o-animation: rotation .6s infinite linear; + animation: rotation .6s infinite linear; + border-left: 6px solid rgba(150, 150, 150, .15); + border-right: 6px solid rgba(150, 150, 150, .15); + border-bottom: 6px solid rgba(150, 150, 150, .15); + border-top: 6px solid rgba(150, 150, 150, .8); + border-radius: 100%; +} + +@-webkit-keyframes rotation { + from { -webkit-transform: rotate(0deg); } + to { -webkit-transform: rotate(359deg); } +} + +@-moz-keyframes rotation { + from { -moz-transform: rotate(0deg); } + to { -moz-transform: rotate(359deg); } +} + +@-o-keyframes rotation { + from { -o-transform: rotate(0deg); } + to { -o-transform: rotate(359deg); } +} + +@keyframes rotation { + from { transform: rotate(0deg); } + to { transform: rotate(359deg); } +} .CodeMirror-hints { background: white; -webkit-box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45); @@ -1076,4 +1123,4 @@ li.CodeMirror-hint-active { border-bottom: solid 1px #c0c0c0; border-top: none; margin-bottom: -1px; -} +} \ No newline at end of file diff --git a/docs/package.json b/docs/package.json index 694d0ff8..7e75e4b5 100644 --- a/docs/package.json +++ b/docs/package.json @@ -18,7 +18,7 @@ "es6-promise": "^3.0.2", "extract-text-webpack-plugin": "^0.9.1", "gatsby": "^0.7.7", - "graphiql": "^0.4.2", + "graphiql": "^0.4.5", "graphql": "^0.4.13", "jeet": "^6.1.2", "lodash": "^3.10.1", diff --git a/docs/pages/community.md b/docs/pages/community.md index 8cdf0f50..926d3925 100644 --- a/docs/pages/community.md +++ b/docs/pages/community.md @@ -21,6 +21,9 @@ Django integration: - **graphql-django-view**: [Source Code][5] - [PyPI package][6] - **django-graphiql**: [Source Code][7] - [PyPI package][8] +Flask integration: +- **graphql-flask**: [Source Code][9] - [PyPI package][10] + ## Other related projects - [Flask GraphQL Demo](https://github.com/amitsaha/flask-graphql-demo) by [@echorand](https://twitter.com/echorand) @@ -37,3 +40,5 @@ Django integration: [6]: https://pypi.python.org/pypi/graphql-django-view [7]: https://github.com/graphql-python/django-graphiql [8]: https://pypi.python.org/pypi/django-graphiql + [9]: https://github.com/graphql-python/graphql-flask + [10]: https://pypi.python.org/pypi/graphql-flask diff --git a/docs/pages/docs/sqlalchemy/tips.md b/docs/pages/docs/sqlalchemy/tips.md new file mode 100644 index 00000000..d27e3bcc --- /dev/null +++ b/docs/pages/docs/sqlalchemy/tips.md @@ -0,0 +1,30 @@ +--- +title: Tips +description: Tips when SQLAlchemy in Graphene +--- + +# Tips + +## Querying + +For make querying to the database work, there are two alternatives: + +* Expose the db session when you create the `graphene.Schema`: + +```python +schema = graphene.Schema(session=session) +``` + +* Create a query for the models. + +```python +Base = declarative_base() +Base.query = db_session.query_property() + +class MyModel(Base): + # ... +``` + +If you don't specify any, the following error will be displayed: + +`A query in the model Base or a session in the schema is required for querying.` diff --git a/docs/pages/docs/sqlalchemy/tutorial.md b/docs/pages/docs/sqlalchemy/tutorial.md new file mode 100644 index 00000000..6d1acb03 --- /dev/null +++ b/docs/pages/docs/sqlalchemy/tutorial.md @@ -0,0 +1,199 @@ +--- +title: Tutorial +description: Using SQLAlchemy with Graphene +--- + +# SQLAlchemy + Flask Tutorial + +Graphene comes with builtin support to SQLAlchemy, which makes quite easy to operate with your current models. + +**Note: The code in this tutorial is pulled from the +[Flask SQLAlchemy example app](https://github.com/graphql-python/graphene/tree/master/examples/flask_sqlalchemy)**. + + +## Setup the Project + +We will setup the project, execute the following: + +```bash +# Create the project directory +mkdir flask_sqlalchemy +cd flask_sqlalchemy + +# Create a virtualenv to isolate our package dependencies locally +virtualenv env +source env/bin/activate # On Windows use `env\Scripts\activate` + +# SQLAlchemy and Graphene with SQLAlchemy support +pip install SQLAlchemy +pip install graphene[sqlalchemy] + +# Install Flask and GraphQL Flask for exposing the schema through HTTP +pip install Flask +pip install graphql-flask +``` + +## Defining our models + +Let's get started with these models: + +```python +# flask_sqlalchemy/models.py +from sqlalchemy import * +from sqlalchemy.orm import (scoped_session, sessionmaker, relationship, + backref) +from sqlalchemy.ext.declarative import declarative_base + +engine = create_engine('sqlite:///database.sqlite3', convert_unicode=True) +db_session = scoped_session(sessionmaker(autocommit=False, + autoflush=False, + bind=engine)) + +Base = declarative_base() +# We will need this for querying +Base.query = db_session.query_property() + + +class Department(Base): + __tablename__ = 'department' + id = Column(Integer, primary_key=True) + name = Column(String) + + +class Employee(Base): + __tablename__ = 'employee' + id = Column(Integer, primary_key=True) + name = Column(String) + hired_on = Column(DateTime, default=func.now()) + department_id = Column(Integer, ForeignKey('department.id')) + department = relationship( + Department, + backref=backref('employees', + uselist=True, + cascade='delete,all')) +``` + +## Schema + +GraphQL presents your objects to the world as a graph structure rather than a more +hierarchical structure to which you may be accustomed. In order to create this +representation, Graphene needs to know about each *type* of object which will appear in +the graph. + +This graph also has a *root type* through which all access begins. This is the `Query` class below. +In this example, we provide the ability to list all employees via `all_employees`, and the +ability to obtain a specific node via `node`. + +Create `flask_sqlalchemy/schema.py` and type the following: + +```python +# flask_sqlalchemy/schema.py +import graphene +from graphene import relay +from graphene.contrib.sqlalchemy import SQLAlchemyNode, SQLAlchemyConnectionField +from models import db_session, Department as DepartmentModel, Employee as EmployeeModel + +schema = graphene.Schema() + + +@schema.register +class Department(SQLAlchemyNode): + class Meta: + model = DepartmentModel + + +@schema.register +class Employee(SQLAlchemyNode): + class Meta: + model = EmployeeModel + + +class Query(graphene.ObjectType): + node = relay.NodeField() + all_employees = SQLAlchemyConnectionField(Employee) + +schema.query = Query +``` + +## Creating GraphQL and GraphiQL views in Flask + +Unlike a RESTful API, there is only a single URL from which GraphQL is accessed. + +We are going to use Flask to create a server that expose the GraphQL schema under `/graphql` and a interface for querying it easily: GraphiQL under `/graphiql`. + +Afortunately for us, the library `graphql-flask` that we installed previously is making the task quite easy. + +```python +# flask_sqlalchemy/app.py +from flask import Flask +from graphql_flask import GraphQL + +from models import db_session +from schema import schema, Department + +app = Flask(__name__) +app.debug = True + +# This is creating the `/graphql` and `/graphiql` endpoints +GraphQL(app, schema=schema) + +@app.teardown_appcontext +def shutdown_session(exception=None): + db_session.remove() + +if __name__ == '__main__': + app.run() +``` + + +## Creating some data + +```bash +$ python + +>>> from models import engine, db_session, Base, Department, Employee +>>> Base.metadata.create_all(bind=engine) + +>>> # Fill the tables with some data +>>> engineering = Department(name='Engineering') +>>> db_session.add(engineering) +>>> hr = Department(name='Human Resources') +>>> db_session.add(hr) + +>>> peter = Employee(name='Peter', department=engineering) +>>> db_session.add(peter) +>>> roy = Employee(name='Roy', department=engineering) +>>> db_session.add(roy) +>>> tracy = Employee(name='Tracy', department=hr) +>>> db_session.add(tracy) +>>> db_session.commit() +``` + + +## Testing our GraphQL schema + +We're now ready to test the API we've built. Let's fire up the server from the command line. + +```bash +$ python ./app.py + + * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit) +``` + +Go to [localhost:5000/graphiql](http://localhost:5000/graphiql) and type your first query! + +```graphql +{ + allEmployees { + edges { + node { + id + name + department { + name + } + } + } + } +} +``` diff --git a/examples/flask_sqlalchemy/README.md b/examples/flask_sqlalchemy/README.md new file mode 100644 index 00000000..992a8acd --- /dev/null +++ b/examples/flask_sqlalchemy/README.md @@ -0,0 +1,50 @@ +Example Flask+SQLAlchemy Project +================================ + +This example project demos integration between Graphene, Flask and SQLAlchemy. +The project contains two models, one named `Department` and another +named `Employee`. + +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/flask_sqlalchemy +``` + +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 the following command will setup the database, and start the server: + +```bash +./app.py + +``` + + +Now head on over to +[http://127.0.0.1:5000/graphiql](http://127.0.0.1:5000/graphiql) +and run some queries! diff --git a/examples/flask_sqlalchemy/__init__.py b/examples/flask_sqlalchemy/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/flask_sqlalchemy/app.py b/examples/flask_sqlalchemy/app.py new file mode 100644 index 00000000..98d9f7a8 --- /dev/null +++ b/examples/flask_sqlalchemy/app.py @@ -0,0 +1,34 @@ +from flask import Flask +from database import db_session, init_db + +from schema import schema +from graphql_flask import GraphQL + +app = Flask(__name__) +app.debug = True + +default_query = ''' +{ + allEmployees { + edges { + node { + id + name + department { + name + } + } + } + } +}'''.strip() + +GraphQL(app, schema=schema, default_query=default_query) + + +@app.teardown_appcontext +def shutdown_session(exception=None): + db_session.remove() + +if __name__ == '__main__': + init_db() + app.run() diff --git a/examples/flask_sqlalchemy/database.py b/examples/flask_sqlalchemy/database.py new file mode 100644 index 00000000..db9a83b4 --- /dev/null +++ b/examples/flask_sqlalchemy/database.py @@ -0,0 +1,33 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import scoped_session, sessionmaker +from sqlalchemy.ext.declarative import declarative_base + +engine = create_engine('sqlite:///database.sqlite3', convert_unicode=True) +db_session = scoped_session(sessionmaker(autocommit=False, + autoflush=False, + bind=engine)) +Base = declarative_base() +Base.query = db_session.query_property() + + +def init_db(): + # import all modules here that might define models so that + # they will be registered properly on the metadata. Otherwise + # you will have to import them first before calling init_db() + from models import Department, Employee + Base.metadata.drop_all(bind=engine) + Base.metadata.create_all(bind=engine) + + # Create the fixtures + engineering = Department(name='Engineering') + db_session.add(engineering) + hr = Department(name='Human Resources') + db_session.add(hr) + + peter = Employee(name='Peter', department=engineering) + db_session.add(peter) + roy = Employee(name='Roy', department=engineering) + db_session.add(roy) + tracy = Employee(name='Tracy', department=hr) + db_session.add(tracy) + db_session.commit() diff --git a/examples/flask_sqlalchemy/models.py b/examples/flask_sqlalchemy/models.py new file mode 100644 index 00000000..7561bb29 --- /dev/null +++ b/examples/flask_sqlalchemy/models.py @@ -0,0 +1,26 @@ +from database import Base +from sqlalchemy import Column, DateTime, String, Integer, ForeignKey, func +from sqlalchemy.orm import relationship, backref + + +class Department(Base): + __tablename__ = 'department' + id = Column(Integer, primary_key=True) + name = Column(String) + + +class Employee(Base): + __tablename__ = 'employee' + id = Column(Integer, primary_key=True) + name = Column(String) + # Use default=func.now() to set the default hiring time + # of an Employee to be the current time when an + # Employee record was created + hired_on = Column(DateTime, default=func.now()) + department_id = Column(Integer, ForeignKey('department.id')) + # Use cascade='delete,all' to propagate the deletion of a Department onto its Employees + department = relationship( + Department, + backref=backref('employees', + uselist=True, + cascade='delete,all')) diff --git a/examples/flask_sqlalchemy/requirements.txt b/examples/flask_sqlalchemy/requirements.txt new file mode 100644 index 00000000..82e701c6 --- /dev/null +++ b/examples/flask_sqlalchemy/requirements.txt @@ -0,0 +1,4 @@ +graphene[sqlalchemy] +graphql_flask==1.1.0 +SQLAlchemy==1.0.11 +Flask==0.10.1 diff --git a/examples/flask_sqlalchemy/schema.py b/examples/flask_sqlalchemy/schema.py new file mode 100644 index 00000000..4010f5ca --- /dev/null +++ b/examples/flask_sqlalchemy/schema.py @@ -0,0 +1,25 @@ +import graphene +from graphene import relay +from graphene.contrib.sqlalchemy import SQLAlchemyNode, SQLAlchemyConnectionField +from models import Department as DepartmentModel, Employee as EmployeeModel + +schema = graphene.Schema() + + +@schema.register +class Department(SQLAlchemyNode): + class Meta: + model = DepartmentModel + + +@schema.register +class Employee(SQLAlchemyNode): + class Meta: + model = EmployeeModel + + +class Query(graphene.ObjectType): + node = relay.NodeField() + all_employees = SQLAlchemyConnectionField(Employee) + +schema.query = Query diff --git a/graphene/contrib/sqlalchemy/__init__.py b/graphene/contrib/sqlalchemy/__init__.py new file mode 100644 index 00000000..88509ba3 --- /dev/null +++ b/graphene/contrib/sqlalchemy/__init__.py @@ -0,0 +1,11 @@ +from graphene.contrib.sqlalchemy.types import ( + SQLAlchemyObjectType, + SQLAlchemyNode +) +from graphene.contrib.sqlalchemy.fields import ( + SQLAlchemyConnectionField, + SQLAlchemyModelField +) + +__all__ = ['SQLAlchemyObjectType', 'SQLAlchemyNode', + 'SQLAlchemyConnectionField', 'SQLAlchemyModelField'] diff --git a/graphene/contrib/sqlalchemy/converter.py b/graphene/contrib/sqlalchemy/converter.py new file mode 100644 index 00000000..25ed6b35 --- /dev/null +++ b/graphene/contrib/sqlalchemy/converter.py @@ -0,0 +1,61 @@ +from singledispatch import singledispatch + +from sqlalchemy import types +from sqlalchemy.orm import interfaces + +from ...core.types.scalars import ID, Boolean, Float, Int, String +from .fields import ConnectionOrListField, SQLAlchemyModelField + + +def convert_sqlalchemy_relationship(relationship): + direction = relationship.direction + model = relationship.mapper.entity + model_field = SQLAlchemyModelField(model, description=relationship.doc) + if direction == interfaces.MANYTOONE: + return model_field + elif (direction == interfaces.ONETOMANY or + direction == interfaces.MANYTOMANY): + return ConnectionOrListField(model_field) + + +def convert_sqlalchemy_column(column): + return convert_sqlalchemy_type(getattr(column, 'type', None), column) + + +@singledispatch +def convert_sqlalchemy_type(type, column): + raise Exception( + "Don't know how to convert the SQLAlchemy field %s (%s)" % (column, column.__class__)) + + +@convert_sqlalchemy_type.register(types.Date) +@convert_sqlalchemy_type.register(types.DateTime) +@convert_sqlalchemy_type.register(types.Time) +@convert_sqlalchemy_type.register(types.String) +@convert_sqlalchemy_type.register(types.Text) +@convert_sqlalchemy_type.register(types.Unicode) +@convert_sqlalchemy_type.register(types.UnicodeText) +@convert_sqlalchemy_type.register(types.Enum) +def convert_column_to_string(type, column): + return String(description=column.doc) + + +@convert_sqlalchemy_type.register(types.SmallInteger) +@convert_sqlalchemy_type.register(types.BigInteger) +@convert_sqlalchemy_type.register(types.Integer) +def convert_column_to_int_or_id(type, column): + if column.primary_key: + return ID(description=column.doc) + else: + return Int(description=column.doc) + + +@convert_sqlalchemy_type.register(types.Boolean) +def convert_column_to_boolean(type, column): + return Boolean(description=column.doc) + + +@convert_sqlalchemy_type.register(types.Float) +@convert_sqlalchemy_type.register(types.Numeric) +def convert_column_to_float(type, column): + return Float(description=column.doc) diff --git a/graphene/contrib/sqlalchemy/fields.py b/graphene/contrib/sqlalchemy/fields.py new file mode 100644 index 00000000..03209c38 --- /dev/null +++ b/graphene/contrib/sqlalchemy/fields.py @@ -0,0 +1,66 @@ +from ...core.exceptions import SkipField +from ...core.fields import Field +from ...core.types.base import FieldType +from ...core.types.definitions import List +from ...relay import ConnectionField +from ...relay.utils import is_node +from .utils import get_type_for_model, maybe_query, get_query + + +class SQLAlchemyConnectionField(ConnectionField): + + def __init__(self, *args, **kwargs): + return super(SQLAlchemyConnectionField, self).__init__(*args, **kwargs) + + @property + def model(self): + return self.type._meta.model + + def get_query(self, resolved_query, args, info): + return resolved_query or get_query(self.model, info) + + def from_list(self, connection_type, resolved, args, info): + query = self.get_query(resolved, args, info) + query = maybe_query(query) + return super(SQLAlchemyConnectionField, self).from_list(connection_type, query, args, info) + + +class ConnectionOrListField(Field): + + def internal_type(self, schema): + model_field = self.type + field_object_type = model_field.get_object_type(schema) + if not field_object_type: + raise SkipField() + if is_node(field_object_type): + field = SQLAlchemyConnectionField(field_object_type) + else: + field = Field(List(field_object_type)) + field.contribute_to_class(self.object_type, self.attname) + return schema.T(field) + + +class SQLAlchemyModelField(FieldType): + + def __init__(self, model, *args, **kwargs): + self.model = model + super(SQLAlchemyModelField, self).__init__(*args, **kwargs) + + def internal_type(self, schema): + _type = self.get_object_type(schema) + if not _type and self.parent._meta.only_fields: + raise Exception( + "Table %r is not accessible by the schema. " + "You can either register the type manually " + "using @schema.register. " + "Or disable the field in %s" % ( + self.model, + self.parent, + ) + ) + if not _type: + raise SkipField() + return schema.T(_type) + + def get_object_type(self, schema): + return get_type_for_model(schema, self.model) diff --git a/graphene/contrib/sqlalchemy/options.py b/graphene/contrib/sqlalchemy/options.py new file mode 100644 index 00000000..1d4b2a4f --- /dev/null +++ b/graphene/contrib/sqlalchemy/options.py @@ -0,0 +1,23 @@ +from ...core.classtypes.objecttype import ObjectTypeOptions +from ...relay.types import Node +from ...relay.utils import is_node + +VALID_ATTRS = ('model', 'only_fields', 'exclude_fields') + + +class SQLAlchemyOptions(ObjectTypeOptions): + + def __init__(self, *args, **kwargs): + super(SQLAlchemyOptions, self).__init__(*args, **kwargs) + self.model = None + self.valid_attrs += VALID_ATTRS + self.only_fields = None + self.exclude_fields = [] + self.filter_fields = None + self.filter_order_by = None + + def contribute_to_class(self, cls, name): + super(SQLAlchemyOptions, self).contribute_to_class(cls, name) + if is_node(cls): + self.exclude_fields = list(self.exclude_fields) + ['id'] + self.interfaces.append(Node) diff --git a/graphene/contrib/sqlalchemy/tests/__init__.py b/graphene/contrib/sqlalchemy/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/graphene/contrib/sqlalchemy/tests/models.py b/graphene/contrib/sqlalchemy/tests/models.py new file mode 100644 index 00000000..ee021054 --- /dev/null +++ b/graphene/contrib/sqlalchemy/tests/models.py @@ -0,0 +1,36 @@ +from __future__ import absolute_import + +from sqlalchemy import Column, Date, ForeignKey, Integer, String, Table +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import relationship + +Base = declarative_base() + +association_table = Table('association', Base.metadata, + Column('pet_id', Integer, ForeignKey('pets.id')), + Column('reporter_id', Integer, ForeignKey('reporters.id'))) + + +class Pet(Base): + __tablename__ = 'pets' + id = Column(Integer(), primary_key=True) + name = Column(String(30)) + reporter_id = Column(Integer(), ForeignKey('reporters.id')) + + +class Reporter(Base): + __tablename__ = 'reporters' + id = Column(Integer(), primary_key=True) + first_name = Column(String(30)) + last_name = Column(String(30)) + email = Column(String()) + pets = relationship('Pet', secondary=association_table, backref='reporters') + articles = relationship('Article', backref='reporter') + + +class Article(Base): + __tablename__ = 'articles' + id = Column(Integer(), primary_key=True) + headline = Column(String(100)) + pub_date = Column(Date()) + reporter_id = Column(Integer(), ForeignKey('reporters.id')) diff --git a/graphene/contrib/sqlalchemy/tests/test_converter.py b/graphene/contrib/sqlalchemy/tests/test_converter.py new file mode 100644 index 00000000..e4cdaa6f --- /dev/null +++ b/graphene/contrib/sqlalchemy/tests/test_converter.py @@ -0,0 +1,105 @@ +from py.test import raises + +import graphene +from graphene.contrib.sqlalchemy.converter import (convert_sqlalchemy_column, + convert_sqlalchemy_relationship) +from graphene.contrib.sqlalchemy.fields import (ConnectionOrListField, + SQLAlchemyModelField) +from sqlalchemy import Column, types + +from .models import Article, Pet, Reporter + + +def assert_column_conversion(sqlalchemy_type, graphene_field, **kwargs): + column = Column(sqlalchemy_type, doc='Custom Help Text', **kwargs) + graphene_type = convert_sqlalchemy_column(column) + assert isinstance(graphene_type, graphene_field) + field = graphene_type.as_field() + assert field.description == 'Custom Help Text' + return field + + +def test_should_unknown_sqlalchemy_field_raise_exception(): + with raises(Exception) as excinfo: + convert_sqlalchemy_column(None) + assert 'Don\'t know how to convert the SQLAlchemy field' in str(excinfo.value) + + +def test_should_date_convert_string(): + assert_column_conversion(types.Date(), graphene.String) + + +def test_should_datetime_convert_string(): + assert_column_conversion(types.DateTime(), graphene.String) + + +def test_should_time_convert_string(): + assert_column_conversion(types.Time(), graphene.String) + + +def test_should_string_convert_string(): + assert_column_conversion(types.String(), graphene.String) + + +def test_should_text_convert_string(): + assert_column_conversion(types.Text(), graphene.String) + + +def test_should_unicode_convert_string(): + assert_column_conversion(types.Unicode(), graphene.String) + + +def test_should_unicodetext_convert_string(): + assert_column_conversion(types.UnicodeText(), graphene.String) + + +def test_should_enum_convert_string(): + assert_column_conversion(types.Enum(), graphene.String) + + +def test_should_small_integer_convert_int(): + assert_column_conversion(types.SmallInteger(), graphene.Int) + + +def test_should_big_integer_convert_int(): + assert_column_conversion(types.BigInteger(), graphene.Int) + + +def test_should_integer_convert_int(): + assert_column_conversion(types.Integer(), graphene.Int) + + +def test_should_integer_convert_id(): + assert_column_conversion(types.Integer(), graphene.ID, primary_key=True) + + +def test_should_boolean_convert_boolean(): + assert_column_conversion(types.Boolean(), graphene.Boolean) + + +def test_should_float_convert_float(): + assert_column_conversion(types.Float(), graphene.Float) + + +def test_should_numeric_convert_float(): + assert_column_conversion(types.Numeric(), graphene.Float) + + +def test_should_manytomany_convert_connectionorlist(): + graphene_type = convert_sqlalchemy_relationship(Reporter.pets.property) + assert isinstance(graphene_type, ConnectionOrListField) + assert isinstance(graphene_type.type, SQLAlchemyModelField) + assert graphene_type.type.model == Pet + + +def test_should_manytoone_convert_connectionorlist(): + field = convert_sqlalchemy_relationship(Article.reporter.property) + assert isinstance(field, SQLAlchemyModelField) + assert field.model == Reporter + + +def test_should_onetomany_convert_model(): + graphene_type = convert_sqlalchemy_relationship(Reporter.articles.property) + assert isinstance(graphene_type, ConnectionOrListField) + assert isinstance(graphene_type.type, SQLAlchemyModelField) + assert graphene_type.type.model == Article diff --git a/graphene/contrib/sqlalchemy/tests/test_query.py b/graphene/contrib/sqlalchemy/tests/test_query.py new file mode 100644 index 00000000..b47398d8 --- /dev/null +++ b/graphene/contrib/sqlalchemy/tests/test_query.py @@ -0,0 +1,173 @@ +import pytest + +import graphene +from graphene import relay +from graphene.contrib.sqlalchemy import SQLAlchemyObjectType, SQLAlchemyNode +from sqlalchemy import create_engine +from sqlalchemy.orm import scoped_session, sessionmaker + +from .models import Base, Reporter, Article + +db = create_engine('sqlite:///test_sqlalchemy.sqlite3') + + +@pytest.yield_fixture(scope='function') +def session(): + connection = db.engine.connect() + transaction = connection.begin() + Base.metadata.create_all(connection) + + # options = dict(bind=connection, binds={}) + session_factory = sessionmaker(bind=connection) + session = scoped_session(session_factory) + + yield session + + # Finalize test here + transaction.rollback() + connection.close() + session.remove() + + +def setup_fixtures(session): + reporter = Reporter(first_name='ABA', last_name='X') + session.add(reporter) + reporter2 = Reporter(first_name='ABO', last_name='Y') + session.add(reporter2) + article = Article(headline='Hi!') + session.add(article) + session.commit() + + +def test_should_query_well(session): + setup_fixtures(session) + + class ReporterType(SQLAlchemyObjectType): + + class Meta: + model = Reporter + + class Query(graphene.ObjectType): + reporter = graphene.Field(ReporterType) + reporters = ReporterType.List() + + def resolve_reporter(self, *args, **kwargs): + return session.query(Reporter).first() + + def resolve_reporters(self, *args, **kwargs): + return session.query(Reporter) + + query = ''' + query ReporterQuery { + reporter { + firstName, + lastName, + email + } + reporters { + firstName + } + } + ''' + expected = { + 'reporter': { + 'firstName': 'ABA', + 'lastName': 'X', + 'email': None + }, + 'reporters': [{ + 'firstName': 'ABA', + }, { + 'firstName': 'ABO', + }] + } + schema = graphene.Schema(query=Query) + result = schema.execute(query) + assert not result.errors + assert result.data == expected + + +def test_should_node(session): + setup_fixtures(session) + + class ReporterNode(SQLAlchemyNode): + + class Meta: + model = Reporter + + @classmethod + def get_node(cls, id, info): + return Reporter(id=2, first_name='Cookie Monster') + + def resolve_articles(self, *args, **kwargs): + return [Article(headline='Hi!')] + + class ArticleNode(SQLAlchemyNode): + + class Meta: + model = Article + + # @classmethod + # def get_node(cls, id, info): + # return Article(id=1, headline='Article node') + + class Query(graphene.ObjectType): + node = relay.NodeField() + reporter = graphene.Field(ReporterNode) + article = graphene.Field(ArticleNode) + + def resolve_reporter(self, *args, **kwargs): + return Reporter(id=1, first_name='ABA', last_name='X') + + def resolve_article(self, *args, **kwargs): + return Article(id=1, headline='Article node') + + query = ''' + query ReporterQuery { + reporter { + id, + firstName, + articles { + edges { + node { + headline + } + } + } + lastName, + email + } + myArticle: node(id:"QXJ0aWNsZU5vZGU6MQ==") { + id + ... on ReporterNode { + firstName + } + ... on ArticleNode { + headline + } + } + } + ''' + expected = { + 'reporter': { + 'id': 'UmVwb3J0ZXJOb2RlOjE=', + 'firstName': 'ABA', + 'lastName': 'X', + 'email': None, + 'articles': { + 'edges': [{ + 'node': { + 'headline': 'Hi!' + } + }] + }, + }, + 'myArticle': { + 'id': 'QXJ0aWNsZU5vZGU6MQ==', + 'headline': 'Hi!' + } + } + schema = graphene.Schema(query=Query, session=session) + result = schema.execute(query) + assert not result.errors + assert result.data == expected diff --git a/graphene/contrib/sqlalchemy/tests/test_schema.py b/graphene/contrib/sqlalchemy/tests/test_schema.py new file mode 100644 index 00000000..090b2e18 --- /dev/null +++ b/graphene/contrib/sqlalchemy/tests/test_schema.py @@ -0,0 +1,45 @@ +from py.test import raises + +from graphene.contrib.sqlalchemy import SQLAlchemyObjectType +from tests.utils import assert_equal_lists + +from .models import Reporter + + +def test_should_raise_if_no_model(): + with raises(Exception) as excinfo: + class Character1(SQLAlchemyObjectType): + pass + assert 'model in the Meta' in str(excinfo.value) + + +def test_should_raise_if_model_is_invalid(): + with raises(Exception) as excinfo: + class Character2(SQLAlchemyObjectType): + + class Meta: + model = 1 + assert 'not a SQLAlchemy model' in str(excinfo.value) + + +def test_should_map_fields_correctly(): + class ReporterType2(SQLAlchemyObjectType): + + class Meta: + model = Reporter + assert_equal_lists( + ReporterType2._meta.fields_map.keys(), + ['articles', 'first_name', 'last_name', 'email', 'pets', 'id'] + ) + + +def test_should_map_only_few_fields(): + class Reporter2(SQLAlchemyObjectType): + + class Meta: + model = Reporter + only_fields = ('id', 'email') + assert_equal_lists( + Reporter2._meta.fields_map.keys(), + ['id', 'email'] + ) diff --git a/graphene/contrib/sqlalchemy/tests/test_types.py b/graphene/contrib/sqlalchemy/tests/test_types.py new file mode 100644 index 00000000..feffbc74 --- /dev/null +++ b/graphene/contrib/sqlalchemy/tests/test_types.py @@ -0,0 +1,102 @@ +from graphql.core.type import GraphQLObjectType +from pytest import raises + +from graphene import Schema +from graphene.contrib.sqlalchemy.types import (SQLAlchemyNode, + SQLAlchemyObjectType) +from graphene.core.fields import Field +from graphene.core.types.scalars import Int +from graphene.relay.fields import GlobalIDField +from tests.utils import assert_equal_lists + +from .models import Article, Reporter + +schema = Schema() + + +class Character(SQLAlchemyObjectType): + '''Character description''' + class Meta: + model = Reporter + + +@schema.register +class Human(SQLAlchemyNode): + '''Human description''' + + pub_date = Int() + + class Meta: + model = Article + exclude_fields = ('id', ) + + +def test_sqlalchemy_interface(): + assert SQLAlchemyNode._meta.interface is True + + +# @patch('graphene.contrib.sqlalchemy.tests.models.Article.filter', return_value=Article(id=1)) +# def test_sqlalchemy_get_node(get): +# human = Human.get_node(1, None) +# get.assert_called_with(id=1) +# assert human.id == 1 + + +def test_objecttype_registered(): + object_type = schema.T(Character) + assert isinstance(object_type, GraphQLObjectType) + assert Character._meta.model == Reporter + assert_equal_lists( + object_type.get_fields().keys(), + ['articles', 'firstName', 'lastName', 'email', 'id'] + ) + + +def test_sqlalchemynode_idfield(): + idfield = SQLAlchemyNode._meta.fields_map['id'] + assert isinstance(idfield, GlobalIDField) + + +def test_node_idfield(): + idfield = Human._meta.fields_map['id'] + assert isinstance(idfield, GlobalIDField) + + +def test_node_replacedfield(): + idfield = Human._meta.fields_map['pub_date'] + assert isinstance(idfield, Field) + assert schema.T(idfield).type == schema.T(Int()) + + +def test_interface_objecttype_init_none(): + h = Human() + assert h._root is None + + +def test_interface_objecttype_init_good(): + instance = Article() + h = Human(instance) + assert h._root == instance + + +def test_interface_objecttype_init_unexpected(): + with raises(AssertionError) as excinfo: + Human(object()) + assert str(excinfo.value) == "Human received a non-compatible instance (object) when expecting Article" + + +def test_object_type(): + object_type = schema.T(Human) + Human._meta.fields_map + assert Human._meta.interface is False + assert isinstance(object_type, GraphQLObjectType) + assert_equal_lists( + object_type.get_fields().keys(), + ['headline', 'id', 'reporter', 'reporterId', 'pubDate'] + ) + assert schema.T(SQLAlchemyNode) in object_type.get_interfaces() + + +def test_node_notinterface(): + assert Human._meta.interface is False + assert SQLAlchemyNode in Human._meta.interfaces diff --git a/graphene/contrib/sqlalchemy/tests/test_utils.py b/graphene/contrib/sqlalchemy/tests/test_utils.py new file mode 100644 index 00000000..2874ffaa --- /dev/null +++ b/graphene/contrib/sqlalchemy/tests/test_utils.py @@ -0,0 +1,25 @@ +from graphene import Schema, ObjectType, String + +from ..utils import get_session + + +def test_get_session(): + session = 'My SQLAlchemy session' + schema = Schema(session=session) + + class Query(ObjectType): + x = String() + + def resolve_x(self, args, info): + return get_session(info) + + query = ''' + query ReporterQuery { + x + } + ''' + + schema = Schema(query=Query, session=session) + result = schema.execute(query) + assert not result.errors + assert result.data['x'] == session diff --git a/graphene/contrib/sqlalchemy/types.py b/graphene/contrib/sqlalchemy/types.py new file mode 100644 index 00000000..07308f39 --- /dev/null +++ b/graphene/contrib/sqlalchemy/types.py @@ -0,0 +1,125 @@ +import inspect + +import six + +from sqlalchemy.inspection import inspect as sqlalchemyinspect +from sqlalchemy.orm.exc import NoResultFound + +from ...core.classtypes.objecttype import ObjectType, ObjectTypeMeta +from ...relay.types import Connection, Node, NodeMeta +from .converter import (convert_sqlalchemy_column, + convert_sqlalchemy_relationship) +from .options import SQLAlchemyOptions +from .utils import is_mapped, get_query + + +class SQLAlchemyObjectTypeMeta(ObjectTypeMeta): + options_class = SQLAlchemyOptions + + def construct_fields(cls): + only_fields = cls._meta.only_fields + exclude_fields = cls._meta.exclude_fields + already_created_fields = {f.attname for f in cls._meta.local_fields} + inspected_model = sqlalchemyinspect(cls._meta.model) + + # Get all the columns for the relationships on the model + for relationship in inspected_model.relationships: + is_not_in_only = only_fields and relationship.key not in only_fields + is_already_created = relationship.key in already_created_fields + is_excluded = relationship.key 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 excldue this field in exclude_fields + continue + converted_relationship = convert_sqlalchemy_relationship(relationship) + cls.add_to_class(relationship.key, converted_relationship) + + for column in inspected_model.columns: + is_not_in_only = only_fields and column.name not in only_fields + is_already_created = column.name in already_created_fields + is_excluded = column.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 excldue this field in exclude_fields + continue + converted_column = convert_sqlalchemy_column(column) + cls.add_to_class(column.name, converted_column) + + def construct(cls, *args, **kwargs): + cls = super(SQLAlchemyObjectTypeMeta, cls).construct(*args, **kwargs) + if not cls._meta.abstract: + if not cls._meta.model: + raise Exception( + 'SQLAlchemy ObjectType %s must have a model in the Meta class attr' % + cls) + elif not inspect.isclass(cls._meta.model) or not is_mapped(cls._meta.model): + raise Exception('Provided model in %s is not a SQLAlchemy model' % cls) + + cls.construct_fields() + return cls + + +class InstanceObjectType(ObjectType): + + class Meta: + abstract = True + + def __init__(self, _root=None): + if _root: + assert isinstance(_root, self._meta.model), ( + '{} received a non-compatible instance ({}) ' + 'when expecting {}'.format( + self.__class__.__name__, + _root.__class__.__name__, + self._meta.model.__name__ + )) + super(InstanceObjectType, self).__init__(_root=_root) + + @property + def instance(self): + return self._root + + @instance.setter + def instance(self, value): + self._root = value + + def __getattr__(self, attr): + return getattr(self._root, attr) + + +class SQLAlchemyObjectType(six.with_metaclass( + SQLAlchemyObjectTypeMeta, InstanceObjectType)): + + class Meta: + abstract = True + + +class SQLAlchemyConnection(Connection): + pass + + +class SQLAlchemyNodeMeta(SQLAlchemyObjectTypeMeta, NodeMeta): + pass + + +class NodeInstance(Node, InstanceObjectType): + + class Meta: + abstract = True + + +class SQLAlchemyNode(six.with_metaclass( + SQLAlchemyNodeMeta, NodeInstance)): + + class Meta: + abstract = True + + @classmethod + def get_node(cls, id, info=None): + try: + model = cls._meta.model + query = get_query(model, info) + instance = query.filter(model.id == id).one() + return cls(instance) + except NoResultFound: + return None diff --git a/graphene/contrib/sqlalchemy/utils.py b/graphene/contrib/sqlalchemy/utils.py new file mode 100644 index 00000000..246a9d86 --- /dev/null +++ b/graphene/contrib/sqlalchemy/utils.py @@ -0,0 +1,49 @@ +from sqlalchemy.ext.declarative.api import DeclarativeMeta +from sqlalchemy.orm.query import Query + +from graphene.utils import LazyList + + +def get_type_for_model(schema, model): + schema = schema + types = schema.types.values() + for _type in types: + type_model = hasattr(_type, '_meta') and getattr( + _type._meta, 'model', None) + if model == type_model: + return _type + + +def get_session(info): + schema = info.schema.graphene_schema + return schema.options.get('session') + + +def get_query(model, info): + query = getattr(model, 'query', None) + if not query: + session = get_session(info) + if not session: + raise Exception('A query in the model Base or a session in the schema is required for querying.\n' + 'Read more http://graphene-python.org/docs/sqlalchemy/tips/#querying') + query = session.query(model) + return query + + +class WrappedQuery(LazyList): + + def __len__(self): + # Dont calculate the length using len(query), as this will + # evaluate the whole queryset and return it's length. + # Use .count() instead + return self._origin.count() + + +def maybe_query(value): + if isinstance(value, Query): + return WrappedQuery(value) + return value + + +def is_mapped(obj): + return isinstance(obj, DeclarativeMeta) diff --git a/graphene/core/schema.py b/graphene/core/schema.py index c8695317..78653c14 100644 --- a/graphene/core/schema.py +++ b/graphene/core/schema.py @@ -26,7 +26,7 @@ class Schema(object): _executor = None def __init__(self, query=None, mutation=None, subscription=None, - name='Schema', executor=None, plugins=None, auto_camelcase=True): + name='Schema', executor=None, plugins=None, auto_camelcase=True, **options): self._types_names = {} self._types = {} self.mutation = mutation @@ -38,6 +38,7 @@ class Schema(object): if auto_camelcase: plugins.append(CamelCase()) self.plugins = PluginManager(self, plugins) + self.options = options signals.init_schema.send(self) def __repr__(self): diff --git a/setup.py b/setup.py index 8163c27c..4a2a8ca1 100644 --- a/setup.py +++ b/setup.py @@ -62,6 +62,7 @@ setup( 'django-filter>=0.10.0', 'pytest>=2.7.2', 'pytest-django', + 'sqlalchemy', 'mock', ], extras_require={ @@ -70,6 +71,10 @@ setup( 'singledispatch>=3.4.0.3', 'graphql-django-view>=1.1.0', ], + 'sqlalchemy': [ + 'sqlalchemy', + 'singledispatch>=3.4.0.3', + ] }, cmdclass={'test': PyTest},