activemodel 0.8.0__tar.gz → 0.10.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.8.0 → activemodel-0.10.0}/.github/workflows/build_and_publish.yml +14 -14
- activemodel-0.10.0/.tool-versions +3 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/CHANGELOG.md +38 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/Makefile +3 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/PKG-INFO +1 -1
- {activemodel-0.8.0 → activemodel-0.10.0}/TODO +2 -1
- {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/base_model.py +128 -9
- {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/mixins/pydantic_json.py +10 -5
- activemodel-0.10.0/activemodel/mixins/typeid.py +33 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/pytest/transaction.py +1 -1
- {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/session_manager.py +7 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/types/typeid.py +0 -2
- activemodel-0.10.0/playground/alternative_typeid_mixin.py +22 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/pyproject.toml +1 -1
- activemodel-0.10.0/test/import_test.py +5 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/test/models.py +14 -1
- activemodel-0.10.0/test/mutation_test.py +35 -0
- activemodel-0.8.0/test/serialization_test.py → activemodel-0.10.0/test/nested_pydantic_json_test.py +98 -0
- activemodel-0.10.0/test/orm/test_upsert.py +185 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/test/orm_test.py +53 -5
- activemodel-0.10.0/test/session_manager_test.py +22 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/uv.lock +356 -289
- activemodel-0.8.0/.tool-versions +0 -3
- activemodel-0.8.0/activemodel/mixins/typeid.py +0 -46
- {activemodel-0.8.0 → activemodel-0.10.0}/.envrc +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/.github/dependabot.yml +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/.github/workflows/repo-sync.yml +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/.gitignore +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/.vscode/settings.json +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/Justfile +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/LICENSE +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/README.md +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/__init__.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/celery.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/errors.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/get_column_from_field_patch.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/logger.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/mixins/__init__.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/mixins/soft_delete.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/mixins/timestamps.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/pytest/__init__.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/pytest/truncate.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/query_wrapper.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/types/__init__.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/utils.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/docker-compose.yml +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/playground/comments.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/playground/env-with-model.patch +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/playground/extract_comments.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/playground/field.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/playground/middleware.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/playground/old_session_manager.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/playground/pydantic_validation.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/playground.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/test/__init__.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/test/comments_test.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/test/conftest.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/test/delete_test.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/test/fastapi_test.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/test/migrations/README +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/test/migrations/alembic.ini +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/test/migrations/env.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/test/migrations/script.py.mako +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/test/migrations_test.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/test/table_name_test.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/test/test_wrapper.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/test/typeid_test.py +0 -0
- {activemodel-0.8.0 → activemodel-0.10.0}/test/utils.py +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,43 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.10.0](https://github.com/iloveitaly/activemodel/compare/v0.9.0...v0.10.0) (2025-04-01)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add upsert method for PostgreSQL support in BaseModel ([d639de6](https://github.com/iloveitaly/activemodel/commit/d639de6cb498b72cd4b42422822c7d59ca6a646c))
|
|
9
|
+
* prevent nested global sessions and add test ([3aca2cc](https://github.com/iloveitaly/activemodel/commit/3aca2ccadf3e029801d5e517f5e660af44564f73))
|
|
10
|
+
* return upserted model and enhance upsert tests ([df2359a](https://github.com/iloveitaly/activemodel/commit/df2359a4ba8a00305bd3d5024b9789642b7b4718))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* use sa_column default instead of sqlmodel ([37e299d](https://github.com/iloveitaly/activemodel/commit/37e299d43dcec7db2cdfd3bc3b572b31b3234f35))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Documentation
|
|
19
|
+
|
|
20
|
+
* enhance docstrings in BaseModel for clarity ([12a5dee](https://github.com/iloveitaly/activemodel/commit/12a5deec1ee2480410bbad9250b253991dd12d86))
|
|
21
|
+
|
|
22
|
+
## [0.9.0](https://github.com/iloveitaly/activemodel/compare/v0.8.0...v0.9.0) (2025-03-26)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Features
|
|
26
|
+
|
|
27
|
+
* add flag_modified and modified_fields to BaseModel ([3059903](https://github.com/iloveitaly/activemodel/commit/305990387797f4fde26c7c89a8d332b6ef7ff21f))
|
|
28
|
+
* add refresh method to BaseModel for database sync ([59be3bb](https://github.com/iloveitaly/activemodel/commit/59be3bb87c64c865b08a2856f043678650c07194))
|
|
29
|
+
* add robust record retrieval methods to BaseModel ([d15b5a1](https://github.com/iloveitaly/activemodel/commit/d15b5a1ffbbb18b6fde94d64dbc76f45c21f7da8))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Bug Fixes
|
|
33
|
+
|
|
34
|
+
* use set_committed_value in PydanticJSONMixin ([5446204](https://github.com/iloveitaly/activemodel/commit/5446204fc4b17ed5f1daa4c898f5ba2242b0fc40))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
### Documentation
|
|
38
|
+
|
|
39
|
+
* update TODOs and correct typeid return value ([52b2514](https://github.com/iloveitaly/activemodel/commit/52b2514b6d30212a56e34c2fe4c849ff79e36e6a))
|
|
40
|
+
|
|
3
41
|
## [0.8.0](https://github.com/iloveitaly/activemodel/compare/v0.7.0...v0.8.0) (2025-03-18)
|
|
4
42
|
|
|
5
43
|
|
|
@@ -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
|
|
@@ -7,9 +7,10 @@ import sqlalchemy as sa
|
|
|
7
7
|
import sqlmodel as sm
|
|
8
8
|
from sqlalchemy import Connection, event
|
|
9
9
|
from sqlalchemy.orm import Mapper, declared_attr
|
|
10
|
-
from
|
|
10
|
+
from sqlalchemy.orm.attributes import flag_modified as sa_flag_modified
|
|
11
|
+
from sqlalchemy.orm.base import instance_state
|
|
12
|
+
from sqlmodel import Column, Field, Session, SQLModel, inspect, select
|
|
11
13
|
from typeid import TypeID
|
|
12
|
-
from inspect import isclass
|
|
13
14
|
|
|
14
15
|
from activemodel.mixins.pydantic_json import PydanticJSONMixin
|
|
15
16
|
|
|
@@ -18,6 +19,7 @@ from . import get_column_from_field_patch # noqa: F401
|
|
|
18
19
|
from .logger import logger
|
|
19
20
|
from .query_wrapper import QueryWrapper
|
|
20
21
|
from .session_manager import get_session
|
|
22
|
+
from sqlalchemy.dialects.postgresql import insert as postgres_insert
|
|
21
23
|
|
|
22
24
|
POSTGRES_INDEXES_NAMING_CONVENTION = {
|
|
23
25
|
"ix": "%(column_0_label)s_idx",
|
|
@@ -136,8 +138,15 @@ class BaseModel(SQLModel):
|
|
|
136
138
|
cls.__table_args__ = {"comment": doc}
|
|
137
139
|
elif isinstance(table_args, dict):
|
|
138
140
|
table_args.setdefault("comment", doc)
|
|
141
|
+
elif isinstance(table_args, tuple):
|
|
142
|
+
# If it's a tuple, we need to convert it to a list and add the comment
|
|
143
|
+
table_args = list(table_args)
|
|
144
|
+
table_args.append({"comment": doc})
|
|
145
|
+
cls.__table_args__ = tuple(table_args)
|
|
139
146
|
else:
|
|
140
|
-
raise ValueError(
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"Unexpected __table_args__ type {type(table_args)}, expected dictionary."
|
|
149
|
+
)
|
|
141
150
|
|
|
142
151
|
# TODO no type check decorator here
|
|
143
152
|
@declared_attr
|
|
@@ -162,8 +171,10 @@ class BaseModel(SQLModel):
|
|
|
162
171
|
"""
|
|
163
172
|
Returns a `Field` object referencing the foreign key of the model.
|
|
164
173
|
|
|
165
|
-
|
|
166
|
-
|
|
174
|
+
Helps quickly build a many-to-one or one-to-one relationship.
|
|
175
|
+
|
|
176
|
+
>>> other_model_id: int = OtherModel.foreign_key()
|
|
177
|
+
>>> other_model = Relationship()
|
|
167
178
|
"""
|
|
168
179
|
|
|
169
180
|
field_options = {"nullable": False} | kwargs
|
|
@@ -185,6 +196,42 @@ class BaseModel(SQLModel):
|
|
|
185
196
|
"convenience method to avoid having to write .select().where() in order to add conditions"
|
|
186
197
|
return cls.select().where(*args)
|
|
187
198
|
|
|
199
|
+
@classmethod
|
|
200
|
+
def upsert(
|
|
201
|
+
cls,
|
|
202
|
+
data: dict[str, t.Any],
|
|
203
|
+
unique_by: str | list[str],
|
|
204
|
+
) -> None:
|
|
205
|
+
"""
|
|
206
|
+
This method will insert a new record if it doesn't exist, or update the existing record if it does.
|
|
207
|
+
|
|
208
|
+
It uses SQLAlchemy's `on_conflict_do_update` and does not yet support MySQL. Some implementation details below.
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
- `index_elements=["name"]`: Specifies the column(s) to check for conflicts (e.g., unique constraint or index). If a row with the same "name" exists, it triggers the update instead of an insert.
|
|
213
|
+
- `values`: Defines the data to insert (e.g., `name="example", value=123`). If no conflict occurs, this data is inserted as a new row.
|
|
214
|
+
|
|
215
|
+
The `set_` parameter (e.g., `set_=dict(value=123)`) then dictates what gets updated on conflict, overriding matching fields in `values` if specified.
|
|
216
|
+
"""
|
|
217
|
+
index_elements = [unique_by] if isinstance(unique_by, str) else unique_by
|
|
218
|
+
|
|
219
|
+
stmt = (
|
|
220
|
+
postgres_insert(cls)
|
|
221
|
+
.values(**data)
|
|
222
|
+
.on_conflict_do_update(index_elements=index_elements, set_=data)
|
|
223
|
+
.returning(cls)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
with get_session() as session:
|
|
227
|
+
result = session.exec(stmt)
|
|
228
|
+
session.commit()
|
|
229
|
+
|
|
230
|
+
# TODO this is so ugly:
|
|
231
|
+
result = result.one()[0]
|
|
232
|
+
|
|
233
|
+
return result
|
|
234
|
+
|
|
188
235
|
def delete(self):
|
|
189
236
|
with get_session() as session:
|
|
190
237
|
if old_session := Session.object_session(self):
|
|
@@ -214,7 +261,26 @@ class BaseModel(SQLModel):
|
|
|
214
261
|
|
|
215
262
|
return self
|
|
216
263
|
|
|
264
|
+
def refresh(self):
|
|
265
|
+
"Refreshes an object from the database"
|
|
266
|
+
|
|
267
|
+
with get_session() as session:
|
|
268
|
+
if (
|
|
269
|
+
old_session := Session.object_session(self)
|
|
270
|
+
) and old_session is not session:
|
|
271
|
+
old_session.expunge(self)
|
|
272
|
+
|
|
273
|
+
session.add(self)
|
|
274
|
+
session.refresh(self)
|
|
275
|
+
|
|
276
|
+
# Only call the transform method if the class is a subclass of PydanticJSONMixin
|
|
277
|
+
if issubclass(self.__class__, PydanticJSONMixin):
|
|
278
|
+
self.__class__.__transform_dict_to_pydantic__(self)
|
|
279
|
+
|
|
280
|
+
return self
|
|
281
|
+
|
|
217
282
|
# TODO shouldn't this be handled by pydantic?
|
|
283
|
+
# TODO where is this actually used? shoudl prob remove this
|
|
218
284
|
def json(self, **kwargs):
|
|
219
285
|
return json.dumps(self.dict(), default=str, **kwargs)
|
|
220
286
|
|
|
@@ -236,6 +302,29 @@ class BaseModel(SQLModel):
|
|
|
236
302
|
def is_new(self) -> bool:
|
|
237
303
|
return not self._sa_instance_state.has_identity
|
|
238
304
|
|
|
305
|
+
def flag_modified(self, *args: str) -> None:
|
|
306
|
+
"""
|
|
307
|
+
Flag one or more fields as modified/mutated/dirty. Useful for marking a field containing sub-objects as modified.
|
|
308
|
+
|
|
309
|
+
Will throw an error if an invalid field is passed.
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
assert len(args) > 0, "Must pass at least one field name"
|
|
313
|
+
|
|
314
|
+
for field_name in args:
|
|
315
|
+
if field_name not in self.__fields__:
|
|
316
|
+
raise ValueError(f"Field '{field_name}' does not exist in the model.")
|
|
317
|
+
|
|
318
|
+
# check if the field exists
|
|
319
|
+
sa_flag_modified(self, field_name)
|
|
320
|
+
|
|
321
|
+
def modified_fields(self) -> set[str]:
|
|
322
|
+
"set of fields that are modified"
|
|
323
|
+
|
|
324
|
+
insp = inspect(self)
|
|
325
|
+
|
|
326
|
+
return {attr.key for attr in insp.attrs if attr.history.has_changes()}
|
|
327
|
+
|
|
239
328
|
@classmethod
|
|
240
329
|
def find_or_create_by(cls, **kwargs):
|
|
241
330
|
"""
|
|
@@ -268,13 +357,15 @@ class BaseModel(SQLModel):
|
|
|
268
357
|
return new_model
|
|
269
358
|
|
|
270
359
|
@classmethod
|
|
271
|
-
def
|
|
360
|
+
def primary_key_column(cls) -> Column:
|
|
272
361
|
"""
|
|
273
362
|
Returns the primary key column of the model by inspecting SQLAlchemy field information.
|
|
274
363
|
|
|
275
364
|
>>> ExampleModel.primary_key_field().name
|
|
276
365
|
"""
|
|
366
|
+
|
|
277
367
|
# TODO note_schema.__class__.__table__.primary_key
|
|
368
|
+
# TODO no reason why this couldn't be cached
|
|
278
369
|
|
|
279
370
|
pk_columns = list(cls.__table__.primary_key.columns)
|
|
280
371
|
|
|
@@ -297,9 +388,8 @@ class BaseModel(SQLModel):
|
|
|
297
388
|
@classmethod
|
|
298
389
|
def get(cls, *args: t.Any, **kwargs: t.Any):
|
|
299
390
|
"""
|
|
300
|
-
Gets a single record from the database. Pass an PK ID or
|
|
391
|
+
Gets a single record (or None) from the database. Pass an PK ID or kwargs to filter by.
|
|
301
392
|
"""
|
|
302
|
-
|
|
303
393
|
# TODO id is hardcoded, not good! Need to dynamically pick the best uid field
|
|
304
394
|
id_field_name = "id"
|
|
305
395
|
|
|
@@ -313,8 +403,37 @@ class BaseModel(SQLModel):
|
|
|
313
403
|
with get_session() as session:
|
|
314
404
|
return session.exec(statement).first()
|
|
315
405
|
|
|
406
|
+
@classmethod
|
|
407
|
+
def one(cls, *args: t.Any, **kwargs: t.Any):
|
|
408
|
+
"""
|
|
409
|
+
Gets a single record from the database. Pass an PK ID or a kwarg to filter by.
|
|
410
|
+
"""
|
|
411
|
+
|
|
412
|
+
args, kwargs = cls.__process_filter_args__(*args, **kwargs)
|
|
413
|
+
statement = select(cls).filter(*args).filter_by(**kwargs)
|
|
414
|
+
|
|
415
|
+
with get_session() as session:
|
|
416
|
+
return session.exec(statement).one()
|
|
417
|
+
|
|
418
|
+
@classmethod
|
|
419
|
+
def __process_filter_args__(cls, *args: t.Any, **kwargs: t.Any):
|
|
420
|
+
"""
|
|
421
|
+
Helper method to process filter arguments and implement some nice DX for our devs.
|
|
422
|
+
"""
|
|
423
|
+
|
|
424
|
+
id_field_name = cls.primary_key_column().name
|
|
425
|
+
|
|
426
|
+
# special case for getting by ID without having to specify the field name
|
|
427
|
+
# TODO should dynamically add new pk types based on column definition
|
|
428
|
+
if len(args) == 1 and isinstance(args[0], (int, TypeID, str, UUID)):
|
|
429
|
+
kwargs[id_field_name] = args[0]
|
|
430
|
+
args = ()
|
|
431
|
+
|
|
432
|
+
return args, kwargs
|
|
433
|
+
|
|
316
434
|
@classmethod
|
|
317
435
|
def all(cls):
|
|
436
|
+
"get a generator for all records in the database"
|
|
318
437
|
with get_session() as session:
|
|
319
438
|
results = session.exec(sm.select(cls))
|
|
320
439
|
|
|
@@ -325,7 +444,7 @@ class BaseModel(SQLModel):
|
|
|
325
444
|
@classmethod
|
|
326
445
|
def sample(cls):
|
|
327
446
|
"""
|
|
328
|
-
Pick a random record from the database.
|
|
447
|
+
Pick a random record from the database. Raises if none exist.
|
|
329
448
|
|
|
330
449
|
Helpful for testing and console debugging.
|
|
331
450
|
"""
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
"""
|
|
2
|
-
|
|
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
|
|
3
7
|
"""
|
|
4
8
|
|
|
5
9
|
from types import UnionType
|
|
6
10
|
from typing import get_args, get_origin
|
|
7
11
|
|
|
8
12
|
from pydantic import BaseModel as PydanticBaseModel
|
|
9
|
-
from sqlalchemy.orm import reconstructor
|
|
13
|
+
from sqlalchemy.orm import reconstructor, attributes
|
|
10
14
|
|
|
11
15
|
|
|
12
16
|
class PydanticJSONMixin:
|
|
@@ -26,6 +30,8 @@ class PydanticJSONMixin:
|
|
|
26
30
|
|
|
27
31
|
- Reconstructor only runs once, when the object is loaded.
|
|
28
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.
|
|
29
35
|
"""
|
|
30
36
|
# TODO do we need to inspect sa_type
|
|
31
37
|
for field_name, field_info in self.model_fields.items():
|
|
@@ -73,10 +79,9 @@ class PydanticJSONMixin:
|
|
|
73
79
|
model_cls, PydanticBaseModel
|
|
74
80
|
):
|
|
75
81
|
parsed_value = [model_cls(**item) for item in raw_value]
|
|
76
|
-
|
|
77
|
-
|
|
82
|
+
attributes.set_committed_value(self, field_name, parsed_value)
|
|
78
83
|
continue
|
|
79
84
|
|
|
80
85
|
# single class
|
|
81
86
|
if issubclass(model_cls, PydanticBaseModel):
|
|
82
|
-
|
|
87
|
+
attributes.set_committed_value(self, field_name, model_cls(**raw_value))
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from sqlmodel import Column, Field
|
|
2
|
+
from typeid import TypeID
|
|
3
|
+
|
|
4
|
+
from activemodel.types.typeid import TypeIDType
|
|
5
|
+
|
|
6
|
+
# global list of prefixes to ensure uniqueness
|
|
7
|
+
_prefixes: list[str] = []
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def TypeIDMixin(prefix: str):
|
|
11
|
+
# make sure duplicate prefixes are not used!
|
|
12
|
+
# NOTE this will cause issues on code reloads
|
|
13
|
+
assert prefix
|
|
14
|
+
assert prefix not in _prefixes, (
|
|
15
|
+
f"prefix {prefix} already exists, pick a different one"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
class _TypeIDMixin:
|
|
19
|
+
__abstract__ = True
|
|
20
|
+
|
|
21
|
+
id: TypeIDType = Field(
|
|
22
|
+
sa_column=Column(
|
|
23
|
+
TypeIDType(prefix),
|
|
24
|
+
primary_key=True,
|
|
25
|
+
nullable=False,
|
|
26
|
+
default=lambda: TypeID(prefix),
|
|
27
|
+
),
|
|
28
|
+
# default_factory=lambda: TypeID(prefix),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
_prefixes.append(prefix)
|
|
32
|
+
|
|
33
|
+
return _TypeIDMixin
|
|
@@ -28,7 +28,7 @@ def database_reset_transaction():
|
|
|
28
28
|
|
|
29
29
|
engine = SessionManager.get_instance().get_engine()
|
|
30
30
|
|
|
31
|
-
logger.info("starting database transaction")
|
|
31
|
+
logger.info("starting global database transaction")
|
|
32
32
|
|
|
33
33
|
with engine.begin() as connection:
|
|
34
34
|
transaction = connection.begin_nested()
|
|
@@ -72,6 +72,7 @@ class SessionManager:
|
|
|
72
72
|
self._database_url,
|
|
73
73
|
# NOTE very important! This enables pydantic models to be serialized for JSONB columns
|
|
74
74
|
json_serializer=_serialize_pydantic_model,
|
|
75
|
+
# TODO move to a constants area
|
|
75
76
|
echo=config("ACTIVEMODEL_LOG_SQL", cast=bool, default=False),
|
|
76
77
|
# https://docs.sqlalchemy.org/en/20/core/pooling.html#disconnect-handling-pessimistic
|
|
77
78
|
pool_pre_ping=True,
|
|
@@ -119,6 +120,9 @@ _session_context = contextvars.ContextVar[Session | None](
|
|
|
119
120
|
|
|
120
121
|
@contextlib.contextmanager
|
|
121
122
|
def global_session():
|
|
123
|
+
if _session_context.get() is not None:
|
|
124
|
+
raise RuntimeError("global session already set")
|
|
125
|
+
|
|
122
126
|
with SessionManager.get_instance().get_session() as s:
|
|
123
127
|
token = _session_context.set(s)
|
|
124
128
|
|
|
@@ -140,6 +144,9 @@ async def aglobal_session():
|
|
|
140
144
|
>>> )
|
|
141
145
|
"""
|
|
142
146
|
|
|
147
|
+
if _session_context.get() is not None:
|
|
148
|
+
raise RuntimeError("global session already set")
|
|
149
|
+
|
|
143
150
|
with SessionManager.get_instance().get_session() as s:
|
|
144
151
|
token = _session_context.set(s)
|
|
145
152
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# TODO not sure if I love the idea of a dynamic class for each mixin as used above
|
|
2
|
+
# may give this approach another shot in the future
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TypeIDMixin2:
|
|
6
|
+
"""
|
|
7
|
+
Mixin class that adds a TypeID primary key to models.
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
>>> class MyModel(BaseModel, TypeIDMixin, prefix="xyz", table=True):
|
|
11
|
+
>>> name: str
|
|
12
|
+
|
|
13
|
+
Will automatically have an `id` field with prefix "xyz"
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init_subclass__(cls, *, prefix: str, **kwargs):
|
|
17
|
+
super().__init_subclass__(**kwargs)
|
|
18
|
+
|
|
19
|
+
cls.id: uuid.UUID = Field(
|
|
20
|
+
sa_column=Column(TypeIDType(prefix), primary_key=True),
|
|
21
|
+
default_factory=lambda: TypeID(prefix),
|
|
22
|
+
)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from pydantic import computed_field
|
|
2
|
-
from
|
|
2
|
+
from sqlalchemy import UniqueConstraint
|
|
3
|
+
from sqlmodel import Column, Field, Integer, Relationship
|
|
3
4
|
|
|
4
5
|
from activemodel import BaseModel
|
|
5
6
|
from activemodel.mixins import TypeIDMixin
|
|
@@ -42,3 +43,15 @@ class ExampleWithComputedProperty(
|
|
|
42
43
|
@property
|
|
43
44
|
def special_note(self) -> str:
|
|
44
45
|
return f"SPECIAL: {self.another_example.note}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class UpsertTestModel(BaseModel, TypeIDMixin("upsert_test"), table=True):
|
|
49
|
+
"""Test model for upsert operations"""
|
|
50
|
+
|
|
51
|
+
name: str = Field(unique=True)
|
|
52
|
+
category: str = Field(index=True)
|
|
53
|
+
value: int = Field(default=0)
|
|
54
|
+
description: str | None = Field(default=None)
|
|
55
|
+
|
|
56
|
+
# Add a composite unique constraint for the multiple unique field test
|
|
57
|
+
__table_args__ = (UniqueConstraint("name", "category", name="compound_constraint"),)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from sqlalchemy.orm.base import instance_state
|
|
3
|
+
from test.models import ExampleRecord
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def assert_modified(model):
|
|
7
|
+
assert instance_state(model).modified
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def assert_clean(model):
|
|
11
|
+
assert not instance_state(model).modified
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_flag_modified(create_and_wipe_database):
|
|
15
|
+
example = ExampleRecord().save()
|
|
16
|
+
assert_clean(example)
|
|
17
|
+
|
|
18
|
+
example.flag_modified("something")
|
|
19
|
+
assert example.modified_fields() == {"something"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_modified_list(create_and_wipe_database):
|
|
23
|
+
example = ExampleRecord().save()
|
|
24
|
+
assert_clean(example)
|
|
25
|
+
|
|
26
|
+
example.something = "hi"
|
|
27
|
+
assert example.modified_fields() == {"something"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_error_on_bad_field(create_and_wipe_database):
|
|
31
|
+
example = ExampleRecord().save()
|
|
32
|
+
assert_clean(example)
|
|
33
|
+
|
|
34
|
+
with pytest.raises(ValueError, match="bad_field"):
|
|
35
|
+
example.flag_modified("bad_field")
|