activemodel 0.11.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 (85) hide show
  1. {activemodel-0.11.0 → activemodel-0.13.0}/.envrc +1 -1
  2. {activemodel-0.11.0 → activemodel-0.13.0}/.github/workflows/build_and_publish.yml +5 -4
  3. {activemodel-0.11.0 → activemodel-0.13.0}/.github/workflows/repo-sync.yml +1 -1
  4. activemodel-0.13.0/.tool-versions +3 -0
  5. {activemodel-0.11.0 → activemodel-0.13.0}/CHANGELOG.md +45 -0
  6. {activemodel-0.11.0 → activemodel-0.13.0}/Makefile +4 -0
  7. activemodel-0.11.0/README.md → activemodel-0.13.0/PKG-INFO +40 -4
  8. activemodel-0.11.0/PKG-INFO → activemodel-0.13.0/README.md +25 -19
  9. {activemodel-0.11.0 → activemodel-0.13.0}/TODO +15 -1
  10. {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/base_model.py +113 -88
  11. activemodel-0.13.0/activemodel/cli/__init__.py +147 -0
  12. {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/mixins/timestamps.py +4 -1
  13. {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/mixins/typeid.py +1 -1
  14. activemodel-0.13.0/activemodel/pytest/__init__.py +2 -0
  15. activemodel-0.13.0/activemodel/pytest/factories.py +102 -0
  16. activemodel-0.13.0/activemodel/pytest/plugin.py +81 -0
  17. activemodel-0.13.0/activemodel/pytest/transaction.py +150 -0
  18. activemodel-0.13.0/activemodel/pytest/truncate.py +147 -0
  19. {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/query_wrapper.py +12 -2
  20. {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/session_manager.py +45 -11
  21. activemodel-0.13.0/activemodel/types/sqlalchemy_protocol.py +10 -0
  22. activemodel-0.13.0/activemodel/types/sqlalchemy_protocol.pyi +132 -0
  23. {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/types/typeid.py +1 -0
  24. activemodel-0.13.0/activemodel/utils.py +29 -0
  25. {activemodel-0.11.0 → activemodel-0.13.0}/pyproject.toml +11 -4
  26. {activemodel-0.11.0 → activemodel-0.13.0}/test/conftest.py +7 -3
  27. activemodel-0.13.0/test/factory_test.py +57 -0
  28. activemodel-0.13.0/test/lifecycle_test.py +194 -0
  29. {activemodel-0.11.0 → activemodel-0.13.0}/test/models.py +4 -0
  30. {activemodel-0.11.0 → activemodel-0.13.0}/test/orm_test.py +27 -0
  31. activemodel-0.13.0/test/pytest/pytest_test.py +134 -0
  32. activemodel-0.13.0/test/session_manager_test.py +60 -0
  33. activemodel-0.13.0/test/table_name_test.py +15 -0
  34. activemodel-0.11.0/test/test_wrapper.py → activemodel-0.13.0/test/test_query_wrapper.py +20 -4
  35. activemodel-0.13.0/uv.lock +1644 -0
  36. activemodel-0.11.0/.tool-versions +0 -3
  37. activemodel-0.11.0/activemodel/pytest/__init__.py +0 -2
  38. activemodel-0.11.0/activemodel/pytest/transaction.py +0 -63
  39. activemodel-0.11.0/activemodel/pytest/truncate.py +0 -46
  40. activemodel-0.11.0/activemodel/utils.py +0 -65
  41. activemodel-0.11.0/test/session_manager_test.py +0 -22
  42. activemodel-0.11.0/test/table_name_test.py +0 -14
  43. activemodel-0.11.0/uv.lock +0 -1367
  44. {activemodel-0.11.0 → activemodel-0.13.0}/.github/dependabot.yml +0 -0
  45. {activemodel-0.11.0 → activemodel-0.13.0}/.gitignore +0 -0
  46. {activemodel-0.11.0 → activemodel-0.13.0}/.vscode/settings.json +0 -0
  47. {activemodel-0.11.0 → activemodel-0.13.0}/Justfile +0 -0
  48. {activemodel-0.11.0 → activemodel-0.13.0}/LICENSE +0 -0
  49. {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/__init__.py +0 -0
  50. {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/celery.py +0 -0
  51. {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/errors.py +0 -0
  52. {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/get_column_from_field_patch.py +0 -0
  53. {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/logger.py +0 -0
  54. {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/mixins/__init__.py +0 -0
  55. {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/mixins/pydantic_json.py +0 -0
  56. {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/mixins/soft_delete.py +0 -0
  57. {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/types/__init__.py +0 -0
  58. {activemodel-0.11.0 → activemodel-0.13.0}/activemodel/types/typeid_patch.py +0 -0
  59. {activemodel-0.11.0 → activemodel-0.13.0}/docker-compose.yml +0 -0
  60. {activemodel-0.11.0 → activemodel-0.13.0}/playground/alternative_typeid_mixin.py +0 -0
  61. {activemodel-0.11.0 → activemodel-0.13.0}/playground/comments.py +0 -0
  62. {activemodel-0.11.0 → activemodel-0.13.0}/playground/env-with-model.patch +0 -0
  63. {activemodel-0.11.0 → activemodel-0.13.0}/playground/extract_comments.py +0 -0
  64. {activemodel-0.11.0 → activemodel-0.13.0}/playground/field.py +0 -0
  65. {activemodel-0.11.0 → activemodel-0.13.0}/playground/middleware.py +0 -0
  66. {activemodel-0.11.0 → activemodel-0.13.0}/playground/old_session_manager.py +0 -0
  67. {activemodel-0.11.0 → activemodel-0.13.0}/playground/pydantic_validation.py +0 -0
  68. {activemodel-0.11.0 → activemodel-0.13.0}/playground.py +0 -0
  69. {activemodel-0.11.0 → activemodel-0.13.0}/test/__init__.py +0 -0
  70. {activemodel-0.11.0 → activemodel-0.13.0}/test/comments_test.py +0 -0
  71. {activemodel-0.11.0 → activemodel-0.13.0}/test/delete_test.py +0 -0
  72. {activemodel-0.11.0 → activemodel-0.13.0}/test/fastapi_test.py +0 -0
  73. {activemodel-0.11.0 → activemodel-0.13.0}/test/import_test.py +0 -0
  74. {activemodel-0.11.0 → activemodel-0.13.0}/test/migrations/README +0 -0
  75. {activemodel-0.11.0 → activemodel-0.13.0}/test/migrations/alembic.ini +0 -0
  76. {activemodel-0.11.0 → activemodel-0.13.0}/test/migrations/env.py +0 -0
  77. {activemodel-0.11.0 → activemodel-0.13.0}/test/migrations/script.py.mako +0 -0
  78. {activemodel-0.11.0 → activemodel-0.13.0}/test/migrations_test.py +0 -0
  79. {activemodel-0.11.0 → activemodel-0.13.0}/test/mutation_test.py +0 -0
  80. {activemodel-0.11.0 → activemodel-0.13.0}/test/nested_pydantic_json_test.py +0 -0
  81. {activemodel-0.11.0 → activemodel-0.13.0}/test/orm/test_upsert.py +0 -0
  82. {activemodel-0.11.0 → activemodel-0.13.0}/test/types/typeid_mixin_test.py +0 -0
  83. {activemodel-0.11.0 → activemodel-0.13.0}/test/types/typeid_pydantic_test.py +0 -0
  84. {activemodel-0.11.0 → activemodel-0.13.0}/test/types/typeid_sqlmodel_test.py +0 -0
  85. {activemodel-0.11.0 → activemodel-0.13.0}/test/utils.py +0 -0
@@ -10,5 +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
-
13
+ export PYTHONPATH=.
14
14
  # export ACTIVEMODEL_LOG_SQL=true
@@ -4,6 +4,7 @@ on:
4
4
  branches:
5
5
  - main
6
6
  - master
7
+ pull_request:
7
8
 
8
9
  env:
9
10
  # avoid build failures due to flaky pypi
@@ -34,8 +35,8 @@ jobs:
34
35
  needs: [release-please]
35
36
  if: needs.release-please.outputs.release_created
36
37
  steps:
37
- - uses: actions/checkout@v4
38
- - uses: jdx/mise-action@v2
38
+ - uses: actions/checkout@v5
39
+ - uses: jdx/mise-action@v3
39
40
  - run: direnv allow . && direnv export gha >> "$GITHUB_ENV"
40
41
  - run: uv build
41
42
  - run: uv publish --token ${{ secrets.PYPI_API_TOKEN }}
@@ -49,8 +50,8 @@ jobs:
49
50
  python-version: ["3.13", "3.12"]
50
51
  runs-on: ${{ matrix.os }}
51
52
  steps:
52
- - uses: actions/checkout@v4
53
- - uses: jdx/mise-action@v2
53
+ - uses: actions/checkout@v5
54
+ - uses: jdx/mise-action@v3
54
55
  - run: mise use python@${{ matrix.python-version }}
55
56
  - run: docker compose up -d --wait
56
57
  - uses: iloveitaly/github-action-direnv-load-and-mask@master
@@ -9,7 +9,7 @@ jobs:
9
9
  runs-on: ubuntu-latest
10
10
  steps:
11
11
  - name: Fetching Local Repository
12
- uses: actions/checkout@v4
12
+ uses: actions/checkout@v5
13
13
  - name: Repository Metadata Sync
14
14
  uses: iloveitaly/github-actions-metadata-sync@main
15
15
  with:
@@ -0,0 +1,3 @@
1
+ python 3.13.6
2
+ uv 0.8.10
3
+ direnv 2.37.1
@@ -1,5 +1,50 @@
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
+
10
+ ## [0.12.0](https://github.com/iloveitaly/activemodel/compare/v0.11.0...v0.12.0) (2025-09-03)
11
+
12
+
13
+ ### Features
14
+
15
+ * add ActiveModelFactory helpers for session management and typeid generation ([a6b9915](https://github.com/iloveitaly/activemodel/commit/a6b9915cc0c74c3b0d1421bc3d0300c1a2f63426))
16
+ * add base SQLModel and ActiveModel polyfactory factories ([12a5e1d](https://github.com/iloveitaly/activemodel/commit/12a5e1db9be741f12db174d5a25f6c2eee61a446))
17
+ * add one_or_none method to BaseModel for safe queries ([7574777](https://github.com/iloveitaly/activemodel/commit/75747773f99a7a6cc82d2ca784ce607abd7ae511))
18
+ * add pytest db_session fixture ([22d2ad8](https://github.com/iloveitaly/activemodel/commit/22d2ad8cbd77260c1c73524f3251ef7fd145caec))
19
+ * add scalar method to QueryWrapper and return from delete ([3d68097](https://github.com/iloveitaly/activemodel/commit/3d680972b6a60b43de6d3826d4289b0f81847b74))
20
+ * add SQLAlchemy protocol generation script ([6e4524e](https://github.com/iloveitaly/activemodel/commit/6e4524ed73644bea5b837ce2a9a7a1994b19df00))
21
+ * add test_session context manager for test DB interactions ([d7a8112](https://github.com/iloveitaly/activemodel/commit/d7a8112f6b303a50df62ca55a0ca8a9c864e4686))
22
+ * export test_session from activemodel.pytest ([9bf692a](https://github.com/iloveitaly/activemodel/commit/9bf692a72937d9a87a1ba2e574cff6dc4000bafd))
23
+ * **pytest:** omit truncation tables, truncation db fixture ([f20f17e](https://github.com/iloveitaly/activemodel/commit/f20f17ea0854dfbde09e86f9446d57d33ab516e8))
24
+ * support engine options ([9b00ecd](https://github.com/iloveitaly/activemodel/commit/9b00ecd4a7b1ac95a6a16b1e7727ef37afe81e00))
25
+ * support passing session to global_session context manager ([567708a](https://github.com/iloveitaly/activemodel/commit/567708aeb6642e75b21932d599cd1431f62c4c65))
26
+
27
+
28
+ ### Bug Fixes
29
+
30
+ * add return type to ActiveModelFactory save method ([96f4a0a](https://github.com/iloveitaly/activemodel/commit/96f4a0a70eb72b14dea8df16b9e92a4b79122285))
31
+ * add ruff link + check on autogenerated types ([91ae5ea](https://github.com/iloveitaly/activemodel/commit/91ae5ea079533dc7e5600cf99c757ec5c6cd872f))
32
+ * allow reentrant global_session when using same session reference ([d4d7949](https://github.com/iloveitaly/activemodel/commit/d4d79493afdf7ef6ae36f9f6d1c2949eb2b7c393))
33
+ * clarify duplicate TypeID prefix error message ([a73c221](https://github.com/iloveitaly/activemodel/commit/a73c221fd51c1efa70812eb1a21a96f0af7faadc))
34
+ * prevent factories from setting timestamp fields by default ([5d78745](https://github.com/iloveitaly/activemodel/commit/5d7874506047b7ae91f22566e1dc2c64d68814f4))
35
+
36
+
37
+ ### Documentation
38
+
39
+ * added related issue ([3c47e60](https://github.com/iloveitaly/activemodel/commit/3c47e601b8f3eb8a85d9cb84a3e64df68ea10d90))
40
+ * clarify session management and querying in usage section ([cdadaff](https://github.com/iloveitaly/activemodel/commit/cdadafffa1ac835f2f6419f40de5bf2f281f94d2))
41
+ * connection pool tip ([5d92b99](https://github.com/iloveitaly/activemodel/commit/5d92b992d4f71af0bf8244826cd4d12281fe350b))
42
+ * fix JSONB usage in example and clarify imports in README ([386e69d](https://github.com/iloveitaly/activemodel/commit/386e69d5ad99baddfba119f62b6d3c6a53809857))
43
+ * improve base model tablename docstring ([de98cbc](https://github.com/iloveitaly/activemodel/commit/de98cbcff0b2ef903207f4c65fb1aa6e014cf40c))
44
+ * **pytest:** clarify ActiveModelFactory.save behavior with relationships and truncation ([6ee2cbe](https://github.com/iloveitaly/activemodel/commit/6ee2cbef23601943271a36a90f2f453de4519c72))
45
+ * **typeid:** clarify exception on invalid UUID string ([bf13a7e](https://github.com/iloveitaly/activemodel/commit/bf13a7e7e537d41a94ff926dcabf316b0d62625b))
46
+ * update TODOs for testing, constraints, and unique key suggestions ([6010f8f](https://github.com/iloveitaly/activemodel/commit/6010f8fec1c3bdb22d68ac3b545ebeafd6bd04ef))
47
+
3
48
  ## [0.11.0](https://github.com/iloveitaly/activemodel/compare/v0.10.0...v0.11.0) (2025-04-05)
4
49
 
5
50
 
@@ -8,6 +8,10 @@ up:
8
8
  db_open:
9
9
  open -a TablePlus $$DATABASE_URL
10
10
 
11
+ lint:
12
+ pyright
13
+ ruff format
14
+
11
15
  clean:
12
16
  rm -rf *.egg-info
13
17
  rm -rf .venv
@@ -1,3 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: activemodel
3
+ Version: 0.13.0
4
+ Summary: Make SQLModel more like an a real ORM
5
+ Project-URL: Repository, https://github.com/iloveitaly/activemodel
6
+ Author-email: Michael Bianco <iloveitaly@gmail.com>
7
+ License-File: LICENSE
8
+ Keywords: activemodel,activerecord,orm,sqlalchemy,sqlmodel
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: python-decouple-typed>=3.11.0
11
+ Requires-Dist: sqlmodel>=0.0.22
12
+ Requires-Dist: textcase>=0.4.0
13
+ Requires-Dist: typeid-python>=0.3.1
14
+ Description-Content-Type: text/markdown
15
+
1
16
  # ActiveModel: ORM Wrapper for SQLModel
2
17
 
3
18
  No, this isn't *really* [ActiveModel](https://guides.rubyonrails.org/active_model_basics.html). It's just a wrapper around SQLModel that provides a more ActiveRecord-like interface.
@@ -44,15 +59,17 @@ from sqlmodel import SQLModel
44
59
 
45
60
  SQLModel.metadata.create_all(get_engine())
46
61
 
47
- # now you can create a user!
62
+ # now you can create a user! without managing sessions!
48
63
  User(a_field="a").save()
49
64
  ```
50
65
 
51
66
  Maybe you like JSON:
52
67
 
53
68
  ```python
54
- from activemodel import BaseModel
69
+ from sqlalchemy.dialects.postgresql import JSONB
55
70
  from pydantic import BaseModel as PydanticBaseModel
71
+
72
+ from activemodel import BaseModel
56
73
  from activemodel.mixins import PydanticJSONMixin, TypeIDMixin, TimestampsMixin
57
74
 
58
75
  class SubObject(PydanticBaseModel):
@@ -66,11 +83,28 @@ class User(
66
83
  TypeIDMixin("user"),
67
84
  table=True
68
85
  ):
69
- list_field: list[SubObject] = Field(sa_type=JSONB())
86
+ list_field: list[SubObject] = Field(sa_type=JSONB)
87
+ ```
88
+
89
+ You'll probably want to query the model. Look ma, no sessions!
90
+
91
+ ```python
92
+ User.where(id="user_123").all()
93
+
94
+ # or, even better, for this case
95
+ User.one("user_123")
70
96
  ```
71
97
 
98
+ Magically creating sessions for DB operations is one of the main problems this project tackles. Even better, you can set
99
+ a single session object to be used for all DB operations. This is helpful for DB transactions, [specifically rolling back
100
+ DB operations on each test.](#pytest)
101
+
72
102
  ## Usage
73
103
 
104
+ ### Pytest
105
+
106
+ TODO detail out truncation and transactions
107
+
74
108
  ### Integrating Alembic
75
109
 
76
110
  `alembic init` will not work out of the box. You need to mutate a handful of files:
@@ -192,6 +226,7 @@ SQLModel & SQLAlchemy are tricky. Here are some useful internal tricks:
192
226
  * Set the value on a field, without marking it as dirty `attributes.set_committed_value(instance, field_name, val)`
193
227
  * Is a model dirty `instance_state(instance).modified`
194
228
  * `select(Table).outerjoin??` won't work in a ipython session, but `Table.__table__.outerjoin??` will. `__table__` is a reference to the underlying SQLAlchemy table record.
229
+ * `get_engine().pool.stats()` is helpful for inspecting connection pools and limits\
195
230
 
196
231
  ### TypeID
197
232
 
@@ -255,6 +290,7 @@ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d
255
290
 
256
291
  * https://github.com/woofz/sqlmodel-basecrud
257
292
  * https://github.com/0xthiagomartins/sqlmodel-controller
293
+ * https://github.com/litestar-org/advanced-alchemy?tab=readme-ov-file
258
294
 
259
295
  ## Inspiration
260
296
 
@@ -267,4 +303,4 @@ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d
267
303
 
268
304
  ## Upstream Changes
269
305
 
270
- - [ ] https://github.com/fastapi/sqlmodel/pull/1293
306
+ - [ ] https://github.com/fastapi/sqlmodel/pull/1293
@@ -1,18 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: activemodel
3
- Version: 0.11.0
4
- Summary: Make SQLModel more like an a real ORM
5
- Project-URL: Repository, https://github.com/iloveitaly/activemodel
6
- Author-email: Michael Bianco <iloveitaly@gmail.com>
7
- License-File: LICENSE
8
- Keywords: activemodel,activerecord,orm,sqlalchemy,sqlmodel
9
- Requires-Python: >=3.10
10
- Requires-Dist: pydash>=8.0.4
11
- Requires-Dist: python-decouple-typed>=3.11.0
12
- Requires-Dist: sqlmodel>=0.0.22
13
- Requires-Dist: typeid-python>=0.3.1
14
- Description-Content-Type: text/markdown
15
-
16
1
  # ActiveModel: ORM Wrapper for SQLModel
17
2
 
18
3
  No, this isn't *really* [ActiveModel](https://guides.rubyonrails.org/active_model_basics.html). It's just a wrapper around SQLModel that provides a more ActiveRecord-like interface.
@@ -59,15 +44,17 @@ from sqlmodel import SQLModel
59
44
 
60
45
  SQLModel.metadata.create_all(get_engine())
61
46
 
62
- # now you can create a user!
47
+ # now you can create a user! without managing sessions!
63
48
  User(a_field="a").save()
64
49
  ```
65
50
 
66
51
  Maybe you like JSON:
67
52
 
68
53
  ```python
69
- from activemodel import BaseModel
54
+ from sqlalchemy.dialects.postgresql import JSONB
70
55
  from pydantic import BaseModel as PydanticBaseModel
56
+
57
+ from activemodel import BaseModel
71
58
  from activemodel.mixins import PydanticJSONMixin, TypeIDMixin, TimestampsMixin
72
59
 
73
60
  class SubObject(PydanticBaseModel):
@@ -81,11 +68,28 @@ class User(
81
68
  TypeIDMixin("user"),
82
69
  table=True
83
70
  ):
84
- list_field: list[SubObject] = Field(sa_type=JSONB())
71
+ list_field: list[SubObject] = Field(sa_type=JSONB)
72
+ ```
73
+
74
+ You'll probably want to query the model. Look ma, no sessions!
75
+
76
+ ```python
77
+ User.where(id="user_123").all()
78
+
79
+ # or, even better, for this case
80
+ User.one("user_123")
85
81
  ```
86
82
 
83
+ Magically creating sessions for DB operations is one of the main problems this project tackles. Even better, you can set
84
+ a single session object to be used for all DB operations. This is helpful for DB transactions, [specifically rolling back
85
+ DB operations on each test.](#pytest)
86
+
87
87
  ## Usage
88
88
 
89
+ ### Pytest
90
+
91
+ TODO detail out truncation and transactions
92
+
89
93
  ### Integrating Alembic
90
94
 
91
95
  `alembic init` will not work out of the box. You need to mutate a handful of files:
@@ -207,6 +211,7 @@ SQLModel & SQLAlchemy are tricky. Here are some useful internal tricks:
207
211
  * Set the value on a field, without marking it as dirty `attributes.set_committed_value(instance, field_name, val)`
208
212
  * Is a model dirty `instance_state(instance).modified`
209
213
  * `select(Table).outerjoin??` won't work in a ipython session, but `Table.__table__.outerjoin??` will. `__table__` is a reference to the underlying SQLAlchemy table record.
214
+ * `get_engine().pool.stats()` is helpful for inspecting connection pools and limits\
210
215
 
211
216
  ### TypeID
212
217
 
@@ -270,6 +275,7 @@ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d
270
275
 
271
276
  * https://github.com/woofz/sqlmodel-basecrud
272
277
  * https://github.com/0xthiagomartins/sqlmodel-controller
278
+ * https://github.com/litestar-org/advanced-alchemy?tab=readme-ov-file
273
279
 
274
280
  ## Inspiration
275
281
 
@@ -282,4 +288,4 @@ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d
282
288
 
283
289
  ## Upstream Changes
284
290
 
285
- - [ ] https://github.com/fastapi/sqlmodel/pull/1293
291
+ - [ ] https://github.com/fastapi/sqlmodel/pull/1293
@@ -10,9 +10,12 @@ Docs are bad:
10
10
 
11
11
  TODO
12
12
 
13
+ - [ ] nested sessions, maybe only with connections
14
+ - [ ] tests for polyfactory support, setting session across all factories
13
15
  - [ ] sessions in tests, they don't work right now
14
16
  - [ ] snake case for attributes https://github.com/sqlalchemy/sqlalchemy/issues/7149
15
-
17
+ - [ ] compound unique constraints don't have great names
18
+ - [ ] https://grok.com/share/bGVnYWN5_7bfd5140-2351-4e4f-a8ba-bdedb59ac1e1 figure out better json tracking
16
19
 
17
20
  find_or_create
18
21
 
@@ -50,3 +53,14 @@ clear engine
50
53
  # _engine = None
51
54
  # _connection = None
52
55
  ```
56
+
57
+ ---
58
+
59
+ There should be a better way to add unique keys
60
+
61
+ ```
62
+ __table_args__ = (
63
+ # users should never see two schemas with the same name
64
+ UniqueConstraint("note_type", "doctor_id", name="doctor_and_note_type_unique"),
65
+ )
66
+ ```
@@ -1,25 +1,23 @@
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
- import pydash
6
7
  import sqlalchemy as sa
7
8
  import sqlmodel as sm
8
- from sqlalchemy import Connection, event
9
- from sqlalchemy.orm import Mapper, declared_attr
9
+ from sqlalchemy.dialects.postgresql import insert as postgres_insert
10
10
  from sqlalchemy.orm.attributes import flag_modified as sa_flag_modified
11
- from sqlalchemy.orm.base import instance_state
12
11
  from sqlmodel import Column, Field, Session, SQLModel, inspect, select
13
12
  from typeid import TypeID
13
+ from sqlalchemy.orm import declared_attr
14
14
 
15
15
  from activemodel.mixins.pydantic_json import PydanticJSONMixin
16
16
 
17
17
  # NOTE: this patches a core method in sqlmodel to support db comments
18
18
  from . import get_column_from_field_patch # noqa: F401
19
- from .logger import logger
20
19
  from .query_wrapper import QueryWrapper
21
20
  from .session_manager import get_session
22
- from sqlalchemy.dialects.postgresql import insert as postgres_insert
23
21
 
24
22
  POSTGRES_INDEXES_NAMING_CONVENTION = {
25
23
  "ix": "%(column_0_label)s_idx",
@@ -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
  """
@@ -152,19 +111,19 @@ class BaseModel(SQLModel):
152
111
  @declared_attr
153
112
  def __tablename__(cls) -> str:
154
113
  """
155
- Automatically generates the table name for the model by converting the class name from camel case to snake case.
156
- This is the recommended format for table names:
114
+ Automatically generates the table name for the model by converting the model's class name from camel case to snake case.
115
+ This is the recommended text case style for table names:
157
116
 
158
117
  https://wiki.postgresql.org/wiki/Don%27t_Do_This#Don.27t_use_upper_case_table_or_column_names
159
118
 
160
- By default, the class is lower cased which makes it harder to read.
119
+ By default, the model's class name is lower cased which makes it harder to read.
161
120
 
162
- Many snake_case libraries struggle with snake case for names like LLMCache, which is why we are using a more
163
- complicated implementation from pydash.
121
+ Also, many text case conversion libraries struggle handling words like "LLMCache", this is why we are using
122
+ a more precise library which processes such acronyms: [`textcase`](https://pypi.org/project/textcase/).
164
123
 
165
124
  https://stackoverflow.com/questions/1175208/elegant-python-function-to-convert-camelcase-to-snake-case
166
125
  """
167
- return pydash.strings.snake_case(cls.__name__)
126
+ return textcase.snake(cls.__name__)
168
127
 
169
128
  @classmethod
170
129
  def foreign_key(cls, **kwargs):
@@ -234,34 +193,81 @@ class BaseModel(SQLModel):
234
193
  return result
235
194
 
236
195
  def delete(self):
196
+ """Delete instance running delete hooks and optional around_delete context manager."""
197
+
198
+ cm = self._get_around_context_manager("around_delete") or nullcontext()
199
+
237
200
  with get_session() as session:
238
- if old_session := Session.object_session(self):
201
+ if (
202
+ old_session := Session.object_session(self)
203
+ ) and old_session is not session:
239
204
  old_session.expunge(self)
240
-
241
205
  session.delete(self)
242
- session.commit()
243
- 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
244
213
 
245
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
+
246
220
  with get_session() as session:
247
- if old_session := Session.object_session(self):
248
- # I was running into an issue where the object was already
249
- # associated with a session, but the session had been closed,
250
- # to get around this, you need to remove it from the old one,
251
- # then add it to the new one (below)
221
+ if (
222
+ old_session := Session.object_session(self)
223
+ ) and old_session is not session:
252
224
  old_session.expunge(self)
253
225
 
254
226
  session.add(self)
255
- # NOTE very important method! This triggers sqlalchemy lifecycle hooks automatically
256
- session.commit()
257
- 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")
258
240
 
259
241
  # Only call the transform method if the class is a subclass of PydanticJSONMixin
260
242
  if issubclass(self.__class__, PydanticJSONMixin):
261
243
  self.__class__.__transform_dict_to_pydantic__(self)
262
-
263
244
  return self
264
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
+
265
271
  def refresh(self):
266
272
  "Refreshes an object from the database"
267
273
 
@@ -282,6 +288,7 @@ class BaseModel(SQLModel):
282
288
 
283
289
  # TODO shouldn't this be handled by pydantic?
284
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?
285
292
  def json(self, **kwargs):
286
293
  return json.dumps(self.dict(), default=str, **kwargs)
287
294
 
@@ -297,7 +304,12 @@ class BaseModel(SQLModel):
297
304
  # TODO got to be a better way to fwd these along...
298
305
  @classmethod
299
306
  def first(cls):
300
- 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()
301
313
 
302
314
  # TODO throw an error if this field is set on the model
303
315
  def is_new(self) -> bool:
@@ -404,6 +416,19 @@ class BaseModel(SQLModel):
404
416
  with get_session() as session:
405
417
  return session.exec(statement).first()
406
418
 
419
+ @classmethod
420
+ def one_or_none(cls, *args: t.Any, **kwargs: t.Any):
421
+ """
422
+ Gets a single record from the database. Pass an PK ID or a kwarg to filter by.
423
+ Returns None if no record is found. Throws an error if more than one record is found.
424
+ """
425
+
426
+ args, kwargs = cls.__process_filter_args__(*args, **kwargs)
427
+ statement = select(cls).filter(*args).filter_by(**kwargs)
428
+
429
+ with get_session() as session:
430
+ return session.exec(statement).one_or_none()
431
+
407
432
  @classmethod
408
433
  def one(cls, *args: t.Any, **kwargs: t.Any):
409
434
  """