From 57cd7866848d5a6ad174710be2a09c1339c159e8 Mon Sep 17 00:00:00 2001 From: Mel van Londen Date: Mon, 20 Sep 2021 21:44:25 -0700 Subject: [PATCH 01/12] Build clean up (#318) --- .github/workflows/deploy.yml | 2 +- tox.ini | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 50ffc6ad..a9f74233 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,7 +3,7 @@ name: 🚀 Deploy to PyPI on: push: tags: - - 'v*' + - '*' jobs: build: diff --git a/tox.ini b/tox.ini index a2843f05..b8ce0618 100644 --- a/tox.ini +++ b/tox.ini @@ -1,12 +1,10 @@ [tox] -envlist = pre-commit,py{36,37,38,39}-sql{11,12,13,14} +envlist = pre-commit,py{36,37,38,39}-sql{12,13,14} skipsdist = true minversion = 3.7.0 [gh-actions] python = - 2.7: py27 - 3.5: py35 3.6: py36 3.7: py37 3.8: py38 From 7bf0aa5cabae99e5f8be46ac2a2f0faf30093c97 Mon Sep 17 00:00:00 2001 From: Kyle Quinn <29496224+quinnkj@users.noreply.github.com> Date: Fri, 8 Apr 2022 06:41:48 -0400 Subject: [PATCH 02/12] I resolved spelling and capitalization mistakes. (#290) For ~~instaling~~installing ~~g~~Graphene, just run this command in your shell. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9b617069..04692973 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ A [SQLAlchemy](http://www.sqlalchemy.org/) integration for [Graphene](http://gra ## Installation -For instaling graphene, just run this command in your shell +For installing Graphene, just run this command in your shell. ```bash pip install "graphene-sqlalchemy>=2.0" @@ -34,7 +34,7 @@ class UserModel(Base): last_name = Column(String) ``` -To create a GraphQL schema for it you simply have to write the following: +To create a GraphQL schema for it, you simply have to write the following: ```python import graphene From 771f4f58f589878820b681598e4a3f4502be00ad Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Wed, 27 Apr 2022 21:31:38 +0200 Subject: [PATCH 03/12] Fix for import from graphql-relay-py (#329) (#330) * Add newlines to make pre-commit happy * Fix import from graphql_relay The module name was deprecated, but all imports should be made from the top level anyway. --- .github/workflows/deploy.yml | 2 +- .github/workflows/lint.yml | 2 +- graphene_sqlalchemy/fields.py | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a9f74233..1ae7b4b6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -23,4 +23,4 @@ jobs: uses: pypa/gh-action-pypi-publish@v1.1.0 with: user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} \ No newline at end of file + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3fc35f9d..559326c4 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,4 +19,4 @@ jobs: - name: Run lint 💅 run: tox env: - TOXENV: flake8 \ No newline at end of file + TOXENV: flake8 diff --git a/graphene_sqlalchemy/fields.py b/graphene_sqlalchemy/fields.py index a22a3ae7..d7a83392 100644 --- a/graphene_sqlalchemy/fields.py +++ b/graphene_sqlalchemy/fields.py @@ -8,8 +8,7 @@ from graphene import NonNull from graphene.relay import Connection, ConnectionField from graphene.relay.connection import connection_adapter, page_info_adapter -from graphql_relay.connection.arrayconnection import \ - connection_from_array_slice +from graphql_relay import connection_from_array_slice from .batching import get_batch_resolver from .utils import EnumValue, get_query From 869a55b3e48b63f1a86f7fbc167b2710be004dc4 Mon Sep 17 00:00:00 2001 From: Jacob Beard Date: Wed, 27 Apr 2022 20:31:37 -0400 Subject: [PATCH 04/12] Add support for N-Dimensional Arrays Fixes #288 --- graphene_sqlalchemy/converter.py | 6 +++++- graphene_sqlalchemy/tests/test_converter.py | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/graphene_sqlalchemy/converter.py b/graphene_sqlalchemy/converter.py index 1720e3d8..04061801 100644 --- a/graphene_sqlalchemy/converter.py +++ b/graphene_sqlalchemy/converter.py @@ -239,11 +239,15 @@ def convert_scalar_list_to_list(type, column, registry=None): return List(String) +def init_array_list_recursive(inner_type, n): + return inner_type if n == 0 else List(init_array_list_recursive(inner_type, n-1)) + + @convert_sqlalchemy_type.register(types.ARRAY) @convert_sqlalchemy_type.register(postgresql.ARRAY) def convert_array_to_list(_type, column, registry=None): inner_type = convert_sqlalchemy_type(column.type.item_type, column) - return List(inner_type) + return List(init_array_list_recursive(inner_type, (column.type.dimensions or 1) - 1)) @convert_sqlalchemy_type.register(postgresql.HSTORE) diff --git a/graphene_sqlalchemy/tests/test_converter.py b/graphene_sqlalchemy/tests/test_converter.py index 3196d003..57c43058 100644 --- a/graphene_sqlalchemy/tests/test_converter.py +++ b/graphene_sqlalchemy/tests/test_converter.py @@ -324,6 +324,21 @@ def test_should_array_convert(): assert field.type.of_type == graphene.Int +def test_should_2d_array_convert(): + field = get_field(types.ARRAY(types.Integer, dimensions=2)) + assert isinstance(field.type, graphene.List) + assert isinstance(field.type.of_type, graphene.List) + assert field.type.of_type.of_type == graphene.Int + + +def test_should_3d_array_convert(): + field = get_field(types.ARRAY(types.Integer, dimensions=3)) + assert isinstance(field.type, graphene.List) + assert isinstance(field.type.of_type, graphene.List) + assert isinstance(field.type.of_type.of_type, graphene.List) + assert field.type.of_type.of_type.of_type == graphene.Int + + def test_should_postgresql_json_convert(): assert get_field(postgresql.JSON()).type == graphene.JSONString From 5da2048f15f16f6e2443a2c2471e9626069dbb04 Mon Sep 17 00:00:00 2001 From: Connor Brinton Date: Thu, 28 Apr 2022 12:05:09 -0400 Subject: [PATCH 05/12] =?UTF-8?q?=F0=9F=A5=85=20Don't=20suppress=20SQLAlch?= =?UTF-8?q?emy=20errors=20when=20mapping=20classes=20(#169)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These changes modify graphene-sqlalchemy so as not to suppress errors coming from SQLAlchemy when attempting to map classes. Previously this made the debugging experience difficult since issues with SQLAlchemy models would produce an unclear error message from graphene-sqlalchemy. With these changes, the SQLAlchemy error is propagated to the end-user, allowing them to correct the real issue quickly. Fixes #121 --- .pre-commit-config.yaml | 8 +-- graphene_sqlalchemy/tests/test_types.py | 65 +++++++++++++++++++++++++ graphene_sqlalchemy/types.py | 8 +-- graphene_sqlalchemy/utils.py | 8 ++- 4 files changed, 81 insertions(+), 8 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 136f8e7a..1c67ab03 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ default_language_version: python: python3.7 repos: -- repo: git://github.com/pre-commit/pre-commit-hooks +- repo: https://github.com/pre-commit/pre-commit-hooks rev: c8bad492e1b1d65d9126dba3fe3bd49a5a52b9d6 # v2.1.0 hooks: - id: check-merge-conflict @@ -11,15 +11,15 @@ repos: exclude: ^docs/.*$ - id: trailing-whitespace exclude: README.md -- repo: git://github.com/PyCQA/flake8 +- repo: https://github.com/PyCQA/flake8 rev: 88caf5ac484f5c09aedc02167c59c66ff0af0068 # 3.7.7 hooks: - id: flake8 -- repo: git://github.com/asottile/seed-isort-config +- repo: https://github.com/asottile/seed-isort-config rev: v1.7.0 hooks: - id: seed-isort-config -- repo: git://github.com/pre-commit/mirrors-isort +- repo: https://github.com/pre-commit/mirrors-isort rev: v4.3.4 hooks: - id: isort diff --git a/graphene_sqlalchemy/tests/test_types.py b/graphene_sqlalchemy/tests/test_types.py index 32f01509..1f15fa1a 100644 --- a/graphene_sqlalchemy/tests/test_types.py +++ b/graphene_sqlalchemy/tests/test_types.py @@ -1,11 +1,14 @@ from unittest import mock import pytest +import sqlalchemy.exc +import sqlalchemy.orm.exc from graphene import (Dynamic, Field, GlobalID, Int, List, Node, NonNull, ObjectType, Schema, String) from graphene.relay import Connection +from .. import utils from ..converter import convert_sqlalchemy_composite from ..fields import (SQLAlchemyConnectionField, UnsortedSQLAlchemyConnectionField, createConnectionField, @@ -492,3 +495,65 @@ class Meta: def test_deprecated_createConnectionField(): with pytest.warns(DeprecationWarning): createConnectionField(None) + + +@mock.patch(utils.__name__ + '.class_mapper') +def test_unique_errors_propagate(class_mapper_mock): + # Define unique error to detect + class UniqueError(Exception): + pass + + # Mock class_mapper effect + class_mapper_mock.side_effect = UniqueError + + # Make sure that errors are propagated from class_mapper when instantiating new classes + error = None + try: + class ArticleOne(SQLAlchemyObjectType): + class Meta(object): + model = Article + except UniqueError as e: + error = e + + # Check that an error occured, and that it was the unique error we gave + assert error is not None + assert isinstance(error, UniqueError) + + +@mock.patch(utils.__name__ + '.class_mapper') +def test_argument_errors_propagate(class_mapper_mock): + # Mock class_mapper effect + class_mapper_mock.side_effect = sqlalchemy.exc.ArgumentError + + # Make sure that errors are propagated from class_mapper when instantiating new classes + error = None + try: + class ArticleTwo(SQLAlchemyObjectType): + class Meta(object): + model = Article + except sqlalchemy.exc.ArgumentError as e: + error = e + + # Check that an error occured, and that it was the unique error we gave + assert error is not None + assert isinstance(error, sqlalchemy.exc.ArgumentError) + + +@mock.patch(utils.__name__ + '.class_mapper') +def test_unmapped_errors_reformat(class_mapper_mock): + # Mock class_mapper effect + class_mapper_mock.side_effect = sqlalchemy.orm.exc.UnmappedClassError(object) + + # Make sure that errors are propagated from class_mapper when instantiating new classes + error = None + try: + class ArticleThree(SQLAlchemyObjectType): + class Meta(object): + model = Article + except ValueError as e: + error = e + + # Check that an error occured, and that it was the unique error we gave + assert error is not None + assert isinstance(error, ValueError) + assert "You need to pass a valid SQLAlchemy Model" in str(error) diff --git a/graphene_sqlalchemy/types.py b/graphene_sqlalchemy/types.py index 72f06c06..ac69b697 100644 --- a/graphene_sqlalchemy/types.py +++ b/graphene_sqlalchemy/types.py @@ -207,9 +207,11 @@ def __init_subclass_with_meta__( _meta=None, **options ): - assert is_mapped_class(model), ( - "You need to pass a valid SQLAlchemy Model in " '{}.Meta, received "{}".' - ).format(cls.__name__, model) + # Make sure model is a valid SQLAlchemy model + if not is_mapped_class(model): + raise ValueError( + "You need to pass a valid SQLAlchemy Model in " '{}.Meta, received "{}".'.format(cls.__name__, model) + ) if not registry: registry = get_global_registry() diff --git a/graphene_sqlalchemy/utils.py b/graphene_sqlalchemy/utils.py index b30c0eb4..340ad47e 100644 --- a/graphene_sqlalchemy/utils.py +++ b/graphene_sqlalchemy/utils.py @@ -27,7 +27,13 @@ def get_query(model, context): def is_mapped_class(cls): try: class_mapper(cls) - except (ArgumentError, UnmappedClassError): + except ArgumentError as error: + # Only handle ArgumentErrors for non-class objects + if "Class object expected" in str(error): + return False + raise + except UnmappedClassError: + # Unmapped classes return false return False else: return True From 0820da77d94d947e35325ba40eea96462bc890a7 Mon Sep 17 00:00:00 2001 From: Cadu Date: Fri, 29 Apr 2022 20:07:58 -0300 Subject: [PATCH 06/12] Support setting @hybrid_property's return type from the functions type annotations. (#340) Adds support for automatic type conversion for @hybrid_property's using converters similar to @convert_sqlalchemy_type.register(). Currently, all basic types and (nested) Lists are supported. This feature replaces the old default string conversion. String conversion is still used as a fallback in case no compatible converter was found to ensure backward compatibility. Thank you @conao3 & @flipbit03! --- graphene_sqlalchemy/converter.py | 124 +++++++++++++++++++- graphene_sqlalchemy/tests/models.py | 122 +++++++++++++++++++ graphene_sqlalchemy/tests/test_converter.py | 91 +++++++++++++- graphene_sqlalchemy/tests/test_types.py | 63 ++++++++-- graphene_sqlalchemy/utils.py | 56 ++++++++- 5 files changed, 438 insertions(+), 18 deletions(-) diff --git a/graphene_sqlalchemy/converter.py b/graphene_sqlalchemy/converter.py index 04061801..a2e03694 100644 --- a/graphene_sqlalchemy/converter.py +++ b/graphene_sqlalchemy/converter.py @@ -1,11 +1,16 @@ +import datetime +import typing +import warnings +from decimal import Decimal from functools import singledispatch +from typing import Any from sqlalchemy import types from sqlalchemy.dialects import postgresql from sqlalchemy.orm import interfaces, strategies -from graphene import (ID, Boolean, Dynamic, Enum, Field, Float, Int, List, - String) +from graphene import (ID, Boolean, Date, DateTime, Dynamic, Enum, Field, Float, + Int, List, String, Time) from graphene.types.json import JSONString from .batching import get_batch_resolver @@ -14,6 +19,14 @@ default_connection_field_factory) from .registry import get_global_registry from .resolvers import get_attr_resolver, get_custom_resolver +from .utils import (registry_sqlalchemy_model_from_str, safe_isinstance, + singledispatchbymatchfunction, value_equals) + +try: + from typing import ForwardRef +except ImportError: + # python 3.6 + from typing import _ForwardRef as ForwardRef try: from sqlalchemy_utils import ChoiceType, JSONType, ScalarListType, TSVectorType @@ -25,7 +38,6 @@ except ImportError: EnumTypeImpl = object - is_selectin_available = getattr(strategies, 'SelectInLoader', None) @@ -48,6 +60,7 @@ def convert_sqlalchemy_relationship(relationship_prop, obj_type, connection_fiel :param dict field_kwargs: :rtype: Dynamic """ + def dynamic_type(): """:rtype: Field|None""" direction = relationship_prop.direction @@ -115,8 +128,7 @@ def _convert_o2m_or_m2m_relationship(relationship_prop, obj_type, batching, conn def convert_sqlalchemy_hybrid_method(hybrid_prop, resolver, **field_kwargs): if 'type_' not in field_kwargs: - # TODO The default type should be dependent on the type of the property propety. - field_kwargs['type_'] = String + field_kwargs['type_'] = convert_hybrid_property_return_type(hybrid_prop) return Field( resolver=resolver, @@ -240,7 +252,7 @@ def convert_scalar_list_to_list(type, column, registry=None): def init_array_list_recursive(inner_type, n): - return inner_type if n == 0 else List(init_array_list_recursive(inner_type, n-1)) + return inner_type if n == 0 else List(init_array_list_recursive(inner_type, n - 1)) @convert_sqlalchemy_type.register(types.ARRAY) @@ -260,3 +272,103 @@ def convert_json_to_string(type, column, registry=None): @convert_sqlalchemy_type.register(JSONType) def convert_json_type_to_string(type, column, registry=None): return JSONString + + +@singledispatchbymatchfunction +def convert_sqlalchemy_hybrid_property_type(arg: Any): + existing_graphql_type = get_global_registry().get_type_for_model(arg) + if existing_graphql_type: + return existing_graphql_type + + # No valid type found, warn and fall back to graphene.String + warnings.warn( + (f"I don't know how to generate a GraphQL type out of a \"{arg}\" type." + "Falling back to \"graphene.String\"") + ) + return String + + +@convert_sqlalchemy_hybrid_property_type.register(value_equals(str)) +def convert_sqlalchemy_hybrid_property_type_str(arg): + return String + + +@convert_sqlalchemy_hybrid_property_type.register(value_equals(int)) +def convert_sqlalchemy_hybrid_property_type_int(arg): + return Int + + +@convert_sqlalchemy_hybrid_property_type.register(value_equals(float)) +def convert_sqlalchemy_hybrid_property_type_float(arg): + return Float + + +@convert_sqlalchemy_hybrid_property_type.register(value_equals(Decimal)) +def convert_sqlalchemy_hybrid_property_type_decimal(arg): + # The reason Decimal should be serialized as a String is because this is a + # base10 type used in things like money, and string allows it to not + # lose precision (which would happen if we downcasted to a Float, for example) + return String + + +@convert_sqlalchemy_hybrid_property_type.register(value_equals(bool)) +def convert_sqlalchemy_hybrid_property_type_bool(arg): + return Boolean + + +@convert_sqlalchemy_hybrid_property_type.register(value_equals(datetime.datetime)) +def convert_sqlalchemy_hybrid_property_type_datetime(arg): + return DateTime + + +@convert_sqlalchemy_hybrid_property_type.register(value_equals(datetime.date)) +def convert_sqlalchemy_hybrid_property_type_date(arg): + return Date + + +@convert_sqlalchemy_hybrid_property_type.register(value_equals(datetime.time)) +def convert_sqlalchemy_hybrid_property_type_time(arg): + return Time + + +@convert_sqlalchemy_hybrid_property_type.register(lambda x: getattr(x, '__origin__', None) in [list, typing.List]) +def convert_sqlalchemy_hybrid_property_type_list_t(arg): + # type is either list[T] or List[T], generic argument at __args__[0] + internal_type = arg.__args__[0] + + graphql_internal_type = convert_sqlalchemy_hybrid_property_type(internal_type) + + return List(graphql_internal_type) + + +@convert_sqlalchemy_hybrid_property_type.register(safe_isinstance(ForwardRef)) +def convert_sqlalchemy_hybrid_property_forwardref(arg): + """ + Generate a lambda that will resolve the type at runtime + This takes care of self-references + """ + + def forward_reference_solver(): + model = registry_sqlalchemy_model_from_str(arg.__forward_arg__) + if not model: + return String + # Always fall back to string if no ForwardRef type found. + return get_global_registry().get_type_for_model(model) + + return forward_reference_solver + + +@convert_sqlalchemy_hybrid_property_type.register(safe_isinstance(str)) +def convert_sqlalchemy_hybrid_property_bare_str(arg): + """ + Convert Bare String into a ForwardRef + """ + + return convert_sqlalchemy_hybrid_property_type(ForwardRef(arg)) + + +def convert_hybrid_property_return_type(hybrid_prop): + # Grab the original method's return type annotations from inside the hybrid property + return_type_annotation = hybrid_prop.fget.__annotations__.get('return', str) + + return convert_sqlalchemy_hybrid_property_type(return_type_annotation) diff --git a/graphene_sqlalchemy/tests/models.py b/graphene_sqlalchemy/tests/models.py index 88e992b9..bda5a863 100644 --- a/graphene_sqlalchemy/tests/models.py +++ b/graphene_sqlalchemy/tests/models.py @@ -1,6 +1,9 @@ from __future__ import absolute_import +import datetime import enum +from decimal import Decimal +from typing import List, Tuple from sqlalchemy import (Column, Date, Enum, ForeignKey, Integer, String, Table, func, select) @@ -69,6 +72,26 @@ class Reporter(Base): def hybrid_prop(self): return self.first_name + @hybrid_property + def hybrid_prop_str(self) -> str: + return self.first_name + + @hybrid_property + def hybrid_prop_int(self) -> int: + return 42 + + @hybrid_property + def hybrid_prop_float(self) -> float: + return 42.3 + + @hybrid_property + def hybrid_prop_bool(self) -> bool: + return True + + @hybrid_property + def hybrid_prop_list(self) -> List[int]: + return [1, 2, 3] + column_prop = column_property( select([func.cast(func.count(id), Integer)]), doc="Column property" ) @@ -95,3 +118,102 @@ def __subclasses__(cls): editor_table = Table("editors", Base.metadata, autoload=True) mapper(ReflectedEditor, editor_table) + + +############################################ +# The models below are mainly used in the +# @hybrid_property type inference scenarios +############################################ + + +class ShoppingCartItem(Base): + __tablename__ = "shopping_cart_items" + + id = Column(Integer(), primary_key=True) + + @hybrid_property + def hybrid_prop_shopping_cart(self) -> List['ShoppingCart']: + return [ShoppingCart(id=1)] + + +class ShoppingCart(Base): + __tablename__ = "shopping_carts" + + id = Column(Integer(), primary_key=True) + + # Standard Library types + + @hybrid_property + def hybrid_prop_str(self) -> str: + return self.first_name + + @hybrid_property + def hybrid_prop_int(self) -> int: + return 42 + + @hybrid_property + def hybrid_prop_float(self) -> float: + return 42.3 + + @hybrid_property + def hybrid_prop_bool(self) -> bool: + return True + + @hybrid_property + def hybrid_prop_decimal(self) -> Decimal: + return Decimal("3.14") + + @hybrid_property + def hybrid_prop_date(self) -> datetime.date: + return datetime.datetime.now().date() + + @hybrid_property + def hybrid_prop_time(self) -> datetime.time: + return datetime.datetime.now().time() + + @hybrid_property + def hybrid_prop_datetime(self) -> datetime.datetime: + return datetime.datetime.now() + + # Lists and Nested Lists + + @hybrid_property + def hybrid_prop_list_int(self) -> List[int]: + return [1, 2, 3] + + @hybrid_property + def hybrid_prop_list_date(self) -> List[datetime.date]: + return [self.hybrid_prop_date, self.hybrid_prop_date, self.hybrid_prop_date] + + @hybrid_property + def hybrid_prop_nested_list_int(self) -> List[List[int]]: + return [self.hybrid_prop_list_int, ] + + @hybrid_property + def hybrid_prop_deeply_nested_list_int(self) -> List[List[List[int]]]: + return [[self.hybrid_prop_list_int, ], ] + + # Other SQLAlchemy Instances + @hybrid_property + def hybrid_prop_first_shopping_cart_item(self) -> ShoppingCartItem: + return ShoppingCartItem(id=1) + + # Other SQLAlchemy Instances + @hybrid_property + def hybrid_prop_shopping_cart_item_list(self) -> List[ShoppingCartItem]: + return [ShoppingCartItem(id=1), ShoppingCartItem(id=2)] + + # Unsupported Type + @hybrid_property + def hybrid_prop_unsupported_type_tuple(self) -> Tuple[str, str]: + return "this will actually", "be a string" + + # Self-references + + @hybrid_property + def hybrid_prop_self_referential(self) -> 'ShoppingCart': + return ShoppingCart(id=1) + + @hybrid_property + def hybrid_prop_self_referential_list(self) -> List['ShoppingCart']: + return [ShoppingCart(id=1)] diff --git a/graphene_sqlalchemy/tests/test_converter.py b/graphene_sqlalchemy/tests/test_converter.py index 57c43058..4b9e74ed 100644 --- a/graphene_sqlalchemy/tests/test_converter.py +++ b/graphene_sqlalchemy/tests/test_converter.py @@ -1,4 +1,5 @@ import enum +from typing import Dict, Union import pytest from sqlalchemy import Column, func, select, types @@ -9,9 +10,11 @@ from sqlalchemy_utils import ChoiceType, JSONType, ScalarListType import graphene +from graphene import Boolean, Float, Int, Scalar, String from graphene.relay import Node -from graphene.types.datetime import DateTime +from graphene.types.datetime import Date, DateTime, Time from graphene.types.json import JSONString +from graphene.types.structures import List, Structure from ..converter import (convert_sqlalchemy_column, convert_sqlalchemy_composite, @@ -20,7 +23,8 @@ default_connection_field_factory) from ..registry import Registry, get_global_registry from ..types import SQLAlchemyObjectType -from .models import Article, CompositeFullName, Pet, Reporter +from .models import (Article, CompositeFullName, Pet, Reporter, ShoppingCart, + ShoppingCartItem) def mock_resolver(): @@ -384,3 +388,86 @@ def __init__(self, col1, col2): Registry(), mock_resolver, ) + + +def test_sqlalchemy_hybrid_property_type_inference(): + class ShoppingCartItemType(SQLAlchemyObjectType): + class Meta: + model = ShoppingCartItem + interfaces = (Node,) + + class ShoppingCartType(SQLAlchemyObjectType): + class Meta: + model = ShoppingCart + interfaces = (Node,) + + ####################################################### + # Check ShoppingCartItem's Properties and Return Types + ####################################################### + + shopping_cart_item_expected_types: Dict[str, Union[Scalar, Structure]] = { + 'hybrid_prop_shopping_cart': List(ShoppingCartType) + } + + assert sorted(list(ShoppingCartItemType._meta.fields.keys())) == sorted([ + # Columns + "id", + # Append Hybrid Properties from Above + *shopping_cart_item_expected_types.keys() + ]) + + for hybrid_prop_name, hybrid_prop_expected_return_type in shopping_cart_item_expected_types.items(): + hybrid_prop_field = ShoppingCartItemType._meta.fields[hybrid_prop_name] + + # this is a simple way of showing the failed property name + # instead of having to unroll the loop. + assert ( + (hybrid_prop_name, str(hybrid_prop_field.type)) == + (hybrid_prop_name, str(hybrid_prop_expected_return_type)) + ) + assert hybrid_prop_field.description is None # "doc" is ignored by hybrid property + + ################################################### + # Check ShoppingCart's Properties and Return Types + ################################################### + + shopping_cart_expected_types: Dict[str, Union[Scalar, Structure]] = { + # Basic types + "hybrid_prop_str": String, + "hybrid_prop_int": Int, + "hybrid_prop_float": Float, + "hybrid_prop_bool": Boolean, + "hybrid_prop_decimal": String, # Decimals should be serialized Strings + "hybrid_prop_date": Date, + "hybrid_prop_time": Time, + "hybrid_prop_datetime": DateTime, + # Lists and Nested Lists + "hybrid_prop_list_int": List(Int), + "hybrid_prop_list_date": List(Date), + "hybrid_prop_nested_list_int": List(List(Int)), + "hybrid_prop_deeply_nested_list_int": List(List(List(Int))), + "hybrid_prop_first_shopping_cart_item": ShoppingCartItemType, + "hybrid_prop_shopping_cart_item_list": List(ShoppingCartItemType), + "hybrid_prop_unsupported_type_tuple": String, + # Self Referential List + "hybrid_prop_self_referential": ShoppingCartType, + "hybrid_prop_self_referential_list": List(ShoppingCartType), + } + + assert sorted(list(ShoppingCartType._meta.fields.keys())) == sorted([ + # Columns + "id", + # Append Hybrid Properties from Above + *shopping_cart_expected_types.keys() + ]) + + for hybrid_prop_name, hybrid_prop_expected_return_type in shopping_cart_expected_types.items(): + hybrid_prop_field = ShoppingCartType._meta.fields[hybrid_prop_name] + + # this is a simple way of showing the failed property name + # instead of having to unroll the loop. + assert ( + (hybrid_prop_name, str(hybrid_prop_field.type)) == + (hybrid_prop_name, str(hybrid_prop_expected_return_type)) + ) + assert hybrid_prop_field.description is None # "doc" is ignored by hybrid property diff --git a/graphene_sqlalchemy/tests/test_types.py b/graphene_sqlalchemy/tests/test_types.py index 1f15fa1a..2d660b67 100644 --- a/graphene_sqlalchemy/tests/test_types.py +++ b/graphene_sqlalchemy/tests/test_types.py @@ -4,8 +4,8 @@ import sqlalchemy.exc import sqlalchemy.orm.exc -from graphene import (Dynamic, Field, GlobalID, Int, List, Node, NonNull, - ObjectType, Schema, String) +from graphene import (Boolean, Dynamic, Field, Float, GlobalID, Int, List, + Node, NonNull, ObjectType, Schema, String) from graphene.relay import Connection from .. import utils @@ -74,7 +74,7 @@ class Meta: model = Article interfaces = (Node,) - assert list(ReporterType._meta.fields.keys()) == [ + assert sorted(list(ReporterType._meta.fields.keys())) == sorted([ # Columns "column_prop", # SQLAlchemy retuns column properties first "id", @@ -86,11 +86,16 @@ class Meta: "composite_prop", # Hybrid "hybrid_prop", + "hybrid_prop_str", + "hybrid_prop_int", + "hybrid_prop_float", + "hybrid_prop_bool", + "hybrid_prop_list", # Relationship "pets", "articles", "favorite_article", - ] + ]) # column first_name_field = ReporterType._meta.fields['first_name'] @@ -115,6 +120,36 @@ class Meta: # "doc" is ignored by hybrid_property assert hybrid_prop.description is None + # hybrid_property_str + hybrid_prop_str = ReporterType._meta.fields['hybrid_prop_str'] + assert hybrid_prop_str.type == String + # "doc" is ignored by hybrid_property + assert hybrid_prop_str.description is None + + # hybrid_property_int + hybrid_prop_int = ReporterType._meta.fields['hybrid_prop_int'] + assert hybrid_prop_int.type == Int + # "doc" is ignored by hybrid_property + assert hybrid_prop_int.description is None + + # hybrid_property_float + hybrid_prop_float = ReporterType._meta.fields['hybrid_prop_float'] + assert hybrid_prop_float.type == Float + # "doc" is ignored by hybrid_property + assert hybrid_prop_float.description is None + + # hybrid_property_bool + hybrid_prop_bool = ReporterType._meta.fields['hybrid_prop_bool'] + assert hybrid_prop_bool.type == Boolean + # "doc" is ignored by hybrid_property + assert hybrid_prop_bool.description is None + + # hybrid_property_list + hybrid_prop_list = ReporterType._meta.fields['hybrid_prop_list'] + assert hybrid_prop_list.type == List(Int) + # "doc" is ignored by hybrid_property + assert hybrid_prop_list.description is None + # relationship favorite_article_field = ReporterType._meta.fields['favorite_article'] assert isinstance(favorite_article_field, Dynamic) @@ -166,7 +201,7 @@ class Meta: interfaces = (Node,) use_connection = False - assert list(ReporterType._meta.fields.keys()) == [ + assert sorted(list(ReporterType._meta.fields.keys())) == sorted([ # Fields from ReporterMixin "first_name", "last_name", @@ -182,7 +217,12 @@ class Meta: # Then the automatic SQLAlchemy fields "id", "favorite_pet_kind", - ] + "hybrid_prop_str", + "hybrid_prop_int", + "hybrid_prop_float", + "hybrid_prop_bool", + "hybrid_prop_list", + ]) first_name_field = ReporterType._meta.fields['first_name'] assert isinstance(first_name_field.type, NonNull) @@ -271,7 +311,7 @@ class Meta: first_name = ORMField() # Takes precedence last_name = ORMField() # Noop - assert list(ReporterType._meta.fields.keys()) == [ + assert sorted(list(ReporterType._meta.fields.keys())) == sorted([ "first_name", "last_name", "column_prop", @@ -279,10 +319,15 @@ class Meta: "favorite_pet_kind", "composite_prop", "hybrid_prop", + "hybrid_prop_str", + "hybrid_prop_int", + "hybrid_prop_float", + "hybrid_prop_bool", + "hybrid_prop_list", "pets", "articles", "favorite_article", - ] + ]) def test_only_and_exclude_fields(): @@ -387,7 +432,7 @@ class Meta: assert issubclass(CustomReporterType, ObjectType) assert CustomReporterType._meta.model == Reporter - assert len(CustomReporterType._meta.fields) == 11 + assert len(CustomReporterType._meta.fields) == 16 # Test Custom SQLAlchemyObjectType with Custom Options diff --git a/graphene_sqlalchemy/utils.py b/graphene_sqlalchemy/utils.py index 340ad47e..301e782c 100644 --- a/graphene_sqlalchemy/utils.py +++ b/graphene_sqlalchemy/utils.py @@ -1,11 +1,15 @@ import re import warnings +from collections import OrderedDict +from typing import Any, Callable, Dict, Optional import pkg_resources from sqlalchemy.exc import ArgumentError from sqlalchemy.orm import class_mapper, object_mapper from sqlalchemy.orm.exc import UnmappedClassError, UnmappedInstanceError +from graphene_sqlalchemy.registry import get_global_registry + def get_session(context): return context.get("session") @@ -87,7 +91,6 @@ def _deprecated_default_symbol_name(column_name, sort_asc): def _deprecated_object_type_for_model(cls, name): - try: return _deprecated_object_type_cache[cls, name] except KeyError: @@ -152,3 +155,54 @@ def sort_argument_for_model(cls, has_default=True): def is_sqlalchemy_version_less_than(version_string): """Check the installed SQLAlchemy version""" return pkg_resources.get_distribution('SQLAlchemy').parsed_version < pkg_resources.parse_version(version_string) + + +class singledispatchbymatchfunction: + """ + Inspired by @singledispatch, this is a variant that works using a matcher function + instead of relying on the type of the first argument. + The register method can be used to register a new matcher, which is passed as the first argument: + """ + + def __init__(self, default: Callable): + self.registry: Dict[Callable, Callable] = OrderedDict() + self.default = default + + def __call__(self, *args, **kwargs): + for matcher_function, final_method in self.registry.items(): + # Register order is important. First one that matches, runs. + if matcher_function(args[0]): + return final_method(*args, **kwargs) + + # No match, using default. + return self.default(*args, **kwargs) + + def register(self, matcher_function: Callable[[Any], bool]): + + def grab_function_from_outside(f): + self.registry[matcher_function] = f + return self + + return grab_function_from_outside + + +def value_equals(value): + """A simple function that makes the equality based matcher functions for + SingleDispatchByMatchFunction prettier""" + return lambda x: x == value + + +def safe_isinstance(cls): + def safe_isinstance_checker(arg): + try: + return isinstance(arg, cls) + except TypeError: + pass + return safe_isinstance_checker + + +def registry_sqlalchemy_model_from_str(model_name: str) -> Optional[Any]: + try: + return next(filter(lambda x: x.__name__ == model_name, list(get_global_registry()._registry.keys()))) + except StopIteration: + pass From b0aa63c968b2d8880310e8ae53c03280446508b0 Mon Sep 17 00:00:00 2001 From: Cadu Date: Tue, 3 May 2022 16:23:11 -0300 Subject: [PATCH 07/12] Added suport for Optional[T] in @hybrid_property's type annotation inference. (#343) Automatic @hybrid_property type conversion now supports Optionals. --- graphene_sqlalchemy/converter.py | 12 ++++++++++++ graphene_sqlalchemy/tests/models.py | 8 +++++++- graphene_sqlalchemy/tests/test_converter.py | 2 ++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/graphene_sqlalchemy/converter.py b/graphene_sqlalchemy/converter.py index a2e03694..a9da6231 100644 --- a/graphene_sqlalchemy/converter.py +++ b/graphene_sqlalchemy/converter.py @@ -331,6 +331,18 @@ def convert_sqlalchemy_hybrid_property_type_time(arg): return Time +@convert_sqlalchemy_hybrid_property_type.register(lambda x: getattr(x, '__origin__', None) == typing.Union) +def convert_sqlalchemy_hybrid_property_type_option_t(arg): + # Option is actually Union[T, ] + + # Just get the T out of the list of arguments by filtering out the NoneType + internal_type = next(filter(lambda x: not type(None) == x, arg.__args__)) + + graphql_internal_type = convert_sqlalchemy_hybrid_property_type(internal_type) + + return graphql_internal_type + + @convert_sqlalchemy_hybrid_property_type.register(lambda x: getattr(x, '__origin__', None) in [list, typing.List]) def convert_sqlalchemy_hybrid_property_type_list_t(arg): # type is either list[T] or List[T], generic argument at __args__[0] diff --git a/graphene_sqlalchemy/tests/models.py b/graphene_sqlalchemy/tests/models.py index bda5a863..bda46e1c 100644 --- a/graphene_sqlalchemy/tests/models.py +++ b/graphene_sqlalchemy/tests/models.py @@ -3,7 +3,7 @@ import datetime import enum from decimal import Decimal -from typing import List, Tuple +from typing import List, Optional, Tuple from sqlalchemy import (Column, Date, Enum, ForeignKey, Integer, String, Table, func, select) @@ -217,3 +217,9 @@ def hybrid_prop_self_referential(self) -> 'ShoppingCart': @hybrid_property def hybrid_prop_self_referential_list(self) -> List['ShoppingCart']: return [ShoppingCart(id=1)] + + # Optional[T] + + @hybrid_property + def hybrid_prop_optional_self_referential(self) -> Optional['ShoppingCart']: + return None diff --git a/graphene_sqlalchemy/tests/test_converter.py b/graphene_sqlalchemy/tests/test_converter.py index 4b9e74ed..70e11713 100644 --- a/graphene_sqlalchemy/tests/test_converter.py +++ b/graphene_sqlalchemy/tests/test_converter.py @@ -452,6 +452,8 @@ class Meta: # Self Referential List "hybrid_prop_self_referential": ShoppingCartType, "hybrid_prop_self_referential_list": List(ShoppingCartType), + # Optionals + "hybrid_prop_optional_self_referential": ShoppingCartType, } assert sorted(list(ShoppingCartType._meta.fields.keys())) == sorted([ From a47dbb369c5e7a057affe7cb98fa15d3acf29cd8 Mon Sep 17 00:00:00 2001 From: Bryan Malyn Date: Thu, 5 May 2022 09:52:47 -0500 Subject: [PATCH 08/12] Pick up the docstrings of hybrid properties (#344) --- graphene_sqlalchemy/converter.py | 3 +++ graphene_sqlalchemy/tests/models.py | 5 +++++ graphene_sqlalchemy/tests/test_types.py | 17 ++++++++++++++++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/graphene_sqlalchemy/converter.py b/graphene_sqlalchemy/converter.py index a9da6231..5d75984b 100644 --- a/graphene_sqlalchemy/converter.py +++ b/graphene_sqlalchemy/converter.py @@ -130,6 +130,9 @@ def convert_sqlalchemy_hybrid_method(hybrid_prop, resolver, **field_kwargs): if 'type_' not in field_kwargs: field_kwargs['type_'] = convert_hybrid_property_return_type(hybrid_prop) + if 'description' not in field_kwargs: + field_kwargs['description'] = getattr(hybrid_prop, "__doc__", None) + return Field( resolver=resolver, **field_kwargs diff --git a/graphene_sqlalchemy/tests/models.py b/graphene_sqlalchemy/tests/models.py index bda46e1c..e41adb51 100644 --- a/graphene_sqlalchemy/tests/models.py +++ b/graphene_sqlalchemy/tests/models.py @@ -68,6 +68,11 @@ class Reporter(Base): articles = relationship("Article", backref="reporter") favorite_article = relationship("Article", uselist=False) + @hybrid_property + def hybrid_prop_with_doc(self): + """Docstring test""" + return self.first_name + @hybrid_property def hybrid_prop(self): return self.first_name diff --git a/graphene_sqlalchemy/tests/test_types.py b/graphene_sqlalchemy/tests/test_types.py index 2d660b67..9a2e992d 100644 --- a/graphene_sqlalchemy/tests/test_types.py +++ b/graphene_sqlalchemy/tests/test_types.py @@ -85,6 +85,7 @@ class Meta: # Composite "composite_prop", # Hybrid + "hybrid_prop_with_doc", "hybrid_prop", "hybrid_prop_str", "hybrid_prop_int", @@ -150,6 +151,12 @@ class Meta: # "doc" is ignored by hybrid_property assert hybrid_prop_list.description is None + # hybrid_prop_with_doc + hybrid_prop_with_doc = ReporterType._meta.fields['hybrid_prop_with_doc'] + assert hybrid_prop_with_doc.type == String + # docstring is picked up from hybrid_prop_with_doc + assert hybrid_prop_with_doc.description == "Docstring test" + # relationship favorite_article_field = ReporterType._meta.fields['favorite_article'] assert isinstance(favorite_article_field, Dynamic) @@ -183,6 +190,7 @@ class Meta: composite_prop = ORMField() # hybrid_property + hybrid_prop_with_doc = ORMField(description='Overridden') hybrid_prop = ORMField(description='Overridden') # relationships @@ -210,6 +218,7 @@ class Meta: "email_v2", "column_prop", "composite_prop", + "hybrid_prop_with_doc", "hybrid_prop", "favorite_article", "articles", @@ -250,6 +259,11 @@ class Meta: assert hybrid_prop_field.description == "Overridden" assert hybrid_prop_field.deprecation_reason is None + hybrid_prop_with_doc_field = ReporterType._meta.fields['hybrid_prop_with_doc'] + assert hybrid_prop_with_doc_field.type == String + assert hybrid_prop_with_doc_field.description == "Overridden" + assert hybrid_prop_with_doc_field.deprecation_reason is None + column_prop_field_v2 = ReporterType._meta.fields['column_prop'] assert column_prop_field_v2.type == String assert column_prop_field_v2.description is None @@ -318,6 +332,7 @@ class Meta: "email", "favorite_pet_kind", "composite_prop", + "hybrid_prop_with_doc", "hybrid_prop", "hybrid_prop_str", "hybrid_prop_int", @@ -432,7 +447,7 @@ class Meta: assert issubclass(CustomReporterType, ObjectType) assert CustomReporterType._meta.model == Reporter - assert len(CustomReporterType._meta.fields) == 16 + assert len(CustomReporterType._meta.fields) == 17 # Test Custom SQLAlchemyObjectType with Custom Options From 294d529711d4c3360b14e0a6bcb8a484fdb19704 Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Thu, 2 Jun 2022 11:08:19 +0200 Subject: [PATCH 09/12] Update README.md --- README.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 04692973..68719f4d 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,14 @@ -Please read [UPGRADE-v2.0.md](https://github.com/graphql-python/graphene/blob/master/UPGRADE-v2.0.md) -to learn how to upgrade to Graphene `2.0`. +Version 3.0 is in beta stage. Please read https://github.com/graphql-python/graphene-sqlalchemy/issues/348 to learn about progress and changes in upcoming +beta releases. --- -# ![Graphene Logo](http://graphene-python.org/favicon.png) Graphene-SQLAlchemy [![Build Status](https://travis-ci.org/graphql-python/graphene-sqlalchemy.svg?branch=master)](https://travis-ci.org/graphql-python/graphene-sqlalchemy) [![PyPI version](https://badge.fury.io/py/graphene-sqlalchemy.svg)](https://badge.fury.io/py/graphene-sqlalchemy) [![Coverage Status](https://coveralls.io/repos/graphql-python/graphene-sqlalchemy/badge.svg?branch=master&service=github)](https://coveralls.io/github/graphql-python/graphene-sqlalchemy?branch=master) +# ![Graphene Logo](http://graphene-python.org/favicon.png) Graphene-SQLAlchemy +[![Build Status](https://github.com/graphql-python/graphene-sqlalchemy/workflows/Tests/badge.svg)](https://github.com/graphql-python/graphene-sqlalchemy/actions) +[![PyPI version](https://badge.fury.io/py/graphene-sqlalchemy.svg)](https://badge.fury.io/py/graphene-sqlalchemy) +![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/graphql-python/graphene-sqlalchemy?color=green&include_prereleases&label=latest) +[![codecov](https://codecov.io/gh/graphql-python/graphene-sqlalchemy/branch/master/graph/badge.svg?token=Zi5S1TikeN)](https://codecov.io/gh/graphql-python/graphene-sqlalchemy) + A [SQLAlchemy](http://www.sqlalchemy.org/) integration for [Graphene](http://graphene-python.org/). @@ -13,7 +18,7 @@ A [SQLAlchemy](http://www.sqlalchemy.org/) integration for [Graphene](http://gra For installing Graphene, just run this command in your shell. ```bash -pip install "graphene-sqlalchemy>=2.0" +pip install "graphene-sqlalchemy>=3" ``` ## Examples From f16d434b716b5602f1406b4fd0e2309bbe6f1fa4 Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Fri, 3 Jun 2022 12:27:52 +0200 Subject: [PATCH 10/12] Add Python 3.10 & Update Build Scripts (#352) This PR drops tests for Python 3.6 and updates the build scripts. --- .flake8 | 4 +++ .github/workflows/deploy.yml | 8 ++--- .github/workflows/lint.yml | 8 ++--- .github/workflows/tests.yml | 14 ++++---- .pre-commit-config.yaml | 33 ++++++++---------- graphene_sqlalchemy/__init__.py | 2 +- graphene_sqlalchemy/converter.py | 3 +- graphene_sqlalchemy/types.py | 60 ++++++++++++++++---------------- graphene_sqlalchemy/utils.py | 1 + setup.py | 7 ++-- tox.ini | 8 ++--- 11 files changed, 76 insertions(+), 72 deletions(-) create mode 100644 .flake8 diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..30f6dedd --- /dev/null +++ b/.flake8 @@ -0,0 +1,4 @@ +[flake8] +ignore = E203,W503 +exclude = .git,.mypy_cache,.pytest_cache,.tox,.venv,__pycache__,build,dist,docs +max-line-length = 120 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1ae7b4b6..9cc136a1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,11 +10,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.9 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 with: - python-version: 3.9 + python-version: '3.10' - name: Build wheel and source tarball run: | pip install wheel diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 559326c4..9352dbe5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -7,11 +7,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.9 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 with: - python-version: 3.9 + python-version: '3.10' - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a9a3bd5d..de78190d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,12 +9,12 @@ jobs: max-parallel: 10 matrix: sql-alchemy: ["1.2", "1.3", "1.4"] - python-version: ["3.6", "3.7", "3.8", "3.9"] + python-version: ["3.7", "3.8", "3.9", "3.10"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -27,12 +27,12 @@ jobs: SQLALCHEMY: ${{ matrix.sql-alchemy }} TOXENV: ${{ matrix.toxenv }} - name: Upload coverage.xml - if: ${{ matrix.sql-alchemy == '1.4' && matrix.python-version == '3.9' }} - uses: actions/upload-artifact@v2 + if: ${{ matrix.sql-alchemy == '1.4' && matrix.python-version == '3.10' }} + uses: actions/upload-artifact@v3 with: name: graphene-sqlalchemy-coverage path: coverage.xml if-no-files-found: error - name: Upload coverage.xml to codecov - if: ${{ matrix.sql-alchemy == '1.4' && matrix.python-version == '3.9' }} - uses: codecov/codecov-action@v1 + if: ${{ matrix.sql-alchemy == '1.4' && matrix.python-version == '3.10' }} + uses: codecov/codecov-action@v3 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1c67ab03..66db3814 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,25 +1,22 @@ default_language_version: - python: python3.7 + python: python3.10 repos: -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: c8bad492e1b1d65d9126dba3fe3bd49a5a52b9d6 # v2.1.0 + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.2.0 hooks: - - id: check-merge-conflict - - id: check-yaml - - id: debug-statements - - id: end-of-file-fixer + - id: check-merge-conflict + - id: check-yaml + - id: debug-statements + - id: end-of-file-fixer exclude: ^docs/.*$ - - id: trailing-whitespace + - id: trailing-whitespace exclude: README.md -- repo: https://github.com/PyCQA/flake8 - rev: 88caf5ac484f5c09aedc02167c59c66ff0af0068 # 3.7.7 + - repo: https://github.com/pycqa/isort + rev: 5.10.1 hooks: - - id: flake8 -- repo: https://github.com/asottile/seed-isort-config - rev: v1.7.0 + - id: isort + name: isort (python) + - repo: https://github.com/PyCQA/flake8 + rev: 4.0.0 hooks: - - id: seed-isort-config -- repo: https://github.com/pre-commit/mirrors-isort - rev: v4.3.4 - hooks: - - id: isort + - id: flake8 diff --git a/graphene_sqlalchemy/__init__.py b/graphene_sqlalchemy/__init__.py index 060bd13b..18d34f1d 100644 --- a/graphene_sqlalchemy/__init__.py +++ b/graphene_sqlalchemy/__init__.py @@ -1,5 +1,5 @@ -from .types import SQLAlchemyObjectType from .fields import SQLAlchemyConnectionField +from .types import SQLAlchemyObjectType from .utils import get_query, get_session __version__ = "3.0.0b1" diff --git a/graphene_sqlalchemy/converter.py b/graphene_sqlalchemy/converter.py index 5d75984b..60e14ddd 100644 --- a/graphene_sqlalchemy/converter.py +++ b/graphene_sqlalchemy/converter.py @@ -29,7 +29,8 @@ from typing import _ForwardRef as ForwardRef try: - from sqlalchemy_utils import ChoiceType, JSONType, ScalarListType, TSVectorType + from sqlalchemy_utils import (ChoiceType, JSONType, ScalarListType, + TSVectorType) except ImportError: ChoiceType = JSONType = ScalarListType = TSVectorType = object diff --git a/graphene_sqlalchemy/types.py b/graphene_sqlalchemy/types.py index ac69b697..e6c3d14c 100644 --- a/graphene_sqlalchemy/types.py +++ b/graphene_sqlalchemy/types.py @@ -25,15 +25,15 @@ class ORMField(OrderedType): def __init__( - self, - model_attr=None, - type_=None, - required=None, - description=None, - deprecation_reason=None, - batching=None, - _creation_counter=None, - **field_kwargs + self, + model_attr=None, + type_=None, + required=None, + description=None, + deprecation_reason=None, + batching=None, + _creation_counter=None, + **field_kwargs ): """ Use this to override fields automatically generated by SQLAlchemyObjectType. @@ -89,7 +89,7 @@ class Meta: def construct_fields( - obj_type, model, registry, only_fields, exclude_fields, batching, connection_field_factory + obj_type, model, registry, only_fields, exclude_fields, batching, connection_field_factory ): """ Construct all the fields for a SQLAlchemyObjectType. @@ -110,11 +110,11 @@ def construct_fields( inspected_model = sqlalchemy.inspect(model) # Gather all the relevant attributes from the SQLAlchemy model in order all_model_attrs = OrderedDict( - inspected_model.column_attrs.items() + - inspected_model.composites.items() + - [(name, item) for name, item in inspected_model.all_orm_descriptors.items() - if isinstance(item, hybrid_property)] + - inspected_model.relationships.items() + inspected_model.column_attrs.items() + + inspected_model.composites.items() + + [(name, item) for name, item in inspected_model.all_orm_descriptors.items() + if isinstance(item, hybrid_property)] + + inspected_model.relationships.items() ) # Filter out excluded fields @@ -191,21 +191,21 @@ class SQLAlchemyObjectTypeOptions(ObjectTypeOptions): class SQLAlchemyObjectType(ObjectType): @classmethod def __init_subclass_with_meta__( - cls, - model=None, - registry=None, - skip_registry=False, - only_fields=(), - exclude_fields=(), - connection=None, - connection_class=None, - use_connection=None, - interfaces=(), - id=None, - batching=False, - connection_field_factory=None, - _meta=None, - **options + cls, + model=None, + registry=None, + skip_registry=False, + only_fields=(), + exclude_fields=(), + connection=None, + connection_class=None, + use_connection=None, + interfaces=(), + id=None, + batching=False, + connection_field_factory=None, + _meta=None, + **options ): # Make sure model is a valid SQLAlchemy model if not is_mapped_class(model): diff --git a/graphene_sqlalchemy/utils.py b/graphene_sqlalchemy/utils.py index 301e782c..084f9b86 100644 --- a/graphene_sqlalchemy/utils.py +++ b/graphene_sqlalchemy/utils.py @@ -140,6 +140,7 @@ def sort_argument_for_model(cls, has_default=True): ) from graphene import Argument, List + from .enums import sort_enum_for_object_type enum = sort_enum_for_object_type( diff --git a/setup.py b/setup.py index da49f1d4..ac9ad7e6 100644 --- a/setup.py +++ b/setup.py @@ -41,9 +41,10 @@ "Intended Audience :: Developers", "Topic :: Software Development :: Libraries", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Programming Language :: Python :: Implementation :: PyPy", ], keywords="api graphql protocol rest relay graphene", @@ -52,8 +53,8 @@ extras_require={ "dev": [ "tox==3.7.0", # Should be kept in sync with tox.ini - "pre-commit==1.14.4", - "flake8==3.7.9", + "pre-commit==2.19", + "flake8==4.0.0", ], "test": tests_require, }, diff --git a/tox.ini b/tox.ini index b8ce0618..2802dee0 100644 --- a/tox.ini +++ b/tox.ini @@ -1,14 +1,14 @@ [tox] -envlist = pre-commit,py{36,37,38,39}-sql{12,13,14} +envlist = pre-commit,py{37,38,39,310}-sql{12,13,14} skipsdist = true minversion = 3.7.0 [gh-actions] python = - 3.6: py36 3.7: py37 3.8: py38 3.9: py39 + 3.10: py310 [gh-actions:env] SQLALCHEMY = @@ -27,14 +27,14 @@ commands = pytest graphene_sqlalchemy --cov=graphene_sqlalchemy --cov-report=term --cov-report=xml {posargs} [testenv:pre-commit] -basepython=python3.9 +basepython=python3.10 deps = .[dev] commands = pre-commit {posargs:run --all-files} [testenv:flake8] -basepython = python3.9 +basepython = python3.10 deps = -e.[dev] commands = flake8 --exclude setup.py,docs,examples,tests,.tox --max-line-length 120 From a70256962f57cb4fd4bd2d72ec87e59703fd6e74 Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Fri, 15 Jul 2022 10:40:37 +0200 Subject: [PATCH 11/12] Native support for additional Type Converters (#353) * Fields generated from Hybrid Properties & Type hints now support Unions (Union[ObjectType1,OT2] or ObjectType1 | OT2) * Support for Variant and types.JSON Columns * BREAKING: Date&Time now convert to their corresponding graphene scalars instead of String. * BREAKING: PG UUID & sqlalchemy_utils.UUIDType now convert to graphene.UUID instead of graphene.String * Change: Sort Enums & ChoiceType enums are now generated from Column.key instead of Column.name, see #330 Signed-off-by: Erik Wrede Co-authored-by: Nicolas Delaby Co-authored-by: davidcim Co-authored-by: Viktor Pegy Co-authored-by: Ian Epperson --- graphene_sqlalchemy/converter.py | 204 +++++++++++----- graphene_sqlalchemy/enums.py | 4 +- graphene_sqlalchemy/registry.py | 38 ++- graphene_sqlalchemy/tests/models.py | 10 +- graphene_sqlalchemy/tests/test_converter.py | 243 +++++++++++++++---- graphene_sqlalchemy/tests/test_registry.py | 56 ++++- graphene_sqlalchemy/tests/test_sort_enums.py | 25 +- graphene_sqlalchemy/tests/test_types.py | 2 +- graphene_sqlalchemy/tests/test_utils.py | 8 +- graphene_sqlalchemy/utils.py | 9 +- 10 files changed, 468 insertions(+), 131 deletions(-) diff --git a/graphene_sqlalchemy/converter.py b/graphene_sqlalchemy/converter.py index 60e14ddd..1e7846eb 100644 --- a/graphene_sqlalchemy/converter.py +++ b/graphene_sqlalchemy/converter.py @@ -1,16 +1,16 @@ import datetime +import sys import typing import warnings from decimal import Decimal from functools import singledispatch -from typing import Any +from typing import Any, cast -from sqlalchemy import types +from sqlalchemy import types as sqa_types from sqlalchemy.dialects import postgresql from sqlalchemy.orm import interfaces, strategies -from graphene import (ID, Boolean, Date, DateTime, Dynamic, Enum, Field, Float, - Int, List, String, Time) +import graphene from graphene.types.json import JSONString from .batching import get_batch_resolver @@ -19,8 +19,9 @@ default_connection_field_factory) from .registry import get_global_registry from .resolvers import get_attr_resolver, get_custom_resolver -from .utils import (registry_sqlalchemy_model_from_str, safe_isinstance, - singledispatchbymatchfunction, value_equals) +from .utils import (DummyImport, registry_sqlalchemy_model_from_str, + safe_isinstance, singledispatchbymatchfunction, + value_equals) try: from typing import ForwardRef @@ -29,15 +30,14 @@ from typing import _ForwardRef as ForwardRef try: - from sqlalchemy_utils import (ChoiceType, JSONType, ScalarListType, - TSVectorType) + from sqlalchemy_utils.types.choice import EnumTypeImpl except ImportError: - ChoiceType = JSONType = ScalarListType = TSVectorType = object + EnumTypeImpl = object try: - from sqlalchemy_utils.types.choice import EnumTypeImpl + import sqlalchemy_utils as sqa_utils except ImportError: - EnumTypeImpl = object + sqa_utils = DummyImport() is_selectin_available = getattr(strategies, 'SelectInLoader', None) @@ -79,7 +79,7 @@ def dynamic_type(): return _convert_o2m_or_m2m_relationship(relationship_prop, obj_type, batching_, connection_field_factory, **field_kwargs) - return Dynamic(dynamic_type) + return graphene.Dynamic(dynamic_type) def _convert_o2o_or_m2o_relationship(relationship_prop, obj_type, batching, orm_field_name, **field_kwargs): @@ -100,7 +100,7 @@ def _convert_o2o_or_m2o_relationship(relationship_prop, obj_type, batching, orm_ resolver = get_batch_resolver(relationship_prop) if batching else \ get_attr_resolver(obj_type, relationship_prop.key) - return Field(child_type, resolver=resolver, **field_kwargs) + return graphene.Field(child_type, resolver=resolver, **field_kwargs) def _convert_o2m_or_m2m_relationship(relationship_prop, obj_type, batching, connection_field_factory, **field_kwargs): @@ -117,7 +117,7 @@ def _convert_o2m_or_m2m_relationship(relationship_prop, obj_type, batching, conn child_type = obj_type._meta.registry.get_type_for_model(relationship_prop.mapper.entity) if not child_type._meta.connection: - return Field(List(child_type), **field_kwargs) + return graphene.Field(graphene.List(child_type), **field_kwargs) # TODO Allow override of connection_field_factory and resolver via ORMField if connection_field_factory is None: @@ -134,7 +134,7 @@ def convert_sqlalchemy_hybrid_method(hybrid_prop, resolver, **field_kwargs): if 'description' not in field_kwargs: field_kwargs['description'] = getattr(hybrid_prop, "__doc__", None) - return Field( + return graphene.Field( resolver=resolver, **field_kwargs ) @@ -181,7 +181,7 @@ def convert_sqlalchemy_column(column_prop, registry, resolver, **field_kwargs): field_kwargs.setdefault('required', not is_column_nullable(column)) field_kwargs.setdefault('description', get_column_doc(column)) - return Field( + return graphene.Field( resolver=resolver, **field_kwargs ) @@ -195,75 +195,90 @@ def convert_sqlalchemy_type(type, column, registry=None): ) -@convert_sqlalchemy_type.register(types.Date) -@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(postgresql.UUID) +@convert_sqlalchemy_type.register(sqa_types.String) +@convert_sqlalchemy_type.register(sqa_types.Text) +@convert_sqlalchemy_type.register(sqa_types.Unicode) +@convert_sqlalchemy_type.register(sqa_types.UnicodeText) @convert_sqlalchemy_type.register(postgresql.INET) @convert_sqlalchemy_type.register(postgresql.CIDR) -@convert_sqlalchemy_type.register(TSVectorType) +@convert_sqlalchemy_type.register(sqa_utils.TSVectorType) +@convert_sqlalchemy_type.register(sqa_utils.EmailType) +@convert_sqlalchemy_type.register(sqa_utils.URLType) +@convert_sqlalchemy_type.register(sqa_utils.IPAddressType) def convert_column_to_string(type, column, registry=None): - return String + return graphene.String + + +@convert_sqlalchemy_type.register(postgresql.UUID) +@convert_sqlalchemy_type.register(sqa_utils.UUIDType) +def convert_column_to_uuid(type, column, registry=None): + return graphene.UUID -@convert_sqlalchemy_type.register(types.DateTime) +@convert_sqlalchemy_type.register(sqa_types.DateTime) def convert_column_to_datetime(type, column, registry=None): - from graphene.types.datetime import DateTime - return DateTime + return graphene.DateTime -@convert_sqlalchemy_type.register(types.SmallInteger) -@convert_sqlalchemy_type.register(types.Integer) +@convert_sqlalchemy_type.register(sqa_types.Time) +def convert_column_to_time(type, column, registry=None): + return graphene.Time + + +@convert_sqlalchemy_type.register(sqa_types.Date) +def convert_column_to_date(type, column, registry=None): + return graphene.Date + + +@convert_sqlalchemy_type.register(sqa_types.SmallInteger) +@convert_sqlalchemy_type.register(sqa_types.Integer) def convert_column_to_int_or_id(type, column, registry=None): - return ID if column.primary_key else Int + return graphene.ID if column.primary_key else graphene.Int -@convert_sqlalchemy_type.register(types.Boolean) +@convert_sqlalchemy_type.register(sqa_types.Boolean) def convert_column_to_boolean(type, column, registry=None): - return Boolean + return graphene.Boolean -@convert_sqlalchemy_type.register(types.Float) -@convert_sqlalchemy_type.register(types.Numeric) -@convert_sqlalchemy_type.register(types.BigInteger) +@convert_sqlalchemy_type.register(sqa_types.Float) +@convert_sqlalchemy_type.register(sqa_types.Numeric) +@convert_sqlalchemy_type.register(sqa_types.BigInteger) def convert_column_to_float(type, column, registry=None): - return Float + return graphene.Float -@convert_sqlalchemy_type.register(types.Enum) +@convert_sqlalchemy_type.register(sqa_types.Enum) def convert_enum_to_enum(type, column, registry=None): return lambda: enum_for_sa_enum(type, registry or get_global_registry()) # TODO Make ChoiceType conversion consistent with other enums -@convert_sqlalchemy_type.register(ChoiceType) +@convert_sqlalchemy_type.register(sqa_utils.ChoiceType) def convert_choice_to_enum(type, column, registry=None): - name = "{}_{}".format(column.table.name, column.name).upper() + name = "{}_{}".format(column.table.name, column.key).upper() if isinstance(type.type_impl, EnumTypeImpl): # type.choices may be Enum/IntEnum, in ChoiceType both presented as EnumMeta # do not use from_enum here because we can have more than one enum column in table - return Enum(name, list((v.name, v.value) for v in type.choices)) + return graphene.Enum(name, list((v.name, v.value) for v in type.choices)) else: - return Enum(name, type.choices) + return graphene.Enum(name, type.choices) -@convert_sqlalchemy_type.register(ScalarListType) +@convert_sqlalchemy_type.register(sqa_utils.ScalarListType) def convert_scalar_list_to_list(type, column, registry=None): - return List(String) + return graphene.List(graphene.String) def init_array_list_recursive(inner_type, n): - return inner_type if n == 0 else List(init_array_list_recursive(inner_type, n - 1)) + return inner_type if n == 0 else graphene.List(init_array_list_recursive(inner_type, n - 1)) -@convert_sqlalchemy_type.register(types.ARRAY) +@convert_sqlalchemy_type.register(sqa_types.ARRAY) @convert_sqlalchemy_type.register(postgresql.ARRAY) def convert_array_to_list(_type, column, registry=None): inner_type = convert_sqlalchemy_type(column.type.item_type, column) - return List(init_array_list_recursive(inner_type, (column.type.dimensions or 1) - 1)) + return graphene.List(init_array_list_recursive(inner_type, (column.type.dimensions or 1) - 1)) @convert_sqlalchemy_type.register(postgresql.HSTORE) @@ -273,38 +288,50 @@ def convert_json_to_string(type, column, registry=None): return JSONString -@convert_sqlalchemy_type.register(JSONType) +@convert_sqlalchemy_type.register(sqa_utils.JSONType) +@convert_sqlalchemy_type.register(sqa_types.JSON) def convert_json_type_to_string(type, column, registry=None): return JSONString +@convert_sqlalchemy_type.register(sqa_types.Variant) +def convert_variant_to_impl_type(type, column, registry=None): + return convert_sqlalchemy_type(type.impl, column, registry=registry) + + @singledispatchbymatchfunction def convert_sqlalchemy_hybrid_property_type(arg: Any): existing_graphql_type = get_global_registry().get_type_for_model(arg) if existing_graphql_type: return existing_graphql_type + if isinstance(arg, type(graphene.ObjectType)): + return arg + + if isinstance(arg, type(graphene.Scalar)): + return arg + # No valid type found, warn and fall back to graphene.String warnings.warn( (f"I don't know how to generate a GraphQL type out of a \"{arg}\" type." "Falling back to \"graphene.String\"") ) - return String + return graphene.String @convert_sqlalchemy_hybrid_property_type.register(value_equals(str)) def convert_sqlalchemy_hybrid_property_type_str(arg): - return String + return graphene.String @convert_sqlalchemy_hybrid_property_type.register(value_equals(int)) def convert_sqlalchemy_hybrid_property_type_int(arg): - return Int + return graphene.Int @convert_sqlalchemy_hybrid_property_type.register(value_equals(float)) def convert_sqlalchemy_hybrid_property_type_float(arg): - return Float + return graphene.Float @convert_sqlalchemy_hybrid_property_type.register(value_equals(Decimal)) @@ -312,39 +339,85 @@ def convert_sqlalchemy_hybrid_property_type_decimal(arg): # The reason Decimal should be serialized as a String is because this is a # base10 type used in things like money, and string allows it to not # lose precision (which would happen if we downcasted to a Float, for example) - return String + return graphene.String @convert_sqlalchemy_hybrid_property_type.register(value_equals(bool)) def convert_sqlalchemy_hybrid_property_type_bool(arg): - return Boolean + return graphene.Boolean @convert_sqlalchemy_hybrid_property_type.register(value_equals(datetime.datetime)) def convert_sqlalchemy_hybrid_property_type_datetime(arg): - return DateTime + return graphene.DateTime @convert_sqlalchemy_hybrid_property_type.register(value_equals(datetime.date)) def convert_sqlalchemy_hybrid_property_type_date(arg): - return Date + return graphene.Date @convert_sqlalchemy_hybrid_property_type.register(value_equals(datetime.time)) def convert_sqlalchemy_hybrid_property_type_time(arg): - return Time + return graphene.Time -@convert_sqlalchemy_hybrid_property_type.register(lambda x: getattr(x, '__origin__', None) == typing.Union) -def convert_sqlalchemy_hybrid_property_type_option_t(arg): - # Option is actually Union[T, ] +def is_union(arg) -> bool: + if sys.version_info >= (3, 10): + from types import UnionType + + if isinstance(arg, UnionType): + return True + return getattr(arg, '__origin__', None) == typing.Union + + +def graphene_union_for_py_union(obj_types: typing.List[graphene.ObjectType], registry) -> graphene.Union: + union_type = registry.get_union_for_object_types(obj_types) + + if union_type is None: + # Union Name is name of the three + union_name = ''.join(sorted([obj_type._meta.name for obj_type in obj_types])) + union_type = graphene.Union(union_name, obj_types) + registry.register_union_type(union_type, obj_types) + + return union_type + + +@convert_sqlalchemy_hybrid_property_type.register(is_union) +def convert_sqlalchemy_hybrid_property_union(arg): + """ + Converts Unions (Union[X,Y], or X | Y for python > 3.10) to the corresponding graphene schema object. + Since Optionals are internally represented as Union[T, ], they are handled here as well. + + The GQL Spec currently only allows for ObjectType unions: + GraphQL Unions represent an object that could be one of a list of GraphQL Object types, but provides for no + guaranteed fields between those types. + That's why we have to check for the nested types to be instances of graphene.ObjectType, except for the union case. + + type(x) == _types.UnionType is necessary to support X | Y notation, but might break in future python releases. + """ + from .registry import get_global_registry + # Option is actually Union[T, ] # Just get the T out of the list of arguments by filtering out the NoneType - internal_type = next(filter(lambda x: not type(None) == x, arg.__args__)) + nested_types = list(filter(lambda x: not type(None) == x, arg.__args__)) - graphql_internal_type = convert_sqlalchemy_hybrid_property_type(internal_type) + # Map the graphene types to the nested types. + # We use convert_sqlalchemy_hybrid_property_type instead of the registry to account for ForwardRefs, Lists,... + graphene_types = list(map(convert_sqlalchemy_hybrid_property_type, nested_types)) + + # If only one type is left after filtering out NoneType, the Union was an Optional + if len(graphene_types) == 1: + return graphene_types[0] + + # Now check if every type is instance of an ObjectType + if not all(isinstance(graphene_type, type(graphene.ObjectType)) for graphene_type in graphene_types): + raise ValueError("Cannot convert hybrid_property Union to graphene.Union: the Union contains scalars. " + "Please add the corresponding hybrid_property to the excluded fields in the ObjectType, " + "or use an ORMField to override this behaviour.") - return graphql_internal_type + return graphene_union_for_py_union(cast(typing.List[graphene.ObjectType], list(graphene_types)), + get_global_registry()) @convert_sqlalchemy_hybrid_property_type.register(lambda x: getattr(x, '__origin__', None) in [list, typing.List]) @@ -354,7 +427,7 @@ def convert_sqlalchemy_hybrid_property_type_list_t(arg): graphql_internal_type = convert_sqlalchemy_hybrid_property_type(internal_type) - return List(graphql_internal_type) + return graphene.List(graphql_internal_type) @convert_sqlalchemy_hybrid_property_type.register(safe_isinstance(ForwardRef)) @@ -363,11 +436,12 @@ def convert_sqlalchemy_hybrid_property_forwardref(arg): Generate a lambda that will resolve the type at runtime This takes care of self-references """ + from .registry import get_global_registry def forward_reference_solver(): model = registry_sqlalchemy_model_from_str(arg.__forward_arg__) if not model: - return String + return graphene.String # Always fall back to string if no ForwardRef type found. return get_global_registry().get_type_for_model(model) diff --git a/graphene_sqlalchemy/enums.py b/graphene_sqlalchemy/enums.py index f100be19..a2ed17ad 100644 --- a/graphene_sqlalchemy/enums.py +++ b/graphene_sqlalchemy/enums.py @@ -144,9 +144,9 @@ def sort_enum_for_object_type( column = orm_field.columns[0] if only_indexed and not (column.primary_key or column.index): continue - asc_name = get_name(column.name, True) + asc_name = get_name(column.key, True) asc_value = EnumValue(asc_name, column.asc()) - desc_name = get_name(column.name, False) + desc_name = get_name(column.key, False) desc_value = EnumValue(desc_name, column.desc()) if column.primary_key: default.append(asc_value) diff --git a/graphene_sqlalchemy/registry.py b/graphene_sqlalchemy/registry.py index acfa744b..80470d9b 100644 --- a/graphene_sqlalchemy/registry.py +++ b/graphene_sqlalchemy/registry.py @@ -1,7 +1,9 @@ from collections import defaultdict +from typing import List, Type from sqlalchemy.types import Enum as SQLAlchemyEnumType +import graphene from graphene import Enum @@ -13,12 +15,13 @@ def __init__(self): self._registry_composites = {} self._registry_enums = {} self._registry_sort_enums = {} + self._registry_unions = {} def register(self, obj_type): - from .types import SQLAlchemyObjectType + from .types import SQLAlchemyObjectType if not isinstance(obj_type, type) or not issubclass( - obj_type, SQLAlchemyObjectType + obj_type, SQLAlchemyObjectType ): raise TypeError( "Expected SQLAlchemyObjectType, but got: {!r}".format(obj_type) @@ -37,7 +40,7 @@ def register_orm_field(self, obj_type, field_name, orm_field): from .types import SQLAlchemyObjectType if not isinstance(obj_type, type) or not issubclass( - obj_type, SQLAlchemyObjectType + obj_type, SQLAlchemyObjectType ): raise TypeError( "Expected SQLAlchemyObjectType, but got: {!r}".format(obj_type) @@ -55,7 +58,7 @@ def register_composite_converter(self, composite, converter): def get_converter_for_composite(self, composite): return self._registry_composites.get(composite) - def register_enum(self, sa_enum, graphene_enum): + def register_enum(self, sa_enum: SQLAlchemyEnumType, graphene_enum: Enum): if not isinstance(sa_enum, SQLAlchemyEnumType): raise TypeError( "Expected SQLAlchemyEnumType, but got: {!r}".format(sa_enum) @@ -67,14 +70,14 @@ def register_enum(self, sa_enum, graphene_enum): self._registry_enums[sa_enum] = graphene_enum - def get_graphene_enum_for_sa_enum(self, sa_enum): + def get_graphene_enum_for_sa_enum(self, sa_enum: SQLAlchemyEnumType): return self._registry_enums.get(sa_enum) - def register_sort_enum(self, obj_type, sort_enum): - from .types import SQLAlchemyObjectType + def register_sort_enum(self, obj_type, sort_enum: Enum): + from .types import SQLAlchemyObjectType if not isinstance(obj_type, type) or not issubclass( - obj_type, SQLAlchemyObjectType + obj_type, SQLAlchemyObjectType ): raise TypeError( "Expected SQLAlchemyObjectType, but got: {!r}".format(obj_type) @@ -83,9 +86,26 @@ def register_sort_enum(self, obj_type, sort_enum): raise TypeError("Expected Graphene Enum, but got: {!r}".format(sort_enum)) self._registry_sort_enums[obj_type] = sort_enum - def get_sort_enum_for_object_type(self, obj_type): + def get_sort_enum_for_object_type(self, obj_type: graphene.ObjectType): return self._registry_sort_enums.get(obj_type) + def register_union_type(self, union: graphene.Union, obj_types: List[Type[graphene.ObjectType]]): + if not isinstance(union, graphene.Union): + raise TypeError( + "Expected graphene.Union, but got: {!r}".format(union) + ) + + for obj_type in obj_types: + if not isinstance(obj_type, type(graphene.ObjectType)): + raise TypeError( + "Expected Graphene ObjectType, but got: {!r}".format(obj_type) + ) + + self._registry_unions[frozenset(obj_types)] = union + + def get_union_for_object_types(self, obj_types : List[Type[graphene.ObjectType]]): + return self._registry_unions.get(frozenset(obj_types)) + registry = None diff --git a/graphene_sqlalchemy/tests/models.py b/graphene_sqlalchemy/tests/models.py index e41adb51..dc399ee0 100644 --- a/graphene_sqlalchemy/tests/models.py +++ b/graphene_sqlalchemy/tests/models.py @@ -5,8 +5,8 @@ from decimal import Decimal from typing import List, Optional, Tuple -from sqlalchemy import (Column, Date, Enum, ForeignKey, Integer, String, Table, - func, select) +from sqlalchemy import (Column, Date, Enum, ForeignKey, Integer, Numeric, + String, Table, func, select) from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import column_property, composite, mapper, relationship @@ -228,3 +228,9 @@ def hybrid_prop_self_referential_list(self) -> List['ShoppingCart']: @hybrid_property def hybrid_prop_optional_self_referential(self) -> Optional['ShoppingCart']: return None + + +class KeyedModel(Base): + __tablename__ = "test330" + id = Column(Integer(), primary_key=True) + reporter_number = Column("% reporter_number", Numeric, key="reporter_number") diff --git a/graphene_sqlalchemy/tests/test_converter.py b/graphene_sqlalchemy/tests/test_converter.py index 70e11713..a6c2b1bf 100644 --- a/graphene_sqlalchemy/tests/test_converter.py +++ b/graphene_sqlalchemy/tests/test_converter.py @@ -1,28 +1,28 @@ import enum +import sys from typing import Dict, Union import pytest +import sqlalchemy_utils as sqa_utils from sqlalchemy import Column, func, select, types from sqlalchemy.dialects import postgresql from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.inspection import inspect from sqlalchemy.orm import column_property, composite -from sqlalchemy_utils import ChoiceType, JSONType, ScalarListType import graphene -from graphene import Boolean, Float, Int, Scalar, String from graphene.relay import Node -from graphene.types.datetime import Date, DateTime, Time -from graphene.types.json import JSONString -from graphene.types.structures import List, Structure +from graphene.types.structures import Structure from ..converter import (convert_sqlalchemy_column, convert_sqlalchemy_composite, + convert_sqlalchemy_hybrid_method, convert_sqlalchemy_relationship) from ..fields import (UnsortedSQLAlchemyConnectionField, default_connection_field_factory) from ..registry import Registry, get_global_registry -from ..types import SQLAlchemyObjectType +from ..types import ORMField, SQLAlchemyObjectType from .models import (Article, CompositeFullName, Pet, Reporter, ShoppingCart, ShoppingCartItem) @@ -51,23 +51,117 @@ class Model(declarative_base()): return convert_sqlalchemy_column(column_prop, get_global_registry(), mock_resolver) -def test_should_unknown_sqlalchemy_field_raise_exception(): - re_err = "Don't know how to convert the SQLAlchemy field" - with pytest.raises(Exception, match=re_err): - # support legacy Binary type and subsequent LargeBinary - get_field(getattr(types, 'LargeBinary', types.BINARY)()) +def get_hybrid_property_type(prop_method): + class Model(declarative_base()): + __tablename__ = 'model' + id_ = Column(types.Integer, primary_key=True) + prop = prop_method + + column_prop = inspect(Model).all_orm_descriptors['prop'] + return convert_sqlalchemy_hybrid_method(column_prop, mock_resolver(), **ORMField().kwargs) + + +def test_hybrid_prop_int(): + @hybrid_property + def prop_method() -> int: + return 42 + + assert get_hybrid_property_type(prop_method).type == graphene.Int + + +@pytest.mark.skipif(sys.version_info < (3, 10), reason="|-Style Unions are unsupported in python < 3.10") +def test_hybrid_prop_scalar_union_310(): + @hybrid_property + def prop_method() -> int | str: + return "not allowed in gql schema" + + with pytest.raises(ValueError, + match=r"Cannot convert hybrid_property Union to " + r"graphene.Union: the Union contains scalars. \.*"): + get_hybrid_property_type(prop_method) + + +@pytest.mark.skipif(sys.version_info < (3, 10), reason="|-Style Unions are unsupported in python < 3.10") +def test_hybrid_prop_scalar_union_and_optional_310(): + """Checks if the use of Optionals does not interfere with non-conform scalar return types""" + + @hybrid_property + def prop_method() -> int | None: + return 42 + + assert get_hybrid_property_type(prop_method).type == graphene.Int + + +@pytest.mark.skipif(sys.version_info < (3, 10), reason="|-Style Unions are unsupported in python < 3.10") +def test_should_union_work_310(): + reg = Registry() + + class PetType(SQLAlchemyObjectType): + class Meta: + model = Pet + registry = reg + + class ShoppingCartType(SQLAlchemyObjectType): + class Meta: + model = ShoppingCartItem + registry = reg + + @hybrid_property + def prop_method() -> Union[PetType, ShoppingCartType]: + return None + + @hybrid_property + def prop_method_2() -> Union[ShoppingCartType, PetType]: + return None + + field_type_1 = get_hybrid_property_type(prop_method).type + field_type_2 = get_hybrid_property_type(prop_method_2).type + + assert isinstance(field_type_1, graphene.Union) + assert field_type_1 is field_type_2 + + # TODO verify types of the union + + +@pytest.mark.skipif(sys.version_info < (3, 10), reason="|-Style Unions are unsupported in python < 3.10") +def test_should_union_work_310(): + reg = Registry() + + class PetType(SQLAlchemyObjectType): + class Meta: + model = Pet + registry = reg + + class ShoppingCartType(SQLAlchemyObjectType): + class Meta: + model = ShoppingCartItem + registry = reg + + @hybrid_property + def prop_method() -> PetType | ShoppingCartType: + return None + @hybrid_property + def prop_method_2() -> ShoppingCartType | PetType: + return None -def test_should_date_convert_string(): - assert get_field(types.Date()).type == graphene.String + field_type_1 = get_hybrid_property_type(prop_method).type + field_type_2 = get_hybrid_property_type(prop_method_2).type + + assert isinstance(field_type_1, graphene.Union) + assert field_type_1 is field_type_2 def test_should_datetime_convert_datetime(): - assert get_field(types.DateTime()).type == DateTime + assert get_field(types.DateTime()).type == graphene.DateTime + +def test_should_time_convert_time(): + assert get_field(types.Time()).type == graphene.Time -def test_should_time_convert_string(): - assert get_field(types.Time()).type == graphene.String + +def test_should_date_convert_date(): + assert get_field(types.Date()).type == graphene.Date def test_should_string_convert_string(): @@ -86,6 +180,30 @@ def test_should_unicodetext_convert_string(): assert get_field(types.UnicodeText()).type == graphene.String +def test_should_tsvector_convert_string(): + assert get_field(sqa_utils.TSVectorType()).type == graphene.String + + +def test_should_email_convert_string(): + assert get_field(sqa_utils.EmailType()).type == graphene.String + + +def test_should_URL_convert_string(): + assert get_field(sqa_utils.URLType()).type == graphene.String + + +def test_should_IPaddress_convert_string(): + assert get_field(sqa_utils.IPAddressType()).type == graphene.String + + +def test_should_inet_convert_string(): + assert get_field(postgresql.INET()).type == graphene.String + + +def test_should_cidr_convert_string(): + assert get_field(postgresql.CIDR()).type == graphene.String + + def test_should_enum_convert_enum(): field = get_field(types.Enum(enum.Enum("TwoNumbers", ("one", "two")))) field_type = field.type() @@ -142,7 +260,7 @@ def test_should_numeric_convert_float(): def test_should_choice_convert_enum(): - field = get_field(ChoiceType([(u"es", u"Spanish"), (u"en", u"English")])) + field = get_field(sqa_utils.ChoiceType([(u"es", u"Spanish"), (u"en", u"English")])) graphene_type = field.type assert issubclass(graphene_type, graphene.Enum) assert graphene_type._meta.name == "MODEL_COLUMN" @@ -155,7 +273,7 @@ class TestEnum(enum.Enum): es = u"Spanish" en = u"English" - field = get_field(ChoiceType(TestEnum, impl=types.String())) + field = get_field(sqa_utils.ChoiceType(TestEnum, impl=types.String())) graphene_type = field.type assert issubclass(graphene_type, graphene.Enum) assert graphene_type._meta.name == "MODEL_COLUMN" @@ -163,12 +281,32 @@ class TestEnum(enum.Enum): assert graphene_type._meta.enum.__members__["en"].value == "English" +def test_choice_enum_column_key_name_issue_301(): + """ + Verifies that the sort enum name is generated from the column key instead of the name, + in case the column has an invalid enum name. See #330 + """ + + class TestEnum(enum.Enum): + es = u"Spanish" + en = u"English" + + testChoice = Column("% descuento1", sqa_utils.ChoiceType(TestEnum, impl=types.String()), key="descuento1") + field = get_field_from_column(testChoice) + + graphene_type = field.type + assert issubclass(graphene_type, graphene.Enum) + assert graphene_type._meta.name == "MODEL_DESCUENTO1" + assert graphene_type._meta.enum.__members__["es"].value == "Spanish" + assert graphene_type._meta.enum.__members__["en"].value == "English" + + def test_should_intenum_choice_convert_enum(): class TestEnum(enum.IntEnum): one = 1 two = 2 - field = get_field(ChoiceType(TestEnum, impl=types.String())) + field = get_field(sqa_utils.ChoiceType(TestEnum, impl=types.String())) graphene_type = field.type assert issubclass(graphene_type, graphene.Enum) assert graphene_type._meta.name == "MODEL_COLUMN" @@ -185,13 +323,22 @@ def test_should_columproperty_convert(): def test_should_scalar_list_convert_list(): - field = get_field(ScalarListType()) + field = get_field(sqa_utils.ScalarListType()) assert isinstance(field.type, graphene.List) assert field.type.of_type == graphene.String def test_should_jsontype_convert_jsonstring(): - assert get_field(JSONType()).type == JSONString + assert get_field(sqa_utils.JSONType()).type == graphene.JSONString + assert get_field(types.JSON).type == graphene.JSONString + + +def test_should_variant_int_convert_int(): + assert get_field(types.Variant(types.Integer(), {})).type == graphene.Int + + +def test_should_variant_string_convert_string(): + assert get_field(types.Variant(types.String(), {})).type == graphene.String def test_should_manytomany_convert_connectionorlist(): @@ -291,7 +438,11 @@ class Meta: def test_should_postgresql_uuid_convert(): - assert get_field(postgresql.UUID()).type == graphene.String + assert get_field(postgresql.UUID()).type == graphene.UUID + + +def test_should_sqlalchemy_utils_uuid_convert(): + assert get_field(sqa_utils.UUIDType()).type == graphene.UUID def test_should_postgresql_enum_convert(): @@ -405,8 +556,8 @@ class Meta: # Check ShoppingCartItem's Properties and Return Types ####################################################### - shopping_cart_item_expected_types: Dict[str, Union[Scalar, Structure]] = { - 'hybrid_prop_shopping_cart': List(ShoppingCartType) + shopping_cart_item_expected_types: Dict[str, Union[graphene.Scalar, Structure]] = { + 'hybrid_prop_shopping_cart': graphene.List(ShoppingCartType) } assert sorted(list(ShoppingCartItemType._meta.fields.keys())) == sorted([ @@ -421,9 +572,9 @@ class Meta: # this is a simple way of showing the failed property name # instead of having to unroll the loop. - assert ( - (hybrid_prop_name, str(hybrid_prop_field.type)) == - (hybrid_prop_name, str(hybrid_prop_expected_return_type)) + assert (hybrid_prop_name, str(hybrid_prop_field.type)) == ( + hybrid_prop_name, + str(hybrid_prop_expected_return_type), ) assert hybrid_prop_field.description is None # "doc" is ignored by hybrid property @@ -431,27 +582,27 @@ class Meta: # Check ShoppingCart's Properties and Return Types ################################################### - shopping_cart_expected_types: Dict[str, Union[Scalar, Structure]] = { + shopping_cart_expected_types: Dict[str, Union[graphene.Scalar, Structure]] = { # Basic types - "hybrid_prop_str": String, - "hybrid_prop_int": Int, - "hybrid_prop_float": Float, - "hybrid_prop_bool": Boolean, - "hybrid_prop_decimal": String, # Decimals should be serialized Strings - "hybrid_prop_date": Date, - "hybrid_prop_time": Time, - "hybrid_prop_datetime": DateTime, + "hybrid_prop_str": graphene.String, + "hybrid_prop_int": graphene.Int, + "hybrid_prop_float": graphene.Float, + "hybrid_prop_bool": graphene.Boolean, + "hybrid_prop_decimal": graphene.String, # Decimals should be serialized Strings + "hybrid_prop_date": graphene.Date, + "hybrid_prop_time": graphene.Time, + "hybrid_prop_datetime": graphene.DateTime, # Lists and Nested Lists - "hybrid_prop_list_int": List(Int), - "hybrid_prop_list_date": List(Date), - "hybrid_prop_nested_list_int": List(List(Int)), - "hybrid_prop_deeply_nested_list_int": List(List(List(Int))), + "hybrid_prop_list_int": graphene.List(graphene.Int), + "hybrid_prop_list_date": graphene.List(graphene.Date), + "hybrid_prop_nested_list_int": graphene.List(graphene.List(graphene.Int)), + "hybrid_prop_deeply_nested_list_int": graphene.List(graphene.List(graphene.List(graphene.Int))), "hybrid_prop_first_shopping_cart_item": ShoppingCartItemType, - "hybrid_prop_shopping_cart_item_list": List(ShoppingCartItemType), - "hybrid_prop_unsupported_type_tuple": String, + "hybrid_prop_shopping_cart_item_list": graphene.List(ShoppingCartItemType), + "hybrid_prop_unsupported_type_tuple": graphene.String, # Self Referential List "hybrid_prop_self_referential": ShoppingCartType, - "hybrid_prop_self_referential_list": List(ShoppingCartType), + "hybrid_prop_self_referential_list": graphene.List(ShoppingCartType), # Optionals "hybrid_prop_optional_self_referential": ShoppingCartType, } @@ -468,8 +619,8 @@ class Meta: # this is a simple way of showing the failed property name # instead of having to unroll the loop. - assert ( - (hybrid_prop_name, str(hybrid_prop_field.type)) == - (hybrid_prop_name, str(hybrid_prop_expected_return_type)) + assert (hybrid_prop_name, str(hybrid_prop_field.type)) == ( + hybrid_prop_name, + str(hybrid_prop_expected_return_type), ) assert hybrid_prop_field.description is None # "doc" is ignored by hybrid property diff --git a/graphene_sqlalchemy/tests/test_registry.py b/graphene_sqlalchemy/tests/test_registry.py index 0403c4f0..f451f355 100644 --- a/graphene_sqlalchemy/tests/test_registry.py +++ b/graphene_sqlalchemy/tests/test_registry.py @@ -1,12 +1,13 @@ import pytest from sqlalchemy.types import Enum as SQLAlchemyEnum +import graphene from graphene import Enum as GrapheneEnum from ..registry import Registry from ..types import SQLAlchemyObjectType from ..utils import EnumValue -from .models import Pet +from .models import Pet, Reporter def test_register_object_type(): @@ -126,3 +127,56 @@ class Meta: re_err = r"Expected Graphene Enum, but got: .*PetType.*" with pytest.raises(TypeError, match=re_err): reg.register_sort_enum(PetType, PetType) + + +def test_register_union(): + reg = Registry() + + class PetType(SQLAlchemyObjectType): + class Meta: + model = Pet + registry = reg + + class ReporterType(SQLAlchemyObjectType): + class Meta: + model = Reporter + + union_types = [PetType, ReporterType] + union = graphene.Union('ReporterPet', tuple(union_types)) + + reg.register_union_type(union, union_types) + + assert reg.get_union_for_object_types(union_types) == union + # Order should not matter + assert reg.get_union_for_object_types([ReporterType, PetType]) == union + + +def test_register_union_scalar(): + reg = Registry() + + union_types = [graphene.String, graphene.Int] + union = graphene.Union('StringInt', tuple(union_types)) + + re_err = r"Expected Graphene ObjectType, but got: .*String.*" + with pytest.raises(TypeError, match=re_err): + reg.register_union_type(union, union_types) + + +def test_register_union_incorrect_types(): + reg = Registry() + + class PetType(SQLAlchemyObjectType): + class Meta: + model = Pet + registry = reg + + class ReporterType(SQLAlchemyObjectType): + class Meta: + model = Reporter + + union_types = [PetType, ReporterType] + union = PetType + + re_err = r"Expected graphene.Union, but got: .*PetType.*" + with pytest.raises(TypeError, match=re_err): + reg.register_union_type(union, union_types) diff --git a/graphene_sqlalchemy/tests/test_sort_enums.py b/graphene_sqlalchemy/tests/test_sort_enums.py index 6291d4f8..e2510abc 100644 --- a/graphene_sqlalchemy/tests/test_sort_enums.py +++ b/graphene_sqlalchemy/tests/test_sort_enums.py @@ -7,7 +7,7 @@ from ..fields import SQLAlchemyConnectionField from ..types import SQLAlchemyObjectType from ..utils import to_type_name -from .models import Base, HairKind, Pet +from .models import Base, HairKind, KeyedModel, Pet from .test_query import to_std_dicts @@ -383,3 +383,26 @@ def makeNodes(nodeList): assert [node["node"]["name"] for node in result.data["noSort"]["edges"]] == [ node["node"]["name"] for node in result.data["noDefaultSort"]["edges"] ] + + +def test_sort_enum_from_key_issue_330(): + """ + Verifies that the sort enum name is generated from the column key instead of the name, + in case the column has an invalid enum name. See #330 + """ + + class KeyedType(SQLAlchemyObjectType): + class Meta: + model = KeyedModel + + sort_enum = KeyedType.sort_enum() + assert isinstance(sort_enum, type(Enum)) + assert sort_enum._meta.name == "KeyedTypeSortEnum" + assert list(sort_enum._meta.enum.__members__) == [ + "ID_ASC", + "ID_DESC", + "REPORTER_NUMBER_ASC", + "REPORTER_NUMBER_DESC", + ] + assert str(sort_enum.REPORTER_NUMBER_ASC.value.value) == 'test330."% reporter_number" ASC' + assert str(sort_enum.REPORTER_NUMBER_DESC.value.value) == 'test330."% reporter_number" DESC' diff --git a/graphene_sqlalchemy/tests/test_types.py b/graphene_sqlalchemy/tests/test_types.py index 9a2e992d..00e8b3af 100644 --- a/graphene_sqlalchemy/tests/test_types.py +++ b/graphene_sqlalchemy/tests/test_types.py @@ -76,7 +76,7 @@ class Meta: assert sorted(list(ReporterType._meta.fields.keys())) == sorted([ # Columns - "column_prop", # SQLAlchemy retuns column properties first + "column_prop", "id", "first_name", "last_name", diff --git a/graphene_sqlalchemy/tests/test_utils.py b/graphene_sqlalchemy/tests/test_utils.py index e13d919c..de359e05 100644 --- a/graphene_sqlalchemy/tests/test_utils.py +++ b/graphene_sqlalchemy/tests/test_utils.py @@ -3,8 +3,8 @@ from graphene import Enum, List, ObjectType, Schema, String -from ..utils import (get_session, sort_argument_for_model, sort_enum_for_model, - to_enum_value_name, to_type_name) +from ..utils import (DummyImport, get_session, sort_argument_for_model, + sort_enum_for_model, to_enum_value_name, to_type_name) from .models import Base, Editor, Pet @@ -99,3 +99,7 @@ class MultiplePK(Base): assert set(arg.default_value) == set( (MultiplePK.foo.name + "_asc", MultiplePK.bar.name + "_asc") ) + +def test_dummy_import(): + dummy_module = DummyImport() + assert dummy_module.foo == object diff --git a/graphene_sqlalchemy/utils.py b/graphene_sqlalchemy/utils.py index 084f9b86..f6ee9b62 100644 --- a/graphene_sqlalchemy/utils.py +++ b/graphene_sqlalchemy/utils.py @@ -8,8 +8,6 @@ from sqlalchemy.orm import class_mapper, object_mapper from sqlalchemy.orm.exc import UnmappedClassError, UnmappedInstanceError -from graphene_sqlalchemy.registry import get_global_registry - def get_session(context): return context.get("session") @@ -203,7 +201,14 @@ def safe_isinstance_checker(arg): def registry_sqlalchemy_model_from_str(model_name: str) -> Optional[Any]: + from graphene_sqlalchemy.registry import get_global_registry try: return next(filter(lambda x: x.__name__ == model_name, list(get_global_registry()._registry.keys()))) except StopIteration: pass + + +class DummyImport: + """The dummy module returns 'object' for a query for any member""" + def __getattr__(self, name): + return object From dfee3e9417cdb8a6ec67b5cd79ee203ce4f72ed7 Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Fri, 15 Jul 2022 10:43:41 +0200 Subject: [PATCH 12/12] Release new beta --- graphene_sqlalchemy/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_sqlalchemy/__init__.py b/graphene_sqlalchemy/__init__.py index 18d34f1d..c5400cee 100644 --- a/graphene_sqlalchemy/__init__.py +++ b/graphene_sqlalchemy/__init__.py @@ -2,7 +2,7 @@ from .types import SQLAlchemyObjectType from .utils import get_query, get_session -__version__ = "3.0.0b1" +__version__ = "3.0.0b2" __all__ = [ "__version__",