activemodel 0.7.0__tar.gz → 0.8.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 (63) hide show
  1. {activemodel-0.7.0 → activemodel-0.8.0}/.envrc +2 -0
  2. {activemodel-0.7.0 → activemodel-0.8.0}/CHANGELOG.md +22 -0
  3. {activemodel-0.7.0 → activemodel-0.8.0}/Makefile +3 -0
  4. {activemodel-0.7.0 → activemodel-0.8.0}/PKG-INFO +51 -4
  5. {activemodel-0.7.0 → activemodel-0.8.0}/README.md +50 -3
  6. {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/base_model.py +37 -5
  7. {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/celery.py +6 -1
  8. {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/get_column_from_field_patch.py +2 -0
  9. {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/mixins/pydantic_json.py +15 -2
  10. activemodel-0.8.0/activemodel/pytest/transaction.py +63 -0
  11. {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/session_manager.py +18 -1
  12. {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/types/typeid.py +1 -0
  13. activemodel-0.8.0/playground/extract_comments.py +33 -0
  14. {activemodel-0.7.0 → activemodel-0.8.0}/pyproject.toml +1 -1
  15. {activemodel-0.7.0 → activemodel-0.8.0}/test/models.py +1 -1
  16. activemodel-0.8.0/test/orm_test.py +111 -0
  17. {activemodel-0.7.0 → activemodel-0.8.0}/test/serialization_test.py +19 -11
  18. {activemodel-0.7.0 → activemodel-0.8.0}/test/typeid_test.py +12 -9
  19. activemodel-0.7.0/activemodel/pytest/transaction.py +0 -51
  20. activemodel-0.7.0/test/orm_test.py +0 -51
  21. {activemodel-0.7.0 → activemodel-0.8.0}/.github/dependabot.yml +0 -0
  22. {activemodel-0.7.0 → activemodel-0.8.0}/.github/workflows/build_and_publish.yml +0 -0
  23. {activemodel-0.7.0 → activemodel-0.8.0}/.github/workflows/repo-sync.yml +0 -0
  24. {activemodel-0.7.0 → activemodel-0.8.0}/.gitignore +0 -0
  25. {activemodel-0.7.0 → activemodel-0.8.0}/.tool-versions +0 -0
  26. {activemodel-0.7.0 → activemodel-0.8.0}/.vscode/settings.json +0 -0
  27. {activemodel-0.7.0 → activemodel-0.8.0}/Justfile +0 -0
  28. {activemodel-0.7.0 → activemodel-0.8.0}/LICENSE +0 -0
  29. {activemodel-0.7.0 → activemodel-0.8.0}/TODO +0 -0
  30. {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/__init__.py +0 -0
  31. {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/errors.py +0 -0
  32. {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/logger.py +0 -0
  33. {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/mixins/__init__.py +0 -0
  34. {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/mixins/soft_delete.py +0 -0
  35. {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/mixins/timestamps.py +0 -0
  36. {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/mixins/typeid.py +0 -0
  37. {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/pytest/__init__.py +0 -0
  38. {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/pytest/truncate.py +0 -0
  39. {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/query_wrapper.py +0 -0
  40. {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/types/__init__.py +0 -0
  41. {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/utils.py +0 -0
  42. {activemodel-0.7.0 → activemodel-0.8.0}/docker-compose.yml +0 -0
  43. {activemodel-0.7.0 → activemodel-0.8.0}/playground/comments.py +0 -0
  44. {activemodel-0.7.0 → activemodel-0.8.0}/playground/env-with-model.patch +0 -0
  45. {activemodel-0.7.0 → activemodel-0.8.0}/playground/field.py +0 -0
  46. {activemodel-0.7.0 → activemodel-0.8.0}/playground/middleware.py +0 -0
  47. {activemodel-0.7.0 → activemodel-0.8.0}/playground/old_session_manager.py +0 -0
  48. {activemodel-0.7.0 → activemodel-0.8.0}/playground/pydantic_validation.py +0 -0
  49. {activemodel-0.7.0 → activemodel-0.8.0}/playground.py +0 -0
  50. {activemodel-0.7.0 → activemodel-0.8.0}/test/__init__.py +0 -0
  51. {activemodel-0.7.0 → activemodel-0.8.0}/test/comments_test.py +0 -0
  52. {activemodel-0.7.0 → activemodel-0.8.0}/test/conftest.py +0 -0
  53. {activemodel-0.7.0 → activemodel-0.8.0}/test/delete_test.py +0 -0
  54. {activemodel-0.7.0 → activemodel-0.8.0}/test/fastapi_test.py +0 -0
  55. {activemodel-0.7.0 → activemodel-0.8.0}/test/migrations/README +0 -0
  56. {activemodel-0.7.0 → activemodel-0.8.0}/test/migrations/alembic.ini +0 -0
  57. {activemodel-0.7.0 → activemodel-0.8.0}/test/migrations/env.py +0 -0
  58. {activemodel-0.7.0 → activemodel-0.8.0}/test/migrations/script.py.mako +0 -0
  59. {activemodel-0.7.0 → activemodel-0.8.0}/test/migrations_test.py +0 -0
  60. {activemodel-0.7.0 → activemodel-0.8.0}/test/table_name_test.py +0 -0
  61. {activemodel-0.7.0 → activemodel-0.8.0}/test/test_wrapper.py +0 -0
  62. {activemodel-0.7.0 → activemodel-0.8.0}/test/utils.py +0 -0
  63. {activemodel-0.7.0 → activemodel-0.8.0}/uv.lock +0 -0
@@ -10,3 +10,5 @@ export POSTGRES_DB=development
10
10
  export DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${DATABASE_HOST}:5432/development
11
11
 
12
12
  export PYTHONBREAKPOINT=ipdb.set_trace
13
+
14
+ # export ACTIVEMODEL_LOG_SQL=true
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.0](https://github.com/iloveitaly/activemodel/compare/v0.7.0...v0.8.0) (2025-03-18)
4
+
5
+
6
+ ### Features
7
+
8
+ * add BaseModel.where method and update test cases ([9fe4c5a](https://github.com/iloveitaly/activemodel/commit/9fe4c5af619690ffb6344cf5c74a2d4b2b46ef02))
9
+ * add primary_key_field ([947a410](https://github.com/iloveitaly/activemodel/commit/947a410766dd764e8ac5b3177152d2ff22cdb609))
10
+ * log start of database transaction in tests ([ac90d6f](https://github.com/iloveitaly/activemodel/commit/ac90d6f18bb0a4cca68527dec9c55b4af1f6e851))
11
+ * reload json fields when record is reloaded from db ([c013082](https://github.com/iloveitaly/activemodel/commit/c013082004bb3a93e81f00eb0c990833a9cae7e2))
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * yield session object in global_session function ([47b33cc](https://github.com/iloveitaly/activemodel/commit/47b33cc1aa66d6076363635191c297c52fcc3deb))
17
+
18
+
19
+ ### Documentation
20
+
21
+ * add comments to clarify SessionManager use and config ([e73561b](https://github.com/iloveitaly/activemodel/commit/e73561b3d52e617a0f92da5cd93981ff429da16f))
22
+ * update comments and README with additional examples and info ([209ee36](https://github.com/iloveitaly/activemodel/commit/209ee36a9df53f927cd9e5b2bb15b3a5776b34ce))
23
+ * update README with setup instructions and SQLModel tips ([f2520b5](https://github.com/iloveitaly/activemodel/commit/f2520b5fa5d7c462e8f7a591b83d874239a34b8d))
24
+
3
25
  ## [0.7.0](https://github.com/iloveitaly/activemodel/compare/v0.6.0...v0.7.0) (2025-02-08)
4
26
 
5
27
 
@@ -2,6 +2,9 @@ setup:
2
2
  uv venv && uv sync
3
3
  @echo "activate: source ./.venv/bin/activate"
4
4
 
5
+ db_open:
6
+ open -a TablePlus $$DATABASE_URL
7
+
5
8
  clean:
6
9
  rm -rf *.egg-info
7
10
  rm -rf .venv
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: activemodel
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Make SQLModel more like an a real ORM
5
5
  Project-URL: Repository, https://github.com/iloveitaly/activemodel
6
6
  Author-email: Michael Bianco <iloveitaly@gmail.com>
@@ -29,10 +29,11 @@ This package provides a thin wrapper around SQLModel that provides a more Active
29
29
  First, setup your DB:
30
30
 
31
31
  ```python
32
-
32
+ import activemodel
33
+ activemodel.init("sqlite:///database.db")
33
34
  ```
34
35
 
35
- Then, setup some models:
36
+ Create models:
36
37
 
37
38
  ```python
38
39
  from activemodel import BaseModel
@@ -51,6 +52,38 @@ class User(
51
52
  a_field: str
52
53
  ```
53
54
 
55
+ You'll need to create the models in the DB. Alembic is the best way to do it, but you can cheat as well:
56
+
57
+ ```python
58
+ from sqlmodel import SQLModel
59
+
60
+ SQLModel.metadata.create_all(get_engine())
61
+
62
+ # now you can create a user!
63
+ User(a_field="a").save()
64
+ ```
65
+
66
+ Maybe you like JSON:
67
+
68
+ ```python
69
+ from activemodel import BaseModel
70
+ from pydantic import BaseModel as PydanticBaseModel
71
+ from activemodel.mixins import PydanticJSONMixin, TypeIDMixin, TimestampsMixin
72
+
73
+ class SubObject(PydanticBaseModel):
74
+ name: str
75
+ value: int
76
+
77
+ class User(
78
+ BaseModel,
79
+ TimestampsMixin,
80
+ PydanticJSONMixin,
81
+ TypeIDMixin("user"),
82
+ table=True
83
+ ):
84
+ list_field: list[SubObject] = Field(sa_type=JSONB())
85
+ ```
86
+
54
87
  ## Usage
55
88
 
56
89
  ### Integrating Alembic
@@ -60,6 +93,7 @@ class User(
60
93
  * To import all of your models you want in your DB. [Here's my recommended way to do this.](https://github.com/iloveitaly/python-starter-template/blob/master/app/models/__init__.py)
61
94
  * Use your DB URL from the ENV
62
95
  * Target sqlalchemy metadata to the sqlmodel-generated metadata
96
+ * Most likely you'll want to add [alembic-postgresql-enum](https://pypi.org/project/alembic-postgresql-enum/) so migrations work properly
63
97
 
64
98
  [Take a look at these scripts for an example of how to fully integrate Alembic into your development workflow.](https://github.com/iloveitaly/python-starter-template/blob/0af2c7e95217e34bde7357cc95be048900000e48/Justfile#L618-L712)
65
99
 
@@ -161,6 +195,15 @@ https://github.com/tomwojcik/starlette-context
161
195
  * Conditional: `Scrape.select().where(Scrape.id < last_scraped.id).all()`
162
196
  * Equality: `MenuItem.select().where(MenuItem.menu_id == menu.id).all()`
163
197
  * `IN` example: `CanonicalMenuItem.select().where(col(CanonicalMenuItem.id).in_(canonized_ids)).all()`
198
+ * Compound where query: `User.where((User.last_active_at != None) & (User.last_active_at > last_24_hours)).count()`
199
+
200
+ ### SQLModel Internals
201
+
202
+ SQLModel & SQLAlchemy are tricky. Here are some useful internal tricks:
203
+
204
+ * `__sqlmodel_relationships__` is where any `RelationshipInfo` objects are stored. This is used to generate relationship fields on the object.
205
+ * `ModelClass.relationship_name.property.local_columns`
206
+ * Get cached fields from a model `object_state(instance).dict.get(field_name)`
164
207
 
165
208
  ### TypeID
166
209
 
@@ -186,7 +229,7 @@ class Appointment(
186
229
  TypeIDMixin("appointment"),
187
230
  table=True
188
231
  ):
189
- # `foreign_key` is a activemodel-specific method to generate the right `Field` for the relationship
232
+ # `foreign_key` is a activemodel method to generate the right `Field` for the relationship
190
233
  # TypeIDType is really important here for fastapi serialization
191
234
  doctor_id: TypeIDType = Doctor.foreign_key()
192
235
  doctor: Doctor = Relationship()
@@ -233,3 +276,7 @@ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d
233
276
  * https://github.com/fastapi/full-stack-fastapi-template
234
277
  * https://github.com/DarylStark/my_data/
235
278
  * https://github.com/petrgazarov/FastAPI-app/tree/main/fastapi_app
279
+
280
+ ## Upstream Changes
281
+
282
+ - [ ] https://github.com/fastapi/sqlmodel/pull/1293
@@ -14,10 +14,11 @@ This package provides a thin wrapper around SQLModel that provides a more Active
14
14
  First, setup your DB:
15
15
 
16
16
  ```python
17
-
17
+ import activemodel
18
+ activemodel.init("sqlite:///database.db")
18
19
  ```
19
20
 
20
- Then, setup some models:
21
+ Create models:
21
22
 
22
23
  ```python
23
24
  from activemodel import BaseModel
@@ -36,6 +37,38 @@ class User(
36
37
  a_field: str
37
38
  ```
38
39
 
40
+ You'll need to create the models in the DB. Alembic is the best way to do it, but you can cheat as well:
41
+
42
+ ```python
43
+ from sqlmodel import SQLModel
44
+
45
+ SQLModel.metadata.create_all(get_engine())
46
+
47
+ # now you can create a user!
48
+ User(a_field="a").save()
49
+ ```
50
+
51
+ Maybe you like JSON:
52
+
53
+ ```python
54
+ from activemodel import BaseModel
55
+ from pydantic import BaseModel as PydanticBaseModel
56
+ from activemodel.mixins import PydanticJSONMixin, TypeIDMixin, TimestampsMixin
57
+
58
+ class SubObject(PydanticBaseModel):
59
+ name: str
60
+ value: int
61
+
62
+ class User(
63
+ BaseModel,
64
+ TimestampsMixin,
65
+ PydanticJSONMixin,
66
+ TypeIDMixin("user"),
67
+ table=True
68
+ ):
69
+ list_field: list[SubObject] = Field(sa_type=JSONB())
70
+ ```
71
+
39
72
  ## Usage
40
73
 
41
74
  ### Integrating Alembic
@@ -45,6 +78,7 @@ class User(
45
78
  * To import all of your models you want in your DB. [Here's my recommended way to do this.](https://github.com/iloveitaly/python-starter-template/blob/master/app/models/__init__.py)
46
79
  * Use your DB URL from the ENV
47
80
  * Target sqlalchemy metadata to the sqlmodel-generated metadata
81
+ * Most likely you'll want to add [alembic-postgresql-enum](https://pypi.org/project/alembic-postgresql-enum/) so migrations work properly
48
82
 
49
83
  [Take a look at these scripts for an example of how to fully integrate Alembic into your development workflow.](https://github.com/iloveitaly/python-starter-template/blob/0af2c7e95217e34bde7357cc95be048900000e48/Justfile#L618-L712)
50
84
 
@@ -146,6 +180,15 @@ https://github.com/tomwojcik/starlette-context
146
180
  * Conditional: `Scrape.select().where(Scrape.id < last_scraped.id).all()`
147
181
  * Equality: `MenuItem.select().where(MenuItem.menu_id == menu.id).all()`
148
182
  * `IN` example: `CanonicalMenuItem.select().where(col(CanonicalMenuItem.id).in_(canonized_ids)).all()`
183
+ * Compound where query: `User.where((User.last_active_at != None) & (User.last_active_at > last_24_hours)).count()`
184
+
185
+ ### SQLModel Internals
186
+
187
+ SQLModel & SQLAlchemy are tricky. Here are some useful internal tricks:
188
+
189
+ * `__sqlmodel_relationships__` is where any `RelationshipInfo` objects are stored. This is used to generate relationship fields on the object.
190
+ * `ModelClass.relationship_name.property.local_columns`
191
+ * Get cached fields from a model `object_state(instance).dict.get(field_name)`
149
192
 
150
193
  ### TypeID
151
194
 
@@ -171,7 +214,7 @@ class Appointment(
171
214
  TypeIDMixin("appointment"),
172
215
  table=True
173
216
  ):
174
- # `foreign_key` is a activemodel-specific method to generate the right `Field` for the relationship
217
+ # `foreign_key` is a activemodel method to generate the right `Field` for the relationship
175
218
  # TypeIDType is really important here for fastapi serialization
176
219
  doctor_id: TypeIDType = Doctor.foreign_key()
177
220
  doctor: Doctor = Relationship()
@@ -218,3 +261,7 @@ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d
218
261
  * https://github.com/fastapi/full-stack-fastapi-template
219
262
  * https://github.com/DarylStark/my_data/
220
263
  * https://github.com/petrgazarov/FastAPI-app/tree/main/fastapi_app
264
+
265
+ ## Upstream Changes
266
+
267
+ - [ ] https://github.com/fastapi/sqlmodel/pull/1293
@@ -9,6 +9,9 @@ from sqlalchemy import Connection, event
9
9
  from sqlalchemy.orm import Mapper, declared_attr
10
10
  from sqlmodel import Field, MetaData, Session, SQLModel, select
11
11
  from typeid import TypeID
12
+ from inspect import isclass
13
+
14
+ from activemodel.mixins.pydantic_json import PydanticJSONMixin
12
15
 
13
16
  # NOTE: this patches a core method in sqlmodel to support db comments
14
17
  from . import get_column_from_field_patch # noqa: F401
@@ -157,7 +160,10 @@ class BaseModel(SQLModel):
157
160
  @classmethod
158
161
  def foreign_key(cls, **kwargs):
159
162
  """
160
- Returns a Field object referencing the foreign key of the model.
163
+ Returns a `Field` object referencing the foreign key of the model.
164
+
165
+ >>> other_model_id: int
166
+ >>> other_model = OtherModel.foreign_key()
161
167
  """
162
168
 
163
169
  field_options = {"nullable": False} | kwargs
@@ -174,6 +180,11 @@ class BaseModel(SQLModel):
174
180
  "create a query wrapper to easily run sqlmodel queries on this model"
175
181
  return QueryWrapper[cls](cls, *args)
176
182
 
183
+ @classmethod
184
+ def where(cls, *args):
185
+ "convenience method to avoid having to write .select().where() in order to add conditions"
186
+ return cls.select().where(*args)
187
+
177
188
  def delete(self):
178
189
  with get_session() as session:
179
190
  if old_session := Session.object_session(self):
@@ -197,11 +208,11 @@ class BaseModel(SQLModel):
197
208
  session.commit()
198
209
  session.refresh(self)
199
210
 
200
- return self
211
+ # Only call the transform method if the class is a subclass of PydanticJSONMixin
212
+ if issubclass(self.__class__, PydanticJSONMixin):
213
+ self.__class__.__transform_dict_to_pydantic__(self)
201
214
 
202
- # except IntegrityError:
203
- # log.quiet(f"{self} already exists in the database.")
204
- # session.rollback()
215
+ return self
205
216
 
206
217
  # TODO shouldn't this be handled by pydantic?
207
218
  def json(self, **kwargs):
@@ -256,6 +267,27 @@ class BaseModel(SQLModel):
256
267
  new_model = cls(**kwargs)
257
268
  return new_model
258
269
 
270
+ @classmethod
271
+ def primary_key_field(cls):
272
+ """
273
+ Returns the primary key column of the model by inspecting SQLAlchemy field information.
274
+
275
+ >>> ExampleModel.primary_key_field().name
276
+ """
277
+ # TODO note_schema.__class__.__table__.primary_key
278
+
279
+ pk_columns = list(cls.__table__.primary_key.columns)
280
+
281
+ if not pk_columns:
282
+ raise ValueError("No primary key defined for the model.")
283
+
284
+ if len(pk_columns) > 1:
285
+ raise ValueError(
286
+ "Multiple primary keys defined. This method supports only single primary key models."
287
+ )
288
+
289
+ return pk_columns[0]
290
+
259
291
  # TODO what's super dangerous here is you pass a kwarg which does not map to a specific
260
292
  # field it will result in `True`, which will return all records, and not give you any typing
261
293
  # errors. Dangerous when iterating on structure quickly
@@ -4,12 +4,17 @@ Do not import unless you have Celery/Kombu installed.
4
4
  In order for TypeID objects to be properly handled by celery, a custom encoder must be registered.
5
5
  """
6
6
 
7
+ # this is not an explicit dependency, only import this file if you have Celery installed
7
8
  from kombu.utils.json import register_type
8
9
  from typeid import TypeID
9
10
 
10
11
 
11
12
  def register_celery_typeid_encoder():
12
- "this ensures TypeID objects passed as arguments to a delayed function are properly serialized"
13
+ """
14
+ Ensures TypeID objects passed as arguments to a delayed function are properly serialized.
15
+
16
+ Run at the top of your celery initialization script.
17
+ """
13
18
 
14
19
  def class_full_name(clz) -> str:
15
20
  return ".".join([clz.__module__, clz.__qualname__])
@@ -6,6 +6,8 @@ Making sure these docstrings make their way to the DB schema is helpful for a bu
6
6
  This patch mutates a core sqlmodel function which translates pydantic FieldInfo objects into sqlalchemy Column objects. It adds the field description as a comment to the column.
7
7
 
8
8
  Note that FieldInfo *from pydantic* is used when a "bare" field is defined. This can be confusing, because when inspecting model fields, the class name looks exactly the same.
9
+
10
+ Some ideas for this originally sourced from: https://github.com/fastapi/sqlmodel/issues/492#issuecomment-2489858633
9
11
  """
10
12
 
11
13
  from typing import (
@@ -1,3 +1,7 @@
1
+ """
2
+ https://github.com/fastapi/sqlmodel/issues/63
3
+ """
4
+
1
5
  from types import UnionType
2
6
  from typing import get_args, get_origin
3
7
 
@@ -9,11 +13,20 @@ class PydanticJSONMixin:
9
13
  """
10
14
  By default, SQLModel does not convert JSONB columns into pydantic models when they are loaded from the database.
11
15
 
12
- This mixin, combined with a custom serializer, fixes that issue.
16
+ This mixin, combined with a custom serializer (`_serialize_pydantic_model`), fixes that issue.
17
+
18
+ >>> class ExampleWithJSON(BaseModel, PydanticJSONMixin, table=True):
19
+ >>> list_field: list[SubObject] = Field(sa_type=JSONB()
13
20
  """
14
21
 
15
22
  @reconstructor
16
- def init_on_load(self):
23
+ def __transform_dict_to_pydantic__(self):
24
+ """
25
+ Transforms dictionary fields into Pydantic models upon loading.
26
+
27
+ - Reconstructor only runs once, when the object is loaded.
28
+ - We manually call this method on save(), etc to ensure the pydantic types are maintained
29
+ """
17
30
  # TODO do we need to inspect sa_type
18
31
  for field_name, field_info in self.model_fields.items():
19
32
  raw_value = getattr(self, field_name, None)
@@ -0,0 +1,63 @@
1
+ from activemodel import SessionManager
2
+
3
+ from ..logger import logger
4
+
5
+
6
+ def database_reset_transaction():
7
+ """
8
+ Wrap all database interactions for a given test in a nested transaction and roll it back after the test.
9
+
10
+ >>> from activemodel.pytest import database_reset_transaction
11
+ >>> database_reset_transaction = pytest.fixture(scope="function", autouse=True)(database_reset_transaction)
12
+
13
+ Transaction-based DB cleaning does *not* work if the DB mutations are happening in a separate process, which should
14
+ use spawn, because the same session is not shared across processes. Note that using `fork` is dangerous.
15
+
16
+ In this case, you should use the truncate.
17
+
18
+ References:
19
+
20
+ - https://stackoverflow.com/questions/62433018/how-to-make-sqlalchemy-transaction-rollback-drop-tables-it-created
21
+ - https://aalvarez.me/posts/setting-up-a-sqlalchemy-and-pytest-based-test-suite/
22
+ - https://github.com/nickjj/docker-flask-example/blob/93af9f4fbf185098ffb1d120ee0693abcd77a38b/test/conftest.py#L77
23
+ - https://github.com/caiola/vinhos.com/blob/c47d0a5d7a4bf290c1b726561d1e8f5d2ac29bc8/backend/test/conftest.py#L46
24
+ - https://stackoverflow.com/questions/64095876/multiprocessing-fork-vs-spawn
25
+
26
+ Using a named SAVEPOINT does not give us anything extra, so we are not using it.
27
+ """
28
+
29
+ engine = SessionManager.get_instance().get_engine()
30
+
31
+ logger.info("starting database transaction")
32
+
33
+ with engine.begin() as connection:
34
+ transaction = connection.begin_nested()
35
+
36
+ if SessionManager.get_instance().session_connection is not None:
37
+ logger.warning("session override already exists")
38
+ # TODO should we throw an exception here?
39
+
40
+ SessionManager.get_instance().session_connection = connection
41
+
42
+ try:
43
+ with SessionManager.get_instance().get_session() as factory_session:
44
+ try:
45
+ from factory.alchemy import SQLAlchemyModelFactory
46
+
47
+ # Ensure that all factories use the same session
48
+ for factory in SQLAlchemyModelFactory.__subclasses__():
49
+ factory._meta.sqlalchemy_session = factory_session
50
+ factory._meta.sqlalchemy_session_persistence = "commit"
51
+ except ImportError:
52
+ pass
53
+
54
+ yield
55
+ finally:
56
+ logger.debug("rolling back transaction")
57
+
58
+ transaction.rollback()
59
+
60
+ # TODO is this necessary? unclear
61
+ connection.close()
62
+
63
+ SessionManager.get_instance().session_connection = None
@@ -44,6 +44,7 @@ def _serialize_pydantic_model(model: BaseModel | list[BaseModel] | None) -> str
44
44
 
45
45
  class SessionManager:
46
46
  _instance: t.ClassVar[t.Optional["SessionManager"]] = None
47
+ "singleton instance of SessionManager"
47
48
 
48
49
  session_connection: Connection | None
49
50
  "optionally specify a specific session connection to use for all get_session() calls, useful for testing"
@@ -69,6 +70,7 @@ class SessionManager:
69
70
  if not self._engine:
70
71
  self._engine = create_engine(
71
72
  self._database_url,
73
+ # NOTE very important! This enables pydantic models to be serialized for JSONB columns
72
74
  json_serializer=_serialize_pydantic_model,
73
75
  echo=config("ACTIVEMODEL_LOG_SQL", cast=bool, default=False),
74
76
  # https://docs.sqlalchemy.org/en/20/core/pooling.html#disconnect-handling-pessimistic
@@ -87,6 +89,7 @@ class SessionManager:
87
89
 
88
90
  return _reuse_session()
89
91
 
92
+ # a connection can generate nested transactions
90
93
  if self.session_connection:
91
94
  return Session(bind=self.session_connection)
92
95
 
@@ -94,6 +97,7 @@ class SessionManager:
94
97
 
95
98
 
96
99
  def init(database_url: str):
100
+ "configure activemodel to connect to a specific database"
97
101
  return SessionManager.get_instance(database_url)
98
102
 
99
103
 
@@ -106,6 +110,8 @@ def get_session():
106
110
 
107
111
 
108
112
  # contextvars must be at the top-level of a module! You will not get a warning if you don't do this.
113
+ # ContextVar is implemented in C, so it's very special and is both thread-safe and asyncio safe. This variable gives us
114
+ # a place to persist a session to use globally across the application.
109
115
  _session_context = contextvars.ContextVar[Session | None](
110
116
  "session_context", default=None
111
117
  )
@@ -117,12 +123,23 @@ def global_session():
117
123
  token = _session_context.set(s)
118
124
 
119
125
  try:
120
- yield
126
+ yield s
121
127
  finally:
122
128
  _session_context.reset(token)
123
129
 
124
130
 
125
131
  async def aglobal_session():
132
+ """
133
+ Use this as a fastapi dependency to get a session that is shared across the request:
134
+
135
+ >>> APIRouter(
136
+ >>> prefix="/internal/v1",
137
+ >>> dependencies=[
138
+ >>> Depends(aglobal_session),
139
+ >>> ]
140
+ >>> )
141
+ """
142
+
126
143
  with SessionManager.get_instance().get_session() as s:
127
144
  token = _session_context.set(s)
128
145
 
@@ -137,6 +137,7 @@ class TypeIDType(types.TypeDecorator):
137
137
 
138
138
  return core_schema.json_or_python_schema(
139
139
  json_schema=from_uuid_schema,
140
+ # TODO in the the future we could add more exact types
140
141
  # metadata=core_schema.str_schema(
141
142
  # pattern="^[0-9a-f]{24}$",
142
143
  # min_length=24,
@@ -0,0 +1,33 @@
1
+ import sqlalchemy as sa
2
+ from sqlmodel import SQLModel, create_engine, Session
3
+
4
+
5
+ def extract_comments(engine):
6
+ comments = {}
7
+ # Reflect all tables if needed; otherwise, rely on model metadata
8
+ for model in SQLModel.__subclasses__():
9
+ table = model.__table__
10
+ # Retrieve table-level comment
11
+ table_comment = table.comment
12
+ # Retrieve comments for each column
13
+ column_comments = {
14
+ col.name: col.comment for col in table.columns if col.comment
15
+ }
16
+ comments[table.name] = {
17
+ "table_comment": table_comment,
18
+ "column_comments": column_comments,
19
+ }
20
+ return comments
21
+
22
+
23
+ if __name__ == "__main__":
24
+ # Adjust your connection string accordingly
25
+ engine = create_engine("sqlite:///database.db")
26
+ with Session(engine) as session:
27
+ comments = extract_comments(engine)
28
+ for table, data in comments.items():
29
+ print(f"Table: {table}")
30
+ print(f" - Table Comment: {data['table_comment']}")
31
+ print(" - Column Comments:")
32
+ for col, comment in data["column_comments"].items():
33
+ print(f" {col}: {comment}")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "activemodel"
3
- version = "0.7.0"
3
+ version = "0.8.0"
4
4
  description = "Make SQLModel more like an a real ORM"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -15,7 +15,7 @@ class ExampleRecord(
15
15
  BaseModel, TimestampsMixin, TypeIDMixin(EXAMPLE_TABLE_PREFIX), table=True
16
16
  ):
17
17
  something: str | None = None
18
- another_with_index: str | None = Field(index=True)
18
+ another_with_index: str | None = Field(index=True, default=None, unique=True)
19
19
 
20
20
 
21
21
  class AnotherExample(BaseModel, TypeIDMixin("myotherid"), table=True):
@@ -0,0 +1,111 @@
1
+ """
2
+ Test core ORM functions
3
+ """
4
+
5
+ from test.models import EXAMPLE_TABLE_PREFIX, AnotherExample, ExampleRecord
6
+
7
+
8
+ def test_empty_count(create_and_wipe_database):
9
+ assert ExampleRecord.count() == 0
10
+
11
+
12
+ def test_all_and_count(create_and_wipe_database):
13
+ AnotherExample().save()
14
+
15
+ records_to_create = 10
16
+
17
+ # create 10 example records
18
+ for i in range(records_to_create):
19
+ ExampleRecord().save()
20
+
21
+ assert ExampleRecord.count() == records_to_create
22
+
23
+ all_records = list(ExampleRecord.all())
24
+ assert len(all_records) == records_to_create
25
+
26
+ assert ExampleRecord.count() == records_to_create
27
+
28
+ record = all_records[0]
29
+ assert isinstance(record, ExampleRecord)
30
+
31
+
32
+ def test_where_returns_expected(create_and_wipe_database):
33
+ # Create records with distinct "something" field values
34
+ ExampleRecord(something="hello").save()
35
+ ExampleRecord(something="world").save()
36
+ ExampleRecord(something="hello").save()
37
+
38
+ # Use the "where" convenience method to filter records
39
+ results = list(ExampleRecord.where(ExampleRecord.something == "hello").all())
40
+
41
+ # Expecting 2 records that match "hello"
42
+ assert len(results) == 2
43
+ for record in results:
44
+ assert record.something == "hello"
45
+
46
+
47
+ def test_where_no_results(create_and_wipe_database):
48
+ # Create a record with a specific value
49
+ ExampleRecord(something="foo").save()
50
+
51
+ # Filter by a value that does not exist to get no results
52
+ result = ExampleRecord.where(ExampleRecord.something == "bar").first()
53
+ assert result is None
54
+
55
+
56
+ def test_where_chaining(create_and_wipe_database):
57
+ # Save multiple records; using the same condition twice should be harmless
58
+ ExampleRecord(something="chain").save()
59
+ ExampleRecord(something="chain").save()
60
+ ExampleRecord(something="other").save()
61
+
62
+ # Chain where calls; in our implementation, chaining should work the same as a single call
63
+ query = (
64
+ ExampleRecord.where(ExampleRecord.something == "chain")
65
+ .where(ExampleRecord.something == "chain")
66
+ .all()
67
+ )
68
+ results = list(query)
69
+
70
+ # Expecting 2 records that match "chain" even after chaining the condition
71
+ assert len(results) == 2
72
+ for record in results:
73
+ assert record.something == "chain"
74
+
75
+
76
+ def test_foreign_key():
77
+ field = ExampleRecord.foreign_key()
78
+ assert field.sa_type.prefix == EXAMPLE_TABLE_PREFIX
79
+
80
+
81
+ def test_basic_query(create_and_wipe_database):
82
+ example = ExampleRecord(something="hi").save()
83
+ query = ExampleRecord.select().where(ExampleRecord.something == "hi")
84
+
85
+ query_as_str = str(query)
86
+ result = query.first()
87
+
88
+
89
+ def test_query_count(create_and_wipe_database):
90
+ AnotherExample().save()
91
+
92
+ example = ExampleRecord(something="hi").save()
93
+ count = ExampleRecord.select().where(ExampleRecord.something == "hi").count()
94
+
95
+ assert count == 1
96
+
97
+
98
+ def test_primary_key(create_and_wipe_database):
99
+ assert ExampleRecord.primary_key_field().name == "id"
100
+
101
+
102
+ def test_get_non_pk(create_and_wipe_database):
103
+ # some paranoid checks here as I attempt to debug the issue
104
+ example = ExampleRecord(something="hi", another_with_index="key_123").save()
105
+
106
+ assert ExampleRecord.count() == 1
107
+
108
+ retrieved_example = ExampleRecord.find_or_create_by(another_with_index="key_123")
109
+
110
+ assert retrieved_example
111
+ assert retrieved_example.id == example.id
@@ -17,24 +17,25 @@ class SubObject(PydanticBaseModel):
17
17
  value: int
18
18
 
19
19
 
20
- class ExampleWithJSON(
20
+ class ExampleWithJSONB(
21
21
  BaseModel, PydanticJSONMixin, TypeIDMixin("json_test"), table=True
22
22
  ):
23
- list_field: list[SubObject] = Field(sa_type=JSONB())
24
- # list_with_generator: list[SubObject] = Field(sa_type=JSONB())
25
- optional_list_field: list[SubObject] | None = Field(sa_type=JSONB(), default=None)
26
- generic_list_field: list[dict] = Field(sa_type=JSONB())
27
- object_field: SubObject = Field(sa_type=JSONB())
28
- unstructured_field: dict = Field(sa_type=JSONB())
29
- semi_structured_field: dict[str, str] = Field(sa_type=JSONB())
30
- optional_object_field: SubObject | None = Field(sa_type=JSONB(), default=None)
23
+ list_field: list[SubObject] = Field(sa_type=JSONB)
24
+ # list_with_generator: list[SubObject] = Field(sa_type=JSONB)
25
+ optional_list_field: list[SubObject] | None = Field(sa_type=JSONB, default=None)
26
+ generic_list_field: list[dict] = Field(sa_type=JSONB)
27
+ object_field: SubObject = Field(sa_type=JSONB)
28
+ unstructured_field: dict = Field(sa_type=JSONB)
29
+ semi_structured_field: dict[str, str] = Field(sa_type=JSONB)
30
+ optional_object_field: SubObject | None = Field(sa_type=JSONB, default=None)
31
31
 
32
32
  normal_field: str | None = Field(default=None)
33
33
 
34
34
 
35
35
  def test_json_serialization(create_and_wipe_database):
36
36
  sub_object = SubObject(name="test", value=1)
37
- example = ExampleWithJSON(
37
+
38
+ example = ExampleWithJSONB(
38
39
  list_field=[sub_object],
39
40
  # list_with_generator=(x for x in [sub_object]),
40
41
  generic_list_field=[{"one": "two", "three": 3, "four": [1, 2, 3]}],
@@ -46,7 +47,14 @@ def test_json_serialization(create_and_wipe_database):
46
47
  optional_object_field=sub_object,
47
48
  ).save()
48
49
 
49
- fresh_example = ExampleWithJSON.get(example.id)
50
+ # make sure the types are preserved when saved
51
+ assert isinstance(example.list_field[0], SubObject)
52
+ assert example.optional_list_field
53
+ assert isinstance(example.optional_list_field[0], SubObject)
54
+ assert isinstance(example.object_field, SubObject)
55
+ assert isinstance(example.optional_object_field, SubObject)
56
+
57
+ fresh_example = ExampleWithJSONB.get(example.id)
50
58
 
51
59
  assert fresh_example is not None
52
60
  assert isinstance(fresh_example.object_field, SubObject)
@@ -38,21 +38,24 @@ def test_get_through_prefixed_uid_as_str():
38
38
  assert record is None
39
39
 
40
40
 
41
- def test_get_through_plain_uid_as_str():
41
+ def test_get_through_plain_uid_as_str(create_and_wipe_database):
42
42
  type_uid = TypeID(prefix=TYPEID_PREFIX)
43
43
 
44
- with temporary_tables():
45
- # pass uid as string. Ex: '01942886-7afc-7129-8f57-db09137ed002'
46
- record = ExampleWithId.get(str(type_uid.uuid))
47
- assert record is None
44
+ # pass uid as string. Ex: '01942886-7afc-7129-8f57-db09137ed002'
45
+ record = ExampleWithId.get(str(type_uid.uuid))
46
+ assert record is None
48
47
 
49
48
 
50
- def test_get_through_plain_uid():
49
+ def test_get_through_plain_uid(create_and_wipe_database):
51
50
  type_uid = TypeID(prefix=TYPEID_PREFIX)
52
51
 
53
- with temporary_tables():
54
- record = ExampleWithId.get(type_uid.uuid)
55
- assert record is None
52
+ record = ExampleWithId.get(type_uid.uuid)
53
+ assert record is None
54
+
55
+
56
+ # def test_non_primary_typeid_key():
57
+ # class NonPrimaryKeyExample(PydanticBaseModel, table=True):
58
+ # something: str | None = None
56
59
 
57
60
 
58
61
  # the wrapped test is probably overkill, but it's protecting against a weird edge case I was running into with fastapi
@@ -1,51 +0,0 @@
1
- from activemodel import SessionManager
2
-
3
-
4
- def database_reset_transaction():
5
- """
6
- Wrap all database interactions for a given test in a nested transaction and roll it back after the test.
7
-
8
- >>> from activemodel.pytest import database_reset_transaction
9
- >>> pytest.fixture(scope="function", autouse=True)(database_reset_transaction)
10
-
11
- References:
12
-
13
- - https://stackoverflow.com/questions/62433018/how-to-make-sqlalchemy-transaction-rollback-drop-tables-it-created
14
- - https://aalvarez.me/posts/setting-up-a-sqlalchemy-and-pytest-based-test-suite/
15
- - https://github.com/nickjj/docker-flask-example/blob/93af9f4fbf185098ffb1d120ee0693abcd77a38b/test/conftest.py#L77
16
- - https://github.com/caiola/vinhos.com/blob/c47d0a5d7a4bf290c1b726561d1e8f5d2ac29bc8/backend/test/conftest.py#L46
17
- """
18
-
19
- engine = SessionManager.get_instance().get_engine()
20
-
21
- with engine.begin() as connection:
22
- transaction = connection.begin_nested()
23
-
24
- SessionManager.get_instance().session_connection = connection
25
-
26
- try:
27
- yield
28
- finally:
29
- transaction.rollback()
30
- # TODO is this necessary?
31
- connection.close()
32
-
33
-
34
- # TODO unsure if this adds any value beyond the above approach
35
- # def database_reset_named_truncation():
36
- # start_truncation_query = """
37
- # BEGIN;
38
- # SAVEPOINT test_truncation_savepoint;
39
- # """
40
-
41
- # raw_sql_exec(start_truncation_query)
42
-
43
- # yield
44
-
45
- # end_truncation_query = """
46
- # ROLLBACK TO SAVEPOINT test_truncation_savepoint;
47
- # RELEASE SAVEPOINT test_truncation_savepoint;
48
- # ROLLBACK;
49
- # """
50
-
51
- # raw_sql_exec(end_truncation_query)
@@ -1,51 +0,0 @@
1
- """
2
- Test core ORM functions
3
- """
4
-
5
- from test.models import EXAMPLE_TABLE_PREFIX, AnotherExample, ExampleRecord
6
-
7
-
8
- def test_empty_count(create_and_wipe_database):
9
- assert ExampleRecord.count() == 0
10
-
11
-
12
- def test_all_and_count(create_and_wipe_database):
13
- AnotherExample().save()
14
-
15
- records_to_create = 10
16
-
17
- # create 10 example records
18
- for i in range(records_to_create):
19
- ExampleRecord().save()
20
-
21
- assert ExampleRecord.count() == records_to_create
22
-
23
- all_records = list(ExampleRecord.all())
24
- assert len(all_records) == records_to_create
25
-
26
- assert ExampleRecord.count() == records_to_create
27
-
28
- record = all_records[0]
29
- assert isinstance(record, ExampleRecord)
30
-
31
-
32
- def test_foreign_key():
33
- field = ExampleRecord.foreign_key()
34
- assert field.sa_type.prefix == EXAMPLE_TABLE_PREFIX
35
-
36
-
37
- def test_basic_query(create_and_wipe_database):
38
- example = ExampleRecord(something="hi").save()
39
- query = ExampleRecord.select().where(ExampleRecord.something == "hi")
40
-
41
- query_as_str = str(query)
42
- result = query.first()
43
-
44
-
45
- def test_query_count(create_and_wipe_database):
46
- AnotherExample().save()
47
-
48
- example = ExampleRecord(something="hi").save()
49
- count = ExampleRecord.select().where(ExampleRecord.something == "hi").count()
50
-
51
- assert count == 1
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes