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

ลบบทความแล้วคอมเมนต์ไปไหน — dependent, CASCADE และ counter_cache

~25 นาที (ลองตามใน console หลายรอบ) · ควรผ่าน บท 111 (foreign key · transaction) · บท 112 (callback) · บท 115 (has_many · belongs_to) มาก่อน

ลองลบบทความที่มีคนมาคอมเมนต์ไว้ แล้วคุณจะเจอ error สีแดงทันที — นั่นไม่ใช่บั๊ก แต่เป็นตู้เก็บแฟ้มกำลังปกป้องข้อมูลให้คุณ
อ่านจบแล้วคุณจะอ่าน error นั้นออก · เลือกได้ว่า "ลบบทความแล้วคอมเมนต์ควรเป็นยังไง" จากทางเลือก 4 แบบ โดยรู้ว่าแต่ละแบบยิง SQL อะไรจริง · แถมของเล็ก ๆ อีกสามชิ้น: counter_cache (จดจำนวนคอมเมนต์ไว้บนบทความ ไม่ต้องเดินไปนับทุกครั้ง) · touch (คอมเมนต์เปลี่ยน → บทความรู้ตัวว่ามีอะไรเปลี่ยน) · inverse_of (แค่รู้จักชื่อ)

🔁 ทวนก่อนเริ่ม — คำจากบทก่อน ๆ ที่บทนี้ใช้ตลอด

🧰 ตรวจด้วย 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 บอกว่างานของใคร):

แล้วอ่าน error ทีละท่อน:

ท่อนใน errorแปลเป็นภาษาคน
(wongnok-blog):3พังที่บรรทัดที่ 3 ที่เราพิมพ์ใน console (post.destroy)
PG::ForeignKeyViolationPostgreSQL (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 แทนการระเบิด)

🗣 ศัพท์ของ error ต้นบท
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)

ทิ้งแฟ้มบทความเลขที่ 1 (มีใบคอมเมนต์อ้างอยู่ 2 ใบ) แล้วใบคอมเมนต์… ① dependent: :destroy ทิ้งทีละใบ พร้อมจดบันทึกทุกใบ ← คนดูแลสต็อกทำ (Rails) ② dependent: :delete_all โกยทิ้งทั้งกองในครั้งเดียว ← คนดูแลสต็อกทำ (Rails) ③ on_delete: :cascade ตู้ทิ้งให้เองอัตโนมัติ ← ตัวตู้ทำ (PostgreSQL) ④ dependent: :restrict_with_error ห้ามทิ้งบทความ ถ้ายังมีใบอ้างอยู่ ← คนดูแลสต็อกห้าม (Rails)
🗑 เทียบให้เห็นภาพ — สี่วิธีเก็บกวาดใบคอมเมนต์ก่อนทิ้งแฟ้มบทความ

① คนดูแลสต็อกหยิบใบคอมเมนต์ออกมาทีละใบ ทิ้งทีละใบ และทำ "ขั้นตอนอัตโนมัติ" ของใบนั้นครบ (เช่น จดลงสมุดว่าทิ้งใบไหน) — ละเอียดแต่ช้า
② คนดูแลสต็อกโกยใบคอมเมนต์ทั้งกองของบทความนี้ลงถังทีเดียว ไม่ได้ดูทีละใบ — เร็ว แต่ไม่มีใครจดอะไร
③ ตัวตู้มีกลไกในตัว: พอแฟ้มบทความถูกดึงออก ใบคอมเมนต์ที่อ้างถึงหล่นลงถังเอง — คนดูแลสต็อกไม่รู้ด้วยซ้ำว่ามีอะไรหล่นไป
④ คนดูแลสต็อกปฏิเสธ: "บทความนี้ยังมีคอมเมนต์ ทิ้งไม่ได้" แล้วบอกเหตุผลกลับมา

ที่ไม่เหมือนกัน: ในร้านจริงถ้าทิ้งผิดยังไปคุ้ยถังขยะคืนได้ · ในฐานข้อมูล ① ② ③ ทิ้งแล้วหายถาวร ไม่มีถังให้คุ้ย (ถ้าธุรกิจอยากกู้คืนได้ ต้องออกแบบ "ลบแบบซ่อนไว้" เอง — แบบ 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

ทางที่ ① — 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

ราคาที่จ่ายคือจำนวนคำสั่ง — ผมลองบทความที่มีคอมเมนต์ 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
$ 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;"`
🗣 ศัพท์ของทางที่ ① ② ③
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

อยากให้ระเบิดแทนคืน 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

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]]

อีกท่าที่เอกสาร 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

เทียบกับ 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
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

เทียบกับ 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
🤲 เทียบให้เห็นภาพ — แฟ้มอยู่ในมืออยู่แล้ว

คุณถือแฟ้มบทความต้มยำกุ้งไว้มือซ้าย แล้วดึงใบคอมเมนต์ออกมาถือมือขวา · ถ้ามีคนถามว่า "ใบนี้เป็นของบทความไหน" คุณแค่ยกมือซ้ายให้ดู ไม่ต้องเดินไปเปิดตู้หยิบแฟ้มต้มยำกุ้งมาอีกฉบับ

ที่ไม่เหมือนกัน: คนจริงรู้เองเสมอว่าถืออะไรอยู่ · 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 และ inverse_of
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 นาที)

  1. เปิด console แล้วลองลบมานะก่อนแก้อะไร: mana = User.find_by(name: "มานะ") แล้ว mana.destroy — จดชื่อ error และลิ้นชักที่ห้าม (ดูท่อน on table "…")
  2. เปิด app/models/user.rb แล้วเติม dependent: ที่เหมาะกับคำตอบของธุรกิจ ให้ทั้ง has_many :posts และ has_many :comments
  3. กลับไปที่ console สั่ง reload! แล้วหามานะใหม่อีกรอบ · สั่ง mana.destroy แล้วตามด้วย mana.errors.full_messages
  4. สร้างผู้ใช้ใหม่ที่ยังไม่มีผลงาน: 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 นี้ แปลว่า…

# 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 ใช้แค่รู้
42delete_all: "ของที่ห้อยใต้ comment จะค้าง"ถ้าหลานมี foreign key ตู้จะไม่ยอมลบแทน (ได้ InvalidForeignKey) · จะค้างจริงก็ต่อเมื่อไม่มี foreign keyแค่รู้
43counter_cache: "ต้องเพิ่มคอลัมน์ก่อน"ต้อง backfill ด้วย ไม่งั้น size ได้ 0 · และ reset_counters ใช้ได้หลังใส่ counter_cache: true แล้วเท่านั้นสนใจ — ลืมแล้วไม่มี error เตือน
43inverse_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ไม่ต้อง — ผลในบทนี้ไม่ต่างกัน

อ้างอิง