sqlmodel-object-helpers 0.0.1__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 (29) hide show
  1. sqlmodel_object_helpers-0.0.1/.github/.gitkeep +0 -0
  2. sqlmodel_object_helpers-0.0.1/.github/workflows/.gitkeep +0 -0
  3. sqlmodel_object_helpers-0.0.1/.github/workflows/publish.yml +50 -0
  4. sqlmodel_object_helpers-0.0.1/LICENSE +75 -0
  5. sqlmodel_object_helpers-0.0.1/PKG-INFO +515 -0
  6. sqlmodel_object_helpers-0.0.1/README.md +481 -0
  7. sqlmodel_object_helpers-0.0.1/pyproject.toml +64 -0
  8. sqlmodel_object_helpers-0.0.1/src/sqlmodel_object_helpers/__init__.py +97 -0
  9. sqlmodel_object_helpers-0.0.1/src/sqlmodel_object_helpers/constants.py +34 -0
  10. sqlmodel_object_helpers-0.0.1/src/sqlmodel_object_helpers/exceptions.py +52 -0
  11. sqlmodel_object_helpers-0.0.1/src/sqlmodel_object_helpers/filters.py +440 -0
  12. sqlmodel_object_helpers-0.0.1/src/sqlmodel_object_helpers/loaders.py +88 -0
  13. sqlmodel_object_helpers-0.0.1/src/sqlmodel_object_helpers/mutations.py +339 -0
  14. sqlmodel_object_helpers-0.0.1/src/sqlmodel_object_helpers/operators.py +43 -0
  15. sqlmodel_object_helpers-0.0.1/src/sqlmodel_object_helpers/query.py +563 -0
  16. sqlmodel_object_helpers-0.0.1/src/sqlmodel_object_helpers/session.py +36 -0
  17. sqlmodel_object_helpers-0.0.1/src/sqlmodel_object_helpers/types/__init__.py +0 -0
  18. sqlmodel_object_helpers-0.0.1/src/sqlmodel_object_helpers/types/filters.py +126 -0
  19. sqlmodel_object_helpers-0.0.1/src/sqlmodel_object_helpers/types/pagination.py +15 -0
  20. sqlmodel_object_helpers-0.0.1/src/sqlmodel_object_helpers/types/projections.py +8 -0
  21. sqlmodel_object_helpers-0.0.1/tests/conftest.py +640 -0
  22. sqlmodel_object_helpers-0.0.1/tests/test_exceptions.py +202 -0
  23. sqlmodel_object_helpers-0.0.1/tests/test_filters.py +352 -0
  24. sqlmodel_object_helpers-0.0.1/tests/test_loaders.py +178 -0
  25. sqlmodel_object_helpers-0.0.1/tests/test_mutations.py +393 -0
  26. sqlmodel_object_helpers-0.0.1/tests/test_operators.py +197 -0
  27. sqlmodel_object_helpers-0.0.1/tests/test_query.py +535 -0
  28. sqlmodel_object_helpers-0.0.1/tests/test_settings.py +166 -0
  29. sqlmodel_object_helpers-0.0.1/tests/test_types.py +737 -0
File without changes
@@ -0,0 +1,50 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+
14
+ - name: Set up Python
15
+ uses: actions/setup-python@v5
16
+ with:
17
+ python-version: '3.14'
18
+
19
+ - name: Install build dependencies
20
+ run: |
21
+ python -m pip install --upgrade pip
22
+ pip install build twine
23
+
24
+ - name: Build package
25
+ run: python -m build
26
+
27
+ - name: Check package
28
+ run: twine check dist/*
29
+
30
+ - name: Upload artifacts
31
+ uses: actions/upload-artifact@v4
32
+ with:
33
+ name: dist
34
+ path: dist/
35
+
36
+ publish:
37
+ needs: build
38
+ runs-on: ubuntu-latest
39
+ environment: pypi
40
+ permissions:
41
+ id-token: write
42
+ steps:
43
+ - name: Download artifacts
44
+ uses: actions/download-artifact@v4
45
+ with:
46
+ name: dist
47
+ path: dist/
48
+
49
+ - name: Publish to PyPI
50
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,75 @@
1
+ # PolyForm Noncommercial License 1.0.0
2
+
3
+ <https://polyformproject.org/licenses/noncommercial/1.0.0>
4
+
5
+ Copyright (c) 2025 ANO NAMIS
6
+
7
+ ## Acceptance
8
+
9
+ In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
10
+
11
+ ## Copyright License
12
+
13
+ The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
14
+
15
+ ## Distribution License
16
+
17
+ The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
18
+
19
+ ## Notices
20
+
21
+ You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
22
+
23
+ > Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
24
+
25
+ ## Changes and New Works License
26
+
27
+ The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
28
+
29
+ ## Patent License
30
+
31
+ The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
32
+
33
+ ## Noncommercial Purposes
34
+
35
+ Any noncommercial purpose is a permitted purpose.
36
+
37
+ ## Personal Uses
38
+
39
+ Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
40
+
41
+ ## Noncommercial Organizations
42
+
43
+ Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
44
+
45
+ ## Fair Use
46
+
47
+ You may have "fair use" rights for the software under the law. These terms do not limit them.
48
+
49
+ ## No Other Rights
50
+
51
+ These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
52
+
53
+ ## Patent Defense
54
+
55
+ If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
56
+
57
+ ## Violations
58
+
59
+ The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
60
+
61
+ ## No Liability
62
+
63
+ ***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
64
+
65
+ ## Definitions
66
+
67
+ The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
68
+
69
+ **You** refers to the individual or entity agreeing to these terms.
70
+
71
+ **Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
72
+
73
+ **Your licenses** are all the licenses granted to you for the software under these terms.
74
+
75
+ **Use** means anything you do with the software requiring one of your licenses.
@@ -0,0 +1,515 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlmodel-object-helpers
3
+ Version: 0.0.1
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` with flush-only semantics for composable transactions
52
+ - **Relationship Safety Check** - `check_for_related_records` pre-deletion inspection of ONETOMANY dependencies
53
+ - **Security Limits** - Configurable depth, list size, and pagination caps to prevent abuse
54
+ - **Type Safety** - Full type annotations with PEP 695 generics and `py.typed` marker (PEP 561)
55
+
56
+ ## Installation
57
+
58
+ ```bash
59
+ pip install sqlmodel-object-helpers
60
+ ```
61
+
62
+ **Note:** The import name is `sqlmodel_object_helpers`:
63
+
64
+ ```python
65
+ from sqlmodel_object_helpers import get_objects, get_object, add_object
66
+ ```
67
+
68
+ ## Quick Start
69
+
70
+ ```python
71
+ from sqlalchemy.ext.asyncio import AsyncSession
72
+ from sqlmodel import SQLModel, Field
73
+
74
+ from sqlmodel_object_helpers import (
75
+ get_object, get_objects, add_object, Pagination,
76
+ )
77
+
78
+
79
+ class User(SQLModel, table=True):
80
+ id: int | None = Field(default=None, primary_key=True)
81
+ name: str
82
+ is_active: bool = True
83
+
84
+
85
+ async def example(session: AsyncSession):
86
+ # Create
87
+ user = await add_object(session, User(name="Alice"))
88
+
89
+ # Get one by PK
90
+ found = await get_object(session, User, pk={"id": user.id})
91
+
92
+ # Get many with filtering
93
+ active_users = await get_objects(
94
+ session, User,
95
+ filters={"is_active": {"eq": True}},
96
+ )
97
+
98
+ # Paginated
99
+ page = await get_objects(
100
+ session, User,
101
+ pagination=Pagination(page=1, per_page=25),
102
+ )
103
+ # page.data -> list[User], page.pagination.total -> int
104
+ ```
105
+
106
+ ## Configuration
107
+
108
+ Override security limits at application startup:
109
+
110
+ ```python
111
+ from sqlmodel_object_helpers import settings
112
+
113
+ settings.max_per_page = 300
114
+ settings.max_filter_depth = 50
115
+ ```
116
+
117
+ | Setting | Default | Description |
118
+ |---------|---------|-------------|
119
+ | `max_filter_depth` | `100` | Maximum recursion depth for AND/OR nesting |
120
+ | `max_and_or_items` | `100` | Maximum sub-filters in a single AND/OR list |
121
+ | `max_load_depth` | `10` | Maximum depth of eager-loading chains |
122
+ | `max_in_list_size` | `1000` | Maximum elements in an IN(...) list |
123
+ | `max_per_page` | `500` | Maximum value for per_page in pagination |
124
+
125
+ ## Query Operations
126
+
127
+ ### get_object
128
+
129
+ 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).
130
+
131
+ ```python
132
+ # By primary key
133
+ user = await get_object(session, User, pk={"id": 5})
134
+
135
+ # By LogicalFilter
136
+ from sqlmodel_object_helpers import LogicalFilter
137
+
138
+ user = await get_object(
139
+ session, User,
140
+ filters=LogicalFilter(condition={"name": {"eq": "Alice"}}),
141
+ )
142
+
143
+ # With eager loading
144
+ user = await get_object(
145
+ session, User,
146
+ pk={"id": 5},
147
+ load_paths=["posts", "posts.comments"],
148
+ )
149
+
150
+ # Graceful mode (returns None instead of raising)
151
+ user = await get_object(session, User, pk={"id": 999}, suspend_error=True)
152
+ ```
153
+
154
+ ### get_objects
155
+
156
+ Fetch multiple objects with filtering, pagination, sorting, and eager loading.
157
+
158
+ ```python
159
+ from sqlmodel_object_helpers import get_objects, Pagination, OrderBy, OrderAsc
160
+
161
+ # Simple filter
162
+ users = await get_objects(session, User, filters={"is_active": {"eq": True}})
163
+
164
+ # With pagination + sorting
165
+ result = await get_objects(
166
+ session, User,
167
+ pagination=Pagination(page=1, per_page=25),
168
+ order_by=OrderBy(sorts=[OrderAsc(asc="name")]),
169
+ )
170
+ # result.data -> list[User]
171
+ # result.pagination -> PaginationR(page=1, per_page=25, total=100)
172
+
173
+ # OR logic between conditions
174
+ users = await get_objects(
175
+ session, User,
176
+ filters={"is_active": {"eq": True}, "role": {"eq": "admin"}},
177
+ logical_operator="OR",
178
+ )
179
+
180
+ # Relationship filters (dot-notation)
181
+ users = await get_objects(
182
+ session, Attempt,
183
+ filters={"application.applicant.last_name": {"eq": "Smith"}},
184
+ load_paths=["application.applicant"],
185
+ )
186
+ ```
187
+
188
+ ### get_projection
189
+
190
+ Fetch specific columns from related tables. Returns flat dicts instead of ORM instances.
191
+
192
+ ```python
193
+ from sqlmodel_object_helpers import get_projection
194
+
195
+ rows = await get_projection(
196
+ session,
197
+ Attempt,
198
+ columns=[
199
+ "id",
200
+ ("application.applicant.last_name", "applicant_name"),
201
+ ("schedule.unit.address", "unit_address"),
202
+ ],
203
+ outer_joins=["schedule"],
204
+ limit=100,
205
+ )
206
+ # [{"id": 1, "applicant_name": "Smith", "unit_address": "123 Main St"}, ...]
207
+ ```
208
+
209
+ ## Mutations
210
+
211
+ 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:
212
+
213
+ ```python
214
+ async with session.begin():
215
+ await add_object(session, billing)
216
+ await add_object(session, payment)
217
+ # commit happens automatically when the block exits
218
+ ```
219
+
220
+ ### add_object / add_objects
221
+
222
+ ```python
223
+ from sqlmodel_object_helpers import add_object, add_objects
224
+
225
+ # Single
226
+ user = await add_object(session, User(name="Alice"))
227
+ # user.id is now populated (server-generated)
228
+
229
+ # Bulk
230
+ users = await add_objects(session, [User(name="Alice"), User(name="Bob")])
231
+ ```
232
+
233
+ ### update_object
234
+
235
+ ```python
236
+ from sqlmodel_object_helpers import update_object
237
+
238
+ user = await get_object(session, User, pk={"id": 1})
239
+ updated = await update_object(session, user, {"name": "Alice Updated", "is_active": False})
240
+ ```
241
+
242
+ Field names are validated against the model before touching the database. Raises `MutationError` if a key does not exist on the model.
243
+
244
+ ### delete_object
245
+
246
+ ```python
247
+ from sqlmodel_object_helpers import delete_object
248
+
249
+ # By instance
250
+ await delete_object(session, User, instance=user)
251
+
252
+ # By PK
253
+ await delete_object(session, User, pk={"id": 5})
254
+ ```
255
+
256
+ ### check_for_related_records
257
+
258
+ Pre-deletion check that inspects all ONETOMANY relationships:
259
+
260
+ ```python
261
+ from sqlmodel_object_helpers import check_for_related_records
262
+
263
+ deps = await check_for_related_records(session, Organization, pk={"id": 1})
264
+ if deps:
265
+ print(deps)
266
+ # ["Related record found in 'Unit (units_lkp)' (id=1)", ...]
267
+ ```
268
+
269
+ Returns `None` if no related records exist, or a list of human-readable dependency descriptions.
270
+
271
+ ## Filtering
272
+
273
+ ### LogicalFilter (AND/OR/condition)
274
+
275
+ Recursive filter structure used by `get_object`. Exactly one of `AND`, `OR`, or `condition` must be set:
276
+
277
+ ```python
278
+ from sqlmodel_object_helpers import LogicalFilter
279
+
280
+ # Simple condition
281
+ f = LogicalFilter(condition={"status_id": {"eq": 10}})
282
+
283
+ # OR
284
+ f = LogicalFilter(OR=[
285
+ LogicalFilter(condition={"status_id": {"eq": 10}}),
286
+ LogicalFilter(condition={"is_blocked": {"eq": True}}),
287
+ ])
288
+
289
+ # Nested AND + OR
290
+ f = LogicalFilter(AND=[
291
+ LogicalFilter(condition={"is_active": {"eq": True}}),
292
+ LogicalFilter(OR=[
293
+ LogicalFilter(condition={"role": {"eq": "admin"}}),
294
+ LogicalFilter(condition={"role": {"eq": "manager"}}),
295
+ ]),
296
+ ])
297
+ ```
298
+
299
+ ### Flat dict filters
300
+
301
+ Used by `get_objects`. Nested dicts are auto-flattened via `flatten_filters`:
302
+
303
+ ```python
304
+ # Nested form (auto-flattened)
305
+ {"application": {"applicant": {"last_name": {"eq": "test"}}}}
306
+
307
+ # Equivalent flat form (dot-notation)
308
+ {"application.applicant.last_name": {"eq": "test"}}
309
+ ```
310
+
311
+ ### Operators
312
+
313
+ | Operator | SQL | Example |
314
+ |----------|-----|---------|
315
+ | `eq` | `=` | `{"eq": 10}` |
316
+ | `ne` | `!=` | `{"ne": 0}` |
317
+ | `gt` | `>` | `{"gt": 5}` |
318
+ | `lt` | `<` | `{"lt": 100}` |
319
+ | `ge` | `>=` | `{"ge": 1}` |
320
+ | `le` | `<=` | `{"le": 50}` |
321
+ | `in_` | `IN (...)` | `{"in_": [1, 2, 3]}` |
322
+ | `not_in` | `NOT IN (...)` | `{"not_in": [4, 5]}` |
323
+ | `like` | `LIKE` | `{"like": "%test%"}` |
324
+ | `ilike` | `ILIKE` | `{"ilike": "%test%"}` |
325
+ | `between` | `BETWEEN` | `{"between": [1, 10]}` |
326
+ | `is` | `IS` | `{"is": null}` |
327
+ | `isnot` | `IS NOT` | `{"isnot": null}` |
328
+ | `match` | `MATCH` | `{"match": "query"}` |
329
+ | `exists` | `IS NOT NULL` / `.any()` | `{"exists": true}` |
330
+
331
+ Multiple operators can be combined on a single field: `{"age": {"ge": 18, "le": 65}}`.
332
+
333
+ ### Typed Filter Models
334
+
335
+ Pydantic models for type-safe filter schemas in API endpoints:
336
+
337
+ ```python
338
+ from sqlmodel_object_helpers import FilterInt, FilterStr, FilterBool, FilterExists
339
+
340
+ class UserFilter(BaseModel):
341
+ age: FilterInt | None = None # eq, ne, gt, lt, ge, le, in_
342
+ name: FilterStr | None = None # eq, ne, like, ilike
343
+ is_active: FilterBool | None = None # eq, ne
344
+ posts: FilterExists | None = None # exists: bool
345
+ ```
346
+
347
+ Available: `FilterInt`, `FilterStr`, `FilterDate`, `FilterDatetime`, `FilterTimedelta`, `FilterBool`, `FilterExists`.
348
+
349
+ ## Eager Loading
350
+
351
+ The library automatically selects the optimal loading strategy:
352
+
353
+ - **`selectinload`** for one-to-many (`uselist=True`) -- avoids cartesian products
354
+ - **`joinedload`** for many-to-one (`uselist=False`) -- single-query efficiency
355
+
356
+ Dot-notation chaining resolves each level independently:
357
+
358
+ ```python
359
+ # Each segment gets the optimal strategy
360
+ load_paths=["application.applicant"]
361
+ # application -> joinedload (many-to-one)
362
+ # applicant -> joinedload (many-to-one)
363
+
364
+ load_paths=["comments"]
365
+ # comments -> selectinload (one-to-many)
366
+ ```
367
+
368
+ Maximum chain depth is controlled by `settings.max_load_depth` (default: 10).
369
+
370
+ ## Pagination & Sorting
371
+
372
+ ### Pagination
373
+
374
+ ```python
375
+ from sqlmodel_object_helpers import get_objects, Pagination
376
+
377
+ result = await get_objects(
378
+ session, User,
379
+ pagination=Pagination(page=2, per_page=25),
380
+ )
381
+ result.data # list[User]
382
+ result.pagination # PaginationR(page=2, per_page=25, total=150)
383
+ ```
384
+
385
+ `per_page` is validated against `settings.max_per_page` (default: 500).
386
+
387
+ ### Sorting
388
+
389
+ ```python
390
+ from sqlmodel_object_helpers import OrderBy, OrderAsc, OrderDesc
391
+
392
+ order = OrderBy(sorts=[
393
+ OrderAsc(asc="last_name"),
394
+ OrderDesc(desc="created_at"),
395
+ ])
396
+
397
+ result = await get_objects(session, User, order_by=order)
398
+ ```
399
+
400
+ ## API Reference
401
+
402
+ ### Settings
403
+
404
+ - `settings` -- Module-level `QueryHelperSettings` instance (mutable at runtime)
405
+ - `QueryHelperSettings` -- Pydantic model for security/performance limits
406
+
407
+ ### Query Functions
408
+
409
+ - `get_object(session, model, ...)` -- Single object by PK or filters
410
+ - `get_objects(session, model, ...)` -- Multiple objects with filtering, pagination, sorting
411
+ - `get_projection(session, model, columns, ...)` -- Column projection across joins
412
+
413
+ ### Mutation Functions
414
+
415
+ - `add_object(session, instance)` -- Add single, flush + refresh
416
+ - `add_objects(session, instances)` -- Add multiple, flush + refresh each
417
+ - `update_object(session, instance, data)` -- Update fields from dict
418
+ - `delete_object(session, model, *, instance, pk)` -- Delete by reference or PK
419
+ - `check_for_related_records(session, model, pk)` -- Pre-deletion dependency check
420
+
421
+ ### Filter Builders
422
+
423
+ - `build_filter(model, filters, ...)` -- Build SQLAlchemy expression from LogicalFilter dict
424
+ - `build_flat_filter(model, filters, ...)` -- Build from flat dot-notation dict (aliased joins)
425
+ - `flatten_filters(filters)` -- Convert nested dicts to flat dot-notation
426
+
427
+ ### Operators
428
+
429
+ - `Operator` -- StrEnum of operator names
430
+ - `SUPPORTED_OPERATORS` -- Dict mapping operator names to SQLAlchemy lambdas
431
+ - `SPECIAL_OPERATORS` -- Frozenset of operators with extended handling (`{"exists"}`)
432
+
433
+ ### Loaders
434
+
435
+ - `build_load_chain(model, path, ...)` -- Build loader option from string/list path
436
+ - `build_load_options(model, attrs)` -- Build single loader option chain
437
+
438
+ ### Exceptions
439
+
440
+ - `QueryError` -- Base exception (has `status_code` attribute for HTTP mapping)
441
+ - `ObjectNotFoundError` -- 404 Not Found
442
+ - `InvalidFilterError` -- 400 Bad Request
443
+ - `InvalidLoadPathError` -- 400 Bad Request
444
+ - `DatabaseError` -- 500 Internal Server Error
445
+ - `MutationError` -- 400 Bad Request
446
+
447
+ ### Filter Types
448
+
449
+ - `FilterInt`, `FilterStr`, `FilterDate`, `FilterDatetime`, `FilterTimedelta`, `FilterBool`, `FilterExists`
450
+ - `OrderAsc`, `OrderDesc`, `OrderBy`
451
+ - `LogicalFilter`
452
+
453
+ ### Pagination Types
454
+
455
+ - `Pagination` -- Request model (page, per\_page)
456
+ - `PaginationR` -- Response model (page, per\_page, total)
457
+ - `GetAllPagination[T]` -- Generic wrapper (data: list[T], pagination: PaginationR | None)
458
+
459
+ ### Projection Types
460
+
461
+ - `ColumnSpec` -- Type alias: `str | tuple[str, str]`
462
+
463
+ ## Development
464
+
465
+ ```bash
466
+ pip install -e ".[dev]"
467
+ pytest tests/
468
+ ```
469
+
470
+ ### Syncing with Remote
471
+
472
+ ```bash
473
+ # Fetch commits and tags
474
+ git pull origin main --tags
475
+
476
+ # If local branch is behind remote
477
+ git reset --hard origin/main
478
+ ```
479
+
480
+ ### Verify sync status
481
+
482
+ ```bash
483
+ git log --oneline origin/main -5
484
+ git diff origin/main
485
+ ```
486
+
487
+ ## CI/CD
488
+
489
+ The pipeline consists of two stages:
490
+
491
+ 1. **auto_tag** — on push to `main`, reads `__version__` from `__init__.py` and automatically creates a tag (if it doesn't exist)
492
+
493
+ 2. **mirror_to_github** — on tag creation, mirrors the repository to GitHub (removing `.gitlab-ci.yml`)
494
+
495
+ ### Release flow
496
+
497
+ 1. Update `__version__` in `src/sqlmodel_object_helpers/__init__.py`
498
+ 2. Push to main
499
+ 3. CI creates tag → mirrors to GitHub → publishes to PyPI
500
+
501
+ ## Versioning
502
+
503
+ This project follows [Semantic Versioning](https://semver.org/) (MAJOR.MINOR.PATCH):
504
+
505
+ - **PATCH** — bug fixes, documentation, metadata
506
+ - **MINOR** — new features (backwards compatible)
507
+ - **MAJOR** — breaking changes
508
+
509
+ ## License
510
+
511
+ This project is licensed under the **PolyForm Noncommercial License 1.0.0**.
512
+
513
+ **You may use this software for noncommercial purposes only.**
514
+
515
+ See [LICENSE](LICENSE) for the full license text, or visit [polyformproject.org](https://polyformproject.org/licenses/noncommercial/1.0.0/).