activemodel 0.12.0__tar.gz → 0.13.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 (77) hide show
  1. {activemodel-0.12.0 → activemodel-0.13.0}/CHANGELOG.md +7 -0
  2. {activemodel-0.12.0 → activemodel-0.13.0}/PKG-INFO +1 -1
  3. {activemodel-0.12.0 → activemodel-0.13.0}/activemodel/base_model.py +92 -82
  4. {activemodel-0.12.0 → activemodel-0.13.0}/pyproject.toml +1 -1
  5. activemodel-0.13.0/test/lifecycle_test.py +194 -0
  6. {activemodel-0.12.0 → activemodel-0.13.0}/test/models.py +4 -0
  7. activemodel-0.12.0/test/test_wrapper.py → activemodel-0.13.0/test/test_query_wrapper.py +3 -4
  8. {activemodel-0.12.0 → activemodel-0.13.0}/.envrc +0 -0
  9. {activemodel-0.12.0 → activemodel-0.13.0}/.github/dependabot.yml +0 -0
  10. {activemodel-0.12.0 → activemodel-0.13.0}/.github/workflows/build_and_publish.yml +0 -0
  11. {activemodel-0.12.0 → activemodel-0.13.0}/.github/workflows/repo-sync.yml +0 -0
  12. {activemodel-0.12.0 → activemodel-0.13.0}/.gitignore +0 -0
  13. {activemodel-0.12.0 → activemodel-0.13.0}/.tool-versions +0 -0
  14. {activemodel-0.12.0 → activemodel-0.13.0}/.vscode/settings.json +0 -0
  15. {activemodel-0.12.0 → activemodel-0.13.0}/Justfile +0 -0
  16. {activemodel-0.12.0 → activemodel-0.13.0}/LICENSE +0 -0
  17. {activemodel-0.12.0 → activemodel-0.13.0}/Makefile +0 -0
  18. {activemodel-0.12.0 → activemodel-0.13.0}/README.md +0 -0
  19. {activemodel-0.12.0 → activemodel-0.13.0}/TODO +0 -0
  20. {activemodel-0.12.0 → activemodel-0.13.0}/activemodel/__init__.py +0 -0
  21. {activemodel-0.12.0 → activemodel-0.13.0}/activemodel/celery.py +0 -0
  22. {activemodel-0.12.0 → activemodel-0.13.0}/activemodel/cli/__init__.py +0 -0
  23. {activemodel-0.12.0 → activemodel-0.13.0}/activemodel/errors.py +0 -0
  24. {activemodel-0.12.0 → activemodel-0.13.0}/activemodel/get_column_from_field_patch.py +0 -0
  25. {activemodel-0.12.0 → activemodel-0.13.0}/activemodel/logger.py +0 -0
  26. {activemodel-0.12.0 → activemodel-0.13.0}/activemodel/mixins/__init__.py +0 -0
  27. {activemodel-0.12.0 → activemodel-0.13.0}/activemodel/mixins/pydantic_json.py +0 -0
  28. {activemodel-0.12.0 → activemodel-0.13.0}/activemodel/mixins/soft_delete.py +0 -0
  29. {activemodel-0.12.0 → activemodel-0.13.0}/activemodel/mixins/timestamps.py +0 -0
  30. {activemodel-0.12.0 → activemodel-0.13.0}/activemodel/mixins/typeid.py +0 -0
  31. {activemodel-0.12.0 → activemodel-0.13.0}/activemodel/pytest/__init__.py +0 -0
  32. {activemodel-0.12.0 → activemodel-0.13.0}/activemodel/pytest/factories.py +0 -0
  33. {activemodel-0.12.0 → activemodel-0.13.0}/activemodel/pytest/plugin.py +0 -0
  34. {activemodel-0.12.0 → activemodel-0.13.0}/activemodel/pytest/transaction.py +0 -0
  35. {activemodel-0.12.0 → activemodel-0.13.0}/activemodel/pytest/truncate.py +0 -0
  36. {activemodel-0.12.0 → activemodel-0.13.0}/activemodel/query_wrapper.py +0 -0
  37. {activemodel-0.12.0 → activemodel-0.13.0}/activemodel/session_manager.py +0 -0
  38. {activemodel-0.12.0 → activemodel-0.13.0}/activemodel/types/__init__.py +0 -0
  39. {activemodel-0.12.0 → activemodel-0.13.0}/activemodel/types/sqlalchemy_protocol.py +0 -0
  40. {activemodel-0.12.0 → activemodel-0.13.0}/activemodel/types/sqlalchemy_protocol.pyi +0 -0
  41. {activemodel-0.12.0 → activemodel-0.13.0}/activemodel/types/typeid.py +0 -0
  42. {activemodel-0.12.0 → activemodel-0.13.0}/activemodel/types/typeid_patch.py +0 -0
  43. {activemodel-0.12.0 → activemodel-0.13.0}/activemodel/utils.py +0 -0
  44. {activemodel-0.12.0 → activemodel-0.13.0}/docker-compose.yml +0 -0
  45. {activemodel-0.12.0 → activemodel-0.13.0}/playground/alternative_typeid_mixin.py +0 -0
  46. {activemodel-0.12.0 → activemodel-0.13.0}/playground/comments.py +0 -0
  47. {activemodel-0.12.0 → activemodel-0.13.0}/playground/env-with-model.patch +0 -0
  48. {activemodel-0.12.0 → activemodel-0.13.0}/playground/extract_comments.py +0 -0
  49. {activemodel-0.12.0 → activemodel-0.13.0}/playground/field.py +0 -0
  50. {activemodel-0.12.0 → activemodel-0.13.0}/playground/middleware.py +0 -0
  51. {activemodel-0.12.0 → activemodel-0.13.0}/playground/old_session_manager.py +0 -0
  52. {activemodel-0.12.0 → activemodel-0.13.0}/playground/pydantic_validation.py +0 -0
  53. {activemodel-0.12.0 → activemodel-0.13.0}/playground.py +0 -0
  54. {activemodel-0.12.0 → activemodel-0.13.0}/test/__init__.py +0 -0
  55. {activemodel-0.12.0 → activemodel-0.13.0}/test/comments_test.py +0 -0
  56. {activemodel-0.12.0 → activemodel-0.13.0}/test/conftest.py +0 -0
  57. {activemodel-0.12.0 → activemodel-0.13.0}/test/delete_test.py +0 -0
  58. {activemodel-0.12.0 → activemodel-0.13.0}/test/factory_test.py +0 -0
  59. {activemodel-0.12.0 → activemodel-0.13.0}/test/fastapi_test.py +0 -0
  60. {activemodel-0.12.0 → activemodel-0.13.0}/test/import_test.py +0 -0
  61. {activemodel-0.12.0 → activemodel-0.13.0}/test/migrations/README +0 -0
  62. {activemodel-0.12.0 → activemodel-0.13.0}/test/migrations/alembic.ini +0 -0
  63. {activemodel-0.12.0 → activemodel-0.13.0}/test/migrations/env.py +0 -0
  64. {activemodel-0.12.0 → activemodel-0.13.0}/test/migrations/script.py.mako +0 -0
  65. {activemodel-0.12.0 → activemodel-0.13.0}/test/migrations_test.py +0 -0
  66. {activemodel-0.12.0 → activemodel-0.13.0}/test/mutation_test.py +0 -0
  67. {activemodel-0.12.0 → activemodel-0.13.0}/test/nested_pydantic_json_test.py +0 -0
  68. {activemodel-0.12.0 → activemodel-0.13.0}/test/orm/test_upsert.py +0 -0
  69. {activemodel-0.12.0 → activemodel-0.13.0}/test/orm_test.py +0 -0
  70. {activemodel-0.12.0 → activemodel-0.13.0}/test/pytest/pytest_test.py +0 -0
  71. {activemodel-0.12.0 → activemodel-0.13.0}/test/session_manager_test.py +0 -0
  72. {activemodel-0.12.0 → activemodel-0.13.0}/test/table_name_test.py +0 -0
  73. {activemodel-0.12.0 → activemodel-0.13.0}/test/types/typeid_mixin_test.py +0 -0
  74. {activemodel-0.12.0 → activemodel-0.13.0}/test/types/typeid_pydantic_test.py +0 -0
  75. {activemodel-0.12.0 → activemodel-0.13.0}/test/types/typeid_sqlmodel_test.py +0 -0
  76. {activemodel-0.12.0 → activemodel-0.13.0}/test/utils.py +0 -0
  77. {activemodel-0.12.0 → activemodel-0.13.0}/uv.lock +0 -0
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.13.0](https://github.com/iloveitaly/activemodel/compare/v0.12.0...v0.13.0) (2025-09-05)
4
+
5
+
6
+ ### Features
7
+
8
+ * rewritten lifecycle hooks that actually work ([af4e6fe](https://github.com/iloveitaly/activemodel/commit/af4e6fe75099ef1cc6a998b471f48f32ee8b7d5d))
9
+
3
10
  ## [0.12.0](https://github.com/iloveitaly/activemodel/compare/v0.11.0...v0.12.0) (2025-09-03)
4
11
 
5
12
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: activemodel
3
- Version: 0.12.0
3
+ Version: 0.13.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,23 +1,21 @@
1
1
  import json
2
2
  import typing as t
3
+ import textcase
3
4
  from uuid import UUID
5
+ from contextlib import nullcontext
4
6
 
5
7
  import sqlalchemy as sa
6
8
  import sqlmodel as sm
7
- import textcase
8
- from sqlalchemy import Connection, event
9
9
  from sqlalchemy.dialects.postgresql import insert as postgres_insert
10
- from sqlalchemy.orm import Mapper, declared_attr
11
10
  from sqlalchemy.orm.attributes import flag_modified as sa_flag_modified
12
- from sqlalchemy.orm.base import instance_state
13
11
  from sqlmodel import Column, Field, Session, SQLModel, inspect, select
14
12
  from typeid import TypeID
13
+ from sqlalchemy.orm import declared_attr
15
14
 
16
15
  from activemodel.mixins.pydantic_json import PydanticJSONMixin
17
16
 
18
17
  # NOTE: this patches a core method in sqlmodel to support db comments
19
18
  from . import get_column_from_field_patch # noqa: F401
20
- from .logger import logger
21
19
  from .query_wrapper import QueryWrapper
22
20
  from .session_manager import get_session
23
21
 
@@ -42,85 +40,46 @@ SQLModel.metadata.naming_convention = POSTGRES_INDEXES_NAMING_CONVENTION
42
40
 
43
41
  class BaseModel(SQLModel):
44
42
  """
45
- Base model class to inherit from so we can hate python less
43
+ Base model class to inherit from so we can hate python less.
46
44
 
47
- https://github.com/woofz/sqlmodel-basecrud/blob/main/sqlmodel_basecrud/basecrud.py
45
+ Some notes:
48
46
 
49
- - {before,after} lifecycle hooks are modeled after Rails.
50
- - class docstrings are converd to table-level comments
51
- - save(), delete(), select(), where(), and other easy methods you would expect
47
+ - Inspired by https://github.com/woofz/sqlmodel-basecrud/blob/main/sqlmodel_basecrud/basecrud.py
48
+ - lifecycle hooks are modeled after Rails.
49
+ - class docstrings are converted to table-level comments
50
+ - save(), delete(), select(), where(), and other easy methods you would expect in a real ORM
52
51
  - Fixes foreign key naming conventions
52
+ - Sane table names
53
+
54
+ Here's how hooks work:
55
+
56
+ Create/Update: before_create, after_create, before_update, after_update, before_save, after_save, around_save
57
+ Delete: before_delete, after_delete, around_delete
58
+
59
+ around_* hooks must be context managers (method returning a CM or a CM attribute).
60
+ Ordering (create): before_create -> before_save -> (enter around_save) -> persist -> after_create -> after_save -> (exit around_save)
61
+ Ordering (update): before_update -> before_save -> (enter around_save) -> persist -> after_update -> after_save -> (exit around_save)
62
+ Delete: before_delete -> (enter around_delete) -> delete -> after_delete -> (exit around_delete)
63
+
64
+ # TODO document this in activemodel, this is an interesting edge case
65
+ # https://claude.ai/share/f09e4f70-2ff7-4cd0-abff-44645134693a
66
+
53
67
  """
54
68
 
55
- # this is used for table-level comments
56
69
  __table_args__ = None
57
70
 
58
71
  @classmethod
59
72
  def __init_subclass__(cls, **kwargs):
60
- "Setup automatic sqlalchemy lifecycle events for the class"
61
-
62
73
  super().__init_subclass__(**kwargs)
63
74
 
64
75
  from sqlmodel._compat import set_config_value
65
76
 
66
- # enables field-level docstrings on the pydanatic `description` field, which we then copy into
67
- # sa_args, which is persisted to sql table comments
77
+ # Enables field-level docstrings on the pydantic `description` field, which we
78
+ # copy into table/column comments by patching SQLModel internals elsewhere.
68
79
  set_config_value(model=cls, parameter="use_attribute_docstrings", value=True)
69
80
 
70
81
  cls._apply_class_doc()
71
82
 
72
- def event_wrapper(method_name: str):
73
- """
74
- This does smart heavy lifting for us to make sqlalchemy lifecycle events nicer to work with:
75
-
76
- * Passes the target first to the lifecycle method, so it feels like an instance method
77
- * Allows as little as a single positional argument, so methods can be simple
78
- * Removes the need for decorators or anything fancy on the subclass
79
- """
80
-
81
- def wrapper(mapper: Mapper, connection: Connection, target: BaseModel):
82
- if hasattr(cls, method_name):
83
- method = getattr(cls, method_name)
84
-
85
- if callable(method):
86
- arg_count = method.__code__.co_argcount
87
-
88
- if arg_count == 1: # Just self/cls
89
- method(target)
90
- elif arg_count == 2: # Self, mapper
91
- method(target, mapper)
92
- elif arg_count == 3: # Full signature
93
- method(target, mapper, connection)
94
- else:
95
- raise TypeError(
96
- f"Method {method_name} must accept either 1 to 3 arguments, got {arg_count}"
97
- )
98
- else:
99
- logger.warning(
100
- "SQLModel lifecycle hook found, but not callable hook_name=%s",
101
- method_name,
102
- )
103
-
104
- return wrapper
105
-
106
- event.listen(cls, "before_insert", event_wrapper("before_insert"))
107
- event.listen(cls, "before_update", event_wrapper("before_update"))
108
-
109
- # before_save maps to two type of events
110
- event.listen(cls, "before_insert", event_wrapper("before_save"))
111
- event.listen(cls, "before_update", event_wrapper("before_save"))
112
-
113
- # now, let's handle after_* variants
114
- event.listen(cls, "after_insert", event_wrapper("after_insert"))
115
- event.listen(cls, "after_update", event_wrapper("after_update"))
116
-
117
- # after_save maps to two type of events
118
- event.listen(cls, "after_insert", event_wrapper("after_save"))
119
- event.listen(cls, "after_update", event_wrapper("after_save"))
120
-
121
- # def foreign_key()
122
- # table.id
123
-
124
83
  @classmethod
125
84
  def _apply_class_doc(cls):
126
85
  """
@@ -234,36 +193,81 @@ class BaseModel(SQLModel):
234
193
  return result
235
194
 
236
195
  def delete(self):
237
- "Delete record completely from the database"
196
+ """Delete instance running delete hooks and optional around_delete context manager."""
197
+
198
+ cm = self._get_around_context_manager("around_delete") or nullcontext()
238
199
 
239
200
  with get_session() as session:
240
- if old_session := Session.object_session(self):
201
+ if (
202
+ old_session := Session.object_session(self)
203
+ ) and old_session is not session:
241
204
  old_session.expunge(self)
242
-
243
205
  session.delete(self)
244
- session.commit()
245
- return True
206
+
207
+ self._call_hook("before_delete")
208
+ with cm:
209
+ session.commit()
210
+ self._call_hook("after_delete")
211
+
212
+ return True
246
213
 
247
214
  def save(self):
215
+ """Persist instance running create/update hooks and optional around_save context manager."""
216
+
217
+ is_new = self.is_new()
218
+ cm = self._get_around_context_manager("around_save") or nullcontext()
219
+
248
220
  with get_session() as session:
249
- if old_session := Session.object_session(self):
250
- # I was running into an issue where the object was already
251
- # associated with a session, but the session had been closed,
252
- # to get around this, you need to remove it from the old one,
253
- # then add it to the new one (below)
221
+ if (
222
+ old_session := Session.object_session(self)
223
+ ) and old_session is not session:
254
224
  old_session.expunge(self)
255
225
 
256
226
  session.add(self)
257
- # NOTE very important method! This triggers sqlalchemy lifecycle hooks automatically
258
- session.commit()
259
- session.refresh(self)
227
+
228
+ # the order and placement of these hooks is really important
229
+ # we need the current object to be in a session otherwise it will not be able to
230
+ # load any relationships.
231
+ self._call_hook("before_create" if is_new else "before_update")
232
+ self._call_hook("before_save")
233
+
234
+ with cm:
235
+ session.commit()
236
+ session.refresh(self)
237
+
238
+ self._call_hook("after_create" if is_new else "after_update")
239
+ self._call_hook("after_save")
260
240
 
261
241
  # Only call the transform method if the class is a subclass of PydanticJSONMixin
262
242
  if issubclass(self.__class__, PydanticJSONMixin):
263
243
  self.__class__.__transform_dict_to_pydantic__(self)
264
-
265
244
  return self
266
245
 
246
+ def _call_hook(self, hook_name: str) -> None:
247
+ method = getattr(self, hook_name, None)
248
+ if callable(method):
249
+ if method.__code__.co_argcount != 1:
250
+ raise TypeError(
251
+ f"Hook '{hook_name}' must accept exactly 1 positional argument (self)"
252
+ )
253
+ method()
254
+
255
+ def _get_around_context_manager(self, name: str) -> t.ContextManager | None:
256
+ obj = getattr(self, name, None)
257
+ if obj is None:
258
+ return None
259
+
260
+ # If it's a callable (method/function), call it to obtain the CM
261
+ if callable(obj):
262
+ obj = obj()
263
+
264
+ cm = obj
265
+ if not (hasattr(cm, "__enter__") and hasattr(cm, "__exit__")):
266
+ raise TypeError(
267
+ f"{name} must return or be a context manager implementing __enter__/__exit__"
268
+ )
269
+ return t.cast(t.ContextManager, cm)
270
+
267
271
  def refresh(self):
268
272
  "Refreshes an object from the database"
269
273
 
@@ -284,6 +288,7 @@ class BaseModel(SQLModel):
284
288
 
285
289
  # TODO shouldn't this be handled by pydantic?
286
290
  # TODO where is this actually used? shoudl prob remove this
291
+ # TODO should we even do this? Can we specify a better json rendering class?
287
292
  def json(self, **kwargs):
288
293
  return json.dumps(self.dict(), default=str, **kwargs)
289
294
 
@@ -299,7 +304,12 @@ class BaseModel(SQLModel):
299
304
  # TODO got to be a better way to fwd these along...
300
305
  @classmethod
301
306
  def first(cls):
302
- return cls.select().first()
307
+ # TODO should use dynamic pk
308
+ return cls.select().order_by(sa.desc(cls.id)).first()
309
+
310
+ # @classmethod
311
+ # def last(cls):
312
+ # return cls.select().first()
303
313
 
304
314
  # TODO throw an error if this field is set on the model
305
315
  def is_new(self) -> bool:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "activemodel"
3
- version = "0.12.0"
3
+ version = "0.13.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,194 @@
1
+ """Tests for manual lifecycle hooks (Rails-style subset).
2
+
3
+ Hooks covered:
4
+ before_create, after_create
5
+ before_update, after_update
6
+ before_save, after_save
7
+ around_save (context manager)
8
+ before_delete, after_delete, around_delete (context manager)
9
+
10
+ Ordering expectation (create):
11
+ before_create -> before_save -> around_save_before -> after_create -> after_save -> around_save_after
12
+ Ordering expectation (update):
13
+ before_update -> before_save -> around_save_before -> after_update -> after_save -> around_save_after
14
+ Ordering expectation (delete):
15
+ before_delete -> around_delete_before -> after_delete -> around_delete_after
16
+ """
17
+
18
+ from contextlib import contextmanager
19
+ from sqlmodel import Field, Relationship
20
+
21
+ import pytest
22
+ from activemodel import BaseModel
23
+ from activemodel.pytest.transaction import database_reset_transaction
24
+ from activemodel.types.typeid import TypeIDType
25
+
26
+ from .models import AnotherExample
27
+
28
+ # simple event capture list used by the test model hooks
29
+ events: list[str] = []
30
+
31
+
32
+ @pytest.fixture(autouse=True)
33
+ def setup_database(create_and_wipe_database):
34
+ """Ensure clean database state for each test"""
35
+ events.clear()
36
+ yield from database_reset_transaction()
37
+
38
+
39
+ class LifecycleModelWithRelationships(BaseModel, table=True):
40
+ """Model used to test after_save accessing a relationship.
41
+
42
+ Intentionally written so that the after_save hook closes the session then attempts
43
+ to lazy-load the relationship, which should raise a SQLAlchemy error (DetachedInstanceError).
44
+ """
45
+
46
+ id: int | None = Field(default=None, primary_key=True)
47
+ note: str | None = Field(default=None)
48
+ another_example_id: TypeIDType = AnotherExample.foreign_key()
49
+ another_example: AnotherExample = Relationship(
50
+ sa_relationship_kwargs={"load_on_pending": True}
51
+ )
52
+
53
+ def log_self_and_relationships(self):
54
+ from activemodel.logger import logger
55
+
56
+ logger.info("self.note=%s", self.note)
57
+ logger.info("another_example.note=%s", self.another_example.note)
58
+
59
+ def before_create(self):
60
+ events.append("before_create")
61
+ self.log_self_and_relationships()
62
+
63
+ def before_update(self):
64
+ events.append("before_update")
65
+ self.log_self_and_relationships()
66
+
67
+ def before_save(self):
68
+ events.append("before_save")
69
+ self.log_self_and_relationships()
70
+
71
+ def after_save(self):
72
+ events.append("after_save")
73
+ self.log_self_and_relationships()
74
+
75
+ def after_create(self):
76
+ events.append("after_create")
77
+ self.log_self_and_relationships()
78
+
79
+ def after_update(self):
80
+ events.append("after_update")
81
+ self.log_self_and_relationships()
82
+
83
+
84
+ class LifecycleModel(BaseModel, table=True):
85
+ id: int | None = Field(default=None, primary_key=True)
86
+ name: str | None = None
87
+
88
+ # Each hook appends its name; BaseModel's event wrapper will call with the appropriate args.
89
+ def before_create(self):
90
+ events.append("before_create")
91
+
92
+ def before_update(self):
93
+ events.append("before_update")
94
+
95
+ def before_save(self):
96
+ events.append("before_save")
97
+
98
+ def after_create(self):
99
+ events.append("after_create")
100
+
101
+ def after_update(self):
102
+ events.append("after_update")
103
+
104
+ def after_save(self):
105
+ events.append("after_save")
106
+
107
+ @contextmanager
108
+ def around_save(self):
109
+ events.append("around_save_before")
110
+ try:
111
+ yield
112
+ finally:
113
+ events.append("around_save_after")
114
+
115
+
116
+ def test_create_lifecycle_hooks():
117
+ LifecycleModel(name="first").save()
118
+
119
+ assert events == [
120
+ "before_create",
121
+ "before_save",
122
+ "around_save_before",
123
+ "around_save_after",
124
+ "after_create",
125
+ "after_save",
126
+ ]
127
+
128
+ assert "before_update" not in events
129
+ assert "after_update" not in events
130
+
131
+
132
+ def test_update_lifecycle_hooks():
133
+ events.clear()
134
+
135
+ obj = LifecycleModel(name="first").save()
136
+
137
+ # Clear after initial insert so we isolate update events.
138
+ events.clear()
139
+ obj.name = "second"
140
+ obj.save()
141
+
142
+ assert events == [
143
+ "before_update",
144
+ "before_save",
145
+ "around_save_before",
146
+ "around_save_after",
147
+ "after_update",
148
+ "after_save",
149
+ ]
150
+ assert "before_create" not in events
151
+ assert "after_create" not in events
152
+
153
+
154
+ class DeleteModel(BaseModel, table=True):
155
+ id: int | None = Field(default=None, primary_key=True)
156
+
157
+ def before_delete(self):
158
+ events.append("before_delete")
159
+
160
+ def after_delete(self):
161
+ events.append("after_delete")
162
+
163
+ @contextmanager
164
+ def around_delete(self):
165
+ events.append("around_delete_before")
166
+ yield
167
+ events.append("around_delete_after")
168
+
169
+
170
+ def test_delete_hooks():
171
+ obj = DeleteModel().save()
172
+
173
+ events.clear()
174
+ obj.delete()
175
+
176
+ assert events == [
177
+ "before_delete",
178
+ "around_delete_before",
179
+ "around_delete_after",
180
+ "after_delete",
181
+ ]
182
+
183
+
184
+ def test_after_save_with_relationship(db_session):
185
+ parent = AnotherExample(note="parent").save()
186
+
187
+ model_with_relationship = LifecycleModelWithRelationships(
188
+ another_example_id=parent.id
189
+ ).save()
190
+
191
+ # test after_save when the relationship exists
192
+ model_with_relationship.refresh()
193
+ model_with_relationship.note = "a new note"
194
+ model_with_relationship.save()
@@ -1,3 +1,7 @@
1
+ """
2
+ Example models to test various ORM cases
3
+ """
4
+
1
5
  from pydantic import computed_field
2
6
  from sqlalchemy import UniqueConstraint
3
7
  from sqlmodel import Column, Field, Integer, Relationship
@@ -1,7 +1,6 @@
1
1
  from typing import Any, Generator, assert_type
2
2
 
3
3
  import sqlmodel as sm
4
- from sqlalchemy import column
5
4
  from sqlmodel.sql.expression import SelectOfScalar
6
5
 
7
6
  from activemodel.query_wrapper import QueryWrapper
@@ -47,8 +46,8 @@ def test_select_with_args(create_and_wipe_database):
47
46
  result = ExampleRecord.select(sm.func.count()).one()
48
47
 
49
48
  assert result == 0
50
- # TODO shouldn't this fail?
51
- assert_type(result, int)
49
+ # TODO type inference for count() currently returns ExampleRecord | int; skip assert_type until generics fixed
50
+ # assert_type(result, int)
52
51
 
53
52
 
54
53
  # TODO needs to be fixed
@@ -57,5 +56,5 @@ def test_result_types(create_and_wipe_database):
57
56
 
58
57
  ExampleRecord().save()
59
58
 
60
- column_results = sm.select(column("id")).select_from(ExampleRecord)
59
+ # column_results = sm.select(column("id")).select_from(ExampleRecord) # unused until type handling improved
61
60
  # TODO column_results type is unknown
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes