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.
Files changed (67) hide show
  1. {activemodel-0.7.0 → activemodel-0.9.0}/.envrc +2 -0
  2. {activemodel-0.7.0 → activemodel-0.9.0}/.github/workflows/build_and_publish.yml +14 -14
  3. activemodel-0.9.0/.tool-versions +3 -0
  4. {activemodel-0.7.0 → activemodel-0.9.0}/CHANGELOG.md +41 -0
  5. {activemodel-0.7.0 → activemodel-0.9.0}/Makefile +6 -0
  6. {activemodel-0.7.0 → activemodel-0.9.0}/PKG-INFO +51 -4
  7. {activemodel-0.7.0 → activemodel-0.9.0}/README.md +50 -3
  8. {activemodel-0.7.0 → activemodel-0.9.0}/TODO +2 -1
  9. {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/base_model.py +113 -8
  10. {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/celery.py +6 -1
  11. {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/get_column_from_field_patch.py +2 -0
  12. {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/mixins/pydantic_json.py +24 -6
  13. activemodel-0.9.0/activemodel/pytest/transaction.py +63 -0
  14. {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/session_manager.py +18 -1
  15. {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/types/typeid.py +1 -2
  16. activemodel-0.9.0/playground/extract_comments.py +33 -0
  17. {activemodel-0.7.0 → activemodel-0.9.0}/pyproject.toml +1 -1
  18. activemodel-0.9.0/test/import_test.py +5 -0
  19. {activemodel-0.7.0 → activemodel-0.9.0}/test/models.py +2 -2
  20. activemodel-0.9.0/test/mutation_test.py +35 -0
  21. activemodel-0.9.0/test/nested_pydantic_json_test.py +191 -0
  22. activemodel-0.9.0/test/orm_test.py +159 -0
  23. {activemodel-0.7.0 → activemodel-0.9.0}/test/typeid_test.py +12 -9
  24. activemodel-0.7.0/.tool-versions +0 -3
  25. activemodel-0.7.0/activemodel/pytest/transaction.py +0 -51
  26. activemodel-0.7.0/test/orm_test.py +0 -51
  27. activemodel-0.7.0/test/serialization_test.py +0 -85
  28. {activemodel-0.7.0 → activemodel-0.9.0}/.github/dependabot.yml +0 -0
  29. {activemodel-0.7.0 → activemodel-0.9.0}/.github/workflows/repo-sync.yml +0 -0
  30. {activemodel-0.7.0 → activemodel-0.9.0}/.gitignore +0 -0
  31. {activemodel-0.7.0 → activemodel-0.9.0}/.vscode/settings.json +0 -0
  32. {activemodel-0.7.0 → activemodel-0.9.0}/Justfile +0 -0
  33. {activemodel-0.7.0 → activemodel-0.9.0}/LICENSE +0 -0
  34. {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/__init__.py +0 -0
  35. {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/errors.py +0 -0
  36. {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/logger.py +0 -0
  37. {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/mixins/__init__.py +0 -0
  38. {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/mixins/soft_delete.py +0 -0
  39. {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/mixins/timestamps.py +0 -0
  40. {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/mixins/typeid.py +0 -0
  41. {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/pytest/__init__.py +0 -0
  42. {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/pytest/truncate.py +0 -0
  43. {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/query_wrapper.py +0 -0
  44. {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/types/__init__.py +0 -0
  45. {activemodel-0.7.0 → activemodel-0.9.0}/activemodel/utils.py +0 -0
  46. {activemodel-0.7.0 → activemodel-0.9.0}/docker-compose.yml +0 -0
  47. {activemodel-0.7.0 → activemodel-0.9.0}/playground/comments.py +0 -0
  48. {activemodel-0.7.0 → activemodel-0.9.0}/playground/env-with-model.patch +0 -0
  49. {activemodel-0.7.0 → activemodel-0.9.0}/playground/field.py +0 -0
  50. {activemodel-0.7.0 → activemodel-0.9.0}/playground/middleware.py +0 -0
  51. {activemodel-0.7.0 → activemodel-0.9.0}/playground/old_session_manager.py +0 -0
  52. {activemodel-0.7.0 → activemodel-0.9.0}/playground/pydantic_validation.py +0 -0
  53. {activemodel-0.7.0 → activemodel-0.9.0}/playground.py +0 -0
  54. {activemodel-0.7.0 → activemodel-0.9.0}/test/__init__.py +0 -0
  55. {activemodel-0.7.0 → activemodel-0.9.0}/test/comments_test.py +0 -0
  56. {activemodel-0.7.0 → activemodel-0.9.0}/test/conftest.py +0 -0
  57. {activemodel-0.7.0 → activemodel-0.9.0}/test/delete_test.py +0 -0
  58. {activemodel-0.7.0 → activemodel-0.9.0}/test/fastapi_test.py +0 -0
  59. {activemodel-0.7.0 → activemodel-0.9.0}/test/migrations/README +0 -0
  60. {activemodel-0.7.0 → activemodel-0.9.0}/test/migrations/alembic.ini +0 -0
  61. {activemodel-0.7.0 → activemodel-0.9.0}/test/migrations/env.py +0 -0
  62. {activemodel-0.7.0 → activemodel-0.9.0}/test/migrations/script.py.mako +0 -0
  63. {activemodel-0.7.0 → activemodel-0.9.0}/test/migrations_test.py +0 -0
  64. {activemodel-0.7.0 → activemodel-0.9.0}/test/table_name_test.py +0 -0
  65. {activemodel-0.7.0 → activemodel-0.9.0}/test/test_wrapper.py +0 -0
  66. {activemodel-0.7.0 → activemodel-0.9.0}/test/utils.py +0 -0
  67. {activemodel-0.7.0 → activemodel-0.9.0}/uv.lock +0 -0
@@ -10,3 +10,5 @@ export POSTGRES_DB=development
10
10
  export DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${DATABASE_HOST}:5432/development
11
11
 
12
12
  export PYTHONBREAKPOINT=ipdb.set_trace
13
+
14
+ # export ACTIVEMODEL_LOG_SQL=true
@@ -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: [build]
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
- build:
46
- runs-on: ubuntu-latest
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: direnv allow . && direnv export gha >> "$GITHUB_ENV"
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
@@ -0,0 +1,3 @@
1
+ python 3.13.2
2
+ uv 0.6.10
3
+ direnv 2.35.0
@@ -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
 
@@ -2,6 +2,12 @@ setup:
2
2
  uv venv && uv sync
3
3
  @echo "activate: source ./.venv/bin/activate"
4
4
 
5
+ up:
6
+ docker compose up -d --wait
7
+
8
+ db_open:
9
+ open -a TablePlus $$DATABASE_URL
10
+
5
11
  clean:
6
12
  rm -rf *.egg-info
7
13
  rm -rf .venv
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: activemodel
3
- Version: 0.7.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
- Then, setup some models:
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-specific method to generate the right `Field` for the relationship
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
- Then, setup some models:
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-specific method to generate the right `Field` for the relationship
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 Field, MetaData, Session, SQLModel, select
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
- # except IntegrityError:
203
- # log.quiet(f"{self} already exists in the database.")
204
- # session.rollback()
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 a kwarg to filter by.
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
- "this ensures TypeID objects passed as arguments to a delayed function are properly serialized"
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 init_on_load(self):
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
- setattr(self, field_name, parsed_value)
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
- setattr(self, field_name, model_cls(**raw_value))
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