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

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

  1. 无物理外键:项目禁止使用物理外键约束
  2. SQLAlchemy 推断失败:没有 ForeignKeyForeignKeyConstraint,SQLAlchemy 无法自动推断关联条件
  3. 明确指定:通过 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 集成测试通过


影响范围

修改的文件

  1. server/app/models/folder.py

    • Folder 类:3 个 Relationship
    • FolderMember 类:3 个 Relationship
  2. server/app/models/project.py

    • Project 类:4 个 Relationship
    • ProjectMember 类:3 个 Relationship
    • ProjectShare 类: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 参数。修复后:

  1. 集成测试全部通过(7/7)
  2. SQLAlchemy 可以正确推断关联关系
  3. 符合项目"禁止物理外键"的架构约束
  4. 为后续 Model 开发提供了标准模式

这是项目架构演进的重要一步,确保了在无物理外键的情况下,ORM 关系仍然可以正常工作。