sqlmodel-object-helpers 0.0.6__tar.gz → 0.0.7__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 (43) hide show
  1. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/PKG-INFO +1 -1
  2. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/src/sqlmodel_object_helpers/__init__.py +1 -1
  3. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/src/sqlmodel_object_helpers/query.py +17 -0
  4. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/src/sqlmodel_object_helpers/types/filters.py +32 -3
  5. sqlmodel_object_helpers-0.0.7/tests/test_data_error_handling.py +153 -0
  6. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/tests/test_datetime_range.py +28 -0
  7. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/tests/test_types.py +17 -4
  8. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/.github/workflows/publish.yml +0 -0
  9. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/.gitignore +0 -0
  10. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/LICENSE +0 -0
  11. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/README.md +0 -0
  12. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/pyproject.toml +0 -0
  13. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/src/sqlmodel_object_helpers/constants.py +0 -0
  14. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/src/sqlmodel_object_helpers/dynamic_meta.py +0 -0
  15. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/src/sqlmodel_object_helpers/exceptions.py +0 -0
  16. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/src/sqlmodel_object_helpers/filters.py +0 -0
  17. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/src/sqlmodel_object_helpers/loaders.py +0 -0
  18. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/src/sqlmodel_object_helpers/mutations.py +0 -0
  19. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/src/sqlmodel_object_helpers/operators.py +0 -0
  20. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/src/sqlmodel_object_helpers/session.py +0 -0
  21. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/src/sqlmodel_object_helpers/standalone.py +0 -0
  22. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/src/sqlmodel_object_helpers/types/__init__.py +0 -0
  23. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/src/sqlmodel_object_helpers/types/columns.py +0 -0
  24. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/src/sqlmodel_object_helpers/types/datetime.py +0 -0
  25. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/src/sqlmodel_object_helpers/types/pagination.py +0 -0
  26. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/src/sqlmodel_object_helpers/types/projections.py +0 -0
  27. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/tests/conftest.py +0 -0
  28. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/tests/test_bulk_mutations.py +0 -0
  29. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/tests/test_column_meta.py +0 -0
  30. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/tests/test_computed_columns.py +0 -0
  31. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/tests/test_count_exists.py +0 -0
  32. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/tests/test_dynamic_meta.py +0 -0
  33. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/tests/test_exceptions.py +0 -0
  34. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/tests/test_filters.py +0 -0
  35. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/tests/test_for_update.py +0 -0
  36. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/tests/test_generated_columns_pg.py +0 -0
  37. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/tests/test_loaders.py +0 -0
  38. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/tests/test_mutations.py +0 -0
  39. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/tests/test_operators.py +0 -0
  40. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/tests/test_query.py +0 -0
  41. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/tests/test_settings.py +0 -0
  42. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/tests/test_standalone.py +0 -0
  43. {sqlmodel_object_helpers-0.0.6 → sqlmodel_object_helpers-0.0.7}/tests/test_time_filter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlmodel-object-helpers
3
- Version: 0.0.6
3
+ Version: 0.0.7
4
4
  Summary: Generic async query helpers for SQLModel: filtering, eager loading, pagination
5
5
  Project-URL: Homepage, https://github.com/itstandart/sqlmodel-object-helpers
6
6
  Project-URL: Repository, https://github.com/itstandart/sqlmodel-object-helpers
@@ -1,6 +1,6 @@
1
1
  """sqlmodel-object-helpers — reusable query helpers for SQLModel projects."""
2
2
 
3
- __version__ = "0.0.6"
3
+ __version__ = "0.0.7"
4
4
 
5
5
  from .constants import QueryHelperSettings, settings
6
6
  from .dynamic_meta import (
@@ -4,6 +4,7 @@ from typing import Any
4
4
 
5
5
  from pydantic import BaseModel
6
6
  from sqlalchemy import exists as sa_exists
7
+ from sqlalchemy.exc import DataError as SADataError
7
8
  from sqlalchemy.ext.asyncio import AsyncSession
8
9
  from sqlalchemy.sql import func
9
10
  from sqlmodel import SQLModel, and_, or_, select
@@ -398,6 +399,10 @@ async def get_objects[T: SQLModel](
398
399
  if not suspend_error:
399
400
  raise
400
401
  return []
402
+ except SADataError as e:
403
+ if not suspend_error:
404
+ raise InvalidFilterError(str(e)) from e
405
+ return []
401
406
  except Exception as e:
402
407
  if not suspend_error:
403
408
  raise DatabaseError(str(e)) from e
@@ -467,6 +472,10 @@ async def count_objects[T: SQLModel](
467
472
  if not suspend_error:
468
473
  raise
469
474
  return 0
475
+ except SADataError as e:
476
+ if not suspend_error:
477
+ raise InvalidFilterError(str(e)) from e
478
+ return 0
470
479
  except Exception as e:
471
480
  if not suspend_error:
472
481
  raise DatabaseError(str(e)) from e
@@ -529,6 +538,10 @@ async def exists_object[T: SQLModel](
529
538
  if not suspend_error:
530
539
  raise
531
540
  return False
541
+ except SADataError as e:
542
+ if not suspend_error:
543
+ raise InvalidFilterError(str(e)) from e
544
+ return False
532
545
  except Exception as e:
533
546
  if not suspend_error:
534
547
  raise DatabaseError(str(e)) from e
@@ -721,6 +734,10 @@ async def get_projection[T: SQLModel](
721
734
  if suspend_error:
722
735
  return []
723
736
  raise
737
+ except SADataError as e:
738
+ if suspend_error:
739
+ return []
740
+ raise InvalidFilterError(str(e)) from e
724
741
  except Exception as e:
725
742
  if suspend_error:
726
743
  return []
@@ -103,6 +103,12 @@ class FilterDate(BaseModel):
103
103
 
104
104
 
105
105
  class FilterNaiveDatetime(BaseModel):
106
+ """Filter for TIMESTAMP WITHOUT TIME ZONE columns.
107
+
108
+ Timezone-aware input is **rejected** (raises ``ValueError`` → HTTP 422)
109
+ because silently stripping timezone info hides conversion bugs on the caller side.
110
+ """
111
+
106
112
  eq: datetime | None = None
107
113
  ne: datetime | None = None
108
114
  gt: datetime | None = None
@@ -110,6 +116,17 @@ class FilterNaiveDatetime(BaseModel):
110
116
  ge: datetime | None = None
111
117
  le: datetime | None = None
112
118
 
119
+ @field_validator("eq", "ne", "gt", "lt", "ge", "le", mode="after")
120
+ @classmethod
121
+ def reject_timezone(cls, v: datetime | None) -> datetime | None:
122
+ if v is not None and v.tzinfo is not None:
123
+ msg = (
124
+ "Timezone-aware datetime is not allowed for naive datetime filters. "
125
+ "Send a naive datetime (without timezone offset), e.g. '2026-03-19T00:00:00'"
126
+ )
127
+ raise ValueError(msg)
128
+ return v
129
+
113
130
 
114
131
  class FilterDatetime(BaseModel):
115
132
  eq: UTCDatetime | None = None
@@ -207,7 +224,8 @@ class FilterNaiveDatetimeRange(BaseModel):
207
224
  """
208
225
  Same as :class:`FilterDatetimeRange` but produces **naive** (timezone-unaware) datetimes.
209
226
 
210
- If the input contains timezone info, it is stripped (replaced with None).
227
+ Timezone-aware input is **rejected** (raises ``ValueError`` HTTP 422)
228
+ because silently stripping timezone info hides conversion bugs on the caller side.
211
229
  """
212
230
 
213
231
  gt: datetime | None = None
@@ -220,6 +238,17 @@ class FilterNaiveDatetimeRange(BaseModel):
220
238
  return cls._parse_range(data)
221
239
  return data
222
240
 
241
+ @field_validator("gt", "lt", mode="after")
242
+ @classmethod
243
+ def reject_timezone(cls, v: datetime | None) -> datetime | None:
244
+ if v is not None and v.tzinfo is not None:
245
+ msg = (
246
+ "Timezone-aware datetime is not allowed for naive datetime range filters. "
247
+ "Send a naive datetime (without timezone offset), e.g. '2026-03-19T00:00:00'"
248
+ )
249
+ raise ValueError(msg)
250
+ return v
251
+
223
252
  @staticmethod
224
253
  def _parse_range(range_str: str) -> dict[str, datetime]:
225
254
  range_str = range_str.strip()
@@ -231,9 +260,9 @@ class FilterNaiveDatetimeRange(BaseModel):
231
260
  result: dict[str, datetime] = {}
232
261
 
233
262
  if left:
234
- result["gt"] = _parse_range_part(left).replace(tzinfo=None)
263
+ result["gt"] = _parse_range_part(left)
235
264
  if right:
236
- result["lt"] = _parse_range_part(right).replace(tzinfo=None)
265
+ result["lt"] = _parse_range_part(right)
237
266
 
238
267
  return result
239
268
 
@@ -0,0 +1,153 @@
1
+ """
2
+ Tests for SADataError → InvalidFilterError handling in query.py.
3
+
4
+ SQLite does not raise sqlalchemy.exc.DataError, so we mock session methods
5
+ to simulate the asyncpg DataError that occurs when e.g. a timezone-aware
6
+ datetime is sent to a TIMESTAMP WITHOUT TIME ZONE column.
7
+
8
+ Covers all query entry points: get_objects, count_objects, exists_object, get_projection.
9
+ """
10
+
11
+ from unittest.mock import AsyncMock, patch
12
+
13
+ import pytest
14
+ from sqlalchemy.exc import DataError as SADataError
15
+
16
+ import sqlmodel_object_helpers as soh
17
+
18
+ from conftest import Applicant
19
+
20
+
21
+ pytestmark = pytest.mark.asyncio
22
+
23
+
24
+ def _make_sa_data_error() -> SADataError:
25
+ """Build a realistic SADataError matching the asyncpg timezone mismatch."""
26
+ return SADataError(
27
+ statement="SELECT ... WHERE gp_payment_date = $1::TIMESTAMP WITHOUT TIME ZONE",
28
+ params=(None,),
29
+ orig=Exception(
30
+ "can't subtract offset-naive and offset-aware datetimes"
31
+ ),
32
+ )
33
+
34
+
35
+ # =============================================================================
36
+ # get_objects — SADataError → InvalidFilterError(400)
37
+ # =============================================================================
38
+
39
+
40
+ async def test_get_objects_data_error_raises_invalid_filter(session):
41
+ """SADataError from DB must be re-raised as InvalidFilterError, not DatabaseError."""
42
+ with patch.object(session, "execute", new_callable=AsyncMock, side_effect=_make_sa_data_error()):
43
+ with pytest.raises(soh.InvalidFilterError) as exc_info:
44
+ await soh.get_objects(session, Applicant)
45
+
46
+ assert exc_info.value.status_code == 400
47
+ assert "offset-naive and offset-aware" in exc_info.value.message
48
+
49
+
50
+ async def test_get_objects_data_error_suspend_returns_empty(session):
51
+ """SADataError with suspend_error=True returns empty list, not raises."""
52
+ with patch.object(session, "execute", new_callable=AsyncMock, side_effect=_make_sa_data_error()):
53
+ result = await soh.get_objects(session, Applicant, suspend_error=True)
54
+
55
+ assert result == []
56
+
57
+
58
+ async def test_get_objects_data_error_not_database_error(session):
59
+ """SADataError must NOT become DatabaseError(500)."""
60
+ with patch.object(session, "execute", new_callable=AsyncMock, side_effect=_make_sa_data_error()):
61
+ with pytest.raises(soh.InvalidFilterError):
62
+ await soh.get_objects(session, Applicant)
63
+
64
+ # Verify it's NOT DatabaseError
65
+ try:
66
+ await soh.get_objects(session, Applicant)
67
+ except soh.DatabaseError:
68
+ pytest.fail("SADataError was incorrectly wrapped as DatabaseError(500)")
69
+ except soh.InvalidFilterError:
70
+ pass # correct behavior
71
+
72
+
73
+ # =============================================================================
74
+ # count_objects — SADataError → InvalidFilterError(400)
75
+ # =============================================================================
76
+
77
+
78
+ async def test_count_objects_data_error_raises_invalid_filter(session):
79
+ """SADataError in count_objects must raise InvalidFilterError."""
80
+ with patch.object(session, "scalar", new_callable=AsyncMock, side_effect=_make_sa_data_error()):
81
+ with pytest.raises(soh.InvalidFilterError) as exc_info:
82
+ await soh.count_objects(session, Applicant)
83
+
84
+ assert exc_info.value.status_code == 400
85
+
86
+
87
+ async def test_count_objects_data_error_suspend_returns_zero(session):
88
+ """SADataError in count_objects with suspend_error=True returns 0."""
89
+ with patch.object(session, "scalar", new_callable=AsyncMock, side_effect=_make_sa_data_error()):
90
+ result = await soh.count_objects(session, Applicant, suspend_error=True)
91
+
92
+ assert result == 0
93
+
94
+
95
+ # =============================================================================
96
+ # exists_object — SADataError → InvalidFilterError(400)
97
+ # =============================================================================
98
+
99
+
100
+ async def test_exists_object_data_error_raises_invalid_filter(session):
101
+ """SADataError in exists_object must raise InvalidFilterError."""
102
+ with patch.object(session, "scalar", new_callable=AsyncMock, side_effect=_make_sa_data_error()):
103
+ with pytest.raises(soh.InvalidFilterError) as exc_info:
104
+ await soh.exists_object(session, Applicant)
105
+
106
+ assert exc_info.value.status_code == 400
107
+
108
+
109
+ async def test_exists_object_data_error_suspend_returns_false(session):
110
+ """SADataError in exists_object with suspend_error=True returns False."""
111
+ with patch.object(session, "scalar", new_callable=AsyncMock, side_effect=_make_sa_data_error()):
112
+ result = await soh.exists_object(session, Applicant, suspend_error=True)
113
+
114
+ assert result is False
115
+
116
+
117
+ # =============================================================================
118
+ # get_projection — SADataError → InvalidFilterError(400)
119
+ # =============================================================================
120
+
121
+
122
+ async def test_get_projection_data_error_raises_invalid_filter(session):
123
+ """SADataError in get_projection must raise InvalidFilterError."""
124
+ with patch.object(session, "execute", new_callable=AsyncMock, side_effect=_make_sa_data_error()):
125
+ with pytest.raises(soh.InvalidFilterError) as exc_info:
126
+ await soh.get_projection(session, Applicant, columns=["id", "last_name"])
127
+
128
+ assert exc_info.value.status_code == 400
129
+
130
+
131
+ async def test_get_projection_data_error_suspend_returns_empty(session):
132
+ """SADataError in get_projection with suspend_error=True returns []."""
133
+ with patch.object(session, "execute", new_callable=AsyncMock, side_effect=_make_sa_data_error()):
134
+ result = await soh.get_projection(
135
+ session, Applicant, columns=["id", "last_name"], suspend_error=True,
136
+ )
137
+
138
+ assert result == []
139
+
140
+
141
+ # =============================================================================
142
+ # Contrast: non-DataError exceptions still become DatabaseError(500)
143
+ # =============================================================================
144
+
145
+
146
+ async def test_generic_exception_still_raises_database_error(session):
147
+ """Non-DataError exceptions must still become DatabaseError(500)."""
148
+ with patch.object(session, "execute", new_callable=AsyncMock, side_effect=RuntimeError("connection lost")):
149
+ with pytest.raises(soh.DatabaseError) as exc_info:
150
+ await soh.get_objects(session, Applicant)
151
+
152
+ assert exc_info.value.status_code == 500
153
+ assert "connection lost" in exc_info.value.message
@@ -293,3 +293,31 @@ class TestFilterNaiveDatetimeRange:
293
293
  assert f.lt == datetime(2026, 2, 1)
294
294
  assert f.gt.tzinfo is None
295
295
  assert f.lt.tzinfo is None
296
+
297
+ def test_rejects_tz_in_dict_input(self):
298
+ """Dict with timezone-aware ISO strings is rejected (was the 500 bug)."""
299
+ with pytest.raises(ValidationError, match="Timezone-aware datetime is not allowed"):
300
+ soh.FilterNaiveDatetimeRange.model_validate(
301
+ {"gt": "2026-03-20T12:24:00.000+03:00", "lt": "2026-03-20T23:59:59.999+03:00"},
302
+ )
303
+
304
+ def test_rejects_tz_in_dict_gt_only(self):
305
+ """Single gt with timezone in dict is rejected."""
306
+ with pytest.raises(ValidationError, match="Timezone-aware datetime is not allowed"):
307
+ soh.FilterNaiveDatetimeRange.model_validate(
308
+ {"gt": "2026-01-01T00:00:00+00:00"},
309
+ )
310
+
311
+ def test_rejects_tz_in_string_input(self):
312
+ """String range with timezone offset is rejected."""
313
+ with pytest.raises(ValidationError, match="Timezone-aware datetime is not allowed"):
314
+ soh.FilterNaiveDatetimeRange.model_validate(
315
+ "2026-04-01T03:00:00+03:00,2026-04-02T03:00:00+03:00"
316
+ )
317
+
318
+ def test_rejects_z_suffix_in_string_input(self):
319
+ """Z suffix means UTC (timezone-aware) — must be rejected for naive range."""
320
+ with pytest.raises(ValidationError, match="Timezone-aware datetime is not allowed"):
321
+ soh.FilterNaiveDatetimeRange.model_validate(
322
+ "2026-04-01T00:00:00Z,2026-04-02T00:00:00Z"
323
+ )
@@ -375,11 +375,24 @@ def test_filter_naive_datetime_accepts_iso_string():
375
375
  assert f.eq == datetime(2026, 6, 15, 10, 30, 0)
376
376
 
377
377
 
378
- def test_filter_naive_datetime_also_accepts_aware():
379
- """FilterNaiveDatetime does not reject aware datetimes no validation."""
378
+ def test_filter_naive_datetime_rejects_aware():
379
+ """FilterNaiveDatetime rejects timezone-aware datetimes with ValidationError."""
380
380
  dt = datetime(2026, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
381
- f = soh.FilterNaiveDatetime(eq=dt)
382
- assert f.eq is not None
381
+ with pytest.raises(ValidationError, match="Timezone-aware datetime is not allowed"):
382
+ soh.FilterNaiveDatetime(eq=dt)
383
+
384
+
385
+ def test_filter_naive_datetime_rejects_aware_iso_string():
386
+ """FilterNaiveDatetime rejects ISO string with timezone offset."""
387
+ with pytest.raises(ValidationError, match="Timezone-aware datetime is not allowed"):
388
+ soh.FilterNaiveDatetime(gt="2026-03-20T12:24:00.000+03:00")
389
+
390
+
391
+ def test_filter_naive_datetime_rejects_aware_all_operators():
392
+ """FilterNaiveDatetime rejects timezone-aware input on every operator."""
393
+ for field in ("eq", "ne", "gt", "lt", "ge", "le"):
394
+ with pytest.raises(ValidationError, match="Timezone-aware datetime is not allowed"):
395
+ soh.FilterNaiveDatetime(**{field: "2026-01-01T00:00:00+00:00"})
383
396
 
384
397
 
385
398
  def test_filter_naive_datetime_all_none():