alchemy-filterset 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.
- alchemy_filterset-0.1.0/PKG-INFO +254 -0
- alchemy_filterset-0.1.0/README.md +227 -0
- alchemy_filterset-0.1.0/pyproject.toml +59 -0
- alchemy_filterset-0.1.0/src/alchemy_filterset/.DS_Store +0 -0
- alchemy_filterset-0.1.0/src/alchemy_filterset/__init__.py +17 -0
- alchemy_filterset-0.1.0/src/alchemy_filterset/exceptions.py +19 -0
- alchemy_filterset-0.1.0/src/alchemy_filterset/filterset.py +157 -0
- alchemy_filterset-0.1.0/src/alchemy_filterset/registry.py +92 -0
- alchemy_filterset-0.1.0/src/alchemy_filterset/resolver.py +91 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: alchemy-filterset
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A dynamic, Django-like filterset architecture for SQLAlchemy & Advanced Alchemy.
|
|
5
|
+
Keywords: sqlalchemy,advanced-alchemy,litestar,fastapi,filters,pydantic
|
|
6
|
+
Author: Morteza Karimi
|
|
7
|
+
Author-email: Morteza Karimi <itismortezakarimi@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Database
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Dist: advanced-alchemy>=1.11.0
|
|
20
|
+
Requires-Dist: pydantic>=2.13.4
|
|
21
|
+
Requires-Dist: sqlalchemy>=2.0.51
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Project-URL: Homepage, https://github.com/MortezaKarimi77/alchemy-filterset
|
|
24
|
+
Project-URL: Repository, https://github.com/MortezaKarimi77/alchemy-filterset.git
|
|
25
|
+
Project-URL: Issues, https://github.com/MortezaKarimi77/alchemy-filterset/issues
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# Alchemy FilterSet 🚀
|
|
29
|
+
|
|
30
|
+
A powerful, dynamic, and type-safe filtering architecture for **SQLAlchemy 2.0** and **Advanced Alchemy**, heavily inspired by `django-filters`.
|
|
31
|
+
|
|
32
|
+
## Why Alchemy FilterSet?
|
|
33
|
+
|
|
34
|
+
While SQLAlchemy and Advanced Alchemy provide excellent tools for querying databases, handling complex HTTP query parameters (like nested relationships, dynamic ordering, and multi-field search) often leads to messy, repetitive, and hard-to-maintain code.
|
|
35
|
+
|
|
36
|
+
`alchemy-filterset` bridges the gap between your Web Framework (FastAPI, Litestar, etc.) and your Database by providing a declarative, Pydantic-powered filter class that securely translates user requests into efficient SQL `EXISTS` expressions.
|
|
37
|
+
|
|
38
|
+
## Design Goals
|
|
39
|
+
|
|
40
|
+
Alchemy FilterSet is designed around a few core principles:
|
|
41
|
+
|
|
42
|
+
- Declarative API inspired by Django FilterSet while remaining SQLAlchemy-native.
|
|
43
|
+
- No automatic joins; relationship filters are translated into EXISTS expressions.
|
|
44
|
+
- Type-safe query parsing powered by Pydantic v2.
|
|
45
|
+
- Extensible lookup registry for custom operators.
|
|
46
|
+
- Framework agnostic (FastAPI, Litestar, Starlette, etc.).
|
|
47
|
+
|
|
48
|
+
## Key Features
|
|
49
|
+
|
|
50
|
+
- 🔗 **Deep Nested Relationships:** Seamlessly filter across infinite layers of relationships (e.g., `province__country__name__icontains="Iran"`).
|
|
51
|
+
- 🚫 **Negation Support:** Easily exclude records using the `not__` prefix (e.g., `not__status="deleted"`).
|
|
52
|
+
- 🗂 **Smart Pagination:** Built-in, declarative pagination controls with frontend limits and backend enforcement.
|
|
53
|
+
- 🔍 **Global Multi-Field Search:** Search across multiple columns and related tables simultaneously with a single `?search=` parameter.
|
|
54
|
+
- ↕️ **Dynamic Ordering:** Sort by any field or nested relationship (e.g., `?ordering=-province__name,created_at`).
|
|
55
|
+
- 🧩 **Association Proxy Support:** Fully supports querying and ordering across SQLAlchemy's `AssociationProxy`.
|
|
56
|
+
- 🛡 **Type-Safe:** Built with Pydantic v2 and Python 3.10+ types.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## 📦 Installation
|
|
61
|
+
|
|
62
|
+
This package requires Python 3.10+ and SQLAlchemy 2.0+.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install alchemy-filterset
|
|
66
|
+
uv add alchemy-filterset
|
|
67
|
+
poetry add alchemy-filterset
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## ⚡ Quick Start
|
|
73
|
+
|
|
74
|
+
Imagine you have two SQLAlchemy models: `Country` and `Province`.
|
|
75
|
+
|
|
76
|
+
### 1. Define your FilterSet
|
|
77
|
+
|
|
78
|
+
Inherit from `SQLAlchemyFilterSet` and declare the allowed filters as Pydantic fields.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from uuid import UUID
|
|
82
|
+
from alchemy_filterset import SQLAlchemyFilterSet
|
|
83
|
+
from my_app.models import Province
|
|
84
|
+
|
|
85
|
+
class ProvinceFilter(SQLAlchemyFilterSet):
|
|
86
|
+
# 1. Bind to your SQLAlchemy Model
|
|
87
|
+
model_cls = Province
|
|
88
|
+
|
|
89
|
+
# 2. Define fields for global search (?search=...)
|
|
90
|
+
search_fields = {"name", "country__name", "country__code"}
|
|
91
|
+
|
|
92
|
+
# 3. Define allowed query parameters using double-underscore syntax
|
|
93
|
+
name__icontains: str | None = None
|
|
94
|
+
population__gt: int | None = None
|
|
95
|
+
is_active: bool | None = None
|
|
96
|
+
|
|
97
|
+
# 4. Filter across relationships seamlessly!
|
|
98
|
+
country__id: UUID | None = None
|
|
99
|
+
country__code__in: list[str] | None = None
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### 2. Use it in your API (FastAPI / Litestar Example)
|
|
103
|
+
|
|
104
|
+
Pass the query parameters to the FilterSet, call `to_statement_filters()`, and pass the result to your Advanced Alchemy repository.
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
# Example using FastAPI/Litestar dependency injection
|
|
108
|
+
@app.get("/provinces")
|
|
109
|
+
async def get_provinces(
|
|
110
|
+
filters: ProvinceFilter = Depends(),
|
|
111
|
+
repo: ProvinceRepository = Depends()
|
|
112
|
+
):
|
|
113
|
+
# 1. Translate Pydantic model to SQLAlchemy expressions
|
|
114
|
+
sql_filters = filters.to_statement_filters()
|
|
115
|
+
|
|
116
|
+
# 2. Pass them to Advanced Alchemy repository
|
|
117
|
+
provinces = await repo.get_many(*sql_filters)
|
|
118
|
+
|
|
119
|
+
return provinces
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Now, your API automatically supports queries like:
|
|
123
|
+
`GET /provinces?country__code__in=IR,US&population__gt=1000000&ordering=-name&page=2`
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## 📖 Feature Guide & Examples
|
|
128
|
+
|
|
129
|
+
### 1. Standard Lookups
|
|
130
|
+
|
|
131
|
+
By default, fields use the exact equality (`eq`) operator. You can append lookups using the `__` syntax.
|
|
132
|
+
|
|
133
|
+
Supported Lookups:
|
|
134
|
+
|
|
135
|
+
- **Comparison:** `eq`, `ne`, `gt`, `ge`, `lt`, `le`, `between`
|
|
136
|
+
- **Collection:** `in`, `notin`
|
|
137
|
+
- **Text:** `contains`, `icontains`, `not_contains`, `not_icontains`, `startswith`, `endswith`
|
|
138
|
+
- **Null Check:** `is_null`, `not_null`
|
|
139
|
+
|
|
140
|
+
```http
|
|
141
|
+
GET /api/users?age__between=18,30
|
|
142
|
+
GET /api/users?status__in=active,pending
|
|
143
|
+
GET /api/users?email__endswith=@gmail.com
|
|
144
|
+
GET /api/users?deleted_at__is_null=true
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### 2. Negation / Exclude (The `not__` prefix)
|
|
148
|
+
|
|
149
|
+
You can negate any standard lookup, nested relationship, or custom filter simply by prefixing it with `not__`.
|
|
150
|
+
This dynamically translates to `!=`, `NOT IN`, or `NOT EXISTS` in SQL.
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
class UserFilter(SQLAlchemyFilterSet):
|
|
154
|
+
model_cls = User
|
|
155
|
+
|
|
156
|
+
# Simple Negation (e.g., status != 'banned')
|
|
157
|
+
not__status: str | None = None
|
|
158
|
+
|
|
159
|
+
# Nested Negation (Users who DO NOT have a specific role)
|
|
160
|
+
not__roles__name__icontains: str | None = None
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### 3. Deep Nested Relationships (The Magic ✨)
|
|
164
|
+
|
|
165
|
+
You don't need to write complex `JOIN`s or `EXISTS` subqueries manually. Just chain relationship names separated by `__`.
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
class CityFilter(SQLAlchemyFilterSet):
|
|
169
|
+
model_cls = City
|
|
170
|
+
|
|
171
|
+
# City -> Province -> Country -> name
|
|
172
|
+
province__country__name__icontains: str | None = None
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Under the hood, this generates efficient SQL `EXISTS` queries using SQLAlchemy's `.has()` and `.any()`.
|
|
176
|
+
|
|
177
|
+
### 4. Global Search
|
|
178
|
+
|
|
179
|
+
Define `search_fields` on your class. If a user passes the `?search=` parameter, the system will apply an `icontains` filter to all specified fields, joining them with an `OR` operator.
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
class PostFilter(SQLAlchemyFilterSet):
|
|
183
|
+
model_cls = Post
|
|
184
|
+
search_fields = {"title", "content", "author__username"}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`GET /api/posts?search=python` will search for "python" in the title, content, OR the author's username.
|
|
188
|
+
|
|
189
|
+
### 5. Dynamic Ordering
|
|
190
|
+
|
|
191
|
+
Users can sort results using the `ordering` parameter. Prefix with `-` for descending order. Separate multiple fields with commas.
|
|
192
|
+
|
|
193
|
+
`GET /api/users?ordering=-created_at,last_name`
|
|
194
|
+
|
|
195
|
+
### 6. Pagination Control
|
|
196
|
+
|
|
197
|
+
Pagination is enabled by default. If the user does not provide `page` or `page_size`, the defaults are used.
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
class HeavyReportFilter(SQLAlchemyFilterSet):
|
|
201
|
+
model_cls = Report
|
|
202
|
+
|
|
203
|
+
# Customize pagination limits per class
|
|
204
|
+
default_page_size = 10
|
|
205
|
+
max_page_size = 50
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**Disabling Pagination:**
|
|
209
|
+
If an API should return all records (e.g., a dropdown list), set `enable_pagination = False`.
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
class DropdownFilter(SQLAlchemyFilterSet):
|
|
213
|
+
model_cls = Category
|
|
214
|
+
enable_pagination = False # Limits/Offsets are entirely ignored
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### 7. Custom Filter Methods
|
|
218
|
+
|
|
219
|
+
Need complex logic that doesn't fit standard lookups? Write a custom method! Name it `filter_<field_name>`.
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
class UserFilter(SQLAlchemyFilterSet):
|
|
223
|
+
model_cls = User
|
|
224
|
+
|
|
225
|
+
has_avatar: bool | None = None
|
|
226
|
+
|
|
227
|
+
def filter_has_avatar(self, value: bool):
|
|
228
|
+
if value:
|
|
229
|
+
return User.avatar_url.is_not(None)
|
|
230
|
+
return User.avatar_url.is_(None)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## 🛠 Advanced Usage: Association Proxies
|
|
236
|
+
|
|
237
|
+
`alchemy-filterset` natively supports SQLAlchemy's `AssociationProxy`. It automatically unpacks the proxy, discovers the underlying tables, and applies the filters or ordering correctly without crashing.
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
class Post(Base):
|
|
241
|
+
__tablename__ = "posts"
|
|
242
|
+
# ...
|
|
243
|
+
# Association proxy to tags
|
|
244
|
+
tags: AssociationProxy[list[str]] = association_proxy("post_tags", "tag_name")
|
|
245
|
+
|
|
246
|
+
class PostFilter(SQLAlchemyFilterSet):
|
|
247
|
+
model_cls = Post
|
|
248
|
+
# Works perfectly!
|
|
249
|
+
tags__icontains: str | None = None
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## License
|
|
253
|
+
|
|
254
|
+
MIT
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# Alchemy FilterSet 🚀
|
|
2
|
+
|
|
3
|
+
A powerful, dynamic, and type-safe filtering architecture for **SQLAlchemy 2.0** and **Advanced Alchemy**, heavily inspired by `django-filters`.
|
|
4
|
+
|
|
5
|
+
## Why Alchemy FilterSet?
|
|
6
|
+
|
|
7
|
+
While SQLAlchemy and Advanced Alchemy provide excellent tools for querying databases, handling complex HTTP query parameters (like nested relationships, dynamic ordering, and multi-field search) often leads to messy, repetitive, and hard-to-maintain code.
|
|
8
|
+
|
|
9
|
+
`alchemy-filterset` bridges the gap between your Web Framework (FastAPI, Litestar, etc.) and your Database by providing a declarative, Pydantic-powered filter class that securely translates user requests into efficient SQL `EXISTS` expressions.
|
|
10
|
+
|
|
11
|
+
## Design Goals
|
|
12
|
+
|
|
13
|
+
Alchemy FilterSet is designed around a few core principles:
|
|
14
|
+
|
|
15
|
+
- Declarative API inspired by Django FilterSet while remaining SQLAlchemy-native.
|
|
16
|
+
- No automatic joins; relationship filters are translated into EXISTS expressions.
|
|
17
|
+
- Type-safe query parsing powered by Pydantic v2.
|
|
18
|
+
- Extensible lookup registry for custom operators.
|
|
19
|
+
- Framework agnostic (FastAPI, Litestar, Starlette, etc.).
|
|
20
|
+
|
|
21
|
+
## Key Features
|
|
22
|
+
|
|
23
|
+
- 🔗 **Deep Nested Relationships:** Seamlessly filter across infinite layers of relationships (e.g., `province__country__name__icontains="Iran"`).
|
|
24
|
+
- 🚫 **Negation Support:** Easily exclude records using the `not__` prefix (e.g., `not__status="deleted"`).
|
|
25
|
+
- 🗂 **Smart Pagination:** Built-in, declarative pagination controls with frontend limits and backend enforcement.
|
|
26
|
+
- 🔍 **Global Multi-Field Search:** Search across multiple columns and related tables simultaneously with a single `?search=` parameter.
|
|
27
|
+
- ↕️ **Dynamic Ordering:** Sort by any field or nested relationship (e.g., `?ordering=-province__name,created_at`).
|
|
28
|
+
- 🧩 **Association Proxy Support:** Fully supports querying and ordering across SQLAlchemy's `AssociationProxy`.
|
|
29
|
+
- 🛡 **Type-Safe:** Built with Pydantic v2 and Python 3.10+ types.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 📦 Installation
|
|
34
|
+
|
|
35
|
+
This package requires Python 3.10+ and SQLAlchemy 2.0+.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install alchemy-filterset
|
|
39
|
+
uv add alchemy-filterset
|
|
40
|
+
poetry add alchemy-filterset
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## ⚡ Quick Start
|
|
46
|
+
|
|
47
|
+
Imagine you have two SQLAlchemy models: `Country` and `Province`.
|
|
48
|
+
|
|
49
|
+
### 1. Define your FilterSet
|
|
50
|
+
|
|
51
|
+
Inherit from `SQLAlchemyFilterSet` and declare the allowed filters as Pydantic fields.
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from uuid import UUID
|
|
55
|
+
from alchemy_filterset import SQLAlchemyFilterSet
|
|
56
|
+
from my_app.models import Province
|
|
57
|
+
|
|
58
|
+
class ProvinceFilter(SQLAlchemyFilterSet):
|
|
59
|
+
# 1. Bind to your SQLAlchemy Model
|
|
60
|
+
model_cls = Province
|
|
61
|
+
|
|
62
|
+
# 2. Define fields for global search (?search=...)
|
|
63
|
+
search_fields = {"name", "country__name", "country__code"}
|
|
64
|
+
|
|
65
|
+
# 3. Define allowed query parameters using double-underscore syntax
|
|
66
|
+
name__icontains: str | None = None
|
|
67
|
+
population__gt: int | None = None
|
|
68
|
+
is_active: bool | None = None
|
|
69
|
+
|
|
70
|
+
# 4. Filter across relationships seamlessly!
|
|
71
|
+
country__id: UUID | None = None
|
|
72
|
+
country__code__in: list[str] | None = None
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### 2. Use it in your API (FastAPI / Litestar Example)
|
|
76
|
+
|
|
77
|
+
Pass the query parameters to the FilterSet, call `to_statement_filters()`, and pass the result to your Advanced Alchemy repository.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
# Example using FastAPI/Litestar dependency injection
|
|
81
|
+
@app.get("/provinces")
|
|
82
|
+
async def get_provinces(
|
|
83
|
+
filters: ProvinceFilter = Depends(),
|
|
84
|
+
repo: ProvinceRepository = Depends()
|
|
85
|
+
):
|
|
86
|
+
# 1. Translate Pydantic model to SQLAlchemy expressions
|
|
87
|
+
sql_filters = filters.to_statement_filters()
|
|
88
|
+
|
|
89
|
+
# 2. Pass them to Advanced Alchemy repository
|
|
90
|
+
provinces = await repo.get_many(*sql_filters)
|
|
91
|
+
|
|
92
|
+
return provinces
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Now, your API automatically supports queries like:
|
|
96
|
+
`GET /provinces?country__code__in=IR,US&population__gt=1000000&ordering=-name&page=2`
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 📖 Feature Guide & Examples
|
|
101
|
+
|
|
102
|
+
### 1. Standard Lookups
|
|
103
|
+
|
|
104
|
+
By default, fields use the exact equality (`eq`) operator. You can append lookups using the `__` syntax.
|
|
105
|
+
|
|
106
|
+
Supported Lookups:
|
|
107
|
+
|
|
108
|
+
- **Comparison:** `eq`, `ne`, `gt`, `ge`, `lt`, `le`, `between`
|
|
109
|
+
- **Collection:** `in`, `notin`
|
|
110
|
+
- **Text:** `contains`, `icontains`, `not_contains`, `not_icontains`, `startswith`, `endswith`
|
|
111
|
+
- **Null Check:** `is_null`, `not_null`
|
|
112
|
+
|
|
113
|
+
```http
|
|
114
|
+
GET /api/users?age__between=18,30
|
|
115
|
+
GET /api/users?status__in=active,pending
|
|
116
|
+
GET /api/users?email__endswith=@gmail.com
|
|
117
|
+
GET /api/users?deleted_at__is_null=true
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### 2. Negation / Exclude (The `not__` prefix)
|
|
121
|
+
|
|
122
|
+
You can negate any standard lookup, nested relationship, or custom filter simply by prefixing it with `not__`.
|
|
123
|
+
This dynamically translates to `!=`, `NOT IN`, or `NOT EXISTS` in SQL.
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
class UserFilter(SQLAlchemyFilterSet):
|
|
127
|
+
model_cls = User
|
|
128
|
+
|
|
129
|
+
# Simple Negation (e.g., status != 'banned')
|
|
130
|
+
not__status: str | None = None
|
|
131
|
+
|
|
132
|
+
# Nested Negation (Users who DO NOT have a specific role)
|
|
133
|
+
not__roles__name__icontains: str | None = None
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### 3. Deep Nested Relationships (The Magic ✨)
|
|
137
|
+
|
|
138
|
+
You don't need to write complex `JOIN`s or `EXISTS` subqueries manually. Just chain relationship names separated by `__`.
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
class CityFilter(SQLAlchemyFilterSet):
|
|
142
|
+
model_cls = City
|
|
143
|
+
|
|
144
|
+
# City -> Province -> Country -> name
|
|
145
|
+
province__country__name__icontains: str | None = None
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Under the hood, this generates efficient SQL `EXISTS` queries using SQLAlchemy's `.has()` and `.any()`.
|
|
149
|
+
|
|
150
|
+
### 4. Global Search
|
|
151
|
+
|
|
152
|
+
Define `search_fields` on your class. If a user passes the `?search=` parameter, the system will apply an `icontains` filter to all specified fields, joining them with an `OR` operator.
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
class PostFilter(SQLAlchemyFilterSet):
|
|
156
|
+
model_cls = Post
|
|
157
|
+
search_fields = {"title", "content", "author__username"}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`GET /api/posts?search=python` will search for "python" in the title, content, OR the author's username.
|
|
161
|
+
|
|
162
|
+
### 5. Dynamic Ordering
|
|
163
|
+
|
|
164
|
+
Users can sort results using the `ordering` parameter. Prefix with `-` for descending order. Separate multiple fields with commas.
|
|
165
|
+
|
|
166
|
+
`GET /api/users?ordering=-created_at,last_name`
|
|
167
|
+
|
|
168
|
+
### 6. Pagination Control
|
|
169
|
+
|
|
170
|
+
Pagination is enabled by default. If the user does not provide `page` or `page_size`, the defaults are used.
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
class HeavyReportFilter(SQLAlchemyFilterSet):
|
|
174
|
+
model_cls = Report
|
|
175
|
+
|
|
176
|
+
# Customize pagination limits per class
|
|
177
|
+
default_page_size = 10
|
|
178
|
+
max_page_size = 50
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**Disabling Pagination:**
|
|
182
|
+
If an API should return all records (e.g., a dropdown list), set `enable_pagination = False`.
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
class DropdownFilter(SQLAlchemyFilterSet):
|
|
186
|
+
model_cls = Category
|
|
187
|
+
enable_pagination = False # Limits/Offsets are entirely ignored
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### 7. Custom Filter Methods
|
|
191
|
+
|
|
192
|
+
Need complex logic that doesn't fit standard lookups? Write a custom method! Name it `filter_<field_name>`.
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
class UserFilter(SQLAlchemyFilterSet):
|
|
196
|
+
model_cls = User
|
|
197
|
+
|
|
198
|
+
has_avatar: bool | None = None
|
|
199
|
+
|
|
200
|
+
def filter_has_avatar(self, value: bool):
|
|
201
|
+
if value:
|
|
202
|
+
return User.avatar_url.is_not(None)
|
|
203
|
+
return User.avatar_url.is_(None)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## 🛠 Advanced Usage: Association Proxies
|
|
209
|
+
|
|
210
|
+
`alchemy-filterset` natively supports SQLAlchemy's `AssociationProxy`. It automatically unpacks the proxy, discovers the underlying tables, and applies the filters or ordering correctly without crashing.
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
class Post(Base):
|
|
214
|
+
__tablename__ = "posts"
|
|
215
|
+
# ...
|
|
216
|
+
# Association proxy to tags
|
|
217
|
+
tags: AssociationProxy[list[str]] = association_proxy("post_tags", "tag_name")
|
|
218
|
+
|
|
219
|
+
class PostFilter(SQLAlchemyFilterSet):
|
|
220
|
+
model_cls = Post
|
|
221
|
+
# Works perfectly!
|
|
222
|
+
tags__icontains: str | None = None
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## License
|
|
226
|
+
|
|
227
|
+
MIT
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "alchemy-filterset"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A dynamic, Django-like filterset architecture for SQLAlchemy & Advanced Alchemy."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "Morteza Karimi", email = "itismortezakarimi@gmail.com" }]
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
license = { text = "MIT" }
|
|
9
|
+
keywords = [
|
|
10
|
+
"sqlalchemy",
|
|
11
|
+
"advanced-alchemy",
|
|
12
|
+
"litestar",
|
|
13
|
+
"fastapi",
|
|
14
|
+
"filters",
|
|
15
|
+
"pydantic",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
25
|
+
"Topic :: Database",
|
|
26
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
dependencies = [
|
|
31
|
+
"advanced-alchemy>=1.11.0",
|
|
32
|
+
"pydantic>=2.13.4",
|
|
33
|
+
"sqlalchemy>=2.0.51",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[dependency-groups]
|
|
37
|
+
test = ["pytest>=9.1.1", "pytest-cov>=7.1.0"]
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://github.com/MortezaKarimi77/alchemy-filterset"
|
|
41
|
+
Repository = "https://github.com/MortezaKarimi77/alchemy-filterset.git"
|
|
42
|
+
Issues = "https://github.com/MortezaKarimi77/alchemy-filterset/issues"
|
|
43
|
+
|
|
44
|
+
[build-system]
|
|
45
|
+
requires = ["uv_build>=0.11.32,<0.12.0"]
|
|
46
|
+
build-backend = "uv_build"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
xfail_strict = true
|
|
51
|
+
testpaths = "tests"
|
|
52
|
+
addopts = ["--strict-config", "--strict-markers", "-s", "-v", "-ra"]
|
|
53
|
+
|
|
54
|
+
[tool.coverage.run]
|
|
55
|
+
branch = true
|
|
56
|
+
source = ["alchemy_filterset"]
|
|
57
|
+
|
|
58
|
+
[tool.coverage.paths]
|
|
59
|
+
source = ["src/alchemy_filterset", "*/site-packages/alchemy_filterset"]
|
|
Binary file
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .exceptions import AttributeNotFoundError, LookupNotFoundError, RelationshipResolverError
|
|
2
|
+
from .filterset import SQLAlchemyFilterSet
|
|
3
|
+
from .registry import LookupRegistry, default_registry
|
|
4
|
+
from .resolver import RelationshipResolver, ResolvedPath
|
|
5
|
+
|
|
6
|
+
__all__ = (
|
|
7
|
+
"AttributeNotFoundError",
|
|
8
|
+
"LookupNotFoundError",
|
|
9
|
+
"LookupRegistry",
|
|
10
|
+
"RelationshipResolver",
|
|
11
|
+
"RelationshipResolverError",
|
|
12
|
+
"ResolvedPath",
|
|
13
|
+
"SQLAlchemyFilterSet",
|
|
14
|
+
"default_registry",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import typing as tp
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LookupNotFoundError(KeyError):
|
|
5
|
+
def __init__(self, lookup: str, available_lookups: tp.Collection[str]) -> None:
|
|
6
|
+
sorted_lookups = ", ".join(sorted(available_lookups))
|
|
7
|
+
super().__init__(
|
|
8
|
+
f"'{lookup}' operator not found in lookup registry.",
|
|
9
|
+
f"Allowed operators are: [{sorted_lookups}]",
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RelationshipResolverError(Exception):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AttributeNotFoundError(RelationshipResolverError):
|
|
18
|
+
def __init__(self, model: type, attribute_name: str) -> None:
|
|
19
|
+
super().__init__(f"The '{attribute_name}' attribute was not found on the '{model.__name__}' model.")
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import typing as tp
|
|
2
|
+
|
|
3
|
+
import pydantic as pyd
|
|
4
|
+
from advanced_alchemy import filters
|
|
5
|
+
from sqlalchemy import ColumnElement, or_
|
|
6
|
+
from sqlalchemy.ext.associationproxy import AssociationProxyInstance
|
|
7
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
8
|
+
|
|
9
|
+
from . import exceptions
|
|
10
|
+
from .registry import LookupRegistry, default_registry
|
|
11
|
+
from .resolver import RelationshipResolver
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SQLAlchemyFilterSet(pyd.BaseModel):
|
|
15
|
+
model_config = pyd.ConfigDict(
|
|
16
|
+
extra="ignore",
|
|
17
|
+
from_attributes=True,
|
|
18
|
+
str_strip_whitespace=True,
|
|
19
|
+
use_enum_values=True,
|
|
20
|
+
validate_assignment=True,
|
|
21
|
+
arbitrary_types_allowed=True,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
model_cls: tp.ClassVar[type[DeclarativeBase]]
|
|
25
|
+
registry: tp.ClassVar[LookupRegistry] = default_registry
|
|
26
|
+
search_fields: tp.ClassVar[tp.Collection[str]] = ()
|
|
27
|
+
|
|
28
|
+
enable_pagination: tp.ClassVar[bool] = True
|
|
29
|
+
default_page_size: tp.ClassVar[int] = 30
|
|
30
|
+
max_page_size: tp.ClassVar[int] = 100
|
|
31
|
+
|
|
32
|
+
page: int | None = pyd.Field(default=None, ge=1)
|
|
33
|
+
page_size: int | None = pyd.Field(default=None, ge=1)
|
|
34
|
+
|
|
35
|
+
ordering: str | None = None
|
|
36
|
+
search: str | None = None
|
|
37
|
+
|
|
38
|
+
@pyd.model_validator(mode="after")
|
|
39
|
+
def validate_pagination_params(self) -> tp.Self:
|
|
40
|
+
if not self.enable_pagination:
|
|
41
|
+
if self.page is not None:
|
|
42
|
+
self.page = None
|
|
43
|
+
if self.page_size is not None:
|
|
44
|
+
self.page_size = None
|
|
45
|
+
else:
|
|
46
|
+
if self.page is None:
|
|
47
|
+
self.page = 1
|
|
48
|
+
if self.page_size is None:
|
|
49
|
+
self.page_size = self.default_page_size
|
|
50
|
+
if self.page_size > self.max_page_size: # noqa: PLR1730
|
|
51
|
+
self.page_size = self.max_page_size
|
|
52
|
+
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def to_statement_filters(self) -> tp.Sequence[filters.StatementFilter | ColumnElement[bool]]:
|
|
56
|
+
if not hasattr(self, "model_cls"):
|
|
57
|
+
raise ValueError(f"The {self.__class__.__name__} class must define the 'model_cls' attribute.")
|
|
58
|
+
|
|
59
|
+
query_filters = []
|
|
60
|
+
|
|
61
|
+
if self.enable_pagination and self.page and self.page_size:
|
|
62
|
+
query_filters.append(filters.LimitOffset(limit=self.page_size, offset=(self.page - 1) * self.page_size))
|
|
63
|
+
|
|
64
|
+
if ordering_expr := self._build_ordering_filters():
|
|
65
|
+
query_filters.extend(ordering_expr)
|
|
66
|
+
|
|
67
|
+
if (search_expr := self._build_search_filter()) is not None:
|
|
68
|
+
query_filters.append(search_expr)
|
|
69
|
+
|
|
70
|
+
query_filters.extend(self._build_field_filters())
|
|
71
|
+
return query_filters
|
|
72
|
+
|
|
73
|
+
def _build_ordering_filters(self) -> tp.Sequence[filters.OrderBy] | None:
|
|
74
|
+
if self.ordering is None or not self.ordering.strip():
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
ordering_filters = []
|
|
78
|
+
fields = [field.strip() for field in self.ordering.split(",") if field.strip()]
|
|
79
|
+
|
|
80
|
+
for field in fields:
|
|
81
|
+
sort_order = "desc" if field.startswith("-") else "asc"
|
|
82
|
+
clean_path = field.strip("+-")
|
|
83
|
+
|
|
84
|
+
resolved = RelationshipResolver.resolve(self.model_cls, clean_path)
|
|
85
|
+
if (target_attribute := resolved.target_attribute) is not None:
|
|
86
|
+
if isinstance(target_attribute, AssociationProxyInstance):
|
|
87
|
+
target_attribute = target_attribute.remote_attr
|
|
88
|
+
|
|
89
|
+
field_to_order = tp.cast(tp.Any, target_attribute)
|
|
90
|
+
ordering_filters.append(filters.OrderBy(field_name=field_to_order, sort_order=sort_order))
|
|
91
|
+
|
|
92
|
+
return ordering_filters if ordering_filters else None
|
|
93
|
+
|
|
94
|
+
def _build_search_filter(self) -> ColumnElement[bool] | None:
|
|
95
|
+
if not self.search or not self.search_fields:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
conditions = []
|
|
99
|
+
for path in self.search_fields:
|
|
100
|
+
try:
|
|
101
|
+
cond = self._build_single_condition(path, lookup="icontains", value=self.search)
|
|
102
|
+
conditions.append(cond)
|
|
103
|
+
except (
|
|
104
|
+
exceptions.RelationshipResolverError,
|
|
105
|
+
exceptions.LookupNotFoundError,
|
|
106
|
+
exceptions.AttributeNotFoundError,
|
|
107
|
+
):
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
return or_(*conditions) if conditions else None
|
|
111
|
+
|
|
112
|
+
def _build_field_filters(self) -> tp.Sequence[ColumnElement[bool]]:
|
|
113
|
+
conditions = []
|
|
114
|
+
reserved_keys = {"page", "page_size", "ordering", "search"}
|
|
115
|
+
|
|
116
|
+
query_params: tp.Mapping[str, tp.Any] = self.model_dump(
|
|
117
|
+
exclude_unset=True, exclude_none=True, exclude=reserved_keys
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
for key, value in query_params.items():
|
|
121
|
+
is_negated = False
|
|
122
|
+
if key.startswith("not__"):
|
|
123
|
+
is_negated = True
|
|
124
|
+
key = key[len("not__") :]
|
|
125
|
+
|
|
126
|
+
if custom_filter_method := getattr(self, f"filter_{key}", None):
|
|
127
|
+
if (cond := custom_filter_method(value)) is not None:
|
|
128
|
+
conditions.append(cond)
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
parts = key.split("__")
|
|
132
|
+
lookup = "eq"
|
|
133
|
+
|
|
134
|
+
if len(parts) > 1 and self.registry.has_lookup(parts[-1]):
|
|
135
|
+
lookup = parts.pop()
|
|
136
|
+
|
|
137
|
+
clean_path = "__".join(parts)
|
|
138
|
+
|
|
139
|
+
cond = self._build_single_condition(clean_path, lookup, value)
|
|
140
|
+
if is_negated:
|
|
141
|
+
cond = ~cond
|
|
142
|
+
|
|
143
|
+
conditions.append(cond)
|
|
144
|
+
return conditions
|
|
145
|
+
|
|
146
|
+
def _build_single_condition(self, path: str, lookup: str, value: tp.Any) -> ColumnElement[bool]:
|
|
147
|
+
resolved = RelationshipResolver.resolve(self.model_cls, path)
|
|
148
|
+
builder_function = self.registry.get(lookup)
|
|
149
|
+
condition = builder_function(resolved.target_attribute, value)
|
|
150
|
+
|
|
151
|
+
for relation, uselist in reversed(resolved.relationship_chain):
|
|
152
|
+
if uselist:
|
|
153
|
+
condition = relation.any(condition)
|
|
154
|
+
else:
|
|
155
|
+
condition = relation.has(condition)
|
|
156
|
+
|
|
157
|
+
return condition
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import typing as tp
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
|
|
4
|
+
import sqlalchemy as sa
|
|
5
|
+
|
|
6
|
+
from .exceptions import LookupNotFoundError
|
|
7
|
+
|
|
8
|
+
type FilterBuilder = tp.Callable[[tp.Any, tp.Any], sa.ColumnElement[bool]]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class LookupRegistry:
|
|
13
|
+
_builders: tp.MutableMapping[str, FilterBuilder] = field(default_factory=dict)
|
|
14
|
+
|
|
15
|
+
@tp.overload
|
|
16
|
+
def register(self, lookup: str | tp.Collection[str]) -> tp.Callable[[FilterBuilder], FilterBuilder]: ...
|
|
17
|
+
|
|
18
|
+
@tp.overload
|
|
19
|
+
def register(self, lookup: str | tp.Collection[str], builder: FilterBuilder) -> FilterBuilder: ...
|
|
20
|
+
|
|
21
|
+
def register(
|
|
22
|
+
self, lookup: str | tp.Collection[str], builder: FilterBuilder | None = None
|
|
23
|
+
) -> FilterBuilder | tp.Callable[[FilterBuilder], FilterBuilder]:
|
|
24
|
+
def decorator(builder_function: FilterBuilder) -> FilterBuilder:
|
|
25
|
+
lookups = (lookup,) if isinstance(lookup, str) else lookup
|
|
26
|
+
|
|
27
|
+
for lookup_ in lookups:
|
|
28
|
+
lookup_ = lookup_.strip().casefold()
|
|
29
|
+
self._builders[lookup_] = builder_function
|
|
30
|
+
|
|
31
|
+
return builder_function
|
|
32
|
+
|
|
33
|
+
return decorator if builder is None else decorator(builder)
|
|
34
|
+
|
|
35
|
+
def get(self, lookup: str) -> FilterBuilder:
|
|
36
|
+
lookup = lookup.strip().casefold()
|
|
37
|
+
if lookup not in self._builders:
|
|
38
|
+
raise LookupNotFoundError(lookup, self._builders.keys())
|
|
39
|
+
return self._builders[lookup]
|
|
40
|
+
|
|
41
|
+
def has_lookup(self, lookup: str) -> bool:
|
|
42
|
+
return lookup.strip().casefold() in self._builders
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def registered_lookups(self) -> set[str]:
|
|
46
|
+
return set(self._builders.keys())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def register_default_lookups(registry: LookupRegistry) -> None:
|
|
50
|
+
# Register comparison lookups
|
|
51
|
+
registry.register(lookup="eq", builder=lambda col, val: col == val)
|
|
52
|
+
registry.register(lookup="ne", builder=lambda col, val: col != val)
|
|
53
|
+
registry.register(lookup="gt", builder=lambda col, val: col > val)
|
|
54
|
+
registry.register(lookup="ge", builder=lambda col, val: col >= val)
|
|
55
|
+
registry.register(lookup="lt", builder=lambda col, val: col < val)
|
|
56
|
+
registry.register(lookup="le", builder=lambda col, val: col <= val)
|
|
57
|
+
registry.register(
|
|
58
|
+
lookup="between",
|
|
59
|
+
builder=lambda col, val: (
|
|
60
|
+
col.between(val[0], val[1]) if isinstance(val, (list, tuple)) and len(val) == 2 else sa.false()
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Register collection lookups
|
|
65
|
+
registry.register(
|
|
66
|
+
lookup="in",
|
|
67
|
+
builder=lambda col, val: col.in_(val if isinstance(val, (list, tuple, set)) else [val]) if val else sa.false(),
|
|
68
|
+
)
|
|
69
|
+
registry.register(
|
|
70
|
+
lookup="notin",
|
|
71
|
+
builder=lambda col, val: (
|
|
72
|
+
col.not_in(val if isinstance(val, (list, tuple, set)) else [val]) if val else sa.true()
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Register text search lookups
|
|
77
|
+
registry.register(lookup="contains", builder=lambda col, val: sa.cast(col, sa.String).like(f"%{val}%"))
|
|
78
|
+
registry.register(lookup="icontains", builder=lambda col, val: sa.cast(col, sa.String).ilike(f"%{val}%"))
|
|
79
|
+
registry.register(lookup="not_contains", builder=lambda col, val: sa.cast(col, sa.String).not_like(f"%{val}%"))
|
|
80
|
+
registry.register(lookup="not_icontains", builder=lambda col, val: sa.cast(col, sa.String).not_ilike(f"%{val}%"))
|
|
81
|
+
registry.register(lookup="startswith", builder=lambda col, val: sa.cast(col, sa.String).like(f"{val}%"))
|
|
82
|
+
registry.register(lookup="istartswith", builder=lambda col, val: sa.cast(col, sa.String).ilike(f"{val}%"))
|
|
83
|
+
registry.register(lookup="endswith", builder=lambda col, val: sa.cast(col, sa.String).like(f"%{val}"))
|
|
84
|
+
registry.register(lookup="iendswith", builder=lambda col, val: sa.cast(col, sa.String).ilike(f"%{val}"))
|
|
85
|
+
|
|
86
|
+
# Register null lookups
|
|
87
|
+
registry.register(lookup="is_null", builder=lambda col, val: col.is_(None) if val else col.is_not(None))
|
|
88
|
+
registry.register(lookup="not_null", builder=lambda col, val: col.is_not(None) if val else col.is_(None))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
default_registry = LookupRegistry()
|
|
92
|
+
register_default_lookups(default_registry)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import typing as tp
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import ColumnElement
|
|
5
|
+
from sqlalchemy.ext.associationproxy import AssociationProxyInstance
|
|
6
|
+
from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute
|
|
7
|
+
|
|
8
|
+
from . import exceptions
|
|
9
|
+
|
|
10
|
+
type QueryableAttribute = InstrumentedAttribute[tp.Any] | AssociationProxyInstance[tp.Any] | ColumnElement[tp.Any]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class ResolvedPath:
|
|
15
|
+
root_model: type[DeclarativeBase]
|
|
16
|
+
target_model: type[DeclarativeBase]
|
|
17
|
+
target_attribute: QueryableAttribute | None
|
|
18
|
+
attribute_name: str
|
|
19
|
+
relationship_chain: list[tuple[InstrumentedAttribute[tp.Any], bool]] = field(default_factory=list)
|
|
20
|
+
remaining_parts: list[str] = field(default_factory=list)
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def is_nested(self) -> bool:
|
|
24
|
+
return len(self.relationship_chain) > 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RelationshipResolver:
|
|
28
|
+
@classmethod
|
|
29
|
+
def resolve(cls, root_model: type[DeclarativeBase], path: str | tp.Sequence[str], sep: str = "__") -> ResolvedPath:
|
|
30
|
+
paths = (path,) if isinstance(path, str) else path
|
|
31
|
+
parts = [part.strip() for path in paths for part in path.split(sep) if part.strip()]
|
|
32
|
+
|
|
33
|
+
if not parts:
|
|
34
|
+
raise exceptions.RelationshipResolverError("The entered path for navigation is empty.")
|
|
35
|
+
|
|
36
|
+
current_model = root_model
|
|
37
|
+
relationship_chain: list[tuple[InstrumentedAttribute[tp.Any], bool]] = []
|
|
38
|
+
remaining_parts: list[str] = []
|
|
39
|
+
|
|
40
|
+
for index, part in enumerate(parts):
|
|
41
|
+
if (attribute := getattr(current_model, part, None)) is None:
|
|
42
|
+
raise exceptions.AttributeNotFoundError(current_model, part)
|
|
43
|
+
|
|
44
|
+
if cls._is_relationship(attribute):
|
|
45
|
+
uselist = attribute.property.uselist
|
|
46
|
+
relationship_chain.append((attribute, uselist))
|
|
47
|
+
current_model = attribute.property.mapper.class_
|
|
48
|
+
|
|
49
|
+
elif isinstance(attribute, AssociationProxyInstance):
|
|
50
|
+
local_attr = attribute.local_attr
|
|
51
|
+
relationship_chain.append((local_attr, local_attr.property.uselist))
|
|
52
|
+
|
|
53
|
+
remote_attr = attribute.remote_attr
|
|
54
|
+
if cls._is_relationship(remote_attr):
|
|
55
|
+
relationship_chain.append((remote_attr, remote_attr.property.uselist))
|
|
56
|
+
current_model = remote_attr.property.mapper.class_
|
|
57
|
+
else:
|
|
58
|
+
remaining_parts = parts[index + 1 :]
|
|
59
|
+
return ResolvedPath(
|
|
60
|
+
root_model=root_model,
|
|
61
|
+
target_model=local_attr.property.mapper.class_,
|
|
62
|
+
target_attribute=attribute,
|
|
63
|
+
attribute_name=part,
|
|
64
|
+
relationship_chain=relationship_chain,
|
|
65
|
+
remaining_parts=remaining_parts,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
else:
|
|
69
|
+
remaining_parts = parts[index + 1 :]
|
|
70
|
+
return ResolvedPath(
|
|
71
|
+
root_model=root_model,
|
|
72
|
+
target_model=current_model,
|
|
73
|
+
target_attribute=attribute,
|
|
74
|
+
attribute_name=part,
|
|
75
|
+
relationship_chain=relationship_chain,
|
|
76
|
+
remaining_parts=remaining_parts,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
last_attribute = relationship_chain[-1][0] if relationship_chain else None
|
|
80
|
+
return ResolvedPath(
|
|
81
|
+
root_model=root_model,
|
|
82
|
+
target_model=current_model,
|
|
83
|
+
target_attribute=last_attribute,
|
|
84
|
+
attribute_name=last_attribute.key if last_attribute else "",
|
|
85
|
+
relationship_chain=relationship_chain,
|
|
86
|
+
remaining_parts=remaining_parts,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _is_relationship(attribute: tp.Any) -> bool:
|
|
91
|
+
return hasattr(attribute, "property") and hasattr(attribute.property, "mapper")
|