sqlalchemy-declarative-filters 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sqlalchemy_declarative_filters-0.1.0/.gitignore +36 -0
- sqlalchemy_declarative_filters-0.1.0/CHANGELOG.md +36 -0
- sqlalchemy_declarative_filters-0.1.0/LICENSE.md +21 -0
- sqlalchemy_declarative_filters-0.1.0/PKG-INFO +406 -0
- sqlalchemy_declarative_filters-0.1.0/README.md +374 -0
- sqlalchemy_declarative_filters-0.1.0/pyproject.toml +111 -0
- sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/__init__.py +41 -0
- sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/__init__.pyi +112 -0
- sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/_backends/__init__.py +59 -0
- sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/_backends/base.py +56 -0
- sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/_backends/dataclass.py +81 -0
- sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/_backends/marshmallow.py +128 -0
- sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/_backends/pydantic.py +64 -0
- sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/_decorators.py +55 -0
- sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/_exceptions.py +43 -0
- sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/_joins.py +313 -0
- sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/_meta.py +235 -0
- sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/_spec.py +182 -0
- sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/marshmallow.py +50 -0
- sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/marshmallow.pyi +46 -0
- sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/py.typed +0 -0
- sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/pydantic.py +44 -0
- sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/pydantic.pyi +60 -0
- sqlalchemy_declarative_filters-0.1.0/tests/__init__.py +0 -0
- sqlalchemy_declarative_filters-0.1.0/tests/conftest.py +102 -0
- sqlalchemy_declarative_filters-0.1.0/tests/test_core.py +626 -0
- sqlalchemy_declarative_filters-0.1.0/tests/test_marshmallow.py +222 -0
- sqlalchemy_declarative_filters-0.1.0/tests/test_pydantic.py +198 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Byte-compiled / cache
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# Build artifacts
|
|
7
|
+
build/
|
|
8
|
+
dist/
|
|
9
|
+
*.egg-info/
|
|
10
|
+
.eggs/
|
|
11
|
+
|
|
12
|
+
# Environments
|
|
13
|
+
.venv/
|
|
14
|
+
venv/
|
|
15
|
+
env/
|
|
16
|
+
|
|
17
|
+
# Tooling caches
|
|
18
|
+
.pytest_cache/
|
|
19
|
+
.mypy_cache/
|
|
20
|
+
.ruff_cache/
|
|
21
|
+
.tox/
|
|
22
|
+
.nox/
|
|
23
|
+
.coverage
|
|
24
|
+
.coverage.*
|
|
25
|
+
coverage.xml
|
|
26
|
+
htmlcov/
|
|
27
|
+
|
|
28
|
+
# Editors / OS
|
|
29
|
+
.idea/
|
|
30
|
+
.vscode/
|
|
31
|
+
*.swp
|
|
32
|
+
.DS_Store
|
|
33
|
+
|
|
34
|
+
# Local scratch
|
|
35
|
+
*.db
|
|
36
|
+
*.sqlite3
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-09-07
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `Filters` base class: every public method in the body becomes a filter, with the
|
|
14
|
+
value parameter's annotation, default and docstring driving the generated schema.
|
|
15
|
+
- Three schema backends behind identical namespaces, so switching is one import line:
|
|
16
|
+
`sqlalchemy_declarative_filters` (dataclass, no dependencies),
|
|
17
|
+
`.pydantic` and `.marshmallow` (the `pydantic` and `marshmallow` extras).
|
|
18
|
+
- `Schema` / `Model` on a filters class, plus `Dataclass`, `Pydantic` and
|
|
19
|
+
`Marshmallow` for reaching a second backend from the same class. All built lazily
|
|
20
|
+
and cached per class.
|
|
21
|
+
- `self` inside a filter is a `Statement`, which forwards everything to the SQLAlchemy
|
|
22
|
+
statement untouched except `join`, which is idempotent. Every filter in one `apply`
|
|
23
|
+
shares the same record of what has been joined, seeded from the incoming statement,
|
|
24
|
+
so a target is joined at most once however many filters ask for it and never if the
|
|
25
|
+
caller joined it first. Conflicting join options raise `JoinConflictWarning` rather
|
|
26
|
+
than passing silently. `Statement.unwrap()` returns the raw statement for the places
|
|
27
|
+
SQLAlchemy inspects an argument's type.
|
|
28
|
+
- `@options` passes field keywords to the active backend verbatim; each namespace
|
|
29
|
+
ships a stub pinning them to that backend's field constructor.
|
|
30
|
+
- `@skip_null` lets an explicit null -- or the strings `""`, `"null"`, `"none"` --
|
|
31
|
+
switch off a filter that declares a default.
|
|
32
|
+
- Filters are inherited, so a project can define one base class and extend it.
|
|
33
|
+
- Typed: `py.typed` plus stubs for the three public namespaces.
|
|
34
|
+
|
|
35
|
+
[Unreleased]: https://github.com/no1sebomb/sqlalchemy-declarative-filters/compare/v0.1.0...HEAD
|
|
36
|
+
[0.1.0]: https://github.com/no1sebomb/sqlalchemy-declarative-filters/releases/tag/v0.1.0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 no1sebomb
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sqlalchemy-declarative-filters
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Declarative SQLAlchemy filters with auto-generated dataclass, Pydantic or Marshmallow schemas.
|
|
5
|
+
Project-URL: Homepage, https://github.com/no1sebomb/sqlalchemy-declarative-filters
|
|
6
|
+
Project-URL: Repository, https://github.com/no1sebomb/sqlalchemy-declarative-filters
|
|
7
|
+
Project-URL: Issues, https://github.com/no1sebomb/sqlalchemy-declarative-filters/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/no1sebomb/sqlalchemy-declarative-filters/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: no1sebomb <noisebombch@gmail.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE.md
|
|
12
|
+
Keywords: dataclasses,fastapi,filters,marshmallow,pydantic,query,sqlalchemy
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Database
|
|
22
|
+
Classifier: Topic :: Database :: Front-Ends
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: sqlalchemy<3.0,>=2.0
|
|
27
|
+
Provides-Extra: marshmallow
|
|
28
|
+
Requires-Dist: marshmallow<5.0,>=3.18; extra == 'marshmallow'
|
|
29
|
+
Provides-Extra: pydantic
|
|
30
|
+
Requires-Dist: pydantic<3.0,>=2.0; extra == 'pydantic'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# sqlalchemy-declarative-filters
|
|
34
|
+
|
|
35
|
+
[](https://github.com/no1sebomb/sqlalchemy-declarative-filters/actions/workflows/ci.yml)
|
|
36
|
+
[](https://pypi.org/project/sqlalchemy-declarative-filters/)
|
|
37
|
+
[](https://pypi.org/project/sqlalchemy-declarative-filters/)
|
|
38
|
+
[](https://www.sqlalchemy.org/)
|
|
39
|
+
[](LICENSE.md)
|
|
40
|
+
|
|
41
|
+
Declare a filter set once, as a class. Get a validated schema and a SQLAlchemy
|
|
42
|
+
statement builder out of it.
|
|
43
|
+
|
|
44
|
+
> **Status: pre-release.** The API is not stable yet.
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from sqlalchemy_declarative_filters import Filters, options, skip_null
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class BookFilters(Filters):
|
|
51
|
+
"""Parameters for filtering the book catalogue."""
|
|
52
|
+
|
|
53
|
+
def ids(self, value: list[int]):
|
|
54
|
+
"""Only books with one of these IDs."""
|
|
55
|
+
return self.where(Book.id.in_(value))
|
|
56
|
+
|
|
57
|
+
@options(metadata={"example": "tomb"})
|
|
58
|
+
def title(self, value: str):
|
|
59
|
+
"""Books whose title contains this."""
|
|
60
|
+
return self.where(Book.title.ilike(f"%{value}%"))
|
|
61
|
+
|
|
62
|
+
def genres(self, value: list[Genre]):
|
|
63
|
+
"""Books of any of these genres."""
|
|
64
|
+
return self.where(Book.genre.in_(value))
|
|
65
|
+
|
|
66
|
+
def author_name(self, value: str):
|
|
67
|
+
"""Books whose author's name contains this."""
|
|
68
|
+
return self.join(Book.author).where(Author.name.ilike(f"%{value}%"))
|
|
69
|
+
|
|
70
|
+
@skip_null
|
|
71
|
+
def availability(self, value: Availability = Availability.IN_PRINT):
|
|
72
|
+
"""Books with this availability. Pass null for any."""
|
|
73
|
+
return self.where(Book.availability == value)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
statement = BookFilters.apply(select(Book), {"title": "tomb", "genres": [Genre.POETRY]})
|
|
78
|
+
# SELECT ... FROM book
|
|
79
|
+
# WHERE lower(book.title) LIKE lower(:t) AND book.genre IN (...)
|
|
80
|
+
# AND book.availability = :a <- the declared default, applied
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`BookFilters.Schema` is a generated schema class -- a dataclass by default, a Pydantic
|
|
84
|
+
model or a Marshmallow schema if you ask for one. It is built from the same
|
|
85
|
+
declarations: the annotation becomes the field's type, the default becomes its
|
|
86
|
+
default, the docstring becomes its description.
|
|
87
|
+
|
|
88
|
+
## Install
|
|
89
|
+
|
|
90
|
+
```console
|
|
91
|
+
pip install sqlalchemy-declarative-filters # dataclass schemas
|
|
92
|
+
pip install sqlalchemy-declarative-filters[pydantic] # Pydantic models
|
|
93
|
+
pip install sqlalchemy-declarative-filters[marshmallow] # Marshmallow schemas
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
SQLAlchemy 2.0 is the only hard dependency. Python 3.10+.
|
|
97
|
+
|
|
98
|
+
## Writing filters
|
|
99
|
+
|
|
100
|
+
Every public method in the class body is a filter. It takes the statement being built
|
|
101
|
+
as `self` and the filter's value as its only other parameter, and returns the narrowed
|
|
102
|
+
statement. Methods are never bound -- `apply` calls them with the statement in the
|
|
103
|
+
`self` position -- so `self.where(...)` is SQLAlchemy's own `where`.
|
|
104
|
+
|
|
105
|
+
| declaration | effect |
|
|
106
|
+
| --- | --- |
|
|
107
|
+
| the value's annotation | the schema field's type |
|
|
108
|
+
| the value's default | the schema field's default, always applied |
|
|
109
|
+
| the docstring | the schema field's description |
|
|
110
|
+
| a leading `_` | not a filter |
|
|
111
|
+
|
|
112
|
+
Five names are unavailable: `where`, `having`, `join`, `outerjoin` and `unwrap`. Those
|
|
113
|
+
are the methods a filter body calls on `self`, so a type checker would read a filter
|
|
114
|
+
of that name as an override of the method rather than as a filter. Declaring one
|
|
115
|
+
raises `FilterDeclarationError` -- it is never silently ignored. Everything else a
|
|
116
|
+
statement offers, `order_by` and `distinct` and the rest, is still a usable filter
|
|
117
|
+
name. If the *incoming parameter* has to be called `where`, rename the method and
|
|
118
|
+
alias the field: `@options(alias="where")` on Pydantic, `data_key="where"` on
|
|
119
|
+
Marshmallow.
|
|
120
|
+
|
|
121
|
+
A filter with **no default** is skipped when its value is `None`; omitting it is how
|
|
122
|
+
you say "do not filter on this". A filter **with** a default is always applied, even
|
|
123
|
+
when the caller does not mention it. To let a caller switch a default off, add
|
|
124
|
+
`@skip_null`: the field then accepts `None`, and the strings `""`, `"null"` and
|
|
125
|
+
`"none"` so a query string can say it too.
|
|
126
|
+
|
|
127
|
+
Filters are inherited, so a project usually defines one base and extends it. A
|
|
128
|
+
subclass redefining a name overrides that filter in place.
|
|
129
|
+
|
|
130
|
+
### What a filter can be
|
|
131
|
+
|
|
132
|
+
Anything you can write as a `where` clause. The annotation is the only thing the
|
|
133
|
+
schema needs, so the whole range works:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
def ids(self, value: list[int]): # a list of primary keys
|
|
137
|
+
"""Only books with one of these IDs."""
|
|
138
|
+
return self.where(Book.id.in_(value))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def title(self, value: str): # substring match
|
|
142
|
+
"""Books whose title contains this."""
|
|
143
|
+
return self.where(Book.title.ilike(f"%{value}%"))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def genre(self, value: Genre): # an enum
|
|
147
|
+
"""Books of this genre."""
|
|
148
|
+
return self.where(Book.genre == value)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def genres(self, value: list[Genre]): # a list of enums
|
|
152
|
+
"""Books of any of these genres."""
|
|
153
|
+
return self.where(Book.genre.in_(value))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def published_after(self, value: int): # a number
|
|
157
|
+
"""Books first published after this year."""
|
|
158
|
+
return self.where(Book.published_in > value)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def max_price(self, value: decimal.Decimal): # a decimal
|
|
162
|
+
"""Books at most this expensive."""
|
|
163
|
+
return self.where(Book.price <= value)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def released_after(self, value: datetime.date): # a date
|
|
167
|
+
"""Books released after this date."""
|
|
168
|
+
return self.where(Book.released_on > value)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def has_publisher(self, value: bool): # a flag, either way round
|
|
172
|
+
"""Books that do, or do not, have a publisher."""
|
|
173
|
+
return self.where(Book.publisher_id.is_not(None) if value else Book.publisher_id.is_(None))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@skip_null
|
|
177
|
+
def availability(self, value: Availability = Availability.IN_PRINT):
|
|
178
|
+
"""Applied with IN_PRINT unless the caller says otherwise; null switches it off."""
|
|
179
|
+
return self.where(Book.availability == value)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def author_name(self, value: str): # reaches another table
|
|
183
|
+
"""Books whose author's name contains this."""
|
|
184
|
+
return self.join(Book.author).where(Author.name.ilike(f"%{value}%"))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def min_rating(self, value: int): # reaches one without a join
|
|
188
|
+
"""Books with at least one review scoring this or better."""
|
|
189
|
+
return self.where(Book.reviews.any(Review.rating >= value))
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Picking a backend
|
|
193
|
+
|
|
194
|
+
The three namespaces export the same names, so a project changes backend by editing
|
|
195
|
+
one import:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
from sqlalchemy_declarative_filters import Filters, options, skip_null # dataclass
|
|
199
|
+
from sqlalchemy_declarative_filters.pydantic import Filters, options, skip_null # Pydantic
|
|
200
|
+
from sqlalchemy_declarative_filters.marshmallow import Filters, options, skip_null # Marshmallow
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Do it once in your own base module and the filter classes themselves never mention a
|
|
204
|
+
backend:
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
# db/filters.py
|
|
208
|
+
from sqlalchemy_declarative_filters.pydantic import Filters, Statement, options, skip_null
|
|
209
|
+
|
|
210
|
+
__all__ = ("Filters", "Statement", "options", "skip_null")
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
# catalogue/filters.py
|
|
215
|
+
from db.filters import Filters, options, skip_null
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class BookFilters(Filters): ...
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
`Filters`, `PydanticFilters` and `MarshmallowFilters` name the same classes; use the
|
|
222
|
+
long form when two backends meet in one module.
|
|
223
|
+
|
|
224
|
+
| | dataclass | Pydantic | Marshmallow |
|
|
225
|
+
| --- | --- | --- | --- |
|
|
226
|
+
| dependency | none | `[pydantic]` | `[marshmallow]` |
|
|
227
|
+
| `Schema` is | a `@dataclass` | a `BaseModel` subclass | a `Schema` subclass |
|
|
228
|
+
| validates | no | yes | yes |
|
|
229
|
+
| `@options` takes | `dataclasses.field` keywords | `pydantic.Field` keywords | `marshmallow.fields.Field` keywords |
|
|
230
|
+
| feed `apply` | the instance | the instance | `Schema().load(params)`, which is a dict |
|
|
231
|
+
|
|
232
|
+
The dataclass backend is a typed container, not a validator. Nothing checks that the
|
|
233
|
+
values match their annotations. It exists so the library installs with no dependencies
|
|
234
|
+
and so `apply(statement, some_dict)` needs nothing at all; reach for Pydantic or
|
|
235
|
+
Marshmallow when the values come from outside your own code.
|
|
236
|
+
|
|
237
|
+
Any class can reach any backend -- `BookFilters.Dataclass`, `.Pydantic`,
|
|
238
|
+
`.Marshmallow` -- each built lazily and cached. `Model` is an alias of `Schema`.
|
|
239
|
+
|
|
240
|
+
### `@options` is backend-specific
|
|
241
|
+
|
|
242
|
+
Keywords go to the active backend's own field constructor, untranslated, and each
|
|
243
|
+
namespace ships a type stub pinning them to it. So you get real completion:
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
@options(min_length=3, examples=["tomb"]) # .pydantic -> pydantic.Field
|
|
247
|
+
@options(validate=validate.Length(min=3)) # .marshmallow -> fields.Field
|
|
248
|
+
@options(metadata={"example": "tomb"}) # dataclass -> dataclasses.field
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Mixing them is an error you get at schema-build time, naming the backend, not a silent
|
|
252
|
+
misconfiguration. Import `options` from the same place as `Filters` and this cannot
|
|
253
|
+
happen.
|
|
254
|
+
|
|
255
|
+
## With FastAPI
|
|
256
|
+
|
|
257
|
+
```python
|
|
258
|
+
from catalogue.filters import BookFilters # a .pydantic Filters subclass
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@router.get("/books")
|
|
262
|
+
async def get_books(
|
|
263
|
+
filters: Annotated[BookFilters.Schema, Query()],
|
|
264
|
+
db_session: AsyncSession = Depends(get_session),
|
|
265
|
+
):
|
|
266
|
+
return await db_session.scalars(BookFilters.apply(select(Book), filters))
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
The generated model carries the descriptions and constraints, so the OpenAPI document
|
|
270
|
+
documents itself.
|
|
271
|
+
|
|
272
|
+
> Do not reach for `model_dump(exclude_unset=True)` on the way in. FastAPI leaves an
|
|
273
|
+
> unprovided query parameter unset, so a filter declaring `availability: Availability =
|
|
274
|
+
> Availability.IN_PRINT` would be dropped before it was ever applied -- the default
|
|
275
|
+
> silently would not hold. `apply` takes the model directly and applies declared
|
|
276
|
+
> defaults.
|
|
277
|
+
|
|
278
|
+
## Joins
|
|
279
|
+
|
|
280
|
+
SQLAlchemy does not deduplicate joins. `select(Book).join(Author).join(Author)`
|
|
281
|
+
compiles to `FROM book JOIN author ON ... JOIN author ON ...`, which most databases
|
|
282
|
+
reject and the rest answer wrongly. So two filters that both need the same join cannot
|
|
283
|
+
each call `.join()` on the statement, and neither can a filter whose join the caller
|
|
284
|
+
already added.
|
|
285
|
+
|
|
286
|
+
`self.join()` can, because `self` is not the raw statement. It is a `Statement`: every
|
|
287
|
+
method forwards to the statement untouched, except `join`, which deduplicates first.
|
|
288
|
+
All the filters in one `apply` share the same record of what has been joined, seeded
|
|
289
|
+
from the statement that came in.
|
|
290
|
+
|
|
291
|
+
```python
|
|
292
|
+
class BookFilters(Filters):
|
|
293
|
+
def author_name(self, value: str):
|
|
294
|
+
"""Books whose author's name contains this."""
|
|
295
|
+
return self.join(Book.author).where(Author.name.ilike(f"%{value}%"))
|
|
296
|
+
|
|
297
|
+
def author_country(self, value: str):
|
|
298
|
+
"""Books by an author from this country."""
|
|
299
|
+
return self.join(Book.author).where(Author.country == value)
|
|
300
|
+
|
|
301
|
+
def publisher_name(self, value: str):
|
|
302
|
+
"""Books from this publisher."""
|
|
303
|
+
return self.outerjoin(Book.publisher).where(Publisher.name == value)
|
|
304
|
+
|
|
305
|
+
def tag(self, value: str):
|
|
306
|
+
"""Books carrying this tag."""
|
|
307
|
+
return self.join(Book.tags).where(Tag.name == value)
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
```python
|
|
311
|
+
BookFilters.apply(select(Book), {"author_name": "borges", "author_country": "AR"})
|
|
312
|
+
# ... FROM book JOIN author ON ... <- once
|
|
313
|
+
BookFilters.apply(select(Book).join(Author), {"author_name": "borges"})
|
|
314
|
+
# ... FROM book JOIN author ON ... <- still once
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Because the join is a statement, not a declaration, it can be conditional:
|
|
318
|
+
|
|
319
|
+
```python
|
|
320
|
+
def written_or_titled(self, value: str):
|
|
321
|
+
"""Books by this author, or with this in the title."""
|
|
322
|
+
if value.startswith("by:"):
|
|
323
|
+
return self.join(Book.author).where(Author.name == value[3:])
|
|
324
|
+
return self.where(Book.title.ilike(f"%{value}%"))
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
A many-to-many relationship brings in its association table on its own. Targets are
|
|
328
|
+
matched by the selectable they resolve to, so `Book.author`, `Author` and
|
|
329
|
+
`Author.__table__` are one target, while two `aliased(Author)` constructs are two.
|
|
330
|
+
When two filters ask for the same target with different options, the first wins and a
|
|
331
|
+
`JoinConflictWarning` says so.
|
|
332
|
+
|
|
333
|
+
### Prefer not to join
|
|
334
|
+
|
|
335
|
+
For pure filtering, a correlated predicate beats a join. It cannot collide with another
|
|
336
|
+
filter's join, and -- unlike a join to a collection -- it does not multiply result rows
|
|
337
|
+
and inflate the count behind your pagination:
|
|
338
|
+
|
|
339
|
+
```python
|
|
340
|
+
def author_born_after(self, value: int):
|
|
341
|
+
"""Books by an author born after this year."""
|
|
342
|
+
return self.where(Book.author.has(Author.born_in > value))
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def min_rating(self, value: int):
|
|
346
|
+
"""Books with at least one review scoring this or better."""
|
|
347
|
+
return self.where(Book.reviews.any(Review.rating >= value))
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Keep `join` for when you need the joined table for something other than the predicate,
|
|
351
|
+
such as ordering by one of its columns.
|
|
352
|
+
|
|
353
|
+
`Exists` has no `.join()`, so a filter set applied to one must not call `self.join()`.
|
|
354
|
+
|
|
355
|
+
### Getting the raw statement back
|
|
356
|
+
|
|
357
|
+
`apply` returns a plain SQLAlchemy statement, so callers never see the wrapper. Inside
|
|
358
|
+
a filter, the few places SQLAlchemy inspects an argument's type rather than calling a
|
|
359
|
+
method on it -- `Column.in_()`, `union()` -- need `self.unwrap()`:
|
|
360
|
+
|
|
361
|
+
```python
|
|
362
|
+
def cheapest(self, value: int):
|
|
363
|
+
"""Books among the N cheapest."""
|
|
364
|
+
inner = self.with_only_columns(Book.id).order_by(Book.price).limit(value).unwrap()
|
|
365
|
+
return self.where(Book.id.in_(inner))
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Joins stay deduplicated afterwards: whatever a filter returns is re-wrapped with the
|
|
369
|
+
same join record before the next filter runs.
|
|
370
|
+
|
|
371
|
+
## Reference
|
|
372
|
+
|
|
373
|
+
```python
|
|
374
|
+
BookFilters.Schema # the generated schema, in this class's backend
|
|
375
|
+
BookFilters.Model # alias of Schema
|
|
376
|
+
BookFilters.Dataclass # the same filters as a dataclass
|
|
377
|
+
BookFilters.Pydantic # ... as a Pydantic model
|
|
378
|
+
BookFilters.Marshmallow # ... as a Marshmallow schema
|
|
379
|
+
BookFilters.apply(statement, values=None) # values: a mapping, a schema instance, or None
|
|
380
|
+
BookFilters.__filters__ # the collected FilterSpec objects
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
Class attributes you can set on a filters class:
|
|
384
|
+
|
|
385
|
+
```python
|
|
386
|
+
__backend__ # "dataclass" | "pydantic" | "marshmallow"; normally set by the base
|
|
387
|
+
__null_strings__ # strings that mean null on a @skip_null filter
|
|
388
|
+
__schema_name__ # overrides the generated schema's class name
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
`Statement` is what `self` is and what a filter returns, so under a strict type checker
|
|
392
|
+
that is the return annotation:
|
|
393
|
+
|
|
394
|
+
```python
|
|
395
|
+
def title(self, value: str) -> Statement:
|
|
396
|
+
"""Books whose title contains this."""
|
|
397
|
+
return self.where(Book.title.ilike(f"%{value}%"))
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
Errors all derive from `FilterError`: `FilterDeclarationError` for a filter that cannot
|
|
401
|
+
be turned into a field, `UnknownFilterError` for a value with no matching filter,
|
|
402
|
+
`BackendNotAvailableError` when an extra is missing.
|
|
403
|
+
|
|
404
|
+
## Licence
|
|
405
|
+
|
|
406
|
+
MIT.
|