From d812c9b9ad455fc35f853d1b4824995046ddd2a1 Mon Sep 17 00:00:00 2001 From: CaselIT Date: Sat, 16 Dec 2017 17:27:28 +0100 Subject: [PATCH 01/10] Added sort support to SQLAlchemyConnectionField --- examples/flask_sqlalchemy/schema.py | 14 +++++--- graphene_sqlalchemy/fields.py | 8 +++-- graphene_sqlalchemy/utils.py | 50 +++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/examples/flask_sqlalchemy/schema.py b/examples/flask_sqlalchemy/schema.py index 5d9d3b72..abf6adec 100644 --- a/examples/flask_sqlalchemy/schema.py +++ b/examples/flask_sqlalchemy/schema.py @@ -1,6 +1,6 @@ import graphene from graphene import relay -from graphene_sqlalchemy import SQLAlchemyConnectionField, SQLAlchemyObjectType +from graphene_sqlalchemy import SQLAlchemyConnectionField, SQLAlchemyObjectType, utils from models import Department as DepartmentModel from models import Employee as EmployeeModel from models import Role as RoleModel @@ -27,11 +27,17 @@ class Meta: interfaces = (relay.Node, ) +SortEnumEmployee = utils.sort_enum_for_model( + EmployeeModel, 'SortEnumEmployee', + lambda c, d: c.upper() + ('_ASC' if d else '_DESC')) + + class Query(graphene.ObjectType): node = relay.Node.Field() - all_employees = SQLAlchemyConnectionField(Employee) - all_roles = SQLAlchemyConnectionField(Role) - role = graphene.Field(Role) + all_employees = SQLAlchemyConnectionField( + Employee, sort=graphene.Argument(SortEnumEmployee, default_value=EmployeeModel.id)) + all_roles = SQLAlchemyConnectionField(Role, sort=utils.sort_argument_for_model(RoleModel)) + all_departments = SQLAlchemyConnectionField(Department) schema = graphene.Schema(query=Query, types=[Department, Employee, Role]) diff --git a/graphene_sqlalchemy/fields.py b/graphene_sqlalchemy/fields.py index bb084b3a..8f399e84 100644 --- a/graphene_sqlalchemy/fields.py +++ b/graphene_sqlalchemy/fields.py @@ -1,3 +1,4 @@ +from collections import Iterable from functools import partial from sqlalchemy.orm.query import Query @@ -16,8 +17,11 @@ def model(self): return self.type._meta.node._meta.model @classmethod - def get_query(cls, model, info, **args): - return get_query(model, info.context) + def get_query(cls, model, info, sort=None, **args): + query = get_query(model, info.context) + if sort is not None: + query = query.order_by(*sort) if isinstance(sort, Iterable) else query.order_by(sort) + return query @property def type(self): diff --git a/graphene_sqlalchemy/utils.py b/graphene_sqlalchemy/utils.py index e78c9802..7353d2b5 100644 --- a/graphene_sqlalchemy/utils.py +++ b/graphene_sqlalchemy/utils.py @@ -1,4 +1,6 @@ +from graphene import Argument, Enum, List from sqlalchemy.exc import ArgumentError +from sqlalchemy.inspection import inspect from sqlalchemy.orm import class_mapper, object_mapper from sqlalchemy.orm.exc import UnmappedClassError, UnmappedInstanceError @@ -34,3 +36,51 @@ def is_mapped_instance(cls): return False else: return True + + +def _symbol_name(column_name, is_asc): + return column_name + ('_asc' if is_asc else '_desc') + + +def _sort_enum_for_model(cls, name=None, symbol_name=_symbol_name): + name = name or cls.__name__ + 'SortEnum' + items = [] + default = [] + for column in inspect(cls).columns.values(): + asc = symbol_name(column.name, True), column.asc() + desc = symbol_name(column.name, False), column.desc() + if column.primary_key: + default.append(asc[1]) + items.extend((asc, desc)) + return Enum(name, items), default + + +def sort_enum_for_model(cls, name=None, symbol_name=_symbol_name): + '''Create Graphene Enum for sorting a SQLAlchemy class query + + Parameters + - cls : Sqlalchemy model class + Model used to create the sort enumerator + - name : str, optional, default None + Name to use for the enumerator. If not provided it will be set to `cls.__name__ + 'SortEnum'` + - symbol_name : function, optional, default `_symbol_name` + Function which takes the column name and a boolean indicating if the sort direction is ascending, + and returns the symbol name for the current column and sort direction. + The default function will create, for a column named 'foo', the symbols 'foo_asc' and 'foo_desc' + + Returns + - Enum + The Graphene enumerator + ''' + enum, _ = _sort_enum_for_model(cls, name, symbol_name) + return enum + + +def sort_argument_for_model(cls, has_default=True): + '''Returns an Graphene argument for the sort field that accepts a list of sorting directions for a model. + If `has_default` is True (the default) it will sort the result by the primary key(s) + ''' + enum, default = _sort_enum_for_model(cls) + if not has_default: + default = None + return Argument(List(enum), default_value=default) From b192e781385d06c967c18d7ac6276d638e403f21 Mon Sep 17 00:00:00 2001 From: CaselIT Date: Sat, 16 Dec 2017 17:54:46 +0100 Subject: [PATCH 02/10] Fixed assertion error while creating the ast graph raised in graphql.utils.ast_from_value --- examples/flask_sqlalchemy/schema.py | 2 +- graphene_sqlalchemy/fields.py | 9 +++++++-- graphene_sqlalchemy/utils.py | 4 ++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/examples/flask_sqlalchemy/schema.py b/examples/flask_sqlalchemy/schema.py index abf6adec..3cbf8e8b 100644 --- a/examples/flask_sqlalchemy/schema.py +++ b/examples/flask_sqlalchemy/schema.py @@ -35,7 +35,7 @@ class Meta: class Query(graphene.ObjectType): node = relay.Node.Field() all_employees = SQLAlchemyConnectionField( - Employee, sort=graphene.Argument(SortEnumEmployee, default_value=EmployeeModel.id)) + Employee, sort=graphene.Argument(SortEnumEmployee, default_value=['id', 'asc'])) all_roles = SQLAlchemyConnectionField(Role, sort=utils.sort_argument_for_model(RoleModel)) all_departments = SQLAlchemyConnectionField(Department) diff --git a/graphene_sqlalchemy/fields.py b/graphene_sqlalchemy/fields.py index 8f399e84..fd9d23a4 100644 --- a/graphene_sqlalchemy/fields.py +++ b/graphene_sqlalchemy/fields.py @@ -1,4 +1,3 @@ -from collections import Iterable from functools import partial from sqlalchemy.orm.query import Query @@ -20,7 +19,13 @@ def model(self): def get_query(cls, model, info, sort=None, **args): query = get_query(model, info.context) if sort is not None: - query = query.order_by(*sort) if isinstance(sort, Iterable) else query.order_by(sort) + if isinstance(sort[0], str): + sort = [sort] + order = [] + for column_name, direction in sort: + column = getattr(model, column_name) + order.append(getattr(column, direction)()) + query = query.order_by(*order) return query @property diff --git a/graphene_sqlalchemy/utils.py b/graphene_sqlalchemy/utils.py index 7353d2b5..5676b13e 100644 --- a/graphene_sqlalchemy/utils.py +++ b/graphene_sqlalchemy/utils.py @@ -47,8 +47,8 @@ def _sort_enum_for_model(cls, name=None, symbol_name=_symbol_name): items = [] default = [] for column in inspect(cls).columns.values(): - asc = symbol_name(column.name, True), column.asc() - desc = symbol_name(column.name, False), column.desc() + asc = symbol_name(column.name, True), [column.name, 'asc'] + desc = symbol_name(column.name, False), [column.name, 'desc'] if column.primary_key: default.append(asc[1]) items.extend((asc, desc)) From 2fd5203a3cf348f76b320500aaf1f3e9bb799ca9 Mon Sep 17 00:00:00 2001 From: CaselIT Date: Sat, 16 Dec 2017 19:20:17 +0100 Subject: [PATCH 03/10] Optimization to the sort implementation The sort enum value is now a subclass of str that cat store the sort operator and be treated as string when creating the ast graph --- examples/flask_sqlalchemy/schema.py | 4 ++-- graphene_sqlalchemy/fields.py | 11 ++++------- graphene_sqlalchemy/utils.py | 20 ++++++++++++++++---- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/examples/flask_sqlalchemy/schema.py b/examples/flask_sqlalchemy/schema.py index 3cbf8e8b..9a078d51 100644 --- a/examples/flask_sqlalchemy/schema.py +++ b/examples/flask_sqlalchemy/schema.py @@ -34,8 +34,8 @@ class Meta: class Query(graphene.ObjectType): node = relay.Node.Field() - all_employees = SQLAlchemyConnectionField( - Employee, sort=graphene.Argument(SortEnumEmployee, default_value=['id', 'asc'])) + all_employees = SQLAlchemyConnectionField(Employee, sort=graphene.Argument(SortEnumEmployee, + default_value=utils.EnumValue('id_asc', EmployeeModel.id.asc()))) all_roles = SQLAlchemyConnectionField(Role, sort=utils.sort_argument_for_model(RoleModel)) all_departments = SQLAlchemyConnectionField(Department) diff --git a/graphene_sqlalchemy/fields.py b/graphene_sqlalchemy/fields.py index fd9d23a4..f10067a6 100644 --- a/graphene_sqlalchemy/fields.py +++ b/graphene_sqlalchemy/fields.py @@ -19,13 +19,10 @@ def model(self): def get_query(cls, model, info, sort=None, **args): query = get_query(model, info.context) if sort is not None: - if isinstance(sort[0], str): - sort = [sort] - order = [] - for column_name, direction in sort: - column = getattr(model, column_name) - order.append(getattr(column, direction)()) - query = query.order_by(*order) + if isinstance(sort, str): + query = query.order_by(sort.order) + else: + query = query.order_by(*(value.order for value in sort)) return query @property diff --git a/graphene_sqlalchemy/utils.py b/graphene_sqlalchemy/utils.py index 5676b13e..b2c26e58 100644 --- a/graphene_sqlalchemy/utils.py +++ b/graphene_sqlalchemy/utils.py @@ -42,16 +42,28 @@ def _symbol_name(column_name, is_asc): return column_name + ('_asc' if is_asc else '_desc') +class EnumValue(str): + '''Subclass of str that stores a string value and the sort order of the column''' + def __new__(cls, str_value, order): + return super(EnumValue, cls).__new__(cls, str_value) + + def __init__(self, str_value, order): + super(EnumValue, self).__init__() + self.order = order + + def _sort_enum_for_model(cls, name=None, symbol_name=_symbol_name): name = name or cls.__name__ + 'SortEnum' items = [] default = [] for column in inspect(cls).columns.values(): - asc = symbol_name(column.name, True), [column.name, 'asc'] - desc = symbol_name(column.name, False), [column.name, 'desc'] + asc_name = symbol_name(column.name, True) + asc_value = EnumValue(asc_name, column.asc()) + desc_name = symbol_name(column.name, False) + desc_value = EnumValue(desc_name, column.desc()) if column.primary_key: - default.append(asc[1]) - items.extend((asc, desc)) + default.append(asc_value) + items.extend(((asc_name, asc_value), (desc_name, desc_value))) return Enum(name, items), default From bead50a228f5c58f95d06df49bb8ed032e836460 Mon Sep 17 00:00:00 2001 From: CaselIT Date: Sun, 17 Dec 2017 12:36:35 +0100 Subject: [PATCH 04/10] Sort is added by default to the connectionField --- examples/flask_sqlalchemy/schema.py | 7 +++++-- graphene_sqlalchemy/fields.py | 18 ++++++++++++++---- graphene_sqlalchemy/tests/test_converter.py | 4 ++-- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/examples/flask_sqlalchemy/schema.py b/examples/flask_sqlalchemy/schema.py index 9a078d51..bd52a44b 100644 --- a/examples/flask_sqlalchemy/schema.py +++ b/examples/flask_sqlalchemy/schema.py @@ -34,10 +34,13 @@ class Meta: class Query(graphene.ObjectType): node = relay.Node.Field() + # Supports sorting only over one field all_employees = SQLAlchemyConnectionField(Employee, sort=graphene.Argument(SortEnumEmployee, default_value=utils.EnumValue('id_asc', EmployeeModel.id.asc()))) - all_roles = SQLAlchemyConnectionField(Role, sort=utils.sort_argument_for_model(RoleModel)) - all_departments = SQLAlchemyConnectionField(Department) + # Add sort over multiple fields, sorting by default over the primary key + all_roles = SQLAlchemyConnectionField(Role) + # Disable sorting over this field + all_departments = SQLAlchemyConnectionField(Department, sort=None) schema = graphene.Schema(query=Query, types=[Department, Employee, Role]) diff --git a/graphene_sqlalchemy/fields.py b/graphene_sqlalchemy/fields.py index f10067a6..1800967c 100644 --- a/graphene_sqlalchemy/fields.py +++ b/graphene_sqlalchemy/fields.py @@ -6,10 +6,10 @@ from graphene.relay.connection import PageInfo from graphql_relay.connection.arrayconnection import connection_from_list_slice -from .utils import get_query +from .utils import get_query, sort_argument_for_model -class SQLAlchemyConnectionField(ConnectionField): +class _UnsortedSQLAlchemyConnectionField(ConnectionField): @property def model(self): @@ -62,7 +62,17 @@ def get_resolver(self, parent_resolver): return partial(self.connection_resolver, parent_resolver, self.type, self.model) -__connectionFactory = SQLAlchemyConnectionField +class SQLAlchemyConnectionField(_UnsortedSQLAlchemyConnectionField): + + def __init__(self, type, *args, **kwargs): + if 'sort' not in kwargs: + kwargs.setdefault('sort', sort_argument_for_model(type._meta.model)) + elif kwargs['sort'] is None: + del kwargs['sort'] + super(SQLAlchemyConnectionField, self).__init__(type, *args, **kwargs) + + +__connectionFactory = _UnsortedSQLAlchemyConnectionField def createConnectionField(_type): @@ -76,4 +86,4 @@ def registerConnectionFieldFactory(factoryMethod): def unregisterConnectionFieldFactory(): global __connectionFactory - __connectionFactory = SQLAlchemyConnectionField + __connectionFactory = _UnsortedSQLAlchemyConnectionField diff --git a/graphene_sqlalchemy/tests/test_converter.py b/graphene_sqlalchemy/tests/test_converter.py index 221de606..eaada375 100644 --- a/graphene_sqlalchemy/tests/test_converter.py +++ b/graphene_sqlalchemy/tests/test_converter.py @@ -16,7 +16,7 @@ from ..converter import (convert_sqlalchemy_column, convert_sqlalchemy_composite, convert_sqlalchemy_relationship) -from ..fields import SQLAlchemyConnectionField +from ..fields import _UnsortedSQLAlchemyConnectionField from ..registry import Registry from ..types import SQLAlchemyObjectType from .models import Article, Pet, Reporter @@ -205,7 +205,7 @@ class Meta: dynamic_field = convert_sqlalchemy_relationship(Reporter.pets.property, A._meta.registry) assert isinstance(dynamic_field, graphene.Dynamic) - assert isinstance(dynamic_field.get_type(), SQLAlchemyConnectionField) + assert isinstance(dynamic_field.get_type(), _UnsortedSQLAlchemyConnectionField) def test_should_manytoone_convert_connectionorlist(): From 442b94b1b5d0c2d7ad593c851f3fa490d720e65d Mon Sep 17 00:00:00 2001 From: CaselIT Date: Wed, 20 Jun 2018 22:03:58 +0200 Subject: [PATCH 05/10] Add sort enum cache to avoid name clash. --- examples/flask_sqlalchemy/schema.py | 4 ++-- graphene_sqlalchemy/fields.py | 12 ++++++------ graphene_sqlalchemy/tests/test_converter.py | 4 ++-- graphene_sqlalchemy/utils.py | 21 +++++++++++++++------ 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/examples/flask_sqlalchemy/schema.py b/examples/flask_sqlalchemy/schema.py index bd52a44b..d4184745 100644 --- a/examples/flask_sqlalchemy/schema.py +++ b/examples/flask_sqlalchemy/schema.py @@ -34,10 +34,10 @@ class Meta: class Query(graphene.ObjectType): node = relay.Node.Field() - # Supports sorting only over one field + # Allow only single column sorting all_employees = SQLAlchemyConnectionField(Employee, sort=graphene.Argument(SortEnumEmployee, default_value=utils.EnumValue('id_asc', EmployeeModel.id.asc()))) - # Add sort over multiple fields, sorting by default over the primary key + # Add sort over multiple columns, sorting by default over the primary key all_roles = SQLAlchemyConnectionField(Role) # Disable sorting over this field all_departments = SQLAlchemyConnectionField(Department, sort=None) diff --git a/graphene_sqlalchemy/fields.py b/graphene_sqlalchemy/fields.py index 1800967c..9dba1136 100644 --- a/graphene_sqlalchemy/fields.py +++ b/graphene_sqlalchemy/fields.py @@ -9,7 +9,7 @@ from .utils import get_query, sort_argument_for_model -class _UnsortedSQLAlchemyConnectionField(ConnectionField): +class UnsortedSQLAlchemyConnectionField(ConnectionField): @property def model(self): @@ -20,9 +20,9 @@ def get_query(cls, model, info, sort=None, **args): query = get_query(model, info.context) if sort is not None: if isinstance(sort, str): - query = query.order_by(sort.order) + query = query.order_by(sort.value) else: - query = query.order_by(*(value.order for value in sort)) + query = query.order_by(*(col.value for col in sort)) return query @property @@ -62,7 +62,7 @@ def get_resolver(self, parent_resolver): return partial(self.connection_resolver, parent_resolver, self.type, self.model) -class SQLAlchemyConnectionField(_UnsortedSQLAlchemyConnectionField): +class SQLAlchemyConnectionField(UnsortedSQLAlchemyConnectionField): def __init__(self, type, *args, **kwargs): if 'sort' not in kwargs: @@ -72,7 +72,7 @@ def __init__(self, type, *args, **kwargs): super(SQLAlchemyConnectionField, self).__init__(type, *args, **kwargs) -__connectionFactory = _UnsortedSQLAlchemyConnectionField +__connectionFactory = UnsortedSQLAlchemyConnectionField def createConnectionField(_type): @@ -86,4 +86,4 @@ def registerConnectionFieldFactory(factoryMethod): def unregisterConnectionFieldFactory(): global __connectionFactory - __connectionFactory = _UnsortedSQLAlchemyConnectionField + __connectionFactory = UnsortedSQLAlchemyConnectionField diff --git a/graphene_sqlalchemy/tests/test_converter.py b/graphene_sqlalchemy/tests/test_converter.py index eaada375..1168792e 100644 --- a/graphene_sqlalchemy/tests/test_converter.py +++ b/graphene_sqlalchemy/tests/test_converter.py @@ -16,7 +16,7 @@ from ..converter import (convert_sqlalchemy_column, convert_sqlalchemy_composite, convert_sqlalchemy_relationship) -from ..fields import _UnsortedSQLAlchemyConnectionField +from ..fields import UnsortedSQLAlchemyConnectionField from ..registry import Registry from ..types import SQLAlchemyObjectType from .models import Article, Pet, Reporter @@ -205,7 +205,7 @@ class Meta: dynamic_field = convert_sqlalchemy_relationship(Reporter.pets.property, A._meta.registry) assert isinstance(dynamic_field, graphene.Dynamic) - assert isinstance(dynamic_field.get_type(), _UnsortedSQLAlchemyConnectionField) + assert isinstance(dynamic_field.get_type(), UnsortedSQLAlchemyConnectionField) def test_should_manytoone_convert_connectionorlist(): diff --git a/graphene_sqlalchemy/utils.py b/graphene_sqlalchemy/utils.py index b2c26e58..221e50a6 100644 --- a/graphene_sqlalchemy/utils.py +++ b/graphene_sqlalchemy/utils.py @@ -43,17 +43,24 @@ def _symbol_name(column_name, is_asc): class EnumValue(str): - '''Subclass of str that stores a string value and the sort order of the column''' - def __new__(cls, str_value, order): + '''Subclass of str that stores a string and an arbitrary value in the "value" property''' + + def __new__(cls, str_value, value): return super(EnumValue, cls).__new__(cls, str_value) - def __init__(self, str_value, order): + def __init__(self, str_value, value): super(EnumValue, self).__init__() - self.order = order + self.value = value + + +# Cache for the generated enums, to avoid name clash +_ENUM_CACHE = {} def _sort_enum_for_model(cls, name=None, symbol_name=_symbol_name): name = name or cls.__name__ + 'SortEnum' + if name in _ENUM_CACHE: + return _ENUM_CACHE[name] items = [] default = [] for column in inspect(cls).columns.values(): @@ -64,7 +71,9 @@ def _sort_enum_for_model(cls, name=None, symbol_name=_symbol_name): if column.primary_key: default.append(asc_value) items.extend(((asc_name, asc_value), (desc_name, desc_value))) - return Enum(name, items), default + enum = Enum(name, items) + _ENUM_CACHE[name] = (enum, default) + return enum, default def sort_enum_for_model(cls, name=None, symbol_name=_symbol_name): @@ -89,7 +98,7 @@ def sort_enum_for_model(cls, name=None, symbol_name=_symbol_name): def sort_argument_for_model(cls, has_default=True): - '''Returns an Graphene argument for the sort field that accepts a list of sorting directions for a model. + '''Returns a Graphene argument for the sort field that accepts a list of sorting directions for a model. If `has_default` is True (the default) it will sort the result by the primary key(s) ''' enum, default = _sort_enum_for_model(cls) From db0e3dbe0235956d6ab4f63b3e3054aebaea4bf1 Mon Sep 17 00:00:00 2001 From: CaselIT Date: Wed, 20 Jun 2018 23:55:22 +0200 Subject: [PATCH 06/10] Add tests of the sort argument --- graphene_sqlalchemy/tests/test_fields.py | 25 ++++ graphene_sqlalchemy/tests/test_query.py | 145 +++++++++++++++++++++++ graphene_sqlalchemy/tests/test_utils.py | 55 ++++++++- 3 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 graphene_sqlalchemy/tests/test_fields.py diff --git a/graphene_sqlalchemy/tests/test_fields.py b/graphene_sqlalchemy/tests/test_fields.py new file mode 100644 index 00000000..72b44f32 --- /dev/null +++ b/graphene_sqlalchemy/tests/test_fields.py @@ -0,0 +1,25 @@ +from ..fields import SQLAlchemyConnectionField +from ..types import SQLAlchemyObjectType +from ..utils import sort_argument_for_model +from .models import Pet as PetModel, Editor + + +class Pet(SQLAlchemyObjectType): + class Meta: + model = PetModel + + +def test_sort_added_by_default(): + arg = SQLAlchemyConnectionField(Pet) + assert 'sort' in arg.args + assert arg.args['sort'] == sort_argument_for_model(PetModel) + + +def test_sort_can_be_removed(): + arg = SQLAlchemyConnectionField(Pet, sort=None) + assert 'sort' not in arg.args + + +def test_custom_sort(): + arg = SQLAlchemyConnectionField(Pet, sort=sort_argument_for_model(Editor)) + assert arg.args['sort'] == sort_argument_for_model(Editor) \ No newline at end of file diff --git a/graphene_sqlalchemy/tests/test_query.py b/graphene_sqlalchemy/tests/test_query.py index 12dd1fad..f5241ab9 100644 --- a/graphene_sqlalchemy/tests/test_query.py +++ b/graphene_sqlalchemy/tests/test_query.py @@ -8,6 +8,7 @@ from ..registry import reset_global_registry from ..fields import SQLAlchemyConnectionField from ..types import SQLAlchemyObjectType +from ..utils import sort_argument_for_model, sort_enum_for_model from .models import Article, Base, Editor, Pet, Reporter db = create_engine('sqlite:///test_sqlalchemy.sqlite3') @@ -365,3 +366,147 @@ class Mutation(graphene.ObjectType): result = schema.execute(query, context_value={'session': session}) assert not result.errors assert result.data == expected + + +def sort_setup(session): + pets = [ + Pet(id=2, name='Lassie', pet_kind='dog'), + Pet(id=22, name='Alf', pet_kind='cat'), + Pet(id=3, name='Barf', pet_kind='dog') + ] + session.add_all(pets) + session.commit() + + +def test_sort(session): + sort_setup(session) + + class PetNode(SQLAlchemyObjectType): + class Meta: + model = Pet + interfaces = (Node, ) + + class Query(graphene.ObjectType): + defaultSort = SQLAlchemyConnectionField(PetNode) + nameSort = SQLAlchemyConnectionField(PetNode) + multipleSort = SQLAlchemyConnectionField(PetNode) + descSort = SQLAlchemyConnectionField(PetNode) + singleColumnSort = SQLAlchemyConnectionField( + PetNode, sort=graphene.Argument(sort_enum_for_model(Pet))) + noDefaultSort = SQLAlchemyConnectionField( + PetNode, sort=sort_argument_for_model(Pet, False)) + noSort = SQLAlchemyConnectionField(PetNode, sort=None) + + query = ''' + query sortTest { + defaultSort{ + edges{ + node{ + id + } + } + } + nameSort(sort: name_asc){ + edges{ + node{ + name + } + } + } + multipleSort(sort: [pet_kind_asc, name_desc]){ + edges{ + node{ + name + petKind + } + } + } + descSort(sort: [name_desc]){ + edges{ + node{ + name + } + } + } + singleColumnSort(sort: name_desc){ + edges{ + node{ + name + } + } + } + noDefaultSort(sort: name_asc){ + edges{ + node{ + name + } + } + } + } + ''' + + def makeNodes(nodeList): + nodes = [{'node': item} for item in nodeList] + return {'edges': nodes} + + expected = { + 'defaultSort': makeNodes([{'id': 'UGV0Tm9kZToy'}, {'id': 'UGV0Tm9kZToz'}, {'id': 'UGV0Tm9kZToyMg=='}]), + 'nameSort': makeNodes([{'name': 'Alf'}, {'name': 'Barf'}, {'name': 'Lassie'}]), + 'noDefaultSort': makeNodes([{'name': 'Alf'}, {'name': 'Barf'}, {'name': 'Lassie'}]), + 'multipleSort': makeNodes([ + {'name': 'Alf', 'petKind': 'cat'}, + {'name': 'Lassie', 'petKind': 'dog'}, + {'name': 'Barf', 'petKind': 'dog'} + ]), + 'descSort': makeNodes([{'name': 'Lassie'}, {'name': 'Barf'}, {'name': 'Alf'}]), + 'singleColumnSort': makeNodes([{'name': 'Lassie'}, {'name': 'Barf'}, {'name': 'Alf'}]), + } # yapf: disable + + schema = graphene.Schema(query=Query) + result = schema.execute(query, context_value={'session': session}) + assert not result.errors + assert result.data == expected + + queryError = ''' + query sortTest { + singleColumnSort(sort: [pet_kind_asc, name_desc]){ + edges{ + node{ + name + } + } + } + } + ''' + result = schema.execute(queryError, context_value={'session': session}) + assert result.errors is not None + + queryNoSort = ''' + query sortTest { + noDefaultSort{ + edges{ + node{ + name + } + } + } + noSort{ + edges{ + node{ + name + } + } + } + } + ''' + + expectedNoSort = { + 'noDefaultSort': makeNodes([{'name': 'Alf'}, {'name': 'Barf'}, {'name': 'Lassie'}]), + 'noSort': makeNodes([{'name': 'Alf'}, {'name': 'Barf'}, {'name': 'Lassie'}]), + } # yapf: disable + + result = schema.execute(queryNoSort, context_value={'session': session}) + assert not result.errors + for key, value in result.data.items(): + assert set(node['node']['name'] for node in value['edges']) == set( + node['node']['name'] for node in expectedNoSort[key]['edges']) diff --git a/graphene_sqlalchemy/tests/test_utils.py b/graphene_sqlalchemy/tests/test_utils.py index 8af3c61e..107f8def 100644 --- a/graphene_sqlalchemy/tests/test_utils.py +++ b/graphene_sqlalchemy/tests/test_utils.py @@ -1,6 +1,8 @@ -from graphene import ObjectType, Schema, String +from graphene import Enum, List, ObjectType, Schema, String +import sqlalchemy as sa -from ..utils import get_session +from ..utils import get_session, sort_enum_for_model, sort_argument_for_model +from .models import Pet, Editor def test_get_session(): @@ -22,3 +24,52 @@ def resolve_x(self, info): result = schema.execute(query, context_value={'session': session}) assert not result.errors assert result.data['x'] == session + + +def test_sort_enum_for_model(): + enum = sort_enum_for_model(Pet) + assert isinstance(enum, type(Enum)) + assert str(enum) == 'PetSortEnum' + for col in sa.inspect(Pet).columns: + assert hasattr(enum, col.name + '_asc') + assert hasattr(enum, col.name + '_desc') + + +def test_sort_enum_for_model_custom_naming(): + enum = sort_enum_for_model(Pet, 'Foo', + lambda n, d: n.upper() + ('A' if d else 'D')) + assert str(enum) == 'Foo' + for col in sa.inspect(Pet).columns: + assert hasattr(enum, col.name.upper() + 'A') + assert hasattr(enum, col.name.upper() + 'D') + + +def test_enum_cache(): + assert sort_enum_for_model(Editor) is sort_enum_for_model(Editor) + + +def test_sort_argument_for_model(): + arg = sort_argument_for_model(Pet) + + assert isinstance(arg.type, List) + assert arg.default_value == [Pet.id.name + '_asc'] + assert arg.type.of_type == sort_enum_for_model(Pet) + + +def test_sort_argument_for_model_no_default(): + arg = sort_argument_for_model(Pet, False) + + assert arg.default_value is None + + +def test_sort_argument_for_model_multiple_pk(): + Base = sa.ext.declarative.declarative_base() + + class MultiplePK(Base): + foo = sa.Column(sa.Integer, primary_key=True) + bar = sa.Column(sa.Integer, primary_key=True) + __tablename__ = 'MultiplePK' + + arg = sort_argument_for_model(MultiplePK) + assert set(arg.default_value) == set((MultiplePK.foo.name + '_asc', + MultiplePK.bar.name + '_asc')) From 9dd9fdf80b657592bbc70341c59b246850f1efe8 Mon Sep 17 00:00:00 2001 From: CaselIT Date: Thu, 21 Jun 2018 22:11:03 +0200 Subject: [PATCH 07/10] Add support with v2 --- graphene_sqlalchemy/fields.py | 16 ++++++++++++---- graphene_sqlalchemy/tests/test_fields.py | 21 +++++++++++++++++---- graphene_sqlalchemy/tests/test_query.py | 24 ++++++++++++++---------- 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/graphene_sqlalchemy/fields.py b/graphene_sqlalchemy/fields.py index c3282844..04253bf8 100644 --- a/graphene_sqlalchemy/fields.py +++ b/graphene_sqlalchemy/fields.py @@ -2,7 +2,7 @@ from promise import is_thenable, Promise from sqlalchemy.orm.query import Query -from graphene.relay import ConnectionField +from graphene.relay import Connection, ConnectionField from graphene.relay.connection import PageInfo from graphql_relay.connection.arrayconnection import connection_from_list_slice @@ -64,9 +64,17 @@ def get_resolver(self, parent_resolver): class SQLAlchemyConnectionField(UnsortedSQLAlchemyConnectionField): def __init__(self, type, *args, **kwargs): - if 'sort' not in kwargs: - kwargs.setdefault('sort', sort_argument_for_model(type._meta.model)) - elif kwargs['sort'] is None: + if 'sort' not in kwargs and issubclass(type, Connection): + # Let super class raise if type is not a Connection + try: + model = type.Edge.node._type._meta.model + kwargs.setdefault('sort', sort_argument_for_model(model)) + except Exception as e: + raise Exception( + 'Cannot create sort argument for {}. A model is required. Set the "sort" argument' + ' to None to disabling the creation of the sort query argument'.format(type.__name__) + ) + elif 'sort' in kwargs and kwargs['sort'] is None: del kwargs['sort'] super(SQLAlchemyConnectionField, self).__init__(type, *args, **kwargs) diff --git a/graphene_sqlalchemy/tests/test_fields.py b/graphene_sqlalchemy/tests/test_fields.py index 72b44f32..745d6991 100644 --- a/graphene_sqlalchemy/tests/test_fields.py +++ b/graphene_sqlalchemy/tests/test_fields.py @@ -1,3 +1,6 @@ +from graphene.relay import Connection +import pytest + from ..fields import SQLAlchemyConnectionField from ..types import SQLAlchemyObjectType from ..utils import sort_argument_for_model @@ -9,17 +12,27 @@ class Meta: model = PetModel +class PetConn(Connection): + class Meta: + node = Pet + + def test_sort_added_by_default(): - arg = SQLAlchemyConnectionField(Pet) + arg = SQLAlchemyConnectionField(PetConn) assert 'sort' in arg.args assert arg.args['sort'] == sort_argument_for_model(PetModel) def test_sort_can_be_removed(): - arg = SQLAlchemyConnectionField(Pet, sort=None) + arg = SQLAlchemyConnectionField(PetConn, sort=None) assert 'sort' not in arg.args def test_custom_sort(): - arg = SQLAlchemyConnectionField(Pet, sort=sort_argument_for_model(Editor)) - assert arg.args['sort'] == sort_argument_for_model(Editor) \ No newline at end of file + arg = SQLAlchemyConnectionField(PetConn, sort=sort_argument_for_model(Editor)) + assert arg.args['sort'] == sort_argument_for_model(Editor) + + +def test_init_raises(): + with pytest.raises(Exception, match='Cannot create sort'): + SQLAlchemyConnectionField(Connection) diff --git a/graphene_sqlalchemy/tests/test_query.py b/graphene_sqlalchemy/tests/test_query.py index 402d9498..1e5e213e 100644 --- a/graphene_sqlalchemy/tests/test_query.py +++ b/graphene_sqlalchemy/tests/test_query.py @@ -3,7 +3,7 @@ from sqlalchemy.orm import scoped_session, sessionmaker import graphene -from graphene.relay import Node +from graphene.relay import Connection, Node from ..registry import reset_global_registry from ..fields import SQLAlchemyConnectionField @@ -153,7 +153,7 @@ class Meta: # def get_node(cls, id, info): # return Article(id=1, headline='Article node') - class ArticleConnection(graphene.relay.Connection): + class ArticleConnection(Connection): class Meta: node = ArticleNode @@ -243,7 +243,7 @@ class Meta: model = Editor interfaces = (Node, ) - class EditorConnection(graphene.relay.Connection): + class EditorConnection(Connection): class Meta: node = EditorNode @@ -394,16 +394,20 @@ class Meta: model = Pet interfaces = (Node, ) + class PetConnection(Connection): + class Meta: + node = PetNode + class Query(graphene.ObjectType): - defaultSort = SQLAlchemyConnectionField(PetNode) - nameSort = SQLAlchemyConnectionField(PetNode) - multipleSort = SQLAlchemyConnectionField(PetNode) - descSort = SQLAlchemyConnectionField(PetNode) + defaultSort = SQLAlchemyConnectionField(PetConnection) + nameSort = SQLAlchemyConnectionField(PetConnection) + multipleSort = SQLAlchemyConnectionField(PetConnection) + descSort = SQLAlchemyConnectionField(PetConnection) singleColumnSort = SQLAlchemyConnectionField( - PetNode, sort=graphene.Argument(sort_enum_for_model(Pet))) + PetConnection, sort=graphene.Argument(sort_enum_for_model(Pet))) noDefaultSort = SQLAlchemyConnectionField( - PetNode, sort=sort_argument_for_model(Pet, False)) - noSort = SQLAlchemyConnectionField(PetNode, sort=None) + PetConnection, sort=sort_argument_for_model(Pet, False)) + noSort = SQLAlchemyConnectionField(PetConnection, sort=None) query = ''' query sortTest { From 8b7c124dfbb184e7eee935393e9eedba6dc09f6a Mon Sep 17 00:00:00 2001 From: CaselIT Date: Thu, 21 Jun 2018 22:37:13 +0200 Subject: [PATCH 08/10] Add some documentation on sorting --- docs/tips.rst | 61 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/docs/tips.rst b/docs/tips.rst index 9c4a98b8..9a17303e 100644 --- a/docs/tips.rst +++ b/docs/tips.rst @@ -2,9 +2,6 @@ Tips ==== -Tips -==== - Querying -------- @@ -30,3 +27,61 @@ For make querying to the database work, there are two alternatives: 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.`` + +Sorting +------- + +By default the SQLAlchemyConnectionField sorts the result elements over the primary key(s). +The query has a `sort` argument which allows to sort over a different column(s) + +Given the model + +.. code:: python + + class Pet(Base): + __tablename__ = 'pets' + id = Column(Integer(), primary_key=True) + name = Column(String(30)) + pet_kind = Column(Enum('cat', 'dog', name='pet_kind'), nullable=False) + + + class PetNode(SQLAlchemyObjectType): + class Meta: + model = Pet + + + class PetConnection(Connection): + class Meta: + node = PetNone + + + class Query(ObjectType): + allPets = SQLAlchemyConnectionField(PetConnection) + +some of the allowed queries are + +- Sort in ascending order over the `name` column + +.. code:: + + allPets(sort: name_asc){ + edges { + node { + name + } + } + } + +- Sort in descending order over the `per_kind` column and in ascending order over the `name` column + +.. code:: + + allPets(sort: [pet_kind_desc, name_asc]) { + edges { + node { + name + petKind + } + } + } + From 36ac0d106f1997c77f67693073356cd9ba573c78 Mon Sep 17 00:00:00 2001 From: CaselIT Date: Thu, 21 Jun 2018 22:50:45 +0200 Subject: [PATCH 09/10] Made other documentation compatible with v2 --- docs/examples.rst | 24 +++++++++++++++------- docs/tutorial.rst | 15 +++++++++++++- examples/flask_sqlalchemy/schema.py | 32 +++++++++++++++++++++-------- 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/docs/examples.rst b/docs/examples.rst index 50b48fab..45fa3c55 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -3,7 +3,7 @@ Schema Examples Search all Models with Union ------------------ +---------------------------- .. code:: python @@ -11,14 +11,24 @@ Search all Models with Union class Meta: model = BookModel interfaces = (relay.Node,) - - + + + class BookConnection(relay.Connection): + class Meta: + node = Book + + class Author(SQLAlchemyObjectType): class Meta: model = AuthorModel interfaces = (relay.Node,) + class AuthorConnection(relay.Connection): + class Meta: + node = Author + + class SearchResult(graphene.Union): class Meta: types = (Book, Author) @@ -29,8 +39,8 @@ Search all Models with Union search = graphene.List(SearchResult, q=graphene.String()) # List field for search results # Normal Fields - all_books = SQLAlchemyConnectionField(Book) - all_authors = SQLAlchemyConnectionField(Author) + all_books = SQLAlchemyConnectionField(BookConnection) + all_authors = SQLAlchemyConnectionField(AuthorConnection) def resolve_search(self, info, **args): q = args.get("q") # Search query @@ -47,13 +57,13 @@ Search all Models with Union # Query Authors authors = author_query.filter(AuthorModel.name.contains(q)).all() - return authors + books # Combine lists + return authors + books # Combine lists schema = graphene.Schema(query=Query, types=[Book, Author, SearchResult]) Example GraphQL query -.. code:: GraphQL +.. code:: book(id: "Qm9vazow") { id diff --git a/docs/tutorial.rst b/docs/tutorial.rst index b07eaecf..26f0cb2b 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -102,15 +102,28 @@ Create ``flask_sqlalchemy/schema.py`` and type the following: interfaces = (relay.Node, ) + class DepartmentConnection(relay.Connection): + class Meta: + node = Department + + class Employee(SQLAlchemyObjectType): class Meta: model = EmployeeModel interfaces = (relay.Node, ) + class EmployeeConnection(relay.Connection): + class Meta: + node = Employee + + class Query(graphene.ObjectType): node = relay.Node.Field() - all_employees = SQLAlchemyConnectionField(Employee) + # Allows sorting over multiple columns, by default over the primary key + all_employees = SQLAlchemyConnectionField(EmployeeConnection) + # Disable sorting over this field + all_departments = SQLAlchemyConnectionField(DepartmentConnection, sort=None) schema = graphene.Schema(query=Query) diff --git a/examples/flask_sqlalchemy/schema.py b/examples/flask_sqlalchemy/schema.py index d4184745..bca71d19 100644 --- a/examples/flask_sqlalchemy/schema.py +++ b/examples/flask_sqlalchemy/schema.py @@ -7,40 +7,54 @@ class Department(SQLAlchemyObjectType): - class Meta: model = DepartmentModel interfaces = (relay.Node, ) -class Employee(SQLAlchemyObjectType): +class DepartmentConnection(relay.Connection): + class Meta: + node = Department + +class Employee(SQLAlchemyObjectType): class Meta: model = EmployeeModel interfaces = (relay.Node, ) -class Role(SQLAlchemyObjectType): +class EmployeeConnection(relay.Connection): + class Meta: + node = Employee + +class Role(SQLAlchemyObjectType): class Meta: model = RoleModel interfaces = (relay.Node, ) -SortEnumEmployee = utils.sort_enum_for_model( - EmployeeModel, 'SortEnumEmployee', +class RoleConnection(relay.Connection): + class Meta: + node = Role + + +SortEnumEmployee = utils.sort_enum_for_model(EmployeeModel, 'SortEnumEmployee', lambda c, d: c.upper() + ('_ASC' if d else '_DESC')) class Query(graphene.ObjectType): node = relay.Node.Field() # Allow only single column sorting - all_employees = SQLAlchemyConnectionField(Employee, sort=graphene.Argument(SortEnumEmployee, + all_employees = SQLAlchemyConnectionField( + EmployeeConnection, + sort=graphene.Argument( + SortEnumEmployee, default_value=utils.EnumValue('id_asc', EmployeeModel.id.asc()))) - # Add sort over multiple columns, sorting by default over the primary key - all_roles = SQLAlchemyConnectionField(Role) + # Allows sorting over multiple columns, by default over the primary key + all_roles = SQLAlchemyConnectionField(RoleConnection) # Disable sorting over this field - all_departments = SQLAlchemyConnectionField(Department, sort=None) + all_departments = SQLAlchemyConnectionField(DepartmentConnection, sort=None) schema = graphene.Schema(query=Query, types=[Department, Employee, Role]) From fb6913bfec3550a05708916b5894474dbb21f6cb Mon Sep 17 00:00:00 2001 From: CaselIT Date: Thu, 21 Jun 2018 22:52:54 +0200 Subject: [PATCH 10/10] Fix linter error --- graphene_sqlalchemy/fields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_sqlalchemy/fields.py b/graphene_sqlalchemy/fields.py index 04253bf8..4a283ad8 100644 --- a/graphene_sqlalchemy/fields.py +++ b/graphene_sqlalchemy/fields.py @@ -69,7 +69,7 @@ def __init__(self, type, *args, **kwargs): try: model = type.Edge.node._type._meta.model kwargs.setdefault('sort', sort_argument_for_model(model)) - except Exception as e: + except Exception: raise Exception( 'Cannot create sort argument for {}. A model is required. Set the "sort" argument' ' to None to disabling the creation of the sort query argument'.format(type.__name__)