Cheat sheet · Rails · Day 2

Rails Day 2 สำหรับมือใหม่ — Active Record · Migration · Validation · Query · Association แผ่นเดียวจบ

คู่กับ บทเรียน 106–119 · ทุกผลรันในหน้านี้ก็อปมาจากบทเรียน ซึ่งรันจริงบนเครื่องตรวจ ไม่ได้พิมพ์จากความจำ

หน้านี้ไม่ได้ให้อ่านรวดเดียว — เปิดไว้ข้างจอตอนลงมือทำ แล้วกวาดตาหาแถวที่ต้องการ
งงว่า “ของชิ้นนี้ในร้านคือใคร” → หัวข้อ 1 · อ่านบรรทัด SQL ใน console ไม่ออก → หัวข้อ 3 · จะแก้หน้าตาตาราง → หัวข้อ 5
หน้าเว็บช้า / log ยาวเป็นพรืด → หัวข้อ 10 (N+1) · ได้ AI หรือ scaffold มาแล้วจะตรวจยังไง → หัวข้อ 13 · จอขึ้นสีแดง → ข้ามไปหัวข้อ 14 (ตาราง error) ได้เลย

🧰 เวอร์ชันที่ใช้ตรวจ — Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 17 (สไลด์ใช้ Ruby 4.0.6 · Rails 8.1.3 — เป็นรุ่นแก้บั๊กในสายเดียวกัน เรื่องในหน้านี้ใช้ได้เหมือนกัน)

📦 ข้อมูลตัวอย่าง — แอปบล็อก wongnok_blog ที่เตรียมในบท 107 (db/seeds.rb): ผู้ใช้ 3 คน (สมชาย · สมหญิง · มานะ) · บทความ 6 ชิ้น (เผยแพร่แล้ว 4 · ร่าง 2) · คอมเมนต์ 7 ใบ · ลิ้นชัก posts มีช่อง title · body · published · views · user_id · users มี name · email · comments มี body · post_id · user_id

ของในเครื่องคุณ เช่น เวลาในวงเล็บ (2.2ms) · เลขนำหน้าไฟล์ migration (20261104…) · เลข 0x… จะไม่ตรงกับในหน้านี้ — เป็นเรื่องปกติ

สารบัญ

  1. ภาพร้านอาหาร — ส่วนที่ Day 2 เพิ่ม
  2. 3 ประโยคหลักของวัน
  3. GORM ↔ Active Record · แปล SQL
  4. คำสั่ง console ที่ใช้บ่อย
  5. migration — แก้หน้าตาตาราง
  6. validation — กฎตรวจแฟ้ม
  7. 4 ชั้นป้องกัน · validation คู่ constraint
  8. callbacks · โค้ดควรอยู่ไหน
  9. query — ใบเบิกของ
  10. N+1 — เดินไปตู้ 101 รอบ
  11. association · dependent
  12. REST · strong params
  13. scaffold · checklist ตรวจงาน AI
  14. ตาราง error → แปลว่า → แก้ยังไง
  15. ศัพท์ภาษาคน
  16. ถ้าเปิดสไลด์ดูประกอบ
  17. อ้างอิง

1 · ภาพร้านอาหาร — ส่วนที่ Day 2 เพิ่ม

Day 1 เดินจากหน้าร้านถึงคนจัดจาน · Day 2 ลงไปห้องเก็บของ — ตู้แฟ้ม (database) · ลิ้นชัก (ตาราง) · แฟ้มหนึ่งแผ่น (แถว) · ช่องบนแฟ้ม (คอลัมน์) · Go ที่เคยเรียน = เปิดร้านเองตั้งแต่ศูนย์ (GORM = ตู้ที่ซื้อมา แต่ต้องบอกขนาดลิ้นชักเองใน struct) · Rails = แฟรนไชส์ที่ได้ครัวครบชุด ตู้ถูกถามเองว่ามีช่องอะไร

Day 2ในร้านอาหารหน้าตาในโค้ด / logใน Go/GORM คือบท
Active Recordแฟ้มที่เดินเข้าลิ้นชักเองได้ — คนดูแลสต็อกฝังอยู่ในแฟ้มpost.save!struct ไม่ save ตัวเอง ต้องยื่น db.Create(&p)106
Data Mapperแฟ้มเป็นกระดาษเปล่า มีพนักงานคลังแยกถือไปเก็บrepo.save(post)repository ของบท 27106
SQLภาษาที่คนดูแลสต็อกใช้สั่งตู้ · Active Record แปลจาก Ruby ให้SELECT COUNT(*) FROM "posts"SQL ที่ GORM ยิงให้106–107
log ของ SQLสมุดบันทึกการเบิกของ — เดินไปตู้ 1 ครั้ง จด 1 บรรทัดPost Load (2.2ms) SELECT …[7.862ms] [rows:1] INSERT …107 · 113
migrationใบสั่งช่างต่อเติม/ดัดแปลงตู้db/migrate/2026…_add_subtitle_to_posts.rbไฟล์ goose -- +goose Up108
db/migrate/*แฟ้มใบสั่งช่างทุกใบตั้งแต่วันเปิดร้าน (ประวัติ)ไฟล์เรียงตามเลขเวลาmigrations/*.sql108
schema.rbแปลนตู้ฉบับล่าสุด (ภาพถ่ายตอนนี้)db/schema.rb · Rails เขียนให้เองไม่มี108
schema_migrationsสมุดเซ็นรับงานช่าง — ใบไหนทำไปแล้วตารางในฐานข้อมูล เก็บเลข 14 หลักตาราง goose_db_version108
rollbackเรียกช่างมารื้องานใบล่าสุดออกbin/rails db:rollbackgoose down108
lockปิดลิ้นชักไว้ระหว่างช่างทำงาน ใครจะเก็บแฟ้มต้องรอmigration ติดป้าย 200,000 แผ่น ใช้ ~15.7 วินาทีเหมือนกัน (เป็นเรื่องของ PostgreSQL)109
indexป้ายแท็บเรียงตัวอักษรในลิ้นชัก หาเจอโดยไม่ต้องไล่ทีละแผ่นadd_index :posts, :titletag uniqueIndex (แบบห้ามซ้ำ)109
expand → migrate → contractปรับปรุงร้านโดยไม่ปิดร้าน: ต่อลิ้นชักใหม่ → ย้ายแฟ้ม → รื้อลิ้นชักเก่า3 เฟส คั่นด้วย deploy ทุกเฟสหลักการเดียวกัน ไม่ขึ้นกับภาษา109
validationคนดูแลสต็อกตรวจแฟ้มก่อนเก็บ (“ไม่มีชื่อเรื่อง ไม่รับ”)validates :title, presence: truebinding:"required" ของ Gin110
DB constraintตัวตู้ที่ใส่แฟ้มผิดแบบไม่ได้เลย — แอบเข้าหลังร้านก็ข้ามไม่ได้null: false · unique index · FK · CHECKgorm:"unique" · gorm:"not null"111
callbackขั้นตอนอัตโนมัติก่อน/หลังเก็บแฟ้ม (ติดป้ายทุกครั้งก่อนเข้าตู้)before_validation :generate_slughook BeforeSave ของ GORM112
relationใบเบิกของที่ยังเขียนไม่เสร็จ — ยังไม่มีใครเดินไปตู้จนกว่าจะ “ยื่น”Post.where(published: true)GORM ไปตู้ตอนเจอ Find113
N+1เดินไปตู้ 101 รอบ หยิบทีละแผ่นlog มี User Load เรียงยาวdb.First อยู่ในลูป for114
includesเขียนใบเบิกทีเดียว หยิบมาทั้งตะกร้าPost.includes(:user)db.Preload("Comments")114
association · foreign keyช่อง “เป็นของบทความเลขที่ …” บนใบคอมเมนต์ (ช่องอยู่บนใบลูก ไม่ใช่ใบแม่)belongs_to :post · ช่อง comments.post_idfield PostID uint + Post Post115
dependentทิ้งแฟ้มบทความแล้ว ใบคอมเมนต์ที่อ้างถึงทำยังไงhas_many :comments, dependent: :destroydb.Select("Comments").Delete(&post)116
strong paramsพนักงานต้อนรับรับเฉพาะช่องบนแบบฟอร์ม ช่องที่แอบเขียนเพิ่มถูกตัดทิ้งparams.expect(post: [:title, :body])struct รับ input แยก (CreatePostInput)117
mass assignmentลูกค้าแอบเขียน “ฉันเป็นผู้จัดการ · ฟรี” แล้วครัวทำตามหมดPost.new(params[:post].permit!)json.Unmarshal(body, &post) ลง struct ที่ลง DB ตรง ๆ117
scaffoldชุดเปิดแผนกใหม่สำเร็จรูปจากแฟรนไชส์ — ใช้ได้ทันที แต่ไม่รู้กฎเฉพาะของร้านเรา (เหมือนโค้ดจาก AI)bin/rails generate scaffold Category …ไม่มี118

2 · 3 ประโยคหลักของวัน

ถ้าจำได้แค่สามบรรทัด จำสามบรรทัดนี้ (สไลด์หน้า 3 และ 59 · สรุปในบท 119)

ประโยคภาษาคนในร้านอาหารเห็นกับมือที่
① schema เปลี่ยนได้โดยไม่ต้องดับแอป (expand → migrate → contract)โค้ดกับตารางไม่มีวันเปลี่ยนพร้อมกันเป๊ะ · จึงเพิ่มของใหม่ก่อน ย้ายข้อมูล แล้วค่อยรื้อของเก่าปรับปรุงร้านโดยไม่ปิดร้าน109 · Lab ขั้น 5 (category_id เริ่มแบบว่างได้ก่อน)
② validation ใน model ไม่ใช่การรับประกัน ต้องมี constraint ที่ DB คู่กัน“ตรวจ” กับ “เก็บ” เป็นสองจังหวะ มีช่องว่างตรงกลาง · และมีประตูข้างที่ข้ามการตรวจได้คนดูแลสต็อกตรวจเก่ง แต่คนเข้าทางหลังร้านเขาไม่เห็น · ตู้ที่ทำร่องไว้กันได้ทุกทาง111 (สมัครพร้อมกันได้อีเมลซ้ำ) · Lab ขั้น 2–4
③ N+1 ไม่ได้อ่านจากโค้ด อ่านจาก logORM ซ่อน SQL เก่งจนลืมว่าทุก post.user คือการถาม DB · ต้องเปิด log นับ queryดูจากใบเบิกไม่รู้ ต้องเปิดสมุดบันทึกการเบิกของ114 (7 query → 2) · Lab ขั้น 6

3 · GORM ↔ Active Record · แปล SQL เป็นภาษาคน

CRUD = งานสี่อย่างกับข้อมูล: สร้าง (Create) · อ่าน (Read) · แก้ (Update) · ลบ (Delete) · ทุกคำสั่งในตารางนี้ลองใน bin/rails console ได้ และ console จะโชว์ SQL ที่ยิงจริงให้ทุกครั้ง (บท 107)

อยากทำGo · GORMRuby · Active RecordSQL ที่ console โชว์ (ย่อ)ภาษาคน
สร้างไว้ในมือp := Post{Title: …}post = Post.new(title: …)— ไม่มี SQLแฟ้มยังไม่เข้าลิ้นชัก · id เป็น nil
เก็บลงตู้db.Create(&p) + เช็ค .Error เองpost.save · post.save!INSERT INTO "posts" (…) VALUES (…) RETURNING "id"ใส่แฟ้มใหม่ลงลิ้นชัก แล้วบอกเลขที่กลับมา
สร้าง + เก็บในบรรทัดเดียว—Post.create(…) · Post.create!(…)INSERT …เหมือน new แล้ว save
หยิบตามเลขที่db.First(&p, 1)Post.find(1)… WHERE "posts"."id" = 1 LIMIT 1ขอแผ่นเลขที่ 1 เอาแผ่นเดียว
หยิบแผ่นแรกที่ตรงเงื่อนไข—Post.find_by(title: "ส้มตำปูปลาร้า")… WHERE "posts"."title" = 'ส้มตำปูปลาร้า' LIMIT 1เจอแผ่นแรกก็หยุด
หยิบทุกแผ่นที่ตรงเงื่อนไขdb.Where("title = ?", x).Find(&list)Post.where(title: x)… WHERE "posts"."title" = '…'ได้รายการ (อาจว่าง)
แผ่นแรก · สุดท้าย—Post.first · Post.lastORDER BY "posts"."id" ASC LIMIT 1 (last = DESC)เรียงตามเลขที่แล้วหยิบหัว/ท้าย
หยิบทุกแผ่นdb.Find(&list)Post.allSELECT "posts".* FROM "posts"ขอทุกช่องของทุกแผ่น
แก้db.Model(&p).Update("Title", x)post.update(title: x)UPDATE "posts" SET "title" = '…', "updated_at" = '…' WHERE "posts"."id" = 1แก้เฉพาะช่องที่เปลี่ยน + เวลาแก้ ของแผ่นเลขที่ 1
ลบdb.Delete(&p)post.destroyDELETE FROM "posts" WHERE "posts"."id" = 1ทิ้งแผ่นเลขที่ 1
นับ—Post.countSELECT COUNT(*) FROM "posts"ให้ตู้นับ ไม่ต้องหยิบแฟ้มมา
บอกว่ามีช่องอะไรเขียนเองใน struct ทุกช่องไม่เขียน · Rails ถามตาราง posts ตอนรัน—ช่องมาจากตาราง ไม่ใช่จากไฟล์
ชื่อตารางไม่ตามกฎfunc (Article) TableName() string { return "posts" }self.table_name = "posts"—บอกชื่อลิ้นชักเอง

หาไม่เจอ — สามคำสั่ง สามคำตอบ (ผลจริง บท 107)

พิมพ์ได้ภาษาคนใน GORM
Post.find(99)Couldn't find Post with 'id'=99 (ActiveRecord::RecordNotFound)ระเบิด · ในหน้าเว็บ Rails แปลงเป็น 404 ให้First คืน gorm.ErrRecordNotFound
Post.find_by(title: "ไม่มีเรื่องนี้")=> nilตอบเงียบ ๆ ว่า “ไม่มี” · เรียก .title ต่อจะพัง for nil—
Post.where(title: "ไม่มีเรื่องนี้")=> []รายการว่าง—

แปล SQL ที่เห็นใน log เป็นภาษาคน (บท 106 · 107 · 113 · 114)

คำใน SQLภาษาคน
SELECT "posts".* · SELECT "posts"."title"ขอทุกช่องของแฟ้ม (* = ทุกช่อง) · ขอแค่ช่อง title
FROM "posts"จากลิ้นชัก posts
WHERE … · ANDเฉพาะแผ่นที่ตรงเงื่อนไข · “และ” ต้องตรงทั้งสองข้อ
IS NULL · IN (1, 2, 3)ช่องนี้ว่าง · ตรงกับตัวใดตัวหนึ่งในรายการ
ORDER BY … ASC / DESC · LIMIT 10เรียงน้อยไปมาก / มากไปน้อย · เอาไม่เกิน 10 แผ่น
COUNT(*) · SUM(…) · AVG(…)นับ · บวกรวม · หาค่าเฉลี่ย — ตู้คิดให้ ไม่ต้องหยิบแฟ้มมา
GROUP BY … · AS "ชื่อ"แยกกองตามช่องนี้ก่อนนับ · ชื่อเล่นที่ Rails ตั้งให้ผล (ข้ามได้)
SELECT 1 AS one … LIMIT 1“มีสักแผ่นไหม” เจอแผ่นแรกก็หยุด (ของ exists? · uniqueness)
INSERT INTO … VALUES … RETURNING "id"ใส่แฟ้มใหม่ แล้วบอกเลขที่กลับมา
UPDATE … SET … WHERE … · DELETE FROM … WHERE …แก้ · ทิ้ง ทุกแผ่นที่ตรง · ไม่มี WHERE = โดนทั้งลิ้นชัก
JOIN … ON … · LEFT OUTER JOINเย็บแฟ้มสองลิ้นชักเป็นแถวเดียวตามเลขที่ที่ตรงกัน · LEFT OUTER = เก็บแถวฝั่งซ้ายไว้แม้หาคู่ไม่เจอ
TRANSACTION … BEGIN · COMMIT · ROLLBACKเริ่มงานชุดหนึ่ง · ยืนยัน (เข้าตู้จริง) · ยกเลิกทั้งชุด
/*application='WongnokBlog'*/ป้ายที่ Rails แปะบอกว่าแอปไหนส่งมา · database ไม่สนใจ · ข้ามได้
Post Create (2.7ms) หน้าบรรทัด“คนดูแลสต็อกของ Post กำลังสร้างแถว ใช้เวลา 2.7 มิลลิวินาที” · CACHE … = ตอบจากความจำ ไม่ได้ไปตู้

4 · คำสั่ง console ที่ใช้บ่อย

เปิดด้วย bin/rails console (ครัวทดลองหลังร้าน) · ทุกบรรทัดในตารางเป็นผลจริงจากข้อมูลตัวอย่าง

ถามสถานะของแฟ้มหนึ่งแผ่น

พิมพ์ได้ (ตัวอย่างจริง)ภาษาคนบท
post.new_record? · post.persisted?true · false (ก่อน save)ยังอยู่ในมือใช่ไหม · เข้าลิ้นชักแล้วใช่ไหม107
post.valid?falseช่วยตรวจตามกฎหน่อย · ตรวจอย่างเดียว ไม่บันทึก110
post.errors.full_messages["User must exist", "Title can't be blank"]ขอดูโน้ตปัญหาทั้งหมดเป็นประโยคเต็ม110
post.errors[:title]["can't be blank"]เฉพาะโน้ตของช่อง title (ฟอร์มใช้โชว์ใต้ช่อง)110
from.reload.views85ไปอ่านค่าล่าสุดจากตู้ใหม่ ไม่เชื่อของในมือ111
post.user_id กับ post.user1 (ไม่มี SQL) · User Load … WHERE "users"."id" = 1อ่านช่องบนแฟ้ม · เดินไปตู้หาเจ้าของ (บ่อเกิด N+1)114

ถามตัวตาราง / model

พิมพ์ได้ (ตัวอย่างจริง)ภาษาคนบท
Post.table_name"posts"model นี้ใช้ลิ้นชักชื่ออะไร106
Post.column_names["id", "title", "body", "created_at", "updated_at"] (ก่อนเพิ่มช่องของ Day 2)แฟ้มมีช่องอะไรบ้าง106
Post.columns_hash["body"].type:textช่องนี้เป็นชนิดไหน106
Category.columns_hash["name"].nulltrueช่องนี้ตู้ยอมให้ว่างไหม (true = ยังยอม)119
Post.count6มีกี่แผ่น107
Post.validators_on(:title)[] = ไม่มีกฎเลยกฎที่คิดว่ามี มีอยู่จริงไหม110
Post.where(published: true).to_sqlข้อความ SQL (ไม่ยิง)ขอดูใบเบิกก่อนยื่น113

นิสัยใน console ที่ช่วยไม่ให้งง

ทำเพราะบท
ต่อท้าย ; nil เช่น q = Post.where(…); nilconsole ยื่นใบเบิกเองเพื่อโชว์ผล (เห็น /* loading for pp */ LIMIT 11) · ใส่ ; nil จะเห็นว่าจริง ๆ ยังไม่ยิง113
แก้ไฟล์ model แล้วพิมพ์ reload! + หาตัวแปรใหม่console ยังจำ model ฉบับเก่า · ตัวแปรเก่ายังถือกฎเก่า110 · 116
สร้างโฟลเดอร์ใหม่ใต้ app/ (เช่น app/services/) → ปิด console แล้วเปิดใหม่reload! ไม่พอ · Rails จดรายชื่อโฟลเดอร์ตอนเปิดแอปเท่านั้น112
bin/rails console --sandboxออกแล้วยกเลิกทุกอย่าง · log จะขึ้น SAVEPOINT แทน COMMIT — ไม่ได้พัง107
เห็น email: "[FILTERED]"ไม่ใช่ error · Rails ซ่อนอีเมลตอนพิมพ์ทั้งแฟ้ม · user.email ยังได้ค่าจริง107
ไม่อยากเปิด console: bin/rails runner 'p [User.count, Post.count, Comment.count]'รันบรรทัดเดียวแล้วจบ · ได้ [3, 6, 7]107

5 · migration — แก้หน้าตาตาราง

schema = หน้าตาของตู้ (มีลิ้นชักอะไร ช่องอะไร ชนิดไหน) ไม่รวมแฟ้มข้างใน · migration = ใบสั่งช่างหนึ่งใบ = เปลี่ยน schema หนึ่งครั้ง · เก็บใน git เหมือนโค้ด ทุกเครื่องจึงได้ตู้หน้าตาเดียวกัน (บท 108)

ของ 3 ชิ้นที่ทำงานคู่กัน — เทียบ goose (บท 31)

ของในร้านอาหารRailsgoose
ใบสั่งทุกใบ (ประวัติ)แฟ้มใบสั่งช่างตั้งแต่วันเปิดร้านdb/migrate/*.rbmigrations/*.sql
แปลนฉบับล่าสุด (ภาพถ่าย)แปลนตู้ตอนนี้db/schema.rb — Rails เขียนให้หลัง migrate · ห้ามแก้มือ แต่ต้อง commitไม่มี
สมุดว่าใบไหนทำแล้วสมุดเซ็นรับงานช่างตาราง schema_migrations (เลข 14 หลักหน้าชื่อไฟล์)ตาราง goose_db_version
สร้างใบใหม่—bin/rails generate migration AddXToYgoose create add_x sql
ขาไป · ขากลับต่อเติม · รื้อdef change (Rails เดาขากลับให้) หรือ def up / def down-- +goose Up / Down

คำสั่ง bin/rails db:* — อันไหนห้ามแตะบนร้านจริง (production = เว็บจริงที่ลูกค้าใช้อยู่)

คำสั่งทำอะไร (ภาษาคน)เครื่องเราproduction
bin/rails db:migrateทำทุกใบที่ยัง down ตามลำดับ แล้วเขียน schema.rb ใหม่ใช้บ่อยใช้ตอน deploy ตามขั้นตอนทีม
bin/rails db:migrate:statusดูว่าใบไหน up (ทำแล้ว) / down (ยัง) · แค่ดูใช้บ่อยปลอดภัย
bin/rails db:rollback · STEP=3รื้อใบล่าสุด 1 ใบ · 3 ใบใช้ได้อันตราย — ข้อมูลในช่องที่รื้อหายจริง
bin/rails db:migrate:redoรื้อใบล่าสุดแล้วทำใหม่ — ลองว่าย้อนได้จริงก่อนเปิด PR—
bin/rails db:prepareยังไม่มี DB → สร้าง + โหลด schema.rb + ใส่ seeds · มีแล้ว → ทำใบที่ downเพิ่ง clone โปรเจกต์บางทีมให้ระบบ deploy เรียก
bin/rails db:seedรัน db/seeds.rb ใส่ข้อมูลตัวอย่าง · สั่งซ้ำ = ข้อมูลเบิ้ล (ได้ 6 users จาก 3)ระวังเบิ้ลแล้วแต่ทีม
bin/rails db:resetลบ DB ทิ้ง → สร้างใหม่จาก schema.rb → ใส่ seedsอยากเริ่มใหม่สะอาด ๆห้ามเด็ดขาด
bin/rails db:migrate:resetลบ DB ทิ้ง → สร้างใหม่ → ไล่ทำทุกใบตั้งแต่ใบแรก · ไม่ใส่ seedsเช็คว่าใบทุกใบยังต่อกันได้ห้ามเด็ดขาด
bin/rails destroy migration AddXToYลบไฟล์ใบสั่ง · ต้อง rollback ก่อนใช้ได้—

คลังคำที่เขียนใน def change

คำสั่งภาษาคน · ในร้านอาหารตัวอย่างบท
create_table · drop_tableต่อลิ้นชักใหม่ · รื้อลิ้นชักทิ้งพร้อมแฟ้มข้างในcreate_table :tags do |t| … end108
add_column · remove_columnเพิ่ม · ลบช่องบนแฟ้ม (ลบ = ของในช่องหายด้วย)add_column :posts, :subtitle, :string108
rename_columnเปลี่ยนชื่อช่อง ข้อมูลยังอยู่ (แต่แอปที่รันอยู่พัง — ใช้ expand/contract)rename_column :posts, :body, :content108 · 109
add_index (+ unique: true)ติดป้ายแท็บ · แบบห้ามซ้ำadd_index :users, :email, unique: true109 · 111
add_referenceเพิ่มช่อง “เป็นของ … เลขที่” + index + กฎ FKadd_reference :posts, :category, null: true, foreign_key: true115 · 119
change_column_nullตั้งกฎ “ช่องนี้ห้ามว่าง” ทีหลัง · แฟ้มเก่าต้องไม่มีช่องว่างค้างchange_column_null :users, :name, false111
add_check_constraintตั้งเงื่อนไขของเราเองที่ตัวตู้add_check_constraint :posts, "views >= 0", name: "views_not_negative"111
remove_foreign_key + add_foreign_key … on_delete: :cascadeเปลี่ยนกฎ FK ให้ตู้ทิ้งลูกตามแม่add_foreign_key :comments, :posts, on_delete: :cascade116

ตั้งชื่อใบให้ถูกแบบ แล้ว generator เขียนเนื้อให้ (บท 108 · ผลจริง)

สั่ง bin/rails generate migration …ได้ใน change
AddSubtitleToPosts subtitle:stringadd_column :posts, :subtitle, :string
RemoveSubtitleFromPosts subtitle:stringremove_column :posts, :subtitle, :string
CreateTags name:stringcreate_table :tags do |t| + t.string :name + t.timestamps
AddSlugToPosts slug:string:indexadd_column + add_index :posts, :slug
AddCategoryToPosts category:referencesadd_reference :posts, :category, null: false, foreign_key: true
AddReadingMinutesPosts reading_minutes:integer (ลืมคำว่า To)⚠️ ว่างเปล่า — def change กับ end เฉย ๆ · ไม่มี error เตือน

จุดพลาดที่เจอบ่อย

พลาดแบบนี้ผลจริงทำแทน
แก้ไฟล์ migration ที่รันไปแล้วเครื่องเราไม่มีอะไรเกิด · เครื่องเพื่อนได้ตู้ต่างจากเรา · rollback พัง No indexes found on posts with the options provided.เขียนใบใหม่ · หรือ rollback ก่อนแล้วค่อยแก้
destroy migration ก่อน rollbackstatus ขึ้น NO FILE · rollback ได้ ActiveRecord::UnknownMigrationVersionErrorลำดับต้องเป็น rollback → destroy
rollback ใบที่เพิ่มคอลัมน์ แล้ว migrate กลับคอลัมน์กลับมา แต่ค่าทุกแผ่นเป็น nilบน production ถือว่าข้อมูลหาย · อย่าใช้ rollback แทนปุ่ม undo
สั่ง db:seed ซ้ำได้ [6, 12, 14] แทน [3, 6, 7]db:reset ในเครื่องตัวเอง
ตั้ง NOT NULL / unique ทั้งที่ตู้มีแฟ้มเสียค้างcontains null values · could not create unique index … is duplicatedหาแฟ้มเสีย → เก็บกวาด/เติมค่า (backfill) → migrate ใหม่
ใช้ execute "UPDATE …" ใน changerollback ได้ This migration uses execute, which is not automatically reversible.เขียน up/down · ย้อนไม่ได้จริงก็ raise ActiveRecord::IrreversibleMigration, "เหตุผล"

ท่าอันตราย vs ท่าปลอดภัย — ถามทุกครั้ง: “แตะกี่แผ่น และระหว่างนั้นใครต้องรอ” (บท 109 · วัดบน 200,000 แถว)

อยากทำท่าตรง ๆแตะกี่แผ่น · ใครรอท่าที่ปลอดภัยกว่า
สร้าง indexadd_indexอ่านทุกแผ่นมาเรียง · ห้ามเขียนจนเสร็จ (~15.7 วินาที)algorithm: :concurrently + disable_ddl_transaction!
เปลี่ยนชนิดข้อมูลchange_column :posts, :views, :bigintเขียนใหม่ทุกแผ่น · ห้ามทั้งอ่านและเขียน (~0.16 วินาที · ถ้ามีป้ายแท็บติดอยู่ ต้องทำป้ายใหม่ด้วย = ~16 วินาที)คอลัมน์ใหม่ + ย้ายข้อมูล + สลับ (expand/contract)
ตั้งกฎห้ามว่างchange_column_null :posts, :title, falseอ่านทุกแผ่นเพื่อตรวจ · ปิดลิ้นชักระหว่างตรวจมีท่าหลายขั้น (บทเรียนไม่สอน) — strong_migrations บอกให้ได้
เพิ่มคอลัมน์พร้อมค่าเริ่มต้นadd_column … default: "draft"Postgres รุ่นใหม่ไม่แตะแผ่นเดิม (ไม่ถึง 0.05 วินาที)ปลอดภัยอยู่แล้ว
ลบคอลัมน์remove_column :users, :nameเร็วมาก แต่แอปที่ยังรันโค้ดเก่าพัง (จำแปลนตู้เก่า)ใส่ self.ignored_columns += ["name"] + deploy ก่อน แล้วค่อยลบ
# ติดป้ายแท็บแบบไม่ปิดลิ้นชัก (บท 109)
class AddIndexToPostsTitle < ActiveRecord::Migration[8.1]
  disable_ddl_transaction!   # ไฟล์นี้ไม่ต้องห่อด้วย transaction
  def change
    add_index :posts, :title, algorithm: :concurrently
  end
end

disable_ddl_transaction! อยู่ใน class แต่นอก def change · ลืมบรรทัดนี้ = พัง CREATE INDEX CONCURRENTLY cannot run inside a transaction block · ไฟล์ที่ปิด transaction แล้ว พังกลางทางจะไม่ย้อนคืนให้ → ใส่งานเรื่องเดียวต่อไฟล์

expand → migrate → contract — เปลี่ยนชื่อคอลัมน์ users.email → email_address โดยไม่ปิดร้าน (เฉลยภารกิจบท 109)

เฟสตู้ (migration)โค้ดdeploy
1 · expand (ต่อลิ้นชักใหม่)add_column :users, :email_address, :string — ไม่แตะแถวเดิม เร็วเขียนลงทั้งสองช่อง (dual write) · ยังอ่านจาก emailครั้งที่ 1
2 · migrate (ย้ายแฟ้ม)ไม่มี migration · เติมค่าย้อนหลัง (backfill) ในงานแยก ทีละกองเปลี่ยนไปอ่าน email_address · ยังเขียนทั้งสองช่องครั้งที่ 2
3 · contract (รื้อของเก่า)(ยังไม่ลบ)เลิกเขียน email · ใส่ self.ignored_columns += ["email"]ครั้งที่ 3
3 · contract (ต่อ)remove_column :users, :email, :string—ครั้งที่ 4 หรือรวมรอบถัดไป
# backfill ทีละ 1,000 แผ่น — รันจาก runner / งานแยก ไม่ใช่ในไฟล์ migration
User.where(email_address: nil).in_batches(of: 1000) { |b| b.update_all("email_address = email") }

บรรทัดนี้: หาแผ่นที่ช่องใหม่ยังว่าง → แบ่งกองละ 1,000 (in_batches) → แต่ละกองสั่ง UPDATE ประโยคเดียว “คัดลอกค่าจาก email ไปใส่ email_address” · ทำทีละกองเพื่อไม่ให้ปิดลิ้นชักนานรวดเดียว

6 · validation — กฎตรวจแฟ้มก่อนเก็บ (บท 110)

ไม่ผ่านกฎ = ไม่เขียนลงตู้ + มีโน้ตปัญหาติดแฟ้มไว้ใน errors · ตรวจทุกครั้งที่ save/create ไม่ว่าเรียกจากหน้าเว็บ console หรือ seeds · ใน Go คือ binding:"required" ของ Gin — แต่ Gin ตรวจตอน handler อ่าน body ส่วน Rails ตรวจที่ model

กฎสำเร็จรูป 5 แบบ — ข้อความจริงที่ได้

กฎตรวจอะไร (ภาษาคน)ข้อความที่ได้จริง
validates :title, presence: trueห้ามว่าง (ช่องว่างล้วนก็นับว่าว่าง)Title can't be blank
length: { in: 5..120 } · { maximum: 120 }ยาว 5 ถึง 120 ตัวอักษร (5..120 = ช่วงตัวเลข)Title is too short (minimum is 5 characters)
numericality: { greater_than_or_equal_to: 0 }ต้องเป็นตัวเลข และ ≥ 0 · ของจากฟอร์มเป็นข้อความเสมอ จึงโดนจับได้จริงViews must be greater than or equal to 0 · Views is not a number
inclusion: { in: [true, false] }ค่าต้องอยู่ในรายการนี้เท่านั้น (เหมาะกับสถานะ)Published is not included in the list
format: { with: /\A[^@\s]+@[^@\s]+\z/ }หน้าตาข้อความต้องตรงแบบ (regex) · \A \z = ต้น/ท้ายข้อความทั้งก้อนEmail is invalid
uniqueness: trueห้ามซ้ำกับแผ่นอื่น — แต่ยังซ้ำได้ถ้าสองคนกดพร้อมกัน (หัวข้อ 7)Email has already been taken
belongs_to :user (ได้กฎฟรี)ต้องมีเจ้าของUser must exist

กฎของร้านเราเอง — validate (ไม่มี s) + errors.add

# app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user
  has_many :comments
  validates :title, presence: true, length: { maximum: 120 }
  validates :views, numericality: { greater_than_or_equal_to: 0 }
  validate :body_needed_to_publish
  private
  def body_needed_to_publish
    return unless published
    if body.blank?
      errors.add(:body, "ต้องมีเนื้อหาก่อนเผยแพร่")
    end
  end
end

validate :ชื่อ = “ตอนตรวจ ให้เรียก method นี้ด้วย” · return unless published = “ถ้าไม่ได้จะเผยแพร่ ออกเลย” · errors.add(:body, "…") = แปะโน้ตที่ช่อง body เอง แค่นี้แฟ้มก็ไม่ผ่าน · ผลจริง: ["Body ต้องมีเนื้อหาก่อนเผยแพร่"]

กับดักตัว s ตัวเดียว — validates กับ validate

เขียนว่าผลจริงแปลว่า
validate :title, presence: true (ลืม s)Unknown key: :presence. … Perhaps you meant to call `validates` instead of `validate`? (ArgumentError)Rails จับได้และใบ้ให้ · โหลด model ไม่ได้ หน้าเว็บ 500
validates :title, presense: true (สะกดผิด)Unknown validator: 'PresenseValidator' (ArgumentError)ดูชื่อในวงเล็บแล้วเห็นตัวสะกดผิด
validate :title (ลืม s และชื่อกฎ)⚠️ เงียบสนิท · ชื่อเรื่องว่างผ่านฉลุยRails เรียก method title เป็นกฎ แต่มันไม่เคยแปะโน้ต · เช็คด้วย Post.validators_on(:title) ได้ []

กฎ ! = “ถ้าทำไม่ได้ ให้ระเบิด”

ไม่มี ! (save · create · update · find_by · destroy)มี ! (save! · create! · update! · find_by! · destroy!)
ถ้าไม่ได้คืน false / แฟ้มที่ยังไม่เข้าตู้ / nilโยน error: RecordInvalid · RecordNotFound · RecordNotDestroyed
ข้อความจริงp.save → => falseValidation failed: Title can't be blank (ActiveRecord::RecordInvalid)
ใช้เมื่อข้อมูลจากฟอร์มที่ผู้ใช้กรอก · ผิดเป็นเรื่องปกติ · อยากโชว์ error บนหน้าจอseeds · สคริปต์ · ใน transaction · ข้อมูลที่เราเขียนเอง ถ้าผิดคือบั๊ก
ใน GORMมีแบบเดียว: db.Create(&post) คืน .Error ให้เช็คเองไม่มีแบบนี้

ท่ามาตรฐานใน controller

def create
  @post = Post.new(post_params)
  @post.user = User.first   # ชั่วคราว: ยังไม่มีระบบ login
  if @post.save
    redirect_to @post, notice: "สร้างบทความแล้ว"
  else
    render :new, status: :unprocessable_content
  end
end

ผ่าน → พาไปหน้าบทความ · ไม่ผ่าน → โชว์ฟอร์มเดิมพร้อมของที่ผู้ใช้พิมพ์และ @post.errors · ตอบ 422 · ใช้ชื่อ :unprocessable_content (ชื่อเก่า :unprocessable_entity ยังได้ 422 แต่ Rack — ชิ้นส่วนที่ Rails ใช้คุยกับ Puma — ประกาศเลิกใช้แล้ว)

7 · 4 ชั้นป้องกัน · validation ต้องมี constraint คู่กัน (บท 111)

ด่านในร้านอาหารในโค้ดกันได้ข้ามได้ด้วย
① HTMLป้ายหน้าร้าน “กรุณากรอกชื่อ”required: true · type="email"เตือนเร็วในเบราว์เซอร์curl ที่แนบบัตรคิว CSRF มาด้วย (ได้ 422 จากด่าน ③ แทน)
② strong paramsพนักงานต้อนรับรับเฉพาะช่องบนแบบฟอร์มparams.expect(user: [:name, :email])ช่องที่แอบเขียนเพิ่ม (หัวข้อ 12)ไม่ได้ดูว่าค่าในช่องถูกไหม
③ validationคนดูแลสต็อกตรวจแฟ้ม บอกเหตุผลสุภาพvalidates …99% ของเวลา · ข้อความภาษาคนประตูข้าง (ตารางล่าง) · สองคนกดพร้อมกัน
④ DB constraintตัวตู้ที่ใส่แฟ้มผิดแบบไม่ได้เลยnull: false · unique index · FK · CHECKรับประกันจริง ทุกทางไม่มี · แต่พูดภาษาเครื่อง (PG::UniqueViolation) ลูกค้าอ่านไม่รู้เรื่อง

คู่ validation ↔ constraint — ทุกกฎใน model ควรมีคู่ที่ตู้

กฎที่อยากได้ด่าน ③ ใน modelด่าน ④ ใน migrationตู้ปฏิเสธแล้วได้ errorใน GORM
ห้ามว่างvalidates :name, presence: truenull: false · change_column_nullActiveRecord::NotNullViolationgorm:"not null"
ห้ามซ้ำvalidates :email, uniqueness: trueadd_index … unique: trueActiveRecord::RecordNotUniquegorm:"unique" · uniqueIndex
ต้องมีเจ้าของจริงbelongs_to :post (ตรวจให้เอง)foreign_key: trueActiveRecord::InvalidForeignKey—
ตัวเลขห้ามติดลบvalidates :views, numericality: { greater_than_or_equal_to: 0 }add_check_constraintActiveRecord::CheckViolation—

⚠️ '' (ข้อความว่าง) ไม่ใช่ NULL — ตู้ที่มี null: false ยังรับ '' ได้ · ต้องมี presence ใน model กันอีกชั้น (บท 119)

ทำไม uniqueness: true อย่างเดียวกันอีเมลซ้ำไม่ได้

กฎนี้ทำสองจังหวะ: ถามตู้ก่อน (User Exists? … SELECT 1 AS one … LIMIT 1) แล้วค่อยเก็บ — เรียกว่า check-then-act · ถ้าสองคนสมัครพร้อมกัน ทั้งคู่ถามตอนที่ยังไม่มีใคร → ทั้งคู่ผ่าน (race condition) · ผลจริงในบท: B: save ได้ true · A: save ได้ true → อีเมลเดียวกันสองแถว · พอเติม unique index แล้วแข่งใหม่: คนที่ช้ากว่าได้ PG::UniqueViolation … (ActiveRecord::RecordNotUnique) เหลือแถวเดียว

def create
  @user = User.new(user_params)
  if @user.save
    redirect_to @user
  else
    render :new, status: :unprocessable_content     # 99%: validation จับได้
  end
rescue ActiveRecord::RecordNotUnique
  @user.errors.add(:email, "ถูกใช้ไปแล้ว")             # 1%: แพ้การแข่ง ตู้ปฏิเสธ
  render :new, status: :unprocessable_content
end

ครบชุดต้องมีสามอย่าง: unique index (รับประกัน) + uniqueness (ข้อความภาษาคน) + rescue (ไม่ให้คนแพ้แข่งเห็นหน้า error)

ประตูข้าง — คำสั่งที่เก็บลงตู้โดยไม่ตรวจ validation

คำสั่งทำอะไรเจอที่ไหนบ่อย
save(validate: false)เก็บโดยไม่ตรวจสคริปต์แก้ข้อมูล · โค้ดที่ AI เขียนให้ “ผ่าน” ไว้ก่อน
update_column · update_columnsแก้ช่องตรง ๆแก้ทีละแถวใน console
update_allแก้หลายแถวด้วย UPDATE ประโยคเดียว (ข้าม callback ด้วย)ล้าง/เติมข้อมูลทั้งตาราง
insert_all · upsert_allใส่หลายแถวทีเดียวนำเข้าข้อมูลจากไฟล์
SQL ตรง ๆไม่ผ่าน Rails เลยpsql · แอปอื่นที่ต่อ DB เดียวกัน

transaction — ทำทั้งหมด หรือไม่ทำเลย (โอนยอดวิว 200 ระหว่างสองบทความ)

ใน ActiveRecord::Base.transaction do … end ใช้log จริงผลในตู้
update (ไม่มี !)UPDATE ใบแรก → COMMIT → => false❌ ใบแรกได้ +200 (540) ใบที่สองไม่ลด (85) — ข้อมูลเพี้ยนครึ่งเดียว
update! (มี !)UPDATE ใบแรก → ROLLBACK → RecordInvalid✅ ย้อนทั้งชุด กลับเป็น 340 กับ 85

จำ: ใน transaction ใช้ตัว ! · มันต้องระเบิดถึงจะสั่งยกเลิกทั้งชุดได้ · อยากยกเลิกเองโดยไม่ระเบิด: raise ActiveRecord::Rollback (บท 112)

8 · callbacks · business logic ควรอยู่ไหน (บท 112)

callback = method ที่ Rails เรียกให้เองตอนแฟ้มถูกสร้าง · บันทึก · ลบ — ประกาศครั้งเดียว ใช้ทุกครั้ง · ใน GORM เรียกว่า hook (func (p *Post) BeforeSave(tx *gorm.DB) error — ต้องสะกดชื่อตรงเป๊ะ)

สั่ง post.save / Post.create / post.update
 1  before_validation   ← เตรียมของก่อนตรวจ (สร้าง slug ทำตรงนี้)
        ··· ตรวจกฎ validation ···
 2  after_validation
 3  before_save         ← ทั้งตอนสร้างใหม่ และตอนแก้ของเดิม
 4  before_create       ← เฉพาะแฟ้มใหม่
   ═══ INSERT หรือ UPDATE ลงตู้ ═══
 5  after_create        ← เฉพาะแฟ้มใหม่
 6  after_save
   ═══ COMMIT — ตู้ยืนยันแล้วจริง ═══
 7  after_commit        ← ของที่ออกไปนอกร้าน (อีเมล · แจ้งเตือน) ทำตรงนี้

ลำดับนี้ยืนยันด้วยการให้ callback ทุกตัว puts ชื่อตัวเอง — บรรทัด 1–4 ขึ้นก่อน INSERT · 5–6 ระหว่าง INSERT กับ COMMIT · 7 หลัง COMMIT

ตัวอย่างทำไม
✅ ใช้ได้สบายสร้าง slug · ทำอีเมลเป็นตัวเล็กเตรียมข้อมูลบนแฟ้มตัวเอง · ไม่ออกไปนอกตู้
⚠️ เริ่มเจ็บยิง API ภายนอก / ส่งอีเมลใน after_saveช้า · ระบบนอกล่มแล้วบันทึกพังตาม · rollback ย้อนอีเมลที่ส่งไปแล้วไม่ได้
⚠️ เริ่มเจ็บcallback ไปแก้ model อื่นที่มี callback ต่ออีกโดมิโน ไล่บั๊กยากมาก
⚠️ เริ่มเจ็บsave ทีเดียวมีของเกิด 8 อย่างอ่าน controller เห็นแค่ post.save ไม่มีใครรู้ว่าเกิดอะไรบ้าง

กับดักที่พังเงียบ

เขียนผลจริงแก้
slug ||= title.to_s.parameterize (ลืม self.)slug เป็น nil ไม่มี error · Ruby สร้างตัวแปร local แทนself.slug ||= … · เรียก setter (method ที่ลงท้าย =) ใน class ต้องมี self. เสมอ
ชื่อเรื่องภาษาไทย + parameterize (method ที่แปลงข้อความเป็นแบบใส่ URL ได้)"ผัดไทยกุ้งสด" ได้ slug "" · "ต้มยำ Tom Yum" ได้ "tom-yum"ตัวไทยหายหมด · เว็บไทยต้องเลือกวิธีอื่น (เช่น ใช้เลข id)
self.slug ||= … กับฟอร์มที่ส่งช่องว่างฟอร์มส่ง '' ไม่ใช่ nil → ||= ไม่เติม → Slug can't be blankself.slug = name.to_s.parameterize if slug.blank? (blank? นับทั้ง nil และ '')
ส่งอีเมลใน after_saveบรรทัด 📧 ขึ้นก่อน COMMIT — ถ้า rollback อีเมลหลอกไปแล้วใช้ after_commit :notify_followers, on: :create
before_validation ทำอีเมลตัวเล็กเก็บเป็นตัวเล็ก แต่ User.find_by(email: "PITI@example.com") ได้ nilnormalizes :email, with: -> email { email.strip.downcase } — ทำให้ทั้งตอนเก็บและตอนค้น

business logic ควรอยู่ที่ไหน — ใครในร้านรับผิดชอบงานไหน

ที่อยู่ใส่อะไรในร้านอาหารตัวอย่าง · โฟลเดอร์
modelของที่เป็นธรรมชาติของสิ่งนั้นคนดูแลสต็อกรู้เรื่องแฟ้มตัวเองvalidation · callback แบบสบาย ๆ · app/models/
concernของที่ model หลายตัวใช้ร่วมกันคู่มือกลางที่หลายลิ้นชักหยิบไปใช้Sluggable · app/models/concerns/
service objectงานหลายขั้น ข้ามหลาย model · ตั้งชื่อเป็นกริยา · มี method callใบงานที่ผู้จัดการถือไปสั่งหลายแผนกPublishPost.new(post).call · app/services/
form objectฟอร์มเดียวที่ข้อมูลกระจายลงหลายตารางใบสมัครใบเดียว แยกเก็บหลายลิ้นชักSignupForm · app/forms/
background jobงานที่ทำทีหลังได้ ไม่ให้ลูกค้ายืนรอ (ส่งอีเมล)ฝากงานให้พนักงานรอบดึกDay 3 · บทเรียนใช้ Rails.logger.info แทน

9 · query — Post.where(…) คือใบเบิกของ ไม่ใช่ข้อมูล (บท 113)

where · order · limit คืน relation = ใบเบิกที่ยังไม่ยื่น · ต่อเงื่อนไขได้เรื่อย ๆ ยังไม่มีใครเดินไปตู้ · ต่างจาก GORM ที่บรรทัดลงท้าย Find คือไปตู้ทันที · ⚠️ พิมพ์ชื่อคอลัมน์ผิดใน where จะยังไม่ error จนกว่าจะยื่นใบ — error จึงชี้บรรทัดที่ยื่น ไม่ใช่บรรทัดที่เขียนผิด

ยื่นใบเมื่อไหร่ — คำสั่งไหนไปตู้จริง

พิมพ์ไปตู้ไหมSQL ที่เห็นจริง (ภาษาคน)
.where · .order · .limit❌แค่เขียนเพิ่มลงใบ
.class · .to_sql · .loaded?❌ถามเรื่องตัวใบ (Post::ActiveRecord_Relation) · loaded? ได้ nil ก่อนยื่น true หลังยื่น
.each { … } · .to_a · .map✅SELECT "posts".* … หยิบทุกแผ่นที่ตรงมาวางบนโต๊ะ · ครั้งที่สองไม่ไปซ้ำ
.first✅ (ถ้ายังไม่โหลด)… ORDER BY "posts"."id" ASC LIMIT 1
.count✅ ทุกครั้งSELECT COUNT(*) … เรียกสองครั้งไปสองครั้ง
.exists? · .any? · .empty?✅SELECT 1 AS one … LIMIT 1 “มีสักแผ่นไหม”
<% @posts.each do |post| %> ใน view✅เหมือน each · log บอกเลขบรรทัดไฟล์ .erb เกินจริง 1

นับสามแบบ ไปตู้ไม่เท่ากัน

คำสั่งทำอะไรใช้เมื่อ
countให้ตู้นับเสมอ (COUNT(*))อยากได้เลขอย่างเดียว
lengthหยิบทุกแผ่นมาแล้วนับในมือแทบไม่ใช้กับ relation
sizeยังไม่โหลด → ให้ตู้นับ · โหลดแล้ว → นับในมือ ไม่ไปตู้ · มี counter_cache → อ่านป้ายใช้เป็นค่าเริ่มต้น โดยเฉพาะหลัง each / includes

ให้ตู้คำนวณให้ — ขอแค่ของที่ใช้ (ผลจริงจากข้อมูลตัวอย่าง)

พิมพ์SQLได้ภาษาคน
Post.pluck(:title)SELECT "posts"."title" FROM "posts"["ต้มยำกุ้งน้ำข้น", …]ขอค่าดิบช่องเดียว ไม่สร้าง object
Post.idsSELECT "posts"."id" FROM "posts"[1, 2, 3, 4, 5, 6]ขอแค่เลขที่
Post.exists?(user_id: 1)SELECT 1 AS one … LIMIT 1trueดีกว่า count > 0 เพราะหยุดที่แผ่นแรก
Post.sum(:views)SELECT SUM("posts"."views") …1055บวกยอดวิวทุกแผ่น
Post.where(published: true).average(:views)SELECT AVG(…) … WHERE … = TRUE0.26375e3 (= 263.75)ค่าเฉลี่ย · ได้เลขแบบวิทยาศาสตร์
Post.group(:user_id).count… GROUP BY "posts"."user_id"{3 => 2, 2 => 2, 1 => 2}แยกกองตามผู้เขียนแล้วนับ
Post.group(:user_id).sum(:views)SELECT SUM(…) … GROUP BY …{3 => 510, 2 => 85, 1 => 460}ยอดวิวรวมของแต่ละคน
Post.select(:id, :title).firstSELECT "posts"."id", "posts"."title" …object ที่มีแค่สองช่องขอช่องอื่นต่อ = missing attribute 'body'
Post.where(published: false).update_all(views: 0)UPDATE "posts" SET "views" = 0 WHERE …2 (จำนวนแถว)แก้หลายแถวทีเดียว · ข้าม validation และ callback
Post.where(…).explain(:analyze)EXPLAIN (ANALYZE) …Seq Scan / Index Scanตู้ไล่ทีละแผ่น หรือใช้ป้ายแท็บ (แค่ให้รู้ว่ามี)

ข้อมูลเยอะ — ยกแฟ้มทีละลัง ไม่ยกทั้งตู้

คำสั่งSQL ที่เห็นภาษาคน
Post.find_each(batch_size: 2) { |p| … }… ORDER BY "posts"."id" ASC LIMIT 2 แล้ว WHERE "posts"."id" > 2 … ต่อไปเรื่อย ๆวนทีละแผ่น แต่หยิบจากตู้ทีละลัง · โต๊ะไม่ล้น
….in_batches(of: 1000) { |batch| … }หยิบเลขที่ทีละกอง แล้วทำกับทั้งกองใช้คู่ update_all ตอน backfill
Post.order(views: :desc).find_each …Scoped order is ignored, use :cursor with :order to configure custom order.⚠️ ไม่ใช่ error · คำสั่งเรียงถูกทิ้ง ได้ลำดับตามเลขที่

10 · N+1 — โค้ด 3 บรรทัดที่เดินไปตู้ 101 รอบ (บท 114)

N+1 = ไปตู้ 1 รอบเอารายการ แล้วไปเพิ่มอีก 1 รอบต่อทุกชิ้นในรายการ · บทความ 6 ชิ้น ขอชื่อผู้เขียน = 7 รอบ · เกิดเพราะ post.user คือการเดินไปตู้ ไม่ใช่อ่านช่องบนแฟ้ม (Rails ยังไม่โหลดของที่ผูกกันจนกว่าจะถาม — lazy loading) · ใน Go คือ db.First(&u, p.UserID) อยู่ในลูป for — ใน Go เห็นด้วยตา แต่ใน Rails ซ่อนอยู่ใน post.user.name

วิธีเห็น — นับจาก log ไม่ใช่จากโค้ด

ดูที่หน้าตาจริงอ่านว่า
consolePost Load … 1 บรรทัด + User Load … WHERE "users"."id" = 1 LIMIT 1 6 บรรทัดนับบรรทัด Load/Count = จำนวนรอบที่ไปตู้
log ของ server (tail -f log/development.log)Completed 200 OK in 87ms (Views: 4.3ms | ActiveRecord: 59.9ms (7 queries, 3 cached) | GC: 9.6ms)ท่อน (7 queries, 3 cached) คือตัวเลขที่ต้องดู
บรรทัด CACHE User Load (0.0ms)ผู้เขียนซ้ำในหน้าเดียว ตอบจากความจำ⚠️ ช่วยได้แค่ตอนซ้ำ · 100 บทความ 100 ผู้เขียน = ยัง 101 รอบ
บรรทัด ↳ app/views/posts/index.html.erb:6ใต้ SQL แต่ละบรรทัดบอกว่า SQL นี้มาจากไฟล์ไหน (ไฟล์ .erb เลขเกินจริง 1)

ก่อน / หลังแก้ — หน้า /posts ที่โชว์ผู้เขียน + จำนวนคอมเมนต์ (ภารกิจบท 114)

controller · viewบรรทัด Completedแปลว่า
Post.all · post.comments.count(13 queries, 3 cached)บทความ 1 + ผู้เขียน 6 + นับคอมเมนต์ 6
Post.includes(:user) · count(8 queries, 0 cached)ผู้เขียนเหลือบรรทัดเดียว IN (1, 2, 3) · แต่ count ยังไปตู้ทุกชิ้น
Post.includes(:user, :comments) · size(3 queries, 0 cached)บทความ 1 · ผู้เขียน 1 · คอมเมนต์ 1 — ไม่โตตามจำนวนบทความ

includes · preload · eager_load — สามคำ สองวิธี (SQL จริง)

คำสั่งไปตู้กี่รอบSQL หน้าตากรองด้วยช่องของลิ้นชักลูกได้ไหมใช้เมื่อ
Post.preload(:user)2SELECT "posts".* FROM "posts" + SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3)❌ error missing FROM-clause entry for table "users"แค่เอามาโชว์
Post.eager_load(:user)1SELECT "posts"."id" AS t0_r0, … FROM "posts" LEFT OUTER JOIN "users" ON "users"."id" = "posts"."user_id"✅ต้องกรองด้วยช่องของ users
Post.includes(:user)ปกติ 2 · มี where(users: …) เป็น 1Rails เลือกให้✅ (สลับเป็น JOIN เอง)ใช้เป็นค่าเริ่มต้น

ตัวอย่างกรอง: Post.includes(:user).where(users: { name: "มานะ" }).map(&:title) → ได้ ["ผัดกะเพราหมูสับ", "แกงเขียวหวานไก่"] ด้วย query เดียว · ใน GORM ใกล้สุดคือ db.Preload("Comments") · ชื่อใน includes ต้องตรงกับใน model (:user เอกพจน์ เพราะ belongs_to :user)

ตัวช่วยจับ N+1

ของทำอะไรหน้าตา
Post.strict_loading.firstห้าม lazy loading · ถามของที่ผูกกันโดยไม่ได้ includes = ระเบิด`Post` is marked for strict_loading. The User association named `:user` cannot be lazily loaded. (ActiveRecord::StrictLoadingViolationError)
counter_cacheป้ายตัวเลขบนแฟ้มแม่ แก้ N+1 ของการนับ (หัวข้อ 11)post.comments.size ไม่ยิง SQL เลย
gem bulletเตือน N+1 ระหว่างพัฒนา · ไม่ได้ติดตั้ง/รันในบทเรียน (ห้ามเพิ่ม gem ในแอปตัวอย่าง)ดูข้อความตัวอย่างในเอกสารของ gem

นิสัยเดียวที่ต้องติดตัว: ก่อนรับงาน (ของตัวเองหรือของ AI) เปิด log ของหน้านั้น หาบรรทัด Completed แล้วดูว่า query โตตามจำนวนแถวไหม

11 · association · dependent (บท 115 · 116)

กฎข้อเดียว: belongs_to อยู่ฝั่งที่มีคอลัมน์ _id · ใบคอมเมนต์มีช่อง post_id → Comment belongs_to :post · บทความไม่มีช่องอะไรเพิ่ม → Post has_many :comments · ช่อง _id ที่ชี้ไปหาแฟ้มลิ้นชักอื่นเรียกว่า foreign key · ใน GORM คือ field PostID uint อยู่ฝั่ง Comment เหมือนกัน

ความสัมพันธ์ 5 แบบ

คำสั่งภาษาคนตัวอย่างในบล็อกช่อง _id อยู่ที่ได้ method
belongs_to“ฉันเป็นของ…หนึ่งตัว”Comment เป็นของ Postcomments.post_id (ตารางตัวเอง)comment.post
has_many“ฉันมี…ได้หลายตัว”Post มีหลาย Commentไม่มีในตารางตัวเอง (อยู่ที่ comments)post.comments
has_one“ฉันมี…ได้ตัวเดียว”User มี Profile หนึ่งหน้า (แค่ภาพ ไม่ได้สร้าง)profiles.user_iduser.profile
has_many :throughหลายต่อหลาย ผ่านตารางกลางที่มี modelPost ติดได้หลาย Tag · Tag อยู่ได้หลาย Postตารางกลาง taggings มีทั้ง post_id และ tag_idpost.tags
has_and_belongs_to_many (habtm)หลายต่อหลาย ตารางกลางเปล่า ไม่มี modelเหมือนแถวบน (แค่รู้จัก)ตาราง posts_tagspost.tags
# app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user                 # posts มี user_id
  has_many :comments               # post_id อยู่ฝั่ง comments
  has_many :taggings
  has_many :tags, through: :taggings
end

ลืมบรรทัด has_many :taggings แต่เขียน through: :taggings = Could not find the association :taggings in model … · Rails ไม่รู้ว่าจะเดินผ่านอะไร

method ที่ได้ฟรีจาก has_many

คำสั่งทำอะไรลงตู้ทันทีไหม
post.commentsใบเบิก “คอมเมนต์ทุกใบของบทความนี้” ต่อ .where / .order ได้— (แค่อ่าน)
post.comments.create(user: mana, body: "…")สร้างพร้อมเติม post_id ให้ ไม่ต้องเขียนเลขที่เอง✅ ถ้าผ่านกฎ
post.comments.build(…)สร้างไว้ในมือพร้อมเติม post_id❌ ต้อง save เอง
post.comments << cเอาคอมเมนต์ที่มีอยู่มาผูกกับบทความนี้✅
post.comments.count · .sizeนับในตู้ · นับในตู้ + ของในมือที่ยังไม่เก็บ (ผลจริง 3 กับ 4)—
post.comment_idsเลขที่คอมเมนต์ทั้งหมด ([5, 6, 7])—
post.comments.clear⚠️ ตัดความสัมพันธ์ = ตั้ง post_id เป็นว่าง → ตู้ห้าม → NotNullViolationอย่าใช้กับ belongs_to ที่บังคับ

ตั้งชื่อทางลัดที่มีเงื่อนไข: has_many :published_posts, -> { where(published: true) }, class_name: "Post" · -> { … } คือ lambda (ก้อนโค้ดเก็บไว้รันทีหลัง) · ลืม class_name = Missing model class PublishedPost …

dependent — ทิ้งแฟ้มบทความแล้ว ใบคอมเมนต์ทำยังไง

ไม่ใส่อะไรเลย → ตู้ห้ามทิ้ง: Key (id)=(1) is still referenced from table "comments". (ActiveRecord::InvalidForeignKey) = “แฟ้มเลขที่ 1 ยังมีใบคอมเมนต์อ้างถึงอยู่”

ทางใครทำSQL ที่เห็นจริงcallback ของลูกเลือกเมื่อธุรกิจตอบว่า…ใน Go
① dependent: :destroyRails หยิบลูกทีละใบแล้ว destroyComment Load + DELETE … WHERE "comments"."id" = 1 ทีละใบ + DELETE บทความ · ลูก 100 ใบ = 104 คำสั่ง✅ ทำงานตอนลูกหายมีงานต้องทำ (ลบรูปแนบ · แจ้งเตือน)db.Select("Comments").Delete(&post)
② dependent: :delete_allRails ยิง DELETE คำสั่งเดียวDELETE FROM "comments" WHERE "comments"."post_id" = 1 · กี่พันใบก็ 4 คำสั่ง❌ ไม่ทำงานลูกเยอะ · ไม่มี callback · ไม่มีหลานdb.Where("post_id = ?", …).Delete(&Comment{})
③ on_delete: :cascade (ใน migration)ตัวตู้ทิ้งลูกเองlog เห็นแค่ DELETE FROM "posts" …❌ Rails ไม่เห็นมีคนลบข้อมูลโดยไม่ผ่านแอปนี้ และห้ามเหลือแฟ้มกำพร้าgorm:"constraint:OnDelete:CASCADE;"
④ dependent: :restrict_with_errorRails ห้ามทิ้งComment Exists? … LIMIT 1 → ROLLBACK → false—ลบแม่ไม่ได้ถ้ายังมีลูก (ผู้ใช้ที่มีผลงาน · หมวดที่มีบทความ)เช็คเองก่อนลบ

ทาง ④ ได้ errors.full_messages = ["Cannot delete record because dependent comments exist"] · ถ้าใช้ destroy! จะได้ ActiveRecord::RecordNotDestroyed หน้าแดงแทน (scaffold ใช้ destroy! — Lab ขั้น 5) · ทุก has_many ที่ชี้มาหาแม่ต้องตัดสินใจ ไม่ใช่แค่ comments — ลืม taggings ก็ยังได้ InvalidForeignKey ท่อน on table "taggings"

ของเล็กอีกสามชิ้น

ของเขียนผลจริงภาษาคน
counter_cacheช่อง comments_count (default: 0, null: false) + belongs_to :post, counter_cache: true + backfill ค่าเก่าสร้างคอมเมนต์: UPDATE "posts" SET "comments_count" = COALESCE("posts"."comments_count", 0) + 1 … (COALESCE(…, 0) = ถ้าช่องว่างให้ถือเป็น 0) · ลูป size เหลือ 1 queryป้ายตัวเลขบนหน้าแฟ้ม ไม่ต้องเดินไปนับ · ต้องใช้ size ถึงได้ประโยชน์
touchbelongs_to :post, counter_cache: true, touch: trueคอมเมนต์ใหม่ → updated_at ของบทความเปลี่ยนตามลูกขยับ แม่รู้ตัว · ฐานของ cache ใน Day 3
inverse_ofปกติไม่ต้องเขียนcomment.post.equal?(post) → trueสองฝั่งใช้ object ตัวเดียวกัน · Rails เดาให้เองเมื่อชื่อตามกฎ (แค่รู้จัก)

12 · REST · strong params · params.expect (บท 117)

resources :posts = 7 เมนูมาตรฐาน (8 แถว เพราะ PATCH กับ PUT ไปเมนูเดียวกัน)

ใบสั่งเมนูภาษาคนGin ที่เคยเขียน
GET /postsindexดูบทความทั้งหมดr.GET("/posts", h.List)
GET /posts/newnewเปิดฟอร์มเขียนใหม่— (API ไม่มีหน้าฟอร์ม)
POST /postscreateบันทึกบทความใหม่r.POST("/posts", h.Create)
GET /posts/:idshowดูชิ้นเดียวr.GET("/posts/:id", h.Get)
GET /posts/:id/editeditเปิดฟอร์มแก้—
PATCH (และ PUT) /posts/:idupdateบันทึกที่แก้r.PUT("/posts/:id", h.Update)
DELETE /posts/:iddestroyลบr.DELETE("/posts/:id", h.Del)

ปรับเซ็ตเมนู — ใน config/routes.rb

เขียนได้ (จาก bin/rails routes -c …)ภาษาคน
resources :posts, only: [:index, :show]2 เส้น: posts#index · posts#showเอาแค่เมนูที่ใช้จริง
resources :posts, except: [:destroy]ทุกเส้นยกเว้น DELETEตัดออกบางเมนู
resources :posts do / resources :comments / end/posts/:post_id/comments · post_comments_pathคอมเมนต์ของบทความเลขไหน · เลขแม่อ่านด้วย params[:post_id]
resources :comments, shallow: true (ซ้อนใน posts)index · new · create ซ้อน · ที่เหลือ /comments/:idซ้อนเฉพาะเส้นที่ยังไม่มี id ลูก · อย่าซ้อนเกินชั้นเดียว
member do post :publish endPOST /posts/:id/publish · publish_postเมนูพิเศษของชิ้นเดียว (มี :id)
collection do get :drafts endGET /posts/drafts · drafts_postsเมนูพิเศษของทั้งแผนก (ไม่มี :id)
resource (ไม่มี s)6 เมนู ไม่มี index ไม่มี id · controller ยังพหูพจน์ของที่ผู้ใช้มีชิ้นเดียว เช่น หน้าตั้งค่าบัญชี

route helper ใน console (ผลจริง): app.post_path(post) → "/posts/5" · app.post_comments_path(post) → "/posts/5/comments" · app.post_comment_path(post, post.comments.first) → "/posts/5/comments/5"

mass assignment → strong params — ยิงจริงด้วย curl (ถือบัตรคิว CSRF ไปด้วย ไม่ได้ปิดการตรวจ)

controller เขียนว่าคนร้ายส่ง title · body · views=999999 · published=true · user_id=3แปลว่า
Post.new(params[:post])500 · ActiveModel::ForbiddenAttributesErrorRails 8 ไม่ยอมเอา params ที่ยังไม่กรองใส่แฟ้มตั้งแต่แรก
Post.new(params[:post].permit!)แก้ user_id=3 ได้จริง → ย้ายบทความไปเป็นของคนอื่น❌ permit! = “รับทุกช่อง” · เจอในโค้ดหรือคำตอบ AI ให้ถือเป็นบั๊ก
params.require(:post).permit(:title, :body){"title" => "ร้านผม", "views" => 0, "published" => false, "user_id" => 1} + log Unpermitted parameters: :views, :published, :user_id.✅ ช่องเกินถูกตัดทิ้ง (allowlist = รายชื่อของที่ผ่านได้)
params.expect(post: [:title, :body])ผลเหมือนแถวบน · แต่ไม่มี log Unpermitted parameters✅ ท่าของ Rails 8 · ตรวจรูปร่างด้วย

ใน Go คือ json.Unmarshal(body, &post) ลง struct ที่ลง DB ตรง ๆ (ได้ Views:999999 Published:true UserID:3) · ทางแก้ฝั่ง Go = struct รับ input แยก CreatePostInput{Title, Body} · Rails ทำหน้าที่เดียวกันด้วย post_params ใต้ private

ส่งรูปร่างผิดมา — 400 (คนส่งผิด) ดีกว่า 500 (server พังเอง)

ส่งมาแบบไหน (-d …)ภาษาคนrequire/permitparams.expect
"post=hello"post เป็นข้อความคำเดียว500 undefined method 'permit' for an instance of String400 ParameterMissing
"post[]=a"post เป็นรายการ500 NoMethodError400 ParameterMissing
"title=x"ไม่มีกลุ่ม post เลย400 ParameterMissing400 ParameterMissing
ห้าช่องของคนร้ายช่องเกิน 3 ช่อง200 · ช่องเกินถูกทิ้ง200 · ช่องเกินถูกทิ้ง

ข้อความ 400 จริง: ActionController::ParameterMissing (param is missing or the value is empty or invalid: post) · strong params ตอบว่า “ช่องนี้รับได้ไหม” · ไม่ได้ตอบว่า “คนนี้ทำได้ไหม” (authorization = Day 4)

13 · scaffold · checklist 8 ข้อตรวจงาน AI (บท 118 · 119)

bin/rails generate scaffold Category name:string slug:string description:text = ได้ทั้งแผนกในคำสั่งเดียว · ย้อนด้วย bin/rails db:rollback ก่อน แล้วค่อย bin/rails destroy scaffold Category (สลับลำดับ = Rails ไม่รู้ว่าต้องรื้ออะไร) · หน้าไม่มีสี = สั่ง bin/rails tailwindcss:build หนึ่งครั้ง

ได้อะไรมา (ไฟล์ใหม่ 16 ไฟล์ + routes 1 บรรทัด)

ไฟล์คืออะไรในร้านอาหาร
db/migrate/…_create_categories.rbสร้างตาราง 3 ช่อง + เวลาสร้าง/แก้ · ไม่มี null: false ไม่มี indexใบสั่งช่างต่อลิ้นชักใหม่
app/models/category.rbmodel ว่างเปล่า ไม่มีกฎสักข้อคนดูแลสต็อกที่ยังไม่รู้กฎร้าน
config/routes.rb +1 บรรทัดresources :categories = 8 เส้นรายการใหม่ในมือพนักงานต้อนรับ
app/controllers/categories_controller.rb7 action · before_action :set_category · respond_to (HTML + JSON) · params.expect(category: [ :name, :slug, :description ]) · destroy!หัวหน้าเชฟกับ 7 เมนู
app/views/categories/*.html.erb (6 ไฟล์)index · show · new · edit + _form · _category (partial = ชิ้นส่วนใช้ซ้ำ ชื่อขึ้นต้น _)แบบจัดจาน
*.json.jbuilder (3 ไฟล์)ตอบเป็น JSON (/categories.json)แบบใส่กล่องกลับบ้าน
app/helpers/… · test/…module ว่าง · เทส (รอ Day 4)—

ของที่ scaffold ไม่ให้ = ของที่ AI ไม่ให้

เรื่องขาดอะไรเติมด้วย · บท
กฎของร้านชื่อซ้ำได้ไหม · ลบหมวดแล้วบทความเป็นยังไง · ใครทำอะไรได้คำตอบมาจากเจ้าของร้าน ไม่ใช่จากโค้ด
ปริมาณข้อมูลจริงไม่มี index สักตัวindex — 109
กฎที่ตัวตู้ไม่มี null: false · ไม่มี unique indexvalidation 110 + constraint 111 คู่กันเสมอ
จำนวนรอบที่เดินไปตู้Category.all ไม่มี limit · ไม่มี includeslimit 113 · includes 114
ใครมีสิทธิ์ใครเปิดหน้าได้ก็กด Destroy ได้strong params 117 · authorization Day 4
ภาษาข้อความอังกฤษตายตัว (Category was successfully created.)แก้ข้อความเอง · หรือ i18n (ระบบแปลภาษาของ Rails · นอกขอบเขตคอร์ส)

⭐ checklist 8 ข้อ — ไล่ทุกครั้งที่รับโค้ดจาก AI หรือ scaffold

ข้อถามอะไรทำไมบท
1เห็น uniqueness: true → มี unique index คู่ไหมมีแค่กฎใน model สองคนกดพร้อมกันยังซ้ำได้111
2เห็น presence: true → มี NOT NULL (null: false) คู่ไหมvalidation กันแค่ทางที่ผ่าน model · constraint กันทุกทาง110 · 111
3ช่องที่ใช้ค้นหา (อยู่ใน where) และช่อง …_id มี index ไหมไม่มีป้ายแท็บ = ไล่หาทีละแผ่น ช้าขึ้นตามจำนวนแฟ้ม109
4มี has_many → เลือก dependent ถูกไหมลบแม่แล้วลูกหายตาม · ห้ามลบ · หรือค้างกำพร้า — ธุรกิจตอบ ไม่ใช่ AI116
5วน each แล้วแตะของที่ผูกกัน → มี includes ไหมไม่มี = N+1 · เปิด log นับ query ก่อนรับงาน114
6หน้ารายการมี limit หรือการแบ่งหน้าไหมModel.all ใน index = ระเบิดเวลาที่รอวันข้อมูลโต113
7migration นี้ แตะกี่แถว ระหว่างนั้นใครต้องรอตารางใหญ่ + lock = แอปค้างทั้งร้าน109
8ใครมีสิทธิ์ทำอันนี้strong params ไม่ใช่ authorization117 · Day 4

prompt ที่ดีบอกกฎของธุรกิจและสิ่งที่ห้ามทำไปด้วย (ตัวอย่างจากภารกิจบท 118):

Ingredient ต้องมี name ห้ามว่าง และห้ามซ้ำ — เติมทั้ง validation ใน model และ constraint ใน migration ใหม่
(null: false + unique index) · หน้า index ให้เรียงตามชื่อและ limit 50 · อย่าแก้ migration เดิมที่รันไปแล้ว

ลำดับ Lab 2 (บท 119) — เติมของที่ scaffold ไม่ให้

ขั้นทำผลที่เห็นจริง
1scaffold Category แล้วกด Create ฟอร์มว่างINSERT … VALUES ('', '', …) — ตู้รับแฟ้มว่าง
2เติม validation + before_validation :fill_slug["Name can't be blank", "Slug can't be blank", "Slug is invalid"]
3migration change_column_null × 2 + unique indexพังก่อน: column "slug" of relation "categories" contains null values → เก็บกวาดแฟ้มเสีย → ผ่าน
4ลองประตูข้าง save(validate: false) อีกรอบตู้ปฏิเสธ: NotNullViolation · RecordNotUnique
5add_reference :posts, :category, null: true + belongs_to :category, optional: true + restrict_with_errorหมวดที่มีบทความลบไม่ได้ · แก้ destroy! ใน controller เป็น if @category.destroy
6Category.order(:name).includes(:posts).limit(50)5 query → (3 queries, 0 cached) ไม่โตตามจำนวนหมวด

14 · ตาราง error → แปลว่า → แก้ยังไง

อ่าน error 3 จังหวะ: ① ชื่อในวงเล็บท้ายบรรทัด ((ActiveRecord::…)) บอกว่าใครโวย → ② ประโยคหลัง ERROR: และบรรทัด DETAIL: / HINT: บอกว่าโวยเรื่องอะไร (ส่วนใหญ่สำคัญที่สุด) → ③ (wongnok-blog):3 = บรรทัดที่ 3 ที่พิมพ์ใน console · …/db/migrate/….rb:3 = บรรทัดในไฟล์ · ชื่อขึ้นต้น PG:: = PostgreSQL (ตัวตู้) เป็นคนโวย · ไม่มี PG:: = Rails โวยเอง

ใน console / model

ข้อความที่เห็นแปลว่าแก้ยังไง
PG::UndefinedTable: ERROR: relation "posts" does not exist (ActiveRecord::StatementInvalid)มี model แล้ว แต่ยังไม่มีลิ้นชักในตู้bin/rails db:migrate · หรือชื่อ class ไม่ตรงชื่อตาราง → self.table_name
PG::UndefinedColumn: ERROR: column posts.publish does not exist + HINT: Perhaps you meant to reference the column "posts.published".ชื่อคอลัมน์ผิด (หรือยังไม่มีช่องนั้นจริง)ดู HINT · ช่องยังไม่มี → เขียน migration ก่อน
unknown attribute 'user_id' for Post. (ActiveModel::UnknownAttributeError)ส่งช่องที่ตารางไม่มีเข้า newเช็ค Post.column_names
undefined method 'slug' for an instance of Post (NoMethodError)แฟ้มไม่มีช่องนี้ · Rails สร้าง method ตามคอลัมน์จริงเท่านั้นmigration เพิ่มคอลัมน์ก่อน
missing attribute 'body' for Post (ActiveModel::MissingAttributeError)ใช้ select(:id, :title) แล้วขอช่องอื่นเพิ่มช่องใน select หรือเลิกใช้ select
Couldn't find Post with 'id'=99 (ActiveRecord::RecordNotFound)find หาไม่เจอ (ในเว็บ = 404)ไม่เจอเป็นเรื่องปกติ → ใช้ find_by แล้วเช็ค nil
undefined method 'name' for nil (NoMethodError)find_by ตอบ nil แล้วเรียกต่อย้อนเช็คบรรทัด find_by (พิมพ์ชื่อผิด?)
["User must exist"] · ["Category must exist"]belongs_to บังคับต้องมีเจ้าของใส่ user: · ของเก่าไม่มีหมวด → optional: true ชั่วคราว
Validation failed: Title can't be blank (ActiveRecord::RecordInvalid)save!/create! ไม่ผ่านกฎอ่านท่อนหลัง Validation failed: · ถ้าเป็นข้อมูลจากฟอร์ม ใช้ save ไม่มี !
Unknown key: :presence. … Perhaps you meant to call `validates` instead of `validate`?ลืมตัว svalidate → validates
The provided regular expression is using multiline anchors (^ or $) …regex ใช้ ^ $ (ข้อความหลายบรรทัดแอบผ่านได้)เปลี่ยนเป็น \A … \z
valid? ได้ true ทั้งที่เพิ่งเติมกฎconsole ใช้ model ฉบับเก่าreload! แล้วสร้าง/หาตัวแปรใหม่
uninitialized constant PublishPost (NameError)โฟลเดอร์ใหม่ใต้ app/ ที่ console ยังไม่รู้จักปิด console แล้วเปิดใหม่
Missing model class PublishedPost for the User#published_posts association.ชื่อความสัมพันธ์ไม่ตรงชื่อ modelเติม class_name: "Post"
Association named 'users' was not found on Post; perhaps you misspelled it? … Did you mean? userincludes(:users) มี s เกินใช้ชื่อเดียวกับใน model (:user)
The :dependent option must be one of [:destroy, :delete_all, :nullify, :restrict_with_error, :restrict_with_exception, :destroy_async], but is :restrictพิมพ์ค่า dependent ผิดเลือกจากรายการในวงเล็บ
… is marked for strict_loading. … cannot be lazily loaded. (ActiveRecord::StrictLoadingViolationError)กำลังจะสร้าง N+1เติม includes ตามชื่อที่มันบอก

ตัวตู้ (PostgreSQL) ปฏิเสธ — constraint ทำงาน

ข้อความที่เห็นแปลว่าแก้ยังไง
null value in column "user_id" of relation "posts" violates not-null constraint (ActiveRecord::NotNullViolation)ช่องที่ห้ามว่างถูกส่งมาว่าง (มักผ่านประตูข้าง)ใส่ค่า · และเติม presence ให้ได้ข้อความภาษาคนก่อนถึงตู้
duplicate key value violates unique constraint "index_users_on_email" (ActiveRecord::RecordNotUnique) + DETAIL: Key (email)=(…) already exists.ค่าซ้ำ · unique index ทำงานใน controller rescue ActiveRecord::RecordNotUnique แล้ว render ฟอร์มพร้อม error
insert or update on table "comments" violates foreign key constraint "fk_rails_…" (ActiveRecord::InvalidForeignKey) + Key (post_id)=(9999) is not present in table "posts".อ้างถึงแฟ้มแม่ที่ไม่มีอยู่จริงใช้เลขที่มีจริง · สร้างผ่าน post.comments.create
update or delete on table "posts" violates foreign key constraint … on table "comments" + is still referenced from table "comments".ทิ้งแม่ทั้งที่ลูกยังอ้างถึงเลือก dependent (หัวข้อ 11) · ดูท่อน on table "…" ว่าลิ้นชักไหนห้าม
new row for relation "posts" violates check constraint "views_not_negative" (ActiveRecord::CheckViolation)ผิดเงื่อนไข CHECKแก้ค่า · เติม numericality คู่กัน

ตอน migrate / rollback

ข้อความที่เห็นแปลว่าแก้ยังไง
PG::NotNullViolation: ERROR: column "slug" of relation "categories" contains null valuesตั้งกฎห้ามว่างไม่ได้ เพราะมีแฟ้มเก่าที่ว่างค้างหา where(slug: nil) → เก็บกวาด/backfill → migrate ใหม่
column "category_id" of relation "posts" contains null values (ตอน add_reference … null: false)ช่องใหม่บังคับต้องมีค่า แต่บทความเดิมไม่มีexpand ก่อน: null: true · เติมค่าแล้วค่อยขัน NOT NULL
PG::UniqueViolation: ERROR: could not create unique index "index_users_on_email" + Key (email)=(…) is duplicated.มีของซ้ำซ่อนอยู่แล้วUser.group(:email).having("COUNT(*) > 1").count หาของซ้ำ · เจ้าของข้อมูลตัดสินว่าเก็บแถวไหน
CREATE INDEX CONCURRENTLY cannot run inside a transaction blockลืม disable_ddl_transaction!เติมใน class นอก def change
No indexes found on posts with the options provided. (ตอน rollback)แก้ไฟล์ใบเก่าหลังรันไปแล้วคืนไฟล์ให้เหมือนตอนรัน → rollback → ค่อยแก้ · หรือเขียนใบใหม่
PG::DuplicateTable: ERROR: relation "index_posts_on_subtitle" already existsมี index ชื่อนี้อยู่แล้ว (สร้างซ้ำสองใบ)เก็บกวาดใบที่ซ้ำ
This migration uses execute, which is not automatically reversible.Rails เดาขากลับของ SQL ดิบไม่ได้เขียน up/down หรือ raise IrreversibleMigration
ActiveRecord::UnknownMigrationVersionError · No migration with version number … · status ขึ้น NO FILEลบไฟล์ก่อน rollbackครั้งหน้า rollback → destroy
Could not generate field 'reading_minutes' with unknown type 'integr'.พิมพ์ชนิดข้อมูลผิด · ไม่มีไฟล์ถูกสร้างสั่งใหม่ให้ถูก (integer)
PG::ObjectInUse: ERROR: database "…" is being accessed by other usersยังมี console/server ต่อตู้อยู่ ทิ้งตู้ไม่ได้exit ทุกหน้าต่างแล้วสั่งใหม่
Database not found: … (ActiveRecord::NoDatabaseError)ยังไม่มี DB ชื่อนั้นbin/rails db:prepare

ในหน้าเว็บ / log ของ server

ข้อความที่เห็นแปลว่าแก้ยังไง
ActiveRecord::PendingMigrationError · Migrations are pending. To resolve this issue, run: bin/rails db:migrateมีใบสั่งที่ยัง downทำตามที่มันบอก แล้ว refresh
500 + ActiveModel::ForbiddenAttributesErrorเอา params ดิบใส่ modelstrong params (params.expect) · ห้าม permit!
400 + param is missing or the value is empty or invalid: commentชื่อกลุ่มที่ส่งมาไม่ตรงกับใน expectส่ง comment[body]=… ให้ตรง
422 + Can't verify CSRF token authenticity.ยิง curl โดยไม่มีบัตรคิวเอา token + cookie (-b jar.txt) ไปด้วย · อย่าปิดการตรวจ
ActiveRecord::RecordNotDestroyed · Failed to destroy Category with id=5destroy! ของ scaffold เจอ restrict_with_errorif @category.destroy … else โชว์ errors.full_messages.to_sentence
The name 'Category' is either already used in your application or reserved by Ruby on Rails.scaffold ชื่อนี้มีอยู่แล้วrollback → destroy scaffold → commit แล้วเริ่มใหม่
Unpermitted parameters: :views, :published, :user_id.ไม่ใช่ error · ช่องเกินถูกตัดทิ้งถ้าช่องที่ควรผ่านโผล่ตรงนี้ = ลืมใส่ใน permit
หน้าเป็นตัวหนังสือดำล้วน ไม่มีสียังไม่ได้สร้างไฟล์ CSS ของ Tailwindbin/rails tailwindcss:build

15 · ศัพท์ภาษาคน

ศัพท์ภาษาคนในร้านอาหารใน Go/GORM
database · table · row · columnที่เก็บข้อมูล · ตาราง · แถว (record) · คอลัมน์ตู้ · ลิ้นชัก · แฟ้มหนึ่งแผ่น · ช่องบนแฟ้มเหมือนกัน
SQL · queryภาษาสั่ง database · คำสั่งหนึ่งประโยค = ไปตู้หนึ่งรอบภาษาของคนดูแลสต็อก · การเดินไปตู้SQL ที่ GORM ยิง
NULLช่องที่ไม่มีค่า · Ruby เห็นเป็น nil · ไม่ใช่ ''ช่องที่ไม่ได้เขียนอะไร—
ORMตัวแปลงระหว่าง object ในโค้ดกับแถวในตารางคนแปลภาษาให้คนดูแลสต็อกGORM
Active Record patternobject หนึ่งตัว = แถวหนึ่ง + รู้วิธี save ตัวเองแฟ้มที่เดินเข้าลิ้นชักเองได้struct ไม่ save ตัวเอง
Data Mapper · repositoryแยก object ข้อมูลล้วนกับตัวที่คุยกับ DBแฟ้มกระดาษเปล่า + พนักงานคลังrepository บท 27
attributeช่องข้อมูลฝั่ง model ที่ Rails สร้าง method ให้จากคอลัมน์ช่องบนแฟ้มfield ของ struct
CRUDสร้าง · อ่าน · แก้ · ลบงานสี่อย่างของคนดูแลสต็อกเหมือนกัน
transaction · commit · rollbackกลุ่มคำสั่งที่ทำทั้งหมดหรือไม่ทำเลย · ยืนยัน · ยกเลิกทั้งชุดงานชุดเดียวที่ห้ามค้างครึ่งdb.Transaction(func…)
schema · migrationหน้าตาของ DB · ไฟล์เปลี่ยนหน้าตาหนึ่งครั้งแปลนตู้ · ใบสั่งช่างgoose
schema driftDB แต่ละเครื่องค่อย ๆ ไม่ตรงกันโดยไม่มีใครรู้ตู้แต่ละสาขาหน้าตาเพี้ยนกันเจอในบท 31
production · deploy · downtimeเว็บจริง · เอาโค้ดใหม่ขึ้น · ช่วงที่เว็บใช้ไม่ได้ร้านจริง · ส่งของเข้าร้าน · ร้านปิดเหมือนกัน
lock · DDLDB กันไม่ให้คนอื่นแตะตารางชั่วคราว · คำสั่งเปลี่ยนโครงตู้ (CREATE · ALTER · DROP)ปิดลิ้นชักระหว่างช่างทำงานเรื่องของ PostgreSQL
index · Seq Scan · Index Scanโครงสร้างช่วยหาเร็ว · ไล่ทีละแถว · ใช้ indexป้ายแท็บ · ไล่ทีละแผ่น · เปิดตามป้ายtag uniqueIndex (แบบห้ามซ้ำ)
expand/contract · backfill · dual writeเพิ่มใหม่ → ย้าย → รื้อเก่า · เติมค่าย้อนหลัง · เขียนลงทั้งช่องเก่าและใหม่ปรับปรุงร้านโดยไม่ปิดร้านหลักการเดียวกัน
validation · constraintกฎที่โค้ดตรวจ (ข้ามได้) · กฎที่ตัว DB บังคับ (ข้ามไม่ได้)คนดูแลสต็อก · ตัวตู้binding ของ Gin · gorm:"not null"
defense in depthกันหลายชั้น ชั้นหนึ่งพลาดอีกชั้นยังกัน4 ด่านหน้าร้านถึงตู้—
race condition · check-then-actผลขึ้นกับใครเสร็จก่อน · ตรวจก่อนแล้วค่อยทำ (มีช่องว่างตรงกลาง)สองคนจองโต๊ะเดียวกันพร้อมกันเหมือนกัน
bang method (!)ถ้าทำไม่ได้ให้โยน errorกดกริ่งเตือนดัง ๆGORM มีแบบเดียว: คืน .Error
callback · lifecyclemethod ที่ Rails เรียกให้เองตามจังหวะชีวิตของแฟ้มขั้นตอนอัตโนมัติก่อน/หลังเก็บhook (BeforeSave)
side effectผลที่ออกไปนอกตัวโค้ด (อีเมล · API) · rollback ย้อนไม่ได้จดหมายที่ส่งออกไปแล้วเหมือนกัน
service object · concernclass ทำงานหลายขั้นหนึ่งอย่าง · module ที่หลาย model ใช้ร่วมใบงานผู้จัดการ · คู่มือกลาง—
relation · lazyใบเบิกที่ยังไม่ยื่น · ยังไม่ทำจนกว่าต้องใช้ผลใบเบิกในมือGORM ยิงตอน Find
N+1 · eager loadingไปตู้ 1 + N รอบ · โหลดของที่ผูกกันมาล่วงหน้าเดิน 101 รอบ · หยิบทั้งตะกร้าloop + First · Preload
primary key · foreign keyเลขที่แฟ้ม (id) · ช่องที่เก็บเลขที่แฟ้มลิ้นชักอื่น (post_id)เลขหน้าแฟ้ม · ช่อง “เป็นของเลขที่ …”ID · PostID
association · join tableบอก model ว่าผูกกับใคร · ตารางกลางของหลายต่อหลายช่องอ้างอิงบนใบลูก · ใบจับคู่field Comments []Comment
parent · child · orphanแฟ้มที่ถูกอ้าง · แฟ้มที่ชี้ไปหาแม่ · ลูกที่แม่หายไปแล้วบทความ · ใบคอมเมนต์ · ใบกำพร้าเหมือนกัน
counter cacheเก็บจำนวนลูกไว้บนแม่ ให้ Rails บวก/ลบให้ป้ายตัวเลขบนหน้าแฟ้มเขียนเอง
REST · resource · nestedURL เป็นคำนาม + verb บอกการกระทำ · ของหนึ่งชนิด · ซ้อนใต้แม่เซ็ตเมนูมาตรฐาน · แผนกย่อยในแผนกr.GET r.POST … เองทีละเส้น
mass assignment · strong parameters · allowlistใส่หลายช่องรวดเดียวจาก hash · ระบบกรอง params · รายชื่อของที่ผ่านได้ใบสั่งที่ลูกค้าเขียนเพิ่ม · พนักงานต้อนรับjson.Unmarshal · struct input แยก
400 · 422 · 500คำขอผิดรูป · ข้อมูลไม่ผ่านกฎ · server พังเอง (ควรเห็นเฉพาะบั๊กจริง)ใบสั่งอ่านไม่ออก · ของไม่ผ่านตรวจ · ครัวไฟไหม้c.JSON(400, …)
scaffold · generator · boilerplateสร้างทั้งแผนกในคำสั่งเดียว · คำสั่งตระกูล generate · โค้ดหน้าตาเดิม ๆ ที่ทุกแอปต้องมีชุดเปิดแผนกสำเร็จรูปไม่มี
authorization · business rule“คนนี้ทำได้ไหม” (Day 4) · กฎที่มาจากการทำงานของร้าน AI เดาไม่ได้ใครเข้าครัวได้ · กฎเจ้าของร้าน—

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

เฉพาะจุดที่พิมพ์ตามสไลด์แล้วพัง · ที่เหลือดูได้ในตารางท้ายแต่ละบท

หน้าสไลด์เขียนของจริงทำยังไง
5Post.new(title: "Hello", user_id: 1)ตอนยังไม่มีช่อง user_id ได้ ActiveModel::UnknownAttributeErrorตัด user_id ออกจนกว่าจะเตรียมตู้บท 107
22numericality: { greater_than: 0 } ที่ viewsviews เริ่มที่ 0 → บทความใหม่ทุกชิ้นได้ Views must be greater than 0greater_than_or_equal_to: 0
23 · 53status: :unprocessable_entityยังได้ 422 แต่ Rack ประกาศเลิกใช้ชื่อนี้:unprocessable_content
24self.slug ||= … โดยไม่บอกให้เพิ่มคอลัมน์ · NotifyFollowersJob.perform_laterundefined method 'slug' · uninitialized constant Post::NotifyFollowersJob (job คือ Day 3)migration AddSlugToPosts ก่อน · ใช้ Rails.logger แทน job
26@user.save! แล้ว rescue แค่ RecordNotUniqueอีเมลซ้ำแบบปกติได้ RecordInvalid ไม่มีใครรับ → หน้าแดงif @user.save + rescue (หัวข้อ 7)
31พิมพ์ q = Post.where(…) “ยังไม่มี SQL วิ่ง”console โชว์ SQL ทุกบรรทัด (loading for pp)ต่อ ; nil
41post.comments.create(body: "hi") · post.comments.clearUser must exist · NotNullViolationใส่ user: · อย่าใช้ clear กับ belongs_to ที่บังคับ
54self.slug ||= name.to_s.parameterize · has_many :posts ใน Categoryฟอร์มส่ง '' → Slug can't be blank · ยังไม่มี category_id → PG::UndefinedColumnif slug.blank? · add_reference … null: true ก่อน

17 · อ้างอิง