activemodel 0.9.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 (67) hide show
  1. {activemodel-0.9.0 → activemodel-0.10.0}/CHANGELOG.md +19 -0
  2. {activemodel-0.9.0 → activemodel-0.10.0}/PKG-INFO +1 -1
  3. {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/base_model.py +53 -7
  4. activemodel-0.10.0/activemodel/mixins/typeid.py +33 -0
  5. {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/session_manager.py +7 -0
  6. activemodel-0.10.0/playground/alternative_typeid_mixin.py +22 -0
  7. {activemodel-0.9.0 → activemodel-0.10.0}/pyproject.toml +1 -1
  8. {activemodel-0.9.0 → activemodel-0.10.0}/test/models.py +13 -0
  9. activemodel-0.10.0/test/orm/test_upsert.py +185 -0
  10. activemodel-0.10.0/test/session_manager_test.py +22 -0
  11. {activemodel-0.9.0 → activemodel-0.10.0}/uv.lock +356 -289
  12. activemodel-0.9.0/activemodel/mixins/typeid.py +0 -46
  13. {activemodel-0.9.0 → activemodel-0.10.0}/.envrc +0 -0
  14. {activemodel-0.9.0 → activemodel-0.10.0}/.github/dependabot.yml +0 -0
  15. {activemodel-0.9.0 → activemodel-0.10.0}/.github/workflows/build_and_publish.yml +0 -0
  16. {activemodel-0.9.0 → activemodel-0.10.0}/.github/workflows/repo-sync.yml +0 -0
  17. {activemodel-0.9.0 → activemodel-0.10.0}/.gitignore +0 -0
  18. {activemodel-0.9.0 → activemodel-0.10.0}/.tool-versions +0 -0
  19. {activemodel-0.9.0 → activemodel-0.10.0}/.vscode/settings.json +0 -0
  20. {activemodel-0.9.0 → activemodel-0.10.0}/Justfile +0 -0
  21. {activemodel-0.9.0 → activemodel-0.10.0}/LICENSE +0 -0
  22. {activemodel-0.9.0 → activemodel-0.10.0}/Makefile +0 -0
  23. {activemodel-0.9.0 → activemodel-0.10.0}/README.md +0 -0
  24. {activemodel-0.9.0 → activemodel-0.10.0}/TODO +0 -0
  25. {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/__init__.py +0 -0
  26. {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/celery.py +0 -0
  27. {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/errors.py +0 -0
  28. {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/get_column_from_field_patch.py +0 -0
  29. {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/logger.py +0 -0
  30. {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/mixins/__init__.py +0 -0
  31. {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/mixins/pydantic_json.py +0 -0
  32. {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/mixins/soft_delete.py +0 -0
  33. {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/mixins/timestamps.py +0 -0
  34. {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/pytest/__init__.py +0 -0
  35. {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/pytest/transaction.py +0 -0
  36. {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/pytest/truncate.py +0 -0
  37. {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/query_wrapper.py +0 -0
  38. {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/types/__init__.py +0 -0
  39. {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/types/typeid.py +0 -0
  40. {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/utils.py +0 -0
  41. {activemodel-0.9.0 → activemodel-0.10.0}/docker-compose.yml +0 -0
  42. {activemodel-0.9.0 → activemodel-0.10.0}/playground/comments.py +0 -0
  43. {activemodel-0.9.0 → activemodel-0.10.0}/playground/env-with-model.patch +0 -0
  44. {activemodel-0.9.0 → activemodel-0.10.0}/playground/extract_comments.py +0 -0
  45. {activemodel-0.9.0 → activemodel-0.10.0}/playground/field.py +0 -0
  46. {activemodel-0.9.0 → activemodel-0.10.0}/playground/middleware.py +0 -0
  47. {activemodel-0.9.0 → activemodel-0.10.0}/playground/old_session_manager.py +0 -0
  48. {activemodel-0.9.0 → activemodel-0.10.0}/playground/pydantic_validation.py +0 -0
  49. {activemodel-0.9.0 → activemodel-0.10.0}/playground.py +0 -0
  50. {activemodel-0.9.0 → activemodel-0.10.0}/test/__init__.py +0 -0
  51. {activemodel-0.9.0 → activemodel-0.10.0}/test/comments_test.py +0 -0
  52. {activemodel-0.9.0 → activemodel-0.10.0}/test/conftest.py +0 -0
  53. {activemodel-0.9.0 → activemodel-0.10.0}/test/delete_test.py +0 -0
  54. {activemodel-0.9.0 → activemodel-0.10.0}/test/fastapi_test.py +0 -0
  55. {activemodel-0.9.0 → activemodel-0.10.0}/test/import_test.py +0 -0
  56. {activemodel-0.9.0 → activemodel-0.10.0}/test/migrations/README +0 -0
  57. {activemodel-0.9.0 → activemodel-0.10.0}/test/migrations/alembic.ini +0 -0
  58. {activemodel-0.9.0 → activemodel-0.10.0}/test/migrations/env.py +0 -0
  59. {activemodel-0.9.0 → activemodel-0.10.0}/test/migrations/script.py.mako +0 -0
  60. {activemodel-0.9.0 → activemodel-0.10.0}/test/migrations_test.py +0 -0
  61. {activemodel-0.9.0 → activemodel-0.10.0}/test/mutation_test.py +0 -0
  62. {activemodel-0.9.0 → activemodel-0.10.0}/test/nested_pydantic_json_test.py +0 -0
  63. {activemodel-0.9.0 → activemodel-0.10.0}/test/orm_test.py +0 -0
  64. {activemodel-0.9.0 → activemodel-0.10.0}/test/table_name_test.py +0 -0
  65. {activemodel-0.9.0 → activemodel-0.10.0}/test/test_wrapper.py +0 -0
  66. {activemodel-0.9.0 → activemodel-0.10.0}/test/typeid_test.py +0 -0
  67. {activemodel-0.9.0 → activemodel-0.10.0}/test/utils.py +0 -0
@@ -1,5 +1,24 @@
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
+
3
22
  ## [0.9.0](https://github.com/iloveitaly/activemodel/compare/v0.8.0...v0.9.0) (2025-03-26)
4
23
 
5
24
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: activemodel
3
- Version: 0.9.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>
@@ -4,11 +4,11 @@ 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
9
7
  import sqlmodel as sm
10
8
  from sqlalchemy import Connection, event
11
9
  from sqlalchemy.orm import Mapper, declared_attr
10
+ from sqlalchemy.orm.attributes import flag_modified as sa_flag_modified
11
+ from sqlalchemy.orm.base import instance_state
12
12
  from sqlmodel import Column, Field, Session, SQLModel, inspect, select
13
13
  from typeid import TypeID
14
14
 
@@ -19,6 +19,7 @@ from . import get_column_from_field_patch # noqa: F401
19
19
  from .logger import logger
20
20
  from .query_wrapper import QueryWrapper
21
21
  from .session_manager import get_session
22
+ from sqlalchemy.dialects.postgresql import insert as postgres_insert
22
23
 
23
24
  POSTGRES_INDEXES_NAMING_CONVENTION = {
24
25
  "ix": "%(column_0_label)s_idx",
@@ -137,8 +138,15 @@ class BaseModel(SQLModel):
137
138
  cls.__table_args__ = {"comment": doc}
138
139
  elif isinstance(table_args, dict):
139
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)
140
146
  else:
141
- raise ValueError("Unexpected __table_args__ type")
147
+ raise ValueError(
148
+ f"Unexpected __table_args__ type {type(table_args)}, expected dictionary."
149
+ )
142
150
 
143
151
  # TODO no type check decorator here
144
152
  @declared_attr
@@ -163,8 +171,10 @@ class BaseModel(SQLModel):
163
171
  """
164
172
  Returns a `Field` object referencing the foreign key of the model.
165
173
 
166
- >>> other_model_id: int
167
- >>> 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()
168
178
  """
169
179
 
170
180
  field_options = {"nullable": False} | kwargs
@@ -186,6 +196,42 @@ class BaseModel(SQLModel):
186
196
  "convenience method to avoid having to write .select().where() in order to add conditions"
187
197
  return cls.select().where(*args)
188
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
+
189
235
  def delete(self):
190
236
  with get_session() as session:
191
237
  if old_session := Session.object_session(self):
@@ -256,9 +302,9 @@ class BaseModel(SQLModel):
256
302
  def is_new(self) -> bool:
257
303
  return not self._sa_instance_state.has_identity
258
304
 
259
- def flag_modified(self, *args: str):
305
+ def flag_modified(self, *args: str) -> None:
260
306
  """
261
- Flag one or more fields as modified. Useful for marking a field containing sub-objects as modified.
307
+ Flag one or more fields as modified/mutated/dirty. Useful for marking a field containing sub-objects as modified.
262
308
 
263
309
  Will throw an error if an invalid field is passed.
264
310
  """
@@ -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
@@ -72,6 +72,7 @@ class SessionManager:
72
72
  self._database_url,
73
73
  # NOTE very important! This enables pydantic models to be serialized for JSONB columns
74
74
  json_serializer=_serialize_pydantic_model,
75
+ # TODO move to a constants area
75
76
  echo=config("ACTIVEMODEL_LOG_SQL", cast=bool, default=False),
76
77
  # https://docs.sqlalchemy.org/en/20/core/pooling.html#disconnect-handling-pessimistic
77
78
  pool_pre_ping=True,
@@ -119,6 +120,9 @@ _session_context = contextvars.ContextVar[Session | None](
119
120
 
120
121
  @contextlib.contextmanager
121
122
  def global_session():
123
+ if _session_context.get() is not None:
124
+ raise RuntimeError("global session already set")
125
+
122
126
  with SessionManager.get_instance().get_session() as s:
123
127
  token = _session_context.set(s)
124
128
 
@@ -140,6 +144,9 @@ async def aglobal_session():
140
144
  >>> )
141
145
  """
142
146
 
147
+ if _session_context.get() is not None:
148
+ raise RuntimeError("global session already set")
149
+
143
150
  with SessionManager.get_instance().get_session() as s:
144
151
  token = _session_context.set(s)
145
152
 
@@ -0,0 +1,22 @@
1
+ # TODO not sure if I love the idea of a dynamic class for each mixin as used above
2
+ # may give this approach another shot in the future
3
+
4
+
5
+ class TypeIDMixin2:
6
+ """
7
+ Mixin class that adds a TypeID primary key to models.
8
+
9
+
10
+ >>> class MyModel(BaseModel, TypeIDMixin, prefix="xyz", table=True):
11
+ >>> name: str
12
+
13
+ Will automatically have an `id` field with prefix "xyz"
14
+ """
15
+
16
+ def __init_subclass__(cls, *, prefix: str, **kwargs):
17
+ super().__init_subclass__(**kwargs)
18
+
19
+ cls.id: uuid.UUID = Field(
20
+ sa_column=Column(TypeIDType(prefix), primary_key=True),
21
+ default_factory=lambda: TypeID(prefix),
22
+ )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "activemodel"
3
- version = "0.9.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"
@@ -1,4 +1,5 @@
1
1
  from pydantic import computed_field
2
+ from sqlalchemy import UniqueConstraint
2
3
  from sqlmodel import Column, Field, Integer, Relationship
3
4
 
4
5
  from activemodel import BaseModel
@@ -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,185 @@
1
+ import pytest
2
+ from sqlalchemy import UniqueConstraint
3
+ from sqlmodel import Field
4
+
5
+ from activemodel import BaseModel
6
+ from activemodel.mixins.typeid import TypeIDMixin
7
+ from test.models import UpsertTestModel
8
+
9
+
10
+ def test_upsert_single_unique_field(create_and_wipe_database):
11
+ """Test upsert with a single unique field"""
12
+ # Create initial record
13
+ result = UpsertTestModel.upsert(
14
+ data={"name": "test1", "category": "A", "value": 10},
15
+ unique_by="name",
16
+ )
17
+
18
+ # 3. Ensure return value is never null
19
+ assert result is not None
20
+
21
+ # 2. Check field values on returned model
22
+ assert result.name == "test1"
23
+ assert result.category == "A"
24
+ assert result.value == 10
25
+
26
+ # Get record to verify it was created
27
+ db_record = UpsertTestModel.one(name="test1")
28
+
29
+ # 1. Check that returned model's ID matches the DB record
30
+ assert db_record.id == result.id
31
+
32
+ # Perform upsert that updates the existing record
33
+ updated_result = UpsertTestModel.upsert(
34
+ data={"name": "test1", "category": "B", "value": 20},
35
+ unique_by="name",
36
+ )
37
+
38
+ # 4. Ensure multiple upserts with same unique_by keys return object with same ID
39
+ assert updated_result.id == result.id
40
+
41
+ # 2. Check field values on returned model
42
+ assert updated_result.name == "test1"
43
+ assert updated_result.category == "B"
44
+ assert updated_result.value == 20
45
+
46
+ assert UpsertTestModel.count() == 1
47
+ record = UpsertTestModel.get(name="test1")
48
+ # 1. Double-check that DB record matches what was returned
49
+ assert record.id == updated_result.id
50
+ assert record.category == "B"
51
+ assert record.value == 20
52
+
53
+
54
+ def test_upsert_multiple_unique_fields(create_and_wipe_database):
55
+ """Test upsert with multiple unique fields"""
56
+ # Create initial records
57
+ result1 = UpsertTestModel.upsert(
58
+ data={"name": "multi1", "category": "X", "value": 100},
59
+ unique_by=["name", "category"],
60
+ )
61
+
62
+ # 3. Ensure return value is never null
63
+ assert result1 is not None
64
+ # 2. Check field values on returned model
65
+ assert result1.name == "multi1"
66
+ assert result1.category == "X"
67
+ assert result1.value == 100
68
+
69
+ # 1. Check that returned model's ID matches the DB record
70
+ db_record1 = UpsertTestModel.get(name="multi1", category="X")
71
+ assert db_record1.id == result1.id
72
+
73
+ result2 = UpsertTestModel.upsert(
74
+ data={"name": "multi2", "category": "X", "value": 200},
75
+ unique_by=["name", "category"],
76
+ )
77
+
78
+ # Different name should create a new record with different ID
79
+ assert result2.id != result1.id
80
+ # 2. Check field values on returned model
81
+ assert result2.name == "multi2"
82
+ assert result2.category == "X"
83
+ assert result2.value == 200
84
+
85
+ assert UpsertTestModel.count() == 2
86
+
87
+ # Update one record based on both unique fields
88
+ updated_result = UpsertTestModel.upsert(
89
+ data={"name": "multi1", "category": "X", "value": 150},
90
+ unique_by=["name", "category"],
91
+ )
92
+
93
+ # 4. Ensure multiple upserts with same unique_by keys return object with same ID
94
+ assert updated_result.id == result1.id
95
+ # 2. Check field values on returned model
96
+ assert updated_result.name == "multi1"
97
+ assert updated_result.category == "X"
98
+ assert updated_result.value == 150
99
+
100
+ # Get records to verify one was updated and one unchanged
101
+ record_x = UpsertTestModel.one(name="multi1", category="X")
102
+ record_y = UpsertTestModel.one(name="multi2", category="X")
103
+
104
+ # 1. Check that DB records match what was returned
105
+ assert record_x.id == updated_result.id
106
+ assert record_x.value == 150 # Updated
107
+ assert record_y.value == 200 # Unchanged
108
+
109
+
110
+ def test_upsert_single_update_field(create_and_wipe_database):
111
+ """Test upsert that updates a single field"""
112
+ # Create initial record
113
+ result = UpsertTestModel.upsert(
114
+ data={"name": "update1", "category": "Z", "value": 5, "description": "Initial"},
115
+ unique_by="name",
116
+ )
117
+
118
+ # 3. Ensure return value is never null
119
+ assert result is not None
120
+ # 2. Check field values on returned model
121
+ assert result.name == "update1"
122
+ assert result.category == "Z"
123
+ assert result.value == 5
124
+ assert result.description == "Initial"
125
+
126
+ # Perform upsert that only updates the value
127
+ updated_result = UpsertTestModel.upsert(
128
+ data={"name": "update1", "category": "Z", "value": 25},
129
+ unique_by="name",
130
+ )
131
+
132
+ # 4. Ensure multiple upserts with same unique_by keys return object with same ID
133
+ assert updated_result.id == result.id
134
+ # 2. Check field values on returned model
135
+ assert updated_result.name == "update1"
136
+ assert updated_result.category == "Z"
137
+ assert updated_result.value == 25
138
+ assert updated_result.description == "Initial" # Should be preserved
139
+
140
+ # Get record to verify field was updated
141
+ record = UpsertTestModel.get(name="update1")
142
+ # 1. Check that DB record matches what was returned
143
+ assert record.id == updated_result.id
144
+ assert record.value == 25 # Updated
145
+ assert record.category == "Z" # Unchanged
146
+ assert record.description == "Initial" # Unchanged
147
+
148
+
149
+ def test_upsert_multiple_update_fields(create_and_wipe_database):
150
+ """Test upsert that updates multiple fields"""
151
+ # Create initial record
152
+ result = UpsertTestModel.upsert(
153
+ data={"name": "update2", "category": "M", "value": 42, "description": "Old"},
154
+ unique_by="name",
155
+ )
156
+
157
+ # 3. Ensure return value is never null
158
+ assert result is not None
159
+ # 2. Check field values on returned model
160
+ assert result.name == "update2"
161
+ assert result.category == "M"
162
+ assert result.value == 42
163
+ assert result.description == "Old"
164
+
165
+ # Perform upsert that updates multiple fields
166
+ updated_result = UpsertTestModel.upsert(
167
+ data={"name": "update2", "value": 99, "description": "New", "category": "N"},
168
+ unique_by="name",
169
+ )
170
+
171
+ # 4. Ensure multiple upserts with same unique_by keys return object with same ID
172
+ assert updated_result.id == result.id
173
+ # 2. Check field values on returned model
174
+ assert updated_result.name == "update2"
175
+ assert updated_result.category == "N"
176
+ assert updated_result.value == 99
177
+ assert updated_result.description == "New"
178
+
179
+ # Get record to verify all fields were updated
180
+ record = UpsertTestModel.get(name="update2")
181
+ # 1. Check that DB record matches what was returned
182
+ assert record.id == updated_result.id
183
+ assert record.value == 99
184
+ assert record.description == "New"
185
+ assert record.category == "N"
@@ -0,0 +1,22 @@
1
+ import contextlib
2
+ import pytest
3
+ from activemodel.session_manager import global_session
4
+
5
+
6
+ def test_global_session_raises_when_nested():
7
+ """Test that global_session raises an error when used in a nested context."""
8
+
9
+ # First global_session should work fine
10
+ with global_session() as outer_session:
11
+ assert outer_session is not None
12
+
13
+ # Attempting to create a nested global_session should fail
14
+ with pytest.raises(RuntimeError) as excinfo:
15
+ with global_session() as _:
16
+ pass # This code shouldn't execute
17
+
18
+ assert "global session already set" in str(excinfo.value)
19
+
20
+ # After exiting the outer context, we should be able to use global_session again
21
+ with global_session() as session:
22
+ assert session is not None