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.
Files changed (64) hide show
  1. {activemodel-0.8.0 → activemodel-0.9.0}/.github/workflows/build_and_publish.yml +14 -14
  2. activemodel-0.9.0/.tool-versions +3 -0
  3. {activemodel-0.8.0 → activemodel-0.9.0}/CHANGELOG.md +19 -0
  4. {activemodel-0.8.0 → activemodel-0.9.0}/Makefile +3 -0
  5. {activemodel-0.8.0 → activemodel-0.9.0}/PKG-INFO +1 -1
  6. {activemodel-0.8.0 → activemodel-0.9.0}/TODO +2 -1
  7. {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/base_model.py +79 -6
  8. {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/mixins/pydantic_json.py +10 -5
  9. {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/pytest/transaction.py +1 -1
  10. {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/types/typeid.py +0 -2
  11. {activemodel-0.8.0 → activemodel-0.9.0}/pyproject.toml +1 -1
  12. activemodel-0.9.0/test/import_test.py +5 -0
  13. {activemodel-0.8.0 → activemodel-0.9.0}/test/models.py +1 -1
  14. activemodel-0.9.0/test/mutation_test.py +35 -0
  15. activemodel-0.8.0/test/serialization_test.py → activemodel-0.9.0/test/nested_pydantic_json_test.py +98 -0
  16. {activemodel-0.8.0 → activemodel-0.9.0}/test/orm_test.py +53 -5
  17. activemodel-0.8.0/.tool-versions +0 -3
  18. {activemodel-0.8.0 → activemodel-0.9.0}/.envrc +0 -0
  19. {activemodel-0.8.0 → activemodel-0.9.0}/.github/dependabot.yml +0 -0
  20. {activemodel-0.8.0 → activemodel-0.9.0}/.github/workflows/repo-sync.yml +0 -0
  21. {activemodel-0.8.0 → activemodel-0.9.0}/.gitignore +0 -0
  22. {activemodel-0.8.0 → activemodel-0.9.0}/.vscode/settings.json +0 -0
  23. {activemodel-0.8.0 → activemodel-0.9.0}/Justfile +0 -0
  24. {activemodel-0.8.0 → activemodel-0.9.0}/LICENSE +0 -0
  25. {activemodel-0.8.0 → activemodel-0.9.0}/README.md +0 -0
  26. {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/__init__.py +0 -0
  27. {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/celery.py +0 -0
  28. {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/errors.py +0 -0
  29. {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/get_column_from_field_patch.py +0 -0
  30. {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/logger.py +0 -0
  31. {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/mixins/__init__.py +0 -0
  32. {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/mixins/soft_delete.py +0 -0
  33. {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/mixins/timestamps.py +0 -0
  34. {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/mixins/typeid.py +0 -0
  35. {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/pytest/__init__.py +0 -0
  36. {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/pytest/truncate.py +0 -0
  37. {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/query_wrapper.py +0 -0
  38. {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/session_manager.py +0 -0
  39. {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/types/__init__.py +0 -0
  40. {activemodel-0.8.0 → activemodel-0.9.0}/activemodel/utils.py +0 -0
  41. {activemodel-0.8.0 → activemodel-0.9.0}/docker-compose.yml +0 -0
  42. {activemodel-0.8.0 → activemodel-0.9.0}/playground/comments.py +0 -0
  43. {activemodel-0.8.0 → activemodel-0.9.0}/playground/env-with-model.patch +0 -0
  44. {activemodel-0.8.0 → activemodel-0.9.0}/playground/extract_comments.py +0 -0
  45. {activemodel-0.8.0 → activemodel-0.9.0}/playground/field.py +0 -0
  46. {activemodel-0.8.0 → activemodel-0.9.0}/playground/middleware.py +0 -0
  47. {activemodel-0.8.0 → activemodel-0.9.0}/playground/old_session_manager.py +0 -0
  48. {activemodel-0.8.0 → activemodel-0.9.0}/playground/pydantic_validation.py +0 -0
  49. {activemodel-0.8.0 → activemodel-0.9.0}/playground.py +0 -0
  50. {activemodel-0.8.0 → activemodel-0.9.0}/test/__init__.py +0 -0
  51. {activemodel-0.8.0 → activemodel-0.9.0}/test/comments_test.py +0 -0
  52. {activemodel-0.8.0 → activemodel-0.9.0}/test/conftest.py +0 -0
  53. {activemodel-0.8.0 → activemodel-0.9.0}/test/delete_test.py +0 -0
  54. {activemodel-0.8.0 → activemodel-0.9.0}/test/fastapi_test.py +0 -0
  55. {activemodel-0.8.0 → activemodel-0.9.0}/test/migrations/README +0 -0
  56. {activemodel-0.8.0 → activemodel-0.9.0}/test/migrations/alembic.ini +0 -0
  57. {activemodel-0.8.0 → activemodel-0.9.0}/test/migrations/env.py +0 -0
  58. {activemodel-0.8.0 → activemodel-0.9.0}/test/migrations/script.py.mako +0 -0
  59. {activemodel-0.8.0 → activemodel-0.9.0}/test/migrations_test.py +0 -0
  60. {activemodel-0.8.0 → activemodel-0.9.0}/test/table_name_test.py +0 -0
  61. {activemodel-0.8.0 → activemodel-0.9.0}/test/test_wrapper.py +0 -0
  62. {activemodel-0.8.0 → activemodel-0.9.0}/test/typeid_test.py +0 -0
  63. {activemodel-0.8.0 → activemodel-0.9.0}/test/utils.py +0 -0
  64. {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: [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,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
 
@@ -2,6 +2,9 @@ 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
+
5
8
  db_open:
6
9
  open -a TablePlus $$DATABASE_URL
7
10
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: activemodel
3
- Version: 0.8.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>
@@ -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 Field, MetaData, Session, SQLModel, select
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 primary_key_field(cls):
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 a kwarg to filter by.
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
- https://github.com/fastapi/sqlmodel/issues/63
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
- setattr(self, field_name, parsed_value)
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
- setattr(self, field_name, model_cls(**raw_value))
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()
@@ -188,5 +188,3 @@ class TypeIDType(types.TypeDecorator):
188
188
  # "minLength": 24,
189
189
  # "maxLength": 24,
190
190
  }
191
-
192
- return core_schema.uuid_schema()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "activemodel"
3
- version = "0.8.0"
3
+ version = "0.9.0"
4
4
  description = "Make SQLModel more like an a real ORM"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -0,0 +1,5 @@
1
+ import activemodel
2
+
3
+
4
+ def test_import() -> None:
5
+ assert isinstance(activemodel.__name__, str)
@@ -1,5 +1,5 @@
1
1
  from pydantic import computed_field
2
- from sqlmodel import Field, Relationship
2
+ from sqlmodel import Column, Field, Integer, Relationship
3
3
 
4
4
  from activemodel import BaseModel
5
5
  from activemodel.mixins import TypeIDMixin
@@ -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")
@@ -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
- from test.models import EXAMPLE_TABLE_PREFIX, AnotherExample, ExampleRecord
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")
@@ -1,3 +0,0 @@
1
- python 3.13.1
2
- uv 0.5.29
3
- direnv 2.35.0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes