Tortoise-ORM 中,关联关系(Relationships)是构建复杂数据模型的核心功能。它支持三种经典关系:

  • 一对一(One-to-One)
  • 一对多(One-to-Many)
  • 多对多(Many-to-Many)

下面我将通过 清晰的代码示例 + 查询用法 + 注意事项,逐一讲解这三种关系在 Tortoise-ORM 中的实现方式。


🧩 前提:基础模型结构

我们以一个常见的博客系统为例:

  • User:用户
  • Profile:用户资料(一对一)
  • Post:文章(一对多:一个用户有多篇文章)
  • Tag:标签(多对多:一篇文章有多个标签,一个标签可用于多篇文章)

✅ 一、一对一(One-to-One)

场景:每个用户有且仅有一个个人资料(Profile)

🔹 模型定义

from tortoise.models import Model
from tortoise import fields

class User(Model):
    id = fields.IntField(pk=True)
    username = fields.CharField(max_length=50, unique=True)

class Profile(Model):
    id = fields.IntField(pk=True)
    bio = fields.TextField()
    # 关键:OneToOneField
    user = fields.OneToOneField('models.User', on_delete=fields.CASCADE)
  • 'models.User':指向模型的路径(模块名.类名)
  • on_delete=fields.CASCADE:当用户被删除时,资料也自动删除
💡 谁持有外键?
在一对一中,“附属”模型(Profile)持有外键,指向主模型(User)。

🔹 使用示例

创建

user = await User.create(username="alice")
profile = await Profile.create(bio="Python developer", user=user)

查询(正向)

profile = await Profile.get(id=1).prefetch_related('user')
print(profile.user.username)  # "alice"

查询(反向)

user = await User.get(id=1).prefetch_related('profile')  # ❌ 默认没有反向属性!

⚠️ 问题:默认情况下,User 没有 .profile 属性!

解决方案:使用 related_name 显式定义反向关系:

class Profile(Model):
    user = fields.OneToOneField(
        'models.User',
        related_name='profile',  # ← 关键!
        on_delete=fields.CASCADE
    )

现在就可以:

user = await User.get(username="alice")
await user.fetch_related('profile')  # 或 prefetch_related
print(user.profile.bio)  # "Python developer"
最佳实践始终为 OneToOneField 设置 related_name

✅ 二、一对多(One-to-Many)

场景:一个用户可以写多篇文章,一篇文章只属于一个用户

🔹 模型定义

class User(Model):
    id = fields.IntField(pk=True)
    username = fields.CharField(max_length=50)

class Post(Model):
    id = fields.IntField(pk=True)
    title = fields.CharField(max_length=200)
    # 外键指向 User
    author = fields.ForeignKeyField(
        'models.User',
        related_name='posts'  # ← 反向关系名
    )
  • ForeignKeyField 表示“多”的一方(Post)持有外键
  • related_name='posts':在 User 上生成 .posts 属性

🔹 使用示例

创建

user = await User.create(username="bob")
post1 = await Post.create(title="FastAPI Guide", author=user)
post2 = await Post.create(title="Tortoise ORM", author=user)

查询:用户的所有文章(反向)

user = await User.get(username="bob").prefetch_related('posts')
for post in user.posts:
    print(post.title)
# 输出:
# FastAPI Guide
# Tortoise ORM

查询:文章的作者(正向)

post = await Post.get(id=1).prefetch_related('author')
print(post.author.username)  # "bob"

过滤:查找某用户的文章

posts = await Post.filter(author__username="bob").all()

注意

  • author 是字段名
  • author__username 是跨表查询语法(双下划线)

✅ 三、多对多(Many-to-Many)

场景:一篇文章可以有多个标签,一个标签可用于多篇文章

🔹 模型定义

class Tag(Model):
    id = fields.IntField(pk=True)
    name = fields.CharField(max_length=30, unique=True)

class Post(Model):
    id = fields.IntField(pk=True)
    title = fields.CharField(max_length=200)
    # 多对多关系
    tags = fields.ManyToManyField(
        'models.Tag',
        related_name='posts'
    )
  • Tortoise 会自动创建中间表(如 post_tag
  • 无需手动定义中间模型(除非需要额外字段)

🔹 使用示例

创建并关联

post = await Post.create(title="Async Python")
tag1 = await Tag.create(name="python")
tag2 = await Tag.create(name="async")

# 关联
await post.tags.add(tag1, tag2)
# 或
await tag1.posts.add(post)

查询:文章的所有标签

post = await Post.get(id=1).prefetch_related('tags')
for tag in post.tags:
    print(tag.name)
# python
# async

查询:带某个标签的所有文章

posts = await Post.filter(tags__name="python").prefetch_related('tags')

移除关联

await post.tags.clear()          # 清空所有标签
await post.tags.remove(tag1)     # 移除单个标签

🔸 高级:自定义中间表(带额外字段)

如果需要在关联中存储额外信息(如“打标签时间”),需手动定义中间模型:

class PostTag(Model):
    post = fields.ForeignKeyField('models.Post')
    tag = fields.ForeignKeyField('models.Tag')
    tagged_at = fields.DatetimeField(auto_now_add=True)

    class Meta:
        unique_together = ("post", "tag")  # 防止重复关联

class Post(Model):
    title = fields.CharField(max_length=200)
    # 通过中间模型定义多对多
    tags = fields.ManyToManyField(
        'models.Tag',
        through='models.PostTag',
        related_name='posts'
    )
⚠️ 使用 through 后,.add() 等方法仍可用,但无法直接设置中间表字段。
如需设置 tagged_at,需手动创建 PostTag 对象。

📊 四、三种关系对比总结

关系类型字段类型谁持外键反向访问典型场景
一对一OneToOneField附属模型(Profile)related_name用户 ↔ 资料、订单 ↔ 发票
一对多ForeignKeyField“多”方(Post)自动通过 related_name用户 ↔ 文章、分类 ↔ 商品
多对多ManyToManyField自动生成中间表双向通过 related_name文章 ↔ 标签、学生 ↔ 课程

⚠️ 五、重要注意事项

1. 必须使用 prefetch_relatedfetch_related

否则反向/多对多关系不会加载,访问时报错或为空。

# ❌ 错误:未预加载
user = await User.get(id=1)
print(len(user.posts))  # 可能报错或返回 0

# ✅ 正确
user = await User.get(id=1).prefetch_related('posts')

2. related_name 是反向查询的关键

  • 如果不设,Tortoise 会自动生成(如 post_set),但不直观
  • 强烈建议显式指定

3. 删除行为(on_delete

  • CASCADE:级联删除
  • SET_NULL:设为 NULL(字段需 null=True
  • RESTRICT(默认):阻止删除(如果有子记录)

4. 查询性能

  • 多层关联时,尽量用 prefetch_related 减少数据库查询次数
  • 避免在循环中做异步查询(N+1 问题)

✅ 六、完整可运行示例(FastAPI + Tortoise)

from fastapi import FastAPI
from tortoise.models import Model
from tortoise import fields
from tortoise.contrib.fastapi import register_tortoise

app = FastAPI()

class User(Model):
    id = fields.IntField(pk=True)
    username = fields.CharField(max_length=50)

class Post(Model):
    id = fields.IntField(pk=True)
    title = fields.CharField(max_length=200)
    author = fields.ForeignKeyField('models.User', related_name='posts')

@app.get("/users/{user_id}/posts")
async def get_user_posts(user_id: int):
    user = await User.get(id=user_id).prefetch_related('posts')
    return {
        "user": user.username,
        "posts": [{"id": p.id, "title": p.title} for p in user.posts]
    }

register_tortoise(
    app,
    db_url="sqlite://db.sqlite3",
    modules={"models": ["__main__"]},
    generate_schemas=True,
)