sqlobjects 1.10.0__tar.gz → 2.0.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 (82) hide show
  1. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/CHANGELOG.md +18 -0
  2. {sqlobjects-1.10.0/sqlobjects.egg-info → sqlobjects-2.0.0}/PKG-INFO +1 -1
  3. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/docs/rules/01-database-session-guide.md +16 -3
  4. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/docs/rules/03-query-operations-guide.md +22 -7
  5. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/docs/rules/04-crud-operations-guide.md +93 -3
  6. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/docs/rules/05-relationships-guide.md +4 -4
  7. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/docs/rules/06-validation-signals-guide.md +9 -9
  8. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/docs/rules/README.md +32 -1
  9. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/pyproject.toml +1 -1
  10. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/__init__.py +1 -1
  11. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/_install_rules.py +18 -2
  12. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/exceptions.py +18 -0
  13. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/expressions/terminal.py +8 -14
  14. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/mixins.py +6 -0
  15. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/objects/bulk.py +26 -4
  16. sqlobjects-2.0.0/sqlobjects/py.typed +0 -0
  17. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/queries/builder.py +72 -27
  18. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/queries/executor.py +4 -0
  19. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/queryset.py +41 -15
  20. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/session.py +24 -1
  21. {sqlobjects-1.10.0 → sqlobjects-2.0.0/sqlobjects.egg-info}/PKG-INFO +1 -1
  22. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects.egg-info/SOURCES.txt +1 -0
  23. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/LICENSE +0 -0
  24. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/README.md +0 -0
  25. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/docs/rules/02-model-definition-guide.md +0 -0
  26. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/docs/rules/07-performance-guide.md +0 -0
  27. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/setup.cfg +0 -0
  28. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/cascade.py +0 -0
  29. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/contrib/__init__.py +0 -0
  30. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/contrib/asgi.py +0 -0
  31. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/contrib/fastapi.py +0 -0
  32. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/database/__init__.py +0 -0
  33. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/database/config.py +0 -0
  34. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/database/manager.py +0 -0
  35. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/expressions/__init__.py +0 -0
  36. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/expressions/aggregate.py +0 -0
  37. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/expressions/base.py +0 -0
  38. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/expressions/cte.py +0 -0
  39. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/expressions/explain.py +0 -0
  40. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/expressions/function.py +0 -0
  41. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/expressions/mixins.py +0 -0
  42. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/expressions/scalar.py +0 -0
  43. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/expressions/subquery.py +0 -0
  44. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/expressions/window.py +0 -0
  45. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/__init__.py +0 -0
  46. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/core.py +0 -0
  47. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/functions.py +0 -0
  48. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/proxies.py +0 -0
  49. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/relations/__init__.py +0 -0
  50. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/relations/descriptors.py +0 -0
  51. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/relations/managers.py +0 -0
  52. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/relations/prefetch.py +0 -0
  53. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/relations/strategies.py +0 -0
  54. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/relations/utils.py +0 -0
  55. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/shortcuts.py +0 -0
  56. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/types/__init__.py +0 -0
  57. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/types/base.py +0 -0
  58. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/types/comparators.py +0 -0
  59. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/types/registry.py +0 -0
  60. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/utils.py +0 -0
  61. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/internal/__init__.py +0 -0
  62. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/internal/operations.py +0 -0
  63. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/internal/results.py +0 -0
  64. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/metadata.py +0 -0
  65. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/model.py +0 -0
  66. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/objects/__init__.py +0 -0
  67. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/objects/core.py +0 -0
  68. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/objects/upsert.py +0 -0
  69. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/queries/__init__.py +0 -0
  70. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/queries/dialect.py +0 -0
  71. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/signals.py +0 -0
  72. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/sql_logging.py +0 -0
  73. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/utils/__init__.py +0 -0
  74. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/utils/inspect.py +0 -0
  75. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/utils/naming.py +0 -0
  76. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/utils/pattern.py +0 -0
  77. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/validators.py +0 -0
  78. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects.egg-info/dependency_links.txt +0 -0
  79. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects.egg-info/entry_points.txt +0 -0
  80. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects.egg-info/requires.txt +0 -0
  81. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects.egg-info/top_level.txt +0 -0
  82. {sqlobjects-1.10.0 → sqlobjects-2.0.0}/tests/test_config.py +0 -0
@@ -1,3 +1,21 @@
1
+ ## 2.0.0 (2026-08-19)
2
+
3
+ ### BREAKING CHANGE
4
+
5
+ - annotate().group_by().all() with non-primary-key
6
+ grouping and group_by().aggregate() now raise QueryError; both
7
+ previously returned silently wrong results. QuerySet.create() removed
8
+ (never wrote to the database) — use Model.objects.create().
9
+
10
+ ### Feat
11
+
12
+ - **rules**: stamp installed rule files with the package version
13
+ - **query**: strict GROUP BY semantics, values-mode aggregation, manager-only hints
14
+ - **orm**: support SQLAlchemy expressions as write values
15
+ - **bulk**: make BulkResult a sequence and guarantee insert RETURNING order
16
+ - **session**: add join_ambient option to ctx_session and warn on nested sessions
17
+ - **build**: ship py.typed marker so type checkers see the package as typed
18
+
1
19
  ## 1.10.0 (2026-07-24)
2
20
 
3
21
  ### Feat
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlobjects
3
- Version: 1.10.0
3
+ Version: 2.0.0
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.0"
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.0"
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
 
@@ -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
 
@@ -4,6 +4,7 @@ This module provides bulk operations functionality and transaction control,
4
4
  merged from the original bulk_transaction.py module.
5
5
  """
6
6
 
7
+ from collections.abc import Iterator
7
8
  from dataclasses import dataclass, field
8
9
  from enum import Enum
9
10
  from typing import Any, Callable, Generic, TypeVar
@@ -82,7 +83,16 @@ class FailedRecord:
82
83
 
83
84
  @dataclass
84
85
  class BulkResult(Generic[T]):
85
- """Result object for bulk operations with detailed information."""
86
+ """Result object for bulk operations with detailed information.
87
+
88
+ Acts as a sequence over ``objects``: iteration, indexing and ``len()``
89
+ all operate on the successfully returned objects. Use ``total_count`` /
90
+ ``success_count`` / ``error_count`` for operation statistics — on partial
91
+ failure ``len(result)`` and ``total_count`` differ.
92
+
93
+ For insert operations the order of ``objects`` matches the order of the
94
+ input rows (RETURNING is sorted by parameter order).
95
+ """
86
96
 
87
97
  success_count: int
88
98
  error_count: int
@@ -111,8 +121,16 @@ class BulkResult(Generic[T]):
111
121
  return 0 < self.success_count < self.total_count
112
122
 
113
123
  def __len__(self) -> int:
114
- """Return total count for len() support."""
115
- return self.total_count
124
+ """Return the number of returned objects (see ``total_count`` for input size)."""
125
+ return len(self.objects)
126
+
127
+ def __iter__(self) -> Iterator[T | dict[str, Any]]:
128
+ """Iterate over the returned objects."""
129
+ return iter(self.objects)
130
+
131
+ def __getitem__(self, index):
132
+ """Index into the returned objects."""
133
+ return self.objects[index]
116
134
 
117
135
 
118
136
  class BulkTransactionManager:
@@ -269,7 +287,11 @@ class BulkOperationHandler:
269
287
  exec_session = session or self.session
270
288
 
271
289
  if return_columns and self.supports_returning(operation):
272
- stmt_with_returning = stmt.returning(*return_columns)
290
+ if operation == "insert":
291
+ # Guarantee RETURNING row order matches input row order for executemany
292
+ stmt_with_returning = stmt.returning(*return_columns, sort_by_parameter_order=True)
293
+ else:
294
+ stmt_with_returning = stmt.returning(*return_columns)
273
295
  # For INSERT operations, use the data directly as parameters
274
296
  if operation == "insert" and isinstance(parameters, list):
275
297
  result = await exec_session.execute(stmt_with_returning, parameters)
File without changes
@@ -11,6 +11,8 @@ from sqlalchemy import (
11
11
  )
12
12
  from sqlalchemy.sql.selectable import Subquery
13
13
 
14
+ from ..exceptions import QueryError
15
+
14
16
 
15
17
  # Export classes for use in other modules
16
18
  __all__ = ["QueryBuilder"]
@@ -447,11 +449,14 @@ class QueryBuilder:
447
449
 
448
450
  return related_columns
449
451
 
450
- def build(self, table):
452
+ def build(self, table, values_fields: tuple[str, ...] | None = None):
451
453
  """Build final SQLAlchemy query object from accumulated clauses.
452
454
 
453
455
  Args:
454
456
  table: SQLAlchemy Table object to query
457
+ values_fields: When given (values()/values_list() mode), select exactly
458
+ these fields in order. Each name must resolve to a table column,
459
+ an annotation alias, or an extra column alias.
455
460
 
456
461
  Returns:
457
462
  SQLAlchemy Select object ready for execution
@@ -479,8 +484,34 @@ class QueryBuilder:
479
484
  # Collect all columns to select (base table + related tables)
480
485
  columns_to_select = []
481
486
 
487
+ if values_fields is not None:
488
+ # values()/values_list() mode: select exactly the requested fields in
489
+ # order so result rows align with the field names positionally
490
+ unknown = [
491
+ f
492
+ for f in values_fields
493
+ if f not in table.c and f not in self.annotations and f not in self.extra_columns
494
+ ]
495
+ if unknown:
496
+ raise QueryError(
497
+ f"Unknown field(s) in values()/values_list(): {unknown}. "
498
+ "Each field must be a model column, an annotation alias, or an extra() column alias."
499
+ )
500
+ for field_name in values_fields:
501
+ if field_name in table.c:
502
+ columns_to_select.append(table.c[field_name])
503
+ elif field_name in self.annotations:
504
+ expr = self.annotations[field_name]
505
+ resolved = expr.resolve(table) if hasattr(expr, "resolve") else expr
506
+ columns_to_select.append(resolved.label(field_name))
507
+ else:
508
+ sql = self.extra_columns[field_name]
509
+ if self.extra_params:
510
+ columns_to_select.append(text(sql).bindparams(**self.extra_params).label(field_name))
511
+ else:
512
+ columns_to_select.append(text(sql).label(field_name))
482
513
  # Handle field selection (only() method)
483
- if self.selected_fields:
514
+ elif self.selected_fields:
484
515
  columns_to_select.extend([table.c[field] for field in self.selected_fields if field in table.c])
485
516
  elif self.deferred_fields or auto_deferred_fields:
486
517
  # For defer() or auto-deferred fields, select all fields except deferred ones
@@ -492,7 +523,7 @@ class QueryBuilder:
492
523
  columns_to_select.extend(table.c)
493
524
 
494
525
  # Add related table columns for select_related (only for select_related, not prefetch_related)
495
- if self.relationships:
526
+ if self.relationships and values_fields is None:
496
527
  related_columns = self._get_select_related_columns(table)
497
528
  columns_to_select.extend(related_columns)
498
529
 
@@ -554,8 +585,8 @@ class QueryBuilder:
554
585
  else:
555
586
  query = query.distinct()
556
587
 
557
- # Apply annotations
558
- if self.annotations:
588
+ # Apply annotations (values mode already selected the requested ones)
589
+ if self.annotations and values_fields is None:
559
590
  annotation_columns = []
560
591
  for alias, expr in self.annotations.items():
561
592
  if hasattr(expr, "resolve"):
@@ -564,8 +595,8 @@ class QueryBuilder:
564
595
  annotation_columns.append(expr.label(alias))
565
596
  query = query.add_columns(*annotation_columns)
566
597
 
567
- # Apply extra columns
568
- if self.extra_columns:
598
+ # Apply extra columns (values mode already selected the requested ones)
599
+ if self.extra_columns and values_fields is None:
569
600
  extra_cols = []
570
601
  for alias, sql in self.extra_columns.items():
571
602
  if self.extra_params:
@@ -577,34 +608,48 @@ class QueryBuilder:
577
608
  # Apply group by
578
609
  if self.group_clauses:
579
610
  group_columns = []
611
+ group_names = set()
580
612
  for field in self.group_clauses:
581
613
  if isinstance(field, str) and field in table.c:
582
614
  group_columns.append(table.c[field])
615
+ group_names.add(field)
583
616
  elif hasattr(field, "resolve") and not isinstance(field, str):
584
- group_columns.append(field.resolve(table))
617
+ resolved = field.resolve(table)
618
+ group_columns.append(resolved)
619
+ if getattr(resolved, "name", None):
620
+ group_names.add(resolved.name)
585
621
  else:
586
622
  group_columns.append(field)
587
-
588
- # For PostgreSQL compatibility, include all non-aggregated columns in GROUP BY
623
+ field_name = getattr(field, "name", None)
624
+ if field_name:
625
+ group_names.add(field_name)
626
+
627
+ # Grouped aggregation must not select columns outside GROUP BY.
628
+ # Previous versions silently added the selected columns to GROUP BY
629
+ # "for PostgreSQL compatibility", which degenerated every group to a
630
+ # single row and produced wrong aggregate values. Grouping by the
631
+ # full primary key is exempt: every column of the table is
632
+ # functionally dependent on it, so one group == one model row.
589
633
  if self.annotations:
590
- # Add all base table columns that are being selected
591
- if self.selected_fields:
592
- for field in self.selected_fields:
593
- if field in table.c and table.c[field] not in group_columns:
594
- group_columns.append(table.c[field])
595
- elif not self.deferred_fields:
596
- # If no specific fields selected and no deferred fields, add all columns
597
- for column in table.c:
598
- if column not in group_columns:
599
- group_columns.append(column)
634
+ pk_names = {col.name for col in table.primary_key.columns}
635
+ if values_fields is not None:
636
+ selected_names = {f for f in values_fields if f in table.c}
637
+ elif self.selected_fields:
638
+ selected_names = {f for f in self.selected_fields if f in table.c}
600
639
  else:
601
- # Add non-deferred columns
602
- all_fields = set(table.columns.keys())
603
- combined_deferred = self.deferred_fields | auto_deferred_fields
604
- selected_fields = all_fields - combined_deferred
605
- for field in selected_fields:
606
- if field in table.c and table.c[field] not in group_columns:
607
- group_columns.append(table.c[field])
640
+ selected_names = set(table.columns.keys()) - self.deferred_fields - auto_deferred_fields
641
+
642
+ grouped_by_pk = bool(pk_names) and pk_names <= group_names
643
+ extra_selected = selected_names - group_names
644
+ if not grouped_by_pk and extra_selected:
645
+ raise QueryError(
646
+ f"column(s) {sorted(extra_selected)} are selected but not in GROUP BY. "
647
+ "SQLObjects no longer adds selected columns to GROUP BY silently — "
648
+ "that would collapse each group to a single row and corrupt aggregates. "
649
+ "Use .values(*group_fields, *aggregate_aliases) for aggregation rows, "
650
+ ".only(*group_fields) to hydrate partial instances, "
651
+ "or group by the primary key to aggregate per model row."
652
+ )
608
653
 
609
654
  query = query.group_by(*group_columns)
610
655
 
@@ -191,6 +191,10 @@ class QueryExecutor:
191
191
  delete_query = delete_query.where(query.whereclause)
192
192
  return delete_query
193
193
  elif query_type in ("values", "values_list"):
194
+ if kwargs.get("prebuilt"):
195
+ # Query was already built with the exact requested fields
196
+ # (including annotations and GROUP BY) — execute as-is
197
+ return query
194
198
  fields = kwargs.get("fields", [])
195
199
  if fields:
196
200
  table = from_table
@@ -1,7 +1,7 @@
1
1
  import re
2
2
  from collections.abc import AsyncGenerator
3
3
  from datetime import date, datetime
4
- from typing import Any, Generic, Literal, TypeVar, Union
4
+ from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, TypeVar, Union
5
5
 
6
6
  from sqlalchemy import (
7
7
  BinaryExpression,
@@ -237,6 +237,30 @@ class QuerySet(Generic[T]):
237
237
  ordering = getattr(self._model_class, "_default_ordering", [])
238
238
  self._builder = self._builder.add_ordering(*ordering)
239
239
 
240
+ # Manager-only method names mapped to their QuerySet equivalent (None if no equivalent)
241
+ _MANAGER_ONLY_METHODS: ClassVar[dict[str, str | None]] = {
242
+ "delete_all": "delete()",
243
+ "update_all": "update(**values)",
244
+ "bulk_create": None,
245
+ "bulk_update": None,
246
+ "bulk_delete": None,
247
+ "create": None,
248
+ "get_or_create": None,
249
+ "update_or_create": None,
250
+ "in_bulk": None,
251
+ }
252
+
253
+ if not TYPE_CHECKING:
254
+ # Hidden from type checkers so unknown attributes still fail static
255
+ # analysis; at runtime it turns bare AttributeErrors on manager-only
256
+ # methods into a hint pointing at the correct API
257
+ def __getattr__(self, name: str):
258
+ if name in self._MANAGER_ONLY_METHODS:
259
+ equivalent = self._MANAGER_ONLY_METHODS[name]
260
+ hint = f"; for a filtered queryset use .{equivalent}" if equivalent else ""
261
+ raise AttributeError(f"'{name}' is defined on Model.objects (manager), not on QuerySet{hint}")
262
+ raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
263
+
240
264
  @staticmethod
241
265
  def _get_field_name(field) -> str:
242
266
  """Extract field name from various field types.
@@ -799,20 +823,35 @@ class QuerySet(Generic[T]):
799
823
  def aggregate(self, **kwargs) -> AggregateExpression:
800
824
  """Create aggregation expression that can be executed or used as subquery.
801
825
 
826
+ Returns a single row aggregated over all matching rows. Incompatible
827
+ with group_by() — for per-group aggregation use
828
+ ``annotate(...).group_by(...).values(*group_fields, *aliases)``.
829
+
802
830
  Args:
803
831
  **kwargs: Aggregation expressions with aliases
804
832
 
805
833
  Returns:
806
834
  AggregateExpression that can be awaited or used in comparisons
807
835
 
836
+ Raises:
837
+ QueryError: If the queryset has GROUP BY clauses
838
+
808
839
  Examples:
809
840
  # Direct execution
810
841
  stats = await User.objects.aggregate(avg_age=User.age.avg())
811
842
 
812
843
  # Use as subquery condition
813
- avg_age = User.objects.aggregate(User.age.avg())
844
+ avg_age = User.objects.aggregate(avg_age=User.age.avg())
814
845
  older_users = await User.objects.filter(User.age > avg_age).all()
815
846
  """
847
+ if self._builder.group_clauses:
848
+ from .exceptions import QueryError
849
+
850
+ raise QueryError(
851
+ "aggregate() returns a single row and ignores GROUP BY. "
852
+ "Use .annotate(...).group_by(...).values(*group_fields, *aggregate_aliases) "
853
+ "for per-group aggregation."
854
+ )
816
855
  return AggregateExpression(self._builder, kwargs, self._executor)
817
856
 
818
857
  def count(self) -> CountExpression:
@@ -1199,19 +1238,6 @@ class QuerySet(Generic[T]):
1199
1238
  # Data Operations Methods - Create, update, and delete data
1200
1239
  # ========================================
1201
1240
 
1202
- async def create(self, validate: bool = True, **kwargs) -> T:
1203
- """Create new object with given field values."""
1204
- # Create instance for validation
1205
- instance = self._model_class.from_dict(kwargs, validate=validate) # type: ignore[reportAttributeAccessIssue]
1206
- if validate and hasattr(instance, "validate_all"):
1207
- validate_method = getattr(instance, "validate_all", None)
1208
- if validate_method:
1209
- validate_method()
1210
-
1211
- # Actual insertion would be implemented here
1212
- # For now, return the created instance (simplified)
1213
- return instance
1214
-
1215
1241
  @emit_signals(Operation.UPDATE, is_bulk=True)
1216
1242
  async def update(self, **values) -> int:
1217
1243
  """Perform bulk update on objects matching query conditions."""
@@ -1,4 +1,5 @@
1
1
  import contextvars
2
+ import logging
2
3
  from collections.abc import AsyncGenerator
3
4
  from contextlib import asynccontextmanager
4
5
  from typing import Any
@@ -13,6 +14,8 @@ from .exceptions import convert_sqlalchemy_error
13
14
 
14
15
  __all__ = ["AsyncSession", "ctx_session", "ctx_sessions", "get_session", "has_session"]
15
16
 
17
+ logger = logging.getLogger("sqlobjects.session")
18
+
16
19
  # Explicit session management (highest priority)
17
20
  _explicit_sessions: contextvars.ContextVar[dict[str, "AsyncSession"]] = contextvars.ContextVar("explicit_sessions")
18
21
 
@@ -298,7 +301,7 @@ class _SessionContextManager:
298
301
 
299
302
 
300
303
  @asynccontextmanager
301
- async def ctx_session(db_name: str | None = None) -> AsyncGenerator[AsyncSession, None]:
304
+ async def ctx_session(db_name: str | None = None, *, join_ambient: bool = False) -> AsyncGenerator[AsyncSession, None]:
302
305
  """Get async context manager for single database transactional session.
303
306
 
304
307
  Creates a transactional session with manual commit control (auto_commit=False).
@@ -306,11 +309,31 @@ async def ctx_session(db_name: str | None = None) -> AsyncGenerator[AsyncSession
306
309
 
307
310
  Args:
308
311
  db_name: Database name (uses default database if None)
312
+ join_ambient: If True and an explicit session already exists in the current
313
+ context, reuse it instead of creating a new one. The ambient session's
314
+ lifecycle (commit/rollback/close) stays with its outer owner; exceptions
315
+ propagate to the owner for rollback. This avoids a second physical
316
+ connection whose row locks would deadlock against the outer transaction
317
+ in a way the database deadlock detector cannot see.
309
318
 
310
319
  Yields:
311
320
  AsyncSession: Transactional session with manual commit control
312
321
  """
313
322
  name = db_name or get_default()
323
+
324
+ if join_ambient and has_session(name):
325
+ yield get_session(name, readonly=False)
326
+ return
327
+
328
+ if has_session(name):
329
+ logger.warning(
330
+ "ctx_session(%r) is creating a new session while an ambient session already exists "
331
+ "in this context. The two sessions use separate physical connections; writes to rows "
332
+ "locked by the outer transaction will block undetectably. "
333
+ "Pass join_ambient=True to reuse the ambient session.",
334
+ name,
335
+ )
336
+
314
337
  session = AsyncSession(name, readonly=False, auto_commit=False)
315
338
 
316
339
  # Set as explicit session in context, save token for nested restore
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlobjects
3
- Version: 1.10.0
3
+ Version: 2.0.0
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>
@@ -17,6 +17,7 @@ sqlobjects/exceptions.py
17
17
  sqlobjects/metadata.py
18
18
  sqlobjects/mixins.py
19
19
  sqlobjects/model.py
20
+ sqlobjects/py.typed
20
21
  sqlobjects/queryset.py
21
22
  sqlobjects/session.py
22
23
  sqlobjects/signals.py
File without changes
File without changes
File without changes