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.
- 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/.github/workflows/publish.yml +50 -0
- sqlmodel_object_helpers-0.0.1/LICENSE +75 -0
- sqlmodel_object_helpers-0.0.1/PKG-INFO +515 -0
- sqlmodel_object_helpers-0.0.1/README.md +481 -0
- sqlmodel_object_helpers-0.0.1/pyproject.toml +64 -0
- sqlmodel_object_helpers-0.0.1/src/sqlmodel_object_helpers/__init__.py +97 -0
- sqlmodel_object_helpers-0.0.1/src/sqlmodel_object_helpers/constants.py +34 -0
- sqlmodel_object_helpers-0.0.1/src/sqlmodel_object_helpers/exceptions.py +52 -0
- sqlmodel_object_helpers-0.0.1/src/sqlmodel_object_helpers/filters.py +440 -0
- sqlmodel_object_helpers-0.0.1/src/sqlmodel_object_helpers/loaders.py +88 -0
- sqlmodel_object_helpers-0.0.1/src/sqlmodel_object_helpers/mutations.py +339 -0
- sqlmodel_object_helpers-0.0.1/src/sqlmodel_object_helpers/operators.py +43 -0
- sqlmodel_object_helpers-0.0.1/src/sqlmodel_object_helpers/query.py +563 -0
- sqlmodel_object_helpers-0.0.1/src/sqlmodel_object_helpers/session.py +36 -0
- sqlmodel_object_helpers-0.0.1/src/sqlmodel_object_helpers/types/__init__.py +0 -0
- sqlmodel_object_helpers-0.0.1/src/sqlmodel_object_helpers/types/filters.py +126 -0
- sqlmodel_object_helpers-0.0.1/src/sqlmodel_object_helpers/types/pagination.py +15 -0
- sqlmodel_object_helpers-0.0.1/src/sqlmodel_object_helpers/types/projections.py +8 -0
- sqlmodel_object_helpers-0.0.1/tests/conftest.py +640 -0
- sqlmodel_object_helpers-0.0.1/tests/test_exceptions.py +202 -0
- sqlmodel_object_helpers-0.0.1/tests/test_filters.py +352 -0
- sqlmodel_object_helpers-0.0.1/tests/test_loaders.py +178 -0
- sqlmodel_object_helpers-0.0.1/tests/test_mutations.py +393 -0
- sqlmodel_object_helpers-0.0.1/tests/test_operators.py +197 -0
- sqlmodel_object_helpers-0.0.1/tests/test_query.py +535 -0
- sqlmodel_object_helpers-0.0.1/tests/test_settings.py +166 -0
- sqlmodel_object_helpers-0.0.1/tests/test_types.py +737 -0
|
File without changes
|
|
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
|
+
[](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` 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/).
|