activemodel 0.8.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.8.0 → activemodel-0.9.0}/.github/workflows/build_and_publish.yml +14 -14
- activemodel-0.9.0/.tool-versions +3 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/CHANGELOG.md +19 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/Makefile +3 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/PKG-INFO +1 -1
- {activemodel-0.8.0 → activemodel-0.9.0}/TODO +2 -1
- {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/base_model.py +79 -6
- {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/mixins/pydantic_json.py +10 -5
- {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/pytest/transaction.py +1 -1
- {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/types/typeid.py +0 -2
- {activemodel-0.8.0 → activemodel-0.9.0}/pyproject.toml +1 -1
- activemodel-0.9.0/test/import_test.py +5 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/test/models.py +1 -1
- activemodel-0.9.0/test/mutation_test.py +35 -0
- activemodel-0.8.0/test/serialization_test.py → activemodel-0.9.0/test/nested_pydantic_json_test.py +98 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/test/orm_test.py +53 -5
- activemodel-0.8.0/.tool-versions +0 -3
- {activemodel-0.8.0 → activemodel-0.9.0}/.envrc +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/.github/dependabot.yml +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/.github/workflows/repo-sync.yml +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/.gitignore +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/.vscode/settings.json +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/Justfile +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/LICENSE +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/README.md +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/__init__.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/celery.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/errors.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/get_column_from_field_patch.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/logger.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/mixins/__init__.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/mixins/soft_delete.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/mixins/timestamps.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/mixins/typeid.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/pytest/__init__.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/pytest/truncate.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/query_wrapper.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/session_manager.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/types/__init__.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/utils.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/docker-compose.yml +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/playground/comments.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/playground/env-with-model.patch +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/playground/extract_comments.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/playground/field.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/playground/middleware.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/playground/old_session_manager.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/playground/pydantic_validation.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/playground.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/test/__init__.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/test/comments_test.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/test/conftest.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/test/delete_test.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/test/fastapi_test.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/test/migrations/README +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/test/migrations/alembic.ini +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/test/migrations/env.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/test/migrations/script.py.mako +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/test/migrations_test.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/test/table_name_test.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/test/test_wrapper.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/test/typeid_test.py +0 -0
- {activemodel-0.8.0 → activemodel-0.9.0}/test/utils.py +0 -0
- {activemodel-0.8.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,24 @@
|
|
|
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
|
+
|
|
3
22
|
## [0.8.0](https://github.com/iloveitaly/activemodel/compare/v0.7.0...v0.8.0) (2025-03-18)
|
|
4
23
|
|
|
5
24
|
|
|
@@ -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,13 @@ 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
|
-
from inspect import isclass
|
|
13
14
|
|
|
14
15
|
from activemodel.mixins.pydantic_json import PydanticJSONMixin
|
|
15
16
|
|
|
@@ -214,7 +215,26 @@ class BaseModel(SQLModel):
|
|
|
214
215
|
|
|
215
216
|
return self
|
|
216
217
|
|
|
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
|
|
235
|
+
|
|
217
236
|
# TODO shouldn't this be handled by pydantic?
|
|
237
|
+
# TODO where is this actually used? shoudl prob remove this
|
|
218
238
|
def json(self, **kwargs):
|
|
219
239
|
return json.dumps(self.dict(), default=str, **kwargs)
|
|
220
240
|
|
|
@@ -236,6 +256,29 @@ class BaseModel(SQLModel):
|
|
|
236
256
|
def is_new(self) -> bool:
|
|
237
257
|
return not self._sa_instance_state.has_identity
|
|
238
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
|
+
|
|
239
282
|
@classmethod
|
|
240
283
|
def find_or_create_by(cls, **kwargs):
|
|
241
284
|
"""
|
|
@@ -268,13 +311,15 @@ class BaseModel(SQLModel):
|
|
|
268
311
|
return new_model
|
|
269
312
|
|
|
270
313
|
@classmethod
|
|
271
|
-
def
|
|
314
|
+
def primary_key_column(cls) -> Column:
|
|
272
315
|
"""
|
|
273
316
|
Returns the primary key column of the model by inspecting SQLAlchemy field information.
|
|
274
317
|
|
|
275
318
|
>>> ExampleModel.primary_key_field().name
|
|
276
319
|
"""
|
|
320
|
+
|
|
277
321
|
# TODO note_schema.__class__.__table__.primary_key
|
|
322
|
+
# TODO no reason why this couldn't be cached
|
|
278
323
|
|
|
279
324
|
pk_columns = list(cls.__table__.primary_key.columns)
|
|
280
325
|
|
|
@@ -297,9 +342,8 @@ class BaseModel(SQLModel):
|
|
|
297
342
|
@classmethod
|
|
298
343
|
def get(cls, *args: t.Any, **kwargs: t.Any):
|
|
299
344
|
"""
|
|
300
|
-
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.
|
|
301
346
|
"""
|
|
302
|
-
|
|
303
347
|
# TODO id is hardcoded, not good! Need to dynamically pick the best uid field
|
|
304
348
|
id_field_name = "id"
|
|
305
349
|
|
|
@@ -313,8 +357,37 @@ class BaseModel(SQLModel):
|
|
|
313
357
|
with get_session() as session:
|
|
314
358
|
return session.exec(statement).first()
|
|
315
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
|
+
|
|
316
388
|
@classmethod
|
|
317
389
|
def all(cls):
|
|
390
|
+
"get a generator for all records in the database"
|
|
318
391
|
with get_session() as session:
|
|
319
392
|
results = session.exec(sm.select(cls))
|
|
320
393
|
|
|
@@ -325,7 +398,7 @@ class BaseModel(SQLModel):
|
|
|
325
398
|
@classmethod
|
|
326
399
|
def sample(cls):
|
|
327
400
|
"""
|
|
328
|
-
Pick a random record from the database.
|
|
401
|
+
Pick a random record from the database. Raises if none exist.
|
|
329
402
|
|
|
330
403
|
Helpful for testing and console debugging.
|
|
331
404
|
"""
|
|
@@ -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))
|
|
@@ -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()
|
|
@@ -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")
|
activemodel-0.8.0/test/serialization_test.py → activemodel-0.9.0/test/nested_pydantic_json_test.py
RENAMED
|
@@ -4,9 +4,11 @@ By default, fast API does not handle converting JSONB to and from Pydantic model
|
|
|
4
4
|
|
|
5
5
|
from pydantic import BaseModel as PydanticBaseModel
|
|
6
6
|
from sqlalchemy.dialects.postgresql import JSONB
|
|
7
|
+
from sqlalchemy.orm.base import instance_state
|
|
7
8
|
from sqlmodel import Field, Session
|
|
8
9
|
|
|
9
10
|
from activemodel import BaseModel
|
|
11
|
+
from sqlalchemy.dialects.postgresql import JSON
|
|
10
12
|
from activemodel.mixins import PydanticJSONMixin, TypeIDMixin
|
|
11
13
|
from activemodel.session_manager import global_session
|
|
12
14
|
from test.models import AnotherExample, ExampleWithComputedProperty
|
|
@@ -32,6 +34,13 @@ class ExampleWithJSONB(
|
|
|
32
34
|
normal_field: str | None = Field(default=None)
|
|
33
35
|
|
|
34
36
|
|
|
37
|
+
class ExampleWithSimpleJSON(
|
|
38
|
+
BaseModel, PydanticJSONMixin, TypeIDMixin("simple_json_test"), table=True
|
|
39
|
+
):
|
|
40
|
+
# NOT JSONB!
|
|
41
|
+
object_field: SubObject = Field(sa_type=JSON)
|
|
42
|
+
|
|
43
|
+
|
|
35
44
|
def test_json_serialization(create_and_wipe_database):
|
|
36
45
|
sub_object = SubObject(name="test", value=1)
|
|
37
46
|
|
|
@@ -54,6 +63,18 @@ def test_json_serialization(create_and_wipe_database):
|
|
|
54
63
|
assert isinstance(example.object_field, SubObject)
|
|
55
64
|
assert isinstance(example.optional_object_field, SubObject)
|
|
56
65
|
|
|
66
|
+
example.refresh()
|
|
67
|
+
|
|
68
|
+
# make sure the automatic dict re-parse doesn't mark as dirty
|
|
69
|
+
assert not instance_state(example).modified
|
|
70
|
+
|
|
71
|
+
# make sure the types are preserved when refreshed
|
|
72
|
+
assert isinstance(example.list_field[0], SubObject)
|
|
73
|
+
assert example.optional_list_field
|
|
74
|
+
assert isinstance(example.optional_list_field[0], SubObject)
|
|
75
|
+
assert isinstance(example.object_field, SubObject)
|
|
76
|
+
assert isinstance(example.optional_object_field, SubObject)
|
|
77
|
+
|
|
57
78
|
fresh_example = ExampleWithJSONB.get(example.id)
|
|
58
79
|
|
|
59
80
|
assert fresh_example is not None
|
|
@@ -91,3 +112,80 @@ def test_computed_serialization(create_and_wipe_database):
|
|
|
91
112
|
|
|
92
113
|
assert ExampleWithComputedProperty.count() == 1
|
|
93
114
|
assert AnotherExample.count() == 1
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_simple_json_object(create_and_wipe_database):
|
|
118
|
+
sub_object = SubObject(name="test", value=1)
|
|
119
|
+
example = ExampleWithSimpleJSON(
|
|
120
|
+
object_field=sub_object,
|
|
121
|
+
).save()
|
|
122
|
+
|
|
123
|
+
# make sure the types are preserved when saved
|
|
124
|
+
assert isinstance(example.object_field, SubObject)
|
|
125
|
+
|
|
126
|
+
example.refresh()
|
|
127
|
+
assert not instance_state(example).modified
|
|
128
|
+
|
|
129
|
+
# make sure the types are preserved when refreshed
|
|
130
|
+
assert isinstance(example.object_field, SubObject)
|
|
131
|
+
assert example.object_field.name == "test"
|
|
132
|
+
assert example.object_field.value == 1
|
|
133
|
+
|
|
134
|
+
fresh_example = ExampleWithSimpleJSON.get(example.id)
|
|
135
|
+
|
|
136
|
+
assert fresh_example is not None
|
|
137
|
+
assert isinstance(fresh_example.object_field, SubObject)
|
|
138
|
+
assert fresh_example.object_field.name == "test"
|
|
139
|
+
assert fresh_example.object_field.value == 1
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_json_object_update(create_and_wipe_database):
|
|
143
|
+
"if we update a entry in a list of json objects, does the change persist?"
|
|
144
|
+
|
|
145
|
+
sub_object = SubObject(name="test", value=1)
|
|
146
|
+
sub_object_2 = SubObject(name="test_2", value=2)
|
|
147
|
+
|
|
148
|
+
example = ExampleWithJSONB(
|
|
149
|
+
list_field=[sub_object, sub_object_2],
|
|
150
|
+
generic_list_field=[{"one": "two"}],
|
|
151
|
+
object_field=sub_object,
|
|
152
|
+
unstructured_field={"one": "two"},
|
|
153
|
+
semi_structured_field={"one": "two"},
|
|
154
|
+
).save()
|
|
155
|
+
|
|
156
|
+
# saving serializes the pydantic model and reloads it, which must not mark the object as dirty!
|
|
157
|
+
assert not instance_state(example).modified
|
|
158
|
+
|
|
159
|
+
# modify a nested object
|
|
160
|
+
example.list_field[0].name = "updated"
|
|
161
|
+
|
|
162
|
+
# the field will *not* be marked as dirty by default
|
|
163
|
+
assert not instance_state(example).modified
|
|
164
|
+
|
|
165
|
+
# so we have to force it to be dirty
|
|
166
|
+
example.flag_modified("list_field")
|
|
167
|
+
|
|
168
|
+
example.object_field.value = 42
|
|
169
|
+
assert instance_state(example).modified
|
|
170
|
+
|
|
171
|
+
example.save()
|
|
172
|
+
|
|
173
|
+
assert example.list_field[0].name == "updated"
|
|
174
|
+
|
|
175
|
+
# NOTE this should be inverted, but we are asserting against the current behavior of `object_field` state not being updated
|
|
176
|
+
assert example.object_field.value != 42
|
|
177
|
+
|
|
178
|
+
# now, let's mark it as modified
|
|
179
|
+
example.object_field.value = 42
|
|
180
|
+
example.flag_modified("object_field")
|
|
181
|
+
example.save()
|
|
182
|
+
|
|
183
|
+
assert example.object_field.value == 42
|
|
184
|
+
|
|
185
|
+
# refresh from database
|
|
186
|
+
fresh_example = ExampleWithJSONB.one(example.id)
|
|
187
|
+
assert not instance_state(example).modified
|
|
188
|
+
|
|
189
|
+
# verify changes persisted
|
|
190
|
+
assert fresh_example.list_field[0].name == "updated"
|
|
191
|
+
assert fresh_example.object_field.value == 42
|
|
@@ -2,7 +2,14 @@
|
|
|
2
2
|
Test core ORM functions
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import pytest
|
|
6
|
+
from test.models import (
|
|
7
|
+
EXAMPLE_TABLE_PREFIX,
|
|
8
|
+
AnotherExample,
|
|
9
|
+
ExampleRecord,
|
|
10
|
+
ExampleWithId,
|
|
11
|
+
)
|
|
12
|
+
import sqlalchemy.exc
|
|
6
13
|
|
|
7
14
|
|
|
8
15
|
def test_empty_count(create_and_wipe_database):
|
|
@@ -95,10 +102,6 @@ def test_query_count(create_and_wipe_database):
|
|
|
95
102
|
assert count == 1
|
|
96
103
|
|
|
97
104
|
|
|
98
|
-
def test_primary_key(create_and_wipe_database):
|
|
99
|
-
assert ExampleRecord.primary_key_field().name == "id"
|
|
100
|
-
|
|
101
|
-
|
|
102
105
|
def test_get_non_pk(create_and_wipe_database):
|
|
103
106
|
# some paranoid checks here as I attempt to debug the issue
|
|
104
107
|
example = ExampleRecord(something="hi", another_with_index="key_123").save()
|
|
@@ -109,3 +112,48 @@ def test_get_non_pk(create_and_wipe_database):
|
|
|
109
112
|
|
|
110
113
|
assert retrieved_example
|
|
111
114
|
assert retrieved_example.id == example.id
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_database_refresh(create_and_wipe_database):
|
|
118
|
+
example = ExampleRecord(something="hi").save()
|
|
119
|
+
example_2 = ExampleRecord.get(example.id)
|
|
120
|
+
|
|
121
|
+
# now, let's update the "hi" on the 2nd example
|
|
122
|
+
example_2.something = "hello"
|
|
123
|
+
example_2.save()
|
|
124
|
+
|
|
125
|
+
# now let's refresh the first example
|
|
126
|
+
example.refresh()
|
|
127
|
+
|
|
128
|
+
assert example.something == "hello"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_primary_key_column():
|
|
132
|
+
assert ExampleRecord.primary_key_column().name == "id"
|
|
133
|
+
assert ExampleWithId.primary_key_column().name == "id"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_one_no_results(create_and_wipe_database):
|
|
137
|
+
record = ExampleRecord()
|
|
138
|
+
# do not save!
|
|
139
|
+
|
|
140
|
+
with pytest.raises(sqlalchemy.exc.NoResultFound):
|
|
141
|
+
ExampleRecord.one(record.id)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_one_single_result(create_and_wipe_database):
|
|
145
|
+
example = ExampleRecord().save()
|
|
146
|
+
result = ExampleRecord.one(example.id)
|
|
147
|
+
|
|
148
|
+
assert result
|
|
149
|
+
assert isinstance(result, ExampleRecord)
|
|
150
|
+
assert result.id == example.id
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_one_multiple_results(create_and_wipe_database):
|
|
154
|
+
# not a pk, but should still throw an error
|
|
155
|
+
example = ExampleRecord(something="hi").save()
|
|
156
|
+
another_example = ExampleRecord(something="hi").save()
|
|
157
|
+
|
|
158
|
+
with pytest.raises(sqlalchemy.exc.MultipleResultsFound):
|
|
159
|
+
ExampleRecord.one(something="hi")
|
activemodel-0.8.0/.tool-versions
DELETED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|