Skip to content

Commit c927ada

Browse files
sabardPaulSchweizererikwrede
authored
feat: add filters (#357)
Co-authored-by: Paul Schweizer <paulschweizer@gmx.net> Co-authored-by: Erik Wrede <erikwrede@users.noreply.github.com>
1 parent b94230e commit c927ada

24 files changed

+2635
-75
lines changed

.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -71,5 +71,8 @@ target/
7171
*.sqlite3
7272
.vscode
7373

74+
# Schema
75+
*.gql
76+
7477
# mypy cache
7578
.mypy_cache/

.pre-commit-config.yaml

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
default_language_version:
2-
python: python3.7
2+
python: python3.8
33
repos:
44
- repo: https://github.com/pre-commit/pre-commit-hooks
55
rev: v4.2.0
@@ -12,7 +12,7 @@ repos:
1212
- id: trailing-whitespace
1313
exclude: README.md
1414
- repo: https://github.com/pycqa/isort
15-
rev: 5.10.1
15+
rev: 5.12.0
1616
hooks:
1717
- id: isort
1818
name: isort (python)

docs/filters.rst

+213
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
=======
2+
Filters
3+
=======
4+
5+
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``.
6+
7+
Migrating from graphene-sqlalchemy-filter
8+
---------------------------------------------
9+
10+
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"]}}``.
11+
12+
.. |graphene-sqlalchemy-filter| replace:: ``graphene-sqlalchemy-filter``
13+
.. _graphene-sqlalchemy-filter: https://github.com/art1415926535/graphene-sqlalchemy-filter
14+
15+
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.
16+
17+
18+
Example model
19+
-------------
20+
21+
Take as example a Pet model similar to that in the sorting example. We will use variations on this arrangement for the following examples.
22+
23+
.. code::
24+
25+
class Pet(Base):
26+
__tablename__ = 'pets'
27+
id = Column(Integer(), primary_key=True)
28+
name = Column(String(30))
29+
age = Column(Integer())
30+
31+
32+
class PetNode(SQLAlchemyObjectType):
33+
class Meta:
34+
model = Pet
35+
36+
37+
class Query(graphene.ObjectType):
38+
allPets = SQLAlchemyConnectionField(PetNode.connection)
39+
40+
41+
Simple filter example
42+
---------------------
43+
44+
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:
45+
46+
.. code::
47+
48+
allPets(filter: {name: {eq: "Fido"}}){
49+
edges {
50+
node {
51+
name
52+
}
53+
}
54+
}
55+
56+
This will return all pets with the name "Fido".
57+
58+
59+
Custom filter types
60+
-------------------
61+
62+
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:
63+
64+
.. code:: python
65+
66+
class MathFilter(FloatFilter):
67+
class Meta:
68+
graphene_type = graphene.Float
69+
70+
@classmethod
71+
def divisible_by_filter(cls, query, field, val: int) -> bool:
72+
return is_(field % val, 0)
73+
74+
class PetType(SQLAlchemyObjectType):
75+
...
76+
77+
age = ORMField(filter_type=MathFilter)
78+
79+
class Query(graphene.ObjectType):
80+
pets = SQLAlchemyConnectionField(PetType.connection)
81+
82+
83+
Filtering over relationships with RelationshipFilter
84+
----------------------------------------------------
85+
86+
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.
87+
88+
89+
:1 relationships
90+
^^^^^^^^^^^^^^^^
91+
92+
When an object or interface defines a singular relationship, relationship object attributes may be filtered directly like so:
93+
94+
Take the following SQLAlchemy model definition as an example:
95+
96+
.. code:: python
97+
98+
class Pet
99+
...
100+
person_id = Column(Integer(), ForeignKey("people.id"))
101+
102+
class Person
103+
...
104+
pets = relationship("Pet", backref="person")
105+
106+
107+
Then, this query will return all pets whose person is named "Ada":
108+
109+
.. code::
110+
111+
allPets(filter: {
112+
person: {name: {eq: "Ada"}}
113+
}) {
114+
...
115+
}
116+
117+
118+
:n relationships
119+
^^^^^^^^^^^^^^^^
120+
121+
However, for plural relationships, relationship object attributes must be filtered through either ``contains`` or ``containsExactly``:
122+
123+
Now, using a many-to-many model definition:
124+
125+
.. code:: python
126+
127+
people_pets_table = sqlalchemy.Table(
128+
"people_pets",
129+
Base.metadata,
130+
Column("person_id", ForeignKey("people.id")),
131+
Column("pet_id", ForeignKey("pets.id")),
132+
)
133+
134+
class Pet
135+
...
136+
137+
class Person
138+
...
139+
pets = relationship("Pet", backref="people")
140+
141+
142+
this query will return all pets which have a person named "Ben" in their ``people`` list.
143+
144+
.. code::
145+
146+
allPets(filter: {
147+
people: {
148+
contains: [{name: {eq: "Ben"}}],
149+
}
150+
}) {
151+
...
152+
}
153+
154+
155+
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.
156+
157+
.. code::
158+
159+
allPets(filter: {
160+
articles: {
161+
containsExactly: [
162+
{name: {eq: "Ada"}},
163+
{name: {eq: "Ben"}},
164+
],
165+
}
166+
}) {
167+
...
168+
}
169+
170+
And/Or Logic
171+
------------
172+
173+
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:
174+
175+
176+
.. code::
177+
178+
allPets(filter: {
179+
or: [
180+
{name: {eq: "Fido"}},
181+
{name: {eq: "Spot"}},
182+
]
183+
}) {
184+
...
185+
}
186+
187+
And to return all pets that are named "Fido" or are 5 years old and named "Spot", use:
188+
189+
.. code::
190+
191+
allPets(filter: {
192+
or: [
193+
{name: {eq: "Fido"}},
194+
{ and: [
195+
{name: {eq: "Spot"}},
196+
{age: {eq: 5}}
197+
}
198+
]
199+
}) {
200+
...
201+
}
202+
203+
204+
Hybrid Property support
205+
-----------------------
206+
207+
Filtering over SQLAlchemy `hybrid properties <https://docs.sqlalchemy.org/en/20/orm/extensions/hybrid.html>`_ is fully supported.
208+
209+
210+
Reporting feedback and bugs
211+
---------------------------
212+
213+
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.

docs/index.rst

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Contents:
1010
inheritance
1111
relay
1212
tips
13+
filters
1314
examples
1415
tutorial
1516
api

examples/filters/README.md

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
Example Filters Project
2+
================================
3+
4+
This example highlights the ability to filter queries in graphene-sqlalchemy.
5+
6+
The project contains two models, one named `Department` and another
7+
named `Employee`.
8+
9+
Getting started
10+
---------------
11+
12+
First you'll need to get the source of the project. Do this by cloning the
13+
whole Graphene-SQLAlchemy repository:
14+
15+
```bash
16+
# Get the example project code
17+
git clone https://github.com/graphql-python/graphene-sqlalchemy.git
18+
cd graphene-sqlalchemy/examples/filters
19+
```
20+
21+
It is recommended to create a virtual environment
22+
for this project. We'll do this using
23+
[virtualenv](http://docs.python-guide.org/en/latest/dev/virtualenvs/)
24+
to keep things simple,
25+
but you may also find something like
26+
[virtualenvwrapper](https://virtualenvwrapper.readthedocs.org/en/latest/)
27+
to be useful:
28+
29+
```bash
30+
# Create a virtualenv in which we can install the dependencies
31+
virtualenv env
32+
source env/bin/activate
33+
```
34+
35+
Install our dependencies:
36+
37+
```bash
38+
pip install -r requirements.txt
39+
```
40+
41+
The following command will setup the database, and start the server:
42+
43+
```bash
44+
python app.py
45+
```
46+
47+
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!

examples/filters/__init__.py

Whitespace-only changes.

examples/filters/app.py

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from database import init_db
2+
from fastapi import FastAPI
3+
from schema import schema
4+
from starlette_graphene3 import GraphQLApp, make_playground_handler
5+
6+
7+
def create_app() -> FastAPI:
8+
init_db()
9+
app = FastAPI()
10+
11+
app.mount("/graphql", GraphQLApp(schema, on_get=make_playground_handler()))
12+
13+
return app
14+
15+
16+
app = create_app()

examples/filters/database.py

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from sqlalchemy import create_engine
2+
from sqlalchemy.ext.declarative import declarative_base
3+
from sqlalchemy.orm import sessionmaker
4+
5+
Base = declarative_base()
6+
engine = create_engine(
7+
"sqlite://", connect_args={"check_same_thread": False}, echo=True
8+
)
9+
session_factory = sessionmaker(autocommit=False, autoflush=False, bind=engine)
10+
11+
from sqlalchemy.orm import scoped_session as scoped_session_factory
12+
13+
scoped_session = scoped_session_factory(session_factory)
14+
15+
Base.query = scoped_session.query_property()
16+
Base.metadata.bind = engine
17+
18+
19+
def init_db():
20+
from models import Person, Pet, Toy
21+
22+
Base.metadata.create_all()
23+
scoped_session.execute("PRAGMA foreign_keys=on")
24+
db = scoped_session()
25+
26+
person1 = Person(name="A")
27+
person2 = Person(name="B")
28+
29+
pet1 = Pet(name="Spot")
30+
pet2 = Pet(name="Milo")
31+
32+
toy1 = Toy(name="disc")
33+
toy2 = Toy(name="ball")
34+
35+
person1.pet = pet1
36+
person2.pet = pet2
37+
38+
pet1.toys.append(toy1)
39+
pet2.toys.append(toy1)
40+
pet2.toys.append(toy2)
41+
42+
db.add(person1)
43+
db.add(person2)
44+
db.add(pet1)
45+
db.add(pet2)
46+
db.add(toy1)
47+
db.add(toy2)
48+
49+
db.commit()

examples/filters/models.py

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import sqlalchemy
2+
from database import Base
3+
from sqlalchemy import Column, ForeignKey, Integer, String
4+
from sqlalchemy.orm import relationship
5+
6+
7+
class Pet(Base):
8+
__tablename__ = "pets"
9+
id = Column(Integer(), primary_key=True)
10+
name = Column(String(30))
11+
age = Column(Integer())
12+
person_id = Column(Integer(), ForeignKey("people.id"))
13+
14+
15+
class Person(Base):
16+
__tablename__ = "people"
17+
id = Column(Integer(), primary_key=True)
18+
name = Column(String(100))
19+
pets = relationship("Pet", backref="person")
20+
21+
22+
pets_toys_table = sqlalchemy.Table(
23+
"pets_toys",
24+
Base.metadata,
25+
Column("pet_id", ForeignKey("pets.id")),
26+
Column("toy_id", ForeignKey("toys.id")),
27+
)
28+
29+
30+
class Toy(Base):
31+
__tablename__ = "toys"
32+
id = Column(Integer(), primary_key=True)
33+
name = Column(String(30))
34+
pets = relationship("Pet", secondary=pets_toys_table, backref="toys")

0 commit comments

Comments
 (0)