Skip to content

Commit 8e9fe31

Browse files
committed
feat: enhance deferred_column_property with additional parameters and typing tests for compatibility
1 parent 08e57d9 commit 8e9fe31

File tree

3 files changed

+208
-9
lines changed

3 files changed

+208
-9
lines changed

sqlmodel/deferred_column.py

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,34 @@
55
with safe deferred loading - returning fallback values instead of raising DetachedInstanceError.
66
"""
77

8-
from typing import Any
8+
from typing import TYPE_CHECKING, Any, Optional, Type, TypeVar
99

1010
from sqlalchemy import event
11-
from sqlalchemy.orm import ColumnProperty, column_property
11+
from sqlalchemy.orm import column_property
1212
from sqlalchemy.orm.attributes import set_committed_value
1313

14+
if TYPE_CHECKING:
15+
from sqlalchemy.orm._typing import _ORMColumnExprArgument
16+
from sqlalchemy.orm.interfaces import PropComparator
17+
from sqlalchemy.orm.properties import MappedSQLExpression
18+
from sqlalchemy.sql._typing import _InfoType
19+
20+
_T = TypeVar("_T")
21+
1422

1523
def deferred_column_property(
16-
expression: Any, *, fallback_value: Any, deferred: bool = True, **kwargs: Any
17-
) -> ColumnProperty:
24+
expression: "_ORMColumnExprArgument[_T]",
25+
*additional_expressions: "_ORMColumnExprArgument[Any]",
26+
fallback_value: Any,
27+
group: Optional[str] = None,
28+
deferred: bool = True,
29+
raiseload: bool = False,
30+
comparator_factory: Optional[Type["PropComparator[_T]"]] = None,
31+
active_history: bool = False,
32+
expire_on_flush: bool = True,
33+
info: Optional["_InfoType"] = None,
34+
doc: Optional[str] = None,
35+
) -> "MappedSQLExpression[_T]":
1836
"""
1937
Create a deferred column property that returns a fallback value instead of raising
2038
DetachedInstanceError when accessed on a detached instance.
@@ -24,12 +42,19 @@ def deferred_column_property(
2442
2543
Args:
2644
expression: The SQL expression for the column property
45+
*additional_expressions: Additional SQL expressions for the column property
2746
fallback_value: Value to return when property cannot be loaded (required)
47+
group: A group name for this property when marked as deferred
2848
deferred: Whether to defer loading the property (defaults to True)
29-
**kwargs: Additional arguments passed to column_property
49+
raiseload: When True, loading this property will raise an error
50+
comparator_factory: A class which extends PropComparator for custom SQL clause generation
51+
active_history: When True, indicates that the "previous" value should be loaded when replaced
52+
expire_on_flush: Whether this property expires on session flush
53+
info: Optional data dictionary which will be populated into the MapperProperty.info attribute
54+
doc: Optional string that will be applied as the doc on the class-bound descriptor
3055
3156
Returns:
32-
A standard ColumnProperty instance with event listeners for fallback handling
57+
A MappedSQLExpression instance with event listeners for fallback handling
3358
3459
Example:
3560
```python
@@ -46,10 +71,20 @@ def __declare_last__(cls):
4671
)
4772
```
4873
"""
49-
kwargs["deferred"] = deferred
5074

51-
# Create a standard ColumnProperty
52-
prop = column_property(expression, **kwargs)
75+
# Create a standard ColumnProperty with all the parameters
76+
prop = column_property(
77+
expression,
78+
*additional_expressions,
79+
group=group,
80+
deferred=deferred,
81+
raiseload=raiseload,
82+
comparator_factory=comparator_factory,
83+
active_history=active_history,
84+
expire_on_flush=expire_on_flush,
85+
info=info,
86+
doc=doc,
87+
)
5388

5489
# Store fallback_value as attribute for later use in event setup
5590
prop._deferred_fallback_value = fallback_value
@@ -78,6 +113,7 @@ def _setup_fallback_listeners(mapper, key: str, fallback_value: Any):
78113
@event.listens_for(class_type, "load")
79114
def _set_deferred_fallback_on_load(target, context):
80115
"""Set fallback value when object is loaded from database"""
116+
# Only set fallback if key is not in __dict__ (not loaded)
81117
if key not in target.__dict__:
82118
set_committed_value(target, key, fallback_value)
83119

tests/test_typing_deferred.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""
2+
Test typing compatibility of deferred_column_property.
3+
4+
This test verifies that deferred_column_property has the same typing behavior
5+
as the original column_property from SQLAlchemy.
6+
"""
7+
8+
from typing import TYPE_CHECKING, Optional
9+
10+
from sqlalchemy.orm import configure_mappers
11+
from sqlalchemy.orm import Query, sessionmaker, undefer
12+
from sqlmodel import Field, SQLModel, create_engine
13+
14+
from sqlmodel.deferred_column import deferred_column_property
15+
16+
if TYPE_CHECKING:
17+
from sqlalchemy.orm.attributes import InstrumentedAttribute
18+
19+
20+
class User(SQLModel, table=True):
21+
id: Optional[int] = Field(default=None, primary_key=True)
22+
name: str = Field(default="default")
23+
age: Optional[int] = None
24+
25+
@classmethod
26+
def __declare_last__(cls):
27+
# Test that deferred_column_property returns proper type
28+
cls.computed_age: "InstrumentedAttribute[int]" = deferred_column_property(
29+
cls.__table__.c.age * 2, fallback_value=0
30+
)
31+
32+
33+
def test_typing_compatibility():
34+
"""Test that typing works correctly with deferred_column_property"""
35+
# Force SQLAlchemy to configure mappers
36+
configure_mappers()
37+
38+
# Test that we can use undefer() with deferred_column_property
39+
engine = create_engine("sqlite:///:memory:")
40+
SQLModel.metadata.create_all(engine)
41+
42+
SessionLocal = sessionmaker(bind=engine)
43+
session = SessionLocal()
44+
45+
# Create test user
46+
user = User(name="Test", age=25)
47+
session.add(user)
48+
session.commit()
49+
user_id = user.id
50+
51+
# Test loading with undefer (should work without typing errors)
52+
query: Query[User] = session.query(User).filter(User.id == user_id)
53+
54+
loaded_user = query.first()
55+
assert loaded_user is not None
56+
print(f"Without undefer - loaded_user.computed_age = {loaded_user.computed_age}")
57+
print(f"Without undefer - loaded_user.age = {loaded_user.age}")
58+
59+
# Now try with undefer
60+
query_with_undefer: Query[User] = (
61+
session.query(User)
62+
.options(
63+
undefer(User.computed_age) # This should not cause typing errors
64+
)
65+
.filter(User.id == user_id)
66+
)
67+
68+
loaded_user_undefer = query_with_undefer.first()
69+
assert loaded_user_undefer is not None
70+
print(
71+
f"With undefer - loaded_user_undefer.computed_age = {loaded_user_undefer.computed_age}"
72+
)
73+
print(f"With undefer - loaded_user_undefer.age = {loaded_user_undefer.age}")
74+
75+
# Test fallback behavior when detached
76+
session.expunge(loaded_user) # Detach from session
77+
assert loaded_user.computed_age == 0 # Should return fallback value
78+
79+
session.close()
80+
81+
print("✅ All typing and functionality tests passed!")
82+
83+
84+
if __name__ == "__main__":
85+
test_typing_compatibility()

tests/test_typing_final.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""
2+
Final typing test for deferred_column_property.
3+
4+
This test verifies that deferred_column_property has the same typing behavior
5+
as the original column_property from SQLAlchemy.
6+
"""
7+
8+
from typing import Optional
9+
10+
from sqlalchemy import create_engine
11+
from sqlalchemy.orm import column_property, undefer
12+
from sqlmodel import Field, SQLModel, Session, select
13+
14+
from sqlmodel.deferred_column import deferred_column_property
15+
16+
17+
class TestTyping(SQLModel, table=True):
18+
id: Optional[int] = Field(default=None, primary_key=True)
19+
salary: int = 50000
20+
21+
@classmethod
22+
def __declare_last__(cls):
23+
# Test both standard column_property and our deferred_column_property
24+
cls.standard_prop = column_property(cls.__table__.c.salary > 40000)
25+
cls.deferred_prop = deferred_column_property(
26+
cls.__table__.c.salary > 60000, fallback_value=False
27+
)
28+
29+
30+
def test_typing_equivalence():
31+
"""Test that both properties have equivalent typing"""
32+
33+
# Test that both can be used with undefer() - this verifies typing compatibility
34+
engine = create_engine("sqlite:///:memory:")
35+
SQLModel.metadata.create_all(engine)
36+
37+
with Session(engine) as session:
38+
user = TestTyping(salary=70000)
39+
session.add(user)
40+
session.commit()
41+
user_id = user.id
42+
43+
# Test that both properties can be used with undefer (no typing errors)
44+
with Session(engine) as session:
45+
stmt = (
46+
select(TestTyping)
47+
.options(
48+
undefer(TestTyping.standard_prop), # Should work without typing errors
49+
undefer(TestTyping.deferred_prop), # Should work without typing errors
50+
)
51+
.where(TestTyping.id == user_id)
52+
)
53+
54+
loaded_user = session.exec(stmt).one()
55+
56+
print(f"User salary: {loaded_user.salary}")
57+
print(f"Standard prop (salary > 40000): {loaded_user.standard_prop}")
58+
print(f"Deferred prop (salary > 60000): {loaded_user.deferred_prop}")
59+
60+
# Both should return True for salary=70000
61+
assert loaded_user.standard_prop is True # 70000 > 40000
62+
assert loaded_user.deferred_prop is True # 70000 > 60000
63+
64+
# Test typing compatibility - both should have similar type annotations
65+
print(f"Standard prop type: {type(TestTyping.standard_prop)}")
66+
print(f"Deferred prop type: {type(TestTyping.deferred_prop)}")
67+
68+
# Both should have descriptor protocol
69+
assert hasattr(TestTyping.standard_prop, "__get__")
70+
assert hasattr(TestTyping.deferred_prop, "__get__")
71+
72+
print("✅ Both properties work correctly with undefer()")
73+
print("✅ Both have descriptor protocol")
74+
print("✅ Typing compatibility confirmed!")
75+
76+
77+
if __name__ == "__main__":
78+
test_typing_equivalence()

0 commit comments

Comments
 (0)