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.
Files changed (37) hide show
  1. sqlmodel_object_helpers-0.0.3/PKG-INFO +604 -0
  2. sqlmodel_object_helpers-0.0.3/README.md +570 -0
  3. {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/src/sqlmodel_object_helpers/__init__.py +37 -39
  4. {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/src/sqlmodel_object_helpers/constants.py +5 -2
  5. {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/src/sqlmodel_object_helpers/exceptions.py +2 -1
  6. {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/src/sqlmodel_object_helpers/filters.py +23 -14
  7. {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/src/sqlmodel_object_helpers/loaders.py +11 -12
  8. {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/src/sqlmodel_object_helpers/mutations.py +196 -44
  9. {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/src/sqlmodel_object_helpers/operators.py +4 -2
  10. {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/src/sqlmodel_object_helpers/query.py +229 -60
  11. {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/src/sqlmodel_object_helpers/session.py +8 -4
  12. {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/src/sqlmodel_object_helpers/types/filters.py +49 -6
  13. {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/tests/conftest.py +127 -21
  14. sqlmodel_object_helpers-0.0.3/tests/test_bulk_mutations.py +154 -0
  15. sqlmodel_object_helpers-0.0.3/tests/test_computed_columns.py +74 -0
  16. sqlmodel_object_helpers-0.0.3/tests/test_count_exists.py +152 -0
  17. {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/tests/test_exceptions.py +39 -45
  18. {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/tests/test_filters.py +71 -77
  19. sqlmodel_object_helpers-0.0.3/tests/test_for_update.py +37 -0
  20. sqlmodel_object_helpers-0.0.3/tests/test_generated_columns_pg.py +148 -0
  21. {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/tests/test_loaders.py +39 -40
  22. {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/tests/test_mutations.py +45 -52
  23. {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/tests/test_operators.py +48 -53
  24. {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/tests/test_query.py +79 -82
  25. {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/tests/test_settings.py +50 -51
  26. sqlmodel_object_helpers-0.0.3/tests/test_time_filter.py +175 -0
  27. {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/tests/test_types.py +101 -115
  28. sqlmodel_object_helpers-0.0.1/.github/.gitkeep +0 -0
  29. sqlmodel_object_helpers-0.0.1/.github/workflows/.gitkeep +0 -0
  30. sqlmodel_object_helpers-0.0.1/PKG-INFO +0 -515
  31. sqlmodel_object_helpers-0.0.1/README.md +0 -481
  32. {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/.github/workflows/publish.yml +0 -0
  33. {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/LICENSE +0 -0
  34. {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/pyproject.toml +0 -0
  35. {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/src/sqlmodel_object_helpers/types/__init__.py +0 -0
  36. {sqlmodel_object_helpers-0.0.1 → sqlmodel_object_helpers-0.0.3}/src/sqlmodel_object_helpers/types/pagination.py +0 -0
  37. {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
+ [![PyPI version](https://badge.fury.io/py/sqlmodel-object-helpers.svg)](https://pypi.org/project/sqlmodel-object-helpers/)
38
+ [![Python 3.14+](https://img.shields.io/badge/python-3.14+-blue.svg)](https://www.python.org/downloads/)
39
+ [![License: PolyForm Noncommercial](https://img.shields.io/badge/License-PolyForm%20Noncommercial-blue.svg)](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/).