🔁 ทวนก่อนเริ่ม — คำพวกนี้จะโผล่ในบทนี้ทั้งหมด
- ตาราง · แถว · คอลัมน์ (บท 106) — ลิ้นชัก · แฟ้มหนึ่งแผ่น · ช่องบนแฟ้ม · ทุกแถวมีช่อง
idเป็นเลขที่แฟ้ม find·find_by·create·new·persisted?(บท 107) — หาแฟ้ม · สร้างแล้วเก็บ · สร้างไว้ในมือ · "เก็บลงตู้แล้วหรือยัง"db/schema.rb(บท 108) — แปลนตู้ฉบับล่าสุด บอกว่าแต่ละลิ้นชักมีช่องอะไรerrors.full_messages(บท 110) — รายการเหตุผลที่แฟ้มถูกปฏิเสธ · เคยเห็น"User must exist"มาแล้วpluck·count·size(บท 113) — ขอแค่บางช่อง · นับในตู้ · นับแบบเลือกวิธีเอง
🧰 เวอร์ชันที่ใช้ตรวจ: 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 แปลว่า "เป็นของบทความเลขที่ …"
นี่คือหน้าตาจริงของสองลิ้นชักในแอปเรา (ตัดมาบางแถว บางช่อง):
ทำไมช่องนี้อยู่บนใบคอมเมนต์ ไม่ใช่บนบทความ? บทความหนึ่งชิ้นมีคอมเมนต์ได้ 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"
t.bigint "post_id", null: false— ลิ้นชัก comments มีช่องpost_idเก็บจำนวนเต็ม (bigint= จำนวนเต็มขนาดใหญ่ ใช้เก็บเลขที่) ·null: false= ห้ามเว้นว่าง- ลิ้นชัก posts มีช่อง
user_id· ลิ้นชัก users ไม่มีช่อง_idของใครเลย add_foreign_key "comments", "posts"— กฎที่ตัวตู้: "เลขในcomments.post_idต้องเป็นidที่มีอยู่จริงในลิ้นชัก posts"…คือส่วนที่ผมตัดออก (ช่องอื่นที่ไม่เกี่ยว เช่นbody·published· ช่องเวลาสร้าง/แก้ และสารบัญt.indexที่ช่วยให้ค้นเร็ว — บท 109)force: :cascade— ข้ามได้ แปลว่า "ถ้ามีลิ้นชักชื่อนี้อยู่แล้วให้รื้อทิ้งสร้างใหม่" ใช้ตอนตั้งตู้ใหม่จากแปลน (บท 108) · ไม่เกี่ยวกับความสัมพันธ์ในบทนี้
ส่วน 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'*/
=> "สมหญิง"
...ใต้=>— console ย่อผลเพราะเป็นบรรทัดตั้งตัวแปร (บท 107) · พิมพ์commentเฉย ๆ ถ้าอยากดูเต็มcomment.post_id→1— แค่ตัวเลขในช่อง ยังไม่ได้ไปไหน (ไม่มี SQL)comment.post— method ที่ได้จากbelongs_to :post· Rails เอาเลข 1 ไปหาในลิ้นชัก posts ให้ · SQLSELECT "posts".* FROM "posts" WHERE "posts"."id" = 1 LIMIT 1แปลว่า "ขอทุกช่องจากลิ้นชัก posts เฉพาะแผ่นที่ id เป็น 1 เอาแผ่นเดียวพอ"comment.user.name— แบบเดียวกัน แต่ใช้เลขในช่องuser_id(= 2 คือสมหญิง)
ทีนี้ถามกลับทาง — จากบทความ ไปหาคอมเมนต์ทั้งหมด:
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">]
post.comments— method ที่ได้จากhas_many :comments· SQLWHERE "comments"."post_id" = 1แปลว่า "ไปลิ้นชัก comments หยิบทุกแผ่นที่ช่อง post_id เขียนว่า 1" — ตรงกับภาพนิติไล่หากล่องที่เขียนเลขห้อง/* loading for pp */ LIMIT 11— console เติมเองเพื่อโชว์บนจอ (เหมือนบท 107) ไม่ใช่คำสั่งของคุณpost.commentsเป็น relation (ใบเบิกของ — บท 113) จึงต่อ.where·.order·.countได้อีก
เทียบกับ 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 เป็นของ Post | comments.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_id | post.tags |
has_and_belongs_to_many(เรียกย่อว่า habtm) | "หลายต่อหลาย ตารางกลางเปล่า ๆ ไม่มี model" | เหมือนแถวบน | ตารางกลาง posts_tags | post.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
post.comments.create(user: mana, body: "…")— "สร้างคอมเมนต์ใต้บทความนี้ เขียนโดยมานะ แล้วเก็บลงตู้เลย" · เราไม่ได้พิมพ์post_idสักตัวuser: mana— ส่ง object ผู้ใช้ไปทั้งตัว Rails หยิบเลขที่ (id= 3) ไปใส่ช่องuser_idให้INSERT INTO "comments" ("body", "post_id", "user_id", …) VALUES ('ใส่มะนาวเยอะ ๆ', 1, 3, …)แปลว่า "เก็บแฟ้มใหม่ลงลิ้นชัก comments: ช่อง body = ใส่มะนาวเยอะ ๆ · post_id = 1 · user_id = 3" — เลข 1 มาจากpostที่เราถือไว้BEGIN/COMMIT— เปิดและปิดรอบการเก็บ (transaction — บท 111) ข้ามไปได้
# สไลด์หน้า 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
build— เหมือนnewของบท 107 (สร้างไว้ในมือ ยังไม่ลงตู้) แต่เติมpost_idให้แล้ว ·new_record?=trueแปลว่ายังไม่ได้เก็บcount— ถามตู้เสมอ · SQLSELECT COUNT(*) … WHERE post_id = 1= "นับว่ามีกี่แผ่นที่ post_id เป็น 1" → 3 (2 อันจาก seeds + อันที่เพิ่งสร้าง)size— ได้ 4 เพราะนับ "ในตู้ 3 + ที่ถือในมือยังไม่เก็บ 1" · พอdraft.saveแล้วcountก็เป็น 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
- 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
:published_posts— ชื่อใหม่ที่เราตั้งเอง จะได้ methodmana.published_posts-> { where(published: true) }— lambda = ก้อนโค้ดที่เก็บไว้รันทีหลัง (เจอครั้งแรกใน บท 112 · ญาติของ block บท 93) · แปลว่า "จดวิธีกรองไว้ก่อน ยังไม่ต้องทำ" แล้ว Rails เอามาต่อท้ายทุกครั้งที่เรียกpublished_posts- ทำไมต้องห่อ? ถ้าเขียน
where(…)เปล่า ๆ Ruby จะพยายามรันมันทันทีตอนอ่านไฟล์ ซึ่งตอนนั้นยังไม่รู้ว่าเป็นผู้ใช้คนไหน · ผมลองแล้ว Rails ไม่รับ ได้NoMethodError class_name: "Post"— บอกว่าแฟ้มพวกนี้คือPost· ต้องบอกเพราะ Rails เดา class จากชื่อ (บท 99):published_posts→PublishedPostซึ่งไม่มี
ถ้าลืม 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 · ติดสองป้ายให้บทความเดียว = สองแผ่น
ร้านมีลิ้นชักบทความกับลิ้นชักป้ายอยู่แล้ว · แทนที่จะเขียนชื่อป้ายลงบนแฟ้มบทความ (ซึ่งช่องไม่พอ) ร้านเปิดลิ้นชักที่สามไว้เก็บใบจับคู่ · ใบหนึ่งเขียนว่า "บทความเลขที่ 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
- ครั้งแรก: model
Tag+ ลิ้นชักtagsที่มีช่องname - ครั้งที่สอง: model
Tagging+ ลิ้นชักtaggings·post:references= "ช่องที่ชี้ไปหาแฟ้มในลิ้นชัก posts" (แบบเดียวกับตอนสร้าง Comment ในบท 107) - บรรทัด
test_unitกับไฟล์ในtest/— ไฟล์เปล่าสำหรับเขียน test (Day 4) · วันนี้ไม่ต้องแตะ
เปิดดูของที่ 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
has_many :taggings— บทความมีใบจับคู่ได้หลายใบ (post_idอยู่ฝั่ง taggings · กฎเดิม)has_many :tags, through: :taggings— "ป้ายของฉัน = ป้ายที่ใบจับคู่ของฉันชี้ไป" · ต้องมีบรรทัดhas_many :taggingsด้วย ไม่งั้น Rails ไม่รู้ว่าจะเดินผ่านอะไร
เปิด 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]]
post.tags << spicy— เราพูดถึงแค่บทความกับป้าย แต่ Rails สร้างใบจับคู่ให้เอง:INSERT INTO "taggings" ("post_id", "tag_id", …) VALUES (1, 1, …)Tagging.pluck(:post_id, :tag_id)→[[1, 1], [1, 2], [2, 1]]— ขอสองช่องเลยได้เป็นคู่[เลขบทความ, เลขป้าย]· ใบจับคู่ 3 ใบ ตรงกับแผนภาพข้างบน…คือบรรทัดที่ผมตัดออก หน้าตาเหมือนบรรทัดก่อนหน้า
อ่าน 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)
- ตอบบนกระดาษก่อน (สไลด์หน้า 44 ข้อ 2): คอลัมน์
user_id·post_id·tag_idอยู่ในตารางไหนบ้าง? (ตอบเป็นชื่อตาราง มีบางคอลัมน์อยู่มากกว่าหนึ่งตาราง) - เช็คคำตอบใน console ทีละตาราง:
Post.column_names·Comment.column_names·Tagging.column_names·User.column_names - ใช้กฎข้อเดียว เติม
app/models/tag.rb(ตอนนี้มีแค่class Tag < ApplicationRecordกับend) ให้ถามtag.postsได้ — ดูแบบจากpost.rbแล้วกลับด้าน - พิมพ์
exitปิด console แล้วเปิดใหม่ จากนั้น:spicy = Tag.find_by(name: "อาหารเผ็ด")แล้วspicy.posts.pluck(:title)
ควรเห็น: => ["ต้มยำกุ้งน้ำข้น", "ส้มตำปูปลาร้า"] และ SQL ที่มี INNER JOIN "taggings"
ถ้าเจอ error แปลว่า…
undefined method 'posts' for an instance of Tag→ ยังไม่ได้เขียนในtag.rbหรือยังไม่ได้ปิด–เปิด console ใหม่Could not find the association :taggings in model Tag (ActiveRecord::HasManyThroughAssociationNotFoundError)→ เขียนthrough: :taggingsแต่ลืมเขียนบรรทัดhas_many :taggings(Rails ไม่รู้ว่าจะเดินผ่านอะไร)undefined method 'posts' for nil (NoMethodError)→find_byหาไม่เจอ ได้nil(คำว่าfor nilคือใบ้) · ยังไม่ได้สร้างป้าย "อาหารเผ็ด" ตามหัวข้อ 7 หรือพิมพ์ชื่อผิด
① คำตอบข้อ 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 และ commentspost_idอยู่ที่ comments และ taggingstag_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" จะเป็นยังไง?
ถ้าเปิดสไลด์ดูประกอบ
| หน้า | สไลด์เขียน | ของจริงในแอปเรา | ต้องสนใจไหม |
|---|---|---|---|
| 41 | post.comments.create(body: "hi") |
ไม่ถูกเก็บ ได้ "User must exist" เพราะ Comment ของเรามี belongs_to :user ด้วย · ต้องใส่ user: | ✅ ถ้าพิมพ์ตาม |
| 41 | post.comments.clear · post.comment_ids = [1, 2] |
พังด้วย ActiveRecord::NotNullViolation เพราะพยายามตั้ง post_id เป็นว่าง แต่ตู้ห้ามว่าง | ✅ อย่าใช้กับ belongs_to ที่บังคับ |
| 41 | has_many :approved_comments, -> { where(approved: true) } |
ตาราง comments ของเราไม่มีช่อง approved · พิมพ์ตามได้ PG::UndefinedColumn · บทนี้ใช้ published_posts แทน | แค่รู้ |
| 41 | has_many :comments, dependent: :destroy |
แอปเรายังไม่มี dependent · อธิบายในบท 116 | ยังไม่ต้องเติม |
| 40 | has_one · User → Profile |
แอปเราไม่มี Profile · เป็นตัวอย่างให้เห็นภาพ | ไม่ต้อง |
| 44 | Quiz 5 ช่อง dependent: :_______ |
เฉลยอยู่บท 116 · ภารกิจบทนี้ทำข้อ 1 (ส่วนอื่น) กับข้อ 2 | ไม่ต้อง |
อ้างอิง
- Rails Guides — Active Record Associations · belongs_to
— อ่านหัวข้อ belongs_to ถึง has_many และตัวอย่าง
"Author must exist"ที่ตรงกับ"Post must exist"ของเรา - Rails Guides — Methods Added by has_many
— รายการ method ที่ได้ฟรีทั้งหมด (
build·create·<<·collection_singular_ids·clear) - Rails Guides — has_many :through vs has_and_belongs_to_many — ส่วนที่บอกว่าเมื่อไหร่ควรเลือกแบบไหน · และหัวข้อ class_name · scopes
- Rails API — ActiveRecord::Associations::ClassMethods
— เอกสารละเอียดของ
has_many·belongs_toทุก option · เปิดดูตอนอยากรู้ว่า option ไหนทำอะไร - GORM — Belongs To · GORM — Has Many · GORM — Many To Many
— ฝั่ง Go ที่เทียบในหัวข้อ 3 และ 7 · ดูว่า field
PostID+Postและ tagmany2manyทำงานยังไง - PostgreSQL 17 — Foreign Keys
— ฝั่งฐานข้อมูลของกฎ "เลขที่ต้องมีอยู่จริง" (
add_foreign_key) · อ่านแค่ย่อหน้าแรกก็พอ