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.
Files changed (80) hide show
  1. activemodel-0.14.0/.tool-versions +4 -0
  2. {activemodel-0.13.0 → activemodel-0.14.0}/CHANGELOG.md +19 -0
  3. {activemodel-0.13.0 → activemodel-0.14.0}/PKG-INFO +3 -2
  4. {activemodel-0.13.0 → activemodel-0.14.0}/README.md +2 -1
  5. {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/mixins/pydantic_json.py +27 -7
  6. {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/query_wrapper.py +57 -1
  7. {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/session_manager.py +8 -0
  8. {activemodel-0.13.0 → activemodel-0.14.0}/pyproject.toml +1 -1
  9. {activemodel-0.13.0 → activemodel-0.14.0}/test/nested_pydantic_json_test.py +47 -19
  10. activemodel-0.14.0/test/test_query_wrapper.py +163 -0
  11. activemodel-0.14.0/uv.lock +1362 -0
  12. activemodel-0.13.0/.tool-versions +0 -3
  13. activemodel-0.13.0/test/test_query_wrapper.py +0 -60
  14. activemodel-0.13.0/uv.lock +0 -1644
  15. {activemodel-0.13.0 → activemodel-0.14.0}/.envrc +0 -0
  16. {activemodel-0.13.0 → activemodel-0.14.0}/.github/dependabot.yml +0 -0
  17. {activemodel-0.13.0 → activemodel-0.14.0}/.github/workflows/build_and_publish.yml +0 -0
  18. {activemodel-0.13.0 → activemodel-0.14.0}/.github/workflows/repo-sync.yml +0 -0
  19. {activemodel-0.13.0 → activemodel-0.14.0}/.gitignore +0 -0
  20. {activemodel-0.13.0 → activemodel-0.14.0}/.vscode/settings.json +0 -0
  21. {activemodel-0.13.0 → activemodel-0.14.0}/Justfile +0 -0
  22. {activemodel-0.13.0 → activemodel-0.14.0}/LICENSE +0 -0
  23. {activemodel-0.13.0 → activemodel-0.14.0}/Makefile +0 -0
  24. {activemodel-0.13.0 → activemodel-0.14.0}/TODO +0 -0
  25. {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/__init__.py +0 -0
  26. {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/base_model.py +0 -0
  27. {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/celery.py +0 -0
  28. {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/cli/__init__.py +0 -0
  29. {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/errors.py +0 -0
  30. {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/get_column_from_field_patch.py +0 -0
  31. {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/logger.py +0 -0
  32. {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/mixins/__init__.py +0 -0
  33. {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/mixins/soft_delete.py +0 -0
  34. {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/mixins/timestamps.py +0 -0
  35. {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/mixins/typeid.py +0 -0
  36. {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/pytest/__init__.py +0 -0
  37. {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/pytest/factories.py +0 -0
  38. {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/pytest/plugin.py +0 -0
  39. {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/pytest/transaction.py +0 -0
  40. {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/pytest/truncate.py +0 -0
  41. {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/types/__init__.py +0 -0
  42. {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/types/sqlalchemy_protocol.py +0 -0
  43. {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/types/sqlalchemy_protocol.pyi +0 -0
  44. {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/types/typeid.py +0 -0
  45. {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/types/typeid_patch.py +0 -0
  46. {activemodel-0.13.0 → activemodel-0.14.0}/activemodel/utils.py +0 -0
  47. {activemodel-0.13.0 → activemodel-0.14.0}/docker-compose.yml +0 -0
  48. {activemodel-0.13.0 → activemodel-0.14.0}/playground/alternative_typeid_mixin.py +0 -0
  49. {activemodel-0.13.0 → activemodel-0.14.0}/playground/comments.py +0 -0
  50. {activemodel-0.13.0 → activemodel-0.14.0}/playground/env-with-model.patch +0 -0
  51. {activemodel-0.13.0 → activemodel-0.14.0}/playground/extract_comments.py +0 -0
  52. {activemodel-0.13.0 → activemodel-0.14.0}/playground/field.py +0 -0
  53. {activemodel-0.13.0 → activemodel-0.14.0}/playground/middleware.py +0 -0
  54. {activemodel-0.13.0 → activemodel-0.14.0}/playground/old_session_manager.py +0 -0
  55. {activemodel-0.13.0 → activemodel-0.14.0}/playground/pydantic_validation.py +0 -0
  56. {activemodel-0.13.0 → activemodel-0.14.0}/playground.py +0 -0
  57. {activemodel-0.13.0 → activemodel-0.14.0}/test/__init__.py +0 -0
  58. {activemodel-0.13.0 → activemodel-0.14.0}/test/comments_test.py +0 -0
  59. {activemodel-0.13.0 → activemodel-0.14.0}/test/conftest.py +0 -0
  60. {activemodel-0.13.0 → activemodel-0.14.0}/test/delete_test.py +0 -0
  61. {activemodel-0.13.0 → activemodel-0.14.0}/test/factory_test.py +0 -0
  62. {activemodel-0.13.0 → activemodel-0.14.0}/test/fastapi_test.py +0 -0
  63. {activemodel-0.13.0 → activemodel-0.14.0}/test/import_test.py +0 -0
  64. {activemodel-0.13.0 → activemodel-0.14.0}/test/lifecycle_test.py +0 -0
  65. {activemodel-0.13.0 → activemodel-0.14.0}/test/migrations/README +0 -0
  66. {activemodel-0.13.0 → activemodel-0.14.0}/test/migrations/alembic.ini +0 -0
  67. {activemodel-0.13.0 → activemodel-0.14.0}/test/migrations/env.py +0 -0
  68. {activemodel-0.13.0 → activemodel-0.14.0}/test/migrations/script.py.mako +0 -0
  69. {activemodel-0.13.0 → activemodel-0.14.0}/test/migrations_test.py +0 -0
  70. {activemodel-0.13.0 → activemodel-0.14.0}/test/models.py +0 -0
  71. {activemodel-0.13.0 → activemodel-0.14.0}/test/mutation_test.py +0 -0
  72. {activemodel-0.13.0 → activemodel-0.14.0}/test/orm/test_upsert.py +0 -0
  73. {activemodel-0.13.0 → activemodel-0.14.0}/test/orm_test.py +0 -0
  74. {activemodel-0.13.0 → activemodel-0.14.0}/test/pytest/pytest_test.py +0 -0
  75. {activemodel-0.13.0 → activemodel-0.14.0}/test/session_manager_test.py +0 -0
  76. {activemodel-0.13.0 → activemodel-0.14.0}/test/table_name_test.py +0 -0
  77. {activemodel-0.13.0 → activemodel-0.14.0}/test/types/typeid_mixin_test.py +0 -0
  78. {activemodel-0.13.0 → activemodel-0.14.0}/test/types/typeid_pydantic_test.py +0 -0
  79. {activemodel-0.13.0 → activemodel-0.14.0}/test/types/typeid_sqlmodel_test.py +0 -0
  80. {activemodel-0.13.0 → activemodel-0.14.0}/test/utils.py +0 -0
@@ -0,0 +1,4 @@
1
+ python 3.13.9
2
+ uv 0.8.10
3
+ direnv 2.37.1
4
+ just 1.42.4
@@ -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.13.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?tab=readme-ov-file
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?tab=readme-ov-file
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 is dict:
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
- model_cls = annotation
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 isinstance(annotation, UnionType):
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
- get_origin(model_cls) is list
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
  """
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "activemodel"
3
- version = "0.13.0"
3
+ version = "0.14.0"
4
4
  description = "Make SQLModel more like an a real ORM"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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
- assert isinstance(example.list_field[0], SubObject)
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
- assert isinstance(example.list_field[0], SubObject)
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
- assert isinstance(fresh_example.object_field, SubObject)
82
- assert isinstance(fresh_example.optional_object_field, SubObject)
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