Skip to content

Commit

Permalink
feat: add filters (#357)
Browse files Browse the repository at this point in the history
Co-authored-by: Paul Schweizer <[email protected]>
Co-authored-by: Erik Wrede <[email protected]>
  • Loading branch information
3 people committed Dec 4, 2023
1 parent b94230e commit c927ada
Show file tree
Hide file tree
Showing 24 changed files with 2,635 additions and 75 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,8 @@ target/
*.sqlite3
.vscode

# Schema
*.gql

# mypy cache
.mypy_cache/
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
default_language_version:
python: python3.7
python: python3.8
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.2.0
Expand All @@ -12,7 +12,7 @@ repos:
- id: trailing-whitespace
exclude: README.md
- repo: https://github.com/pycqa/isort
rev: 5.10.1
rev: 5.12.0
hooks:
- id: isort
name: isort (python)
Expand Down
213 changes: 213 additions & 0 deletions docs/filters.rst
Original file line number Diff line number Diff line change
@@ -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 <https://dgraph.io/docs/query-language/>`_ 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 <https://docs.sqlalchemy.org/en/20/orm/extensions/hybrid.html>`_ is fully supported.


Reporting feedback and bugs
---------------------------

Filtering is a new feature to graphene-sqlalchemy, so please `post an issue on Github <https://github.com/graphql-python/graphene-sqlalchemy/issues>`_ if you run into any problems or have ideas on how to improve the implementation.
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Contents:
inheritance
relay
tips
filters
examples
tutorial
api
47 changes: 47 additions & 0 deletions examples/filters/README.md
Original file line number Diff line number Diff line change
@@ -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:https://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:https://127.0.0.1:5000/graphql](http:https://127.0.0.1:5000/graphql) and run some queries!
Empty file added examples/filters/__init__.py
Empty file.
16 changes: 16 additions & 0 deletions examples/filters/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
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:
init_db()
app = FastAPI()

app.mount("/graphql", GraphQLApp(schema, on_get=make_playground_handler()))

return app


app = create_app()
49 changes: 49 additions & 0 deletions examples/filters/database.py
Original file line number Diff line number Diff line change
@@ -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:https://", 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()
34 changes: 34 additions & 0 deletions examples/filters/models.py
Original file line number Diff line number Diff line change
@@ -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")
Loading

0 comments on commit c927ada

Please sign in to comment.