activemodel 0.11.0__tar.gz → 0.12.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 (84) hide show
  1. {activemodel-0.11.0 → activemodel-0.12.0}/.envrc +1 -1
  2. {activemodel-0.11.0 → activemodel-0.12.0}/.github/workflows/build_and_publish.yml +5 -4
  3. {activemodel-0.11.0 → activemodel-0.12.0}/.github/workflows/repo-sync.yml +1 -1
  4. activemodel-0.12.0/.tool-versions +3 -0
  5. {activemodel-0.11.0 → activemodel-0.12.0}/CHANGELOG.md +38 -0
  6. {activemodel-0.11.0 → activemodel-0.12.0}/Makefile +4 -0
  7. activemodel-0.11.0/README.md → activemodel-0.12.0/PKG-INFO +40 -4
  8. activemodel-0.11.0/PKG-INFO → activemodel-0.12.0/README.md +25 -19
  9. {activemodel-0.11.0 → activemodel-0.12.0}/TODO +15 -1
  10. {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/base_model.py +23 -8
  11. activemodel-0.12.0/activemodel/cli/__init__.py +147 -0
  12. {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/mixins/timestamps.py +4 -1
  13. {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/mixins/typeid.py +1 -1
  14. activemodel-0.12.0/activemodel/pytest/__init__.py +2 -0
  15. activemodel-0.12.0/activemodel/pytest/factories.py +102 -0
  16. activemodel-0.12.0/activemodel/pytest/plugin.py +81 -0
  17. activemodel-0.12.0/activemodel/pytest/transaction.py +150 -0
  18. activemodel-0.12.0/activemodel/pytest/truncate.py +147 -0
  19. {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/query_wrapper.py +12 -2
  20. {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/session_manager.py +45 -11
  21. activemodel-0.12.0/activemodel/types/sqlalchemy_protocol.py +10 -0
  22. activemodel-0.12.0/activemodel/types/sqlalchemy_protocol.pyi +132 -0
  23. {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/types/typeid.py +1 -0
  24. activemodel-0.12.0/activemodel/utils.py +29 -0
  25. {activemodel-0.11.0 → activemodel-0.12.0}/pyproject.toml +11 -4
  26. {activemodel-0.11.0 → activemodel-0.12.0}/test/conftest.py +7 -3
  27. activemodel-0.12.0/test/factory_test.py +57 -0
  28. {activemodel-0.11.0 → activemodel-0.12.0}/test/orm_test.py +27 -0
  29. activemodel-0.12.0/test/pytest/pytest_test.py +134 -0
  30. activemodel-0.12.0/test/session_manager_test.py +60 -0
  31. activemodel-0.12.0/test/table_name_test.py +15 -0
  32. {activemodel-0.11.0 → activemodel-0.12.0}/test/test_wrapper.py +17 -0
  33. activemodel-0.12.0/uv.lock +1644 -0
  34. activemodel-0.11.0/.tool-versions +0 -3
  35. activemodel-0.11.0/activemodel/pytest/__init__.py +0 -2
  36. activemodel-0.11.0/activemodel/pytest/transaction.py +0 -63
  37. activemodel-0.11.0/activemodel/pytest/truncate.py +0 -46
  38. activemodel-0.11.0/activemodel/utils.py +0 -65
  39. activemodel-0.11.0/test/session_manager_test.py +0 -22
  40. activemodel-0.11.0/test/table_name_test.py +0 -14
  41. activemodel-0.11.0/uv.lock +0 -1367
  42. {activemodel-0.11.0 → activemodel-0.12.0}/.github/dependabot.yml +0 -0
  43. {activemodel-0.11.0 → activemodel-0.12.0}/.gitignore +0 -0
  44. {activemodel-0.11.0 → activemodel-0.12.0}/.vscode/settings.json +0 -0
  45. {activemodel-0.11.0 → activemodel-0.12.0}/Justfile +0 -0
  46. {activemodel-0.11.0 → activemodel-0.12.0}/LICENSE +0 -0
  47. {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/__init__.py +0 -0
  48. {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/celery.py +0 -0
  49. {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/errors.py +0 -0
  50. {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/get_column_from_field_patch.py +0 -0
  51. {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/logger.py +0 -0
  52. {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/mixins/__init__.py +0 -0
  53. {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/mixins/pydantic_json.py +0 -0
  54. {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/mixins/soft_delete.py +0 -0
  55. {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/types/__init__.py +0 -0
  56. {activemodel-0.11.0 → activemodel-0.12.0}/activemodel/types/typeid_patch.py +0 -0
  57. {activemodel-0.11.0 → activemodel-0.12.0}/docker-compose.yml +0 -0
  58. {activemodel-0.11.0 → activemodel-0.12.0}/playground/alternative_typeid_mixin.py +0 -0
  59. {activemodel-0.11.0 → activemodel-0.12.0}/playground/comments.py +0 -0
  60. {activemodel-0.11.0 → activemodel-0.12.0}/playground/env-with-model.patch +0 -0
  61. {activemodel-0.11.0 → activemodel-0.12.0}/playground/extract_comments.py +0 -0
  62. {activemodel-0.11.0 → activemodel-0.12.0}/playground/field.py +0 -0
  63. {activemodel-0.11.0 → activemodel-0.12.0}/playground/middleware.py +0 -0
  64. {activemodel-0.11.0 → activemodel-0.12.0}/playground/old_session_manager.py +0 -0
  65. {activemodel-0.11.0 → activemodel-0.12.0}/playground/pydantic_validation.py +0 -0
  66. {activemodel-0.11.0 → activemodel-0.12.0}/playground.py +0 -0
  67. {activemodel-0.11.0 → activemodel-0.12.0}/test/__init__.py +0 -0
  68. {activemodel-0.11.0 → activemodel-0.12.0}/test/comments_test.py +0 -0
  69. {activemodel-0.11.0 → activemodel-0.12.0}/test/delete_test.py +0 -0
  70. {activemodel-0.11.0 → activemodel-0.12.0}/test/fastapi_test.py +0 -0
  71. {activemodel-0.11.0 → activemodel-0.12.0}/test/import_test.py +0 -0
  72. {activemodel-0.11.0 → activemodel-0.12.0}/test/migrations/README +0 -0
  73. {activemodel-0.11.0 → activemodel-0.12.0}/test/migrations/alembic.ini +0 -0
  74. {activemodel-0.11.0 → activemodel-0.12.0}/test/migrations/env.py +0 -0
  75. {activemodel-0.11.0 → activemodel-0.12.0}/test/migrations/script.py.mako +0 -0
  76. {activemodel-0.11.0 → activemodel-0.12.0}/test/migrations_test.py +0 -0
  77. {activemodel-0.11.0 → activemodel-0.12.0}/test/models.py +0 -0
  78. {activemodel-0.11.0 → activemodel-0.12.0}/test/mutation_test.py +0 -0
  79. {activemodel-0.11.0 → activemodel-0.12.0}/test/nested_pydantic_json_test.py +0 -0
  80. {activemodel-0.11.0 → activemodel-0.12.0}/test/orm/test_upsert.py +0 -0
  81. {activemodel-0.11.0 → activemodel-0.12.0}/test/types/typeid_mixin_test.py +0 -0
  82. {activemodel-0.11.0 → activemodel-0.12.0}/test/types/typeid_pydantic_test.py +0 -0
  83. {activemodel-0.11.0 → activemodel-0.12.0}/test/types/typeid_sqlmodel_test.py +0 -0
  84. {activemodel-0.11.0 → activemodel-0.12.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,43 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.12.0](https://github.com/iloveitaly/activemodel/compare/v0.11.0...v0.12.0) (2025-09-03)
4
+
5
+
6
+ ### Features
7
+
8
+ * add ActiveModelFactory helpers for session management and typeid generation ([a6b9915](https://github.com/iloveitaly/activemodel/commit/a6b9915cc0c74c3b0d1421bc3d0300c1a2f63426))
9
+ * add base SQLModel and ActiveModel polyfactory factories ([12a5e1d](https://github.com/iloveitaly/activemodel/commit/12a5e1db9be741f12db174d5a25f6c2eee61a446))
10
+ * add one_or_none method to BaseModel for safe queries ([7574777](https://github.com/iloveitaly/activemodel/commit/75747773f99a7a6cc82d2ca784ce607abd7ae511))
11
+ * add pytest db_session fixture ([22d2ad8](https://github.com/iloveitaly/activemodel/commit/22d2ad8cbd77260c1c73524f3251ef7fd145caec))
12
+ * add scalar method to QueryWrapper and return from delete ([3d68097](https://github.com/iloveitaly/activemodel/commit/3d680972b6a60b43de6d3826d4289b0f81847b74))
13
+ * add SQLAlchemy protocol generation script ([6e4524e](https://github.com/iloveitaly/activemodel/commit/6e4524ed73644bea5b837ce2a9a7a1994b19df00))
14
+ * add test_session context manager for test DB interactions ([d7a8112](https://github.com/iloveitaly/activemodel/commit/d7a8112f6b303a50df62ca55a0ca8a9c864e4686))
15
+ * export test_session from activemodel.pytest ([9bf692a](https://github.com/iloveitaly/activemodel/commit/9bf692a72937d9a87a1ba2e574cff6dc4000bafd))
16
+ * **pytest:** omit truncation tables, truncation db fixture ([f20f17e](https://github.com/iloveitaly/activemodel/commit/f20f17ea0854dfbde09e86f9446d57d33ab516e8))
17
+ * support engine options ([9b00ecd](https://github.com/iloveitaly/activemodel/commit/9b00ecd4a7b1ac95a6a16b1e7727ef37afe81e00))
18
+ * support passing session to global_session context manager ([567708a](https://github.com/iloveitaly/activemodel/commit/567708aeb6642e75b21932d599cd1431f62c4c65))
19
+
20
+
21
+ ### Bug Fixes
22
+
23
+ * add return type to ActiveModelFactory save method ([96f4a0a](https://github.com/iloveitaly/activemodel/commit/96f4a0a70eb72b14dea8df16b9e92a4b79122285))
24
+ * add ruff link + check on autogenerated types ([91ae5ea](https://github.com/iloveitaly/activemodel/commit/91ae5ea079533dc7e5600cf99c757ec5c6cd872f))
25
+ * allow reentrant global_session when using same session reference ([d4d7949](https://github.com/iloveitaly/activemodel/commit/d4d79493afdf7ef6ae36f9f6d1c2949eb2b7c393))
26
+ * clarify duplicate TypeID prefix error message ([a73c221](https://github.com/iloveitaly/activemodel/commit/a73c221fd51c1efa70812eb1a21a96f0af7faadc))
27
+ * prevent factories from setting timestamp fields by default ([5d78745](https://github.com/iloveitaly/activemodel/commit/5d7874506047b7ae91f22566e1dc2c64d68814f4))
28
+
29
+
30
+ ### Documentation
31
+
32
+ * added related issue ([3c47e60](https://github.com/iloveitaly/activemodel/commit/3c47e601b8f3eb8a85d9cb84a3e64df68ea10d90))
33
+ * clarify session management and querying in usage section ([cdadaff](https://github.com/iloveitaly/activemodel/commit/cdadafffa1ac835f2f6419f40de5bf2f281f94d2))
34
+ * connection pool tip ([5d92b99](https://github.com/iloveitaly/activemodel/commit/5d92b992d4f71af0bf8244826cd4d12281fe350b))
35
+ * fix JSONB usage in example and clarify imports in README ([386e69d](https://github.com/iloveitaly/activemodel/commit/386e69d5ad99baddfba119f62b6d3c6a53809857))
36
+ * improve base model tablename docstring ([de98cbc](https://github.com/iloveitaly/activemodel/commit/de98cbcff0b2ef903207f4c65fb1aa6e014cf40c))
37
+ * **pytest:** clarify ActiveModelFactory.save behavior with relationships and truncation ([6ee2cbe](https://github.com/iloveitaly/activemodel/commit/6ee2cbef23601943271a36a90f2f453de4519c72))
38
+ * **typeid:** clarify exception on invalid UUID string ([bf13a7e](https://github.com/iloveitaly/activemodel/commit/bf13a7e7e537d41a94ff926dcabf316b0d62625b))
39
+ * update TODOs for testing, constraints, and unique key suggestions ([6010f8f](https://github.com/iloveitaly/activemodel/commit/6010f8fec1c3bdb22d68ac3b545ebeafd6bd04ef))
40
+
3
41
  ## [0.11.0](https://github.com/iloveitaly/activemodel/compare/v0.10.0...v0.11.0) (2025-04-05)
4
42
 
5
43
 
@@ -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.12.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
+ ```
@@ -2,10 +2,11 @@ import json
2
2
  import typing as t
3
3
  from uuid import UUID
4
4
 
5
- import pydash
6
5
  import sqlalchemy as sa
7
6
  import sqlmodel as sm
7
+ import textcase
8
8
  from sqlalchemy import Connection, event
9
+ from sqlalchemy.dialects.postgresql import insert as postgres_insert
9
10
  from sqlalchemy.orm import Mapper, declared_attr
10
11
  from sqlalchemy.orm.attributes import flag_modified as sa_flag_modified
11
12
  from sqlalchemy.orm.base import instance_state
@@ -19,7 +20,6 @@ from . import get_column_from_field_patch # noqa: F401
19
20
  from .logger import logger
20
21
  from .query_wrapper import QueryWrapper
21
22
  from .session_manager import get_session
22
- from sqlalchemy.dialects.postgresql import insert as postgres_insert
23
23
 
24
24
  POSTGRES_INDEXES_NAMING_CONVENTION = {
25
25
  "ix": "%(column_0_label)s_idx",
@@ -152,19 +152,19 @@ class BaseModel(SQLModel):
152
152
  @declared_attr
153
153
  def __tablename__(cls) -> str:
154
154
  """
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:
155
+ Automatically generates the table name for the model by converting the model's class name from camel case to snake case.
156
+ This is the recommended text case style for table names:
157
157
 
158
158
  https://wiki.postgresql.org/wiki/Don%27t_Do_This#Don.27t_use_upper_case_table_or_column_names
159
159
 
160
- By default, the class is lower cased which makes it harder to read.
160
+ By default, the model's class name is lower cased which makes it harder to read.
161
161
 
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.
162
+ Also, many text case conversion libraries struggle handling words like "LLMCache", this is why we are using
163
+ a more precise library which processes such acronyms: [`textcase`](https://pypi.org/project/textcase/).
164
164
 
165
165
  https://stackoverflow.com/questions/1175208/elegant-python-function-to-convert-camelcase-to-snake-case
166
166
  """
167
- return pydash.strings.snake_case(cls.__name__)
167
+ return textcase.snake(cls.__name__)
168
168
 
169
169
  @classmethod
170
170
  def foreign_key(cls, **kwargs):
@@ -234,6 +234,8 @@ class BaseModel(SQLModel):
234
234
  return result
235
235
 
236
236
  def delete(self):
237
+ "Delete record completely from the database"
238
+
237
239
  with get_session() as session:
238
240
  if old_session := Session.object_session(self):
239
241
  old_session.expunge(self)
@@ -404,6 +406,19 @@ class BaseModel(SQLModel):
404
406
  with get_session() as session:
405
407
  return session.exec(statement).first()
406
408
 
409
+ @classmethod
410
+ def one_or_none(cls, *args: t.Any, **kwargs: t.Any):
411
+ """
412
+ Gets a single record from the database. Pass an PK ID or a kwarg to filter by.
413
+ Returns None if no record is found. Throws an error if more than one record is found.
414
+ """
415
+
416
+ args, kwargs = cls.__process_filter_args__(*args, **kwargs)
417
+ statement = select(cls).filter(*args).filter_by(**kwargs)
418
+
419
+ with get_session() as session:
420
+ return session.exec(statement).one_or_none()
421
+
407
422
  @classmethod
408
423
  def one(cls, *args: t.Any, **kwargs: t.Any):
409
424
  """
@@ -0,0 +1,147 @@
1
+ """
2
+ This module provides utilities for generating Protocol type definitions for SQLAlchemy's
3
+ SelectOfScalar methods, as well as formatting and fixing Python files using ruff.
4
+ """
5
+
6
+ import inspect
7
+ import logging
8
+ import os
9
+ import subprocess
10
+ from pathlib import Path
11
+ from typing import Any # already imported in header of generated file
12
+
13
+ import sqlmodel as sm
14
+ from sqlmodel.sql.expression import SelectOfScalar
15
+
16
+ from test.test_wrapper import QueryWrapper
17
+
18
+ # Set up logging
19
+ logging.basicConfig(level=logging.DEBUG)
20
+ logger = logging.getLogger(__name__)
21
+
22
+ QUERY_WRAPPER_CLASS_NAME = QueryWrapper.__name__
23
+
24
+
25
+ def format_python_file(file_path: str | Path) -> bool:
26
+ """
27
+ Format a Python file using ruff.
28
+
29
+ Args:
30
+ file_path: Path to the Python file to format
31
+
32
+ Returns:
33
+ bool: True if formatting was successful, False otherwise
34
+ """
35
+ try:
36
+ subprocess.run(["ruff", "format", str(file_path)], check=True)
37
+ logger.info(f"Formatted file using ruff at {file_path}")
38
+ return True
39
+ except subprocess.CalledProcessError as e:
40
+ logger.error(f"Error running ruff to format the file: {e}")
41
+ return False
42
+
43
+
44
+ def fix_python_file(file_path: str | Path) -> bool:
45
+ """
46
+ Fix linting issues in a Python file using ruff.
47
+
48
+ Args:
49
+ file_path: Path to the Python file to fix
50
+
51
+ Returns:
52
+ bool: True if fixing was successful, False otherwise
53
+ """
54
+ try:
55
+ subprocess.run(["ruff", "check", str(file_path), "--fix"], check=True)
56
+ logger.info(f"Fixed linting issues using ruff at {file_path}")
57
+ return True
58
+ except subprocess.CalledProcessError as e:
59
+ logger.error(f"Error running ruff to fix the file: {e}")
60
+ return False
61
+
62
+
63
+ def generate_sqlalchemy_protocol():
64
+ """Generate Protocol type definitions for SQLAlchemy SelectOfScalar methods"""
65
+ logger.info("Starting SQLAlchemy protocol generation")
66
+
67
+ header = """
68
+ # IMPORTANT: This file is auto-generated. Do not edit directly.
69
+
70
+ from typing import Protocol, TypeVar, Any, Generic
71
+ import sqlmodel as sm
72
+ from sqlalchemy.sql.base import _NoArg
73
+
74
+ T = TypeVar('T', bound=sm.SQLModel, covariant=True)
75
+
76
+ class SQLAlchemyQueryMethods(Protocol, Generic[T]):
77
+ \"""Protocol defining SQLAlchemy query methods forwarded by QueryWrapper.__getattr__\"""
78
+ """
79
+ # Initialize output list for generated method signatures
80
+ output: list = []
81
+
82
+ try:
83
+ # Get all methods from SelectOfScalar
84
+ methods = inspect.getmembers(SelectOfScalar)
85
+ logger.debug(f"Discovered {len(methods)} methods from SelectOfScalar")
86
+
87
+ for name, method in methods:
88
+ # Skip private/dunder methods
89
+ if name.startswith("_"):
90
+ continue
91
+
92
+ if not inspect.isfunction(method) and not inspect.ismethod(method):
93
+ logger.debug(f"Skipping non-method: {name}")
94
+ continue
95
+
96
+ logger.debug(f"Processing method: {name}")
97
+ try:
98
+ signature = inspect.signature(method)
99
+ params = []
100
+
101
+ # Process parameters, skipping 'self'
102
+ for param_name, param in list(signature.parameters.items())[1:]:
103
+ if param.kind == param.VAR_POSITIONAL:
104
+ params.append(f"*{param_name}: Any")
105
+ elif param.kind == param.VAR_KEYWORD:
106
+ params.append(f"**{param_name}: Any")
107
+ else:
108
+ if param.default is inspect.Parameter.empty:
109
+ params.append(f"{param_name}: Any")
110
+ else:
111
+ default_repr = repr(param.default)
112
+ params.append(f"{param_name}: Any = {default_repr}")
113
+
114
+ params_str = ", ".join(params)
115
+ output.append(
116
+ f' def {name}(self, {params_str}) -> "{QUERY_WRAPPER_CLASS_NAME}[T]": ...'
117
+ )
118
+ except (ValueError, TypeError) as e:
119
+ logger.warning(f"Could not get signature for {name}: {e}")
120
+ # Some methods might not have proper signatures
121
+ output.append(
122
+ f' def {name}(self, *args: Any, **kwargs: Any) -> "{QUERY_WRAPPER_CLASS_NAME}[T]": ...'
123
+ )
124
+
125
+ # Write the output to a file
126
+ protocol_path = (
127
+ Path(__file__).parent.parent / "types" / "sqlalchemy_protocol.py"
128
+ )
129
+
130
+ # Ensure directory exists
131
+ os.makedirs(protocol_path.parent, exist_ok=True)
132
+
133
+ with open(protocol_path, "w") as f:
134
+ f.write(header + "\n".join(output))
135
+
136
+ logger.info(f"Generated SQLAlchemy protocol at {protocol_path}")
137
+
138
+ # Format and fix the generated file with ruff
139
+ format_python_file(protocol_path)
140
+ fix_python_file(protocol_path)
141
+ except Exception as e:
142
+ logger.error(f"Error generating SQLAlchemy protocol: {e}", exc_info=True)
143
+ raise
144
+
145
+
146
+ if __name__ == "__main__":
147
+ generate_sqlalchemy_protocol()
@@ -20,7 +20,10 @@ class TimestampsMixin:
20
20
  >>> class MyModel(TimestampsMixin, SQLModel):
21
21
  >>> pass
22
22
 
23
- Originally pulled from: https://github.com/tiangolo/sqlmodel/issues/252
23
+ Notes:
24
+
25
+ - Originally pulled from: https://github.com/tiangolo/sqlmodel/issues/252
26
+ - Related issue: https://github.com/fastapi/sqlmodel/issues/539
24
27
  """
25
28
 
26
29
  created_at: datetime | None = Field(
@@ -17,7 +17,7 @@ def TypeIDMixin(prefix: str):
17
17
  # NOTE this will cause issues on code reloads
18
18
  assert prefix
19
19
  assert prefix not in _prefixes, (
20
- f"prefix {prefix} already exists, pick a different one"
20
+ f"TypeID prefix '{prefix}' already exists, pick a different one"
21
21
  )
22
22
 
23
23
  class _TypeIDMixin:
@@ -0,0 +1,2 @@
1
+ from .transaction import database_reset_transaction, test_session
2
+ from .truncate import database_reset_truncate
@@ -0,0 +1,102 @@
1
+ """
2
+ Notes on polyfactory:
3
+
4
+ 1. is_supported_type validates that the class can be used to generate a factory
5
+ https://github.com/litestar-org/polyfactory/issues/655#issuecomment-2727450854
6
+ """
7
+
8
+ import typing as t
9
+
10
+ from polyfactory.factories.pydantic_factory import ModelFactory
11
+ from polyfactory.field_meta import FieldMeta
12
+ from typeid import TypeID
13
+
14
+ from activemodel.session_manager import global_session
15
+
16
+ # TODO not currently used
17
+ # def type_id_provider(cls, field_meta):
18
+ # # TODO this doesn't work well with __ args:
19
+ # # https://github.com/litestar-org/polyfactory/pull/666/files
20
+ # return str(TypeID("hi"))
21
+
22
+
23
+ # BaseFactory.add_provider(TypeIDType, type_id_provider)
24
+
25
+
26
+ class SQLModelFactory[T](ModelFactory[T]):
27
+ """
28
+ Base factory for SQLModel models:
29
+
30
+ 1. Ability to ignore all relationship fks
31
+ 2. Option to ignore all pks
32
+ """
33
+
34
+ __is_base_factory__ = True
35
+
36
+ @classmethod
37
+ def should_set_field_value(cls, field_meta: FieldMeta, **kwargs: t.Any) -> bool:
38
+ # TODO what is this checking for?
39
+ has_object_override = hasattr(cls, field_meta.name)
40
+
41
+ # TODO this should be more intelligent, it's goal is to detect all of the relationship field and avoid settings them
42
+ if not has_object_override and (
43
+ field_meta.name == "id" or field_meta.name.endswith("_id")
44
+ ):
45
+ return False
46
+
47
+ return super().should_set_field_value(field_meta, **kwargs)
48
+
49
+
50
+ # TODO we need to think through how to handle relationships and autogenerate them
51
+ class ActiveModelFactory[T](SQLModelFactory[T]):
52
+ __is_base_factory__ = True
53
+ __sqlalchemy_session__ = None
54
+
55
+ # TODO we shouldn't have to type this, but `save()` typing is not working
56
+ @classmethod
57
+ def save(cls, *args, **kwargs) -> T:
58
+ """
59
+ Where this gets tricky, is this can be called multiple times within the same callstack. This can happen when
60
+ a factory uses other factories to create relationships.
61
+
62
+ In a truncation strategy, the __sqlalchemy_session__ is set to None.
63
+ """
64
+ with global_session(cls.__sqlalchemy_session__):
65
+ return cls.build(*args, **kwargs).save()
66
+
67
+ @classmethod
68
+ def foreign_key_typeid(cls):
69
+ """
70
+ Return a random type id for the foreign key on this model.
71
+
72
+ This is helpful for generating TypeIDs for testing 404s, parsing, manually settings, etc
73
+ """
74
+ # TODO right now assumes the model is typeid, maybe we should assert against this?
75
+ primary_key_name = cls.__model__.primary_key_column().name
76
+ return TypeID(
77
+ cls.__model__.model_fields[primary_key_name].sa_column.type.prefix
78
+ )
79
+
80
+ @classmethod
81
+ def should_set_field_value(cls, field_meta: FieldMeta, **kwargs: t.Any) -> bool:
82
+ # do not default deleted at mixin to deleted!
83
+ # TODO should be smarter about detecting if the mixin is in place
84
+ if field_meta.name in ["deleted_at", "updated_at", "created_at"]:
85
+ return False
86
+
87
+ return super().should_set_field_value(field_meta, **kwargs)
88
+
89
+ # @classmethod
90
+ # def build(
91
+ # cls,
92
+ # factory_use_construct: bool | None = None,
93
+ # sqlmodel_save: bool = False,
94
+ # **kwargs: t.Any,
95
+ # ) -> T:
96
+ # result = super().build(factory_use_construct=factory_use_construct, **kwargs)
97
+
98
+ # # TODO allow magic dunder method here
99
+ # if sqlmodel_save:
100
+ # result.save()
101
+
102
+ # return result