From d02f83d43433e9d5e349f0c256fbc2b162ff657f Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Wed, 25 Jan 2023 21:35:13 +0100 Subject: [PATCH 1/2] fix: support aliases on filtered model fields --- graphene_sqlalchemy/filters.py | 48 +++++++++++++++++++++++++++++----- graphene_sqlalchemy/types.py | 18 +++++++------ 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/graphene_sqlalchemy/filters.py b/graphene_sqlalchemy/filters.py index c833658..c07f3ca 100644 --- a/graphene_sqlalchemy/filters.py +++ b/graphene_sqlalchemy/filters.py @@ -1,6 +1,7 @@ import re from typing import Any, Dict, List, Tuple, Type, TypeVar, Union +from graphql import Undefined from sqlalchemy import and_, func, not_, or_ from sqlalchemy.orm import Query, aliased # , selectinload @@ -11,7 +12,6 @@ ) from graphene_sqlalchemy.utils import is_list - BaseTypeFilterSelf = TypeVar( "BaseTypeFilterSelf", Dict[str, Any], InputObjectTypeContainer ) @@ -36,14 +36,39 @@ def _get_functions_by_regex( return matching_functions +class SQLAlchemyFilterInputField(graphene.InputField): + def __init__( + self, + type_, + model_attr, + name=None, + default_value=Undefined, + deprecation_reason=None, + description=None, + required=False, + _creation_counter=None, + **extra_args, + ): + super(SQLAlchemyFilterInputField, self).__init__( + type_, + name, + default_value, + deprecation_reason, + description, + required, + _creation_counter, + **extra_args, + ) + + self.model_attr = model_attr + + class BaseTypeFilter(graphene.InputObjectType): @classmethod def __init_subclass_with_meta__( cls, filter_fields=None, model=None, _meta=None, **options ): - from graphene_sqlalchemy.converter import ( - convert_sqlalchemy_type, - ) + from graphene_sqlalchemy.converter import convert_sqlalchemy_type # Init meta options class if it doesn't exist already if not _meta: @@ -144,7 +169,8 @@ def execute_filters( # Check with a profiler is required to determine necessity input_field = cls._meta.fields[field] if isinstance(input_field, graphene.Dynamic): - field_filter_type = input_field.get_type().type + input_field = input_field.get_type() + field_filter_type = input_field.type else: field_filter_type = cls._meta.fields[field].type # raise Exception @@ -161,7 +187,9 @@ def execute_filters( ) clauses.extend(_clauses) else: - model_field = getattr(model, field) + model_field = getattr( + model, input_field.model_attr + ) # getattr(model, field) if issubclass(field_filter_type, BaseTypeFilter): # Get the model to join on the Filter Query joined_model = field_filter_type._meta.model @@ -196,6 +224,10 @@ def execute_filters( ) clauses.extend(_clauses) elif issubclass(field_filter_type, FieldFilter): + print("got", model_field) + print(repr(model_field)) + print(model_field == 1) + print("with input", field_filters) query, _clauses = field_filter_type.execute_filters( query, model_field, field_filters ) @@ -241,7 +273,9 @@ def __init_subclass_with_meta__(cls, graphene_type=None, _meta=None, **options): ), "Each filter method must have a value field with valid type annotations" # If type is generic, replace with actual type of filter class replace_type_vars = {ScalarFilterInputType: _meta.graphene_type} - field_type = convert_sqlalchemy_type(_annotations.get("val", str), replace_type_vars=replace_type_vars) + field_type = convert_sqlalchemy_type( + _annotations.get("val", str), replace_type_vars=replace_type_vars + ) new_filter_fields.update({field_name: graphene.InputField(field_type)}) # Add all fields to the meta options. graphene.InputbjectType will take care of the rest diff --git a/graphene_sqlalchemy/types.py b/graphene_sqlalchemy/types.py index 0fb59dd..7f64aab 100644 --- a/graphene_sqlalchemy/types.py +++ b/graphene_sqlalchemy/types.py @@ -37,6 +37,7 @@ IdFilter, IntFilter, RelationshipFilter, + SQLAlchemyFilterInputField, StringFilter, ) from .registry import Registry, get_global_registry @@ -154,13 +155,14 @@ def filter_field_from_type_field( field: Union[graphene.Field, graphene.Dynamic, Type[UnmountedType]], registry: Registry, filter_type: Optional[Type], + model_attr_name: Any, ) -> Optional[Union[graphene.InputField, graphene.Dynamic]]: # If a custom filter type was set for this field, use it here if filter_type: - return graphene.InputField(filter_type) + return SQLAlchemyFilterInputField(filter_type, model_attr_name) if issubclass(type(field), graphene.Scalar): filter_class = registry.get_filter_for_scalar_type(type(field)) - return graphene.InputField(filter_class) + return SQLAlchemyFilterInputField(filter_class, model_attr_name) # If the field is Dynamic, we don't know its type yet and can't select the right filter if isinstance(field, graphene.Dynamic): @@ -179,7 +181,7 @@ def resolve_dynamic(): if not reg_res: print("filter class was none!!!") print(type_) - return graphene.InputField(reg_res) + return SQLAlchemyFilterInputField(reg_res, model_attr_name) elif isinstance(type_, Field): if isinstance(type_.type, graphene.List): inner_type = get_nullable_type(type_.type.of_type) @@ -187,10 +189,10 @@ def resolve_dynamic(): if not reg_res: print("filter class was none!!!") print(type_) - return graphene.InputField(reg_res) + return SQLAlchemyFilterInputField(reg_res, model_attr_name) reg_res = registry.get_filter_for_base_type(type_.type) - return graphene.InputField(reg_res) + return SQLAlchemyFilterInputField(reg_res, model_attr_name) else: warnings.warn(f"Unexpected Dynamic Type: {type_}") # Investigate # raise Exception(f"Unexpected Dynamic Type: {type_}") @@ -213,14 +215,14 @@ def resolve_dynamic(): # Field might be a SQLAlchemyObjectType, due to hybrid properties if issubclass(type_, SQLAlchemyObjectType): filter_class = registry.get_filter_for_base_type(type_) - return graphene.InputField(filter_class) + return SQLAlchemyFilterInputField(filter_class, model_attr_name) filter_class = registry.get_filter_for_scalar_type(type_) if not filter_class: warnings.warn( f"No compatible filters found for {field.type}. Skipping field." ) return None - return graphene.InputField(filter_class) + return SQLAlchemyFilterInputField(filter_class, model_attr_name) raise Exception(f"Expected a graphene.Field or graphene.Dynamic, but got: {field}") @@ -362,7 +364,7 @@ def construct_fields_and_filters( fields[orm_field_name] = field if filtering_enabled_for_field: filters[orm_field_name] = filter_field_from_type_field( - field, registry, filter_type + field, registry, filter_type, attr_name ) return fields, filters From f1da1f1e1fe8b79b3b627e6dc403c56405db5506 Mon Sep 17 00:00:00 2001 From: Sabar Dasgupta Date: Mon, 24 Apr 2023 12:35:57 -0400 Subject: [PATCH 2/2] merge add-filters --- .github/workflows/docs.yml | 19 ++ .github/workflows/lint.yml | 8 +- .github/workflows/manage_issues.yml | 49 +++++ README.md | 8 +- README.rst | 102 ----------- docs/api.rst | 18 ++ docs/conf.py | 7 +- docs/filters.rst | 213 ++++++++++++++++++++++ docs/index.rst | 7 +- docs/inheritance.rst | 11 +- docs/relay.rst | 43 +++++ docs/requirements.txt | 1 + docs/starter.rst | 118 ++++++++++++ docs/tips.rst | 1 + examples/filters/README.md | 47 +++++ examples/filters/__init__.py | 0 examples/filters/app.py | 18 ++ examples/filters/database.py | 49 +++++ examples/filters/models.py | 34 ++++ examples/filters/requirements.txt | 3 + examples/filters/run.sh | 1 + examples/filters/schema.py | 42 +++++ graphene_sqlalchemy/__init__.py | 2 +- graphene_sqlalchemy/filters.py | 50 ++--- graphene_sqlalchemy/tests/test_filters.py | 167 +++++++---------- graphene_sqlalchemy/types.py | 54 +++--- setup.py | 6 +- 27 files changed, 802 insertions(+), 276 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 .github/workflows/manage_issues.yml delete mode 100644 README.rst create mode 100644 docs/api.rst create mode 100644 docs/filters.rst create mode 100644 docs/relay.rst create mode 100644 docs/starter.rst create mode 100644 examples/filters/README.md create mode 100644 examples/filters/__init__.py create mode 100644 examples/filters/app.py create mode 100644 examples/filters/database.py create mode 100644 examples/filters/models.py create mode 100644 examples/filters/requirements.txt create mode 100755 examples/filters/run.sh create mode 100644 examples/filters/schema.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..89f4446 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,19 @@ +name: Deploy Docs + +# Runs on pushes targeting the default branch +on: + push: + branches: [master] + +jobs: + pages: + runs-on: ubuntu-22.04 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + permissions: + pages: write + id-token: write + steps: + - id: deployment + uses: sphinx-notes/pages@v3 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9352dbe..355a94d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,6 +1,12 @@ name: Lint -on: [push, pull_request] +on: + push: + branches: + - 'master' + pull_request: + branches: + - '*' jobs: build: diff --git a/.github/workflows/manage_issues.yml b/.github/workflows/manage_issues.yml new file mode 100644 index 0000000..5876acb --- /dev/null +++ b/.github/workflows/manage_issues.yml @@ -0,0 +1,49 @@ +name: Issue Manager + +on: + schedule: + - cron: "0 0 * * *" + issue_comment: + types: + - created + issues: + types: + - labeled + pull_request_target: + types: + - labeled + workflow_dispatch: + +permissions: + issues: write + pull-requests: write + +concurrency: + group: lock + +jobs: + lock-old-closed-issues: + runs-on: ubuntu-latest + steps: + - uses: dessant/lock-threads@v4 + with: + issue-inactive-days: '180' + process-only: 'issues' + issue-comment: > + This issue has been automatically locked since there + has not been any recent activity after it was closed. + Please open a new issue for related topics referencing + this issue. + close-labelled-issues: + runs-on: ubuntu-latest + steps: + - uses: tiangolo/issue-manager@0.4.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + config: > + { + "needs-reply": { + "delay": 2200000, + "message": "This issue was closed due to inactivity. If your request is still relevant, please open a new issue referencing this one and provide all of the requested information." + } + } diff --git a/README.md b/README.md index 6e96f91..29da89d 100644 --- a/README.md +++ b/README.md @@ -109,11 +109,11 @@ schema = graphene.Schema(query=Query) ### Full Examples -To learn more check out the following [examples](examples/): +To learn more check out the following [examples](https://github.com/graphql-python/graphene-sqlalchemy/tree/master/examples/): -- [Flask SQLAlchemy example](examples/flask_sqlalchemy) -- [Nameko SQLAlchemy example](examples/nameko_sqlalchemy) +- [Flask SQLAlchemy example](https://github.com/graphql-python/graphene-sqlalchemy/tree/master/examples/flask_sqlalchemy) +- [Nameko SQLAlchemy example](https://github.com/graphql-python/graphene-sqlalchemy/tree/master/examples/nameko_sqlalchemy) ## Contributing -See [CONTRIBUTING.md](/CONTRIBUTING.md) +See [CONTRIBUTING.md](https://github.com/graphql-python/graphene-sqlalchemy/blob/master/CONTRIBUTING.md) diff --git a/README.rst b/README.rst deleted file mode 100644 index d82b807..0000000 --- a/README.rst +++ /dev/null @@ -1,102 +0,0 @@ -Please read -`UPGRADE-v2.0.md `__ -to learn how to upgrade to Graphene ``2.0``. - --------------- - -|Graphene Logo| Graphene-SQLAlchemy |Build Status| |PyPI version| |Coverage Status| -=================================================================================== - -A `SQLAlchemy `__ integration for -`Graphene `__. - -Installation ------------- - -For instaling graphene, just run this command in your shell - -.. code:: bash - - pip install "graphene-sqlalchemy>=2.0" - -Examples --------- - -Here is a simple SQLAlchemy model: - -.. code:: python - - from sqlalchemy import Column, Integer, String - from sqlalchemy.orm import backref, relationship - - from sqlalchemy.ext.declarative import declarative_base - - Base = declarative_base() - - class UserModel(Base): - __tablename__ = 'department' - id = Column(Integer, primary_key=True) - name = Column(String) - last_name = Column(String) - -To create a GraphQL schema for it you simply have to write the -following: - -.. code:: python - - from graphene_sqlalchemy import SQLAlchemyObjectType - - class User(SQLAlchemyObjectType): - class Meta: - model = UserModel - - class Query(graphene.ObjectType): - users = graphene.List(User) - - def resolve_users(self, info): - query = User.get_query(info) # SQLAlchemy query - return query.all() - - schema = graphene.Schema(query=Query) - -Then you can simply query the schema: - -.. code:: python - - query = ''' - query { - users { - name, - lastName - } - } - ''' - result = schema.execute(query, context_value={'session': db_session}) - -To learn more check out the following `examples `__: - -- **Full example**: `Flask SQLAlchemy - example `__ - -Contributing ------------- - -After cloning this repo, ensure dependencies are installed by running: - -.. code:: sh - - python setup.py install - -After developing, the full test suite can be evaluated by running: - -.. code:: sh - - python setup.py test # Use --pytest-args="-v -s" for verbose mode - -.. |Graphene Logo| image:: http://graphene-python.org/favicon.png -.. |Build Status| image:: https://travis-ci.org/graphql-python/graphene-sqlalchemy.svg?branch=master - :target: https://travis-ci.org/graphql-python/graphene-sqlalchemy -.. |PyPI version| image:: https://badge.fury.io/py/graphene-sqlalchemy.svg - :target: https://badge.fury.io/py/graphene-sqlalchemy -.. |Coverage Status| image:: https://coveralls.io/repos/graphql-python/graphene-sqlalchemy/badge.svg?branch=master&service=github - :target: https://coveralls.io/github/graphql-python/graphene-sqlalchemy?branch=master diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..237cf1b --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,18 @@ +API Reference +============== + +SQLAlchemyObjectType +-------------------- +.. autoclass:: graphene_sqlalchemy.SQLAlchemyObjectType + +SQLAlchemyInterface +------------------- +.. autoclass:: graphene_sqlalchemy.SQLAlchemyInterface + +ORMField +-------------------- +.. autoclass:: graphene_sqlalchemy.types.ORMField + +SQLAlchemyConnectionField +------------------------- +.. autoclass:: graphene_sqlalchemy.SQLAlchemyConnectionField diff --git a/docs/conf.py b/docs/conf.py index 9c9fc1d..1d8830b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,7 +23,10 @@ # import os # import sys # sys.path.insert(0, os.path.abspath('.')) +import os +import sys +sys.path.insert(0, os.path.abspath("..")) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. @@ -80,7 +83,7 @@ # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = "en" # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: @@ -175,7 +178,7 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ["_static"] +# html_static_path = ["_static"] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied diff --git a/docs/filters.rst b/docs/filters.rst new file mode 100644 index 0000000..ac36803 --- /dev/null +++ b/docs/filters.rst @@ -0,0 +1,213 @@ +======= +Filters +======= + +Starting in graphene-sqlalchemy version 3, the SQLAlchemyConnectionField class implements filtering by default. The query utilizes a ``filter`` keyword to specify a filter class that inherits from ``graphene.InputObjectType``. + +Migrating from graphene-sqlalchemy-filter +--------------------------------------------- + +If like many of us, you have been using |graphene-sqlalchemy-filter|_ to implement filters and would like to use the in-built mechanism here, there are a couple key differences to note. Mainly, in an effort to simplify the generated schema, filter keywords are nested under their respective fields instead of concatenated. For example, the filter partial ``{usernameIn: ["moderator", "cool guy"]}`` would be represented as ``{username: {in: ["moderator", "cool guy"]}}``. + +.. |graphene-sqlalchemy-filter| replace:: ``graphene-sqlalchemy-filter`` +.. _graphene-sqlalchemy-filter: https://github.com/art1415926535/graphene-sqlalchemy-filter + +Further, some of the constructs found in libraries like `DGraph's DQL `_ have been implemented, so if you have created custom implementations for these features, you may want to take a look at the examples below. + + +Example model +------------- + +Take as example a Pet model similar to that in the sorting example. We will use variations on this arrangement for the following examples. + +.. code:: + + class Pet(Base): + __tablename__ = 'pets' + id = Column(Integer(), primary_key=True) + name = Column(String(30)) + age = Column(Integer()) + + + class PetNode(SQLAlchemyObjectType): + class Meta: + model = Pet + + + class Query(graphene.ObjectType): + allPets = SQLAlchemyConnectionField(PetNode.connection) + + +Simple filter example +--------------------- + +Filters are defined at the object level through the ``BaseTypeFilter`` class. The ``BaseType`` encompasses both Graphene ``ObjectType``\ s and ``Interface``\ s. Each ``BaseTypeFilter`` instance may define fields via ``FieldFilter`` and relationships via ``RelationshipFilter``. Here's a basic example querying a single field on the Pet model: + +.. code:: + + allPets(filter: {name: {eq: "Fido"}}){ + edges { + node { + name + } + } + } + +This will return all pets with the name "Fido". + + +Custom filter types +------------------- + +If you'd like to implement custom behavior for filtering a field, you can do so by extending one of the base filter classes in ``graphene_sqlalchemy.filters``. For example, if you'd like to add a ``divisible_by`` keyword to filter the age attribute on the ``Pet`` model, you can do so as follows: + +.. code:: python + + class MathFilter(FloatFilter): + class Meta: + graphene_type = graphene.Float + + @classmethod + def divisible_by_filter(cls, query, field, val: int) -> bool: + return is_(field % val, 0) + + class PetType(SQLAlchemyObjectType): + ... + + age = ORMField(filter_type=MathFilter) + + class Query(graphene.ObjectType): + pets = SQLAlchemyConnectionField(PetType.connection) + + +Filtering over relationships with RelationshipFilter +---------------------------------------------------- + +When a filter class field refers to another object in a relationship, you may nest filters on relationship object attributes. This happens directly for 1:1 and m:1 relationships and through the ``contains`` and ``containsExactly`` keywords for 1:n and m:n relationships. + + +:1 relationships +^^^^^^^^^^^^^^^^ + +When an object or interface defines a singular relationship, relationship object attributes may be filtered directly like so: + +Take the following SQLAlchemy model definition as an example: + +.. code:: python + + class Pet + ... + person_id = Column(Integer(), ForeignKey("people.id")) + + class Person + ... + pets = relationship("Pet", backref="person") + + +Then, this query will return all pets whose person is named "Ada": + +.. code:: + + allPets(filter: { + person: {name: {eq: "Ada"}} + }) { + ... + } + + +:n relationships +^^^^^^^^^^^^^^^^ + +However, for plural relationships, relationship object attributes must be filtered through either ``contains`` or ``containsExactly``: + +Now, using a many-to-many model definition: + +.. code:: python + + people_pets_table = sqlalchemy.Table( + "people_pets", + Base.metadata, + Column("person_id", ForeignKey("people.id")), + Column("pet_id", ForeignKey("pets.id")), + ) + + class Pet + ... + + class Person + ... + pets = relationship("Pet", backref="people") + + +this query will return all pets which have a person named "Ben" in their ``people`` list. + +.. code:: + + allPets(filter: { + people: { + contains: [{name: {eq: "Ben"}}], + } + }) { + ... + } + + +and this one will return all pets which hvae a person list that contains exactly the people "Ada" and "Ben" and no fewer or people with other names. + +.. code:: + + allPets(filter: { + articles: { + containsExactly: [ + {name: {eq: "Ada"}}, + {name: {eq: "Ben"}}, + ], + } + }) { + ... + } + +And/Or Logic +------------ + +Filters can also be chained together logically using `and` and `or` keywords nested under `filter`. Clauses are passed directly to `sqlalchemy.and_` and `slqlalchemy.or_`, respectively. To return all pets named "Fido" or "Spot", use: + + +.. code:: + + allPets(filter: { + or: [ + {name: {eq: "Fido"}}, + {name: {eq: "Spot"}}, + ] + }) { + ... + } + +And to return all pets that are named "Fido" or are 5 years old and named "Spot", use: + +.. code:: + + allPets(filter: { + or: [ + {name: {eq: "Fido"}}, + { and: [ + {name: {eq: "Spot"}}, + {age: {eq: 5}} + } + ] + }) { + ... + } + + +Hybrid Property support +----------------------- + +Filtering over SQLAlchemy `hybrid properties `_ is fully supported. + + +Reporting feedback and bugs +--------------------------- + +Filtering is a new feature to graphene-sqlalchemy, so please `post an issue on Github `_ if you run into any problems or have ideas on how to improve the implementation. diff --git a/docs/index.rst b/docs/index.rst index 81b2f31..4245eba 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,6 +6,11 @@ Contents: .. toctree:: :maxdepth: 0 - tutorial + starter + inheritance + relay tips + filters examples + tutorial + api diff --git a/docs/inheritance.rst b/docs/inheritance.rst index 7473216..d7fcca9 100644 --- a/docs/inheritance.rst +++ b/docs/inheritance.rst @@ -1,9 +1,13 @@ Inheritance Examples ==================== + Create interfaces from inheritance relationships ------------------------------------------------ -.. note:: If you're using `AsyncSession`, please check the chapter `Eager Loading & Using with AsyncSession`_. + +.. note:: + If you're using `AsyncSession`, please check the chapter `Eager Loading & Using with AsyncSession`_. + SQLAlchemy has excellent support for class inheritance hierarchies. These hierarchies can be represented in your GraphQL schema by means of interfaces_. Much like ObjectTypes, Interfaces in @@ -111,13 +115,16 @@ class to the Schema constructor via the `types=` argument: See also: `Graphene Interfaces `_ + Eager Loading & Using with AsyncSession --------------------- +---------------------------------------- + When querying the base type in multi-table inheritance or joined table inheritance, you can only directly refer to polymorphic fields when they are loaded eagerly. This restricting is in place because AsyncSessions don't allow implicit async operations such as the loads of the joined tables. To load the polymorphic fields eagerly, you can use the `with_polymorphic` attribute of the mapper args in the base model: .. code:: python + class Person(Base): id = Column(Integer(), primary_key=True) type = Column(String()) diff --git a/docs/relay.rst b/docs/relay.rst new file mode 100644 index 0000000..7b733c7 --- /dev/null +++ b/docs/relay.rst @@ -0,0 +1,43 @@ +Relay +========== + +:code:`graphene-sqlalchemy` comes with pre-defined +connection fields to quickly create a functioning relay API. +Using the :code:`SQLAlchemyConnectionField`, you have access to relay pagination, +sorting and filtering (filtering is coming soon!). + +To be used in a relay connection, your :code:`SQLAlchemyObjectType` must implement +the :code:`Node` interface from :code:`graphene.relay`. This handles the creation of +the :code:`Connection` and :code:`Edge` types automatically. + +The following example creates a relay-paginated connection: + + + +.. code:: python + + class Pet(Base): + __tablename__ = 'pets' + id = Column(Integer(), primary_key=True) + name = Column(String(30)) + pet_kind = Column(Enum('cat', 'dog', name='pet_kind'), nullable=False) + + + class PetNode(SQLAlchemyObjectType): + class Meta: + model = Pet + interfaces=(Node,) + + + class Query(ObjectType): + all_pets = SQLAlchemyConnectionField(PetNode.connection) + +To disable sorting on the connection, you can set :code:`sort` to :code:`None` the +:code:`SQLAlchemyConnectionField`: + + +.. code:: python + + class Query(ObjectType): + all_pets = SQLAlchemyConnectionField(PetNode.connection, sort=None) + diff --git a/docs/requirements.txt b/docs/requirements.txt index 666a8c9..220b7cf 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,3 @@ +sphinx # Docs template http://graphene-python.org/sphinx_graphene_theme.zip diff --git a/docs/starter.rst b/docs/starter.rst new file mode 100644 index 0000000..6e09ab0 --- /dev/null +++ b/docs/starter.rst @@ -0,0 +1,118 @@ +Getting Started +================= + +Welcome to the graphene-sqlalchemy documentation! +Graphene is a powerful Python library for building GraphQL APIs, +and SQLAlchemy is a popular ORM (Object-Relational Mapping) +tool for working with databases. When combined, graphene-sqlalchemy +allows developers to quickly and easily create a GraphQL API that +seamlessly interacts with a SQLAlchemy-managed database. +It is fully compatible with SQLAlchemy 1.4 and 2.0. +This documentation provides detailed instructions on how to get +started with graphene-sqlalchemy, including installation, setup, +and usage examples. + +Installation +------------ + +To install :code:`graphene-sqlalchemy`, just run this command in your shell: + +.. code:: bash + + pip install --pre "graphene-sqlalchemy" + +Examples +-------- + +Here is a simple SQLAlchemy model: + +.. code:: python + + from sqlalchemy import Column, Integer, String + from sqlalchemy.ext.declarative import declarative_base + + Base = declarative_base() + + class UserModel(Base): + __tablename__ = 'user' + id = Column(Integer, primary_key=True) + name = Column(String) + last_name = Column(String) + +To create a GraphQL schema for it, you simply have to write the +following: + +.. code:: python + + import graphene + from graphene_sqlalchemy import SQLAlchemyObjectType + + class User(SQLAlchemyObjectType): + class Meta: + model = UserModel + # use `only_fields` to only expose specific fields ie "name" + # only_fields = ("name",) + # use `exclude_fields` to exclude specific fields ie "last_name" + # exclude_fields = ("last_name",) + + class Query(graphene.ObjectType): + users = graphene.List(User) + + def resolve_users(self, info): + query = User.get_query(info) # SQLAlchemy query + return query.all() + + schema = graphene.Schema(query=Query) + +Then you can simply query the schema: + +.. code:: python + + query = ''' + query { + users { + name, + lastName + } + } + ''' + result = schema.execute(query, context_value={'session': db_session}) + + +It is important to provide a session for graphene-sqlalchemy to resolve the models. +In this example, it is provided using the GraphQL context. See :doc:`tips` for +other ways to implement this. + +You may also subclass SQLAlchemyObjectType by providing +``abstract = True`` in your subclasses Meta: + +.. code:: python + + from graphene_sqlalchemy import SQLAlchemyObjectType + + class ActiveSQLAlchemyObjectType(SQLAlchemyObjectType): + class Meta: + abstract = True + + @classmethod + def get_node(cls, info, id): + return cls.get_query(info).filter( + and_(cls._meta.model.deleted_at==None, + cls._meta.model.id==id) + ).first() + + class User(ActiveSQLAlchemyObjectType): + class Meta: + model = UserModel + + class Query(graphene.ObjectType): + users = graphene.List(User) + + def resolve_users(self, info): + query = User.get_query(info) # SQLAlchemy query + return query.all() + + schema = graphene.Schema(query=Query) + +More complex inhertiance using SQLAlchemy's polymorphic models is also supported. +You can check out :doc:`inheritance` for a guide. diff --git a/docs/tips.rst b/docs/tips.rst index baa8233..a3ed69e 100644 --- a/docs/tips.rst +++ b/docs/tips.rst @@ -4,6 +4,7 @@ Tips Querying -------- +.. _querying: In order to make querying against the database work, there are two alternatives: diff --git a/examples/filters/README.md b/examples/filters/README.md new file mode 100644 index 0000000..a72e75d --- /dev/null +++ b/examples/filters/README.md @@ -0,0 +1,47 @@ +Example Filters Project +================================ + +This example highlights the ability to filter queries in graphene-sqlalchemy. + +The project contains two models, one named `Department` and another +named `Employee`. + +Getting started +--------------- + +First you'll need to get the source of the project. Do this by cloning the +whole Graphene-SQLAlchemy repository: + +```bash +# Get the example project code +git clone https://github.com/graphql-python/graphene-sqlalchemy.git +cd graphene-sqlalchemy/examples/filters +``` + +It is recommended to create a virtual environment +for this project. We'll do this using +[virtualenv](http://docs.python-guide.org/en/latest/dev/virtualenvs/) +to keep things simple, +but you may also find something like +[virtualenvwrapper](https://virtualenvwrapper.readthedocs.org/en/latest/) +to be useful: + +```bash +# Create a virtualenv in which we can install the dependencies +virtualenv env +source env/bin/activate +``` + +Install our dependencies: + +```bash +pip install -r requirements.txt +``` + +The following command will setup the database, and start the server: + +```bash +python app.py +``` + +Now head over to your favorite GraphQL client, POST to [http://127.0.0.1:5000/graphql](http://127.0.0.1:5000/graphql) and run some queries! diff --git a/examples/filters/__init__.py b/examples/filters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/filters/app.py b/examples/filters/app.py new file mode 100644 index 0000000..6cb1563 --- /dev/null +++ b/examples/filters/app.py @@ -0,0 +1,18 @@ +from database import init_db +from fastapi import FastAPI +from schema import schema +from starlette_graphene3 import GraphQLApp, make_playground_handler + + +def create_app() -> FastAPI: + print("HERE") + init_db() + print("HERE?") + app = FastAPI() + + app.mount("/graphql", GraphQLApp(schema, on_get=make_playground_handler())) + + return app + + +app = create_app() diff --git a/examples/filters/database.py b/examples/filters/database.py new file mode 100644 index 0000000..8f6522f --- /dev/null +++ b/examples/filters/database.py @@ -0,0 +1,49 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +Base = declarative_base() +engine = create_engine( + "sqlite://", connect_args={"check_same_thread": False}, echo=True +) +session_factory = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +from sqlalchemy.orm import scoped_session as scoped_session_factory + +scoped_session = scoped_session_factory(session_factory) + +Base.query = scoped_session.query_property() +Base.metadata.bind = engine + + +def init_db(): + from models import Person, Pet, Toy + + Base.metadata.create_all() + scoped_session.execute("PRAGMA foreign_keys=on") + db = scoped_session() + + person1 = Person(name="A") + person2 = Person(name="B") + + pet1 = Pet(name="Spot") + pet2 = Pet(name="Milo") + + toy1 = Toy(name="disc") + toy2 = Toy(name="ball") + + person1.pet = pet1 + person2.pet = pet2 + + pet1.toys.append(toy1) + pet2.toys.append(toy1) + pet2.toys.append(toy2) + + db.add(person1) + db.add(person2) + db.add(pet1) + db.add(pet2) + db.add(toy1) + db.add(toy2) + + db.commit() diff --git a/examples/filters/models.py b/examples/filters/models.py new file mode 100644 index 0000000..1b22956 --- /dev/null +++ b/examples/filters/models.py @@ -0,0 +1,34 @@ +import sqlalchemy +from database import Base +from sqlalchemy import Column, ForeignKey, Integer, String +from sqlalchemy.orm import relationship + + +class Pet(Base): + __tablename__ = "pets" + id = Column(Integer(), primary_key=True) + name = Column(String(30)) + age = Column(Integer()) + person_id = Column(Integer(), ForeignKey("people.id")) + + +class Person(Base): + __tablename__ = "people" + id = Column(Integer(), primary_key=True) + name = Column(String(100)) + pets = relationship("Pet", backref="person") + + +pets_toys_table = sqlalchemy.Table( + "pets_toys", + Base.metadata, + Column("pet_id", ForeignKey("pets.id")), + Column("toy_id", ForeignKey("toys.id")), +) + + +class Toy(Base): + __tablename__ = "toys" + id = Column(Integer(), primary_key=True) + name = Column(String(30)) + pets = relationship("Pet", secondary=pets_toys_table, backref="toys") diff --git a/examples/filters/requirements.txt b/examples/filters/requirements.txt new file mode 100644 index 0000000..b433ec5 --- /dev/null +++ b/examples/filters/requirements.txt @@ -0,0 +1,3 @@ +-e ../../ +fastapi +uvicorn diff --git a/examples/filters/run.sh b/examples/filters/run.sh new file mode 100755 index 0000000..ec36544 --- /dev/null +++ b/examples/filters/run.sh @@ -0,0 +1 @@ +uvicorn app:app --port 5000 diff --git a/examples/filters/schema.py b/examples/filters/schema.py new file mode 100644 index 0000000..2728cab --- /dev/null +++ b/examples/filters/schema.py @@ -0,0 +1,42 @@ +from models import Person as PersonModel +from models import Pet as PetModel +from models import Toy as ToyModel + +import graphene +from graphene import relay +from graphene_sqlalchemy import SQLAlchemyObjectType +from graphene_sqlalchemy.fields import SQLAlchemyConnectionField + + +class Pet(SQLAlchemyObjectType): + class Meta: + model = PetModel + name = "Pet" + interfaces = (relay.Node,) + batching = True + + +class Person(SQLAlchemyObjectType): + class Meta: + model = PersonModel + name = "Person" + interfaces = (relay.Node,) + batching = True + + +class Toy(SQLAlchemyObjectType): + class Meta: + model = ToyModel + name = "Toy" + interfaces = (relay.Node,) + batching = True + + +class Query(graphene.ObjectType): + node = relay.Node.Field() + pets = SQLAlchemyConnectionField(Pet.connection) + people = SQLAlchemyConnectionField(Person.connection) + toys = SQLAlchemyConnectionField(Toy.connection) + + +schema = graphene.Schema(query=Query) diff --git a/graphene_sqlalchemy/__init__.py b/graphene_sqlalchemy/__init__.py index fb32379..253e1d9 100644 --- a/graphene_sqlalchemy/__init__.py +++ b/graphene_sqlalchemy/__init__.py @@ -2,7 +2,7 @@ from .types import SQLAlchemyInterface, SQLAlchemyObjectType from .utils import get_query, get_session -__version__ = "3.0.0b3" +__version__ = "3.0.0b4" __all__ = [ "__version__", diff --git a/graphene_sqlalchemy/filters.py b/graphene_sqlalchemy/filters.py index c07f3ca..ffa10da 100644 --- a/graphene_sqlalchemy/filters.py +++ b/graphene_sqlalchemy/filters.py @@ -2,7 +2,7 @@ from typing import Any, Dict, List, Tuple, Type, TypeVar, Union from graphql import Undefined -from sqlalchemy import and_, func, not_, or_ +from sqlalchemy import and_, not_, or_ from sqlalchemy.orm import Query, aliased # , selectinload import graphene @@ -308,6 +308,8 @@ def in_filter(cls, query, field, val: List[ScalarFilterInputType]): def not_in_filter(cls, query, field, val: List[ScalarFilterInputType]): return field.notin_(val) + # TODO add like/ilike + @classmethod def execute_filters( cls, query, field, filter_dict: Dict[str, any] @@ -326,6 +328,18 @@ class StringFilter(FieldFilter): class Meta: graphene_type = graphene.String + @classmethod + def like_filter(cls, query, field, val: ScalarFilterInputType) -> bool: + return field.like(val) + + @classmethod + def ilike_filter(cls, query, field, val: ScalarFilterInputType) -> bool: + return field.ilike(val) + + @classmethod + def notlike_filter(cls, query, field, val: ScalarFilterInputType) -> bool: + return field.notlike(val) + class BooleanFilter(FieldFilter): class Meta: @@ -443,7 +457,7 @@ def contains_filter( joined_model_alias = aliased(relationship_prop) # Join the aliased model onto the query - query = query.join(field.of_type(joined_model_alias)) + query = query.join(field.of_type(joined_model_alias)).distinct() print("Joined model", relationship_prop) print(query) # pass the alias so group can join group @@ -462,37 +476,7 @@ def contains_exactly_filter( relationship_prop, val: List[ScalarFilterInputType], ): - print("Contains exactly called: ", query, val) - session = query.session - child_model_ids = [] - for v in val: - print("Contains exactly loop: ", v) - - # Always alias the model - joined_model_alias = aliased(relationship_prop) - - subquery = session.query(joined_model_alias.id) - subquery, _clauses = cls._meta.base_type_filter.execute_filters( - subquery, v, model_alias=joined_model_alias - ) - subquery_ids = [s_id[0] for s_id in subquery.filter(and_(*_clauses)).all()] - child_model_ids.extend(subquery_ids) - - # Join the relationship onto the query - joined_model_alias = aliased(relationship_prop) - joined_field = field.of_type(joined_model_alias) - query = query.join(joined_field) - - # Construct clauses from child_model_ids - query = ( - query.filter(joined_model_alias.id.in_(child_model_ids)) - .group_by(parent_model) - .having(func.count(str(field)) == len(child_model_ids)) - # TODO should filter on aliased field - # .having(func.count(joined_field) == len(child_model_ids)) - ) - - return query, [] + raise NotImplementedError @classmethod def execute_filters( diff --git a/graphene_sqlalchemy/tests/test_filters.py b/graphene_sqlalchemy/tests/test_filters.py index f706a19..be99222 100644 --- a/graphene_sqlalchemy/tests/test_filters.py +++ b/graphene_sqlalchemy/tests/test_filters.py @@ -26,6 +26,15 @@ # fp.write(str(schema)) +def assert_and_raise_result(result, expected): + if result.errors: + for error in result.errors: + raise error + assert not result.errors + result = to_std_dicts(result.data) + assert result == expected + + async def add_test_data(session): reporter = Reporter(first_name="John", last_name="Doe", favorite_pet_kind="cat") session.add(reporter) @@ -68,42 +77,36 @@ class Meta: model = Article name = "Article" interfaces = (relay.Node,) - connection_class = Connection class ImageType(SQLAlchemyObjectType): class Meta: model = Image name = "Image" interfaces = (relay.Node,) - connection_class = Connection class PetType(SQLAlchemyObjectType): class Meta: model = Pet name = "Pet" interfaces = (relay.Node,) - connection_class = Connection class ReaderType(SQLAlchemyObjectType): class Meta: model = Reader name = "Reader" interfaces = (relay.Node,) - connection_class = Connection class ReporterType(SQLAlchemyObjectType): class Meta: model = Reporter name = "Reporter" interfaces = (relay.Node,) - connection_class = Connection class TagType(SQLAlchemyObjectType): class Meta: model = Tag name = "Tag" interfaces = (relay.Node,) - connection_class = Connection class Query(graphene.ObjectType): node = relay.Node.Field() @@ -139,7 +142,7 @@ async def test_filter_simple(session): query = """ query { - reporters (filter: {lastName: {eq: "Roe"}}) { + reporters (filter: {lastName: {eq: "Roe", like: "%oe"}}) { edges { node { firstName @@ -153,11 +156,7 @@ async def test_filter_simple(session): } schema = graphene.Schema(query=Query) result = await schema.execute_async(query, context_value={"session": session}) - print(result) - print(result.errors) - assert not result.errors - result = to_std_dicts(result.data) - assert result == expected + assert_and_raise_result(result, expected) # Test a custom filter type @@ -205,9 +204,7 @@ class Query(graphene.ObjectType): } schema = graphene.Schema(query=Query) result = await schema.execute_async(query, context_value={"session": session}) - assert not result.errors - result = to_std_dicts(result.data) - assert result == expected + assert_and_raise_result(result, expected) # Test a 1:1 relationship @@ -240,9 +237,7 @@ async def test_filter_relationship_one_to_one(session): } schema = graphene.Schema(query=Query) result = await schema.execute_async(query, context_value={"session": session}) - assert not result.errors - result = to_std_dicts(result.data) - assert result == expected + assert_and_raise_result(result, expected) # Test a 1:n relationship @@ -272,38 +267,35 @@ async def test_filter_relationship_one_to_many(session): } schema = graphene.Schema(query=Query) result = await schema.execute_async(query, context_value={"session": session}) - assert not result.errors - result = to_std_dicts(result.data) - assert result == expected - - # test containsExactly - query = """ - query { - reporters (filter: { - articles: { - containsExactly: [ - {headline: {eq: "Hi!"}} - {headline: {eq: "Hello!"}} - ] - } - }) { - edges { - node { - firstName - lastName - } - } - } - } - """ - expected = { - "reporters": {"edges": [{"node": {"firstName": "John", "lastName": "Woe"}}]} - } - schema = graphene.Schema(query=Query) - result = await schema.execute_async(query, context_value={"session": session}) - assert not result.errors - result = to_std_dicts(result.data) - assert result == expected + assert_and_raise_result(result, expected) + + # TODO test containsExactly + # # test containsExactly + # query = """ + # query { + # reporters (filter: { + # articles: { + # containsExactly: [ + # {headline: {eq: "Hi!"}} + # {headline: {eq: "Hello!"}} + # ] + # } + # }) { + # edges { + # node { + # firstName + # lastName + # } + # } + # } + # } + # """ + # expected = { + # "reporters": {"edges": [{"node": {"firstName": "John", "lastName": "Woe"}}]} + # } + # schema = graphene.Schema(query=Query) + # result = await schema.execute_async(query, context_value={"session": session}) + # assert_and_raise_result(result, expected) async def add_n2m_test_data(session): @@ -371,9 +363,7 @@ async def test_filter_relationship_many_to_many_contains(session): } schema = graphene.Schema(query=Query) result = await schema.execute_async(query, context_value={"session": session}) - assert not result.errors - result = to_std_dicts(result.data) - assert result == expected + assert_and_raise_result(result, expected) # test contains 2 query = """ @@ -402,9 +392,7 @@ async def test_filter_relationship_many_to_many_contains(session): } schema = graphene.Schema(query=Query) result = await schema.execute_async(query, context_value={"session": session}) - assert not result.errors - result = to_std_dicts(result.data) - assert result == expected + assert_and_raise_result(result, expected) # test reverse query = """ @@ -433,15 +421,14 @@ async def test_filter_relationship_many_to_many_contains(session): } schema = graphene.Schema(query=Query) result = await schema.execute_async(query, context_value={"session": session}) - assert not result.errors - result = to_std_dicts(result.data) - assert result == expected + assert_and_raise_result(result, expected) # Test n:m relationship containsExactly @pytest.mark.xfail @pytest.mark.asyncio async def test_filter_relationship_many_to_many_contains_exactly(session): + raise NotImplementedError await add_n2m_test_data(session) Query = create_schema(session) @@ -469,9 +456,7 @@ async def test_filter_relationship_many_to_many_contains_exactly(session): } schema = graphene.Schema(query=Query) result = await schema.execute_async(query, context_value={"session": session}) - assert not result.errors - result = to_std_dicts(result.data) - assert result == expected + assert_and_raise_result(result, expected) # test containsExactly 2 query = """ @@ -496,9 +481,7 @@ async def test_filter_relationship_many_to_many_contains_exactly(session): } schema = graphene.Schema(query=Query) result = await schema.execute_async(query, context_value={"session": session}) - assert not result.errors - result = to_std_dicts(result.data) - assert result == expected + assert_and_raise_result(result, expected) # test reverse query = """ @@ -524,14 +507,14 @@ async def test_filter_relationship_many_to_many_contains_exactly(session): } schema = graphene.Schema(query=Query) result = await schema.execute_async(query, context_value={"session": session}) - assert not result.errors - result = to_std_dicts(result.data) - assert result == expected + assert_and_raise_result(result, expected) # Test n:m relationship both contains and containsExactly +@pytest.mark.xfail @pytest.mark.asyncio async def test_filter_relationship_many_to_many_contains_and_contains_exactly(session): + raise NotImplementedError await add_n2m_test_data(session) Query = create_schema(session) @@ -561,9 +544,7 @@ async def test_filter_relationship_many_to_many_contains_and_contains_exactly(se } schema = graphene.Schema(query=Query) result = await schema.execute_async(query, context_value={"session": session}) - assert not result.errors - result = to_std_dicts(result.data) - assert result == expected + assert_and_raise_result(result, expected) # Test n:m nested relationship @@ -596,9 +577,7 @@ async def test_filter_relationship_many_to_many_nested(session): } schema = graphene.Schema(query=Query) result = await schema.execute_async(query, context_value={"session": session}) - assert not result.errors - result = to_std_dicts(result.data) - assert result == expected + assert_and_raise_result(result, expected) # test nested readers->articles->tags query = """ @@ -629,9 +608,7 @@ async def test_filter_relationship_many_to_many_nested(session): } schema = graphene.Schema(query=Query) result = await schema.execute_async(query, context_value={"session": session}) - assert not result.errors - result = to_std_dicts(result.data) - assert result == expected + assert_and_raise_result(result, expected) # test nested reverse query = """ @@ -662,9 +639,7 @@ async def test_filter_relationship_many_to_many_nested(session): } schema = graphene.Schema(query=Query) result = await schema.execute_async(query, context_value={"session": session}) - assert not result.errors - result = to_std_dicts(result.data) - assert result == expected + assert_and_raise_result(result, expected) # test filter on both levels of nesting query = """ @@ -696,9 +671,7 @@ async def test_filter_relationship_many_to_many_nested(session): } schema = graphene.Schema(query=Query) result = await schema.execute_async(query, context_value={"session": session}) - assert not result.errors - result = to_std_dicts(result.data) - assert result == expected + assert_and_raise_result(result, expected) # Test connecting filters with "and" @@ -732,9 +705,7 @@ async def test_filter_logic_and(session): } schema = graphene.Schema(query=Query) result = await schema.execute_async(query, context_value={"session": session}) - assert not result.errors - result = to_std_dicts(result.data) - assert result == expected + assert_and_raise_result(result, expected) # Test connecting filters with "or" @@ -772,9 +743,7 @@ async def test_filter_logic_or(session): } schema = graphene.Schema(query=Query) result = await schema.execute_async(query, context_value={"session": session}) - assert not result.errors - result = to_std_dicts(result.data) - assert result == expected + assert_and_raise_result(result, expected) # Test connecting filters with "and" and "or" together @@ -815,9 +784,7 @@ async def test_filter_logic_and_or(session): } schema = graphene.Schema(query=Query) result = await schema.execute_async(query, context_value={"session": session}) - assert not result.errors - result = to_std_dicts(result.data) - assert result == expected + assert_and_raise_result(result, expected) async def add_hybrid_prop_test_data(session): @@ -876,9 +843,7 @@ async def test_filter_hybrid_property(session): } schema = graphene.Schema(query=Query) result = await schema.execute_async(query, context_value={"session": session}) - assert not result.errors - result = to_std_dicts(result.data) - assert result == expected + assert_and_raise_result(result, expected) # test hybrid_prop_float query = """ @@ -901,9 +866,7 @@ async def test_filter_hybrid_property(session): } schema = graphene.Schema(query=Query) result = await schema.execute_async(query, context_value={"session": session}) - assert not result.errors - result = to_std_dicts(result.data) - assert result == expected + assert_and_raise_result(result, expected) # test hybrid_prop different model without expression query = """ @@ -1014,9 +977,7 @@ async def test_additional_filters(session): } schema = graphene.Schema(query=Query) result = await schema.execute_async(query, context_value={"session": session}) - assert not result.errors - result = to_std_dicts(result.data) - assert result == expected + assert_and_raise_result(result, expected) # test gt, lt, gte, and lte filters query = """ @@ -1035,6 +996,4 @@ async def test_additional_filters(session): } schema = graphene.Schema(query=Query) result = await schema.execute_async(query, context_value={"session": session}) - assert not result.errors - result = to_std_dicts(result.data) - assert result == expected + assert_and_raise_result(result, expected) diff --git a/graphene_sqlalchemy/types.py b/graphene_sqlalchemy/types.py index 7f64aab..c11db7e 100644 --- a/graphene_sqlalchemy/types.py +++ b/graphene_sqlalchemy/types.py @@ -576,13 +576,15 @@ class SQLAlchemyObjectType(SQLAlchemyBase, ObjectType): Usage: - class MyModel(Base): - id = Column(Integer(), primary_key=True) - name = Column(String()) + .. code-block:: python - class MyType(SQLAlchemyObjectType): - class Meta: - model = MyModel + class MyModel(Base): + id = Column(Integer(), primary_key=True) + name = Column(String()) + + class MyType(SQLAlchemyObjectType): + class Meta: + model = MyModel """ @classmethod @@ -619,30 +621,32 @@ class SQLAlchemyInterface(SQLAlchemyBase, Interface): Usage (using joined table inheritance): - class MyBaseModel(Base): - id = Column(Integer(), primary_key=True) - type = Column(String()) - name = Column(String()) + .. code-block:: python - __mapper_args__ = { - "polymorphic_on": type, - } + class MyBaseModel(Base): + id = Column(Integer(), primary_key=True) + type = Column(String()) + name = Column(String()) - class MyChildModel(Base): - date = Column(Date()) + __mapper_args__ = { + "polymorphic_on": type, + } - __mapper_args__ = { - "polymorphic_identity": "child", - } + class MyChildModel(Base): + date = Column(Date()) - class MyBaseType(SQLAlchemyInterface): - class Meta: - model = MyBaseModel + __mapper_args__ = { + "polymorphic_identity": "child", + } - class MyChildType(SQLAlchemyObjectType): - class Meta: - model = MyChildModel - interfaces = (MyBaseType,) + class MyBaseType(SQLAlchemyInterface): + class Meta: + model = MyBaseModel + + class MyChildType(SQLAlchemyObjectType): + class Meta: + model = MyChildModel + interfaces = (MyBaseType,) """ @classmethod diff --git a/setup.py b/setup.py index 9122baf..0f9ec81 100644 --- a/setup.py +++ b/setup.py @@ -34,8 +34,12 @@ name="graphene-sqlalchemy", version=version, description="Graphene SQLAlchemy integration", - long_description=open("README.rst").read(), + long_description=open("README.md").read(), + long_description_content_type="text/markdown", url="https://github.com/graphql-python/graphene-sqlalchemy", + project_urls={ + "Documentation": "https://docs.graphene-python.org/projects/sqlalchemy/en/latest", + }, author="Syrus Akbary", author_email="me@syrusakbary.com", license="MIT",