Skip to content

Commit 5d255c5

Browse files
committed
feat: enhance async handling in SafeDeferredColumnLoader and add tests for fallback behavior
1 parent e4a89e8 commit 5d255c5

File tree

2 files changed

+102
-3
lines changed

2 files changed

+102
-3
lines changed

sqlmodel/deferred_column.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@ def __init__(self, parent, strategy_key, fallback_value=None):
2424
def _load_for_state(self, state, passive):
2525
"""
2626
Override the default behavior to return fallback value instead of raising
27-
DetachedInstanceError when session is None.
27+
DetachedInstanceError or MissingGreenlet when session is None or async context is missing.
2828
"""
2929
from sqlalchemy.orm import LoaderCallableStatus
30+
from sqlalchemy.orm.attributes import set_committed_value
3031

3132
if not state.key:
3233
return LoaderCallableStatus.ATTR_EMPTY
@@ -37,13 +38,54 @@ def _load_for_state(self, state, passive):
3738
if not passive & PassiveFlag.SQL_OK:
3839
return LoaderCallableStatus.PASSIVE_NO_RESULT
3940

41+
# Check if the attribute is already loaded
42+
if self.key not in state.unloaded:
43+
# Attribute is already loaded, use parent implementation
44+
return super()._load_for_state(state, passive)
45+
4046
# Check if we have a session before attempting to load
4147
session = _state_session(state)
4248
if session is None:
43-
# No session available, return fallback value directly
49+
# No session available, set fallback value directly on the instance
50+
instance = state.obj()
51+
if instance is not None:
52+
set_committed_value(instance, self.key, self.fallback_value)
53+
return LoaderCallableStatus.ATTR_WAS_SET
4454
return self.fallback_value
4555

46-
# We have a session, use the parent implementation
56+
# Check if this is an async session context that might cause MissingGreenlet
57+
try:
58+
# Try to access session._connection_for_bind to check if we're in async context
59+
# without proper greenlet
60+
if hasattr(session, "get_bind") and hasattr(
61+
session, "_connection_for_bind"
62+
):
63+
# This is a more elegant way to detect async context issues
64+
# If we're in async session without greenlet context, this will fail
65+
session.get_bind()
66+
except Exception as e:
67+
# Handle async-related errors (MissingGreenlet, etc.)
68+
error_msg = str(e).lower()
69+
if any(
70+
keyword in error_msg
71+
for keyword in [
72+
"greenlet",
73+
"await_only",
74+
"asyncio",
75+
"async",
76+
"missinggreenlet",
77+
]
78+
):
79+
# This is an async-related error, set fallback value directly
80+
instance = state.obj()
81+
if instance is not None:
82+
set_committed_value(instance, self.key, self.fallback_value)
83+
return LoaderCallableStatus.ATTR_WAS_SET
84+
return self.fallback_value
85+
# For other exceptions, re-raise them
86+
raise
87+
88+
# We have a proper session, use the parent implementation
4789
return super()._load_for_state(state, passive)
4890

4991

tests/test_async_fallback.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""
2+
Test async behavior of deferred_column_property
3+
"""
4+
5+
from typing import Optional
6+
7+
from sqlalchemy import create_engine
8+
from sqlmodel import Field, SQLModel, deferred_column_property
9+
10+
11+
def test_async_fallback_value():
12+
"""Test that deferred_column_property returns fallback value in async context without greenlet"""
13+
14+
class AsyncTestModel(SQLModel, table=True):
15+
__tablename__ = "async_test_model"
16+
17+
id: Optional[int] = Field(default=None, primary_key=True)
18+
name: str
19+
value: int = 0
20+
21+
@classmethod
22+
def __declare_last__(cls):
23+
cls.computed_value = deferred_column_property(
24+
cls.__table__.c.value * 2,
25+
fallback_value=-999,
26+
deferred=True,
27+
)
28+
29+
# Create regular engine first to set up data
30+
sync_engine = create_engine("sqlite:///:memory:")
31+
SQLModel.metadata.create_all(sync_engine)
32+
33+
from sqlmodel import Session
34+
35+
with Session(sync_engine) as session:
36+
test_record = AsyncTestModel(name="AsyncTest", value=5)
37+
session.add(test_record)
38+
session.commit()
39+
session.refresh(test_record)
40+
record_id = test_record.id
41+
42+
# Now test with async session context (simulating MissingGreenlet scenario)
43+
with Session(sync_engine) as session:
44+
record = session.get(AsyncTestModel, record_id)
45+
assert record is not None
46+
47+
# Close session to simulate detached state that might cause async issues
48+
session.close()
49+
50+
# This should return fallback value without raising MissingGreenlet
51+
computed = record.computed_value
52+
assert computed == -999
53+
54+
55+
if __name__ == "__main__":
56+
test_async_fallback_value()
57+
print("✅ Async fallback test passed!")

0 commit comments

Comments
 (0)