activemodel 0.7.0__tar.gz → 0.9.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.7.0 → activemodel-0.9.0}/.envrc +2 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/.github/workflows/build_and_publish.yml +14 -14
- activemodel-0.9.0/.tool-versions +3 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/CHANGELOG.md +41 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/Makefile +6 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/PKG-INFO +51 -4
- {activemodel-0.7.0 → activemodel-0.9.0}/README.md +50 -3
- {activemodel-0.7.0 → activemodel-0.9.0}/TODO +2 -1
- {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/base_model.py +113 -8
- {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/celery.py +6 -1
- {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/get_column_from_field_patch.py +2 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/mixins/pydantic_json.py +24 -6
- activemodel-0.9.0/activemodel/pytest/transaction.py +63 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/session_manager.py +18 -1
- {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/types/typeid.py +1 -2
- activemodel-0.9.0/playground/extract_comments.py +33 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/pyproject.toml +1 -1
- activemodel-0.9.0/test/import_test.py +5 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/test/models.py +2 -2
- activemodel-0.9.0/test/mutation_test.py +35 -0
- activemodel-0.9.0/test/nested_pydantic_json_test.py +191 -0
- activemodel-0.9.0/test/orm_test.py +159 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/test/typeid_test.py +12 -9
- activemodel-0.7.0/.tool-versions +0 -3
- activemodel-0.7.0/activemodel/pytest/transaction.py +0 -51
- activemodel-0.7.0/test/orm_test.py +0 -51
- activemodel-0.7.0/test/serialization_test.py +0 -85
- {activemodel-0.7.0 → activemodel-0.9.0}/.github/dependabot.yml +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/.github/workflows/repo-sync.yml +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/.gitignore +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/.vscode/settings.json +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/Justfile +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/LICENSE +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/__init__.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/errors.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/logger.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/mixins/__init__.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/mixins/soft_delete.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/mixins/timestamps.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/mixins/typeid.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/pytest/__init__.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/pytest/truncate.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/query_wrapper.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/types/__init__.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/utils.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/docker-compose.yml +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/playground/comments.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/playground/env-with-model.patch +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/playground/field.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/playground/middleware.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/playground/old_session_manager.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/playground/pydantic_validation.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/playground.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/test/__init__.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/test/comments_test.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/test/conftest.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/test/delete_test.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/test/fastapi_test.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/test/migrations/README +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/test/migrations/alembic.ini +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/test/migrations/env.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/test/migrations/script.py.mako +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/test/migrations_test.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/test/table_name_test.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/test/test_wrapper.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/test/utils.py +0 -0
- {activemodel-0.7.0 → activemodel-0.9.0}/uv.lock +0 -0
|
@@ -5,11 +5,6 @@ on:
|
|
|
5
5
|
- main
|
|
6
6
|
- master
|
|
7
7
|
|
|
8
|
-
# write permissions for release-please
|
|
9
|
-
# permissions:
|
|
10
|
-
# contents: write
|
|
11
|
-
# pull-requests: write
|
|
12
|
-
|
|
13
8
|
env:
|
|
14
9
|
# avoid build failures due to flaky pypi
|
|
15
10
|
PIP_DEFAULT_TIMEOUT: 60
|
|
@@ -17,10 +12,13 @@ env:
|
|
|
17
12
|
|
|
18
13
|
DATABASE_HOST: localhost
|
|
19
14
|
|
|
15
|
+
# required otherwise github api calls are rate limited
|
|
16
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
17
|
+
|
|
20
18
|
jobs:
|
|
21
19
|
release-please:
|
|
22
20
|
runs-on: ubuntu-latest
|
|
23
|
-
needs: [
|
|
21
|
+
needs: [matrix-test]
|
|
24
22
|
outputs:
|
|
25
23
|
release_created: ${{ steps.release.outputs.release_created }}
|
|
26
24
|
steps:
|
|
@@ -42,17 +40,19 @@ jobs:
|
|
|
42
40
|
- run: uv build
|
|
43
41
|
- run: uv publish --token ${{ secrets.PYPI_API_TOKEN }}
|
|
44
42
|
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
matrix-test:
|
|
44
|
+
strategy:
|
|
45
|
+
matrix:
|
|
46
|
+
os: [ubuntu-latest]
|
|
47
|
+
# TODO test on macos-latest, does not have docker by default :/
|
|
48
|
+
# unfortunately, some of the typing stuff we use requires new python versions
|
|
49
|
+
python-version: ["3.13", "3.12"]
|
|
50
|
+
runs-on: ${{ matrix.os }}
|
|
47
51
|
steps:
|
|
48
52
|
- uses: actions/checkout@v4
|
|
49
53
|
- uses: jdx/mise-action@v2
|
|
50
|
-
- run:
|
|
54
|
+
- run: mise use python@${{ matrix.python-version }}
|
|
51
55
|
- run: docker compose up -d --wait
|
|
56
|
+
- uses: iloveitaly/github-action-direnv-load-and-mask@master
|
|
52
57
|
- run: uv sync
|
|
53
|
-
|
|
54
|
-
# `uv run` prefix is required since the venv is not activated
|
|
55
|
-
|
|
56
|
-
- name: Make sure we can import the module
|
|
57
|
-
run: uv run python -c 'import ${{ github.event.repository.name }}'
|
|
58
58
|
- run: uv run pytest
|
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.9.0](https://github.com/iloveitaly/activemodel/compare/v0.8.0...v0.9.0) (2025-03-26)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add flag_modified and modified_fields to BaseModel ([3059903](https://github.com/iloveitaly/activemodel/commit/305990387797f4fde26c7c89a8d332b6ef7ff21f))
|
|
9
|
+
* add refresh method to BaseModel for database sync ([59be3bb](https://github.com/iloveitaly/activemodel/commit/59be3bb87c64c865b08a2856f043678650c07194))
|
|
10
|
+
* add robust record retrieval methods to BaseModel ([d15b5a1](https://github.com/iloveitaly/activemodel/commit/d15b5a1ffbbb18b6fde94d64dbc76f45c21f7da8))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* use set_committed_value in PydanticJSONMixin ([5446204](https://github.com/iloveitaly/activemodel/commit/5446204fc4b17ed5f1daa4c898f5ba2242b0fc40))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Documentation
|
|
19
|
+
|
|
20
|
+
* update TODOs and correct typeid return value ([52b2514](https://github.com/iloveitaly/activemodel/commit/52b2514b6d30212a56e34c2fe4c849ff79e36e6a))
|
|
21
|
+
|
|
22
|
+
## [0.8.0](https://github.com/iloveitaly/activemodel/compare/v0.7.0...v0.8.0) (2025-03-18)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Features
|
|
26
|
+
|
|
27
|
+
* add BaseModel.where method and update test cases ([9fe4c5a](https://github.com/iloveitaly/activemodel/commit/9fe4c5af619690ffb6344cf5c74a2d4b2b46ef02))
|
|
28
|
+
* add primary_key_field ([947a410](https://github.com/iloveitaly/activemodel/commit/947a410766dd764e8ac5b3177152d2ff22cdb609))
|
|
29
|
+
* log start of database transaction in tests ([ac90d6f](https://github.com/iloveitaly/activemodel/commit/ac90d6f18bb0a4cca68527dec9c55b4af1f6e851))
|
|
30
|
+
* reload json fields when record is reloaded from db ([c013082](https://github.com/iloveitaly/activemodel/commit/c013082004bb3a93e81f00eb0c990833a9cae7e2))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
### Bug Fixes
|
|
34
|
+
|
|
35
|
+
* yield session object in global_session function ([47b33cc](https://github.com/iloveitaly/activemodel/commit/47b33cc1aa66d6076363635191c297c52fcc3deb))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
### Documentation
|
|
39
|
+
|
|
40
|
+
* add comments to clarify SessionManager use and config ([e73561b](https://github.com/iloveitaly/activemodel/commit/e73561b3d52e617a0f92da5cd93981ff429da16f))
|
|
41
|
+
* update comments and README with additional examples and info ([209ee36](https://github.com/iloveitaly/activemodel/commit/209ee36a9df53f927cd9e5b2bb15b3a5776b34ce))
|
|
42
|
+
* update README with setup instructions and SQLModel tips ([f2520b5](https://github.com/iloveitaly/activemodel/commit/f2520b5fa5d7c462e8f7a591b83d874239a34b8d))
|
|
43
|
+
|
|
3
44
|
## [0.7.0](https://github.com/iloveitaly/activemodel/compare/v0.6.0...v0.7.0) (2025-02-08)
|
|
4
45
|
|
|
5
46
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: activemodel
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Make SQLModel more like an a real ORM
|
|
5
5
|
Project-URL: Repository, https://github.com/iloveitaly/activemodel
|
|
6
6
|
Author-email: Michael Bianco <iloveitaly@gmail.com>
|
|
@@ -29,10 +29,11 @@ This package provides a thin wrapper around SQLModel that provides a more Active
|
|
|
29
29
|
First, setup your DB:
|
|
30
30
|
|
|
31
31
|
```python
|
|
32
|
-
|
|
32
|
+
import activemodel
|
|
33
|
+
activemodel.init("sqlite:///database.db")
|
|
33
34
|
```
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
Create models:
|
|
36
37
|
|
|
37
38
|
```python
|
|
38
39
|
from activemodel import BaseModel
|
|
@@ -51,6 +52,38 @@ class User(
|
|
|
51
52
|
a_field: str
|
|
52
53
|
```
|
|
53
54
|
|
|
55
|
+
You'll need to create the models in the DB. Alembic is the best way to do it, but you can cheat as well:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from sqlmodel import SQLModel
|
|
59
|
+
|
|
60
|
+
SQLModel.metadata.create_all(get_engine())
|
|
61
|
+
|
|
62
|
+
# now you can create a user!
|
|
63
|
+
User(a_field="a").save()
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Maybe you like JSON:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from activemodel import BaseModel
|
|
70
|
+
from pydantic import BaseModel as PydanticBaseModel
|
|
71
|
+
from activemodel.mixins import PydanticJSONMixin, TypeIDMixin, TimestampsMixin
|
|
72
|
+
|
|
73
|
+
class SubObject(PydanticBaseModel):
|
|
74
|
+
name: str
|
|
75
|
+
value: int
|
|
76
|
+
|
|
77
|
+
class User(
|
|
78
|
+
BaseModel,
|
|
79
|
+
TimestampsMixin,
|
|
80
|
+
PydanticJSONMixin,
|
|
81
|
+
TypeIDMixin("user"),
|
|
82
|
+
table=True
|
|
83
|
+
):
|
|
84
|
+
list_field: list[SubObject] = Field(sa_type=JSONB())
|
|
85
|
+
```
|
|
86
|
+
|
|
54
87
|
## Usage
|
|
55
88
|
|
|
56
89
|
### Integrating Alembic
|
|
@@ -60,6 +93,7 @@ class User(
|
|
|
60
93
|
* To import all of your models you want in your DB. [Here's my recommended way to do this.](https://github.com/iloveitaly/python-starter-template/blob/master/app/models/__init__.py)
|
|
61
94
|
* Use your DB URL from the ENV
|
|
62
95
|
* Target sqlalchemy metadata to the sqlmodel-generated metadata
|
|
96
|
+
* Most likely you'll want to add [alembic-postgresql-enum](https://pypi.org/project/alembic-postgresql-enum/) so migrations work properly
|
|
63
97
|
|
|
64
98
|
[Take a look at these scripts for an example of how to fully integrate Alembic into your development workflow.](https://github.com/iloveitaly/python-starter-template/blob/0af2c7e95217e34bde7357cc95be048900000e48/Justfile#L618-L712)
|
|
65
99
|
|
|
@@ -161,6 +195,15 @@ https://github.com/tomwojcik/starlette-context
|
|
|
161
195
|
* Conditional: `Scrape.select().where(Scrape.id < last_scraped.id).all()`
|
|
162
196
|
* Equality: `MenuItem.select().where(MenuItem.menu_id == menu.id).all()`
|
|
163
197
|
* `IN` example: `CanonicalMenuItem.select().where(col(CanonicalMenuItem.id).in_(canonized_ids)).all()`
|
|
198
|
+
* Compound where query: `User.where((User.last_active_at != None) & (User.last_active_at > last_24_hours)).count()`
|
|
199
|
+
|
|
200
|
+
### SQLModel Internals
|
|
201
|
+
|
|
202
|
+
SQLModel & SQLAlchemy are tricky. Here are some useful internal tricks:
|
|
203
|
+
|
|
204
|
+
* `__sqlmodel_relationships__` is where any `RelationshipInfo` objects are stored. This is used to generate relationship fields on the object.
|
|
205
|
+
* `ModelClass.relationship_name.property.local_columns`
|
|
206
|
+
* Get cached fields from a model `object_state(instance).dict.get(field_name)`
|
|
164
207
|
|
|
165
208
|
### TypeID
|
|
166
209
|
|
|
@@ -186,7 +229,7 @@ class Appointment(
|
|
|
186
229
|
TypeIDMixin("appointment"),
|
|
187
230
|
table=True
|
|
188
231
|
):
|
|
189
|
-
# `foreign_key` is a activemodel
|
|
232
|
+
# `foreign_key` is a activemodel method to generate the right `Field` for the relationship
|
|
190
233
|
# TypeIDType is really important here for fastapi serialization
|
|
191
234
|
doctor_id: TypeIDType = Doctor.foreign_key()
|
|
192
235
|
doctor: Doctor = Relationship()
|
|
@@ -233,3 +276,7 @@ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d
|
|
|
233
276
|
* https://github.com/fastapi/full-stack-fastapi-template
|
|
234
277
|
* https://github.com/DarylStark/my_data/
|
|
235
278
|
* https://github.com/petrgazarov/FastAPI-app/tree/main/fastapi_app
|
|
279
|
+
|
|
280
|
+
## Upstream Changes
|
|
281
|
+
|
|
282
|
+
- [ ] https://github.com/fastapi/sqlmodel/pull/1293
|
|
@@ -14,10 +14,11 @@ This package provides a thin wrapper around SQLModel that provides a more Active
|
|
|
14
14
|
First, setup your DB:
|
|
15
15
|
|
|
16
16
|
```python
|
|
17
|
-
|
|
17
|
+
import activemodel
|
|
18
|
+
activemodel.init("sqlite:///database.db")
|
|
18
19
|
```
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
Create models:
|
|
21
22
|
|
|
22
23
|
```python
|
|
23
24
|
from activemodel import BaseModel
|
|
@@ -36,6 +37,38 @@ class User(
|
|
|
36
37
|
a_field: str
|
|
37
38
|
```
|
|
38
39
|
|
|
40
|
+
You'll need to create the models in the DB. Alembic is the best way to do it, but you can cheat as well:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from sqlmodel import SQLModel
|
|
44
|
+
|
|
45
|
+
SQLModel.metadata.create_all(get_engine())
|
|
46
|
+
|
|
47
|
+
# now you can create a user!
|
|
48
|
+
User(a_field="a").save()
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Maybe you like JSON:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from activemodel import BaseModel
|
|
55
|
+
from pydantic import BaseModel as PydanticBaseModel
|
|
56
|
+
from activemodel.mixins import PydanticJSONMixin, TypeIDMixin, TimestampsMixin
|
|
57
|
+
|
|
58
|
+
class SubObject(PydanticBaseModel):
|
|
59
|
+
name: str
|
|
60
|
+
value: int
|
|
61
|
+
|
|
62
|
+
class User(
|
|
63
|
+
BaseModel,
|
|
64
|
+
TimestampsMixin,
|
|
65
|
+
PydanticJSONMixin,
|
|
66
|
+
TypeIDMixin("user"),
|
|
67
|
+
table=True
|
|
68
|
+
):
|
|
69
|
+
list_field: list[SubObject] = Field(sa_type=JSONB())
|
|
70
|
+
```
|
|
71
|
+
|
|
39
72
|
## Usage
|
|
40
73
|
|
|
41
74
|
### Integrating Alembic
|
|
@@ -45,6 +78,7 @@ class User(
|
|
|
45
78
|
* To import all of your models you want in your DB. [Here's my recommended way to do this.](https://github.com/iloveitaly/python-starter-template/blob/master/app/models/__init__.py)
|
|
46
79
|
* Use your DB URL from the ENV
|
|
47
80
|
* Target sqlalchemy metadata to the sqlmodel-generated metadata
|
|
81
|
+
* Most likely you'll want to add [alembic-postgresql-enum](https://pypi.org/project/alembic-postgresql-enum/) so migrations work properly
|
|
48
82
|
|
|
49
83
|
[Take a look at these scripts for an example of how to fully integrate Alembic into your development workflow.](https://github.com/iloveitaly/python-starter-template/blob/0af2c7e95217e34bde7357cc95be048900000e48/Justfile#L618-L712)
|
|
50
84
|
|
|
@@ -146,6 +180,15 @@ https://github.com/tomwojcik/starlette-context
|
|
|
146
180
|
* Conditional: `Scrape.select().where(Scrape.id < last_scraped.id).all()`
|
|
147
181
|
* Equality: `MenuItem.select().where(MenuItem.menu_id == menu.id).all()`
|
|
148
182
|
* `IN` example: `CanonicalMenuItem.select().where(col(CanonicalMenuItem.id).in_(canonized_ids)).all()`
|
|
183
|
+
* Compound where query: `User.where((User.last_active_at != None) & (User.last_active_at > last_24_hours)).count()`
|
|
184
|
+
|
|
185
|
+
### SQLModel Internals
|
|
186
|
+
|
|
187
|
+
SQLModel & SQLAlchemy are tricky. Here are some useful internal tricks:
|
|
188
|
+
|
|
189
|
+
* `__sqlmodel_relationships__` is where any `RelationshipInfo` objects are stored. This is used to generate relationship fields on the object.
|
|
190
|
+
* `ModelClass.relationship_name.property.local_columns`
|
|
191
|
+
* Get cached fields from a model `object_state(instance).dict.get(field_name)`
|
|
149
192
|
|
|
150
193
|
### TypeID
|
|
151
194
|
|
|
@@ -171,7 +214,7 @@ class Appointment(
|
|
|
171
214
|
TypeIDMixin("appointment"),
|
|
172
215
|
table=True
|
|
173
216
|
):
|
|
174
|
-
# `foreign_key` is a activemodel
|
|
217
|
+
# `foreign_key` is a activemodel method to generate the right `Field` for the relationship
|
|
175
218
|
# TypeIDType is really important here for fastapi serialization
|
|
176
219
|
doctor_id: TypeIDType = Doctor.foreign_key()
|
|
177
220
|
doctor: Doctor = Relationship()
|
|
@@ -218,3 +261,7 @@ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d
|
|
|
218
261
|
* https://github.com/fastapi/full-stack-fastapi-template
|
|
219
262
|
* https://github.com/DarylStark/my_data/
|
|
220
263
|
* https://github.com/petrgazarov/FastAPI-app/tree/main/fastapi_app
|
|
264
|
+
|
|
265
|
+
## Upstream Changes
|
|
266
|
+
|
|
267
|
+
- [ ] https://github.com/fastapi/sqlmodel/pull/1293
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
Docs are bad:
|
|
2
2
|
|
|
3
|
+
- `another_example_id: TypeIDType = AnotherExample.foreign_key(nullable=True)` nullable should be able to be defined via types
|
|
3
4
|
- JSON field, specifically JSONB https://github.com/tiangolo/sqlmodel/discussions/696 and https://github.com/fastapi/sqlmodel/discussions/1105
|
|
4
5
|
- One-to-many relationships
|
|
5
6
|
- Data validation https://github.com/tiangolo/sqlmodel/issues/52
|
|
@@ -9,8 +10,8 @@ Docs are bad:
|
|
|
9
10
|
|
|
10
11
|
TODO
|
|
11
12
|
|
|
13
|
+
- [ ] sessions in tests, they don't work right now
|
|
12
14
|
- [ ] snake case for attributes https://github.com/sqlalchemy/sqlalchemy/issues/7149
|
|
13
|
-
- [ ] foreign key names https://github.com/fastapi/sqlmodel/discussions/1213
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
find_or_create
|
|
@@ -4,12 +4,16 @@ from uuid import UUID
|
|
|
4
4
|
|
|
5
5
|
import pydash
|
|
6
6
|
import sqlalchemy as sa
|
|
7
|
+
from sqlalchemy.orm.attributes import flag_modified as sa_flag_modified
|
|
8
|
+
from sqlalchemy.orm.base import instance_state
|
|
7
9
|
import sqlmodel as sm
|
|
8
10
|
from sqlalchemy import Connection, event
|
|
9
11
|
from sqlalchemy.orm import Mapper, declared_attr
|
|
10
|
-
from sqlmodel import
|
|
12
|
+
from sqlmodel import Column, Field, Session, SQLModel, inspect, select
|
|
11
13
|
from typeid import TypeID
|
|
12
14
|
|
|
15
|
+
from activemodel.mixins.pydantic_json import PydanticJSONMixin
|
|
16
|
+
|
|
13
17
|
# NOTE: this patches a core method in sqlmodel to support db comments
|
|
14
18
|
from . import get_column_from_field_patch # noqa: F401
|
|
15
19
|
from .logger import logger
|
|
@@ -157,7 +161,10 @@ class BaseModel(SQLModel):
|
|
|
157
161
|
@classmethod
|
|
158
162
|
def foreign_key(cls, **kwargs):
|
|
159
163
|
"""
|
|
160
|
-
Returns a Field object referencing the foreign key of the model.
|
|
164
|
+
Returns a `Field` object referencing the foreign key of the model.
|
|
165
|
+
|
|
166
|
+
>>> other_model_id: int
|
|
167
|
+
>>> other_model = OtherModel.foreign_key()
|
|
161
168
|
"""
|
|
162
169
|
|
|
163
170
|
field_options = {"nullable": False} | kwargs
|
|
@@ -174,6 +181,11 @@ class BaseModel(SQLModel):
|
|
|
174
181
|
"create a query wrapper to easily run sqlmodel queries on this model"
|
|
175
182
|
return QueryWrapper[cls](cls, *args)
|
|
176
183
|
|
|
184
|
+
@classmethod
|
|
185
|
+
def where(cls, *args):
|
|
186
|
+
"convenience method to avoid having to write .select().where() in order to add conditions"
|
|
187
|
+
return cls.select().where(*args)
|
|
188
|
+
|
|
177
189
|
def delete(self):
|
|
178
190
|
with get_session() as session:
|
|
179
191
|
if old_session := Session.object_session(self):
|
|
@@ -197,13 +209,32 @@ class BaseModel(SQLModel):
|
|
|
197
209
|
session.commit()
|
|
198
210
|
session.refresh(self)
|
|
199
211
|
|
|
212
|
+
# Only call the transform method if the class is a subclass of PydanticJSONMixin
|
|
213
|
+
if issubclass(self.__class__, PydanticJSONMixin):
|
|
214
|
+
self.__class__.__transform_dict_to_pydantic__(self)
|
|
215
|
+
|
|
200
216
|
return self
|
|
201
217
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
218
|
+
def refresh(self):
|
|
219
|
+
"Refreshes an object from the database"
|
|
220
|
+
|
|
221
|
+
with get_session() as session:
|
|
222
|
+
if (
|
|
223
|
+
old_session := Session.object_session(self)
|
|
224
|
+
) and old_session is not session:
|
|
225
|
+
old_session.expunge(self)
|
|
226
|
+
|
|
227
|
+
session.add(self)
|
|
228
|
+
session.refresh(self)
|
|
229
|
+
|
|
230
|
+
# Only call the transform method if the class is a subclass of PydanticJSONMixin
|
|
231
|
+
if issubclass(self.__class__, PydanticJSONMixin):
|
|
232
|
+
self.__class__.__transform_dict_to_pydantic__(self)
|
|
233
|
+
|
|
234
|
+
return self
|
|
205
235
|
|
|
206
236
|
# TODO shouldn't this be handled by pydantic?
|
|
237
|
+
# TODO where is this actually used? shoudl prob remove this
|
|
207
238
|
def json(self, **kwargs):
|
|
208
239
|
return json.dumps(self.dict(), default=str, **kwargs)
|
|
209
240
|
|
|
@@ -225,6 +256,29 @@ class BaseModel(SQLModel):
|
|
|
225
256
|
def is_new(self) -> bool:
|
|
226
257
|
return not self._sa_instance_state.has_identity
|
|
227
258
|
|
|
259
|
+
def flag_modified(self, *args: str):
|
|
260
|
+
"""
|
|
261
|
+
Flag one or more fields as modified. Useful for marking a field containing sub-objects as modified.
|
|
262
|
+
|
|
263
|
+
Will throw an error if an invalid field is passed.
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
assert len(args) > 0, "Must pass at least one field name"
|
|
267
|
+
|
|
268
|
+
for field_name in args:
|
|
269
|
+
if field_name not in self.__fields__:
|
|
270
|
+
raise ValueError(f"Field '{field_name}' does not exist in the model.")
|
|
271
|
+
|
|
272
|
+
# check if the field exists
|
|
273
|
+
sa_flag_modified(self, field_name)
|
|
274
|
+
|
|
275
|
+
def modified_fields(self) -> set[str]:
|
|
276
|
+
"set of fields that are modified"
|
|
277
|
+
|
|
278
|
+
insp = inspect(self)
|
|
279
|
+
|
|
280
|
+
return {attr.key for attr in insp.attrs if attr.history.has_changes()}
|
|
281
|
+
|
|
228
282
|
@classmethod
|
|
229
283
|
def find_or_create_by(cls, **kwargs):
|
|
230
284
|
"""
|
|
@@ -256,6 +310,29 @@ class BaseModel(SQLModel):
|
|
|
256
310
|
new_model = cls(**kwargs)
|
|
257
311
|
return new_model
|
|
258
312
|
|
|
313
|
+
@classmethod
|
|
314
|
+
def primary_key_column(cls) -> Column:
|
|
315
|
+
"""
|
|
316
|
+
Returns the primary key column of the model by inspecting SQLAlchemy field information.
|
|
317
|
+
|
|
318
|
+
>>> ExampleModel.primary_key_field().name
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
# TODO note_schema.__class__.__table__.primary_key
|
|
322
|
+
# TODO no reason why this couldn't be cached
|
|
323
|
+
|
|
324
|
+
pk_columns = list(cls.__table__.primary_key.columns)
|
|
325
|
+
|
|
326
|
+
if not pk_columns:
|
|
327
|
+
raise ValueError("No primary key defined for the model.")
|
|
328
|
+
|
|
329
|
+
if len(pk_columns) > 1:
|
|
330
|
+
raise ValueError(
|
|
331
|
+
"Multiple primary keys defined. This method supports only single primary key models."
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
return pk_columns[0]
|
|
335
|
+
|
|
259
336
|
# TODO what's super dangerous here is you pass a kwarg which does not map to a specific
|
|
260
337
|
# field it will result in `True`, which will return all records, and not give you any typing
|
|
261
338
|
# errors. Dangerous when iterating on structure quickly
|
|
@@ -265,9 +342,8 @@ class BaseModel(SQLModel):
|
|
|
265
342
|
@classmethod
|
|
266
343
|
def get(cls, *args: t.Any, **kwargs: t.Any):
|
|
267
344
|
"""
|
|
268
|
-
Gets a single record from the database. Pass an PK ID or
|
|
345
|
+
Gets a single record (or None) from the database. Pass an PK ID or kwargs to filter by.
|
|
269
346
|
"""
|
|
270
|
-
|
|
271
347
|
# TODO id is hardcoded, not good! Need to dynamically pick the best uid field
|
|
272
348
|
id_field_name = "id"
|
|
273
349
|
|
|
@@ -281,8 +357,37 @@ class BaseModel(SQLModel):
|
|
|
281
357
|
with get_session() as session:
|
|
282
358
|
return session.exec(statement).first()
|
|
283
359
|
|
|
360
|
+
@classmethod
|
|
361
|
+
def one(cls, *args: t.Any, **kwargs: t.Any):
|
|
362
|
+
"""
|
|
363
|
+
Gets a single record from the database. Pass an PK ID or a kwarg to filter by.
|
|
364
|
+
"""
|
|
365
|
+
|
|
366
|
+
args, kwargs = cls.__process_filter_args__(*args, **kwargs)
|
|
367
|
+
statement = select(cls).filter(*args).filter_by(**kwargs)
|
|
368
|
+
|
|
369
|
+
with get_session() as session:
|
|
370
|
+
return session.exec(statement).one()
|
|
371
|
+
|
|
372
|
+
@classmethod
|
|
373
|
+
def __process_filter_args__(cls, *args: t.Any, **kwargs: t.Any):
|
|
374
|
+
"""
|
|
375
|
+
Helper method to process filter arguments and implement some nice DX for our devs.
|
|
376
|
+
"""
|
|
377
|
+
|
|
378
|
+
id_field_name = cls.primary_key_column().name
|
|
379
|
+
|
|
380
|
+
# special case for getting by ID without having to specify the field name
|
|
381
|
+
# TODO should dynamically add new pk types based on column definition
|
|
382
|
+
if len(args) == 1 and isinstance(args[0], (int, TypeID, str, UUID)):
|
|
383
|
+
kwargs[id_field_name] = args[0]
|
|
384
|
+
args = ()
|
|
385
|
+
|
|
386
|
+
return args, kwargs
|
|
387
|
+
|
|
284
388
|
@classmethod
|
|
285
389
|
def all(cls):
|
|
390
|
+
"get a generator for all records in the database"
|
|
286
391
|
with get_session() as session:
|
|
287
392
|
results = session.exec(sm.select(cls))
|
|
288
393
|
|
|
@@ -293,7 +398,7 @@ class BaseModel(SQLModel):
|
|
|
293
398
|
@classmethod
|
|
294
399
|
def sample(cls):
|
|
295
400
|
"""
|
|
296
|
-
Pick a random record from the database.
|
|
401
|
+
Pick a random record from the database. Raises if none exist.
|
|
297
402
|
|
|
298
403
|
Helpful for testing and console debugging.
|
|
299
404
|
"""
|
|
@@ -4,12 +4,17 @@ Do not import unless you have Celery/Kombu installed.
|
|
|
4
4
|
In order for TypeID objects to be properly handled by celery, a custom encoder must be registered.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
# this is not an explicit dependency, only import this file if you have Celery installed
|
|
7
8
|
from kombu.utils.json import register_type
|
|
8
9
|
from typeid import TypeID
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
def register_celery_typeid_encoder():
|
|
12
|
-
"
|
|
13
|
+
"""
|
|
14
|
+
Ensures TypeID objects passed as arguments to a delayed function are properly serialized.
|
|
15
|
+
|
|
16
|
+
Run at the top of your celery initialization script.
|
|
17
|
+
"""
|
|
13
18
|
|
|
14
19
|
def class_full_name(clz) -> str:
|
|
15
20
|
return ".".join([clz.__module__, clz.__qualname__])
|
|
@@ -6,6 +6,8 @@ Making sure these docstrings make their way to the DB schema is helpful for a bu
|
|
|
6
6
|
This patch mutates a core sqlmodel function which translates pydantic FieldInfo objects into sqlalchemy Column objects. It adds the field description as a comment to the column.
|
|
7
7
|
|
|
8
8
|
Note that FieldInfo *from pydantic* is used when a "bare" field is defined. This can be confusing, because when inspecting model fields, the class name looks exactly the same.
|
|
9
|
+
|
|
10
|
+
Some ideas for this originally sourced from: https://github.com/fastapi/sqlmodel/issues/492#issuecomment-2489858633
|
|
9
11
|
"""
|
|
10
12
|
|
|
11
13
|
from typing import (
|
|
@@ -1,19 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Need to store nested Pydantic models in PostgreSQL using FastAPI and SQLModel.
|
|
3
|
+
|
|
4
|
+
SQLModel lacks a direct JSONField equivalent (like Tortoise ORM's JSONField), making it tricky to handle nested model data as JSON in the DB.
|
|
5
|
+
|
|
6
|
+
Extensive discussion on the problem: https://github.com/fastapi/sqlmodel/issues/63
|
|
7
|
+
"""
|
|
8
|
+
|
|
1
9
|
from types import UnionType
|
|
2
10
|
from typing import get_args, get_origin
|
|
3
11
|
|
|
4
12
|
from pydantic import BaseModel as PydanticBaseModel
|
|
5
|
-
from sqlalchemy.orm import reconstructor
|
|
13
|
+
from sqlalchemy.orm import reconstructor, attributes
|
|
6
14
|
|
|
7
15
|
|
|
8
16
|
class PydanticJSONMixin:
|
|
9
17
|
"""
|
|
10
18
|
By default, SQLModel does not convert JSONB columns into pydantic models when they are loaded from the database.
|
|
11
19
|
|
|
12
|
-
This mixin, combined with a custom serializer, fixes that issue.
|
|
20
|
+
This mixin, combined with a custom serializer (`_serialize_pydantic_model`), fixes that issue.
|
|
21
|
+
|
|
22
|
+
>>> class ExampleWithJSON(BaseModel, PydanticJSONMixin, table=True):
|
|
23
|
+
>>> list_field: list[SubObject] = Field(sa_type=JSONB()
|
|
13
24
|
"""
|
|
14
25
|
|
|
15
26
|
@reconstructor
|
|
16
|
-
def
|
|
27
|
+
def __transform_dict_to_pydantic__(self):
|
|
28
|
+
"""
|
|
29
|
+
Transforms dictionary fields into Pydantic models upon loading.
|
|
30
|
+
|
|
31
|
+
- Reconstructor only runs once, when the object is loaded.
|
|
32
|
+
- We manually call this method on save(), etc to ensure the pydantic types are maintained
|
|
33
|
+
- `set_committed_value` sets Pydantic models as committed, avoiding `setattr` marking fields as modified
|
|
34
|
+
after loading from the database.
|
|
35
|
+
"""
|
|
17
36
|
# TODO do we need to inspect sa_type
|
|
18
37
|
for field_name, field_info in self.model_fields.items():
|
|
19
38
|
raw_value = getattr(self, field_name, None)
|
|
@@ -60,10 +79,9 @@ class PydanticJSONMixin:
|
|
|
60
79
|
model_cls, PydanticBaseModel
|
|
61
80
|
):
|
|
62
81
|
parsed_value = [model_cls(**item) for item in raw_value]
|
|
63
|
-
|
|
64
|
-
|
|
82
|
+
attributes.set_committed_value(self, field_name, parsed_value)
|
|
65
83
|
continue
|
|
66
84
|
|
|
67
85
|
# single class
|
|
68
86
|
if issubclass(model_cls, PydanticBaseModel):
|
|
69
|
-
|
|
87
|
+
attributes.set_committed_value(self, field_name, model_cls(**raw_value))
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from activemodel import SessionManager
|
|
2
|
+
|
|
3
|
+
from ..logger import logger
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def database_reset_transaction():
|
|
7
|
+
"""
|
|
8
|
+
Wrap all database interactions for a given test in a nested transaction and roll it back after the test.
|
|
9
|
+
|
|
10
|
+
>>> from activemodel.pytest import database_reset_transaction
|
|
11
|
+
>>> database_reset_transaction = pytest.fixture(scope="function", autouse=True)(database_reset_transaction)
|
|
12
|
+
|
|
13
|
+
Transaction-based DB cleaning does *not* work if the DB mutations are happening in a separate process, which should
|
|
14
|
+
use spawn, because the same session is not shared across processes. Note that using `fork` is dangerous.
|
|
15
|
+
|
|
16
|
+
In this case, you should use the truncate.
|
|
17
|
+
|
|
18
|
+
References:
|
|
19
|
+
|
|
20
|
+
- https://stackoverflow.com/questions/62433018/how-to-make-sqlalchemy-transaction-rollback-drop-tables-it-created
|
|
21
|
+
- https://aalvarez.me/posts/setting-up-a-sqlalchemy-and-pytest-based-test-suite/
|
|
22
|
+
- https://github.com/nickjj/docker-flask-example/blob/93af9f4fbf185098ffb1d120ee0693abcd77a38b/test/conftest.py#L77
|
|
23
|
+
- https://github.com/caiola/vinhos.com/blob/c47d0a5d7a4bf290c1b726561d1e8f5d2ac29bc8/backend/test/conftest.py#L46
|
|
24
|
+
- https://stackoverflow.com/questions/64095876/multiprocessing-fork-vs-spawn
|
|
25
|
+
|
|
26
|
+
Using a named SAVEPOINT does not give us anything extra, so we are not using it.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
engine = SessionManager.get_instance().get_engine()
|
|
30
|
+
|
|
31
|
+
logger.info("starting global database transaction")
|
|
32
|
+
|
|
33
|
+
with engine.begin() as connection:
|
|
34
|
+
transaction = connection.begin_nested()
|
|
35
|
+
|
|
36
|
+
if SessionManager.get_instance().session_connection is not None:
|
|
37
|
+
logger.warning("session override already exists")
|
|
38
|
+
# TODO should we throw an exception here?
|
|
39
|
+
|
|
40
|
+
SessionManager.get_instance().session_connection = connection
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
with SessionManager.get_instance().get_session() as factory_session:
|
|
44
|
+
try:
|
|
45
|
+
from factory.alchemy import SQLAlchemyModelFactory
|
|
46
|
+
|
|
47
|
+
# Ensure that all factories use the same session
|
|
48
|
+
for factory in SQLAlchemyModelFactory.__subclasses__():
|
|
49
|
+
factory._meta.sqlalchemy_session = factory_session
|
|
50
|
+
factory._meta.sqlalchemy_session_persistence = "commit"
|
|
51
|
+
except ImportError:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
yield
|
|
55
|
+
finally:
|
|
56
|
+
logger.debug("rolling back transaction")
|
|
57
|
+
|
|
58
|
+
transaction.rollback()
|
|
59
|
+
|
|
60
|
+
# TODO is this necessary? unclear
|
|
61
|
+
connection.close()
|
|
62
|
+
|
|
63
|
+
SessionManager.get_instance().session_connection = None
|