Skip to content

Commit e4a89e8

Browse files
committed
feat: add deferred_column_property for safe deferred loading in SQLModel
1 parent 0566470 commit e4a89e8

File tree

4 files changed

+217
-4
lines changed

4 files changed

+217
-4
lines changed

sqlmodel/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@
114114
from sqlalchemy.types import Uuid as Uuid
115115

116116
# From SQLModel, modifications of SQLAlchemy or equivalents of Pydantic
117+
from .deferred_column import deferred_column_property as deferred_column_property
117118
from .main import Field as Field
118119
from .main import Relationship as Relationship
119120
from .main import SQLModel as SQLModel

sqlmodel/deferred_column.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""
2+
Deferred Column Property implementation for SQLModel.
3+
4+
This module provides deferred_column_property function that creates column properties
5+
with safe deferred loading - returning fallback values instead of raising DetachedInstanceError.
6+
"""
7+
8+
from typing import Any
9+
10+
from sqlalchemy.orm import ColumnProperty
11+
from sqlalchemy.orm.strategies import DeferredColumnLoader, _state_session
12+
13+
14+
class SafeDeferredColumnLoader(DeferredColumnLoader):
15+
"""
16+
A custom deferred column loader that returns a fallback value instead of
17+
raising DetachedInstanceError when the session is detached.
18+
"""
19+
20+
def __init__(self, parent, strategy_key, fallback_value=None):
21+
super().__init__(parent, strategy_key)
22+
self.fallback_value = fallback_value
23+
24+
def _load_for_state(self, state, passive):
25+
"""
26+
Override the default behavior to return fallback value instead of raising
27+
DetachedInstanceError when session is None.
28+
"""
29+
from sqlalchemy.orm import LoaderCallableStatus
30+
31+
if not state.key:
32+
return LoaderCallableStatus.ATTR_EMPTY
33+
34+
# Check passive flags
35+
from sqlalchemy.orm import PassiveFlag
36+
37+
if not passive & PassiveFlag.SQL_OK:
38+
return LoaderCallableStatus.PASSIVE_NO_RESULT
39+
40+
# Check if we have a session before attempting to load
41+
session = _state_session(state)
42+
if session is None:
43+
# No session available, return fallback value directly
44+
return self.fallback_value
45+
46+
# We have a session, use the parent implementation
47+
return super()._load_for_state(state, passive)
48+
49+
50+
class SafeColumnProperty(ColumnProperty):
51+
"""
52+
Custom ColumnProperty that uses SafeDeferredColumnLoader for deferred loading.
53+
"""
54+
55+
def __init__(self, *args, fallback_value=None, **kwargs):
56+
self.fallback_value = fallback_value
57+
super().__init__(*args, **kwargs)
58+
59+
def do_init(self):
60+
"""Override to set our custom strategy after parent initialization."""
61+
super().do_init()
62+
63+
# Replace the strategy with our safe version if it's deferred
64+
if self.deferred and hasattr(self, "strategy"):
65+
# Create our safe loader with the same parameters as the original
66+
original_strategy = self.strategy
67+
68+
# Create new strategy with proper initialization
69+
safe_strategy = SafeDeferredColumnLoader(
70+
parent=self, # The ColumnProperty itself
71+
strategy_key=original_strategy.strategy_key,
72+
fallback_value=self.fallback_value,
73+
)
74+
75+
self.strategy = safe_strategy
76+
77+
78+
def deferred_column_property(
79+
expression: Any, *, fallback_value: Any, deferred: bool = True, **kwargs: Any
80+
) -> SafeColumnProperty:
81+
"""
82+
Create a deferred column property that returns a fallback value instead of raising
83+
DetachedInstanceError when accessed on a detached instance.
84+
85+
This function behaves exactly like SQLAlchemy's column_property, but with safe
86+
deferred loading behavior when the session is detached.
87+
88+
Args:
89+
expression: The SQL expression for the column property
90+
fallback_value: Value to return when property cannot be loaded (required)
91+
deferred: Whether to defer loading the property (defaults to True)
92+
**kwargs: Additional arguments passed to column_property
93+
94+
Returns:
95+
A SafeColumnProperty instance
96+
97+
Example:
98+
```python
99+
class User(SQLModel, table=True):
100+
id: Optional[int] = Field(default=None, primary_key=True)
101+
user_id: Optional[int] = None
102+
103+
@classmethod
104+
def __declare_last__(cls):
105+
cls.computed_value = deferred_column_property(
106+
cls.__table__.c.user_id * 2,
107+
fallback_value=0,
108+
deferred=True
109+
)
110+
```
111+
"""
112+
kwargs["deferred"] = deferred
113+
return SafeColumnProperty(expression, fallback_value=fallback_value, **kwargs)

sqlmodel/main.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,17 +149,15 @@ def __init__(self, default: Any = Undefined, **kwargs: Any) -> None:
149149
)
150150
if primary_key is not Undefined:
151151
raise RuntimeError(
152-
"Passing primary_key is not supported when "
153-
"also passing a sa_column"
152+
"Passing primary_key is not supported when also passing a sa_column"
154153
)
155154
if nullable is not Undefined:
156155
raise RuntimeError(
157156
"Passing nullable is not supported when also passing a sa_column"
158157
)
159158
if foreign_key is not Undefined:
160159
raise RuntimeError(
161-
"Passing foreign_key is not supported when "
162-
"also passing a sa_column"
160+
"Passing foreign_key is not supported when also passing a sa_column"
163161
)
164162
if ondelete is not Undefined:
165163
raise RuntimeError(
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
from typing import Optional
2+
3+
from sqlalchemy import create_engine
4+
from sqlalchemy.orm import undefer
5+
from sqlmodel import Field, Session, SQLModel, deferred_column_property, select
6+
7+
8+
def test_deferred_column_property(clear_sqlmodel):
9+
"""Test deferred_column_property that returns fallback value instead of raising DetachedInstanceError"""
10+
11+
class DeferredModel(SQLModel, table=True):
12+
__tablename__ = "deferred_model"
13+
14+
id: Optional[int] = Field(default=None, primary_key=True)
15+
name: str
16+
value: int = 0
17+
18+
@classmethod
19+
def __declare_last__(cls):
20+
# Use deferred_column_property instead of regular column_property
21+
cls.computed_value = deferred_column_property(
22+
cls.__table__.c.value * 2,
23+
fallback_value=-1,
24+
deferred=True,
25+
)
26+
27+
engine = create_engine("sqlite://")
28+
SQLModel.metadata.create_all(engine)
29+
30+
test_record = DeferredModel(name="Test", value=5)
31+
32+
with Session(engine) as session:
33+
session.add(test_record)
34+
session.commit()
35+
session.refresh(test_record)
36+
record_id = test_record.id
37+
38+
# Query without loading deferred property
39+
with Session(engine) as session:
40+
record = session.get(DeferredModel, record_id)
41+
assert record is not None
42+
assert record.name == "Test"
43+
assert record.value == 5
44+
45+
# Close session to ensure deferred property cannot be loaded
46+
session.close()
47+
48+
# Access deferred property - should return fallback value instead of raising error
49+
computed = record.computed_value
50+
assert computed == -1 # This is the key difference from regular column_property
51+
52+
# Test with explicit loading using undefer
53+
with Session(engine) as session:
54+
# Use undefer with class attribute instead of string
55+
statement = (
56+
select(DeferredModel)
57+
.options(undefer(DeferredModel.computed_value))
58+
.where(DeferredModel.id == record_id)
59+
)
60+
record = session.exec(statement).first()
61+
assert record is not None
62+
63+
# Now the deferred property should be loaded and computed
64+
computed = record.computed_value
65+
assert computed == 10 # 5 * 2 = 10
66+
67+
# Test different fallback value
68+
class CustomFallbackModel(SQLModel, table=True):
69+
__tablename__ = "custom_fallback_model"
70+
71+
id: Optional[int] = Field(default=None, primary_key=True)
72+
name: str
73+
value: int = 0
74+
75+
@classmethod
76+
def __declare_last__(cls):
77+
cls.computed_value = deferred_column_property(
78+
cls.__table__.c.value * 3,
79+
fallback_value=999, # Custom fallback value
80+
deferred=True,
81+
)
82+
83+
SQLModel.metadata.create_all(engine)
84+
85+
custom_record = CustomFallbackModel(name="Custom", value=7)
86+
87+
with Session(engine) as session:
88+
session.add(custom_record)
89+
session.commit()
90+
session.refresh(custom_record)
91+
custom_record_id = custom_record.id
92+
93+
# Test custom fallback value
94+
with Session(engine) as session:
95+
record = session.get(CustomFallbackModel, custom_record_id)
96+
assert record is not None
97+
session.close()
98+
99+
# Should return custom fallback value
100+
computed = record.computed_value
101+
assert computed == 999

0 commit comments

Comments
 (0)