🔁 ทวนก่อนเริ่ม — คำจากบทก่อน ๆ ที่บทนี้ใช้ตลอด
has_many :comments·belongs_to :post(บท 115) — บทความหนึ่งชิ้นมีใบคอมเมนต์หลายใบ · ใบคอมเมนต์แต่ละใบเป็นของบทความหนึ่งชิ้น- foreign key (บท 111 · บท 115) — ช่อง
post_idบนใบคอมเมนต์ ("เป็นของบทความเลขที่ …") พร้อมกฎของตู้ว่าเลขนั้นต้องมีอยู่จริง - transaction (บท 111) —
BEGIN…COMMIT= ทำทั้งชุดหรือไม่ทำเลย ·ROLLBACK= ยกเลิกทั้งชุด เหมือนไม่เคยเกิด - callback (บท 112) — ขั้นตอนอัตโนมัติที่ทำก่อน/หลังเก็บแฟ้มทุกครั้ง
- migration ·
db:reset(บท 108) — ใบสั่งช่างดัดแปลงตู้ · ล้างตู้ทิ้งแล้วสร้างใหม่จากแปลน (schema.rb) พร้อมข้อมูลตัวอย่าง
🧰 ตรวจด้วย Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 17 (สไลด์ใช้ Ruby 4.0.6 · Rails 8.1.3 — รุ่นแก้บั๊กในสายเดียวกัน ใช้แทนกันได้)
🗂 ตัวอย่างในบทนี้ใช้ตาราง users / posts / comments และข้อมูลตัวอย่าง (seeds) ชุดเดียวกับที่สร้างไว้ใน บท 107 ·
บทความที่ใช้บ่อย: ต้มยำกุ้งน้ำข้น (เลขที่ 1 · มีคอมเมนต์ 2 ใบ) และ ก๋วยเตี๋ยวเรืออยุธยา (เลขที่ 4 · ไม่มีคอมเมนต์)
⚠️ เริ่มบทด้วย bin/rails db:reset หนึ่งครั้ง — ถ้าทำบท 115ครบ ต้มยำกุ้งจะมีป้าย (tags) ติดอยู่
ซึ่งทำให้ทางที่ ①–③ ข้างล่างลบต้มยำกุ้งไม่ผ่าน (ติดลิ้นชัก taggings แทน) · seeds ไม่มีป้าย reset แล้วป้ายหายหมด ผลจะตรงกับในบท
(ลิ้นชัก taggings กลับมาคุยกันอีกทีตอนท้ายหัวข้อ "เลือกแบบไหน")
ผลทุกก้อนรันจริงแล้วก็อปมา · เวลา (ms) · วันที่ · เลข 0x… ของคุณจะไม่ตรงกับในบท ·
บทนี้จะลบข้อมูลหลายรอบ — ลบแล้วอยากได้ข้อมูลคืน ให้สั่ง bin/rails db:reset (ในเครื่องตัวเองเท่านั้น)
ลองลบบทความที่มีคอมเมนต์ — แล้วตู้ไม่ยอม
เปิดครัวทดลองด้วย bin/rails console แล้วลองทิ้งบทความต้มยำกุ้ง (ที่มีคนคอมเมนต์ไว้ 2 ใบ) ด้วย destroy แบบที่เรียนในบท 107:
wongnok-blog(dev):001> post = Post.find_by(title: "ต้มยำกุ้งน้ำข้น")
Post Load (1.6ms) SELECT "posts".* FROM "posts" WHERE "posts"."title" = 'ต้มยำกุ้งน้ำข้น' LIMIT 1 /*application='WongnokBlog'*/
=> #<Post … (ผลยาว ตัดออก)
wongnok-blog(dev):002> post.comments.count
Comment Count (1.8ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 1 /*application='WongnokBlog'*/
=> 2
wongnok-blog(dev):003> post.destroy
TRANSACTION (1.9ms) BEGIN /*application='WongnokBlog'*/
Post Destroy (5.6ms) DELETE FROM "posts" WHERE "posts"."id" = 1 /*application='WongnokBlog'*/
TRANSACTION (0.7ms) ROLLBACK /*application='WongnokBlog'*/
(wongnok-blog):3:in '<main>': PG::ForeignKeyViolation: ERROR: update or delete on table "posts" violates foreign key constraint "fk_rails_2fd19c0db7" on table "comments" (ActiveRecord::InvalidForeignKey)
DETAIL: Key (id)=(1) is still referenced from table "comments".
… (ข้อความเดิมซ้ำอีกรอบจากชั้นในของ PostgreSQL ตามด้วยรายชื่อไฟล์ยาว ๆ และ "... 55 levels..." — ข้ามได้)
อ่าน log ทีละบรรทัด (บรรทัดที่ย่อหน้าเข้าไปคือ SQL ที่ Rails ยิงไปที่ตู้ · คำหน้าสุด เช่น Post Destroy บอกว่างานของใคร):
BEGIN— เริ่ม transaction: "งานต่อจากนี้ ทำทั้งชุดหรือไม่ทำเลย"DELETE FROM "posts" WHERE "posts"."id" = 1— DELETE คือคำสั่ง "ทิ้ง" · แปลว่า "ทิ้งแฟ้มจากลิ้นชักpostsเฉพาะแผ่นที่เลขที่ (id) เป็น 1" · สังเกตว่า Rails ไม่ได้เช็คอะไรก่อนเลย ส่งคำสั่งทิ้งไปตรง ๆROLLBACK— ตู้ปฏิเสธ Rails เลยยกเลิกทั้งชุด · ไม่มีอะไรหาย บทความยังอยู่ครบ
แล้วอ่าน error ทีละท่อน:
| ท่อนใน error | แปลเป็นภาษาคน |
|---|---|
(wongnok-blog):3 | พังที่บรรทัดที่ 3 ที่เราพิมพ์ใน console (post.destroy) |
PG::ForeignKeyViolation | PostgreSQL (PG) บอกว่า "ผิดกฎ foreign key" |
update or delete on table "posts" violates foreign key constraint … on table "comments" | "การแก้หรือทิ้งแฟ้มในลิ้นชัก posts ไปขัดกฎของลิ้นชัก comments" |
"fk_rails_2fd19c0db7" | ชื่อกฎที่ Rails ตั้งให้อัตโนมัติตอนสร้างตาราง comments (บท 111 เจอชื่อเดียวกัน) — ไม่ต้องจำ |
(ActiveRecord::InvalidForeignKey) | ชื่อ error ฝั่ง Rails ที่ห่อ error ของ PostgreSQL ไว้อีกชั้น — ถ้าจะ rescue ให้ใช้ชื่อนี้ |
DETAIL: Key (id)=(1) is still referenced from table "comments". | "แฟ้มเลขที่ 1 ยังมีใบในลิ้นชัก comments อ้างถึงอยู่" ← ประโยคที่สำคัญที่สุด |
สรุปเป็นภาษาคน: ถ้าทิ้งบทความเลขที่ 1 ไป ใบคอมเมนต์ 2 ใบที่เขียนว่า "เป็นของบทความเลขที่ 1" จะชี้ไปหาของที่ไม่มีอยู่จริง ตู้เลยไม่ยอม
กฎนี้มาจาก foreign_key: true ตอนสร้างตาราง comments (post:references ในบท 107) — ตัวเดียวกับที่บท 111 ใช้กันใบคอมเมนต์ที่ชี้ไปบทความเลข 9999
· บท 111 เห็นด้าน "เพิ่มใบลูกที่ไม่มีแม่" · บทนี้เห็นอีกด้าน "ทิ้งแม่ที่ยังมีลูก"
ใบคอมเมนต์ทุกใบมีช่อง "เป็นของบทความเลขที่ …" เขียนไว้ · ตู้ของร้านเราออกแบบมาพิเศษ —
พอมีคนจะดึงแฟ้มบทความเลขที่ 1 ออกไปทิ้ง ตู้จะไม่ยอมปล่อยแฟ้มออกมา (ดึงยังไงก็ไม่ออก) เพราะยังมีใบคอมเมนต์ 2 ใบเขียนเลข 1 อยู่
คนดูแลสต็อก (model) ไม่ได้เป็นคนห้าม — เขาเดินไปดึงตามสั่งเลย แต่ตัวตู้ไม่ยอม
ที่ไม่เหมือนกัน: ตู้จริงที่ดึงแฟ้มไม่ออก คนจะลองงัด หรือเดินไปหาเองว่าใบไหนอ้างอยู่ ·
ตู้ของ PostgreSQL แค่ปฏิเสธแล้วบอกชื่อลิ้นชักที่อ้างถึง (table "comments") — มันไม่ได้ถามว่าเราอยากให้ทำอะไรกับใบคอมเมนต์
เราต้องบอกเองล่วงหน้า และนั่นคือเรื่องของทั้งบทนี้
เทียบกับ Go: ถ้าเขียน db.Delete(&post) ด้วย GORM (บท 27) กับตารางที่มี foreign key แบบนี้ ก็ถูกตู้ปฏิเสธด้วยกฎเดียวกัน
— เพราะคนปฏิเสธคือ PostgreSQL ไม่ใช่ Rails · ภาษาไหนมาต่อตู้นี้ก็โดนกฎเดียวกันหมด (ฝั่ง Go จะได้ error กลับมาเป็นค่า err แทนการระเบิด)
- foreign key constraint ·
ActiveRecord::InvalidForeignKey - กฎของตู้ที่บังคับว่าเลขในช่อง
post_idต้องมีอยู่จริงในลิ้นชัก posts — ทั้งตอนเพิ่มใบลูก และตอนทิ้งแฟ้มแม่ · พิมพ์บอก AI ได้ว่า "destroying a Post raises ActiveRecord::InvalidForeignKey because comments still reference it — what are my options?" - parent · child record (แม่ · ลูก)
- แฟ้มที่ถูกอ้างถึง (บทความ) คือแม่ · แฟ้มที่มีช่องชี้ไปหาแม่ (ใบคอมเมนต์ที่มี
post_id) คือลูก · ถาม senior ได้ว่า "ตารางนี้ถ้าลบ parent แล้ว child ต้องเป็นยังไง ธุรกิจตัดสินไว้หรือยัง" - orphan record (แฟ้มกำพร้า)
- ใบลูกที่แม่หายไปแล้ว ชี้ไปหาเลขที่ไม่มีอยู่จริง · foreign key มีไว้กันไม่ให้เกิดสิ่งนี้ · ถาม senior ได้ว่า "ตารางเก่า ๆ ที่ไม่มี foreign key มี orphan ค้างอยู่เท่าไหร่"
ทีนี้ลองทิ้งบทความ ก๋วยเตี๋ยวเรืออยุธยา (เลขที่ 4) ที่ยังไม่มีใครคอมเมนต์เลย — ตารางเดิม กฎ foreign key เดิม:
post = Post.find_by(title: "ก๋วยเตี๋ยวเรืออยุธยา")
post.comments.count # => 0
post.destroy
ได้ error แบบเดียวกันไหม?
ทิ้งได้ปกติ ไม่มี error:
wongnok-blog(dev):003> post.destroy
TRANSACTION (2.1ms) BEGIN /*application='WongnokBlog'*/
Post Destroy (4.1ms) DELETE FROM "posts" WHERE "posts"."id" = 4 /*application='WongnokBlog'*/
TRANSACTION (1.6ms) COMMIT /*application='WongnokBlog'*/
=> #<Post … (ผลยาว ตัดออก)
wongnok-blog(dev):004> Post.count
Post Count (2.7ms) SELECT COUNT(*) FROM "posts" /*application='WongnokBlog'*/
=> 5
SQL เหมือนกันเป๊ะ (DELETE … WHERE "posts"."id" = 4) ต่างกันแค่บรรทัดสุดท้าย: COMMIT (ยืนยันจริง) แทน ROLLBACK
· ตู้ไม่ได้ห้ามทิ้งบทความทุกชิ้น ห้ามเฉพาะชิ้นที่ยังมีใบลูกอ้างอยู่
บทเรียน: นี่คือเหตุผลที่บั๊กแบบนี้มักหลุดไปถึงตอนใช้งานจริง — ตอนทดสอบเรามักลบบทความที่เพิ่งสร้าง (ยังไม่มีคอมเมนต์) เลยไม่เคยเห็น error
· ทดลองเสร็จแล้วสั่ง bin/rails db:reset ให้บทความกลับมาครบ 6 ชิ้น
สี่ทางเลือก — ภาพรวมก่อนลงมือ
Rails ไม่เดาให้ว่าลบบทความแล้วคอมเมนต์ควรเป็นยังไง เพราะคำตอบขึ้นกับธุรกิจ · เราต้องเลือกเองหนึ่งในสี่แบบนี้ แล้วเขียนบอกไว้ในโค้ด (สามแบบเขียนใน model · หนึ่งแบบเขียนใน migration)
① คนดูแลสต็อกหยิบใบคอมเมนต์ออกมาทีละใบ ทิ้งทีละใบ และทำ "ขั้นตอนอัตโนมัติ" ของใบนั้นครบ (เช่น จดลงสมุดว่าทิ้งใบไหน) — ละเอียดแต่ช้า
② คนดูแลสต็อกโกยใบคอมเมนต์ทั้งกองของบทความนี้ลงถังทีเดียว ไม่ได้ดูทีละใบ — เร็ว แต่ไม่มีใครจดอะไร
③ ตัวตู้มีกลไกในตัว: พอแฟ้มบทความถูกดึงออก ใบคอมเมนต์ที่อ้างถึงหล่นลงถังเอง — คนดูแลสต็อกไม่รู้ด้วยซ้ำว่ามีอะไรหล่นไป
④ คนดูแลสต็อกปฏิเสธ: "บทความนี้ยังมีคอมเมนต์ ทิ้งไม่ได้" แล้วบอกเหตุผลกลับมา
ที่ไม่เหมือนกัน: ในร้านจริงถ้าทิ้งผิดยังไปคุ้ยถังขยะคืนได้ · ในฐานข้อมูล ① ② ③ ทิ้งแล้วหายถาวร ไม่มีถังให้คุ้ย
(ถ้าธุรกิจอยากกู้คืนได้ ต้องออกแบบ "ลบแบบซ่อนไว้" เอง — แบบ DeletedAt ของ GORM ในบท 27)
เราจะลองทีละแบบกับบทความต้มยำกุ้ง แล้วดูว่า Rails ยิง SQL อะไรจริง · ก่อนเริ่ม ให้แปะ callback (ขั้นตอนอัตโนมัติ จากบท 112) ไว้ที่ใบคอมเมนต์
ชั่วคราว — เพื่อให้เห็นด้วยตาว่าแบบไหน "จดบันทึก" แบบไหนไม่จด
(เพิ่มแค่บรรทัด after_destroy · กฎอื่นที่คุณเติมไว้จากบทก่อน ๆ คงไว้ตามเดิม):
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :post
belongs_to :user
after_destroy { puts "📝 จดบันทึก: ทิ้งใบคอมเมนต์เลขที่ #{id} แล้ว" }
end
after_destroy— callback แบบที่ทำงานหลังจากใบนี้ถูกทิ้ง (ญาติของafter_commitในบท 112){ puts "…" }— block (บท 93) ที่พิมพ์ข้อความออกจอ ·#{id}= แทรกเลขที่ของใบนี้ลงในข้อความ- ในงานจริงตรงนี้อาจเป็น "ลบรูปที่แนบมากับคอมเมนต์" หรือ "หักแต้มผู้ใช้" — งานที่ต้องเกิดทุกครั้งที่ใบคอมเมนต์หายไป
ทางที่ ① — dependent: :destroy ทิ้งทีละใบ พร้อมจดบันทึก
บอก Rails ว่า "ก่อนทิ้งบทความ ให้ทิ้งใบคอมเมนต์ของมันให้หมดก่อน แบบทีละใบ" · เติมแค่ท้ายบรรทัด has_many :comments
(บรรทัดอื่นในไฟล์ของคุณ เช่น has_many :taggings จากบท 115 คงไว้):
# app/models/post.rb
class Post < ApplicationRecord
belongs_to :user
has_many :comments, dependent: :destroy
end
dependent: แปลว่า "ของที่พึ่งพาแฟ้มนี้" · :destroy คือวิธีจัดการ · แก้ไฟล์แล้วต้องสั่ง reload! ใน console (หรือปิดแล้วเปิดใหม่ — บท 104)
แล้วหาบทความใหม่อีกรอบ เพราะตัวแปร post เก่ายังจำกฎเก่าอยู่:
wongnok-blog(dev):001> post = Post.find_by(title: "ต้มยำกุ้งน้ำข้น")
Post Load (1.5ms) SELECT "posts".* FROM "posts" WHERE "posts"."title" = 'ต้มยำกุ้งน้ำข้น' LIMIT 1 /*application='WongnokBlog'*/
=> #<Post … (ผลยาว ตัดออก)
wongnok-blog(dev):002> post.destroy
TRANSACTION (1.4ms) BEGIN /*application='WongnokBlog'*/
Comment Load (1.3ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 1 /*application='WongnokBlog'*/
Comment Destroy (1.3ms) DELETE FROM "comments" WHERE "comments"."id" = 1 /*application='WongnokBlog'*/
📝 จดบันทึก: ทิ้งใบคอมเมนต์เลขที่ 1 แล้ว
Comment Destroy (0.9ms) DELETE FROM "comments" WHERE "comments"."id" = 2 /*application='WongnokBlog'*/
📝 จดบันทึก: ทิ้งใบคอมเมนต์เลขที่ 2 แล้ว
Post Destroy (1.2ms) DELETE FROM "posts" WHERE "posts"."id" = 1 /*application='WongnokBlog'*/
TRANSACTION (1.9ms) COMMIT /*application='WongnokBlog'*/
=> #<Post … (ผลยาว ตัดออก)
wongnok-blog(dev):003> Comment.count
Comment Count (2.7ms) SELECT COUNT(*) FROM "comments" /*application='WongnokBlog'*/
=> 5
Comment Load … SELECT "comments".* … WHERE "comments"."post_id" = 1— "หยิบใบคอมเมนต์ทุกใบของบทความเลขที่ 1 ออกมาก่อน" (เอาขึ้นมาเป็น object ใน Ruby)DELETE FROM "comments" WHERE "comments"."id" = 1— ทิ้งใบเลขที่ 1 → ตามด้วย 📝 = callback ของใบนั้นทำงาน- ทำซ้ำกับใบเลขที่ 2 · หนึ่งใบ = หนึ่งคำสั่ง DELETE
- ลูกหมดแล้ว ค่อย
DELETE FROM "posts" … = 1· ตู้ยอม →COMMIT· คอมเมนต์ทั้งร้านเหลือ 5 จาก 7
ราคาที่จ่ายคือจำนวนคำสั่ง — ผมลองบทความที่มีคอมเมนต์ 100 ใบ แล้วนับ SQL ตอน destroy ได้ 104 คำสั่ง
(BEGIN 1 · หยิบคอมเมนต์ 1 · DELETE ทีละใบ 100 · DELETE บทความ 1 · COMMIT 1) · คอมเมนต์ 5,000 ใบก็ราว 5,000 กว่าคำสั่ง ช้าตามจำนวนลูก
เทียบกับ Go: GORM มีท่าที่ใกล้เคียง คือบอกตอนลบว่าให้ลบลูกชื่ออะไรไปด้วย (ต้องเขียนทุกครั้งที่ลบ ไม่ได้ประกาศไว้ที่ struct ครั้งเดียวแบบ Rails):
// Go + GORM — ลบบทความพร้อม Comments ของมัน (ตามเอกสาร GORM หัวข้อ Delete Associations)
db.Select("Comments").Delete(&post)
Select("Comments") ตรงนี้ไม่ได้แปลว่า "ค้นหา" — แปลว่า "เลือกลูกชื่อ Comments (ชื่อ field ใน struct Post) ให้ลบตามไปด้วย" · ไม่ใส่ = GORM ลบแค่บทความ แล้วโดนตู้ปฏิเสธแบบต้นบท
ทดลองเสร็จ สั่ง exit ออกจาก console แล้ว bin/rails db:reset ให้ต้มยำกุ้งกลับมา ก่อนไปทางที่ ②
ทางที่ ② — dependent: :delete_all โกยทิ้งทั้งกอง
เปลี่ยนคำเดียวใน app/models/post.rb (callback 📝 ของ Comment ยังอยู่เหมือนเดิม):
has_many :comments, dependent: :delete_all
เปิด console ใหม่ แล้วทิ้งต้มยำกุ้งอีกรอบ:
post = Post.find_by(title: "ต้มยำกุ้งน้ำข้น")
post.destroy
บรรทัด 📝 จดบันทึก จะขึ้นกี่บรรทัด? และ SQL ที่ขึ้นต้นด้วย DELETE จะมีกี่บรรทัด?
📝 ไม่ขึ้นเลยสักบรรทัด · DELETE มีแค่ 2 บรรทัด:
wongnok-blog(dev):002> post.destroy
TRANSACTION (1.7ms) BEGIN /*application='WongnokBlog'*/
Comment Delete All (1.1ms) DELETE FROM "comments" WHERE "comments"."post_id" = 1 /*application='WongnokBlog'*/
Post Destroy (0.9ms) DELETE FROM "posts" WHERE "posts"."id" = 1 /*application='WongnokBlog'*/
TRANSACTION (1.0ms) COMMIT /*application='WongnokBlog'*/
=> #<Post … (ผลยาว ตัดออก)
wongnok-blog(dev):003> Comment.count
Comment Count (1.8ms) SELECT COUNT(*) FROM "comments" /*application='WongnokBlog'*/
=> 5
DELETE FROM "comments" WHERE "comments"."post_id" = 1— "ทิ้งใบคอมเมนต์ทุกใบที่เป็นของบทความ 1" ในคำสั่งเดียว · สังเกตว่า WHERE ดูที่post_idไม่ใช่idทีละใบ- ไม่มี
Comment Load— Rails ไม่ได้หยิบใบคอมเมนต์ขึ้นมาเป็น object เลย · ไม่มี object ก็ไม่มีใครเรียก callback → ไม่มี 📝 - ผลลัพธ์ในตู้เหมือนทางที่ ① ทุกอย่าง (คอมเมนต์เหลือ 5) — ต่างกันที่วิธีทำ
กับบทความที่มีคอมเมนต์ 100 ใบ ผมนับได้ 4 คำสั่ง (BEGIN · DELETE คอมเมนต์ · DELETE บทความ · COMMIT) เทียบกับ 104 ของทางที่ ① · และไม่ว่าจะมีกี่พันใบ ก็ยังเป็น 4 คำสั่ง
ใน Go ท่านี้คือการเขียนลบลูกด้วยเงื่อนไขเองก่อน แล้วค่อยลบแม่ — Rails แค่เขียนสองบรรทัดนี้ให้อัตโนมัติ:
// Go + GORM — สิ่งที่ dependent: :delete_all ทำให้ ถ้าเขียนเอง
db.Where("post_id = ?", post.ID).Delete(&Comment{}) // DELETE FROM comments WHERE post_id = …
db.Delete(&post)
ราคาของความเร็ว: ถ้าวันหนึ่งใบคอมเมนต์มีลูกของมันเอง (เช่น ตาราง likes ที่มี comment_id) — delete_all ไม่ไปจัดการหลานให้ ·
ผมลองสร้างตาราง likes ที่มี foreign key ชี้ไปใบคอมเมนต์แล้วทิ้งบทความ ได้ ActiveRecord::InvalidForeignKey แบบเดียวกับต้นบท (คราวนี้ลิ้นชัก likes เป็นคนห้าม)
ทดลองเสร็จ exit แล้ว bin/rails db:reset อีกรอบ
ทางที่ ③ — on_delete: :cascade ให้ตู้ทิ้งเอง
สองทางแรก Rails เป็นคนลบ · ทางนี้ย้ายงานไปให้ตัวตู้ (PostgreSQL) — เราแก้กฎ foreign key เดิมจาก "ห้ามทิ้งแม่ที่มีลูก" เป็น "ทิ้งแม่แล้วให้ลูกหายตาม" · การแก้กฎของตู้ = แก้โครงตู้ → ต้องเขียน migration (ใบสั่งช่าง บท 108)
เริ่มจากเอา dependent: ออกจาก Post ก่อน (ให้เห็นผลของตู้ล้วน ๆ):
has_many :comments
แล้วสร้างใบสั่งช่างเปล่า ๆ หนึ่งใบ (ชื่อ migration ตั้งเองได้ ขอให้อ่านแล้วรู้ว่าทำอะไร):
$ bin/rails generate migration ChangeCommentsPostForeignKeyToCascade
invoke active_record
create db/migrate/20261104120515_change_comments_post_foreign_key_to_cascade.rb
ตัวเลขหน้าชื่อไฟล์คือวันเวลาที่สร้าง — ของคุณจะต่าง · เปิดไฟล์นั้น แล้วเขียนข้างใน change สองบรรทัด:
# db/migrate/20261104120515_change_comments_post_foreign_key_to_cascade.rb
class ChangeCommentsPostForeignKeyToCascade < ActiveRecord::Migration[8.1]
def change
remove_foreign_key :comments, :posts
add_foreign_key :comments, :posts, on_delete: :cascade
end
end
remove_foreign_key :comments, :posts— "รื้อกฎเดิมที่ผูกลิ้นชัก comments ไว้กับลิ้นชัก posts ออก" (กฎของตู้แก้ตรง ๆ ไม่ได้ ต้องรื้อแล้วติดใหม่)add_foreign_key :comments, :posts, on_delete: :cascade— "ติดกฎใหม่ ที่เวลาทิ้งแฟ้มบทความ ให้ทิ้งใบคอมเมนต์ที่อ้างถึงตามไปด้วย" ·on_delete= "เมื่อมีการทิ้ง" ·:cascade= "ไหลต่อเป็นทอด ๆ" (แบบน้ำตก)
$ bin/rails db:migrate
== 20261104120515 ChangeCommentsPostForeignKeyToCascade: migrating ============
-- remove_foreign_key(:comments, :posts)
-> 0.0095s
-- add_foreign_key(:comments, :posts, {on_delete: :cascade})
-> 0.0041s
== 20261104120515 ChangeCommentsPostForeignKeyToCascade: migrated (0.0136s) ===
เปิดแปลนตู้ฉบับล่าสุด (db/schema.rb) จะเห็นบรรทัดกฎของ comments เปลี่ยนไปหนึ่งบรรทัด (แสดงแบบ git diff — บรรทัด - คือของเดิม · + คือของใหม่ ·
นอกจากนี้เลข version: บรรทัดบนสุดก็เปลี่ยนเป็นเลขของ migration ใหม่ตามปกติ):
- add_foreign_key "comments", "posts"
+ add_foreign_key "comments", "posts", on_delete: :cascade
migration นี้ย้อนได้ด้วย bin/rails db:rollback — ผมลองแล้ว Rails รื้อกฎ cascade ออกแล้วติดกฎเดิมกลับให้เอง
(เพราะเราบอกชื่อลิ้นชักทั้งสองฝั่งไว้ครบ มันเลยรู้ว่าต้องติดอะไรคืน) · ถ้าลอง rollback ดู ให้สั่ง bin/rails db:migrate กลับก่อนไปต่อ
· ทีนี้ bin/rails db:reset แล้วเปิด console ทิ้งต้มยำกุ้ง:
wongnok-blog(dev):001> post = Post.find_by(title: "ต้มยำกุ้งน้ำข้น")
… (Post Load — ตัดออก)
wongnok-blog(dev):002> post.destroy
TRANSACTION (2.4ms) BEGIN /*application='WongnokBlog'*/
Post Destroy (5.2ms) DELETE FROM "posts" WHERE "posts"."id" = 1 /*application='WongnokBlog'*/
TRANSACTION (2.0ms) COMMIT /*application='WongnokBlog'*/
=> #<Post … (ผลยาว ตัดออก)
wongnok-blog(dev):003> Comment.count
Comment Count (1.1ms) SELECT COUNT(*) FROM "comments" /*application='WongnokBlog'*/
=> 5
เทียบกับต้นบทดี ๆ: SQL ที่ Rails ยิงเหมือนกันทุกตัวอักษร (DELETE FROM "posts" WHERE "posts"."id" = 1) — ต่างกันแค่คราวนี้ตู้ตอบ COMMIT
คอมเมนต์ 2 ใบหายไปจริง (เหลือ 5) แต่ log ไม่มีบรรทัดไหนพูดถึงคอมเมนต์เลย และไม่มี 📝 — ตู้ทิ้งเองข้างใน Rails ไม่รู้เรื่อง
ข้อดีที่สองทางแรกให้ไม่ได้: กฎนี้อยู่ในตู้ ใครมาทิ้งแฟ้มทางไหนก็โดน — ผมลองสั่ง SQL ตรง ๆ DELETE FROM posts WHERE id = 5 โดยไม่ผ่าน model
ใบคอมเมนต์ของบทความ 5 ก็หายตามครบ · นี่คือแนวคิดเดียวกับบท 111: กฎใน model ข้ามได้ กฎในตู้ข้ามไม่ได้
ทดลองเสร็จ exit แล้ว bin/rails db:reset ให้บทความกลับมาครบ 6 ชิ้น (กฎ cascade ยังอยู่ เพราะอยู่ในแปลนตู้แล้ว)
เทียบกับ Go: กฎนี้เป็นของ PostgreSQL ล้วน ๆ จึงเขียนได้ทั้งใน SQL ของ goose (บท 31) และใน tag ของ GORM:
-- goose (SQL ล้วน) — คำว่า ON DELETE CASCADE ตัวเดียวกับที่ Rails สร้างให้
post_id BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE
// GORM — บอกผ่าน tag บน struct (ตามเอกสาร GORM หัวข้อ Foreign Key Constraint)
Comments []Comment `gorm:"constraint:OnDelete:CASCADE;"`
- บรรทัด goose แปลว่า "ช่อง
post_idเป็นเลขจำนวนเต็ม (BIGINT) · ห้ามว่าง (NOT NULL) · ต้องตรงกับidที่มีอยู่จริงในลิ้นชัก posts (REFERENCES posts(id)) · และเมื่อแฟ้มบทความถูกทิ้ง ให้ทิ้งใบนี้ตาม (ON DELETE CASCADE)" - บรรทัด GORM คือ field
Commentsใน struct Post พร้อม tag ที่บอกว่า "ตอนAutoMigrateสร้างตาราง (บท 27) ให้ติดกฎ cascade ด้วย"
dependent: :destroy- Rails หยิบลูกขึ้นมาทีละใบแล้ว
destroy· callback ของลูกทำงานครบ · ช้าตามจำนวนลูก · พิมพ์บอก AI ได้ว่า "use dependent: :destroy because Comment has an after_destroy callback that must run" dependent: :delete_all- Rails ยิง DELETE คำสั่งเดียวตามเงื่อนไข
post_id· ไม่สร้าง object · callback ไม่ทำงาน · ถาม senior ได้ว่า "comment มี callback หรือมีตารางลูกต่ออีกไหม ถ้าไม่มี ใช้ delete_all ได้หรือเปล่า" ON DELETE CASCADE·on_delete: :cascade- กฎของ foreign key ในตู้ (ไม่ใช่ของ Rails) ที่ทิ้งลูกตามแม่อัตโนมัติ · ทำงานแม้คนลบไม่ผ่าน Rails · Rails ไม่เห็นและไม่เรียก callback · พิมพ์บอก AI ได้ว่า "write a Rails migration that changes the comments → posts foreign key to ON DELETE CASCADE, reversible"
ทางที่ ④ — dependent: :restrict_with_error ห้ามทิ้ง
บางธุรกิจไม่อยากให้ลบเลยถ้ายังมีของอ้างอยู่ — เช่น ห้ามลบหมวดหมู่ที่ยังมีบทความ (สไลด์หน้า 54 ใช้ตัวนี้กับ Category) · ต่างจากต้นบทตรงที่คราวนี้ Rails เป็นคนห้ามเอง อย่างสุภาพ ก่อนจะไปถึงตู้ · ลองกับ Post:
has_many :comments, dependent: :restrict_with_error
wongnok-blog(dev):001> post = Post.find_by(title: "ต้มยำกุ้งน้ำข้น")
… (Post Load — ตัดออก)
wongnok-blog(dev):002> post.destroy
TRANSACTION (2.5ms) BEGIN /*application='WongnokBlog'*/
Comment Exists? (1.7ms) SELECT 1 AS one FROM "comments" WHERE "comments"."post_id" = 1 LIMIT 1 /*application='WongnokBlog'*/
TRANSACTION (1.2ms) ROLLBACK /*application='WongnokBlog'*/
=> false
wongnok-blog(dev):003> post.errors.full_messages
=> ["Cannot delete record because dependent comments exist"]
wongnok-blog(dev):004> Post.count
Post Count (3.2ms) SELECT COUNT(*) FROM "posts" /*application='WongnokBlog'*/
=> 6
SELECT 1 AS one FROM "comments" WHERE "comments"."post_id" = 1 LIMIT 1— "ไม่ต้องหยิบแฟ้มมา แค่บอกว่ามีใบคอมเมนต์ของบทความ 1 สักใบไหม" (LIMIT 1= เจอใบแรกก็หยุด · คำสั่งเดียวกับexists?ในบท 113)- เจอ → ไม่ส่ง DELETE เลย แล้ว
ROLLBACK·destroyคืนfalse(เหมือนsaveที่คืนfalseในบท 110) errors.full_messages— "ลบไม่ได้ เพราะยังมี comments ที่พึ่งพาอยู่" · ข้อความนี้เอาไปโชว์บนหน้าเว็บได้เลย ไม่ต้องหน้าแดง
อยากให้ระเบิดแทนคืน false ก็ใช้ destroy! ตามกฎ ! ของบท 110 — ผลจริง:
Failed to destroy Post with id=1 (ActiveRecord::RecordNotDestroyed)
dependent: :restrict_with_error- ห้ามลบแม่ถ้ายังมีลูก โดย Rails ถามตู้ก่อนว่ามีลูกไหม ·
destroyคืนfalseพร้อมข้อความในerrorsแทนการระเบิด · พิมพ์บอก AI ได้ว่า "use dependent: :restrict_with_error so deleting a category with posts shows an error message instead of a 500 page" ActiveRecord::RecordNotDestroyed- error ที่ได้จาก
destroy!เมื่อการลบถูกห้าม · คู่กับRecordInvalidของsave!· ถาม senior ได้ว่า "ตรงนี้ควรใช้ destroy แล้วเช็ค false หรือ destroy! แล้ว rescue"
เลือกแบบไหน — คำตอบมาจากธุรกิจ ไม่ใช่จาก AI
ทั้งสี่แบบ "ถูก" ทางเทคนิคหมด · ที่ต่างคือธุรกิจอยากให้เกิดอะไร — คำถามแบบนี้ AI ตอบแทนไม่ได้ เพราะมันไม่รู้กฎของร้านคุณ (ถ้าไม่บอก มันก็ได้แต่เดา) · ถามคนที่รู้เรื่องธุรกิจด้วยคำถามในคอลัมน์ซ้าย:
| ถามธุรกิจว่า… | ถ้าคำตอบคือ… | เลือก |
|---|---|---|
| ลบแม่แล้ว ลูกต้องเก็บไว้ไหม? | ต้องเก็บ ห้ามลบแม่ (เช่น ลูกค้าที่มีประวัติสั่งซื้อ · หมวดหมู่ที่ยังมีบทความ) | :restrict_with_error |
| ตอนลูกหายไป มีงานต้องทำไหม? | มี (ลบรูปแนบ · หักแต้ม · แจ้งเตือน) — งานพวกนี้เป็น callback | :destroy |
| ลูกเยอะไหม · มีหลานต่อไหม? | ลูกเป็นพัน ๆ · ไม่มี callback · ไม่มีหลาน | :delete_all |
| มีใครลบข้อมูลโดยไม่ผ่านแอปนี้ไหม? | มี (สคริปต์ SQL · แอปอื่นใช้ตู้เดียวกัน) และต้องไม่เหลือแฟ้มกำพร้าเด็ดขาด | on_delete: :cascade (ใช้คู่กับแบบอื่นได้) |
ตอนพิมพ์ dependent: ผิด Rails จะบอกค่าที่ใช้ได้ทั้งหมดในข้อความ error (ดูภารกิจ) — มีอีกสองค่าที่บทนี้ไม่ได้ลอง:
:nullify (ลบช่อง post_id ของลูกให้ว่าง — ใช้กับตารางเราไม่ได้ ผมลองแล้วตู้ฟ้อง NotNullViolation เพราะ post_id ตั้ง null: false ไว้)
กับ :destroy_async (ทิ้งทีละใบแบบทางที่ ① แต่ทำทีหลังด้วยงานเบื้องหลัง — รอ Day 3)
บล็อกของเราเลือกอะไร: ลบบทความแล้วคอมเมนต์หายตามได้ · และอยากได้ทั้ง callback และการรับประกันของตู้ → ใช้ทางที่ ① คู่กับ ③ · Rails ลบลูกทีละใบก่อน (callback ทำงาน) · ส่วน cascade ในตู้เป็น "ตาข่ายรองรับ" เผื่อมีใครลบข้ามแอป — เป็นหลักเดียวกับประโยคหลักของวันนี้ข้อ ②: กฎใน model ไม่ใช่การรับประกัน ต้องมีกฎที่ตู้คู่กัน
ทำให้แอปของคุณอยู่ในสภาพนี้ก่อนไปหัวข้อถัดไป (โชว์เฉพาะบรรทัดที่เกี่ยว · กฎอื่นจากบทก่อน ๆ คงไว้):
# app/models/post.rb
class Post < ApplicationRecord
belongs_to :user
has_many :comments, dependent: :destroy
has_many :taggings, dependent: :destroy # ถ้ามีจากบท 115
has_many :tags, through: :taggings # ถ้ามีจากบท 115
end
# app/models/comment.rb — ลบบรรทัด after_destroy ที่ใส่ทดลองออก
class Comment < ApplicationRecord
belongs_to :post
belongs_to :user
end
ทุก has_many ที่ชี้มาหาบทความต้องตัดสินใจ ไม่ใช่แค่ comments — ผมลองติดป้าย "เผ็ด" ให้ต้มยำกุ้ง
แล้วใส่ dependent: :destroy ให้ comments อย่างเดียว · destroy ยังได้ InvalidForeignKey โดยคราวนี้ท่อน on table "taggings"
และคอมเมนต์ 2 ใบไม่หาย (transaction ยกเลิกทั้งชุด) · เติม dependent: :destroy ให้ has_many :taggings แล้วลบได้ —
ใบจับคู่หายตาม แต่ตัวป้าย "เผ็ด" ยังอยู่ ใช้กับบทความอื่นต่อได้
migration cascade เก็บไว้ตามเดิม · แล้วสั่ง bin/rails db:reset ให้ได้ข้อมูลครบ 6 บทความ 7 คอมเมนต์
counter_cache — ป้ายตัวเลขบนหน้าแฟ้ม ไม่ต้องเดินไปนับ
หน้ารายการบทความมักโชว์ "มีคอมเมนต์กี่ใบ" ข้างทุกชื่อ · ถ้าเขียนตรง ๆ ทุกบทความจะเดินไปนับที่ลิ้นชัก comments หนึ่งรอบ —
นี่คือ N+1 แบบที่บท 114 สอน แต่เป็นรุ่น COUNT:
wongnok-blog(dev):001> Post.all.each { |post| puts "#{post.title}: #{post.comments.size}" }; nil
Post Load (39.4ms) SELECT "posts".* FROM "posts" /*application='WongnokBlog'*/
Comment Count (1.3ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 1 /*application='WongnokBlog'*/
ต้มยำกุ้งน้ำข้น: 2
Comment Count (0.9ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 2 /*application='WongnokBlog'*/
ส้มตำปูปลาร้า: 1
Comment Count (0.8ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 3 /*application='WongnokBlog'*/
ข้าวมันไก่ประตูน้ำ: 1
Comment Count (0.8ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 4 /*application='WongnokBlog'*/
ก๋วยเตี๋ยวเรืออยุธยา: 0
Comment Count (0.8ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 5 /*application='WongnokBlog'*/
ผัดกะเพราหมูสับ: 3
Comment Count (0.7ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 6 /*application='WongnokBlog'*/
แกงเขียวหวานไก่: 0
=> nil
; nilท้ายบรรทัด — แค่ให้ console ไม่พิมพ์บทความทั้ง 6 ชิ้นออกมาซ้ำ (บรรทัดนั้นคืนค่าnilแทน)- หยิบบทความ 1 รอบ +
COUNT(*)(นับว่ามีกี่ใบ) อีก 6 รอบ = 7 คำสั่ง · บทความ 50 ชิ้นต่อหน้า = 51 คำสั่ง
counter_cache แก้ด้วยการจดจำนวนไว้บนแฟ้มบทความเลย — เพิ่มช่อง comments_count ในลิ้นชัก posts
แล้วให้ Rails บวก/ลบตัวเลขนี้ให้เองทุกครั้งที่มีใบคอมเมนต์เพิ่มหรือหาย · เวลาอยากรู้จำนวน แค่อ่านช่องนี้ ไม่ต้องเดินไปนับ
แทนที่ทุกครั้งที่ลูกค้าถาม "บทความนี้มีกี่คอมเมนต์" แล้วต้องเดินไปเปิดลิ้นชัก comments นับทีละใบ · คนดูแลสต็อกติดป้ายตัวเลขไว้บนหน้าแฟ้มบทความ ทุกครั้งที่เก็บใบคอมเมนต์ใหม่ก็ +1 ทุกครั้งที่ทิ้งก็ −1 · ใครถามก็อ่านจากป้ายได้ทันที
ที่ไม่เหมือนกัน: คนจริงเห็นป้ายเพี้ยนก็สงสัยแล้วไปนับใหม่ · Rails เชื่อป้ายเสมอ ไม่เคยไปนับตรวจ ·
ถ้ามีคนเพิ่มหรือลบใบคอมเมนต์โดยไม่ผ่านคนดูแลสต็อก (เช่น SQL ตรง ๆ หรือ delete_all ที่ไม่สร้าง object) ป้ายจะผิดไปเงียบ ๆ จนกว่าเราจะสั่งนับใหม่
ขั้นที่ 1 · เพิ่มช่องบนแฟ้ม — สร้าง migration ที่บอกชื่อช่องกับชนิดไปด้วยเลย:
$ bin/rails generate migration AddCommentsCountToPosts comments_count:integer
invoke active_record
create db/migrate/20261104121224_add_comments_count_to_posts.rb
generator เขียนให้แค่ add_column :posts, :comments_count, :integer · เติม default: 0, null: false เอง (เริ่มที่ 0 และห้ามว่าง — ไม่งั้นบทความใหม่จะมีป้ายว่างเปล่า):
# db/migrate/20261104121224_add_comments_count_to_posts.rb
class AddCommentsCountToPosts < ActiveRecord::Migration[8.1]
def change
add_column :posts, :comments_count, :integer, default: 0, null: false
end
end
$ bin/rails db:migrate
== 20261104121224 AddCommentsCountToPosts: migrating ==========================
-- add_column(:posts, :comments_count, :integer, {default: 0, null: false})
-> 0.0062s
== 20261104121224 AddCommentsCountToPosts: migrated (0.0062s) =================
ขั้นที่ 2 · บอก Rails ให้ดูแลป้าย — ใส่ที่ฝั่งลูก (belongs_to) เพราะใบคอมเมนต์เป็นคนรู้ว่าตัวเองเพิ่งเกิดหรือเพิ่งหาย:
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :post, counter_cache: true
belongs_to :user
end
Rails เดาชื่อช่องจากกฎตั้งชื่อ: comments + _count = comments_count · ถ้าตั้งชื่อช่องอื่นต้องบอกชื่อแทน true
ป้ายพร้อมแล้ว · เปิด console ใหม่ แล้วถามบทความผัดกะเพรา (มีคอมเมนต์จริง 3 ใบ):
post = Post.find_by(title: "ผัดกะเพราหมูสับ")
post.comments.size
post.comments.count
size กับ count ได้ตัวเลขเท่ากันไหม?
ไม่เท่า — size ได้ 0 ซึ่งผิด:
wongnok-blog(dev):002> post.comments.size
=> 0
wongnok-blog(dev):003> post.comments.count
Comment Count (2.0ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 5 /*application='WongnokBlog'*/
=> 3
size— ไม่มี SQL เลย อ่านเลขจากป้ายcomments_countตรง ๆ · แต่ป้ายเพิ่งติดและเริ่มที่ 0 ทุกแฟ้ม ยังไม่มีใครเขียนเลขจริงลงไปcount— เดินไปนับที่ตู้ทุกครั้ง (COUNT(*)) ไม่สนป้าย จึงได้ 3 ที่ถูก
บทเรียน: ติดป้ายใหม่ต้องเขียนเลขเริ่มต้นให้ถูกก่อน — งานนี้เรียกว่า backfill (เติมข้อมูลย้อนหลังให้แฟ้มที่มีอยู่แล้ว) · สไลด์บอกแค่ "ต้องเพิ่มคอลัมน์ก่อน" แต่ไม่ได้บอกขั้นนี้ — ถ้าลืม หน้าเว็บจะโชว์ "0 คอมเมนต์" ทุกบทความเก่าโดยไม่มี error สักบรรทัด
ขั้นที่ 3 · backfill — ให้ตู้นับคอมเมนต์ของทุกบทความแล้วเขียนลงป้ายในคำสั่งเดียว:
wongnok-blog(dev):004> Post.update_all("comments_count = (SELECT COUNT(*) FROM comments WHERE comments.post_id = posts.id)")
Post Update All (6.1ms) UPDATE "posts" SET comments_count = (SELECT COUNT(*) FROM comments WHERE comments.post_id = posts.id) /*application='WongnokBlog'*/
=> 6
wongnok-blog(dev):005> Post.pluck(:title, :comments_count)
Post Pluck (1.9ms) SELECT "posts"."title", "posts"."comments_count" FROM "posts" /*application='WongnokBlog'*/
=>
[["ต้มยำกุ้งน้ำข้น", 2],
["ส้มตำปูปลาร้า", 1],
["ข้าวมันไก่ประตูน้ำ", 1],
["ก๋วยเตี๋ยวเรืออยุธยา", 0],
["ผัดกะเพราหมูสับ", 3],
["แกงเขียวหวานไก่", 0]]
Post.update_all("…")— แก้ทุกแฟ้มในคำสั่งเดียว (ทวนบท 113) · ข้อความในวงเล็บคือ SQL ที่เราเขียนเอง Rails ส่งไปให้ตู้ตามนั้นเลยUPDATE "posts" SET comments_count = …— UPDATE … SET = "แก้ช่องนี้ของทุกแฟ้มในลิ้นชัก posts ให้เป็น…" (ไม่มี WHERE = ทุกแผ่น)(SELECT COUNT(*) FROM comments WHERE comments.post_id = posts.id)— วงเล็บคือคำถามย่อย: "นับใบคอมเมนต์ที่post_idตรงกับเลขที่ของแฟ้มบทความแผ่นที่กำลังแก้อยู่"=> 6— แก้ไป 6 แฟ้ม ·pluck(บท 113) ยืนยันว่าป้ายตรงกับจำนวนจริงแล้ว
อีกท่าที่เอกสาร Rails แนะนำคือ Post.find_each { |post| Post.reset_counters(post.id, :comments) } = "ไล่ทีละบทความ (find_each จากบท 114) แล้วสั่งนับป้ายใหม่" — ได้ผลเท่ากัน แต่ช้ากว่า (2 คำสั่งต่อบทความ)
· ⚠️ ใช้ได้หลังใส่ counter_cache: true แล้วเท่านั้น — สั่งก่อนจะได้ undefined method 'counter_cache_column' for nil (NoMethodError)
backfill ทำครั้งเดียวพอ · ต่อจากนี้ถ้าสั่ง bin/rails db:reset ป้ายจะถูกเองตั้งแต่ต้น เพราะ seeds สร้างคอมเมนต์ผ่าน Rails (p1.comments.create!) ซึ่ง +1 ให้ทุกใบ — ผมลองแล้วได้ 2 · 1 · 1 · 0 · 3 · 0 ตรงกับจำนวนจริง
ผลลัพธ์ — loop เดิมเป๊ะ เหลือคำสั่งเดียว:
wongnok-blog(dev):006> Post.all.each { |post| puts "#{post.title}: #{post.comments.size}" }; nil
Post Load (2.1ms) SELECT "posts".* FROM "posts" /*application='WongnokBlog'*/
ต้มยำกุ้งน้ำข้น: 2
ส้มตำปูปลาร้า: 1
ข้าวมันไก่ประตูน้ำ: 1
ก๋วยเตี๋ยวเรืออยุธยา: 0
ผัดกะเพราหมูสับ: 3
แกงเขียวหวานไก่: 0
=> nil
แล้วป้ายอัปเดตเองจริงไหม? ลองเพิ่มแล้วทิ้งคอมเมนต์หนึ่งใบที่บทความแกงเขียวหวาน (ป้ายเป็น 0):
wongnok-blog(dev):007> post = Post.find_by(title: "แกงเขียวหวานไก่")
… (Post Load — ตัดออก)
wongnok-blog(dev):008> comment = post.comments.create!(user: User.first, body: "รอสูตรอยู่นะ")
… (User Load — ตัดออก)
TRANSACTION (1.6ms) BEGIN /*application='WongnokBlog'*/
Comment Create (3.9ms) INSERT INTO "comments" ("body", "post_id", "user_id", "created_at", "updated_at") VALUES ('รอสูตรอยู่นะ', 6, 1, '2026-11-04 12:14:44.371076', '2026-11-04 12:14:44.371076') RETURNING "id" /*application='WongnokBlog'*/
Post Update All (1.1ms) UPDATE "posts" SET "comments_count" = COALESCE("posts"."comments_count", 0) + 1 WHERE "posts"."id" = 6 /*application='WongnokBlog'*/
TRANSACTION (1.6ms) COMMIT /*application='WongnokBlog'*/
=> #<Comment:0x000000012657e190 id: 8, body: "รอสูตรอยู่นะ", post_id: 6, …>
wongnok-blog(dev):009> post.reload.comments_count
Post Load (1.1ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 6 LIMIT 1 /*application='WongnokBlog'*/
=> 1
wongnok-blog(dev):010> comment.destroy
TRANSACTION (2.2ms) BEGIN /*application='WongnokBlog'*/
Comment Destroy (4.3ms) DELETE FROM "comments" WHERE "comments"."id" = 8 /*application='WongnokBlog'*/
Post Update All (2.0ms) UPDATE "posts" SET "comments_count" = COALESCE("posts"."comments_count", 0) - 1 WHERE "posts"."id" = 6 /*application='WongnokBlog'*/
TRANSACTION (2.2ms) COMMIT /*application='WongnokBlog'*/
=> #<Comment:0x000000012657e190 id: 8, …>
wongnok-blog(dev):011> post.reload.comments_count
Post Load (2.6ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 6 LIMIT 1 /*application='WongnokBlog'*/
=> 0
INSERT INTO "comments" (…) VALUES (…) RETURNING "id"— INSERT = "เพิ่มแฟ้มใหม่ลงลิ้นชัก comments โดยกรอกช่องในวงเล็บแรกด้วยค่าในวงเล็บหลัง แล้วบอกเลขที่ (id) ที่ได้กลับมา" (แบบเดียวกับบท 107)UPDATE "posts" SET "comments_count" = COALESCE("posts"."comments_count", 0) + 1 WHERE "posts"."id" = 6— บรรทัดที่ counter_cache เพิ่มให้: "แก้ป้ายของบทความเลขที่ 6 เป็น เลขเดิม +1" ·COALESCE(…, 0)= "ถ้าช่องว่าง ให้ถือเป็น 0" · ตอนทิ้งก็ −1 · อยู่ใน transaction เดียวกับ INSERT/DELETE ใบคอมเมนต์post.reload— โหลดแฟ้มบทความจากตู้ใหม่ (Post Load) เพราะตัวแปรpostในมือยังเป็นค่าเก่า- เลขที่ใบคอมเมนต์ (
8) ของคุณอาจต่าง ถ้าเคยสร้างคอมเมนต์เพิ่มไว้
เทียบกับ Go: GORM ไม่มีป้ายอัตโนมัติ — ต้องเพิ่ม field CommentsCount ใน struct แล้วเขียน
UPDATE posts SET comments_count = comments_count + 1 เองทุกที่ที่สร้างคอมเมนต์ (และอย่าลืมใส่ใน transaction เดียวกัน) · Rails เขียนให้ทุกที่จากบรรทัดเดียว
size · count · length ไม่เหมือนกัน (ทวนจากบท 113): หลังติด counter_cache แล้ว
loop เดิมถ้าใช้ count ยังได้ 7 คำสั่ง (นับที่ตู้ทุกครั้ง) · ถ้าใช้ length ได้ 7 คำสั่งเหมือนกันแต่หยิบใบคอมเมนต์ทุกใบขึ้นมาแล้วนับในมือ — ผมลองทั้งคู่แล้ว ·
อยากได้ประโยชน์จากป้าย ต้องใช้ size
- counter cache
- คอลัมน์ที่เก็บจำนวนลูกไว้บนแฟ้มแม่ (
posts.comments_count) ให้ Rails บวก/ลบให้เอง · แก้ N+1 ของCOUNT· พิมพ์บอก AI ได้ว่า "add a counter_cache for post.comments, include the migration with default 0 and a backfill" - backfill
- การเติมค่าให้แฟ้มเก่าที่มีอยู่ก่อนเพิ่มคอลัมน์ใหม่ · ไม่ทำ = ข้อมูลเก่าผิดเงียบ ๆ · ถาม senior ได้ว่า "คอลัมน์ใหม่นี้ต้อง backfill ไหม ทำใน migration หรือแยกเป็น task" (ตารางใหญ่ ๆ อย่าทำทีเดียวในตอน deploy — เรื่องตู้ค้างจากบท 109)
reset_counters- method ของ Rails ที่สั่งนับใหม่แล้วเขียนลงป้าย · ใช้ตอน backfill หรือตอนสงสัยว่าป้ายเพี้ยน · ถาม senior ได้ว่า "เคยมีคนแก้ comments ด้วย SQL ตรง ๆ ไหม ควร reset_counters หรือเปล่า"
touch และ inverse_of — ของเล็กอีกสองชิ้น
touch: true = "ทุกครั้งที่ใบคอมเมนต์ถูกเก็บหรือทิ้ง ให้แก้เวลา updated_at (แก้ไขล่าสุดเมื่อ) ของบทความแม่เป็นเวลาปัจจุบันด้วย" ·
มีไว้ให้ระบบรู้ว่า "บทความนี้มีอะไรเปลี่ยน" แม้ตัวบทความเองไม่ได้ถูกแก้ — สำคัญมากตอนทำ cache (เก็บหน้าเว็บที่ทำเสร็จแล้วไว้ใช้ซ้ำ ไม่ต้องทำใหม่ทุกครั้ง — Day 3)
ทุกครั้งที่มีใบคอมเมนต์ใหม่เสียบเข้าแฟ้มบทความ คนดูแลสต็อกจะประทับตราเวลาใหม่ที่หน้าแฟ้มบทความด้วย · พนักงานที่ถ่ายเอกสารแฟ้มนี้เก็บไว้ (cache) แค่ดูตราก็รู้ว่าสำเนาในมือเก่าแล้ว ต้องถ่ายใหม่
ที่ไม่เหมือนกัน: ตราประทับจริงไม่มีต้นทุน · ใน Rails การประทับ = เขียนลงลิ้นชัก posts อีกหนึ่งครั้ง ถ้าคอมเมนต์เปลี่ยนถี่มาก (เช่น บทความดังที่มีคนเม้นทุกวินาที) ลิ้นชัก posts ก็โดนเขียนถี่ตามไปด้วย (สไลด์หน้า 43 เตือนไว้)
# app/models/comment.rb
belongs_to :post, counter_cache: true, touch: true
wongnok-blog(dev):001> post = Post.find_by(title: "ส้มตำปูปลาร้า")
… (Post Load — ตัดออก)
wongnok-blog(dev):002> post.updated_at
=> 2026-11-04 12:13:58.622813000 UTC +00:00
wongnok-blog(dev):003> post.comments.create!(user: User.last, body: "ใส่ปูดองด้วยไหม")
… (User Load · BEGIN · INSERT ใบคอมเมนต์ — ตัดออก)
Post Update All (1.0ms) UPDATE "posts" SET "comments_count" = COALESCE("posts"."comments_count", 0) + 1, "updated_at" = '2026-11-04 12:15:14.736287' WHERE "posts"."id" = 2 /*application='WongnokBlog'*/
TRANSACTION (1.4ms) COMMIT /*application='WongnokBlog'*/
=> #<Comment:0x000000012790bfc8 id: 9, body: "ใส่ปูดองด้วยไหม", post_id: 2, …>
wongnok-blog(dev):004> post.reload.updated_at
Post Load (3.1ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 2 LIMIT 1 /*application='WongnokBlog'*/
=> 2026-11-04 12:15:14.736287000 UTC +00:00
- เวลา
updated_atของบทความเปลี่ยนจาก12:13:58เป็น12:15:14— ทั้งที่เราไม่ได้แตะตัวบทความเลย (ของคุณจะเป็นวันเวลาตอนที่ลอง ·UTC= เวลามาตรฐานโลก ช้ากว่าไทย 7 ชั่วโมง) - Rails ฉลาดพอรวมสองงานไว้ใน UPDATE เดียว: +1 ที่ป้าย และประทับเวลาใหม่
เทียบกับ Go: GORM ไม่มีตัวเลือกแบบนี้ — ถ้าอยากให้เวลาของบทความเปลี่ยนตามคอมเมนต์ ต้องสั่งแก้ UpdatedAt ของบทความเองทุกที่ที่สร้าง แก้ หรือลบคอมเมนต์ · Rails ทำให้ทุกที่จากคำว่า touch: true คำเดียว
inverse_of — แค่รู้จักไว้ · มันบอก Rails ว่า has_many :comments ฝั่ง Post กับ belongs_to :post ฝั่ง Comment
คือความสัมพันธ์เส้นเดียวกันมองจากสองฝั่ง · ประโยชน์คือ ถ้าเราหยิบคอมเมนต์ผ่านบทความ แล้วถามกลับว่า "ใบนี้เป็นของบทความไหน"
Rails ตอบด้วย object บทความตัวเดิมในมือ ไม่เดินไปตู้อีกรอบ · ส่วนใหญ่ Rails เดาเองได้จากชื่อ — ผมลองแล้ว ไม่ต้องเขียนอะไรเพิ่ม:
wongnok-blog(dev):001> post = Post.find_by(title: "ต้มยำกุ้งน้ำข้น")
… (Post Load — ตัดออก)
wongnok-blog(dev):002> comment = post.comments.first
Comment Load (2.5ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 1 ORDER BY "comments"."id" ASC LIMIT 1 /*application='WongnokBlog'*/
=> #<Comment … (ตัดออก)
wongnok-blog(dev):003> comment.post.equal?(post)
=> true
post.comments.first— หยิบใบคอมเมนต์ใบแรกของต้มยำกุ้ง (ORDER BY … ASC LIMIT 1= เรียงเลขที่จากน้อยไปมาก แล้วเอาใบแรก)comment.post.equal?(post)—equal?= "เป็น object ตัวเดียวกันเป๊ะไหม" (ไม่ใช่แค่ค่าเท่ากัน) →trueและไม่มี SQL เลยตอนเรียกcomment.post
คุณถือแฟ้มบทความต้มยำกุ้งไว้มือซ้าย แล้วดึงใบคอมเมนต์ออกมาถือมือขวา · ถ้ามีคนถามว่า "ใบนี้เป็นของบทความไหน" คุณแค่ยกมือซ้ายให้ดู ไม่ต้องเดินไปเปิดตู้หยิบแฟ้มต้มยำกุ้งมาอีกฉบับ
ที่ไม่เหมือนกัน: คนจริงรู้เองเสมอว่าถืออะไรอยู่ · Rails รู้ได้ก็ต่อเมื่อเดาจากชื่อออก ·
ถ้าตั้งชื่อนอกกฎ เช่น ใส่ foreign_key: เอง (ผมลองแล้ว ได้ false = ไปหยิบแฟ้มมาใหม่อีกฉบับ)
หรือใช้ through: ตามเอกสาร Rails — ตรงนั้นแหละที่ต้องเขียน inverse_of: บอกเอง
(ส่วน published_posts ในบท 115 ที่มี class_name กับเงื่อนไข ผมลองแล้ว Rails 8.1 ยังเดาเองได้)
เทียบกับ Go: ใน GORM field Post ใน struct Comment เป็นก้อนข้อมูลอีกก้อนที่ต้องสั่งโหลด (Preload) เอง ไม่ได้ชี้กลับไปหาตัวแปร post ที่ถืออยู่อัตโนมัติ
· Rails ทำให้เองเมื่อชื่อตรงกฎ
touch: true- ลูกเปลี่ยน →
updated_atของแม่เปลี่ยนตาม · เป็นฐานของ cache แบบซ้อนชั้นที่เรียกว่า Russian doll caching (รอ Day 3) · ถาม senior ได้ว่า "touch ตัวนี้ทำให้ตาราง posts โดนเขียนบ่อยเกินไหม ถ้าคอมเมนต์เข้ามาถี่" inverse_of· bi-directional association- บอกว่าสองฝั่งคือความสัมพันธ์เดียวกัน ให้ใช้ object ตัวเดิมร่วมกัน · Rails เดาให้เองเมื่อชื่อตรงกฎ · พิมพ์บอก AI ได้ว่า "this association uses a custom foreign_key (or :through) — do I need inverse_of here?"
ภารกิจ: ห้ามลบผู้ใช้ที่ยังมีผลงานอยู่
ตอนนี้บทความจัดการเรียบร้อยแล้ว · แต่ผู้ใช้ (User) ยังไม่มีใครตัดสินว่า "ลบผู้ใช้แล้วบทความกับคอมเมนต์ของเขาไปไหน"
· ธุรกิจตอบมาแล้ว: "ห้ามลบผู้ใช้ที่ยังมีบทความหรือคอมเมนต์ — ให้แจ้งว่าลบไม่ได้ ไม่ใช่หน้าแดง"
🎯 ภารกิจ · ปกป้องผู้ใช้ด้วย restrict_with_error (~10 นาที)
- เปิด console แล้วลองลบมานะก่อนแก้อะไร:
mana = User.find_by(name: "มานะ")แล้วmana.destroy— จดชื่อ error และลิ้นชักที่ห้าม (ดูท่อนon table "…") - เปิด
app/models/user.rbแล้วเติมdependent:ที่เหมาะกับคำตอบของธุรกิจ ให้ทั้งhas_many :postsและhas_many :comments - กลับไปที่ console สั่ง
reload!แล้วหามานะใหม่อีกรอบ · สั่งmana.destroyแล้วตามด้วยmana.errors.full_messages - สร้างผู้ใช้ใหม่ที่ยังไม่มีผลงาน:
newbie = User.create!(name: "ทดลอง", email: "test@example.com")แล้วnewbie.destroy· ปิดท้ายด้วยUser.count
ควรเห็น: ข้อ 1 ได้ ActiveRecord::InvalidForeignKey · ข้อ 3 ได้ false กับข้อความ Cannot delete record because dependent posts exist ·
ข้อ 4 ลบได้ และ User.count ยังเป็น 3
ถ้าเจอ error นี้ แปลว่า…
- ข้อ 3 ยังได้
ActiveRecord::InvalidForeignKeyเหมือนข้อ 1 → console ยังใช้ model รุ่นเก่า · ลืมreload!หรือลืมหาmanaใหม่หลัง reload (ตัวแปรเก่ายังจำกฎเก่า) The :dependent option must be one of [:destroy, :delete_all, :nullify, :restrict_with_error, :restrict_with_exception, :destroy_async], but is :restrict (ArgumentError)→ พิมพ์ค่าdependent:ผิด (ตัวอย่างนี้พิมพ์แค่:restrict) · error บอกค่าที่ใช้ได้ทั้งหมดมาให้แล้ว เลือกจากในวงเล็บ
# app/models/user.rb (โชว์เฉพาะสองบรรทัดที่แก้ · validates และ has_many อื่นจากบท 110–115 คงไว้)
class User < ApplicationRecord
has_many :posts, dependent: :restrict_with_error
has_many :comments, dependent: :restrict_with_error
end
ผลจริง ข้อ 1 (ก่อนแก้) — คราวนี้คนห้ามคือลิ้นชัก comments (เพราะมานะเคยคอมเมนต์ไว้) · ตู้ตอบกลับแค่กฎแรกที่ชน:
wongnok-blog(dev):001> mana = User.find_by(name: "มานะ")
User Load (1.9ms) SELECT "users".* FROM "users" WHERE "users"."name" = 'มานะ' LIMIT 1 /*application='WongnokBlog'*/
=> #<User:0x000000012374cc90 id: 3, name: "มานะ", email: "[FILTERED]", …>
wongnok-blog(dev):002> mana.destroy
TRANSACTION (1.2ms) BEGIN /*application='WongnokBlog'*/
User Destroy (3.7ms) DELETE FROM "users" WHERE "users"."id" = 3 /*application='WongnokBlog'*/
TRANSACTION (0.4ms) ROLLBACK /*application='WongnokBlog'*/
(wongnok-blog):2:in '<main>': PG::ForeignKeyViolation: ERROR: update or delete on table "users" violates foreign key constraint "fk_rails_03de2dc08c" on table "comments" (ActiveRecord::InvalidForeignKey)
DETAIL: Key (id)=(3) is still referenced from table "comments".
…
ข้อ 3–4 (แก้ไฟล์แล้ว + reload! ใน console เดิม):
wongnok-blog(dev):003> reload!
Reloading...
=> nil
wongnok-blog(dev):004> mana = User.find_by(name: "มานะ")
User Load (0.9ms) SELECT "users".* FROM "users" WHERE "users"."name" = 'มานะ' LIMIT 1 /*application='WongnokBlog'*/
=> #<User:0x00000001224d23d0 id: 3, name: "มานะ", email: "[FILTERED]", …>
wongnok-blog(dev):005> mana.destroy
TRANSACTION (1.3ms) BEGIN /*application='WongnokBlog'*/
Post Exists? (1.4ms) SELECT 1 AS one FROM "posts" WHERE "posts"."user_id" = 3 LIMIT 1 /*application='WongnokBlog'*/
TRANSACTION (0.9ms) ROLLBACK /*application='WongnokBlog'*/
=> false
wongnok-blog(dev):006> mana.errors.full_messages
=> ["Cannot delete record because dependent posts exist"]
wongnok-blog(dev):007> newbie = User.create!(name: "ทดลอง", email: "test@example.com")
TRANSACTION (2.1ms) BEGIN /*application='WongnokBlog'*/
User Create (4.1ms) INSERT INTO "users" ("name", "email", "created_at", "updated_at") VALUES ('ทดลอง', 'test@example.com', '2026-11-04 12:24:36.357292', '2026-11-04 12:24:36.357292') RETURNING "id" /*application='WongnokBlog'*/
TRANSACTION (1.8ms) COMMIT /*application='WongnokBlog'*/
=> #<User:0x000000011fd0a3c0 id: 4, name: "ทดลอง", email: "[FILTERED]", …>
wongnok-blog(dev):008> newbie.destroy
TRANSACTION (2.1ms) BEGIN /*application='WongnokBlog'*/
Post Exists? (3.9ms) SELECT 1 AS one FROM "posts" WHERE "posts"."user_id" = 4 LIMIT 1 /*application='WongnokBlog'*/
Comment Exists? (1.0ms) SELECT 1 AS one FROM "comments" WHERE "comments"."user_id" = 4 LIMIT 1 /*application='WongnokBlog'*/
User Destroy (1.3ms) DELETE FROM "users" WHERE "users"."id" = 4 /*application='WongnokBlog'*/
TRANSACTION (1.6ms) COMMIT /*application='WongnokBlog'*/
=> #<User:0x000000011fd0a3c0 id: 4, name: "ทดลอง", email: "[FILTERED]", …>
wongnok-blog(dev):009> User.count
User Count (2.5ms) SELECT COUNT(*) FROM "users" /*application='WongnokBlog'*/
=> 3
- มานะ: Rails ถามแค่ลิ้นชัก posts ก็เจอแล้ว เลยหยุดทันที ไม่ถามต่อถึง comments — ข้อความจึงพูดถึงแค่
posts - ผู้ใช้ใหม่: Rails ถามครบทั้งสองลิ้นชัก ไม่เจออะไร → ค่อยส่ง DELETE · ลำดับการถามตรงกับลำดับบรรทัด
has_manyในไฟล์ - ถ้า
Userของคุณมีvalidates :email, uniqueness: trueจากบท 111 ตอนUser.create!จะมีบรรทัดUser Exists? … WHERE "users"."email" = 'test@example.com' LIMIT 1เพิ่มก่อนUser Create— คือ Rails เช็คอีเมลซ้ำก่อนเก็บ ปกติ email: "[FILTERED]"ไม่ใช่ error — Rails ซ่อนอีเมลเวลาโชว์ทั้งแฟ้ม (บท 107) · เลขที่ผู้ใช้ใหม่ (4) ของคุณอาจต่าง- ทำไมไม่ใช้
:destroy? เพราะผู้ใช้ลาออกไม่ควรทำให้บทความกับคอมเมนต์ที่คนอื่นตอบไว้หายทั้งหมด — คำตอบมาจากธุรกิจ ถ้าธุรกิจตอบต่าง ตัวเลือกก็ต่าง
Quiz
ข้อ 1 · ต้นบท post.destroy ได้ ActiveRecord::InvalidForeignKey และ log มี DELETE FROM "posts" … ตามด้วย ROLLBACK — ใครเป็นคนไม่ยอมให้ลบ?
ตัวเลือกที่ 3 ถูก · log ยืนยันว่า Rails ส่ง DELETE ออกไปแล้ว ตู้ตอบกลับด้วย PG::ForeignKeyViolation (PG = PostgreSQL) แล้ว Rails จึง ROLLBACK
ตัวเลือกที่ 1 คือสิ่งที่ restrict_with_error ทำ (มีบรรทัด Comment Exists? และไม่มี DELETE) — แต่ต้นบทยังไม่ได้ใส่ ·
ตัวเลือกที่ 2 ไม่มี nil เกี่ยวเลย error พูดถึง foreign key ตรง ๆ ·
ตัวเลือกที่ 4 ผิด — ในกล่องทายผลแรกของบท console ลบก๋วยเตี๋ยวเรือได้ปกติ
จุดที่ต้องจำ: error ที่ขึ้นต้นด้วย PG:: = ตู้เป็นคนพูด · ถ้าไม่บอกล่วงหน้าว่าลูกจะเป็นยังไง Rails ไม่เช็คให้ ส่งคำสั่งไปให้ตู้ตัดสินเลย
ข้อ 2 · ใบคอมเมนต์มี callback ที่ต้องลบรูปแนบออกจากที่เก็บไฟล์ทุกครั้งที่ใบนั้นหายไป — ตอนลบบทความ ควรใช้แบบไหน?
ตัวเลือกที่ 1 ถูก · ในบทนี้มีแค่ทางที่ ① ที่เห็นบรรทัด 📝 ขึ้นทุกใบ — เพราะ Rails หยิบใบคอมเมนต์ขึ้นมาเป็น object ทีละใบ callback จึงได้ทำงาน
ตัวเลือกที่ 2 ลบได้เร็ว แต่ไม่มี Comment Load ไม่มี object → ไม่มี 📝 → รูปแนบค้างเป็นขยะ ·
ตัวเลือกที่ 3 ตู้ลบเองข้างใน log ไม่พูดถึงคอมเมนต์เลย callback ไม่ทำงานแน่นอน ·
ตัวเลือกที่ 4 ไม่ได้ลบคอมเมนต์ (และไม่ลบบทความด้วย) ไม่ตรงกับโจทย์ที่อยากลบบทความ
จุดที่ต้องจำ: "ลูกมีงานที่ต้องทำตอนหายไปไหม" — มี → :destroy · ยอมจ่ายด้วยจำนวนคำสั่งที่เท่ากับจำนวนลูก
ข้อ 3 · ใช้ on_delete: :cascade และไม่มี dependent: ใน Post · ทิ้งบทความที่มีคอมเมนต์ 2 ใบ — log ของ Rails จะมีบรรทัด DELETE กี่บรรทัด?
ตัวเลือกที่ 4 ถูก · ผลจริงในหัวข้อทางที่ ③ มี DELETE บรรทัดเดียว (DELETE FROM "posts" WHERE "posts"."id" = 1) ตามด้วย COMMIT · แต่คอมเมนต์ลดจาก 7 เหลือ 5 จริง
ตัวเลือกที่ 1 คือหน้าตาของทางที่ ① (:destroy) ·
ตัวเลือกที่ 2 คือหน้าตาของทางที่ ② (:delete_all) ·
ตัวเลือกที่ 3 ผิดเพราะคนเริ่มลบยังเป็น Rails — Rails สั่งลบบทความ ตู้แค่ลบลูกตามให้ต่อจากนั้น
จุดที่ต้องจำ: cascade = Rails ไม่รู้เรื่อง · ดู log อย่างเดียวจะไม่รู้เลยว่ามีคอมเมนต์หายไป ต้องดูที่ schema.rb (on_delete: :cascade)
ข้อ 4 · เพิ่มคอลัมน์ comments_count + ใส่ counter_cache: true + backfill เรียบร้อย · loop โชว์จำนวนคอมเมนต์ของบทความ 6 ชิ้น — ใช้ method ไหนแล้ว log มี SQL แค่คำสั่งเดียว (ตอนหยิบบทความ)?
ตัวเลือกที่ 2 ถูก · ผลจริง: loop ที่ใช้ size มีแค่ Post Load บรรทัดเดียว แล้วตัวเลขออกมาครบ 6 บรรทัด
ตัวเลือกที่ 1 — count ไปนับที่ตู้ (COUNT(*)) ทุกครั้ง ไม่สนป้าย ผมลองแล้วได้ 7 คำสั่ง ·
ตัวเลือกที่ 3 — length ต้องหยิบใบคอมเมนต์ทุกใบขึ้นมาก่อนถึงจะนับในมือได้ ได้ 7 คำสั่งเหมือนกัน (และหนักกว่า) ·
ตัวเลือกที่ 4 — ป้ายไม่ได้เปลี่ยนพฤติกรรมของ count กับ length
จุดที่ต้องจำ: counter_cache ได้ผลเฉพาะ size · และถ้าลืม backfill size จะตอบ 0 อย่างมั่นใจ
ถามกลับได้เลย
ถ้าตารางของคุณมีลูกหลายชั้น (บทความ → คอมเมนต์ → ไลก์) แล้วไม่แน่ใจว่าแต่ละชั้นควรใช้ dependent แบบไหน
— ส่งให้เพื่อน senior หรือ AI สามอย่าง: db/schema.rb · model ทุกตัวที่เกี่ยว · และคำตอบของธุรกิจว่า "ลบแม่แล้วลูกต้องเป็นยังไง"
· อย่างสุดท้ายสำคัญที่สุด ไม่มีมันใครก็ตอบแทนไม่ได้
ถ้าเปิดสไลด์ดูประกอบ
| หน้า | สไลด์เขียน | ของจริง | ต้องสนใจไหม |
|---|---|---|---|
| 42 | "1 post 5000 comment = 5001 query" | นับจริงกับคอมเมนต์ 100 ใบได้ 104 คำสั่ง (DELETE คอมเมนต์ 100 + หยิบคอมเมนต์ 1 · DELETE บทความ 1 · BEGIN/COMMIT 2) | ไม่ต้อง — ภาพเดียวกัน: ยิ่งลูกเยอะยิ่งช้าตาม |
| 42 | มีสามท่า | Rails มี 6 ค่าสำหรับ dependent: (ดู error ในภารกิจ) · บทนี้เพิ่ม :restrict_with_error ที่สไลด์หน้า 54 ใช้ | แค่รู้ |
| 42 | delete_all: "ของที่ห้อยใต้ comment จะค้าง" | ถ้าหลานมี foreign key ตู้จะไม่ยอมลบแทน (ได้ InvalidForeignKey) · จะค้างจริงก็ต่อเมื่อไม่มี foreign key | แค่รู้ |
| 43 | counter_cache: "ต้องเพิ่มคอลัมน์ก่อน" | ต้อง backfill ด้วย ไม่งั้น size ได้ 0 · และ reset_counters ใช้ได้หลังใส่ counter_cache: true แล้วเท่านั้น | สนใจ — ลืมแล้วไม่มี error เตือน |
| 43 | inverse_of: "ต้องใส่เมื่อใช้ class_name หรือชื่อไม่ตรงกติกา" | ลอง published_posts (มี class_name) แล้ว Rails 8.1 ยังเดาได้ · ที่เดาไม่ได้จริงคือ foreign_key: (ลองแล้ว) กับ through: ตามเอกสาร · ส่วน "วันเสาร์ทำ Russian doll caching" ในเว็บนี้คือ Day 3 | แค่รู้ |
| ทั้งชุด | Ruby 4.0.6 · Rails 8.1.3 | เครื่องตรวจใช้ Ruby 4.0.9 · Rails 8.1.4 | ไม่ต้อง — ผลในบทนี้ไม่ต่างกัน |
อ้างอิง
- Rails Guides — Active Record Associations · :dependent — อ่านหัวข้อ 8.1.2 :dependent รายการค่าทั้งหมดพร้อมคำอธิบายสั้น ๆ ว่าแต่ละค่าเรียก callback หรือไม่
- Rails Guides — Active Record Associations · Counter Cache — อ่านหัวข้อ 8.3 Counter Cache (ตัวอย่าง Book/Author แบบเดียวกับ Post/Comment) แล้วต่อด้วย 8.1.5 :touch และ 8.1.7 :inverse_of
- Rails API — reset_counters — อ่านย่อหน้าแรก ที่บอกว่าใช้ตอนเพิ่ม counter cache ใหม่ หรือตอนเลขเพี้ยนเพราะมีคนแก้ด้วย SQL ตรง ๆ
- Rails API — add_foreign_key —
อ่านรายการ options ตรง
:on_delete(ค่าที่ใช้ได้::nullify·:cascade·:restrict) · คู่กับ remove_foreign_key - PostgreSQL 17 — 5.5.5 Foreign Keys —
อ่านช่วงที่อธิบาย
ON DELETE CASCADEกับRESTRICTด้วยตัวอย่าง orders/products — นี่คือกฎของตู้ที่ทางที่ ③ ใช้ - GORM — Delete Associations และ
GORM — Foreign Key Constraint —
ฝั่ง Go ที่เทียบในบทนี้:
Select(...).Deleteกับ tagconstraint:OnDelete