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.
@@ -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"]
@@ -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")