activemodel 0.13.0__py3-none-any.whl → 0.14.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/mixins/pydantic_json.py +27 -7
- activemodel/query_wrapper.py +57 -1
- activemodel/session_manager.py +8 -0
- {activemodel-0.13.0.dist-info → activemodel-0.14.0.dist-info}/METADATA +3 -2
- {activemodel-0.13.0.dist-info → activemodel-0.14.0.dist-info}/RECORD +8 -8
- {activemodel-0.13.0.dist-info → activemodel-0.14.0.dist-info}/WHEEL +0 -0
- {activemodel-0.13.0.dist-info → activemodel-0.14.0.dist-info}/entry_points.txt +0 -0
- {activemodel-0.13.0.dist-info → activemodel-0.14.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -6,9 +6,9 @@ SQLModel lacks a direct JSONField equivalent (like Tortoise ORM's JSONField), ma
|
|
|
6
6
|
Extensive discussion on the problem: https://github.com/fastapi/sqlmodel/issues/63
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
from types import UnionType
|
|
10
9
|
from typing import get_args, get_origin
|
|
11
|
-
|
|
10
|
+
import typing
|
|
11
|
+
import types
|
|
12
12
|
from pydantic import BaseModel as PydanticBaseModel
|
|
13
13
|
from sqlalchemy.orm import reconstructor, attributes
|
|
14
14
|
|
|
@@ -21,6 +21,11 @@ class PydanticJSONMixin:
|
|
|
21
21
|
|
|
22
22
|
>>> class ExampleWithJSON(BaseModel, PydanticJSONMixin, table=True):
|
|
23
23
|
>>> list_field: list[SubObject] = Field(sa_type=JSONB()
|
|
24
|
+
|
|
25
|
+
Notes:
|
|
26
|
+
|
|
27
|
+
- Tuples of pydantic models are not supported, only lists.
|
|
28
|
+
- Nested lists of pydantic models are not supported, e.g. list[list[SubObject]]
|
|
24
29
|
"""
|
|
25
30
|
|
|
26
31
|
@reconstructor
|
|
@@ -37,6 +42,7 @@ class PydanticJSONMixin:
|
|
|
37
42
|
for field_name, field_info in self.model_fields.items():
|
|
38
43
|
raw_value = getattr(self, field_name, None)
|
|
39
44
|
|
|
45
|
+
# if the field is not set on the model, we can avoid doing anything with it
|
|
40
46
|
if raw_value is None:
|
|
41
47
|
continue
|
|
42
48
|
|
|
@@ -44,32 +50,43 @@ class PydanticJSONMixin:
|
|
|
44
50
|
origin = get_origin(annotation)
|
|
45
51
|
|
|
46
52
|
# e.g. `dict` or `dict[str, str]`, we don't want to do anything with these
|
|
47
|
-
if origin
|
|
53
|
+
if origin in (dict, tuple):
|
|
48
54
|
continue
|
|
49
55
|
|
|
50
56
|
annotation_args = get_args(annotation)
|
|
51
57
|
is_top_level_list = origin is list
|
|
58
|
+
model_cls = annotation
|
|
52
59
|
|
|
60
|
+
# TODO not sure what was going on here...
|
|
53
61
|
# if origin is not None:
|
|
54
62
|
# assert annotation.__class__ == origin
|
|
55
63
|
|
|
56
|
-
|
|
64
|
+
# UnionType is only one way of defining an optional. If older typing syntax is used `Tuple[str] | None` the
|
|
65
|
+
# type annotation is different: `typing.Optional[typing.Tuple[float, float]]`. This is why we check both
|
|
66
|
+
# types below.
|
|
57
67
|
|
|
58
68
|
# e.g. SomePydanticModel | None or list[SomePydanticModel] | None
|
|
59
|
-
# annotation_args are (type, NoneType) in this case
|
|
60
|
-
if
|
|
69
|
+
# annotation_args are (type, NoneType) in this case. Remove NoneType.
|
|
70
|
+
if origin in (typing.Union, types.UnionType):
|
|
61
71
|
non_none_types = [t for t in annotation_args if t is not type(None)]
|
|
62
72
|
|
|
63
73
|
if len(non_none_types) == 1:
|
|
64
74
|
model_cls = non_none_types[0]
|
|
75
|
+
else:
|
|
76
|
+
# if there's more than one non-none type, it isn't meant to be serialized to JSON
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
model_cls_origin = get_origin(model_cls)
|
|
65
80
|
|
|
66
81
|
# e.g. list[SomePydanticModel] | None, we have to unpack it
|
|
67
82
|
# model_cls will print as a list, but it contains a subtype if you dig into it
|
|
68
83
|
if (
|
|
69
|
-
|
|
84
|
+
model_cls_origin is list
|
|
70
85
|
and len(list_annotation_args := get_args(model_cls)) == 1
|
|
71
86
|
):
|
|
72
87
|
model_cls = list_annotation_args[0]
|
|
88
|
+
model_cls_origin = get_origin(model_cls)
|
|
89
|
+
|
|
73
90
|
is_top_level_list = True
|
|
74
91
|
|
|
75
92
|
# e.g. list[SomePydanticModel] or list[SomePydanticModel] | None
|
|
@@ -82,6 +99,9 @@ class PydanticJSONMixin:
|
|
|
82
99
|
attributes.set_committed_value(self, field_name, parsed_value)
|
|
83
100
|
continue
|
|
84
101
|
|
|
102
|
+
if model_cls_origin in (list, tuple):
|
|
103
|
+
continue
|
|
104
|
+
|
|
85
105
|
# single class
|
|
86
106
|
if issubclass(model_cls, PydanticBaseModel):
|
|
87
107
|
attributes.set_committed_value(self, field_name, model_cls(**raw_value))
|
activemodel/query_wrapper.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import sqlmodel as sm
|
|
2
2
|
from sqlmodel.sql.expression import SelectOfScalar
|
|
3
|
+
from typing import overload, Literal
|
|
3
4
|
|
|
4
5
|
from activemodel.types.sqlalchemy_protocol import SQLAlchemyQueryMethods
|
|
5
6
|
|
|
@@ -48,6 +49,8 @@ class QueryWrapper[T: sm.SQLModel](SQLAlchemyQueryMethods[T]):
|
|
|
48
49
|
with get_session() as session:
|
|
49
50
|
return session.scalar(sm.select(sm.func.count()).select_from(self.target))
|
|
50
51
|
|
|
52
|
+
# TODO typing is broken here
|
|
53
|
+
# TODO would be great to define a default return type if nothing is found
|
|
51
54
|
def scalar(self):
|
|
52
55
|
"""
|
|
53
56
|
>>>
|
|
@@ -63,6 +66,17 @@ class QueryWrapper[T: sm.SQLModel](SQLAlchemyQueryMethods[T]):
|
|
|
63
66
|
with get_session() as session:
|
|
64
67
|
return session.delete(self.target)
|
|
65
68
|
|
|
69
|
+
def exists(self) -> bool:
|
|
70
|
+
"""Return True if the current query yields at least one row.
|
|
71
|
+
|
|
72
|
+
Uses the SQLAlchemy exists() construct against a LIMIT 1 version of
|
|
73
|
+
the current target for efficiency. Keeps the original target intact.
|
|
74
|
+
"""
|
|
75
|
+
with get_session() as session:
|
|
76
|
+
exists_stmt = sm.select(sm.exists(self.target))
|
|
77
|
+
result = session.scalar(exists_stmt)
|
|
78
|
+
return bool(result)
|
|
79
|
+
|
|
66
80
|
def __getattr__(self, name):
|
|
67
81
|
"""
|
|
68
82
|
This implements the magic that forwards function calls to sqlalchemy.
|
|
@@ -79,7 +93,7 @@ class QueryWrapper[T: sm.SQLModel](SQLAlchemyQueryMethods[T]):
|
|
|
79
93
|
|
|
80
94
|
def wrapper(*args, **kwargs):
|
|
81
95
|
result = sqlalchemy_target(*args, **kwargs)
|
|
82
|
-
self.target = result
|
|
96
|
+
self.target = result # type: ignore[assignment]
|
|
83
97
|
return self
|
|
84
98
|
|
|
85
99
|
return wrapper
|
|
@@ -95,6 +109,48 @@ class QueryWrapper[T: sm.SQLModel](SQLAlchemyQueryMethods[T]):
|
|
|
95
109
|
|
|
96
110
|
return compile_sql(self.target)
|
|
97
111
|
|
|
112
|
+
@overload
|
|
113
|
+
def sample(self) -> T | None: ...
|
|
114
|
+
|
|
115
|
+
@overload
|
|
116
|
+
def sample(self, n: Literal[1]) -> T | None: ...
|
|
117
|
+
|
|
118
|
+
@overload
|
|
119
|
+
def sample(self, n: int) -> list[T]: ...
|
|
120
|
+
|
|
121
|
+
def sample(self, n: int = 1) -> T | None | list[T]:
|
|
122
|
+
"""Return a random sample of rows from the current query.
|
|
123
|
+
|
|
124
|
+
Parameters
|
|
125
|
+
----------
|
|
126
|
+
n: int
|
|
127
|
+
Number of rows to return. Defaults to 1.
|
|
128
|
+
|
|
129
|
+
Behavior
|
|
130
|
+
--------
|
|
131
|
+
- Returns a single model instance when ``n == 1`` (or ``None`` if no rows)
|
|
132
|
+
- Returns a list[Model] when ``n > 1`` (possibly empty list when no rows)
|
|
133
|
+
- Sampling is performed by appending an ``ORDER BY RANDOM()`` / ``func.random()``
|
|
134
|
+
and ``LIMIT n`` clause to the existing query target.
|
|
135
|
+
- Keeps original query intact (does not mutate ``self.target``) so further
|
|
136
|
+
chaining works as expected.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
if n < 1:
|
|
140
|
+
raise ValueError("n must be >= 1")
|
|
141
|
+
|
|
142
|
+
# Build a new randomized limited query leaving self.target untouched
|
|
143
|
+
randomized = self.target.order_by(sm.func.random()).limit(n)
|
|
144
|
+
|
|
145
|
+
with get_session() as session:
|
|
146
|
+
result = list(session.exec(randomized))
|
|
147
|
+
|
|
148
|
+
if n == 1:
|
|
149
|
+
# Return the single instance or None
|
|
150
|
+
return result[0] if result else None
|
|
151
|
+
|
|
152
|
+
return result
|
|
153
|
+
|
|
98
154
|
def __repr__(self) -> str:
|
|
99
155
|
# TODO we should improve structure of this a bit more, maybe wrap in <> or something?
|
|
100
156
|
return f"{self.__class__.__name__}: Current SQL:\n{self.sql()}"
|
activemodel/session_manager.py
CHANGED
|
@@ -150,6 +150,14 @@ def global_session(session: Session | None = None):
|
|
|
150
150
|
This may only be called a single time per callstack. There is one exception: if you call this multiple times
|
|
151
151
|
and pass in the same session reference, it will result in a noop.
|
|
152
152
|
|
|
153
|
+
In complex testing code, you'll need to be careful here. For example:
|
|
154
|
+
|
|
155
|
+
- Unit test using a transaction db fixture (which sets __sqlalchemy_session__)
|
|
156
|
+
- Factory has a after_save hook
|
|
157
|
+
- That hook triggers a celery job
|
|
158
|
+
- The celery job (properly) calls `with global_session()`
|
|
159
|
+
- However, since `global_session()` is already set with __sqlalchemy_session__, this will raise an error
|
|
160
|
+
|
|
153
161
|
Args:
|
|
154
162
|
session: Use an existing session instead of creating a new one
|
|
155
163
|
"""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: activemodel
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.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>
|
|
@@ -290,7 +290,8 @@ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d
|
|
|
290
290
|
|
|
291
291
|
* https://github.com/woofz/sqlmodel-basecrud
|
|
292
292
|
* https://github.com/0xthiagomartins/sqlmodel-controller
|
|
293
|
-
* https://github.com/litestar-org/advanced-alchemy
|
|
293
|
+
* https://github.com/litestar-org/advanced-alchemy
|
|
294
|
+
* https://github.com/dialoguemd/fastapi-sqla
|
|
294
295
|
|
|
295
296
|
## Inspiration
|
|
296
297
|
|
|
@@ -4,12 +4,12 @@ activemodel/celery.py,sha256=L1vKcO_HoPA5ZCfsXjxgPpDUMYDuoQMakGA9rppN7Lo,897
|
|
|
4
4
|
activemodel/errors.py,sha256=wycWYmk9ws4TZpxvTdtXVy2SFESb8NqKgzdivBoF0vw,115
|
|
5
5
|
activemodel/get_column_from_field_patch.py,sha256=wAEDm_ZvSqyJwfgkXVpxsevw11hd-7VLy7zuJG8Ak7Y,4986
|
|
6
6
|
activemodel/logger.py,sha256=vU7QiGSy_AJuJFmClUocqIJ-Ltku_8C24ZU8L6fLJR0,53
|
|
7
|
-
activemodel/query_wrapper.py,sha256=
|
|
8
|
-
activemodel/session_manager.py,sha256=
|
|
7
|
+
activemodel/query_wrapper.py,sha256=DLfmpMQr5veBjQIU2KEsp7Pe3MvdxQ-R-C6tkj0SgU8,4832
|
|
8
|
+
activemodel/session_manager.py,sha256=4jK0rs3KxU84WOvHCX6iuy0mauJg02tQ2VUQj-OEk68,7124
|
|
9
9
|
activemodel/utils.py,sha256=tZlAk0G46g6dwYuN7dIr8xU9QC_aLZYqjDXYkGiCtUg,888
|
|
10
10
|
activemodel/cli/__init__.py,sha256=HrgJjB5pRuE6hbwgy0Dw4oHvGZ47kH0LPVAdG9l6-vw,5021
|
|
11
11
|
activemodel/mixins/__init__.py,sha256=05EQl2u_Wgf_wkly-GTaTsR7zWpmpKcb96Js7r_rZTw,160
|
|
12
|
-
activemodel/mixins/pydantic_json.py,sha256=
|
|
12
|
+
activemodel/mixins/pydantic_json.py,sha256=Nm8Y0ra7N-2lEvLHtmZYq1XE1-4n2BIff812DzKgOb4,4461
|
|
13
13
|
activemodel/mixins/soft_delete.py,sha256=Ax4mGsQI7AVTE8c4GiWxpyB_W179-dDct79GtjP0owU,461
|
|
14
14
|
activemodel/mixins/timestamps.py,sha256=C6QQNnzrNUOW1EAsMpEVpImEeTIYDMPP0wocEw2RDQw,1078
|
|
15
15
|
activemodel/mixins/typeid.py,sha256=777btWRUW6YBGPApeaEdHQaoKmwblehukHzmkKoXv6o,1340
|
|
@@ -23,8 +23,8 @@ activemodel/types/sqlalchemy_protocol.py,sha256=2MSuGIp6pcIyiy8uK7qX3FLWABBMQOJG
|
|
|
23
23
|
activemodel/types/sqlalchemy_protocol.pyi,sha256=SP4Z50SGcw6qSexGgNd_4g6E_sQwpIE44vgNT4ncmeI,5667
|
|
24
24
|
activemodel/types/typeid.py,sha256=qycqklKv5nKuCqjJRnxA-6MjtcWJ4vFUsAVBc1ySwfg,7865
|
|
25
25
|
activemodel/types/typeid_patch.py,sha256=y6kiCJQ_NzeKfuI4UtRAs7QW_nEog5RIA_-k4HUBMkU,575
|
|
26
|
-
activemodel-0.
|
|
27
|
-
activemodel-0.
|
|
28
|
-
activemodel-0.
|
|
29
|
-
activemodel-0.
|
|
30
|
-
activemodel-0.
|
|
26
|
+
activemodel-0.14.0.dist-info/METADATA,sha256=pnDgnDPyY5Nz0ooqF0rrxe1DE7JcFFKcMqXbO-6aGOs,10750
|
|
27
|
+
activemodel-0.14.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
28
|
+
activemodel-0.14.0.dist-info/entry_points.txt,sha256=rytVrsNgUT4oDiW9RvRH6JBTHQn0hPZLK-jzQt3dY9s,51
|
|
29
|
+
activemodel-0.14.0.dist-info/licenses/LICENSE,sha256=L8mmpX47rB-xtJ_HsK0zpfO6viEjxbLYGn70BMp8os4,1071
|
|
30
|
+
activemodel-0.14.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|