activemodel 0.10.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.10.0 → activemodel-0.12.0}/.envrc +1 -1
  2. {activemodel-0.10.0 → activemodel-0.12.0}/.github/workflows/build_and_publish.yml +5 -4
  3. {activemodel-0.10.0 → activemodel-0.12.0}/.github/workflows/repo-sync.yml +1 -1
  4. activemodel-0.12.0/.tool-versions +3 -0
  5. {activemodel-0.10.0 → activemodel-0.12.0}/CHANGELOG.md +58 -0
  6. {activemodel-0.10.0 → activemodel-0.12.0}/Makefile +4 -0
  7. {activemodel-0.10.0 → activemodel-0.12.0}/PKG-INFO +40 -16
  8. {activemodel-0.10.0 → activemodel-0.12.0}/README.md +38 -14
  9. {activemodel-0.10.0 → activemodel-0.12.0}/TODO +15 -1
  10. {activemodel-0.10.0 → activemodel-0.12.0}/activemodel/base_model.py +25 -9
  11. activemodel-0.12.0/activemodel/cli/__init__.py +147 -0
  12. {activemodel-0.10.0 → activemodel-0.12.0}/activemodel/mixins/timestamps.py +4 -1
  13. {activemodel-0.10.0 → activemodel-0.12.0}/activemodel/mixins/typeid.py +10 -2
  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.10.0 → activemodel-0.12.0}/activemodel/query_wrapper.py +12 -2
  20. {activemodel-0.10.0 → activemodel-0.12.0}/activemodel/session_manager.py +64 -14
  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.10.0 → activemodel-0.12.0}/activemodel/types/typeid.py +36 -22
  24. activemodel-0.12.0/activemodel/types/typeid_patch.py +22 -0
  25. activemodel-0.12.0/activemodel/utils.py +29 -0
  26. {activemodel-0.10.0 → activemodel-0.12.0}/pyproject.toml +11 -4
  27. {activemodel-0.10.0 → activemodel-0.12.0}/test/conftest.py +7 -3
  28. activemodel-0.12.0/test/factory_test.py +57 -0
  29. {activemodel-0.10.0 → activemodel-0.12.0}/test/orm_test.py +27 -0
  30. activemodel-0.12.0/test/pytest/pytest_test.py +134 -0
  31. activemodel-0.12.0/test/session_manager_test.py +60 -0
  32. activemodel-0.12.0/test/table_name_test.py +15 -0
  33. {activemodel-0.10.0 → activemodel-0.12.0}/test/test_wrapper.py +17 -0
  34. activemodel-0.12.0/test/types/typeid_mixin_test.py +22 -0
  35. activemodel-0.12.0/test/types/typeid_pydantic_test.py +48 -0
  36. activemodel-0.10.0/test/typeid_test.py → activemodel-0.12.0/test/types/typeid_sqlmodel_test.py +0 -19
  37. activemodel-0.12.0/uv.lock +1644 -0
  38. activemodel-0.10.0/.tool-versions +0 -3
  39. activemodel-0.10.0/activemodel/pytest/__init__.py +0 -2
  40. activemodel-0.10.0/activemodel/pytest/transaction.py +0 -63
  41. activemodel-0.10.0/activemodel/pytest/truncate.py +0 -46
  42. activemodel-0.10.0/activemodel/utils.py +0 -65
  43. activemodel-0.10.0/test/session_manager_test.py +0 -22
  44. activemodel-0.10.0/test/table_name_test.py +0 -14
  45. activemodel-0.10.0/uv.lock +0 -1367
  46. {activemodel-0.10.0 → activemodel-0.12.0}/.github/dependabot.yml +0 -0
  47. {activemodel-0.10.0 → activemodel-0.12.0}/.gitignore +0 -0
  48. {activemodel-0.10.0 → activemodel-0.12.0}/.vscode/settings.json +0 -0
  49. {activemodel-0.10.0 → activemodel-0.12.0}/Justfile +0 -0
  50. {activemodel-0.10.0 → activemodel-0.12.0}/LICENSE +0 -0
  51. {activemodel-0.10.0 → activemodel-0.12.0}/activemodel/__init__.py +0 -0
  52. {activemodel-0.10.0 → activemodel-0.12.0}/activemodel/celery.py +0 -0
  53. {activemodel-0.10.0 → activemodel-0.12.0}/activemodel/errors.py +0 -0
  54. {activemodel-0.10.0 → activemodel-0.12.0}/activemodel/get_column_from_field_patch.py +0 -0
  55. {activemodel-0.10.0 → activemodel-0.12.0}/activemodel/logger.py +0 -0
  56. {activemodel-0.10.0 → activemodel-0.12.0}/activemodel/mixins/__init__.py +0 -0
  57. {activemodel-0.10.0 → activemodel-0.12.0}/activemodel/mixins/pydantic_json.py +0 -0
  58. {activemodel-0.10.0 → activemodel-0.12.0}/activemodel/mixins/soft_delete.py +0 -0
  59. {activemodel-0.10.0 → activemodel-0.12.0}/activemodel/types/__init__.py +0 -0
  60. {activemodel-0.10.0 → activemodel-0.12.0}/docker-compose.yml +0 -0
  61. {activemodel-0.10.0 → activemodel-0.12.0}/playground/alternative_typeid_mixin.py +0 -0
  62. {activemodel-0.10.0 → activemodel-0.12.0}/playground/comments.py +0 -0
  63. {activemodel-0.10.0 → activemodel-0.12.0}/playground/env-with-model.patch +0 -0
  64. {activemodel-0.10.0 → activemodel-0.12.0}/playground/extract_comments.py +0 -0
  65. {activemodel-0.10.0 → activemodel-0.12.0}/playground/field.py +0 -0
  66. {activemodel-0.10.0 → activemodel-0.12.0}/playground/middleware.py +0 -0
  67. {activemodel-0.10.0 → activemodel-0.12.0}/playground/old_session_manager.py +0 -0
  68. {activemodel-0.10.0 → activemodel-0.12.0}/playground/pydantic_validation.py +0 -0
  69. {activemodel-0.10.0 → activemodel-0.12.0}/playground.py +0 -0
  70. {activemodel-0.10.0 → activemodel-0.12.0}/test/__init__.py +0 -0
  71. {activemodel-0.10.0 → activemodel-0.12.0}/test/comments_test.py +0 -0
  72. {activemodel-0.10.0 → activemodel-0.12.0}/test/delete_test.py +0 -0
  73. {activemodel-0.10.0 → activemodel-0.12.0}/test/fastapi_test.py +0 -0
  74. {activemodel-0.10.0 → activemodel-0.12.0}/test/import_test.py +0 -0
  75. {activemodel-0.10.0 → activemodel-0.12.0}/test/migrations/README +0 -0
  76. {activemodel-0.10.0 → activemodel-0.12.0}/test/migrations/alembic.ini +0 -0
  77. {activemodel-0.10.0 → activemodel-0.12.0}/test/migrations/env.py +0 -0
  78. {activemodel-0.10.0 → activemodel-0.12.0}/test/migrations/script.py.mako +0 -0
  79. {activemodel-0.10.0 → activemodel-0.12.0}/test/migrations_test.py +0 -0
  80. {activemodel-0.10.0 → activemodel-0.12.0}/test/models.py +0 -0
  81. {activemodel-0.10.0 → activemodel-0.12.0}/test/mutation_test.py +0 -0
  82. {activemodel-0.10.0 → activemodel-0.12.0}/test/nested_pydantic_json_test.py +0 -0
  83. {activemodel-0.10.0 → activemodel-0.12.0}/test/orm/test_upsert.py +0 -0
  84. {activemodel-0.10.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,63 @@
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
+
41
+ ## [0.11.0](https://github.com/iloveitaly/activemodel/compare/v0.10.0...v0.11.0) (2025-04-05)
42
+
43
+
44
+ ### Features
45
+
46
+ * add pydantic patch for TypeID schema serialization ([767726e](https://github.com/iloveitaly/activemodel/commit/767726eaf2a20f1c65162ba7d3a599495c83f721))
47
+ * add typeid prefix to db field comments ([34e4574](https://github.com/iloveitaly/activemodel/commit/34e457488a6e59c4e7daf1d7495b59ac64bd7ea2))
48
+ * render TypeIDType in plain old pydantic models ([8aa0a4d](https://github.com/iloveitaly/activemodel/commit/8aa0a4db51b425a60889064e723129041e418da5))
49
+
50
+
51
+ ### Bug Fixes
52
+
53
+ * typing on upsert properly returns self ([8965113](https://github.com/iloveitaly/activemodel/commit/896511399c818e8a4a1c01ed324921e04073a279))
54
+
55
+
56
+ ### Documentation
57
+
58
+ * add advanced SQLAlchemy tips to README.md ([c624ac0](https://github.com/iloveitaly/activemodel/commit/c624ac0712598d06759166ce9f91a37f40fefcfe))
59
+ * update comments in session_manager to clarify usage ([e657a2e](https://github.com/iloveitaly/activemodel/commit/e657a2e5985be6f0770bf945fc062a05dcff8cc8))
60
+
3
61
  ## [0.10.0](https://github.com/iloveitaly/activemodel/compare/v0.9.0...v0.10.0) (2025-04-01)
4
62
 
5
63
 
@@ -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,15 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: activemodel
3
- Version: 0.10.0
3
+ Version: 0.12.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>
7
7
  License-File: LICENSE
8
8
  Keywords: activemodel,activerecord,orm,sqlalchemy,sqlmodel
9
9
  Requires-Python: >=3.10
10
- Requires-Dist: pydash>=8.0.4
11
10
  Requires-Dist: python-decouple-typed>=3.11.0
12
11
  Requires-Dist: sqlmodel>=0.0.22
12
+ Requires-Dist: textcase>=0.4.0
13
13
  Requires-Dist: typeid-python>=0.3.1
14
14
  Description-Content-Type: text/markdown
15
15
 
@@ -59,15 +59,17 @@ from sqlmodel import SQLModel
59
59
 
60
60
  SQLModel.metadata.create_all(get_engine())
61
61
 
62
- # now you can create a user!
62
+ # now you can create a user! without managing sessions!
63
63
  User(a_field="a").save()
64
64
  ```
65
65
 
66
66
  Maybe you like JSON:
67
67
 
68
68
  ```python
69
- from activemodel import BaseModel
69
+ from sqlalchemy.dialects.postgresql import JSONB
70
70
  from pydantic import BaseModel as PydanticBaseModel
71
+
72
+ from activemodel import BaseModel
71
73
  from activemodel.mixins import PydanticJSONMixin, TypeIDMixin, TimestampsMixin
72
74
 
73
75
  class SubObject(PydanticBaseModel):
@@ -81,11 +83,28 @@ class User(
81
83
  TypeIDMixin("user"),
82
84
  table=True
83
85
  ):
84
- list_field: list[SubObject] = Field(sa_type=JSONB())
86
+ list_field: list[SubObject] = Field(sa_type=JSONB)
85
87
  ```
86
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")
96
+ ```
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
+
87
102
  ## Usage
88
103
 
104
+ ### Pytest
105
+
106
+ TODO detail out truncation and transactions
107
+
89
108
  ### Integrating Alembic
90
109
 
91
110
  `alembic init` will not work out of the box. You need to mutate a handful of files:
@@ -110,14 +129,14 @@ index 0d07420..a63631c 100644
110
129
  # Use forward slashes (/) also on windows to provide an os agnostic path
111
130
  -script_location = .
112
131
  +script_location = migrations
113
-
132
+
114
133
  # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
115
134
  # Uncomment the line below if you want the files to be prepended with date and time
116
135
  # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
117
136
  # for all available tokens
118
137
  # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
119
138
  +file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(rev)s_%%(slug)s
120
-
139
+
121
140
  # sys.path path, will be prepended to sys.path if present.
122
141
  # defaults to the current working directory.
123
142
  diff --git i/test/migrations/env.py w/test/migrations/env.py
@@ -129,12 +148,12 @@ index 36112a3..a1e15c2 100644
129
148
  +# isort: off
130
149
  +
131
150
  from logging.config import fileConfig
132
-
151
+
133
152
  from sqlalchemy import engine_from_config
134
153
  @@ -14,11 +17,17 @@ config = context.config
135
154
  if config.config_file_name is not None:
136
155
  fileConfig(config.config_file_name)
137
-
156
+
138
157
  +from sqlmodel import SQLModel
139
158
  +from test.models import *
140
159
  +from test.utils import database_url
@@ -147,7 +166,7 @@ index 36112a3..a1e15c2 100644
147
166
  # target_metadata = mymodel.Base.metadata
148
167
  -target_metadata = None
149
168
  +target_metadata = SQLModel.metadata
150
-
169
+
151
170
  # other values from the config, defined by the needs of env.py,
152
171
  # can be acquired:
153
172
  diff --git i/test/migrations/script.py.mako w/test/migrations/script.py.mako
@@ -155,13 +174,13 @@ index fbc4b07..9dc78bb 100644
155
174
  --- i/test/migrations/script.py.mako
156
175
  +++ w/test/migrations/script.py.mako
157
176
  @@ -9,6 +9,8 @@ from typing import Sequence, Union
158
-
177
+
159
178
  from alembic import op
160
179
  import sqlalchemy as sa
161
180
  +import sqlmodel
162
181
  +import activemodel
163
182
  ${imports if imports else ""}
164
-
183
+
165
184
  # revision identifiers, used by Alembic.
166
185
  ```
167
186
 
@@ -178,7 +197,7 @@ This tool is added to all `BaseModel`s and makes it easy to write SQL queries. S
178
197
 
179
198
  ### Easy Database Sessions
180
199
 
181
- I hate the idea f
200
+ I hate the idea f
182
201
 
183
202
  * Behavior should be intuitive and easy to understand. If you run `save()`, it should save, not stick the save in a transaction.
184
203
  * Don't worry about dead sessions. This makes it easy to lazy-load computed properties and largely eliminates the need to think about database sessions.
@@ -186,7 +205,7 @@ I hate the idea f
186
205
  There are a couple of thorny problems we need to solve for here:
187
206
 
188
207
  * In-memory fastapi servers are not the same as a uvicorn server, which is threaded *and* uses some sort of threadpool model for handling async requests. I don't claim to understand the entire implementation. For global DB session state (a) we can't use global variables (b) we can't use thread-local variables.
189
- *
208
+ *
190
209
 
191
210
  https://github.com/tomwojcik/starlette-context
192
211
 
@@ -202,8 +221,12 @@ https://github.com/tomwojcik/starlette-context
202
221
  SQLModel & SQLAlchemy are tricky. Here are some useful internal tricks:
203
222
 
204
223
  * `__sqlmodel_relationships__` is where any `RelationshipInfo` objects are stored. This is used to generate relationship fields on the object.
205
- * `ModelClass.relationship_name.property.local_columns`
224
+ * `ModelClass.relationship_name.property.local_columns`
206
225
  * Get cached fields from a model `object_state(instance).dict.get(field_name)`
226
+ * Set the value on a field, without marking it as dirty `attributes.set_committed_value(instance, field_name, val)`
227
+ * Is a model dirty `instance_state(instance).modified`
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\
207
230
 
208
231
  ### TypeID
209
232
 
@@ -267,6 +290,7 @@ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d
267
290
 
268
291
  * https://github.com/woofz/sqlmodel-basecrud
269
292
  * https://github.com/0xthiagomartins/sqlmodel-controller
293
+ * https://github.com/litestar-org/advanced-alchemy?tab=readme-ov-file
270
294
 
271
295
  ## Inspiration
272
296
 
@@ -279,4 +303,4 @@ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d
279
303
 
280
304
  ## Upstream Changes
281
305
 
282
- - [ ] https://github.com/fastapi/sqlmodel/pull/1293
306
+ - [ ] https://github.com/fastapi/sqlmodel/pull/1293
@@ -44,15 +44,17 @@ from sqlmodel import SQLModel
44
44
 
45
45
  SQLModel.metadata.create_all(get_engine())
46
46
 
47
- # now you can create a user!
47
+ # now you can create a user! without managing sessions!
48
48
  User(a_field="a").save()
49
49
  ```
50
50
 
51
51
  Maybe you like JSON:
52
52
 
53
53
  ```python
54
- from activemodel import BaseModel
54
+ from sqlalchemy.dialects.postgresql import JSONB
55
55
  from pydantic import BaseModel as PydanticBaseModel
56
+
57
+ from activemodel import BaseModel
56
58
  from activemodel.mixins import PydanticJSONMixin, TypeIDMixin, TimestampsMixin
57
59
 
58
60
  class SubObject(PydanticBaseModel):
@@ -66,11 +68,28 @@ class User(
66
68
  TypeIDMixin("user"),
67
69
  table=True
68
70
  ):
69
- list_field: list[SubObject] = Field(sa_type=JSONB())
71
+ list_field: list[SubObject] = Field(sa_type=JSONB)
70
72
  ```
71
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")
81
+ ```
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
+
72
87
  ## Usage
73
88
 
89
+ ### Pytest
90
+
91
+ TODO detail out truncation and transactions
92
+
74
93
  ### Integrating Alembic
75
94
 
76
95
  `alembic init` will not work out of the box. You need to mutate a handful of files:
@@ -95,14 +114,14 @@ index 0d07420..a63631c 100644
95
114
  # Use forward slashes (/) also on windows to provide an os agnostic path
96
115
  -script_location = .
97
116
  +script_location = migrations
98
-
117
+
99
118
  # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
100
119
  # Uncomment the line below if you want the files to be prepended with date and time
101
120
  # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
102
121
  # for all available tokens
103
122
  # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
104
123
  +file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(rev)s_%%(slug)s
105
-
124
+
106
125
  # sys.path path, will be prepended to sys.path if present.
107
126
  # defaults to the current working directory.
108
127
  diff --git i/test/migrations/env.py w/test/migrations/env.py
@@ -114,12 +133,12 @@ index 36112a3..a1e15c2 100644
114
133
  +# isort: off
115
134
  +
116
135
  from logging.config import fileConfig
117
-
136
+
118
137
  from sqlalchemy import engine_from_config
119
138
  @@ -14,11 +17,17 @@ config = context.config
120
139
  if config.config_file_name is not None:
121
140
  fileConfig(config.config_file_name)
122
-
141
+
123
142
  +from sqlmodel import SQLModel
124
143
  +from test.models import *
125
144
  +from test.utils import database_url
@@ -132,7 +151,7 @@ index 36112a3..a1e15c2 100644
132
151
  # target_metadata = mymodel.Base.metadata
133
152
  -target_metadata = None
134
153
  +target_metadata = SQLModel.metadata
135
-
154
+
136
155
  # other values from the config, defined by the needs of env.py,
137
156
  # can be acquired:
138
157
  diff --git i/test/migrations/script.py.mako w/test/migrations/script.py.mako
@@ -140,13 +159,13 @@ index fbc4b07..9dc78bb 100644
140
159
  --- i/test/migrations/script.py.mako
141
160
  +++ w/test/migrations/script.py.mako
142
161
  @@ -9,6 +9,8 @@ from typing import Sequence, Union
143
-
162
+
144
163
  from alembic import op
145
164
  import sqlalchemy as sa
146
165
  +import sqlmodel
147
166
  +import activemodel
148
167
  ${imports if imports else ""}
149
-
168
+
150
169
  # revision identifiers, used by Alembic.
151
170
  ```
152
171
 
@@ -163,7 +182,7 @@ This tool is added to all `BaseModel`s and makes it easy to write SQL queries. S
163
182
 
164
183
  ### Easy Database Sessions
165
184
 
166
- I hate the idea f
185
+ I hate the idea f
167
186
 
168
187
  * Behavior should be intuitive and easy to understand. If you run `save()`, it should save, not stick the save in a transaction.
169
188
  * Don't worry about dead sessions. This makes it easy to lazy-load computed properties and largely eliminates the need to think about database sessions.
@@ -171,7 +190,7 @@ I hate the idea f
171
190
  There are a couple of thorny problems we need to solve for here:
172
191
 
173
192
  * In-memory fastapi servers are not the same as a uvicorn server, which is threaded *and* uses some sort of threadpool model for handling async requests. I don't claim to understand the entire implementation. For global DB session state (a) we can't use global variables (b) we can't use thread-local variables.
174
- *
193
+ *
175
194
 
176
195
  https://github.com/tomwojcik/starlette-context
177
196
 
@@ -187,8 +206,12 @@ https://github.com/tomwojcik/starlette-context
187
206
  SQLModel & SQLAlchemy are tricky. Here are some useful internal tricks:
188
207
 
189
208
  * `__sqlmodel_relationships__` is where any `RelationshipInfo` objects are stored. This is used to generate relationship fields on the object.
190
- * `ModelClass.relationship_name.property.local_columns`
209
+ * `ModelClass.relationship_name.property.local_columns`
191
210
  * Get cached fields from a model `object_state(instance).dict.get(field_name)`
211
+ * Set the value on a field, without marking it as dirty `attributes.set_committed_value(instance, field_name, val)`
212
+ * Is a model dirty `instance_state(instance).modified`
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\
192
215
 
193
216
  ### TypeID
194
217
 
@@ -252,6 +275,7 @@ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d
252
275
 
253
276
  * https://github.com/woofz/sqlmodel-basecrud
254
277
  * https://github.com/0xthiagomartins/sqlmodel-controller
278
+ * https://github.com/litestar-org/advanced-alchemy?tab=readme-ov-file
255
279
 
256
280
  ## Inspiration
257
281
 
@@ -264,4 +288,4 @@ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d
264
288
 
265
289
  ## Upstream Changes
266
290
 
267
- - [ ] 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):
@@ -196,12 +196,13 @@ class BaseModel(SQLModel):
196
196
  "convenience method to avoid having to write .select().where() in order to add conditions"
197
197
  return cls.select().where(*args)
198
198
 
199
+ # TODO we should add an instance method for this as well
199
200
  @classmethod
200
201
  def upsert(
201
202
  cls,
202
203
  data: dict[str, t.Any],
203
204
  unique_by: str | list[str],
204
- ) -> None:
205
+ ) -> t.Self:
205
206
  """
206
207
  This method will insert a new record if it doesn't exist, or update the existing record if it does.
207
208
 
@@ -233,6 +234,8 @@ class BaseModel(SQLModel):
233
234
  return result
234
235
 
235
236
  def delete(self):
237
+ "Delete record completely from the database"
238
+
236
239
  with get_session() as session:
237
240
  if old_session := Session.object_session(self):
238
241
  old_session.expunge(self)
@@ -403,6 +406,19 @@ class BaseModel(SQLModel):
403
406
  with get_session() as session:
404
407
  return session.exec(statement).first()
405
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
+
406
422
  @classmethod
407
423
  def one(cls, *args: t.Any, **kwargs: t.Any):
408
424
  """