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.
Files changed (68) hide show
  1. {activemodel-0.8.0 → activemodel-0.10.0}/.github/workflows/build_and_publish.yml +14 -14
  2. activemodel-0.10.0/.tool-versions +3 -0
  3. {activemodel-0.8.0 → activemodel-0.10.0}/CHANGELOG.md +38 -0
  4. {activemodel-0.8.0 → activemodel-0.10.0}/Makefile +3 -0
  5. {activemodel-0.8.0 → activemodel-0.10.0}/PKG-INFO +1 -1
  6. {activemodel-0.8.0 → activemodel-0.10.0}/TODO +2 -1
  7. {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/base_model.py +128 -9
  8. {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/mixins/pydantic_json.py +10 -5
  9. activemodel-0.10.0/activemodel/mixins/typeid.py +33 -0
  10. {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/pytest/transaction.py +1 -1
  11. {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/session_manager.py +7 -0
  12. {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/types/typeid.py +0 -2
  13. activemodel-0.10.0/playground/alternative_typeid_mixin.py +22 -0
  14. {activemodel-0.8.0 → activemodel-0.10.0}/pyproject.toml +1 -1
  15. activemodel-0.10.0/test/import_test.py +5 -0
  16. {activemodel-0.8.0 → activemodel-0.10.0}/test/models.py +14 -1
  17. activemodel-0.10.0/test/mutation_test.py +35 -0
  18. activemodel-0.8.0/test/serialization_test.py → activemodel-0.10.0/test/nested_pydantic_json_test.py +98 -0
  19. activemodel-0.10.0/test/orm/test_upsert.py +185 -0
  20. {activemodel-0.8.0 → activemodel-0.10.0}/test/orm_test.py +53 -5
  21. activemodel-0.10.0/test/session_manager_test.py +22 -0
  22. {activemodel-0.8.0 → activemodel-0.10.0}/uv.lock +356 -289
  23. activemodel-0.8.0/.tool-versions +0 -3
  24. activemodel-0.8.0/activemodel/mixins/typeid.py +0 -46
  25. {activemodel-0.8.0 → activemodel-0.10.0}/.envrc +0 -0
  26. {activemodel-0.8.0 → activemodel-0.10.0}/.github/dependabot.yml +0 -0
  27. {activemodel-0.8.0 → activemodel-0.10.0}/.github/workflows/repo-sync.yml +0 -0
  28. {activemodel-0.8.0 → activemodel-0.10.0}/.gitignore +0 -0
  29. {activemodel-0.8.0 → activemodel-0.10.0}/.vscode/settings.json +0 -0
  30. {activemodel-0.8.0 → activemodel-0.10.0}/Justfile +0 -0
  31. {activemodel-0.8.0 → activemodel-0.10.0}/LICENSE +0 -0
  32. {activemodel-0.8.0 → activemodel-0.10.0}/README.md +0 -0
  33. {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/__init__.py +0 -0
  34. {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/celery.py +0 -0
  35. {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/errors.py +0 -0
  36. {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/get_column_from_field_patch.py +0 -0
  37. {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/logger.py +0 -0
  38. {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/mixins/__init__.py +0 -0
  39. {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/mixins/soft_delete.py +0 -0
  40. {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/mixins/timestamps.py +0 -0
  41. {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/pytest/__init__.py +0 -0
  42. {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/pytest/truncate.py +0 -0
  43. {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/query_wrapper.py +0 -0
  44. {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/types/__init__.py +0 -0
  45. {activemodel-0.8.0 → activemodel-0.10.0}/activemodel/utils.py +0 -0
  46. {activemodel-0.8.0 → activemodel-0.10.0}/docker-compose.yml +0 -0
  47. {activemodel-0.8.0 → activemodel-0.10.0}/playground/comments.py +0 -0
  48. {activemodel-0.8.0 → activemodel-0.10.0}/playground/env-with-model.patch +0 -0
  49. {activemodel-0.8.0 → activemodel-0.10.0}/playground/extract_comments.py +0 -0
  50. {activemodel-0.8.0 → activemodel-0.10.0}/playground/field.py +0 -0
  51. {activemodel-0.8.0 → activemodel-0.10.0}/playground/middleware.py +0 -0
  52. {activemodel-0.8.0 → activemodel-0.10.0}/playground/old_session_manager.py +0 -0
  53. {activemodel-0.8.0 → activemodel-0.10.0}/playground/pydantic_validation.py +0 -0
  54. {activemodel-0.8.0 → activemodel-0.10.0}/playground.py +0 -0
  55. {activemodel-0.8.0 → activemodel-0.10.0}/test/__init__.py +0 -0
  56. {activemodel-0.8.0 → activemodel-0.10.0}/test/comments_test.py +0 -0
  57. {activemodel-0.8.0 → activemodel-0.10.0}/test/conftest.py +0 -0
  58. {activemodel-0.8.0 → activemodel-0.10.0}/test/delete_test.py +0 -0
  59. {activemodel-0.8.0 → activemodel-0.10.0}/test/fastapi_test.py +0 -0
  60. {activemodel-0.8.0 → activemodel-0.10.0}/test/migrations/README +0 -0
  61. {activemodel-0.8.0 → activemodel-0.10.0}/test/migrations/alembic.ini +0 -0
  62. {activemodel-0.8.0 → activemodel-0.10.0}/test/migrations/env.py +0 -0
  63. {activemodel-0.8.0 → activemodel-0.10.0}/test/migrations/script.py.mako +0 -0
  64. {activemodel-0.8.0 → activemodel-0.10.0}/test/migrations_test.py +0 -0
  65. {activemodel-0.8.0 → activemodel-0.10.0}/test/table_name_test.py +0 -0
  66. {activemodel-0.8.0 → activemodel-0.10.0}/test/test_wrapper.py +0 -0
  67. {activemodel-0.8.0 → activemodel-0.10.0}/test/typeid_test.py +0 -0
  68. {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: [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,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
 
@@ -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.10.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
@@ -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 sqlmodel import Field, MetaData, Session, SQLModel, select
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("Unexpected __table_args__ type")
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
- >>> other_model_id: int
166
- >>> other_model = OtherModel.foreign_key()
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 primary_key_field(cls):
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 a kwarg to filter by.
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
- 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))
@@ -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
 
@@ -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()
@@ -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,6 +1,6 @@
1
1
  [project]
2
2
  name = "activemodel"
3
- version = "0.8.0"
3
+ version = "0.10.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,6 @@
1
1
  from pydantic import computed_field
2
- from sqlmodel import Field, Relationship
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")