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

Validation — คนดูแลสต็อกตรวจแฟ้มก่อนเก็บ

~20 นาที · ควรผ่าน บทเรียน 107 (console CRUD) มาก่อน · ใช้ของจาก บท 102 และ บท 93 ด้วย

ตอนนี้แอปบล็อกของเรารับบทความที่ไม่มีชื่อเรื่องลงฐานข้อมูลได้หน้าตาเฉย ไม่มีใครห้าม
บทนี้คุณจะเขียนกฎให้ model ตรวจข้อมูลก่อนบันทึก เช่น "ชื่อเรื่องห้ามว่าง" แล้วลองในครัวทดลอง (console) ว่าข้อมูลผิดโดนตีกลับยังไง
อ่านจบแล้วคุณจะ: ใช้กฎสำเร็จรูป 5 แบบเป็น · เขียนกฎของร้านเราเองได้ · ไม่ตกกับดักตัว s ตัวเดียว · และเลือกได้ว่าเมื่อไหร่ใช้ save เมื่อไหร่ใช้ save!

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

🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 17 (สไลด์ใช้ 4.0.6 · 8.1.3 — เรื่องในบทนี้ไม่ต่างกัน) · ข้อมูลตัวอย่าง: ตาราง users / posts / comments และ seeds ชุดเดียวกับที่สร้างไว้ในบท 107 (สมชาย · สมหญิง · มานะ · บทความ 6 ชิ้น) · ผลรันทุกกล่องมาจากเครื่องจริง · เวลา ms · เลข 0x… · เลข id · วันเวลาใน SQL ของคุณจะไม่ตรงกับของผม

1 · ตอนนี้ตู้รับทุกอย่าง — ปัญหาที่ validation มาแก้

ลองเปิด console แล้วสร้างบทความที่ชื่อเรื่องเป็นข้อความว่าง ("") ดู · model Post ตอนนี้ยังไม่มีกฎอะไรเลยนอกจาก belongs_to :user กับ has_many :comments:

wongnok-blog(dev):001> p = Post.create(title: "", user: User.first)
  User Load (1.1ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT 1 /*application='WongnokBlog'*/
  TRANSACTION (0.6ms)  BEGIN /*application='WongnokBlog'*/
  Post Create (1.8ms)  INSERT INTO "posts" ("title", "body", "created_at", "updated_at", "published", "views", "user_id") VALUES ('', NULL, '2026-11-04 11:52:45.670994', '2026-11-04 11:52:45.670994', FALSE, 0, 1) RETURNING "id" /*application='WongnokBlog'*/
  TRANSACTION (1.3ms)  COMMIT /*application='WongnokBlog'*/
=>
#<Post:0x00000001245443c0
...
wongnok-blog(dev):002> p.persisted?
=> true

อ่านสมุดบันทึกการเบิกของ (log ของ SQL) ทีละบรรทัด:

บทความที่ไม่มีชื่อเรื่องเข้าตู้ไปเรียบร้อย · หน้ารายการบทความจะมีบรรทัดที่ไม่มีชื่อเรื่องโผล่มาให้ผู้อ่านงง
ลบทิ้งก่อนไปต่อด้วย p.destroy (บท 107) · แล้วเราจะหาคนมายืนตรวจแฟ้มก่อนเข้าตู้

validation คือกฎที่ model ตรวจข้อมูลก่อนบันทึก · ถ้าผิดกฎข้อไหน Rails ไม่ยอมเขียนลงฐานข้อมูล แล้วจดไว้ว่าผิดอะไร เพื่อเอาไปบอกผู้ใช้ได้ · กฎเขียนไว้ใน model ที่เดียว จึงตรวจทุกทางที่ข้อมูลผ่าน model — หน้าเว็บ · console · seeds

🗂 เทียบให้เห็นภาพ — คนดูแลสต็อกตรวจแฟ้มก่อนเก็บ

model Post คือคนดูแลสต็อกที่ฝังอยู่ในแฟ้มทุกแผ่น พาแฟ้มเดินเข้าตู้เอง (บท 106) · ตอนนี้เขารับทุกแผ่นที่ยื่นมา แม้จะเป็นกระดาษเปล่า
validation คือรายการตรวจที่เราแปะไว้บนโต๊ะเขา: "ช่องชื่อเรื่องห้ามว่าง" · ถ้าแฟ้มไม่ผ่าน เขาส่งคืนพร้อมโน้ตสีเหลืองบอกว่าผิดข้อไหน ไม่เดินไปตู้เลย

ที่ไม่เหมือนกัน: คนดูแลสต็อกจริงเห็นแฟ้มแปลก ๆ แล้วสงสัยเองได้ · Rails ตรวจเฉพาะข้อที่เขียนไว้ในรายการ ไม่มีสามัญสำนึก ไม่เขียนกฎ = ไม่ตรวจ · และคนดูแลสต็อกคนนี้ยืนอยู่หน้าประตูบานเดียว — ใครเดินอ้อมไปทางหลังร้าน (มีทางเลี่ยงจริง เดี๋ยวดูในบท 111) เขาไม่เห็น

เทียบกับ Go: ใน Gin (บท 39) เราติด tag binding:"required" บน struct ที่รับข้อมูลจาก request · การตรวจเกิดที่ handler ตอน c.ShouldBindJSON(&in) · โค้ดทางอื่นที่สร้างข้อมูล (สคริปต์ · งานเบื้องหลัง) ต้องตรวจเองอีกรอบ
Rails ย้ายกฎไปไว้ที่ model · ใครก็ตามที่บันทึกผ่าน Post โดนตรวจด้วยกฎชุดเดียวกัน

Go (Gin) — กฎอยู่บน struct ของ request

type PostInput struct {
    Title string `json:"title" binding:"required"`
}

// ตรวจตอน handler อ่าน body
var in PostInput
if err := c.ShouldBindJSON(&in); err != nil {
    c.JSON(400, gin.H{"error": err.Error()})
    return
}

Rails — กฎอยู่ใน model

class Post < ApplicationRecord
  validates :title, presence: true
end

# ตรวจทุกครั้งที่ save / create
# ไม่ว่าเรียกจากหน้าเว็บ console หรือ seeds
🗣 ศัพท์ที่เพิ่งเรียนไป
validation
กฎตรวจข้อมูลที่ model ทำก่อนบันทึก · ไม่ผ่าน = ไม่เขียนลงฐานข้อมูล + มีรายการปัญหาให้โชว์ · ถาม senior ได้ว่า "กฎข้อนี้ควรเป็น validation ใน model หรือเช็คใน controller" (คำตอบส่วนใหญ่คือ model)
INSERT · BEGIN · COMMIT
INSERT = คำสั่ง SQL "เพิ่มแถวใหม่" · BEGIN/COMMIT = เริ่มงาน/ยืนยันงาน (transaction — กลุ่มคำสั่งที่ "ทำทั้งหมดหรือไม่ทำเลย" · บท 111 ใช้จริง) · เวลาส่ง log ให้ AI ดู พิมพ์ได้ว่า "this INSERT ran even though title is empty — which validation should stop it?"

2 · กฎแรก — validates :title, presence: true

เปิดไฟล์ model แล้วเพิ่มบรรทัดเดียว:

# app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user
  has_many :comments

  validates :title, presence: true
end

ทีนี้ลองในครัวทดลอง · ถ้า console เปิดค้างไว้ พิมพ์ reload! ก่อน (ไม่งั้นมันยังใช้ model ฉบับเก่า) · ผมเปิด console ใหม่แล้วพิมพ์ 5 บรรทัดนี้:

wongnok-blog(dev):001> p = Post.new
=> #<Post:0x00000001244bf300 id: nil, title: nil, body: nil, created_at: nil, updated_at: nil, published: false, views: 0, user_id: nil>
wongnok-blog(dev):002> p.valid?
=> false
wongnok-blog(dev):003> p.errors.full_messages
=> ["User must exist", "Title can't be blank"]
wongnok-blog(dev):004> p.errors[:title]
=> ["can't be blank"]
wongnok-blog(dev):005> p.errors[:user]
=> ["must exist"]
บรรทัดแปลเป็นภาษาคน
p = Post.new สร้างแฟ้มเปล่าในมือ (ยังไม่เข้าตู้) · ทุกช่องเป็น nil ยกเว้น published: false กับ views: 0 ที่ตารางตั้งค่าเริ่มต้นไว้ (บท 108)
p.valid? "ช่วยตรวจตามรายการหน่อย ผ่านไหม" · ได้ false = ไม่ผ่าน · ตรวจอย่างเดียว ไม่บันทึก · ไม่มี SQL โผล่เลย เพราะยังไม่มีใครเดินไปตู้
p.errors.full_messages "ขอดูโน้ตสีเหลืองทั้งหมด เป็นประโยคเต็ม" · ได้ array (รายการ) ของข้อความภาษาอังกฤษ · ชื่อช่องขึ้นต้นประโยคให้เอง
p.errors[:title] "ขอดูเฉพาะโน้ตของช่อง title" · ได้ข้อความแบบไม่มีชื่อช่องนำหน้า · หน้าฟอร์มใช้ท่านี้โชว์ error ใต้ช่องที่ผิด
p.errors[:user] "ขอดูเฉพาะโน้ตของผู้เขียน" · ได้ "must exist" = ยังไม่ได้บอกว่าใครเขียน (โน้ตข้อนี้มาจากไหน ดูย่อหน้าถัดไป)

สังเกต: เราเขียนกฎแค่ข้อเดียว แต่ได้ error สองข้อ · "User must exist" มาจากไหน?
มาจาก belongs_to :user ซึ่งแอบเพิ่มกฎ "ต้องมีผู้เขียน" ให้เอง (ตัวเดียวกับที่เจอในบท 107) · บทความทุกชิ้นต้องเป็นของ user สักคน (เรื่อง belongs_to ละเอียดในบท 115) · ถ้าเปิดสไลด์หน้า 23 จะเห็นแค่ ["Title can't be blank"] เพราะตัวอย่างในสไลด์ไม่มี belongs_to

เทียบกับ Go: p.errors คล้าย err ที่ ShouldBindJSON คืนมา แต่ต่างกันตรงที่ Rails ไม่ได้คืนออกมา — มันติดอยู่กับแฟ้มเอง · ใครถือ p อยู่ก็เปิดดูได้ภายหลัง (ซึ่ง view จะใช้ประโยชน์จากตรงนี้ในหัวข้อ 7)

# model ยังเป็นแบบข้างบน (presence: true ที่ title)
wongnok-blog(dev):001> q = Post.new(title: "   ", user: User.first)
wongnok-blog(dev):002> q.valid?
#                          ↑ ชื่อเรื่องเป็นช่องว่างสามตัว · มีผู้เขียนแล้ว

คิดว่าได้ true หรือ false? (ใบ้: บท 94 — .presence กับข้อความที่มีแต่ช่องว่าง)

wongnok-blog(dev):001> q = Post.new(title: "   ", user: User.first)
  User Load (1.3ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT 1 /*application='WongnokBlog'*/
=> #<Post:0x000000012635cab0 id: nil, title: "   ", body: nil, created_at: nil, updated_at: nil, published: false, views: 0, user_id: 1>
wongnok-blog(dev):002> q.valid?
=> false
wongnok-blog(dev):003> q.errors.full_messages
=> ["Title can't be blank"]

ได้ false · presence: true ใช้เกณฑ์เดียวกับ .presence ในบท 94 คือ "ว่าง (blank)" ซึ่งรวมข้อความที่มีแต่ช่องว่างด้วย · ช่องว่างสามตัวจึงนับว่าไม่มีชื่อเรื่อง · และรอบนี้เหลือ error ข้อเดียว เพราะใส่ผู้เขียนให้แล้ว (user_id: 1)

wongnok-blog(dev):001> Post.new.errors.full_messages
#                      ↑ ยังไม่ได้สั่ง valid? หรือ save เลย

แฟ้มเปล่าแบบนี้ผิดกฎแน่ ๆ · คิดว่าจะได้ error กี่ข้อ?

wongnok-blog(dev):001> Post.new.errors.full_messages
=> []

ได้ array ว่าง · โน้ตสีเหลืองถูกเขียนตอนตรวจเท่านั้น (ตอน valid? · save · create) · แฟ้มที่ยังไม่มีใครตรวจจึงไม่มีโน้ต แม้จะผิดกฎเต็มไปหมด · ถ้าเห็น errors ว่างทั้งที่คิดว่าควรมี ให้ถามตัวเองก่อนว่า "ตรวจไปหรือยัง"

🗣 ศัพท์ที่เพิ่งเรียนไป
validates · presence
validates :ช่อง, ชื่อกฎ: ค่า = ตั้งกฎสำเร็จรูป · presence = ห้ามว่าง (รวมช่องว่างล้วน) · พิมพ์บอก AI ได้ว่า "add presence validation on Post#title and show me the error message in the console"
valid? · errors · full_messages
valid? = ตรวจโดยไม่บันทึก · errors = รายการปัญหาที่ติดอยู่กับ object · full_messages = แปลงเป็นประโยคเต็ม · เวลาบันทึกไม่ผ่านแล้วไม่รู้ทำไม ถาม senior ได้ว่า "ขอดู errors.full_messages ของ object นี้หน่อย" — เกือบทุกครั้งคำตอบอยู่ในนั้น

3 · คลังกฎสำเร็จรูป — 5 แบบที่ใช้บ่อย

Rails มีกฎสำเร็จรูปให้เลือกหลายแบบ ไม่ต้องจำครบ แค่รู้ว่ามี — เวลาอ่านโค้ดที่ AI เขียนมาจะได้รู้ว่าแต่ละบรรทัดตรวจอะไร
ผมเขียนกฎลง Post 4 ข้อ กับ User 1 ข้อ (ใช้ช่องที่มีจริงในตารางของเรา):

# app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user
  has_many :comments

  validates :title, presence: true
  validates :title, length: { in: 5..120 }
  validates :views, numericality: { greater_than_or_equal_to: 0 }
  validates :published, inclusion: { in: [true, false] }
end

# app/models/user.rb
class User < ApplicationRecord
  has_many :posts
  has_many :comments

  validates :email, format: { with: /\A[^@\s]+@[^@\s]+\z/ }
end

สังเกตว่ากฎเหล่านี้มีปีกกาซ้อนอีกชั้น เช่น length: { in: 5..120 } — ชื่อกฎอยู่หน้า : ส่วนในปีกกาคือรายละเอียดของกฎ ("ยาวเท่าไหร่ถึงผ่าน") · ต่างจาก presence: true ที่ไม่มีรายละเอียดอะไรให้บอก แค่ "เปิดใช้"

แล้วลองทีละกฎใน console (reload! ก่อนถ้าเปิดค้างไว้) · ผมเขียน 3 คำสั่งในบรรทัดเดียวคั่นด้วย ; (สร้างแฟ้ม ; ตรวจ ; ดูโน้ต) — console โชว์แค่ผลของคำสั่งสุดท้าย:

wongnok-blog(dev):001> u = User.first
  User Load (1.4ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT 1 /*application='WongnokBlog'*/
=>
#<User:0x0000000122633cb0
...
wongnok-blog(dev):002> p = Post.new(user: u, title: "ลาบ"); p.valid?; p.errors.full_messages
=> ["Title is too short (minimum is 5 characters)"]
wongnok-blog(dev):003> p = Post.new(user: u, title: "ลาบหมูทอด", views: -5); p.valid?; p.errors.full_messages
=> ["Views must be greater than or equal to 0"]
wongnok-blog(dev):004> p = Post.new(user: u, title: "ลาบหมูทอด", views: "เยอะมาก"); p.valid?; p.errors.full_messages
=> ["Views is not a number"]
wongnok-blog(dev):005> p = Post.new(user: u, title: "ลาบหมูทอด", published: nil); p.valid?; p.errors.full_messages
=> ["Published is not included in the list"]
wongnok-blog(dev):006> x = User.new(email: "somchai.example.com"); x.valid?; x.errors.full_messages
=> ["Email is invalid"]
wongnok-blog(dev):007> x = User.new(email: "som chai@example.com"); x.valid?; x.errors.full_messages
=> ["Email is invalid"]
wongnok-blog(dev):008> x = User.new(email: "somchai@example.com"); x.valid?
=> true
กฎตรวจอะไร (ภาษาคน)ข้อความที่ได้จริง
presence: trueห้ามว่างTitle can't be blank
length: { in: 5..120 } ยาว 5 ถึง 120 ตัวอักษร · 5..120 คือช่วงตัวเลข (range) "ตั้งแต่ 5 ถึง 120" · ใช้ maximum: หรือ minimum: อย่างเดียวก็ได้ Title is too short (minimum is 5 characters)
numericality: { greater_than_or_equal_to: 0 } ต้องเป็นตัวเลข และ ≥ 0 · ส่งข้อความที่ไม่ใช่ตัวเลขมาก็โดนจับ (ของที่มาจากฟอร์มเว็บเป็นข้อความเสมอ ผู้ใช้จึงพิมพ์ "เยอะมาก" ส่งมาได้จริง) Views must be greater than or equal to 0 · Views is not a number
inclusion: { in: [true, false] } ค่าต้องเป็นหนึ่งในรายการนี้เท่านั้น · ใช้กับช่องที่มีตัวเลือกจำกัด เช่น สถานะ Published is not included in the list
format: { with: /…/ } หน้าตาข้อความต้องตรงแบบที่กำหนด (regex — อธิบายข้างล่าง) Email is invalid

อ่าน regex ของอีเมลเป็นภาษาคน — regex คือ "แบบฟอร์มของข้อความ" เขียนไว้ระหว่าง / … / (บท 104 เคยเห็นผ่านตา):

/ \A [^@\s]+ @ [^@\s]+ \z / │ │ │ │ │ │ │ │ │ └─ จบข้อความตรงนี้ (ห้ามมีอะไรต่อ) │ │ │ └────────── ตัวอักษรอะไรก็ได้ที่ไม่ใช่ @ และไม่ใช่ช่องว่าง · 1 ตัวขึ้นไป │ │ └────────────────── ต้องมี @ ตรงนี้หนึ่งตัว │ └─────────────────────────── ตัวอักษรอะไรก็ได้ที่ไม่ใช่ @ และไม่ใช่ช่องว่าง · 1 ตัวขึ้นไป └─────────────────────────────────── เริ่มต้นข้อความตรงนี้

แปลรวม: "มีอะไรสักอย่าง @ มีอะไรสักอย่าง · ไม่มีช่องว่าง · มี @ ตัวเดียว" · [^…] = "ตัวไหนก็ได้ที่ไม่ใช่ของในวงเล็บ" · \s = ช่องว่าง · + = หนึ่งตัวขึ้นไป · กฎนี้กันพิมพ์ผิดแบบโจ่งแจ้งได้ แต่ไม่ได้รับประกันว่าอีเมลมีอยู่จริง

⚠️ สองจุดที่พิมพ์ตามแล้วพัง
(1) สไลด์หน้า 22 เขียน numericality: { greater_than: 0 } ที่ views — ตาราง posts ตั้ง views เริ่มต้นเป็น 0 · ใส่กฎนี้แล้วบทความใหม่ทุกชิ้นบันทึกไม่ได้ ผมลองแล้วได้ ["Views must be greater than 0"] · ใช้ greater_than_or_equal_to: 0 แทน
(2) ถ้า AI เขียน regex ด้วย ^ กับ $ แทน \A กับ \z · Rails ไม่ยอมโหลด model เลย: The provided regular expression is using multiline anchors (^ or $), which may present a security risk. Did you mean to use \A and \z, …? (ArgumentError) — Rails กันไว้เพราะ ^ $ หมายถึง "ต้นบรรทัด/ท้ายบรรทัด" ข้อความหลายบรรทัดจึงแอบผ่านได้ · แก้เป็น \A … \z

เกร็ดสำหรับภาษาไทย: length นับทุกตัวอักษรรวมสระบนล่างและวรรณยุกต์ · ใน console "กุ้ง".length ได้ 4 (ก · ุ · ้ · ง) ไม่ใช่ 2 อย่างที่ตาเห็น · ตั้งค่าต่ำสุดสำหรับชื่อเรื่องไทยเผื่อไว้หน่อย

อีกหนึ่งกับดัก: ทำไม published ใช้ inclusion ไม่ใช้ presence? เพราะใน Rails false นับว่า "ว่าง" — ผมลอง validates :published, presence: true แล้วสร้างบทความ draft (published: false) ได้ ["Published can't be blank"] · ช่อง true/false จึงใช้ inclusion: { in: [true, false] } แทน

📏 เทียบให้เห็นภาพ — กล่องเครื่องมือตรวจของแฟรนไชส์

แฟรนไชส์ส่งกล่องเครื่องมือตรวจมาให้ทุกสาขา: ไม้บรรทัด (length — ยาวเกินไหม) · เครื่องคิดเลข (numericality — เป็นตัวเลขไหม ติดลบไหม) · รายการตัวเลือก (inclusion — อยู่ในรายการไหม) · แผ่นแม่แบบเจาะรู (format — วางทาบแล้วตรงแบบไหม) · คนดูแลสต็อกหยิบเครื่องมือตามที่เราสั่งในรายการ

ที่ไม่เหมือนกัน: เครื่องมือจริงตรวจของจริง (เช่น ไม้บรรทัดวัดความยาวกระดาษ) · เครื่องมือของ Rails ตรวจแค่หน้าตาของข้อมูล — อีเมลที่ผ่าน format อาจไม่มีใครใช้อยู่จริง · และมันวัด "ตัวอักษร" แบบคอมพิวเตอร์ ไม่ใช่แบบที่ตาคนเห็น (ดู "กุ้ง".length ด้านบน)

เทียบกับ Go: กฎพวกนี้คือ tag ของ validator ที่ Gin ใช้ในบท 39 แบบเดียวกัน — binding:"min=1,max=20" ≈ length / numericality · binding:"oneof=draft live" ≈ inclusion · ต่างกันที่ Go เขียนเป็นข้อความใน tag ส่วน Rails เขียนเป็น hash ที่ Ruby อ่านเข้าใจ

กฎหลายข้อของช่องเดียวกันรวมเป็นบรรทัดเดียวได้ เช่น validates :title, presence: true, length: { maximum: 120 } · ผลเหมือนเขียนแยกสองบรรทัด

🗣 ศัพท์ที่เพิ่งเรียนไป
validation helpers (length · numericality · inclusion · format)
ชื่อเรียกรวมของกฎสำเร็จรูปที่ Rails มีให้ (ในโค้ดของ Rails อยู่ใน module ชื่อ HelperMethods · ในคู่มือ Rails อยู่หมวด Validations) · พิมพ์บอก AI ได้ว่า "use built-in Rails validation helpers instead of a custom method if one exists" เวลา AI เขียน if เช็คเองยาว ๆ
regex · \A \z
regex (regular expression) = รูปแบบข้อความ · \A \z = ต้น/ท้ายข้อความทั้งก้อน · ถาม senior ได้ว่า "regex ตัวนี้ anchor ด้วย \A กับ \z หรือยัง" — คำถามสั้น ๆ ที่จับบั๊กความปลอดภัยได้จริง

4 · กฎของร้านเราเอง — validate + errors.add

บางกฎไม่มีของสำเร็จรูป เช่นกฎของร้านเรา: "บทความที่จะเผยแพร่ ต้องมีเนื้อหา (draft ไม่มีเนื้อหาได้)" · กฎแบบนี้เขียนเป็น method ธรรมดาแล้วบอก Rails ว่า "ตอนตรวจ ให้เรียก method นี้ด้วย"
ผมเปลี่ยน app/models/post.rb เป็นแบบนี้ (เอากฎทดลองในหัวข้อ 3 ออก เหลือที่ใช้จริง):

# app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user
  has_many :comments

  validates :title, presence: true, length: { maximum: 120 }
  validates :views, numericality: { greater_than_or_equal_to: 0 }
  validate :body_needed_to_publish

  private

  def body_needed_to_publish
    return unless published
    if body.blank?
      errors.add(:body, "ต้องมีเนื้อหาก่อนเผยแพร่")
    end
  end
end
บรรทัดแปลเป็นภาษาคน
validate :body_needed_to_publish ไม่มี s · "ตอนตรวจ ให้เรียก method ชื่อนี้ด้วย" (ส่งชื่อ method เป็น symbol แบบเดียวกับ before_action ในบท 102)
private method ข้างล่างใช้ได้แค่ภายใน class · คนนอกเรียก post.body_needed_to_publish ไม่ได้ (บท 102)
return unless published "ถ้าไม่ได้จะเผยแพร่ ก็จบ ไม่ต้องตรวจต่อ" · unless (คำใหม่) = "ถ้าไม่…" ตรงข้ามกับ if · return unless x อ่านว่า "ออกจาก method เลย ถ้า x ไม่จริง" · published คือค่าในช่อง published ของแฟ้มนี้
if body.blank? "ถ้าช่องเนื้อหาว่าง" · blank? คือเกณฑ์ "ว่าง" ตัวเดียวกับที่ presence ใช้ (บท 94)
errors.add(:body, "…") แปะโน้ตสีเหลืองเองที่ช่อง body · แค่นี้แฟ้มก็ถือว่าไม่ผ่านแล้ว ไม่ต้อง return true/false
wongnok-blog(dev):001> p = Post.new(user: User.first, title: "ลาบหมูทอด", published: true)
  User Load (1.3ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT 1 /*application='WongnokBlog'*/
=> #<Post:0x0000000124b069e8 id: nil, title: "ลาบหมูทอด", body: nil, created_at: nil, updated_at: nil, published: true, views: 0, user_id: 1>
wongnok-blog(dev):002> p.valid?
=> false
wongnok-blog(dev):003> p.errors.full_messages
=> ["Body ต้องมีเนื้อหาก่อนเผยแพร่"]
wongnok-blog(dev):004> p.body = "สูตรลาบทอดกรอบ ๆ"
=> "สูตรลาบทอดกรอบ ๆ"
wongnok-blog(dev):005> p.valid?
=> true

ได้ "Body ต้องมีเนื้อหาก่อนเผยแพร่" — Rails เติมชื่อช่องภาษาอังกฤษนำหน้าให้เองเสมอ จึงได้ประโยคครึ่งไทยครึ่งอังกฤษ · ตอนนี้ยอมรับไปก่อน · การแปลข้อความ error ทั้งระบบเป็นไทยมีกลไกของมันเอง (ชื่อเรียก I18n) ซึ่งไม่อยู่ในบทนี้

📋 เทียบให้เห็นภาพ — กฎเฉพาะของสาขาเรา

กฎสำเร็จรูป (validates) คือรายการตรวจมาตรฐานจากแฟรนไชส์ เช่น "ห้ามว่าง" "ไม่เกิน 120 ตัว" — ทุกสาขาใช้เหมือนกัน · validate :ชื่อmethod คือกฎที่ร้านเราเขียนเพิ่มเองติดไว้บนโต๊ะคนดูแลสต็อก: "แฟ้มที่จะขึ้นหน้าร้าน ต้องมีสูตรแนบ" · errors.add คือมือที่เขียนโน้ตสีเหลืองแปะคืน

ที่ไม่เหมือนกัน: กฎที่ร้านเขียนเองบนกระดาษ คนอ่านแล้วตีความได้ · method ของเรา Rails เรียกตามชื่อเป๊ะ ๆ · สะกดชื่อใน validate ไม่ตรงกับ def แม้ตัวเดียว ได้ NoMethodError ตอนตรวจ · และ method ต้องแปะโน้ตเอง — แค่ return false ไม่มีผลอะไรเลย

🗣 ศัพท์ที่เพิ่งเรียนไป
custom validation method
method ของเราเองที่ลงทะเบียนด้วย validate :ชื่อ แล้วเรียก errors.add เมื่อผิดกฎ · พิมพ์บอก AI ได้ว่า "write a custom validation method: a published post must have a body; use errors.add on :body"
errors.add
เพิ่มปัญหาหนึ่งข้อให้ช่องที่ระบุ · มีปัญหาแม้ข้อเดียว valid? ก็เป็น false · ถาม senior ได้ว่า "error ข้อนี้ควรผูกกับช่องไหน ให้ฟอร์มโชว์ใต้ช่องที่ถูก"

5 · กับดักตัว s ตัวเดียว — validates กับ validate

สองคำนี้หน้าตาเกือบเหมือนกัน แต่คนละงาน:

สไลด์หน้า 22 บอกว่าพิมพ์สลับกัน "ไม่ error แค่กฎไม่ทำงานเฉย ๆ" · ผมลองทุกแบบใน Rails 8.1.4 แล้วผลจริงไม่ตรงกับสไลด์ทั้งหมด · ไฟล์ทดลองหน้าตาแบบนี้ (เปลี่ยนแค่บรรทัดที่ 6 ทีละแบบ):

# app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user
  has_many :comments

  validate :title, presence: true
end

แล้วสั่ง p = Post.new(user: User.first, title: ""); p.valid? ใน console ใหม่ · ผล (ตัดมาแค่บรรทัดแรกของ error · บรรทัดที่เหลือชี้เข้าไปในโค้ดของ Rails):

บรรทัดที่ 6 เขียนว่าผลจริงแปลว่า
validate :title, presence: true
(ลืม s)
app/models/post.rb:6:in '<class:Post>': Unknown key: :presence. Valid keys are: :on, :if, :unless, :prepend, :except_on. Perhaps you meant to call `validates` instead of `validate`? (ArgumentError) Rails จับได้ และใบ้ให้ด้วยว่า "หมายถึง validates หรือเปล่า" · โหลด model ไม่ได้เลย เปิดหน้าเว็บจะได้ 500
validates :title, presense: true
(สะกดชื่อกฎผิด)
app/models/post.rb:6:in '<class:Post>': Unknown validator: 'PresenseValidator' (ArgumentError) Rails หากฎชื่อนี้ไม่เจอ · ดูชื่อในวงเล็บแล้วจะเห็นตัวสะกดที่ผิด
validate :title
(ลืม s และลืมชื่อกฎ)
=> true ⚠️ เงียบสนิท — อันนี้แหละอันตรายจริง · Rails เข้าใจว่า "เรียก method ชื่อ title เป็นกฎ" · method title มีอยู่แล้ว (อ่านค่าช่อง title) แต่ไม่เคยแปะโน้ต · ชื่อเรื่องว่างจึงผ่านฉลุย

ตัวเลข :6 ใน error คือบรรทัดที่ 6 ของไฟล์ · นับบรรทัดคอมเมนต์ # app/models/post.rb เป็นบรรทัดที่ 1 ด้วย · in '<class:Post>' = "เกิดตอนกำลังอ่านตัว class Post" (ยังไม่ได้อยู่ใน method ไหน) · ส่วน Valid keys are: :on, :if, … คือตัวเลือกเสริมที่ validate รับได้ ยังไม่ต้องรู้จัก · error สองแบบแรกเด้งขึ้นตอนที่ Rails โหลด model ครั้งแรก (ครั้งแรกที่พิมพ์คำว่า Post) ไม่ใช่ตอน valid?

บทเรียนจากตาราง: Rails รุ่นนี้ช่วยจับการพิมพ์ผิดส่วนใหญ่ให้แล้ว แต่แบบที่สามหลุดได้เสมอ · เขียนกฎเสร็จ ลองให้มันพังหนึ่งครั้งเสมอ (สร้างของผิดกฎแล้วดูว่า valid? เป็น false จริง)

อีกทางคือถาม model ตรง ๆ ว่า "ช่อง title มีกฎอะไรบ้าง" ด้วย Post.validators_on(:title) · ผมลองกับไฟล์สองแบบ (console ใหม่ทุกรอบ):

# แบบที่ 3 — บรรทัดที่ 6 เป็น validate :title (ตัวที่เงียบ)
wongnok-blog(dev):001> Post.validators_on(:title)
=> []

# แบบที่ถูก — บรรทัดที่ 6 เป็น validates :title, presence: true
wongnok-blog(dev):001> Post.validators_on(:title)
=> [#<ActiveRecord::Validations::PresenceValidator:0x0000000124832e10 @attributes=[:title], @options={}>]

เทียบกับ Go: ใน Go ถ้าเรียกฟังก์ชันที่ไม่มีอยู่ go build ไม่ผ่านตั้งแต่ก่อนรัน · Ruby ไม่มีขั้น build — ความผิดพวกนี้รู้ตอนรันเท่านั้น และแบบที่สามไม่ผิดไวยากรณ์เลยสักตัว จึงไม่มีใครเตือนได้

🪧 เทียบให้เห็นภาพ — ป้ายสั่งงานสองแบบที่เขียนคล้ายกัน

validates :title, presence: true = ป้าย "ตรวจช่อง title ด้วยเครื่องมือ presence" · validate :body_needed_to_publish = ป้าย "เรียกพนักงานชื่อ body_needed_to_publish มาตรวจ"
validate :title จึงอ่านว่า "เรียกพนักงานชื่อ title มาตรวจ" · พนักงานชื่อนี้มีจริง แต่หน้าที่ของเขาคืออ่านชื่อเรื่องออกเสียงเฉย ๆ ไม่เคยแปะโน้ตตีกลับใคร แฟ้มจึงผ่านทุกแผ่น

ที่ไม่เหมือนกัน: ในร้านจริง หัวหน้าเห็นป้ายแปลก ๆ แล้วถามได้ว่า "หมายถึงอะไร" · Rails ถามกลับได้แค่ตอนที่ป้ายผิดรูปจนอ่านไม่ออก (สองแบบแรก) · ป้ายที่ถูกรูปแต่ผิดความหมาย Rails ทำตามทันทีโดยไม่สงสัย

🗣 ศัพท์ที่เพิ่งเรียนไป
validates vs validate
มี s = ใช้ validator สำเร็จรูป · ไม่มี s = ลงทะเบียน custom method · เวลาให้ AI ตรวจโค้ด พิมพ์ได้ว่า "check every validate/validates line: is any validate called with a column name instead of a method?"
Post.validators
รายชื่อกฎทั้งหมดที่ model มีจริงตอนนี้ (รวมกฎที่ belongs_to เพิ่มให้) · Post.validators_on(:title) = เฉพาะช่องเดียว · ถาม senior ได้ว่า "ขอเช็ค validators_on(:email) หน่อย ว่ากฎที่คิดว่ามี มีอยู่จริงไหม"

6 · save กับ save! — ตีกลับเงียบ ๆ หรือกดกริ่ง

ถึงตอนนี้เราใช้ valid? เพื่อถามอย่างเดียว · ของจริงเราใช้ save ซึ่งตรวจก่อนแล้วค่อยบันทึก · Rails มีสองแบบ ต่างกันที่ "ถ้าไม่ผ่าน จะบอกเรายังไง" · ใช้ model ฉบับหัวข้อ 4 เปิด console ใหม่:

wongnok-blog(dev):001> p = Post.new(title: "", user: User.first)
  User Load (2.4ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT 1 /*application='WongnokBlog'*/
=> #<Post:0x00000001239cec48 id: nil, title: "", body: nil, created_at: nil, updated_at: nil, published: false, views: 0, user_id: 1>
wongnok-blog(dev):002> p.save
=> false
wongnok-blog(dev):003> p.save!
(wongnok-blog):3:in '<main>': Validation failed: Title can't be blank (ActiveRecord::RecordInvalid)
wongnok-blog(dev):004> p.title = "ลาบหมูทอด"
=> "ลาบหมูทอด"
wongnok-blog(dev):005> p.save
  TRANSACTION (1.9ms)  BEGIN /*application='WongnokBlog'*/
  Post Create (5.0ms)  INSERT INTO "posts" ("title", "body", "created_at", "updated_at", "published", "views", "user_id") VALUES ('ลาบหมูทอด', NULL, '2026-11-04 11:55:23.124491', '2026-11-04 11:55:23.124491', FALSE, 0, 1) RETURNING "id" /*application='WongnokBlog'*/
  TRANSACTION (1.3ms)  COMMIT /*application='WongnokBlog'*/
=> true

create (= new + save รวดเดียว) ก็มีคู่แบบเดียวกัน · ต่อใน console เดิม:

wongnok-blog(dev):006> Post.create(title: "", user: User.first)
  User Load (2.2ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT 1 /*application='WongnokBlog'*/
=> #<Post:0x000000012245e750 id: nil, title: "", body: nil, created_at: nil, updated_at: nil, published: false, views: 0, user_id: 1>
wongnok-blog(dev):007> Post.create!(title: "", user: User.first)
  User Load (2.5ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT 1 /*application='WongnokBlog'*/
(wongnok-blog):7:in '<main>': Validation failed: Title can't be blank (ActiveRecord::RecordInvalid)

create ที่ไม่ผ่านไม่ได้คืน false — คืนแฟ้มที่ยังไม่เข้าตู้มาให้ (สังเกต id: nil) · ถ้าไม่เช็คต่อ จะไม่รู้เลยว่าไม่ได้บันทึก · ส่วน create! ระเบิดทันทีแบบเดียวกับ save!

ทวนบท 93: ที่นั่น ! แปลว่า "ระวัง ตัวนี้อันตรายกว่า" (เช่น upcase! เขียนทับของเดิม) · ใน Rails ความ "อันตรายกว่า" มีความหมายเดียวทั้งระบบคือ "ถ้าทำไม่ได้ ให้ระเบิด" — ใช้กับคู่อื่นด้วย เช่น update / update! · find_by / find_by!:

wongnok-blog(dev):001> User.find_by(email: "nobody@example.com")
  User Load (1.5ms)  SELECT "users".* FROM "users" WHERE "users"."email" = 'nobody@example.com' LIMIT 1 /*application='WongnokBlog'*/
=> nil
wongnok-blog(dev):002> User.find_by!(email: "nobody@example.com")
  User Load (2.0ms)  SELECT "users".* FROM "users" WHERE "users"."email" = 'nobody@example.com' LIMIT 1 /*application='WongnokBlog'*/
(wongnok-blog):2:in '<main>': Couldn't find User with [WHERE "users"."email" = $1] (ActiveRecord::RecordNotFound)

SQL บรรทัดนี้แปลว่า "ขอแฟ้มผู้ใช้ทุกช่อง (*) เฉพาะแผ่นที่ (WHERE) ช่อง email ตรงกับ nobody@example.com · เอาแค่แผ่นเดียว (LIMIT 1)" · ($1 ในข้อความ error = ช่องที่ Rails เว้นไว้ให้ใส่ค่าอีเมล ไม่ได้แปลว่าค้นหาคำว่า $1) · หาไม่เจอทั้งคู่ แต่ find_by คืน nil เงียบ ๆ ส่วน find_by! โยน RecordNotFound (ตัวเดียวกับที่ rescue_from ในบท 96 รับไปทำหน้า 404)

เทียบกับ Go: GORM ในบท 27 มีแบบเดียว คือ db.Create(&post) คืน object ที่มี .Error ให้เราเช็คเอง — ซึ่งบท 27 เตือนว่า "ลืมเช็คได้ง่ายมาก" · save ของ Rails ก็นิสัยเดียวกัน (ไม่เช็คค่า = ไม่รู้) · ส่วน save! คล้ายการ panic ใน Go ที่หยุดโปรแกรมเว้นแต่มีคนดักรับ

🔔 เทียบให้เห็นภาพ — ส่งคืนพร้อมโน้ต กับ กดกริ่งหยุดครัว

save = คนดูแลสต็อกตรวจแล้วไม่ผ่าน ก็ส่งแฟ้มคืนพร้อมโน้ตสีเหลือง ตอบสั้น ๆ ว่า "ไม่รับ" (false) · คนส่งต้องเปิดโน้ตอ่านเองว่าผิดอะไร · เหมาะกับงานที่คาดไว้แล้วว่าผิดได้บ่อย เช่น ลูกค้ากรอกฟอร์มไม่ครบ
save! = ตรวจไม่ผ่านแล้วกดกริ่งฉุกเฉิน ทั้งครัวหยุด · เหมาะกับงานที่ไม่ควรผิดเลย ถ้าผิดแปลว่ามีบั๊ก

ที่ไม่เหมือนกัน: กริ่งในครัวจริงดังแล้วทุกคนได้ยิน · save! ระเบิดแล้วมีคนดักรับได้ (rescue · บท 96) ถ้าดักไว้ ครัวไม่หยุด · และ save ที่คืน false ถ้าเราไม่เช็คค่า มันเงียบจริง ๆ ไม่มีใครรู้เลยว่าแฟ้มไม่ได้เข้าตู้

ไม่มี ! (save · create · find_by)มี ! (save! · create! · find_by!)
ถ้าไม่ได้คืน false / แฟ้มที่ไม่ได้เข้าตู้ / nilโยน error (RecordInvalid / RecordNotFound)
ใช้เมื่อข้อมูลมาจากฟอร์มที่ผู้ใช้กรอก · ผิดเป็นเรื่องปกติ · อยากโชว์ error กลับไปที่หน้าจอ seeds · สคริปต์ · งานเบื้องหลัง (Day 3) · ข้อมูลที่เราเขียนเอง ถ้าผิดคือบั๊ก · อยากให้หยุดดัง ๆ
ตัวอย่างในแอปเราif @post.save ใน controller (หัวข้อถัดไป)db/seeds.rb ใช้ User.create! · posts.create! ทุกบรรทัด

มีอีกที่หนึ่งที่ต้องใช้ ! คือใน transaction (กลุ่มคำสั่งที่ "บันทึกทั้งหมดหรือไม่บันทึกเลย") · transaction จะยกเลิกทั้งก้อนก็ต่อเมื่อมี error ระเบิดออกมา · save ที่แค่คืน false ไม่ทำให้ยกเลิก — รายละเอียดในบท 111

🗣 ศัพท์ที่เพิ่งเรียนไป
bang method (save! · create! · find_by!)
method ที่มี ! · ใน Active Record หมายถึง "ทำไม่ได้แล้วโยน exception" · ถาม senior ได้ว่า "ตรงนี้ควรใช้ save หรือ save! — ผิดแล้วเป็น input ผู้ใช้หรือเป็นบั๊ก"
ActiveRecord::RecordInvalid · RecordNotFound
ชื่อ error ที่ save!/create! และ find_by!/find โยนออกมา · เวลาเจอใน log พิมพ์บอก AI ได้ว่า "seeds failed with ActiveRecord::RecordInvalid: Validation failed: … — which record and which rule?"

บทความ "ลาบหมูทอด" ที่เพิ่งบันทึกไว้อยู่ในตู้จริงแล้ว · จะเก็บไว้หรือลบด้วย Post.where(title: "ลาบหมูทอด").destroy_all ก็ได้ (ผมลองแล้ว เห็น DELETE FROM "posts" WHERE … = "ลบแฟ้มแผ่นที่…" · แล้ว Post.count กลับเป็น 6)

7 · ใน controller — if @post.save … else render :new

ฟอร์ม "เขียนบทความใหม่" คืองานแบบ "ผู้ใช้กรอก ผิดได้บ่อย" จึงใช้ save แบบไม่มี ! · นี่คือ action create ที่บท 102 ให้อ่านล่วงหน้า · ตอนนี้รันได้จริงแล้ว (ผมเขียน route · controller · view ทดลองใน sandbox แล้วยิง request ดู):

# app/controllers/posts_controller.rb (ตัดมาเฉพาะ action create)
def create
  @post = Post.new(post_params)
  @post.user = User.first   # ชั่วคราว: ยังไม่มีระบบ login
  if @post.save
    redirect_to @post, notice: "สร้างบทความแล้ว"
  else
    render :new, status: :unprocessable_content
  end
end
บรรทัดแปลเป็นภาษาคน
@post = Post.new(post_params) สร้างแฟ้มจากของที่ผู้ใช้กรอก · post_params = method ที่หยิบเฉพาะช่องที่อนุญาต (เขียนด้วย params.expect — ละเอียดในบท 117) · วางบนถาด @ ให้ view ใช้ได้
@post.user = User.first ใส่ผู้เขียนให้ ไม่งั้นโดน User must exist · ของจริงจะเป็น "คนที่ login อยู่" (Day 4)
if @post.save ตรวจตามกฎแล้วบันทึก · ได้ true เข้า if · ได้ false เข้า else
redirect_to @post, … สำเร็จ → พาไปหน้าบทความนั้น (/posts/เลข id) · กด refresh แล้วไม่ส่งฟอร์มซ้ำ (บท 102)
render :new, status: :unprocessable_content ไม่ผ่าน → โชว์ฟอร์มเดิม ด้วย @post ตัวเดิมที่มีทั้งของที่ผู้ใช้พิมพ์และโน้ตสีเหลือง (@post.errors) · ตอบ status 422 · render :new ไม่ได้รัน def new (บท 102)

ผมส่งฟอร์มที่ชื่อเรื่องว่าง · log ของ server (ตัดบรรทัดที่ไม่เกี่ยวและวงเล็บท้าย Completed ออก):

Started POST "/posts" for ::1 at 2026-11-04 18:50:27 +0700
Processing by PostsController#create as */*
  Parameters: {"authenticity_token" => "[FILTERED]", "post" => {"title" => "", "body" => "x"}}
  User Load (0.6ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT 1 /*action='create',application='WongnokBlog',controller='posts'*/
  Rendered posts/new.html.erb within layouts/application (Duration: 0.5ms | GC: 0.0ms)
Completed 422 Unprocessable Content in 14ms

แล้วโน้ตสีเหลืองไปโผล่บนหน้าเว็บได้ยังไง? · คนจัดจาน (view) เปิดดูโน้ตที่ติดมากับ @post แล้ววนเขียนเป็นรายการ · นี่คือส่วนบนของ view ฟอร์มที่ผมใช้ทดลอง:

<%# app/views/posts/new.html.erb (ตัดมาเฉพาะส่วนโชว์ error) %>
<% if @post.errors.any? %>
  <ul>
    <% @post.errors.full_messages.each do |message| %>
      <li><%= message %></li>
    <% end %>
  </ul>
<% end %>

ผลจริงเมื่อส่งฟอร์มแบบเดียวกัน (ชื่อเรื่องว่าง · เนื้อหา x): หน้าที่ได้กลับมามีรายการบรรทัดเดียว Title can't be blank · และช่องเนื้อหายังมีคำว่า x ที่ผมพิมพ์ไว้ — เพราะ render ใช้ @post ตัวเดิมที่ยังถือของที่ผู้ใช้กรอก

⚠️ สไลด์หน้า 23 เขียน status: :unprocessable_entity (ชื่อเก่า บท 100/102 เคยเตือน) · ผมลองใส่ใน controller นี้แล้ว ยังได้ 422 เหมือนกัน และ log ไม่มีคำเตือนอะไรเลย เพราะ Rails 8.1.4 แอบแปลงเป็นชื่อใหม่ให้เอง · แต่ Rack (ชิ้นส่วนที่ Rails ใช้คุยกับ Puma) ประกาศเลิกใช้ชื่อนี้แล้ว ถ้าเรียกผ่าน Rack ตรง ๆ จะเห็น warning: Status code :unprocessable_entity is deprecated and will be removed in a future version of Rack. Please use :unprocessable_content instead. · เขียนใหม่ให้ใช้ :unprocessable_content ไว้ก่อน

8 · validation เป็นประตูหน้า ไม่ใช่กำแพง

ทุกอย่างในบทนี้ทำงานเฉพาะตอนที่ข้อมูลเดินผ่าน model · มีทางเลี่ยงอยู่จริง เช่น save(validate: false) ที่บันทึกโดยไม่ตรวจอะไรเลย · หรือ SQL ที่ยิงตรงเข้าฐานข้อมูลโดยไม่ผ่าน Rails
นี่คือประโยคหลักข้อที่สองของวัน: validation ใน model ให้ข้อความ error สวย ๆ กับผู้ใช้ แต่ไม่ใช่การรับประกัน · ของสำคัญต้องมีกฎที่ตัวตู้ (ฐานข้อมูล) คู่กันด้วย — ลองทางเลี่ยงพวกนี้จริง ๆ ในบท 111

ภารกิจ — ตั้งกฎให้ User

🎯 ภารกิจ (~10 นาที)

ทำให้ผู้ใช้ทุกคนต้องมีชื่อ และต้องมีอีเมลที่หน้าตาถูก · ใช้แอปเดียวกับที่ลองมาทั้งบท (ตาราง users มีช่อง name กับ email อยู่แล้ว):

  1. เปิด app/models/user.rb · ถ้ายังไม่ได้พิมพ์ตามหัวข้อ 3 ให้เพิ่มบรรทัด validates :email, format: { with: /\A[^@\s]+@[^@\s]+\z/ } ก่อน · แล้วเพิ่มกฎ presence ให้ :name · และรวม presence เข้ากับ format ของ :email ในบรรทัดเดียว (ท่ารวมกฎท้ายหัวข้อ 3)
  2. เปิด bin/rails console (ถ้าเปิดค้างอยู่ พิมพ์ reload!) แล้วพิมพ์ทีละบรรทัด:
    u = User.new(name: "", email: "mana.example.com")
    u.valid?
    u.errors.full_messages
    User.create!(name: "ใหม่", email: "")
    User.all.all?(&:valid?)

บรรทัดสุดท้าย User.all.all?(&:valid?) แปลว่า "ผู้ใช้ทุกคนในตู้ ผ่านกฎใหม่หมดไหม" (&:valid? = เรียก valid? กับทุกตัว · ท่าเดียวกับ .map(&:title) ในบท 92 · all? = "จริงทุกตัวไหม") — กันไม่ให้กฎใหม่ทำให้ข้อมูลเก่ากลายเป็นของผิดกฎ

ควรเห็น: valid? เป็น false · full_messages มี 2 ข้อ (ชื่อว่าง · อีเมลผิดรูป) · create! ระเบิดด้วย RecordInvalid ที่บอก 2 ปัญหาของ email · บรรทัดสุดท้ายได้ true

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

เห็นอะไรแปลว่า
u.valid? ได้ true ทั้งที่แก้ไฟล์แล้ว console ยังใช้ model ฉบับเก่า → พิมพ์ reload! (ขึ้น Reloading...) แล้วสร้าง u ใหม่ · ผมลองแล้ว ไม่ reload ผลไม่เปลี่ยนจริง
User.create! ไม่ระเบิด แต่คืนผู้ใช้ที่มีเลข id ออกมา กฎยังไม่ทำงาน (สาเหตุเดียวกับแถวบน) และผู้ใช้อีเมลว่างเข้าตู้ไปแล้ว 1 คน → reload! แล้วลบทิ้งด้วย User.where(name: "ใหม่").destroy_all
Unknown key: :presence. … Perhaps you meant to call `validates` instead of `validate`? (ArgumentError) ลืมตัว s → แก้ validate เป็น validates
Unknown validator: 'PresenseValidator' (ArgumentError) สะกดชื่อกฎผิด → ดูชื่อในวงเล็บ (ตัวที่ถูกคือ presence)
The provided regular expression is using multiline anchors (^ or $) … regex ใช้ ^ $ → เปลี่ยนเป็น \A \z
# app/models/user.rb
class User < ApplicationRecord
  has_many :posts
  has_many :comments

  validates :name, presence: true
  validates :email, presence: true, format: { with: /\A[^@\s]+@[^@\s]+\z/ }
end

ผลรันจริง (ต่อจาก console ที่ reload! แล้ว):

wongnok-blog(dev):003> reload!
Reloading...
=> nil
wongnok-blog(dev):004> u = User.new(name: "", email: "mana.example.com")
=> #<User:0x0000000123e3a548 id: nil, name: "", email: "[FILTERED]", created_at: nil, updated_at: nil>
wongnok-blog(dev):005> u.valid?
=> false
wongnok-blog(dev):006> u.errors.full_messages
=> ["Name can't be blank", "Email is invalid"]
wongnok-blog(dev):007> User.create!(name: "ใหม่", email: "")
(wongnok-blog):7:in '<main>': Validation failed: Email can't be blank, Email is invalid (ActiveRecord::RecordInvalid)
wongnok-blog(dev):008> User.all.all?(&:valid?)
  User Load (3.4ms)  SELECT "users".* FROM "users" /*application='WongnokBlog'*/
=> true
  • email ที่เป็น "" โดนสองกฎพร้อมกัน (ว่าง + ไม่ตรงแบบ) · Rails ตรวจครบทุกข้อแล้วรายงานทีเดียว ไม่ได้หยุดที่ข้อแรก
  • email: "[FILTERED]" = Rails ซ่อนค่าอีเมลตอนโชว์ object ให้เอง (กันข้อมูลส่วนตัวหลุดลง log) · ค่าจริงยังอยู่ครบ
  • เลขบรรทัด :003–:008 ของคุณอาจไม่ตรง ขึ้นกับว่าพิมพ์อะไรไปก่อน
  • ยังไม่ได้กันอีเมลซ้ำ (สมชายสองคนใช้อีเมลเดียวกันได้) — นั่นคือเรื่องของบท 111 ที่กฎใน model อย่างเดียวกันไม่อยู่

ทดสอบความเข้าใจ

1. Post มี belongs_to :user และ validates :title, presence: true · พิมพ์ p = Post.new(title: "ลาบหมูทอด") (ไม่ได้ใส่ผู้เขียน) แล้ว p.save จะได้อะไร?

ตัวเลือกที่ 2 ถูก · ผมลองแล้วได้ => false และ p.errors.full_messages เป็น ["User must exist"] · ไม่มี SQL โผล่เลยสักบรรทัด (ไม่มี INSERT) — คนดูแลสต็อกตีกลับก่อนเดินไปตู้

ตัวเลือกที่ 1 ผิด — belongs_to แอบเพิ่มกฎ "ต้องมีผู้เขียน" ไว้ให้ แม้เราไม่ได้เขียน · ตัวเลือกที่ 3 ผิด — save ไม่มี ! แค่คืน false ไม่ระเบิด · ตัวเลือกที่ 4 ผิด — validation ตีกลับก่อนถึงฐานข้อมูล ฐานข้อมูลจึงไม่ได้เห็นแฟ้มนี้เลย

จุดที่ต้องจำ: บันทึกไม่ผ่านแล้วงง ให้ดู errors.full_messages ก่อน · กฎบางข้อมาจาก belongs_to ไม่ได้มาจากบรรทัดที่เราเขียน

2. เพื่อนเขียน validate :title (ไม่มี s และไม่มี presence: true) ไว้ใน Post · Post.new(user: User.first, title: "").valid? ได้อะไร?

ตัวเลือกที่ 4 ถูก · ผมลองแล้วได้ => true · validate :ชื่อ = "เรียก method ชื่อนี้ตอนตรวจ" · title เป็น method อ่านค่าช่องที่มีอยู่แล้ว มันคืนค่าออกมาเฉย ๆ ไม่ได้เรียก errors.add

ตัวเลือกที่ 1 ผิด — Rails เตือนเฉพาะตอนเขียน validate :title, presence: true (มีชื่อกฎต่อท้าย) · แบบไม่มีชื่อกฎ Rails ไม่มีเบาะแสให้จับ · ตัวเลือกที่ 2 ผิด — Rails ไม่เดาใจ ทำตามที่เขียนเป๊ะ ๆ · ตัวเลือกที่ 3 ผิด — title มีอยู่จริง เพราะ Rails สร้าง method ให้ทุกคอลัมน์ (บท 106)

จุดที่ต้องจำ: เขียนกฎเสร็จ ลองให้มันพังหนึ่งครั้งเสมอ · มี s = กฎสำเร็จรูป · ไม่มี s = เรียก method ของเรา

3. ใน db/seeds.rb ถ้าบรรทัดไหนสร้างผู้ใช้ไม่ผ่านกฎ อยากให้รู้ทันทีตอนรัน · ควรเขียนแบบไหน?

ตัวเลือกที่ 3 ถูก · seeds เป็นข้อมูลที่เราเขียนเอง ผิดแปลว่าบั๊ก · create! โยน Validation failed: … ทันที (seeds ของแอปเราก็ใช้ create! ทุกบรรทัด)

ตัวเลือกที่ 1 ผิด — create ที่ไม่ผ่านคืนแฟ้มที่ id: nil มาเงียบ ๆ สคริปต์วิ่งต่อจนจบโดยไม่มีใครเปิด errors ดู · ตัวเลือกที่ 2 ผิด — new ไม่บันทึกอะไรเลย seeds จึงไม่ได้ข้อมูล · ตัวเลือกที่ 4 ผิด — find_by! คือ "หา" ไม่ใช่ "สร้าง" · ! แปลว่าหาไม่เจอให้ระเบิด

จุดที่ต้องจำ: ผิดเป็นเรื่องปกติ (ฟอร์มผู้ใช้) → ไม่มี ! แล้วเช็คผล · ผิดคือบั๊ก (seeds · สคริปต์) → มี ! ให้ระเบิดดัง ๆ

4. ใน action create ถ้า @post.save ได้ false ทำไมถึง render :new, status: :unprocessable_content แทนที่จะ redirect_to ไปหน้าฟอร์ม?

ตัวเลือกที่ 1 ถูก · @post ที่ save ไม่ผ่านยังถือทั้งค่าที่ผู้ใช้กรอกและ @post.errors · render ส่งต่อให้ view ใน request เดิม ผู้ใช้เห็นว่าผิดตรงไหนโดยไม่ต้องพิมพ์ใหม่

ตัวเลือกที่ 2 ผิด — action ที่รับ POST redirect ได้ปกติ (ตอนสำเร็จเราก็ redirect) แต่ redirect = request ใหม่ @post กับ errors หายหมด (บท 102) · ตัวเลือกที่ 3 ผิด — render :new แค่ใช้ไฟล์ view ชื่อ new ไม่ได้รัน def new · ตัวเลือกที่ 4 ผิด — 422 แค่บอกว่า "ข้อมูลใช้ไม่ได้" ค่าในฟอร์มมาจาก @post ไม่ได้มาจาก browser

จุดที่ต้องจำ: save สำเร็จ → redirect · save ไม่ผ่าน → render ฟอร์มเดิมพร้อม 422 · ใช้ :unprocessable_content ไม่ใช่ชื่อเก่า

ถามกลับได้เลย — ถ้าบันทึกไม่ผ่านแล้วไม่รู้ทำไม ให้ก็อปผลของ p.errors.full_messages และไฟล์ model มาถาม · สองอย่างนี้บอกเกือบทุกอย่างว่ากฎข้อไหนตีกลับ และกฎนั้นมาจากบรรทัดไหน

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

หน้าสไลด์เขียนของจริง (Rails 8.1.4)ต้องสนใจไหม
22validates :views, numericality: { greater_than: 0 } views เริ่มต้นเป็น 0 → บทความใหม่ทุกชิ้นได้ Views must be greater than 0 ใช่ — ใช้ greater_than_or_equal_to: 0
22พิมพ์ validate แทน validates "ไม่ error แค่กฎไม่ทำงาน" validate :title, presence: true ได้ ArgumentError พร้อมใบ้ · แบบที่เงียบจริงคือ validate :title (ไม่มีชื่อกฎ) รู้ไว้ — หัวข้อ 5
22ใช้ช่อง slug · status · terms · published_at ตาราง posts ของเราไม่มีช่องเหล่านี้ · บทนี้ลองกฎเดียวกันกับช่องที่มีจริง ไม่ต้อง
23p.errors.full_messages # => ["Title can't be blank"] แอปเรามี belongs_to :user จึงได้ "User must exist" เพิ่มมาด้วย รู้ไว้ — หัวข้อ 2
23status: :unprocessable_entity ยังได้ 422 · Rails 8.1.4 แปลงให้เงียบ ๆ แต่ Rack ประกาศเลิกใช้ชื่อนี้แล้ว · ชื่อใหม่ :unprocessable_content ใช่ — เขียนชื่อใหม่
28p.save(validate: false) · และ Post.where("title LIKE ?", "%console%") "ล้างของทดสอบทิ้ง" save(validate: false) สอนในบท 111 · where แค่ค้น ไม่ได้ลบ ต้องต่อ .destroy_all รู้ไว้

อ้างอิง