activemodel 0.5.0__py3-none-any.whl → 0.7.0__py3-none-any.whl
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/__init__.py +2 -0
- activemodel/base_model.py +105 -29
- activemodel/celery.py +28 -0
- activemodel/errors.py +6 -0
- activemodel/get_column_from_field_patch.py +137 -0
- activemodel/mixins/__init__.py +2 -0
- activemodel/mixins/pydantic_json.py +69 -0
- activemodel/mixins/soft_delete.py +17 -0
- activemodel/mixins/typeid.py +27 -17
- activemodel/pytest/truncate.py +1 -1
- activemodel/query_wrapper.py +24 -10
- activemodel/session_manager.py +75 -5
- activemodel/types/__init__.py +1 -0
- activemodel/types/typeid.py +140 -5
- activemodel/utils.py +51 -1
- activemodel-0.7.0.dist-info/METADATA +235 -0
- activemodel-0.7.0.dist-info/RECORD +24 -0
- {activemodel-0.5.0.dist-info → activemodel-0.7.0.dist-info}/WHEEL +1 -2
- activemodel/_session_manager.py +0 -153
- activemodel-0.5.0.dist-info/METADATA +0 -66
- activemodel-0.5.0.dist-info/RECORD +0 -20
- activemodel-0.5.0.dist-info/top_level.txt +0 -1
- {activemodel-0.5.0.dist-info → activemodel-0.7.0.dist-info}/entry_points.txt +0 -0
- {activemodel-0.5.0.dist-info → activemodel-0.7.0.dist-info/licenses}/LICENSE +0 -0
activemodel/session_manager.py
CHANGED
|
@@ -3,24 +3,57 @@ Class to make managing sessions with SQL Model easy. Also provides a common entr
|
|
|
3
3
|
database environment when testing.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
import contextlib
|
|
7
|
+
import contextvars
|
|
8
|
+
import json
|
|
6
9
|
import typing as t
|
|
7
10
|
|
|
8
11
|
from decouple import config
|
|
9
|
-
from
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
from sqlalchemy import Connection, Engine
|
|
10
14
|
from sqlmodel import Session, create_engine
|
|
11
15
|
|
|
12
16
|
|
|
17
|
+
def _serialize_pydantic_model(model: BaseModel | list[BaseModel] | None) -> str | None:
|
|
18
|
+
"""
|
|
19
|
+
Pydantic models do not serialize to JSON. You'll get an error such as:
|
|
20
|
+
|
|
21
|
+
'TypeError: Object of type TranscriptEntry is not JSON serializable'
|
|
22
|
+
|
|
23
|
+
https://github.com/fastapi/sqlmodel/issues/63#issuecomment-2581016387
|
|
24
|
+
|
|
25
|
+
This custom serializer is passed to the DB engine to properly serialize pydantic models to
|
|
26
|
+
JSON for storage in a JSONB column.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
# TODO I bet this will fail on lists with mixed types
|
|
30
|
+
|
|
31
|
+
if isinstance(model, BaseModel):
|
|
32
|
+
return model.model_dump_json()
|
|
33
|
+
if isinstance(model, list):
|
|
34
|
+
# not everything in a list is a pydantic model
|
|
35
|
+
def dump_if_model(m):
|
|
36
|
+
if isinstance(m, BaseModel):
|
|
37
|
+
return m.model_dump()
|
|
38
|
+
return m
|
|
39
|
+
|
|
40
|
+
return json.dumps([dump_if_model(m) for m in model])
|
|
41
|
+
else:
|
|
42
|
+
return json.dumps(model)
|
|
43
|
+
|
|
44
|
+
|
|
13
45
|
class SessionManager:
|
|
14
46
|
_instance: t.ClassVar[t.Optional["SessionManager"]] = None
|
|
15
47
|
|
|
16
|
-
session_connection:
|
|
48
|
+
session_connection: Connection | None
|
|
49
|
+
"optionally specify a specific session connection to use for all get_session() calls, useful for testing"
|
|
17
50
|
|
|
18
51
|
@classmethod
|
|
19
52
|
def get_instance(cls, database_url: str | None = None) -> "SessionManager":
|
|
20
53
|
if cls._instance is None:
|
|
21
|
-
assert (
|
|
22
|
-
|
|
23
|
-
)
|
|
54
|
+
assert database_url is not None, (
|
|
55
|
+
"Database URL required for first initialization"
|
|
56
|
+
)
|
|
24
57
|
cls._instance = cls(database_url)
|
|
25
58
|
|
|
26
59
|
return cls._instance
|
|
@@ -28,6 +61,7 @@ class SessionManager:
|
|
|
28
61
|
def __init__(self, database_url: str):
|
|
29
62
|
self._database_url = database_url
|
|
30
63
|
self._engine = None
|
|
64
|
+
|
|
31
65
|
self.session_connection = None
|
|
32
66
|
|
|
33
67
|
# TODO why is this type not reimported?
|
|
@@ -35,6 +69,7 @@ class SessionManager:
|
|
|
35
69
|
if not self._engine:
|
|
36
70
|
self._engine = create_engine(
|
|
37
71
|
self._database_url,
|
|
72
|
+
json_serializer=_serialize_pydantic_model,
|
|
38
73
|
echo=config("ACTIVEMODEL_LOG_SQL", cast=bool, default=False),
|
|
39
74
|
# https://docs.sqlalchemy.org/en/20/core/pooling.html#disconnect-handling-pessimistic
|
|
40
75
|
pool_pre_ping=True,
|
|
@@ -44,6 +79,14 @@ class SessionManager:
|
|
|
44
79
|
return self._engine
|
|
45
80
|
|
|
46
81
|
def get_session(self):
|
|
82
|
+
if gsession := _session_context.get():
|
|
83
|
+
|
|
84
|
+
@contextlib.contextmanager
|
|
85
|
+
def _reuse_session():
|
|
86
|
+
yield gsession
|
|
87
|
+
|
|
88
|
+
return _reuse_session()
|
|
89
|
+
|
|
47
90
|
if self.session_connection:
|
|
48
91
|
return Session(bind=self.session_connection)
|
|
49
92
|
|
|
@@ -60,3 +103,30 @@ def get_engine():
|
|
|
60
103
|
|
|
61
104
|
def get_session():
|
|
62
105
|
return SessionManager.get_instance().get_session()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# contextvars must be at the top-level of a module! You will not get a warning if you don't do this.
|
|
109
|
+
_session_context = contextvars.ContextVar[Session | None](
|
|
110
|
+
"session_context", default=None
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@contextlib.contextmanager
|
|
115
|
+
def global_session():
|
|
116
|
+
with SessionManager.get_instance().get_session() as s:
|
|
117
|
+
token = _session_context.set(s)
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
yield
|
|
121
|
+
finally:
|
|
122
|
+
_session_context.reset(token)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def aglobal_session():
|
|
126
|
+
with SessionManager.get_instance().get_session() as s:
|
|
127
|
+
token = _session_context.set(s)
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
yield
|
|
131
|
+
finally:
|
|
132
|
+
_session_context.reset(token)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .typeid import TypeIDType
|
activemodel/types/typeid.py
CHANGED
|
@@ -3,11 +3,18 @@ Lifted from: https://github.com/akhundMurad/typeid-python/blob/main/examples/sql
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
from typing import Optional
|
|
6
|
+
from uuid import UUID
|
|
6
7
|
|
|
8
|
+
from pydantic import (
|
|
9
|
+
GetJsonSchemaHandler,
|
|
10
|
+
)
|
|
11
|
+
from pydantic_core import CoreSchema, core_schema
|
|
7
12
|
from sqlalchemy import types
|
|
8
13
|
from sqlalchemy.util import generic_repr
|
|
9
14
|
from typeid import TypeID
|
|
10
15
|
|
|
16
|
+
from activemodel.errors import TypeIDValidationError
|
|
17
|
+
|
|
11
18
|
|
|
12
19
|
class TypeIDType(types.TypeDecorator):
|
|
13
20
|
"""
|
|
@@ -45,12 +52,140 @@ class TypeIDType(types.TypeDecorator):
|
|
|
45
52
|
)
|
|
46
53
|
|
|
47
54
|
def process_bind_param(self, value, dialect):
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
"""
|
|
56
|
+
This is run when a search query is built or ...
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
if value is None:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
if isinstance(value, UUID):
|
|
63
|
+
# then it's a UUID class, such as UUID('01942886-7afc-7129-8f57-db09137ed002')
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
if isinstance(value, str) and value.startswith(self.prefix + "_"):
|
|
67
|
+
# then it's a TypeID such as 'user_01h45ytscbebyvny4gc8cr8ma2'
|
|
68
|
+
value = TypeID.from_string(value)
|
|
69
|
+
|
|
70
|
+
if isinstance(value, str):
|
|
71
|
+
# no prefix, raw UUID, let's coerce it into a UUID which SQLAlchemy can handle
|
|
72
|
+
# ex: '01942886-7afc-7129-8f57-db09137ed002'
|
|
73
|
+
return UUID(value)
|
|
74
|
+
|
|
75
|
+
if isinstance(value, TypeID):
|
|
76
|
+
# TODO in what case could this None prefix ever occur?
|
|
77
|
+
if self.prefix is None:
|
|
78
|
+
if value.prefix is None:
|
|
79
|
+
raise TypeIDValidationError(
|
|
80
|
+
"Must have a valid prefix set on the class"
|
|
81
|
+
)
|
|
82
|
+
else:
|
|
83
|
+
if value.prefix != self.prefix:
|
|
84
|
+
raise TypeIDValidationError(
|
|
85
|
+
f"Expected '{self.prefix}' but got '{value.prefix}'"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return value.uuid
|
|
52
89
|
|
|
53
|
-
|
|
90
|
+
raise ValueError("Unexpected input type")
|
|
54
91
|
|
|
55
92
|
def process_result_value(self, value, dialect):
|
|
93
|
+
if value is None:
|
|
94
|
+
return None
|
|
95
|
+
|
|
56
96
|
return TypeID.from_uuid(value, self.prefix)
|
|
97
|
+
|
|
98
|
+
# def coerce_compared_value(self, op, value):
|
|
99
|
+
# """
|
|
100
|
+
# This method is called when SQLAlchemy needs to compare a column to a value.
|
|
101
|
+
# By returning self, we indicate that this type can handle TypeID instances.
|
|
102
|
+
# """
|
|
103
|
+
# if isinstance(value, TypeID):
|
|
104
|
+
# return self
|
|
105
|
+
|
|
106
|
+
# return super().coerce_compared_value(op, value)
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def __get_pydantic_core_schema__(
|
|
110
|
+
cls, _core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
|
|
111
|
+
) -> CoreSchema:
|
|
112
|
+
"""
|
|
113
|
+
This fixes the following error: 'Unable to serialize unknown type' by telling pydantic how to serialize this field.
|
|
114
|
+
|
|
115
|
+
Note that TypeIDType MUST be the type of the field in SQLModel otherwise you'll get serialization errors.
|
|
116
|
+
This is done automatically for the mixin but for any relationship fields you'll need to specify the type explicitly.
|
|
117
|
+
|
|
118
|
+
- https://github.com/karma-dev-team/karma-system/blob/ee0c1a06ab2cb7aaca6dc4818312e68c5c623365/app/server/value_objects/steam_id.py#L88
|
|
119
|
+
- https://github.com/hhimanshu/uv-workspaces/blob/main/packages/api/src/_lib/dto/typeid_field.py
|
|
120
|
+
- https://github.com/karma-dev-team/karma-system/blob/ee0c1a06ab2cb7aaca6dc4818312e68c5c623365/app/base/typeid/type_id.py#L14
|
|
121
|
+
- https://github.com/pydantic/pydantic/issues/10060
|
|
122
|
+
- https://github.com/fastapi/fastapi/discussions/10027
|
|
123
|
+
- https://github.com/alice-biometrics/petisco/blob/b01ef1b84949d156f73919e126ed77aa8e0b48dd/petisco/base/domain/model/uuid.py#L50
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
from_uuid_schema = core_schema.chain_schema(
|
|
127
|
+
[
|
|
128
|
+
# TODO not sure how this is different from the UUID schema, should try it out.
|
|
129
|
+
# core_schema.is_instance_schema(TypeID),
|
|
130
|
+
# core_schema.uuid_schema(),
|
|
131
|
+
core_schema.no_info_plain_validator_function(
|
|
132
|
+
TypeID.from_string,
|
|
133
|
+
json_schema_input_schema=core_schema.str_schema(),
|
|
134
|
+
),
|
|
135
|
+
]
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return core_schema.json_or_python_schema(
|
|
139
|
+
json_schema=from_uuid_schema,
|
|
140
|
+
# metadata=core_schema.str_schema(
|
|
141
|
+
# pattern="^[0-9a-f]{24}$",
|
|
142
|
+
# min_length=24,
|
|
143
|
+
# max_length=24,
|
|
144
|
+
# ),
|
|
145
|
+
# metadata={
|
|
146
|
+
# "pydantic_js_input_core_schema": core_schema.str_schema(
|
|
147
|
+
# pattern="^[0-9a-f]{24}$",
|
|
148
|
+
# min_length=24,
|
|
149
|
+
# max_length=24,
|
|
150
|
+
# )
|
|
151
|
+
# },
|
|
152
|
+
python_schema=core_schema.union_schema([from_uuid_schema]),
|
|
153
|
+
serialization=core_schema.plain_serializer_function_ser_schema(
|
|
154
|
+
lambda x: str(x)
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
@classmethod
|
|
159
|
+
def __get_pydantic_json_schema__(
|
|
160
|
+
cls, schema: CoreSchema, handler: GetJsonSchemaHandler
|
|
161
|
+
):
|
|
162
|
+
"""
|
|
163
|
+
Called when generating the openapi schema. This overrides the `function-plain` type which
|
|
164
|
+
is generated by the `no_info_plain_validator_function`.
|
|
165
|
+
|
|
166
|
+
This logis seems to be a hot part of the codebase, so I'd expect this to break as pydantic
|
|
167
|
+
fastapi continue to evolve.
|
|
168
|
+
|
|
169
|
+
Note that this method can return multiple types. A return value can be as simple as:
|
|
170
|
+
|
|
171
|
+
{"type": "string"}
|
|
172
|
+
|
|
173
|
+
Or, you could return a more specific JSON schema type:
|
|
174
|
+
|
|
175
|
+
core_schema.uuid_schema()
|
|
176
|
+
|
|
177
|
+
The problem with using something like uuid_schema is the specifi patterns
|
|
178
|
+
|
|
179
|
+
https://github.com/BeanieODM/beanie/blob/2190cd9d1fc047af477d5e6897cc283799f54064/beanie/odm/fields.py#L153
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
"type": "string",
|
|
184
|
+
# TODO implement a more strict pattern in regex
|
|
185
|
+
# https://github.com/jetify-com/typeid/blob/3d182feed5687c21bb5ab93d5f457ff96749b68b/spec/README.md?plain=1#L38
|
|
186
|
+
# "pattern": "^[0-9a-f]{24}$",
|
|
187
|
+
# "minLength": 24,
|
|
188
|
+
# "maxLength": 24,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return core_schema.uuid_schema()
|
activemodel/utils.py
CHANGED
|
@@ -1,15 +1,65 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import pkgutil
|
|
3
|
+
import sys
|
|
4
|
+
from types import ModuleType
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import text
|
|
7
|
+
from sqlmodel import SQLModel
|
|
1
8
|
from sqlmodel.sql.expression import SelectOfScalar
|
|
2
9
|
|
|
3
|
-
from
|
|
10
|
+
from .logger import logger
|
|
11
|
+
from .session_manager import get_engine, get_session
|
|
4
12
|
|
|
5
13
|
|
|
6
14
|
def compile_sql(target: SelectOfScalar):
|
|
15
|
+
"convert a query into SQL, helpful for debugging"
|
|
7
16
|
dialect = get_engine().dialect
|
|
8
17
|
# TODO I wonder if we could store the dialect to avoid getting an engine reference
|
|
9
18
|
compiled = target.compile(dialect=dialect, compile_kwargs={"literal_binds": True})
|
|
10
19
|
return str(compiled)
|
|
11
20
|
|
|
12
21
|
|
|
22
|
+
# TODO document further, lots of risks here
|
|
13
23
|
def raw_sql_exec(raw_query: str):
|
|
14
24
|
with get_session() as session:
|
|
15
25
|
session.execute(text(raw_query))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def find_all_sqlmodels(module: ModuleType):
|
|
29
|
+
"""Import all model classes from module and submodules into current namespace."""
|
|
30
|
+
|
|
31
|
+
logger.debug(f"Starting model import from module: {module.__name__}")
|
|
32
|
+
model_classes = {}
|
|
33
|
+
|
|
34
|
+
# Walk through all submodules
|
|
35
|
+
for loader, module_name, is_pkg in pkgutil.walk_packages(module.__path__):
|
|
36
|
+
full_name = f"{module.__name__}.{module_name}"
|
|
37
|
+
logger.debug(f"Importing submodule: {full_name}")
|
|
38
|
+
|
|
39
|
+
# Check if module is already imported
|
|
40
|
+
if full_name in sys.modules:
|
|
41
|
+
submodule = sys.modules[full_name]
|
|
42
|
+
else:
|
|
43
|
+
logger.warning(
|
|
44
|
+
f"Module not found in sys.modules, not importing: {full_name}"
|
|
45
|
+
)
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
# Get all classes from module
|
|
49
|
+
for name, obj in inspect.getmembers(submodule):
|
|
50
|
+
if inspect.isclass(obj) and issubclass(obj, SQLModel) and obj != SQLModel:
|
|
51
|
+
logger.debug(f"Found model class: {name}")
|
|
52
|
+
model_classes[name] = obj
|
|
53
|
+
|
|
54
|
+
logger.debug(f"Completed model import. Found {len(model_classes)} models")
|
|
55
|
+
return model_classes
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def hash_function_code(func):
|
|
59
|
+
"get sha of a function to easily assert that it hasn't changed"
|
|
60
|
+
|
|
61
|
+
import hashlib
|
|
62
|
+
import inspect
|
|
63
|
+
|
|
64
|
+
source = inspect.getsource(func)
|
|
65
|
+
return hashlib.sha256(source.encode()).hexdigest()
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: activemodel
|
|
3
|
+
Version: 0.7.0
|
|
4
|
+
Summary: Make SQLModel more like an a real ORM
|
|
5
|
+
Project-URL: Repository, https://github.com/iloveitaly/activemodel
|
|
6
|
+
Author-email: Michael Bianco <iloveitaly@gmail.com>
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: activemodel,activerecord,orm,sqlalchemy,sqlmodel
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: pydash>=8.0.4
|
|
11
|
+
Requires-Dist: python-decouple-typed>=3.11.0
|
|
12
|
+
Requires-Dist: sqlmodel>=0.0.22
|
|
13
|
+
Requires-Dist: typeid-python>=0.3.1
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# ActiveModel: ORM Wrapper for SQLModel
|
|
17
|
+
|
|
18
|
+
No, this isn't *really* [ActiveModel](https://guides.rubyonrails.org/active_model_basics.html). It's just a wrapper around SQLModel that provides a more ActiveRecord-like interface.
|
|
19
|
+
|
|
20
|
+
SQLModel is *not* an ORM. It's a SQL query builder and a schema definition tool.
|
|
21
|
+
|
|
22
|
+
This package provides a thin wrapper around SQLModel that provides a more ActiveRecord-like interface with things like:
|
|
23
|
+
|
|
24
|
+
* Timestamp column mixins
|
|
25
|
+
* Lifecycle hooks
|
|
26
|
+
|
|
27
|
+
## Getting Started
|
|
28
|
+
|
|
29
|
+
First, setup your DB:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Then, setup some models:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from activemodel import BaseModel
|
|
39
|
+
from activemodel.mixins import TimestampsMixin, TypeIDMixin
|
|
40
|
+
|
|
41
|
+
class User(
|
|
42
|
+
BaseModel,
|
|
43
|
+
# optionally, obviously
|
|
44
|
+
TimestampsMixin,
|
|
45
|
+
# you can use a different pk type, but why would you?
|
|
46
|
+
# put this mixin last otherwise `id` will not be the first column in the DB
|
|
47
|
+
TypeIDMixin("user"),
|
|
48
|
+
# wire this model into the DB, without this alembic will not generate a migration
|
|
49
|
+
table=True
|
|
50
|
+
):
|
|
51
|
+
a_field: str
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
### Integrating Alembic
|
|
57
|
+
|
|
58
|
+
`alembic init` will not work out of the box. You need to mutate a handful of files:
|
|
59
|
+
|
|
60
|
+
* 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
|
+
* Use your DB URL from the ENV
|
|
62
|
+
* Target sqlalchemy metadata to the sqlmodel-generated metadata
|
|
63
|
+
|
|
64
|
+
[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
|
+
|
|
66
|
+
Here's a diff from the bare `alembic init` from version `1.14.1`.
|
|
67
|
+
|
|
68
|
+
```diff
|
|
69
|
+
diff --git i/test/migrations/alembic.ini w/test/migrations/alembic.ini
|
|
70
|
+
index 0d07420..a63631c 100644
|
|
71
|
+
--- i/test/migrations/alembic.ini
|
|
72
|
+
+++ w/test/migrations/alembic.ini
|
|
73
|
+
@@ -3,13 +3,14 @@
|
|
74
|
+
[alembic]
|
|
75
|
+
# path to migration scripts
|
|
76
|
+
# Use forward slashes (/) also on windows to provide an os agnostic path
|
|
77
|
+
-script_location = .
|
|
78
|
+
+script_location = migrations
|
|
79
|
+
|
|
80
|
+
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
|
81
|
+
# Uncomment the line below if you want the files to be prepended with date and time
|
|
82
|
+
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
|
83
|
+
# for all available tokens
|
|
84
|
+
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
|
85
|
+
+file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(rev)s_%%(slug)s
|
|
86
|
+
|
|
87
|
+
# sys.path path, will be prepended to sys.path if present.
|
|
88
|
+
# defaults to the current working directory.
|
|
89
|
+
diff --git i/test/migrations/env.py w/test/migrations/env.py
|
|
90
|
+
index 36112a3..a1e15c2 100644
|
|
91
|
+
--- i/test/migrations/env.py
|
|
92
|
+
+++ w/test/migrations/env.py
|
|
93
|
+
@@ -1,3 +1,6 @@
|
|
94
|
+
+# fmt: off
|
|
95
|
+
+# isort: off
|
|
96
|
+
+
|
|
97
|
+
from logging.config import fileConfig
|
|
98
|
+
|
|
99
|
+
from sqlalchemy import engine_from_config
|
|
100
|
+
@@ -14,11 +17,17 @@ config = context.config
|
|
101
|
+
if config.config_file_name is not None:
|
|
102
|
+
fileConfig(config.config_file_name)
|
|
103
|
+
|
|
104
|
+
+from sqlmodel import SQLModel
|
|
105
|
+
+from test.models import *
|
|
106
|
+
+from test.utils import database_url
|
|
107
|
+
+
|
|
108
|
+
+config.set_main_option("sqlalchemy.url", database_url())
|
|
109
|
+
+
|
|
110
|
+
# add your model's MetaData object here
|
|
111
|
+
# for 'autogenerate' support
|
|
112
|
+
# from myapp import mymodel
|
|
113
|
+
# target_metadata = mymodel.Base.metadata
|
|
114
|
+
-target_metadata = None
|
|
115
|
+
+target_metadata = SQLModel.metadata
|
|
116
|
+
|
|
117
|
+
# other values from the config, defined by the needs of env.py,
|
|
118
|
+
# can be acquired:
|
|
119
|
+
diff --git i/test/migrations/script.py.mako w/test/migrations/script.py.mako
|
|
120
|
+
index fbc4b07..9dc78bb 100644
|
|
121
|
+
--- i/test/migrations/script.py.mako
|
|
122
|
+
+++ w/test/migrations/script.py.mako
|
|
123
|
+
@@ -9,6 +9,8 @@ from typing import Sequence, Union
|
|
124
|
+
|
|
125
|
+
from alembic import op
|
|
126
|
+
import sqlalchemy as sa
|
|
127
|
+
+import sqlmodel
|
|
128
|
+
+import activemodel
|
|
129
|
+
${imports if imports else ""}
|
|
130
|
+
|
|
131
|
+
# revision identifiers, used by Alembic.
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Here are some useful resources around Alembic + SQLModel:
|
|
135
|
+
|
|
136
|
+
* https://github.com/fastapi/sqlmodel/issues/85
|
|
137
|
+
* https://testdriven.io/blog/fastapi-sqlmodel/
|
|
138
|
+
|
|
139
|
+
### Query Wrapper
|
|
140
|
+
|
|
141
|
+
This tool is added to all `BaseModel`s and makes it easy to write SQL queries. Some examples:
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
### Easy Database Sessions
|
|
146
|
+
|
|
147
|
+
I hate the idea f
|
|
148
|
+
|
|
149
|
+
* Behavior should be intuitive and easy to understand. If you run `save()`, it should save, not stick the save in a transaction.
|
|
150
|
+
* 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.
|
|
151
|
+
|
|
152
|
+
There are a couple of thorny problems we need to solve for here:
|
|
153
|
+
|
|
154
|
+
* 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.
|
|
155
|
+
*
|
|
156
|
+
|
|
157
|
+
https://github.com/tomwojcik/starlette-context
|
|
158
|
+
|
|
159
|
+
### Example Queries
|
|
160
|
+
|
|
161
|
+
* Conditional: `Scrape.select().where(Scrape.id < last_scraped.id).all()`
|
|
162
|
+
* Equality: `MenuItem.select().where(MenuItem.menu_id == menu.id).all()`
|
|
163
|
+
* `IN` example: `CanonicalMenuItem.select().where(col(CanonicalMenuItem.id).in_(canonized_ids)).all()`
|
|
164
|
+
|
|
165
|
+
### TypeID
|
|
166
|
+
|
|
167
|
+
I'm a massive fan of Stripe-style prefixed UUIDs. [There's an excellent project](https://github.com/jetify-com/typeid)
|
|
168
|
+
that defined a clear spec for these IDs. I've used the python implementation of this spec and developed a clean integration
|
|
169
|
+
with SQLModel that plays well with fastapi as well.
|
|
170
|
+
|
|
171
|
+
Here's an example of defining a relationship:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
import uuid
|
|
175
|
+
|
|
176
|
+
from activemodel import BaseModel
|
|
177
|
+
from activemodel.mixins import TimestampsMixin, TypeIDMixin
|
|
178
|
+
from activemodel.types import TypeIDType
|
|
179
|
+
from sqlmodel import Field, Relationship
|
|
180
|
+
|
|
181
|
+
from .patient import Patient
|
|
182
|
+
|
|
183
|
+
class Appointment(
|
|
184
|
+
BaseModel,
|
|
185
|
+
# this adds an `id` field to the model with the correct type
|
|
186
|
+
TypeIDMixin("appointment"),
|
|
187
|
+
table=True
|
|
188
|
+
):
|
|
189
|
+
# `foreign_key` is a activemodel-specific method to generate the right `Field` for the relationship
|
|
190
|
+
# TypeIDType is really important here for fastapi serialization
|
|
191
|
+
doctor_id: TypeIDType = Doctor.foreign_key()
|
|
192
|
+
doctor: Doctor = Relationship()
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Limitations
|
|
196
|
+
|
|
197
|
+
### Validation
|
|
198
|
+
|
|
199
|
+
SQLModel does not currently support pydantic validations (when `table=True`). This is very surprising, but is actually the intended functionality:
|
|
200
|
+
|
|
201
|
+
* https://github.com/fastapi/sqlmodel/discussions/897
|
|
202
|
+
* https://github.com/fastapi/sqlmodel/pull/1041
|
|
203
|
+
* https://github.com/fastapi/sqlmodel/issues/453
|
|
204
|
+
* https://github.com/fastapi/sqlmodel/issues/52#issuecomment-1311987732
|
|
205
|
+
|
|
206
|
+
For validation:
|
|
207
|
+
|
|
208
|
+
* When consuming API data, use a separate shadow model to validate the data with `table=False` and then inherit from that model in a model with `table=True`.
|
|
209
|
+
* When validating ORM data, use SQL Alchemy hooks.
|
|
210
|
+
|
|
211
|
+
<!--
|
|
212
|
+
|
|
213
|
+
This looks neat
|
|
214
|
+
https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d2a4/src/my_model/model.py#L155
|
|
215
|
+
schema_extra={
|
|
216
|
+
'pattern': r'^[a-z0-9_\-\.]+\@[a-z0-9_\-\.]+\.[a-z\.]+$'
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
extra constraints
|
|
220
|
+
|
|
221
|
+
https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d2a4/src/my_model/model.py#L424C1-L426C6
|
|
222
|
+
-->
|
|
223
|
+
## Related Projects
|
|
224
|
+
|
|
225
|
+
* https://github.com/woofz/sqlmodel-basecrud
|
|
226
|
+
* https://github.com/0xthiagomartins/sqlmodel-controller
|
|
227
|
+
|
|
228
|
+
## Inspiration
|
|
229
|
+
|
|
230
|
+
* https://github.com/peterdresslar/fastapi-sqlmodel-alembic-pg
|
|
231
|
+
* [Albemic instructions](https://github.com/fastapi/sqlmodel/pull/899/files)
|
|
232
|
+
* https://github.com/fastapiutils/fastapi-utils/
|
|
233
|
+
* https://github.com/fastapi/full-stack-fastapi-template
|
|
234
|
+
* https://github.com/DarylStark/my_data/
|
|
235
|
+
* https://github.com/petrgazarov/FastAPI-app/tree/main/fastapi_app
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
activemodel/__init__.py,sha256=q_lHQyIM70ApvjduTo9GtenQjJXsfYZsAAquD_51kF4,137
|
|
2
|
+
activemodel/base_model.py,sha256=aa5cJYZsRvYYk-TvPP_DoblvtZrn6-KGB7YS0rbAJbw,10964
|
|
3
|
+
activemodel/celery.py,sha256=F2X5VJoNej6yg__nbF2VXaq6MK8jmgUlfo5XXivfgtU,740
|
|
4
|
+
activemodel/errors.py,sha256=wycWYmk9ws4TZpxvTdtXVy2SFESb8NqKgzdivBoF0vw,115
|
|
5
|
+
activemodel/get_column_from_field_patch.py,sha256=Rl1KIsELSLxDGDb4K7l97Fjr1qyXZtlTJlMa7-ddllc,4869
|
|
6
|
+
activemodel/logger.py,sha256=vU7QiGSy_AJuJFmClUocqIJ-Ltku_8C24ZU8L6fLJR0,53
|
|
7
|
+
activemodel/query_wrapper.py,sha256=rNdvueppMse2MIi-RafTEC34GPGRal_wqH2CzhmlWS8,2520
|
|
8
|
+
activemodel/session_manager.py,sha256=JpCN_mTsbYOOud9HsX6mP_HOf3i57O5haMgnv9WOeyU,3874
|
|
9
|
+
activemodel/utils.py,sha256=g17UqkphzTmb6YdpmYwT1TM00eDiXXuWn39-xNiu0AA,2112
|
|
10
|
+
activemodel/mixins/__init__.py,sha256=05EQl2u_Wgf_wkly-GTaTsR7zWpmpKcb96Js7r_rZTw,160
|
|
11
|
+
activemodel/mixins/pydantic_json.py,sha256=HfnUKy_XA7MQcmR9yMTBweaNgxJhhEo3Z07hFz31gIg,2555
|
|
12
|
+
activemodel/mixins/soft_delete.py,sha256=Ax4mGsQI7AVTE8c4GiWxpyB_W179-dDct79GtjP0owU,461
|
|
13
|
+
activemodel/mixins/timestamps.py,sha256=Q-IFljeVVJQqw3XHdOi7dkqzefiVg1zhJvq_bldpmjg,992
|
|
14
|
+
activemodel/mixins/typeid.py,sha256=DGjlIg8PRBYoaBbWkkxc6jkScyl-p53KuSR98lLgAvE,1284
|
|
15
|
+
activemodel/pytest/__init__.py,sha256=W9KKQHbPkyq0jrMXaiL8hG2Nsbjy_LN9HhvgGm8W_7g,98
|
|
16
|
+
activemodel/pytest/transaction.py,sha256=rrsoHnbu79kNdnI5fZeOZr5hzrLB-cQH10MueQp5jV4,1670
|
|
17
|
+
activemodel/pytest/truncate.py,sha256=IGiPLkUm2yyOKww6c6CKcVbwi2xAAFBopx9q2ABfu8w,1582
|
|
18
|
+
activemodel/types/__init__.py,sha256=y5fiGVtPJxGEhuf-TvyrkhM2yaKRcIWo6XAx-CFFjM8,31
|
|
19
|
+
activemodel/types/typeid.py,sha256=uTCtTm-HdvqZ2_wkIAOkCwDMNiNZna9AbSbOBNBca7o,7225
|
|
20
|
+
activemodel-0.7.0.dist-info/METADATA,sha256=Ht_6c2mD2_qGnrEDfMFxazdNqB9P4POPAU7dCrZ0s94,8216
|
|
21
|
+
activemodel-0.7.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
22
|
+
activemodel-0.7.0.dist-info/entry_points.txt,sha256=YLX62TP_hR-n3HMBkdBex4W7XRiyOtIPkwy22puIjjQ,61
|
|
23
|
+
activemodel-0.7.0.dist-info/licenses/LICENSE,sha256=L8mmpX47rB-xtJ_HsK0zpfO6viEjxbLYGn70BMp8os4,1071
|
|
24
|
+
activemodel-0.7.0.dist-info/RECORD,,
|