diff --git a/graphene-sqlalchemy/graphene_sqlalchemy/__init__.py b/graphene-sqlalchemy/graphene_sqlalchemy/__init__.py new file mode 100644 index 00000000..10bf8f5e --- /dev/null +++ b/graphene-sqlalchemy/graphene_sqlalchemy/__init__.py @@ -0,0 +1,10 @@ +from graphene.contrib.sqlalchemy.types import ( + SQLAlchemyObjectType, + SQLAlchemyNode +) +from graphene.contrib.sqlalchemy.fields import ( + SQLAlchemyConnectionField +) + +__all__ = ['SQLAlchemyObjectType', 'SQLAlchemyNode', + 'SQLAlchemyConnectionField'] diff --git a/graphene-sqlalchemy/graphene_sqlalchemy/converter.py b/graphene-sqlalchemy/graphene_sqlalchemy/converter.py new file mode 100644 index 00000000..1bf7e37f --- /dev/null +++ b/graphene-sqlalchemy/graphene_sqlalchemy/converter.py @@ -0,0 +1,89 @@ +from singledispatch import singledispatch +from sqlalchemy import types +from sqlalchemy.orm import interfaces +from sqlalchemy.dialects import postgresql + +from graphene import Enum, ID, Boolean, Float, Int, String, List +from graphene.types.json import JSONString +from .fields import ConnectionOrListField, SQLAlchemyModelField + +try: + from sqlalchemy_utils.types.choice import ChoiceType +except ImportError: + class ChoiceType(object): + pass + + +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) +@convert_sqlalchemy_type.register(postgresql.ENUM) +@convert_sqlalchemy_type.register(postgresql.UUID) +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) + + +@convert_sqlalchemy_type.register(ChoiceType) +def convert_column_to_enum(type, column): + name = '{}_{}'.format(column.table.name, column.name).upper() + return Enum(name, type.choices, description=column.doc) + + +@convert_sqlalchemy_type.register(postgresql.ARRAY) +def convert_postgres_array_to_list(type, column): + graphene_type = convert_sqlalchemy_type(column.type.item_type, column) + return List(graphene_type, description=column.doc) + + +@convert_sqlalchemy_type.register(postgresql.HSTORE) +@convert_sqlalchemy_type.register(postgresql.JSON) +@convert_sqlalchemy_type.register(postgresql.JSONB) +def convert_json_to_string(type, column): + return JSONString(description=column.doc) diff --git a/graphene-sqlalchemy/graphene_sqlalchemy/fields.py b/graphene-sqlalchemy/graphene_sqlalchemy/fields.py new file mode 100644 index 00000000..598cd341 --- /dev/null +++ b/graphene-sqlalchemy/graphene_sqlalchemy/fields.py @@ -0,0 +1,69 @@ +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_query, get_type_for_model, maybe_query + + +class DefaultQuery(object): + pass + + +class SQLAlchemyConnectionField(ConnectionField): + + def __init__(self, *args, **kwargs): + kwargs['default'] = kwargs.pop('default', lambda: DefaultQuery) + return super(SQLAlchemyConnectionField, self).__init__(*args, **kwargs) + + @property + def model(self): + return self.type._meta.model + + def from_list(self, connection_type, resolved, args, context, info): + if resolved is DefaultQuery: + resolved = get_query(self.model, info) + query = maybe_query(resolved) + return super(SQLAlchemyConnectionField, self).from_list(connection_type, query, args, context, 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-sqlalchemy/graphene_sqlalchemy/options.py b/graphene-sqlalchemy/graphene_sqlalchemy/options.py new file mode 100644 index 00000000..44886287 --- /dev/null +++ b/graphene-sqlalchemy/graphene_sqlalchemy/options.py @@ -0,0 +1,24 @@ +from ...core.classtypes.objecttype import ObjectTypeOptions +from ...relay.types import Node +from ...relay.utils import is_node + +VALID_ATTRS = ('model', 'only_fields', 'exclude_fields', 'identifier') + + +class SQLAlchemyOptions(ObjectTypeOptions): + + def __init__(self, *args, **kwargs): + super(SQLAlchemyOptions, self).__init__(*args, **kwargs) + self.model = None + self.identifier = "id" + 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-sqlalchemy/graphene_sqlalchemy/tests/__init__.py b/graphene-sqlalchemy/graphene_sqlalchemy/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/graphene-sqlalchemy/graphene_sqlalchemy/tests/models.py b/graphene-sqlalchemy/graphene_sqlalchemy/tests/models.py new file mode 100644 index 00000000..40f95e59 --- /dev/null +++ b/graphene-sqlalchemy/graphene_sqlalchemy/tests/models.py @@ -0,0 +1,42 @@ +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 Editor(Base): + __tablename__ = 'editors' + editor_id = Column(Integer(), primary_key=True) + name = Column(String(100)) + + +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-sqlalchemy/graphene_sqlalchemy/tests/test_converter.py b/graphene-sqlalchemy/graphene_sqlalchemy/tests/test_converter.py new file mode 100644 index 00000000..521911ee --- /dev/null +++ b/graphene-sqlalchemy/graphene_sqlalchemy/tests/test_converter.py @@ -0,0 +1,150 @@ +from py.test import raises +from sqlalchemy import Column, Table, types +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy_utils.types.choice import ChoiceType +from sqlalchemy.dialects import postgresql + +import graphene +from graphene.core.types.custom_scalars import JSONString +from graphene.contrib.sqlalchemy.converter import (convert_sqlalchemy_column, + convert_sqlalchemy_relationship) +from graphene.contrib.sqlalchemy.fields import (ConnectionOrListField, + SQLAlchemyModelField) + +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_choice_convert_enum(): + TYPES = [ + (u'es', u'Spanish'), + (u'en', u'English') + ] + column = Column(ChoiceType(TYPES), doc='Language', name='language') + Base = declarative_base() + + Table('translatedmodel', Base.metadata, column) + graphene_type = convert_sqlalchemy_column(column) + assert issubclass(graphene_type, graphene.Enum) + assert graphene_type._meta.type_name == 'TRANSLATEDMODEL_LANGUAGE' + assert graphene_type._meta.description == 'Language' + assert graphene_type.__enum__.__members__['es'].value == 'Spanish' + assert graphene_type.__enum__.__members__['en'].value == 'English' + + +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 + + +def test_should_postgresql_uuid_convert(): + assert_column_conversion(postgresql.UUID(), graphene.String) + + +def test_should_postgresql_enum_convert(): + assert_column_conversion(postgresql.ENUM(), graphene.String) + + +def test_should_postgresql_array_convert(): + assert_column_conversion(postgresql.ARRAY(types.Integer), graphene.List) + + +def test_should_postgresql_json_convert(): + assert_column_conversion(postgresql.JSON(), JSONString) + + +def test_should_postgresql_jsonb_convert(): + assert_column_conversion(postgresql.JSONB(), JSONString) + + +def test_should_postgresql_hstore_convert(): + assert_column_conversion(postgresql.HSTORE(), JSONString) diff --git a/graphene-sqlalchemy/graphene_sqlalchemy/tests/test_query.py b/graphene-sqlalchemy/graphene_sqlalchemy/tests/test_query.py new file mode 100644 index 00000000..da8f8e11 --- /dev/null +++ b/graphene-sqlalchemy/graphene_sqlalchemy/tests/test_query.py @@ -0,0 +1,239 @@ +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import scoped_session, sessionmaker + +import graphene +from graphene import relay +from graphene.contrib.sqlalchemy import (SQLAlchemyConnectionField, + SQLAlchemyNode, SQLAlchemyObjectType) + +from .models import Article, Base, Editor, Reporter + +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) + editor = Editor(name="John") + session.add(editor) + 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) + all_articles = SQLAlchemyConnectionField(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 + } + allArticles { + edges { + node { + headline + } + } + } + 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!' + } + }] + }, + }, + 'allArticles': { + '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 + + +def test_should_custom_identifier(session): + setup_fixtures(session) + + class EditorNode(SQLAlchemyNode): + + class Meta: + model = Editor + identifier = "editor_id" + + class Query(graphene.ObjectType): + node = relay.NodeField(EditorNode) + all_editors = SQLAlchemyConnectionField(EditorNode) + + query = ''' + query EditorQuery { + allEditors { + edges { + node { + id, + name + } + } + }, + node(id: "RWRpdG9yTm9kZTox") { + name + } + } + ''' + expected = { + 'allEditors': { + 'edges': [{ + 'node': { + 'id': 'RWRpdG9yTm9kZTox', + 'name': 'John' + } + }] + }, + 'node': { + 'name': 'John' + } + } + + schema = graphene.Schema(query=Query, session=session) + result = schema.execute(query) + assert not result.errors + assert result.data == expected diff --git a/graphene-sqlalchemy/graphene_sqlalchemy/tests/test_schema.py b/graphene-sqlalchemy/graphene_sqlalchemy/tests/test_schema.py new file mode 100644 index 00000000..090b2e18 --- /dev/null +++ b/graphene-sqlalchemy/graphene_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-sqlalchemy/graphene_sqlalchemy/tests/test_types.py b/graphene-sqlalchemy/graphene_sqlalchemy/tests/test_types.py new file mode 100644 index 00000000..378411ae --- /dev/null +++ b/graphene-sqlalchemy/graphene_sqlalchemy/tests/test_types.py @@ -0,0 +1,102 @@ +from graphql.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-sqlalchemy/graphene_sqlalchemy/tests/test_utils.py b/graphene-sqlalchemy/graphene_sqlalchemy/tests/test_utils.py new file mode 100644 index 00000000..2925f016 --- /dev/null +++ b/graphene-sqlalchemy/graphene_sqlalchemy/tests/test_utils.py @@ -0,0 +1,25 @@ +from graphene import ObjectType, Schema, 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-sqlalchemy/graphene_sqlalchemy/types.py b/graphene-sqlalchemy/graphene_sqlalchemy/types.py new file mode 100644 index 00000000..20202ab7 --- /dev/null +++ b/graphene-sqlalchemy/graphene_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 get_query, is_mapped + + +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 name, column in inspected_model.columns.items(): + is_not_in_only = only_fields and name not in only_fields + is_already_created = name in already_created_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 excldue this field in exclude_fields + continue + converted_column = convert_sqlalchemy_column(column) + cls.add_to_class(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): + super(InstanceObjectType, self).__init__(_root=_root) + assert not self._root or isinstance(self._root, self._meta.model), ( + '{} received a non-compatible instance ({}) ' + 'when expecting {}'.format( + self.__class__.__name__, + self._root.__class__.__name__, + self._meta.model.__name__ + )) + + @property + def instance(self): + return self._root + + @instance.setter + def instance(self, value): + self._root = value + + +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 + + def to_global_id(self): + id_ = getattr(self.instance, self._meta.identifier) + return self.global_id(id_) + + @classmethod + def get_node(cls, id, info=None): + try: + model = cls._meta.model + identifier = cls._meta.identifier + query = get_query(model, info) + instance = query.filter(getattr(model, identifier) == id).one() + return cls(instance) + except NoResultFound: + return None diff --git a/graphene-sqlalchemy/graphene_sqlalchemy/utils.py b/graphene-sqlalchemy/graphene_sqlalchemy/utils.py new file mode 100644 index 00000000..246a9d86 --- /dev/null +++ b/graphene-sqlalchemy/graphene_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-sqlalchemy/setup.py b/graphene-sqlalchemy/setup.py new file mode 100644 index 00000000..cefcdc57 --- /dev/null +++ b/graphene-sqlalchemy/setup.py @@ -0,0 +1,43 @@ +from setuptools import find_packages, setup + +setup( + name='graphene-sqlalchemy', + version='1.0', + + description='Graphene SQLAlchemy integration', + # long_description=open('README.rst').read(), + + url='https://github.com/graphql-python/graphene-sqlalchemy', + + 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', + 'singledispatch>=3.4.0.3', + ], + tests_require=[ + 'pytest>=2.7.2', + 'mock', + ], +)