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

Lab 2 — เติมของที่ scaffold ไม่ให้ แล้วเขียน prompt ให้ AI ทำได้ครบ

~25 นาทีอ่าน + ลงมือทำตามอีก ~50 นาที · ควรผ่าน บท 110 (validation) · บท 111 (constraint) · บท 114 (N+1) · บท 116 (dependent) · บท 118 (scaffold) มาก่อน

บทนี้เป็น Lab ท้ายวัน — ไม่มีของใหม่ให้จำ แต่จะเอาทุกเรื่องของ Day 2 มาใช้จริงกับแผนกใหม่หนึ่งแผนก: หมวดอาหาร (Category)
เราจะสั่ง scaffold สร้างแผนกให้ทั้งชุด แล้วเติมของที่มันไม่ให้ทีละอย่าง: กฎตรวจแฟ้ม · กฎของตัวตู้ · ผูกกับบทความ · หน้ารายการที่ไม่เดินไปตู้หลายรอบ · ระหว่างทางจะทำให้พังโดยตั้งใจ เพื่อเห็นกับตาว่าด่านไหนกันอะไรได้
อ่านจบแล้วคุณจะตรวจโค้ดที่เครื่อง (หรือ AI) เขียนให้ได้ว่าขาดอะไร และเขียน prompt (ข้อความสั่งงาน AI) ที่บอกครบตั้งแต่รอบแรก

🔁 ทวนก่อนเริ่ม — Lab นี้ใช้คำพวกนี้ตลอด ถ้าข้อไหนไม่คุ้นให้กลับไปบทนั้นก่อน

🍜 ข้อมูลตัวอย่าง: ทำในโปรเจกต์ 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 ตรงกับเรื่องที่เรียนมาทั้งวัน:

ขั้น 0 เตรียมร้าน เปิด server · เช็คว่าไม่มี Category ค้างจากบท 118 │ ขั้น 1 scaffold ชุดเปิดแผนกสำเร็จรูป → ใช้ได้ทันที แต่รับฟอร์มว่าง ๆ ด้วย (บท 118) │ ขั้น 2 validation คนดูแลสต็อกเริ่มตรวจแฟ้ม → แต่ยังมีประตูข้าง (บท 110) │ ขั้น 3 constraint สั่งช่างปรับตัวตู้ → ช่างไม่ยอม เพราะในตู้มีแฟ้มเสีย (บท 108 · 111) │ ขั้น 4 ทำพังโดยตั้งใจ เข้าทางประตูข้างอีกรอบ → คราวนี้ตัวตู้ปฏิเสธ (บท 111) │ ขั้น 5 ภารกิจ: ผูกกับบทความ หมวดหนึ่งมีหลายบทความ · ห้ามลบหมวดที่ยังมีบทความ (บท 115 · 116) │ ขั้น 6 ของแถม: หน้ารายการ เรียง · จำกัดจำนวน · หยิบทีเดียวทั้งตะกร้า (บท 113 · 114) │ ปิดท้าย เขียน prompt ให้ AI ทำทั้งหมดนี้ได้ในรอบเดียว

ขั้นที่สำคัญที่สุดคือ ขั้น 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) · ไม่ต้องอ่านทุกบรรทัด — ทวนแค่ว่าแต่ละกลุ่มคือใครในร้าน:

ต่อลิ้นชักจริง (ผลจริง):

$ 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
🍽 เทียบให้เห็นภาพ — ชุดเปิดแผนกใหม่จากแฟรนไชส์

สำนักงานใหญ่ส่งชุดเปิดแผนกมาให้ครบ: ลิ้นชักเปล่าหนึ่งลิ้นชัก · พนักงานต้อนรับที่รู้เส้นทาง · เชฟ · คนจัดจาน · แบบฟอร์มสั่งของ · เปิดร้านได้ทันทีในห้านาที
แต่ชุดนี้ถูกออกแบบให้ใช้ได้กับทุกสาขาทั่วประเทศ — มันไม่รู้ว่าร้านเรามีกฎว่า "หมวดต้องมีชื่อ" "ชื่อ 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 แล้วทำตามนี้ทีละข้อ:

  1. กด New category · กรอก Name = Street Food · Slug = street-food · กด Create Category → เห็น Category was successfully created.
  2. สร้างอีกอัน: Name = Dessert · Slug = dessert
  3. กด Edit ที่ Dessert · เปลี่ยน Name เป็น ขนมหวาน · กด Update → แก้ได้
  4. เปิดหน้า Dessert (กด Show) แล้วกด Destroy this category · ยืนยัน → หายไปจากรายการ
  5. สุดท้าย: กด 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 ที่ได้กลับมา"

สังเกตว่าฟอร์มส่งช่องที่ไม่ได้กรอกมาเป็น '' ไม่ใช่ 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

⚠️ สไลด์หน้า 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">

ต่อในครัวเดิม — ลองให้คนดูแลสต็อกตรวจแฟ้มใหม่หลายแบบ (ผลจริง):

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"]
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"]

ประตูข้าง: บันทึกโดยไม่ให้ตรวจ

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

สั่งช่างลงมือ (ผลจริง):

$ 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)

อ่านทีละส่วน (ข้อความยาว แต่ใจความอยู่สามจุด):

ตัวการคือแผ่น 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

สั่งช่างรอบสอง (ผลจริง):

$ 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

ลองประตูข้างกับกฎห้ามซ้ำด้วย — แอบใส่ 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

เทียบกับ 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 นาที)

  1. สร้างใบสั่งช่าง: bin/rails generate migration AddCategoryToPosts category:references · เปิดไฟล์ดูว่า generator เขียนอะไรมาให้ · ลอง bin/rails db:migrate ตามที่ได้มาเลย แล้วอ่าน error
  2. แก้ใบสั่งช่างให้บทความเดิมไม่พัง แล้ว migrate ใหม่จนผ่าน
  3. ใน app/models/category.rb เพิ่ม has_many :posts, dependent: :restrict_with_error ไว้บนสุดของ class
  4. ใน app/models/post.rb เพิ่ม belongs_to :category · แล้วใน console ลอง Post.find_by(title: "ต้มยำกุ้งน้ำข้น").update(views: 121) — ผ่านไหม? ถ้าไม่ผ่าน แก้ให้ผ่าน
  5. ใน console ให้ ต้มยำกุ้งน้ำข้น กับ ส้มตำปูปลาร้า อยู่หมวด อาหารไทย · แล้วลอง destroy หมวดอาหารไทย · ดู errors.full_messages
  6. ในเบราว์เซอร์ กด Destroy ที่หมวด อาหารไทย · เห็นอะไร? แก้ action destroy ใน controller ให้ได้ข้อความแทนหน้าแดง

ควรเห็น: ข้อ 5 destroy ได้ false และข้อความ Cannot delete record because dependent posts exist · หมวดยังอยู่ · ข้อ 6 หลังแก้ กลับมาหน้ารายการพร้อมข้อความเดียวกัน

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

ข้อ 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>
# 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 Count 3 บรรทัด = หนึ่งบรรทัดต่อหนึ่งหมวด · ทั้งหน้าไปตู้ 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

เปิด /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)

สไลด์หน้า 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 นี้ — ใส่ไว้เพราะสไลด์ขอ · ได้โค้ดกลับมาแล้วต้องรันตรวจเองเหมือนข้ออื่น

🗣 ศัพท์ของการสั่งงาน AI
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:

การบ้าน (ไม่บังคับ แต่ทำแล้วดี)

จากสไลด์หน้า 60 — เลือกทำตามแรงที่เหลือ:

  1. ทำ Lab นี้ให้จบ ถ้ายังค้างขั้นไหน · commit ทุกขั้นที่ผ่าน
  2. เอา prompt ที่เขียนในหัวข้อ "เขียน prompt ให้ AI" ไปลองกับ AI จริง ในโปรเจกต์ทดลอง (อย่าลองในโปรเจกต์ที่ทำ Lab ไว้แล้ว เพราะ Category มีอยู่แล้ว) แล้วตรวจของที่ได้ด้วย checklist 8 ข้อของ บท 118 · จดว่าข้อไหน AI ทำครบ ข้อไหนต้องสั่งซ้ำ
  3. ลอง gem สองตัวที่สไลด์แนะนำ ในโปรเจกต์ของคุณเอง (บทนี้ไม่ได้ติดตั้งและไม่ได้รัน — ข้อมูลด้านล่างมาจากเอกสารของ gem):
    • bullet — คอยดูระหว่างที่เราเปิดหน้าเว็บ ถ้าเจอ N+1 จะเตือน (เช่นใน log หรือกล่องแจ้งเตือนในเบราว์เซอร์) · เหมือนมีคนยืนนับว่าเราเดินไปตู้กี่รอบ
    • rack-mini-profiler — แปะป้ายเล็ก ๆ ที่มุมหน้าเว็บ บอกว่าหน้านี้ใช้เวลาเท่าไร และกดดู SQL ที่ยิงได้
    • วิธีติดตั้งอยู่ใน README ของแต่ละตัว (แก้ Gemfile แล้ว bundle install · บท 98) · ติดตั้งแล้วเปิดหน้า /categories ลองสลับมี/ไม่มี includes ดู
  4. พิมพ์ 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เปลี่ยนแค่บางส่วนของหน้าโดยไม่ต้องโหลดใหม่ทั้งหน้า
StimulusJavaScript ชิ้นเล็ก ๆ ที่ 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 ได้ทันที

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

หน้าสไลด์เขียนของจริงต้องสนใจไหม
54before_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 · 57has_many :posts, dependent: :restrict_with_errorposts ยังไม่มี category_id · ต้อง migration null: true + optional: true · และ destroy! ของ scaffold ให้หน้าแดงสนใจ — ภารกิจขั้น 5
58ให้ขอ params.expect ของ Rails 8 ใน promptscaffold ของ Rails 8.1 เขียน params.expect(category: [ :name, :slug ]) ให้อยู่แล้วแค่รู้ไว้ — ยังควรใส่ใน prompt เผื่อ AI ใช้แบบเก่า
58index action ขอ paginationRails ไม่มีระบบแบ่งหน้าสำเร็จรูปในตัว ปกติใช้ gem เพิ่ม · บทนี้ใช้ limit(50) แทนแค่รู้ไว้
60ใส่ gem bullet กับ rack-mini-profilerบทนี้ไม่ได้ติดตั้ง · ไม่ได้รัน · ไม่มีผลรันของสองตัวนี้ในบททำเองได้ในโปรเจกต์ของคุณ (การบ้านข้อ 3)

อ้างอิง