Skip to content

Commit ddd0474

Browse files
committed
feat: add support for hybrid properties with setters in SQLModel and enhance association proxy handling
1 parent c6f39f0 commit ddd0474

File tree

5 files changed

+546
-0
lines changed

5 files changed

+546
-0
lines changed

sqlmodel/_compat.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,16 @@ def sqlmodel_validate(
359359
value = getattr(use_obj, key, Undefined)
360360
if value is not Undefined:
361361
setattr(new_obj, key, value)
362+
# Get and set any hybrid property values with setters
363+
for key, value in cls.__dict__.items():
364+
if hasattr(value, '__set__') and hasattr(value, 'fget'):
365+
# This is likely a hybrid property with a setter
366+
if isinstance(use_obj, dict):
367+
hybrid_value = use_obj.get(key, Undefined)
368+
else:
369+
hybrid_value = getattr(use_obj, key, Undefined)
370+
if hybrid_value is not Undefined:
371+
setattr(new_obj, key, hybrid_value)
362372
return new_obj
363373

364374
def sqlmodel_init(*, self: "SQLModel", data: Dict[str, Any]) -> None:
@@ -579,6 +589,9 @@ def sqlmodel_validate(
579589
and key in m.__sqlalchemy_association_proxies__
580590
):
581591
setattr(m, key, obj[key])
592+
# Check for hybrid properties with setters
593+
elif key in cls.__dict__ and hasattr(cls.__dict__[key], '__set__') and hasattr(cls.__dict__[key], 'fget'):
594+
setattr(m, key, obj[key])
582595
m._init_private_attributes() # type: ignore[attr-defined] # noqa
583596
return m
584597

@@ -603,3 +616,6 @@ def sqlmodel_init(*, self: "SQLModel", data: Dict[str, Any]) -> None:
603616
setattr(self, key, data[key])
604617
elif key in self.__sqlalchemy_association_proxies__:
605618
setattr(self, key, data[key])
619+
# Check for hybrid properties with setters
620+
elif key in self.__class__.__dict__ and hasattr(self.__class__.__dict__[key], '__set__') and hasattr(self.__class__.__dict__[key], 'fget'):
621+
setattr(self, key, data[key])

sqlmodel/main.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -938,6 +938,21 @@ def __setattr__(self, name: str, value: Any) -> None:
938938
):
939939
association_proxy = self.__sqlalchemy_association_proxies__[name]
940940
association_proxy.__set__(self, value)
941+
# Set in SQLAlchemy hybrid properties with setters
942+
if (
943+
is_table_model_class(self.__class__)
944+
and name in self.__class__.__dict__
945+
):
946+
class_attr = self.__class__.__dict__[name]
947+
# Check if this is a hybrid property with a setter
948+
if hasattr(class_attr, '__set__'):
949+
try:
950+
# Try to use the hybrid property setter
951+
class_attr.__set__(self, value)
952+
return
953+
except AttributeError:
954+
# No setter available, continue with normal flow
955+
pass
941956
# Set in Pydantic model to trigger possible validation changes, only for
942957
# non relationship values
943958
if (

test_hybrid_property.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
"""
2+
Тест для проверки работы hybrid_property с setter'ами в SQLModel
3+
"""
4+
5+
from typing import List, Optional
6+
from sqlalchemy.ext.hybrid import hybrid_property
7+
from sqlalchemy.orm import relationship
8+
from sqlmodel import SQLModel, Field
9+
10+
11+
class Permission(SQLModel, table=True):
12+
__tablename__ = "permission"
13+
14+
id: Optional[int] = Field(default=None, primary_key=True)
15+
name: str
16+
17+
18+
class Role(SQLModel, table=True):
19+
__tablename__ = "role"
20+
21+
id: Optional[int] = Field(default=None, primary_key=True)
22+
name: str
23+
24+
# Relationship to permissions
25+
permissions: List[Permission] = relationship()
26+
27+
28+
class User(SQLModel, table=True):
29+
__tablename__ = "user"
30+
31+
id: Optional[int] = Field(default=None, primary_key=True)
32+
name: str
33+
role_id: Optional[int] = Field(default=None, foreign_key="role.id")
34+
35+
# Relationships
36+
role: Optional[Role] = relationship()
37+
permissions_grant: List[Permission] = relationship()
38+
permissions_revoke: List[Permission] = relationship()
39+
40+
@hybrid_property
41+
def permissions(self) -> List[Permission]:
42+
"""Getter для permissions - объединяет роль и индивидуальные разрешения"""
43+
role_permissions = self.role.permissions if self.role else []
44+
return list(
45+
set(role_permissions + self.permissions_grant)
46+
- set(self.permissions_revoke)
47+
)
48+
49+
@permissions.inplace.setter
50+
def _permissions_setter(self, value: List[Permission]) -> None:
51+
"""Setter для permissions - вычисляет grant и revoke списки"""
52+
role_permissions = self.role.permissions if self.role else []
53+
self.permissions_grant = list(set(value) - set(role_permissions))
54+
self.permissions_revoke = list(set(role_permissions) - set(value))
55+
56+
57+
def test_hybrid_property_setter_direct():
58+
"""Тест прямого присвоения через hybrid property setter"""
59+
print("Тестируем hybrid property setter...")
60+
61+
try:
62+
# Создаем пользователя
63+
user = User(id=1, name="John Doe")
64+
print(f"✅ Создан пользователь: {user.name}")
65+
66+
# Проверяем, что hybrid property найден в классе
67+
if hasattr(user.__class__, 'permissions'):
68+
permissions_attr = getattr(user.__class__, 'permissions')
69+
print(f"✅ permissions найден: {type(permissions_attr)}")
70+
71+
# Проверяем, что это hybrid_property
72+
if isinstance(permissions_attr, hybrid_property):
73+
print("✅ permissions является hybrid_property")
74+
75+
# Проверяем наличие setter'а
76+
if hasattr(permissions_attr, 'setter'):
77+
print("✅ hybrid_property имеет setter")
78+
else:
79+
print("❌ hybrid_property НЕ имеет setter")
80+
else:
81+
print(f"❌ permissions НЕ является hybrid_property: {type(permissions_attr)}")
82+
else:
83+
print("❌ permissions НЕ найден в классе")
84+
85+
# Создаем тестовые разрешения
86+
perm1 = Permission(id=1, name="read")
87+
perm2 = Permission(id=2, name="write")
88+
test_permissions = [perm1, perm2]
89+
90+
# Инициализируем списки
91+
user.permissions_grant = []
92+
user.permissions_revoke = []
93+
user.role = None
94+
95+
print("\nТестируем присвоение через hybrid property setter...")
96+
97+
# Присваиваем через setter
98+
user.permissions = test_permissions
99+
100+
print(f"✅ Присвоение завершено без ошибок")
101+
print(f"✅ permissions_grant: {len(user.permissions_grant)} элементов")
102+
print(f"✅ permissions_revoke: {len(user.permissions_revoke)} элементов")
103+
104+
except Exception as e:
105+
print(f"❌ Ошибка: {e}")
106+
import traceback
107+
traceback.print_exc()
108+
109+
110+
def test_hybrid_property_with_model_validate():
111+
"""Тест hybrid property через model_validate"""
112+
print("\nТестируем hybrid property через model_validate...")
113+
114+
try:
115+
# Создаем тестовые данные
116+
perm1 = Permission(id=1, name="read")
117+
perm2 = Permission(id=2, name="write")
118+
119+
test_data = {
120+
"id": 1,
121+
"name": "Jane Doe",
122+
"role_id": None,
123+
"permissions": [perm1, perm2] # Через hybrid property
124+
}
125+
126+
print("Выполняем model_validate с hybrid property...")
127+
validated_user = User.model_validate(test_data)
128+
129+
print(f"✅ model_validate успешно: {validated_user.name}")
130+
131+
# Проверяем, что setter был вызван
132+
if hasattr(validated_user, 'permissions_grant'):
133+
print(f"✅ permissions_grant установлен: {len(validated_user.permissions_grant)} элементов")
134+
else:
135+
print("❌ permissions_grant НЕ установлен")
136+
137+
except Exception as e:
138+
print(f"⚠️ model_validate с hybrid property не сработал: {e}")
139+
# Это может быть ожидаемо в зависимости от реализации
140+
141+
142+
def test_hybrid_property_inspection():
143+
"""Тест проверки структуры hybrid property"""
144+
print("\nИнспекция hybrid property...")
145+
146+
try:
147+
user = User(id=1, name="Test")
148+
149+
# Получаем атрибут класса
150+
permissions_attr = getattr(User, 'permissions')
151+
152+
print(f"Тип атрибута: {type(permissions_attr)}")
153+
print(f"Атрибуты: {dir(permissions_attr)}")
154+
155+
# Проверяем различные способы определения setter'а
156+
checks = [
157+
("hasattr(permissions_attr, 'setter')", hasattr(permissions_attr, 'setter')),
158+
("hasattr(permissions_attr, '__set__')", hasattr(permissions_attr, '__set__')),
159+
("hasattr(permissions_attr, 'fset')", hasattr(permissions_attr, 'fset')),
160+
("hasattr(permissions_attr, 'inplace')", hasattr(permissions_attr, 'inplace')),
161+
]
162+
163+
for check_name, result in checks:
164+
print(f"{check_name}: {result}")
165+
166+
# Если есть inplace, проверим его атрибуты
167+
if hasattr(permissions_attr, 'inplace'):
168+
inplace_attr = permissions_attr.inplace
169+
print(f"inplace тип: {type(inplace_attr)}")
170+
print(f"inplace атрибуты: {dir(inplace_attr)}")
171+
172+
except Exception as e:
173+
print(f"❌ Ошибка в инспекции: {e}")
174+
import traceback
175+
traceback.print_exc()
176+
177+
178+
if __name__ == "__main__":
179+
print("Запуск тестов hybrid property setter...")
180+
test_hybrid_property_inspection()
181+
test_hybrid_property_setter_direct()
182+
test_hybrid_property_with_model_validate()
183+
print("\nВсе тесты завершены!")

0 commit comments

Comments
 (0)