You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
9.8 KiB
9.8 KiB
Model Relationship 配置修复
变更日期:2026-01-28
变更类型:修复
影响范围:server/app/models/folder.py,server/app/models/project.py
变更概述
修复 SQLModel Relationship 配置,为所有关联关系添加 primaryjoin 参数,解决移除物理外键后 SQLAlchemy 无法自动推断关联条件的问题。
问题背景
在执行用户服务集成测试时,遇到以下错误:
sqlalchemy.exc.NoForeignKeysError: Could not determine join condition between
parent/child tables on relationship Folder.owner - there are no foreign keys
linking these tables. Ensure that referencing columns are associated with a
ForeignKey or ForeignKeyConstraint, or specify a 'primaryjoin' expression.
根本原因
项目已移除所有物理外键约束(FOREIGN KEY),仅在应用层保证引用完整性。但 SQLModel 的 Relationship 依赖物理外键或 ForeignKey 定义来自动推断关联条件。
移除物理外键后,必须通过 primaryjoin 参数明确指定关联条件。
修复内容
1. Folder Model
文件:server/app/models/folder.py
修复前
# 关系
owner: Optional["User"] = Relationship(
sa_relationship_kwargs={"foreign_keys": "[Folder.owner_id]"}
)
parent: Optional["Folder"] = Relationship(
sa_relationship_kwargs={
"remote_side": "Folder.id",
"foreign_keys": "[Folder.parent_folder_id]",
}
)
members: List["FolderMember"] = Relationship(
back_populates="folder",
sa_relationship_kwargs={"cascade": "all, delete-orphan"},
)
修复后
# 关系(无物理外键,使用 primaryjoin 明确指定关联条件)
owner: Optional["User"] = Relationship(
sa_relationship_kwargs={
"primaryjoin": "Folder.owner_id == User.user_id",
"foreign_keys": "[Folder.owner_id]",
}
)
parent: Optional["Folder"] = Relationship(
sa_relationship_kwargs={
"primaryjoin": "Folder.parent_folder_id == Folder.id",
"remote_side": "[Folder.id]",
"foreign_keys": "[Folder.parent_folder_id]",
}
)
members: List["FolderMember"] = Relationship(
back_populates="folder",
sa_relationship_kwargs={
"primaryjoin": "Folder.id == FolderMember.folder_id",
"foreign_keys": "[FolderMember.folder_id]",
"cascade": "all, delete-orphan",
},
)
FolderMember 修复
# 关系(无物理外键,使用 primaryjoin 明确指定关联条件)
folder: Optional[Folder] = Relationship(
back_populates="members",
sa_relationship_kwargs={
"primaryjoin": "FolderMember.folder_id == Folder.id",
"foreign_keys": "[FolderMember.folder_id]",
}
)
user: Optional["User"] = Relationship(
sa_relationship_kwargs={
"primaryjoin": "FolderMember.user_id == User.user_id",
"foreign_keys": "[FolderMember.user_id]",
}
)
inviter: Optional["User"] = Relationship(
sa_relationship_kwargs={
"primaryjoin": "FolderMember.invited_by == User.user_id",
"foreign_keys": "[FolderMember.invited_by]",
}
)
2. Project Model
文件:server/app/models/project.py
Project 修复
# 关系(无物理外键,使用 primaryjoin 明确指定关联条件)
owner: Optional["User"] = Relationship(
sa_relationship_kwargs={
"primaryjoin": "Project.owner_id == User.user_id",
"foreign_keys": "[Project.owner_id]",
}
)
folder: Optional["Folder"] = Relationship(
sa_relationship_kwargs={
"primaryjoin": "Project.folder_id == Folder.id",
"foreign_keys": "[Project.folder_id]",
}
)
members: List["ProjectMember"] = Relationship(
back_populates="project",
sa_relationship_kwargs={
"primaryjoin": "Project.id == ProjectMember.project_id",
"foreign_keys": "[ProjectMember.project_id]",
"cascade": "all, delete-orphan",
},
)
shares: List["ProjectShare"] = Relationship(
back_populates="project",
sa_relationship_kwargs={
"primaryjoin": "Project.id == ProjectShare.project_id",
"foreign_keys": "[ProjectShare.project_id]",
"cascade": "all, delete-orphan",
},
)
ProjectMember 修复
# 关系(无物理外键,使用 primaryjoin 明确指定关联条件)
project: Optional[Project] = Relationship(
back_populates="members",
sa_relationship_kwargs={
"primaryjoin": "ProjectMember.project_id == Project.id",
"foreign_keys": "[ProjectMember.project_id]",
}
)
user: Optional["User"] = Relationship(
sa_relationship_kwargs={
"primaryjoin": "ProjectMember.user_id == User.user_id",
"foreign_keys": "[ProjectMember.user_id]",
}
)
inviter: Optional["User"] = Relationship(
sa_relationship_kwargs={
"primaryjoin": "ProjectMember.invited_by == User.user_id",
"foreign_keys": "[ProjectMember.invited_by]",
}
)
ProjectShare 修复
# 关系(无物理外键,使用 primaryjoin 明确指定关联条件)
project: Optional[Project] = Relationship(
back_populates="shares",
sa_relationship_kwargs={
"primaryjoin": "ProjectShare.project_id == Project.id",
"foreign_keys": "[ProjectShare.project_id]",
}
)
creator: Optional["User"] = Relationship(
sa_relationship_kwargs={
"primaryjoin": "ProjectShare.created_by == User.user_id",
"foreign_keys": "[ProjectShare.created_by]",
}
)
修复原理
primaryjoin 参数
primaryjoin 参数明确指定了两个表之间的关联条件,格式为:
"LeftTable.left_column == RightTable.right_column"
为什么需要 primaryjoin
- 无物理外键:项目禁止使用物理外键约束
- SQLAlchemy 推断失败:没有
ForeignKey或ForeignKeyConstraint,SQLAlchemy 无法自动推断关联条件 - 明确指定:通过
primaryjoin明确告诉 SQLAlchemy 如何关联两个表
自引用关系
对于自引用关系(如 Folder.parent),还需要 remote_side 参数:
parent: Optional["Folder"] = Relationship(
sa_relationship_kwargs={
"primaryjoin": "Folder.parent_folder_id == Folder.id",
"remote_side": "[Folder.id]", # 指定远端列
"foreign_keys": "[Folder.parent_folder_id]",
}
)
验证结果
集成测试通过
修复后,用户服务集成测试全部通过:
docker exec jointo-server-app pytest tests/integration/test_user_service.py -v
结果:
============================== test session starts ==============================
collected 7 items
tests/integration/test_user_service.py::TestUserServiceIntegration::test_create_and_get_user PASSED [ 14%]
tests/integration/test_user_service.py::TestUserServiceIntegration::test_logout_with_session_ownership_validation PASSED [ 28%]
tests/integration/test_user_service.py::TestUserServiceIntegration::test_delete_user_cascade PASSED [ 42%]
tests/integration/test_user_service.py::TestUserServiceIntegration::test_create_session_with_user_validation PASSED [ 57%]
tests/integration/test_user_service.py::TestUserServiceIntegration::test_update_username_once_only PASSED [ 71%]
tests/integration/test_user_service.py::TestUserServiceIntegration::test_bind_email_uniqueness PASSED [ 85%]
tests/integration/test_user_service.py::TestUserServiceIntegration::test_get_credits_info PASSED [100%]
============================== 7 passed in 0.64s ==============================
✅ 7/7 集成测试通过
影响范围
修改的文件
-
server/app/models/folder.pyFolder类:3 个 RelationshipFolderMember类:3 个 Relationship
-
server/app/models/project.pyProject类:4 个 RelationshipProjectMember类:3 个 RelationshipProjectShare类:2 个 Relationship
不影响的功能
- ✅ 数据库表结构不变
- ✅ 应用层引用完整性验证逻辑不变
- ✅ Repository 和 Service 代码不变
- ✅ API 接口不变
最佳实践
无物理外键项目的 Relationship 配置
在禁止使用物理外键的项目中,所有 Relationship 必须遵循以下模式:
# 单向关系
related_object: Optional["RelatedModel"] = Relationship(
sa_relationship_kwargs={
"primaryjoin": "CurrentModel.foreign_key_column == RelatedModel.primary_key_column",
"foreign_keys": "[CurrentModel.foreign_key_column]",
}
)
# 双向关系(back_populates)
related_objects: List["RelatedModel"] = Relationship(
back_populates="current_object",
sa_relationship_kwargs={
"primaryjoin": "CurrentModel.primary_key_column == RelatedModel.foreign_key_column",
"foreign_keys": "[RelatedModel.foreign_key_column]",
"cascade": "all, delete-orphan", # 可选
},
)
# 自引用关系
parent: Optional["CurrentModel"] = Relationship(
sa_relationship_kwargs={
"primaryjoin": "CurrentModel.parent_id == CurrentModel.id",
"remote_side": "[CurrentModel.id]",
"foreign_keys": "[CurrentModel.parent_id]",
}
)
相关文档
总结
成功修复了 Folder 和 Project Model 的 Relationship 配置,为所有关联关系添加了 primaryjoin 参数。修复后:
- ✅ 集成测试全部通过(7/7)
- ✅ SQLAlchemy 可以正确推断关联关系
- ✅ 符合项目"禁止物理外键"的架构约束
- ✅ 为后续 Model 开发提供了标准模式
这是项目架构演进的重要一步,确保了在无物理外键的情况下,ORM 关系仍然可以正常工作。