activemodel 0.11.0__tar.gz → 0.13.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.13.0}/.envrc +1 -1
- {activemodel-0.11.0 → activemodel-0.13.0}/.github/workflows/build_and_publish.yml +5 -4
- {activemodel-0.11.0 → activemodel-0.13.0}/.github/workflows/repo-sync.yml +1 -1
- activemodel-0.13.0/.tool-versions +3 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/CHANGELOG.md +45 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/Makefile +4 -0
- activemodel-0.11.0/README.md → activemodel-0.13.0/PKG-INFO +40 -4
- activemodel-0.11.0/PKG-INFO → activemodel-0.13.0/README.md +25 -19
- {activemodel-0.11.0 → activemodel-0.13.0}/TODO +15 -1
- {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/base_model.py +113 -88
- activemodel-0.13.0/activemodel/cli/__init__.py +147 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/mixins/timestamps.py +4 -1
- {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/mixins/typeid.py +1 -1
- activemodel-0.13.0/activemodel/pytest/__init__.py +2 -0
- activemodel-0.13.0/activemodel/pytest/factories.py +102 -0
- activemodel-0.13.0/activemodel/pytest/plugin.py +81 -0
- activemodel-0.13.0/activemodel/pytest/transaction.py +150 -0
- activemodel-0.13.0/activemodel/pytest/truncate.py +147 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/query_wrapper.py +12 -2
- {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/session_manager.py +45 -11
- activemodel-0.13.0/activemodel/types/sqlalchemy_protocol.py +10 -0
- activemodel-0.13.0/activemodel/types/sqlalchemy_protocol.pyi +132 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/types/typeid.py +1 -0
- activemodel-0.13.0/activemodel/utils.py +29 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/pyproject.toml +11 -4
- {activemodel-0.11.0 → activemodel-0.13.0}/test/conftest.py +7 -3
- activemodel-0.13.0/test/factory_test.py +57 -0
- activemodel-0.13.0/test/lifecycle_test.py +194 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/test/models.py +4 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/test/orm_test.py +27 -0
- activemodel-0.13.0/test/pytest/pytest_test.py +134 -0
- activemodel-0.13.0/test/session_manager_test.py +60 -0
- activemodel-0.13.0/test/table_name_test.py +15 -0
- activemodel-0.11.0/test/test_wrapper.py → activemodel-0.13.0/test/test_query_wrapper.py +20 -4
- activemodel-0.13.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.13.0}/.github/dependabot.yml +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/.gitignore +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/.vscode/settings.json +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/Justfile +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/LICENSE +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/__init__.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/celery.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/errors.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/get_column_from_field_patch.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/logger.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/mixins/__init__.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/mixins/pydantic_json.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/mixins/soft_delete.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/types/__init__.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/types/typeid_patch.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/docker-compose.yml +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/playground/alternative_typeid_mixin.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/playground/comments.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/playground/env-with-model.patch +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/playground/extract_comments.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/playground/field.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/playground/middleware.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/playground/old_session_manager.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/playground/pydantic_validation.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/playground.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/test/__init__.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/test/comments_test.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/test/delete_test.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/test/fastapi_test.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/test/import_test.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/test/migrations/README +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/test/migrations/alembic.ini +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/test/migrations/env.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/test/migrations/script.py.mako +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/test/migrations_test.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/test/mutation_test.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/test/nested_pydantic_json_test.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/test/orm/test_upsert.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/test/types/typeid_mixin_test.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/test/types/typeid_pydantic_test.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.0}/test/types/typeid_sqlmodel_test.py +0 -0
- {activemodel-0.11.0 → activemodel-0.13.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,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.13.0](https://github.com/iloveitaly/activemodel/compare/v0.12.0...v0.13.0) (2025-09-05)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* rewritten lifecycle hooks that actually work ([af4e6fe](https://github.com/iloveitaly/activemodel/commit/af4e6fe75099ef1cc6a998b471f48f32ee8b7d5d))
|
|
9
|
+
|
|
10
|
+
## [0.12.0](https://github.com/iloveitaly/activemodel/compare/v0.11.0...v0.12.0) (2025-09-03)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* add ActiveModelFactory helpers for session management and typeid generation ([a6b9915](https://github.com/iloveitaly/activemodel/commit/a6b9915cc0c74c3b0d1421bc3d0300c1a2f63426))
|
|
16
|
+
* add base SQLModel and ActiveModel polyfactory factories ([12a5e1d](https://github.com/iloveitaly/activemodel/commit/12a5e1db9be741f12db174d5a25f6c2eee61a446))
|
|
17
|
+
* add one_or_none method to BaseModel for safe queries ([7574777](https://github.com/iloveitaly/activemodel/commit/75747773f99a7a6cc82d2ca784ce607abd7ae511))
|
|
18
|
+
* add pytest db_session fixture ([22d2ad8](https://github.com/iloveitaly/activemodel/commit/22d2ad8cbd77260c1c73524f3251ef7fd145caec))
|
|
19
|
+
* add scalar method to QueryWrapper and return from delete ([3d68097](https://github.com/iloveitaly/activemodel/commit/3d680972b6a60b43de6d3826d4289b0f81847b74))
|
|
20
|
+
* add SQLAlchemy protocol generation script ([6e4524e](https://github.com/iloveitaly/activemodel/commit/6e4524ed73644bea5b837ce2a9a7a1994b19df00))
|
|
21
|
+
* add test_session context manager for test DB interactions ([d7a8112](https://github.com/iloveitaly/activemodel/commit/d7a8112f6b303a50df62ca55a0ca8a9c864e4686))
|
|
22
|
+
* export test_session from activemodel.pytest ([9bf692a](https://github.com/iloveitaly/activemodel/commit/9bf692a72937d9a87a1ba2e574cff6dc4000bafd))
|
|
23
|
+
* **pytest:** omit truncation tables, truncation db fixture ([f20f17e](https://github.com/iloveitaly/activemodel/commit/f20f17ea0854dfbde09e86f9446d57d33ab516e8))
|
|
24
|
+
* support engine options ([9b00ecd](https://github.com/iloveitaly/activemodel/commit/9b00ecd4a7b1ac95a6a16b1e7727ef37afe81e00))
|
|
25
|
+
* support passing session to global_session context manager ([567708a](https://github.com/iloveitaly/activemodel/commit/567708aeb6642e75b21932d599cd1431f62c4c65))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
### Bug Fixes
|
|
29
|
+
|
|
30
|
+
* add return type to ActiveModelFactory save method ([96f4a0a](https://github.com/iloveitaly/activemodel/commit/96f4a0a70eb72b14dea8df16b9e92a4b79122285))
|
|
31
|
+
* add ruff link + check on autogenerated types ([91ae5ea](https://github.com/iloveitaly/activemodel/commit/91ae5ea079533dc7e5600cf99c757ec5c6cd872f))
|
|
32
|
+
* allow reentrant global_session when using same session reference ([d4d7949](https://github.com/iloveitaly/activemodel/commit/d4d79493afdf7ef6ae36f9f6d1c2949eb2b7c393))
|
|
33
|
+
* clarify duplicate TypeID prefix error message ([a73c221](https://github.com/iloveitaly/activemodel/commit/a73c221fd51c1efa70812eb1a21a96f0af7faadc))
|
|
34
|
+
* prevent factories from setting timestamp fields by default ([5d78745](https://github.com/iloveitaly/activemodel/commit/5d7874506047b7ae91f22566e1dc2c64d68814f4))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
### Documentation
|
|
38
|
+
|
|
39
|
+
* added related issue ([3c47e60](https://github.com/iloveitaly/activemodel/commit/3c47e601b8f3eb8a85d9cb84a3e64df68ea10d90))
|
|
40
|
+
* clarify session management and querying in usage section ([cdadaff](https://github.com/iloveitaly/activemodel/commit/cdadafffa1ac835f2f6419f40de5bf2f281f94d2))
|
|
41
|
+
* connection pool tip ([5d92b99](https://github.com/iloveitaly/activemodel/commit/5d92b992d4f71af0bf8244826cd4d12281fe350b))
|
|
42
|
+
* fix JSONB usage in example and clarify imports in README ([386e69d](https://github.com/iloveitaly/activemodel/commit/386e69d5ad99baddfba119f62b6d3c6a53809857))
|
|
43
|
+
* improve base model tablename docstring ([de98cbc](https://github.com/iloveitaly/activemodel/commit/de98cbcff0b2ef903207f4c65fb1aa6e014cf40c))
|
|
44
|
+
* **pytest:** clarify ActiveModelFactory.save behavior with relationships and truncation ([6ee2cbe](https://github.com/iloveitaly/activemodel/commit/6ee2cbef23601943271a36a90f2f453de4519c72))
|
|
45
|
+
* **typeid:** clarify exception on invalid UUID string ([bf13a7e](https://github.com/iloveitaly/activemodel/commit/bf13a7e7e537d41a94ff926dcabf316b0d62625b))
|
|
46
|
+
* update TODOs for testing, constraints, and unique key suggestions ([6010f8f](https://github.com/iloveitaly/activemodel/commit/6010f8fec1c3bdb22d68ac3b545ebeafd6bd04ef))
|
|
47
|
+
|
|
3
48
|
## [0.11.0](https://github.com/iloveitaly/activemodel/compare/v0.10.0...v0.11.0) (2025-04-05)
|
|
4
49
|
|
|
5
50
|
|
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: activemodel
|
|
3
|
+
Version: 0.13.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
|
+
```
|
|
@@ -1,25 +1,23 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import typing as t
|
|
3
|
+
import textcase
|
|
3
4
|
from uuid import UUID
|
|
5
|
+
from contextlib import nullcontext
|
|
4
6
|
|
|
5
|
-
import pydash
|
|
6
7
|
import sqlalchemy as sa
|
|
7
8
|
import sqlmodel as sm
|
|
8
|
-
from sqlalchemy import
|
|
9
|
-
from sqlalchemy.orm import Mapper, declared_attr
|
|
9
|
+
from sqlalchemy.dialects.postgresql import insert as postgres_insert
|
|
10
10
|
from sqlalchemy.orm.attributes import flag_modified as sa_flag_modified
|
|
11
|
-
from sqlalchemy.orm.base import instance_state
|
|
12
11
|
from sqlmodel import Column, Field, Session, SQLModel, inspect, select
|
|
13
12
|
from typeid import TypeID
|
|
13
|
+
from sqlalchemy.orm import declared_attr
|
|
14
14
|
|
|
15
15
|
from activemodel.mixins.pydantic_json import PydanticJSONMixin
|
|
16
16
|
|
|
17
17
|
# NOTE: this patches a core method in sqlmodel to support db comments
|
|
18
18
|
from . import get_column_from_field_patch # noqa: F401
|
|
19
|
-
from .logger import logger
|
|
20
19
|
from .query_wrapper import QueryWrapper
|
|
21
20
|
from .session_manager import get_session
|
|
22
|
-
from sqlalchemy.dialects.postgresql import insert as postgres_insert
|
|
23
21
|
|
|
24
22
|
POSTGRES_INDEXES_NAMING_CONVENTION = {
|
|
25
23
|
"ix": "%(column_0_label)s_idx",
|
|
@@ -42,85 +40,46 @@ SQLModel.metadata.naming_convention = POSTGRES_INDEXES_NAMING_CONVENTION
|
|
|
42
40
|
|
|
43
41
|
class BaseModel(SQLModel):
|
|
44
42
|
"""
|
|
45
|
-
Base model class to inherit from so we can hate python less
|
|
43
|
+
Base model class to inherit from so we can hate python less.
|
|
46
44
|
|
|
47
|
-
|
|
45
|
+
Some notes:
|
|
48
46
|
|
|
49
|
-
-
|
|
50
|
-
-
|
|
51
|
-
-
|
|
47
|
+
- Inspired by https://github.com/woofz/sqlmodel-basecrud/blob/main/sqlmodel_basecrud/basecrud.py
|
|
48
|
+
- lifecycle hooks are modeled after Rails.
|
|
49
|
+
- class docstrings are converted to table-level comments
|
|
50
|
+
- save(), delete(), select(), where(), and other easy methods you would expect in a real ORM
|
|
52
51
|
- Fixes foreign key naming conventions
|
|
52
|
+
- Sane table names
|
|
53
|
+
|
|
54
|
+
Here's how hooks work:
|
|
55
|
+
|
|
56
|
+
Create/Update: before_create, after_create, before_update, after_update, before_save, after_save, around_save
|
|
57
|
+
Delete: before_delete, after_delete, around_delete
|
|
58
|
+
|
|
59
|
+
around_* hooks must be context managers (method returning a CM or a CM attribute).
|
|
60
|
+
Ordering (create): before_create -> before_save -> (enter around_save) -> persist -> after_create -> after_save -> (exit around_save)
|
|
61
|
+
Ordering (update): before_update -> before_save -> (enter around_save) -> persist -> after_update -> after_save -> (exit around_save)
|
|
62
|
+
Delete: before_delete -> (enter around_delete) -> delete -> after_delete -> (exit around_delete)
|
|
63
|
+
|
|
64
|
+
# TODO document this in activemodel, this is an interesting edge case
|
|
65
|
+
# https://claude.ai/share/f09e4f70-2ff7-4cd0-abff-44645134693a
|
|
66
|
+
|
|
53
67
|
"""
|
|
54
68
|
|
|
55
|
-
# this is used for table-level comments
|
|
56
69
|
__table_args__ = None
|
|
57
70
|
|
|
58
71
|
@classmethod
|
|
59
72
|
def __init_subclass__(cls, **kwargs):
|
|
60
|
-
"Setup automatic sqlalchemy lifecycle events for the class"
|
|
61
|
-
|
|
62
73
|
super().__init_subclass__(**kwargs)
|
|
63
74
|
|
|
64
75
|
from sqlmodel._compat import set_config_value
|
|
65
76
|
|
|
66
|
-
#
|
|
67
|
-
#
|
|
77
|
+
# Enables field-level docstrings on the pydantic `description` field, which we
|
|
78
|
+
# copy into table/column comments by patching SQLModel internals elsewhere.
|
|
68
79
|
set_config_value(model=cls, parameter="use_attribute_docstrings", value=True)
|
|
69
80
|
|
|
70
81
|
cls._apply_class_doc()
|
|
71
82
|
|
|
72
|
-
def event_wrapper(method_name: str):
|
|
73
|
-
"""
|
|
74
|
-
This does smart heavy lifting for us to make sqlalchemy lifecycle events nicer to work with:
|
|
75
|
-
|
|
76
|
-
* Passes the target first to the lifecycle method, so it feels like an instance method
|
|
77
|
-
* Allows as little as a single positional argument, so methods can be simple
|
|
78
|
-
* Removes the need for decorators or anything fancy on the subclass
|
|
79
|
-
"""
|
|
80
|
-
|
|
81
|
-
def wrapper(mapper: Mapper, connection: Connection, target: BaseModel):
|
|
82
|
-
if hasattr(cls, method_name):
|
|
83
|
-
method = getattr(cls, method_name)
|
|
84
|
-
|
|
85
|
-
if callable(method):
|
|
86
|
-
arg_count = method.__code__.co_argcount
|
|
87
|
-
|
|
88
|
-
if arg_count == 1: # Just self/cls
|
|
89
|
-
method(target)
|
|
90
|
-
elif arg_count == 2: # Self, mapper
|
|
91
|
-
method(target, mapper)
|
|
92
|
-
elif arg_count == 3: # Full signature
|
|
93
|
-
method(target, mapper, connection)
|
|
94
|
-
else:
|
|
95
|
-
raise TypeError(
|
|
96
|
-
f"Method {method_name} must accept either 1 to 3 arguments, got {arg_count}"
|
|
97
|
-
)
|
|
98
|
-
else:
|
|
99
|
-
logger.warning(
|
|
100
|
-
"SQLModel lifecycle hook found, but not callable hook_name=%s",
|
|
101
|
-
method_name,
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
return wrapper
|
|
105
|
-
|
|
106
|
-
event.listen(cls, "before_insert", event_wrapper("before_insert"))
|
|
107
|
-
event.listen(cls, "before_update", event_wrapper("before_update"))
|
|
108
|
-
|
|
109
|
-
# before_save maps to two type of events
|
|
110
|
-
event.listen(cls, "before_insert", event_wrapper("before_save"))
|
|
111
|
-
event.listen(cls, "before_update", event_wrapper("before_save"))
|
|
112
|
-
|
|
113
|
-
# now, let's handle after_* variants
|
|
114
|
-
event.listen(cls, "after_insert", event_wrapper("after_insert"))
|
|
115
|
-
event.listen(cls, "after_update", event_wrapper("after_update"))
|
|
116
|
-
|
|
117
|
-
# after_save maps to two type of events
|
|
118
|
-
event.listen(cls, "after_insert", event_wrapper("after_save"))
|
|
119
|
-
event.listen(cls, "after_update", event_wrapper("after_save"))
|
|
120
|
-
|
|
121
|
-
# def foreign_key()
|
|
122
|
-
# table.id
|
|
123
|
-
|
|
124
83
|
@classmethod
|
|
125
84
|
def _apply_class_doc(cls):
|
|
126
85
|
"""
|
|
@@ -152,19 +111,19 @@ class BaseModel(SQLModel):
|
|
|
152
111
|
@declared_attr
|
|
153
112
|
def __tablename__(cls) -> str:
|
|
154
113
|
"""
|
|
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
|
|
114
|
+
Automatically generates the table name for the model by converting the model's class name from camel case to snake case.
|
|
115
|
+
This is the recommended text case style for table names:
|
|
157
116
|
|
|
158
117
|
https://wiki.postgresql.org/wiki/Don%27t_Do_This#Don.27t_use_upper_case_table_or_column_names
|
|
159
118
|
|
|
160
|
-
By default, the class is lower cased which makes it harder to read.
|
|
119
|
+
By default, the model's class name is lower cased which makes it harder to read.
|
|
161
120
|
|
|
162
|
-
|
|
163
|
-
|
|
121
|
+
Also, many text case conversion libraries struggle handling words like "LLMCache", this is why we are using
|
|
122
|
+
a more precise library which processes such acronyms: [`textcase`](https://pypi.org/project/textcase/).
|
|
164
123
|
|
|
165
124
|
https://stackoverflow.com/questions/1175208/elegant-python-function-to-convert-camelcase-to-snake-case
|
|
166
125
|
"""
|
|
167
|
-
return
|
|
126
|
+
return textcase.snake(cls.__name__)
|
|
168
127
|
|
|
169
128
|
@classmethod
|
|
170
129
|
def foreign_key(cls, **kwargs):
|
|
@@ -234,34 +193,81 @@ class BaseModel(SQLModel):
|
|
|
234
193
|
return result
|
|
235
194
|
|
|
236
195
|
def delete(self):
|
|
196
|
+
"""Delete instance running delete hooks and optional around_delete context manager."""
|
|
197
|
+
|
|
198
|
+
cm = self._get_around_context_manager("around_delete") or nullcontext()
|
|
199
|
+
|
|
237
200
|
with get_session() as session:
|
|
238
|
-
if
|
|
201
|
+
if (
|
|
202
|
+
old_session := Session.object_session(self)
|
|
203
|
+
) and old_session is not session:
|
|
239
204
|
old_session.expunge(self)
|
|
240
|
-
|
|
241
205
|
session.delete(self)
|
|
242
|
-
|
|
243
|
-
|
|
206
|
+
|
|
207
|
+
self._call_hook("before_delete")
|
|
208
|
+
with cm:
|
|
209
|
+
session.commit()
|
|
210
|
+
self._call_hook("after_delete")
|
|
211
|
+
|
|
212
|
+
return True
|
|
244
213
|
|
|
245
214
|
def save(self):
|
|
215
|
+
"""Persist instance running create/update hooks and optional around_save context manager."""
|
|
216
|
+
|
|
217
|
+
is_new = self.is_new()
|
|
218
|
+
cm = self._get_around_context_manager("around_save") or nullcontext()
|
|
219
|
+
|
|
246
220
|
with get_session() as session:
|
|
247
|
-
if
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
# to get around this, you need to remove it from the old one,
|
|
251
|
-
# then add it to the new one (below)
|
|
221
|
+
if (
|
|
222
|
+
old_session := Session.object_session(self)
|
|
223
|
+
) and old_session is not session:
|
|
252
224
|
old_session.expunge(self)
|
|
253
225
|
|
|
254
226
|
session.add(self)
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
session
|
|
227
|
+
|
|
228
|
+
# the order and placement of these hooks is really important
|
|
229
|
+
# we need the current object to be in a session otherwise it will not be able to
|
|
230
|
+
# load any relationships.
|
|
231
|
+
self._call_hook("before_create" if is_new else "before_update")
|
|
232
|
+
self._call_hook("before_save")
|
|
233
|
+
|
|
234
|
+
with cm:
|
|
235
|
+
session.commit()
|
|
236
|
+
session.refresh(self)
|
|
237
|
+
|
|
238
|
+
self._call_hook("after_create" if is_new else "after_update")
|
|
239
|
+
self._call_hook("after_save")
|
|
258
240
|
|
|
259
241
|
# Only call the transform method if the class is a subclass of PydanticJSONMixin
|
|
260
242
|
if issubclass(self.__class__, PydanticJSONMixin):
|
|
261
243
|
self.__class__.__transform_dict_to_pydantic__(self)
|
|
262
|
-
|
|
263
244
|
return self
|
|
264
245
|
|
|
246
|
+
def _call_hook(self, hook_name: str) -> None:
|
|
247
|
+
method = getattr(self, hook_name, None)
|
|
248
|
+
if callable(method):
|
|
249
|
+
if method.__code__.co_argcount != 1:
|
|
250
|
+
raise TypeError(
|
|
251
|
+
f"Hook '{hook_name}' must accept exactly 1 positional argument (self)"
|
|
252
|
+
)
|
|
253
|
+
method()
|
|
254
|
+
|
|
255
|
+
def _get_around_context_manager(self, name: str) -> t.ContextManager | None:
|
|
256
|
+
obj = getattr(self, name, None)
|
|
257
|
+
if obj is None:
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
# If it's a callable (method/function), call it to obtain the CM
|
|
261
|
+
if callable(obj):
|
|
262
|
+
obj = obj()
|
|
263
|
+
|
|
264
|
+
cm = obj
|
|
265
|
+
if not (hasattr(cm, "__enter__") and hasattr(cm, "__exit__")):
|
|
266
|
+
raise TypeError(
|
|
267
|
+
f"{name} must return or be a context manager implementing __enter__/__exit__"
|
|
268
|
+
)
|
|
269
|
+
return t.cast(t.ContextManager, cm)
|
|
270
|
+
|
|
265
271
|
def refresh(self):
|
|
266
272
|
"Refreshes an object from the database"
|
|
267
273
|
|
|
@@ -282,6 +288,7 @@ class BaseModel(SQLModel):
|
|
|
282
288
|
|
|
283
289
|
# TODO shouldn't this be handled by pydantic?
|
|
284
290
|
# TODO where is this actually used? shoudl prob remove this
|
|
291
|
+
# TODO should we even do this? Can we specify a better json rendering class?
|
|
285
292
|
def json(self, **kwargs):
|
|
286
293
|
return json.dumps(self.dict(), default=str, **kwargs)
|
|
287
294
|
|
|
@@ -297,7 +304,12 @@ class BaseModel(SQLModel):
|
|
|
297
304
|
# TODO got to be a better way to fwd these along...
|
|
298
305
|
@classmethod
|
|
299
306
|
def first(cls):
|
|
300
|
-
|
|
307
|
+
# TODO should use dynamic pk
|
|
308
|
+
return cls.select().order_by(sa.desc(cls.id)).first()
|
|
309
|
+
|
|
310
|
+
# @classmethod
|
|
311
|
+
# def last(cls):
|
|
312
|
+
# return cls.select().first()
|
|
301
313
|
|
|
302
314
|
# TODO throw an error if this field is set on the model
|
|
303
315
|
def is_new(self) -> bool:
|
|
@@ -404,6 +416,19 @@ class BaseModel(SQLModel):
|
|
|
404
416
|
with get_session() as session:
|
|
405
417
|
return session.exec(statement).first()
|
|
406
418
|
|
|
419
|
+
@classmethod
|
|
420
|
+
def one_or_none(cls, *args: t.Any, **kwargs: t.Any):
|
|
421
|
+
"""
|
|
422
|
+
Gets a single record from the database. Pass an PK ID or a kwarg to filter by.
|
|
423
|
+
Returns None if no record is found. Throws an error if more than one record is found.
|
|
424
|
+
"""
|
|
425
|
+
|
|
426
|
+
args, kwargs = cls.__process_filter_args__(*args, **kwargs)
|
|
427
|
+
statement = select(cls).filter(*args).filter_by(**kwargs)
|
|
428
|
+
|
|
429
|
+
with get_session() as session:
|
|
430
|
+
return session.exec(statement).one_or_none()
|
|
431
|
+
|
|
407
432
|
@classmethod
|
|
408
433
|
def one(cls, *args: t.Any, **kwargs: t.Any):
|
|
409
434
|
"""
|