sqlobjects 1.2.5__tar.gz → 1.4.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.2.5 → sqlobjects-1.4.0}/CHANGELOG.md +12 -0
- {sqlobjects-1.2.5/sqlobjects.egg-info → sqlobjects-1.4.0}/PKG-INFO +1 -1
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/docs/rules/01-database-session-guide.md +60 -6
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/docs/rules/03-query-operations-guide.md +41 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/docs/rules/05-relationships-guide.md +3 -1
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/pyproject.toml +1 -1
- sqlobjects-1.4.0/sqlobjects/contrib/__init__.py +0 -0
- sqlobjects-1.4.0/sqlobjects/contrib/asgi.py +63 -0
- sqlobjects-1.4.0/sqlobjects/contrib/fastapi.py +26 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/functions.py +34 -5
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/metadata.py +37 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/session.py +23 -9
- {sqlobjects-1.2.5 → sqlobjects-1.4.0/sqlobjects.egg-info}/PKG-INFO +1 -1
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects.egg-info/SOURCES.txt +3 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/LICENSE +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/README.md +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/docs/rules/02-model-definition-guide.md +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/docs/rules/04-crud-operations-guide.md +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/docs/rules/06-validation-signals-guide.md +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/docs/rules/07-performance-guide.md +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/docs/rules/README.md +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/setup.cfg +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/__init__.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/_install_rules.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/cascade.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/database/__init__.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/database/config.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/database/manager.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/exceptions.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/expressions/__init__.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/expressions/aggregate.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/expressions/base.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/expressions/cte.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/expressions/explain.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/expressions/function.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/expressions/mixins.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/expressions/scalar.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/expressions/subquery.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/expressions/terminal.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/expressions/window.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/__init__.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/core.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/proxies.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/relations/__init__.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/relations/descriptors.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/relations/managers.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/relations/prefetch.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/relations/strategies.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/relations/utils.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/shortcuts.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/types/__init__.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/types/base.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/types/comparators.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/types/registry.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/utils.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/internal/__init__.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/internal/operations.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/internal/results.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/mixins.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/model.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/objects/__init__.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/objects/bulk.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/objects/core.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/objects/upsert.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/queries/__init__.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/queries/builder.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/queries/dialect.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/queries/executor.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/queryset.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/signals.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/utils/__init__.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/utils/inspect.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/utils/naming.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/utils/pattern.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/validators.py +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects.egg-info/dependency_links.txt +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects.egg-info/entry_points.txt +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects.egg-info/requires.txt +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects.egg-info/top_level.txt +0 -0
- {sqlobjects-1.2.5 → sqlobjects-1.4.0}/tests/test_config.py +0 -0
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## 1.4.0 (2026-03-11)
|
|
2
|
+
|
|
3
|
+
### Feat
|
|
4
|
+
|
|
5
|
+
- **session**: fix nested ctx_session() and add ASGI/FastAPI integration
|
|
6
|
+
|
|
7
|
+
## 1.3.0 (2026-03-10)
|
|
8
|
+
|
|
9
|
+
### Feat
|
|
10
|
+
|
|
11
|
+
- **fields**: support class name reference in foreign_key() with delayed matching
|
|
12
|
+
|
|
1
13
|
## 1.2.5 (2026-03-06)
|
|
2
14
|
|
|
3
15
|
### Fix
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlobjects
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.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>
|
|
@@ -71,6 +71,21 @@ async with ctx_session() as session:
|
|
|
71
71
|
user = await User.objects.using("analytics").create(username="test")
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
+
### Session Availability Check
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from sqlobjects.session import has_session
|
|
78
|
+
|
|
79
|
+
# Check if an explicit session is active
|
|
80
|
+
if has_session():
|
|
81
|
+
# Inside a ctx_session() block — use the existing session
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
# Check for a specific database
|
|
85
|
+
if has_session("analytics"):
|
|
86
|
+
pass
|
|
87
|
+
```
|
|
88
|
+
|
|
74
89
|
## Best Practices
|
|
75
90
|
|
|
76
91
|
### ✅ Do
|
|
@@ -105,11 +120,12 @@ await init_db(
|
|
|
105
120
|
- **Don't create sessions manually** (use context managers)
|
|
106
121
|
|
|
107
122
|
```python
|
|
108
|
-
#
|
|
123
|
+
# Caution: Nested sessions use separate transactions
|
|
109
124
|
async with ctx_session() as session1:
|
|
110
125
|
user = await User.objects.using(session1).create(username="alice")
|
|
111
126
|
async with ctx_session() as session2:
|
|
112
|
-
#
|
|
127
|
+
# Nesting is safe (Token-based restore), but uses a separate transaction
|
|
128
|
+
# — session1 won't see session2's uncommitted changes and vice versa
|
|
113
129
|
await Post.objects.using(session2).create(author_id=user.id)
|
|
114
130
|
|
|
115
131
|
# Bad: No transaction control
|
|
@@ -251,24 +267,24 @@ async def main():
|
|
|
251
267
|
"main": {"url": "postgresql://localhost/main", "pool_size": 20},
|
|
252
268
|
"analytics": {"url": "sqlite:///analytics.db"}
|
|
253
269
|
}, default="main")
|
|
254
|
-
|
|
270
|
+
|
|
255
271
|
await create_tables(ObjectModel, "main")
|
|
256
272
|
await create_tables(ObjectModel, "analytics")
|
|
257
|
-
|
|
273
|
+
|
|
258
274
|
# Single database transaction
|
|
259
275
|
async with ctx_session() as session:
|
|
260
276
|
user = await User.objects.using(session).create(
|
|
261
277
|
username="alice",
|
|
262
278
|
email="alice@example.com"
|
|
263
279
|
)
|
|
264
|
-
|
|
280
|
+
|
|
265
281
|
# Multi-database transaction
|
|
266
282
|
async with ctx_sessions("main", "analytics") as sessions:
|
|
267
283
|
user = await User.objects.using(sessions["main"]).get(User.username == "alice")
|
|
268
284
|
await Log.objects.using(sessions["analytics"]).create(
|
|
269
285
|
message=f"User {user.username} logged in"
|
|
270
286
|
)
|
|
271
|
-
|
|
287
|
+
|
|
272
288
|
# Cleanup
|
|
273
289
|
await close_all_dbs()
|
|
274
290
|
|
|
@@ -276,3 +292,41 @@ async def main():
|
|
|
276
292
|
import asyncio
|
|
277
293
|
asyncio.run(main())
|
|
278
294
|
```
|
|
295
|
+
|
|
296
|
+
## Web Framework Integration
|
|
297
|
+
|
|
298
|
+
### ASGI Middleware
|
|
299
|
+
|
|
300
|
+
Use `SessionMiddleware` for automatic request-scoped session management in any ASGI framework (FastAPI, Starlette, etc.):
|
|
301
|
+
|
|
302
|
+
```python
|
|
303
|
+
from fastapi import FastAPI
|
|
304
|
+
from sqlobjects.contrib.asgi import SessionMiddleware
|
|
305
|
+
|
|
306
|
+
app = FastAPI()
|
|
307
|
+
app.add_middleware(SessionMiddleware)
|
|
308
|
+
# Each request gets an auto-managed session: commit on success, rollback on error
|
|
309
|
+
|
|
310
|
+
# Optional: target a specific database or use readonly mode
|
|
311
|
+
app.add_middleware(SessionMiddleware, db_name="analytics", readonly=True)
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### FastAPI Dependency Injection
|
|
315
|
+
|
|
316
|
+
Use `get_db_session` for explicit session access in route handlers:
|
|
317
|
+
|
|
318
|
+
```python
|
|
319
|
+
from fastapi import Depends
|
|
320
|
+
from sqlobjects.contrib.fastapi import get_db_session
|
|
321
|
+
from sqlobjects.session import AsyncSession
|
|
322
|
+
|
|
323
|
+
@app.post("/users")
|
|
324
|
+
async def create_user(session: AsyncSession = Depends(get_db_session)):
|
|
325
|
+
user = await User.objects.using(session).create(username="alice")
|
|
326
|
+
return {"id": user.id}
|
|
327
|
+
|
|
328
|
+
@app.get("/users/{user_id}")
|
|
329
|
+
async def get_user(user_id: int, session: AsyncSession = Depends(get_db_session)):
|
|
330
|
+
user = await User.objects.using(session).get(User.id == user_id)
|
|
331
|
+
return {"username": user.username}
|
|
332
|
+
```
|
|
@@ -204,6 +204,47 @@ active_users = User.objects.filter(User.is_active == True).subquery("active_user
|
|
|
204
204
|
posts = await Post.objects.join(active_users, Post.author_id == active_users.c.id).all()
|
|
205
205
|
```
|
|
206
206
|
|
|
207
|
+
### Window Functions
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
from sqlobjects.expressions import func
|
|
211
|
+
|
|
212
|
+
# Window functions are used via annotate() + func.xxx().over()
|
|
213
|
+
users = await User.objects.annotate(
|
|
214
|
+
row_num=func.row_number().over(order_by=[User.created_at])
|
|
215
|
+
).all()
|
|
216
|
+
|
|
217
|
+
# Ranking with partitions
|
|
218
|
+
users = await User.objects.annotate(
|
|
219
|
+
dept_rank=func.rank().over(
|
|
220
|
+
partition_by=[User.department],
|
|
221
|
+
order_by=[(User.salary, 'desc')]
|
|
222
|
+
)
|
|
223
|
+
).all()
|
|
224
|
+
|
|
225
|
+
# LAG/LEAD for adjacent row access
|
|
226
|
+
users = await User.objects.annotate(
|
|
227
|
+
prev_salary=func.lag(User.salary, 1).over(order_by=[User.created_at])
|
|
228
|
+
).all()
|
|
229
|
+
|
|
230
|
+
# Available: row_number(), rank(), dense_rank(), percent_rank(),
|
|
231
|
+
# ntile(n), lag(), lead(), first_value(), last_value(), nth_value()
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### CTE (Common Table Expressions)
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
# Basic CTE: define a reusable named subquery
|
|
238
|
+
adults = User.objects.filter(User.age >= 18).cte("adults")
|
|
239
|
+
result = await User.objects.with_cte(adults).filter(adults.c.age < 30).all()
|
|
240
|
+
|
|
241
|
+
# Recursive CTE: hierarchical queries
|
|
242
|
+
base = Employee.objects.filter(Employee.manager_id.is_(None)).cte("hierarchy", recursive=True)
|
|
243
|
+
recursive_part = Employee.objects.join(base, Employee.manager_id == base.c.id)
|
|
244
|
+
hierarchy = base.union_all(recursive_part)
|
|
245
|
+
all_employees = await Employee.objects.with_cte(hierarchy).select_from(hierarchy).all()
|
|
246
|
+
```
|
|
247
|
+
|
|
207
248
|
### Manual Joins
|
|
208
249
|
|
|
209
250
|
```python
|
|
@@ -19,7 +19,9 @@ from sqlobjects.fields import Column, StringColumn, foreign_key, relationship, R
|
|
|
19
19
|
# One-to-Many (Foreign Key)
|
|
20
20
|
class Post(ObjectModel):
|
|
21
21
|
title: Column[str] = StringColumn(length=200)
|
|
22
|
-
|
|
22
|
+
# Both class name and table name are supported:
|
|
23
|
+
author_id: Column[int] = foreign_key("User.id") # class name (auto-resolved)
|
|
24
|
+
# author_id: Column[int] = foreign_key("users.id") # table name (also works)
|
|
23
25
|
author: Related["User"] = relationship("User", back_populates="posts")
|
|
24
26
|
|
|
25
27
|
class User(ObjectModel):
|
|
File without changes
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""ASGI middleware for request-scoped SQLObjects session management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..database.manager import get_default
|
|
8
|
+
from ..session import AsyncSession, _SessionContextManager
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SessionMiddleware:
|
|
12
|
+
"""ASGI middleware that provides a request-scoped database session.
|
|
13
|
+
|
|
14
|
+
Automatically creates an AsyncSession for each HTTP/WebSocket request,
|
|
15
|
+
sets it in the ContextVar so that model operations use it, and commits
|
|
16
|
+
on success or rolls back on failure.
|
|
17
|
+
|
|
18
|
+
Usage with FastAPI::
|
|
19
|
+
|
|
20
|
+
from fastapi import FastAPI
|
|
21
|
+
from sqlobjects.contrib.asgi import SessionMiddleware
|
|
22
|
+
|
|
23
|
+
app = FastAPI()
|
|
24
|
+
app.add_middleware(SessionMiddleware)
|
|
25
|
+
|
|
26
|
+
Usage with Starlette::
|
|
27
|
+
|
|
28
|
+
from starlette.applications import Starlette
|
|
29
|
+
from sqlobjects.contrib.asgi import SessionMiddleware
|
|
30
|
+
|
|
31
|
+
app = Starlette()
|
|
32
|
+
app.add_middleware(SessionMiddleware)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
app: Any,
|
|
38
|
+
db_name: str | None = None,
|
|
39
|
+
readonly: bool = False,
|
|
40
|
+
) -> None:
|
|
41
|
+
self.app = app
|
|
42
|
+
self.db_name = db_name
|
|
43
|
+
self.readonly = readonly
|
|
44
|
+
|
|
45
|
+
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
|
46
|
+
if scope["type"] not in ("http", "websocket"):
|
|
47
|
+
await self.app(scope, receive, send)
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
name = self.db_name or get_default()
|
|
51
|
+
session = AsyncSession(name, readonly=self.readonly, auto_commit=False)
|
|
52
|
+
token = _SessionContextManager.set_session(session, name)
|
|
53
|
+
try:
|
|
54
|
+
await self.app(scope, receive, send)
|
|
55
|
+
if not self.readonly:
|
|
56
|
+
await session.commit()
|
|
57
|
+
except Exception:
|
|
58
|
+
if not self.readonly:
|
|
59
|
+
await session.rollback()
|
|
60
|
+
raise
|
|
61
|
+
finally:
|
|
62
|
+
await session.close()
|
|
63
|
+
_SessionContextManager.reset_session(token)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""FastAPI dependency for SQLObjects session management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncGenerator
|
|
6
|
+
|
|
7
|
+
from ..session import AsyncSession, ctx_session
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def get_db_session(db_name: str | None = None) -> AsyncGenerator[AsyncSession, None]:
|
|
11
|
+
"""FastAPI dependency that yields a transactional session.
|
|
12
|
+
|
|
13
|
+
Usage::
|
|
14
|
+
|
|
15
|
+
from fastapi import Depends
|
|
16
|
+
from sqlobjects.contrib.fastapi import get_db_session
|
|
17
|
+
from sqlobjects.session import AsyncSession
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.post("/users")
|
|
21
|
+
async def create_user(session: AsyncSession = Depends(get_db_session)):
|
|
22
|
+
user = await User.objects.using(session).create(name="Alice")
|
|
23
|
+
return {"id": user.id}
|
|
24
|
+
"""
|
|
25
|
+
async with ctx_session(db_name) as session:
|
|
26
|
+
yield session
|
|
@@ -8,6 +8,22 @@ from .core import Column, column
|
|
|
8
8
|
from .shortcuts import ComputedColumn, IdentityColumn
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
def _extract_table_or_class_name(reference: str) -> str | None:
|
|
12
|
+
"""从 'X.col' 或 'schema.X.col' 中提取 X(可能是表名或类名)。
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
X 部分,用于后续延迟匹配;格式不合法时返回 None
|
|
16
|
+
"""
|
|
17
|
+
parts = reference.split(".")
|
|
18
|
+
if len(parts) == 2:
|
|
19
|
+
# "X.col"
|
|
20
|
+
return parts[0]
|
|
21
|
+
if len(parts) == 3:
|
|
22
|
+
# "schema.X.col"
|
|
23
|
+
return parts[1]
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
11
27
|
def identity(
|
|
12
28
|
*,
|
|
13
29
|
start: int = 1,
|
|
@@ -76,7 +92,10 @@ def foreign_key(
|
|
|
76
92
|
"""Create foreign key column with database constraint behavior.
|
|
77
93
|
|
|
78
94
|
Args:
|
|
79
|
-
reference: Foreign key reference in format "
|
|
95
|
+
reference: Foreign key reference in format "X.column" where X can be either
|
|
96
|
+
a model class name (e.g. "User.id", "UserProfile.id") or a database table
|
|
97
|
+
name (e.g. "users.id"). Class names are automatically resolved to table
|
|
98
|
+
names via delayed matching. Schema is also supported: "schema.X.column".
|
|
80
99
|
type: Column type, "auto" for automatic type inference
|
|
81
100
|
nullable: Whether column can be null
|
|
82
101
|
ondelete: Database constraint behavior when referenced object is deleted
|
|
@@ -89,12 +108,15 @@ def foreign_key(
|
|
|
89
108
|
Column descriptor with foreign key constraint
|
|
90
109
|
|
|
91
110
|
Examples:
|
|
92
|
-
#
|
|
111
|
+
# Using class name (resolved automatically)
|
|
112
|
+
author_id: Column[int] = foreign_key("User.id")
|
|
113
|
+
|
|
114
|
+
# Using table name directly (also works)
|
|
93
115
|
author_id: Column[int] = foreign_key("users.id")
|
|
94
116
|
|
|
95
117
|
# Complete constraint configuration
|
|
96
118
|
author_id: Column[int] = foreign_key(
|
|
97
|
-
"
|
|
119
|
+
"User.id",
|
|
98
120
|
ondelete="CASCADE",
|
|
99
121
|
onupdate="CASCADE",
|
|
100
122
|
nullable=False
|
|
@@ -102,7 +124,7 @@ def foreign_key(
|
|
|
102
124
|
|
|
103
125
|
# Deferred constraint for circular references
|
|
104
126
|
parent_id: Column[int] = foreign_key(
|
|
105
|
-
"
|
|
127
|
+
"Category.id",
|
|
106
128
|
deferrable=True,
|
|
107
129
|
initially="DEFERRED"
|
|
108
130
|
)
|
|
@@ -122,9 +144,16 @@ def foreign_key(
|
|
|
122
144
|
fk_kwargs["deferrable"] = True
|
|
123
145
|
fk_kwargs["initially"] = initially
|
|
124
146
|
|
|
125
|
-
#
|
|
147
|
+
# Reference is passed directly to SQLAlchemy; delayed matching in
|
|
148
|
+
# ModelProcessor._resolve_class_foreign_keys will correct the table
|
|
149
|
+
# name if the first segment turns out to be a class name.
|
|
126
150
|
fk_constraint = ForeignKey(reference, **fk_kwargs)
|
|
127
151
|
|
|
152
|
+
# Store the table-or-class segment for delayed resolution
|
|
153
|
+
table_or_class = _extract_table_or_class_name(reference)
|
|
154
|
+
if table_or_class:
|
|
155
|
+
fk_constraint.info["_fk_ref"] = table_or_class
|
|
156
|
+
|
|
128
157
|
# Use existing column() function with foreign key
|
|
129
158
|
return column(
|
|
130
159
|
type=type,
|
|
@@ -368,6 +368,9 @@ class ModelProcessor(type):
|
|
|
368
368
|
# Auto-register model to ModelRegistry
|
|
369
369
|
registry.register_model(cast(type["ObjectModel"], cls))
|
|
370
370
|
|
|
371
|
+
# Resolve class name foreign key references to actual table names
|
|
372
|
+
mcs._resolve_class_foreign_keys(registry)
|
|
373
|
+
|
|
371
374
|
# Process pending M2M tables
|
|
372
375
|
registry.process_pending_m2m()
|
|
373
376
|
|
|
@@ -851,6 +854,40 @@ class ModelProcessor(type):
|
|
|
851
854
|
|
|
852
855
|
cls.__eq__ = __eq__
|
|
853
856
|
|
|
857
|
+
@classmethod
|
|
858
|
+
def _resolve_class_foreign_keys(mcs, registry):
|
|
859
|
+
"""延迟解析外键引用:优先按类名匹配,匹配不到则视为表名。
|
|
860
|
+
|
|
861
|
+
foreign_key("User.id") 传入时原样交给 SQLAlchemy(_colspec="User.id")。
|
|
862
|
+
每次新模型注册后,此方法遍历所有 FK:
|
|
863
|
+
1. 取出 info["_fk_ref"](即 "." 前的那段,如 "User")
|
|
864
|
+
2. 尝试在 registry 中按类名查找模型
|
|
865
|
+
3. 找到 → 替换 _colspec 为实际表名,标记 _resolved
|
|
866
|
+
4. 找不到 → 保留原样(视为已经是表名)
|
|
867
|
+
"""
|
|
868
|
+
for table in registry.tables.values():
|
|
869
|
+
for col in table.columns:
|
|
870
|
+
for fk in col.foreign_keys:
|
|
871
|
+
fk_info = fk.info
|
|
872
|
+
if fk_info.get("_fk_resolved"):
|
|
873
|
+
continue
|
|
874
|
+
ref_name = fk_info.get("_fk_ref")
|
|
875
|
+
if not ref_name:
|
|
876
|
+
continue
|
|
877
|
+
model = registry.get_model(ref_name)
|
|
878
|
+
if not model or not hasattr(model, "__table__"):
|
|
879
|
+
continue
|
|
880
|
+
# Found matching model — rewrite _colspec to actual table name
|
|
881
|
+
actual_table = model.__table__.name
|
|
882
|
+
schema, tname, colname = fk._column_tokens
|
|
883
|
+
if tname != actual_table:
|
|
884
|
+
if schema:
|
|
885
|
+
fk._colspec = f"{schema}.{actual_table}.{colname}"
|
|
886
|
+
else:
|
|
887
|
+
fk._colspec = f"{actual_table}.{colname}"
|
|
888
|
+
fk.__dict__.pop("_column_tokens", None)
|
|
889
|
+
fk_info["_fk_resolved"] = True
|
|
890
|
+
|
|
854
891
|
@classmethod
|
|
855
892
|
def _bind_column_attributes_to_table(mcs, cls: Any, table) -> None:
|
|
856
893
|
"""Bind ColumnAttribute instances to their corresponding table columns.
|
|
@@ -259,8 +259,14 @@ class _SessionContextManager:
|
|
|
259
259
|
return AsyncSession(name, readonly, auto_commit)
|
|
260
260
|
|
|
261
261
|
@classmethod
|
|
262
|
-
def set_session(
|
|
263
|
-
|
|
262
|
+
def set_session(
|
|
263
|
+
cls, session: AsyncSession, db_name: str | None = None
|
|
264
|
+
) -> contextvars.Token[dict[str, "AsyncSession"]]:
|
|
265
|
+
"""Set active session in current context.
|
|
266
|
+
|
|
267
|
+
Returns a Token that can be used with reset_session() to restore the
|
|
268
|
+
previous ContextVar state, enabling correct nested ctx_session() behavior.
|
|
269
|
+
"""
|
|
264
270
|
name = db_name or get_default()
|
|
265
271
|
try:
|
|
266
272
|
current_sessions = _explicit_sessions.get({})
|
|
@@ -268,7 +274,12 @@ class _SessionContextManager:
|
|
|
268
274
|
current_sessions = {}
|
|
269
275
|
new_sessions = current_sessions.copy()
|
|
270
276
|
new_sessions[name] = session
|
|
271
|
-
_explicit_sessions.set(new_sessions)
|
|
277
|
+
return _explicit_sessions.set(new_sessions)
|
|
278
|
+
|
|
279
|
+
@classmethod
|
|
280
|
+
def reset_session(cls, token: contextvars.Token[dict[str, "AsyncSession"]]) -> None:
|
|
281
|
+
"""Restore ContextVar to a previous state using a token from set_session()."""
|
|
282
|
+
_explicit_sessions.reset(token)
|
|
272
283
|
|
|
273
284
|
@classmethod
|
|
274
285
|
def clear_session(cls, db_name: str | None = None) -> None:
|
|
@@ -302,8 +313,8 @@ async def ctx_session(db_name: str | None = None) -> AsyncGenerator[AsyncSession
|
|
|
302
313
|
name = db_name or get_default()
|
|
303
314
|
session = AsyncSession(name, readonly=False, auto_commit=False)
|
|
304
315
|
|
|
305
|
-
# Set as explicit session in context
|
|
306
|
-
_SessionContextManager.set_session(session, name)
|
|
316
|
+
# Set as explicit session in context, save token for nested restore
|
|
317
|
+
token = _SessionContextManager.set_session(session, name)
|
|
307
318
|
|
|
308
319
|
try:
|
|
309
320
|
yield session
|
|
@@ -316,7 +327,7 @@ async def ctx_session(db_name: str | None = None) -> AsyncGenerator[AsyncSession
|
|
|
316
327
|
finally:
|
|
317
328
|
# Cleanup
|
|
318
329
|
await session.close()
|
|
319
|
-
_SessionContextManager.
|
|
330
|
+
_SessionContextManager.reset_session(token)
|
|
320
331
|
|
|
321
332
|
|
|
322
333
|
@asynccontextmanager
|
|
@@ -336,13 +347,14 @@ async def ctx_sessions(*db_names: str) -> AsyncGenerator[dict[str, AsyncSession]
|
|
|
336
347
|
raise ValueError("At least one database name must be provided")
|
|
337
348
|
|
|
338
349
|
sessions: dict[str, AsyncSession] = {}
|
|
350
|
+
tokens: list[contextvars.Token[dict[str, AsyncSession]]] = []
|
|
339
351
|
|
|
340
352
|
try:
|
|
341
353
|
# Create sessions for all
|
|
342
354
|
for db_name in db_names:
|
|
343
355
|
session = AsyncSession(db_name, readonly=False, auto_commit=False)
|
|
344
356
|
sessions[db_name] = session
|
|
345
|
-
_SessionContextManager.set_session(session, db_name)
|
|
357
|
+
tokens.append(_SessionContextManager.set_session(session, db_name))
|
|
346
358
|
|
|
347
359
|
yield sessions
|
|
348
360
|
|
|
@@ -358,9 +370,11 @@ async def ctx_sessions(*db_names: str) -> AsyncGenerator[dict[str, AsyncSession]
|
|
|
358
370
|
|
|
359
371
|
finally:
|
|
360
372
|
# Cleanup all sessions
|
|
361
|
-
for
|
|
373
|
+
for session in sessions.values():
|
|
362
374
|
await session.close()
|
|
363
|
-
|
|
375
|
+
# Reset tokens in reverse order
|
|
376
|
+
for token in reversed(tokens):
|
|
377
|
+
_SessionContextManager.reset_session(token)
|
|
364
378
|
|
|
365
379
|
|
|
366
380
|
def get_session(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlobjects
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.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>
|
|
@@ -27,6 +27,9 @@ sqlobjects.egg-info/dependency_links.txt
|
|
|
27
27
|
sqlobjects.egg-info/entry_points.txt
|
|
28
28
|
sqlobjects.egg-info/requires.txt
|
|
29
29
|
sqlobjects.egg-info/top_level.txt
|
|
30
|
+
sqlobjects/contrib/__init__.py
|
|
31
|
+
sqlobjects/contrib/asgi.py
|
|
32
|
+
sqlobjects/contrib/fastapi.py
|
|
30
33
|
sqlobjects/database/__init__.py
|
|
31
34
|
sqlobjects/database/config.py
|
|
32
35
|
sqlobjects/database/manager.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|