บทเรียน 115 · Rails · Day 2

Association — บทความ ผู้เขียน คอมเมนต์ ผูกกันยังไง

~25 นาที (ลงมือใน console เยอะ) · ควรผ่าน บทเรียน 107 (console CRUD + ข้อมูลตัวอย่าง) และ บทเรียน 113 (query) มาก่อน

ในแอปบล็อกของเรา คอมเมนต์ต้องรู้ว่ามันอยู่ใต้บทความไหน · บทความต้องรู้ว่าใครเขียน
บทนี้จะอธิบายว่าข้อมูลสองลิ้นชัก "ผูกกัน" ด้วยอะไร แล้วให้คุณจำกฎข้อเดียวที่ใช้ตัดสินได้ทุกกรณีว่าต้องเขียน belongs_to หรือ has_many ไว้ที่ไหน
อ่านจบแล้วคุณจะสร้างคอมเมนต์ใต้บทความได้โดยไม่ต้องพิมพ์เลขที่บทความเอง · ตั้งชื่อทางลัดอย่าง "บทความที่เผยแพร่แล้วของคนนี้" ได้ · และติดป้าย (tag) ให้บทความได้ ซึ่งเป็นความสัมพันธ์แบบ "หลายต่อหลาย"

🔁 ทวนก่อนเริ่ม — คำพวกนี้จะโผล่ในบทนี้ทั้งหมด

🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 17 (สไลด์ใช้ Ruby 4.0.6 · Rails 8.1.3 — เรื่องในบทนี้ไม่ต่างกัน)
📦 ข้อมูลตัวอย่าง: ใช้ตาราง users / posts / comments และข้อมูลตัวอย่าง (seeds) ชุดเดียวกับ บท 107 หัวข้อ "เตรียมตู้ของวันที่ 2" (ผู้ใช้ 3 · บทความ 6 · คอมเมนต์ 7) · ถ้าเล่นบทอื่นจนข้อมูลเปลี่ยน ให้ exit ออกจาก console แล้วทำขั้นที่ 6 ของบท 107 อีกรอบ (bin/rails db:migrate:reset แล้ว bin/rails db:seed) เลขที่ต่าง ๆ จะได้ตรงกับในบท
ผลรันทุกกล่องมาจากเครื่องจริง · เวลา (ms) · เลขที่อยู่ object (0x0000…) และวันเวลาของคุณจะไม่ตรงกับของผม

1 · ข้อมูลที่ผูกกัน — ใบคอมเมนต์มีช่อง "เป็นของบทความเลขที่ …"

แอปบล็อกเก็บของไว้ 3 ลิ้นชัก: ผู้ใช้ (users) · บทความ (posts) · คอมเมนต์ (comments) · คอมเมนต์ "น่ากินมาก" ต้องรู้ว่ามันอยู่ใต้บทความไหน และใครเป็นคนเขียน

วิธีที่ฐานข้อมูลใช้คือไม่ก๊อปบทความทั้งเรื่องมาไว้บนใบคอมเมนต์ แต่จดแค่เลขที่ของบทความ (id) ไว้ในช่องหนึ่งบนใบคอมเมนต์ · ช่องนั้นชื่อ post_id แปลว่า "เป็นของบทความเลขที่ …"

นี่คือหน้าตาจริงของสองลิ้นชักในแอปเรา (ตัดมาบางแถว บางช่อง):

ลิ้นชัก posts ลิ้นชัก comments ┌─────┬──────────────────┐ ┌─────┬──────────────────────┬─────────┬─────────┐ │ id │ title │ │ id │ body │ post_id │ user_id │ ├─────┼──────────────────┤ ├─────┼──────────────────────┼─────────┼─────────┤ │ 1 │ ต้มยำกุ้งน้ำข้น │ │ 1 │ น่ากินมาก │ 1 │ 2 │ │ 2 │ ส้มตำปูปลาร้า │ │ 2 │ ขอสูตรน้ำพริกเผาด้วย │ 1 │ 3 │ └─────┴──────────────────┘ │ 3 │ เผ็ดแค่ไหนดี │ 2 │ 3 │ └─────┴──────────────────────┴─────────┴─────────┘ post_id ของคอมเมนต์ 1 กับ 2 เป็น 1 → อยู่ใต้ "ต้มยำกุ้งน้ำข้น" · คอมเมนต์ 3 อยู่ใต้ "ส้มตำปูปลาร้า"

ทำไมช่องนี้อยู่บนใบคอมเมนต์ ไม่ใช่บนบทความ? บทความหนึ่งชิ้นมีคอมเมนต์ได้ 0 อัน 2 อัน หรือ 100 อัน — เราเพิ่มช่องบนแฟ้มบทความให้พอทุกกรณีไม่ได้ · แต่คอมเมนต์หนึ่งอันอยู่ใต้บทความได้แค่ชิ้นเดียวเสมอ แค่ช่องเดียวก็พอ
ด้วยเหตุผลเดียวกัน บทความก็มีช่อง user_id = "เขียนโดยผู้ใช้เลขที่ …"

📦 เทียบให้เห็นภาพ — พัสดุที่ห้องนิติของคอนโด

พัสดุทุกกล่องที่วางบนชั้นห้องนิติมีเลขห้อง "1203" เขียนไว้ · ห้อง 1203 ไม่ต้องจดว่าตัวเองมีพัสดุกี่กล่อง อยากรู้ว่าห้อง 1203 มีพัสดุอะไรบ้าง นิติก็ไล่หากล่องที่เขียนว่า "1203"
ตู้แฟ้มของร้านเราก็เหมือนกัน: ใบคอมเมนต์มีช่อง "เป็นของบทความเลขที่ …" (post_id) · อยากรู้ว่าบทความเลข 1 มีคอมเมนต์อะไร คนดูแลสต็อกก็ไปลิ้นชัก comments หยิบทุกแผ่นที่ช่องนี้เขียนว่า 1

ที่ไม่เหมือนกัน: คนส่งของเขียน "ห้อง 9999" ทั้งที่ตึกมีแค่ 30 ชั้นก็ไม่มีใครห้าม · แต่ตู้ของเราตั้งกฎไว้ (foreign key — เดี๋ยวเห็นในหัวข้อ 2) ว่าเลขที่ต้องมีแฟ้มนั้นอยู่จริง ไม่งั้นไม่รับ · และเลขบนใบคอมเมนต์ไม่ใช่ตัวบทความ — ถ้าวันหนึ่งแฟ้มบทความถูกทิ้ง ใบคอมเมนต์ที่อ้างถึงจะเป็นยังไง อยู่ใน บท 116

🗣 ศัพท์ที่เพิ่งเรียนไป
primary key
ช่องที่เป็น "เลขที่แฟ้ม" ไม่ซ้ำกันในลิ้นชัก · ใน Rails คือช่อง id ที่สร้างให้เองทุกตาราง · ถาม senior ได้ว่า "ตารางนี้ใช้ id ธรรมดาเป็น primary key หรือใช้แบบอื่น"
foreign key
ช่องที่เก็บเลขที่ของแฟ้มในลิ้นชักอื่น เช่น comments.post_id · ชื่อมักลงท้ายด้วย _id · คำเดียวกันยังใช้เรียก "กฎที่ตู้" ที่บังคับว่าเลขต้องมีอยู่จริงด้วย · พิมพ์บอก AI ได้ว่า "comments.post_id is a foreign key to posts.id"
association
การบอก model ว่า "แฟ้มลิ้นชักนี้ผูกกับลิ้นชักนั้น" เพื่อให้ Rails สร้าง method อย่าง comment.post ให้ · ถาม senior ได้ว่า "Post กับ Comment ใน repo นี้ผูก association ไว้ครบสองฝั่งไหม"

2 · กฎข้อเดียว: belongs_to อยู่ฝั่งที่มีคอลัมน์ _id

ช่อง _id อยู่ตารางไหน เปิดดูได้ในแปลนตู้ db/schema.rb (บท 108) · นี่คือของจริงจากแอปเรา ตัดมาเฉพาะส่วนที่เกี่ยว:

# db/schema.rb (ตัดมาบางส่วน)
  create_table "comments", force: :cascade do |t|
    t.text "body"
    t.bigint "post_id", null: false
    t.bigint "user_id", null: false
    …
  end

  create_table "posts", force: :cascade do |t|
    t.string "title"
    …
    t.bigint "user_id", null: false
    …
  end

  create_table "users", force: :cascade do |t|
    t.string "name"
    t.string "email"
    …
  end

  add_foreign_key "comments", "posts"
  add_foreign_key "comments", "users"
  add_foreign_key "posts", "users"

ส่วน model ทั้งสามไฟล์ในแอปเราเขียนไว้แบบนี้ (ตั้งแต่บท 107):

# app/models/comment.rb
class Comment < ApplicationRecord
  belongs_to :post   # comments มี post_id
  belongs_to :user   # comments มี user_id
end

# app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user   # posts มี user_id
  has_many :comments # post_id อยู่ฝั่ง comments
end

# app/models/user.rb
class User < ApplicationRecord
  has_many :posts    # user_id อยู่ฝั่ง posts
  has_many :comments # user_id อยู่ฝั่ง comments
end

สังเกตว่าทุกบรรทัดตอบได้ด้วยกฎเดียว: ตารางไหนมีช่อง xxx_id → model ของตารางนั้นเขียน belongs_to :xxx · ฝั่งที่ถูกชี้ถึงเขียน has_many (มีได้หลายอัน)
users ไม่มีช่อง _id เลย → User จึงไม่มี belongs_to สักบรรทัด

✅ กฎข้อเดียวที่ต้องจำ: belongs_to อยู่ฝั่งที่มีคอลัมน์ _id · สงสัยเมื่อไหร่ เปิด db/schema.rb หาว่าช่อง _id อยู่ใน create_table ไหน ตอบได้ทุกกรณี

3 · ถามแฟ้มว่าเป็นของใคร — ลองใน console

พอเขียน belongs_to / has_many ไว้แล้ว Rails สร้าง method ให้ฟรี · เปิด bin/rails console แล้วพิมพ์ตามทีละบรรทัด (ผลจริง):

wongnok-blog(dev):001> comment = Comment.find(1)
  Comment Load (2.2ms)  SELECT "comments".* FROM "comments" WHERE "comments"."id" = 1 LIMIT 1 /*application='WongnokBlog'*/
=>
#<Comment:0x0000000123e82ca8
...
wongnok-blog(dev):002> comment.post_id
=> 1
wongnok-blog(dev):003> comment.post.title
  Post Load (1.3ms)  SELECT "posts".* FROM "posts" WHERE "posts"."id" = 1 LIMIT 1 /*application='WongnokBlog'*/
=> "ต้มยำกุ้งน้ำข้น"
wongnok-blog(dev):004> comment.user.name
  User Load (1.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = 2 LIMIT 1 /*application='WongnokBlog'*/
=> "สมหญิง"

ทีนี้ถามกลับทาง — จากบทความ ไปหาคอมเมนต์ทั้งหมด:

wongnok-blog(dev):005> post = Post.find(1)
  Post Load (1.0ms)  SELECT "posts".* FROM "posts" WHERE "posts"."id" = 1 LIMIT 1 /*application='WongnokBlog'*/
=>
#<Post:0x0000000124c2e618
...
wongnok-blog(dev):006> post.comments
  Comment Load (1.0ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 1 /* loading for pp */ LIMIT 11 /*application='WongnokBlog'*/
=>
[#<Comment:0x0000000124c2c958
  id: 1,
  body: "น่ากินมาก",
  post_id: 1,
  user_id: 2,
  created_at: "2026-11-04 12:02:56.246385000 +0000",
  updated_at: "2026-11-04 12:02:56.246385000 +0000">,
 #<Comment:0x0000000124c2c818
  id: 2,
  body: "ขอสูตรน้ำพริกเผาด้วย",
  post_id: 1,
  user_id: 3,
  created_at: "2026-11-04 12:02:56.250122000 +0000",
  updated_at: "2026-11-04 12:02:56.250122000 +0000">]

เทียบกับ Go (GORM): บท 27 เราเขียน struct ให้ GORM ทีละช่อง · ถ้าจะผูกบทความกับคอมเมนต์ GORM ใช้ field สองแบบ (ผม compile และรันกับ Postgres จริงแล้ว):

type Post struct {
    ID       uint
    Title    string
    Comments []Comment // has many: ไม่มีคอลัมน์เพิ่มในตาราง posts
}

type Comment struct {
    ID     uint
    Body   string
    PostID uint // คอลัมน์ post_id อยู่ฝั่งนี้
    Post   Post // belongs to
}

// อยากได้คอมเมนต์มาด้วย ต้องสั่ง Preload เอง
db.Preload("Comments").First(&post, 1)

กฎเดียวกันเป๊ะ: PostID อยู่ใน Comment · ต่างกันที่ GORM ไม่ไปหยิบให้ถ้าไม่สั่ง Preload (ผมลอง db.First(&post, 1) เฉย ๆ ได้ len(post.Comments) เป็น 0) · Rails ไปหยิบให้เองตอนที่คุณใช้ post.comments ไม่ต้องสั่งล่วงหน้า — สะดวก แต่ก็เผลอไปตู้บ่อยเกินได้ (นั่นคือ N+1 ใน บท 114)

4 · ความสัมพันธ์ 5 แบบ — ใช้กฎเดียวกันทั้งหมด

Rails มีคำสั่งผูกความสัมพันธ์ 5 แบบ · ทุกแบบตอบด้วยคำถามเดียว "ช่อง _id อยู่ตารางไหน" (แถว has_one กับ habtm ไม่มีในแอปเรา เป็นตัวอย่างให้เห็นภาพเฉย ๆ):

คำสั่งภาษาคนตัวอย่างในบล็อกช่อง _id อยู่ที่ได้ method
belongs_to"ฉันเป็นของ…หนึ่งตัว" Comment เป็นของ Postcomments.post_id (ตารางตัวเอง)comment.post
has_many"ฉันมี…ได้หลายตัว" Post มีหลาย Commentไม่มีในตารางตัวเอง (อยู่ที่ comments)post.comments
has_one"ฉันมี…ได้แค่ตัวเดียว" User มีหน้าโปรไฟล์ Profile หนึ่งหน้าprofiles.user_id (อีกตาราง)user.profile
has_many :through"หลายต่อหลาย ผ่านตารางกลางที่มี model" Post ติดได้หลาย Tag · Tag อยู่ได้หลาย Postตารางกลาง taggings มีทั้ง post_id และ tag_idpost.tags
has_and_belongs_to_many
(เรียกย่อว่า habtm)
"หลายต่อหลาย ตารางกลางเปล่า ๆ ไม่มี model" เหมือนแถวบนตารางกลาง posts_tagspost.tags

คู่ที่คนสับสนบ่อยคือ belongs_to กับ has_one เพราะภาษาอังกฤษฟังคล้ายกัน ("โปรไฟล์เป็นของผู้ใช้" กับ "ผู้ใช้มีโปรไฟล์") · ไม่ต้องเดาจากความหมาย ใช้กฎ: ช่อง user_id อยู่ที่ profiles → Profile belongs_to :user · User เป็นฝั่ง has_one

🗣 ศัพท์ที่ใช้คุยเรื่องรูปแบบความสัมพันธ์
one-to-many (1 ต่อหลาย)
หนึ่งบทความ หลายคอมเมนต์ · ในโค้ดคือคู่ has_many + belongs_to · ถาม senior ได้ว่า "Post กับ Comment เป็น one-to-many ใช่ไหม foreign key อยู่ที่ comments"
one-to-one (1 ต่อ 1)
หนึ่งผู้ใช้ หนึ่งโปรไฟล์ · คู่ has_one + belongs_to · พิมพ์บอก AI ได้ว่า "User has_one Profile, profiles table has user_id"
many-to-many (หลายต่อหลาย)
บทความหลายชิ้น กับป้ายหลายป้าย · ต้องมีตารางกลางเสมอ (หัวข้อ 7) · ถาม senior ได้ว่า "many-to-many ตัวนี้ใช้ has_many :through หรือ habtm"

5 · ของฟรีจาก has_many — สร้างคอมเมนต์โดยไม่ต้องเขียนเลขที่เอง

has_many :comments บรรทัดเดียวให้ method มาเป็นสิบตัว · ตัวที่ใช้บ่อยที่สุดคือสร้างคอมเมนต์ผ่านบทความ ซึ่ง Rails เติม post_id ให้เอง · เปิด console ใหม่แล้วลอง (ผลจริง):

wongnok-blog(dev):001> post = Post.find(1)
  Post Load (1.3ms)  SELECT "posts".* FROM "posts" WHERE "posts"."id" = 1 LIMIT 1 /*application='WongnokBlog'*/
=>
#<Post:0x00000001239e87b0
...
wongnok-blog(dev):002> mana = User.find_by(name: "มานะ")
  User Load (1.7ms)  SELECT "users".* FROM "users" WHERE "users"."name" = 'มานะ' LIMIT 1 /*application='WongnokBlog'*/
=>
#<User:0x0000000123e6cbb0
...
wongnok-blog(dev):003> c = post.comments.create(user: mana, body: "ใส่มะนาวเยอะ ๆ")
  TRANSACTION (1.9ms)  BEGIN /*application='WongnokBlog'*/
  Comment Create (4.4ms)  INSERT INTO "comments" ("body", "post_id", "user_id", "created_at", "updated_at") VALUES ('ใส่มะนาวเยอะ ๆ', 1, 3, '2026-11-04 12:39:13.586700', '2026-11-04 12:39:13.586700') RETURNING "id" /*application='WongnokBlog'*/
  TRANSACTION (1.4ms)  COMMIT /*application='WongnokBlog'*/
=>
#<Comment:0x00000001240458b0
...
wongnok-blog(dev):004> c.post_id
=> 1
# สไลด์หน้า 41 เขียนไว้แบบนี้ — ลองพิมพ์ต่อใน console เดิม
hi = Post.find(3).comments.create(body: "hi")
hi.persisted?
hi.errors.full_messages

คอมเมนต์ "hi" จะถูกเก็บลงตู้ไหม? persisted? ได้อะไร?

ผลจริง — ไม่ถูกเก็บ และไม่มี error สีแดงเด้งขึ้นมาด้วย:

wongnok-blog(dev):005> hi = Post.find(3).comments.create(body: "hi")
  Post Load (2.5ms)  SELECT "posts".* FROM "posts" WHERE "posts"."id" = 3 LIMIT 1 /*application='WongnokBlog'*/
=>
#<Comment:0x0000000124152fa0
...
wongnok-blog(dev):006> hi.persisted?
=> false
wongnok-blog(dev):007> hi.errors.full_messages
=> ["User must exist"]

ในแอปเรา Comment มี belongs_to :user ด้วย · belongs_to เพิ่มกฎ "ต้องมีเจ้าของ" ให้เองเสมอ (เจอแล้วในบท 110) · สไลด์มีแค่ post กับ body เลยผ่าน แต่ของเราต้องบอกผู้เขียนด้วย · สังเกตว่าไม่มี INSERT เลย (มีแค่ SELECT ของ Post.find(3)) — Rails ตรวจแล้วปฏิเสธก่อนจะเก็บลงตู้ · create ไม่มี ! จึงคืนแฟ้มที่ไม่ผ่านให้เงียบ ๆ (บท 110)

ท่ายาว — เขียนเลขที่เอง

ถ้าไม่ใช้ has_many ก็สร้างคอมเมนต์ตรง ๆ ได้ แต่ต้องกรอกเลขเองทุกช่อง · เทียบสองแบบ:

# ท่ายาว: กรอกเลขที่เอง
Comment.create(post_id: post.id,
               user_id: mana.id,
               body: "…")
# ท่าสั้น: ฝากผ่านบทความ
post.comments.create(user: mana,
                     body: "…")

ได้ INSERT หน้าตาเดียวกัน · แต่ท่ายาวมีที่ให้พลาดเพิ่มหนึ่งที่: ถ้าพิมพ์ post_id: 2 ทั้งที่ตั้งใจจะใส่ 1 ก็เก็บได้สบาย ๆ เพราะบทความเลข 2 มีจริง ไม่มีใครฟ้อง คอมเมนต์ไปโผล่ผิดบทความ · ท่าสั้นพลาดแบบนี้ไม่ได้เลย เพราะเลขมาจากบทความที่ถืออยู่ในมือ
เทียบกับ Go: ใน GORM ของบท 27 เราก็ต้องเขียน Comment{PostID: post.ID, …} เอง — คือท่ายาวนี่แหละ

อีกเรื่องที่เห็นได้จาก log ของท่ายาว (ผลจริง · ผมเปิด console อีกหน้าต่างที่ใช้บทความเลข 2) · ท่อนนี้ดูอย่างเดียวพอ ไม่ต้องพิมพ์ตาม จะได้ไม่หลุดจาก console ของหัวข้อนี้:

wongnok-blog(dev):001> post = Post.find(2)
  Post Load (1.2ms)  SELECT "posts".* FROM "posts" WHERE "posts"."id" = 2 LIMIT 1 /*application='WongnokBlog'*/
=>
#<Post:0x0000000124c78d80
...
wongnok-blog(dev):002> long = Comment.create(post_id: post.id, user_id: 1, body: "ท่ายาว")
  TRANSACTION (2.0ms)  BEGIN /*application='WongnokBlog'*/
  Post Load (3.8ms)  SELECT "posts".* FROM "posts" WHERE "posts"."id" = 2 LIMIT 1 /*application='WongnokBlog'*/
  User Load (1.3ms)  SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1 /*application='WongnokBlog'*/
  Comment Create (2.6ms)  INSERT INTO "comments" ("body", "post_id", "user_id", "created_at", "updated_at") VALUES ('ท่ายาว', 2, 1, '2026-11-04 12:40:07.969344', '2026-11-04 12:40:07.969344') RETURNING "id" /*application='WongnokBlog'*/
  TRANSACTION (1.8ms)  COMMIT /*application='WongnokBlog'*/
…

ก่อน INSERT มี SELECT สองครั้ง — Rails เดินไปเช็คว่า "บทความเลข 2 มีจริงไหม · ผู้ใช้เลข 1 มีจริงไหม" ตามกฎของ belongs_to · ท่าสั้นไม่ต้องเช็ค เพราะเราส่งแฟ้มตัวจริงให้แล้ว
และถ้าไม่บอกบทความเลย ก็โดนปฏิเสธแบบเดียวกับ "hi" ข้างบน (ผลจริง หน้าต่างเดียวกัน):

wongnok-blog(dev):003> bad = Comment.create(user_id: 1, body: "ไม่มีบทความ")
…
wongnok-blog(dev):004> bad.errors.full_messages
=> ["Post must exist"]
📂 เทียบให้เห็นภาพ — ยื่นใบคอมเมนต์ผ่านแฟ้มบทความ

ท่าสั้นเหมือนคุณหยิบแฟ้ม "ต้มยำกุ้งน้ำข้น" ขึ้นมาแล้วบอกคนดูแลสต็อกว่า "เพิ่มใบคอมเมนต์ใบนี้ให้แฟ้มนี้" — เขาเห็นเลขที่บนแฟ้มที่คุณถืออยู่ ก็เขียนช่อง "เป็นของบทความเลขที่ 1" ให้เอง · ท่ายาวคือคุณเดินไปลิ้นชัก comments กรอกเลขที่เองจากความจำ — จำผิดหนึ่งตัวก็ไปผิดแฟ้ม

ที่ไม่เหมือนกัน: ภาพนี้ชวนให้คิดว่าใบคอมเมนต์ถูก "เย็บติด" ไว้ในแฟ้มบทความ · ความจริงใบคอมเมนต์ยังอยู่ในลิ้นชัก comments ของมันเหมือนเดิม แค่มีเลข 1 อยู่ในช่อง post_id · ทุกครั้งที่เรียก post.comments Rails ต้องเดินไปค้นลิ้นชัก comments ใหม่ (เห็น SQL ทุกครั้ง)

build · count กับ size · comment_ids · <<

กลับมาที่ console ของหัวข้อนี้ (ที่ยังมี post = บทความ 1 กับ mana อยู่ · เลขบรรทัดข้างล่างนับต่อจาก predict "hi" ถ้าข้าม predict ไป เลขของคุณจะน้อยกว่า 3 ไม่เป็นไร) · ถ้าปิดไปแล้ว พิมพ์แค่บรรทัด 001–002 ใหม่ อย่าพิมพ์ create ซ้ำ ไม่งั้นตัวเลขจะไม่ตรงกับข้างล่าง · ลองสร้างแบบ "ถือไว้ในมือก่อน" ด้วย build แล้วนับดู (ผลจริง):

wongnok-blog(dev):008> draft = post.comments.build(user: mana, body: "ยังไม่ได้เก็บ")
=>
#<Comment:0x0000000124151ce0
...
wongnok-blog(dev):009> draft.post_id
=> 1
wongnok-blog(dev):010> draft.new_record?
=> true
wongnok-blog(dev):011> post.comments.count
  Comment Count (2.9ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 1 /*application='WongnokBlog'*/
=> 3
wongnok-blog(dev):012> post.comments.size
  Comment Count (2.6ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 1 /*application='WongnokBlog'*/
=> 4
wongnok-blog(dev):013> draft.save
  TRANSACTION (1.3ms)  BEGIN /*application='WongnokBlog'*/
  Comment Create (2.7ms)  INSERT INTO "comments" ("body", "post_id", "user_id", "created_at", "updated_at") VALUES ('ยังไม่ได้เก็บ', 1, 3, '2026-11-04 12:39:29.596118', '2026-11-04 12:39:29.596118') RETURNING "id" /*application='WongnokBlog'*/
  TRANSACTION (1.4ms)  COMMIT /*application='WongnokBlog'*/
=> true
wongnok-blog(dev):014> post.comments.count
  Comment Count (1.5ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 1 /*application='WongnokBlog'*/
=> 4

อยากได้แค่เลขที่ของคอมเมนต์ทั้งหมดใต้บทความหนึ่ง ใช้ comment_ids (ผลจริง จากบทความเลข 5 ที่มี 3 คอมเมนต์ใน seeds):

wongnok-blog(dev):001> Post.find(5).comment_ids
  Post Load (1.7ms)  SELECT "posts".* FROM "posts" WHERE "posts"."id" = 5 LIMIT 1 /*application='WongnokBlog'*/
  Comment Pluck (1.4ms)  SELECT "comments"."id" FROM "comments" WHERE "comments"."post_id" = 5 /*application='WongnokBlog'*/
=> [5, 6, 7]

ชื่อ method มาจากชื่อความสัมพันธ์: has_many :comments → ตัด s เติม _ids → comment_ids · SQL ขอแค่ช่อง id เหมือน pluck(:id) ของบท 113

สุดท้าย << (อ่านว่า "ต่อท้าย" — เครื่องหมายเดียวกับที่ใช้เติมของลง array ใน Ruby) ใช้กับคอมเมนต์ที่สร้างไว้ก่อนแล้ว (ผลจริง พิมพ์ต่อใน console เดิมที่มี post กับ mana):

wongnok-blog(dev):015> extra = Comment.new(user: mana, body: "ต่อท้ายด้วยลูกศร")
=>
#<Comment:0x0000000120b3e248
...
wongnok-blog(dev):016> post.comments << extra
  TRANSACTION (2.0ms)  BEGIN /*application='WongnokBlog'*/
  Comment Create (3.9ms)  INSERT INTO "comments" ("body", "post_id", "user_id", "created_at", "updated_at") VALUES ('ต่อท้ายด้วยลูกศร', 1, 3, '2026-11-04 12:39:34.387864', '2026-11-04 12:39:34.387864') RETURNING "id" /*application='WongnokBlog'*/
  TRANSACTION (2.0ms)  COMMIT /*application='WongnokBlog'*/
  Comment Load (1.5ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 1 /* loading for pp */ LIMIT 11 /*application='WongnokBlog'*/
=>
[#<Comment:0x0000000120b3cd08
…(console โชว์คอมเมนต์ทั้ง 5 อันของบทความนี้ ผมตัดออก)
wongnok-blog(dev):017> extra.persisted?
=> true

<< ใส่ post_id ให้แล้วเก็บลงตู้ทันที ไม่ต้อง save ต่อ — ต่างจาก build ที่ยังอยู่ในมือ · (บรรทัด Comment Load … loading for pp ท้ายสุดคือ console ไปหยิบรายการมาโชว์บนจอ เหมือนหัวข้อ 3) · สรุปเป็นตาราง:

คำสั่งทำอะไรลงตู้ทันทีไหม
post.commentsใบเบิก "คอมเมนต์ทุกอันของบทความนี้" ต่อ .where / .order ได้— (แค่อ่าน)
post.comments.create(…)สร้างพร้อมเติม post_id✅ ลงเลย (ถ้าผ่านกฎ)
post.comments.build(…)สร้างไว้ในมือพร้อมเติม post_id❌ ต้อง save เอง
post.comments << cเอาคอมเมนต์ที่มีอยู่มาผูกกับบทความนี้✅ ลงเลย
post.comments.countนับในตู้เสมอ—
post.comments.sizeนับในตู้ + ของในมือที่ยังไม่เก็บ—
post.comment_idsเลขที่คอมเมนต์ทั้งหมด—

⚠️ สไลด์หน้า 41 มี post.comments.clear และ post.comment_ids = [1, 2] ("ตัดคอมเมนต์ทั้งหมดออกจากบทความนี้" กับ "ให้บทความนี้เหลือแค่คอมเมนต์ 1 และ 2") — ในแอปเราพิมพ์ตามแล้วพัง ไม่ต้องลองเอง · เพราะ "ตัดความสัมพันธ์" แปลว่าลบเลขในช่อง post_id ให้ว่าง แต่ตู้ของเราห้ามช่องนี้ว่าง (null: false) · ผลจริงจากบทความเลข 2 (ตัดบรรทัดยาวท้าย ๆ ออก):

wongnok-blog(dev):003> post.comments.clear
  Comment Update All (1.7ms)  UPDATE "comments" SET "post_id" = NULL WHERE "comments"."post_id" = 2 /*application='WongnokBlog'*/
(wongnok-blog):3:in '<main>': PG::NotNullViolation: ERROR:  null value in column "post_id" of relation "comments" violates not-null constraint (ActiveRecord::NotNullViolation)

UPDATE … SET "post_id" = NULL = "แก้ทุกแผ่นที่ post_id เป็น 2 ให้ช่องนี้ว่าง" → ตู้ปฏิเสธ ไม่มีอะไรเปลี่ยน · อยากลบคอมเมนต์จริง ๆ รอ บท 116

🗣 ศัพท์ของ method ที่ได้ฟรี
association methods / collection methods
method ที่ Rails สร้างให้จาก has_many เช่น create · build · << · comment_ids · พิมพ์บอก AI ได้ว่า "create the comment through the association (post.comments.create) instead of setting post_id manually"
build vs create
build = สร้างในหน่วยความจำ ยังไม่ลง DB · create = สร้างแล้ว save เลย · มี create! ที่ระเบิดถ้าเก็บไม่ได้ด้วย · ถาม senior ได้ว่า "ตรงนี้ควร build ก่อนแล้วค่อย save หรือ create ไปเลย"
required belongs_to
belongs_to ใน Rails สมัยนี้บังคับว่าต้องมีของที่ชี้ถึงเสมอ ได้ error "… must exist" · ถ้าอยากยอมให้ไม่มีเจ้าของได้ เขียน belongs_to :post, optional: true (แอปเราไม่ได้ใช้) · ถาม senior ได้ว่า "comment ตัวนี้ควร optional: true ไหม หรือต้องมี post เสมอ"

6 · ตั้งชื่อทางลัด — "บทความที่เผยแพร่แล้วของคนนี้"

หน้าโปรไฟล์ของผู้เขียนมักโชว์เฉพาะบทความที่เผยแพร่แล้ว · เขียนได้ว่า mana.posts.where(published: true) แต่ถ้าต้องพิมพ์ทุกที่ก็ยาวและลืมง่าย · Rails ให้ตั้งชื่อความสัมพันธ์ใหม่ที่มีเงื่อนไขติดมาด้วยได้ · แก้ไฟล์ app/models/user.rb เพิ่มบรรทัดสุดท้าย:

# app/models/user.rb
class User < ApplicationRecord
  has_many :posts
  has_many :comments
  has_many :published_posts, -> { where(published: true) }, class_name: "Post"
end

ถ้าลืม class_name จะเจอแบบนี้ตอนเรียกใช้ (ผลจริง ตัดบรรทัดยาวท้าย ๆ ออก):

wongnok-blog(dev):002> mana.published_posts
(wongnok-blog):2:in '<main>': Missing model class PublishedPost for the User#published_posts association. You can specify a different model class with the :class_name option. (NameError)

ใส่ class_name แล้วปิด console เปิดใหม่ (console ไม่เห็นไฟล์ที่แก้ระหว่างเปิดค้าง — บท 104) · ผลจริง:

wongnok-blog(dev):001> mana = User.find_by(name: "มานะ")
  User Load (2.0ms)  SELECT "users".* FROM "users" WHERE "users"."name" = 'มานะ' LIMIT 1 /*application='WongnokBlog'*/
=>
#<User:0x0000000125c40ad8
...
wongnok-blog(dev):002> mana.posts.pluck(:title)
  Post Pluck (1.4ms)  SELECT "posts"."title" FROM "posts" WHERE "posts"."user_id" = 3 /*application='WongnokBlog'*/
=> ["ผัดกะเพราหมูสับ", "แกงเขียวหวานไก่"]
wongnok-blog(dev):003> mana.published_posts.pluck(:title)
  Post Pluck (1.0ms)  SELECT "posts"."title" FROM "posts" WHERE "posts"."user_id" = 3 AND "posts"."published" = TRUE /*application='WongnokBlog'*/
=> ["ผัดกะเพราหมูสับ"]

SQL บรรทัดที่สองแปลว่า "ขอแค่ช่อง title จากลิ้นชัก posts เฉพาะแผ่นที่ user_id เป็น 3 และ published เป็นจริง" · เงื่อนไขของเราไปต่อท้ายให้เอง · ได้ 1 เรื่อง เพราะ "แกงเขียวหวานไก่" ยังเป็นฉบับร่าง

📝 เทียบให้เห็นภาพ — ใบเบิกแบบพิมพ์สำเร็จ

บท 113 บอกว่า relation คือใบเบิกของที่ยังไม่ยื่น · mana.posts คือใบเบิกเปล่าที่เขียนไว้แค่ "ของมานะ" · published_posts เหมือนร้านพิมพ์แบบฟอร์มใบเบิกไว้ล่วงหน้า มีบรรทัด "เฉพาะที่เผยแพร่แล้ว" ติดมาเลย · หยิบมาใช้เมื่อไหร่ก็ไม่ต้องเขียนเงื่อนไขเองอีก

ที่ไม่เหมือนกัน: แบบฟอร์มกระดาษพิมพ์ครั้งเดียวใช้ไปตลอด · แต่ lambda ถูก "รันใหม่" ทุกครั้งที่เรียก · และใบเบิกนี้ยังต่อเงื่อนไขได้อีก เช่น mana.published_posts.order(:title)

เทียบกับ Go: ฝั่ง Go ที่ไม่มีของแบบนี้ให้ เราจะเขียนฟังก์ชันเล็ก ๆ อย่าง PublishedPosts(db, userID) ที่ใส่ Where("published = ?", true) ไว้ข้างใน แล้วเรียกฟังก์ชันนั้นทุกที่ · Rails ให้เขียนแค่บรรทัดเดียวใน model แล้วได้ method มาเลย

🗣 ศัพท์ของหัวข้อนี้
scoped association
ความสัมพันธ์ที่มีเงื่อนไขติดมา (-> { where(…) }) · สไลด์หน้า 41 เรียกว่า "scope บน association" · พิมพ์บอก AI ได้ว่า "add a scoped has_many :published_posts on User with class_name Post"
lambda (-> { })
block ที่เก็บไว้เรียกทีหลัง · Rails ใช้บ่อยกับของที่ต้อง "คิดใหม่ทุกครั้งที่เรียก" · ถาม senior ได้ว่า "ทำไมตรงนี้ต้องห่อด้วย lambda ไม่เขียน where ตรง ๆ"
class_name
option ที่บอก Rails ว่าความสัมพันธ์นี้ชี้ไป model ไหน เมื่อชื่อที่ตั้งไม่ตรงกับชื่อ model · เห็น error "Missing model class …" ให้นึกถึง class_name ก่อน

7 · หลายต่อหลาย — ติดป้ายให้บทความด้วย has_many :through

อยากให้บทความติดป้ายได้ เช่น "อาหารเผ็ด" · "สตรีทฟู้ด" · บทความหนึ่งติดได้หลายป้าย และป้ายหนึ่งก็อยู่ได้หลายบทความ · ช่อง _id จะอยู่ไหน? ใส่ tag_id ที่ posts ไม่ได้ (มีหลายป้าย) · ใส่ post_id ที่ tags ก็ไม่ได้ (อยู่หลายบทความ)

ทางออกคือลิ้นชักที่สามอยู่ตรงกลาง ชื่อ taggings (การติดป้าย) · แฟ้มหนึ่งแผ่นในลิ้นชักนี้คือ "หนึ่งคู่": มีช่อง post_id และ tag_id · ติดสองป้ายให้บทความเดียว = สองแผ่น

posts taggings (ลิ้นชักกลาง) tags ┌─────┬─────────────────┐ ┌─────┬────────┬───────┐ ┌─────┬────────────┐ │ 1 │ ต้มยำกุ้งน้ำข้น │ │ id │ post_id│ tag_id│ │ 1 │ อาหารเผ็ด │ │ 2 │ ส้มตำปูปลาร้า │ ├─────┼────────┼───────┤ │ 2 │ สตรีทฟู้ด │ └─────┴─────────────────┘ │ 1 │ 1 │ 1 │ └─────┴────────────┘ │ 2 │ 1 │ 2 │ │ 3 │ 2 │ 1 │ └─────┴────────┴───────┘ ใบจับคู่ 1–2: ต้มยำกุ้ง ติดป้าย "อาหารเผ็ด" กับ "สตรีทฟู้ด" · ใบที่ 3: ส้มตำ ติดป้าย "อาหารเผ็ด"
🏷 เทียบให้เห็นภาพ — สมุดจับคู่ "แฟ้มไหนติดป้ายอะไร"

ร้านมีลิ้นชักบทความกับลิ้นชักป้ายอยู่แล้ว · แทนที่จะเขียนชื่อป้ายลงบนแฟ้มบทความ (ซึ่งช่องไม่พอ) ร้านเปิดลิ้นชักที่สามไว้เก็บใบจับคู่ · ใบหนึ่งเขียนว่า "บทความเลขที่ 1 ติดป้ายเลขที่ 2" · อยากรู้ว่าบทความ 1 มีป้ายอะไร ก็ไล่ใบจับคู่ที่เขียนว่าบทความ 1 แล้วเดินไปหยิบป้ายตามเลข

ที่ไม่เหมือนกัน: คนจริงต้องเดินสองลิ้นชักทีละรอบ · ฐานข้อมูลทำทั้งสองขั้นในคำสั่ง SQL เดียว (JOIN — เดี๋ยวเห็นข้างล่าง) · และใบจับคู่ในตู้เป็นแฟ้มเต็มตัว มี id และช่องเวลาสร้าง — จะเติมช่องอื่นอย่าง "ใครเป็นคนติดป้าย" ก็ได้ ซึ่งเป็นเหตุผลที่เลือก :through

ลงมือ: สร้างป้ายกับใบจับคู่

ในโฟลเดอร์โปรเจกต์ สั่ง generator สองครั้ง (ผลจริง · เลขเวลาหน้าชื่อไฟล์ของคุณจะต่าง):

$ bin/rails generate model Tag name:string
      invoke  active_record
      create    db/migrate/20261104120348_create_tags.rb
      create    app/models/tag.rb
      invoke    test_unit
      create      test/models/tag_test.rb
      create      test/fixtures/tags.yml
$ bin/rails generate model Tagging post:references tag:references
      invoke  active_record
      create    db/migrate/20261104120349_create_taggings.rb
      create    app/models/tagging.rb
      invoke    test_unit
      create      test/models/tagging_test.rb
      create      test/fixtures/taggings.yml

เปิดดูของที่ generator เขียนให้ — ใบสั่งช่างกับ model ของ Tagging:

# db/migrate/20261104120349_create_taggings.rb
class CreateTaggings < ActiveRecord::Migration[8.1]
  def change
    create_table :taggings do |t|
      t.references :post, null: false, foreign_key: true
      t.references :tag, null: false, foreign_key: true

      t.timestamps
    end
  end
end

# app/models/tagging.rb
class Tagging < ApplicationRecord
  belongs_to :post
  belongs_to :tag
end

ตรงกับกฎเป๊ะ: ลิ้นชัก taggings มีช่อง post_id กับ tag_id → Tagging มี belongs_to สองบรรทัด
t.references :post = "ทำช่อง post_id ให้" (Rails เติม _id เอง) · null: false = ห้ามว่าง · foreign_key: true คือกฎที่ตู้ "เลขต้องมีจริง" · t.timestamps = ช่องเวลาสร้าง/แก้ · สั่งช่างลงมือ:

$ bin/rails db:migrate
== 20261104120348 CreateTags: migrating =======================================
-- create_table(:tags)
   -> 0.0080s
== 20261104120348 CreateTags: migrated (0.0080s) ==============================

== 20261104120349 CreateTaggings: migrating ===================================
-- create_table(:taggings)
   -> 0.0097s
== 20261104120349 CreateTaggings: migrated (0.0097s) ==========================

แล้วบอก Post ว่า "ป้ายของฉันหาได้โดยเดินผ่านใบจับคู่" · แก้ app/models/post.rb เพิ่มสองบรรทัดล่าง:

# app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user
  has_many :comments
  has_many :taggings
  has_many :tags, through: :taggings
end

เปิด console สร้างป้ายสองอัน แล้วติดให้บทความ (ผลจริง):

wongnok-blog(dev):001> spicy = Tag.create!(name: "อาหารเผ็ด")
  TRANSACTION (0.9ms)  BEGIN /*application='WongnokBlog'*/
  Tag Create (2.8ms)  INSERT INTO "tags" ("name", "created_at", "updated_at") VALUES ('อาหารเผ็ด', '2026-11-04 12:04:02.461063', '2026-11-04 12:04:02.461063') RETURNING "id" /*application='WongnokBlog'*/
  TRANSACTION (2.1ms)  COMMIT /*application='WongnokBlog'*/
=>
#<Tag:0x000000012300b288
...
wongnok-blog(dev):002> street = Tag.create!(name: "สตรีทฟู้ด")
…
wongnok-blog(dev):003> post = Post.find(1)
…
wongnok-blog(dev):004> post.tags << spicy
  TRANSACTION (1.0ms)  BEGIN /*application='WongnokBlog'*/
  Tagging Create (2.2ms)  INSERT INTO "taggings" ("post_id", "tag_id", "created_at", "updated_at") VALUES (1, 1, '2026-11-04 12:04:02.496763', '2026-11-04 12:04:02.496763') RETURNING "id" /*application='WongnokBlog'*/
  TRANSACTION (1.3ms)  COMMIT /*application='WongnokBlog'*/
  Tag Load (1.7ms)  SELECT "tags".* FROM "tags" INNER JOIN "taggings" ON "tags"."id" = "taggings"."tag_id" WHERE "taggings"."post_id" = 1 /* loading for pp */ LIMIT 11 /*application='WongnokBlog'*/
=>
[#<Tag:0x0000000122bcaa20
  id: 1,
  name: "อาหารเผ็ด",
  created_at: "2026-11-04 12:04:02.461063000 +0000",
  updated_at: "2026-11-04 12:04:02.461063000 +0000">]
wongnok-blog(dev):005> post.tags << street
…
wongnok-blog(dev):006> post.tags.pluck(:name)
  Tag Pluck (1.0ms)  SELECT "tags"."name" FROM "tags" INNER JOIN "taggings" ON "tags"."id" = "taggings"."tag_id" WHERE "taggings"."post_id" = 1 /*application='WongnokBlog'*/
=> ["อาหารเผ็ด", "สตรีทฟู้ด"]
wongnok-blog(dev):007> Post.find(2).tags << spicy
…
wongnok-blog(dev):008> Tagging.pluck(:post_id, :tag_id)
  Tagging Pluck (2.6ms)  SELECT "taggings"."post_id", "taggings"."tag_id" FROM "taggings" /*application='WongnokBlog'*/
=> [[1, 1], [1, 2], [2, 1]]

อ่าน JOIN เป็นภาษาคน: SELECT "tags"."name" FROM "tags" INNER JOIN "taggings" ON "tags"."id" = "taggings"."tag_id" WHERE "taggings"."post_id" = 1
= "เอาลิ้นชัก tags กับลิ้นชัก taggings มาวางคู่กัน · จับคู่แผ่นที่เลขป้ายตรงกัน (tags.id = taggings.tag_id) · เก็บเฉพาะคู่ที่ใบจับคู่เขียนว่าบทความ 1 · แล้วขอแค่ชื่อป้าย"
JOIN = ต่อสองลิ้นชักเข้าด้วยกันชั่วคราวตามเลขที่ตรงกัน · INNER = เอาเฉพาะแผ่นที่จับคู่ได้

เทียบกับ Go: GORM ก็มีแบบหลายต่อหลาย โดยเขียน tag บน field เช่น Tags []Tag `gorm:"many2many:post_tags;"` แล้ว GORM ทำตารางกลางให้ · ใกล้กับ habtm ข้างล่างมากกว่า เพราะไม่มี struct ของตารางกลางให้เราเห็น (ตัวอย่างจากเอกสาร GORM ไม่ได้รันในบทนี้)

through หรือ habtm — เลือกยังไง

แบบที่ 5 ในตารางหัวข้อ 4 (has_and_belongs_to_many) ก็ทำหลายต่อหลายได้ โดยไม่ต้องมี model ตรงกลาง · ผมลองในแอปทดลองแล้ว (ลองเสร็จลบทิ้ง): เขียน has_and_belongs_to_many :tags ใน Post แล้ว Rails ไปหาตารางกลางชื่อ posts_tags ให้เอง ซึ่งเราต้องสร้างให้มีแค่ post_id กับ tag_id — คู่มือทางการบอกให้สร้างแบบไม่มี id และไม่มี model

has_many :through (ที่เราทำ)has_and_belongs_to_many
ตารางกลางtaggings (ตั้งชื่อเองได้)posts_tags (ชื่อสองตารางเรียงตามตัวอักษร)
มี model ตรงกลางไหมมี (Tagging) ใส่ validation ได้ (บท 110)ไม่มี
เติมช่องอื่นในใบจับคู่ได้ไหมได้ เช่น "ใครเป็นคนติด" · "ลำดับที่โชว์"ไม่สะดวก — ไม่มี model ให้อ่าน/เขียนช่องพวกนั้น
เขียนกี่บรรทัดมากกว่า (3 model)น้อยกว่า

สไลด์หน้า 40 สรุปไว้ดี: ถ้าตารางกลางมีโอกาสได้ช่องเพิ่มในอนาคต ให้ใช้ :through ตั้งแต่แรก · คู่มือทางการของ Rails ก็บอกว่า :through ยืดหยุ่นกว่า ใส่ validation และช่องเพิ่มได้ (ลิงก์ท้ายบท) · วันนี้รู้จัก habtm ไว้แค่อ่านโค้ดคนอื่นออกก็พอ

🗣 ศัพท์ของหลายต่อหลาย
join table
ตารางกลางที่เก็บคู่ของสองตาราง เช่น taggings · posts_tags · ถาม senior ได้ว่า "join table ตัวนี้ควรมี unique index คู่ post_id + tag_id ไหม จะได้ติดป้ายซ้ำไม่ได้"
join model
model ของตารางกลาง (Tagging) · มีเฉพาะแบบ :through · พิมพ์บอก AI ได้ว่า "use has_many :through with a Tagging join model, not has_and_belongs_to_many"
JOIN (SQL)
คำสั่งให้ฐานข้อมูลต่อสองตารางตามเลขที่ตรงกันในคำสั่งเดียว · เห็นใน log เมื่อใช้ :through · อ่าน log แล้วเห็น INNER JOIN taggings = Rails กำลังเดินผ่านตารางกลางให้

ภารกิจ — ให้ป้ายรู้ว่าตัวเองอยู่บนบทความไหน (แบบฝึก Quiz 5 ของสไลด์)

🎯 ภารกิจ (~10 นาที) · ทำต่อจากหัวข้อ 7

ตอนนี้ post.tags ใช้ได้แล้ว แต่ถามกลับทาง spicy.posts ยังไม่ได้ (ผลจริง):

wongnok-blog(dev):009> spicy.posts
(wongnok-blog):9:in '<main>': undefined method 'posts' for an instance of Tag (NoMethodError)
  1. ตอบบนกระดาษก่อน (สไลด์หน้า 44 ข้อ 2): คอลัมน์ user_id · post_id · tag_id อยู่ในตารางไหนบ้าง? (ตอบเป็นชื่อตาราง มีบางคอลัมน์อยู่มากกว่าหนึ่งตาราง)
  2. เช็คคำตอบใน console ทีละตาราง: Post.column_names · Comment.column_names · Tagging.column_names · User.column_names
  3. ใช้กฎข้อเดียว เติม app/models/tag.rb (ตอนนี้มีแค่ class Tag < ApplicationRecord กับ end) ให้ถาม tag.posts ได้ — ดูแบบจาก post.rb แล้วกลับด้าน
  4. พิมพ์ exit ปิด console แล้วเปิดใหม่ จากนั้น: spicy = Tag.find_by(name: "อาหารเผ็ด") แล้ว spicy.posts.pluck(:title)

ควรเห็น: => ["ต้มยำกุ้งน้ำข้น", "ส้มตำปูปลาร้า"] และ SQL ที่มี INNER JOIN "taggings"

ถ้าเจอ error แปลว่า…

① คำตอบข้อ 2 ของสไลด์ — ผลจริงจาก console (บรรทัด 001 Post.column_names ยาวหลายบรรทัด ผมตัดออก — มี "user_id" อยู่ท้ายสุด · เลขบรรทัดของคุณจะต่างถ้าพิมพ์ต่อใน console เดิม):

wongnok-blog(dev):002> Comment.column_names
=> ["id", "body", "post_id", "user_id", "created_at", "updated_at"]
wongnok-blog(dev):003> Tagging.column_names
=> ["id", "post_id", "tag_id", "created_at", "updated_at"]
wongnok-blog(dev):004> User.column_names
=> ["id", "name", "email", "created_at", "updated_at"]
  • user_id อยู่ที่ posts และ comments
  • post_id อยู่ที่ comments และ taggings
  • tag_id อยู่ที่ taggings เท่านั้น · ส่วน users กับ tags ไม่มี _id ของใครเลย → ไม่มี belongs_to

② app/models/tag.rb:

# app/models/tag.rb
class Tag < ApplicationRecord
  has_many :taggings
  has_many :posts, through: :taggings
end

③ ผลจริงหลังเปิด console ใหม่:

wongnok-blog(dev):001> spicy = Tag.find_by(name: "อาหารเผ็ด")
  Tag Load (2.0ms)  SELECT "tags".* FROM "tags" WHERE "tags"."name" = 'อาหารเผ็ด' LIMIT 1 /*application='WongnokBlog'*/
=>
#<Tag:0x000000012716a6e8
...
wongnok-blog(dev):002> spicy.posts.pluck(:title)
  Post Pluck (3.1ms)  SELECT "posts"."title" FROM "posts" INNER JOIN "taggings" ON "posts"."id" = "taggings"."post_id" WHERE "taggings"."tag_id" = 1 /*application='WongnokBlog'*/
=> ["ต้มยำกุ้งน้ำข้น", "ส้มตำปูปลาร้า"]

SQL = "ต่อลิ้นชัก posts กับ taggings ตามเลขบทความ · เก็บคู่ที่ใบจับคู่เขียนว่าป้าย 1 · ขอแค่ชื่อเรื่อง" — กลับด้านกับของ post.tags เป๊ะ

④ ข้อ 1 ของสไลด์ (เติมช่องว่าง) ตามแอปเรา:

class User < ApplicationRecord
  has_many :posts
  has_many :comments
end

class Post < ApplicationRecord
  belongs_to :user                 # posts มี user_id
  has_many :comments               # สไลด์มี dependent: :____ ต่อท้าย — เฉลยบท 116
  has_many :taggings
  has_many :tags, through: :taggings
end

ทดสอบความเข้าใจ

จะเพิ่มปุ่ม "ถูกใจ" · ตาราง likes มีคอลัมน์ post_id กับ user_id · ใน app/models/like.rb ควรเขียนอะไร?

ตัวเลือกที่ 2 ถูก — ตาราง likes มีช่อง _id สองช่อง → Like เขียน belongs_to สองบรรทัด แบบเดียวกับ Tagging

ตัวเลือกที่ 1 ผิด เพราะ has_many ใช้กับฝั่งที่ไม่มีช่อง _id · การกดถูกใจหนึ่งครั้งเป็นของบทความเดียว ไม่ใช่หลายบทความ · ตัวเลือกที่ 3 ผิด เพราะ has_one หมายถึงช่อง _id อยู่อีกตาราง (เช่น profiles.user_id) · ตัวเลือกที่ 4 ผิด เพราะ post.rb ใส่ has_many :likes ได้ก็จริง แต่ฝั่ง Like ต้องมี belongs_to ด้วย ไม่งั้นถาม like.post ไม่ได้

จุดที่ต้องจำ: ช่อง _id อยู่ตารางไหน model นั้นเขียน belongs_to

ทำไม post.comments.create(user: mana, body: "…") ปลอดภัยกว่า Comment.create(post_id: post.id, user_id: mana.id, body: "…")?

ตัวเลือกที่ 4 ถูก — ใน log เห็นว่า post_id = 1 มาจาก post ที่เราถือ · ท่ายาวพิมพ์ post_id: 2 ผิดก็เก็บได้เงียบ ๆ เพราะบทความ 2 มีจริง

ตัวเลือกที่ 1 ผิด เพราะท่าสั้นยังตรวจกฎครบ — ตัวอย่าง "hi" ได้ "User must exist" · ตัวเลือกที่ 2 ผิด เพราะท่ายาวก็ INSERT ลงตู้จริง เห็นใน log · ตัวเลือกที่ 3 ผิด เพราะคอมเมนต์อยู่ในตาราง comments เสมอ แค่มีเลขบทความในช่อง post_id

จุดที่ต้องจำ: สร้างลูกผ่านแม่ (post.comments.create) แทนการกรอก post_id เอง

บทความเลข 1 มีคอมเมนต์ในตู้ 3 อัน · พิมพ์ draft = post.comments.build(user: mana, body: "x") แล้วยังไม่ save · post.comments.count กับ post.comments.size ได้อะไร?

ตัวเลือกที่ 1 ถูก — ผลจริงในหัวข้อ 5: count ถามตู้ได้ 3 · size นับตู้ 3 + ที่ถือในมือ 1 = 4

ตัวเลือกที่ 2 ผิด เพราะ build ยังไม่ลงตู้ count จึงไม่เห็น · ตัวเลือกที่ 3 ผิด เพราะกลับด้าน — ตัวที่เห็นของในมือคือ size · ตัวเลือกที่ 4 ผิด เพราะ size รวมของที่ build ไว้ด้วย

จุดที่ต้องจำ: build = อยู่ในมือ · count = ถามตู้อย่างเดียว · size = ตู้ + ในมือ

ทีมอยากเก็บว่า "ใครเป็นคนติดป้ายนี้ และติดเมื่อไหร่" ในความสัมพันธ์บทความ–ป้าย ควรเลือกแบบไหน?

ตัวเลือกที่ 3 ถูก — ข้อมูล "ใครติด · ติดเมื่อไหร่" เป็นของใบจับคู่ · :through มี Tagging ที่เติมช่องได้ (และมีช่องเวลาสร้างให้แล้ว)

ตัวเลือกที่ 1 ผิด เพราะตารางกลางของ habtm ไม่มี model ให้อ่าน/เขียนช่องเพิ่ม (ตาราง posts_tags มีแค่ post_id กับ tag_id) · ตัวเลือกที่ 2 ผิด เพราะบทความหนึ่งมีหลายป้าย ช่องเดียวเก็บไม่พอ · ตัวเลือกที่ 4 ผิด เพราะ has_one คือมีได้แค่ตัวเดียวตลอดไป ไม่ใช่หลายต่อหลาย

จุดที่ต้องจำ: หลายต่อหลาย + อาจมีช่องเพิ่ม → has_many :through ตั้งแต่แรก

💬 สรุป 3 บรรทัด ① ใบลูกจดเลขที่ของใบแม่ (post_id) · ② belongs_to อยู่ฝั่งที่มีคอลัมน์ _id · ③ หลายต่อหลายต้องมีลิ้นชักกลาง ใช้ has_many :through
ยังงงตรงไหน เอาโค้ด model กับ db/schema.rb ของคุณไปถาม AI ได้เลย เช่น "ช่วยอ่าน schema นี้แล้วบอกว่าแต่ละ model ควรมี belongs_to / has_many อะไรบ้าง และเพราะอะไร"
บทหน้า: ถ้าลบบทความ "ต้มยำกุ้งน้ำข้น" ทิ้ง คอมเมนต์ 2 อันที่เขียนว่า "เป็นของบทความเลขที่ 1" จะเป็นยังไง?

ถ้าเปิดสไลด์ดูประกอบ

หน้าสไลด์เขียนของจริงในแอปเราต้องสนใจไหม
41post.comments.create(body: "hi") ไม่ถูกเก็บ ได้ "User must exist" เพราะ Comment ของเรามี belongs_to :user ด้วย · ต้องใส่ user:✅ ถ้าพิมพ์ตาม
41post.comments.clear · post.comment_ids = [1, 2] พังด้วย ActiveRecord::NotNullViolation เพราะพยายามตั้ง post_id เป็นว่าง แต่ตู้ห้ามว่าง✅ อย่าใช้กับ belongs_to ที่บังคับ
41has_many :approved_comments, -> { where(approved: true) } ตาราง comments ของเราไม่มีช่อง approved · พิมพ์ตามได้ PG::UndefinedColumn · บทนี้ใช้ published_posts แทนแค่รู้
41has_many :comments, dependent: :destroy แอปเรายังไม่มี dependent · อธิบายในบท 116ยังไม่ต้องเติม
40has_one · User → Profile แอปเราไม่มี Profile · เป็นตัวอย่างให้เห็นภาพไม่ต้อง
44Quiz 5 ช่อง dependent: :_______ เฉลยอยู่บท 116 · ภารกิจบทนี้ทำข้อ 1 (ส่วนอื่น) กับข้อ 2ไม่ต้อง

อ้างอิง