sqlobjects 1.10.0__tar.gz → 2.0.1__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 (82) hide show
  1. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/CHANGELOG.md +24 -0
  2. {sqlobjects-1.10.0/sqlobjects.egg-info → sqlobjects-2.0.1}/PKG-INFO +1 -1
  3. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/docs/rules/01-database-session-guide.md +16 -3
  4. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/docs/rules/03-query-operations-guide.md +22 -7
  5. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/docs/rules/04-crud-operations-guide.md +93 -3
  6. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/docs/rules/05-relationships-guide.md +4 -4
  7. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/docs/rules/06-validation-signals-guide.md +9 -9
  8. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/docs/rules/README.md +32 -1
  9. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/pyproject.toml +1 -1
  10. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/__init__.py +1 -1
  11. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/_install_rules.py +18 -2
  12. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/exceptions.py +18 -0
  13. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/expressions/terminal.py +8 -14
  14. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/metadata.py +27 -8
  15. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/mixins.py +6 -0
  16. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/objects/bulk.py +26 -4
  17. sqlobjects-2.0.1/sqlobjects/py.typed +0 -0
  18. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/queries/builder.py +72 -27
  19. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/queries/executor.py +4 -0
  20. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/queryset.py +41 -15
  21. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/session.py +24 -1
  22. {sqlobjects-1.10.0 → sqlobjects-2.0.1/sqlobjects.egg-info}/PKG-INFO +1 -1
  23. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects.egg-info/SOURCES.txt +1 -0
  24. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/LICENSE +0 -0
  25. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/README.md +0 -0
  26. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/docs/rules/02-model-definition-guide.md +0 -0
  27. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/docs/rules/07-performance-guide.md +0 -0
  28. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/setup.cfg +0 -0
  29. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/cascade.py +0 -0
  30. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/contrib/__init__.py +0 -0
  31. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/contrib/asgi.py +0 -0
  32. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/contrib/fastapi.py +0 -0
  33. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/database/__init__.py +0 -0
  34. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/database/config.py +0 -0
  35. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/database/manager.py +0 -0
  36. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/expressions/__init__.py +0 -0
  37. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/expressions/aggregate.py +0 -0
  38. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/expressions/base.py +0 -0
  39. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/expressions/cte.py +0 -0
  40. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/expressions/explain.py +0 -0
  41. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/expressions/function.py +0 -0
  42. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/expressions/mixins.py +0 -0
  43. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/expressions/scalar.py +0 -0
  44. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/expressions/subquery.py +0 -0
  45. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/expressions/window.py +0 -0
  46. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/__init__.py +0 -0
  47. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/core.py +0 -0
  48. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/functions.py +0 -0
  49. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/proxies.py +0 -0
  50. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/relations/__init__.py +0 -0
  51. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/relations/descriptors.py +0 -0
  52. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/relations/managers.py +0 -0
  53. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/relations/prefetch.py +0 -0
  54. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/relations/strategies.py +0 -0
  55. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/relations/utils.py +0 -0
  56. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/shortcuts.py +0 -0
  57. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/types/__init__.py +0 -0
  58. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/types/base.py +0 -0
  59. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/types/comparators.py +0 -0
  60. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/types/registry.py +0 -0
  61. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/utils.py +0 -0
  62. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/internal/__init__.py +0 -0
  63. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/internal/operations.py +0 -0
  64. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/internal/results.py +0 -0
  65. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/model.py +0 -0
  66. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/objects/__init__.py +0 -0
  67. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/objects/core.py +0 -0
  68. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/objects/upsert.py +0 -0
  69. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/queries/__init__.py +0 -0
  70. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/queries/dialect.py +0 -0
  71. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/signals.py +0 -0
  72. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/sql_logging.py +0 -0
  73. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/utils/__init__.py +0 -0
  74. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/utils/inspect.py +0 -0
  75. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/utils/naming.py +0 -0
  76. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/utils/pattern.py +0 -0
  77. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/validators.py +0 -0
  78. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects.egg-info/dependency_links.txt +0 -0
  79. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects.egg-info/entry_points.txt +0 -0
  80. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects.egg-info/requires.txt +0 -0
  81. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects.egg-info/top_level.txt +0 -0
  82. {sqlobjects-1.10.0 → sqlobjects-2.0.1}/tests/test_config.py +0 -0
@@ -1,3 +1,27 @@
1
+ ## 2.0.1 (2026-08-31)
2
+
3
+ ### Fix
4
+
5
+ - **metadata**: include dialect kwargs in index dedup signature
6
+
7
+ ## 2.0.0 (2026-08-19)
8
+
9
+ ### BREAKING CHANGE
10
+
11
+ - annotate().group_by().all() with non-primary-key
12
+ grouping and group_by().aggregate() now raise QueryError; both
13
+ previously returned silently wrong results. QuerySet.create() removed
14
+ (never wrote to the database) — use Model.objects.create().
15
+
16
+ ### Feat
17
+
18
+ - **rules**: stamp installed rule files with the package version
19
+ - **query**: strict GROUP BY semantics, values-mode aggregation, manager-only hints
20
+ - **orm**: support SQLAlchemy expressions as write values
21
+ - **bulk**: make BulkResult a sequence and guarantee insert RETURNING order
22
+ - **session**: add join_ambient option to ctx_session and warn on nested sessions
23
+ - **build**: ship py.typed marker so type checkers see the package as typed
24
+
1
25
  ## 1.10.0 (2026-07-24)
2
26
 
3
27
  ### Feat
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlobjects
3
- Version: 1.10.0
3
+ Version: 2.0.1
4
4
  Summary: Django-style async ORM library based on SQLAlchemy with chainable queries, Q objects, and relationship loading
5
5
  Author-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
6
6
  Maintainer-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
@@ -120,14 +120,27 @@ await init_db(
120
120
  - **Don't create sessions manually** (use context managers)
121
121
 
122
122
  ```python
123
- # Caution: Nested sessions use separate transactions
123
+ # DANGER: Nested ctx_session opens a SECOND physical connection
124
124
  async with ctx_session() as session1:
125
125
  user = await User.objects.using(session1).create(username="alice")
126
126
  async with ctx_session() as session2:
127
- # Nesting is safe (Token-based restore), but uses a separate transaction
128
- # session1 won't see session2's uncommitted changes and vice versa
127
+ # ContextVar restore is safe, but session2 is a separate physical
128
+ # connection with its own transaction:
129
+ # - session1 won't see session2's uncommitted changes and vice versa
130
+ # - if session2 writes a row that session1 holds a lock on, session2
131
+ # blocks forever while session1 sits idle-in-transaction waiting for
132
+ # this coroutine to return. The database deadlock detector CANNOT
133
+ # see this cycle — it manifests as request timeouts, and session1's
134
+ # locks block unrelated requests until then.
129
135
  await Post.objects.using(session2).create(author_id=user.id)
130
136
 
137
+ # Good: join the ambient session when running inside a possibly-active transaction
138
+ async with ctx_session(join_ambient=True) as session:
139
+ # Reuses the outer session if one exists (no second connection);
140
+ # creates a new one only at the top level. Lifecycle (commit/rollback/close)
141
+ # belongs to the outermost owner.
142
+ await Post.objects.using(session).create(author_id=user.id)
143
+
131
144
  # Bad: No transaction control
132
145
  user = await User.objects.create(username="alice")
133
146
  # If next operation fails, user is already created
@@ -24,7 +24,7 @@ users = await User.objects.filter(
24
24
 
25
25
  # Comparison operators
26
26
  adults = await User.objects.filter(User.age >= 18).all()
27
- recent = await User.objects.filter(User.created_at > datetime.now() - timedelta(days=7)).all()
27
+ recent = await User.objects.filter(User.created_at > datetime.now(timezone.utc) - timedelta(days=7)).all()
28
28
  ```
29
29
 
30
30
  ### String Operations
@@ -113,18 +113,33 @@ users = await User.objects.annotate(
113
113
 
114
114
  ### Grouping
115
115
 
116
+ Grouped aggregation returns **one row per group**, not model instances — use
117
+ values-mode: list the grouping columns and aggregate aliases in `.values()`.
118
+
116
119
  ```python
117
- # Group by with aggregation
120
+ # Group by with aggregation → list[dict], one dict per group
118
121
  dept_stats = await User.objects.annotate(
119
122
  user_count=func.count()
120
- ).group_by("department").all()
123
+ ).group_by("department").values("department", "user_count")
124
+ # [{"department": "sales", "user_count": 10}, ...]
121
125
 
122
126
  # Having clause (filter groups)
123
127
  large_depts = await User.objects.annotate(
124
128
  user_count=func.count()
125
- ).group_by("department").having(func.count() > 10).all()
129
+ ).group_by("department").having(func.count() > 10).values("department", "user_count")
130
+
131
+ # Grouping by the primary key is the one case where .all() stays valid:
132
+ # each group is exactly one model row (e.g. counting related rows per user)
133
+ users = await User.objects.annotate(post_count=Post.id.count()) \
134
+ .join(Post.__table__, User.id == Post.author_id, join_type="left") \
135
+ .group_by(User.id).all()
126
136
  ```
127
137
 
138
+ **Do not** call `.all()` on a query grouped by a non-primary-key column — the
139
+ selected model columns would fall outside GROUP BY, so SQLObjects raises
140
+ `QueryError` instead of silently corrupting the aggregation (each group would
141
+ collapse to a single row).
142
+
128
143
  ### Distinct
129
144
 
130
145
  ```python
@@ -408,7 +423,7 @@ from sqlobjects.model import ObjectModel
408
423
  from sqlobjects.fields import Column, StringColumn, IntegerColumn, BooleanColumn
409
424
  from sqlobjects import Q
410
425
  from sqlobjects.expressions import func
411
- from datetime import datetime, timedelta
426
+ from datetime import datetime, timedelta, timezone
412
427
 
413
428
  class User(ObjectModel):
414
429
  username: Column[str] = StringColumn(length=50)
@@ -426,10 +441,10 @@ admins_or_staff = await User.objects.filter(
426
441
  Q(User.department == "admin") | Q(User.department == "staff")
427
442
  ).all()
428
443
 
429
- # Aggregation
444
+ # Aggregation (one row per group — see "Grouping" section)
430
445
  dept_stats = await User.objects.annotate(
431
446
  user_count=func.count()
432
- ).group_by("department").all()
447
+ ).group_by("department").values("department", "user_count")
433
448
 
434
449
  # Pagination with field selection
435
450
  users = await User.objects.only(
@@ -87,6 +87,32 @@ await User.objects.bulk_delete(user_ids, id_field="id", batch_size=1000)
87
87
  await User.objects.filter(User.is_active == False).delete()
88
88
  ```
89
89
 
90
+ ### BulkResult (return_objects=True)
91
+
92
+ `bulk_create` / `bulk_update` / `bulk_delete` return a plain `int` count by default.
93
+ With `return_objects=True` they return a **`BulkResult`**, not a list:
94
+
95
+ ```python
96
+ result = await User.objects.bulk_create(users_data, return_objects=True)
97
+
98
+ result.objects # list[User] — or list[dict] when return_fields is given
99
+ result.success_count # rows written successfully
100
+ result.error_count # rows that failed
101
+ result.total_count # input rows (success + failed)
102
+ result.failed_records # list[FailedRecord] with .index/.data/.error per failure
103
+ ```
104
+
105
+ Key semantics:
106
+
107
+ - **Sequence protocol**: `len(result)`, `for obj in result`, and `result[i]` all
108
+ operate on `result.objects` (the successfully returned objects).
109
+ - **Partial failure**: `len(result)` can be less than `result.total_count`.
110
+ Always read `success_count` / `failed_records` when `error_count > 0`.
111
+ - **Order guarantee**: for `bulk_create`, `result.objects` order matches the
112
+ input rows order.
113
+ - **`return_fields`**: when specified, `objects` contains dicts limited to those
114
+ fields instead of model instances.
115
+
90
116
  ### Cascade on Delete
91
117
 
92
118
  Both delete entry points detect and apply cascade behavior automatically:
@@ -126,9 +152,11 @@ users_data = [
126
152
  await User.objects.bulk_create(users_data, batch_size=500)
127
153
 
128
154
  # Bulk update with match fields
155
+ # (bulk rows must be plain values — datetime.now(timezone.utc) here, never naive
156
+ # local time; per-row SQL expressions are not supported in bulk operations)
129
157
  mappings = [
130
- {"id": 1, "status": "active", "last_seen": datetime.now()},
131
- {"id": 2, "status": "inactive", "last_seen": datetime.now()},
158
+ {"id": 1, "status": "active", "last_seen": datetime.now(timezone.utc)},
159
+ {"id": 2, "status": "inactive", "last_seen": datetime.now(timezone.utc)},
132
160
  ]
133
161
  await User.objects.bulk_update(mappings, match_fields=["id"])
134
162
 
@@ -137,6 +165,68 @@ user_ids = list(range(1, 1001))
137
165
  await User.objects.bulk_delete(user_ids, id_field="id", batch_size=1000)
138
166
  ```
139
167
 
168
+ ## SQL Expressions as Write Values
169
+
170
+ `create()` and `QuerySet.update()` accept SQLAlchemy expressions as values —
171
+ they are evaluated by the database, not serialized in Python:
172
+
173
+ ```python
174
+ from sqlalchemy import func, text
175
+
176
+ # Database clock, not application clock
177
+ await Task.objects.filter(Task.id == task_id).update(claimed_at=func.now())
178
+
179
+ # Relative updates (atomic, no read-modify-write race)
180
+ await User.objects.filter(User.id == user_id).update(age=User.age + 1)
181
+
182
+ # SQL fragments
183
+ await Task.objects.filter(Task.id == task_id).update(
184
+ not_before=text("now() + interval '60 seconds'") # PostgreSQL
185
+ )
186
+
187
+ # Also works in create()
188
+ task = await Task.objects.create(name="reindex", scheduled_at=func.now())
189
+ ```
190
+
191
+ Notes:
192
+
193
+ - After `create()` with an expression value, the in-memory instance attribute
194
+ holds the expression object, not the database result — re-fetch the row if
195
+ you need the actual stored value.
196
+ - **Bulk operations (`bulk_create` / `bulk_update`) do not support per-row SQL
197
+ expressions** — they execute with driver-level parameter batching, which
198
+ requires plain values.
199
+
200
+ ### Clock Discipline
201
+
202
+ Any field that participates in SQL-side time comparison (scheduling, leases,
203
+ debounce, `not_before` checks) **must use the database clock**, never
204
+ `datetime.now()` from the application:
205
+
206
+ ```python
207
+ # Bad: naive local time — breaks when app timezone != database timezone
208
+ await Task.objects.filter(Task.id == tid).update(not_before=datetime.now())
209
+
210
+ # Good: database clock — always consistent with SQL-side now() comparisons
211
+ await Task.objects.filter(Task.id == tid).update(not_before=func.now())
212
+ ```
213
+
214
+ A naive `datetime.now()` written from an application in UTC+8 compared against
215
+ PostgreSQL's `now()` (UTC) schedules work 8 hours into the future.
216
+
217
+ Which clock to use, by scenario:
218
+
219
+ | Scenario | Correct clock | Example |
220
+ |---|---|---|
221
+ | SQL-compared writes (scheduling, leases, `not_before`) | database clock | `update(not_before=func.now())` |
222
+ | Creation timestamps | database clock via default | `server_default=func.now()` |
223
+ | Instance attributes / signal handlers (need a real value in memory) | aware app clock | `self.updated_at = datetime.now(timezone.utc)` |
224
+ | Bulk operation rows (no per-row SQL expressions) | aware app clock | `{"last_seen": datetime.now(timezone.utc)}` |
225
+ | Read-side window comparisons (relative windows computed in Python) | aware app clock | `filter(created_at >= datetime.now(timezone.utc) - timedelta(days=7))` |
226
+
227
+ Never naive `datetime.now()` — in every scenario above the naive form breaks
228
+ as soon as the application timezone differs from UTC.
229
+
140
230
  ## Best Practices
141
231
 
142
232
  ### ✅ Do
@@ -352,7 +442,7 @@ await User.objects.bulk_create(users_data, batch_size=1000)
352
442
  from sqlobjects.model import ObjectModel
353
443
  from sqlobjects.fields import Column, StringColumn, BooleanColumn
354
444
  from sqlobjects.session import ctx_session
355
- from datetime import datetime
445
+ from datetime import datetime, timezone
356
446
 
357
447
  class User(ObjectModel):
358
448
  username: Column[str] = StringColumn(length=50)
@@ -132,7 +132,7 @@ posts = await Post.objects.select_related(Post.author).all()
132
132
  # Good: Filtered prefetch
133
133
  users = await User.objects.prefetch_related(
134
134
  recent_posts=Post.objects.filter(
135
- Post.created_at >= datetime.now() - timedelta(days=7)
135
+ Post.created_at >= datetime.now(timezone.utc) - timedelta(days=7)
136
136
  ).order_by('-created_at')
137
137
  ).all()
138
138
  ```
@@ -350,7 +350,7 @@ posts = await Post.objects.select_related("author").defer(
350
350
  # Filter prefetched relationships
351
351
  users = await User.objects.prefetch_related(
352
352
  recent_posts=Post.objects.filter(
353
- Post.created_at >= datetime.now() - timedelta(days=7)
353
+ Post.created_at >= datetime.now(timezone.utc) - timedelta(days=7)
354
354
  ).order_by('-created_at').limit(10)
355
355
  ).all()
356
356
  ```
@@ -439,7 +439,7 @@ class Post(ObjectModel):
439
439
  ```python
440
440
  from sqlobjects.model import ObjectModel
441
441
  from sqlobjects.fields import Column, StringColumn, column, foreign_key, relationship, Related
442
- from datetime import datetime, timedelta
442
+ from datetime import datetime, timedelta, timezone
443
443
 
444
444
  class User(ObjectModel):
445
445
  username: Column[str] = StringColumn(length=50)
@@ -478,7 +478,7 @@ posts = await Post.objects.select_related("author").prefetch_related("comments",
478
478
  # Advanced prefetch with filtering
479
479
  users = await User.objects.prefetch_related(
480
480
  recent_posts=Post.objects.filter(
481
- Post.created_at >= datetime.now() - timedelta(days=7)
481
+ Post.created_at >= datetime.now(timezone.utc) - timedelta(days=7)
482
482
  ).order_by('-created_at').limit(5)
483
483
  ).all()
484
484
 
@@ -58,7 +58,7 @@ class User(ObjectModel):
58
58
  ```python
59
59
  from sqlobjects.model import ObjectModel
60
60
  from sqlobjects.signals import SignalContext
61
- from datetime import datetime
61
+ from datetime import datetime, timezone
62
62
 
63
63
  class User(ObjectModel):
64
64
  username: Column[str] = StringColumn(length=50)
@@ -69,7 +69,7 @@ class User(ObjectModel):
69
69
  # Universal save signals (always triggered)
70
70
  async def before_save(self, context: SignalContext):
71
71
  """Called before any save operation"""
72
- self.updated_at = datetime.now()
72
+ self.updated_at = datetime.now(timezone.utc)
73
73
 
74
74
  async def after_save(self, context: SignalContext):
75
75
  """Called after any save operation"""
@@ -78,7 +78,7 @@ class User(ObjectModel):
78
78
  # Operation-specific signals (triggered based on detected operation)
79
79
  async def before_create(self, context: SignalContext):
80
80
  """Only triggered for CREATE operations"""
81
- self.created_at = datetime.now()
81
+ self.created_at = datetime.now(timezone.utc)
82
82
 
83
83
  async def before_update(self, context: SignalContext):
84
84
  """Only triggered for UPDATE operations"""
@@ -162,7 +162,7 @@ class User(ObjectModel):
162
162
 
163
163
  # Good: Fast signal operations
164
164
  async def before_save(self, context: SignalContext):
165
- self.updated_at = datetime.now() # Fast
165
+ self.updated_at = datetime.now(timezone.utc) # Fast
166
166
 
167
167
  # Good: Non-critical operations with error handling
168
168
  async def after_create(self, context: SignalContext):
@@ -198,7 +198,7 @@ async def before_save(self, context: SignalContext):
198
198
 
199
199
  # Good: Keep before_save fast, use after_save for heavy operations
200
200
  async def before_save(self, context: SignalContext):
201
- self.updated_at = datetime.now() # Fast
201
+ self.updated_at = datetime.now(timezone.utc) # Fast
202
202
 
203
203
  async def after_save(self, context: SignalContext):
204
204
  asyncio.create_task(self.process_large_file()) # Background task
@@ -302,7 +302,7 @@ async def after_create(self, context: SignalContext):
302
302
  # Background tasks for non-critical operations
303
303
  async def after_save(self, context: SignalContext):
304
304
  # Critical operations (blocking)
305
- self.updated_at = datetime.now()
305
+ self.updated_at = datetime.now(timezone.utc)
306
306
 
307
307
  # Non-critical operations (background)
308
308
  if not context.is_bulk:
@@ -390,7 +390,7 @@ from sqlobjects.model import ObjectModel
390
390
  from sqlobjects.fields import Column, column, StringColumn, IntegerColumn, BooleanColumn
391
391
  from sqlobjects.signals import SignalContext
392
392
  from sqlobjects.exceptions import ValidationError
393
- from datetime import datetime
393
+ from datetime import datetime, timezone
394
394
  import asyncio
395
395
 
396
396
  # Custom validators
@@ -420,10 +420,10 @@ class User(ObjectModel):
420
420
 
421
421
  # Instance signals
422
422
  async def before_save(self, context: SignalContext):
423
- self.updated_at = datetime.now()
423
+ self.updated_at = datetime.now(timezone.utc)
424
424
 
425
425
  async def before_create(self, context: SignalContext):
426
- self.created_at = datetime.now()
426
+ self.created_at = datetime.now(timezone.utc)
427
427
 
428
428
  async def after_create(self, context: SignalContext):
429
429
  try:
@@ -12,6 +12,31 @@ Best practices and usage patterns for SQLObjects, optimized for AI coding assist
12
12
  - **[06. Validation & Signals Guide](06-validation-signals-guide.md)** - Data validation and lifecycle hooks
13
13
  - **[07. Performance Guide](07-performance-guide.md)** - Optimization techniques and best practices
14
14
 
15
+ ## Method Ownership Matrix
16
+
17
+ The single most common mistake is calling a manager-only method on a QuerySet
18
+ (or vice versa). `Model.objects` is the **manager**; `filter()` returns a
19
+ **QuerySet**. They own different methods:
20
+
21
+ | Operation | Manager (`Model.objects`) | QuerySet (`.filter(...)`) |
22
+ |---|---|---|
23
+ | Create | `create()`, `get_or_create()`, `update_or_create()` | — |
24
+ | Bulk write | `bulk_create()`, `bulk_update()`, `bulk_delete()` | — |
25
+ | Whole-table write | `delete_all()`, `update_all(**values)` | — |
26
+ | Filtered write | — | `update(**values)`, `delete()` |
27
+ | Read | `get()`, `all()`, `first()`, `last()`, `in_bulk()` | `get()`, `all()`, `first()`, `last()` |
28
+ | Refine | `filter()`, `exclude()` (returns QuerySet) | `filter()`, `exclude()`, `order_by()`, `limit()`, `offset()` |
29
+ | Projection | `values()`, `values_list()`, `only()`, `defer()` | `values()`, `values_list()`, `only()`, `defer()` |
30
+ | Aggregate | `count()`, `aggregate()`, `annotate()`, `group_by()`, `having()` | `count()`, `aggregate()`, `annotate()`, `group_by()`, `having()` |
31
+
32
+ Rules of thumb:
33
+
34
+ - `filter(...).delete_all()` / `filter(...).update_all()` **do not exist** — a
35
+ filtered write is `filter(...).delete()` / `filter(...).update(**values)`.
36
+ - `bulk_*` operate on explicit row data, so they live on the manager only.
37
+ - Calling a manager-only method on a QuerySet raises `AttributeError` with a
38
+ hint pointing to the correct method.
39
+
15
40
  ## Purpose
16
41
 
17
42
  These rules provide concise, actionable guidance for using SQLObjects effectively. Each guide includes:
@@ -57,4 +82,10 @@ sqlobjects-install-rules kiro # For Kiro
57
82
 
58
83
  ## Version
59
84
 
60
- These rules are for SQLObjects 1.0+
85
+ `sqlobjects-install-rules` stamps each installed file with the exact package
86
+ version it was generated from — trust that stamp over this line. Version-
87
+ sensitive behaviors to watch: grouped aggregation (`annotate` + `group_by`)
88
+ raises `QueryError` on out-of-group column selection and returns rows via
89
+ `.values()` since 2.0, and `aggregate()` combined with `group_by()` also
90
+ raises `QueryError` since 2.0; earlier versions silently expanded GROUP BY
91
+ (or dropped it entirely for `aggregate()`) and returned wrong results.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlobjects"
3
- version = "1.10.0"
3
+ version = "2.0.1"
4
4
  description = "Django-style async ORM library based on SQLAlchemy with chainable queries, Q objects, and relationship loading"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -19,7 +19,7 @@ from .queryset import Q, QuerySet
19
19
  from .sql_logging import ObjectLogger, get_caller_frame
20
20
 
21
21
 
22
- __version__ = "1.10.0"
22
+ __version__ = "2.0.1"
23
23
 
24
24
  __all__ = [
25
25
  # Core classes
@@ -5,6 +5,16 @@ import sys
5
5
  from pathlib import Path
6
6
 
7
7
 
8
+ def _get_package_version() -> str:
9
+ """Get the installed sqlobjects version."""
10
+ try:
11
+ from . import __version__
12
+
13
+ return __version__
14
+ except Exception:
15
+ return "unknown"
16
+
17
+
8
18
  def find_project_root() -> Path:
9
19
  """Find project root by looking for common markers."""
10
20
  current = Path.cwd()
@@ -73,13 +83,19 @@ def install_rules(target_name: str, target_dir: Path | None = None) -> bool:
73
83
  print(f"Error: Rules directory not found at {rules_dir}", file=sys.stderr)
74
84
  return False
75
85
 
76
- # Create target directory and copy files
86
+ # Create target directory and copy files, stamping the package version so
87
+ # AI assistants reading the rules know which behaviors they document
77
88
  try:
78
89
  target_dir.mkdir(parents=True, exist_ok=True)
79
90
 
91
+ version = _get_package_version()
92
+ stamp = f"<!-- Generated for SQLObjects {version} — behaviors documented here match this version -->\n\n"
93
+
80
94
  copied_count = 0
81
95
  for file in rules_dir.glob("*.md"):
82
- shutil.copy2(file, target_dir / file.name)
96
+ content = file.read_text(encoding="utf-8")
97
+ (target_dir / file.name).write_text(stamp + content, encoding="utf-8")
98
+ shutil.copystat(file, target_dir / file.name)
83
99
  copied_count += 1
84
100
 
85
101
  if copied_count > 0:
@@ -20,6 +20,7 @@ __all__ = [
20
20
  "ConfigurationError",
21
21
  "DeferredFieldError",
22
22
  "PrimaryKeyError",
23
+ "QueryError",
23
24
  "SQLError",
24
25
  "OperationalError",
25
26
  "DataError",
@@ -400,6 +401,23 @@ class PrimaryKeyError(SQLObjectsError):
400
401
  super().__init__(message)
401
402
 
402
403
 
404
+ class QueryError(SQLObjectsError):
405
+ """Raised when a query is constructed in a way that cannot be executed safely.
406
+
407
+ SQLObjects raises this instead of silently rewriting the query semantics —
408
+ for example when annotated GROUP BY queries select columns outside the
409
+ grouping columns, which would degenerate every group to a single row.
410
+
411
+ Examples:
412
+ >>> try:
413
+ ... await User.objects.annotate(cnt=func.count()).group_by("department").all()
414
+ ... except QueryError:
415
+ ... print("Use .values()/.only() with the grouping columns instead")
416
+ """
417
+
418
+ pass
419
+
420
+
403
421
  class SQLError(SQLObjectsError):
404
422
  """Base class for SQLAlchemy operation errors.
405
423
 
@@ -3,8 +3,6 @@
3
3
  from datetime import date, datetime
4
4
  from typing import TYPE_CHECKING, Any, TypeVar
5
5
 
6
- from sqlalchemy import and_, select
7
-
8
6
  from .base import QueryExpression
9
7
 
10
8
 
@@ -173,23 +171,19 @@ class ValuesExpression(QueryExpression[list[dict[str, Any]]]):
173
171
 
174
172
  def get_query(self):
175
173
  """Return SQLAlchemy query object."""
176
- return self._builder.build(self._builder.model_class.get_table())
174
+ return self._builder.build(self._builder.model_class.get_table(), values_fields=self._fields)
177
175
 
178
176
  async def execute(self) -> list[dict[str, Any]]:
179
177
  if not self._executor:
180
178
  raise RuntimeError("No executor available for values execution")
181
- query = self._builder.build(self._builder.model_class.get_table())
182
- result = await self._executor.execute(query, "values", fields=self._fields)
179
+ query = self.get_query()
180
+ result = await self._executor.execute(query, "values", fields=self._fields, prebuilt=True)
183
181
  if isinstance(result, list):
184
182
  return [dict(zip(self._fields, row, strict=False)) for row in result]
185
183
  return []
186
184
 
187
185
  def get_sql(self) -> str:
188
- table = self._builder.model_class.get_table()
189
- columns = [table.c[field] for field in self._fields if field in table.c]
190
- query = select(*columns).select_from(table)
191
- if hasattr(self._builder, "conditions") and self._builder.conditions:
192
- query = query.where(and_(*self._builder.conditions))
186
+ query = self.get_query()
193
187
  return str(query.compile(compile_kwargs={"literal_binds": True}))
194
188
 
195
189
 
@@ -204,13 +198,13 @@ class ValuesListExpression(QueryExpression[list[Any] | list[tuple[Any, ...]]]):
204
198
 
205
199
  def get_query(self):
206
200
  """Return SQLAlchemy query object."""
207
- return self._builder.build(self._builder.model_class.get_table())
201
+ return self._builder.build(self._builder.model_class.get_table(), values_fields=self._fields)
208
202
 
209
203
  async def execute(self) -> list[Any] | list[tuple[Any, ...]]:
210
204
  if not self._executor:
211
205
  raise RuntimeError("No executor available for values_list execution")
212
- query = self._builder.build(self._builder.model_class.get_table())
213
- result = await self._executor.execute(query, "values_list", fields=list(self._fields))
206
+ query = self.get_query()
207
+ result = await self._executor.execute(query, "values_list", fields=list(self._fields), prebuilt=True)
214
208
  if isinstance(result, list):
215
209
  if self._flat and len(self._fields) == 1:
216
210
  return [row[0] for row in result]
@@ -218,7 +212,7 @@ class ValuesListExpression(QueryExpression[list[Any] | list[tuple[Any, ...]]]):
218
212
  return []
219
213
 
220
214
  def get_sql(self) -> str:
221
- query = self._builder.build(self._builder.model_class.get_table())
215
+ query = self.get_query()
222
216
  return str(query.compile(compile_kwargs={"literal_binds": True}))
223
217
 
224
218
 
@@ -595,29 +595,48 @@ class ModelProcessor(type):
595
595
  def col_sig(idx):
596
596
  return tuple(sorted(col.name for col in idx.columns)) if idx.columns else None
597
597
 
598
- full_sig_map: dict[tuple, list] = {} # (cols, unique) -> indexes
598
+ def dialect_sig(idx):
599
+ # Dialect kwargs (postgresql_where / postgresql_using / mysql_using, etc.)
600
+ # are part of an index's identity: same columns with different partial
601
+ # predicates or access methods are distinct indexes, not duplicates.
602
+ # Values may be str or SQLAlchemy expressions, so compare via str().
603
+ # dialect_kwargs only holds explicitly passed args, not dialect defaults.
604
+ kwargs = getattr(idx, "dialect_kwargs", None) or {}
605
+ return tuple(sorted((k, str(v)) for k, v in kwargs.items() if v is not None))
606
+
607
+ def is_partial(idx):
608
+ return any(k.endswith("_where") for k, _ in dialect_sig(idx))
609
+
610
+ full_sig_map: dict[tuple, list] = {} # (cols, unique, dialect_sig) -> indexes
599
611
  col_map: dict[tuple, list] = {} # cols -> indexes
600
612
 
601
613
  for idx in list(table.indexes):
602
614
  cs = col_sig(idx)
603
615
  if cs is None:
604
616
  continue
605
- full_sig_map.setdefault((cs, getattr(idx, "unique", False)), []).append(idx)
617
+ full_sig_map.setdefault((cs, getattr(idx, "unique", False), dialect_sig(idx)), []).append(idx)
606
618
  col_map.setdefault(cs, []).append(idx)
607
619
 
608
620
  to_remove: set = set()
609
621
 
610
- # Remove exact duplicates: prefer explicit names over SQLAlchemy auto-generated "ix_*"
622
+ # Remove exact duplicates (same columns, unique flag, and dialect kwargs):
623
+ # prefer explicit names over SQLAlchemy auto-generated "ix_*". Sort by name
624
+ # so the kept index is deterministic regardless of set iteration order.
611
625
  for indexes in full_sig_map.values():
612
626
  if len(indexes) > 1:
613
- explicit = [i for i in indexes if not (i.name and i.name.startswith("ix_"))]
614
- keep = explicit[0] if explicit else indexes[0]
627
+ explicit = sorted(
628
+ (i for i in indexes if not (i.name and i.name.startswith("ix_"))),
629
+ key=lambda i: i.name or "",
630
+ )
631
+ keep = explicit[0] if explicit else sorted(indexes, key=lambda i: i.name or "")[0]
615
632
  to_remove.update(i for i in indexes if i is not keep)
616
633
 
617
- # Remove non-unique when unique exists on same columns
634
+ # Remove full-table non-unique when a full-table unique exists on same columns.
635
+ # Partial indexes (unique or not) never participate: a partial unique index
636
+ # only covers rows matching its predicate and cannot replace a full index.
618
637
  for indexes in col_map.values():
619
- if any(getattr(i, "unique", False) for i in indexes):
620
- to_remove.update(i for i in indexes if not getattr(i, "unique", False))
638
+ if any(getattr(i, "unique", False) and not is_partial(i) for i in indexes):
639
+ to_remove.update(i for i in indexes if not getattr(i, "unique", False) and not is_partial(i))
621
640
 
622
641
  for idx in to_remove:
623
642
  table.indexes.discard(idx)
@@ -266,6 +266,12 @@ class ValidationMixin(PrimaryKeyMixin):
266
266
  )
267
267
  if validators:
268
268
  value = getattr(self, field_name, None)
269
+ # SQL expressions (func.now(), text(...), column arithmetic) are
270
+ # evaluated by the database — Python-side validators don't apply
271
+ from sqlalchemy.sql import ClauseElement
272
+
273
+ if isinstance(value, ClauseElement):
274
+ return
269
275
  try:
270
276
  from .validators import validate_field_value
271
277