🧰 เวอร์ชันที่ใช้ตรวจ — 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… จะไม่ตรงกับในหน้านี้ — เป็นเรื่องปกติ
สารบัญ
ภาพร้านอาหาร — ส่วนที่ Day 2 เพิ่ม
3 ประโยคหลักของวัน
GORM ↔ Active Record · แปล SQL
คำสั่ง console ที่ใช้บ่อย
migration — แก้หน้าตาตาราง
validation — กฎตรวจแฟ้ม
4 ชั้นป้องกัน · validation คู่ constraint
callbacks · โค้ดควรอยู่ไหน
query — ใบเบิกของ
N+1 — เดินไปตู้ 101 รอบ
association · dependent
REST · strong params
scaffold · checklist ตรวจงาน AI
ตาราง error → แปลว่า → แก้ยังไง
ศัพท์ภาษาคน
ถ้าเปิดสไลด์ดูประกอบ
อ้างอิง
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 ของบท 27 106
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 Up 108
db/migrate/*แฟ้มใบสั่งช่างทุกใบตั้งแต่วันเปิดร้าน (ประวัติ) ไฟล์เรียงตามเลขเวลา migrations/*.sql108
schema.rbแปลนตู้ฉบับล่าสุด (ภาพถ่ายตอนนี้) db/schema.rb · Rails เขียนให้เองไม่มี 108
schema_migrationsสมุดเซ็นรับงานช่าง — ใบไหนทำไปแล้ว ตารางในฐานข้อมูล เก็บเลข 14 หลัก ตาราง goose_db_version 108
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 ของ GORM 112
relation ใบเบิกของที่ยังเขียนไม่เสร็จ — ยังไม่มีใครเดินไปตู้จนกว่าจะ “ยื่น” Post.where(published: true)GORM ไปตู้ตอนเจอ Find 113
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 Post 115
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 ไม่ได้อ่านจากโค้ด อ่านจาก log ORM ซ่อน 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 · GORM Ruby · Active Record SQL ที่ 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(…); nil console ยื่นใบเบิกเองเพื่อโชว์ผล (เห็น /* 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)
ของ ในร้านอาหาร Rails goose
ใบสั่งทุกใบ (ประวัติ) แฟ้มใบสั่งช่างตั้งแต่วันเปิดร้าน 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 + กฎ FK add_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 …" ใน change rollback ได้ This migration uses execute, which is not automatically reversible. เขียน up/down · ย้อนไม่ได้จริงก็ raise ActiveRecord::IrreversibleMigration, "เหตุผล"
ท่าอันตราย vs ท่าปลอดภัย — ถามทุกครั้ง: “แตะกี่แผ่น และระหว่างนั้นใครต้องรอ” (บท 109 · วัดบน 200,000 แถว)
อยากทำ ท่าตรง ๆ แตะกี่แผ่น · ใครรอ ท่าที่ปลอดภัยกว่า
สร้าง index add_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 blank self.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") ได้ nil normalizes :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 ไม่ใช่จากโค้ด
ดูที่ หน้าตาจริง อ่านว่า
console Post 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)2 SELECT "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)1 SELECT "posts"."id" AS t0_r0, … FROM "posts" LEFT OUTER JOIN "users" ON "users"."id" = "posts"."user_id"✅ ต้องกรองด้วยช่องของ users
Post.includes(:user)ปกติ 2 · มี where(users: …) เป็น 1 Rails เลือกให้ ✅ (สลับเป็น 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 เป็นของ Post comments.post_id (ตารางตัวเอง)comment.post
has_many“ฉันมี…ได้หลายตัว” Post มีหลาย Comment ไม่มีในตารางตัวเอง (อยู่ที่ comments) post.comments
has_one“ฉันมี…ได้ตัวเดียว” User มี Profile หนึ่งหน้า (แค่ภาพ ไม่ได้สร้าง) profiles.user_iduser.profile
has_many :throughหลายต่อหลาย ผ่านตารางกลางที่มี model Post ติดได้หลาย Tag · Tag อยู่ได้หลาย Post ตารางกลาง taggings มีทั้ง post_id และ tag_id post.tags
has_and_belongs_to_many (habtm)หลายต่อหลาย ตารางกลางเปล่า ไม่มี model เหมือนแถวบน (แค่รู้จัก) ตาราง posts_tags post.tags
# 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: :destroy Rails หยิบลูกทีละใบแล้ว destroy Comment Load + DELETE … WHERE "comments"."id" = 1 ทีละใบ + DELETE บทความ · ลูก 100 ใบ = 104 คำสั่ง✅ ทำงาน ตอนลูกหายมีงานต้องทำ (ลบรูปแนบ · แจ้งเตือน) db.Select("Comments").Delete(&post)
② dependent: :delete_all Rails ยิง 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_error Rails ห้ามทิ้ง 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 ถึงได้ประโยชน์
touch belongs_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 String 400 ParameterMissing
"post[]=a"post เป็นรายการ 500 NoMethodError 400 ParameterMissing
"title=x"ไม่มีกลุ่ม post เลย 400 ParameterMissing 400 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 index validation 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 ถูกไหม ลบแม่แล้วลูกหายตาม · ห้ามลบ · หรือค้างกำพร้า — ธุรกิจตอบ ไม่ใช่ AI 116
5 วน each แล้วแตะของที่ผูกกัน → มี includes ไหม ไม่มี = N+1 · เปิด log นับ query ก่อนรับงาน 114
6 หน้ารายการมี limit หรือการแบ่งหน้าไหม Model.all ใน index = ระเบิดเวลาที่รอวันข้อมูลโต113
7 migration นี้ แตะกี่แถว ระหว่างนั้นใครต้องรอ ตารางใหญ่ + lock = แอปค้างทั้งร้าน 109
8 ใครมีสิทธิ์ทำอันนี้ strong params ไม่ใช่ authorization 117 · Day 4
prompt ที่ดีบอกกฎของธุรกิจ และสิ่งที่ห้ามทำ ไปด้วย (ตัวอย่างจากภารกิจบท 118):
Ingredient ต้องมี name ห้ามว่าง และห้ามซ้ำ — เติมทั้ง validation ใน model และ constraint ใน migration ใหม่
(null: false + unique index) · หน้า index ให้เรียงตามชื่อและ limit 50 · อย่าแก้ migration เดิมที่รันไปแล้ว
ลำดับ Lab 2 (บท 119) — เติมของที่ scaffold ไม่ให้
ขั้น ทำ ผลที่เห็นจริง
1 scaffold Category แล้วกด Create ฟอร์มว่าง INSERT … VALUES ('', '', …) — ตู้รับแฟ้มว่าง
2 เติม validation + before_validation :fill_slug ["Name can't be blank", "Slug can't be blank", "Slug is invalid"]
3 migration change_column_null × 2 + unique index พังก่อน: column "slug" of relation "categories" contains null values → เก็บกวาดแฟ้มเสีย → ผ่าน
4 ลองประตูข้าง save(validate: false) อีกรอบ ตู้ปฏิเสธ: NotNullViolation · RecordNotUnique
5 add_reference :posts, :category, null: true + belongs_to :category, optional: true + restrict_with_errorหมวดที่มีบทความลบไม่ได้ · แก้ destroy! ใน controller เป็น if @category.destroy
6 Category.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`?ลืมตัว s validate → 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 ดิบใส่ model strong 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 ของ Tailwind bin/rails tailwindcss:build
15 · ศัพท์ภาษาคน
ศัพท์ ภาษาคน ในร้านอาหาร ใน Go/GORM
database · table · row · column ที่เก็บข้อมูล · ตาราง · แถว (record) · คอลัมน์ ตู้ · ลิ้นชัก · แฟ้มหนึ่งแผ่น · ช่องบนแฟ้ม เหมือนกัน
SQL · query ภาษาสั่ง database · คำสั่งหนึ่งประโยค = ไปตู้หนึ่งรอบ ภาษาของคนดูแลสต็อก · การเดินไปตู้ SQL ที่ GORM ยิง
NULL ช่องที่ไม่มีค่า · Ruby เห็นเป็น nil · ไม่ใช่ '' ช่องที่ไม่ได้เขียนอะไร —
ORM ตัวแปลงระหว่าง object ในโค้ดกับแถวในตาราง คนแปลภาษาให้คนดูแลสต็อก GORM
Active Record pattern object หนึ่งตัว = แถวหนึ่ง + รู้วิธี 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 drift DB แต่ละเครื่องค่อย ๆ ไม่ตรงกันโดยไม่มีใครรู้ ตู้แต่ละสาขาหน้าตาเพี้ยนกัน เจอในบท 31
production · deploy · downtime เว็บจริง · เอาโค้ดใหม่ขึ้น · ช่วงที่เว็บใช้ไม่ได้ ร้านจริง · ส่งของเข้าร้าน · ร้านปิด เหมือนกัน
lock · DDL DB กันไม่ให้คนอื่นแตะตารางชั่วคราว · คำสั่งเปลี่ยนโครงตู้ (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 · lifecycle method ที่ Rails เรียกให้เองตามจังหวะชีวิตของแฟ้ม ขั้นตอนอัตโนมัติก่อน/หลังเก็บ hook (BeforeSave)
side effect ผลที่ออกไปนอกตัวโค้ด (อีเมล · API) · rollback ย้อนไม่ได้ จดหมายที่ส่งออกไปแล้ว เหมือนกัน
service object · concern class ทำงานหลายขั้นหนึ่งอย่าง · 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 · nested URL เป็นคำนาม + 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 · ถ้าเปิดสไลด์ดูประกอบ
เฉพาะจุดที่พิมพ์ตามสไลด์แล้วพัง · ที่เหลือดูได้ในตารางท้ายแต่ละบท
หน้า สไลด์เขียน ของจริง ทำยังไง
5 Post.new(title: "Hello", user_id: 1)ตอนยังไม่มีช่อง user_id ได้ ActiveModel::UnknownAttributeError ตัด user_id ออกจนกว่าจะเตรียมตู้บท 107
22 numericality: { greater_than: 0 } ที่ viewsviews เริ่มที่ 0 → บทความใหม่ทุกชิ้นได้ Views must be greater than 0 greater_than_or_equal_to: 0
23 · 53 status: :unprocessable_entityยังได้ 422 แต่ Rack ประกาศเลิกใช้ชื่อนี้ :unprocessable_content
24 self.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
41 post.comments.create(body: "hi") · post.comments.clearUser must exist · NotNullViolationใส่ user: · อย่าใช้ clear กับ belongs_to ที่บังคับ
54 self.slug ||= name.to_s.parameterize · has_many :posts ใน Categoryฟอร์มส่ง '' → Slug can't be blank · ยังไม่มี category_id → PG::UndefinedColumn if slug.blank? · add_reference … null: true ก่อน
17 · อ้างอิง
← กลับไปบทเรียน 106–119 🏠 หน้าแรก