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.
Files changed (80) hide show
  1. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/CHANGELOG.md +12 -0
  2. {sqlobjects-1.2.5/sqlobjects.egg-info → sqlobjects-1.4.0}/PKG-INFO +1 -1
  3. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/docs/rules/01-database-session-guide.md +60 -6
  4. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/docs/rules/03-query-operations-guide.md +41 -0
  5. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/docs/rules/05-relationships-guide.md +3 -1
  6. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/pyproject.toml +1 -1
  7. sqlobjects-1.4.0/sqlobjects/contrib/__init__.py +0 -0
  8. sqlobjects-1.4.0/sqlobjects/contrib/asgi.py +63 -0
  9. sqlobjects-1.4.0/sqlobjects/contrib/fastapi.py +26 -0
  10. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/functions.py +34 -5
  11. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/metadata.py +37 -0
  12. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/session.py +23 -9
  13. {sqlobjects-1.2.5 → sqlobjects-1.4.0/sqlobjects.egg-info}/PKG-INFO +1 -1
  14. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects.egg-info/SOURCES.txt +3 -0
  15. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/LICENSE +0 -0
  16. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/README.md +0 -0
  17. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/docs/rules/02-model-definition-guide.md +0 -0
  18. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/docs/rules/04-crud-operations-guide.md +0 -0
  19. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/docs/rules/06-validation-signals-guide.md +0 -0
  20. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/docs/rules/07-performance-guide.md +0 -0
  21. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/docs/rules/README.md +0 -0
  22. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/setup.cfg +0 -0
  23. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/__init__.py +0 -0
  24. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/_install_rules.py +0 -0
  25. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/cascade.py +0 -0
  26. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/database/__init__.py +0 -0
  27. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/database/config.py +0 -0
  28. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/database/manager.py +0 -0
  29. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/exceptions.py +0 -0
  30. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/expressions/__init__.py +0 -0
  31. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/expressions/aggregate.py +0 -0
  32. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/expressions/base.py +0 -0
  33. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/expressions/cte.py +0 -0
  34. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/expressions/explain.py +0 -0
  35. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/expressions/function.py +0 -0
  36. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/expressions/mixins.py +0 -0
  37. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/expressions/scalar.py +0 -0
  38. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/expressions/subquery.py +0 -0
  39. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/expressions/terminal.py +0 -0
  40. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/expressions/window.py +0 -0
  41. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/__init__.py +0 -0
  42. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/core.py +0 -0
  43. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/proxies.py +0 -0
  44. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/relations/__init__.py +0 -0
  45. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/relations/descriptors.py +0 -0
  46. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/relations/managers.py +0 -0
  47. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/relations/prefetch.py +0 -0
  48. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/relations/strategies.py +0 -0
  49. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/relations/utils.py +0 -0
  50. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/shortcuts.py +0 -0
  51. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/types/__init__.py +0 -0
  52. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/types/base.py +0 -0
  53. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/types/comparators.py +0 -0
  54. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/types/registry.py +0 -0
  55. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/fields/utils.py +0 -0
  56. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/internal/__init__.py +0 -0
  57. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/internal/operations.py +0 -0
  58. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/internal/results.py +0 -0
  59. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/mixins.py +0 -0
  60. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/model.py +0 -0
  61. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/objects/__init__.py +0 -0
  62. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/objects/bulk.py +0 -0
  63. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/objects/core.py +0 -0
  64. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/objects/upsert.py +0 -0
  65. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/queries/__init__.py +0 -0
  66. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/queries/builder.py +0 -0
  67. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/queries/dialect.py +0 -0
  68. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/queries/executor.py +0 -0
  69. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/queryset.py +0 -0
  70. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/signals.py +0 -0
  71. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/utils/__init__.py +0 -0
  72. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/utils/inspect.py +0 -0
  73. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/utils/naming.py +0 -0
  74. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/utils/pattern.py +0 -0
  75. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects/validators.py +0 -0
  76. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects.egg-info/dependency_links.txt +0 -0
  77. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects.egg-info/entry_points.txt +0 -0
  78. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects.egg-info/requires.txt +0 -0
  79. {sqlobjects-1.2.5 → sqlobjects-1.4.0}/sqlobjects.egg-info/top_level.txt +0 -0
  80. {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.2.5
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
- # Bad: Mixing sessions
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
- # Different session - may cause issues
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
- author_id: Column[int] = foreign_key("users.id")
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):
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlobjects"
3
- version = "1.2.5"
3
+ version = "1.4.0"
4
4
  description = "Django-style async ORM library based on SQLAlchemy with chainable queries, Q objects, and relationship loading"
5
5
  readme = "README.md"
6
6
  license = "MIT"
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 "table.column"
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
- # Basic usage with auto type inference
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
- "users.id",
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
- "categories.id",
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
- # Create ForeignKey constraint
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(cls, session: AsyncSession, db_name: str | None = None) -> None:
263
- """Set active session in current context."""
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.clear_session(name)
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 db_name, session in sessions.items():
373
+ for session in sessions.values():
362
374
  await session.close()
363
- _SessionContextManager.clear_session(db_name)
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.2.5
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