sqlmodel-object-helpers 0.0.1__tar.gz → 0.0.3__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.
- sqlmodel_object_helpers-0.0.3/PKG-INFO +604 -0
- sqlmodel_object_helpers-0.0.3/README.md +570 -0
- {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/src/sqlmodel_object_helpers/__init__.py +37 -39
- {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/src/sqlmodel_object_helpers/constants.py +5 -2
- {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/src/sqlmodel_object_helpers/exceptions.py +2 -1
- {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/src/sqlmodel_object_helpers/filters.py +23 -14
- {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/src/sqlmodel_object_helpers/loaders.py +11 -12
- {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/src/sqlmodel_object_helpers/mutations.py +196 -44
- {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/src/sqlmodel_object_helpers/operators.py +4 -2
- {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/src/sqlmodel_object_helpers/query.py +229 -60
- {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/src/sqlmodel_object_helpers/session.py +8 -4
- {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/src/sqlmodel_object_helpers/types/filters.py +49 -6
- {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/tests/conftest.py +127 -21
- sqlmodel_object_helpers-0.0.3/tests/test_bulk_mutations.py +154 -0
- sqlmodel_object_helpers-0.0.3/tests/test_computed_columns.py +74 -0
- sqlmodel_object_helpers-0.0.3/tests/test_count_exists.py +152 -0
- {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/tests/test_exceptions.py +39 -45
- {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/tests/test_filters.py +71 -77
- sqlmodel_object_helpers-0.0.3/tests/test_for_update.py +37 -0
- sqlmodel_object_helpers-0.0.3/tests/test_generated_columns_pg.py +148 -0
- {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/tests/test_loaders.py +39 -40
- {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/tests/test_mutations.py +45 -52
- {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/tests/test_operators.py +48 -53
- {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/tests/test_query.py +79 -82
- {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/tests/test_settings.py +50 -51
- sqlmodel_object_helpers-0.0.3/tests/test_time_filter.py +175 -0
- {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/tests/test_types.py +101 -115
- sqlmodel_object_helpers-0.0.1/.github/.gitkeep +0 -0
- sqlmodel_object_helpers-0.0.1/.github/workflows/.gitkeep +0 -0
- sqlmodel_object_helpers-0.0.1/PKG-INFO +0 -515
- sqlmodel_object_helpers-0.0.1/README.md +0 -481
- {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/.github/workflows/publish.yml +0 -0
- {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/LICENSE +0 -0
- {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/pyproject.toml +0 -0
- {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/src/sqlmodel_object_helpers/types/__init__.py +0 -0
- {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/src/sqlmodel_object_helpers/types/pagination.py +0 -0
- {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/src/sqlmodel_object_helpers/types/projections.py +0 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqlmodel-object-helpers
|
|
3
|
+
Version: 0.0.3
|
|
4
|
+
Summary: Generic async query helpers for SQLModel: filtering, eager loading, pagination
|
|
5
|
+
Project-URL: Homepage, https://github.com/itstandart/sqlmodel-object-helpers
|
|
6
|
+
Project-URL: Repository, https://github.com/itstandart/sqlmodel-object-helpers
|
|
7
|
+
Project-URL: Documentation, https://github.com/itstandart/sqlmodel-object-helpers#readme
|
|
8
|
+
Project-URL: Issues, https://github.com/itstandart/sqlmodel-object-helpers/issues
|
|
9
|
+
Author-email: IT Standart <aitistandart@gmail.com>
|
|
10
|
+
License-Expression: LicenseRef-PolyForm-Noncommercial-1.0.0
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: async,filter,pagination,query,sqlalchemy,sqlmodel
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: Other/Proprietary License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Database
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.14
|
|
24
|
+
Requires-Dist: pydantic>=2.12
|
|
25
|
+
Requires-Dist: sqlalchemy>=2.0.46
|
|
26
|
+
Requires-Dist: sqlmodel>=0.0.22
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: aiosqlite>=0.20; extra == 'dev'
|
|
29
|
+
Requires-Dist: mypy>=1.13.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: ruff>=0.8.0; extra == 'dev'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# sqlmodel-object-helpers
|
|
36
|
+
|
|
37
|
+
[](https://pypi.org/project/sqlmodel-object-helpers/)
|
|
38
|
+
[](https://www.python.org/downloads/)
|
|
39
|
+
[](https://polyformproject.org/licenses/noncommercial/1.0.0/)
|
|
40
|
+
|
|
41
|
+
Generic async query helpers for [SQLModel](https://sqlmodel.tiangolo.com/): filtering, eager loading, pagination, and mutations with security limits and full type safety.
|
|
42
|
+
|
|
43
|
+
## Features
|
|
44
|
+
|
|
45
|
+
- **Async-First** - All query and mutation functions are `async`, designed for `AsyncSession`
|
|
46
|
+
- **Flexible Filtering** - `LogicalFilter` with recursive AND/OR/condition trees, plus flat dict filters with dot-notation for relationship traversal
|
|
47
|
+
- **15 Operators** - eq, ne, gt, lt, ge, le, in\_, not\_in, like, ilike, between, is, isnot, match, exists
|
|
48
|
+
- **Smart Eager Loading** - Automatic `selectinload` for one-to-many and `joinedload` for many-to-one
|
|
49
|
+
- **Pagination** - Page/per\_page with total count and configurable max\_per\_page limit
|
|
50
|
+
- **Projections** - Select specific columns across joins with dot-notation and SQL aliases
|
|
51
|
+
- **CRUD Mutations** - `add_object`, `update_object`, `delete_object` plus bulk `update_objects`, `delete_objects` with flush-only semantics for composable transactions
|
|
52
|
+
- **Count & Exists** - `count_objects` (single `SELECT count(*)`) and `exists_object` (`SELECT EXISTS(...)`) without loading data
|
|
53
|
+
- **Row Locking** - `for_update` parameter on `get_object` for `SELECT ... FOR UPDATE`
|
|
54
|
+
- **Time Filtering** - `TimeFilter` for `created_at`/`updated_at` range filtering with half-open interval semantics
|
|
55
|
+
- **Relationship Safety Check** - `check_for_related_records` pre-deletion inspection of ONETOMANY dependencies
|
|
56
|
+
- **Security Limits** - Configurable depth, list size, and pagination caps to prevent abuse
|
|
57
|
+
- **Type Safety** - Full type annotations with PEP 695 generics and `py.typed` marker (PEP 561)
|
|
58
|
+
|
|
59
|
+
## Installation
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install sqlmodel-object-helpers
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
One import, everything through dot notation:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import sqlmodel_object_helpers as soh
|
|
69
|
+
|
|
70
|
+
soh.get_object(session, ...)
|
|
71
|
+
soh.add_object(session, ...)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Quick Start
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from datetime import datetime
|
|
78
|
+
|
|
79
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
80
|
+
from sqlmodel import SQLModel, Field
|
|
81
|
+
|
|
82
|
+
import sqlmodel_object_helpers as soh
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class User(SQLModel, table=True):
|
|
86
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
87
|
+
name: str
|
|
88
|
+
is_active: bool = True
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def example(session: AsyncSession):
|
|
92
|
+
# Create
|
|
93
|
+
user = await soh.add_object(session, User(name="Alice"))
|
|
94
|
+
|
|
95
|
+
# Get one by PK
|
|
96
|
+
found = await soh.get_object(session, User, pk={"id": user.id})
|
|
97
|
+
|
|
98
|
+
# Get many with filtering
|
|
99
|
+
active_users = await soh.get_objects(
|
|
100
|
+
session, User,
|
|
101
|
+
filters={"is_active": {soh.Operator.EQ: True}},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Paginated
|
|
105
|
+
page = await soh.get_objects(
|
|
106
|
+
session, User,
|
|
107
|
+
pagination=soh.Pagination(page=1, per_page=25),
|
|
108
|
+
)
|
|
109
|
+
# page.data -> list[User], page.pagination.total -> int
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Configuration
|
|
113
|
+
|
|
114
|
+
Override security limits at application startup:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
import sqlmodel_object_helpers as soh
|
|
118
|
+
|
|
119
|
+
soh.settings.max_per_page = 300
|
|
120
|
+
soh.settings.max_filter_depth = 50
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
| Setting | Default | Description |
|
|
124
|
+
|---------|---------|-------------|
|
|
125
|
+
| `max_filter_depth` | `100` | Maximum recursion depth for AND/OR nesting |
|
|
126
|
+
| `max_and_or_items` | `100` | Maximum sub-filters in a single AND/OR list |
|
|
127
|
+
| `max_load_depth` | `10` | Maximum depth of eager-loading chains |
|
|
128
|
+
| `max_in_list_size` | `1000` | Maximum elements in an IN(...) list |
|
|
129
|
+
| `max_per_page` | `500` | Maximum value for per_page in pagination |
|
|
130
|
+
|
|
131
|
+
## Query Operations
|
|
132
|
+
|
|
133
|
+
### get_object
|
|
134
|
+
|
|
135
|
+
Fetch a single object by primary key or filters. Supports ORM mode (model instances with eager loading) and SQL mode (flat dicts from specific columns).
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
import sqlmodel_object_helpers as soh
|
|
139
|
+
|
|
140
|
+
# By primary key
|
|
141
|
+
user = await soh.get_object(session, User, pk={"id": 5})
|
|
142
|
+
|
|
143
|
+
# By LogicalFilter
|
|
144
|
+
user = await soh.get_object(
|
|
145
|
+
session, User,
|
|
146
|
+
filters=soh.LogicalFilter(condition={"name": {soh.Operator.EQ: "Alice"}}),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# With eager loading
|
|
150
|
+
user = await soh.get_object(
|
|
151
|
+
session, User,
|
|
152
|
+
pk={"id": 5},
|
|
153
|
+
load_paths=["posts", "posts.comments"],
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Graceful mode (returns None instead of raising)
|
|
157
|
+
user = await soh.get_object(session, User, pk={"id": 999}, suspend_error=True)
|
|
158
|
+
|
|
159
|
+
# Row-level locking (SELECT ... FOR UPDATE)
|
|
160
|
+
user = await soh.get_object(session, User, pk={"id": 5}, for_update=True)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### get_objects
|
|
164
|
+
|
|
165
|
+
Fetch multiple objects with filtering, pagination, sorting, and eager loading.
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
import sqlmodel_object_helpers as soh
|
|
169
|
+
|
|
170
|
+
# Simple filter
|
|
171
|
+
users = await soh.get_objects(session, User, filters={"is_active": {soh.Operator.EQ: True}})
|
|
172
|
+
|
|
173
|
+
# With pagination + sorting
|
|
174
|
+
result = await soh.get_objects(
|
|
175
|
+
session, User,
|
|
176
|
+
pagination=soh.Pagination(page=1, per_page=25),
|
|
177
|
+
order_by=soh.OrderBy(sorts=[soh.OrderAsc(asc="name")]),
|
|
178
|
+
)
|
|
179
|
+
# result.data -> list[User]
|
|
180
|
+
# result.pagination -> PaginationR(page=1, per_page=25, total=100)
|
|
181
|
+
|
|
182
|
+
# OR logic between conditions
|
|
183
|
+
users = await soh.get_objects(
|
|
184
|
+
session, User,
|
|
185
|
+
filters={"is_active": {soh.Operator.EQ: True}, "role": {soh.Operator.EQ: "admin"}},
|
|
186
|
+
logical_operator="OR",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Relationship filters (dot-notation)
|
|
190
|
+
users = await soh.get_objects(
|
|
191
|
+
session, Attempt,
|
|
192
|
+
filters={"application.applicant.last_name": {soh.Operator.EQ: "Smith"}},
|
|
193
|
+
load_paths=["application.applicant"],
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Time filtering
|
|
197
|
+
from datetime import datetime
|
|
198
|
+
|
|
199
|
+
recent = await soh.get_objects(
|
|
200
|
+
session, User,
|
|
201
|
+
time_filter=soh.TimeFilter(
|
|
202
|
+
created_after=datetime(2026, 1, 1),
|
|
203
|
+
created_before=datetime(2026, 2, 1),
|
|
204
|
+
),
|
|
205
|
+
)
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### count_objects
|
|
209
|
+
|
|
210
|
+
Return the count of records matching filters without loading any data.
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
import sqlmodel_object_helpers as soh
|
|
214
|
+
|
|
215
|
+
# Total count
|
|
216
|
+
total = await soh.count_objects(session, User)
|
|
217
|
+
|
|
218
|
+
# Filtered count
|
|
219
|
+
active = await soh.count_objects(session, User, filters={"is_active": {soh.Operator.EQ: True}})
|
|
220
|
+
|
|
221
|
+
# With time filter
|
|
222
|
+
recent = await soh.count_objects(
|
|
223
|
+
session, User,
|
|
224
|
+
time_filter=soh.TimeFilter(created_after=datetime(2026, 1, 1)),
|
|
225
|
+
)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### exists_object
|
|
229
|
+
|
|
230
|
+
Check whether at least one record matches the criteria. Uses `EXISTS (SELECT ... LIMIT 1)` -- the database stops at the first match.
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
import sqlmodel_object_helpers as soh
|
|
234
|
+
|
|
235
|
+
# By PK
|
|
236
|
+
found = await soh.exists_object(session, User, pk={"id": 5})
|
|
237
|
+
|
|
238
|
+
# By filter
|
|
239
|
+
has_admin = await soh.exists_object(session, User, filters={"role": {soh.Operator.EQ: "admin"}})
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### get_projection
|
|
243
|
+
|
|
244
|
+
Fetch specific columns from related tables. Returns flat dicts instead of ORM instances.
|
|
245
|
+
|
|
246
|
+
```python
|
|
247
|
+
import sqlmodel_object_helpers as soh
|
|
248
|
+
|
|
249
|
+
rows = await soh.get_projection(
|
|
250
|
+
session,
|
|
251
|
+
Attempt,
|
|
252
|
+
columns=[
|
|
253
|
+
"id",
|
|
254
|
+
("application.applicant.last_name", "applicant_name"),
|
|
255
|
+
("schedule.unit.address", "unit_address"),
|
|
256
|
+
],
|
|
257
|
+
outer_joins=["schedule"],
|
|
258
|
+
limit=100,
|
|
259
|
+
)
|
|
260
|
+
# [{"id": 1, "applicant_name": "Smith", "unit_address": "123 Main St"}, ...]
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## Mutations
|
|
264
|
+
|
|
265
|
+
All mutation functions use `session.flush()` instead of `session.commit()` -- the caller manages transaction boundaries. This allows composing multiple mutations into a single atomic transaction:
|
|
266
|
+
|
|
267
|
+
```python
|
|
268
|
+
import sqlmodel_object_helpers as soh
|
|
269
|
+
|
|
270
|
+
async with session.begin():
|
|
271
|
+
await soh.add_object(session, billing)
|
|
272
|
+
await soh.add_object(session, payment)
|
|
273
|
+
# commit happens automatically when the block exits
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### add_object / add_objects
|
|
277
|
+
|
|
278
|
+
```python
|
|
279
|
+
import sqlmodel_object_helpers as soh
|
|
280
|
+
|
|
281
|
+
# Single
|
|
282
|
+
user = await soh.add_object(session, User(name="Alice"))
|
|
283
|
+
# user.id is now populated (server-generated)
|
|
284
|
+
|
|
285
|
+
# Bulk
|
|
286
|
+
users = await soh.add_objects(session, [User(name="Alice"), User(name="Bob")])
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### update_object
|
|
290
|
+
|
|
291
|
+
```python
|
|
292
|
+
import sqlmodel_object_helpers as soh
|
|
293
|
+
|
|
294
|
+
user = await soh.get_object(session, User, pk={"id": 1})
|
|
295
|
+
updated = await soh.update_object(session, user, {"name": "Alice Updated", "is_active": False})
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Field names are validated against the model before touching the database. Raises `MutationError` if a key does not exist on the model.
|
|
299
|
+
|
|
300
|
+
### delete_object
|
|
301
|
+
|
|
302
|
+
```python
|
|
303
|
+
import sqlmodel_object_helpers as soh
|
|
304
|
+
|
|
305
|
+
# By instance
|
|
306
|
+
await soh.delete_object(session, User, instance=user)
|
|
307
|
+
|
|
308
|
+
# By PK
|
|
309
|
+
await soh.delete_object(session, User, pk={"id": 5})
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### update_objects / delete_objects
|
|
313
|
+
|
|
314
|
+
Bulk operations that issue a single SQL statement without loading objects:
|
|
315
|
+
|
|
316
|
+
```python
|
|
317
|
+
import sqlmodel_object_helpers as soh
|
|
318
|
+
|
|
319
|
+
# Bulk update: UPDATE users SET is_active=False WHERE role='guest'
|
|
320
|
+
count = await soh.update_objects(
|
|
321
|
+
session, User,
|
|
322
|
+
data={"is_active": False},
|
|
323
|
+
filters={"role": {soh.Operator.EQ: "guest"}},
|
|
324
|
+
)
|
|
325
|
+
# count -> number of rows updated
|
|
326
|
+
|
|
327
|
+
# Bulk delete: DELETE FROM users WHERE is_active=False
|
|
328
|
+
count = await soh.delete_objects(
|
|
329
|
+
session, User,
|
|
330
|
+
filters={"is_active": {soh.Operator.EQ: False}},
|
|
331
|
+
)
|
|
332
|
+
# count -> number of rows deleted
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Filters are **required** for safety -- empty filters are rejected. Dot-notation (relationship) filters are not supported in bulk operations.
|
|
336
|
+
|
|
337
|
+
### check_for_related_records
|
|
338
|
+
|
|
339
|
+
Pre-deletion check that inspects all ONETOMANY relationships:
|
|
340
|
+
|
|
341
|
+
```python
|
|
342
|
+
import sqlmodel_object_helpers as soh
|
|
343
|
+
|
|
344
|
+
deps = await soh.check_for_related_records(session, Organization, pk={"id": 1})
|
|
345
|
+
if deps:
|
|
346
|
+
print(deps)
|
|
347
|
+
# ["Related record found in 'Unit (units_lkp)' (id=1)", ...]
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Returns `None` if no related records exist, or a list of human-readable dependency descriptions.
|
|
351
|
+
|
|
352
|
+
## Filtering
|
|
353
|
+
|
|
354
|
+
### LogicalFilter (AND/OR/condition)
|
|
355
|
+
|
|
356
|
+
Recursive filter structure used by `get_object`. Exactly one of `AND`, `OR`, or `condition` must be set:
|
|
357
|
+
|
|
358
|
+
```python
|
|
359
|
+
import sqlmodel_object_helpers as soh
|
|
360
|
+
|
|
361
|
+
# Simple condition
|
|
362
|
+
f = soh.LogicalFilter(condition={"status_id": {soh.Operator.EQ: 10}})
|
|
363
|
+
|
|
364
|
+
# OR
|
|
365
|
+
f = soh.LogicalFilter(OR=[
|
|
366
|
+
soh.LogicalFilter(condition={"status_id": {soh.Operator.EQ: 10}}),
|
|
367
|
+
soh.LogicalFilter(condition={"is_blocked": {soh.Operator.EQ: True}}),
|
|
368
|
+
])
|
|
369
|
+
|
|
370
|
+
# Nested AND + OR
|
|
371
|
+
f = soh.LogicalFilter(AND=[
|
|
372
|
+
soh.LogicalFilter(condition={"is_active": {soh.Operator.EQ: True}}),
|
|
373
|
+
soh.LogicalFilter(OR=[
|
|
374
|
+
soh.LogicalFilter(condition={"role": {soh.Operator.EQ: "admin"}}),
|
|
375
|
+
soh.LogicalFilter(condition={"role": {soh.Operator.EQ: "manager"}}),
|
|
376
|
+
]),
|
|
377
|
+
])
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
### Flat dict filters
|
|
381
|
+
|
|
382
|
+
Used by `get_objects`. Nested dicts are auto-flattened via `flatten_filters`:
|
|
383
|
+
|
|
384
|
+
```python
|
|
385
|
+
# Nested form (auto-flattened)
|
|
386
|
+
{"application": {"applicant": {"last_name": {soh.Operator.EQ: "test"}}}}
|
|
387
|
+
|
|
388
|
+
# Equivalent flat form (dot-notation)
|
|
389
|
+
{"application.applicant.last_name": {soh.Operator.EQ: "test"}}
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
### Operators
|
|
393
|
+
|
|
394
|
+
| Enum member | SQL | Example |
|
|
395
|
+
|-------------|-----|---------|
|
|
396
|
+
| `Operator.EQ` | `=` | `{soh.Operator.EQ: 10}` |
|
|
397
|
+
| `Operator.NE` | `!=` | `{soh.Operator.NE: 0}` |
|
|
398
|
+
| `Operator.GT` | `>` | `{soh.Operator.GT: 5}` |
|
|
399
|
+
| `Operator.LT` | `<` | `{soh.Operator.LT: 100}` |
|
|
400
|
+
| `Operator.GE` | `>=` | `{soh.Operator.GE: 1}` |
|
|
401
|
+
| `Operator.LE` | `<=` | `{soh.Operator.LE: 50}` |
|
|
402
|
+
| `Operator.IN` | `IN (...)` | `{soh.Operator.IN: [1, 2, 3]}` |
|
|
403
|
+
| `Operator.NOT_IN` | `NOT IN (...)` | `{soh.Operator.NOT_IN: [4, 5]}` |
|
|
404
|
+
| `Operator.LIKE` | `LIKE` | `{soh.Operator.LIKE: "%test%"}` |
|
|
405
|
+
| `Operator.ILIKE` | `ILIKE` | `{soh.Operator.ILIKE: "%test%"}` |
|
|
406
|
+
| `Operator.BETWEEN` | `BETWEEN` | `{soh.Operator.BETWEEN: [1, 10]}` |
|
|
407
|
+
| `Operator.IS` | `IS` | `{soh.Operator.IS: None}` |
|
|
408
|
+
| `Operator.IS_NOT` | `IS NOT` | `{soh.Operator.IS_NOT: None}` |
|
|
409
|
+
| `Operator.MATCH` | `MATCH` | `{soh.Operator.MATCH: "query"}` |
|
|
410
|
+
| `"exists"` | `IS NOT NULL` / `.any()` | `{"exists": True}` |
|
|
411
|
+
|
|
412
|
+
`Operator` is a `StrEnum` — each member equals its string value (`Operator.EQ == "eq"`), so string keys still work but enum members provide type safety and autocompletion.
|
|
413
|
+
|
|
414
|
+
Multiple operators can be combined on a single field: `{"age": {soh.Operator.GE: 18, soh.Operator.LE: 65}}`.
|
|
415
|
+
|
|
416
|
+
### Typed Filter Models
|
|
417
|
+
|
|
418
|
+
Pydantic models for type-safe filter schemas in API endpoints:
|
|
419
|
+
|
|
420
|
+
```python
|
|
421
|
+
import sqlmodel_object_helpers as soh
|
|
422
|
+
from pydantic import BaseModel
|
|
423
|
+
|
|
424
|
+
class UserFilter(BaseModel):
|
|
425
|
+
age: soh.FilterInt | None = None # eq, ne, gt, lt, ge, le, in_
|
|
426
|
+
name: soh.FilterStr | None = None # eq, ne, like, ilike
|
|
427
|
+
is_active: soh.FilterBool | None = None # eq, ne
|
|
428
|
+
posts: soh.FilterExists | None = None # exists: bool
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
Available: `FilterInt`, `FilterStr`, `FilterDate`, `FilterDatetime`, `FilterTimedelta`, `FilterBool`, `FilterExists`.
|
|
432
|
+
|
|
433
|
+
## Eager Loading
|
|
434
|
+
|
|
435
|
+
The library automatically selects the optimal loading strategy:
|
|
436
|
+
|
|
437
|
+
- **`selectinload`** for one-to-many (`uselist=True`) -- avoids cartesian products
|
|
438
|
+
- **`joinedload`** for many-to-one (`uselist=False`) -- single-query efficiency
|
|
439
|
+
|
|
440
|
+
Dot-notation chaining resolves each level independently:
|
|
441
|
+
|
|
442
|
+
```python
|
|
443
|
+
# Each segment gets the optimal strategy
|
|
444
|
+
load_paths=["application.applicant"]
|
|
445
|
+
# application -> joinedload (many-to-one)
|
|
446
|
+
# applicant -> joinedload (many-to-one)
|
|
447
|
+
|
|
448
|
+
load_paths=["comments"]
|
|
449
|
+
# comments -> selectinload (one-to-many)
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
Maximum chain depth is controlled by `settings.max_load_depth` (default: 10).
|
|
453
|
+
|
|
454
|
+
## Pagination & Sorting
|
|
455
|
+
|
|
456
|
+
### Pagination
|
|
457
|
+
|
|
458
|
+
```python
|
|
459
|
+
import sqlmodel_object_helpers as soh
|
|
460
|
+
|
|
461
|
+
result = await soh.get_objects(
|
|
462
|
+
session, User,
|
|
463
|
+
pagination=soh.Pagination(page=2, per_page=25),
|
|
464
|
+
)
|
|
465
|
+
result.data # list[User]
|
|
466
|
+
result.pagination # PaginationR(page=2, per_page=25, total=150)
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
`per_page` is validated against `settings.max_per_page` (default: 500).
|
|
470
|
+
|
|
471
|
+
### Sorting
|
|
472
|
+
|
|
473
|
+
```python
|
|
474
|
+
import sqlmodel_object_helpers as soh
|
|
475
|
+
|
|
476
|
+
order = soh.OrderBy(sorts=[
|
|
477
|
+
soh.OrderAsc(asc="last_name"),
|
|
478
|
+
soh.OrderDesc(desc="created_at"),
|
|
479
|
+
])
|
|
480
|
+
|
|
481
|
+
result = await soh.get_objects(session, User, order_by=order)
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
## API Reference
|
|
485
|
+
|
|
486
|
+
### Settings
|
|
487
|
+
|
|
488
|
+
- `soh.settings` -- Module-level `QueryHelperSettings` instance (mutable at runtime)
|
|
489
|
+
- `soh.QueryHelperSettings` -- Pydantic model for security/performance limits
|
|
490
|
+
|
|
491
|
+
### Query Functions
|
|
492
|
+
|
|
493
|
+
- `soh.get_object(session, model, ...)` -- Single object by PK or filters (supports `for_update` row locking)
|
|
494
|
+
- `soh.get_objects(session, model, ...)` -- Multiple objects with filtering, pagination, sorting, `time_filter`
|
|
495
|
+
- `soh.count_objects(session, model, ...)` -- Count matching records (`SELECT count(*)`)
|
|
496
|
+
- `soh.exists_object(session, model, ...)` -- Check existence (`SELECT EXISTS(...)`)
|
|
497
|
+
- `soh.get_projection(session, model, columns, ...)` -- Column projection across joins
|
|
498
|
+
|
|
499
|
+
### Mutation Functions
|
|
500
|
+
|
|
501
|
+
- `soh.add_object(session, instance)` -- Add single, flush + refresh
|
|
502
|
+
- `soh.add_objects(session, instances)` -- Add multiple, flush + refresh each
|
|
503
|
+
- `soh.update_object(session, instance, data)` -- Update fields from dict
|
|
504
|
+
- `soh.update_objects(session, model, data, filters)` -- Bulk update via single `UPDATE ... WHERE`
|
|
505
|
+
- `soh.delete_object(session, model, *, instance, pk)` -- Delete by reference or PK
|
|
506
|
+
- `soh.delete_objects(session, model, filters)` -- Bulk delete via single `DELETE ... WHERE`
|
|
507
|
+
- `soh.check_for_related_records(session, model, pk)` -- Pre-deletion dependency check
|
|
508
|
+
|
|
509
|
+
### Filter Builders
|
|
510
|
+
|
|
511
|
+
- `soh.build_filter(model, filters, ...)` -- Build SQLAlchemy expression from LogicalFilter dict
|
|
512
|
+
- `soh.build_flat_filter(model, filters, ...)` -- Build from flat dot-notation dict (aliased joins)
|
|
513
|
+
- `soh.flatten_filters(filters)` -- Convert nested dicts to flat dot-notation
|
|
514
|
+
|
|
515
|
+
### Operators
|
|
516
|
+
|
|
517
|
+
- `soh.Operator` -- StrEnum of operator names
|
|
518
|
+
- `soh.SUPPORTED_OPERATORS` -- Dict mapping operator names to SQLAlchemy lambdas
|
|
519
|
+
- `soh.SPECIAL_OPERATORS` -- Frozenset of operators with extended handling (`{"exists"}`)
|
|
520
|
+
|
|
521
|
+
### Loaders
|
|
522
|
+
|
|
523
|
+
- `soh.build_load_chain(model, path, ...)` -- Build loader option from string/list path
|
|
524
|
+
- `soh.build_load_options(model, attrs)` -- Build single loader option chain
|
|
525
|
+
|
|
526
|
+
### Exceptions
|
|
527
|
+
|
|
528
|
+
- `soh.QueryError` -- Base exception (has `status_code` attribute for HTTP mapping)
|
|
529
|
+
- `soh.ObjectNotFoundError` -- 404 Not Found
|
|
530
|
+
- `soh.InvalidFilterError` -- 400 Bad Request
|
|
531
|
+
- `soh.InvalidLoadPathError` -- 400 Bad Request
|
|
532
|
+
- `soh.DatabaseError` -- 500 Internal Server Error
|
|
533
|
+
- `soh.MutationError` -- 400 Bad Request
|
|
534
|
+
|
|
535
|
+
### Filter Types
|
|
536
|
+
|
|
537
|
+
- `soh.FilterInt`, `soh.FilterStr`, `soh.FilterDate`, `soh.FilterDatetime`, `soh.FilterTimedelta`, `soh.FilterBool`, `soh.FilterExists`
|
|
538
|
+
- `soh.OrderAsc`, `soh.OrderDesc`, `soh.OrderBy`
|
|
539
|
+
- `soh.LogicalFilter`
|
|
540
|
+
- `soh.TimeFilter` -- `created_after`, `created_before`, `updated_after`, `updated_before` with half-open interval `[after, before)`
|
|
541
|
+
|
|
542
|
+
### Pagination Types
|
|
543
|
+
|
|
544
|
+
- `soh.Pagination` -- Request model (page, per\_page)
|
|
545
|
+
- `soh.PaginationR` -- Response model (page, per\_page, total)
|
|
546
|
+
- `soh.GetAllPagination[T]` -- Generic wrapper (data: list[T], pagination: PaginationR | None)
|
|
547
|
+
|
|
548
|
+
### Projection Types
|
|
549
|
+
|
|
550
|
+
- `soh.ColumnSpec` -- Type alias: `str | tuple[str, str]`
|
|
551
|
+
|
|
552
|
+
## Development
|
|
553
|
+
|
|
554
|
+
```bash
|
|
555
|
+
pip install -e ".[dev]"
|
|
556
|
+
pytest tests/
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
### Syncing with Remote
|
|
560
|
+
|
|
561
|
+
```bash
|
|
562
|
+
# Fetch commits and tags
|
|
563
|
+
git pull origin main --tags
|
|
564
|
+
|
|
565
|
+
# If local branch is behind remote
|
|
566
|
+
git reset --hard origin/main
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
### Verify sync status
|
|
570
|
+
|
|
571
|
+
```bash
|
|
572
|
+
git log --oneline origin/main -5
|
|
573
|
+
git diff origin/main
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
## CI/CD
|
|
577
|
+
|
|
578
|
+
The pipeline consists of two stages:
|
|
579
|
+
|
|
580
|
+
1. **auto_tag** — on push to `main`, reads `__version__` from `__init__.py` and automatically creates a tag (if it doesn't exist)
|
|
581
|
+
|
|
582
|
+
2. **mirror_to_github** — on tag creation, mirrors the repository to GitHub (removing `.gitlab-ci.yml`)
|
|
583
|
+
|
|
584
|
+
### Release flow
|
|
585
|
+
|
|
586
|
+
1. Update `__version__` in `src/sqlmodel_object_helpers/__init__.py`
|
|
587
|
+
2. Push to main
|
|
588
|
+
3. CI creates tag → mirrors to GitHub → publishes to PyPI
|
|
589
|
+
|
|
590
|
+
## Versioning
|
|
591
|
+
|
|
592
|
+
This project follows [Semantic Versioning](https://semver.org/) (MAJOR.MINOR.PATCH):
|
|
593
|
+
|
|
594
|
+
- **PATCH** — bug fixes, documentation, metadata
|
|
595
|
+
- **MINOR** — new features (backwards compatible)
|
|
596
|
+
- **MAJOR** — breaking changes
|
|
597
|
+
|
|
598
|
+
## License
|
|
599
|
+
|
|
600
|
+
This project is licensed under the **PolyForm Noncommercial License 1.0.0**.
|
|
601
|
+
|
|
602
|
+
**You may use this software for noncommercial purposes only.**
|
|
603
|
+
|
|
604
|
+
See [LICENSE](LICENSE) for the full license text, or visit [polyformproject.org](https://polyformproject.org/licenses/noncommercial/1.0.0/).
|