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.
Files changed (82) hide show
  1. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/CHANGELOG.md +28 -0
  2. {sqlobjects-1.9.1/sqlobjects.egg-info → sqlobjects-2.0.0}/PKG-INFO +12 -5
  3. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/README.md +11 -4
  4. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/docs/rules/01-database-session-guide.md +77 -9
  5. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/docs/rules/02-model-definition-guide.md +35 -2
  6. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/docs/rules/03-query-operations-guide.md +25 -10
  7. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/docs/rules/04-crud-operations-guide.md +125 -7
  8. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/docs/rules/05-relationships-guide.md +96 -6
  9. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/docs/rules/06-validation-signals-guide.md +9 -9
  10. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/docs/rules/07-performance-guide.md +7 -6
  11. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/docs/rules/README.md +34 -3
  12. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/pyproject.toml +2 -1
  13. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/__init__.py +1 -1
  14. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/_install_rules.py +18 -2
  15. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/exceptions.py +18 -0
  16. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/expressions/cte.py +1 -1
  17. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/expressions/terminal.py +8 -14
  18. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/mixins.py +14 -0
  19. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/objects/bulk.py +26 -4
  20. sqlobjects-2.0.0/sqlobjects/py.typed +0 -0
  21. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/queries/builder.py +72 -27
  22. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/queries/executor.py +4 -0
  23. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/queryset.py +42 -16
  24. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/session.py +24 -1
  25. {sqlobjects-1.9.1 → sqlobjects-2.0.0/sqlobjects.egg-info}/PKG-INFO +12 -5
  26. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects.egg-info/SOURCES.txt +1 -0
  27. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/LICENSE +0 -0
  28. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/setup.cfg +0 -0
  29. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/cascade.py +0 -0
  30. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/contrib/__init__.py +0 -0
  31. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/contrib/asgi.py +0 -0
  32. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/contrib/fastapi.py +0 -0
  33. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/database/__init__.py +0 -0
  34. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/database/config.py +0 -0
  35. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/database/manager.py +0 -0
  36. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/expressions/__init__.py +0 -0
  37. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/expressions/aggregate.py +0 -0
  38. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/expressions/base.py +0 -0
  39. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/expressions/explain.py +0 -0
  40. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/expressions/function.py +0 -0
  41. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/expressions/mixins.py +0 -0
  42. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/expressions/scalar.py +0 -0
  43. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/expressions/subquery.py +0 -0
  44. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/expressions/window.py +0 -0
  45. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/__init__.py +0 -0
  46. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/core.py +0 -0
  47. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/functions.py +0 -0
  48. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/proxies.py +0 -0
  49. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/relations/__init__.py +0 -0
  50. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/relations/descriptors.py +0 -0
  51. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/relations/managers.py +0 -0
  52. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/relations/prefetch.py +0 -0
  53. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/relations/strategies.py +0 -0
  54. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/relations/utils.py +0 -0
  55. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/shortcuts.py +0 -0
  56. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/types/__init__.py +0 -0
  57. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/types/base.py +0 -0
  58. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/types/comparators.py +0 -0
  59. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/types/registry.py +0 -0
  60. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/fields/utils.py +0 -0
  61. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/internal/__init__.py +0 -0
  62. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/internal/operations.py +0 -0
  63. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/internal/results.py +0 -0
  64. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/metadata.py +0 -0
  65. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/model.py +0 -0
  66. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/objects/__init__.py +0 -0
  67. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/objects/core.py +0 -0
  68. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/objects/upsert.py +0 -0
  69. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/queries/__init__.py +0 -0
  70. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/queries/dialect.py +0 -0
  71. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/signals.py +0 -0
  72. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/sql_logging.py +0 -0
  73. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/utils/__init__.py +0 -0
  74. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/utils/inspect.py +0 -0
  75. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/utils/naming.py +0 -0
  76. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/utils/pattern.py +0 -0
  77. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects/validators.py +0 -0
  78. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects.egg-info/dependency_links.txt +0 -0
  79. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects.egg-info/entry_points.txt +0 -0
  80. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects.egg-info/requires.txt +0 -0
  81. {sqlobjects-1.9.1 → sqlobjects-2.0.0}/sqlobjects.egg-info/top_level.txt +0 -0
  82. {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: 1.9.1
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.queries import Q
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
- - **v2.0**: Database health checks, window functions, advanced bulk operations
384
- - **v2.1**: Advanced field optimization, query performance tools
385
- - **v2.2+**: CTE support, advanced SQL functions
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.queries import Q
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
- - **v2.0**: Database health checks, window functions, advanced bulk operations
355
- - **v2.1**: Advanced field optimization, query performance tools
356
- - **v2.2+**: CTE support, advanced SQL functions
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
- # Caution: Nested sessions use separate transactions
123
+ # DANGER: Nested ctx_session opens a SECOND physical connection
124
124
  async with ctx_session() as session1:
125
125
  user = await User.objects.using(session1).create(username="alice")
126
126
  async with ctx_session() as session2:
127
- # Nesting is safe (Token-based restore), but uses a separate transaction
128
- # session1 won't see session2's uncommitted changes and vice versa
127
+ # ContextVar restore is safe, but session2 is a separate physical
128
+ # connection with its own transaction:
129
+ # - session1 won't see session2's uncommitted changes and vice versa
130
+ # - if session2 writes a row that session1 holds a lock on, session2
131
+ # blocks forever while session1 sits idle-in-transaction waiting for
132
+ # this coroutine to return. The database deadlock detector CANNOT
133
+ # see this cycle — it manifests as request timeouts, and session1's
134
+ # locks block unrelated requests until then.
129
135
  await Post.objects.using(session2).create(author_id=user.id)
130
136
 
137
+ # Good: join the ambient session when running inside a possibly-active transaction
138
+ async with ctx_session(join_ambient=True) as session:
139
+ # Reuses the outer session if one exists (no second connection);
140
+ # creates a new one only at the top level. Lifecycle (commit/rollback/close)
141
+ # belongs to the outermost owner.
142
+ await Post.objects.using(session).create(author_id=user.id)
143
+
131
144
  # Bad: No transaction control
132
145
  user = await User.objects.create(username="alice")
133
146
  # If next operation fails, user is already created
@@ -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, close_all_dbs
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 close_all_dbs()
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, index, constraint
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.queries import Q
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").all()
123
+ ).group_by("department").values("department", "user_count")
124
+ # [{"department": "sales", "user_count": 10}, ...]
121
125
 
122
126
  # Having clause (filter groups)
123
127
  large_depts = await User.objects.annotate(
124
128
  user_count=func.count()
125
- ).group_by("department").having(func.count() > 10).all()
129
+ ).group_by("department").having(func.count() > 10).values("department", "user_count")
130
+
131
+ # Grouping by the primary key is the one case where .all() stays valid:
132
+ # each group is exactly one model row (e.g. counting related rows per user)
133
+ users = await User.objects.annotate(post_count=Post.id.count()) \
134
+ .join(Post.__table__, User.id == Post.author_id, join_type="left") \
135
+ .group_by(User.id).all()
126
136
  ```
127
137
 
138
+ **Do not** call `.all()` on a query grouped by a non-primary-key column — the
139
+ selected model columns would fall outside GROUP BY, so SQLObjects raises
140
+ `QueryError` instead of silently corrupting the aggregation (each group would
141
+ collapse to a single row).
142
+
128
143
  ### Distinct
129
144
 
130
145
  ```python
@@ -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).select_from(hierarchy).all()
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.queries import Q
424
+ from sqlobjects import Q
410
425
  from sqlobjects.expressions import func
411
- from datetime import datetime, timedelta
426
+ from datetime import datetime, timedelta, timezone
412
427
 
413
428
  class User(ObjectModel):
414
429
  username: Column[str] = StringColumn(length=50)
@@ -426,10 +441,10 @@ admins_or_staff = await User.objects.filter(
426
441
  Q(User.department == "admin") | Q(User.department == "staff")
427
442
  ).all()
428
443
 
429
- # Aggregation
444
+ # Aggregation (one row per group — see "Grouping" section)
430
445
  dept_stats = await User.objects.annotate(
431
446
  user_count=func.count()
432
- ).group_by("department").all()
447
+ ).group_by("department").values("department", "user_count")
433
448
 
434
449
  # Pagination with field selection
435
450
  users = await User.objects.only(
@@ -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").update_all(is_active=False)
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).delete_all()
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
- if user._has_changes():
196
- changed_fields = user._get_changed_fields()
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)