🔁 ทวนก่อนเริ่ม — คำพวกนี้จะโผล่ในบทนี้ทั้งหมด
- model
Post(บท 106) — class ในapp/models/post.rbเปรียบเป็นคนดูแลสต็อกที่รู้ว่าแฟ้มบทความเก็บลิ้นชักไหน new·save·create·persisted?(บท 107) — สร้างแฟ้มในมือ · เอาเข้าตู้ · ทำสองอย่างรวดเดียว · ถามว่า "อยู่ในตู้แล้วหรือยัง"post.errors.full_messages·User must exist(บท 107) — ตอน save บทความที่ไม่มีผู้เขียนแล้วได้falseเราถามด้วยคำสั่งนี้ว่า "ไม่รับเพราะอะไร" · บทนี้คือเรื่องเต็มของมัน- console (บท 104) — ครัวทดลองหลังร้าน เปิดด้วย
bin/rails console· แก้ไฟล์ในapp/แล้วต้องพิมพ์reload!ก่อนลองใหม่ !ท้ายชื่อ method (บท 93) — ป้าย "ระวัง ตัวนี้อันตรายกว่าตัวที่ไม่มี!"render·redirect_to· 422 (บท 102 · บท 100) — ยกจานให้เลย · ชี้ไปเคาน์เตอร์อื่น · status "ข้อมูลที่ส่งมาใช้ไม่ได้"
🧰 เวอร์ชันที่ใช้ตรวจ: 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) ทีละบรรทัด:
User Load (1.1ms)·Post Create (1.8ms)= ป้ายหัวบรรทัดบอกว่า model ไหนทำอะไร (โหลด · สร้าง) และใช้เวลากี่มิลลิวินาทีSELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT 1= "ขอดู (SELECT) แฟ้มทุกช่อง (*) จากลิ้นชัก users เรียงตามเลข id จากน้อยไปมาก เอาแผ่นแรกแผ่นเดียว (LIMIT 1)" — คือUser.first(ได้สมชาย)BEGIN…COMMIT= "เริ่มงาน … ยืนยันงาน" · ของที่อยู่ระหว่างสองบรรทัดนี้ถูกเขียนลงตู้จริงINSERT INTO "posts" (…) VALUES ('', NULL, …)= "ใส่แฟ้มแผ่นใหม่ลงลิ้นชัก posts · ช่อง title เป็นข้อความว่าง ('') · ช่อง body ว่างเปล่า (NULL)" · INSERT คือคำสั่ง "เพิ่มแถวใหม่" ของ SQL ·RETURNING "id"ท้ายบรรทัด = "ใส่เสร็จแล้วบอกเลขที่แฟ้มกลับมาด้วย"/*application='WongnokBlog'*/ท้ายทุกบรรทัด = ป้ายที่ Rails แปะบอกว่า SQL มาจากแอปไหน — ข้ามได้=>ตามด้วย...= console ย่อผลยาว ๆ ให้เอง เวลาบรรทัดนั้นเป็นการใส่ค่าให้ตัวแปร (p = …)
บทความที่ไม่มีชื่อเรื่องเข้าตู้ไปเรียบร้อย · หน้ารายการบทความจะมีบรรทัดที่ไม่มีชื่อเรื่องโผล่มาให้ผู้อ่านงง
ลบทิ้งก่อนไปต่อด้วย 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
validates= "ขอตั้งกฎสำเร็จรูปให้ช่องนี้" · เป็น method ที่ApplicationRecordให้มา (เรียกในตัว class ได้เลย แบบเดียวกับbelongs_to):title= ชื่อช่องที่จะตรวจ (symbol — บท 92)presence: true= ชื่อกฎ "ต้องมีของ" · ว่าง (nil) · ข้อความว่าง ("") · ช่องว่างล้วน ไม่ผ่านทั้งหมด · เป็น hash ท้าย argument ที่ไม่ต้องใส่ปีกกา (บท 97)
ทีนี้ลองในครัวทดลอง · ถ้า 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·presencevalidates :ช่อง, ชื่อกฎ: ค่า= ตั้งกฎสำเร็จรูป ·presence= ห้ามว่าง (รวมช่องว่างล้วน) · พิมพ์บอก AI ได้ว่า "add presence validation on Post#title and show me the error message in the console"valid?·errors·full_messagesvalid?= ตรวจโดยไม่บันทึก ·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 เคยเห็นผ่านตา):
แปลรวม: "มีอะไรสักอย่าง @ มีอะไรสักอย่าง · ไม่มีช่องว่าง · มี @ ตัวเดียว" ·
[^…] = "ตัวไหนก็ได้ที่ไม่ใช่ของในวงเล็บ" · \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
สองคำนี้หน้าตาเกือบเหมือนกัน แต่คนละงาน:
validates :title, presence: true— มี s = "ใช้กฎสำเร็จรูปกับช่อง title"validate :body_needed_to_publish— ไม่มี s = "เรียก method ของเราชื่อนี้"
สไลด์หน้า 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={}>]
[]= array ว่าง = "ช่อง title ไม่มีกฎตรวจเลยสักข้อ" — จับแบบที่สามได้ทันทีโดยไม่ต้องลองสร้างแฟ้มPresenceValidator= "ตัวตรวจ presence" (validator แปลว่า ตัวตรวจ) ·@attributes=[:title]= ตรวจช่อง title ·@options={}= ไม่มีรายละเอียดเพิ่ม- อยากเห็นกฎทุกช่องใช้
Post.validators(ไม่มี_on) · จะเห็นตัวตรวจของ user ที่belongs_toเพิ่มให้ปนอยู่ด้วย
เทียบกับ 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 ทำตามทันทีโดยไม่สงสัย
validatesvsvalidate- มี 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
p.save→false· ไม่มีบรรทัด INSERT เลย คนดูแลสต็อกตีกลับก่อนเดินไปถึงตู้ · โค้ดเราต้องเช็คเองว่าได้trueหรือfalsep.save!→ โยน error ชื่อActiveRecord::RecordInvalidพร้อมข้อความValidation failed: …บอกว่าผิดข้อไหน ·(wongnok-blog):3:in '<main>'= "เกิดที่บรรทัดที่ 3 ที่พิมพ์ใน console · ไม่ได้อยู่ใน method ไหน" · ถ้าไม่มีใครrescue(บท 96) โปรแกรมหยุดตรงนั้น- แก้ชื่อเรื่องแล้ว
p.saveอีกรอบ → ผ่าน เห็น BEGIN · INSERT · COMMIT แล้วได้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
for ::1= request มาจากเครื่องตัวเอง (::1คือ localhost อีกแบบ) ·as */*= ผมยิงด้วยcurlไม่ได้กดจาก browser — ของคุณอาจเห็นคำอื่นตรงนี้"authenticity_token" => "[FILTERED]"= รหัสลับที่ฟอร์มของ Rails แนบมาให้เองทุกครั้ง กันเว็บอื่นแอบส่งฟอร์มแทนเรา · Rails ซ่อนค่าไว้ใน log · ไม่ต้องทำอะไรกับมัน"post" => {"title" => "", …}= ผู้ใช้ส่งชื่อเรื่องว่างมาจริง- มีแค่ SELECT หา User · ไม่มี INSERT — validation ตีกลับก่อนถึงตู้
Rendered posts/new.html.erb+Completed 422= เข้าelse· โชว์ฟอร์มเดิม
แล้วโน้ตสีเหลืองไปโผล่บนหน้าเว็บได้ยังไง? · คนจัดจาน (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 %>
<%# … %>= comment ของ ERB ไม่โผล่บนหน้าเว็บ ·<% %>= รันโค้ดเฉย ๆ ·<%= %>= รันแล้วเอาผลไปแปะบนหน้า (บท 103)@post.errors.any?= "มีโน้ตอย่างน้อยหนึ่งข้อไหม" · ไม่มี = ไม่ต้องวาดรายการเลย (ตอนเปิดฟอร์มครั้งแรกจึงไม่เห็นกล่อง error).each do |message|= วนทีละประโยค (บท 93) · แต่ละประโยคกลายเป็นหนึ่งบรรทัด<li>
ผลจริงเมื่อส่งฟอร์มแบบเดียวกัน (ชื่อเรื่องว่าง · เนื้อหา 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 อยู่แล้ว):
- เปิด
app/models/user.rb· ถ้ายังไม่ได้พิมพ์ตามหัวข้อ 3 ให้เพิ่มบรรทัดvalidates :email, format: { with: /\A[^@\s]+@[^@\s]+\z/ }ก่อน · แล้วเพิ่มกฎpresenceให้:name· และรวมpresenceเข้ากับformatของ:emailในบรรทัดเดียว (ท่ารวมกฎท้ายหัวข้อ 3) - เปิด
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) | ต้องสนใจไหม |
|---|---|---|---|
| 22 | validates :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 ของเราไม่มีช่องเหล่านี้ · บทนี้ลองกฎเดียวกันกับช่องที่มีจริง | ไม่ต้อง |
| 23 | p.errors.full_messages # => ["Title can't be blank"] |
แอปเรามี belongs_to :user จึงได้ "User must exist" เพิ่มมาด้วย |
รู้ไว้ — หัวข้อ 2 |
| 23 | status: :unprocessable_entity |
ยังได้ 422 · Rails 8.1.4 แปลงให้เงียบ ๆ แต่ Rack ประกาศเลิกใช้ชื่อนี้แล้ว · ชื่อใหม่ :unprocessable_content |
ใช่ — เขียนชื่อใหม่ |
| 28 | p.save(validate: false) · และ Post.where("title LIKE ?", "%console%") "ล้างของทดสอบทิ้ง" |
save(validate: false) สอนในบท 111 · where แค่ค้น ไม่ได้ลบ ต้องต่อ .destroy_all |
รู้ไว้ |
อ้างอิง
- Rails Guides — Active Record Validations —
อ่าน Validation Triggers (method ไหนตรวจ · ไหนไม่ตรวจ) · Checking Validity (
valid?) · แล้วข้ามไปหมวด Validations ดูกฎ presence · length · format · numericality · inclusion ทีละตัว - Rails Guides — Custom Methods —
วิธีเขียน
validate :ชื่อmethod+errors.add· ต่อด้วย Working with Validation Errors ถ้าอยากรู้ว่าerrorsทำอะไรได้อีก - Rails API — ActiveRecord::Validations —
ดู
save·save!·valid?สั้น ๆ ว่าแต่ละตัวคืนอะไร และsave!โยนRecordInvalid - Rails API — ActiveModel::Validations::ClassMethods —
เทียบ
validateกับvalidatesในหน้าเดียว · รายการ options ที่validateรับ (:on·:if·:unless…) คือที่มาของ error "Unknown key" - go-playground/validator —
ตัวตรวจที่อยู่เบื้องหลัง
binding:"required"ของ Gin (บท 39) · ดูรายชื่อ tag ใน README เทียบกับกฎสำเร็จรูปของ Rails