sqlobjects 1.9.1__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.9.1 → sqlobjects-2.0.0}/CHANGELOG.md +28 -0
- {sqlobjects-1.9.1/sqlobjects.egg-info → sqlobjects-2.0.0}/PKG-INFO +12 -5
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/README.md +11 -4
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/docs/rules/01-database-session-guide.md +77 -9
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/docs/rules/02-model-definition-guide.md +35 -2
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/docs/rules/03-query-operations-guide.md +25 -10
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/docs/rules/04-crud-operations-guide.md +125 -7
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/docs/rules/05-relationships-guide.md +96 -6
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/docs/rules/06-validation-signals-guide.md +9 -9
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/docs/rules/07-performance-guide.md +7 -6
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/docs/rules/README.md +34 -3
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/pyproject.toml +2 -1
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/__init__.py +1 -1
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/_install_rules.py +18 -2
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/exceptions.py +18 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/expressions/cte.py +1 -1
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/expressions/terminal.py +8 -14
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/mixins.py +14 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/objects/bulk.py +26 -4
- sqlobjects-2.0.0/sqlobjects/py.typed +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/queries/builder.py +72 -27
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/queries/executor.py +4 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/queryset.py +42 -16
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/session.py +24 -1
- {sqlobjects-1.9.1 → sqlobjects-2.0.0/sqlobjects.egg-info}/PKG-INFO +12 -5
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects.egg-info/SOURCES.txt +1 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/LICENSE +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/setup.cfg +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/cascade.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/contrib/__init__.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/contrib/asgi.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/contrib/fastapi.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/database/__init__.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/database/config.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/database/manager.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/expressions/__init__.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/expressions/aggregate.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/expressions/base.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/expressions/explain.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/expressions/function.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/expressions/mixins.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/expressions/scalar.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/expressions/subquery.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/expressions/window.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/__init__.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/core.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/functions.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/proxies.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/relations/__init__.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/relations/descriptors.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/relations/managers.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/relations/prefetch.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/relations/strategies.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/relations/utils.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/shortcuts.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/types/__init__.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/types/base.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/types/comparators.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/types/registry.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/utils.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/internal/__init__.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/internal/operations.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/internal/results.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/metadata.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/model.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/objects/__init__.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/objects/core.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/objects/upsert.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/queries/__init__.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/queries/dialect.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/signals.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/sql_logging.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/utils/__init__.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/utils/inspect.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/utils/naming.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/utils/pattern.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/validators.py +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects.egg-info/dependency_links.txt +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects.egg-info/entry_points.txt +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects.egg-info/requires.txt +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects.egg-info/top_level.txt +0 -0
- {sqlobjects-1.9.1 → sqlobjects-2.0.0}/tests/test_config.py +0 -0
|
@@ -1,3 +1,31 @@
|
|
|
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
|
+
|
|
19
|
+
## 1.10.0 (2026-07-24)
|
|
20
|
+
|
|
21
|
+
### Feat
|
|
22
|
+
|
|
23
|
+
- **model**: add public get_dirty_fields() accessor on BaseMixin
|
|
24
|
+
|
|
25
|
+
### Fix
|
|
26
|
+
|
|
27
|
+
- **build**: sync __version__ with pyproject and auto-update via cz version_files
|
|
28
|
+
|
|
1
29
|
## 1.9.1 (2026-04-09)
|
|
2
30
|
|
|
3
31
|
### Fix
|
|
@@ -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>
|
|
@@ -48,6 +48,8 @@ loading. SQLObjects combines the familiar Django ORM API with the performance an
|
|
|
48
48
|
- **📊 High performance** - Built on SQLAlchemy Core for optimal performance
|
|
49
49
|
- **🔄 Smart operations** - Automatic CREATE/UPDATE detection and bulk operations
|
|
50
50
|
- **🎣 Lifecycle hooks** - Comprehensive signal system for database operations
|
|
51
|
+
- **🌊 Cascade operations** - Database- and ORM-level cascade deletes/updates with automatic detection
|
|
52
|
+
- **📝 SQL logging** - Zero-config `sqlobjects.sql` logger with user-code caller rewriting
|
|
51
53
|
- **🗄️ Multi-database support** - Seamless multi-database configuration and routing
|
|
52
54
|
|
|
53
55
|
## 🚀 Quick Start
|
|
@@ -89,7 +91,7 @@ active_users = await User.objects.filter(
|
|
|
89
91
|
).order_by("-age").limit(10).all()
|
|
90
92
|
|
|
91
93
|
# Complex queries with Q objects
|
|
92
|
-
from sqlobjects
|
|
94
|
+
from sqlobjects import Q
|
|
93
95
|
|
|
94
96
|
users = await User.objects.filter(
|
|
95
97
|
Q(User.age >= 18) & (Q(User.username.like("%admin%")) | Q(User.is_active == True))
|
|
@@ -380,9 +382,14 @@ uv run pytest
|
|
|
380
382
|
|
|
381
383
|
See our [TODO.md](TODO.md) for planned features:
|
|
382
384
|
|
|
383
|
-
- **
|
|
384
|
-
- **
|
|
385
|
-
- **
|
|
385
|
+
- **Database management**: connection health checks, dynamic default-database switching, connection-pool monitoring
|
|
386
|
+
- **Advanced SQL**: additional aggregate functions (array / JSON aggregation)
|
|
387
|
+
- **Query optimization**: index suggestions and slow-query analysis tools
|
|
388
|
+
- **Migrations**: model-diff migration generation and batched data-migration tooling
|
|
389
|
+
|
|
390
|
+
Already implemented: window functions, CTEs (including recursive), `EXPLAIN`
|
|
391
|
+
query analysis, cascade operations, SQL logging, and high-performance bulk
|
|
392
|
+
operations.
|
|
386
393
|
|
|
387
394
|
## 📄 License
|
|
388
395
|
|
|
@@ -19,6 +19,8 @@ loading. SQLObjects combines the familiar Django ORM API with the performance an
|
|
|
19
19
|
- **📊 High performance** - Built on SQLAlchemy Core for optimal performance
|
|
20
20
|
- **🔄 Smart operations** - Automatic CREATE/UPDATE detection and bulk operations
|
|
21
21
|
- **🎣 Lifecycle hooks** - Comprehensive signal system for database operations
|
|
22
|
+
- **🌊 Cascade operations** - Database- and ORM-level cascade deletes/updates with automatic detection
|
|
23
|
+
- **📝 SQL logging** - Zero-config `sqlobjects.sql` logger with user-code caller rewriting
|
|
22
24
|
- **🗄️ Multi-database support** - Seamless multi-database configuration and routing
|
|
23
25
|
|
|
24
26
|
## 🚀 Quick Start
|
|
@@ -60,7 +62,7 @@ active_users = await User.objects.filter(
|
|
|
60
62
|
).order_by("-age").limit(10).all()
|
|
61
63
|
|
|
62
64
|
# Complex queries with Q objects
|
|
63
|
-
from sqlobjects
|
|
65
|
+
from sqlobjects import Q
|
|
64
66
|
|
|
65
67
|
users = await User.objects.filter(
|
|
66
68
|
Q(User.age >= 18) & (Q(User.username.like("%admin%")) | Q(User.is_active == True))
|
|
@@ -351,9 +353,14 @@ uv run pytest
|
|
|
351
353
|
|
|
352
354
|
See our [TODO.md](TODO.md) for planned features:
|
|
353
355
|
|
|
354
|
-
- **
|
|
355
|
-
- **
|
|
356
|
-
- **
|
|
356
|
+
- **Database management**: connection health checks, dynamic default-database switching, connection-pool monitoring
|
|
357
|
+
- **Advanced SQL**: additional aggregate functions (array / JSON aggregation)
|
|
358
|
+
- **Query optimization**: index suggestions and slow-query analysis tools
|
|
359
|
+
- **Migrations**: model-diff migration generation and batched data-migration tooling
|
|
360
|
+
|
|
361
|
+
Already implemented: window functions, CTEs (including recursive), `EXPLAIN`
|
|
362
|
+
query analysis, cascade operations, SQL logging, and high-performance bulk
|
|
363
|
+
operations.
|
|
357
364
|
|
|
358
365
|
## 📄 License
|
|
359
366
|
|
|
@@ -104,7 +104,7 @@ async with ctx_session() as session:
|
|
|
104
104
|
|
|
105
105
|
# Good: Connection pool configuration
|
|
106
106
|
await init_db(
|
|
107
|
-
"postgresql://localhost/db",
|
|
107
|
+
"postgresql+asyncpg://localhost/db",
|
|
108
108
|
pool_size=20,
|
|
109
109
|
max_overflow=30,
|
|
110
110
|
pool_timeout=30,
|
|
@@ -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
|
|
@@ -141,7 +154,7 @@ await Post.objects.create(title="Post", author_id=user.id)
|
|
|
141
154
|
```python
|
|
142
155
|
# Production configuration
|
|
143
156
|
await init_db(
|
|
144
|
-
"postgresql://localhost/db",
|
|
157
|
+
"postgresql+asyncpg://localhost/db",
|
|
145
158
|
pool_size=20, # Base connections
|
|
146
159
|
max_overflow=30, # Burst capacity
|
|
147
160
|
pool_timeout=30, # Wait time for connection
|
|
@@ -249,7 +262,7 @@ except OperationalError:
|
|
|
249
262
|
## Complete Example
|
|
250
263
|
|
|
251
264
|
```python
|
|
252
|
-
from sqlobjects.database import init_dbs, create_tables,
|
|
265
|
+
from sqlobjects.database import init_dbs, create_tables, close_dbs
|
|
253
266
|
from sqlobjects.session import ctx_session, ctx_sessions
|
|
254
267
|
from sqlobjects.model import ObjectModel
|
|
255
268
|
from sqlobjects.fields import Column, StringColumn
|
|
@@ -264,8 +277,8 @@ class Log(ObjectModel):
|
|
|
264
277
|
async def main():
|
|
265
278
|
# Setup
|
|
266
279
|
await init_dbs({
|
|
267
|
-
"main": {"url": "postgresql://localhost/main", "pool_size": 20},
|
|
268
|
-
"analytics": {"url": "sqlite:///analytics.db"}
|
|
280
|
+
"main": {"url": "postgresql+asyncpg://localhost/main", "pool_size": 20},
|
|
281
|
+
"analytics": {"url": "sqlite+aiosqlite:///analytics.db"}
|
|
269
282
|
}, default="main")
|
|
270
283
|
|
|
271
284
|
await create_tables(ObjectModel, "main")
|
|
@@ -286,7 +299,7 @@ async def main():
|
|
|
286
299
|
)
|
|
287
300
|
|
|
288
301
|
# Cleanup
|
|
289
|
-
await
|
|
302
|
+
await close_dbs()
|
|
290
303
|
|
|
291
304
|
# Run
|
|
292
305
|
import asyncio
|
|
@@ -330,3 +343,58 @@ async def get_user(user_id: int, session: AsyncSession = Depends(get_db_session)
|
|
|
330
343
|
user = await User.objects.using(session).get(User.id == user_id)
|
|
331
344
|
return {"username": user.username}
|
|
332
345
|
```
|
|
346
|
+
|
|
347
|
+
## SQL Logging
|
|
348
|
+
|
|
349
|
+
SQLObjects ships zero-config SQL logging through the standard library `logging`
|
|
350
|
+
module. Every executed statement is logged at `DEBUG` level to the logger named
|
|
351
|
+
**`sqlobjects.sql`**. This logger is an `ObjectLogger` (installed automatically
|
|
352
|
+
on import) that rewrites each record's caller fields — `pathname`, `filename`,
|
|
353
|
+
`funcName`, `lineno` — to the first user-code frame, skipping internal
|
|
354
|
+
`sqlobjects.*` and `sqlalchemy.*` frames. Your log output therefore points at
|
|
355
|
+
the line in your application that issued the query, not at ORM internals.
|
|
356
|
+
|
|
357
|
+
### Enabling
|
|
358
|
+
|
|
359
|
+
```python
|
|
360
|
+
import logging
|
|
361
|
+
|
|
362
|
+
# Show SQL (and everything else) at DEBUG
|
|
363
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
364
|
+
logging.getLogger("sqlobjects.sql").setLevel(logging.DEBUG)
|
|
365
|
+
|
|
366
|
+
# Now queries emit DEBUG log records
|
|
367
|
+
users = await User.objects.filter(User.is_active == True).all()
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
To capture SQL only, without lowering the level of the rest of your app, attach
|
|
371
|
+
a dedicated handler to the `sqlobjects.sql` logger:
|
|
372
|
+
|
|
373
|
+
```python
|
|
374
|
+
import logging
|
|
375
|
+
|
|
376
|
+
sql_logger = logging.getLogger("sqlobjects.sql")
|
|
377
|
+
sql_logger.setLevel(logging.DEBUG)
|
|
378
|
+
|
|
379
|
+
handler = logging.StreamHandler()
|
|
380
|
+
handler.setFormatter(logging.Formatter("%(pathname)s:%(lineno)d - %(message)s"))
|
|
381
|
+
sql_logger.addHandler(handler)
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
Statement compilation only happens when the logger is actually enabled for
|
|
385
|
+
`DEBUG`, so leaving logging off has no runtime cost. Each record carries the
|
|
386
|
+
compiled SQL as the message plus an `extra` dict with `sql`, `params`, and
|
|
387
|
+
`duration_ms`.
|
|
388
|
+
|
|
389
|
+
### Public API
|
|
390
|
+
|
|
391
|
+
```python
|
|
392
|
+
from sqlobjects import ObjectLogger, get_caller_frame
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
- **`ObjectLogger`** — the `logging.Logger` subclass used for `sqlobjects.sql`.
|
|
396
|
+
It accepts an `extra_skip_packages` list to skip additional module prefixes
|
|
397
|
+
(e.g. your own middleware) when resolving the user-code frame.
|
|
398
|
+
- **`get_caller_frame(extra_skip_packages=None, max_frames=1)`** — returns the
|
|
399
|
+
first user-code frame as a `"path:lineno in func"` string (or a list when
|
|
400
|
+
`max_frames > 1`), applying the same skip rules. Useful for custom logging.
|
|
@@ -69,7 +69,8 @@ class User(ObjectModel):
|
|
|
69
69
|
|
|
70
70
|
```python
|
|
71
71
|
from sqlobjects.model import ObjectModel
|
|
72
|
-
from sqlobjects.fields import Column, StringColumn,
|
|
72
|
+
from sqlobjects.fields import Column, StringColumn, IntegerColumn
|
|
73
|
+
from sqlobjects.metadata import index, constraint
|
|
73
74
|
|
|
74
75
|
class User(ObjectModel):
|
|
75
76
|
username: Column[str] = StringColumn(length=50)
|
|
@@ -88,6 +89,38 @@ class User(ObjectModel):
|
|
|
88
89
|
]
|
|
89
90
|
```
|
|
90
91
|
|
|
92
|
+
### Named & Composite Foreign Keys
|
|
93
|
+
|
|
94
|
+
For composite foreign keys, custom constraint names, or explicit referential
|
|
95
|
+
actions defined at the table level, use the `foreignkey()` constraint builder
|
|
96
|
+
in `Config.constraints`. For simple single-column foreign keys, prefer the
|
|
97
|
+
`foreign_key()` field descriptor (see the Relationships guide) instead.
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from sqlobjects.model import ObjectModel
|
|
101
|
+
from sqlobjects.fields import Column, StringColumn, IntegerColumn
|
|
102
|
+
from sqlobjects.metadata import foreignkey
|
|
103
|
+
|
|
104
|
+
class OrderItem(ObjectModel):
|
|
105
|
+
order_id: Column[int] = IntegerColumn()
|
|
106
|
+
product_id: Column[int] = IntegerColumn()
|
|
107
|
+
quantity: Column[int] = IntegerColumn()
|
|
108
|
+
|
|
109
|
+
class Config:
|
|
110
|
+
constraints = [
|
|
111
|
+
# Named single-column foreign key with explicit referential action
|
|
112
|
+
foreignkey("order_id", "Order.id", name="fk_items_order", ondelete="CASCADE"),
|
|
113
|
+
# Composite foreign key
|
|
114
|
+
foreignkey(["product_id", "order_id"], ["Product.id", "Order.id"]),
|
|
115
|
+
]
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`foreignkey(fields, references, *, name=None, ondelete=None, onupdate=None,
|
|
119
|
+
deferrable=False, initially="IMMEDIATE")` accepts a single field/reference or a
|
|
120
|
+
list for composite keys. References may be given as class names (e.g.
|
|
121
|
+
`"Order.id"`) or table names (e.g. `"orders.id"`); class names are resolved
|
|
122
|
+
automatically.
|
|
123
|
+
|
|
91
124
|
### Field Parameters
|
|
92
125
|
|
|
93
126
|
```python
|
|
@@ -333,8 +366,8 @@ from sqlobjects.model import ObjectModel
|
|
|
333
366
|
from sqlobjects.fields import (
|
|
334
367
|
Column, column, StringColumn, IntegerColumn,
|
|
335
368
|
BooleanColumn, DateTimeColumn, JsonColumn,
|
|
336
|
-
index, constraint
|
|
337
369
|
)
|
|
370
|
+
from sqlobjects.metadata import index, constraint
|
|
338
371
|
from datetime import datetime
|
|
339
372
|
|
|
340
373
|
class User(ObjectModel):
|
|
@@ -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
|
|
@@ -41,7 +41,7 @@ users = await User.objects.filter(User.username.like("admin%")).all()
|
|
|
41
41
|
### Complex Filtering with Q Objects
|
|
42
42
|
|
|
43
43
|
```python
|
|
44
|
-
from sqlobjects
|
|
44
|
+
from sqlobjects import Q
|
|
45
45
|
|
|
46
46
|
# OR conditions
|
|
47
47
|
users = await User.objects.filter(
|
|
@@ -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
|
|
@@ -242,7 +257,7 @@ result = await User.objects.with_cte(adults).filter(adults.c.age < 30).all()
|
|
|
242
257
|
base = Employee.objects.filter(Employee.manager_id.is_(None)).cte("hierarchy", recursive=True)
|
|
243
258
|
recursive_part = Employee.objects.join(base, Employee.manager_id == base.c.id)
|
|
244
259
|
hierarchy = base.union_all(recursive_part)
|
|
245
|
-
all_employees = await Employee.objects.with_cte(hierarchy).
|
|
260
|
+
all_employees = await Employee.objects.with_cte(hierarchy).all()
|
|
246
261
|
```
|
|
247
262
|
|
|
248
263
|
### Manual Joins
|
|
@@ -406,9 +421,9 @@ users = await User.objects.filter(
|
|
|
406
421
|
```python
|
|
407
422
|
from sqlobjects.model import ObjectModel
|
|
408
423
|
from sqlobjects.fields import Column, StringColumn, IntegerColumn, BooleanColumn
|
|
409
|
-
from sqlobjects
|
|
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(
|
|
@@ -69,7 +69,7 @@ mappings = [
|
|
|
69
69
|
await User.objects.bulk_update(mappings, match_fields=["id"], batch_size=1000)
|
|
70
70
|
|
|
71
71
|
# Update all matching records
|
|
72
|
-
await User.objects.filter(User.department == "sales").
|
|
72
|
+
await User.objects.filter(User.department == "sales").update(is_active=False)
|
|
73
73
|
```
|
|
74
74
|
|
|
75
75
|
### Delete Operations
|
|
@@ -84,9 +84,63 @@ user_ids = [1, 2, 3, 4, 5]
|
|
|
84
84
|
await User.objects.bulk_delete(user_ids, id_field="id", batch_size=1000)
|
|
85
85
|
|
|
86
86
|
# Delete all matching records
|
|
87
|
-
await User.objects.filter(User.is_active == False).
|
|
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
|
+
|
|
116
|
+
### Cascade on Delete
|
|
117
|
+
|
|
118
|
+
Both delete entry points detect and apply cascade behavior automatically:
|
|
119
|
+
|
|
120
|
+
- **`instance.delete(cascade=None)`** — with the default `None`, the instance
|
|
121
|
+
auto-detects whether any relationship declares an on-delete cascade and
|
|
122
|
+
handles related objects accordingly. Pass `cascade=True` to force cascade
|
|
123
|
+
handling or `cascade=False` to skip it and issue a direct delete.
|
|
124
|
+
- **`QuerySet.delete(cascade="auto")`** — accepts a strategy string:
|
|
125
|
+
- `"auto"` (default): choose automatically based on the model's relationships
|
|
126
|
+
and delete signals
|
|
127
|
+
- `"full"`: complete cascade deletion with full ORM functionality (fires signals)
|
|
128
|
+
- `"fast"`: fast cascade deletion with minimal ORM processing
|
|
129
|
+
- `"none"`: direct SQL deletion without any ORM cascade processing
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
# Instance delete with explicit cascade control
|
|
133
|
+
user = await User.objects.get(User.id == 1)
|
|
134
|
+
await user.delete(cascade=True) # force cascade
|
|
135
|
+
await user.delete(cascade=False) # skip cascade, direct delete
|
|
136
|
+
|
|
137
|
+
# QuerySet delete with an explicit strategy
|
|
138
|
+
await User.objects.filter(User.is_active == False).delete(cascade="fast")
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
See the Relationships guide for how cascade behavior is configured on
|
|
142
|
+
relationships and foreign keys.
|
|
143
|
+
|
|
90
144
|
### Bulk Operations
|
|
91
145
|
|
|
92
146
|
```python
|
|
@@ -98,9 +152,11 @@ users_data = [
|
|
|
98
152
|
await User.objects.bulk_create(users_data, batch_size=500)
|
|
99
153
|
|
|
100
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)
|
|
101
157
|
mappings = [
|
|
102
|
-
{"id": 1, "status": "active", "last_seen": datetime.now()},
|
|
103
|
-
{"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)},
|
|
104
160
|
]
|
|
105
161
|
await User.objects.bulk_update(mappings, match_fields=["id"])
|
|
106
162
|
|
|
@@ -109,6 +165,68 @@ user_ids = list(range(1, 1001))
|
|
|
109
165
|
await User.objects.bulk_delete(user_ids, id_field="id", batch_size=1000)
|
|
110
166
|
```
|
|
111
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
|
+
|
|
112
230
|
## Best Practices
|
|
113
231
|
|
|
114
232
|
### ✅ Do
|
|
@@ -192,8 +310,8 @@ user.is_active = False
|
|
|
192
310
|
await user.save() # Only updates email and is_active fields
|
|
193
311
|
|
|
194
312
|
# Check dirty fields
|
|
195
|
-
|
|
196
|
-
|
|
313
|
+
changed_fields = user.get_dirty_fields()
|
|
314
|
+
if changed_fields:
|
|
197
315
|
print(f"Modified fields: {changed_fields}")
|
|
198
316
|
```
|
|
199
317
|
|
|
@@ -324,7 +442,7 @@ await User.objects.bulk_create(users_data, batch_size=1000)
|
|
|
324
442
|
from sqlobjects.model import ObjectModel
|
|
325
443
|
from sqlobjects.fields import Column, StringColumn, BooleanColumn
|
|
326
444
|
from sqlobjects.session import ctx_session
|
|
327
|
-
from datetime import datetime
|
|
445
|
+
from datetime import datetime, timezone
|
|
328
446
|
|
|
329
447
|
class User(ObjectModel):
|
|
330
448
|
username: Column[str] = StringColumn(length=50)
|