activemodel 0.7.0__tar.gz → 0.8.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.7.0 → activemodel-0.8.0}/.envrc +2 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/CHANGELOG.md +22 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/Makefile +3 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/PKG-INFO +51 -4
- {activemodel-0.7.0 → activemodel-0.8.0}/README.md +50 -3
- {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/base_model.py +37 -5
- {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/celery.py +6 -1
- {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/get_column_from_field_patch.py +2 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/mixins/pydantic_json.py +15 -2
- activemodel-0.8.0/activemodel/pytest/transaction.py +63 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/session_manager.py +18 -1
- {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/types/typeid.py +1 -0
- activemodel-0.8.0/playground/extract_comments.py +33 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/pyproject.toml +1 -1
- {activemodel-0.7.0 → activemodel-0.8.0}/test/models.py +1 -1
- activemodel-0.8.0/test/orm_test.py +111 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/test/serialization_test.py +19 -11
- {activemodel-0.7.0 → activemodel-0.8.0}/test/typeid_test.py +12 -9
- activemodel-0.7.0/activemodel/pytest/transaction.py +0 -51
- activemodel-0.7.0/test/orm_test.py +0 -51
- {activemodel-0.7.0 → activemodel-0.8.0}/.github/dependabot.yml +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/.github/workflows/build_and_publish.yml +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/.github/workflows/repo-sync.yml +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/.gitignore +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/.tool-versions +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/.vscode/settings.json +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/Justfile +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/LICENSE +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/TODO +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/__init__.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/errors.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/logger.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/mixins/__init__.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/mixins/soft_delete.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/mixins/timestamps.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/mixins/typeid.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/pytest/__init__.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/pytest/truncate.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/query_wrapper.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/types/__init__.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/activemodel/utils.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/docker-compose.yml +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/playground/comments.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/playground/env-with-model.patch +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/playground/field.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/playground/middleware.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/playground/old_session_manager.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/playground/pydantic_validation.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/playground.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/test/__init__.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/test/comments_test.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/test/conftest.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/test/delete_test.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/test/fastapi_test.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/test/migrations/README +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/test/migrations/alembic.ini +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/test/migrations/env.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/test/migrations/script.py.mako +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/test/migrations_test.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/test/table_name_test.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/test/test_wrapper.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/test/utils.py +0 -0
- {activemodel-0.7.0 → activemodel-0.8.0}/uv.lock +0 -0
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.8.0](https://github.com/iloveitaly/activemodel/compare/v0.7.0...v0.8.0) (2025-03-18)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add BaseModel.where method and update test cases ([9fe4c5a](https://github.com/iloveitaly/activemodel/commit/9fe4c5af619690ffb6344cf5c74a2d4b2b46ef02))
|
|
9
|
+
* add primary_key_field ([947a410](https://github.com/iloveitaly/activemodel/commit/947a410766dd764e8ac5b3177152d2ff22cdb609))
|
|
10
|
+
* log start of database transaction in tests ([ac90d6f](https://github.com/iloveitaly/activemodel/commit/ac90d6f18bb0a4cca68527dec9c55b4af1f6e851))
|
|
11
|
+
* reload json fields when record is reloaded from db ([c013082](https://github.com/iloveitaly/activemodel/commit/c013082004bb3a93e81f00eb0c990833a9cae7e2))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Bug Fixes
|
|
15
|
+
|
|
16
|
+
* yield session object in global_session function ([47b33cc](https://github.com/iloveitaly/activemodel/commit/47b33cc1aa66d6076363635191c297c52fcc3deb))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Documentation
|
|
20
|
+
|
|
21
|
+
* add comments to clarify SessionManager use and config ([e73561b](https://github.com/iloveitaly/activemodel/commit/e73561b3d52e617a0f92da5cd93981ff429da16f))
|
|
22
|
+
* update comments and README with additional examples and info ([209ee36](https://github.com/iloveitaly/activemodel/commit/209ee36a9df53f927cd9e5b2bb15b3a5776b34ce))
|
|
23
|
+
* update README with setup instructions and SQLModel tips ([f2520b5](https://github.com/iloveitaly/activemodel/commit/f2520b5fa5d7c462e8f7a591b83d874239a34b8d))
|
|
24
|
+
|
|
3
25
|
## [0.7.0](https://github.com/iloveitaly/activemodel/compare/v0.6.0...v0.7.0) (2025-02-08)
|
|
4
26
|
|
|
5
27
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: activemodel
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.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>
|
|
@@ -29,10 +29,11 @@ This package provides a thin wrapper around SQLModel that provides a more Active
|
|
|
29
29
|
First, setup your DB:
|
|
30
30
|
|
|
31
31
|
```python
|
|
32
|
-
|
|
32
|
+
import activemodel
|
|
33
|
+
activemodel.init("sqlite:///database.db")
|
|
33
34
|
```
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
Create models:
|
|
36
37
|
|
|
37
38
|
```python
|
|
38
39
|
from activemodel import BaseModel
|
|
@@ -51,6 +52,38 @@ class User(
|
|
|
51
52
|
a_field: str
|
|
52
53
|
```
|
|
53
54
|
|
|
55
|
+
You'll need to create the models in the DB. Alembic is the best way to do it, but you can cheat as well:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from sqlmodel import SQLModel
|
|
59
|
+
|
|
60
|
+
SQLModel.metadata.create_all(get_engine())
|
|
61
|
+
|
|
62
|
+
# now you can create a user!
|
|
63
|
+
User(a_field="a").save()
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Maybe you like JSON:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from activemodel import BaseModel
|
|
70
|
+
from pydantic import BaseModel as PydanticBaseModel
|
|
71
|
+
from activemodel.mixins import PydanticJSONMixin, TypeIDMixin, TimestampsMixin
|
|
72
|
+
|
|
73
|
+
class SubObject(PydanticBaseModel):
|
|
74
|
+
name: str
|
|
75
|
+
value: int
|
|
76
|
+
|
|
77
|
+
class User(
|
|
78
|
+
BaseModel,
|
|
79
|
+
TimestampsMixin,
|
|
80
|
+
PydanticJSONMixin,
|
|
81
|
+
TypeIDMixin("user"),
|
|
82
|
+
table=True
|
|
83
|
+
):
|
|
84
|
+
list_field: list[SubObject] = Field(sa_type=JSONB())
|
|
85
|
+
```
|
|
86
|
+
|
|
54
87
|
## Usage
|
|
55
88
|
|
|
56
89
|
### Integrating Alembic
|
|
@@ -60,6 +93,7 @@ class User(
|
|
|
60
93
|
* To import all of your models you want in your DB. [Here's my recommended way to do this.](https://github.com/iloveitaly/python-starter-template/blob/master/app/models/__init__.py)
|
|
61
94
|
* Use your DB URL from the ENV
|
|
62
95
|
* Target sqlalchemy metadata to the sqlmodel-generated metadata
|
|
96
|
+
* Most likely you'll want to add [alembic-postgresql-enum](https://pypi.org/project/alembic-postgresql-enum/) so migrations work properly
|
|
63
97
|
|
|
64
98
|
[Take a look at these scripts for an example of how to fully integrate Alembic into your development workflow.](https://github.com/iloveitaly/python-starter-template/blob/0af2c7e95217e34bde7357cc95be048900000e48/Justfile#L618-L712)
|
|
65
99
|
|
|
@@ -161,6 +195,15 @@ https://github.com/tomwojcik/starlette-context
|
|
|
161
195
|
* Conditional: `Scrape.select().where(Scrape.id < last_scraped.id).all()`
|
|
162
196
|
* Equality: `MenuItem.select().where(MenuItem.menu_id == menu.id).all()`
|
|
163
197
|
* `IN` example: `CanonicalMenuItem.select().where(col(CanonicalMenuItem.id).in_(canonized_ids)).all()`
|
|
198
|
+
* Compound where query: `User.where((User.last_active_at != None) & (User.last_active_at > last_24_hours)).count()`
|
|
199
|
+
|
|
200
|
+
### SQLModel Internals
|
|
201
|
+
|
|
202
|
+
SQLModel & SQLAlchemy are tricky. Here are some useful internal tricks:
|
|
203
|
+
|
|
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`
|
|
206
|
+
* Get cached fields from a model `object_state(instance).dict.get(field_name)`
|
|
164
207
|
|
|
165
208
|
### TypeID
|
|
166
209
|
|
|
@@ -186,7 +229,7 @@ class Appointment(
|
|
|
186
229
|
TypeIDMixin("appointment"),
|
|
187
230
|
table=True
|
|
188
231
|
):
|
|
189
|
-
# `foreign_key` is a activemodel
|
|
232
|
+
# `foreign_key` is a activemodel method to generate the right `Field` for the relationship
|
|
190
233
|
# TypeIDType is really important here for fastapi serialization
|
|
191
234
|
doctor_id: TypeIDType = Doctor.foreign_key()
|
|
192
235
|
doctor: Doctor = Relationship()
|
|
@@ -233,3 +276,7 @@ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d
|
|
|
233
276
|
* https://github.com/fastapi/full-stack-fastapi-template
|
|
234
277
|
* https://github.com/DarylStark/my_data/
|
|
235
278
|
* https://github.com/petrgazarov/FastAPI-app/tree/main/fastapi_app
|
|
279
|
+
|
|
280
|
+
## Upstream Changes
|
|
281
|
+
|
|
282
|
+
- [ ] https://github.com/fastapi/sqlmodel/pull/1293
|
|
@@ -14,10 +14,11 @@ This package provides a thin wrapper around SQLModel that provides a more Active
|
|
|
14
14
|
First, setup your DB:
|
|
15
15
|
|
|
16
16
|
```python
|
|
17
|
-
|
|
17
|
+
import activemodel
|
|
18
|
+
activemodel.init("sqlite:///database.db")
|
|
18
19
|
```
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
Create models:
|
|
21
22
|
|
|
22
23
|
```python
|
|
23
24
|
from activemodel import BaseModel
|
|
@@ -36,6 +37,38 @@ class User(
|
|
|
36
37
|
a_field: str
|
|
37
38
|
```
|
|
38
39
|
|
|
40
|
+
You'll need to create the models in the DB. Alembic is the best way to do it, but you can cheat as well:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from sqlmodel import SQLModel
|
|
44
|
+
|
|
45
|
+
SQLModel.metadata.create_all(get_engine())
|
|
46
|
+
|
|
47
|
+
# now you can create a user!
|
|
48
|
+
User(a_field="a").save()
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Maybe you like JSON:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from activemodel import BaseModel
|
|
55
|
+
from pydantic import BaseModel as PydanticBaseModel
|
|
56
|
+
from activemodel.mixins import PydanticJSONMixin, TypeIDMixin, TimestampsMixin
|
|
57
|
+
|
|
58
|
+
class SubObject(PydanticBaseModel):
|
|
59
|
+
name: str
|
|
60
|
+
value: int
|
|
61
|
+
|
|
62
|
+
class User(
|
|
63
|
+
BaseModel,
|
|
64
|
+
TimestampsMixin,
|
|
65
|
+
PydanticJSONMixin,
|
|
66
|
+
TypeIDMixin("user"),
|
|
67
|
+
table=True
|
|
68
|
+
):
|
|
69
|
+
list_field: list[SubObject] = Field(sa_type=JSONB())
|
|
70
|
+
```
|
|
71
|
+
|
|
39
72
|
## Usage
|
|
40
73
|
|
|
41
74
|
### Integrating Alembic
|
|
@@ -45,6 +78,7 @@ class User(
|
|
|
45
78
|
* To import all of your models you want in your DB. [Here's my recommended way to do this.](https://github.com/iloveitaly/python-starter-template/blob/master/app/models/__init__.py)
|
|
46
79
|
* Use your DB URL from the ENV
|
|
47
80
|
* Target sqlalchemy metadata to the sqlmodel-generated metadata
|
|
81
|
+
* Most likely you'll want to add [alembic-postgresql-enum](https://pypi.org/project/alembic-postgresql-enum/) so migrations work properly
|
|
48
82
|
|
|
49
83
|
[Take a look at these scripts for an example of how to fully integrate Alembic into your development workflow.](https://github.com/iloveitaly/python-starter-template/blob/0af2c7e95217e34bde7357cc95be048900000e48/Justfile#L618-L712)
|
|
50
84
|
|
|
@@ -146,6 +180,15 @@ https://github.com/tomwojcik/starlette-context
|
|
|
146
180
|
* Conditional: `Scrape.select().where(Scrape.id < last_scraped.id).all()`
|
|
147
181
|
* Equality: `MenuItem.select().where(MenuItem.menu_id == menu.id).all()`
|
|
148
182
|
* `IN` example: `CanonicalMenuItem.select().where(col(CanonicalMenuItem.id).in_(canonized_ids)).all()`
|
|
183
|
+
* Compound where query: `User.where((User.last_active_at != None) & (User.last_active_at > last_24_hours)).count()`
|
|
184
|
+
|
|
185
|
+
### SQLModel Internals
|
|
186
|
+
|
|
187
|
+
SQLModel & SQLAlchemy are tricky. Here are some useful internal tricks:
|
|
188
|
+
|
|
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`
|
|
191
|
+
* Get cached fields from a model `object_state(instance).dict.get(field_name)`
|
|
149
192
|
|
|
150
193
|
### TypeID
|
|
151
194
|
|
|
@@ -171,7 +214,7 @@ class Appointment(
|
|
|
171
214
|
TypeIDMixin("appointment"),
|
|
172
215
|
table=True
|
|
173
216
|
):
|
|
174
|
-
# `foreign_key` is a activemodel
|
|
217
|
+
# `foreign_key` is a activemodel method to generate the right `Field` for the relationship
|
|
175
218
|
# TypeIDType is really important here for fastapi serialization
|
|
176
219
|
doctor_id: TypeIDType = Doctor.foreign_key()
|
|
177
220
|
doctor: Doctor = Relationship()
|
|
@@ -218,3 +261,7 @@ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d
|
|
|
218
261
|
* https://github.com/fastapi/full-stack-fastapi-template
|
|
219
262
|
* https://github.com/DarylStark/my_data/
|
|
220
263
|
* https://github.com/petrgazarov/FastAPI-app/tree/main/fastapi_app
|
|
264
|
+
|
|
265
|
+
## Upstream Changes
|
|
266
|
+
|
|
267
|
+
- [ ] https://github.com/fastapi/sqlmodel/pull/1293
|
|
@@ -9,6 +9,9 @@ from sqlalchemy import Connection, event
|
|
|
9
9
|
from sqlalchemy.orm import Mapper, declared_attr
|
|
10
10
|
from sqlmodel import Field, MetaData, Session, SQLModel, select
|
|
11
11
|
from typeid import TypeID
|
|
12
|
+
from inspect import isclass
|
|
13
|
+
|
|
14
|
+
from activemodel.mixins.pydantic_json import PydanticJSONMixin
|
|
12
15
|
|
|
13
16
|
# NOTE: this patches a core method in sqlmodel to support db comments
|
|
14
17
|
from . import get_column_from_field_patch # noqa: F401
|
|
@@ -157,7 +160,10 @@ class BaseModel(SQLModel):
|
|
|
157
160
|
@classmethod
|
|
158
161
|
def foreign_key(cls, **kwargs):
|
|
159
162
|
"""
|
|
160
|
-
Returns a Field object referencing the foreign key of the model.
|
|
163
|
+
Returns a `Field` object referencing the foreign key of the model.
|
|
164
|
+
|
|
165
|
+
>>> other_model_id: int
|
|
166
|
+
>>> other_model = OtherModel.foreign_key()
|
|
161
167
|
"""
|
|
162
168
|
|
|
163
169
|
field_options = {"nullable": False} | kwargs
|
|
@@ -174,6 +180,11 @@ class BaseModel(SQLModel):
|
|
|
174
180
|
"create a query wrapper to easily run sqlmodel queries on this model"
|
|
175
181
|
return QueryWrapper[cls](cls, *args)
|
|
176
182
|
|
|
183
|
+
@classmethod
|
|
184
|
+
def where(cls, *args):
|
|
185
|
+
"convenience method to avoid having to write .select().where() in order to add conditions"
|
|
186
|
+
return cls.select().where(*args)
|
|
187
|
+
|
|
177
188
|
def delete(self):
|
|
178
189
|
with get_session() as session:
|
|
179
190
|
if old_session := Session.object_session(self):
|
|
@@ -197,11 +208,11 @@ class BaseModel(SQLModel):
|
|
|
197
208
|
session.commit()
|
|
198
209
|
session.refresh(self)
|
|
199
210
|
|
|
200
|
-
|
|
211
|
+
# Only call the transform method if the class is a subclass of PydanticJSONMixin
|
|
212
|
+
if issubclass(self.__class__, PydanticJSONMixin):
|
|
213
|
+
self.__class__.__transform_dict_to_pydantic__(self)
|
|
201
214
|
|
|
202
|
-
|
|
203
|
-
# log.quiet(f"{self} already exists in the database.")
|
|
204
|
-
# session.rollback()
|
|
215
|
+
return self
|
|
205
216
|
|
|
206
217
|
# TODO shouldn't this be handled by pydantic?
|
|
207
218
|
def json(self, **kwargs):
|
|
@@ -256,6 +267,27 @@ class BaseModel(SQLModel):
|
|
|
256
267
|
new_model = cls(**kwargs)
|
|
257
268
|
return new_model
|
|
258
269
|
|
|
270
|
+
@classmethod
|
|
271
|
+
def primary_key_field(cls):
|
|
272
|
+
"""
|
|
273
|
+
Returns the primary key column of the model by inspecting SQLAlchemy field information.
|
|
274
|
+
|
|
275
|
+
>>> ExampleModel.primary_key_field().name
|
|
276
|
+
"""
|
|
277
|
+
# TODO note_schema.__class__.__table__.primary_key
|
|
278
|
+
|
|
279
|
+
pk_columns = list(cls.__table__.primary_key.columns)
|
|
280
|
+
|
|
281
|
+
if not pk_columns:
|
|
282
|
+
raise ValueError("No primary key defined for the model.")
|
|
283
|
+
|
|
284
|
+
if len(pk_columns) > 1:
|
|
285
|
+
raise ValueError(
|
|
286
|
+
"Multiple primary keys defined. This method supports only single primary key models."
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
return pk_columns[0]
|
|
290
|
+
|
|
259
291
|
# TODO what's super dangerous here is you pass a kwarg which does not map to a specific
|
|
260
292
|
# field it will result in `True`, which will return all records, and not give you any typing
|
|
261
293
|
# errors. Dangerous when iterating on structure quickly
|
|
@@ -4,12 +4,17 @@ Do not import unless you have Celery/Kombu installed.
|
|
|
4
4
|
In order for TypeID objects to be properly handled by celery, a custom encoder must be registered.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
# this is not an explicit dependency, only import this file if you have Celery installed
|
|
7
8
|
from kombu.utils.json import register_type
|
|
8
9
|
from typeid import TypeID
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
def register_celery_typeid_encoder():
|
|
12
|
-
"
|
|
13
|
+
"""
|
|
14
|
+
Ensures TypeID objects passed as arguments to a delayed function are properly serialized.
|
|
15
|
+
|
|
16
|
+
Run at the top of your celery initialization script.
|
|
17
|
+
"""
|
|
13
18
|
|
|
14
19
|
def class_full_name(clz) -> str:
|
|
15
20
|
return ".".join([clz.__module__, clz.__qualname__])
|
|
@@ -6,6 +6,8 @@ Making sure these docstrings make their way to the DB schema is helpful for a bu
|
|
|
6
6
|
This patch mutates a core sqlmodel function which translates pydantic FieldInfo objects into sqlalchemy Column objects. It adds the field description as a comment to the column.
|
|
7
7
|
|
|
8
8
|
Note that FieldInfo *from pydantic* is used when a "bare" field is defined. This can be confusing, because when inspecting model fields, the class name looks exactly the same.
|
|
9
|
+
|
|
10
|
+
Some ideas for this originally sourced from: https://github.com/fastapi/sqlmodel/issues/492#issuecomment-2489858633
|
|
9
11
|
"""
|
|
10
12
|
|
|
11
13
|
from typing import (
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
"""
|
|
2
|
+
https://github.com/fastapi/sqlmodel/issues/63
|
|
3
|
+
"""
|
|
4
|
+
|
|
1
5
|
from types import UnionType
|
|
2
6
|
from typing import get_args, get_origin
|
|
3
7
|
|
|
@@ -9,11 +13,20 @@ class PydanticJSONMixin:
|
|
|
9
13
|
"""
|
|
10
14
|
By default, SQLModel does not convert JSONB columns into pydantic models when they are loaded from the database.
|
|
11
15
|
|
|
12
|
-
This mixin, combined with a custom serializer, fixes that issue.
|
|
16
|
+
This mixin, combined with a custom serializer (`_serialize_pydantic_model`), fixes that issue.
|
|
17
|
+
|
|
18
|
+
>>> class ExampleWithJSON(BaseModel, PydanticJSONMixin, table=True):
|
|
19
|
+
>>> list_field: list[SubObject] = Field(sa_type=JSONB()
|
|
13
20
|
"""
|
|
14
21
|
|
|
15
22
|
@reconstructor
|
|
16
|
-
def
|
|
23
|
+
def __transform_dict_to_pydantic__(self):
|
|
24
|
+
"""
|
|
25
|
+
Transforms dictionary fields into Pydantic models upon loading.
|
|
26
|
+
|
|
27
|
+
- Reconstructor only runs once, when the object is loaded.
|
|
28
|
+
- We manually call this method on save(), etc to ensure the pydantic types are maintained
|
|
29
|
+
"""
|
|
17
30
|
# TODO do we need to inspect sa_type
|
|
18
31
|
for field_name, field_info in self.model_fields.items():
|
|
19
32
|
raw_value = getattr(self, field_name, None)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from activemodel import SessionManager
|
|
2
|
+
|
|
3
|
+
from ..logger import logger
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def database_reset_transaction():
|
|
7
|
+
"""
|
|
8
|
+
Wrap all database interactions for a given test in a nested transaction and roll it back after the test.
|
|
9
|
+
|
|
10
|
+
>>> from activemodel.pytest import database_reset_transaction
|
|
11
|
+
>>> database_reset_transaction = pytest.fixture(scope="function", autouse=True)(database_reset_transaction)
|
|
12
|
+
|
|
13
|
+
Transaction-based DB cleaning does *not* work if the DB mutations are happening in a separate process, which should
|
|
14
|
+
use spawn, because the same session is not shared across processes. Note that using `fork` is dangerous.
|
|
15
|
+
|
|
16
|
+
In this case, you should use the truncate.
|
|
17
|
+
|
|
18
|
+
References:
|
|
19
|
+
|
|
20
|
+
- https://stackoverflow.com/questions/62433018/how-to-make-sqlalchemy-transaction-rollback-drop-tables-it-created
|
|
21
|
+
- https://aalvarez.me/posts/setting-up-a-sqlalchemy-and-pytest-based-test-suite/
|
|
22
|
+
- https://github.com/nickjj/docker-flask-example/blob/93af9f4fbf185098ffb1d120ee0693abcd77a38b/test/conftest.py#L77
|
|
23
|
+
- https://github.com/caiola/vinhos.com/blob/c47d0a5d7a4bf290c1b726561d1e8f5d2ac29bc8/backend/test/conftest.py#L46
|
|
24
|
+
- https://stackoverflow.com/questions/64095876/multiprocessing-fork-vs-spawn
|
|
25
|
+
|
|
26
|
+
Using a named SAVEPOINT does not give us anything extra, so we are not using it.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
engine = SessionManager.get_instance().get_engine()
|
|
30
|
+
|
|
31
|
+
logger.info("starting database transaction")
|
|
32
|
+
|
|
33
|
+
with engine.begin() as connection:
|
|
34
|
+
transaction = connection.begin_nested()
|
|
35
|
+
|
|
36
|
+
if SessionManager.get_instance().session_connection is not None:
|
|
37
|
+
logger.warning("session override already exists")
|
|
38
|
+
# TODO should we throw an exception here?
|
|
39
|
+
|
|
40
|
+
SessionManager.get_instance().session_connection = connection
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
with SessionManager.get_instance().get_session() as factory_session:
|
|
44
|
+
try:
|
|
45
|
+
from factory.alchemy import SQLAlchemyModelFactory
|
|
46
|
+
|
|
47
|
+
# Ensure that all factories use the same session
|
|
48
|
+
for factory in SQLAlchemyModelFactory.__subclasses__():
|
|
49
|
+
factory._meta.sqlalchemy_session = factory_session
|
|
50
|
+
factory._meta.sqlalchemy_session_persistence = "commit"
|
|
51
|
+
except ImportError:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
yield
|
|
55
|
+
finally:
|
|
56
|
+
logger.debug("rolling back transaction")
|
|
57
|
+
|
|
58
|
+
transaction.rollback()
|
|
59
|
+
|
|
60
|
+
# TODO is this necessary? unclear
|
|
61
|
+
connection.close()
|
|
62
|
+
|
|
63
|
+
SessionManager.get_instance().session_connection = None
|
|
@@ -44,6 +44,7 @@ def _serialize_pydantic_model(model: BaseModel | list[BaseModel] | None) -> str
|
|
|
44
44
|
|
|
45
45
|
class SessionManager:
|
|
46
46
|
_instance: t.ClassVar[t.Optional["SessionManager"]] = None
|
|
47
|
+
"singleton instance of SessionManager"
|
|
47
48
|
|
|
48
49
|
session_connection: Connection | None
|
|
49
50
|
"optionally specify a specific session connection to use for all get_session() calls, useful for testing"
|
|
@@ -69,6 +70,7 @@ class SessionManager:
|
|
|
69
70
|
if not self._engine:
|
|
70
71
|
self._engine = create_engine(
|
|
71
72
|
self._database_url,
|
|
73
|
+
# NOTE very important! This enables pydantic models to be serialized for JSONB columns
|
|
72
74
|
json_serializer=_serialize_pydantic_model,
|
|
73
75
|
echo=config("ACTIVEMODEL_LOG_SQL", cast=bool, default=False),
|
|
74
76
|
# https://docs.sqlalchemy.org/en/20/core/pooling.html#disconnect-handling-pessimistic
|
|
@@ -87,6 +89,7 @@ class SessionManager:
|
|
|
87
89
|
|
|
88
90
|
return _reuse_session()
|
|
89
91
|
|
|
92
|
+
# a connection can generate nested transactions
|
|
90
93
|
if self.session_connection:
|
|
91
94
|
return Session(bind=self.session_connection)
|
|
92
95
|
|
|
@@ -94,6 +97,7 @@ class SessionManager:
|
|
|
94
97
|
|
|
95
98
|
|
|
96
99
|
def init(database_url: str):
|
|
100
|
+
"configure activemodel to connect to a specific database"
|
|
97
101
|
return SessionManager.get_instance(database_url)
|
|
98
102
|
|
|
99
103
|
|
|
@@ -106,6 +110,8 @@ def get_session():
|
|
|
106
110
|
|
|
107
111
|
|
|
108
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.
|
|
109
115
|
_session_context = contextvars.ContextVar[Session | None](
|
|
110
116
|
"session_context", default=None
|
|
111
117
|
)
|
|
@@ -117,12 +123,23 @@ def global_session():
|
|
|
117
123
|
token = _session_context.set(s)
|
|
118
124
|
|
|
119
125
|
try:
|
|
120
|
-
yield
|
|
126
|
+
yield s
|
|
121
127
|
finally:
|
|
122
128
|
_session_context.reset(token)
|
|
123
129
|
|
|
124
130
|
|
|
125
131
|
async def aglobal_session():
|
|
132
|
+
"""
|
|
133
|
+
Use this as a fastapi dependency to get a session that is shared across the request:
|
|
134
|
+
|
|
135
|
+
>>> APIRouter(
|
|
136
|
+
>>> prefix="/internal/v1",
|
|
137
|
+
>>> dependencies=[
|
|
138
|
+
>>> Depends(aglobal_session),
|
|
139
|
+
>>> ]
|
|
140
|
+
>>> )
|
|
141
|
+
"""
|
|
142
|
+
|
|
126
143
|
with SessionManager.get_instance().get_session() as s:
|
|
127
144
|
token = _session_context.set(s)
|
|
128
145
|
|
|
@@ -137,6 +137,7 @@ class TypeIDType(types.TypeDecorator):
|
|
|
137
137
|
|
|
138
138
|
return core_schema.json_or_python_schema(
|
|
139
139
|
json_schema=from_uuid_schema,
|
|
140
|
+
# TODO in the the future we could add more exact types
|
|
140
141
|
# metadata=core_schema.str_schema(
|
|
141
142
|
# pattern="^[0-9a-f]{24}$",
|
|
142
143
|
# min_length=24,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import sqlalchemy as sa
|
|
2
|
+
from sqlmodel import SQLModel, create_engine, Session
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def extract_comments(engine):
|
|
6
|
+
comments = {}
|
|
7
|
+
# Reflect all tables if needed; otherwise, rely on model metadata
|
|
8
|
+
for model in SQLModel.__subclasses__():
|
|
9
|
+
table = model.__table__
|
|
10
|
+
# Retrieve table-level comment
|
|
11
|
+
table_comment = table.comment
|
|
12
|
+
# Retrieve comments for each column
|
|
13
|
+
column_comments = {
|
|
14
|
+
col.name: col.comment for col in table.columns if col.comment
|
|
15
|
+
}
|
|
16
|
+
comments[table.name] = {
|
|
17
|
+
"table_comment": table_comment,
|
|
18
|
+
"column_comments": column_comments,
|
|
19
|
+
}
|
|
20
|
+
return comments
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if __name__ == "__main__":
|
|
24
|
+
# Adjust your connection string accordingly
|
|
25
|
+
engine = create_engine("sqlite:///database.db")
|
|
26
|
+
with Session(engine) as session:
|
|
27
|
+
comments = extract_comments(engine)
|
|
28
|
+
for table, data in comments.items():
|
|
29
|
+
print(f"Table: {table}")
|
|
30
|
+
print(f" - Table Comment: {data['table_comment']}")
|
|
31
|
+
print(" - Column Comments:")
|
|
32
|
+
for col, comment in data["column_comments"].items():
|
|
33
|
+
print(f" {col}: {comment}")
|
|
@@ -15,7 +15,7 @@ class ExampleRecord(
|
|
|
15
15
|
BaseModel, TimestampsMixin, TypeIDMixin(EXAMPLE_TABLE_PREFIX), table=True
|
|
16
16
|
):
|
|
17
17
|
something: str | None = None
|
|
18
|
-
another_with_index: str | None = Field(index=True)
|
|
18
|
+
another_with_index: str | None = Field(index=True, default=None, unique=True)
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
class AnotherExample(BaseModel, TypeIDMixin("myotherid"), table=True):
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test core ORM functions
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from test.models import EXAMPLE_TABLE_PREFIX, AnotherExample, ExampleRecord
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_empty_count(create_and_wipe_database):
|
|
9
|
+
assert ExampleRecord.count() == 0
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_all_and_count(create_and_wipe_database):
|
|
13
|
+
AnotherExample().save()
|
|
14
|
+
|
|
15
|
+
records_to_create = 10
|
|
16
|
+
|
|
17
|
+
# create 10 example records
|
|
18
|
+
for i in range(records_to_create):
|
|
19
|
+
ExampleRecord().save()
|
|
20
|
+
|
|
21
|
+
assert ExampleRecord.count() == records_to_create
|
|
22
|
+
|
|
23
|
+
all_records = list(ExampleRecord.all())
|
|
24
|
+
assert len(all_records) == records_to_create
|
|
25
|
+
|
|
26
|
+
assert ExampleRecord.count() == records_to_create
|
|
27
|
+
|
|
28
|
+
record = all_records[0]
|
|
29
|
+
assert isinstance(record, ExampleRecord)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_where_returns_expected(create_and_wipe_database):
|
|
33
|
+
# Create records with distinct "something" field values
|
|
34
|
+
ExampleRecord(something="hello").save()
|
|
35
|
+
ExampleRecord(something="world").save()
|
|
36
|
+
ExampleRecord(something="hello").save()
|
|
37
|
+
|
|
38
|
+
# Use the "where" convenience method to filter records
|
|
39
|
+
results = list(ExampleRecord.where(ExampleRecord.something == "hello").all())
|
|
40
|
+
|
|
41
|
+
# Expecting 2 records that match "hello"
|
|
42
|
+
assert len(results) == 2
|
|
43
|
+
for record in results:
|
|
44
|
+
assert record.something == "hello"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_where_no_results(create_and_wipe_database):
|
|
48
|
+
# Create a record with a specific value
|
|
49
|
+
ExampleRecord(something="foo").save()
|
|
50
|
+
|
|
51
|
+
# Filter by a value that does not exist to get no results
|
|
52
|
+
result = ExampleRecord.where(ExampleRecord.something == "bar").first()
|
|
53
|
+
assert result is None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_where_chaining(create_and_wipe_database):
|
|
57
|
+
# Save multiple records; using the same condition twice should be harmless
|
|
58
|
+
ExampleRecord(something="chain").save()
|
|
59
|
+
ExampleRecord(something="chain").save()
|
|
60
|
+
ExampleRecord(something="other").save()
|
|
61
|
+
|
|
62
|
+
# Chain where calls; in our implementation, chaining should work the same as a single call
|
|
63
|
+
query = (
|
|
64
|
+
ExampleRecord.where(ExampleRecord.something == "chain")
|
|
65
|
+
.where(ExampleRecord.something == "chain")
|
|
66
|
+
.all()
|
|
67
|
+
)
|
|
68
|
+
results = list(query)
|
|
69
|
+
|
|
70
|
+
# Expecting 2 records that match "chain" even after chaining the condition
|
|
71
|
+
assert len(results) == 2
|
|
72
|
+
for record in results:
|
|
73
|
+
assert record.something == "chain"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_foreign_key():
|
|
77
|
+
field = ExampleRecord.foreign_key()
|
|
78
|
+
assert field.sa_type.prefix == EXAMPLE_TABLE_PREFIX
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_basic_query(create_and_wipe_database):
|
|
82
|
+
example = ExampleRecord(something="hi").save()
|
|
83
|
+
query = ExampleRecord.select().where(ExampleRecord.something == "hi")
|
|
84
|
+
|
|
85
|
+
query_as_str = str(query)
|
|
86
|
+
result = query.first()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_query_count(create_and_wipe_database):
|
|
90
|
+
AnotherExample().save()
|
|
91
|
+
|
|
92
|
+
example = ExampleRecord(something="hi").save()
|
|
93
|
+
count = ExampleRecord.select().where(ExampleRecord.something == "hi").count()
|
|
94
|
+
|
|
95
|
+
assert count == 1
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_primary_key(create_and_wipe_database):
|
|
99
|
+
assert ExampleRecord.primary_key_field().name == "id"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_get_non_pk(create_and_wipe_database):
|
|
103
|
+
# some paranoid checks here as I attempt to debug the issue
|
|
104
|
+
example = ExampleRecord(something="hi", another_with_index="key_123").save()
|
|
105
|
+
|
|
106
|
+
assert ExampleRecord.count() == 1
|
|
107
|
+
|
|
108
|
+
retrieved_example = ExampleRecord.find_or_create_by(another_with_index="key_123")
|
|
109
|
+
|
|
110
|
+
assert retrieved_example
|
|
111
|
+
assert retrieved_example.id == example.id
|
|
@@ -17,24 +17,25 @@ class SubObject(PydanticBaseModel):
|
|
|
17
17
|
value: int
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
class
|
|
20
|
+
class ExampleWithJSONB(
|
|
21
21
|
BaseModel, PydanticJSONMixin, TypeIDMixin("json_test"), table=True
|
|
22
22
|
):
|
|
23
|
-
list_field: list[SubObject] = Field(sa_type=JSONB
|
|
24
|
-
# list_with_generator: list[SubObject] = Field(sa_type=JSONB
|
|
25
|
-
optional_list_field: list[SubObject] | None = Field(sa_type=JSONB
|
|
26
|
-
generic_list_field: list[dict] = Field(sa_type=JSONB
|
|
27
|
-
object_field: SubObject = Field(sa_type=JSONB
|
|
28
|
-
unstructured_field: dict = Field(sa_type=JSONB
|
|
29
|
-
semi_structured_field: dict[str, str] = Field(sa_type=JSONB
|
|
30
|
-
optional_object_field: SubObject | None = Field(sa_type=JSONB
|
|
23
|
+
list_field: list[SubObject] = Field(sa_type=JSONB)
|
|
24
|
+
# list_with_generator: list[SubObject] = Field(sa_type=JSONB)
|
|
25
|
+
optional_list_field: list[SubObject] | None = Field(sa_type=JSONB, default=None)
|
|
26
|
+
generic_list_field: list[dict] = Field(sa_type=JSONB)
|
|
27
|
+
object_field: SubObject = Field(sa_type=JSONB)
|
|
28
|
+
unstructured_field: dict = Field(sa_type=JSONB)
|
|
29
|
+
semi_structured_field: dict[str, str] = Field(sa_type=JSONB)
|
|
30
|
+
optional_object_field: SubObject | None = Field(sa_type=JSONB, default=None)
|
|
31
31
|
|
|
32
32
|
normal_field: str | None = Field(default=None)
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
def test_json_serialization(create_and_wipe_database):
|
|
36
36
|
sub_object = SubObject(name="test", value=1)
|
|
37
|
-
|
|
37
|
+
|
|
38
|
+
example = ExampleWithJSONB(
|
|
38
39
|
list_field=[sub_object],
|
|
39
40
|
# list_with_generator=(x for x in [sub_object]),
|
|
40
41
|
generic_list_field=[{"one": "two", "three": 3, "four": [1, 2, 3]}],
|
|
@@ -46,7 +47,14 @@ def test_json_serialization(create_and_wipe_database):
|
|
|
46
47
|
optional_object_field=sub_object,
|
|
47
48
|
).save()
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
# make sure the types are preserved when saved
|
|
51
|
+
assert isinstance(example.list_field[0], SubObject)
|
|
52
|
+
assert example.optional_list_field
|
|
53
|
+
assert isinstance(example.optional_list_field[0], SubObject)
|
|
54
|
+
assert isinstance(example.object_field, SubObject)
|
|
55
|
+
assert isinstance(example.optional_object_field, SubObject)
|
|
56
|
+
|
|
57
|
+
fresh_example = ExampleWithJSONB.get(example.id)
|
|
50
58
|
|
|
51
59
|
assert fresh_example is not None
|
|
52
60
|
assert isinstance(fresh_example.object_field, SubObject)
|
|
@@ -38,21 +38,24 @@ def test_get_through_prefixed_uid_as_str():
|
|
|
38
38
|
assert record is None
|
|
39
39
|
|
|
40
40
|
|
|
41
|
-
def test_get_through_plain_uid_as_str():
|
|
41
|
+
def test_get_through_plain_uid_as_str(create_and_wipe_database):
|
|
42
42
|
type_uid = TypeID(prefix=TYPEID_PREFIX)
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
assert record is None
|
|
44
|
+
# pass uid as string. Ex: '01942886-7afc-7129-8f57-db09137ed002'
|
|
45
|
+
record = ExampleWithId.get(str(type_uid.uuid))
|
|
46
|
+
assert record is None
|
|
48
47
|
|
|
49
48
|
|
|
50
|
-
def test_get_through_plain_uid():
|
|
49
|
+
def test_get_through_plain_uid(create_and_wipe_database):
|
|
51
50
|
type_uid = TypeID(prefix=TYPEID_PREFIX)
|
|
52
51
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
record = ExampleWithId.get(type_uid.uuid)
|
|
53
|
+
assert record is None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# def test_non_primary_typeid_key():
|
|
57
|
+
# class NonPrimaryKeyExample(PydanticBaseModel, table=True):
|
|
58
|
+
# something: str | None = None
|
|
56
59
|
|
|
57
60
|
|
|
58
61
|
# the wrapped test is probably overkill, but it's protecting against a weird edge case I was running into with fastapi
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
from activemodel import SessionManager
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
def database_reset_transaction():
|
|
5
|
-
"""
|
|
6
|
-
Wrap all database interactions for a given test in a nested transaction and roll it back after the test.
|
|
7
|
-
|
|
8
|
-
>>> from activemodel.pytest import database_reset_transaction
|
|
9
|
-
>>> pytest.fixture(scope="function", autouse=True)(database_reset_transaction)
|
|
10
|
-
|
|
11
|
-
References:
|
|
12
|
-
|
|
13
|
-
- https://stackoverflow.com/questions/62433018/how-to-make-sqlalchemy-transaction-rollback-drop-tables-it-created
|
|
14
|
-
- https://aalvarez.me/posts/setting-up-a-sqlalchemy-and-pytest-based-test-suite/
|
|
15
|
-
- https://github.com/nickjj/docker-flask-example/blob/93af9f4fbf185098ffb1d120ee0693abcd77a38b/test/conftest.py#L77
|
|
16
|
-
- https://github.com/caiola/vinhos.com/blob/c47d0a5d7a4bf290c1b726561d1e8f5d2ac29bc8/backend/test/conftest.py#L46
|
|
17
|
-
"""
|
|
18
|
-
|
|
19
|
-
engine = SessionManager.get_instance().get_engine()
|
|
20
|
-
|
|
21
|
-
with engine.begin() as connection:
|
|
22
|
-
transaction = connection.begin_nested()
|
|
23
|
-
|
|
24
|
-
SessionManager.get_instance().session_connection = connection
|
|
25
|
-
|
|
26
|
-
try:
|
|
27
|
-
yield
|
|
28
|
-
finally:
|
|
29
|
-
transaction.rollback()
|
|
30
|
-
# TODO is this necessary?
|
|
31
|
-
connection.close()
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
# TODO unsure if this adds any value beyond the above approach
|
|
35
|
-
# def database_reset_named_truncation():
|
|
36
|
-
# start_truncation_query = """
|
|
37
|
-
# BEGIN;
|
|
38
|
-
# SAVEPOINT test_truncation_savepoint;
|
|
39
|
-
# """
|
|
40
|
-
|
|
41
|
-
# raw_sql_exec(start_truncation_query)
|
|
42
|
-
|
|
43
|
-
# yield
|
|
44
|
-
|
|
45
|
-
# end_truncation_query = """
|
|
46
|
-
# ROLLBACK TO SAVEPOINT test_truncation_savepoint;
|
|
47
|
-
# RELEASE SAVEPOINT test_truncation_savepoint;
|
|
48
|
-
# ROLLBACK;
|
|
49
|
-
# """
|
|
50
|
-
|
|
51
|
-
# raw_sql_exec(end_truncation_query)
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Test core ORM functions
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
from test.models import EXAMPLE_TABLE_PREFIX, AnotherExample, ExampleRecord
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def test_empty_count(create_and_wipe_database):
|
|
9
|
-
assert ExampleRecord.count() == 0
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def test_all_and_count(create_and_wipe_database):
|
|
13
|
-
AnotherExample().save()
|
|
14
|
-
|
|
15
|
-
records_to_create = 10
|
|
16
|
-
|
|
17
|
-
# create 10 example records
|
|
18
|
-
for i in range(records_to_create):
|
|
19
|
-
ExampleRecord().save()
|
|
20
|
-
|
|
21
|
-
assert ExampleRecord.count() == records_to_create
|
|
22
|
-
|
|
23
|
-
all_records = list(ExampleRecord.all())
|
|
24
|
-
assert len(all_records) == records_to_create
|
|
25
|
-
|
|
26
|
-
assert ExampleRecord.count() == records_to_create
|
|
27
|
-
|
|
28
|
-
record = all_records[0]
|
|
29
|
-
assert isinstance(record, ExampleRecord)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def test_foreign_key():
|
|
33
|
-
field = ExampleRecord.foreign_key()
|
|
34
|
-
assert field.sa_type.prefix == EXAMPLE_TABLE_PREFIX
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def test_basic_query(create_and_wipe_database):
|
|
38
|
-
example = ExampleRecord(something="hi").save()
|
|
39
|
-
query = ExampleRecord.select().where(ExampleRecord.something == "hi")
|
|
40
|
-
|
|
41
|
-
query_as_str = str(query)
|
|
42
|
-
result = query.first()
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def test_query_count(create_and_wipe_database):
|
|
46
|
-
AnotherExample().save()
|
|
47
|
-
|
|
48
|
-
example = ExampleRecord(something="hi").save()
|
|
49
|
-
count = ExampleRecord.select().where(ExampleRecord.something == "hi").count()
|
|
50
|
-
|
|
51
|
-
assert count == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|