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.
- {activemodel-0.9.0 → activemodel-0.10.0}/CHANGELOG.md +19 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/PKG-INFO +1 -1
- {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/base_model.py +53 -7
- activemodel-0.10.0/activemodel/mixins/typeid.py +33 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/session_manager.py +7 -0
- activemodel-0.10.0/playground/alternative_typeid_mixin.py +22 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/pyproject.toml +1 -1
- {activemodel-0.9.0 → activemodel-0.10.0}/test/models.py +13 -0
- activemodel-0.10.0/test/orm/test_upsert.py +185 -0
- activemodel-0.10.0/test/session_manager_test.py +22 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/uv.lock +356 -289
- activemodel-0.9.0/activemodel/mixins/typeid.py +0 -46
- {activemodel-0.9.0 → activemodel-0.10.0}/.envrc +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/.github/dependabot.yml +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/.github/workflows/build_and_publish.yml +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/.github/workflows/repo-sync.yml +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/.gitignore +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/.tool-versions +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/.vscode/settings.json +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/Justfile +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/LICENSE +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/Makefile +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/README.md +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/TODO +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/__init__.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/celery.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/errors.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/get_column_from_field_patch.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/logger.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/mixins/__init__.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/mixins/pydantic_json.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/mixins/soft_delete.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/mixins/timestamps.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/pytest/__init__.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/pytest/transaction.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/pytest/truncate.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/query_wrapper.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/types/__init__.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/types/typeid.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/activemodel/utils.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/docker-compose.yml +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/playground/comments.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/playground/env-with-model.patch +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/playground/extract_comments.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/playground/field.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/playground/middleware.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/playground/old_session_manager.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/playground/pydantic_validation.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/playground.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/test/__init__.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/test/comments_test.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/test/conftest.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/test/delete_test.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/test/fastapi_test.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/test/import_test.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/test/migrations/README +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/test/migrations/alembic.ini +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/test/migrations/env.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/test/migrations/script.py.mako +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/test/migrations_test.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/test/mutation_test.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/test/nested_pydantic_json_test.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/test/orm_test.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/test/table_name_test.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/test/test_wrapper.py +0 -0
- {activemodel-0.9.0 → activemodel-0.10.0}/test/typeid_test.py +0 -0
- {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
|
|
|
@@ -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(
|
|
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
|
-
|
|
167
|
-
|
|
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,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
|