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.
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/CHANGELOG.md +18 -0
- {sqlobjects-1.10.0/sqlobjects.egg-info → sqlobjects-2.0.0}/PKG-INFO +1 -1
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/docs/rules/01-database-session-guide.md +16 -3
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/docs/rules/03-query-operations-guide.md +22 -7
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/docs/rules/04-crud-operations-guide.md +93 -3
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/docs/rules/05-relationships-guide.md +4 -4
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/docs/rules/06-validation-signals-guide.md +9 -9
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/docs/rules/README.md +32 -1
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/pyproject.toml +1 -1
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/__init__.py +1 -1
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/_install_rules.py +18 -2
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/exceptions.py +18 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/expressions/terminal.py +8 -14
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/mixins.py +6 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/objects/bulk.py +26 -4
- sqlobjects-2.0.0/sqlobjects/py.typed +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/queries/builder.py +72 -27
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/queries/executor.py +4 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/queryset.py +41 -15
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/session.py +24 -1
- {sqlobjects-1.10.0 → sqlobjects-2.0.0/sqlobjects.egg-info}/PKG-INFO +1 -1
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects.egg-info/SOURCES.txt +1 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/LICENSE +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/README.md +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/docs/rules/02-model-definition-guide.md +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/docs/rules/07-performance-guide.md +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/setup.cfg +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/cascade.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/contrib/__init__.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/contrib/asgi.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/contrib/fastapi.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/database/__init__.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/database/config.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/database/manager.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/expressions/__init__.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/expressions/aggregate.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/expressions/base.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/expressions/cte.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/expressions/explain.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/expressions/function.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/expressions/mixins.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/expressions/scalar.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/expressions/subquery.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/expressions/window.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/__init__.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/core.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/functions.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/proxies.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/relations/__init__.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/relations/descriptors.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/relations/managers.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/relations/prefetch.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/relations/strategies.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/relations/utils.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/shortcuts.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/types/__init__.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/types/base.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/types/comparators.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/types/registry.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/fields/utils.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/internal/__init__.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/internal/operations.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/internal/results.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/metadata.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/model.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/objects/__init__.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/objects/core.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/objects/upsert.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/queries/__init__.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/queries/dialect.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/signals.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/sql_logging.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/utils/__init__.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/utils/inspect.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/utils/naming.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/utils/pattern.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects/validators.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects.egg-info/dependency_links.txt +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects.egg-info/entry_points.txt +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects.egg-info/requires.txt +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.0}/sqlobjects.egg-info/top_level.txt +0 -0
- {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:
|
|
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
|
-
#
|
|
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
|
-
#
|
|
128
|
-
#
|
|
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").
|
|
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).
|
|
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").
|
|
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
|
-
|
|
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.
|
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
115
|
-
return self.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
591
|
-
if
|
|
592
|
-
for
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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:
|
|
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>
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|