activemodel 0.13.0__tar.gz → 0.14.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.14.0/.tool-versions +4 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/CHANGELOG.md +19 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/PKG-INFO +3 -2
- {activemodel-0.13.0 → activemodel-0.14.0}/README.md +2 -1
- {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/mixins/pydantic_json.py +27 -7
- {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/query_wrapper.py +57 -1
- {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/session_manager.py +8 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/pyproject.toml +1 -1
- {activemodel-0.13.0 → activemodel-0.14.0}/test/nested_pydantic_json_test.py +47 -19
- activemodel-0.14.0/test/test_query_wrapper.py +163 -0
- activemodel-0.14.0/uv.lock +1362 -0
- activemodel-0.13.0/.tool-versions +0 -3
- activemodel-0.13.0/test/test_query_wrapper.py +0 -60
- activemodel-0.13.0/uv.lock +0 -1644
- {activemodel-0.13.0 → activemodel-0.14.0}/.envrc +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/.github/dependabot.yml +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/.github/workflows/build_and_publish.yml +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/.github/workflows/repo-sync.yml +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/.gitignore +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/.vscode/settings.json +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/Justfile +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/LICENSE +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/Makefile +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/TODO +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/__init__.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/base_model.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/celery.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/cli/__init__.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/errors.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/get_column_from_field_patch.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/logger.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/mixins/__init__.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/mixins/soft_delete.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/mixins/timestamps.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/mixins/typeid.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/pytest/__init__.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/pytest/factories.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/pytest/plugin.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/pytest/transaction.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/pytest/truncate.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/types/__init__.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/types/sqlalchemy_protocol.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/types/sqlalchemy_protocol.pyi +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/types/typeid.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/types/typeid_patch.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/utils.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/docker-compose.yml +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/playground/alternative_typeid_mixin.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/playground/comments.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/playground/env-with-model.patch +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/playground/extract_comments.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/playground/field.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/playground/middleware.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/playground/old_session_manager.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/playground/pydantic_validation.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/playground.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/test/__init__.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/test/comments_test.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/test/conftest.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/test/delete_test.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/test/factory_test.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/test/fastapi_test.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/test/import_test.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/test/lifecycle_test.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/test/migrations/README +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/test/migrations/alembic.ini +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/test/migrations/env.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/test/migrations/script.py.mako +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/test/migrations_test.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/test/models.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/test/mutation_test.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/test/orm/test_upsert.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/test/orm_test.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/test/pytest/pytest_test.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/test/session_manager_test.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/test/table_name_test.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/test/types/typeid_mixin_test.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/test/types/typeid_pydantic_test.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/test/types/typeid_sqlmodel_test.py +0 -0
- {activemodel-0.13.0 → activemodel-0.14.0}/test/utils.py +0 -0
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.14.0](https://github.com/iloveitaly/activemodel/compare/v0.13.0...v0.14.0) (2025-10-16)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **query_wrapper:** add efficient exists() query method with tests ([257452f](https://github.com/iloveitaly/activemodel/commit/257452fdf976ce263b13997816a3b1b81d2902e9))
|
|
9
|
+
* **query:** add sample() to query wrapper for random row selection ([d35800c](https://github.com/iloveitaly/activemodel/commit/d35800c46244db21fd92e615609b895acdee25dc))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
* handle tuples and Optionals in JSON mixin ([1aa1018](https://github.com/iloveitaly/activemodel/commit/1aa1018d9714a43089fa9943daf1b4fcbc7742b9))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
* clarify global_session usage in complex test scenarios ([a131b0f](https://github.com/iloveitaly/activemodel/commit/a131b0f64287b4225ac1eb2289e6ce6f006aa4d5))
|
|
20
|
+
* fastapi-sqla ([2183686](https://github.com/iloveitaly/activemodel/commit/2183686421095856c59649c51c03ba1edaea9515))
|
|
21
|
+
|
|
3
22
|
## [0.13.0](https://github.com/iloveitaly/activemodel/compare/v0.12.0...v0.13.0) (2025-09-05)
|
|
4
23
|
|
|
5
24
|
|
|
@@ -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
|
|
|
@@ -275,7 +275,8 @@ https://github.com/DarylStark/my_data/blob/a17b8b3a8463b9953821b89fee895e272f94d
|
|
|
275
275
|
|
|
276
276
|
* https://github.com/woofz/sqlmodel-basecrud
|
|
277
277
|
* https://github.com/0xthiagomartins/sqlmodel-controller
|
|
278
|
-
* https://github.com/litestar-org/advanced-alchemy
|
|
278
|
+
* https://github.com/litestar-org/advanced-alchemy
|
|
279
|
+
* https://github.com/dialoguemd/fastapi-sqla
|
|
279
280
|
|
|
280
281
|
## Inspiration
|
|
281
282
|
|
|
@@ -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))
|
|
@@ -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()}"
|
|
@@ -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
|
"""
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
By default, fast API does not handle converting JSONB to and from Pydantic models.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
from typing import Optional, Tuple
|
|
5
6
|
from pydantic import BaseModel as PydanticBaseModel
|
|
6
7
|
from sqlalchemy.dialects.postgresql import JSONB
|
|
7
8
|
from sqlalchemy.orm.base import instance_state
|
|
9
|
+
from sqlalchemy.types import UserDefinedType
|
|
8
10
|
from sqlmodel import Field, Session
|
|
9
11
|
|
|
10
12
|
from activemodel import BaseModel
|
|
@@ -14,6 +16,25 @@ from activemodel.session_manager import global_session
|
|
|
14
16
|
from test.models import AnotherExample, ExampleWithComputedProperty
|
|
15
17
|
|
|
16
18
|
|
|
19
|
+
class CustomTupleType(UserDefinedType):
|
|
20
|
+
"""Custom SQLAlchemy type for testing tuple serialization."""
|
|
21
|
+
|
|
22
|
+
def get_col_spec(self):
|
|
23
|
+
return "TEXT"
|
|
24
|
+
|
|
25
|
+
def bind_processor(self, dialect):
|
|
26
|
+
return lambda value: None if value is None else ",".join(map(str, value))
|
|
27
|
+
|
|
28
|
+
def result_processor(self, dialect, coltype):
|
|
29
|
+
def process_value(value) -> Tuple[float, float] | None:
|
|
30
|
+
if value is None:
|
|
31
|
+
return None
|
|
32
|
+
parts = value.split(",")
|
|
33
|
+
return (float(parts[0]), float(parts[1]))
|
|
34
|
+
|
|
35
|
+
return process_value
|
|
36
|
+
|
|
37
|
+
|
|
17
38
|
class SubObject(PydanticBaseModel):
|
|
18
39
|
name: str
|
|
19
40
|
value: int
|
|
@@ -30,7 +51,9 @@ class ExampleWithJSONB(
|
|
|
30
51
|
unstructured_field: dict = Field(sa_type=JSONB)
|
|
31
52
|
semi_structured_field: dict[str, str] = Field(sa_type=JSONB)
|
|
32
53
|
optional_object_field: SubObject | None = Field(sa_type=JSONB, default=None)
|
|
33
|
-
|
|
54
|
+
old_optional_object_field: Optional[SubObject] = Field(sa_type=JSONB, default=None)
|
|
55
|
+
tuple_field: tuple[float, float] = Field(sa_type=CustomTupleType)
|
|
56
|
+
optional_tuple: Tuple | None = Field(sa_type=CustomTupleType, default=None)
|
|
34
57
|
normal_field: str | None = Field(default=None)
|
|
35
58
|
|
|
36
59
|
|
|
@@ -54,14 +77,28 @@ def test_json_serialization(create_and_wipe_database):
|
|
|
54
77
|
normal_field="test",
|
|
55
78
|
semi_structured_field={"one": "two", "three": "three"},
|
|
56
79
|
optional_object_field=sub_object,
|
|
80
|
+
old_optional_object_field=sub_object,
|
|
81
|
+
tuple_field=(1.0, 2.0),
|
|
82
|
+
optional_tuple=(1.0, 2.0),
|
|
57
83
|
).save()
|
|
58
84
|
|
|
85
|
+
def assert_types_preserved(obj: ExampleWithJSONB):
|
|
86
|
+
"""Helper to verify all JSONB fields maintain their proper types."""
|
|
87
|
+
assert isinstance(obj.list_field[0], SubObject)
|
|
88
|
+
assert obj.optional_list_field is not None
|
|
89
|
+
assert isinstance(obj.optional_list_field[0], SubObject)
|
|
90
|
+
assert isinstance(obj.object_field, SubObject)
|
|
91
|
+
assert isinstance(obj.optional_object_field, SubObject)
|
|
92
|
+
assert isinstance(obj.old_optional_object_field, SubObject)
|
|
93
|
+
assert isinstance(obj.tuple_field, tuple)
|
|
94
|
+
assert isinstance(obj.optional_tuple, tuple)
|
|
95
|
+
assert isinstance(obj.generic_list_field, list)
|
|
96
|
+
assert isinstance(obj.generic_list_field[0], dict)
|
|
97
|
+
assert isinstance(obj.unstructured_field, dict)
|
|
98
|
+
assert isinstance(obj.semi_structured_field, dict)
|
|
99
|
+
|
|
59
100
|
# make sure the types are preserved when saved
|
|
60
|
-
|
|
61
|
-
assert example.optional_list_field
|
|
62
|
-
assert isinstance(example.optional_list_field[0], SubObject)
|
|
63
|
-
assert isinstance(example.object_field, SubObject)
|
|
64
|
-
assert isinstance(example.optional_object_field, SubObject)
|
|
101
|
+
assert_types_preserved(example)
|
|
65
102
|
|
|
66
103
|
example.refresh()
|
|
67
104
|
|
|
@@ -69,23 +106,13 @@ def test_json_serialization(create_and_wipe_database):
|
|
|
69
106
|
assert not instance_state(example).modified
|
|
70
107
|
|
|
71
108
|
# make sure the types are preserved when refreshed
|
|
72
|
-
|
|
73
|
-
assert example.optional_list_field
|
|
74
|
-
assert isinstance(example.optional_list_field[0], SubObject)
|
|
75
|
-
assert isinstance(example.object_field, SubObject)
|
|
76
|
-
assert isinstance(example.optional_object_field, SubObject)
|
|
109
|
+
assert_types_preserved(example)
|
|
77
110
|
|
|
78
111
|
fresh_example = ExampleWithJSONB.get(example.id)
|
|
79
112
|
|
|
80
113
|
assert fresh_example is not None
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
assert isinstance(fresh_example.generic_list_field, list)
|
|
84
|
-
assert isinstance(fresh_example.generic_list_field[0], dict)
|
|
85
|
-
assert isinstance(fresh_example.list_field[0], SubObject)
|
|
86
|
-
assert fresh_example.optional_list_field
|
|
87
|
-
assert isinstance(fresh_example.optional_list_field[0], SubObject)
|
|
88
|
-
assert isinstance(fresh_example.unstructured_field, dict)
|
|
114
|
+
# make sure the types are preserved when loaded from database
|
|
115
|
+
assert_types_preserved(fresh_example)
|
|
89
116
|
|
|
90
117
|
|
|
91
118
|
def test_computed_serialization(create_and_wipe_database):
|
|
@@ -151,6 +178,7 @@ def test_json_object_update(create_and_wipe_database):
|
|
|
151
178
|
object_field=sub_object,
|
|
152
179
|
unstructured_field={"one": "two"},
|
|
153
180
|
semi_structured_field={"one": "two"},
|
|
181
|
+
tuple_field=(1.0, 2.0),
|
|
154
182
|
).save()
|
|
155
183
|
|
|
156
184
|
# saving serializes the pydantic model and reloads it, which must not mark the object as dirty!
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from typing import Any, Generator, assert_type
|
|
2
|
+
import uuid
|
|
3
|
+
|
|
4
|
+
import sqlmodel as sm
|
|
5
|
+
from sqlmodel.sql.expression import SelectOfScalar
|
|
6
|
+
from sqlalchemy import column
|
|
7
|
+
|
|
8
|
+
from activemodel.query_wrapper import QueryWrapper
|
|
9
|
+
from test.models import ExampleRecord, UpsertTestModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_basic_types(create_and_wipe_database):
|
|
13
|
+
qw = ExampleRecord.select()
|
|
14
|
+
|
|
15
|
+
sm_query = sm.select(ExampleRecord)
|
|
16
|
+
assert_type(sm_query, SelectOfScalar[ExampleRecord])
|
|
17
|
+
|
|
18
|
+
# assert type annotation of qw is QueryWrapper[ExampleRecord]
|
|
19
|
+
assert_type(qw, QueryWrapper[ExampleRecord])
|
|
20
|
+
assert isinstance(qw, QueryWrapper)
|
|
21
|
+
|
|
22
|
+
all_records = qw.all()
|
|
23
|
+
assert_type(all_records, Generator[ExampleRecord, Any, None])
|
|
24
|
+
|
|
25
|
+
all_records_list = list(all_records)
|
|
26
|
+
assert_type(all_records_list, list[ExampleRecord])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_scalar_single_column(create_and_wipe_database):
|
|
30
|
+
"""Ensure QueryWrapper.scalar returns the first column value when selecting a single scalar expression.
|
|
31
|
+
|
|
32
|
+
We create a record, build a query selecting only the id column and assert scalar() returns that id.
|
|
33
|
+
"""
|
|
34
|
+
record = ExampleRecord(something="hello").save()
|
|
35
|
+
|
|
36
|
+
# Build a query selecting only the id column from the ExampleRecord table
|
|
37
|
+
# Using the model .select(...) helper that forwards args to QueryWrapper
|
|
38
|
+
query = ExampleRecord.select(ExampleRecord.id).where(ExampleRecord.id == record.id)
|
|
39
|
+
|
|
40
|
+
value = query.scalar()
|
|
41
|
+
|
|
42
|
+
# Should return the primary key of the inserted record
|
|
43
|
+
assert value == record.id
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_exists_basic(create_and_wipe_database):
|
|
47
|
+
# empty table
|
|
48
|
+
assert ExampleRecord.select().exists() is False
|
|
49
|
+
|
|
50
|
+
r = ExampleRecord(something="hello").save()
|
|
51
|
+
assert ExampleRecord.select().exists() is True
|
|
52
|
+
|
|
53
|
+
# filter matches
|
|
54
|
+
assert ExampleRecord.select().where(ExampleRecord.id == r.id).exists() is True
|
|
55
|
+
# filter no match
|
|
56
|
+
assert (
|
|
57
|
+
ExampleRecord.select().where(ExampleRecord.id == str(uuid.uuid4())).exists()
|
|
58
|
+
is False
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_exists_does_not_mutate_query(create_and_wipe_database):
|
|
63
|
+
ExampleRecord(something="one").save()
|
|
64
|
+
q = ExampleRecord.select()
|
|
65
|
+
before_sql = q.sql()
|
|
66
|
+
assert q.exists() is True
|
|
67
|
+
# ensure calling exists didn't change underlying query
|
|
68
|
+
assert q.sql() == before_sql
|
|
69
|
+
# further chaining still works
|
|
70
|
+
assert q.where(ExampleRecord.something == "one").exists() is True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# TODO needs to be fixed
|
|
74
|
+
def test_select_with_args(create_and_wipe_database):
|
|
75
|
+
result = ExampleRecord.select(sm.func.count()).one()
|
|
76
|
+
|
|
77
|
+
assert result == 0
|
|
78
|
+
# TODO type inference for count() currently returns ExampleRecord | int; skip precise assert_type until generics fixed
|
|
79
|
+
# assert_type(result, int)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# TODO needs to be fixed
|
|
83
|
+
def test_result_types(create_and_wipe_database):
|
|
84
|
+
"ensure the result types are lists of the specific classes the wrapper was generated from"
|
|
85
|
+
|
|
86
|
+
ExampleRecord().save()
|
|
87
|
+
|
|
88
|
+
column_results = sm.select(column("id")).select_from(ExampleRecord)
|
|
89
|
+
# TODO column_results type is unknown
|
|
90
|
+
_ = column_results
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_scalar_sum_empty_returns_int_zero(create_and_wipe_database):
|
|
94
|
+
"""SUM over empty table should allow easy int coercion via `or 0`."""
|
|
95
|
+
raw_sum = UpsertTestModel.select(sm.func.sum(UpsertTestModel.value)).scalar()
|
|
96
|
+
assert raw_sum is None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_scalar_sum_with_rows_returns_int(create_and_wipe_database):
|
|
100
|
+
"""SUM over table with rows returns an int when coerced; raw may be int already."""
|
|
101
|
+
UpsertTestModel(name="a", category="c1", value=5).save()
|
|
102
|
+
UpsertTestModel(name="b", category="c1", value=7).save()
|
|
103
|
+
UpsertTestModel(name="c", category="c2", value=0).save()
|
|
104
|
+
|
|
105
|
+
raw_sum = UpsertTestModel.select(sm.func.sum(UpsertTestModel.value)).scalar()
|
|
106
|
+
assert isinstance(raw_sum, int)
|
|
107
|
+
assert raw_sum == 12
|
|
108
|
+
|
|
109
|
+
# assert_type(raw_sum, int) # generic inference currently loose
|
|
110
|
+
|
|
111
|
+
# Filtered sum (where no matching rows) again returns None -> coerce
|
|
112
|
+
filtered_none = (
|
|
113
|
+
UpsertTestModel.select(sm.func.sum(UpsertTestModel.value))
|
|
114
|
+
.where(UpsertTestModel.category == "does_not_exist")
|
|
115
|
+
.scalar()
|
|
116
|
+
)
|
|
117
|
+
assert filtered_none is None
|
|
118
|
+
# assert isinstance(filtered_sum, int)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_sample_single_none_when_empty(create_and_wipe_database):
|
|
122
|
+
"""sample() with no rows returns None when n==1."""
|
|
123
|
+
assert ExampleRecord.select().sample() is None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_sample_single_record(create_and_wipe_database):
|
|
127
|
+
r = ExampleRecord(something="one").save()
|
|
128
|
+
# With only one row we always get that row
|
|
129
|
+
sampled = ExampleRecord.select().sample()
|
|
130
|
+
assert sampled == r
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_sample_multiple(create_and_wipe_database):
|
|
134
|
+
# Insert several records
|
|
135
|
+
records = [ExampleRecord(something=str(i)).save() for i in range(10)]
|
|
136
|
+
|
|
137
|
+
sample_n = 5
|
|
138
|
+
sampled = ExampleRecord.select().sample(sample_n)
|
|
139
|
+
assert isinstance(sampled, list)
|
|
140
|
+
assert len(sampled) == sample_n
|
|
141
|
+
# Ensure all sampled items are part of inserted records set
|
|
142
|
+
record_ids = {r.id for r in records}
|
|
143
|
+
for row in sampled:
|
|
144
|
+
assert row.id in record_ids
|
|
145
|
+
# Should be unique (very high probability); enforce deterministically by set length
|
|
146
|
+
assert len({row.id for row in sampled}) == sample_n
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_sample_does_not_mutate_query(create_and_wipe_database):
|
|
150
|
+
ExampleRecord(something="one").save()
|
|
151
|
+
q = ExampleRecord.select().where(ExampleRecord.something == "one")
|
|
152
|
+
before_sql = q.sql()
|
|
153
|
+
_ = q.sample() # run sample
|
|
154
|
+
# underlying query unchanged
|
|
155
|
+
assert q.sql() == before_sql
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_sample_error_conditions(create_and_wipe_database):
|
|
159
|
+
try:
|
|
160
|
+
ExampleRecord.select().sample(0)
|
|
161
|
+
assert False, "Expected ValueError for n < 1"
|
|
162
|
+
except ValueError:
|
|
163
|
+
pass
|