🔁 ทวนก่อนเริ่ม — คำพวกนี้จะโผล่ในบทนี้ทั้งหมด
resources :posts(บท 101 · 117) — บรรทัดเดียวได้ URL มาตรฐาน 8 เส้น ไปหา 7 เมนู (index · show · new · create · edit · update · destroy — update มีสองเส้นคือ PATCH กับ PUT)before_action(บท 102) — "ก่อนเข้าเมนูพวกนี้ ให้เรียก method นี้ก่อนเสมอ"if @post.save … else render :new(บท 110) — เก็บได้ก็พาไปหน้าอื่น · เก็บไม่ได้ก็โชว์ฟอร์มเดิมพร้อมข้อผิดพลาดparams.expect(บท 117) — รับเฉพาะช่องที่อยู่ในรายการ ช่องอื่นที่ลูกค้าแอบเขียนมาถูกตัดทิ้ง- validation กับ constraint (บท 110–111) — กฎใน model คือคนดูแลสต็อกตรวจแฟ้ม · กฎในตาราง (NOT NULL · unique index) คือตัวตู้ที่ใส่แฟ้มผิดแบบไม่ได้เลย
🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 17 (สไลด์ใช้ 4.0.6 · 8.1.3 — โค้ดที่ generate ได้ต่างจากสไลด์นิดหน่อย บอกไว้ทุกจุด) ·
📦 ข้อมูล: แอป wongnok_blog ที่มีตาราง users/posts/comments และข้อมูลตัวอย่าง (seeds) ชุดเดียวกับบท 107 ·
บทนี้เพิ่มตารางใหม่ categories (หมวดหมู่อาหาร) ด้วย scaffold ทีละขั้นด้านล่าง ·
ผลรันทุกกล่องมาจากเครื่องจริง · เวลา (ms) · เลขเวลาในชื่อไฟล์ migration · เลข id ของคุณจะไม่ตรงกับของผม
1 · scaffold คืออะไร — ชุดเปิดแผนกใหม่สำเร็จรูป
ที่ผ่านมาเราสร้างของทีละชิ้น: generate model (บท 99) · เขียน route (บท 101) · เขียน controller (บท 102) · เขียน view (บท 103)
scaffold คือคำสั่ง generate ตัวใหญ่ที่ทำทุกชิ้นนั้นให้ในทีเดียว สำหรับของหนึ่งอย่าง (เช่น หมวดหมู่)
ได้หน้าเว็บที่ดูรายการ · ดูทีละชิ้น · สร้าง · แก้ · ลบ ได้ทันที
สไลด์เรียกมันว่า "vibe code รุ่นปี 2005" (vibe code = บอก AI ว่าอยากได้อะไรแล้วรับโค้ดมาใช้เลย โดยไม่ได้เขียนเอง) — สั่งคำเดียวได้โค้ดมาทั้งกอง เหมือนสั่ง AI ทุกวันนี้
ต่างกันที่ scaffold ไม่ได้ "คิด" อะไรเลย มันเติมชื่อที่เราให้ลงในแม่แบบที่เตรียมไว้ · ผลจึงเหมือนกันทุกครั้ง และไม่รู้อะไรเกี่ยวกับร้านเราเลย
เทียบกับ Go: ตอนทำ wongnok API ด้วย Go คุณเขียนเองทุกชิ้น — struct ของ GORM (บท 27) · ไฟล์ SQL ของ goose (บท 31) · handler ของ Gin ทีละตัว (บท 38) · Go ไม่มีคำสั่งมาตรฐานที่เขียนชุดนี้ให้ · Rails มี เพราะทุกแอป Rails วางของที่เดิมตามกฎตั้งชื่อ (ร้านแฟรนไชส์ที่ทุกสาขาวางของตำแหน่งเดียวกัน)
ร้านเราอยากเปิดแผนกหมวดหมู่อาหาร · แทนที่จะต่อตู้เอง จ้างเชฟเอง ทำป้ายเมนูเอง สำนักงานใหญ่ส่งชุดสำเร็จรูปมาให้ทั้งกล่อง: ใบสั่งช่างต่อลิ้นชักใหม่ (migration) · คนดูแลสต็อก (model) · หัวหน้าเชฟพร้อมเมนู 7 อย่าง (controller) · แบบจัดจาน 4 แบบ (view) · พนักงานต้อนรับได้รายการใหม่ (route) — แกะกล่องแล้วเปิดแผนกได้วันนั้นเลย
ที่ไม่เหมือนกัน: ชุดของแฟรนไชส์จริงมักมีคู่มือกฎมาด้วย · ชุดนี้ไม่มีกฎเลย — คนดูแลสต็อกรับแฟ้มเปล่า ๆ · ลิ้นชักไม่มีป้ายแท็บ · หัวหน้าเชฟให้ใครก็ได้สั่งทิ้งแฟ้ม (เดี๋ยวเห็นกับตาในหัวข้อ 6) · และของในกล่องกลายเป็นไฟล์ของเราทันที — สำนักงานใหญ่ไม่ส่งรุ่นใหม่มาอัปเดตให้ แก้ได้ทุกบรรทัด และต้องดูแลเองทุกบรรทัด
- scaffold (นั่งร้าน)
- คำสั่ง
bin/rails generate scaffoldที่สร้าง migration + model + controller + view + route + test ของ resource หนึ่งตัว (ของหนึ่งอย่างที่มีชุด URL มาตรฐาน 8 เส้น บท 117) ในทีเดียว · ชื่อมาจากนั่งร้านก่อสร้าง — ของชั่วคราวที่ช่วยให้ขึ้นโครงได้เร็ว ไม่ใช่ตัวตึก · ถาม senior ได้ว่า "ฟีเจอร์นี้ scaffold มาเป็นจุดตั้งต้นได้ไหม หรือทีมเขียนมือเท่านั้น" - generator
- คำสั่งตระกูล
bin/rails generate …ทั้งหมด (model · migration · controller · scaffold) ที่เติมชื่อลงแม่แบบแล้วเขียนไฟล์ให้ · ย้อนได้ด้วยbin/rails destroy …ชื่อเดียวกัน (ใช้ในภารกิจ) · พิมพ์บอก AI ได้ว่า "show me the exact rails generate command, don't write the files by hand" ได้ไฟล์ที่ชื่อตรงกฎแน่นอน
2 · สั่งคำเดียว ได้อะไรมา
เปิดโฟลเดอร์แอป wongnok_blog แล้วสั่งสร้างแผนกหมวดหมู่ ที่มีสามช่อง: name (ชื่อหมวด) ·
slug (ชื่อสั้นภาษาอังกฤษไว้ใส่ใน URL เช่น one-dish) · description (คำอธิบายยาว ๆ)
$ bin/rails generate scaffold Category name:string slug:string description:text
invoke active_record
create db/migrate/20261104120550_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
Category name:string …— ชื่อ model เป็นเอกพจน์ตัวใหญ่ ตามกฎ (บท 99) · ตามด้วยชื่อช่อง:ชนิดแบบเดียวกับ generate model ·string= ข้อความสั้น ·text= ข้อความยาวinvoke= "เรียกผู้ช่วยแผนกนี้มาทำงาน" ·create= "เขียนไฟล์นี้แล้ว" ·route= "เติมบรรทัดนี้ลงไฟล์ route"- คำหลัง
invokeคือชื่อผู้ช่วยแต่ละคน:active_recordเขียน model + migration ·test_unitเขียนเทส ·tailwindcssเขียนหน้าเว็บที่แต่งด้วย Tailwind ·helperเขียนไฟล์ helper ·jbuilderเขียนแบบตอบเป็น JSON — ไม่ต้องจำ ดูตารางข้างล่างพอ - เลข
20261104120550คือวันเวลาที่สั่ง ของคุณจะต่าง
จัดกลุ่มใหม่ให้อ่านง่าย — ทุกไฟล์เข้าช่องในร้านที่เรารู้จักแล้ว:
| ไฟล์ | คืออะไร | ในร้านอาหาร |
|---|---|---|
db/migrate/…_create_categories.rb | สร้างตาราง categories 3 ช่อง + เวลาสร้าง/แก้ | ใบสั่งช่างต่อลิ้นชักใหม่ (บท 108) |
app/models/category.rb | model Category | คนดูแลสต็อกของลิ้นชักนี้ |
config/routes.rb (+1 บรรทัด) | resources :categories = 8 เส้น | รายการใหม่ในมือพนักงานต้อนรับ |
app/controllers/categories_controller.rb | 7 action ครบ | หัวหน้าเชฟกับ 7 เมนู |
app/views/categories/*.html.erb (6 ไฟล์) | หน้ารายการ · ดูทีละชิ้น · สร้าง · แก้ + ชิ้นส่วนใช้ซ้ำ 2 ชิ้น | แบบจัดจาน |
app/views/categories/*.json.jbuilder (3 ไฟล์) | แบบตอบเป็น JSON (หัวข้อ 4) | แบบใส่กล่องกลับบ้าน |
app/helpers/categories_helper.rb | module ว่าง ๆ ไว้ใส่ helper ของแผนกนี้ | ลิ้นชักเครื่องมือจัดจานที่ยังว่าง |
test/… (3 ไฟล์) | เทสของ controller 7 ข้อ · เทสของ model (ว่าง) · ข้อมูลทดสอบ | รอ Day 4 |
ผมนับจาก git: ไฟล์ใหม่ 16 ไฟล์ + แก้ config/routes.rb 1 บรรทัด รวม 265 บรรทัด
ใช้เวลาประมาณ 0.4 วินาที (วัดสองรอบได้ 0.42 กับ 0.46 · เครื่องคุณจะต่าง) ·
สไลด์ใช้ตัวเลขต่างไปนิด (ดูตารางท้ายบท) แต่แก่นเหมือนกัน: ไม่ได้พิมพ์โค้ดเองสักบรรทัด
ไฟล์ migration ยังไม่ได้รัน (ใบสั่งช่างเขียนเสร็จแล้ว แต่ช่างยังไม่มาต่อตู้) — สั่งรันก่อน:
$ bin/rails db:migrate
== 20261104120550 CreateCategories: migrating =================================
-- create_table(:categories)
-> 0.0105s
== 20261104120550 CreateCategories: migrated (0.0105s) ========================
แปลว่า "เริ่มทำใบสั่งช่าง CreateCategories → ต่อลิ้นชักใหม่ชื่อ categories (create_table) ใช้เวลา 0.01 วินาที → เสร็จ" ·
ชุดเดียวกับที่เห็นในบท 108 · เวลาของคุณจะต่าง
แล้วถามพนักงานต้อนรับว่ารายการใหม่มีอะไรบ้าง (-c categories = ขอดูเฉพาะของแผนก categories):
$ bin/rails routes -c categories
Prefix Verb URI Pattern Controller#Action
categories GET /categories(.:format) categories#index
POST /categories(.:format) categories#create
new_category GET /categories/new(.:format) categories#new
edit_category GET /categories/:id/edit(.:format) categories#edit
category GET /categories/:id(.:format) categories#show
PATCH /categories/:id(.:format) categories#update
PUT /categories/:id(.:format) categories#update
DELETE /categories/:id(.:format) categories#destroy
8 เส้นชุดเดียวกับ resources :posts ในบท 101 ทุกตัวอักษร แค่เปลี่ยนชื่อ ·
(.:format) ท้าย URL แปลว่าต่อท้ายด้วย .json ได้ — เก็บไว้ดูในหัวข้อ 4
3 · เปิดแผนกใหม่ ลองใช้จริง
เปิดร้านด้วย bin/rails server แล้วเปิด browser ที่ http://localhost:3000/categories ·
จะเห็นหัวข้อ Categories · ปุ่ม New category · และข้อความ No categories found.
⚠️ ถ้าหน้าเว็บขึ้นเป็นตัวหนังสือดำล้วน ไม่มีปุ่มสี — โค้ดไม่ได้พัง · หน้าของ scaffold ใช้ Tailwind
(ชุดคำสั่งแต่งหน้าตาที่ rails new --css=tailwind ใส่ไว้) ซึ่งต้องสร้างไฟล์ CSS (ไฟล์แต่งหน้าตา) ใหม่ทุกครั้งที่มีหน้าใหม่ ·
เปิด terminal อีกหน้าต่างแล้วสั่ง bin/rails tailwindcss:build หนึ่งครั้ง (ผมลองแล้ว: ขึ้น Done in 42ms · เลข ms ของคุณจะต่าง) แล้ว refresh หน้าเว็บ · ยังใช้ bin/rails server เหมือนเดิม
ลองทำตามนี้: กด New category → กรอก Name อาหารจานเดียว · Slug one-dish ·
Description ข้าวราดแกง กะเพรา ข้าวมันไก่ → กด Create Category ·
หน้าเว็บพาไป /categories/1 พร้อมข้อความสีเขียว Category was successfully created.
หน้าต่างที่รัน server จด log ไว้แบบนี้ (ผมตัดคอมเมนต์ /*action=…*/ ท้าย SQL และบรรทัด ↳ ออกให้อ่านง่าย):
Started POST "/categories" for ::1 at 2026-11-04 19:08:46 +0700
Processing by CategoriesController#create as TURBO_STREAM
Parameters: {"authenticity_token" => "[FILTERED]", "category" => {"name" => "อาหารจานเดียว", "slug" => "one-dish", "description" => "ข้าวราดแกง กะเพรา ข้าวมันไก่"}, "commit" => "Create Category"}
TRANSACTION (0.9ms) BEGIN
Category Create (3.1ms) INSERT INTO "categories" ("name", "slug", "description", "created_at", "updated_at") VALUES ('อาหารจานเดียว', 'one-dish', 'ข้าวราดแกง กะเพรา ข้าวมันไก่', '2026-11-04 12:08:46.921718', '2026-11-04 12:08:46.921718') RETURNING "id"
TRANSACTION (1.0ms) COMMIT
Redirected to http://localhost:3000/categories/1
Completed 302 Found in 7ms (ActiveRecord: 5.4ms (1 query, 0 cached) | GC: 0.0ms)
| บรรทัด | แปลเป็นภาษาคน |
|---|---|
Started POST "/categories" for ::1 | ลูกค้ายื่นแบบฟอร์ม "สร้างของใหม่" (POST) มาที่ /categories · ::1 = ลูกค้าคือเครื่องเราเอง (บท 100) |
Processing by CategoriesController#create as TURBO_STREAM | เข้าแผนก categories เมนู create ·
TURBO_STREAM คือป้ายที่ browser ติดมาเพราะแอปใช้ Turbo (ตัวช่วยให้หน้าเว็บเปลี่ยนเร็วโดยไม่โหลดทั้งหน้า — Day 3) · ตอนนี้ข้ามได้ |
Parameters: {… "category" => {"name" => …}} | ของในกล่อง params · ช่องของฟอร์มซ้อนอยู่ในกล่องชื่อ category (สำคัญตอนอ่าน controller) ·
authenticity_token คือบัตรคิว (CSRF token · บท 103) ที่ยืนยันว่าฟอร์มนี้มาจากหน้าของร้านเราจริง Rails ซ่อนค่าไว้ให้ |
BEGIN · COMMIT | เปิดรอบงาน · ปิดรอบงาน — "ทำทั้งหมด หรือไม่ทำเลย" (transaction บท 111) |
INSERT INTO "categories" (…) VALUES (…) RETURNING "id" | "เพิ่มแฟ้มใหม่หนึ่งแผ่นลงลิ้นชัก categories เขียนช่อง name · slug · description · เวลาสร้าง · เวลาแก้ ตามค่าเหล่านี้ แล้วบอกเลขที่แฟ้มกลับมา" |
Redirected to …/categories/1 · 302 Found | บอกลูกค้าให้ไปเปิดหน้าแฟ้มเลขที่ 1 (redirect บท 102) · 302 คือเลขของคำตอบแบบ "ไปที่อื่น" |
ทีนี้ลองแบบที่ลูกค้าขี้เกียจทำ:
กด New category แล้วกด Create Category ทันที โดยไม่กรอกอะไรเลยสักช่อง
คิดว่าหน้าเว็บจะขึ้นอะไร — ข้อความแดงว่า "กรอกไม่ครบ" · หน้า error · หรืออย่างอื่น?
บันทึกสำเร็จ — หน้าเว็บพาไป /categories/2 พร้อมข้อความเขียว Category was successfully created. และทุกช่องว่างเปล่า · log ยืนยัน (ตัดมาเฉพาะบรรทัดที่สำคัญ):
Parameters: {"authenticity_token" => "[FILTERED]", "category" => {"name" => "", "slug" => "", "description" => ""}, "commit" => "Create Category"}
Category Create (3.1ms) INSERT INTO "categories" ("name", "slug", "description", "created_at", "updated_at") VALUES ('', '', '', '2026-11-04 12:08:56.935084', '2026-11-04 12:08:56.935084') RETURNING "id"
Redirected to http://localhost:3000/categories/2
Completed 302 Found in 6ms (ActiveRecord: 5.6ms (1 query, 0 cached) | GC: 0.0ms)
VALUES ('', '', '', …) = "เก็บแฟ้มที่ทุกช่องเป็นข้อความว่าง" · ไม่มีใครห้าม เพราะ model ไม่มี validation (บท 110) และตารางไม่มี NOT NULL (บท 111) ·
scaffold ให้ "ร้านที่ทำงานได้" แต่ไม่ได้ให้ "ร้านที่มีกฎ" — นี่คือเรื่องหลักของหัวข้อ 6
ได้แฟ้มว่างมาหนึ่งแผ่นแล้ว (เลขที่ 2) — ใช้มันลองอีกสองอย่าง อย่าแตะแฟ้มเลขที่ 1 เพราะหัวข้อ 6 ยังใช้ ·
ที่หน้าแฟ้มว่าง กด Edit this category กรอก Name ของหวาน · Slug dessert แล้วกด Update Category ·
จากนั้นกด Destroy this category (browser ถาม "Are you sure?" ให้กด OK) · ได้ SQL ตามนี้ (ตัดมาเฉพาะบรรทัดที่หยิบ/แก้/ทิ้งแฟ้ม กับบรรทัดสุดท้าย · บรรทัด BEGIN · COMMIT มีเหมือนตอนสร้าง แต่ตัดออก):
# กด Update Category — PATCH /categories/2
Category Load (2.3ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = 2 LIMIT 1
Category Update (3.2ms) UPDATE "categories" SET "name" = 'ของหวาน', "slug" = 'dessert', "updated_at" = '2026-11-04 12:09:03.833330' WHERE "categories"."id" = 2
Completed 303 See Other in 10ms (ActiveRecord: 8.7ms (2 queries, 0 cached) | GC: 0.0ms)
# กด Destroy this category — DELETE /categories/2
Category Load (2.7ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = 2 LIMIT 1
Category Destroy (2.6ms) DELETE FROM "categories" WHERE "categories"."id" = 2
Completed 303 See Other in 13ms (ActiveRecord: 9.3ms (2 queries, 0 cached) | GC: 0.2ms)
SELECT "categories".* FROM "categories" WHERE "categories"."id" = 2 LIMIT 1— "ขอทุกช่องของแฟ้มในลิ้นชัก categories เฉพาะแผ่นเลขที่ 2 เอาแผ่นเดียว" · มาจากCategory.find(หัวข้อ 4)UPDATE … SET "name" = 'ของหวาน', … WHERE "categories"."id" = 2— "แก้แฟ้มเลขที่ 2: เขียนช่อง name กับ slug ใหม่ แล้วอัปเดตเวลาแก้" · ช่อง description ไม่ได้เปลี่ยน Rails เลยไม่เขียนทับDELETE FROM "categories" WHERE "categories"."id" = 2— "ทิ้งแฟ้มเลขที่ 2 ออกจากลิ้นชัก"303 See Other— ญาติของ 302 แปลว่า "ไปเปิดหน้าอื่นด้วยการขอดูธรรมดา (GET)" · ใช้หลังแก้/ลบ เพราะถ้าตอบ 302 browser บางตัวจะเดินไปหน้าใหม่ด้วยคำสั่งเดิม (เช่น DELETE ซ้ำอีกรอบ) · 303 บังคับให้เป็น GET (เอกสารของredirect_toเขียนไว้) · Turbo ของ Day 3 ก็อาศัยเลขนี้
ของแถม: จำ (.:format) ใน routes ได้ไหม — ลองต่อท้าย .json จะได้ข้อมูลเป็น JSON (แบบที่ Gin ตอบด้วย c.JSON) โดยไม่ได้เขียนอะไรเพิ่ม:
$ curl -s http://localhost:3000/categories.json
[{"id":1,"name":"อาหารจานเดียว","slug":"one-dish","description":"ข้าวราดแกง กะเพรา ข้าวมันไก่","created_at":"2026-11-04T12:08:46.921Z","updated_at":"2026-11-04T12:08:46.921Z","url":"http://localhost:3000/categories/1.json"}]
[ … ] คือรายการ · ในนั้นเหลือแฟ้มเลขที่ 1 แผ่นเดียวเพราะแผ่นที่ 2 ลบไปแล้ว · ช่อง url ไม่ได้อยู่ในตาราง ไฟล์ .jbuilder เติมให้ ว่าแฟ้มนี้ดูแบบ JSON ได้ที่ไหน
4 · อ่าน controller ทีละ action
ไฟล์ที่ยาวที่สุด (70 บรรทัด) และสำคัญที่สุด · อ่านทีละท่อน — ทุกบรรทัดใช้ของที่เรียนมาแล้ว
ยกเว้นของใหม่ชื่อ respond_to ซึ่งอธิบายตรงที่เจอ
ท่อน 1 — before_action กับ 4 เมนูที่สั้นมาก
# app/controllers/categories_controller.rb
class CategoriesController < ApplicationController
before_action :set_category, only: %i[ show edit update destroy ]
# GET /categories or /categories.json
def index
@categories = Category.all
end
# GET /categories/1 or /categories/1.json
def show
end
# GET /categories/new
def new
@category = Category.new
end
# GET /categories/1/edit
def edit
end
before_action :set_category, only: %i[ show edit update destroy ]— "ก่อนเข้า 4 เมนูนี้ ให้เรียกset_categoryก่อน" (บท 102) ·%i[ … ]คือวิธีเขียน array ของ symbol แบบย่อ =[:show, :edit, :update, :destroy]· 4 เมนูนี้มีเลขที่แฟ้ม (:id) ใน URL ทั้งหมด จึงต้องไปหยิบแฟ้มก่อนเหมือนกัน@categories = Category.all— "เอาแฟ้มทุกแผ่นในลิ้นชัก" วางบนถาดให้หน้ารายการ · จำบรรทัดนี้ไว้ หัวข้อ 6 จะกลับมาdef show·def editว่างเปล่า — เพราะbefore_actionหยิบ@categoryมาวางบนถาดให้แล้ว · ที่เหลือ Rails เปิด view ชื่อตรงกับเมนูให้เอง (implicit render บท 102)@category = Category.new— แฟ้มเปล่าที่ยังไม่ลงลิ้นชัก (บท 107) ให้ฟอร์มใช้เป็นแม่แบบ- คอมเมนต์
# GET /categories …เหนือแต่ละเมนู = scaffold จดไว้ให้ว่าเมนูนี้รับ URL ไหน (ตรงกับตาราง routes หัวข้อ 2)
ท่อน 2 — create และของใหม่ชื่อ respond_to
# POST /categories or /categories.json
def create
@category = Category.new(category_params)
respond_to do |format|
if @category.save
format.html { redirect_to @category, notice: "Category was successfully created." }
format.json { render :show, status: :created, location: @category }
else
format.html { render :new, status: :unprocessable_content }
format.json { render json: @category.errors, status: :unprocessable_content }
end
end
end
ตัด respond_to ออกก่อน จะเหลือท่าเดียวกับบท 110 เป๊ะ:
Category.new(category_params)— สร้างแฟ้มจากของในฟอร์ม ที่ผ่านการคัดแล้ว (category_paramsอยู่ท่อน 4)if @category.save— เก็บได้ (true) →redirect_to @categoryพาไปหน้าแฟ้มนั้น พร้อมข้อความเขียว (notice:= flash ฝากโชว์หน้าถัดไปครั้งเดียว บท 102)else→render :new, status: :unprocessable_content— โชว์ฟอร์มเดิมพร้อมข้อผิดพลาด ตอบเลข 422 ("ข้อมูลไม่ผ่าน" บท 110) · Turbo ต้องได้ 422 ถึงจะวาดฟอร์มที่มีข้อผิดพลาดให้ (Day 3) · ตอนนี้ทางนี้ไม่มีวันเกิด เพราะไม่มีกฎsaveจึงได้trueเสมอ (เห็นแล้วใน predict)
ของใหม่: respond_to do |format| = "ถามก่อนว่าลูกค้าอยากได้คำตอบแบบไหน" ·
มาจากหน้าเว็บ (HTML) → ทำตาม format.html { … } · มาทาง .json → ทำตาม format.json { … } ·
{ … } คือ block (บท 93) ที่จะรันเฉพาะแบบที่ตรงกับลูกค้า
format.json { render :show, status: :created, location: @category }— ฝั่ง JSON ไม่มีหน้าให้ redirect จึงตอบข้อมูลแฟ้มใหม่ (ใช้แบบshow.json.jbuilder) · เลข 201 (:created= "สร้างแล้ว") ·location:= แนบที่อยู่ของแฟ้มใหม่ (/categories/1) ไปกับคำตอบrender json: @category.errors— เก็บไม่ได้ก็ส่งรายการข้อผิดพลาด (errorsบท 110) กลับไปเป็น JSON แทนการโชว์ฟอร์ม
เมนูเดียวกัน (create) แต่ลูกค้ามีสองแบบ: นั่งทานในร้าน (browser — อยากได้หน้า HTML) กับ สั่งกลับบ้าน (แอปมือถือหรือโปรแกรมอื่น — อยากได้ JSON) ·
เชฟทำอาหารเหมือนกัน (save ครั้งเดียว) ต่างกันแค่ตอนส่ง: จัดจาน หรือ ใส่กล่อง · respond_to คือพนักงานที่ถามว่า "ทานที่นี่หรือกลับบ้านคะ"
ที่ไม่เหมือนกัน: ร้านจริงถามลูกค้าด้วยปาก · Rails ดูท้าย URL (.json) หรือป้ายที่ browser ติดมาเอง ลูกค้าไม่ต้องตอบ ·
และถ้าแอปเราไม่มีลูกค้าสั่งกลับบ้านเลย ส่วน format.json กับไฟล์ .jbuilder 3 ไฟล์ก็เป็นของเกินที่ลบทิ้งได้
เทียบกับ Go: ใน Gin คุณเลือกเองในแต่ละ handler ว่าจะตอบ c.HTML(…) หรือ c.JSON(…) ·
ถ้าอยากได้ทั้งสองแบบจาก URL เดียว ต้องเขียน if ตรวจเอง · respond_to คือ if นั้นที่ Rails เตรียมไว้ให้
ท่อน 3 — update กับ destroy
# PATCH/PUT /categories/1 or /categories/1.json
def update
respond_to do |format|
if @category.update(category_params)
format.html { redirect_to @category, notice: "Category was successfully updated.", status: :see_other }
format.json { render :show, status: :ok, location: @category }
else
format.html { render :edit, status: :unprocessable_content }
format.json { render json: @category.errors, status: :unprocessable_content }
end
end
end
# DELETE /categories/1 or /categories/1.json
def destroy
@category.destroy!
respond_to do |format|
format.html { redirect_to categories_path, notice: "Category was successfully destroyed.", status: :see_other }
format.json { head :no_content }
end
end
updateหน้าตาเหมือนcreateทุกอย่าง ต่างแค่@category.update(…)แทนnew+save(บท 107) และไม่ผ่านก็กลับไปฟอร์มแก้ (:edit)status: :see_other— คือเลข 303 ที่เห็นใน log หัวข้อ 3 · ส่วนstatus: :okฝั่ง JSON คือเลข 200 = "เรียบร้อย"@category.destroy!— ทิ้งแฟ้ม · ตัว!แปลว่า "ถ้าทิ้งไม่ได้ให้ระเบิด" (กฎ ! บท 110) · ทิ้งเสร็จพาไปหน้ารายการ (categories_path)head :no_content— ฝั่ง JSON ตอบเลข 204 ("เรียบร้อย ไม่มีอะไรส่งกลับ")
ท่อน 4 — private: ผู้ช่วยสองคน
private
# Use callbacks to share common setup or constraints between actions.
def set_category
@category = Category.find(params.expect(:id))
end
# Only allow a list of trusted parameters through.
def category_params
params.expect(category: [ :name, :slug, :description ])
end
end
private— method ใต้คำนี้ route เรียกตรง ๆ ไม่ได้ เป็นผู้ช่วยหลังครัว ไม่ใช่เมนู (บท 102)- คอมเมนต์อังกฤษ
Use callbacks …ที่ scaffold เขียนมา — คำว่า callbacks ในที่นี้หมายถึงbefore_actionนั่นเอง (ญาติของ callback ฝั่ง model บท 112) Category.find(params.expect(:id))— หยิบแฟ้มตามเลขที่ใน URL (บท 107) ·params.expect(:id)= "ต้องมีidและต้องเป็นค่าธรรมดา" · ถ้าไม่เจอแฟ้มfindระเบิด และ Rails ตอบ 404 ("ไม่มีหน้านี้") ให้เอง · ผมลอง/categories/99: log ขึ้นCompleted 404 Not Foundแต่ในเครื่องเรา browser จะโชว์หน้าแดงActiveRecord::RecordNotFound in CategoriesController#showกับCouldn't find Category with 'id'="99"— ไม่ใช่โค้ดพัง เป็นหน้าช่วยนักพัฒนา · ลูกค้าบนเว็บจริงจะเห็นแค่หน้า 404params.expect(category: [ :name, :slug, :description ])— "เปิดกล่องcategory(ที่เห็นใน log หัวข้อ 3) แล้วหยิบแค่สามช่องนี้" · ช่องอื่นที่ลูกค้าแอบเขียนมาถูกตัดทิ้ง = strong parameters (บท 117)
✅ ของที่ scaffold ทำถูก (สไลด์หน้า 53 ชมไว้ ผมเห็นด้วยทุกข้อ): ใช้ before_action แทนการเขียน find ซ้ำ 4 ที่ ·
ใช้ท่า if save … else render พร้อม status 422 · คัดช่องด้วย params.expect ของ Rails 8 · เก็บผู้ช่วยไว้ใต้ private ·
ถ้าเขียน controller เอง ใช้ไฟล์นี้เป็นแบบได้เลย
respond_to· format- ส่วนของ controller ที่เลือกคำตอบตามแบบที่ลูกค้าขอ (HTML · JSON …) · แบบมาจากท้าย URL หรือหัวของ request · พิมพ์บอก AI ได้ว่า "this app is HTML only — remove the respond_to / format.json branches and jbuilder files" เวลาอยากได้โค้ดที่สั้นลง
- 303 See Other · 422 Unprocessable Content
- 303 = "ไปดูหน้าอื่นด้วย GET" ใช้หลังแก้/ลบ · 422 = "ข้อมูลที่ส่งมาไม่ผ่านกฎ" ใช้ตอน render ฟอร์มเดิม · Turbo ของ Day 3 ต้องการสองเลขนี้ · ถาม senior ได้ว่า "ทำไม scaffold ใช้ 303 กับ update/destroy แต่ create ใช้ 302 ธรรมดา"
5 · อ่าน model · migration · ฟอร์ม
สามไฟล์นี้สั้นมาก — สั้นจนน่าสงสัย:
# app/models/category.rb
class Category < ApplicationRecord
end
# db/migrate/20261104120550_create_categories.rb
class CreateCategories < ActiveRecord::Migration[8.1]
def change
create_table :categories do |t|
t.string :name
t.string :slug
t.text :description
t.timestamps
end
end
end
- model ว่าง — คนดูแลสต็อกที่ไม่มีกฎสักข้อ (ไม่มี
validatesบท 110) · ไม่รู้ว่าเกี่ยวกับใคร (ไม่มี association = การผูกกับตารางอื่น เช่น หมวดหนึ่งมีหลายบทความ — บท 115) - migration — ต่อลิ้นชักสามช่อง +
t.timestamps(ช่องเวลาสร้าง/แก้ ซึ่ง Rails ใส่null: falseให้เองสองช่องนี้) · แต่ช่องของเราไม่มีnull: false(ปล่อยว่างได้) ·slugไม่มี index (ไม่มีป้ายแท็บ บท 109) และไม่มี unique (ซ้ำได้ บท 111)
ส่วนฟอร์มอยู่ในไฟล์ที่ชื่อขึ้นต้นด้วยขีดล่าง · ผมตัดคลาสแต่งหน้าตา (class: …) ออก และย่อบางท่อนเป็นคำอธิบายไทยในเครื่องหมาย … ให้เห็นโครง (ไฟล์จริงยาวกว่านี้):
<%# app/views/categories/_form.html.erb (ตัด class ของ Tailwind ออก) %>
<%= form_with(model: category) do |form| %>
<% if category.errors.any? %>
… โชว์รายการข้อผิดพลาด (category.errors บท 110) …
<% end %>
<%= form.label :name %>
<%= form.text_field :name %>
… slug แบบเดียวกัน · description ใช้ form.textarea (ช่องกรอกหลายบรรทัด) …
<%= form.submit %>
<% end %>
- บรรทัดแรก
<%# … %>คือคอมเมนต์ของ ERB (มี#ต่อท้าย<%· บท 103) — ไม่โผล่บนหน้าเว็บ - ไฟล์ที่ชื่อขึ้นต้นด้วย
_เรียกว่า partial (ของใหม่) = ชิ้นส่วนหน้าเว็บที่เอาไปใช้ซ้ำได้ · หน้า new กับหน้า edit เรียกฟอร์มชิ้นเดียวกันด้วย<%= render "form", category: @category %>("form"= ไฟล์_formโดยไม่ต้องพิมพ์ขีดล่าง ·category:= ส่งแฟ้มบนถาดเข้าไปในชื่อcategory) _category.html.erbก็เป็น partial — โชว์แฟ้มหนึ่งแผ่น ใช้ทั้งหน้ารายการ (วนทีละแผ่น) และหน้า showform_with(model: category) do |form|— วาดฟอร์มของแฟ้มนี้ ·formคือผู้ช่วยวาดช่อง (บท 97 ที่ถามว่า "f โผล่มาจากไหน") ·form.label :name= ป้ายชื่อช่อง (ขึ้นว่า Name) ·form.text_field :name= ช่องกรอกของ name ·form.submit= ปุ่มส่งฟอร์ม
- partial
- ไฟล์ view ที่ชื่อขึ้นต้นด้วย
_เรียกใช้ด้วยrender "ชื่อ"· ในร้านคือแม่พิมพ์จัดจานชิ้นเดียวที่คนจัดจานใช้กับหลายเมนู · เทียบ Go: API ที่เราเขียนด้วย Gin ตอบ JSON อย่างเดียว ไม่มีหน้าเว็บ · ใกล้สุดคือ func ช่วยตัวเดียวที่ handler สองตัวเรียกใช้ร่วมกัน แทนการก็อปโค้ดซ้ำ · พิมพ์บอก AI ได้ว่า "extract this into a partial and render it from both new and edit" _method(method override)- ช่องซ่อนในฟอร์มที่บอก Rails ว่า "ถือว่าเป็น PATCH / DELETE นะ" เพราะฟอร์ม HTML ส่งได้แค่ GET กับ POST · ถาม senior ได้ว่า "ฟอร์มนี้ส่ง POST แต่ log ขึ้น PATCH เพราะ _method ใช่ไหม"
คำถามที่น่าสนใจ: ฟอร์มชิ้นเดียว ทำไมหน้า new ส่งไปสร้าง แต่หน้า edit ส่งไปแก้? ผมดึง HTML ของสองหน้ามาดูเฉพาะแท็กฟอร์มกับปุ่ม:
<!-- หน้า /categories/new -->
<form action="/categories" accept-charset="UTF-8" method="post">
<input type="submit" name="commit" value="Create Category" data-disable-with="Create Category" />
<!-- หน้า /categories/1/edit -->
<form action="/categories/1" accept-charset="UTF-8" method="post">
<input type="hidden" name="_method" value="patch" />
<input type="submit" name="commit" value="Update Category" data-disable-with="Update Category" />
form_with ถามแฟ้มว่า persisted? ("อยู่ในลิ้นชักแล้วหรือยัง" บท 107):
แฟ้มใหม่ → ส่งไป /categories ปุ่ม Create · แฟ้มที่มีเลขที่แล้ว → ส่งไป /categories/1 ปุ่ม Update ·
_method = "patch" คือป้ายซ่อนที่บอก Rails ให้ถือว่าเป็น PATCH เพราะฟอร์ม HTML ส่งได้แค่ GET กับ POST
ส่วนที่เหลือในแท็กไม่ต้องสนใจ: accept-charset="UTF-8" = ส่งตัวอักษรแบบที่รองรับภาษาไทย ·
data-disable-with = กดแล้วปุ่มกดซ้ำไม่ได้ระหว่างรอ (กันลูกค้ากดสองที)
6 · ของที่ scaffold ไม่ให้ = ของที่ AI ไม่ให้
ตอนนี้ร้านเปิดแผนกหมวดหมู่ได้แล้ว · ลองทำตัวเป็นลูกค้าที่ทำพลาดในครัวทดลอง (bin/rails console) ·
สมมติว่ามีหมวด one-dish อยู่แล้วหนึ่งอัน (จากหัวข้อ 3) แล้วสร้างอีกอันด้วย slug ซ้ำ:
wongnok-blog(dev):001> Category.create(name: "อาหารเส้น", slug: "one-dish")
wongnok-blog(dev):002> Category.where(slug: "one-dish").count
wongnok-blog(dev):003> Category.new.save
บรรทัด 2 ได้เลขอะไร · บรรทัด 3 ได้ true หรือ false?
ผลจริง (ตัดคอมเมนต์ /*application=…*/ ท้าย SQL ออกเหมือนหัวข้อ 3 · เวลา ms · เลข 0x… ของคุณจะต่าง):
wongnok-blog(dev):001> Category.create(name: "อาหารเส้น", slug: "one-dish")
TRANSACTION (0.5ms) BEGIN
Category Create (1.4ms) INSERT INTO "categories" ("name", "slug", "description", "created_at", "updated_at") VALUES ('อาหารเส้น', 'one-dish', NULL, '2026-11-04 12:12:00.817389', '2026-11-04 12:12:00.817389') RETURNING "id"
TRANSACTION (1.1ms) COMMIT
=>
#<Category:0x00000001285b3ff0
id: 3,
name: "อาหารเส้น",
slug: "one-dish",
description: nil,
created_at: "2026-11-04 12:12:00.817389000 +0000",
updated_at: "2026-11-04 12:12:00.817389000 +0000">
wongnok-blog(dev):002> Category.where(slug: "one-dish").count
Category Count (2.3ms) SELECT COUNT(*) FROM "categories" WHERE "categories"."slug" = 'one-dish'
=> 2
wongnok-blog(dev):003> Category.new.save
TRANSACTION (1.4ms) BEGIN
Category Create (2.6ms) INSERT INTO "categories" ("name", "slug", "description", "created_at", "updated_at") VALUES (NULL, NULL, NULL, '2026-11-04 12:12:03.505033', '2026-11-04 12:12:03.505033') RETURNING "id"
TRANSACTION (1.6ms) COMMIT
=> true
- บรรทัด 2 ได้ 2 —
SELECT COUNT(*) … WHERE "categories"."slug" = 'one-dish'= "นับแฟ้มในลิ้นชัก categories ที่ช่อง slug เป็น one-dish" · slug ที่ควรเป็นชื่อเฉพาะของหมวดซ้ำกันแล้ว - บรรทัด 3 ได้
true—VALUES (NULL, NULL, NULL, …)= เก็บแฟ้มที่ "ไม่มีค่าเลย" สักช่อง (NULL= nil ของฝั่งตาราง) · ต่างจากฟอร์มในหัวข้อ 3 ที่ส่งข้อความว่าง''
หน้า /categories ตอนนี้โชว์ 3 แถว: อาหารจานเดียว · อาหารเส้น (slug ซ้ำ) · และแถวว่างทั้งแถว (id ของคุณอาจต่าง)
ทั้ง scaffold และ AI เขียนโค้ดที่ทำงานได้ให้เราได้ · แต่ทั้งคู่ไม่รู้ว่าร้านเราทำงานยังไง — สไลด์หน้า 55 แบ่งของที่ขาดเป็น 6 เรื่อง ผมโยงแต่ละเรื่องกลับไปบทที่สอนวิธีเติม:
| เรื่อง | scaffold ให้อะไร · ขาดอะไร | เติมด้วยอะไร · บทไหน |
|---|---|---|
| 1 · กฎของร้าน | ไม่รู้ว่าชื่อหมวดซ้ำได้ไหม · ลบหมวดแล้วบทความในหมวดเป็นยังไง · ใครทำอะไรได้ | คำตอบมาจากเจ้าของร้าน ไม่ใช่จากโค้ด · แล้วค่อยแปลงเป็นข้อข้างล่าง |
| 2 · ปริมาณข้อมูลจริง | ไม่รู้ว่าลิ้นชักนี้จะมี 100 แผ่นหรือ 10 ล้านแผ่น · ไม่มี index สักตัว | index ที่ช่องที่ใช้ค้นหา — บท 109 |
| 3 · กฎที่ตัวตู้ | ไม่มี null: false · ไม่มี unique index (เห็นแล้วข้างบน) | validation บท 110 + NOT NULL · unique index บท 111 คู่กันเสมอ |
| 4 · จำนวนครั้งที่เดินไปตู้ | Category.all ไม่มี limit · ไม่มีการแบ่งหน้า · ไม่มี includes | limit บท 113 · includes กัน N+1 บท 114 |
| 5 · ใครมีสิทธิ์ | มี strong params (กันช่องแปลก) แต่ไม่มีการตรวจคน — ใครเปิดหน้าได้ก็กด Destroy ได้ | strong params บท 117 · การตรวจสิทธิ์ (authorization) = Day 4 |
| 6 · ภาษาและข้อความ | ข้อความอังกฤษเขียนตายตัว ("Category was successfully created." · No categories found.) | แก้ข้อความเอง หรือใช้ระบบแปลภาษา i18n ของ Rails (นอกขอบเขตคอร์ส ลิงก์อยู่ท้ายบท) |
ข้อ 4 ดูจาก log ได้เลย — ตอนเปิดหน้ารายการ Rails ยิง SELECT "categories".* FROM "categories"
("ขอทุกช่องของทุกแผ่นในลิ้นชัก categories" ไม่มี LIMIT) · วันนี้มี 3 แผ่นเลยเร็ว · สไลด์ยกตัวอย่างว่าถ้ามี 50,000 แผ่นก็ดึงมาหมดทุกครั้งที่มีคนเปิดหน้า
params.expect คือพนักงานต้อนรับที่ตัดช่องแปลก ๆ ทิ้งจากใบสั่ง — ลูกค้าเขียนว่า "ฉันเป็นผู้จัดการ" ก็ถูกตัด เหลือแค่ name · slug · description ·
แต่ถ้าลูกค้าคนไหนเดินมายื่นใบสั่ง"ทิ้งแฟ้มหมวดอาหารจานเดียว" ที่กรอกถูกแบบฟอร์มทุกช่อง พนักงานก็ส่งเข้าครัวทันที — ไม่มีใครถามว่าคุณเป็นใคร
ที่ไม่เหมือนกัน: ร้านจริงอย่างน้อยยังเห็นหน้าลูกค้า · เว็บที่ไม่มีระบบ login และไม่มีการตรวจสิทธิ์ ไม่รู้แม้แต่ว่าใครกด · และการตรวจสิทธิ์ไม่ใช่ด่านเดียวหน้าร้าน — ต้องถามทุกเมนูที่แตะของสำคัญ (ลบ · แก้ · ดูของคนอื่น) ซึ่งเป็นเรื่องของ Day 4
- authorization (การตรวจสิทธิ์)
- คำถาม "คนนี้ทำสิ่งนี้ได้ไหม" · คนละเรื่องกับ strong params ที่ถามว่า "ช่องนี้รับได้ไหม" · Day 4 ใช้ gem ชื่อ Pundit · ถาม senior ได้ว่า "action destroy นี้ใครกดได้บ้าง และเราเช็คที่ไหน" — คำถามที่ควรถามทุกครั้งที่มีโค้ดเพิ่มปุ่มลบ
- business rule (กฎของธุรกิจ)
- กฎที่มาจากการทำงานของร้าน ไม่ได้มาจากโค้ด เช่น "ชื่อหมวดห้ามซ้ำ" "หมวดที่ยังมีบทความห้ามลบ" · AI เดาไม่ได้ เราต้องบอก · พิมพ์บอก AI ได้ว่า "business rules: slug must be unique and lowercase; a category with posts cannot be deleted" ก่อนให้มันเขียนโค้ด
สไลด์หน้า 54 ร่างโค้ดที่ต้องเติมไว้ให้เห็นภาพ — อ่านเป็นแผนที่ว่าแต่ละบรรทัดมาจากบทไหน (ลงมือเติมจริงใน Lab บท 119):
| โค้ดจากสไลด์หน้า 54 | แปลว่า | บท |
|---|---|---|
validates :name, presence: true, length: { maximum: 80 } | คนดูแลสต็อกห้ามรับชื่อว่าง และยาวไม่เกิน 80 ตัว | 110 |
validates :slug, presence: true, uniqueness: true, format: { with: /\A[a-z0-9-]+\z/ } | slug ห้ามว่าง ห้ามซ้ำ และมีได้แค่ a–z · 0–9 · ขีด (/…/ คือ regex = แบบของตัวอักษรที่ยอมรับ บท 110) | 110 · 111 |
change_column_null :categories, :name, false (และ :slug) | migration ที่สั่งให้ตัวตู้ห้ามช่องนี้เป็น NULL | 108 · 111 |
add_index :categories, :slug, unique: true | ติดป้ายแท็บที่ slug และให้ตัวตู้ห้ามซ้ำ | 109 · 111 |
has_many :posts, dependent: :restrict_with_error | หมวดหนึ่งมีหลายบทความ · หมวดที่ยังมีบทความห้ามลบ | 115 · 116 |
scope :alphabetical, -> { order(:name) } | ตั้งชื่อเล่น alphabetical ให้เงื่อนไข "เรียงตามชื่อ" ที่ใช้บ่อย แล้วเรียก Category.alphabetical ได้ ·
-> { … } คือ lambda = ก้อนโค้ดที่เก็บไว้รันทีหลัง (บท 112 · 115) | 113 · 115 |
Category.alphabetical.includes(:posts).limit(50) | เรียงตามชื่อ (scope แถวบน) · หยิบบทความมาทีเดียว · เอาแค่ 50 แผ่น | 113 · 114 |
⚠️ สองแถวสุดท้ายพิมพ์ตามในแอปเราแล้วพัง — ตาราง posts ยังไม่มีช่อง category_id (scaffold ไม่ได้สร้างความสัมพันธ์ให้) ·
ผมลองแล้วได้ PG::UndefinedColumn: ERROR: column posts.category_id does not exist · ต้องเพิ่มช่องนั้นก่อน (บท 115)
⚠️ และเติม NOT NULL ทีหลังไม่ได้ ถ้าตู้มีแฟ้มเสียอยู่แล้ว — ผมสั่ง change_column_null :categories, :name, false หลังจากเก็บแฟ้มว่างไปแล้ว migration ล้มทันที:
PG::NotNullViolation: ERROR: column "name" of relation "categories" contains null values · ต้องเก็บกวาดแฟ้มเสียก่อน ·
นี่คือเหตุผลที่ควรถาม checklist ข้างล่างตอนรับงาน ไม่ใช่หลังเปิดใช้ไปแล้ว
อีกเรื่องที่เห็นจากสองการทดลอง: ฟอร์มส่งข้อความว่าง '' ส่วน console ส่ง NULL ·
NOT NULL ของตู้กันได้แค่ NULL — ผมลองกับตารางทดสอบที่มี NOT NULL: ใส่ '' ผ่าน · ใส่ NULL ได้ ActiveRecord::NotNullViolation ·
ข้อความว่างจากฟอร์มจึงต้องให้ presence: true เป็นคนกัน · validation กับ constraint ต้องมาคู่กัน เพราะกันคนละรู (ประโยคหลักข้อ ② ของวัน)
7 · checklist 8 ข้อ ตรวจงานที่ AI (หรือ scaffold) เขียน
เวลาได้โค้ดชั้นข้อมูลมาจาก AI · จาก scaffold · หรือจากเพื่อน ให้ไล่ถาม 8 ข้อนี้ก่อนรับ (สไลด์หน้า 56) · ทุกข้อคือเรื่องที่เรียนมาทั้งวัน — ถ้าข้อไหนตอบไม่ได้ ย้อนไปบทนั้น
| ข้อ | ถามอะไร | ทำไม | เรียนในบท |
|---|---|---|---|
| 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 |
ข้อ 1–2 คือ "กฎคู่" ของบท 111 (ซึ่งมี checklist ฉบับละเอียดของเรื่องนี้อยู่แล้ว) · ข้อ 3–7 คือ "ร้านตอนคนเยอะ" · ข้อ 8 คือ "ร้านที่มีคนไม่หวังดี" · scaffold ของเราตอบ "ไม่มี" เกือบทุกข้อ — ซึ่งเป็นเรื่องปกติของโค้ดตั้งต้น ไม่ใช่ความผิดของมัน
ซื้อบ้านจากโครงการ ผู้รับเหมาสร้างเสร็จเร็วและดูสวย · แต่ก่อนเซ็นรับ คนส่วนใหญ่เดินตรวจด้วยใบตรวจรับบ้าน: เปิดน้ำทุกก๊อก · เคาะกระเบื้องหาโพรง · ดูรอยร้าว · checklist 8 ข้อคือใบตรวจรับของโค้ดชั้นข้อมูล — AI คือผู้รับเหมา เราคือคนเซ็นรับ
ที่ไม่เหมือนกัน: บ้านตรวจครั้งเดียวตอนโอน · โค้ดต้องตรวจทุกครั้งที่มีการแก้ (ทุก PR = คำขอรวมโค้ดเข้าโปรเจกต์) · และใบตรวจรับบ้านถามว่า "ของที่สร้างมาเสียไหม" แต่ checklist นี้ถามว่า "ของที่ควรมีแต่ไม่ได้สร้าง มีไหม" — ซึ่งมองหายากกว่า เพราะต้องรู้ว่าควรมีอะไร
- code review (รีวิวโค้ด)
- การอ่านโค้ดคนอื่น (หรือของ AI) ก่อนรวมเข้าโปรเจกต์ · PR (pull request) คือคำขอรวมโค้ดที่เปิดให้คนอื่นรีวิว · คุยกับทีมได้ว่า "ผมไล่ checklist ชั้นข้อมูล 8 ข้อแล้ว ข้อ 3 กับ 8 ยังไม่ผ่าน" — บอกชัดกว่า "ดูแล้วโอเค"
- boilerplate
- โค้ดหน้าตาเดิม ๆ ที่ทุกแอปต้องมี (CRUD 7 action · ฟอร์ม · หน้ารายการ) — ของที่ scaffold และ AI ทำได้ดีที่สุด · พิมพ์บอก AI ได้ว่า "generate the boilerplate, then list what business rules and DB constraints you assumed" ให้มันบอกเองว่าเดาอะไรไว้
ภารกิจ — scaffold Ingredient แล้วตรวจรับตาม checklist
🎯 แผนกวัตถุดิบ (~10 นาที)
ร้านอยากมีรายชื่อวัตถุดิบ เช่น กุ้ง · ใบกะเพรา · กฎของร้าน: ชื่อวัตถุดิบห้ามว่าง และห้ามซ้ำ
(ไม่ใช้ชื่อ Tag เพราะบท 115ให้คุณสร้าง model Tag ไว้แล้ว — scaffold ชื่อซ้ำจะไม่ยอมทำงาน)
- ในโฟลเดอร์แอป สั่ง
bin/rails generate scaffold Ingredient name:stringแล้วนับว่าได้ไฟล์createกี่บรรทัด - สั่ง
bin/rails db:migrate· เปิด server แล้วเข้าhttp://localhost:3000/ingredients· สร้างวัตถุดิบกุ้งสองครั้ง กับวัตถุดิบที่ไม่กรอกชื่อหนึ่งครั้ง - เปิด
app/models/ingredient.rb·db/migrate/…_create_ingredients.rb·app/controllers/ingredients_controller.rbแล้วไล่ checklist ทั้ง 8 ข้อ จดว่าข้อไหน "ผ่าน" · "ไม่ผ่าน" · "ไม่เกี่ยว" - เขียนประโยคเดียวที่จะสั่ง AI ให้เติมของที่ขาด (ไม่ต้องให้มันทำจริง — Lab บท 119 ทำ)
ควรเห็น: create 17 บรรทัด (16 ไฟล์ + โฟลเดอร์ app/views/ingredients) + route resources :ingredients · หน้า /ingredients โชว์ กุ้ง สองแถวกับแถวว่างหนึ่งแถว ไม่มีอะไรห้าม
ถ้าเจอ error นี้ แปลว่า…
- หน้าเว็บขึ้น
ActiveRecord::PendingMigrationError·Migrations are pending. To resolve this issue, run: bin/rails db:migrate→ มีใบสั่งช่างที่ยังไม่ได้ทำ (ข้ามขั้น 2) · สั่งbin/rails db:migrateแล้ว refresh - สั่ง generate รอบสองแล้วขึ้น
The name 'Ingredient' is either already used in your application or reserved by Ruby on Rails.→ มีIngredientอยู่แล้วจากรอบแรก · อยากเริ่มใหม่ให้ย้อนตามลำดับ:bin/rails db:rollback(รื้องานช่างใบล่าสุด บท 108) แล้วbin/rails destroy scaffold Ingredient(ลบไฟล์ทั้งชุด) - หน้าไม่มีสี →
bin/rails tailwindcss:buildแบบหัวข้อ 3
ทำเสร็จแล้ว รื้อ Ingredient ออก ด้วยสองคำสั่งเดียวกับข้างบน ตามลำดับ: bin/rails db:rollback แล้ว bin/rails destroy scaffold Ingredient ·
สั่ง bin/rails db:migrate:status แล้วบรรทัดล่างสุดต้องกลับมาเป็น up … Create categories ·
Category เก็บไว้ได้ — Lab บท 119 มีขั้นตอนรื้อ Category ของบทนี้บอกไว้ต้นบท (ขั้นตอนนั้นเขียนไว้สำหรับกรณีที่ Category เป็นงานช่างใบล่าสุด จึงต้องรื้อ Ingredient ก่อน)
ไฟล์ที่ได้ (ผลจริงจากเครื่องผม · ตัดมาเฉพาะบรรทัด create กับ route):
create db/migrate/20261104124304_create_ingredients.rb
create app/models/ingredient.rb
create test/models/ingredient_test.rb
create test/fixtures/ingredients.yml
route resources :ingredients
create app/controllers/ingredients_controller.rb
create app/views/ingredients
create app/views/ingredients/index.html.erb
create app/views/ingredients/edit.html.erb
create app/views/ingredients/show.html.erb
create app/views/ingredients/new.html.erb
create app/views/ingredients/_form.html.erb
create app/views/ingredients/_ingredient.html.erb
create test/controllers/ingredients_controller_test.rb
create app/helpers/ingredients_helper.rb
create app/views/ingredients/index.json.jbuilder
create app/views/ingredients/show.json.jbuilder
create app/views/ingredients/_ingredient.json.jbuilder
17 บรรทัด = 16 ไฟล์ + โฟลเดอร์ app/views/ingredients อีกหนึ่ง ชุดเดียวกับ Category ทุกไฟล์ ·
migration ที่ได้:
class CreateIngredients < ActiveRecord::Migration[8.1]
def change
create_table :ingredients do |t|
t.string :name
t.timestamps
end
end
end
| ข้อ | ผล | เหตุผล |
|---|---|---|
| 1 uniqueness + unique index | ❌ ไม่ผ่าน | กฎร้านบอกห้ามซ้ำ แต่ไม่มีทั้ง uniqueness และ unique index · เห็นจริงว่า กุ้ง ซ้ำได้ |
| 2 presence + NOT NULL | ❌ ไม่ผ่าน | model ว่าง · migration ไม่มี null: false · วัตถุดิบที่ไม่มีชื่อก็เก็บได้ |
| 3 index ช่องที่ค้นหา | ❌ ไม่ผ่าน | ถ้าจะหาวัตถุดิบจากชื่อ (where(name: …)) ควรมี index · unique index ของข้อ 1 ทำหน้าที่นี้ได้ด้วย |
| 4 dependent | ➖ ยังไม่เกี่ยว | Ingredient ยังไม่ผูกกับตารางอื่น · พอผูกแล้ว (เมนูหนึ่งใช้หลายวัตถุดิบ วัตถุดิบหนึ่งอยู่ในหลายเมนู — has_many :through บท 115) ต้องตอบข้อนี้ |
| 5 includes | ➖ ยังไม่เกี่ยว | หน้า /ingredients ไม่ได้แตะของที่ผูกกัน · log ของหน้ารายการมีแต่ query ของลิ้นชัก ingredients |
| 6 limit | ❌ ไม่ผ่าน | @ingredients = Ingredient.all ใน index |
| 7 lock | ✅ ผ่าน | create_table ของตารางใหม่ที่ว่างเปล่า ไม่มีใครรอ (ต่างจากการแก้ตารางเดิมที่มีข้อมูลเยอะ บท 109) |
| 8 ใครมีสิทธิ์ | ❌ ไม่ผ่าน | ใครเปิดหน้าได้ก็สร้าง/แก้/ลบวัตถุดิบได้ · มีแค่ params.expect(ingredient: [ :name ]) ที่คัดช่อง |
ตัวอย่างประโยคสั่ง AI (ภาษาไทยก็ได้):
Ingredient ต้องมี name ห้ามว่าง และห้ามซ้ำ — เติมทั้ง validation ใน model และ constraint ใน migration ใหม่
(null: false + unique index) · หน้า index ให้เรียงตามชื่อและ limit 50 · อย่าแก้ migration เดิมที่รันไปแล้ว
ประโยคท้าย "อย่าแก้ migration เดิม" มาจากกับดักในบท 108 · ถ้าไม่บอก AI อาจแก้ไฟล์ create_ingredients ตรง ๆ ซึ่งเครื่องที่รันไปแล้วจะไม่รู้เรื่อง
ทดสอบความเข้าใจ
ข้อ 1 · ใน controller ที่ scaffold เขียนให้ before_action :set_category, only: %i[ show edit update destroy ] — ทำไม index · new · create ไม่อยู่ในรายการ?
ตัวเลือกที่ 3 ถูก · set_category ทำ Category.find(params.expect(:id)) ซึ่งต้องมี :id ใน URL ·
ดูตาราง routes หัวข้อ 2: มีแค่ show · edit · update · destroy ที่ URL มี :id · index ดูทุกแผ่น · new กับ create ทำแฟ้มใหม่ที่ยังไม่มีเลขที่
ตัวเลือกที่ 1 ผิด — ทั้งเจ็ดเป็นเมนูธรรมดา (อยู่เหนือ private) · ตัวเลือกที่ 2 ผิด — ถ้าเติม index เข้าไป find จะหาเลขที่ที่ไม่มีแล้วระเบิด ·
ตัวเลือกที่ 4 ผิด — ปัญหาไม่ได้อยู่ที่วิธีหา แต่อยู่ที่ไม่มีอะไรให้หา
จุดที่ต้องจำ: before_action … only: = "เมนูที่ต้องหยิบแฟ้มเดิมก่อน" · ดูจาก URL ว่ามี :id ไหม
ข้อ 2 · category_params เขียนว่า params.expect(category: [ :name, :slug, :description ]) — บรรทัดนี้ป้องกันอะไรได้?
ตัวเลือกที่ 1 ถูก · strong params คัดช่อง (บท 117) ไม่ได้ดูคน ·
ตัวเลือกที่ 2 คือ authorization ซึ่ง scaffold ไม่มี (Day 4) ·
ตัวเลือกที่ 3 คือ presence ของ validation — predict หัวข้อ 3 พิสูจน์แล้วว่าช่องว่างผ่านได้ ·
ตัวเลือกที่ 4 คือ uniqueness + unique index — console หัวข้อ 6 ได้ slug ซ้ำ 2 แผ่น
จุดที่ต้องจำ: strong params กันรูปร่างของข้อมูล · validation กันค่า · constraint กันทุกทาง · authorization กันคน — สี่อย่างนี้แทนกันไม่ได้
ข้อ 3 · AI ส่ง model มาให้ มีบรรทัด validates :email, uniqueness: true — คำถามแรกจาก checklist ที่ควรถามคืออะไร?
ตัวเลือกที่ 4 ถูก · checklist ข้อ 1 · บท 111 พิสูจน์ว่า uniqueness อย่างเดียวยังได้อีเมลซ้ำเมื่อมีสองคนกดพร้อมกัน · ต้องมีตัวตู้ (unique index) เป็นคนตัดสินสุดท้าย
ตัวเลือกที่ 1 เป็นคำถามที่ดี (ข้อ 6) แต่ไม่เกี่ยวกับบรรทัดนี้ · ตัวเลือกที่ 2 เรื่องข้อความ ไม่ทำให้ข้อมูลเสีย · ตัวเลือกที่ 3 ไม่เกี่ยวกับความถูกต้องของข้อมูลเลย
จุดที่ต้องจำ: เห็น uniqueness → ถามหา unique index · เห็น presence → ถามหา null: false · กฎใน model ต้องมีคู่ที่ตัวตู้
ข้อ 4 · def index; @categories = Category.all; end ที่ scaffold เขียนให้ — ทำงานได้ถูกต้องวันนี้ แล้วจะเป็นปัญหาตอนไหน?
ตัวเลือกที่ 2 ถูก · log หัวข้อ 6: SELECT "categories".* FROM "categories" ไม่มี LIMIT = ขอทุกแผ่นทุกครั้ง ·
3 แผ่นเร็ว · หลายหมื่นแผ่นทั้งช้าและกินหน่วยความจำ · checklist ข้อ 6 · แก้ด้วย limit (บท 113) หรือการแบ่งหน้า
ตัวเลือกที่ 1 ผิด — คนละคนก็ได้ผลแยกกัน ไม่ซ้ำกันเอง · ตัวเลือกที่ 3 ผิด — all ไม่ได้หาแถวไหนเจาะจง ลบไปแล้วก็แค่ไม่โชว์ (เราลบแฟ้มที่ 2 แล้วหน้ารายการยังขึ้นปกติ) ·
ตัวเลือกที่ 4 ผิด — all ยังใช้ได้ใน Rails 8.1.4 ที่ผมรัน
จุดที่ต้องจำ: โค้ดที่ "ทำงานได้" กับโค้ดที่ "ทำงานได้ตอนข้อมูลเยอะ" คนละเรื่อง · Model.all ใน index = ระเบิดเวลา
ถามกลับได้เลย — ถ้าอ่าน controller แล้วติดบรรทัดไหน หรืออยากรู้ว่า checklist ข้อไหนใช้กับโค้ดในงานของคุณยังไง ก็อปโค้ดนั้นมาถาม AI ได้เลยว่า "ไล่ checklist 8 ข้อนี้กับโค้ดนี้ให้หน่อย"
ถ้าเปิดสไลด์ดูประกอบ
| หน้า | สไลด์เขียน | ของจริง (Rails 8.1.4) | ต้องสนใจไหม |
|---|---|---|---|
| 52 | 13 ไฟล์ · ~220 บรรทัด · 1.4 วินาที | ไฟล์ใหม่ 16 ไฟล์ (มี helper กับ .jbuilder 3 ไฟล์เพิ่ม) + routes 1 บรรทัด = 265 บรรทัด · ~0.4 วินาที | ไม่ต้อง — แก่นเหมือนกัน |
| 53 | controller ไม่มี respond_to · ข้อความ "Category was created." · find(params[:id]) | มี respond_to + format.json ทุก action · "Category was successfully created." · find(params.expect(:id)) | ไม่ต้อง — สไลด์ย่อให้สั้น |
| 53 | status: :unprocessable_entity | :unprocessable_content · ชื่อเก่าในสไลด์ยังได้ 422 เหมือนกัน และ Rails 8.1.4 ไม่เตือนอะไร แต่ Rack ประกาศเลิกใช้ชื่อเก่าแล้ว (บท 110) | สนใจถ้าพิมพ์เอง — ใช้ชื่อใหม่ |
| 54 | has_many :posts · includes(:posts) | ตาราง posts ยังไม่มี category_id → PG::UndefinedColumn | สนใจ — ต้องเพิ่มช่องก่อน (บท 115) |
| 54 | before_validation { self.slug ||= name.to_s.parameterize } | "อาหารจานเดียว".parameterize ได้ "" (ตัดอักษรไทยทิ้งหมด) · และฟอร์มส่ง slug มาเป็น "" ไม่ใช่ nil → ||= ไม่ทำงาน | สนใจถ้าชื่อเป็นภาษาไทย — callback บท 112 |
| ทั้งชุด | Ruby 4.0.6 · Rails 8.1.3 | เครื่องตรวจใช้ Ruby 4.0.9 · Rails 8.1.4 | ไม่ต้อง |
อ้างอิง
- Rails Guides — The Rails Command Line —
อ่านหัวข้อ 4.4 Generating Scaffolds รายการไฟล์ทางการของ scaffold เกือบเหมือนหัวข้อ 2 ของบทนี้ (คู่มือใช้
Post· ขึ้นerbแทนtailwindcssและมีไฟล์เทสtest/system/…เพิ่มหนึ่งไฟล์ที่แอปเราไม่มี) - Rails Guides — Action Controller Overview —
อ่านหัวข้อ 7 Controller Callbacks (
before_action·only:) แล้วย้อนไปหัวข้อ 4 Strong Parameters (params.expect) - Rails API — ActionController::MimeResponds#respond_to —
อ่านตัวอย่างแรกที่เขียน
respond_to do |format|และประโยค "What that says is…" ใต้มัน · ตัวอย่างใช้format.xmlแทน JSON แต่หลักเดียวกัน (ทานที่ร้าน vs กลับบ้าน) - Rails Guides — Action View Form Helpers —
อ่านหัวข้อ 2 Creating Forms with Model Objects ที่อธิบายว่า
form_with model:เลือก create หรือ update จากแฟ้มใหม่หรือเก่า - Rails Guides — Layouts and Rendering in Rails —
อ่านหัวข้อ 3.4 Using Partials (ไฟล์
_form·_category) - Rails Guides — Rails Internationalization (I18n) API — แค่อ่านย่อหน้าแรกให้รู้ว่ามีระบบแปลข้อความ (ข้อ 6 ของของที่ขาด) · ไม่อยู่ในคอร์ส