activemodel 0.9.0__tar.gz → 0.11.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {activemodel-0.9.0 → activemodel-0.11.0}/CHANGELOG.md +39 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/PKG-INFO +14 -11
- {activemodel-0.9.0 → activemodel-0.11.0}/README.md +13 -10
- {activemodel-0.9.0 → activemodel-0.11.0}/activemodel/base_model.py +54 -7
- activemodel-0.11.0/activemodel/mixins/typeid.py +41 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/activemodel/session_manager.py +27 -4
- {activemodel-0.9.0 → activemodel-0.11.0}/activemodel/types/typeid.py +35 -22
- activemodel-0.11.0/activemodel/types/typeid_patch.py +22 -0
- activemodel-0.11.0/playground/alternative_typeid_mixin.py +22 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/pyproject.toml +1 -1
- {activemodel-0.9.0 → activemodel-0.11.0}/test/models.py +13 -0
- activemodel-0.11.0/test/orm/test_upsert.py +185 -0
- activemodel-0.11.0/test/session_manager_test.py +22 -0
- activemodel-0.11.0/test/types/typeid_mixin_test.py +22 -0
- activemodel-0.11.0/test/types/typeid_pydantic_test.py +48 -0
- activemodel-0.9.0/test/typeid_test.py → activemodel-0.11.0/test/types/typeid_sqlmodel_test.py +0 -19
- {activemodel-0.9.0 → activemodel-0.11.0}/uv.lock +356 -289
- activemodel-0.9.0/activemodel/mixins/typeid.py +0 -46
- {activemodel-0.9.0 → activemodel-0.11.0}/.envrc +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/.github/dependabot.yml +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/.github/workflows/build_and_publish.yml +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/.github/workflows/repo-sync.yml +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/.gitignore +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/.tool-versions +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/.vscode/settings.json +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/Justfile +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/LICENSE +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/Makefile +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/TODO +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/activemodel/__init__.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/activemodel/celery.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/activemodel/errors.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/activemodel/get_column_from_field_patch.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/activemodel/logger.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/activemodel/mixins/__init__.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/activemodel/mixins/pydantic_json.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/activemodel/mixins/soft_delete.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/activemodel/mixins/timestamps.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/activemodel/pytest/__init__.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/activemodel/pytest/transaction.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/activemodel/pytest/truncate.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/activemodel/query_wrapper.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/activemodel/types/__init__.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/activemodel/utils.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/docker-compose.yml +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/playground/comments.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/playground/env-with-model.patch +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/playground/extract_comments.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/playground/field.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/playground/middleware.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/playground/old_session_manager.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/playground/pydantic_validation.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/playground.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/test/__init__.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/test/comments_test.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/test/conftest.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/test/delete_test.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/test/fastapi_test.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/test/import_test.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/test/migrations/README +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/test/migrations/alembic.ini +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/test/migrations/env.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/test/migrations/script.py.mako +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/test/migrations_test.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/test/mutation_test.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/test/nested_pydantic_json_test.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/test/orm_test.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/test/table_name_test.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/test/test_wrapper.py +0 -0
- {activemodel-0.9.0 → activemodel-0.11.0}/test/utils.py +0 -0
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.11.0](https://github.com/iloveitaly/activemodel/compare/v0.10.0...v0.11.0) (2025-04-05)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add pydantic patch for TypeID schema serialization ([767726e](https://github.com/iloveitaly/activemodel/commit/767726eaf2a20f1c65162ba7d3a599495c83f721))
|
|
9
|
+
* add typeid prefix to db field comments ([34e4574](https://github.com/iloveitaly/activemodel/commit/34e457488a6e59c4e7daf1d7495b59ac64bd7ea2))
|
|
10
|
+
* render TypeIDType in plain old pydantic models ([8aa0a4d](https://github.com/iloveitaly/activemodel/commit/8aa0a4db51b425a60889064e723129041e418da5))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* typing on upsert properly returns self ([8965113](https://github.com/iloveitaly/activemodel/commit/896511399c818e8a4a1c01ed324921e04073a279))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Documentation
|
|
19
|
+
|
|
20
|
+
* add advanced SQLAlchemy tips to README.md ([c624ac0](https://github.com/iloveitaly/activemodel/commit/c624ac0712598d06759166ce9f91a37f40fefcfe))
|
|
21
|
+
* update comments in session_manager to clarify usage ([e657a2e](https://github.com/iloveitaly/activemodel/commit/e657a2e5985be6f0770bf945fc062a05dcff8cc8))
|
|
22
|
+
|
|
23
|
+
## [0.10.0](https://github.com/iloveitaly/activemodel/compare/v0.9.0...v0.10.0) (2025-04-01)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Features
|
|
27
|
+
|
|
28
|
+
* add upsert method for PostgreSQL support in BaseModel ([d639de6](https://github.com/iloveitaly/activemodel/commit/d639de6cb498b72cd4b42422822c7d59ca6a646c))
|
|
29
|
+
* prevent nested global sessions and add test ([3aca2cc](https://github.com/iloveitaly/activemodel/commit/3aca2ccadf3e029801d5e517f5e660af44564f73))
|
|
30
|
+
* return upserted model and enhance upsert tests ([df2359a](https://github.com/iloveitaly/activemodel/commit/df2359a4ba8a00305bd3d5024b9789642b7b4718))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
### Bug Fixes
|
|
34
|
+
|
|
35
|
+
* use sa_column default instead of sqlmodel ([37e299d](https://github.com/iloveitaly/activemodel/commit/37e299d43dcec7db2cdfd3bc3b572b31b3234f35))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
### Documentation
|
|
39
|
+
|
|
40
|
+
* enhance docstrings in BaseModel for clarity ([12a5dee](https://github.com/iloveitaly/activemodel/commit/12a5deec1ee2480410bbad9250b253991dd12d86))
|
|
41
|
+
|
|
3
42
|
## [0.9.0](https://github.com/iloveitaly/activemodel/compare/v0.8.0...v0.9.0) (2025-03-26)
|
|
4
43
|
|
|
5
44
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: activemodel
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.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>
|
|
@@ -110,14 +110,14 @@ index 0d07420..a63631c 100644
|
|
|
110
110
|
# Use forward slashes (/) also on windows to provide an os agnostic path
|
|
111
111
|
-script_location = .
|
|
112
112
|
+script_location = migrations
|
|
113
|
-
|
|
113
|
+
|
|
114
114
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
|
115
115
|
# Uncomment the line below if you want the files to be prepended with date and time
|
|
116
116
|
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
|
117
117
|
# for all available tokens
|
|
118
118
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
|
119
119
|
+file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(rev)s_%%(slug)s
|
|
120
|
-
|
|
120
|
+
|
|
121
121
|
# sys.path path, will be prepended to sys.path if present.
|
|
122
122
|
# defaults to the current working directory.
|
|
123
123
|
diff --git i/test/migrations/env.py w/test/migrations/env.py
|
|
@@ -129,12 +129,12 @@ index 36112a3..a1e15c2 100644
|
|
|
129
129
|
+# isort: off
|
|
130
130
|
+
|
|
131
131
|
from logging.config import fileConfig
|
|
132
|
-
|
|
132
|
+
|
|
133
133
|
from sqlalchemy import engine_from_config
|
|
134
134
|
@@ -14,11 +17,17 @@ config = context.config
|
|
135
135
|
if config.config_file_name is not None:
|
|
136
136
|
fileConfig(config.config_file_name)
|
|
137
|
-
|
|
137
|
+
|
|
138
138
|
+from sqlmodel import SQLModel
|
|
139
139
|
+from test.models import *
|
|
140
140
|
+from test.utils import database_url
|
|
@@ -147,7 +147,7 @@ index 36112a3..a1e15c2 100644
|
|
|
147
147
|
# target_metadata = mymodel.Base.metadata
|
|
148
148
|
-target_metadata = None
|
|
149
149
|
+target_metadata = SQLModel.metadata
|
|
150
|
-
|
|
150
|
+
|
|
151
151
|
# other values from the config, defined by the needs of env.py,
|
|
152
152
|
# can be acquired:
|
|
153
153
|
diff --git i/test/migrations/script.py.mako w/test/migrations/script.py.mako
|
|
@@ -155,13 +155,13 @@ index fbc4b07..9dc78bb 100644
|
|
|
155
155
|
--- i/test/migrations/script.py.mako
|
|
156
156
|
+++ w/test/migrations/script.py.mako
|
|
157
157
|
@@ -9,6 +9,8 @@ from typing import Sequence, Union
|
|
158
|
-
|
|
158
|
+
|
|
159
159
|
from alembic import op
|
|
160
160
|
import sqlalchemy as sa
|
|
161
161
|
+import sqlmodel
|
|
162
162
|
+import activemodel
|
|
163
163
|
${imports if imports else ""}
|
|
164
|
-
|
|
164
|
+
|
|
165
165
|
# revision identifiers, used by Alembic.
|
|
166
166
|
```
|
|
167
167
|
|
|
@@ -178,7 +178,7 @@ This tool is added to all `BaseModel`s and makes it easy to write SQL queries. S
|
|
|
178
178
|
|
|
179
179
|
### Easy Database Sessions
|
|
180
180
|
|
|
181
|
-
I hate the idea f
|
|
181
|
+
I hate the idea f
|
|
182
182
|
|
|
183
183
|
* Behavior should be intuitive and easy to understand. If you run `save()`, it should save, not stick the save in a transaction.
|
|
184
184
|
* 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 +186,7 @@ I hate the idea f
|
|
|
186
186
|
There are a couple of thorny problems we need to solve for here:
|
|
187
187
|
|
|
188
188
|
* 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
|
-
*
|
|
189
|
+
*
|
|
190
190
|
|
|
191
191
|
https://github.com/tomwojcik/starlette-context
|
|
192
192
|
|
|
@@ -202,8 +202,11 @@ https://github.com/tomwojcik/starlette-context
|
|
|
202
202
|
SQLModel & SQLAlchemy are tricky. Here are some useful internal tricks:
|
|
203
203
|
|
|
204
204
|
* `__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`
|
|
205
|
+
* `ModelClass.relationship_name.property.local_columns`
|
|
206
206
|
* Get cached fields from a model `object_state(instance).dict.get(field_name)`
|
|
207
|
+
* Set the value on a field, without marking it as dirty `attributes.set_committed_value(instance, field_name, val)`
|
|
208
|
+
* Is a model dirty `instance_state(instance).modified`
|
|
209
|
+
* `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.
|
|
207
210
|
|
|
208
211
|
### TypeID
|
|
209
212
|
|
|
@@ -95,14 +95,14 @@ index 0d07420..a63631c 100644
|
|
|
95
95
|
# Use forward slashes (/) also on windows to provide an os agnostic path
|
|
96
96
|
-script_location = .
|
|
97
97
|
+script_location = migrations
|
|
98
|
-
|
|
98
|
+
|
|
99
99
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
|
100
100
|
# Uncomment the line below if you want the files to be prepended with date and time
|
|
101
101
|
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
|
102
102
|
# for all available tokens
|
|
103
103
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
|
104
104
|
+file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(rev)s_%%(slug)s
|
|
105
|
-
|
|
105
|
+
|
|
106
106
|
# sys.path path, will be prepended to sys.path if present.
|
|
107
107
|
# defaults to the current working directory.
|
|
108
108
|
diff --git i/test/migrations/env.py w/test/migrations/env.py
|
|
@@ -114,12 +114,12 @@ index 36112a3..a1e15c2 100644
|
|
|
114
114
|
+# isort: off
|
|
115
115
|
+
|
|
116
116
|
from logging.config import fileConfig
|
|
117
|
-
|
|
117
|
+
|
|
118
118
|
from sqlalchemy import engine_from_config
|
|
119
119
|
@@ -14,11 +17,17 @@ config = context.config
|
|
120
120
|
if config.config_file_name is not None:
|
|
121
121
|
fileConfig(config.config_file_name)
|
|
122
|
-
|
|
122
|
+
|
|
123
123
|
+from sqlmodel import SQLModel
|
|
124
124
|
+from test.models import *
|
|
125
125
|
+from test.utils import database_url
|
|
@@ -132,7 +132,7 @@ index 36112a3..a1e15c2 100644
|
|
|
132
132
|
# target_metadata = mymodel.Base.metadata
|
|
133
133
|
-target_metadata = None
|
|
134
134
|
+target_metadata = SQLModel.metadata
|
|
135
|
-
|
|
135
|
+
|
|
136
136
|
# other values from the config, defined by the needs of env.py,
|
|
137
137
|
# can be acquired:
|
|
138
138
|
diff --git i/test/migrations/script.py.mako w/test/migrations/script.py.mako
|
|
@@ -140,13 +140,13 @@ index fbc4b07..9dc78bb 100644
|
|
|
140
140
|
--- i/test/migrations/script.py.mako
|
|
141
141
|
+++ w/test/migrations/script.py.mako
|
|
142
142
|
@@ -9,6 +9,8 @@ from typing import Sequence, Union
|
|
143
|
-
|
|
143
|
+
|
|
144
144
|
from alembic import op
|
|
145
145
|
import sqlalchemy as sa
|
|
146
146
|
+import sqlmodel
|
|
147
147
|
+import activemodel
|
|
148
148
|
${imports if imports else ""}
|
|
149
|
-
|
|
149
|
+
|
|
150
150
|
# revision identifiers, used by Alembic.
|
|
151
151
|
```
|
|
152
152
|
|
|
@@ -163,7 +163,7 @@ This tool is added to all `BaseModel`s and makes it easy to write SQL queries. S
|
|
|
163
163
|
|
|
164
164
|
### Easy Database Sessions
|
|
165
165
|
|
|
166
|
-
I hate the idea f
|
|
166
|
+
I hate the idea f
|
|
167
167
|
|
|
168
168
|
* Behavior should be intuitive and easy to understand. If you run `save()`, it should save, not stick the save in a transaction.
|
|
169
169
|
* 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 +171,7 @@ I hate the idea f
|
|
|
171
171
|
There are a couple of thorny problems we need to solve for here:
|
|
172
172
|
|
|
173
173
|
* 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
|
-
*
|
|
174
|
+
*
|
|
175
175
|
|
|
176
176
|
https://github.com/tomwojcik/starlette-context
|
|
177
177
|
|
|
@@ -187,8 +187,11 @@ https://github.com/tomwojcik/starlette-context
|
|
|
187
187
|
SQLModel & SQLAlchemy are tricky. Here are some useful internal tricks:
|
|
188
188
|
|
|
189
189
|
* `__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`
|
|
190
|
+
* `ModelClass.relationship_name.property.local_columns`
|
|
191
191
|
* Get cached fields from a model `object_state(instance).dict.get(field_name)`
|
|
192
|
+
* Set the value on a field, without marking it as dirty `attributes.set_committed_value(instance, field_name, val)`
|
|
193
|
+
* Is a model dirty `instance_state(instance).modified`
|
|
194
|
+
* `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.
|
|
192
195
|
|
|
193
196
|
### TypeID
|
|
194
197
|
|
|
@@ -4,11 +4,11 @@ from uuid import UUID
|
|
|
4
4
|
|
|
5
5
|
import pydash
|
|
6
6
|
import sqlalchemy as sa
|
|
7
|
-
from sqlalchemy.orm.attributes import flag_modified as sa_flag_modified
|
|
8
|
-
from sqlalchemy.orm.base import instance_state
|
|
9
7
|
import sqlmodel as sm
|
|
10
8
|
from sqlalchemy import Connection, event
|
|
11
9
|
from sqlalchemy.orm import Mapper, declared_attr
|
|
10
|
+
from sqlalchemy.orm.attributes import flag_modified as sa_flag_modified
|
|
11
|
+
from sqlalchemy.orm.base import instance_state
|
|
12
12
|
from sqlmodel import Column, Field, Session, SQLModel, inspect, select
|
|
13
13
|
from typeid import TypeID
|
|
14
14
|
|
|
@@ -19,6 +19,7 @@ from . import get_column_from_field_patch # noqa: F401
|
|
|
19
19
|
from .logger import logger
|
|
20
20
|
from .query_wrapper import QueryWrapper
|
|
21
21
|
from .session_manager import get_session
|
|
22
|
+
from sqlalchemy.dialects.postgresql import insert as postgres_insert
|
|
22
23
|
|
|
23
24
|
POSTGRES_INDEXES_NAMING_CONVENTION = {
|
|
24
25
|
"ix": "%(column_0_label)s_idx",
|
|
@@ -137,8 +138,15 @@ class BaseModel(SQLModel):
|
|
|
137
138
|
cls.__table_args__ = {"comment": doc}
|
|
138
139
|
elif isinstance(table_args, dict):
|
|
139
140
|
table_args.setdefault("comment", doc)
|
|
141
|
+
elif isinstance(table_args, tuple):
|
|
142
|
+
# If it's a tuple, we need to convert it to a list and add the comment
|
|
143
|
+
table_args = list(table_args)
|
|
144
|
+
table_args.append({"comment": doc})
|
|
145
|
+
cls.__table_args__ = tuple(table_args)
|
|
140
146
|
else:
|
|
141
|
-
raise ValueError(
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"Unexpected __table_args__ type {type(table_args)}, expected dictionary."
|
|
149
|
+
)
|
|
142
150
|
|
|
143
151
|
# TODO no type check decorator here
|
|
144
152
|
@declared_attr
|
|
@@ -163,8 +171,10 @@ class BaseModel(SQLModel):
|
|
|
163
171
|
"""
|
|
164
172
|
Returns a `Field` object referencing the foreign key of the model.
|
|
165
173
|
|
|
166
|
-
|
|
167
|
-
|
|
174
|
+
Helps quickly build a many-to-one or one-to-one relationship.
|
|
175
|
+
|
|
176
|
+
>>> other_model_id: int = OtherModel.foreign_key()
|
|
177
|
+
>>> other_model = Relationship()
|
|
168
178
|
"""
|
|
169
179
|
|
|
170
180
|
field_options = {"nullable": False} | kwargs
|
|
@@ -186,6 +196,43 @@ class BaseModel(SQLModel):
|
|
|
186
196
|
"convenience method to avoid having to write .select().where() in order to add conditions"
|
|
187
197
|
return cls.select().where(*args)
|
|
188
198
|
|
|
199
|
+
# TODO we should add an instance method for this as well
|
|
200
|
+
@classmethod
|
|
201
|
+
def upsert(
|
|
202
|
+
cls,
|
|
203
|
+
data: dict[str, t.Any],
|
|
204
|
+
unique_by: str | list[str],
|
|
205
|
+
) -> t.Self:
|
|
206
|
+
"""
|
|
207
|
+
This method will insert a new record if it doesn't exist, or update the existing record if it does.
|
|
208
|
+
|
|
209
|
+
It uses SQLAlchemy's `on_conflict_do_update` and does not yet support MySQL. Some implementation details below.
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
- `index_elements=["name"]`: Specifies the column(s) to check for conflicts (e.g., unique constraint or index). If a row with the same "name" exists, it triggers the update instead of an insert.
|
|
214
|
+
- `values`: Defines the data to insert (e.g., `name="example", value=123`). If no conflict occurs, this data is inserted as a new row.
|
|
215
|
+
|
|
216
|
+
The `set_` parameter (e.g., `set_=dict(value=123)`) then dictates what gets updated on conflict, overriding matching fields in `values` if specified.
|
|
217
|
+
"""
|
|
218
|
+
index_elements = [unique_by] if isinstance(unique_by, str) else unique_by
|
|
219
|
+
|
|
220
|
+
stmt = (
|
|
221
|
+
postgres_insert(cls)
|
|
222
|
+
.values(**data)
|
|
223
|
+
.on_conflict_do_update(index_elements=index_elements, set_=data)
|
|
224
|
+
.returning(cls)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
with get_session() as session:
|
|
228
|
+
result = session.exec(stmt)
|
|
229
|
+
session.commit()
|
|
230
|
+
|
|
231
|
+
# TODO this is so ugly:
|
|
232
|
+
result = result.one()[0]
|
|
233
|
+
|
|
234
|
+
return result
|
|
235
|
+
|
|
189
236
|
def delete(self):
|
|
190
237
|
with get_session() as session:
|
|
191
238
|
if old_session := Session.object_session(self):
|
|
@@ -256,9 +303,9 @@ class BaseModel(SQLModel):
|
|
|
256
303
|
def is_new(self) -> bool:
|
|
257
304
|
return not self._sa_instance_state.has_identity
|
|
258
305
|
|
|
259
|
-
def flag_modified(self, *args: str):
|
|
306
|
+
def flag_modified(self, *args: str) -> None:
|
|
260
307
|
"""
|
|
261
|
-
Flag one or more fields as modified. Useful for marking a field containing sub-objects as modified.
|
|
308
|
+
Flag one or more fields as modified/mutated/dirty. Useful for marking a field containing sub-objects as modified.
|
|
262
309
|
|
|
263
310
|
Will throw an error if an invalid field is passed.
|
|
264
311
|
"""
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from sqlmodel import Column, Field
|
|
2
|
+
from typeid import TypeID
|
|
3
|
+
|
|
4
|
+
from activemodel.types import typeid_patch # noqa: F401
|
|
5
|
+
from activemodel.types.typeid import TypeIDType
|
|
6
|
+
|
|
7
|
+
# global list of prefixes to ensure uniqueness
|
|
8
|
+
_prefixes: list[str] = []
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def TypeIDMixin(prefix: str):
|
|
12
|
+
"""
|
|
13
|
+
Mixin that adds a TypeID primary key field to a SQLModel. Specify the prefix to use for the TypeID.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# make sure duplicate prefixes are not used!
|
|
17
|
+
# NOTE this will cause issues on code reloads
|
|
18
|
+
assert prefix
|
|
19
|
+
assert prefix not in _prefixes, (
|
|
20
|
+
f"prefix {prefix} already exists, pick a different one"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
class _TypeIDMixin:
|
|
24
|
+
__abstract__ = True
|
|
25
|
+
|
|
26
|
+
id: TypeIDType = Field(
|
|
27
|
+
sa_column=Column(
|
|
28
|
+
TypeIDType(prefix),
|
|
29
|
+
primary_key=True,
|
|
30
|
+
nullable=False,
|
|
31
|
+
# default on the sa_column level ensures that an ID is generated when creating a new record, even when
|
|
32
|
+
# raw SQLAlchemy operations are used instead of activemodel operations
|
|
33
|
+
default=lambda: TypeID(prefix),
|
|
34
|
+
),
|
|
35
|
+
# add a database comment to document the prefix, since it's not stored in the DB otherwise
|
|
36
|
+
description=f"TypeID with prefix: {prefix}",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
_prefixes.append(prefix)
|
|
40
|
+
|
|
41
|
+
return _TypeIDMixin
|
|
@@ -47,7 +47,7 @@ class SessionManager:
|
|
|
47
47
|
"singleton instance of SessionManager"
|
|
48
48
|
|
|
49
49
|
session_connection: Connection | None
|
|
50
|
-
"optionally specify a specific session connection to use for all get_session() calls, useful for testing"
|
|
50
|
+
"optionally specify a specific session connection to use for all get_session() calls, useful for testing and migrations"
|
|
51
51
|
|
|
52
52
|
@classmethod
|
|
53
53
|
def get_instance(cls, database_url: str | None = None) -> "SessionManager":
|
|
@@ -72,6 +72,7 @@ class SessionManager:
|
|
|
72
72
|
self._database_url,
|
|
73
73
|
# NOTE very important! This enables pydantic models to be serialized for JSONB columns
|
|
74
74
|
json_serializer=_serialize_pydantic_model,
|
|
75
|
+
# TODO move to a constants area
|
|
75
76
|
echo=config("ACTIVEMODEL_LOG_SQL", cast=bool, default=False),
|
|
76
77
|
# https://docs.sqlalchemy.org/en/20/core/pooling.html#disconnect-handling-pessimistic
|
|
77
78
|
pool_pre_ping=True,
|
|
@@ -81,6 +82,8 @@ class SessionManager:
|
|
|
81
82
|
return self._engine
|
|
82
83
|
|
|
83
84
|
def get_session(self):
|
|
85
|
+
"get a new database session, respecting any globally set sessions"
|
|
86
|
+
|
|
84
87
|
if gsession := _session_context.get():
|
|
85
88
|
|
|
86
89
|
@contextlib.contextmanager
|
|
@@ -102,23 +105,40 @@ def init(database_url: str):
|
|
|
102
105
|
|
|
103
106
|
|
|
104
107
|
def get_engine():
|
|
108
|
+
"alias to get the database engine without importing SessionManager"
|
|
105
109
|
return SessionManager.get_instance().get_engine()
|
|
106
110
|
|
|
107
111
|
|
|
108
112
|
def get_session():
|
|
113
|
+
"alias to get a database session without importing SessionManager"
|
|
109
114
|
return SessionManager.get_instance().get_session()
|
|
110
115
|
|
|
111
116
|
|
|
112
|
-
# contextvars must be at the top-level of a module! You will not get a warning if you don't do this.
|
|
113
|
-
# ContextVar is implemented in C, so it's very special and is both thread-safe and asyncio safe. This variable gives us
|
|
114
|
-
# a place to persist a session to use globally across the application.
|
|
115
117
|
_session_context = contextvars.ContextVar[Session | None](
|
|
116
118
|
"session_context", default=None
|
|
117
119
|
)
|
|
120
|
+
"""
|
|
121
|
+
This is a VERY important ContextVar, it sets a global session to be used across all ActiveModel operations by default
|
|
122
|
+
and ensures get_session() uses this session as well.
|
|
123
|
+
|
|
124
|
+
contextvars must be at the top-level of a module! You will not get a warning if you don't do this.
|
|
125
|
+
ContextVar is implemented in C, so it's very special and is both thread-safe and asyncio safe. This variable gives us
|
|
126
|
+
a place to persist a session to use globally across the application.
|
|
127
|
+
"""
|
|
118
128
|
|
|
119
129
|
|
|
120
130
|
@contextlib.contextmanager
|
|
121
131
|
def global_session():
|
|
132
|
+
"""
|
|
133
|
+
Generate a session shared across all activemodel calls.
|
|
134
|
+
|
|
135
|
+
Alternatively, you can pass a session to use globally into the context manager, which is helpful for migrations
|
|
136
|
+
and testing.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
if _session_context.get() is not None:
|
|
140
|
+
raise RuntimeError("global session already set")
|
|
141
|
+
|
|
122
142
|
with SessionManager.get_instance().get_session() as s:
|
|
123
143
|
token = _session_context.set(s)
|
|
124
144
|
|
|
@@ -140,6 +160,9 @@ async def aglobal_session():
|
|
|
140
160
|
>>> )
|
|
141
161
|
"""
|
|
142
162
|
|
|
163
|
+
if _session_context.get() is not None:
|
|
164
|
+
raise RuntimeError("global session already set")
|
|
165
|
+
|
|
143
166
|
with SessionManager.get_instance().get_session() as s:
|
|
144
167
|
token = _session_context.set(s)
|
|
145
168
|
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
Lifted from: https://github.com/akhundMurad/typeid-python/blob/main/examples/sqlalchemy.py
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
from typing import Optional
|
|
6
5
|
from uuid import UUID
|
|
7
6
|
|
|
8
7
|
from pydantic import (
|
|
@@ -19,25 +18,32 @@ from activemodel.errors import TypeIDValidationError
|
|
|
19
18
|
class TypeIDType(types.TypeDecorator):
|
|
20
19
|
"""
|
|
21
20
|
A SQLAlchemy TypeDecorator that allows storing TypeIDs in the database.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
21
|
+
|
|
22
|
+
The prefix will not be persisted to the database, instead the database-native UUID field will be used.
|
|
23
|
+
At retrieval time a TypeID will be constructed (in python) based on the configured prefix and the UUID
|
|
24
|
+
value from the database.
|
|
25
|
+
|
|
26
|
+
For example:
|
|
27
|
+
|
|
28
|
+
>>> id = mapped_column(
|
|
29
|
+
>>> TypeIDType("user"),
|
|
30
|
+
>>> primary_key=True,
|
|
31
|
+
>>> default=lambda: TypeID("user")
|
|
32
|
+
>>> )
|
|
33
|
+
|
|
34
|
+
Will result in TypeIDs such as "user_01h45ytscbebyvny4gc8cr8ma2". There's a mixin provided to make it easy
|
|
35
|
+
to add a `id` pk field to your model with a specific prefix.
|
|
33
36
|
"""
|
|
34
37
|
|
|
38
|
+
# TODO are we sure we wouldn't use TypeID here?
|
|
35
39
|
impl = types.Uuid
|
|
40
|
+
# TODO why the types version?
|
|
36
41
|
# impl = uuid.UUID
|
|
42
|
+
|
|
37
43
|
cache_ok = True
|
|
38
|
-
prefix:
|
|
44
|
+
prefix: str
|
|
39
45
|
|
|
40
|
-
def __init__(self, prefix:
|
|
46
|
+
def __init__(self, prefix: str, *args, **kwargs):
|
|
41
47
|
self.prefix = prefix
|
|
42
48
|
super().__init__(*args, **kwargs)
|
|
43
49
|
|
|
@@ -90,6 +96,8 @@ class TypeIDType(types.TypeDecorator):
|
|
|
90
96
|
raise ValueError("Unexpected input type")
|
|
91
97
|
|
|
92
98
|
def process_result_value(self, value, dialect):
|
|
99
|
+
"convert a raw UUID, without a prefix, to a TypeID with the correct prefix"
|
|
100
|
+
|
|
93
101
|
if value is None:
|
|
94
102
|
return None
|
|
95
103
|
|
|
@@ -123,13 +131,19 @@ class TypeIDType(types.TypeDecorator):
|
|
|
123
131
|
- https://github.com/alice-biometrics/petisco/blob/b01ef1b84949d156f73919e126ed77aa8e0b48dd/petisco/base/domain/model/uuid.py#L50
|
|
124
132
|
"""
|
|
125
133
|
|
|
134
|
+
def convert_from_string(value: str | TypeID) -> TypeID:
|
|
135
|
+
if isinstance(value, TypeID):
|
|
136
|
+
return value
|
|
137
|
+
|
|
138
|
+
return TypeID.from_string(value)
|
|
139
|
+
|
|
126
140
|
from_uuid_schema = core_schema.chain_schema(
|
|
127
141
|
[
|
|
128
142
|
# TODO not sure how this is different from the UUID schema, should try it out.
|
|
129
143
|
# core_schema.is_instance_schema(TypeID),
|
|
130
144
|
# core_schema.uuid_schema(),
|
|
131
145
|
core_schema.no_info_plain_validator_function(
|
|
132
|
-
|
|
146
|
+
convert_from_string,
|
|
133
147
|
json_schema_input_schema=core_schema.str_schema(),
|
|
134
148
|
),
|
|
135
149
|
]
|
|
@@ -151,11 +165,10 @@ class TypeIDType(types.TypeDecorator):
|
|
|
151
165
|
# )
|
|
152
166
|
# },
|
|
153
167
|
python_schema=core_schema.union_schema([from_uuid_schema]),
|
|
154
|
-
serialization=core_schema.plain_serializer_function_ser_schema(
|
|
155
|
-
lambda x: str(x)
|
|
156
|
-
),
|
|
168
|
+
serialization=core_schema.plain_serializer_function_ser_schema(str),
|
|
157
169
|
)
|
|
158
170
|
|
|
171
|
+
# TODO I have a feeling that the `serialization` param in the above method solves this for us.
|
|
159
172
|
@classmethod
|
|
160
173
|
def __get_pydantic_json_schema__(
|
|
161
174
|
cls, schema: CoreSchema, handler: GetJsonSchemaHandler
|
|
@@ -164,18 +177,18 @@ class TypeIDType(types.TypeDecorator):
|
|
|
164
177
|
Called when generating the openapi schema. This overrides the `function-plain` type which
|
|
165
178
|
is generated by the `no_info_plain_validator_function`.
|
|
166
179
|
|
|
167
|
-
This
|
|
180
|
+
This logic seems to be a hot part of the codebase, so I'd expect this to break as pydantic
|
|
168
181
|
fastapi continue to evolve.
|
|
169
182
|
|
|
170
183
|
Note that this method can return multiple types. A return value can be as simple as:
|
|
171
184
|
|
|
172
|
-
{"type": "string"}
|
|
185
|
+
>>> {"type": "string"}
|
|
173
186
|
|
|
174
187
|
Or, you could return a more specific JSON schema type:
|
|
175
188
|
|
|
176
|
-
core_schema.uuid_schema()
|
|
189
|
+
>>> core_schema.uuid_schema()
|
|
177
190
|
|
|
178
|
-
The problem with using something like uuid_schema is the
|
|
191
|
+
The problem with using something like uuid_schema is the specific patterns
|
|
179
192
|
|
|
180
193
|
https://github.com/BeanieODM/beanie/blob/2190cd9d1fc047af477d5e6897cc283799f54064/beanie/odm/fields.py#L153
|
|
181
194
|
"""
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from typing import Any, Type
|
|
2
|
+
|
|
3
|
+
from pydantic import GetCoreSchemaHandler
|
|
4
|
+
from pydantic_core import CoreSchema, core_schema
|
|
5
|
+
|
|
6
|
+
from typeid import TypeID
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@classmethod
|
|
10
|
+
def get_pydantic_core_schema(
|
|
11
|
+
cls: Type[TypeID], source_type: Any, handler: GetCoreSchemaHandler
|
|
12
|
+
) -> CoreSchema:
|
|
13
|
+
return core_schema.union_schema(
|
|
14
|
+
[
|
|
15
|
+
core_schema.str_schema(),
|
|
16
|
+
core_schema.is_instance_schema(cls),
|
|
17
|
+
],
|
|
18
|
+
serialization=core_schema.plain_serializer_function_ser_schema(str),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
TypeID.__get_pydantic_core_schema__ = get_pydantic_core_schema
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# TODO not sure if I love the idea of a dynamic class for each mixin as used above
|
|
2
|
+
# may give this approach another shot in the future
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TypeIDMixin2:
|
|
6
|
+
"""
|
|
7
|
+
Mixin class that adds a TypeID primary key to models.
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
>>> class MyModel(BaseModel, TypeIDMixin, prefix="xyz", table=True):
|
|
11
|
+
>>> name: str
|
|
12
|
+
|
|
13
|
+
Will automatically have an `id` field with prefix "xyz"
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init_subclass__(cls, *, prefix: str, **kwargs):
|
|
17
|
+
super().__init_subclass__(**kwargs)
|
|
18
|
+
|
|
19
|
+
cls.id: uuid.UUID = Field(
|
|
20
|
+
sa_column=Column(TypeIDType(prefix), primary_key=True),
|
|
21
|
+
default_factory=lambda: TypeID(prefix),
|
|
22
|
+
)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from pydantic import computed_field
|
|
2
|
+
from sqlalchemy import UniqueConstraint
|
|
2
3
|
from sqlmodel import Column, Field, Integer, Relationship
|
|
3
4
|
|
|
4
5
|
from activemodel import BaseModel
|
|
@@ -42,3 +43,15 @@ class ExampleWithComputedProperty(
|
|
|
42
43
|
@property
|
|
43
44
|
def special_note(self) -> str:
|
|
44
45
|
return f"SPECIAL: {self.another_example.note}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class UpsertTestModel(BaseModel, TypeIDMixin("upsert_test"), table=True):
|
|
49
|
+
"""Test model for upsert operations"""
|
|
50
|
+
|
|
51
|
+
name: str = Field(unique=True)
|
|
52
|
+
category: str = Field(index=True)
|
|
53
|
+
value: int = Field(default=0)
|
|
54
|
+
description: str | None = Field(default=None)
|
|
55
|
+
|
|
56
|
+
# Add a composite unique constraint for the multiple unique field test
|
|
57
|
+
__table_args__ = (UniqueConstraint("name", "category", name="compound_constraint"),)
|