Skip to content

Polymorphic association ​

Bài toán: một loại dữ liệu gắn được vào nhiều loại bảng cha. Bình luận có thể nằm dưới bài viết, video hoặc sản phẩm. Lượt thích, file đính kèm, thông báo, nhật ký hoạt động, tag cũng hay gặp cảnh này.

Quan hệ một – nhiều thông thường chỉ cần một cột khoá ngoại: comments.post_id REFERENCES posts(id). Khi cha có thể là posts, videos hoặc products, một cột khoá ngoại không đủ, vì khoá ngoại chỉ trỏ được tới một bảng. Có bốn cách giải.

Polymorphic có phải là đánh tag? ​

Tag là một ví dụ hay gặp, nhưng hai khái niệm khác nhau:

  • Tag là quan hệ nhiều – nhiều: một bài có nhiều tag, một tag có ở nhiều bài. Nếu chỉ bài viết có tag thì một bảng nối post_tags(post_id, tag_id) là đủ, không có gì polymorphic (Quan hệ).
  • Polymorphic là phía cha có nhiều loại. Tag trở thành polymorphic khi cùng một bộ tag dùng cho cả bài viết, video và sản phẩm: bảng nối giờ phải trỏ tới nhiều loại bảng, kiểu taggings(tag_id, taggable_type, taggable_id).
Một loại chaNhiều loại cha (polymorphic)
Một – nhiềucomments.post_idBình luận cho bài viết, video, sản phẩm
Nhiều – nhiềupost_tags(post_id, tag_id)Tag dùng chung cho bài viết, video, sản phẩm

Bốn cách bên dưới dùng được cho cả hai dòng của bảng. Ví dụ dùng bình luận.

1. Cặp cột loại + id ​

Cách Rails và Laravel làm mặc định: một cột ghi loại cha, một cột ghi id cha.

sql
CREATE TABLE comments (
  id               BIGINT PRIMARY KEY,
  commentable_type VARCHAR(20) NOT NULL,   -- 'post' | 'video' | 'product'
  commentable_id   BIGINT NOT NULL,
  user_id          BIGINT NOT NULL,
  body             TEXT NOT NULL,
  created_at       TIMESTAMP NOT NULL,
  CHECK (commentable_type IN ('post', 'video', 'product'))
);
CREATE INDEX idx_comments_target ON comments (commentable_type, commentable_id, created_at);

-- comment của bài #1
SELECT * FROM comments
WHERE commentable_type = 'post' AND commentable_id = 1
ORDER BY created_at;

Được: một bảng duy nhất, thêm loại cha mới không phải đổi schema, ORM hỗ trợ sẵn (Rails polymorphic: true, Laravel morphTo; Sequelize làm được bằng scope kèm constraints: false; Prisma không hỗ trợ trực tiếp).

Mất: không có khoá ngoại, nên database không bảo vệ gì cả:

  • Xoá bài viết thì comment ở lại thành mồ côi. Ứng dụng phải tự xoá trong cùng transaction.
  • Ghi ('post', 999) với bài 999 không tồn tại vẫn thành công.
  • Lấy tên của nơi comment phải LEFT JOIN từng bảng cha kèm điều kiện type, hoặc chạy một query cho mỗi loại.
  • Mọi bảng cha phải dùng cùng kiểu id (cùng BIGINT, hoặc cùng UUID).

Nếu chọn cách này, giảm rủi ro bằng:

  • Giá trị type ngắn và ổn định ('post'), đừng lưu tên class ('App\Models\Post'): đổi tên class là dữ liệu cũ hỏng. Laravel có Relation::enforceMorphMap cho việc này.
  • CHECK giới hạn danh sách type (MySQL thực thi CHECK từ 8.0.16).
  • Index bắt đầu bằng (commentable_type, commentable_id).
  • Job định kỳ tìm và dọn mồ côi:
sql
SELECT c.id FROM comments c
WHERE c.commentable_type = 'post'
  AND NOT EXISTS (SELECT 1 FROM posts p WHERE p.id = c.commentable_id);

2. Nhiều cột khoá ngoại ​

Mỗi loại cha một cột, chỉ đúng một cột được khác NULL. Tên gọi khác: exclusive arc.

sql
CREATE TABLE comments (
  id         BIGINT PRIMARY KEY,
  post_id    BIGINT NULL,
  video_id   BIGINT NULL,
  product_id BIGINT NULL,
  user_id    BIGINT NOT NULL,
  body       TEXT NOT NULL,
  created_at TIMESTAMP NOT NULL,
  FOREIGN KEY (post_id)    REFERENCES posts (id),
  FOREIGN KEY (video_id)   REFERENCES videos (id),
  FOREIGN KEY (product_id) REFERENCES products (id),
  CHECK (
    (CASE WHEN post_id    IS NOT NULL THEN 1 ELSE 0 END)
  + (CASE WHEN video_id   IS NOT NULL THEN 1 ELSE 0 END)
  + (CASE WHEN product_id IS NOT NULL THEN 1 ELSE 0 END) = 1
  )
);
CREATE INDEX idx_comments_post    ON comments (post_id);
CREATE INDEX idx_comments_video   ON comments (video_id);
CREATE INDEX idx_comments_product ON comments (product_id);

Được: khoá ngoại thật, không có mồ côi, JOIN theo từng cột rõ ràng.

Mất: thêm loại cha là ALTER TABLE thêm cột, khoá ngoại, index và sửa CHECK. Nhiều cột NULL. Hợp khi chỉ có vài loại cha và ít khi thêm.

Lưu ý theo engine:

  • PostgreSQL viết CHECK gọn hơn: CHECK (num_nonnulls(post_id, video_id, product_id) = 1).
  • MySQL không cho dùng ON DELETE CASCADE hay SET NULL trên cột có mặt trong CHECK. Vì vậy ví dụ trên để mặc định: muốn xoá bài thì xoá comment trước.
  • PostgreSQL không tự tạo index cho cột khoá ngoại; MySQL InnoDB thì tự tạo nếu chưa có. Viết rõ CREATE INDEX như trên chạy được cả hai.

3. Bảng riêng cho từng loại ​

post_comments, video_comments, product_comments, mỗi bảng có khoá ngoại tới đúng bảng cha.

sql
CREATE TABLE post_comments (
  id         BIGINT PRIMARY KEY,
  post_id    BIGINT NOT NULL,
  user_id    BIGINT NOT NULL,
  body       TEXT NOT NULL,
  created_at TIMESTAMP NOT NULL,
  FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE
);
-- video_comments, product_comments giống hệt, chỉ khác cột cha

Được: khoá ngoại đầy đủ, ON DELETE CASCADE dùng thoải mái, mỗi bảng có thể có cột và quy tắc riêng (bình luận sản phẩm có số sao).

Mất: câu hỏi xuyên loại (mọi comment của một user, mọi comment chờ kiểm duyệt) phải UNION ALL tất cả các bảng; code và migration lặp lại theo số loại.

Với tag, đây thường là lựa chọn đúng: post_tags(post_id, tag_id), video_tags(video_id, tag_id) dùng chung bảng tags. Mỗi bảng nối có khoá ngoại hai đầu, và câu hỏi hay gặp nhất (tag của bài này, bài có tag này) chỉ chạm một bảng nối.

4. Bảng cha chung ​

Tạo một bảng đại diện cho mọi thứ có thể được bình luận. Bài viết, video, sản phẩm mỗi cái có một dòng ở đó; comment trỏ vào bảng chung.

sql
CREATE TABLE commentables (
  id    BIGINT PRIMARY KEY,
  kind  VARCHAR(20) NOT NULL,     -- 'post' | 'video' | 'product'
  title VARCHAR(200) NOT NULL     -- tuỳ chọn: tên hiển thị, để khỏi JOIN xuống bảng con
);

CREATE TABLE posts (
  id             BIGINT PRIMARY KEY,
  commentable_id BIGINT NOT NULL UNIQUE,
  title          VARCHAR(200) NOT NULL,
  FOREIGN KEY (commentable_id) REFERENCES commentables (id) ON DELETE CASCADE
);
-- videos, products cũng có commentable_id như vậy

CREATE TABLE comments (
  id             BIGINT PRIMARY KEY,
  commentable_id BIGINT NOT NULL,
  user_id        BIGINT NOT NULL,
  body           TEXT NOT NULL,
  created_at     TIMESTAMP NOT NULL,
  FOREIGN KEY (commentable_id) REFERENCES commentables (id) ON DELETE CASCADE
);
CREATE INDEX idx_comments_target ON comments (commentable_id, created_at);

Được: khoá ngoại đầy đủ, thêm loại cha không đổi bảng comments, và nhiều tính năng dùng chung một bảng cha: likes, attachments, taggings đều trỏ vào commentables (lúc đó nên đặt tên chung hơn, ví dụ entities).

Mất: thêm một tầng gián tiếp. Tạo bài viết phải tạo dòng commentables trước, trong cùng transaction. Xoá thì xoá dòng commentables để CASCADE dọn cả bài lẫn comment. Lấy comment của bài #1 phải đi qua posts.commentable_id.

Biến thể: bảng con dùng luôn id của bảng cha làm khoá chính (posts.id REFERENCES commentables(id)), giống bảng con theo loại. Bớt được một cột, nhưng khó áp vào bảng đã có sẵn dữ liệu.

So sánh ​

Thử: cùng một tình huống trên bốn thiết kế
1. Cặp cột loại + id
Có vấn đề

Bài bị xoá, 3 comment ở lại thành mồ côi. Ứng dụng phải tự xoá comment trong cùng transaction, và chỗ nào quên là có rác.

DELETE FROM posts WHERE id = 1;
-- comments vẫn còn 3 dòng
-- ('post', 1, …)
Ổn ở 2/5 tình huống
2. Nhiều cột khoá ngoại
Ổn

Khoá ngoại chặn xoá bài khi còn comment. Xoá comment trước rồi mới xoá bài. Không thể có mồ côi.

DELETE FROM comments WHERE post_id = 1;
DELETE FROM posts WHERE id = 1;
Ổn ở 4/5 tình huống
3. Bảng riêng cho từng loại
Ổn

post_comments có khoá ngoại ON DELETE CASCADE: comment đi theo bài.

DELETE FROM posts WHERE id = 1;
-- post_comments tự xoá theo CASCADE
Ổn ở 3/5 tình huống
4. Bảng cha chung
Ổn

Xoá dòng ở bảng cha chung: CASCADE xoá cả bài lẫn comment.

DELETE FROM commentables WHERE id = 101;
-- posts và comments trỏ tới 101
-- tự xoá theo CASCADE
Ổn ở 4/5 tình huống
1. Loại + id2. Nhiều cột FK3. Bảng riêng4. Bảng cha chung
Khoá ngoạiKhôngCóCóCó
Thêm loại cha mớiKhông đổi schemaThêm cột vào commentsThêm bảng, sửa UNIONThêm cột vào bảng cha mới
Comment của một chaIndex (type, id)Index từng cộtMột bảngQua commentable_id
Câu hỏi xuyên loạiDễDễUNION ALLDễ
Lấy thông tin chaLEFT JOIN từng loại có điều kiện typeLEFT JOIN từng cộtCó sẵnMột JOIN nếu bảng chung lưu tên
Hỗ trợ ORMRails, Laravel có sẵnNhư quan hệ thườngNhư quan hệ thườngNhư quan hệ thường

Chọn cách nào ​

Ba câu hỏi:

  1. Có cần database đảm bảo toàn vẹn không? Tiền, đơn hàng, quyền truy cập: cần. Nhật ký hoạt động, thông báo: thường không, và nhật ký còn phải giữ lại sau khi cha bị xoá.
  2. Số loại cha có tăng thường xuyên không?
  3. Có hay hỏi xuyên mọi loại không? (mọi comment của một user, hàng chờ kiểm duyệt)
Tình huốngChọn
Vài loại cha cố định, cần toàn vẹn2. Nhiều cột khoá ngoại
Nhiều tính năng (comment, like, file, tag) cùng gắn vào nhiều loại4. Bảng cha chung
Tag cho vài loại đối tượng3. Bảng nối riêng mỗi loại, dùng chung bảng tags
Mỗi loại có cột, quy tắc riêng; ít hỏi xuyên loại3. Bảng riêng
Nhật ký, thông báo, audit; loại tăng liên tục1. Loại + id, có CHECK và job dọn
Dự án Rails/Laravel, dữ liệu không quan trọng1. Loại + id (mặc định của framework)

Ghi nhớ

  1. Polymorphic là khi phía cha có nhiều loại bảng; tag chỉ polymorphic khi một bộ tag dùng cho nhiều loại đối tượng.
  2. Cặp cột loại + id dễ làm nhất nhưng không có khoá ngoại: có mồ côi, có id trỏ vào hư không.
  3. Nhiều cột khoá ngoại + CHECK đúng một cột khác NULL: toàn vẹn đầy đủ, hợp khi ít loại.
  4. Bảng riêng mỗi loại: toàn vẹn đầy đủ, nhưng câu hỏi xuyên loại phải UNION.
  5. Bảng cha chung: toàn vẹn đầy đủ, dễ mở rộng, đáng dùng khi nhiều tính năng cùng gắn vào nhiều loại.