🔁 ทวนก่อนเริ่ม — Lab นี้ใช้คำพวกนี้ตลอด ถ้าข้อไหนไม่คุ้นให้กลับไปบทนั้นก่อน
- migration = ใบสั่งช่างมาต่อเติมตู้ ·
bin/rails db:migrate= ให้ช่างลงมือ (บท 108) - validation = คนดูแลสต็อกตรวจแฟ้มก่อนเก็บ ·
errors.full_messages= รายการเหตุผลที่ไม่รับ (บท 110) - constraint (เช่น
NOT NULL· unique index) = ตัวตู้ที่ออกแบบให้ใส่แฟ้มผิดแบบไม่ได้เลย (บท 111) - N+1 = เดินไปตู้ทีละแผ่นหลายสิบรอบ ·
includes= เขียนใบเบิกทีเดียวหยิบมาทั้งตะกร้า (บท 114) - scaffold = ชุดเปิดแผนกสำเร็จรูป ได้ model + migration + route + controller + view ในคำสั่งเดียว (บท 118)
🍜 ข้อมูลตัวอย่าง: ทำในโปรเจกต์ wongnok_blog ของคุณเอง ที่มีตาราง users / posts / comments และข้อมูลตัวอย่าง (seeds) ชุดเดียวกับ
บท 107 (ผู้ใช้ 3 คน · บทความ 6 ชิ้น · คอมเมนต์ 7 อัน) · หมวดอาหารเราจะสร้างกันเองในบทนี้
🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 17 (สไลด์ใช้ Ruby 4.0.6 · Rails 8.1.3 — เรื่องในบทนี้ไม่ต่างกัน)
ผลรันทุกก้อนในบทนี้รันจริงแล้วก็อปมา · ค่าที่เปลี่ยนทุกครั้ง เช่น เวลา ms · เลข id · เลขวันเวลาหน้าชื่อไฟล์ migration · PID
ของคุณจะไม่ตรงกับของผม — เป็นเรื่องปกติ · path ยาว ๆ ของโฟลเดอร์ในเครื่องผมย่อเป็น …
แผนที่ของ Lab: เปิดแผนกใหม่ แล้วเติมของที่ชุดสำเร็จรูปไม่ให้
ร้านเรามีบทความ 6 ชิ้นแล้ว แต่ยังไม่มีการแบ่งหมวด · วันนี้เราจะเปิดแผนกหมวดอาหาร เช่น "อาหารไทย" "Street Food"
แล้วให้บทความแต่ละชิ้นอยู่ในหมวดได้
แต่ละขั้นของ Lab ตรงกับเรื่องที่เรียนมาทั้งวัน:
ขั้นที่สำคัญที่สุดคือ ขั้น 2–4 — สไลด์หน้า 57 บอกว่าจุดประสงค์ของ Lab นี้คือได้เห็นว่า
"ถ้าใส่ NOT NULL (กฎของตู้ว่าช่องนี้ห้ามว่าง) ครบ การบันทึกแบบข้ามการตรวจจะพังที่ชั้น DB ไม่ใช่ชั้น model"
· ภาษาคน: ต่อให้ใครหลบคนดูแลสต็อกได้ ตัวตู้ก็ยังไม่ยอมรับแฟ้มเสีย
ขั้น 0 — เตรียมร้านก่อนเริ่ม
เปิด terminal สองแท็บในโฟลเดอร์ wongnok_blog · แท็บแรกเปิดร้าน แท็บที่สองไว้พิมพ์คำสั่ง (เหมือน Lab 1)
# แท็บที่ 1 — เปิดร้าน (ปล่อยค้างไว้ทั้ง Lab)
$ bin/rails server
# แท็บที่ 2 — เช็คว่างานเก่าถูกเก็บเข้า git หมดแล้ว (ไม่ขึ้นอะไรเลย = สะอาด)
$ git status --short
ทำไมต้องสะอาด: ระหว่าง Lab เราจะใช้ git diff ดูว่าแต่ละขั้นเปลี่ยนอะไร · ถ้ามีของค้างจากบทก่อนจะปนกันจนอ่านไม่ออก
· ถ้ายังมีของค้าง ให้ git add . && git commit -m "ก่อน Lab 2" ก่อน
⚠️ ถ้าเคยทำ บท 118 แล้วยังมี Category ค้างอยู่ (มีไฟล์ app/models/category.rb)
คำสั่ง scaffold ในขั้น 1 จะไม่ยอมทำ · ผลจริง:
The name 'Category' is either already used in your application or reserved by Ruby on Rails. Please choose an alternative or use --skip-collision-check or --force to skip this check and run this generator again.
แปลว่า "ชื่อ Category มีคนใช้แล้ว" · รื้อของเก่าออกก่อน ตามลำดับนี้:
① bin/rails db:migrate:status ถ้าบรรทัดล่างสุดคือ up … Create categories → สั่ง bin/rails db:rollback (ให้ช่างรื้อลิ้นชัก categories ออก)
② bin/rails destroy scaffold Category (ลบไฟล์ทั้งชุดที่ scaffold สร้าง)
· ต้อง ① ก่อน ② เพราะ ② ลบไฟล์ migration ทิ้งด้วย — ลบไฟล์ก่อนแล้ว Rails จะไม่รู้ว่าต้องรื้ออะไร (กับดักเดียวกับ บท 108)
③ รื้อเสร็จ git status --short จะขึ้นรายการไฟล์ที่ถูกลบ (D …) เต็มจอ · commit อีกรอบด้วยคำสั่งเดียวกับข้างบน
(git add . && git commit -m "รื้อ Category ของบท 118") ให้กลับมาสะอาดก่อนเริ่มขั้น 1 — ไม่งั้น git diff ของขั้นต่อ ๆ ไปจะปนกับไฟล์ที่ลบ
ขั้น 1 — scaffold: ขอชุดเปิดแผนกสำเร็จรูปหนึ่งชุด
สั่งคำสั่งเดียวกับสไลด์ · ขอแผนก Category ที่แฟ้มแต่ละแผ่นมีสองช่อง: name (ชื่อหมวด) กับ slug
(ชื่อสั้นภาษาอังกฤษสำหรับใส่ใน URL เช่น street-food — เคยเจอใน บท 112) · ผลจริง:
$ bin/rails generate scaffold Category name:string slug:string
invoke active_record
create db/migrate/20261104120835_create_categories.rb
create app/models/category.rb
invoke test_unit
create test/models/category_test.rb
create test/fixtures/categories.yml
invoke resource_route
route resources :categories
invoke scaffold_controller
create app/controllers/categories_controller.rb
invoke tailwindcss
create app/views/categories
create app/views/categories/index.html.erb
create app/views/categories/edit.html.erb
create app/views/categories/show.html.erb
create app/views/categories/new.html.erb
create app/views/categories/_form.html.erb
create app/views/categories/_category.html.erb
invoke resource_route
invoke test_unit
create test/controllers/categories_controller_test.rb
invoke helper
create app/helpers/categories_helper.rb
invoke test_unit
invoke jbuilder
create app/views/categories/index.json.jbuilder
create app/views/categories/show.json.jbuilder
create app/views/categories/_category.json.jbuilder
ชุดเดียวกับที่อ่านทีละไฟล์ไปแล้วในบท 118 (คราวนี้ไม่มีช่อง description) · ไม่ต้องอ่านทุกบรรทัด — ทวนแค่ว่าแต่ละกลุ่มคือใครในร้าน:
db/migrate/…_create_categories.rb— ใบสั่งช่างต่อลิ้นชักใหม่ชื่อ categories (เลขหน้าชื่อไฟล์ของคุณจะต่าง)app/models/category.rb— คนดูแลสต็อกของแผนกนี้route resources :categories— พนักงานต้อนรับได้รายการใหม่ 8 เส้น (บท 101)categories_controller.rb+app/views/categories/*.html.erb— หัวหน้าเชฟกับคนจัดจานของแผนกtest/…·*.json.jbuilder— ไฟล์เทส กับแบบจัดจานเป็นข้อมูล JSON · วันนี้ยังไม่แตะ (เทสอยู่ Day 4) ·tailwindcssโผล่เพราะเราสร้างแอปด้วย--css=tailwind
ต่อลิ้นชักจริง (ผลจริง):
$ bin/rails db:migrate
== 20261104120835 CreateCategories: migrating =================================
-- create_table(:categories)
-> 0.0091s
== 20261104120835 CreateCategories: migrated (0.0091s) ========================
เปิดดูใบสั่งช่างกับคนดูแลสต็อกที่ได้มา — นี่คือไฟล์จริงสองไฟล์ ไม่ได้ตัดอะไรออก:
# db/migrate/20261104120835_create_categories.rb
class CreateCategories < ActiveRecord::Migration[8.1]
def change
create_table :categories do |t|
t.string :name
t.string :slug
t.timestamps
end
end
end
# app/models/category.rb
class Category < ApplicationRecord
end
t.string :name·t.string :slug— ต่อช่องสองช่องบนแฟ้ม · ไม่มีnull: false= ช่องนี้ว่างก็ได้t.timestamps— ต่อช่องcreated_atกับupdated_at(เวลาสร้าง · เวลาแก้ล่าสุด) ให้เอง (บท 108)- ไม่มี index ที่
slug(index = ป้ายแท็บเรียงตัวอักษรในลิ้นชัก · บท 109) และไม่มีกฎ "ห้ามซ้ำ" = slug ซ้ำกันกี่แผ่นตู้ก็รับ class Category < ApplicationRecordแล้วendเลย = คนดูแลสต็อกที่ไม่ตรวจอะไรเลย
สำนักงานใหญ่ส่งชุดเปิดแผนกมาให้ครบ: ลิ้นชักเปล่าหนึ่งลิ้นชัก · พนักงานต้อนรับที่รู้เส้นทาง · เชฟ · คนจัดจาน · แบบฟอร์มสั่งของ
· เปิดร้านได้ทันทีในห้านาที
แต่ชุดนี้ถูกออกแบบให้ใช้ได้กับทุกสาขาทั่วประเทศ — มันไม่รู้ว่าร้านเรามีกฎว่า "หมวดต้องมีชื่อ" "ชื่อ URL ห้ามซ้ำ" "ห้ามทิ้งหมวดที่ยังมีบทความ"
กฎพวกนั้นเราต้องติดเอง
ที่ไม่เหมือนกัน: ชุดจากแฟรนไชส์จริงมักมีคู่มือบอกว่า "ต้องตั้งค่าอะไรเพิ่ม" · scaffold ไม่บอกอะไรเลย มันสร้างไฟล์เสร็จแล้วเงียบ — โค้ดที่ได้ดูครบ ใช้ได้จริง จนเราไม่รู้ตัวว่าขาด (เหมือนโค้ดจาก AI ที่บทนี้จะพูดถึงตอนท้าย)
เทียบกับ Go: ฝั่ง Go ไม่มีคำสั่งแบบนี้ — ใน Gin คุณเขียน handler ทีละตัว เขียน struct เอง และเขียน migration ของ goose เอง (บท 31) · ข้อดีคือทุกบรรทัดคุณตั้งใจเขียน · ข้อเสียคือช้า · scaffold กลับกัน: เร็วมาก แต่ทุกบรรทัดที่ได้คือ "ค่าตั้งต้นที่ไม่มีกฎ" ที่เราต้องอ่านเองว่าขาดอะไร
- scaffold
- คำสั่งที่สร้าง model · migration · route · controller · view ของหนึ่งเรื่องให้ครบในคำสั่งเดียว (แปลตรงตัวว่า "นั่งร้าน" — โครงชั่วคราวที่ต้องเติมต่อ) · พิมพ์บอก AI ได้ว่า "this was generated by rails scaffold, list what validations and DB constraints are missing" แล้วให้มันไล่ให้
- generator ·
bin/rails destroy - generator = คำสั่งตระกูล
bin/rails generate …ที่เขียนไฟล์ให้ ·bin/rails destroy …คือคำสั่งคู่กันที่ลบไฟล์ชุดเดียวกันทิ้ง · ถาม senior ได้ว่า "ทีมเราใช้ scaffold ได้ไหม หรือให้เขียน controller เอง"
ลองใช้แผนกใหม่ในเบราว์เซอร์
ถ้าเปิดร้านค้างไว้ในแท็บแรก (ขั้น 0) เปิดได้เลย · ถ้ายังไม่ได้เปิด ผลตอนเปิดร้านจะหน้าตาแบบนี้ (ผลจริง · PID ของคุณจะต่าง):
$ bin/rails server
=> Booting Puma
=> Rails 8.1.4 application starting in development
=> Run `bin/rails server --help` for more startup options
Puma starting in single mode...
* Puma version: 8.0.2 ("Into the Arena")
* Ruby version: ruby 4.0.9 (2026-10-23 revision 3e93b23afc) +PRISM [arm64-darwin25]
* Min threads: 3
* Max threads: 3
* Environment: development
* PID: 55512
* Listening on http://127.0.0.1:3000
* Listening on http://[::1]:3000
Use Ctrl-C to stop
เปิด http://localhost:3000/categories จะเห็นหัวข้อ Categories กับปุ่ม New category แล้วทำตามนี้ทีละข้อ:
- กด New category · กรอก Name =
Street Food· Slug =street-food· กด Create Category → เห็นCategory was successfully created. - สร้างอีกอัน: Name =
Dessert· Slug =dessert - กด Edit ที่ Dessert · เปลี่ยน Name เป็น
ขนมหวาน· กด Update → แก้ได้ - เปิดหน้า Dessert (กด Show) แล้วกด Destroy this category · ยืนยัน → หายไปจากรายการ
- สุดท้าย: กด New category แล้วกด Create เลย ไม่กรอกอะไรสักช่อง
ข้อ 5 บันทึกผ่าน ได้ข้อความ successfully created เหมือนกัน · ในหน้ารายการมีหมวดหนึ่งอันที่ Name กับ Slug ว่างเปล่า · ดูในสมุดบันทึกของแท็บที่เปิดร้าน จะเห็นบรรทัดนี้ (ผลจริง):
Category Create (3.4ms) INSERT INTO "categories" ("name", "slug", "created_at", "updated_at") VALUES ('', '', '2026-11-04 12:10:36.852251', '2026-11-04 12:10:36.852251') RETURNING "id" /*action='create',application='WongnokBlog',controller='categories'*/
แปลเป็นภาษาคน: "ใส่แฟ้มใหม่ลงลิ้นชัก categories โดยช่อง name เป็น '' (ข้อความว่าง) ช่อง slug เป็น '' แล้วบอกเลข id ที่ได้กลับมา"
INSERT INTO "categories" (…)= ใส่แฟ้มใหม่ลงลิ้นชักนี้ โดยกรอกช่องตามรายชื่อในวงเล็บ ·VALUES (…)= ค่าของแต่ละช่องตามลำดับ (บท 107)RETURNING "id"= "ใส่เสร็จแล้วบอกเลขที่แฟ้มกลับมาด้วย" ·/*action='create',…*/ท้ายบรรทัด = ป้ายที่ Rails แปะบอกว่ามาจาก controller ไหน ตู้ไม่สนใจ ข้ามได้ (บท 113)
สังเกตว่าฟอร์มส่งช่องที่ไม่ได้กรอกมาเป็น '' ไม่ใช่ nil — จำจุดนี้ไว้ จะกลับมาเจอในขั้น 2
✅ จบขั้น 1: scaffold ให้แผนกที่ใช้ได้จริงครบ 4 อย่าง (สร้าง · ดู · แก้ · ลบ) ภายในสองคำสั่ง — และรับแฟ้มเปล่าโดยไม่บ่นสักคำ
· ตอนนี้ในลิ้นชักมีหมวด 2 แผ่น: Street Food กับแผ่นว่าง · commit ไว้ก่อน: git add . && git commit -m "scaffold Category"
ขั้น 2 — validation: ให้คนดูแลสต็อกเริ่มตรวจแฟ้ม
เปิด app/models/category.rb แล้วแทนที่ทั้งไฟล์ด้วยโค้ดนี้ · เป็นกฎจากสไลด์หน้า 54 ที่แก้จุดพังไปหนึ่งจุด (ดูกล่องเตือนใต้โค้ด):
# app/models/category.rb
class Category < ApplicationRecord
before_validation :fill_slug
validates :name, presence: true, length: { maximum: 80 }
validates :slug, presence: true, uniqueness: true,
format: { with: /\A[a-z0-9-]+\z/ }
private
def fill_slug
self.slug = name.to_s.parameterize if slug.blank?
end
end
before_validation :fill_slug— "ก่อนตรวจแฟ้มทุกครั้ง ให้เรียก methodfill_slug" (callback · บท 112)validates :name, presence: true, length: { maximum: 80 }— ชื่อห้ามว่าง และยาวไม่เกิน 80 ตัวอักษร (บท 110)validates :slug, presence: true, uniqueness: true, …— slug ห้ามว่าง · ห้ามซ้ำกับแผ่นอื่น · และต้องหน้าตาตรงแบบformat: { with: /\A[a-z0-9-]+\z/ }— ของในเครื่องหมาย/…/คือ "แบบของตัวอักษร" (regex) · แบบที่ยอมรับ:\A= ต้นข้อความ ·[a-z0-9-]+= ตัวอังกฤษเล็ก ตัวเลข หรือขีด อย่างน้อยหนึ่งตัว ·\z= ท้ายข้อความ → "ทั้งข้อความต้องมีแค่ตัวพวกนี้" ·Street-Food(มีตัวใหญ่) หรือขนมไม่ผ่านprivate— method ที่อยู่ใต้คำนี้ใช้กันเองภายในแฟ้ม คนนอกเรียกไม่ได้ (บท 102)self.slug = name.to_s.parameterize if slug.blank?— "ถ้า slug ว่าง ให้เอาชื่อมาแปลงเป็น slug" · อ่านทีละท่อน:self.slug == เขียนลงช่อง slug ของแฟ้มแผ่นนี้ (ต้องมีself.· บท 112) ·to_s= แปลงเป็นข้อความ (ถ้า name เป็นnilจะได้''แทนการพัง) ·parameterizeแปลง"Noodle Soup"เป็น"noodle-soup"·blank?= ว่างไหม (nilหรือ''หรือมีแต่ช่องว่าง ได้trueหมด)
⚠️ สไลด์หน้า 54 เขียน self.slug ||= name.to_s.parameterize — พิมพ์ตามแล้วฟอร์มในเบราว์เซอร์ใช้ไม่ได้
· ||= เติมให้เฉพาะตอน slug เป็น nil แต่ฟอร์มส่งช่องว่างมาเป็น '' (เห็นในขั้น 1) จึงไม่เติม
· ผมลองกรอก Name = Rice Bowl เว้น Slug ว่าง กับเวอร์ชันของสไลด์ ได้หน้าฟอร์มเดิมพร้อมกล่องแดง (ผลจริง):
2 errors prohibited this category from being saved:
Slug can't be blank
Slug is invalid
(ผลนี้คือตอนที่ลบแผ่นว่างของขั้น 1 ไปแล้ว — ถ้าแผ่นว่างยังอยู่ จะได้ Slug has already been taken เพิ่มอีกบรรทัด เพราะ slug '' ซ้ำกับแผ่นนั้น)
ใช้ if slug.blank? แบบในโค้ดด้านบนแทน — blank? ถือว่าทั้ง nil และ '' คือว่าง
ลองกฎใหม่ใน console
เปิดครัวทดลอง bin/rails console ในแท็บที่สอง · เริ่มจากถามแผ่นว่างที่สร้างไว้ในขั้น 1 ก่อน (ผลจริง):
wongnok-blog(dev):001> old = Category.find_by(name: "")
Category Load (1.8ms) SELECT "categories".* FROM "categories" WHERE "categories"."name" = '' LIMIT 1 /*application='WongnokBlog'*/
=>
#<Category:0x0000000126793610
...
wongnok-blog(dev):002> old.valid?
Category Exists? (1.5ms) SELECT 1 AS one FROM "categories" WHERE "categories"."slug" = '' AND "categories"."id" != 3 LIMIT 1 /*application='WongnokBlog'*/
=> false
wongnok-blog(dev):003> old.errors.full_messages
=> ["Name can't be blank", "Slug can't be blank", "Slug is invalid"]
wongnok-blog(dev):004> old.destroy
TRANSACTION (1.6ms) BEGIN /*application='WongnokBlog'*/
Category Destroy (3.7ms) DELETE FROM "categories" WHERE "categories"."id" = 3 /*application='WongnokBlog'*/
TRANSACTION (1.7ms) COMMIT /*application='WongnokBlog'*/
=>
#<Category:0x0000000126793610
id: 3,
name: "",
slug: "",
created_at: "2026-11-04 12:10:36.852251000 +0000",
updated_at: "2026-11-04 12:10:36.852251000 +0000">
- บรรทัด 001 —
SELECT "categories".* … WHERE "categories"."name" = '' LIMIT 1= "ขอแฟ้มทุกช่องจากลิ้นชัก categories เฉพาะแผ่นที่ชื่อเป็นข้อความว่าง เอาแผ่นแรกแผ่นเดียว" ·=> #<Category…ตามด้วย...คือ console ย่อผลให้สั้นเพราะเป็นการเก็บค่าลงตัวแปร (ไม่ได้พัง) - บรรทัด 002 —
Category Exists?คือกฎuniquenessทำงาน: "มีแผ่นอื่น ("id" != 3= id ไม่ใช่ 3) ที่ slug เป็น''ไหม" ·SELECT 1 AS one … LIMIT 1= ไม่ต้องหยิบแฟ้มมา แค่ตอบว่า "มีสักแผ่นไหม" (บท 113) ·valid?ตอบfalse= แฟ้มนี้ผิดกฎ - บรรทัด 003 — ผิดสามข้อ · แฟ้มนี้นอนอยู่ในตู้ตั้งแต่ขั้น 1 — กฎใหม่ไม่ได้ย้อนไปตรวจแฟ้มเก่าให้ จะตรวจก็ต่อเมื่อมีคนสั่ง save หรือ valid?
- บรรทัด 004 — ทิ้งแผ่นเสียนี้ (
DELETE FROM … WHERE id = 3= "ดึงแฟ้มเลขที่ 3 ออกจากลิ้นชัก") · เลข id ของคุณอาจต่าง · บรรทัดTRANSACTION … BEGIN/COMMITที่ห่ออยู่ อธิบายในก้อนถัดไป
ต่อในครัวเดิม — ลองให้คนดูแลสต็อกตรวจแฟ้มใหม่หลายแบบ (ผลจริง):
wongnok-blog(dev):005> c = Category.new
=>
#<Category:0x00000001276a99d8
...
wongnok-blog(dev):006> c.valid?
Category Exists? (2.5ms) SELECT 1 AS one FROM "categories" WHERE "categories"."slug" = '' LIMIT 1 /*application='WongnokBlog'*/
=> false
wongnok-blog(dev):007> c.errors.full_messages
=> ["Name can't be blank", "Slug can't be blank", "Slug is invalid"]
wongnok-blog(dev):008> noodle = Category.create(name: "Noodle Soup")
TRANSACTION (2.0ms) BEGIN /*application='WongnokBlog'*/
Category Exists? (3.9ms) SELECT 1 AS one FROM "categories" WHERE "categories"."slug" = 'noodle-soup' LIMIT 1 /*application='WongnokBlog'*/
Category Create (1.5ms) INSERT INTO "categories" ("name", "slug", "created_at", "updated_at") VALUES ('Noodle Soup', 'noodle-soup', '2026-11-04 12:10:59.846018', '2026-11-04 12:10:59.846018') RETURNING "id" /*application='WongnokBlog'*/
TRANSACTION (1.9ms) COMMIT /*application='WongnokBlog'*/
=>
#<Category:0x00000001276a8d58
...
wongnok-blog(dev):009> noodle.slug
=> "noodle-soup"
wongnok-blog(dev):010> thai = Category.create(name: "อาหารไทย")
TRANSACTION (2.1ms) BEGIN /*application='WongnokBlog'*/
Category Exists? (3.8ms) SELECT 1 AS one FROM "categories" WHERE "categories"."slug" = '' LIMIT 1 /*application='WongnokBlog'*/
TRANSACTION (0.8ms) ROLLBACK /*application='WongnokBlog'*/
=>
#<Category:0x00000001276a7458
...
wongnok-blog(dev):011> thai.errors.full_messages
=> ["Slug can't be blank", "Slug is invalid"]
- บรรทัด 008–009 — ไม่ได้ส่ง slug มา · callback เติมให้ก่อนตรวจ · ใน
INSERTช่อง slug เป็น'noodle-soup'แล้ว ·BEGIN…COMMIT= เปิดงานหนึ่งชุด แล้วยืนยันว่าลงตู้จริง (transaction · บท 111) - บรรทัด 010–011 — ชื่อภาษาไทยแปลงเป็น slug ได้ข้อความว่าง (
parameterizeเก็บไว้แค่ตัวอังกฤษ ตัวเลข ขีด และขีดล่าง_— เจอแล้วใน บท 112) · คราวนี้กฎpresenceจับได้ ·ROLLBACK= "ยกเลิก ไม่มีอะไรลงตู้" · หมวดภาษาไทยจึงต้องกรอก slug เอง
wongnok-blog(dev):012> thai = Category.create(name: "อาหารไทย", slug: "thai-food")
TRANSACTION (2.2ms) BEGIN /*application='WongnokBlog'*/
Category Exists? (4.3ms) SELECT 1 AS one FROM "categories" WHERE "categories"."slug" = 'thai-food' LIMIT 1 /*application='WongnokBlog'*/
Category Create (1.5ms) INSERT INTO "categories" ("name", "slug", "created_at", "updated_at") VALUES ('อาหารไทย', 'thai-food', '2026-11-04 12:11:01.069190', '2026-11-04 12:11:01.069190') RETURNING "id" /*application='WongnokBlog'*/
TRANSACTION (2.3ms) COMMIT /*application='WongnokBlog'*/
=>
#<Category:0x00000001276a6558
...
wongnok-blog(dev):013> thai.persisted?
=> true
wongnok-blog(dev):014> bad = Category.create(name: "Dessert", slug: "Dessert!!")
TRANSACTION (2.1ms) BEGIN /*application='WongnokBlog'*/
Category Exists? (9.5ms) SELECT 1 AS one FROM "categories" WHERE "categories"."slug" = 'Dessert!!' LIMIT 1 /*application='WongnokBlog'*/
TRANSACTION (1.6ms) ROLLBACK /*application='WongnokBlog'*/
=>
#<Category:0x00000001276a4d98
...
wongnok-blog(dev):015> bad.errors.full_messages
=> ["Slug is invalid"]
wongnok-blog(dev):016> dup = Category.create(name: "ก๋วยเตี๋ยว", slug: "noodle-soup")
TRANSACTION (2.3ms) BEGIN /*application='WongnokBlog'*/
Category Exists? (4.4ms) SELECT 1 AS one FROM "categories" WHERE "categories"."slug" = 'noodle-soup' LIMIT 1 /*application='WongnokBlog'*/
TRANSACTION (1.5ms) ROLLBACK /*application='WongnokBlog'*/
=>
#<Category:0x00000001276a3fd8
...
wongnok-blog(dev):017> dup.errors.full_messages
=> ["Slug has already been taken"]
- บรรทัด 012–013 — กรอก slug เองแล้วผ่าน ·
persisted?เป็นtrue= อยู่ในตู้แล้ว (บท 107) - บรรทัด 014–015 —
Dessert!!มีตัวใหญ่กับเครื่องหมาย!→ ไม่ตรงแบบ - บรรทัด 016–017 — slug
noodle-soupมีแล้ว →has already been taken= "มีคนใช้แล้ว" · สังเกตว่ากฎนี้ทำงานด้วยการถามตู้ก่อน (Category Exists?) แล้วค่อยตัดสิน — จำไว้ ขั้น 4 จะเห็นว่าทำไมสำคัญ
ประตูข้าง: บันทึกโดยไม่ให้ตรวจ
save(validate: false) = "เก็บแฟ้มนี้ลงตู้เลย ไม่ต้องผ่านคนดูแลสต็อก" (บท 111 เรียกว่าประตูข้าง)
· โค้ดจริงที่ข้ามการตรวจแบบนี้มีหลายทาง เช่น สคริปต์ย้ายข้อมูล หรือ method อย่าง update_column · นี่คือคำสั่งในสไลด์หน้า 57:
wongnok-blog(dev):018> x = Category.new(name: "x")
wongnok-blog(dev):019> x.save(validate: false)
wongnok-blog(dev):020> x.slug
บรรทัด 019 ได้ true หรือ false? และบรรทัด 020 slug เป็นอะไร — "x" (callback เติมให้) หรืออย่างอื่น?
ผลจริง:
wongnok-blog(dev):018> x = Category.new(name: "x")
=>
#<Category:0x00000001276a3218
...
wongnok-blog(dev):019> x.save(validate: false)
TRANSACTION (1.9ms) BEGIN /*application='WongnokBlog'*/
Category Create (3.2ms) INSERT INTO "categories" ("name", "slug", "created_at", "updated_at") VALUES ('x', NULL, '2026-11-04 12:11:03.216417', '2026-11-04 12:11:03.216417') RETURNING "id" /*application='WongnokBlog'*/
TRANSACTION (2.1ms) COMMIT /*application='WongnokBlog'*/
=> true
wongnok-blog(dev):020> x.slug
=> nil
เข้าตู้ได้ (true) และ slug เป็น nil · ใน INSERT ช่อง slug เป็น NULL (คำของ SQL ที่แปลว่า "ไม่มีค่า" — ตรงกับ nil ของ Ruby)
ทำไม callback ไม่เติม slug ให้: before_validation แปลว่า "ก่อนการตรวจ" — พอสั่งไม่ตรวจ จุดแวะนี้ก็ถูกข้ามไปด้วย
· ตอนนี้ในตู้มีแฟ้มที่ผิดกฎของเราหนึ่งแผ่น และคนดูแลสต็อกไม่รู้เรื่องเลย
สุดท้ายลองฟอร์มจริง: ในเบราว์เซอร์กด New category · Name = Rice Bowl · เว้น Slug ว่าง · กด Create
→ ได้ Category was successfully created. และ Slug เป็น rice-bowl (นี่คือจุดที่เวอร์ชัน ||= ของสไลด์พัง)
· ส่วนฟอร์มว่างทั้งสองช่องแบบขั้น 1 ตอนนี้จะได้กล่องแดงกลับมาแทน
✅ จบขั้น 2: ฟอร์มกับ console ตรวจแฟ้มได้แล้ว — แต่ประตูข้างยังเปิดอยู่ · ในลิ้นชักตอนนี้มี Street Food · Noodle Soup · อาหารไทย · Rice Bowl · และแผ่น x ที่ slug ว่าง
· อย่าเพิ่งลบแผ่น x ขั้นต่อไปต้องใช้
ขั้น 3 — constraint: สั่งช่างปรับตัวตู้ (แล้วช่างไม่ยอม)
ตอนนี้กฎอยู่ที่คนดูแลสต็อก (model) อย่างเดียว · เราจะให้ตัวตู้ (database) บังคับกฎเดียวกันด้วย: ช่อง name กับ slug ห้ามว่าง และ slug ห้ามซ้ำ · ของพวกนี้เปลี่ยนรูปทรงตู้ จึงต้องทำผ่านใบสั่งช่าง (migration) · สร้างใบเปล่าก่อน (ผลจริง):
$ bin/rails generate migration AddConstraintsToCategories
invoke active_record
create db/migrate/20261104121132_add_constraints_to_categories.rb
เปิดไฟล์ที่ได้ (ชื่อไฟล์ของคุณขึ้นต้นด้วยเลขต่างจากผม) แล้วแทนที่ทั้งไฟล์ด้วยโค้ดนี้ · ไม่ต้องใส่บรรทัดคอมเมนต์ชื่อไฟล์ — เลขบรรทัดใน error ด้านล่างนับจากโค้ดแบบนี้:
class AddConstraintsToCategories < ActiveRecord::Migration[8.1]
def change
change_column_null :categories, :name, false
change_column_null :categories, :slug, false
add_index :categories, :slug, unique: true
end
end
- บรรทัด 3
change_column_null :categories, :name, false— "ช่อง name ของลิ้นชัก categories: ว่างได้ไหม? →falseไม่ได้" = ใส่NOT NULL - บรรทัด 4 — แบบเดียวกันกับช่อง slug
- บรรทัด 5
add_index :categories, :slug, unique: true— ติดป้ายแท็บเรียงตาม slug (บท 109) และunique: true= "ป้ายเดียวกันห้ามมีสองแผ่น" · ตัวตู้จะตรวจเองทุกครั้งที่มีแฟ้มเข้า
สั่งช่างลงมือ (ผลจริง):
$ bin/rails db:migrate
== 20261104121132 AddConstraintsToCategories: migrating =======================
-- change_column_null(:categories, :name, false)
-> 0.0032s
-- change_column_null(:categories, :slug, false)
bin/rails aborted!
StandardError: An error has occurred, this and all later migrations canceled: (StandardError)
PG::NotNullViolation: ERROR: column "slug" of relation "categories" contains null values
…/db/migrate/20261104121132_add_constraints_to_categories.rb:4:in 'AddConstraintsToCategories#change'
…/bin/rails:4:in '<main>'
Caused by:
ActiveRecord::NotNullViolation: PG::NotNullViolation: ERROR: column "slug" of relation "categories" contains null values (ActiveRecord::NotNullViolation)
…/db/migrate/20261104121132_add_constraints_to_categories.rb:4:in 'AddConstraintsToCategories#change'
…/bin/rails:4:in '<main>'
Caused by:
PG::NotNullViolation: ERROR: column "slug" of relation "categories" contains null values (PG::NotNullViolation)
…/db/migrate/20261104121132_add_constraints_to_categories.rb:4:in 'AddConstraintsToCategories#change'
…/bin/rails:4:in '<main>'
Tasks: TOP => db:migrate
(See full trace by running task with --trace)
อ่านทีละส่วน (ข้อความยาว แต่ใจความอยู่สามจุด):
-- change_column_null(:categories, :name, false)-> 0.0032s— งานแรกเสร็จ · งานที่สองไม่มีเวลาต่อท้าย = พังตรงนี้ ·this and all later migrations canceled= "ใบนี้และใบที่ต่อคิวอยู่ถูกยกเลิกหมด"PG::NotNullViolation: … column "slug" of relation "categories" contains null values— ข้อความจาก PostgreSQL (PG::นำหน้า = มาจากตัวตู้): "ช่อง slug ของลิ้นชัก categories มีแฟ้มที่ไม่มีค่าอยู่" (relation = ชื่อเรียกตารางของ PostgreSQL)…add_constraints_to_categories.rb:4— บรรทัด 4 ของใบสั่งช่าง คือบรรทัด slug พอดี · บรรทัด…/bin/rails:4ข้างล่างคือตัวคำสั่งที่เราพิมพ์ ข้ามได้ · ส่วนCaused by:คือข้อความเดียวกันที่ถูกห่อซ้ำหลายชั้น อ่านชั้นแรกพอ
ตัวการคือแผ่น x จากประตูข้างในขั้น 2 · แล้วงานแรก (ช่อง name) ที่เสร็จไปแล้วล่ะ? ดูสถานะของใบสั่งช่าง (ผลจริง):
$ bin/rails db:migrate:status
database: w119
Status Migration ID Migration Name
--------------------------------------------------
up 20261104113601 Create posts
up 20261104113603 Add published and views to posts
up 20261104113604 Create users
up 20261104113606 Add user to posts
up 20261104113607 Create comments
up 20261104120835 Create categories
down 20261104121132 Add constraints to categories
ใบสุดท้ายเป็น down = ยังไม่ได้ทำ · งานแรกที่เสร็จไปแล้วก็ถูกยกเลิกด้วย
เพราะ Rails ห่อทั้งใบสั่งช่างไว้ใน transaction (ทำทั้งหมด หรือไม่ทำเลย · บท 111) — ตู้กลับไปเหมือนก่อนสั่งทุกอย่าง
(บรรทัด database: w119 คือฐานข้อมูลทดลองของผม — ของคุณจะเป็นชื่อแบบ wongnok_blog_development · บรรทัด up ของคุณอาจมากกว่านี้ถ้าทำภารกิจของบทก่อน ๆ ไว้)
เราสั่งช่างว่า "เปลี่ยนลิ้นชักให้รับเฉพาะแฟ้มที่มีป้ายชื่อ" · ช่างเปิดลิ้นชักดูก่อน เจอแฟ้ม x ที่ไม่มีป้าย · ช่างตอบว่า "ผมทำไม่ได้ ถ้าเปลี่ยนแล้วแฟ้มนี้จะอยู่ในลิ้นชักไม่ได้ — ไปจัดการแฟ้มนี้ก่อน" แล้วเก็บเครื่องมือกลับโดยรื้องานที่ทำไปครึ่งหนึ่งคืนด้วย
ที่ไม่เหมือนกัน: ช่างจริงอาจบอกว่าเจอแฟ้มเสียกี่แผ่น แผ่นไหน · PostgreSQL บอกแค่ว่า "มี" ไม่บอกว่าแผ่นไหน — เราต้องไปค้นเอง (ทำต่อด้านล่าง) · และบนร้านจริงที่ตารางใหญ่ การค้นแฟ้มเสียทั้งลิ้นชักอาจใช้เวลานาน (บท 109)
หาแฟ้มที่ผิดกฎใหม่ แล้วจัดการก่อน (ผลจริง · เปิด console ใหม่ได้):
wongnok-blog(dev):001> Category.columns_hash["name"].null
=> true
wongnok-blog(dev):002> Category.where(slug: nil).pluck(:id, :name)
Category Pluck (1.9ms) SELECT "categories"."id", "categories"."name" FROM "categories" WHERE "categories"."slug" IS NULL /*application='WongnokBlog'*/
=> [[6, "x"]]
wongnok-blog(dev):003> Category.find_by(name: "x").destroy
Category Load (1.9ms) SELECT "categories".* FROM "categories" WHERE "categories"."name" = 'x' LIMIT 1 /*application='WongnokBlog'*/
TRANSACTION (0.8ms) BEGIN /*application='WongnokBlog'*/
Category Destroy (2.0ms) DELETE FROM "categories" WHERE "categories"."id" = 6 /*application='WongnokBlog'*/
TRANSACTION (1.5ms) COMMIT /*application='WongnokBlog'*/
=>
#<Category:0x0000000125eeb300
id: 6,
name: "x",
slug: nil,
created_at: "2026-11-04 12:11:03.216417000 +0000",
updated_at: "2026-11-04 12:11:03.216417000 +0000">
wongnok-blog(dev):004> Category.where(slug: [nil, ""]).count
Category Count (1.3ms) SELECT COUNT(*) FROM "categories" WHERE ("categories"."slug" = '' OR "categories"."slug" IS NULL) /*application='WongnokBlog'*/
=> 0
- บรรทัด 001 —
columns_hash["name"].null= "ช่อง name ยังว่างได้ไหม" →trueยืนยันว่างานแรกถูกยกเลิกจริง (บท 107) - บรรทัด 002 —
WHERE "categories"."slug" IS NULL= "เฉพาะแผ่นที่ช่อง slug ไม่มีค่า" (SQL ใช้IS NULLไม่ใช่= NULL) ·pluck(:id, :name)ขอแค่สองช่อง (บท 113) → เจอแผ่นเดียว - บรรทัด 003 — ทิ้งแผ่น x · ร้านจริงอย่าลบง่าย ๆ แฟ้มพวกนี้อาจเป็นข้อมูลของลูกค้า — ปกติจะเติมค่าให้ถูก (เช่น ตั้ง slug ให้) แทน · ที่นี่ลบได้เพราะเป็นของทดลอง
- บรรทัด 004 — เช็คซ้ำว่าไม่มีแผ่นที่ slug ว่างเหลือ ·
COUNT(*)= "นับว่ามีกี่แผ่น" ·OR= "หรือ" · ส่ง array[nil, ""]ให้whereได้ความหมายว่า "เป็นค่าใดค่าหนึ่งในนี้"
สั่งช่างรอบสอง (ผลจริง):
$ bin/rails db:migrate
== 20261104121132 AddConstraintsToCategories: migrating =======================
-- change_column_null(:categories, :name, false)
-> 0.0023s
-- change_column_null(:categories, :slug, false)
-> 0.0010s
-- add_index(:categories, :slug, {unique: true})
-> 0.0023s
== 20261104121132 AddConstraintsToCategories: migrated (0.0057s) ==============
แปลนตู้ฉบับล่าสุดเปลี่ยนตาม · git diff db/schema.rb (ผลจริง · ตัดเฉพาะส่วนของ categories):
create_table "categories", force: :cascade do |t|
- t.string "name"
- t.string "slug"
+ t.string "name", null: false
+ t.string "slug", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.index ["slug"], name: "index_categories_on_slug", unique: true
end
บรรทัด - คือของเดิม · + คือของใหม่ · ตอนนี้แปลนตู้เขียนชัดว่าสองช่องห้ามว่าง และมีป้ายแท็บ slug ที่ห้ามซ้ำ
(ใครเปิดไฟล์นี้ก็รู้กฎของตู้โดยไม่ต้องอ่าน model)
เทียบกับ Go: ฝั่ง GORM คุณเขียนกฎของตู้ไว้ใน tag ของ struct แล้ว AutoMigrate สร้างให้ (บท 27) — ท่อนหนึ่งจาก struct (ไม่ใช่ไฟล์เต็ม):
type Category struct {
ID uint
Name string `gorm:"not null"`
Slug string `gorm:"not null;uniqueIndex"`
}
และใน goose คุณเขียน SQL ตรง ๆ ว่า slug TEXT NOT NULL UNIQUE (บท 31)
· Rails ใช้คำต่างกัน แต่ปลายทางคือกฎเดียวกันในตัวตู้ PostgreSQL
- NOT NULL constraint
- กฎของตู้ว่า "ช่องนี้ต้องมีค่า" · ใส่ด้วย
null: falseตอนสร้าง หรือchange_column_null …, falseทีหลัง · ระวัง:''(ข้อความว่าง) ไม่ใช่NULL— ตัวตู้รับ''ได้ ต้องพึ่งpresenceใน model กันอีกชั้น · ถาม senior ได้ว่า "คอลัมน์นี้ควร NOT NULL ไหม และมีแถวเก่าที่เป็น null อยู่หรือเปล่า" - unique index
- ป้ายแท็บที่ตัวตู้รับประกันว่าไม่มีค่าซ้ำ · คู่แท้ของ
validates … uniqueness: true· พิมพ์บอก AI ได้ว่า "add a unique index on categories.slug to back the uniqueness validation" - data cleanup · backfill
- งานจัดการแฟ้มเก่าให้ถูกกฎใหม่ก่อนเปลี่ยนตู้ · cleanup = เก็บกวาด/ลบของเสีย · backfill = เติมค่าที่ขาดให้แถวเก่า · ถาม senior ได้ว่า "ก่อนรัน migration นี้บน production ต้อง backfill แถวเก่าก่อนไหม"
ขั้น 4 — ทำพังโดยตั้งใจอีกรอบ: คราวนี้ตัวตู้ปฏิเสธ
คำสั่งเดิมจากสไลด์หน้า 57 ทุกตัวอักษร — แต่ตอนนี้ตู้มีกฎแล้ว · ผลจริง (ตรง … ผมตัดออกราว 20 บรรทัด: ข้อความเดิมซ้ำอีกรอบ กับรายการไฟล์ภายใน Rails ที่ขึ้นต้นด้วย from — ไม่ต้องอ่าน):
wongnok-blog(dev):001> c = Category.new(name: "x")
=>
#<Category:0x00000001245398f8
...
wongnok-blog(dev):002> c.save(validate: false)
TRANSACTION (1.4ms) BEGIN /*application='WongnokBlog'*/
Category Create (4.0ms) INSERT INTO "categories" ("name", "slug", "created_at", "updated_at") VALUES ('x', NULL, '2026-11-04 12:11:56.611483', '2026-11-04 12:11:56.611483') RETURNING "id" /*application='WongnokBlog'*/
TRANSACTION (1.0ms) ROLLBACK /*application='WongnokBlog'*/
(wongnok-blog):2:in '<main>': PG::NotNullViolation: ERROR: null value in column "slug" of relation "categories" violates not-null constraint (ActiveRecord::NotNullViolation)
DETAIL: Failing row contains (8, x, null, 2026-11-04 12:11:56.611483, 2026-11-04 12:11:56.611483).
…
wongnok-blog(dev):003> c.persisted?
=> false
INSERT … VALUES ('x', NULL, …)— Rails ยังส่งคำสั่งไปเหมือนขั้น 2 ทุกอย่าง (model ไม่ได้ห้าม เพราะเราสั่งไม่ตรวจ)ROLLBACKแล้วตามด้วยข้อความแดง — ตัวตู้ปฏิเสธเอง: "ค่าว่างในช่อง slug ผิดกฎห้ามว่าง" ·(wongnok-blog):2:in '<main>'หน้าข้อความ = "พังที่คำสั่งบรรทัด 002 ของ console"(ActiveRecord::NotNullViolation)ในวงเล็บท้ายบรรทัด = ชื่อ error ฝั่ง Rails ที่เอาไปใช้กับrescueได้ (บท 96)DETAIL: Failing row contains (8, x, null, …)— แฟ้มที่ถูกปฏิเสธหน้าตาแบบนี้ · เลข 8 คือ id ที่ตู้จองไว้แล้วไม่ได้ใช้ (ของคุณจะต่าง)- บรรทัด 003 —
false= ไม่มีอะไรลงตู้
ลองประตูข้างกับกฎห้ามซ้ำด้วย — แอบใส่ slug ที่ Street Food ใช้อยู่แล้ว (ผลจริง · ตัดบรรทัด from … เหมือนเดิม):
wongnok-blog(dev):004> twin = Category.new(name: "Street Food 2", slug: "street-food")
=>
#<Category:0x0000000124110b50
...
wongnok-blog(dev):005> twin.save(validate: false)
TRANSACTION (1.4ms) BEGIN /*application='WongnokBlog'*/
Category Create (3.6ms) INSERT INTO "categories" ("name", "slug", "created_at", "updated_at") VALUES ('Street Food 2', 'street-food', '2026-11-04 12:11:57.541466', '2026-11-04 12:11:57.541466') RETURNING "id" /*application='WongnokBlog'*/
TRANSACTION (1.0ms) ROLLBACK /*application='WongnokBlog'*/
(wongnok-blog):5:in '<main>': PG::UniqueViolation: ERROR: duplicate key value violates unique constraint "index_categories_on_slug" (ActiveRecord::RecordNotUnique)
DETAIL: Key (slug)=(street-food) already exists.
…
wongnok-blog(dev):006> Category.count
Category Count (1.2ms) SELECT COUNT(*) FROM "categories" /*application='WongnokBlog'*/
=> 4
duplicate key value violates unique constraint "index_categories_on_slug"— "ค่านี้ซ้ำ ผิดกฎของป้ายแท็บindex_categories_on_slug" (ชื่อป้ายตรงกับใน schema.rb)DETAIL: Key (slug)=(street-food) already exists.— บอกตรง ๆ ว่าค่าไหนซ้ำCategory.countได้ 4 = Street Food · Noodle Soup · อาหารไทย · Rice Bowl เท่าเดิม ไม่มีแผ่นแอบเข้า
เทียบกับ Go: ข้อความ duplicate key value violates unique constraint คุณเคยเห็นแล้วตอนเขียน integration test ใน บท 29
— ตัวอักษรเดียวกันเป๊ะ เพราะคนพูดคือ PostgreSQL ไม่ใช่ GORM หรือ Rails · framework แค่ห่อชื่อ error ของตัวเองไว้รอบ ๆ
สรุปสองรอบของประตูข้างเทียบกัน:
| คำสั่ง | ก่อนมี constraint (ขั้น 2) | หลังมี constraint (ขั้น 4) |
|---|---|---|
Category.new(name: "x").save(validate: false) | true · แฟ้ม slug ว่างเข้าตู้ · ไม่มีใครรู้ | ActiveRecord::NotNullViolation · ไม่มีอะไรลงตู้ |
| slug ซ้ำผ่านประตูข้าง | (ไม่ได้ลอง แต่ไม่มีอะไรห้าม — ตู้ยังไม่มีป้ายแท็บ) | ActiveRecord::RecordNotUnique · ไม่มีอะไรลงตู้ |
| ใครเป็นคนห้าม | ไม่มี — คนดูแลสต็อกถูกสั่งให้หลบ | ตัวตู้ (PostgreSQL) — สั่งหลบไม่ได้ |
validation คือคนดูแลสต็อกยืนหน้าตู้ ตรวจแฟ้มทุกแผ่นที่เดินผ่านประตูหน้า · ตรวจเก่ง บอกเหตุผลเป็นภาษาคนได้ ("ไม่มีชื่อเรื่อง ไม่รับ")
แต่ถ้ามีใครเดินเข้าทางหลังร้าน (save(validate: false) · สคริปต์ย้ายข้อมูล · คนเปิด database ตรง ๆ) เขาก็ไม่เห็น
constraint คือตัวลิ้นชักที่ทำร่องไว้พอดีกับแฟ้มที่มีป้ายเท่านั้น · แฟ้มไม่มีป้ายใส่ไม่ลงทางกายภาพ ไม่ว่าจะมาทางไหน
· ร้านที่ดีต้องมีทั้งสองอย่าง: คนตรวจไว้บอกลูกค้าดี ๆ · ลิ้นชักไว้รับประกัน
ที่ไม่เหมือนกัน: ลิ้นชักจริงปฏิเสธแบบเงียบ ๆ (แค่ใส่ไม่ลง) · constraint ปฏิเสธแบบระเบิด — ถ้าโค้ดไม่ได้เตรียม rescue ไว้
ผู้ใช้จะเห็นหน้าแดง error 500 (หน้า "ระบบมีปัญหา" ของ server) แทนข้อความดี ๆ (วิธีรับมือดูใน บท 111) · นี่คือเหตุผลที่ยังต้องมี validation ไว้ข้างหน้าเสมอ
- constraint violation
- "ทำผิดกฎของตู้" · ใน Rails โผล่เป็น
ActiveRecord::NotNullViolation(ช่องว่างที่ห้ามว่าง) ·ActiveRecord::RecordNotUnique(ค่าซ้ำ) ·ActiveRecord::InvalidForeignKey(อ้างถึงแฟ้มที่ไม่มีอยู่ · บท 116) · ถาม senior ได้ว่า "ถ้าโดน RecordNotUnique ตรงนี้ ทีมเราจะ rescue แล้วตอบผู้ใช้ว่าอะไร" - defense in depth
- แนวคิด "กันหลายชั้น เผื่อชั้นหนึ่งหลุด" — validation ชั้นหนึ่ง constraint อีกชั้น · พิมพ์บอก AI ได้ว่า "for every validation you add, also add the matching DB constraint in the migration"
✅ จบขั้น 4 — นี่คือหัวใจของ Lab: คำสั่งเดียวกันทุกตัวอักษร ก่อนเติม constraint เข้าตู้ได้ หลังเติมพังที่ชั้น DB
· commit ไว้: git add . && git commit -m "Category validations + constraints"
ภารกิจ: ขั้น 5 — ผูกหมวดกับบทความ แล้วห้ามลบหมวดที่ยังมีบทความ
ตอนนี้หมวดยังลอยอยู่เฉย ๆ · เราอยากให้ "หมวดหนึ่งมีหลายบทความ" (has_many · บท 115)
และห้ามลบหมวดที่ยังมีบทความอยู่ (dependent: :restrict_with_error · บท 116)
· ขั้นนี้ทำเองก่อน แล้วค่อยกดดูเฉลย
เรื่องเดียวที่ต้องคิดก่อนเริ่ม: ช่อง "เป็นของหมวดเลขที่ …" ต้องอยู่บนใบลูก (บทความ) ไม่ใช่ใบแม่ (หมวด) — เหมือน user_id ที่อยู่บน posts
· และบทความเดิม 6 ชิ้นในตู้ยังไม่มีหมวด · ถ้าสั่งช่างว่า "ช่องหมวดห้ามว่าง" จะเจอเรื่องเดียวกับขั้น 3 ทันที
🎯 ภารกิจ Lab 2 · ขั้น 5 (~10 นาที)
- สร้างใบสั่งช่าง:
bin/rails generate migration AddCategoryToPosts category:references· เปิดไฟล์ดูว่า generator เขียนอะไรมาให้ · ลองbin/rails db:migrateตามที่ได้มาเลย แล้วอ่าน error - แก้ใบสั่งช่างให้บทความเดิมไม่พัง แล้ว migrate ใหม่จนผ่าน
- ใน
app/models/category.rbเพิ่มhas_many :posts, dependent: :restrict_with_errorไว้บนสุดของ class - ใน
app/models/post.rbเพิ่มbelongs_to :category· แล้วใน console ลองPost.find_by(title: "ต้มยำกุ้งน้ำข้น").update(views: 121)— ผ่านไหม? ถ้าไม่ผ่าน แก้ให้ผ่าน - ใน console ให้ ต้มยำกุ้งน้ำข้น กับ ส้มตำปูปลาร้า อยู่หมวด อาหารไทย · แล้วลอง
destroyหมวดอาหารไทย · ดูerrors.full_messages - ในเบราว์เซอร์ กด Destroy ที่หมวด อาหารไทย · เห็นอะไร? แก้ action
destroyใน controller ให้ได้ข้อความแทนหน้าแดง
ควรเห็น: ข้อ 5 destroy ได้ false และข้อความ Cannot delete record because dependent posts exist · หมวดยังอยู่ · ข้อ 6 หลังแก้ กลับมาหน้ารายการพร้อมข้อความเดียวกัน
ถ้าเจอ error แปลว่า…
PG::NotNullViolation: ERROR: column "category_id" of relation "posts" contains null values→ ใบสั่งช่างยังมีnull: falseแต่บทความเดิมไม่มีหมวด (ข้อ 1–2)["Category must exist"]ตอน update บทความ →belongs_toบังคับว่าต้องมีหมวดเสมอ (บท 115) แต่บทความเดิมไม่มี (ข้อ 4)- หน้าแดง
ActiveRecord::RecordNotDestroyed·Failed to destroy Category with id=…→ controller ของ scaffold ใช้destroy!·!= "ถ้าทำไม่ได้ให้ระเบิด" (บท 110) (ข้อ 6) PG::UndefinedColumn: ERROR: column posts.category_id does not exist→ "ลิ้นชัก posts ไม่มีช่อง category_id" · เพิ่มhas_manyใน model แล้ว แต่ migration ยังไม่ผ่าน · กลับไปข้อ 2
ข้อ 1 — ใบสั่งช่างที่ generator ให้มา (ไฟล์จริง ไม่ได้แก้):
class AddCategoryToPosts < ActiveRecord::Migration[8.1]
def change
add_reference :posts, :category, null: false, foreign_key: true
end
end
รันตามนั้นเลย (ผลจริง):
$ bin/rails db:migrate
== 20261104121206 AddCategoryToPosts: migrating ===============================
-- add_reference(:posts, :category, {null: false, foreign_key: true})
bin/rails aborted!
StandardError: An error has occurred, this and all later migrations canceled: (StandardError)
PG::NotNullViolation: ERROR: column "category_id" of relation "posts" contains null values
…/db/migrate/20261104121206_add_category_to_posts.rb:3:in 'AddCategoryToPosts#change'
…/bin/rails:4:in '<main>'
…
ช่องใหม่ category_id ถูกต่อให้บทความเดิม 6 แผ่นด้วยค่าว่าง แล้ว null: false บอกว่าห้ามว่าง → ช่างไม่ยอม (เรื่องเดียวกับขั้น 3)
ข้อ 2 — เปลี่ยน null: false เป็น null: true (บรรทัด 3):
add_reference :posts, :category, null: true, foreign_key: true
$ bin/rails db:migrate
== 20261104121206 AddCategoryToPosts: migrating ===============================
-- add_reference(:posts, :category, {null: true, foreign_key: true})
-> 0.0063s
== 20261104121206 AddCategoryToPosts: migrated (0.0064s) ======================
null: true = "บทความยังไม่มีหมวดก็ได้" · foreign_key: true ยังอยู่ = ถ้ามีหมวด ต้องเป็นหมวดที่มีอยู่จริง
· ใน schema.rb จะได้ช่อง t.bigint "category_id" ป้ายแท็บ index_posts_on_category_id และ add_foreign_key "posts", "categories"
· นี่คือท่า expand ของ บท 109: เปิดช่องใหม่ให้ว่างได้ก่อน → ค่อยเติมหมวดให้บทความทุกชิ้น → แล้วค่อยขันเป็น NOT NULL ทีหลังถ้าธุรกิจต้องการ
ข้อ 3–4 — สอง model (ไฟล์เต็ม):
# app/models/category.rb
class Category < ApplicationRecord
has_many :posts, dependent: :restrict_with_error
before_validation :fill_slug
validates :name, presence: true, length: { maximum: 80 }
validates :slug, presence: true, uniqueness: true,
format: { with: /\A[a-z0-9-]+\z/ }
private
def fill_slug
self.slug = name.to_s.parameterize if slug.blank?
end
end
# app/models/post.rb
class Post < ApplicationRecord
belongs_to :user
belongs_to :category, optional: true
has_many :comments
end
(post.rb ของคุณอาจมีบรรทัดอื่นเพิ่มจากภารกิจบทก่อน ๆ — เพิ่มแค่บรรทัด belongs_to :category)
· ก่อนใส่ optional: true ผลจริงเป็นแบบนี้ — บทความเดิมทุกชิ้นแก้อะไรไม่ได้เลย แม้แต่เพิ่มยอดวิว:
wongnok-blog(dev):001> post = Post.find_by(title: "ต้มยำกุ้งน้ำข้น")
Post Load (1.5ms) SELECT "posts".* FROM "posts" WHERE "posts"."title" = 'ต้มยำกุ้งน้ำข้น' LIMIT 1 /*application='WongnokBlog'*/
=>
#<Post:0x0000000125bc0f40
...
wongnok-blog(dev):002> post.update(views: 121)
TRANSACTION (1.5ms) BEGIN /*application='WongnokBlog'*/
TRANSACTION (1.0ms) ROLLBACK /*application='WongnokBlog'*/
=> false
wongnok-blog(dev):003> post.errors.full_messages
=> ["Category must exist"]
optional: true = "บทความไม่มีหมวดก็ได้" · ปิดกฎ "ต้องมีเจ้าของ" ที่ belongs_to ใส่ให้เอง
· คู่มือทางการเตือนว่าเปิด optional แล้วควรมี foreign key ที่ DB คู่กัน — ซึ่งเรามีแล้วจากข้อ 2
ข้อ 5 — ผลจริง (หลังใส่ optional: true · ถ้าเปิด console ค้างไว้ พิมพ์ reload! ก่อน):
wongnok-blog(dev):001> thai = Category.find_by(slug: "thai-food")
Category Load (2.4ms) SELECT "categories".* FROM "categories" WHERE "categories"."slug" = 'thai-food' LIMIT 1 /*application='WongnokBlog'*/
=>
#<Category:0x0000000127611200
...
wongnok-blog(dev):002> Post.find_by(title: "ต้มยำกุ้งน้ำข้น").update(category: thai)
Post Load (1.5ms) SELECT "posts".* FROM "posts" WHERE "posts"."title" = 'ต้มยำกุ้งน้ำข้น' LIMIT 1 /*application='WongnokBlog'*/
TRANSACTION (1.7ms) BEGIN /*application='WongnokBlog'*/
Post Update (3.8ms) UPDATE "posts" SET "updated_at" = '2026-11-04 12:26:06.012675', "category_id" = 5 WHERE "posts"."id" = 1 /*application='WongnokBlog'*/
TRANSACTION (1.8ms) COMMIT /*application='WongnokBlog'*/
=> true
wongnok-blog(dev):003> thai.posts << Post.find_by(title: "ส้มตำปูปลาร้า")
Post Load (2.2ms) SELECT "posts".* FROM "posts" WHERE "posts"."title" = 'ส้มตำปูปลาร้า' LIMIT 1 /*application='WongnokBlog'*/
TRANSACTION (1.7ms) BEGIN /*application='WongnokBlog'*/
Post Update (3.4ms) UPDATE "posts" SET "updated_at" = '2026-11-04 12:26:06.310574', "category_id" = 5 WHERE "posts"."id" = 2 /*application='WongnokBlog'*/
TRANSACTION (1.0ms) COMMIT /*application='WongnokBlog'*/
Post Load (0.7ms) SELECT "posts".* FROM "posts" WHERE "posts"."category_id" = 5 /* loading for pp */ LIMIT 11 /*application='WongnokBlog'*/
=>
[#<Post:0x00000001276be888
id: 1,
title: "ต้มยำกุ้งน้ำข้น",
…
category_id: 5>,
#<Post:0x00000001276be748
id: 2,
title: "ส้มตำปูปลาร้า",
…
category_id: 5>]
wongnok-blog(dev):004> thai.posts.count
Post Count (3.1ms) SELECT COUNT(*) FROM "posts" WHERE "posts"."category_id" = 5 /*application='WongnokBlog'*/
=> 2
(บรรทัด 003 ผมตัดช่องกลางของบทความสองชิ้นออกเป็น …) · UPDATE … SET "category_id" = 5 WHERE "posts"."id" = 1 = "แก้แฟ้มบทความเลขที่ 1 ให้ช่องหมวดเป็น 5"
· << = "ใส่บทความนี้เข้าหมวด" แล้ว UPDATE ให้ทันที (บท 115)
· บรรทัด loading for pp คือ console ไปหยิบรายการมาโชว์บนจอเฉย ๆ · จากนั้นลองทิ้งหมวด ใน console ใหม่ (ผลจริง):
wongnok-blog(dev):001> thai = Category.find_by(slug: "thai-food")
Category Load (1.3ms) SELECT "categories".* FROM "categories" WHERE "categories"."slug" = 'thai-food' LIMIT 1 /*application='WongnokBlog'*/
=>
#<Category:0x00000001259e35b0
...
wongnok-blog(dev):002> thai.destroy
TRANSACTION (0.8ms) BEGIN /*application='WongnokBlog'*/
Post Exists? (0.7ms) SELECT 1 AS one FROM "posts" WHERE "posts"."category_id" = 5 LIMIT 1 /*application='WongnokBlog'*/
TRANSACTION (0.5ms) ROLLBACK /*application='WongnokBlog'*/
=> false
wongnok-blog(dev):003> thai.errors.full_messages
=> ["Cannot delete record because dependent posts exist"]
ก่อนทิ้ง Rails ถามตู้ก่อน: "มีบทความไหนที่ช่องหมวดเป็น 5 ไหม" (Post Exists?) · มี → ROLLBACK ไม่ทิ้ง · คืน false พร้อมเหตุผล
· ลองกับหมวด Rice Bowl ที่ไม่มีบทความ จะเห็น Post Exists? ตามด้วย DELETE FROM "categories" แล้ว COMMIT — ทิ้งได้ปกติ
ข้อ 6 — กด Destroy ในเบราว์เซอร์ ได้หน้าแดง (ผลจริง · ส่วนหัวของหน้า):
ActiveRecord::RecordNotDestroyed
in CategoriesController#destroy
Failed to destroy Category with id=5
เพราะ scaffold เขียน @category.destroy! · ! = "ถ้าทิ้งไม่ได้ให้ระเบิด" · แก้ action destroy ใน app/controllers/categories_controller.rb เป็น:
# DELETE /categories/1 or /categories/1.json
def destroy
if @category.destroy
redirect_to categories_path, notice: "Category was successfully destroyed.", status: :see_other
else
redirect_to categories_path, notice: @category.errors.full_messages.to_sentence, status: :see_other
end
end
if @category.destroy— ไม่มี!→ ทิ้งไม่ได้ก็แค่ได้falseไม่ระเบิดnotice: …to_sentence—to_sentenceต่อรายการข้อความเป็นประโยคเดียว · ฝากไปโชว์หน้าถัดไปครั้งเดียว (flash · บท 102) · ปกติข้อความ error ใช้alert:แต่หน้า index ที่ scaffold ให้มาโชว์แค่notice— ใช้ไปก่อน Day 3 ค่อยจัดหน้าตาstatus: :see_other— เลข 303 แบบที่ scaffold ใช้อยู่แล้ว · ทำให้เบราว์เซอร์เปิดหน้ารายการด้วย GET- เวอร์ชันนี้ตัดส่วนตอบ JSON ของ scaffold ออกเพื่อให้สั้น — ถ้าแอปของคุณต้องตอบ JSON ด้วย ให้คง
respond_to(ส่วนที่แยกว่าจะตอบเป็นหน้าเว็บหรือ JSON) ไว้
กด Destroy อีกครั้ง → กลับมาหน้ารายการ มีข้อความสีเขียวด้านบนว่า Cannot delete record because dependent posts exist (ผลจริง) · หมวดยังอยู่
- nullable foreign key ·
optional: true - ช่องอ้างอิงที่ว่างได้ (ยังไม่มีเจ้าของ) แต่ถ้ามีค่าต้องชี้ไปของที่มีจริง · ใช้ตอนเพิ่มความสัมพันธ์ให้ตารางที่มีข้อมูลเก่าอยู่แล้ว · ถาม senior ได้ว่า "category_id ควรเป็น NOT NULL ในที่สุดไหม ถ้าใช่ จะ backfill บทความเก่าเมื่อไหร่"
restrict_with_errorกับrestrict_with_exception- สองแบบของ "ห้ามทิ้งถ้ายังมีลูก" ·
_with_error= คืนfalse+ ใส่เหตุผลใน errors (เหมาะกับหน้าเว็บ) ·_with_exception= ระเบิด error · พิมพ์บอก AI ได้ว่า "use dependent: :restrict_with_error and handle a false return from destroy in the controller, don't use destroy!"
ขั้น 6 (ของแถม) — หน้ารายการ: เรียง · จำกัดจำนวน · หยิบทีเดียวทั้งตะกร้า
scaffold ให้ @categories = Category.all = "เอาทุกแผ่น ไม่เรียง ไม่จำกัด" · ร้านเรามี 4 หมวดไม่เป็นไร แต่ถ้าวันหนึ่งมี 5,000 หมวดก็จะโหลดทั้งหมดในหน้าเดียว
· และเราอยากโชว์ว่าแต่ละหมวดมีกี่บทความ
เปิด app/views/categories/_category.html.erb (แบบจัดจานของหมวดหนึ่งแผ่น) · ไฟล์ที่ scaffold ให้มามี 10 บรรทัด
· เพิ่มกล่อง Posts สี่บรรทัดต่อจากกล่องของ Slug ให้ทั้งไฟล์เป็นแบบนี้ (ไม่มีบรรทัดคอมเมนต์ชื่อไฟล์ในไฟล์จริง):
<div id="<%= dom_id category %>" class="w-full sm:w-auto my-5 space-y-5">
<div>
<strong class="block font-medium mb-1">Name:</strong>
<%= category.name %>
</div>
<div>
<strong class="block font-medium mb-1">Slug:</strong>
<%= category.slug %>
</div>
<div> <%# ← เพิ่ม %>
<strong class="block font-medium mb-1">Posts:</strong>
<%= category.posts.size %>
</div>
</div>
- บรรทัด 10–13 คือของที่เพิ่ม · ไม่ต้องพิมพ์
<%# ← เพิ่ม %>(ใส่ไว้ชี้ให้ดูเฉย ๆ) class="block font-medium mb-1"= คำสั่งแต่งหน้าตาของ Tailwind · ก็อปจากกล่อง Slug ข้างบนมา จะได้หน้าตาเหมือนกัน<%= category.posts.size %>= "พิมพ์จำนวนบทความของหมวดนี้" (size· บท 113)- ไฟล์นี้เป็น partial (ชิ้นส่วนหน้าเว็บที่ใช้ซ้ำ · บท 118) ถูกเรียกหนึ่งครั้งต่อหนึ่งหมวด — หน้า index วนทีละหมวดแล้วสั่ง
render category
# controller ยังเป็นของ scaffold
def index
@categories = Category.all
end
ในตู้มีหมวด 3 แผ่น: Street Food · Noodle Soup · อาหารไทย (ถ้าคุณไม่ได้ลองลบ Rice Bowl ในเฉลยภารกิจข้อ 5 จะมี 4)
· เปิด /categories หนึ่งครั้ง สมุดบันทึกจะมีบรรทัด Post Count (นับบทความ) กี่บรรทัด?
ผลจริงจากแท็บที่เปิดร้าน (ตอนรันของผมเหลือ 3 หมวด · ตัดบรรทัดที่ขึ้นต้นด้วย Rendering / Rendered ออก):
Started GET "/categories" for ::1 at 2026-11-04 19:47:24 +0700
Processing by CategoriesController#index as HTML
Category Exists? (1.7ms) SELECT 1 AS one FROM "categories" LIMIT 1 /*action='index',application='WongnokBlog',controller='categories'*/
↳ app/views/categories/index.html.erb:15
Category Load (1.0ms) SELECT "categories".* FROM "categories" /*action='index',application='WongnokBlog',controller='categories'*/
↳ app/views/categories/index.html.erb:16
Post Count (1.1ms) SELECT COUNT(*) FROM "posts" WHERE "posts"."category_id" = 1 /*action='index',application='WongnokBlog',controller='categories'*/
↳ app/views/categories/_category.html.erb:13
Post Count (0.8ms) SELECT COUNT(*) FROM "posts" WHERE "posts"."category_id" = 4 /*action='index',application='WongnokBlog',controller='categories'*/
↳ app/views/categories/_category.html.erb:13
Post Count (0.6ms) SELECT COUNT(*) FROM "posts" WHERE "posts"."category_id" = 5 /*action='index',application='WongnokBlog',controller='categories'*/
↳ app/views/categories/_category.html.erb:13
Completed 200 OK in 14ms (Views: 8.6ms | ActiveRecord: 5.2ms (5 queries, 0 cached) | GC: 5.6ms)
Post Count3 บรรทัด = หนึ่งบรรทัดต่อหนึ่งหมวด · ทั้งหน้าไปตู้5 queries(ดูบรรทัด Completed)Category Exists?— มาจากif @categories.any?ในหน้า index: "มีสักแผ่นไหม" ·Category Load— หยิบหมวดทั้งหมด↳ …_category.html.erb:13— บอกว่าบรรทัดไหนในโค้ดสั่งให้ไปตู้ · (เลขคลาดได้หนึ่งบรรทัดเพราะวิธีที่ ERB แปลงไฟล์ — ของผมsizeอยู่บรรทัด 12)- นี่คือ N+1 ตัวเล็ก ๆ: 1 ครั้งหยิบหมวด + N ครั้งนับบทความ · 50 หมวดจะเป็น 52 ครั้ง · ดูจากโค้ดไม่เห็นเลย เห็นจาก log เท่านั้น (บท 114)
แก้ action index ใน app/controllers/categories_controller.rb
(สไลด์หน้า 54 ตั้งชื่อ order(:name) ว่า alphabetical ไว้ใน model ก่อน — ผลเหมือนกัน ที่นี่เขียนตรง ๆ บรรทัดเดียวให้อ่านง่าย):
def index
@categories = Category.order(:name).includes(:posts).limit(50)
end
order(:name)— เรียงตามชื่อ (ORDER BY) ·limit(50)— เอาไม่เกิน 50 แผ่น (LIMIT) · บท 113includes(:posts)— "หยิบบทความของทุกหมวดมาทีเดียวทั้งตะกร้า" (บท 114)
เปิด /categories ใหม่ (ผลจริง · ตัดบรรทัด Rendering / Rendered ออก):
Started GET "/categories" for ::1 at 2026-11-04 19:47:25 +0700
Processing by CategoriesController#index as HTML
Category Exists? (1.3ms) SELECT 1 AS one FROM "categories" LIMIT 1 /*action='index',application='WongnokBlog',controller='categories'*/
↳ app/views/categories/index.html.erb:15
Category Load (1.2ms) SELECT "categories".* FROM "categories" ORDER BY "categories"."name" ASC LIMIT 50 /*action='index',application='WongnokBlog',controller='categories'*/
↳ app/views/categories/index.html.erb:16
Post Load (1.0ms) SELECT "posts".* FROM "posts" WHERE "posts"."category_id" IN (4, 1, 5) /*action='index',application='WongnokBlog',controller='categories'*/
↳ app/views/categories/index.html.erb:16
Completed 200 OK in 25ms (Views: 8.8ms | ActiveRecord: 13.6ms (3 queries, 0 cached) | GC: 1.1ms)
ORDER BY "categories"."name" ASC LIMIT 50— "เรียงตามชื่อจากน้อยไปมาก เอาไม่เกิน 50 แผ่น"WHERE "posts"."category_id" IN (4, 1, 5)— "ขอบทความทุกชิ้นที่ช่องหมวดเป็น 4 หรือ 1 หรือ 5" · ไปตู้ครั้งเดียว ได้บทความของทุกหมวด ·Post Countหายไปหมด เพราะของอยู่ในมือแล้วsizeนับเองได้3 queries— ต่อให้มี 50 หมวดก็ยัง 3 · เวลา ms รอบนี้มากกว่ารอบก่อน เพราะข้อมูลน้อยมากและเวลาแกว่งทุกรอบ — ตัวเลขที่ต้องดูคือจำนวนครั้ง
สไลด์หน้า 58 ขอ pagination (แบ่งหน้า: หน้า 1 แสดง 1–50 หน้า 2 แสดง 51–100) · Rails ไม่มีระบบแบ่งหน้าสำเร็จรูปในตัว ปกติทีมจะใช้ gem เพิ่ม
บทนี้จึงใช้ limit(50) แทน — กันไม่ให้หน้าระเบิดได้ แต่ยังกดไปหน้าถัดไปไม่ได้
เขียน prompt ให้ AI ทำทั้งหมดนี้ได้ในรอบเดียว
หัวข้อนี้มาจากโจทย์ข้อสุดท้ายของสไลด์ (Quiz 7) · ไม่มีอะไรต้องรัน — เป็นการเอาทุกขั้นที่ทำมาเขียนเป็นคำสั่งงาน
สไลด์หน้า 58 ยก prompt ที่คนส่วนใหญ่เขียนมาให้ดู:
"ช่วยสร้าง model Category ที่มีชื่อกับ slug ให้หน่อย"
สไลด์อ้างว่าผลที่ได้มักเป็น model ที่มี validates presence สองบรรทัด กับ migration ที่ไม่มี constraint อะไรเลย
(ผมไม่ได้ลองส่งให้ AI ในบทนี้ — แต่ขั้น 1 เราเห็นแล้วว่า scaffold ให้น้อยกว่านั้นอีก)
· เหตุผลง่าย ๆ: prompt บอกแค่รูปร่าง (มีช่องอะไร) ไม่ได้บอกกฎของร้าน · ทั้ง scaffold และ AI ไม่รู้กฎที่เราไม่บอก
โทรไปบอกสำนักงานใหญ่ว่า "ส่งชุดแผนกหมวดอาหารมาหน่อย" → ได้ชุดมาตรฐานแบบขั้น 1: ลิ้นชัก เชฟ คนจัดจาน ใช้ได้ทันที
· แต่ไม่มีกฎของร้านเราสักข้อ เพราะเราไม่ได้บอก
ถ้าอยากได้แบบที่ร้านเราใช้ ต้องบอกเป็นข้อ ๆ: "แฟ้มไม่มีชื่อห้ามเข้าลิ้นชัก · ป้ายห้ามซ้ำ · ห้ามทิ้งหมวดที่ยังมีบทความ · …"
· ยิ่งบอกกฎครบ ยิ่งได้ของถูกตั้งแต่รอบแรก — และเราเอารายการเดียวกันนี้ไว้ตรวจรับของได้ด้วย
ที่ไม่เหมือนกัน: พนักงานสำนักงานใหญ่ที่ดีมักถามกลับว่า "ชื่อหมวดซ้ำได้ไหมครับ" · AI ส่วนใหญ่ไม่ถาม มันเดาค่าที่พบบ่อยแล้วเขียนออกมามั่นใจมาก · และของจากสำนักงานใหญ่มีคนรับผิดชอบ แต่โค้ดจาก AI คนรับผิดชอบคือคนที่กด merge (รวมโค้ดเข้าโปรเจกต์หลัก) — คือเรา · ต้องรันตรวจเองแบบที่ทำมาทั้ง Lab
โจทย์: ก่อนกดเฉลย เขียน prompt ของคุณเองลงในโน้ตหรือแชท — ใช้ทุกอย่างที่เพิ่งทำใน Lab นี้ (ขั้น 1–6) ใส่เงื่อนไขให้มากที่สุด · ไม่จำกัดความยาว · ภาษาไทยได้ · แล้วค่อยเทียบกับตัวอย่าง
ตัวอย่างที่ผมเขียนจากของที่ทำใน Lab นี้ + รายการในสไลด์หน้า 58 (ไม่ได้ลองส่งให้ AI จริงในบทนี้ — เป็นตัวอย่างวิธีเขียน ไม่ใช่สูตรที่รับประกันผล):
ช่วยเพิ่มหมวดอาหาร (Category) ให้แอป Rails 8.1 ชื่อ wongnok_blog (PostgreSQL)
โดยเริ่มจาก bin/rails generate scaffold Category name:string slug:string แล้วเติมให้ครบตามนี้
กฎของข้อมูล (ต้องมีทั้งใน model และใน migration ทุกข้อ)
1. name กับ slug ห้ามว่าง — validates presence ใน model และ null: false ที่ DB
2. slug ห้ามซ้ำ — validates uniqueness ใน model และ unique index ที่ DB
3. slug รับได้แค่ a-z 0-9 และขีด (-) — ใช้ format validation
4. name ยาวไม่เกิน 80 ตัวอักษร
5. ถ้าไม่ได้กรอก slug ให้สร้างจาก name อัตโนมัติด้วย before_validation
ระวัง: ฟอร์มส่งช่องว่างมาเป็น "" ไม่ใช่ nil ให้เช็คด้วย blank? ไม่ใช่ ||=
ถ้าชื่อเป็นภาษาไทยจนสร้าง slug ไม่ได้ ให้ขึ้น error ไม่ใช่บันทึกค่าว่าง
ความสัมพันธ์
6. Category has_many :posts — เพิ่ม category_id ให้ posts แบบว่างได้ (null: true)
เพราะบทความเดิมยังไม่มีหมวด แต่ต้องมี foreign key และ index
Post belongs_to :category, optional: true
7. ห้ามลบหมวดที่ยังมีบทความ — dependent: :restrict_with_error
และแก้ destroy ใน controller ไม่ให้ใช้ destroy! ถ้าลบไม่ได้ให้กลับหน้ารายการพร้อมข้อความ
หน้ารายการ (index)
8. เรียงตามชื่อ · แบ่งหน้าหรืออย่างน้อย limit 50
9. หน้ารายการโชว์จำนวนบทความของแต่ละหมวด — ใช้ includes(:posts) กัน N+1
อื่น ๆ
10. ข้อความ error เป็นภาษาไทยผ่าน i18n (ไฟล์ใน config/locales)
11. ใช้ params.expect ของ Rails 8 ไม่ใช่ require/permit
12. ถ้า migration ไหนจะพังเพราะข้อมูลเก่า (เช่น NOT NULL กับแถวที่เป็น null) ให้เตือนก่อน
และบอกวิธีจัดการข้อมูลเก่า
ส่งกลับมาเป็น: รายชื่อไฟล์ที่สร้าง/แก้ · โค้ดเต็มของแต่ละไฟล์ · คำสั่งที่ต้องรันตามลำดับ
· และคำสั่งใน bin/rails console ที่ใช้พิสูจน์ว่ากฎแต่ละข้อทำงานจริง
(รวมถึง save(validate: false) ที่ต้องพังที่ชั้น DB)
สามสิ่งที่ทำให้ prompt นี้ต่างจากประโยคเดียวของสไลด์:
- บอกบริบท — Rails เวอร์ชันไหน DB อะไร มีข้อมูลเก่าอยู่แล้ว (AI จะได้ไม่ใส่
null: falseที่พังแบบภารกิจข้อ 1) - บอกกฎเป็นคู่ — ทุกกฎระบุว่าต้องอยู่ทั้ง model และ DB · ข้อ 5 บอกกับดักที่เราเจอเองด้วย
- บอกวิธีตรวจรับ — ขอคำสั่งพิสูจน์ใน console มาด้วย · เราจะได้รันตรวจแทนการเชื่อว่า "ดูครบแล้ว"
ข้อ 10 (i18n = ระบบแปลข้อความเป็นหลายภาษาของ Rails · ข้อความ can't be blank จะกลายเป็นภาษาไทย) กับการแบ่งหน้าในข้อ 8
เราไม่ได้ทำใน Lab นี้ — ใส่ไว้เพราะสไลด์ขอ · ได้โค้ดกลับมาแล้วต้องรันตรวจเองเหมือนข้ออื่น
- prompt · context
- prompt = ข้อความที่เราพิมพ์สั่ง AI · context = ข้อมูลรอบตัวที่ AI ต้องรู้ถึงจะทำถูก (เวอร์ชัน · DB · ข้อมูลเก่า · กฎของธุรกิจ) · ถาม senior ได้ว่า "ทีมเรามี prompt ตั้งต้นสำหรับงาน model ใหม่ไหม"
- acceptance criteria
- รายการ "งานนี้ถือว่าเสร็จเมื่อ…" ที่ตรวจได้จริงทีละข้อ · ในตัวอย่างคือข้อ 1–12 + คำสั่งพิสูจน์ · พิมพ์บอก AI ได้ว่า "here are the acceptance criteria; for each one, show me how to verify it in the console"
กลับมาที่ 3 ประโยคของวัน
ตอนเช้า (สไลด์หน้า 3) ผู้สอนบอกว่าถ้าจำได้แค่ 3 อย่าง ให้จำสามประโยคนี้ · สไลด์หน้า 59 กลับมาถามอีกครั้ง · Lab นี้ทำให้คุณเห็นทั้งสามประโยคกับมือแล้ว:
| ประโยค | ในร้านอาหาร | เห็นกับมือใน Lab ตรงไหน |
|---|---|---|
| ① schema เปลี่ยนได้โดยไม่ต้องดับแอป expand → migrate → contract · เพราะโค้ดกับ DB ไม่มีวันเปลี่ยนพร้อมกันเป๊ะ |
ปรับปรุงร้านโดยไม่ปิดร้าน: ต่อลิ้นชักใหม่ก่อน → ย้ายแฟ้ม → ค่อยรื้อของเก่า | ภารกิจข้อ 1–2: category_id ต้องเริ่มแบบ null: true (expand) · เติมหมวดให้บทความ (migrate) · ค่อยขันเป็น NOT NULL ทีหลัง · ขั้น 3: เปลี่ยนตู้ทั้งที่มีแฟ้มเสียค้าง = ช่างไม่ยอม |
| ② validation ใน model ไม่ใช่การรับประกัน ต้องมี constraint ที่ DB คู่กัน · เพราะ "ตรวจ" กับ "เขียน" เป็นสองคำสั่งแยกกัน |
คนดูแลสต็อกตรวจเก่ง แต่คนเข้าทางหลังร้านเขาไม่เห็น · ลิ้นชักที่ทำร่องไว้กันได้ทุกทาง | ขั้น 2: save(validate: false) เข้าตู้ได้ · ขั้น 4: คำสั่งเดียวกันพังที่ DB · และกฎ uniqueness ถามตู้ก่อน (Category Exists?) แล้วค่อยเขียน — สองจังหวะ มีช่องว่างตรงกลาง (บท 111) |
| ③ N+1 ไม่ได้อ่านจากโค้ด อ่านจาก log includes / preload / eager_load · เพราะ ORM ซ่อน SQL เก่งจนลืมว่าทุกบรรทัดคือการถาม DB |
เดินไปตู้ทีละแผ่นหลายสิบรอบ · ดูจากใบเบิกไม่รู้ ต้องเปิดสมุดบันทึกการเบิกของ | ขั้น 6: โค้ด category.posts.size บรรทัดเดียวดูไม่มีพิษภัย · log บอกว่า 5 query · เติม includes เหลือ 3 และไม่โตตามจำนวนหมวด |
Checklist ท้ายวัน — ควรเข้าใจอะไรแล้ว
ติ๊กในใจทีละข้อ · ข้อไหนลังเล กลับไปบทนั้น 5 นาทีก่อนเข้า Day 3:
- ☐ อธิบายได้ว่า Active Record คือ "แฟ้มที่เดินเข้าลิ้นชักเองได้" และต่างจาก Data Mapper ยังไง (106)
- ☐ สร้าง อ่าน แก้ ลบ ใน console ได้ และแปล SQL ที่ console โชว์เป็นภาษาคนได้ (107)
- ☐ เขียน migration · migrate · rollback ได้ และรู้ว่า
schema.rbคือแปลนตู้ฉบับล่าสุด (108) - ☐ รู้ว่า migration แบบไหนอาจทำร้านค้าง และเปลี่ยนชื่อคอลัมน์แบบ expand → migrate → contract (109)
- ☐ ใส่ validation · อ่าน
errors.full_messages· รู้ว่าsaveกับsave!ต่างกันยังไง (110) - ☐ ใส่ constraint คู่กับ validation ทุกครั้ง และอธิบายได้ว่าทำไม uniqueness อย่างเดียวกันซ้ำไม่ได้ (111 + Lab นี้)
- ☐ รู้ว่า callback เหมาะกับงานแบบไหน และงานไหนควรย้ายออกไป (112)
- ☐ เปิด log แล้วนับได้ว่าหน้าหนึ่งไปตู้กี่ครั้ง และแก้ N+1 ด้วย
includes(113–114) - ☐ ผูก
belongs_to/has_manyได้ และเลือกdependentจากกฎของธุรกิจ (115–116) - ☐ อธิบายได้ว่า
params.expectกันอะไร และไม่ได้กันอะไร (117) - ☐ อ่านไฟล์ที่ scaffold (หรือ AI) เขียนให้ แล้วบอกได้ว่าขาดอะไร ด้วย checklist 8 ข้อ (118 + Lab นี้)
การบ้าน (ไม่บังคับ แต่ทำแล้วดี)
จากสไลด์หน้า 60 — เลือกทำตามแรงที่เหลือ:
- ทำ Lab นี้ให้จบ ถ้ายังค้างขั้นไหน · commit ทุกขั้นที่ผ่าน
- เอา prompt ที่เขียนในหัวข้อ "เขียน prompt ให้ AI" ไปลองกับ AI จริง ในโปรเจกต์ทดลอง (อย่าลองในโปรเจกต์ที่ทำ Lab ไว้แล้ว เพราะ Category มีอยู่แล้ว) แล้วตรวจของที่ได้ด้วย checklist 8 ข้อของ บท 118 · จดว่าข้อไหน AI ทำครบ ข้อไหนต้องสั่งซ้ำ
- ลอง gem สองตัวที่สไลด์แนะนำ ในโปรเจกต์ของคุณเอง (บทนี้ไม่ได้ติดตั้งและไม่ได้รัน — ข้อมูลด้านล่างมาจากเอกสารของ gem):
- bullet — คอยดูระหว่างที่เราเปิดหน้าเว็บ ถ้าเจอ N+1 จะเตือน (เช่นใน log หรือกล่องแจ้งเตือนในเบราว์เซอร์) · เหมือนมีคนยืนนับว่าเราเดินไปตู้กี่รอบ
- rack-mini-profiler — แปะป้ายเล็ก ๆ ที่มุมหน้าเว็บ บอกว่าหน้านี้ใช้เวลาเท่าไร และกดดู SQL ที่ยิงได้
- วิธีติดตั้งอยู่ใน README ของแต่ละตัว (แก้
Gemfileแล้วbundle install· บท 98) · ติดตั้งแล้วเปิดหน้า/categoriesลองสลับมี/ไม่มีincludesดู
- พิมพ์
bin/rails routesดูทั้งโปรเจกต์ · นับว่ามีกี่เส้นที่มาจากresourcesที่เพิ่มในวันนี้
เตรียมก่อนวันถัดไป (สไลด์หน้า 60): โปรเจกต์ wongnok_blog รันได้และมีข้อมูลตัวอย่างพอสมควร · PostgreSQL ที่โปรเจกต์ใช้เปิดอยู่ (bin/rails db:migrate:status ขึ้นตารางได้ = ใช้ได้) · พักผ่อนให้พอ — สไลด์บอกว่าวันถัดไปเขียนโค้ดเยอะกว่าวันนี้มาก
Day 3 จะเจออะไร
Day 2 ทำให้ห้องเก็บของแน่นหนา · Day 3 กลับไปที่หน้าร้าน — ทำให้มันเป็นแอปที่คนใช้จริง · หัวข้อจากสไลด์หน้า 60 (แค่ให้รู้ว่ารออยู่ ยังไม่ต้องเข้าใจ):
| หัวข้อ | ภาษาคนสั้น ๆ |
|---|---|
| View layer | คนจัดจานแบบจริงจัง — ทำไมแอปแบบ Rails ไม่ต้องมีชั้น API แยก |
| CSS + asset pipeline | แต่งหน้าตา และวิธีที่ Rails จัดการไฟล์ CSS/JS |
| Hotwire / Turbo | เปลี่ยนแค่บางส่วนของหน้าโดยไม่ต้องโหลดใหม่ทั้งหน้า |
| Stimulus | JavaScript ชิ้นเล็ก ๆ ที่ HTML เป็นคนเรียกใช้ |
| Background jobs | งานที่ทำทีหลังได้ ไม่ให้ลูกค้ายืนรอ เช่น ส่งอีเมล |
| Rich text | ช่องเขียนบทความที่มีตัวหนา ลิงก์ รูป — และกรองของอันตรายออก |
| ไฟล์ + object storage | อัปโหลดรูปประกอบบทความ เก็บไว้ที่อื่นนอก database |
| Caching | เก็บจานที่จัดเสร็จแล้วไว้เสิร์ฟซ้ำ ไม่ต้องทำใหม่ทุกครั้ง |
ส่วน Day 4 คือเรื่องคน: login ด้วย Keycloak · ใครทำอะไรได้ (authorization — ข้อ 8 ของ checklist ที่ strong params กันไม่ได้) · การเขียนเทส
Quiz ท้ายวัน
สี่ข้อทวนทั้งวันผ่านของที่เพิ่งทำใน Lab · เฉลยทุกข้อพิสูจน์ด้วยผลรันในบทนี้ หรือเหตุผลที่ตรวจแล้วในบทก่อน
ข้อ 1 · หลังเติม migration ในขั้น 3 แล้ว (slug เป็น NOT NULL) สั่ง Category.new(name: "x").save(validate: false) — เกิดอะไรขึ้น?
ตัวเลือกที่ 3 ถูก · ผลจริงในขั้น 4: PG::NotNullViolation: ERROR: null value in column "slug" … (ActiveRecord::NotNullViolation) แล้ว c.persisted? เป็น false
ตัวเลือกที่ 1 คือผลของขั้น 2 ตอนตู้ยังไม่มีกฎ · ตัวเลือกที่ 2 คือพฤติกรรมของ validation (คืน false เงียบ ๆ) แต่เราสั่งข้ามการตรวจไปแล้ว คนที่ปฏิเสธคือตัวตู้ซึ่งตอบด้วยการระเบิด ·
ตัวเลือกที่ 4 ผิดเพราะ before_validation ถูกข้ามไปพร้อมการตรวจ — ใน INSERT ช่อง slug เป็น NULL
จุดที่ต้องจำ: validation ข้ามได้ · constraint ข้ามไม่ได้ — นี่คือประโยคที่ ② ของวัน
ข้อ 2 · หน้า /categories มี 3 หมวด แต่ละหมวดโชว์ category.posts.size · หลังเปลี่ยน controller เป็น Category.order(:name).includes(:posts).limit(50) — ใน log มีบรรทัดที่ไปหยิบบทความกี่บรรทัด?
ตัวเลือกที่ 1 ถูก · ผลจริงในขั้น 6: Post Load … WHERE "posts"."category_id" IN (4, 1, 5) บรรทัดเดียว · ทั้งหน้า 3 queries
ตัวเลือกที่ 2 คือผลก่อนเติม includes (Post Count 3 บรรทัด) ·
ตัวเลือกที่ 3 ผิดครึ่งเดียว — size ไม่ต้องไปตู้ตอนนับ เพราะบทความอยู่ในมือแล้ว แต่ต้องไปหยิบมาก่อนหนึ่งครั้ง ·
ตัวเลือกที่ 4 คือหยิบซ้ำซ้อน — หยิบทั้งตะกร้าแล้วยังเดินไปตู้ทีละหมวดอีก ถ้าเป็นแบบนั้น includes ก็ไม่ได้ช่วยอะไร (ผลจริงไม่มีบรรทัดรายหมวดเลย)
จุดที่ต้องจำ: แก้ N+1 แล้วจำนวนครั้งต้องไม่โตตามจำนวนแถว · ตรวจด้วยการนับใน log ไม่ใช่ด้วยการอ่านโค้ด — ประโยคที่ ③
ข้อ 3 · ร้านเปิดอยู่ตลอด ไม่อยากปิดเลย · อยากเปลี่ยนชื่อช่อง name ของ categories เป็น title · ลำดับไหนปลอดภัย?
ตัวเลือกที่ 2 ถูก · นี่คือ expand → migrate → contract จาก บท 109 — ทุกช่วงระหว่างทาง โค้ดเก่าก็ใช้ได้ โค้ดใหม่ก็ใช้ได้
ตัวเลือกที่ 1 มีช่วงที่ตู้เปลี่ยนแล้วแต่แอปยังเป็นโค้ดเก่า → โค้ดเก่าหาช่อง name ไม่เจอ · ตัวเลือกที่ 4 กลับด้านกัน มีช่วงที่โค้ดใหม่หาช่อง title ไม่เจอ
(บท 109 อธิบายว่าช่วงนี้หนีไม่พ้น เพราะ migrate กับ restart แอปเกิดคนละจังหวะ) · ตัวเลือกที่ 3 ข้อมูลหายทั้งหมด
จุดที่ต้องจำ: ไม่มี deploy ไหนที่โค้ดกับตู้เปลี่ยนพร้อมกันเป๊ะ — ต้องมีช่วงที่ของเก่ากับของใหม่อยู่ด้วยกันได้ · ประโยคที่ ①
ข้อ 4 · Category มี has_many :posts, dependent: :restrict_with_error · หมวด อาหารไทย มีบทความ 2 ชิ้น · สั่ง thai.destroy — ได้อะไร?
ตัวเลือกที่ 4 ถูก · ผลจริงในภารกิจข้อ 5: thai.destroy → false · thai.errors.full_messages → ["Cannot delete record because dependent posts exist"]
ตัวเลือกที่ 1 คือ dependent: :destroy · ตัวเลือกที่ 2 คือ dependent: :nullify (บท 116) ·
ตัวเลือกที่ 3 คือ restrict_with_exception — หรือการเรียก destroy! แบบ controller ของ scaffold ซึ่งเราเห็นหน้าแดง RecordNotDestroyed ในภารกิจข้อ 6
จุดที่ต้องจำ: จะเลือก dependent แบบไหน คำตอบมาจากกฎของธุรกิจ ไม่ใช่จาก scaffold หรือ AI · และถ้าใช้ restrict_with_error ต้องเช็คผลของ destroy ใน controller ด้วย
ถามกลับได้เลย
ถ้า migration ของคุณพังด้วยข้อความที่ไม่มีในบทนี้ หรือ AI เขียนโค้ดมาแล้วคุณไม่แน่ใจว่าครบ — นั่นคือของดีที่สุดที่จะเอาไปถาม senior หรือ AI
· ส่งไปสามอย่าง: ข้อความ error บรรทัดแรก · ไฟล์ migration หรือ model ที่เกี่ยว · ผลของ git diff
· ถ้าเป็นเรื่องช้า แนบ log ของ request นั้นทั้งก้อน (จาก Started GET ถึง Completed) — คนช่วยจะนับ query ได้ทันที
ถ้าเปิดสไลด์ดูประกอบ
| หน้า | สไลด์เขียน | ของจริง | ต้องสนใจไหม |
|---|---|---|---|
| 54 | before_validation { self.slug ||= name.to_s.parameterize } | ฟอร์มส่ง slug ว่างมาเป็น "" → ||= ไม่เติม → Slug can't be blank | สนใจ — ใช้ if slug.blank? (ขั้น 2) |
| 57 | เติม validation → เขียน migration NOT NULL + unique → ทำ save(validate: false) | ถ้าลองประตูข้างก่อนรัน migration แผ่นที่แอบเข้าจะทำให้ migration พัง | สนใจ — บทนี้ตั้งใจให้เจอ แล้วสอนวิธีจัดการ (ขั้น 3) |
| 54 · 57 | has_many :posts, dependent: :restrict_with_error | posts ยังไม่มี category_id · ต้อง migration null: true + optional: true · และ destroy! ของ scaffold ให้หน้าแดง | สนใจ — ภารกิจขั้น 5 |
| 58 | ให้ขอ params.expect ของ Rails 8 ใน prompt | scaffold ของ Rails 8.1 เขียน params.expect(category: [ :name, :slug ]) ให้อยู่แล้ว | แค่รู้ไว้ — ยังควรใส่ใน prompt เผื่อ AI ใช้แบบเก่า |
| 58 | index action ขอ pagination | Rails ไม่มีระบบแบ่งหน้าสำเร็จรูปในตัว ปกติใช้ gem เพิ่ม · บทนี้ใช้ limit(50) แทน | แค่รู้ไว้ |
| 60 | ใส่ gem bullet กับ rack-mini-profiler | บทนี้ไม่ได้ติดตั้ง · ไม่ได้รัน · ไม่มีผลรันของสองตัวนี้ในบท | ทำเองได้ในโปรเจกต์ของคุณ (การบ้านข้อ 3) |
อ้างอิง
- Rails Guides — The Rails Command Line — อ่านหัวข้อ 4.4 Generating Scaffolds รายการไฟล์ที่ scaffold สร้าง ตรงกับผลในขั้น 1
- Rails Guides — Active Record Validations —
อ่านหัวข้อ 2.10 uniqueness ย่อหน้าที่ขึ้นต้นว่า "This validation does not create a uniqueness constraint in the database"
คู่มือทางการเขียนเองว่าต้องมี unique index คู่กัน · แล้วดู หัวข้อ 1.4 Skipping Validations รายการคำสั่งที่ข้ามการตรวจ (มี
save(validate: false)) - Rails Guides — Active Record Migrations —
อ่านหัวข้อ 3.4 Changing Columns ย่อหน้าของ
change_column_nullที่เตือนว่าต้องแน่ใจว่าแถวเก่าไม่เป็น null ก่อน (ขั้น 3) · และ หัวข้อ 4.1.1 Transactions ที่อธิบายว่าทำไมงานครึ่งแรกถูกยกเลิกด้วย - Rails Guides — Active Record Associations —
อ่านหัวข้อ 2.1 belongs_to ย่อหน้าที่พูดถึง
optional: true(ภารกิจข้อ 4) แล้วต่อด้วย หัวข้อ 8.1.2 :dependent เทียบ:restrict_with_errorกับ:restrict_with_exception(quiz ข้อ 4) - Rails Guides — Active Record Query Interface — อ่านหัวข้อ 16.1 N + 1 Queries Problem และ 16.2 includes ตัวอย่างทางการแบบเดียวกับขั้น 6
- Rails API — ActiveSupport::Inflector#parameterize — ตัวอย่างการแปลงข้อความเป็น slug (ทำไมชื่อภาษาไทยได้ข้อความว่าง)