sqlobjects 1.10.0__tar.gz → 2.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/CHANGELOG.md +24 -0
- {sqlobjects-1.10.0/sqlobjects.egg-info → sqlobjects-2.0.1}/PKG-INFO +1 -1
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/docs/rules/01-database-session-guide.md +16 -3
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/docs/rules/03-query-operations-guide.md +22 -7
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/docs/rules/04-crud-operations-guide.md +93 -3
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/docs/rules/05-relationships-guide.md +4 -4
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/docs/rules/06-validation-signals-guide.md +9 -9
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/docs/rules/README.md +32 -1
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/pyproject.toml +1 -1
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/__init__.py +1 -1
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/_install_rules.py +18 -2
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/exceptions.py +18 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/expressions/terminal.py +8 -14
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/metadata.py +27 -8
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/mixins.py +6 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/objects/bulk.py +26 -4
- sqlobjects-2.0.1/sqlobjects/py.typed +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/queries/builder.py +72 -27
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/queries/executor.py +4 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/queryset.py +41 -15
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/session.py +24 -1
- {sqlobjects-1.10.0 → sqlobjects-2.0.1/sqlobjects.egg-info}/PKG-INFO +1 -1
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects.egg-info/SOURCES.txt +1 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/LICENSE +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/README.md +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/docs/rules/02-model-definition-guide.md +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/docs/rules/07-performance-guide.md +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/setup.cfg +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/cascade.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/contrib/__init__.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/contrib/asgi.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/contrib/fastapi.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/database/__init__.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/database/config.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/database/manager.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/expressions/__init__.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/expressions/aggregate.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/expressions/base.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/expressions/cte.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/expressions/explain.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/expressions/function.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/expressions/mixins.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/expressions/scalar.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/expressions/subquery.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/expressions/window.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/__init__.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/core.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/functions.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/proxies.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/relations/__init__.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/relations/descriptors.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/relations/managers.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/relations/prefetch.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/relations/strategies.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/relations/utils.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/shortcuts.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/types/__init__.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/types/base.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/types/comparators.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/types/registry.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/fields/utils.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/internal/__init__.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/internal/operations.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/internal/results.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/model.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/objects/__init__.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/objects/core.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/objects/upsert.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/queries/__init__.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/queries/dialect.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/signals.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/sql_logging.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/utils/__init__.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/utils/inspect.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/utils/naming.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/utils/pattern.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects/validators.py +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects.egg-info/dependency_links.txt +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects.egg-info/entry_points.txt +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects.egg-info/requires.txt +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/sqlobjects.egg-info/top_level.txt +0 -0
- {sqlobjects-1.10.0 → sqlobjects-2.0.1}/tests/test_config.py +0 -0
|
@@ -1,3 +1,27 @@
|
|
|
1
|
+
## 2.0.1 (2026-08-31)
|
|
2
|
+
|
|
3
|
+
### Fix
|
|
4
|
+
|
|
5
|
+
- **metadata**: include dialect kwargs in index dedup signature
|
|
6
|
+
|
|
7
|
+
## 2.0.0 (2026-08-19)
|
|
8
|
+
|
|
9
|
+
### BREAKING CHANGE
|
|
10
|
+
|
|
11
|
+
- annotate().group_by().all() with non-primary-key
|
|
12
|
+
grouping and group_by().aggregate() now raise QueryError; both
|
|
13
|
+
previously returned silently wrong results. QuerySet.create() removed
|
|
14
|
+
(never wrote to the database) — use Model.objects.create().
|
|
15
|
+
|
|
16
|
+
### Feat
|
|
17
|
+
|
|
18
|
+
- **rules**: stamp installed rule files with the package version
|
|
19
|
+
- **query**: strict GROUP BY semantics, values-mode aggregation, manager-only hints
|
|
20
|
+
- **orm**: support SQLAlchemy expressions as write values
|
|
21
|
+
- **bulk**: make BulkResult a sequence and guarantee insert RETURNING order
|
|
22
|
+
- **session**: add join_ambient option to ctx_session and warn on nested sessions
|
|
23
|
+
- **build**: ship py.typed marker so type checkers see the package as typed
|
|
24
|
+
|
|
1
25
|
## 1.10.0 (2026-07-24)
|
|
2
26
|
|
|
3
27
|
### Feat
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlobjects
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.1
|
|
4
4
|
Summary: Django-style async ORM library based on SQLAlchemy with chainable queries, Q objects, and relationship loading
|
|
5
5
|
Author-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
|
|
6
6
|
Maintainer-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
|
|
@@ -120,14 +120,27 @@ await init_db(
|
|
|
120
120
|
- **Don't create sessions manually** (use context managers)
|
|
121
121
|
|
|
122
122
|
```python
|
|
123
|
-
#
|
|
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
|
|
|
@@ -595,29 +595,48 @@ class ModelProcessor(type):
|
|
|
595
595
|
def col_sig(idx):
|
|
596
596
|
return tuple(sorted(col.name for col in idx.columns)) if idx.columns else None
|
|
597
597
|
|
|
598
|
-
|
|
598
|
+
def dialect_sig(idx):
|
|
599
|
+
# Dialect kwargs (postgresql_where / postgresql_using / mysql_using, etc.)
|
|
600
|
+
# are part of an index's identity: same columns with different partial
|
|
601
|
+
# predicates or access methods are distinct indexes, not duplicates.
|
|
602
|
+
# Values may be str or SQLAlchemy expressions, so compare via str().
|
|
603
|
+
# dialect_kwargs only holds explicitly passed args, not dialect defaults.
|
|
604
|
+
kwargs = getattr(idx, "dialect_kwargs", None) or {}
|
|
605
|
+
return tuple(sorted((k, str(v)) for k, v in kwargs.items() if v is not None))
|
|
606
|
+
|
|
607
|
+
def is_partial(idx):
|
|
608
|
+
return any(k.endswith("_where") for k, _ in dialect_sig(idx))
|
|
609
|
+
|
|
610
|
+
full_sig_map: dict[tuple, list] = {} # (cols, unique, dialect_sig) -> indexes
|
|
599
611
|
col_map: dict[tuple, list] = {} # cols -> indexes
|
|
600
612
|
|
|
601
613
|
for idx in list(table.indexes):
|
|
602
614
|
cs = col_sig(idx)
|
|
603
615
|
if cs is None:
|
|
604
616
|
continue
|
|
605
|
-
full_sig_map.setdefault((cs, getattr(idx, "unique", False)), []).append(idx)
|
|
617
|
+
full_sig_map.setdefault((cs, getattr(idx, "unique", False), dialect_sig(idx)), []).append(idx)
|
|
606
618
|
col_map.setdefault(cs, []).append(idx)
|
|
607
619
|
|
|
608
620
|
to_remove: set = set()
|
|
609
621
|
|
|
610
|
-
# Remove exact duplicates
|
|
622
|
+
# Remove exact duplicates (same columns, unique flag, and dialect kwargs):
|
|
623
|
+
# prefer explicit names over SQLAlchemy auto-generated "ix_*". Sort by name
|
|
624
|
+
# so the kept index is deterministic regardless of set iteration order.
|
|
611
625
|
for indexes in full_sig_map.values():
|
|
612
626
|
if len(indexes) > 1:
|
|
613
|
-
explicit =
|
|
614
|
-
|
|
627
|
+
explicit = sorted(
|
|
628
|
+
(i for i in indexes if not (i.name and i.name.startswith("ix_"))),
|
|
629
|
+
key=lambda i: i.name or "",
|
|
630
|
+
)
|
|
631
|
+
keep = explicit[0] if explicit else sorted(indexes, key=lambda i: i.name or "")[0]
|
|
615
632
|
to_remove.update(i for i in indexes if i is not keep)
|
|
616
633
|
|
|
617
|
-
# Remove non-unique when unique exists on same columns
|
|
634
|
+
# Remove full-table non-unique when a full-table unique exists on same columns.
|
|
635
|
+
# Partial indexes (unique or not) never participate: a partial unique index
|
|
636
|
+
# only covers rows matching its predicate and cannot replace a full index.
|
|
618
637
|
for indexes in col_map.values():
|
|
619
|
-
if any(getattr(i, "unique", False) for i in indexes):
|
|
620
|
-
to_remove.update(i for i in indexes if not getattr(i, "unique", False))
|
|
638
|
+
if any(getattr(i, "unique", False) and not is_partial(i) for i in indexes):
|
|
639
|
+
to_remove.update(i for i in indexes if not getattr(i, "unique", False) and not is_partial(i))
|
|
621
640
|
|
|
622
641
|
for idx in to_remove:
|
|
623
642
|
table.indexes.discard(idx)
|
|
@@ -266,6 +266,12 @@ class ValidationMixin(PrimaryKeyMixin):
|
|
|
266
266
|
)
|
|
267
267
|
if validators:
|
|
268
268
|
value = getattr(self, field_name, None)
|
|
269
|
+
# SQL expressions (func.now(), text(...), column arithmetic) are
|
|
270
|
+
# evaluated by the database — Python-side validators don't apply
|
|
271
|
+
from sqlalchemy.sql import ClauseElement
|
|
272
|
+
|
|
273
|
+
if isinstance(value, ClauseElement):
|
|
274
|
+
return
|
|
269
275
|
try:
|
|
270
276
|
from .validators import validate_field_value
|
|
271
277
|
|