# 

# 03-2. 關聯關係

&gt; ⏱️ **閱讀時間：** 20 分鐘
&gt; 🎯 **難度：** ⭐⭐⭐ (進階)

---

## 🤔 一句話解釋

**SQLAlchemy 的 relationship 讓你用 Python 物件存取關聯資料，不用手動寫 JOIN。**

---

## 🔗 關聯類型總覽

```
┌─────────────────────────────────────────────────────────────┐
│                      關聯類型                                │
├─────────────────────────────────────────────────────────────┤
│  One-to-Many (一對多)                                        │
│  ┌─────┐         ┌─────┐                                    │
│  │User │ ──1:N──▶│Post │  一個使用者有多篇文章               │
│  └─────┘         └─────┘                                    │
├─────────────────────────────────────────────────────────────┤
│  Many-to-One (多對一)                                        │
│  ┌─────┐         ┌────────┐                                 │
│  │Post │ ──N:1──▶│Category│  多篇文章屬於一個分類            │
│  └─────┘         └────────┘                                 │
├─────────────────────────────────────────────────────────────┤
│  One-to-One (一對一)                                         │
│  ┌─────┐         ┌───────┐                                  │
│  │User │ ──1:1──▶│Profile│  一個使用者有一個個人資料         │
│  └─────┘         └───────┘                                  │
├─────────────────────────────────────────────────────────────┤
│  Many-to-Many (多對多)                                       │
│  ┌─────┐         ┌─────┐                                    │
│  │Post │ ──N:N──▶│ Tag │  文章和標籤的多對多關係             │
│  └─────┘         └─────┘                                    │
└─────────────────────────────────────────────────────────────┘
```

---

## 🔢 One-to-Many（一對多）

### 範例：使用者與文章

```python
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime
from sqlalchemy.orm import relationship, Mapped, mapped_column
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime
from typing import List, Optional

Base = declarative_base()

class User(Base):
    __tablename__ = &#34;users&#34;

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    username: Mapped[str] = mapped_column(String(50), unique=True)
    email: Mapped[str] = mapped_column(String(100), unique=True)

    # 關聯：一個使用者有多篇文章
    posts: Mapped[List[&#34;Post&#34;]] = relationship(
        &#34;Post&#34;,
        back_populates=&#34;author&#34;,
        cascade=&#34;all, delete-orphan&#34;  # 刪除使用者時，也刪除他的文章
    )

    def __repr__(self):
        return f&#34;&lt;User(id={self.id}, username=&#39;{self.username}&#39;)&gt;&#34;

class Post(Base):
    __tablename__ = &#34;posts&#34;

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    title: Mapped[str] = mapped_column(String(200))
    content: Mapped[str] = mapped_column(Text)
    created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)

    # 外鍵
    author_id: Mapped[int] = mapped_column(ForeignKey(&#34;users.id&#34;))

    # 關聯：文章屬於一個作者
    author: Mapped[&#34;User&#34;] = relationship(&#34;User&#34;, back_populates=&#34;posts&#34;)

    def __repr__(self):
        return f&#34;&lt;Post(id={self.id}, title=&#39;{self.title}&#39;)&gt;&#34;
```

### 使用方式

```python
from sqlalchemy.orm import Session

def demo_one_to_many(db: Session):
    # 建立使用者和文章
    user = User(username=&#34;john&#34;, email=&#34;john@example.com&#34;)

    # 方法 1: 從使用者端新增文章
    user.posts = [
        Post(title=&#34;First Post&#34;, content=&#34;Hello World&#34;),
        Post(title=&#34;Second Post&#34;, content=&#34;Another post&#34;)
    ]
    db.add(user)
    db.commit()

    # 方法 2: 從文章端關聯使用者
    post = Post(
        title=&#34;Third Post&#34;,
        content=&#34;Content here&#34;,
        author=user  # 直接指定 author
    )
    db.add(post)
    db.commit()

    # 查詢：取得使用者的所有文章
    user = db.query(User).filter(User.username == &#34;john&#34;).first()
    for post in user.posts:
        print(f&#34;- {post.title}&#34;)

    # 查詢：取得文章的作者
    post = db.query(Post).first()
    print(f&#34;Author: {post.author.username}&#34;)
```

---

## 1️⃣ One-to-One（一對一）

### 範例：使用者與個人資料

```python
class User(Base):
    __tablename__ = &#34;users&#34;

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    username: Mapped[str] = mapped_column(String(50), unique=True)

    # 一對一關聯
    profile: Mapped[Optional[&#34;Profile&#34;]] = relationship(
        &#34;Profile&#34;,
        back_populates=&#34;user&#34;,
        uselist=False,  # 關鍵：設為 False 表示一對一
        cascade=&#34;all, delete-orphan&#34;
    )

class Profile(Base):
    __tablename__ = &#34;profiles&#34;

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    bio: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
    avatar_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
    website: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)

    # 外鍵（unique=True 確保一對一）
    user_id: Mapped[int] = mapped_column(
        ForeignKey(&#34;users.id&#34;),
        unique=True  # 確保每個使用者只有一個 Profile
    )

    # 關聯
    user: Mapped[&#34;User&#34;] = relationship(&#34;User&#34;, back_populates=&#34;profile&#34;)
```

### 使用方式

```python
def demo_one_to_one(db: Session):
    # 建立使用者和個人資料
    user = User(
        username=&#34;john&#34;,
        profile=Profile(
            bio=&#34;Python developer&#34;,
            website=&#34;https://john.dev&#34;
        )
    )
    db.add(user)
    db.commit()

    # 存取
    print(user.profile.bio)  # Python developer
    print(user.profile.user.username)  # john
```

---

## 🔄 Many-to-Many（多對多）

### 範例：文章與標籤

```python
from sqlalchemy import Table, Column, Integer, ForeignKey

# 關聯表（不需要 Model 類別）
post_tags = Table(
    &#34;post_tags&#34;,
    Base.metadata,
    Column(&#34;post_id&#34;, Integer, ForeignKey(&#34;posts.id&#34;), primary_key=True),
    Column(&#34;tag_id&#34;, Integer, ForeignKey(&#34;tags.id&#34;), primary_key=True)
)

class Post(Base):
    __tablename__ = &#34;posts&#34;

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    title: Mapped[str] = mapped_column(String(200))
    content: Mapped[str] = mapped_column(Text)

    # 多對多關聯
    tags: Mapped[List[&#34;Tag&#34;]] = relationship(
        &#34;Tag&#34;,
        secondary=post_tags,  # 指定關聯表
        back_populates=&#34;posts&#34;
    )

class Tag(Base):
    __tablename__ = &#34;tags&#34;

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    name: Mapped[str] = mapped_column(String(50), unique=True)

    # 多對多關聯
    posts: Mapped[List[&#34;Post&#34;]] = relationship(
        &#34;Post&#34;,
        secondary=post_tags,
        back_populates=&#34;tags&#34;
    )
```

### 使用方式

```python
def demo_many_to_many(db: Session):
    # 建立標籤
    python_tag = Tag(name=&#34;Python&#34;)
    fastapi_tag = Tag(name=&#34;FastAPI&#34;)
    db.add_all([python_tag, fastapi_tag])
    db.commit()

    # 建立文章並關聯標籤
    post = Post(
        title=&#34;FastAPI Tutorial&#34;,
        content=&#34;Learn FastAPI...&#34;,
        tags=[python_tag, fastapi_tag]
    )
    db.add(post)
    db.commit()

    # 查詢：取得文章的所有標籤
    post = db.query(Post).first()
    for tag in post.tags:
        print(f&#34;- {tag.name}&#34;)

    # 查詢：取得標籤下的所有文章
    tag = db.query(Tag).filter(Tag.name == &#34;Python&#34;).first()
    for post in tag.posts:
        print(f&#34;- {post.title}&#34;)

    # 新增標籤到文章
    new_tag = db.query(Tag).filter(Tag.name == &#34;FastAPI&#34;).first()
    post.tags.append(new_tag)
    db.commit()

    # 移除標籤
    post.tags.remove(new_tag)
    db.commit()
```

### 帶額外資料的多對多

```python
# 如果關聯表需要額外欄位，就需要用 Model 類別

class PostTag(Base):
    &#34;&#34;&#34;文章標籤關聯（帶額外資料）&#34;&#34;&#34;
    __tablename__ = &#34;post_tags&#34;

    post_id: Mapped[int] = mapped_column(
        ForeignKey(&#34;posts.id&#34;),
        primary_key=True
    )
    tag_id: Mapped[int] = mapped_column(
        ForeignKey(&#34;tags.id&#34;),
        primary_key=True
    )

    # 額外欄位
    created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
    created_by: Mapped[Optional[int]] = mapped_column(ForeignKey(&#34;users.id&#34;))

    # 關聯
    post: Mapped[&#34;Post&#34;] = relationship(&#34;Post&#34;, back_populates=&#34;post_tags&#34;)
    tag: Mapped[&#34;Tag&#34;] = relationship(&#34;Tag&#34;, back_populates=&#34;post_tags&#34;)

class Post(Base):
    __tablename__ = &#34;posts&#34;

    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(200))

    # 透過關聯 Model
    post_tags: Mapped[List[&#34;PostTag&#34;]] = relationship(
        &#34;PostTag&#34;,
        back_populates=&#34;post&#34;,
        cascade=&#34;all, delete-orphan&#34;
    )

    # 方便存取（使用 association_proxy）
    @property
    def tags(self) -&gt; List[&#34;Tag&#34;]:
        return [pt.tag for pt in self.post_tags]

class Tag(Base):
    __tablename__ = &#34;tags&#34;

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(50), unique=True)

    post_tags: Mapped[List[&#34;PostTag&#34;]] = relationship(
        &#34;PostTag&#34;,
        back_populates=&#34;tag&#34;
    )
```

---

## 📥 載入策略（Loading Strategies）

### 問題：N&#43;1 查詢

```python
# ❌ N&#43;1 問題：會執行 1 &#43; N 次查詢
users = db.query(User).all()
for user in users:
    print(user.posts)  # 每次存取都會查詢一次
```

### 解決方案

#### 1. Eager Loading（積極載入）

```python
from sqlalchemy.orm import joinedload, selectinload, subqueryload

# joinedload: 使用 JOIN（適合一對一、多對一）
stmt = select(User).options(joinedload(User.profile))
users = db.scalars(stmt).all()

# selectinload: 使用 IN 子查詢（適合一對多）
stmt = select(User).options(selectinload(User.posts))
users = db.scalars(stmt).unique().all()

# subqueryload: 使用子查詢
stmt = select(User).options(subqueryload(User.posts))
users = db.scalars(stmt).all()
```

#### 2. 巢狀載入

```python
# 載入多層關聯
stmt = select(User).options(
    selectinload(User.posts).selectinload(Post.tags)
)
users = db.scalars(stmt).unique().all()

# 現在可以存取所有資料，不會額外查詢
for user in users:
    for post in user.posts:
        for tag in post.tags:
            print(f&#34;{user.username} - {post.title} - {tag.name}&#34;)
```

#### 3. Lazy Loading 選項

```python
class User(Base):
    __tablename__ = &#34;users&#34;

    id: Mapped[int] = mapped_column(primary_key=True)

    # lazy 選項
    posts: Mapped[List[&#34;Post&#34;]] = relationship(
        &#34;Post&#34;,
        back_populates=&#34;author&#34;,
        lazy=&#34;select&#34;  # 預設：存取時才查詢
        # lazy=&#34;joined&#34;   # 使用 JOIN 立即載入
        # lazy=&#34;selectin&#34; # 使用 IN 查詢立即載入
        # lazy=&#34;subquery&#34; # 使用子查詢立即載入
        # lazy=&#34;dynamic&#34;  # 返回 Query 物件，可以進一步過濾
    )
```

### 載入策略比較

| 策略 | 查詢數 | 適用場景 |
|------|--------|----------|
| `select`（預設）| 1 &#43; N | 不確定是否需要關聯資料 |
| `joined` | 1 | 一對一、多對一 |
| `selectin` | 2 | 一對多（推薦）|
| `subquery` | 2 | 一對多（複雜過濾）|

---

## 🔄 Cascade 選項

### 常用 Cascade

```python
class User(Base):
    __tablename__ = &#34;users&#34;

    id: Mapped[int] = mapped_column(primary_key=True)

    posts: Mapped[List[&#34;Post&#34;]] = relationship(
        &#34;Post&#34;,
        back_populates=&#34;author&#34;,
        cascade=&#34;all, delete-orphan&#34;
        # cascade 選項：
        # - &#34;save-update&#34;: 儲存父物件時，也儲存子物件
        # - &#34;merge&#34;: 合併父物件時，也合併子物件
        # - &#34;expunge&#34;: 從 Session 移除父物件時，也移除子物件
        # - &#34;delete&#34;: 刪除父物件時，也刪除子物件
        # - &#34;delete-orphan&#34;: 從關聯移除的子物件也會被刪除
        # - &#34;all&#34;: 包含以上所有（除了 delete-orphan）
    )
```

### Cascade 範例

```python
def demo_cascade(db: Session):
    # 建立使用者和文章
    user = User(username=&#34;john&#34;)
    user.posts = [
        Post(title=&#34;Post 1&#34;, content=&#34;...&#34;),
        Post(title=&#34;Post 2&#34;, content=&#34;...&#34;)
    ]
    db.add(user)
    db.commit()

    # 刪除使用者時，文章也會被刪除（cascade=&#34;delete&#34;）
    db.delete(user)
    db.commit()

    # 從關聯移除文章時，文章會被刪除（cascade=&#34;delete-orphan&#34;）
    user.posts.remove(user.posts[0])
    db.commit()  # 被移除的文章也從資料庫刪除
```

---

## 📝 實戰範例：部落格系統

```python
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime, Boolean, Table
from sqlalchemy.orm import relationship, Mapped, mapped_column
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime
from typing import List, Optional

Base = declarative_base()

# 多對多關聯表
post_tags = Table(
    &#34;post_tags&#34;,
    Base.metadata,
    Column(&#34;post_id&#34;, Integer, ForeignKey(&#34;posts.id&#34;, ondelete=&#34;CASCADE&#34;), primary_key=True),
    Column(&#34;tag_id&#34;, Integer, ForeignKey(&#34;tags.id&#34;, ondelete=&#34;CASCADE&#34;), primary_key=True)
)

class User(Base):
    &#34;&#34;&#34;使用者&#34;&#34;&#34;
    __tablename__ = &#34;users&#34;

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    username: Mapped[str] = mapped_column(String(50), unique=True, index=True)
    email: Mapped[str] = mapped_column(String(100), unique=True)
    hashed_password: Mapped[str] = mapped_column(String(255))
    is_active: Mapped[bool] = mapped_column(default=True)
    created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)

    # 關聯
    profile: Mapped[Optional[&#34;Profile&#34;]] = relationship(
        &#34;Profile&#34;,
        back_populates=&#34;user&#34;,
        uselist=False,
        cascade=&#34;all, delete-orphan&#34;
    )
    posts: Mapped[List[&#34;Post&#34;]] = relationship(
        &#34;Post&#34;,
        back_populates=&#34;author&#34;,
        cascade=&#34;all, delete-orphan&#34;
    )
    comments: Mapped[List[&#34;Comment&#34;]] = relationship(
        &#34;Comment&#34;,
        back_populates=&#34;author&#34;,
        cascade=&#34;all, delete-orphan&#34;
    )

class Profile(Base):
    &#34;&#34;&#34;使用者個人資料&#34;&#34;&#34;
    __tablename__ = &#34;profiles&#34;

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    bio: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
    avatar_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
    website: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)

    user_id: Mapped[int] = mapped_column(ForeignKey(&#34;users.id&#34;), unique=True)
    user: Mapped[&#34;User&#34;] = relationship(&#34;User&#34;, back_populates=&#34;profile&#34;)

class Category(Base):
    &#34;&#34;&#34;文章分類&#34;&#34;&#34;
    __tablename__ = &#34;categories&#34;

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    name: Mapped[str] = mapped_column(String(50), unique=True)
    slug: Mapped[str] = mapped_column(String(50), unique=True)
    description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)

    posts: Mapped[List[&#34;Post&#34;]] = relationship(&#34;Post&#34;, back_populates=&#34;category&#34;)

class Tag(Base):
    &#34;&#34;&#34;標籤&#34;&#34;&#34;
    __tablename__ = &#34;tags&#34;

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    name: Mapped[str] = mapped_column(String(50), unique=True)
    slug: Mapped[str] = mapped_column(String(50), unique=True)

    posts: Mapped[List[&#34;Post&#34;]] = relationship(
        &#34;Post&#34;,
        secondary=post_tags,
        back_populates=&#34;tags&#34;
    )

class Post(Base):
    &#34;&#34;&#34;文章&#34;&#34;&#34;
    __tablename__ = &#34;posts&#34;

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    title: Mapped[str] = mapped_column(String(200))
    slug: Mapped[str] = mapped_column(String(200), unique=True, index=True)
    content: Mapped[str] = mapped_column(Text)
    excerpt: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
    is_published: Mapped[bool] = mapped_column(default=False)
    view_count: Mapped[int] = mapped_column(default=0)
    created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
    updated_at: Mapped[Optional[datetime]] = mapped_column(onupdate=datetime.utcnow)
    published_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)

    # 外鍵
    author_id: Mapped[int] = mapped_column(ForeignKey(&#34;users.id&#34;))
    category_id: Mapped[Optional[int]] = mapped_column(
        ForeignKey(&#34;categories.id&#34;),
        nullable=True
    )

    # 關聯
    author: Mapped[&#34;User&#34;] = relationship(&#34;User&#34;, back_populates=&#34;posts&#34;)
    category: Mapped[Optional[&#34;Category&#34;]] = relationship(
        &#34;Category&#34;,
        back_populates=&#34;posts&#34;
    )
    tags: Mapped[List[&#34;Tag&#34;]] = relationship(
        &#34;Tag&#34;,
        secondary=post_tags,
        back_populates=&#34;posts&#34;
    )
    comments: Mapped[List[&#34;Comment&#34;]] = relationship(
        &#34;Comment&#34;,
        back_populates=&#34;post&#34;,
        cascade=&#34;all, delete-orphan&#34;
    )

class Comment(Base):
    &#34;&#34;&#34;留言&#34;&#34;&#34;
    __tablename__ = &#34;comments&#34;

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    content: Mapped[str] = mapped_column(Text)
    created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)

    # 外鍵
    author_id: Mapped[int] = mapped_column(ForeignKey(&#34;users.id&#34;))
    post_id: Mapped[int] = mapped_column(ForeignKey(&#34;posts.id&#34;))
    parent_id: Mapped[Optional[int]] = mapped_column(
        ForeignKey(&#34;comments.id&#34;),
        nullable=True
    )

    # 關聯
    author: Mapped[&#34;User&#34;] = relationship(&#34;User&#34;, back_populates=&#34;comments&#34;)
    post: Mapped[&#34;Post&#34;] = relationship(&#34;Post&#34;, back_populates=&#34;comments&#34;)

    # 自引用關聯（巢狀留言）
    replies: Mapped[List[&#34;Comment&#34;]] = relationship(
        &#34;Comment&#34;,
        backref=&#34;parent&#34;,
        remote_side=[id],
        cascade=&#34;all, delete-orphan&#34;
    )
```

### 查詢範例

```python
from sqlalchemy import select
from sqlalchemy.orm import selectinload, joinedload

def get_posts_with_details(db: Session, skip: int = 0, limit: int = 10):
    &#34;&#34;&#34;取得文章（含作者、分類、標籤）&#34;&#34;&#34;
    stmt = (
        select(Post)
        .where(Post.is_published == True)
        .options(
            joinedload(Post.author),
            joinedload(Post.category),
            selectinload(Post.tags)
        )
        .order_by(Post.published_at.desc())
        .offset(skip)
        .limit(limit)
    )
    return db.scalars(stmt).unique().all()

def get_post_with_comments(db: Session, slug: str):
    &#34;&#34;&#34;取得文章和留言&#34;&#34;&#34;
    stmt = (
        select(Post)
        .where(Post.slug == slug)
        .options(
            joinedload(Post.author).joinedload(User.profile),
            selectinload(Post.comments).joinedload(Comment.author),
            selectinload(Post.tags)
        )
    )
    return db.scalar(stmt)
```

---

## ✅ 重點總結

### 關聯設定

| 關聯類型 | 外鍵位置 | relationship 設定 |
|----------|----------|-------------------|
| 一對多 | 多的那方 | `uselist=True`（預設）|
| 一對一 | 子方 | `uselist=False` |
| 多對多 | 關聯表 | `secondary=table` |

### 載入策略

| 策略 | 方法 | 適用場景 |
|------|------|----------|
| `joinedload` | JOIN | 一對一、多對一 |
| `selectinload` | IN 查詢 | 一對多（推薦）|
| `subqueryload` | 子查詢 | 複雜過濾 |

---

## 🎤 面試這樣答

### Q: 什麼是 N&#43;1 問題？如何解決？

**答案：**

&gt; N&#43;1 問題是指查詢 N 筆資料時，存取關聯會額外執行 N 次查詢：
&gt;
&gt; ```python
&gt; users = db.query(User).all()  # 1 次查詢
&gt; for user in users:
&gt;     print(user.posts)  # N 次查詢
&gt; ```
&gt;
&gt; 解決方法是使用 Eager Loading：
&gt;
&gt; ```python
&gt; from sqlalchemy.orm import selectinload
&gt;
&gt; stmt = select(User).options(selectinload(User.posts))
&gt; users = db.scalars(stmt).all()  # 只有 2 次查詢
&gt; ```

---

**上一篇：** [03-1. SQLAlchemy 基礎](./03-1)
**下一篇：** [03-3. 非同步資料庫操作](./03-3)

---

最後更新：2025-12-17


---

> 作者: luk  
> URL: https://yoru-karu-blog-lalaluk-52581ac5e0cef170a3c8922c19182ecb6f7bd604.gitlab.io/posts/tutorial/fastapi/03-2/  

