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.
Files changed (28) hide show
  1. sqlalchemy_declarative_filters-0.1.0/.gitignore +36 -0
  2. sqlalchemy_declarative_filters-0.1.0/CHANGELOG.md +36 -0
  3. sqlalchemy_declarative_filters-0.1.0/LICENSE.md +21 -0
  4. sqlalchemy_declarative_filters-0.1.0/PKG-INFO +406 -0
  5. sqlalchemy_declarative_filters-0.1.0/README.md +374 -0
  6. sqlalchemy_declarative_filters-0.1.0/pyproject.toml +111 -0
  7. sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/__init__.py +41 -0
  8. sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/__init__.pyi +112 -0
  9. sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/_backends/__init__.py +59 -0
  10. sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/_backends/base.py +56 -0
  11. sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/_backends/dataclass.py +81 -0
  12. sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/_backends/marshmallow.py +128 -0
  13. sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/_backends/pydantic.py +64 -0
  14. sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/_decorators.py +55 -0
  15. sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/_exceptions.py +43 -0
  16. sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/_joins.py +313 -0
  17. sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/_meta.py +235 -0
  18. sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/_spec.py +182 -0
  19. sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/marshmallow.py +50 -0
  20. sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/marshmallow.pyi +46 -0
  21. sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/py.typed +0 -0
  22. sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/pydantic.py +44 -0
  23. sqlalchemy_declarative_filters-0.1.0/src/sqlalchemy_declarative_filters/pydantic.pyi +60 -0
  24. sqlalchemy_declarative_filters-0.1.0/tests/__init__.py +0 -0
  25. sqlalchemy_declarative_filters-0.1.0/tests/conftest.py +102 -0
  26. sqlalchemy_declarative_filters-0.1.0/tests/test_core.py +626 -0
  27. sqlalchemy_declarative_filters-0.1.0/tests/test_marshmallow.py +222 -0
  28. 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
+ [![CI](https://github.com/no1sebomb/sqlalchemy-declarative-filters/actions/workflows/ci.yml/badge.svg)](https://github.com/no1sebomb/sqlalchemy-declarative-filters/actions/workflows/ci.yml)
36
+ [![PyPI](https://img.shields.io/pypi/v/sqlalchemy-declarative-filters.svg)](https://pypi.org/project/sqlalchemy-declarative-filters/)
37
+ [![Python](https://img.shields.io/pypi/pyversions/sqlalchemy-declarative-filters.svg)](https://pypi.org/project/sqlalchemy-declarative-filters/)
38
+ [![SQLAlchemy](https://img.shields.io/badge/SQLAlchemy-2.0-d71f00.svg)](https://www.sqlalchemy.org/)
39
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](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.