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

scaffold — โค้ดที่เครื่องเขียนให้ทั้งชุด แล้วเราต้องอ่านให้ออก

~25 นาที (มีลงมือสองรอบ) · ควรผ่าน บท 110 · บท 111 · บท 117 มาก่อน · ใช้ของจาก บท 102 (controller) ด้วย

Rails มีคำสั่งเดียวที่เขียนแผนกใหม่ให้ทั้งชุด — ตาราง · model · controller · หน้าเว็บ 4 หน้า · เทส — ใช้เวลาไม่ถึงวินาที
บทนี้คุณจะสั่งมันจริง เปิดหน้าเว็บที่ได้ ลองสร้าง แก้ ลบ แล้วอ่านโค้ดที่มันเขียนทีละไฟล์ด้วยความรู้ที่มีอยู่แล้ว
อ่านจบแล้วคุณจะชี้ได้ว่ามันลืมอะไร และมี checklist 8 ข้อไว้ตรวจโค้ดที่ AI เขียนให้ — เพราะของที่ scaffold ลืม คือของชุดเดียวกับที่ AI ลืม

🔁 ทวนก่อนเริ่ม — คำพวกนี้จะโผล่ในบทนี้ทั้งหมด

🧰 เวอร์ชันที่ใช้ตรวจ: 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

จัดกลุ่มใหม่ให้อ่านง่าย — ทุกไฟล์เข้าช่องในร้านที่เรารู้จักแล้ว:

ไฟล์คืออะไรในร้านอาหาร
db/migrate/…_create_categories.rbสร้างตาราง categories 3 ช่อง + เวลาสร้าง/แก้ใบสั่งช่างต่อลิ้นชักใหม่ (บท 108)
app/models/category.rbmodel Categoryคนดูแลสต็อกของลิ้นชักนี้
config/routes.rb (+1 บรรทัด)resources :categories = 8 เส้นรายการใหม่ในมือพนักงานต้อนรับ
app/controllers/categories_controller.rb7 action ครบหัวหน้าเชฟกับ 7 เมนู
app/views/categories/*.html.erb (6 ไฟล์)หน้ารายการ · ดูทีละชิ้น · สร้าง · แก้ + ชิ้นส่วนใช้ซ้ำ 2 ชิ้นแบบจัดจาน
app/views/categories/*.json.jbuilder (3 ไฟล์)แบบตอบเป็น JSON (หัวข้อ 4)แบบใส่กล่องกลับบ้าน
app/helpers/categories_helper.rbmodule ว่าง ๆ ไว้ใส่ 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)

ของแถม: จำ (.: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

ท่อน 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 เป๊ะ:

ของใหม่: respond_to do |format| = "ถามก่อนว่าลูกค้าอยากได้คำตอบแบบไหน" · มาจากหน้าเว็บ (HTML) → ทำตาม format.html { … } · มาทาง .json → ทำตาม format.json { … } · { … } คือ block (บท 93) ที่จะรันเฉพาะแบบที่ตรงกับลูกค้า

🥡 เทียบให้เห็นภาพ — ทานที่ร้าน หรือ ใส่กล่องกลับบ้าน

เมนูเดียวกัน (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

ท่อน 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

✅ ของที่ 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

ส่วนฟอร์มอยู่ในไฟล์ที่ชื่อขึ้นต้นด้วยขีดล่าง · ผมตัดคลาสแต่งหน้าตา (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 %>
🗣 ศัพท์ที่เพิ่งเรียนไป
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 · ไม่มีการแบ่งหน้า · ไม่มี includeslimit บท 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 ที่สั่งให้ตัวตู้ห้ามช่องนี้เป็น NULL108 · 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 ถูกไหมลบแม่แล้วลูกหายตาม · ห้ามลบ · หรือค้างกำพร้า — คำตอบมาจากธุรกิจ ไม่ใช่จาก AI116
5วน each แล้วแตะของที่ผูกกัน → มี includes ไหมไม่มี = N+1 (เดินไปตู้ทีละแผ่นซ้ำ ๆ แทนที่จะหยิบทีเดียวทั้งตะกร้า) · เปิด log นับ query ก่อนรับงานเสมอ114
6หน้ารายการ มี limit หรือการแบ่งหน้าไหมModel.all ใน index = ระเบิดเวลาที่รอวันข้อมูลโต113
7migration นี้ แตะกี่แถว ระหว่างนั้นใครต้องรอตารางใหญ่ + lock (ปิดลิ้นชักไว้ระหว่างช่างทำงาน) = แอปค้างทั้งร้าน109
8ใครมีสิทธิ์ทำอันนี้strong params ไม่ใช่ authorization117 · 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 ชื่อซ้ำจะไม่ยอมทำงาน)

  1. ในโฟลเดอร์แอป สั่ง bin/rails generate scaffold Ingredient name:string แล้วนับว่าได้ไฟล์ create กี่บรรทัด
  2. สั่ง bin/rails db:migrate · เปิด server แล้วเข้า http://localhost:3000/ingredients · สร้างวัตถุดิบ กุ้ง สองครั้ง กับวัตถุดิบที่ไม่กรอกชื่อหนึ่งครั้ง
  3. เปิด app/models/ingredient.rb · db/migrate/…_create_ingredients.rb · app/controllers/ingredients_controller.rb แล้วไล่ checklist ทั้ง 8 ข้อ จดว่าข้อไหน "ผ่าน" · "ไม่ผ่าน" · "ไม่เกี่ยว"
  4. เขียนประโยคเดียวที่จะสั่ง AI ให้เติมของที่ขาด (ไม่ต้องให้มันทำจริง — Lab บท 119 ทำ)

ควรเห็น: create 17 บรรทัด (16 ไฟล์ + โฟลเดอร์ app/views/ingredients) + route resources :ingredients · หน้า /ingredients โชว์ กุ้ง สองแถวกับแถวว่างหนึ่งแถว ไม่มีอะไรห้าม

ถ้าเจอ error นี้ แปลว่า…

ทำเสร็จแล้ว รื้อ 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)ต้องสนใจไหม
5213 ไฟล์ · ~220 บรรทัด · 1.4 วินาทีไฟล์ใหม่ 16 ไฟล์ (มี helper กับ .jbuilder 3 ไฟล์เพิ่ม) + routes 1 บรรทัด = 265 บรรทัด · ~0.4 วินาทีไม่ต้อง — แก่นเหมือนกัน
53controller ไม่มี respond_to · ข้อความ "Category was created." · find(params[:id])มี respond_to + format.json ทุก action · "Category was successfully created." · find(params.expect(:id))ไม่ต้อง — สไลด์ย่อให้สั้น
53status: :unprocessable_entity:unprocessable_content · ชื่อเก่าในสไลด์ยังได้ 422 เหมือนกัน และ Rails 8.1.4 ไม่เตือนอะไร แต่ Rack ประกาศเลิกใช้ชื่อเก่าแล้ว (บท 110)สนใจถ้าพิมพ์เอง — ใช้ชื่อใหม่
54has_many :posts · includes(:posts)ตาราง posts ยังไม่มี category_id → PG::UndefinedColumnสนใจ — ต้องเพิ่มช่องก่อน (บท 115)
54before_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ไม่ต้อง

อ้างอิง