activemodel 0.11.0__tar.gz → 0.12.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {activemodel-0.11.0 → activemodel-0.12.0}/.envrc +1 -1
- {activemodel-0.11.0 → activemodel-0.12.0}/.github/workflows/build_and_publish.yml +5 -4
- {activemodel-0.11.0 → activemodel-0.12.0}/.github/workflows/repo-sync.yml +1 -1
- activemodel-0.12.0/.tool-versions +3 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/CHANGELOG.md +38 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/Makefile +4 -0
- activemodel-0.11.0/README.md → activemodel-0.12.0/PKG-INFO +40 -4
- activemodel-0.11.0/PKG-INFO → activemodel-0.12.0/README.md +25 -19
- {activemodel-0.11.0 → activemodel-0.12.0}/TODO +15 -1
- {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/base_model.py +23 -8
- activemodel-0.12.0/activemodel/cli/__init__.py +147 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/mixins/timestamps.py +4 -1
- {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/mixins/typeid.py +1 -1
- activemodel-0.12.0/activemodel/pytest/__init__.py +2 -0
- activemodel-0.12.0/activemodel/pytest/factories.py +102 -0
- activemodel-0.12.0/activemodel/pytest/plugin.py +81 -0
- activemodel-0.12.0/activemodel/pytest/transaction.py +150 -0
- activemodel-0.12.0/activemodel/pytest/truncate.py +147 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/query_wrapper.py +12 -2
- {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/session_manager.py +45 -11
- activemodel-0.12.0/activemodel/types/sqlalchemy_protocol.py +10 -0
- activemodel-0.12.0/activemodel/types/sqlalchemy_protocol.pyi +132 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/types/typeid.py +1 -0
- activemodel-0.12.0/activemodel/utils.py +29 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/pyproject.toml +11 -4
- {activemodel-0.11.0 → activemodel-0.12.0}/test/conftest.py +7 -3
- activemodel-0.12.0/test/factory_test.py +57 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/test/orm_test.py +27 -0
- activemodel-0.12.0/test/pytest/pytest_test.py +134 -0
- activemodel-0.12.0/test/session_manager_test.py +60 -0
- activemodel-0.12.0/test/table_name_test.py +15 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/test/test_wrapper.py +17 -0
- activemodel-0.12.0/uv.lock +1644 -0
- activemodel-0.11.0/.tool-versions +0 -3
- activemodel-0.11.0/activemodel/pytest/__init__.py +0 -2
- activemodel-0.11.0/activemodel/pytest/transaction.py +0 -63
- activemodel-0.11.0/activemodel/pytest/truncate.py +0 -46
- activemodel-0.11.0/activemodel/utils.py +0 -65
- activemodel-0.11.0/test/session_manager_test.py +0 -22
- activemodel-0.11.0/test/table_name_test.py +0 -14
- activemodel-0.11.0/uv.lock +0 -1367
- {activemodel-0.11.0 → activemodel-0.12.0}/.github/dependabot.yml +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/.gitignore +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/.vscode/settings.json +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/Justfile +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/LICENSE +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/__init__.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/celery.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/errors.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/get_column_from_field_patch.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/logger.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/mixins/__init__.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/mixins/pydantic_json.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/mixins/soft_delete.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/types/__init__.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/types/typeid_patch.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/docker-compose.yml +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/playground/alternative_typeid_mixin.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/playground/comments.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/playground/env-with-model.patch +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/playground/extract_comments.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/playground/field.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/playground/middleware.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/playground/old_session_manager.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/playground/pydantic_validation.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/playground.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/test/__init__.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/test/comments_test.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/test/delete_test.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/test/fastapi_test.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/test/import_test.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/test/migrations/README +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/test/migrations/alembic.ini +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/test/migrations/env.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/test/migrations/script.py.mako +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/test/migrations_test.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/test/models.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/test/mutation_test.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/test/nested_pydantic_json_test.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/test/orm/test_upsert.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/test/types/typeid_mixin_test.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/test/types/typeid_pydantic_test.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/test/types/typeid_sqlmodel_test.py +0 -0
- {activemodel-0.11.0 → activemodel-0.12.0}/test/utils.py +0 -0
|
@@ -4,6 +4,7 @@ on:
|
|
|
4
4
|
branches:
|
|
5
5
|
- main
|
|
6
6
|
- master
|
|
7
|
+
pull_request:
|
|
7
8
|
|
|
8
9
|
env:
|
|
9
10
|
# avoid build failures due to flaky pypi
|
|
@@ -34,8 +35,8 @@ jobs:
|
|
|
34
35
|
needs: [release-please]
|
|
35
36
|
if: needs.release-please.outputs.release_created
|
|
36
37
|
steps:
|
|
37
|
-
- uses: actions/checkout@
|
|
38
|
-
- uses: jdx/mise-action@
|
|
38
|
+
- uses: actions/checkout@v5
|
|
39
|
+
- uses: jdx/mise-action@v3
|
|
39
40
|
- run: direnv allow . && direnv export gha >> "$GITHUB_ENV"
|
|
40
41
|
- run: uv build
|
|
41
42
|
- run: uv publish --token ${{ secrets.PYPI_API_TOKEN }}
|
|
@@ -49,8 +50,8 @@ jobs:
|
|
|
49
50
|
python-version: ["3.13", "3.12"]
|
|
50
51
|
runs-on: ${{ matrix.os }}
|
|
51
52
|
steps:
|
|
52
|
-
- uses: actions/checkout@
|
|
53
|
-
- uses: jdx/mise-action@
|
|
53
|
+
- uses: actions/checkout@v5
|
|
54
|
+
- uses: jdx/mise-action@v3
|
|
54
55
|
- run: mise use python@${{ matrix.python-version }}
|
|
55
56
|
- run: docker compose up -d --wait
|
|
56
57
|
- uses: iloveitaly/github-action-direnv-load-and-mask@master
|
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.12.0](https://github.com/iloveitaly/activemodel/compare/v0.11.0...v0.12.0) (2025-09-03)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add ActiveModelFactory helpers for session management and typeid generation ([a6b9915](https://github.com/iloveitaly/activemodel/commit/a6b9915cc0c74c3b0d1421bc3d0300c1a2f63426))
|
|
9
|
+
* add base SQLModel and ActiveModel polyfactory factories ([12a5e1d](https://github.com/iloveitaly/activemodel/commit/12a5e1db9be741f12db174d5a25f6c2eee61a446))
|
|
10
|
+
* add one_or_none method to BaseModel for safe queries ([7574777](https://github.com/iloveitaly/activemodel/commit/75747773f99a7a6cc82d2ca784ce607abd7ae511))
|
|
11
|
+
* add pytest db_session fixture ([22d2ad8](https://github.com/iloveitaly/activemodel/commit/22d2ad8cbd77260c1c73524f3251ef7fd145caec))
|
|
12
|
+
* add scalar method to QueryWrapper and return from delete ([3d68097](https://github.com/iloveitaly/activemodel/commit/3d680972b6a60b43de6d3826d4289b0f81847b74))
|
|
13
|
+
* add SQLAlchemy protocol generation script ([6e4524e](https://github.com/iloveitaly/activemodel/commit/6e4524ed73644bea5b837ce2a9a7a1994b19df00))
|
|
14
|
+
* add test_session context manager for test DB interactions ([d7a8112](https://github.com/iloveitaly/activemodel/commit/d7a8112f6b303a50df62ca55a0ca8a9c864e4686))
|
|
15
|
+
* export test_session from activemodel.pytest ([9bf692a](https://github.com/iloveitaly/activemodel/commit/9bf692a72937d9a87a1ba2e574cff6dc4000bafd))
|
|
16
|
+
* **pytest:** omit truncation tables, truncation db fixture ([f20f17e](https://github.com/iloveitaly/activemodel/commit/f20f17ea0854dfbde09e86f9446d57d33ab516e8))
|
|
17
|
+
* support engine options ([9b00ecd](https://github.com/iloveitaly/activemodel/commit/9b00ecd4a7b1ac95a6a16b1e7727ef37afe81e00))
|
|
18
|
+
* support passing session to global_session context manager ([567708a](https://github.com/iloveitaly/activemodel/commit/567708aeb6642e75b21932d599cd1431f62c4c65))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Bug Fixes
|
|
22
|
+
|
|
23
|
+
* add return type to ActiveModelFactory save method ([96f4a0a](https://github.com/iloveitaly/activemodel/commit/96f4a0a70eb72b14dea8df16b9e92a4b79122285))
|
|
24
|
+
* add ruff link + check on autogenerated types ([91ae5ea](https://github.com/iloveitaly/activemodel/commit/91ae5ea079533dc7e5600cf99c757ec5c6cd872f))
|
|
25
|
+
* allow reentrant global_session when using same session reference ([d4d7949](https://github.com/iloveitaly/activemodel/commit/d4d79493afdf7ef6ae36f9f6d1c2949eb2b7c393))
|
|
26
|
+
* clarify duplicate TypeID prefix error message ([a73c221](https://github.com/iloveitaly/activemodel/commit/a73c221fd51c1efa70812eb1a21a96f0af7faadc))
|
|
27
|
+
* prevent factories from setting timestamp fields by default ([5d78745](https://github.com/iloveitaly/activemodel/commit/5d7874506047b7ae91f22566e1dc2c64d68814f4))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
### Documentation
|
|
31
|
+
|
|
32
|
+
* added related issue ([3c47e60](https://github.com/iloveitaly/activemodel/commit/3c47e601b8f3eb8a85d9cb84a3e64df68ea10d90))
|
|
33
|
+
* clarify session management and querying in usage section ([cdadaff](https://github.com/iloveitaly/activemodel/commit/cdadafffa1ac835f2f6419f40de5bf2f281f94d2))
|
|
34
|
+
* connection pool tip ([5d92b99](https://github.com/iloveitaly/activemodel/commit/5d92b992d4f71af0bf8244826cd4d12281fe350b))
|
|
35
|
+
* fix JSONB usage in example and clarify imports in README ([386e69d](https://github.com/iloveitaly/activemodel/commit/386e69d5ad99baddfba119f62b6d3c6a53809857))
|
|
36
|
+
* improve base model tablename docstring ([de98cbc](https://github.com/iloveitaly/activemodel/commit/de98cbcff0b2ef903207f4c65fb1aa6e014cf40c))
|
|
37
|
+
* **pytest:** clarify ActiveModelFactory.save behavior with relationships and truncation ([6ee2cbe](https://github.com/iloveitaly/activemodel/commit/6ee2cbef23601943271a36a90f2f453de4519c72))
|
|
38
|
+
* **typeid:** clarify exception on invalid UUID string ([bf13a7e](https://github.com/iloveitaly/activemodel/commit/bf13a7e7e537d41a94ff926dcabf316b0d62625b))
|
|
39
|
+
* update TODOs for testing, constraints, and unique key suggestions ([6010f8f](https://github.com/iloveitaly/activemodel/commit/6010f8fec1c3bdb22d68ac3b545ebeafd6bd04ef))
|
|
40
|
+
|
|
3
41
|
## [0.11.0](https://github.com/iloveitaly/activemodel/compare/v0.10.0...v0.11.0) (2025-04-05)
|
|
4
42
|
|
|
5
43
|
|
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: activemodel
|
|
3
|
+
Version: 0.12.0
|
|
4
|
+
Summary: Make SQLModel more like an a real ORM
|
|
5
|
+
Project-URL: Repository, https://github.com/iloveitaly/activemodel
|
|
6
|
+
Author-email: Michael Bianco <iloveitaly@gmail.com>
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: activemodel,activerecord,orm,sqlalchemy,sqlmodel
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: python-decouple-typed>=3.11.0
|
|
11
|
+
Requires-Dist: sqlmodel>=0.0.22
|
|
12
|
+
Requires-Dist: textcase>=0.4.0
|
|
13
|
+
Requires-Dist: typeid-python>=0.3.1
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
1
16
|
# ActiveModel: ORM Wrapper for SQLModel
|
|
2
17
|
|
|
3
18
|
No, this isn't *really* [ActiveModel](https://guides.rubyonrails.org/active_model_basics.html). It's just a wrapper around SQLModel that provides a more ActiveRecord-like interface.
|
|
@@ -44,15 +59,17 @@ from sqlmodel import SQLModel
|
|
|
44
59
|
|
|
45
60
|
SQLModel.metadata.create_all(get_engine())
|
|
46
61
|
|
|
47
|
-
# now you can create a user!
|
|
62
|
+
# now you can create a user! without managing sessions!
|
|
48
63
|
User(a_field="a").save()
|
|
49
64
|
```
|
|
50
65
|
|
|
51
66
|
Maybe you like JSON:
|
|
52
67
|
|
|
53
68
|
```python
|
|
54
|
-
from
|
|
69
|
+
from sqlalchemy.dialects.postgresql import JSONB
|
|
55
70
|
from pydantic import BaseModel as PydanticBaseModel
|
|
71
|
+
|
|
72
|
+
from activemodel import BaseModel
|
|
56
73
|
from activemodel.mixins import PydanticJSONMixin, TypeIDMixin, TimestampsMixin
|
|
57
74
|
|
|
58
75
|
class SubObject(PydanticBaseModel):
|
|
@@ -66,11 +83,28 @@ class User(
|
|
|
66
83
|
TypeIDMixin("user"),
|
|
67
84
|
table=True
|
|
68
85
|
):
|
|
69
|
-
list_field: list[SubObject] = Field(sa_type=JSONB
|
|
86
|
+
list_field: list[SubObject] = Field(sa_type=JSONB)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
You'll probably want to query the model. Look ma, no sessions!
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
User.where(id="user_123").all()
|
|
93
|
+
|
|
94
|
+
# or, even better, for this case
|
|
95
|
+
User.one("user_123")
|
|
70
96
|
```
|
|
71
97
|
|
|
98
|
+
Magically creating sessions for DB operations is one of the main problems this project tackles. Even better, you can set
|
|
99
|
+
a single session object to be used for all DB operations. This is helpful for DB transactions, [specifically rolling back
|
|
100
|
+
DB operations on each test.](#pytest)
|
|
101
|
+
|
|
72
102
|
## Usage
|
|
73
103
|
|
|
104
|
+
### Pytest
|
|
105
|
+
|
|
106
|
+
TODO detail out truncation and transactions
|
|
107
|
+
|
|
74
108
|
### Integrating Alembic
|
|
75
109
|
|
|
76
110
|
`alembic init` will not work out of the box. You need to mutate a handful of files:
|
|
@@ -192,6 +226,7 @@ SQLModel & SQLAlchemy are tricky. Here are some useful internal tricks:
|
|
|
192
226
|
* Set the value on a field, without marking it as dirty `attributes.set_committed_value(instance, field_name, val)`
|
|
193
227
|
* Is a model dirty `instance_state(instance).modified`
|
|
194
228
|
* `select(Table).outerjoin??` won't work in a ipython session, but `Table.__table__.outerjoin??` will. `__table__` is a reference to the underlying SQLAlchemy table record.
|
|
229
|
+
* `get_engine().pool.stats()` is helpful for inspecting connection pools and limits\
|
|
195
230
|
|
|
196
231
|
### TypeID
|
|
197
232
|
|
|
@@ -255,6 +290,7 @@ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d
|
|
|
255
290
|
|
|
256
291
|
* https://github.com/woofz/sqlmodel-basecrud
|
|
257
292
|
* https://github.com/0xthiagomartins/sqlmodel-controller
|
|
293
|
+
* https://github.com/litestar-org/advanced-alchemy?tab=readme-ov-file
|
|
258
294
|
|
|
259
295
|
## Inspiration
|
|
260
296
|
|
|
@@ -267,4 +303,4 @@ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d
|
|
|
267
303
|
|
|
268
304
|
## Upstream Changes
|
|
269
305
|
|
|
270
|
-
- [ ] https://github.com/fastapi/sqlmodel/pull/1293
|
|
306
|
+
- [ ] https://github.com/fastapi/sqlmodel/pull/1293
|
|
@@ -1,18 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: activemodel
|
|
3
|
-
Version: 0.11.0
|
|
4
|
-
Summary: Make SQLModel more like an a real ORM
|
|
5
|
-
Project-URL: Repository, https://github.com/iloveitaly/activemodel
|
|
6
|
-
Author-email: Michael Bianco <iloveitaly@gmail.com>
|
|
7
|
-
License-File: LICENSE
|
|
8
|
-
Keywords: activemodel,activerecord,orm,sqlalchemy,sqlmodel
|
|
9
|
-
Requires-Python: >=3.10
|
|
10
|
-
Requires-Dist: pydash>=8.0.4
|
|
11
|
-
Requires-Dist: python-decouple-typed>=3.11.0
|
|
12
|
-
Requires-Dist: sqlmodel>=0.0.22
|
|
13
|
-
Requires-Dist: typeid-python>=0.3.1
|
|
14
|
-
Description-Content-Type: text/markdown
|
|
15
|
-
|
|
16
1
|
# ActiveModel: ORM Wrapper for SQLModel
|
|
17
2
|
|
|
18
3
|
No, this isn't *really* [ActiveModel](https://guides.rubyonrails.org/active_model_basics.html). It's just a wrapper around SQLModel that provides a more ActiveRecord-like interface.
|
|
@@ -59,15 +44,17 @@ from sqlmodel import SQLModel
|
|
|
59
44
|
|
|
60
45
|
SQLModel.metadata.create_all(get_engine())
|
|
61
46
|
|
|
62
|
-
# now you can create a user!
|
|
47
|
+
# now you can create a user! without managing sessions!
|
|
63
48
|
User(a_field="a").save()
|
|
64
49
|
```
|
|
65
50
|
|
|
66
51
|
Maybe you like JSON:
|
|
67
52
|
|
|
68
53
|
```python
|
|
69
|
-
from
|
|
54
|
+
from sqlalchemy.dialects.postgresql import JSONB
|
|
70
55
|
from pydantic import BaseModel as PydanticBaseModel
|
|
56
|
+
|
|
57
|
+
from activemodel import BaseModel
|
|
71
58
|
from activemodel.mixins import PydanticJSONMixin, TypeIDMixin, TimestampsMixin
|
|
72
59
|
|
|
73
60
|
class SubObject(PydanticBaseModel):
|
|
@@ -81,11 +68,28 @@ class User(
|
|
|
81
68
|
TypeIDMixin("user"),
|
|
82
69
|
table=True
|
|
83
70
|
):
|
|
84
|
-
list_field: list[SubObject] = Field(sa_type=JSONB
|
|
71
|
+
list_field: list[SubObject] = Field(sa_type=JSONB)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
You'll probably want to query the model. Look ma, no sessions!
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
User.where(id="user_123").all()
|
|
78
|
+
|
|
79
|
+
# or, even better, for this case
|
|
80
|
+
User.one("user_123")
|
|
85
81
|
```
|
|
86
82
|
|
|
83
|
+
Magically creating sessions for DB operations is one of the main problems this project tackles. Even better, you can set
|
|
84
|
+
a single session object to be used for all DB operations. This is helpful for DB transactions, [specifically rolling back
|
|
85
|
+
DB operations on each test.](#pytest)
|
|
86
|
+
|
|
87
87
|
## Usage
|
|
88
88
|
|
|
89
|
+
### Pytest
|
|
90
|
+
|
|
91
|
+
TODO detail out truncation and transactions
|
|
92
|
+
|
|
89
93
|
### Integrating Alembic
|
|
90
94
|
|
|
91
95
|
`alembic init` will not work out of the box. You need to mutate a handful of files:
|
|
@@ -207,6 +211,7 @@ SQLModel & SQLAlchemy are tricky. Here are some useful internal tricks:
|
|
|
207
211
|
* Set the value on a field, without marking it as dirty `attributes.set_committed_value(instance, field_name, val)`
|
|
208
212
|
* Is a model dirty `instance_state(instance).modified`
|
|
209
213
|
* `select(Table).outerjoin??` won't work in a ipython session, but `Table.__table__.outerjoin??` will. `__table__` is a reference to the underlying SQLAlchemy table record.
|
|
214
|
+
* `get_engine().pool.stats()` is helpful for inspecting connection pools and limits\
|
|
210
215
|
|
|
211
216
|
### TypeID
|
|
212
217
|
|
|
@@ -270,6 +275,7 @@ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d
|
|
|
270
275
|
|
|
271
276
|
* https://github.com/woofz/sqlmodel-basecrud
|
|
272
277
|
* https://github.com/0xthiagomartins/sqlmodel-controller
|
|
278
|
+
* https://github.com/litestar-org/advanced-alchemy?tab=readme-ov-file
|
|
273
279
|
|
|
274
280
|
## Inspiration
|
|
275
281
|
|
|
@@ -282,4 +288,4 @@ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d
|
|
|
282
288
|
|
|
283
289
|
## Upstream Changes
|
|
284
290
|
|
|
285
|
-
- [ ] https://github.com/fastapi/sqlmodel/pull/1293
|
|
291
|
+
- [ ] https://github.com/fastapi/sqlmodel/pull/1293
|
|
@@ -10,9 +10,12 @@ Docs are bad:
|
|
|
10
10
|
|
|
11
11
|
TODO
|
|
12
12
|
|
|
13
|
+
- [ ] nested sessions, maybe only with connections
|
|
14
|
+
- [ ] tests for polyfactory support, setting session across all factories
|
|
13
15
|
- [ ] sessions in tests, they don't work right now
|
|
14
16
|
- [ ] snake case for attributes https://github.com/sqlalchemy/sqlalchemy/issues/7149
|
|
15
|
-
|
|
17
|
+
- [ ] compound unique constraints don't have great names
|
|
18
|
+
- [ ] https://grok.com/share/bGVnYWN5_7bfd5140-2351-4e4f-a8ba-bdedb59ac1e1 figure out better json tracking
|
|
16
19
|
|
|
17
20
|
find_or_create
|
|
18
21
|
|
|
@@ -50,3 +53,14 @@ clear engine
|
|
|
50
53
|
# _engine = None
|
|
51
54
|
# _connection = None
|
|
52
55
|
```
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
There should be a better way to add unique keys
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
__table_args__ = (
|
|
63
|
+
# users should never see two schemas with the same name
|
|
64
|
+
UniqueConstraint("note_type", "doctor_id", name="doctor_and_note_type_unique"),
|
|
65
|
+
)
|
|
66
|
+
```
|
|
@@ -2,10 +2,11 @@ import json
|
|
|
2
2
|
import typing as t
|
|
3
3
|
from uuid import UUID
|
|
4
4
|
|
|
5
|
-
import pydash
|
|
6
5
|
import sqlalchemy as sa
|
|
7
6
|
import sqlmodel as sm
|
|
7
|
+
import textcase
|
|
8
8
|
from sqlalchemy import Connection, event
|
|
9
|
+
from sqlalchemy.dialects.postgresql import insert as postgres_insert
|
|
9
10
|
from sqlalchemy.orm import Mapper, declared_attr
|
|
10
11
|
from sqlalchemy.orm.attributes import flag_modified as sa_flag_modified
|
|
11
12
|
from sqlalchemy.orm.base import instance_state
|
|
@@ -19,7 +20,6 @@ from . import get_column_from_field_patch # noqa: F401
|
|
|
19
20
|
from .logger import logger
|
|
20
21
|
from .query_wrapper import QueryWrapper
|
|
21
22
|
from .session_manager import get_session
|
|
22
|
-
from sqlalchemy.dialects.postgresql import insert as postgres_insert
|
|
23
23
|
|
|
24
24
|
POSTGRES_INDEXES_NAMING_CONVENTION = {
|
|
25
25
|
"ix": "%(column_0_label)s_idx",
|
|
@@ -152,19 +152,19 @@ class BaseModel(SQLModel):
|
|
|
152
152
|
@declared_attr
|
|
153
153
|
def __tablename__(cls) -> str:
|
|
154
154
|
"""
|
|
155
|
-
Automatically generates the table name for the model by converting the class name from camel case to snake case.
|
|
156
|
-
This is the recommended
|
|
155
|
+
Automatically generates the table name for the model by converting the model's class name from camel case to snake case.
|
|
156
|
+
This is the recommended text case style for table names:
|
|
157
157
|
|
|
158
158
|
https://wiki.postgresql.org/wiki/Don%27t_Do_This#Don.27t_use_upper_case_table_or_column_names
|
|
159
159
|
|
|
160
|
-
By default, the class is lower cased which makes it harder to read.
|
|
160
|
+
By default, the model's class name is lower cased which makes it harder to read.
|
|
161
161
|
|
|
162
|
-
|
|
163
|
-
|
|
162
|
+
Also, many text case conversion libraries struggle handling words like "LLMCache", this is why we are using
|
|
163
|
+
a more precise library which processes such acronyms: [`textcase`](https://pypi.org/project/textcase/).
|
|
164
164
|
|
|
165
165
|
https://stackoverflow.com/questions/1175208/elegant-python-function-to-convert-camelcase-to-snake-case
|
|
166
166
|
"""
|
|
167
|
-
return
|
|
167
|
+
return textcase.snake(cls.__name__)
|
|
168
168
|
|
|
169
169
|
@classmethod
|
|
170
170
|
def foreign_key(cls, **kwargs):
|
|
@@ -234,6 +234,8 @@ class BaseModel(SQLModel):
|
|
|
234
234
|
return result
|
|
235
235
|
|
|
236
236
|
def delete(self):
|
|
237
|
+
"Delete record completely from the database"
|
|
238
|
+
|
|
237
239
|
with get_session() as session:
|
|
238
240
|
if old_session := Session.object_session(self):
|
|
239
241
|
old_session.expunge(self)
|
|
@@ -404,6 +406,19 @@ class BaseModel(SQLModel):
|
|
|
404
406
|
with get_session() as session:
|
|
405
407
|
return session.exec(statement).first()
|
|
406
408
|
|
|
409
|
+
@classmethod
|
|
410
|
+
def one_or_none(cls, *args: t.Any, **kwargs: t.Any):
|
|
411
|
+
"""
|
|
412
|
+
Gets a single record from the database. Pass an PK ID or a kwarg to filter by.
|
|
413
|
+
Returns None if no record is found. Throws an error if more than one record is found.
|
|
414
|
+
"""
|
|
415
|
+
|
|
416
|
+
args, kwargs = cls.__process_filter_args__(*args, **kwargs)
|
|
417
|
+
statement = select(cls).filter(*args).filter_by(**kwargs)
|
|
418
|
+
|
|
419
|
+
with get_session() as session:
|
|
420
|
+
return session.exec(statement).one_or_none()
|
|
421
|
+
|
|
407
422
|
@classmethod
|
|
408
423
|
def one(cls, *args: t.Any, **kwargs: t.Any):
|
|
409
424
|
"""
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides utilities for generating Protocol type definitions for SQLAlchemy's
|
|
3
|
+
SelectOfScalar methods, as well as formatting and fixing Python files using ruff.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import inspect
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any # already imported in header of generated file
|
|
12
|
+
|
|
13
|
+
import sqlmodel as sm
|
|
14
|
+
from sqlmodel.sql.expression import SelectOfScalar
|
|
15
|
+
|
|
16
|
+
from test.test_wrapper import QueryWrapper
|
|
17
|
+
|
|
18
|
+
# Set up logging
|
|
19
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
QUERY_WRAPPER_CLASS_NAME = QueryWrapper.__name__
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def format_python_file(file_path: str | Path) -> bool:
|
|
26
|
+
"""
|
|
27
|
+
Format a Python file using ruff.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
file_path: Path to the Python file to format
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
bool: True if formatting was successful, False otherwise
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
subprocess.run(["ruff", "format", str(file_path)], check=True)
|
|
37
|
+
logger.info(f"Formatted file using ruff at {file_path}")
|
|
38
|
+
return True
|
|
39
|
+
except subprocess.CalledProcessError as e:
|
|
40
|
+
logger.error(f"Error running ruff to format the file: {e}")
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def fix_python_file(file_path: str | Path) -> bool:
|
|
45
|
+
"""
|
|
46
|
+
Fix linting issues in a Python file using ruff.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
file_path: Path to the Python file to fix
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
bool: True if fixing was successful, False otherwise
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
subprocess.run(["ruff", "check", str(file_path), "--fix"], check=True)
|
|
56
|
+
logger.info(f"Fixed linting issues using ruff at {file_path}")
|
|
57
|
+
return True
|
|
58
|
+
except subprocess.CalledProcessError as e:
|
|
59
|
+
logger.error(f"Error running ruff to fix the file: {e}")
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def generate_sqlalchemy_protocol():
|
|
64
|
+
"""Generate Protocol type definitions for SQLAlchemy SelectOfScalar methods"""
|
|
65
|
+
logger.info("Starting SQLAlchemy protocol generation")
|
|
66
|
+
|
|
67
|
+
header = """
|
|
68
|
+
# IMPORTANT: This file is auto-generated. Do not edit directly.
|
|
69
|
+
|
|
70
|
+
from typing import Protocol, TypeVar, Any, Generic
|
|
71
|
+
import sqlmodel as sm
|
|
72
|
+
from sqlalchemy.sql.base import _NoArg
|
|
73
|
+
|
|
74
|
+
T = TypeVar('T', bound=sm.SQLModel, covariant=True)
|
|
75
|
+
|
|
76
|
+
class SQLAlchemyQueryMethods(Protocol, Generic[T]):
|
|
77
|
+
\"""Protocol defining SQLAlchemy query methods forwarded by QueryWrapper.__getattr__\"""
|
|
78
|
+
"""
|
|
79
|
+
# Initialize output list for generated method signatures
|
|
80
|
+
output: list = []
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
# Get all methods from SelectOfScalar
|
|
84
|
+
methods = inspect.getmembers(SelectOfScalar)
|
|
85
|
+
logger.debug(f"Discovered {len(methods)} methods from SelectOfScalar")
|
|
86
|
+
|
|
87
|
+
for name, method in methods:
|
|
88
|
+
# Skip private/dunder methods
|
|
89
|
+
if name.startswith("_"):
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
if not inspect.isfunction(method) and not inspect.ismethod(method):
|
|
93
|
+
logger.debug(f"Skipping non-method: {name}")
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
logger.debug(f"Processing method: {name}")
|
|
97
|
+
try:
|
|
98
|
+
signature = inspect.signature(method)
|
|
99
|
+
params = []
|
|
100
|
+
|
|
101
|
+
# Process parameters, skipping 'self'
|
|
102
|
+
for param_name, param in list(signature.parameters.items())[1:]:
|
|
103
|
+
if param.kind == param.VAR_POSITIONAL:
|
|
104
|
+
params.append(f"*{param_name}: Any")
|
|
105
|
+
elif param.kind == param.VAR_KEYWORD:
|
|
106
|
+
params.append(f"**{param_name}: Any")
|
|
107
|
+
else:
|
|
108
|
+
if param.default is inspect.Parameter.empty:
|
|
109
|
+
params.append(f"{param_name}: Any")
|
|
110
|
+
else:
|
|
111
|
+
default_repr = repr(param.default)
|
|
112
|
+
params.append(f"{param_name}: Any = {default_repr}")
|
|
113
|
+
|
|
114
|
+
params_str = ", ".join(params)
|
|
115
|
+
output.append(
|
|
116
|
+
f' def {name}(self, {params_str}) -> "{QUERY_WRAPPER_CLASS_NAME}[T]": ...'
|
|
117
|
+
)
|
|
118
|
+
except (ValueError, TypeError) as e:
|
|
119
|
+
logger.warning(f"Could not get signature for {name}: {e}")
|
|
120
|
+
# Some methods might not have proper signatures
|
|
121
|
+
output.append(
|
|
122
|
+
f' def {name}(self, *args: Any, **kwargs: Any) -> "{QUERY_WRAPPER_CLASS_NAME}[T]": ...'
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Write the output to a file
|
|
126
|
+
protocol_path = (
|
|
127
|
+
Path(__file__).parent.parent / "types" / "sqlalchemy_protocol.py"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Ensure directory exists
|
|
131
|
+
os.makedirs(protocol_path.parent, exist_ok=True)
|
|
132
|
+
|
|
133
|
+
with open(protocol_path, "w") as f:
|
|
134
|
+
f.write(header + "\n".join(output))
|
|
135
|
+
|
|
136
|
+
logger.info(f"Generated SQLAlchemy protocol at {protocol_path}")
|
|
137
|
+
|
|
138
|
+
# Format and fix the generated file with ruff
|
|
139
|
+
format_python_file(protocol_path)
|
|
140
|
+
fix_python_file(protocol_path)
|
|
141
|
+
except Exception as e:
|
|
142
|
+
logger.error(f"Error generating SQLAlchemy protocol: {e}", exc_info=True)
|
|
143
|
+
raise
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
generate_sqlalchemy_protocol()
|
|
@@ -20,7 +20,10 @@ class TimestampsMixin:
|
|
|
20
20
|
>>> class MyModel(TimestampsMixin, SQLModel):
|
|
21
21
|
>>> pass
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
Notes:
|
|
24
|
+
|
|
25
|
+
- Originally pulled from: https://github.com/tiangolo/sqlmodel/issues/252
|
|
26
|
+
- Related issue: https://github.com/fastapi/sqlmodel/issues/539
|
|
24
27
|
"""
|
|
25
28
|
|
|
26
29
|
created_at: datetime | None = Field(
|
|
@@ -17,7 +17,7 @@ def TypeIDMixin(prefix: str):
|
|
|
17
17
|
# NOTE this will cause issues on code reloads
|
|
18
18
|
assert prefix
|
|
19
19
|
assert prefix not in _prefixes, (
|
|
20
|
-
f"prefix {prefix} already exists, pick a different one"
|
|
20
|
+
f"TypeID prefix '{prefix}' already exists, pick a different one"
|
|
21
21
|
)
|
|
22
22
|
|
|
23
23
|
class _TypeIDMixin:
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Notes on polyfactory:
|
|
3
|
+
|
|
4
|
+
1. is_supported_type validates that the class can be used to generate a factory
|
|
5
|
+
https://github.com/litestar-org/polyfactory/issues/655#issuecomment-2727450854
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import typing as t
|
|
9
|
+
|
|
10
|
+
from polyfactory.factories.pydantic_factory import ModelFactory
|
|
11
|
+
from polyfactory.field_meta import FieldMeta
|
|
12
|
+
from typeid import TypeID
|
|
13
|
+
|
|
14
|
+
from activemodel.session_manager import global_session
|
|
15
|
+
|
|
16
|
+
# TODO not currently used
|
|
17
|
+
# def type_id_provider(cls, field_meta):
|
|
18
|
+
# # TODO this doesn't work well with __ args:
|
|
19
|
+
# # https://github.com/litestar-org/polyfactory/pull/666/files
|
|
20
|
+
# return str(TypeID("hi"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# BaseFactory.add_provider(TypeIDType, type_id_provider)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SQLModelFactory[T](ModelFactory[T]):
|
|
27
|
+
"""
|
|
28
|
+
Base factory for SQLModel models:
|
|
29
|
+
|
|
30
|
+
1. Ability to ignore all relationship fks
|
|
31
|
+
2. Option to ignore all pks
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
__is_base_factory__ = True
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def should_set_field_value(cls, field_meta: FieldMeta, **kwargs: t.Any) -> bool:
|
|
38
|
+
# TODO what is this checking for?
|
|
39
|
+
has_object_override = hasattr(cls, field_meta.name)
|
|
40
|
+
|
|
41
|
+
# TODO this should be more intelligent, it's goal is to detect all of the relationship field and avoid settings them
|
|
42
|
+
if not has_object_override and (
|
|
43
|
+
field_meta.name == "id" or field_meta.name.endswith("_id")
|
|
44
|
+
):
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
return super().should_set_field_value(field_meta, **kwargs)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# TODO we need to think through how to handle relationships and autogenerate them
|
|
51
|
+
class ActiveModelFactory[T](SQLModelFactory[T]):
|
|
52
|
+
__is_base_factory__ = True
|
|
53
|
+
__sqlalchemy_session__ = None
|
|
54
|
+
|
|
55
|
+
# TODO we shouldn't have to type this, but `save()` typing is not working
|
|
56
|
+
@classmethod
|
|
57
|
+
def save(cls, *args, **kwargs) -> T:
|
|
58
|
+
"""
|
|
59
|
+
Where this gets tricky, is this can be called multiple times within the same callstack. This can happen when
|
|
60
|
+
a factory uses other factories to create relationships.
|
|
61
|
+
|
|
62
|
+
In a truncation strategy, the __sqlalchemy_session__ is set to None.
|
|
63
|
+
"""
|
|
64
|
+
with global_session(cls.__sqlalchemy_session__):
|
|
65
|
+
return cls.build(*args, **kwargs).save()
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def foreign_key_typeid(cls):
|
|
69
|
+
"""
|
|
70
|
+
Return a random type id for the foreign key on this model.
|
|
71
|
+
|
|
72
|
+
This is helpful for generating TypeIDs for testing 404s, parsing, manually settings, etc
|
|
73
|
+
"""
|
|
74
|
+
# TODO right now assumes the model is typeid, maybe we should assert against this?
|
|
75
|
+
primary_key_name = cls.__model__.primary_key_column().name
|
|
76
|
+
return TypeID(
|
|
77
|
+
cls.__model__.model_fields[primary_key_name].sa_column.type.prefix
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def should_set_field_value(cls, field_meta: FieldMeta, **kwargs: t.Any) -> bool:
|
|
82
|
+
# do not default deleted at mixin to deleted!
|
|
83
|
+
# TODO should be smarter about detecting if the mixin is in place
|
|
84
|
+
if field_meta.name in ["deleted_at", "updated_at", "created_at"]:
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
return super().should_set_field_value(field_meta, **kwargs)
|
|
88
|
+
|
|
89
|
+
# @classmethod
|
|
90
|
+
# def build(
|
|
91
|
+
# cls,
|
|
92
|
+
# factory_use_construct: bool | None = None,
|
|
93
|
+
# sqlmodel_save: bool = False,
|
|
94
|
+
# **kwargs: t.Any,
|
|
95
|
+
# ) -> T:
|
|
96
|
+
# result = super().build(factory_use_construct=factory_use_construct, **kwargs)
|
|
97
|
+
|
|
98
|
+
# # TODO allow magic dunder method here
|
|
99
|
+
# if sqlmodel_save:
|
|
100
|
+
# result.save()
|
|
101
|
+
|
|
102
|
+
# return result
|