🔁 ทวนก่อนเริ่ม — บทนี้ใช้คำพวกนี้ตลอด ถ้าข้อไหนไม่คุ้นให้กลับไปบทนั้นก่อน
- validation ·
validates= กฎใน model ที่คนดูแลสต็อกใช้ตรวจแฟ้มก่อนเก็บ ·valid?ถามว่าผ่านไหม ·errors.full_messagesขอเหตุผลที่ไม่ผ่านเป็นประโยค (บท 110) savevssave!= ไม่ผ่านแล้วคืนfalseเงียบ ๆ · ไม่ผ่านแล้วระเบิด (โยน error) — กฎ!(บท 110)- migration ·
bin/rails db:migrate= ใบสั่งช่างมาดัดแปลงตู้ · สั่งให้ช่างลงมือ (บท 108) - SQL ใน console = บรรทัดที่ Rails พิมพ์ให้ดูทุกครั้งที่เดินไปตู้ — สมุดบันทึกการเบิกของ (บท 107)
- index = สารบัญ/ป้ายแท็บในลิ้นชัก ช่วยให้หาแฟ้มเจอเร็ว (บท 109) — บทนี้จะใช้มันอีกแบบ คือ "กันซ้ำ"
📋 model ที่บทนี้เริ่มต้น = model ตอนจบบท 110:
Post มีกฎ title ห้ามว่าง/ยาวไม่เกิน 120 · views ห้ามติดลบ (numericality) · บทความที่เผยแพร่ต้องมีเนื้อหา ·
User มีกฎ name ห้ามว่าง · email ห้ามว่างและต้องหน้าตาเป็นอีเมล (format) ·
ทุกกฎยังอยู่ในโค้ด Ruby อย่างเดียว ในตู้ยังไม่มีกฎพวกนี้เลย
🍜 ข้อมูลตัวอย่าง: ใช้ตาราง users · posts · comments และข้อมูลตัวอย่าง (seeds) ชุดเดียวกับบท 107
(สมชาย · สมหญิง · มานะ และบทความอาหาร 6 ชิ้น) · บทนี้จะแก้ model และเพิ่ม migrationหลายไฟล์ —
แนะนำให้ git add . && git commit -m "ก่อนบท 111" ไว้ก่อน จะได้ย้อนกลับได้
🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 17 (สไลด์ใช้ Ruby 4.0.6 · Rails 8.1.3 — เรื่องในบทนี้ไม่ต่างกัน)
ผลรันทุกก้อนรันจริงแล้วก็อปมา · ค่าที่เปลี่ยนทุกครั้ง เช่น เวลา ms · เลข id · เลขหน้าชื่อไฟล์ migration · เวลาที่บันทึก
ของคุณจะไม่ตรงกับของผม — เป็นเรื่องปกติ · path ยาว ๆ ในเครื่องผมย่อเป็น …
ประโยคเดียวของบทนี้
กฎใน model (validation) ไม่ใช่การรับประกัน — ของสำคัญต้องมีกฎที่ตัว database คู่กันเสมอ
นี่คือประโยคหลักข้อ ② ของ Day 2 (สไลด์หน้า 3 และ 59) · ทั้งบทนี้คือการพิสูจน์ประโยคนี้ด้วยผลรันจริง
ทำไมต้องพิสูจน์? เพราะพอเขียน validates :email, uniqueness: true ("อีเมลห้ามซ้ำ") แล้วลองสมัครซ้ำใน console
มันก็ฟ้องจริง ๆ — คนส่วนใหญ่เลยเชื่อว่า "กันแล้ว" · บทนี้จะทำให้เห็นว่ามันหลุดได้ ตอนไหน และปิดยังไง
ข้อมูลผิด ๆ ถูกกันได้ 4 ด่าน
ข้อมูลหนึ่งชิ้น (เช่น อีเมลที่ลูกค้ากรอกตอนสมัคร) เดินจากเบราว์เซอร์ไปถึง database ผ่านด่านได้ 4 ด่าน · แต่ละด่านกันได้คนละแบบ — ด่านที่อยู่ใกล้ลูกค้า บริการดี (บอกเร็ว บอกสวย) · ด่านที่อยู่ลึก รับประกันได้จริง (ข้ามไม่ได้)
- ด่าน ② strong parameters (
params.expect) แค่รู้ชื่อไว้ก่อน — บท 117 สอนละเอียด · บทนี้โฟกัสด่าน ① ③ ④ - ด่าน ④ คำว่า constraint แปลตรง ๆ ว่า "ข้อบังคับ" — คือกฎที่เขียนไว้ในตัวตารางของ database เอง ไม่ใช่ในโค้ด Ruby
- สี่ชื่อในกล่อง ④ ภาษาคน: NOT NULL = ช่องนี้ห้ามว่าง · unique index = ช่องนี้ห้ามซ้ำกับแผ่นอื่น · CHECK = เงื่อนไขที่เราเขียนเอง เช่น "ต้อง ≥ 0" · foreign key = เลขที่อ้างถึงแฟ้มอื่น ต้องมีแฟ้มนั้นอยู่จริง — บทนี้ได้ลองครบทุกตัว
ใน Go ที่เคยเรียน คุณเคยเห็นทั้งด่าน ③ และ ④ มาแล้วโดยไม่รู้ตัว:
binding:"required" ของ Gin (บท 39) คือด่าน ③ — โค้ดเราตรวจเอง ·
ส่วน gorm:"unique" ใน struct (บท 27) พอสั่ง AutoMigrate
GORM จะไปสร้างกฎห้ามซ้ำที่ตัวตาราง — นั่นคือด่าน ④
// Go — สองบรรทัดนี้อยู่คนละด่านกัน
Title string `json:"title" binding:"required"` // ด่าน ③ Gin ตรวจในโค้ด
Email string `gorm:"unique"` // ด่าน ④ GORM สร้างกฎในตาราง
ข้อความในเครื่องหมาย `…` ท้ายแต่ละช่องของ struct คือ tag (ป้ายกำกับที่ library อ่าน) ·
สองบรรทัดหน้าตาเหมือนกัน แต่ binding ถูกอ่านโดย Gin ตอนรับ request · gorm ถูกอ่านโดย GORM ตอนสร้างตาราง
① ป้ายหน้าร้าน เขียนว่า "กรุณากรอกชื่อในใบสั่ง" — ลูกค้าส่วนใหญ่ทำตาม แต่คนที่เดินเข้าทางหลังร้านไม่เห็นป้ายนี้เลย
② พนักงานต้อนรับ รับเฉพาะช่องที่อยู่บนแบบฟอร์ม ใครแอบเขียน "ขอฟรี" ไว้ขอบกระดาษ ตัดทิ้ง
③ คนดูแลสต็อก ตรวจแฟ้มก่อนเก็บ ไม่ผ่านก็บอกเหตุผลสุภาพ ๆ "ไม่มีชื่อเรื่อง ไม่รับครับ"
④ ตัวตู้ ออกแบบมาให้ใส่แฟ้มผิดแบบไม่ได้ทางกายภาพ — ไม่ว่าใครถือแฟ้มมา ทางประตูไหน ก็ยัดไม่เข้า
ที่ไม่เหมือนกัน: ป้ายหน้าร้านจริงอย่างน้อยก็ติดอยู่ที่ร้านเรา ·
แต่ด่าน ① ของแอปอยู่ในเบราว์เซอร์ของลูกค้า — เราควบคุมไม่ได้เลย ลูกค้าจะลบป้ายทิ้งก็ได้ ส่วนด่าน ② ③ คือโค้ดของเราเอง
และตัวตู้จริงไม่พูด แต่ตู้ของเรา "พูด" ได้ — แค่พูดเป็นภาษาเครื่อง (PG::UniqueViolation) ที่ลูกค้าอ่านไม่รู้เรื่อง
เราจึงยังต้องมีคนดูแลสต็อกไว้แปลเป็นภาษาคน
- validation (application-level)
- กฎที่โค้ดของเราตรวจเอง (ด่าน ③) · บอกเหตุผลเป็นภาษาคนได้ แต่เลี่ยงได้ · ถาม senior ได้ว่า "กฎนี้มีแค่ validation หรือมี constraint ที่ DB ด้วย"
- database constraint
- กฎที่ตัว database บังคับ (ด่าน ④) เช่น
NOT NULL·UNIQUE·CHECK·FOREIGN KEY· ข้ามไม่ได้แม้ยิง SQL ตรง · พิมพ์บอก AI ได้ว่า "add a database constraint to match this validation" - defense in depth
- แนวคิด "ป้องกันหลายชั้น ชั้นหนึ่งพลาด อีกชั้นยังกันอยู่" · ไม่ได้แปลว่าทุกชั้นต้องเก่งเท่ากัน แต่ละชั้นมีหน้าที่ของมัน
เตรียมแผนกสมัครสมาชิก (ก็อปวางได้เลย)
จะทดลองด่าน ① ได้ ต้องมีฟอร์มก่อน · เพิ่มไฟล์ 3 ไฟล์นี้ในแอป wongnok_blog ของคุณ —
route · controller · view แบบเดียวกับ Lab ของ Day 1 (บท 105)
① route — แก้ config/routes.rb ให้มีบรรทัด resources นี้ (บรรทัดอื่นในไฟล์คงไว้ได้):
Rails.application.routes.draw do
resources :users, only: [:new, :create, :show]
end
resources :users ให้ 8 เส้นแบบบท 101 ·
only: [...] = "เอาแค่ 3 เส้นนี้" — หน้าฟอร์ม (new) · รับฟอร์ม (create) · หน้าดูผล (show)
② controller — ไฟล์ใหม่ app/controllers/users_controller.rb:
class UsersController < ApplicationController
def new
@user = User.new
end
def create
@user = User.new(user_params)
if @user.save
redirect_to @user
else
render :new, status: :unprocessable_content
end
end
def show
@user = User.find(params[:id])
end
private
def user_params
params.expect(user: [:name, :email])
end
end
new— เตรียมแฟ้มเปล่าหนึ่งแผ่นวางบนถาด (@user) ให้ view เอาไปทำฟอร์มcreate— สร้างแฟ้มจากของที่ลูกค้ากรอก ·if @user.save= ถ้าเก็บได้ พาไปหน้าดูผล (redirect_to @user— Rails แปลง user เป็น URL/users/เลข idให้เอง) ถ้าเก็บไม่ได้ (คืนfalse) ให้แสดงฟอร์มเดิมอีกรอบพร้อมเหตุผลstatus: :unprocessable_content= ตอบกลับด้วยรหัส 422 แปลว่า "ข้อมูลที่ส่งมาใช้ไม่ได้" (รหัส HTTP แบบบท 34) · ชื่อเดียวกับที่บท 110 ใช้ · สไลด์เขียนชื่อเก่า:unprocessable_entityซึ่งยังได้ 422 แต่ขึ้นคำเตือน deprecateduser_params— ด่าน ② · "จากของที่ส่งมา รับเฉพาะnameกับemailในกลุ่มuser" ·private= method ใต้บรรทัดนี้เรียกจากนอก class ไม่ได้ (ทวนบท 102 · ทำไมuser_paramsต้องมี ละเอียดบท 117)
③ view — ไฟล์ใหม่ app/views/users/new.html.erb (หน้าฟอร์ม) และ app/views/users/show.html.erb (หน้าดูผล):
<h1>สมัครสมาชิก</h1>
<% @user.errors.full_messages.each do |message| %>
<p class="error"><%= message %></p>
<% end %>
<%= form_with model: @user do |f| %>
<%= f.text_field :name, required: true %>
<%= f.email_field :email, required: true %>
<%= f.submit "สมัคร" %>
<% end %>
<p>สมัครแล้ว: <%= @user.name %> (<%= @user.email %>)</p>
- ก้อนบน — วนพิมพ์เหตุผลที่ไม่ผ่านทีละบรรทัด (ถ้าไม่มี ก็ไม่พิมพ์อะไร)
form_with model: @user— ตัวช่วยสร้างฟอร์มให้ตรงกับ model (ละเอียด Day 3) · ส่งไปที่POST /usersให้เองrequired: true— ด่าน ① · บอกเบราว์เซอร์ว่า "ช่องนี้ห้ามว่าง" ·email_fieldได้type="email"ให้ด้วย
เปิด server (bin/rails server แบบ Day 1) แล้วดู HTML ที่ได้ (ผมใช้ curl ดึงมา ตัดมาเฉพาะส่วนฟอร์ม):
$ curl -s http://localhost:3000/users/new
…
<form action="/users" accept-charset="UTF-8" method="post"><input type="hidden" name="authenticity_token" value="FfDTTtzF…" />
<input required="required" type="text" name="user[name]" id="user_name" />
<input required="required" type="email" name="user[email]" id="user_email" />
<input type="submit" name="commit" value="สมัคร" data-disable-with="สมัคร" />
</form>
เห็น required="required" ทั้งสองช่อง · ชื่อช่องเป็น user[name] · user[email] (นี่คือ "กลุ่ม user" ที่ params.expect พูดถึง)
· ส่วน authenticity_token เดี๋ยวได้เจอในหัวข้อถัดไป
ด่าน ① ป้ายหน้าร้าน — เบราว์เซอร์กันได้ curl ข้ามได้
เปิด http://localhost:3000/users/new ในเบราว์เซอร์แล้วกด "สมัคร" ทั้งที่ยังว่าง —
เบราว์เซอร์ไม่ยอมส่ง แต่ขึ้นกล่องเตือนใต้ช่อง (เบราว์เซอร์ที่ผมใช้ตรวจ ตั้งภาษาอังกฤษ ขึ้นว่า Please fill out this field. — ของคุณอาจเป็นภาษาไทย)
· ผมเช็ค log ของ server แล้ว ไม่มีใบสั่ง POST เข้ามาเลย — ป้ายหน้าร้านทำงานจริง
แต่ป้ายนี้อยู่ในเบราว์เซอร์ · ถ้าส่งด้วยโปรแกรมอื่นที่ไม่อ่าน HTML เช่น curl ก็ไม่มีใครเตือน
· ลองส่งอีเมลว่างตรง ๆ หนึ่งบรรทัด:
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/users -d "user[name]=ghost" -d "user[email]="
422
-d "ชื่อ=ค่า"— ส่งข้อมูลแบบฟอร์ม (curl จะใช้POSTให้เอง) ·user[email]=คืออีเมลว่าง-o /dev/null -w '%{http_code}\n'— ทิ้งหน้าเว็บ พิมพ์แค่รหัสตอบกลับ
ได้ 422 — แต่ไม่ใช่เพราะอีเมลว่าง · ดูใน log ของ server:
Can't verify CSRF token authenticity.
ActionController::InvalidAuthenticityToken (Can't verify CSRF token authenticity.):
Rails ฝังรหัสลับ (authenticity_token) ไว้ในฟอร์มทุกใบ แล้วเช็คว่าใบสั่งที่ส่งกลับมามีรหัสนี้ไหม
— กันเว็บอื่นแอบส่งฟอร์มแทนเรา (เรื่องนี้ชื่อ CSRF เคยเห็นในบท 103 · ไม่ใช่การตรวจข้อมูล)
· ถ้าเอารหัสจากหน้าฟอร์มมาด้วย ก็ส่งได้ — ใช้สองคำสั่ง:
$ TOKEN=$(curl -s -c jar.txt http://localhost:3000/users/new | grep -o 'name="authenticity_token" value="[^"]*"' | cut -d'"' -f4)
$ curl -s -i -b jar.txt http://localhost:3000/users -d "authenticity_token=$TOKEN" -d "user[name]=ghost" -d "user[email]="
HTTP/1.1 422 Unprocessable Content
…
<p class="error">Email can't be blank</p>
<p class="error">Email is invalid</p>
…
- บรรทัดแรก — เปิดหน้าฟอร์ม เก็บ cookie (บัตรสมาชิกที่เว็บฝากไว้ · บท 103) ลงไฟล์
jar.txt(-c) แล้วตัดเอาเฉพาะรหัสลับใส่ตัวแปรTOKEN· ไม่ต้องเข้าใจทุกตัวอักษร - บรรทัดสอง — ส่งฟอร์มพร้อม cookie (
-b) และรหัสลับ ·-i= พิมพ์หัวของคำตอบให้ดูด้วย - ได้
422พร้อมหน้าฟอร์มที่มีเหตุผลสองบรรทัด ·'คือเครื่องหมาย'ในแบบที่ HTML เขียน (เบราว์เซอร์จะโชว์เป็นcan't)
ส่วนใน log ของ server มีบรรทัดนี้ (เวลาของคุณจะต่าง):
Started POST "/users" for ::1 at 2026-11-04 19:19:02 +0700
…
Completed 422 Unprocessable Content in 6ms (Views: 1.2ms | ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 0.3ms)
Started POST "/users" = ใบสั่งเดินทะลุป้ายหน้าร้านเข้ามาถึงครัวแล้ว — ป้าย required ไม่ได้กันอะไรเลย เพราะใบสั่งไม่ได้ผ่านเบราว์เซอร์ ·
ที่อีเมลว่างไม่ถูกเก็บ เป็นเพราะด่าน ③ (กฎ presence · format ของ email ที่ใส่ไว้ในบท 110) ตีกลับให้
ถ้าไม่มีด่าน ③ ล่ะ? ผมลองใส่ # หน้าบรรทัด validates ทั้งสองของ User ชั่วคราว แล้วส่งสองคำสั่งเดิม — ได้
HTTP/1.1 302 Found กับ location: http://localhost:3000/users/4 = เก็บสำเร็จ · user อีเมลว่างเข้าตู้ไปจริง
(ผมลบทิ้งแล้วเอา # ออกตามเดิม ไม่ต้องลองตาม) · ด่าน ① มีไว้ให้ลูกค้าดี ๆ สะดวก ไม่ได้มีไว้กันคนตั้งใจ
ด่าน ③ คนดูแลสต็อก — มีประตูข้างชื่อ save(validate: false)
ด่าน ③ ดูแข็งแรงกว่า เพราะอยู่ในโค้ดของเราเอง ไม่ว่าส่งมาจากเบราว์เซอร์หรือ curl ก็ต้องผ่าน save เหมือนกัน
· แต่ Rails มีคำสั่งที่จงใจข้ามการตรวจให้ใช้ด้วย — ตัวที่เห็นชัดที่สุดคือ save(validate: false)
("เก็บเลย ไม่ต้องตรวจ")
Post ของคุณมีกฎ "ต้องมีชื่อเรื่อง" อยู่แล้วจากบท 110 — ไม่ต้องแก้อะไร · บรรทัดที่เกี่ยวกับการทดลองนี้คือ:
# app/models/post.rb (ตัดมาเฉพาะบรรทัดที่เกี่ยว · กฎอื่นจากบท 110 คงไว้)
validates :title, presence: true, length: { maximum: 120 }
ใน bin/rails console — สร้างบทความของสมชายที่ไม่มีชื่อเรื่อง แล้วลองเก็บสองแบบ:
somchai = User.find_by(name: "สมชาย")
post = Post.new(user: somchai)
post.save
post.save(validate: false)
save คืน false แน่ ๆ (ไม่มีชื่อเรื่อง) — แล้ว save(validate: false) ล่ะ? แฟ้มแผ่นนี้เข้าตู้ไหม?
เข้าตู้ — ทั้งที่ไม่มีชื่อเรื่อง · ผลจริง (ผมพิมพ์ valid? · errors.full_messages และอ่านค่ากลับเพิ่มให้ดูด้วย):
wongnok-blog(dev):001> somchai = User.find_by(name: "สมชาย")
User Load (1.5ms) SELECT "users".* FROM "users" WHERE "users"."name" = 'สมชาย' LIMIT 1 /*application='WongnokBlog'*/
=> #<User … (ผลยาว ตัดออก)
wongnok-blog(dev):002> post = Post.new(user: somchai)
=> #<Post … (ผลยาว ตัดออก)
wongnok-blog(dev):003> post.valid?
=> false
wongnok-blog(dev):004> post.errors.full_messages
=> ["Title can't be blank"]
wongnok-blog(dev):005> post.save
=> false
wongnok-blog(dev):006> post.save(validate: false)
TRANSACTION (1.2ms) BEGIN /*application='WongnokBlog'*/
Post Create (3.2ms) INSERT INTO "posts" ("title", "body", "created_at", "updated_at", "published", "views", "user_id") VALUES (NULL, NULL, '2026-11-04 11:46:08.959402', '2026-11-04 11:46:08.959402', FALSE, 0, 1) RETURNING "id" /*application='WongnokBlog'*/
TRANSACTION (1.4ms) COMMIT /*application='WongnokBlog'*/
=> true
wongnok-blog(dev):007> post.id
=> 8
wongnok-blog(dev):008> Post.find(post.id).title
Post Load (2.0ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 8 LIMIT 1 /*application='WongnokBlog'*/
=> nil
อ่าน SQL ทีละบรรทัดเป็นภาษาคน:
SELECT "users".* FROM "users" WHERE "users"."name" = 'สมชาย' LIMIT 1— "ขอแฟ้มทุกช่อง (SELECT … *) จากลิ้นชัก users (FROM) เฉพาะแผ่นที่ช่อง name เป็น สมชาย (WHERE) เอาแค่แผ่นเดียว (LIMIT 1)"BEGIN…COMMIT— "เริ่มงานชุดหนึ่ง" … "ยืนยันว่าเก็บจริง" (คือ transaction — อธิบายท้ายบท)INSERT INTO "posts" (…) VALUES (NULL, NULL, …, 1)— "ใส่แฟ้มแผ่นใหม่ลงลิ้นชัก posts ช่องตามลำดับในวงเล็บแรก ค่าตามวงเล็บที่สอง" · ช่องแรก (title) เป็นNULL= ไม่มีค่า (คือnilฝั่ง Ruby) · ช่องสุดท้าย (user_id) เป็น 1 = สมชาย ·RETURNING "id"= "เก็บเสร็จแล้วบอกเลขที่แฟ้มกลับมาด้วย" (บท 107)- ท่อน
/*application='WongnokBlog'*/ท้ายทุกบรรทัด — ป้ายที่ Rails แปะบอกว่าคำสั่งมาจากแอปไหน ไม่ต้องสนใจ
save(validate: false) คืน true · อ่านกลับมาได้ title เป็น nil —
คนดูแลสต็อกถูกข้าม และตัวตู้ก็ไม่ว่าอะไร เพราะช่อง title ในตาราง posts ไม่ได้ตั้งกฎห้ามว่างไว้
· เลข id ของคุณจะต่างไป
ลบของทดลองทิ้งก่อนไปต่อ: Post.where(title: nil).destroy_all (destroy_all = ลบทุกแผ่นที่ค้นเจอ)
ทีนี้ลองแบบเดียวกัน แต่ไม่ใส่ผู้เขียน — Post.new เปล่า ๆ เหมือนในสไลด์หน้า 28:
wongnok-blog(dev):001> post = Post.new
=> #<Post … (ผลยาว ตัดออก)
wongnok-blog(dev):002> post.save(validate: false)
TRANSACTION (2.0ms) BEGIN /*application='WongnokBlog'*/
Post Create (5.6ms) INSERT INTO "posts" ("title", "body", "created_at", "updated_at", "published", "views", "user_id") VALUES (NULL, NULL, '2026-11-04 12:04:59.481696', '2026-11-04 12:04:59.481696', FALSE, 0, NULL) RETURNING "id" /*application='WongnokBlog'*/
TRANSACTION (0.7ms) ROLLBACK /*application='WongnokBlog'*/
(wongnok-blog):2:in '<main>': PG::NotNullViolation: ERROR: null value in column "user_id" of relation "posts" violates not-null constraint (ActiveRecord::NotNullViolation)
DETAIL: Failing row contains (9, null, null, 2026-11-04 12:04:59.481696, 2026-11-04 12:04:59.481696, f, 0, null).
… (บรรทัดยาว ๆ ที่ขึ้นต้นด้วย from … และข้อความเดิมซ้ำอีกรอบ ตัดออก)
- Rails ข้ามการตรวจจริง แล้วส่ง
INSERTไปที่ตู้ — ช่อง user_id เป็นNULL - ตู้ปฏิเสธ:
null value in column "user_id" … violates not-null constraint= "ช่อง user_id ห้ามว่าง แต่คุณส่งค่าว่างมา" ROLLBACK= "ยกเลิกงานชุดนี้ ไม่เก็บอะไรเลย" · ฝั่ง Ruby ได้ error ชื่อActiveRecord::NotNullViolation·(wongnok-blog):2หน้า error = "พังที่คำสั่งบรรทัดที่ 2 ที่พิมพ์ใน console"DETAIL: Failing row contains (…)= หน้าตาของแฟ้มที่ถูกปฏิเสธ · ตัวเลขแรก (ของผมคือ 9) เป็น id ที่ตู้จองไว้ให้แล้วแต่ไม่ได้ใช้ — บทความถัดไปจะข้ามเลขนี้ไป เป็นเรื่องปกติ
ทำไมรอบนี้ตู้กันได้ แต่รอบที่แล้วไม่กัน? เพราะตอนบท 107 สร้างช่อง user_id
(generate migration AddUserToPosts user:references) generator ใส่ null: false ไว้ให้ (ดูใน db/schema.rb: t.bigint "user_id", null: false) ·
ส่วนช่อง title ไม่ได้ใส่ — นี่คือ NOT NULL: กฎ "ช่องนี้ห้ามว่าง" ที่เขียนไว้ในตัวตาราง
save(validate: false) ไม่ใช่ประตูข้างบานเดียว · เอกสารทางการของ Rails ระบุคำสั่งที่ข้าม validationไว้เป็นรายการ
(Rails Guides — Active Record Validations หัวข้อ 1.4) ตัวที่จะเจอบ่อย:
| คำสั่ง | ทำอะไร | เจอที่ไหนบ่อย |
|---|---|---|
save(validate: false) | เก็บโดยไม่ตรวจ | script แก้ข้อมูล · โค้ดที่ AI เขียนให้ "ผ่าน" ไว้ก่อน |
update_column · update_columns | แก้ช่องตรง ๆ ไม่ตรวจ | แก้ข้อมูลทีละแถวใน console |
update_all | แก้หลายแถวทีเดียว ไม่ตรวจ | ล้างข้อมูลทั้งตาราง (ภารกิจท้ายบทจะได้ใช้) |
insert_all · upsert_all | ใส่หลายแถวทีเดียว ไม่ตรวจ | นำเข้าข้อมูลจากไฟล์ |
| SQL ตรง ๆ | ไม่ผ่าน Rails เลย | เปิด psql (โปรแกรมพิมพ์ SQL ตรง ๆ ของ PostgreSQL) หรือโปรแกรมอื่นที่ต่อ database เดียวกัน |
ทั้งหมดนี้เดินอ้อมคนดูแลสต็อกได้ แต่อ้อมตัวตู้ไม่ได้ — ถ้าตู้มีกฎ · นั่นคือเหตุผลที่ของสำคัญต้องมีด่าน ④
คนดูแลสต็อกยืนตรวจแฟ้มอยู่หน้าห้องเก็บของ · แต่ร้านมีประตูหลังที่พนักงานบางคนถือแฟ้มเดินเข้าไปเสียบลิ้นชักเองได้เลย ไม่ผ่านโต๊ะตรวจ
ลิ้นชัก posts ทำช่อง "ผู้เขียน" เป็นช่องบังคับไว้ (แฟ้มที่ช่องนี้ว่าง เสียบไม่ลง) แต่ช่อง "ชื่อเรื่อง" ไม่ได้ทำ ·
แฟ้มไร้ชื่อเรื่องจึงเข้าทางประตูหลังได้ ส่วนแฟ้มไร้ผู้เขียนเข้าไม่ได้ไม่ว่าทางไหน
ที่ไม่เหมือนกัน: ประตูหลังร้านจริงเป็นเรื่องผิดปกติ · ใน Rails คำสั่งพวกนี้ตั้งใจทำไว้ให้ใช้ (เช่น แก้ข้อมูลทีละมาก ๆ ให้เร็ว) ไม่ใช่ช่องโหว่ — ปัญหาคือคนใช้มักลืมว่ามันไม่ผ่านโต๊ะตรวจ
เทียบ Go: ใน GORM ถ้าเขียน SQL เองด้วย db.Exec("INSERT INTO …") ก็ข้ามทุกอย่างในโค้ด Go ไปเหมือนกัน ·
กฎที่เหลืออยู่คือกฎที่อยู่ในตาราง เช่น tag gorm:"not null" ที่ GORM ใส่ให้ตอนสร้างตาราง (ด่าน ④)
- NOT NULL constraint
- กฎที่ตารางบอกว่า "ช่องนี้ห้ามเป็น NULL (ไม่มีค่า)" · ใน migration เขียน
null: false· พังแล้วได้ActiveRecord::NotNullViolation· ถาม senior ได้ว่า "คอลัมน์นี้ presence อย่างเดียว หรือมี NOT NULL ด้วย" - skip validations / bypass
- คำสั่งที่เก็บลง database โดยไม่รัน validation · พิมพ์บอก AI ได้ว่า "don't use update_column or save(validate: false) here, I want validations to run"
uniqueness: true — กฎที่ดูเหมือนกันอีเมลซ้ำได้
ประตูข้างยังพอเข้าใจได้ — "ก็คนจงใจข้ามนี่" · แต่เรื่องต่อไปน่ากลัวกว่า: ไม่มีใครข้ามอะไรเลย ใช้ save ธรรมดา แต่ข้อมูลก็ยังซ้ำ
· ใส่กฎ "อีเมลห้ามซ้ำ" ให้ User ก่อน — เพิ่มบรรทัดสุดท้ายบรรทัดเดียวต่อจากกฎของบท 110 (ไฟล์ app/models/user.rb):
# 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/ }
validates :email, uniqueness: true # ← บรรทัดใหม่ของบทนี้
end
uniqueness: true = "อีเมลนี้ต้องไม่ซ้ำกับแฟ้มอื่นในลิ้นชัก users" · จะเขียนรวมกับบรรทัดบน
(presence: true, format: …, uniqueness: true) ก็ได้ผลเหมือนกัน — แยกบรรทัดไว้ให้เห็นชัดว่าอันไหนใหม่
ลองสมัครด้วยอีเมลของสมชายใน console — กฎทำงานดี:
wongnok-blog(dev):001> user = User.new(name: "สมชายตัวปลอม", email: "somchai@example.com")
=> #<User … (ผลยาว ตัดออก)
wongnok-blog(dev):002> user.save
TRANSACTION (1.6ms) BEGIN /*application='WongnokBlog'*/
User Exists? (4.3ms) SELECT 1 AS one FROM "users" WHERE "users"."email" = 'somchai@example.com' LIMIT 1 /*application='WongnokBlog'*/
TRANSACTION (1.9ms) ROLLBACK /*application='WongnokBlog'*/
=> false
wongnok-blog(dev):003> user.errors.full_messages
=> ["Email has already been taken"]
ดูบรรทัด User Exists? ให้ดี — นี่คือวิธีที่ uniqueness ตรวจ:
SELECT 1 AS one FROM "users" WHERE "users"."email" = 'somchai@example.com' LIMIT 1
= "เปิดลิ้นชัก users หาแผ่นที่อีเมลเป็นอันนี้ ถ้าเจอสักแผ่นให้ตอบเลข 1 กลับมา" · เจอ → ไม่ผ่าน → ไม่ส่ง INSERT
ปัญหาอยู่ตรงนี้: "ถามตู้" กับ "เก็บแฟ้ม" เป็นสองคำสั่งแยกกัน และมีช่องว่างระหว่างสองคำสั่งเสมอ · ถ้ามีอีกคนแทรกเข้ามาในช่องว่างนั้นพอดี — ทั้งสองคนจะได้คำตอบว่า "ยังไม่มีใครใช้" ทั้งคู่
ร้านมีโต๊ะริมหน้าต่างตัวเดียว · ลูกค้าสองคนโทรเข้ามาพร้อมกัน พนักงานสองคนรับสายคนละเครื่อง
ทั้งคู่เปิดสมุดจองดูพร้อมกัน — "โต๊ะริมหน้าต่างคืนนี้ ว่างครับ" · แล้วทั้งคู่ก็เขียนชื่อลูกค้าของตัวเองลงไป
คืนนั้นลูกค้าสองคนมาถึงโต๊ะเดียวกัน — พนักงานไม่มีใครผิด แต่ระบบ "ดูก่อนแล้วค่อยเขียน" ผิด
ที่ไม่เหมือนกัน: ในร้านจริง พนักงานคนที่สองยังมีโอกาสเห็นว่ามีชื่อเพิ่งเขียนลงไป ·
ในแอป สองคำขอวิ่งพร้อมกันเร็วระดับมิลลิวินาที ไม่มีใครเห็นใคร
และในเครื่องเรา (คนใช้คนเดียว) แทบไม่เกิดเลย — มันจะโผล่ตอนมีคนใช้จริงเยอะ ๆ หรือตอนลูกค้ากดปุ่ม "สมัคร" สองครั้งรัว ๆ
ถ้าจำบท 24 ของ Go ได้ — นี่คือเรื่องเดียวกับ counter++
ที่สอง goroutine ทำพร้อมกันแล้วตัวเลขหาย: อ่าน → คิด → เขียนกลับ โดยมีคนอื่นแทรกกลางทางได้ ·
ต่างกันแค่บท 24 แย่งตัวแปรในหน่วยความจำ ส่วนบทนี้แย่งแถวใน database
ลองให้เกิดจริง: สองโปรแกรมสมัครพร้อมกัน
ช่องว่างจริงสั้นแค่เสี้ยววินาที ตาเปล่าจับไม่ทัน · ผมจึงเขียนสคริปต์ที่แทรกการรอ 2 วินาทีไว้ระหว่าง "ตรวจ" กับ "เก็บ"
เพื่อขยายช่องว่างให้เห็น · สร้างไฟล์ race.rb ไว้ที่โฟลเดอร์บนสุดของแอป (ที่เดียวกับ Gemfile):
# race.rb — จำลองคนสองคนสมัครด้วยอีเมลเดียวกันในเวลาเดียวกัน
$stdout.sync = true
name = ARGV[0]
User.after_validation do
puts "#{name}: ตรวจแล้ว ไม่มีใครใช้อีเมลนี้ → รอ 2 วินาที"
sleep 2
end
user = User.new(name: name, email: "nan@example.com")
puts "#{name}: save ได้ #{user.save}"
$stdout.sync = true— ให้putsพิมพ์ออกจอทันที (ไม่งั้นโปรแกรมจะเก็บไว้พิมพ์ทีเดียวตอนจบ ลำดับบรรทัดจะดูเพี้ยน)ARGV[0]— คำแรกที่พิมพ์ต่อท้ายคำสั่ง · เราจะส่งAกับBไว้แยกว่าใครเป็นใครUser.after_validation do … end— "ทุกครั้งที่ตรวจเสร็จ ให้ทำสิ่งนี้ก่อนเก็บ" (เรียกว่า callback · เจอละเอียดบท 112) · ใช้แค่แทรกsleep 2และมีผลเฉพาะตอนรันสคริปต์นี้ ไม่ได้แก้ modeluser.save—saveธรรมดา ไม่ได้ข้ามอะไรเลย: ตรวจ (SELECT) → รอ 2 วินาที → เก็บ (INSERT)"#{name}: …"— แทรกค่าตัวแปรในข้อความ (บท 91)
สั่งรันสองตัวพร้อมกัน แล้วนับว่าได้อีเมลนี้กี่แถว:
$ bin/rails runner race.rb A & bin/rails runner race.rb B & wait
B: ตรวจแล้ว ไม่มีใครใช้อีเมลนี้ → รอ 2 วินาที
A: ตรวจแล้ว ไม่มีใครใช้อีเมลนี้ → รอ 2 วินาที
B: save ได้ true
A: save ได้ true
$ bin/rails runner 'p User.where(email: "nan@example.com").pluck(:id, :name, :email)'
[[6, "B", "nan@example.com"], [7, "A", "nan@example.com"]]
bin/rails runner ไฟล์— รันไฟล์ Ruby ในแอปโดยไม่ต้องเปิด console&ท้ายคำสั่ง = "ปล่อยให้รันไป ไม่ต้องรอ" (สองตัวจึงวิ่งพร้อมกัน) ·wait= "รอทุกตัวที่ปล่อยไปให้จบก่อน" · terminal ของคุณอาจพิมพ์บรรทัดอย่าง[1] 12345เพิ่ม — เป็นเลขงานของ shell ไม่ต้องสนใจ- ทั้งสองคนผ่านการตรวจ ทั้งสองคนเก็บได้ → อีเมลเดียวกัน 2 แถว · ใครพิมพ์ก่อน (A หรือ B) สลับกันได้ทุกรอบ
pluck(:id, :name, :email)= ขอเฉพาะสามช่องนี้ของทุกแถวที่ค้นเจอ (เจอละเอียดบท 113)
- race condition
- ผลลัพธ์ขึ้นกับว่าใครทำเสร็จก่อนใคร · ทดสอบทีละคนผ่านเสมอ แต่ใช้จริงพร้อมกันแล้วพัง · ถาม senior ได้ว่า "ตรงนี้มี race condition ไหม ถ้าสอง request มาพร้อมกัน"
- check-then-act
- รูปแบบ "ตรวจก่อน แล้วค่อยทำ" ที่เป็นต้นเหตุของ race condition ·
validates uniquenessคือ check-then-act · พิมพ์บอก AI ได้ว่า "this uniqueness check is check-then-act, how do we make the database enforce it?"
ทางแก้: ให้ตัวตู้เป็นคนตัดสิน — unique index
แก้ race condition ด้วยการตรวจให้ "เร็วขึ้น" ไม่ได้ — ช่องว่างยังอยู่ · ทางแก้คือให้คนที่เก็บแฟ้ม (database) เป็นคนตรวจเอง ในจังหวะเดียวกับที่เก็บ ไม่มีช่องว่างให้ใครแทรก · เครื่องมือที่ database มีให้คือ unique index
index (ภาษาคน) = สารบัญที่ database ทำไว้ให้ช่องหนึ่ง เรียงตามตัวอักษร เพื่อหาเจอเร็วโดยไม่ต้องไล่ทีละแผ่น (บท 109 พูดถึงเรื่องความเร็ว) · unique index = สารบัญที่มีกฎเพิ่มว่า "ชื่อหนึ่งมีได้ช่องเดียว" — ใครจะใส่แฟ้มที่อีเมลซ้ำกับในสารบัญ ตู้ปฏิเสธทันที
ลองนึกถึงลิ้นชัก users ที่ติดป้ายแท็บเรียงตามอีเมล หนึ่งแท็บใส่แฟ้มได้แผ่นเดียว
พนักงานสองคนถือแฟ้ม nan@example.com มาพร้อมกัน · คนแรกเสียบลงแท็บได้ · คนที่สองเสียบไม่ลง เพราะแท็บนั้นมีแฟ้มแล้ว
— ไม่ต้องมีใครเปิดสมุดตรวจก่อน ตัวลิ้นชักตัดสินเอง ณ วินาทีที่เสียบ
ที่ไม่เหมือนกัน: พนักงานคนที่สองในชีวิตจริงเห็นเองว่าแท็บเต็ม แล้วเดินกลับไปบอกลูกค้าได้ ·
ในแอป database แค่โยน error กลับมา (ActiveRecord::RecordNotUnique) — ถ้าโค้ดเราไม่รับ ลูกค้าจะเห็นหน้า error
(เรื่องนี้แก้ในหัวข้อถัดไป)
ขั้น 1 — สร้าง migration
$ bin/rails generate migration AddUniqueIndexToUsers
invoke active_record
create db/migrate/20261104114726_add_unique_index_to_users.rb
เปิดไฟล์ที่ได้ (เลขหน้าชื่อไฟล์ของคุณจะต่าง) แล้วเติมบรรทัด add_index — โค้ดเดียวกับสไลด์หน้า 26:
class AddUniqueIndexToUsers < ActiveRecord::Migration[8.1]
def change
add_index :users, :email, unique: true
end
end
add_index :users, :email, unique: true = "ทำสารบัญให้ช่อง email ของลิ้นชัก users และห้ามซ้ำ"
· เทียบ goose (บท 31) คือ SQL CREATE UNIQUE INDEX … ON users (email) ที่ Rails เขียนให้
ขั้น 2 — db:migrate แล้วพัง (ตั้งใจ)
$ bin/rails db:migrate
== 20261104114726 AddUniqueIndexToUsers: migrating ============================
-- add_index(:users, :email, {unique: true})
bin/rails aborted!
StandardError: An error has occurred, this and all later migrations canceled: (StandardError)
PG::UniqueViolation: ERROR: could not create unique index "index_users_on_email"
DETAIL: Key (email)=(nan@example.com) is duplicated.
…/db/migrate/20261104114726_add_unique_index_to_users.rb:3:in 'AddUniqueIndexToUsers#change'
… (Caused by: ซ้ำอีกสองรอบ ตัดออก)
could not create unique index · Key (email)=(nan@example.com) is duplicated
= "ทำสารบัญห้ามซ้ำไม่ได้ เพราะตอนนี้มีอีเมลนี้ซ้ำอยู่แล้ว" — แถวซ้ำจากการทดลองเมื่อกี้นั่นเอง
· บรรทัด this and all later migrations canceled = "ใบสั่งช่างใบนี้และใบหลังจากนี้ยกเลิกหมด" — ตู้ยังเหมือนเดิม ใบนี้ยังไม่ถูกเซ็นรับ แก้แล้วสั่งใหม่ได้
· ช่างไม่ยอมติดป้ายแท็บแบบช่องเดียว ในเมื่อมีสองแผ่นยัดอยู่ช่องเดียวกันแล้ว · :3 คือบรรทัดที่ 3 ของไฟล์ migration (บรรทัด add_index)
ถ้าแอปเปิดใช้มาสักพักโดยมีแค่ uniqueness: true อาจมีอีเมลซ้ำซ่อนอยู่แล้วโดยไม่มีใครรู้ ·
migration นี้จะพังจนกว่าจะล้างของซ้ำก่อน — และการตัดสินว่า "เก็บแถวไหน ลบแถวไหน" เป็นเรื่องของเจ้าของข้อมูล ไม่ใช่ของโค้ด
ขั้น 3 — หาของซ้ำ ล้างทิ้ง แล้ว migrate ใหม่
ใน console — หาว่าอีเมลไหนซ้ำ แล้วดูว่าซ้ำกี่แถว:
wongnok-blog(dev):001> User.group(:email).having("COUNT(*) > 1").count
User Count (0.9ms) SELECT COUNT(*) AS "count_all", "users"."email" AS "users_email" FROM "users" GROUP BY "users"."email" HAVING (COUNT(*) > 1) /*application='WongnokBlog'*/
=> {"nan@example.com" => 2}
wongnok-blog(dev):002> User.where(email: "nan@example.com").pluck(:id, :name)
User Pluck (1.7ms) SELECT "users"."id", "users"."name" FROM "users" WHERE "users"."email" = 'nan@example.com' /*application='WongnokBlog'*/
=> [[6, "B"], [7, "A"]]
- บรรทัด 001 —
group(:email)"จัดแฟ้มเป็นกองตามอีเมล" ·having("COUNT(*) > 1")"เอาเฉพาะกองที่มีมากกว่า 1 แผ่น" ·.count"นับแต่ละกอง" (COUNT(*)ใน SQL = นับจำนวนแถว · group เจอละเอียดบท 113) - SQL ของบรรทัด 001 ภาษาคน: "นับแฟ้ม (
COUNT(*)) แยกกองตามอีเมล (GROUP BY) เอาเฉพาะกองที่นับได้เกิน 1 (HAVING)" ·AS "count_all"แค่ตั้งชื่อเล่นให้ผลลัพธ์ ไม่ต้องสนใจ - ผลเป็น hash: อีเมล
nan@example.comมี 2 แถว · บรรทัด 002 ดูว่าเป็น id ไหนบ้าง (SQL: "ขอแค่ช่อง id กับ name จากลิ้นชัก users เฉพาะแผ่นที่อีเมลเป็นอันนี้")
ลบแถวที่ไม่ต้องการ: User.find(7).destroy (ใส่เลข id ของคุณ) · แล้ว migrate ใหม่ — รอบนี้ผ่าน:
$ bin/rails db:migrate
== 20261104114726 AddUniqueIndexToUsers: migrating ============================
-- add_index(:users, :email, {unique: true})
-> 0.0054s
== 20261104114726 AddUniqueIndexToUsers: migrated (0.0055s) ===================
ใน db/schema.rb (แปลนตู้ฉบับล่าสุด) ส่วนของตาราง users มีบรรทัดใหม่:
t.index ["email"], name: "index_users_on_email", unique: true
ขั้น 4 — แข่งกันอีกรอบ
ลบแถว nan@example.com ที่เหลือก่อน (ไม่งั้นรอบนี้ validation จะเห็นแถวเก่าแล้วไม่ผ่านทั้งคู่):
User.where(email: "nan@example.com").destroy_all · แล้วรันคำสั่งเดิม:
$ bin/rails runner race.rb A & bin/rails runner race.rb B & wait
B: ตรวจแล้ว ไม่มีใครใช้อีเมลนี้ → รอ 2 วินาที
A: ตรวจแล้ว ไม่มีใครใช้อีเมลนี้ → รอ 2 วินาที
B: save ได้ true
…/activerecord-8.1.4/lib/active_record/connection_adapters/postgresql/database_statements.rb:152:in 'PG::Connection#exec': PG::UniqueViolation: ERROR: duplicate key value violates unique constraint "index_users_on_email" (ActiveRecord::RecordNotUnique)
DETAIL: Key (email)=(nan@example.com) already exists.
… (บรรทัด from … ตัดออก)
from …/race.rb:10:in '<main>'
…
$ bin/rails runner 'p User.where(email: "nan@example.com").pluck(:id, :name)'
[[8, "B"]]
- สองคนยังผ่านการตรวจทั้งคู่เหมือนเดิม — validation ไม่ได้ฉลาดขึ้น
- แต่ตอนเก็บ ตู้รับแค่คนแรก · คนที่สองได้
duplicate key value violates unique constraint "index_users_on_email"= "ค่านี้ซ้ำกับที่มีในสารบัญห้ามซ้ำ" · ฝั่ง Ruby เป็น error ชื่อActiveRecord::RecordNotUnique race.rb:10= บรรทัดที่ 10 ของrace.rb(นับคอมเมนต์บรรทัดแรกด้วย) คือบรรทัดputsสุดท้ายที่เรียกuser.save· A จึงไม่ได้พิมพ์ "save ได้ …" เพราะระเบิดก่อนถึงตอนพิมพ์- บรรทัดยาว ๆ ที่ขึ้นต้นด้วย
…/activerecord-8.1.4/…= ที่อยู่ไฟล์ข้างใน Rails ที่ error เกิด · ไม่ต้องอ่าน ให้มองหาชื่อ error ในวงเล็บท้ายบรรทัด - ผลสุดท้าย: เหลือแถวเดียว · รอบนี้ B ชนะ — ของคุณอาจเป็น A
เทียบ Go: gorm:"unique" ของบท 27 ทำแบบนี้มาตั้งแต่แรก — GORM สร้างกฎที่ตู้ ไม่ได้ตรวจในโค้ด ·
ส่วน Rails แยกสองอย่างออกจากกัน: validates … uniqueness (ด่าน ③) กับ add_index … unique: true (ด่าน ④) — ต้องใส่เองทั้งสองที่
- unique index
- สารบัญของคอลัมน์ที่ห้ามมีค่าซ้ำ · database ตรวจตอนเก็บ ไม่มีช่องว่างให้แทรก · ถาม senior ได้ว่า "uniqueness ตัวนี้มี unique index ใน migration รองรับไหม"
ActiveRecord::RecordNotUnique- error ที่ Rails โยนเมื่อตู้ปฏิเสธค่าซ้ำ (เบื้องหลังคือ
PG::UniqueViolationของ PostgreSQL) · พิมพ์บอก AI ได้ว่า "rescue ActiveRecord::RecordNotUnique and show a form error instead of a 500"
ด่านสุดท้ายต้องมีคนรับ — rescue ใน controller (สไลด์หน้า 26)
ตอนนี้ตู้กันอีเมลซ้ำได้แน่นอนแล้ว · แต่ถ้าคนที่สองแพ้การแข่ง ลูกค้าจะได้ error ภาษาเครื่อง — ต้องมีคนรับ error นั้นแล้วแปลเป็นข้อความในฟอร์ม · สไลด์หน้า 26 ให้โค้ด controller ไว้แบบนี้:
def create
@user = User.new(user_params)
@user.save!
redirect_to @user
rescue ActiveRecord::RecordNotUnique
@user.errors.add(:email, "ถูกใช้ไปแล้ว")
render :new, status: :unprocessable_entity
end
@user.save!— เก็บแบบมี!: ไม่ผ่านแล้วระเบิด (บท 110)rescue ActiveRecord::RecordNotUnique— "ถ้าระหว่างทางมี error ชนิดนี้ ให้กระโดดมาทำตรงนี้แทน" (rescueแบบบท 96 · เขียนในdefได้เลยโดยไม่ต้องมีbegin)@user.errors.add(:email, "ถูกใช้ไปแล้ว")— เติมเหตุผลเองลงในรายการ error ของช่อง email · view จะพิมพ์ออกมาเป็นEmail ถูกใช้ไปแล้วrender :new, status: :unprocessable_entity— แสดงฟอร์มเดิมอีกรอบพร้อมรหัส 422 (ชื่อเก่าของ:unprocessable_content· โค้ดฉบับแก้ข้างล่างใช้ชื่อใหม่)
เอาโค้ดของสไลด์ไปแทน create เดิม (model User ยังมี validates :email, uniqueness: true) ·
แล้วสมัครด้วยอีเมล somchai@example.com ธรรมดา ๆ คนเดียว ไม่ได้แข่งกับใคร
ลูกค้าจะเห็นฟอร์มพร้อมข้อความ "Email ถูกใช้ไปแล้ว" ใช่ไหม?
ไม่ใช่ — ได้หน้า error แทนฟอร์ม · ผลจริง (ข้อความบนหน้าที่ได้ ตัดมาเฉพาะหัว):
ActiveRecord::RecordInvalid in UsersController#create
Validation failed: Email has already been taken
และใน log ของ server:
Completed 422 Unprocessable Content in 7ms (ActiveRecord: 6.5ms (1 query, 0 cached) | GC: 0.0ms)
ActiveRecord::RecordInvalid (Validation failed: Email has already been taken):
เกิดอะไรขึ้น: กรณีปกติ (99%) validation จับได้ก่อน → save! ระเบิดด้วย ActiveRecord::RecordInvalid
(error ของ "ไม่ผ่านการตรวจ") · แต่ rescue ดักไว้แค่ RecordNotUnique (error ของ "ตู้ปฏิเสธ") — คนละชนิด ไม่มีใครรับ
หน้าที่เห็นนี้คือหน้า error แบบละเอียดของโหมด development · ผมลองปิดโหมดละเอียด (ตั้งให้ทำตัวแบบเว็บจริง) แล้วลูกค้าจะเห็นหน้าสำเร็จรูป
The change you wanted was rejected (422 Unprocessable Entity) — ไม่ใช่ฟอร์มเดิม ข้อมูลที่กรอกไว้หายหมด
แก้ให้ครบสองกรณี: ใช้ if @user.save แบบเดิม (รับกรณี 99% ที่ validation จับได้)
แล้วเติม rescue ของสไลด์ต่อท้าย (รับ 1% ที่แพ้การแข่ง) — create ใน app/controllers/users_controller.rb กลายเป็น
(99% กับ 1% เป็นตัวเลขเปรียบเทียบว่า "แทบทุกครั้ง" กับ "นาน ๆ ครั้ง" ไม่ใช่สถิติจริง):
def create
@user = User.new(user_params)
if @user.save
redirect_to @user
else
render :new, status: :unprocessable_content # 99%: validation จับได้
end
rescue ActiveRecord::RecordNotUnique
@user.errors.add(:email, "ถูกใช้ไปแล้ว") # 1%: แพ้การแข่ง ตู้ปฏิเสธ
render :new, status: :unprocessable_content
end
ผลจริงทั้งสองกรณี (HTML ที่ได้ ตัดมาเฉพาะบรรทัดข้อความ error · บรรทัด HTTP 422 คือรหัสตอบกลับที่ผมให้ curl พิมพ์ต่อท้าย):
# กรณี 99% — สมัครด้วย somchai@example.com ตามปกติ
<p class="error">Email has already been taken</p>
HTTP 422
# กรณี 1% — จำลองการแพ้แข่ง ด้วยการปิดบรรทัด validates :email, uniqueness: true ใน user.rb ชั่วคราว (ใส่ # ข้างหน้า)
<p class="error">Email ถูกใช้ไปแล้ว</p>
HTTP 422
ทั้งสองกรณีลูกค้าได้ฟอร์มเดิมพร้อมข้อความ และอีเมลที่กรอกไว้ยังอยู่ในช่อง ·
ข้อความกรณี 1% เป็นภาษาไทยเพราะเราเขียนเองใน errors.add ส่วนกรณี 99% เป็นข้อความสำเร็จรูปของ Rails ·
อย่าลืมเอา # หน้า validates ออกหลังลอง ·
ทำไมปิด validation แล้วได้ผลเหมือน "แพ้แข่ง"? เพราะทั้งสองแบบคือ "คนดูแลสต็อกไม่ได้จับ แฟ้มไปชนสารบัญห้ามซ้ำที่ตู้"
ทำไมต้องมีทั้งสามอย่าง — และ pattern เดียวกันใช้ได้ทุกเรื่อง
| ของ | อยู่ด่าน | หน้าที่ | ถ้าไม่มี |
|---|---|---|---|
add_index … unique: true | ④ ตัวตู้ | รับประกัน ข้ามไม่ได้ | อีเมลซ้ำหลุดเข้าตู้ได้ (race · ประตูข้าง) |
validates … uniqueness | ③ คนดูแลสต็อก | ข้อความภาษาคน ใน 99% ของเวลา | ยังกันได้ (rescue รับ) แต่ลูกค้าเห็นปัญหาไม่พร้อมกัน — เช่น ลืมกรอกชื่อ + อีเมลซ้ำ จะเห็นแค่ "ชื่อว่าง" ก่อน แก้ชื่อแล้วส่งใหม่ถึงรู้ว่าอีเมลซ้ำ |
rescue … RecordNotUnique | controller | รับ 1% ที่หลุด ไม่ให้เป็นหน้า error | คนที่แพ้การแข่งเห็นหน้า error |
เรื่องอีเมลเป็นแค่ตัวอย่างเดียว · กฎสำคัญทุกข้อ ให้ใส่เป็นคู่: ด่าน ③ ไว้บอกสวย + ด่าน ④ ไว้รับประกัน (สไลด์หน้า 26):
| กฎที่อยากได้ | ด่าน ③ ใน model | ด่าน ④ ใน migration | ตู้ปฏิเสธแล้วได้ error |
|---|---|---|---|
| ห้ามว่าง | validates :name, presence: true | null: false · change_column_null | ActiveRecord::NotNullViolation |
| ห้ามซ้ำ | validates :email, uniqueness: true | add_index … unique: true | ActiveRecord::RecordNotUnique |
| ต้องมีเจ้าของจริง | belongs_to :post (ตรวจให้เอง) | foreign_key: true | ActiveRecord::InvalidForeignKey |
| ตัวเลขห้ามติดลบ | validates :views, numericality: { greater_than_or_equal_to: 0 } | add_check_constraint | ActiveRecord::CheckViolation |
ลองแถวที่ 3 กับ 4 ให้เห็นจริงสั้น ๆ
ต้องมีเจ้าของจริง — belongs_to คู่กับ foreign key
foreign key (ภาษาคน) = ช่อง "เป็นของบทความเลขที่ …" บนใบคอมเมนต์ พร้อมกฎของตู้ว่าเลขนั้นต้องมีอยู่จริงในลิ้นชัก posts
(บท 115 สอนละเอียด) · ตาราง comments มีกฎนี้แล้วตั้งแต่สร้าง (post:references ตอน generate model Comment ในบท 107) — ลองแปะใบคอมเมนต์ให้บทความเลขที่ 9999 ที่ไม่มีอยู่จริง:
wongnok-blog(dev):001> comment = Comment.new(body: "ลอย ๆ", post_id: 9999, user_id: 1)
=> #<Comment … (ผลยาว ตัดออก)
wongnok-blog(dev):002> comment.valid?
Post Load (2.8ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 9999 LIMIT 1 /*application='WongnokBlog'*/
User Load (1.6ms) SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1 /*application='WongnokBlog'*/
=> false
wongnok-blog(dev):003> comment.errors.full_messages
=> ["Post must exist"]
wongnok-blog(dev):004> comment.save(validate: false)
TRANSACTION (3.0ms) BEGIN /*application='WongnokBlog'*/
Comment Create (7.8ms) INSERT INTO "comments" ("body", "post_id", "user_id", "created_at", "updated_at") VALUES ('ลอย ๆ', 9999, 1, '2026-11-04 12:05:17.216739', '2026-11-04 12:05:17.216739') RETURNING "id" /*application='WongnokBlog'*/
TRANSACTION (1.3ms) ROLLBACK /*application='WongnokBlog'*/
(wongnok-blog):4:in '<main>': PG::ForeignKeyViolation: ERROR: insert or update on table "comments" violates foreign key constraint "fk_rails_2fd19c0db7" (ActiveRecord::InvalidForeignKey)
DETAIL: Key (post_id)=(9999) is not present in table "posts".
…
- ด่าน ③:
belongs_to :postใน model ไปเปิดลิ้นชัก posts หาเลข 9999 ให้เอง ไม่เจอ →Post must exist(บทความต้องมีอยู่จริง) · บรรทัดUser Loadคือbelongs_to :userไปหา user เลข 1 ด้วย — เจอ ผ่าน - ด่าน ④: ข้ามการตรวจไปแล้ว ตู้ก็ยังปฏิเสธ —
Key (post_id)=(9999) is not present in table "posts"= "เลข 9999 ไม่มีในลิ้นชัก posts" fk_rails_2fd19c0db7= ชื่อกฎที่ Rails ตั้งให้อัตโนมัติ ไม่ต้องจำ
ตัวเลขห้ามติดลบ — numericality คู่กับ CHECK
CHECK constraint = กฎของตู้ที่เราเขียนเงื่อนไขเองได้ เช่น "ช่อง views ต้อง ≥ 0" · สร้าง migration ใหม่:
$ bin/rails generate migration AddViewsCheckToPosts
class AddViewsCheckToPosts < ActiveRecord::Migration[8.1]
def change
add_check_constraint :posts, "views >= 0", name: "views_not_negative"
end
end
add_check_constraint :posts, "views >= 0"— "ลิ้นชัก posts: ทุกแผ่นต้องมีช่อง views มากกว่าหรือเท่ากับ 0" · ข้อความในเครื่องหมายคำพูดคือเงื่อนไขแบบ SQLname: "views_not_negative"— ตั้งชื่อกฎเอง จะได้อ่าน error รู้เรื่อง
$ bin/rails db:migrate
== 20261104114930 AddViewsCheckToPosts: migrating =============================
-- add_check_constraint(:posts, "views >= 0", {name: "views_not_negative"})
-> 0.0016s
== 20261104114930 AddViewsCheckToPosts: migrated (0.0016s) ====================
Post มีกฎ numericality ของ views อยู่แล้วจากบท 110 — ถ้าเปิดไว้ คนดูแลสต็อกจะตีกลับก่อนถึงตู้ เราจะไม่เห็นตู้ทำงาน ·
ปิดชั่วคราวด้วยการใส่ # หน้าบรรทัดนี้ใน app/models/post.rb:
# validates :views, numericality: { greater_than_or_equal_to: 0 }
เปิด console แล้วลองแก้ยอดวิวของต้มยำกุ้งเป็น −5:
wongnok-blog(dev):001> post = Post.find_by(title: "ต้มยำกุ้งน้ำข้น")
Post Load (1.9ms) SELECT "posts".* FROM "posts" WHERE "posts"."title" = 'ต้มยำกุ้งน้ำข้น' LIMIT 1 /*application='WongnokBlog'*/
=> #<Post … (ผลยาว ตัดออก)
wongnok-blog(dev):002> post.views = -5
=> -5
wongnok-blog(dev):003> post.save
TRANSACTION (1.2ms) BEGIN /*application='WongnokBlog'*/
Post Update (3.1ms) UPDATE "posts" SET "updated_at" = '2026-11-04 11:49:49.463438', "views" = -5 WHERE "posts"."id" = 1 /*application='WongnokBlog'*/
TRANSACTION (0.9ms) ROLLBACK /*application='WongnokBlog'*/
(wongnok-blog):3:in '<main>': PG::CheckViolation: ERROR: new row for relation "posts" violates check constraint "views_not_negative" (ActiveRecord::CheckViolation)
…
UPDATE "posts" SET "views" = -5 WHERE "posts"."id" = 1 = "ในลิ้นชัก posts แก้ช่อง views เป็น −5 เฉพาะแผ่นเลขที่ 1" ·
ตู้ปฏิเสธด้วยกฎชื่อ views_not_negative · ฝั่ง Ruby ได้ ActiveRecord::CheckViolation — กันได้ แต่เป็นภาษาเครื่อง
· ทีนี้เอา # ออก ให้ด่าน ③ กลับมา จะได้ข้อความภาษาคน:
validates :views, numericality: { greater_than_or_equal_to: 0 }
เปิด console ใหม่ (หรือพิมพ์ reload! แบบบท 104 — console เก่ายังจำ model ฉบับเดิม) แล้วพิมพ์ชุดเดิม:
wongnok-blog(dev):001> post = Post.find_by(title: "ต้มยำกุ้งน้ำข้น")
Post Load (0.8ms) SELECT "posts".* FROM "posts" WHERE "posts"."title" = 'ต้มยำกุ้งน้ำข้น' LIMIT 1 /*application='WongnokBlog'*/
=> #<Post … (ผลยาว ตัดออก)
wongnok-blog(dev):002> post.views = -5
=> -5
wongnok-blog(dev):003> post.save
=> false
wongnok-blog(dev):004> post.errors.full_messages
=> ["Views must be greater than or equal to 0"]
รอบนี้ไม่มี SQL เลย — คนดูแลสต็อกตีกลับตั้งแต่ก่อนเดินไปตู้ · คู่นี้แหละที่ต้องการ: ผ่านโค้ดเรา → ได้ข้อความสวย · เลี่ยงโค้ดเรา → ตู้ยังกัน
transaction — ทั้งหมด หรือไม่ทำเลย (และทำไมต้องใช้ !)
บางงานมีหลายขั้นที่ต้องสำเร็จพร้อมกัน เช่น โอนเงิน = หักบัญชีเรา + เพิ่มบัญชีเพื่อน · ถ้าหักแล้วแต่เพิ่มไม่สำเร็จ เงินหายไปเฉย ๆ · transaction คือการบอก database ว่า "คำสั่งชุดนี้ ให้นับเป็นงานเดียว — สำเร็จทั้งหมด หรือยกเลิกทั้งหมด"
BEGIN= เริ่มงานชุด ·COMMIT= ยืนยัน เก็บจริงทั้งชุด ·ROLLBACK= ยกเลิก ย้อนทุกอย่างในชุดกลับเหมือนไม่เคยทำ- คุณเห็นสามคำนี้มาตลอดบท เพราะ
saveทุกครั้งห่อตัวเองด้วย transaction อยู่แล้ว · ใน Rails เขียนเองได้ด้วยActiveRecord::Base.transaction do … end(ActiveRecord::Base= แม่ของApplicationRecordที่เห็นในบท 106 · ทุกคำสั่งใน block นี้นับเป็นงานชุดเดียว) - กฎของ Rails: ใน block นี้ ถ้ามี error ระเบิดออกมา → ROLLBACK · ถ้าจบปกติ → COMMIT
คุณโอน 200 บาทให้เพื่อน · ธนาคารหักบัญชีคุณ แล้วไปเพิ่มบัญชีเพื่อน — ถ้าระบบล่มกลางทาง ธนาคารคืนเงินให้คุณทั้งก้อน เหมือนไม่เคยกดโอน · ไม่มีทางที่เงินจะออกจากบัญชีคุณไปแล้วหายกลางอากาศ
ที่ไม่เหมือนกัน: ธนาคารรู้เองว่าขั้นไหนพัง · แต่ Rails รู้ว่า "พัง" ก็ต่อเมื่อมี error ระเบิดออกมาเท่านั้น
— ถ้าใช้ update (ไม่มี !) ที่คืน false เงียบ ๆ Rails ถือว่า "ไม่มีอะไรพัง" แล้ว COMMIT ตามปกติ
ลองกับข้อมูลเรา — สมมติยอดวิวเป็นเงิน: โอน 200 วิวจากข้าวมันไก่ (มี 85) ไปให้ส้มตำ (มี 340) ·
ข้าวมันไก่มีไม่พอ หักแล้วจะติดลบ → กฎ numericality ที่เพิ่งเปิดกลับมาต้องไม่ยอม · แบบแรกใช้ update ไม่มี !:
wongnok-blog(dev):001> from = Post.find_by(title: "ข้าวมันไก่ประตูน้ำ"); from.views
Post Load (1.9ms) SELECT "posts".* FROM "posts" WHERE "posts"."title" = 'ข้าวมันไก่ประตูน้ำ' LIMIT 1 /*application='WongnokBlog'*/
=> 85
wongnok-blog(dev):002> to = Post.find_by(title: "ส้มตำปูปลาร้า"); to.views
Post Load (1.7ms) SELECT "posts".* FROM "posts" WHERE "posts"."title" = 'ส้มตำปูปลาร้า' LIMIT 1 /*application='WongnokBlog'*/
=> 340
wongnok-blog(dev):003* ActiveRecord::Base.transaction do
wongnok-blog(dev):004* to.update(views: to.views + 200)
wongnok-blog(dev):005* from.update(views: from.views - 200)
wongnok-blog(dev):006> end
TRANSACTION (1.8ms) BEGIN /*application='WongnokBlog'*/
Post Update (3.8ms) UPDATE "posts" SET "updated_at" = '2026-11-04 11:51:01.908179', "views" = 540 WHERE "posts"."id" = 2 /*application='WongnokBlog'*/
TRANSACTION (1.7ms) COMMIT /*application='WongnokBlog'*/
=> false
wongnok-blog(dev):007> from.reload.views
Post Load (1.6ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 3 LIMIT 1 /*application='WongnokBlog'*/
=> 85
wongnok-blog(dev):008> to.reload.views
Post Load (3.1ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 2 LIMIT 1 /*application='WongnokBlog'*/
=> 540
- บรรทัด 001–002 — หยิบบทความสองชิ้นมา ·
;= เขียนสองคำสั่งในบรรทัดเดียว · console แสดงผลของคำสั่งหลัง (ยอดวิว) - บรรทัด 003–006 — พิมพ์ทีละบรรทัดแล้วกด Enter ได้เลย ·
*หลังเลขบรรทัดแปลว่า "block ยังเปิดค้างอยู่ พิมพ์ต่อได้" · พอพิมพ์endครบ บรรทัดนั้นกลับเป็น>แล้ว console ก็รันทั้งก้อน (ช่องว่างหน้าบรรทัด console จัดให้เอง) - ส้มตำ
updateผ่าน →UPDATE … "views" = 540· ข้าวมันไก่updateไม่ผ่านการตรวจ → คืนfalseไม่ส่ง SQL - ไม่มี error ระเบิด → Rails COMMIT ·
reload= อ่านค่าใหม่จากตู้ · ผล: ข้าวมันไก่ยังมี 85 แต่ส้มตำได้ 540 — วิว 200 โผล่ขึ้นมาจากอากาศ
คืนยอดส้มตำเป็น 340 ก่อน (to.update!(views: 340)) · แล้วเปิด console ใหม่ พิมพ์บรรทัด 001–002 เหมือนเดิม
(ได้ 85 กับ 340) จากนั้นลองแบบที่สอง ใช้ update! มี !:
wongnok-blog(dev):003* ActiveRecord::Base.transaction do
wongnok-blog(dev):004* to.update!(views: to.views + 200)
wongnok-blog(dev):005* from.update!(views: from.views - 200)
wongnok-blog(dev):006> end
TRANSACTION (1.3ms) BEGIN /*application='WongnokBlog'*/
Post Update (3.1ms) UPDATE "posts" SET "updated_at" = '2026-11-04 11:51:37.913352', "views" = 540 WHERE "posts"."id" = 2 /*application='WongnokBlog'*/
TRANSACTION (0.8ms) ROLLBACK /*application='WongnokBlog'*/
(wongnok-blog):5:in 'block in <main>': Validation failed: Views must be greater than or equal to 0 (ActiveRecord::RecordInvalid)
from (wongnok-blog):3:in '<main>'
wongnok-blog(dev):007> from.reload.views
Post Load (2.9ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 3 LIMIT 1 /*application='WongnokBlog'*/
=> 85
wongnok-blog(dev):008> to.reload.views
Post Load (2.1ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 2 LIMIT 1 /*application='WongnokBlog'*/
=> 340
- ส้มตำยังถูก
UPDATEเป็น 540 เหมือนเดิม — แต่ยังไม่ได้ COMMIT - บรรทัด 5 (
from.update!) ไม่ผ่าน → ระเบิดActiveRecord::RecordInvalid→ Rails ROLLBACK ทั้งชุด - ผล: ส้มตำกลับเป็น 340 · ข้าวมันไก่ 85 — เหมือนไม่เคยโอน ✔
เทียบ Go: db.Transaction ของ GORM (บท 27) ใช้กฎ "คืน error = rollback · คืน nil = commit" ·
ใน Rails สัญญาณเดียวกันคือ "ระเบิด = rollback · จบปกติ = commit" — update ที่คืน false เหมือนฟังก์ชัน Go ที่ลืม return err
(สไลด์หน้า 23 ส่วนล่างจึงเขียนว่า ใน transaction ต้องใช้ตัวที่มี !)
- transaction · atomic
- กลุ่มคำสั่งที่นับเป็นงานเดียว สำเร็จทั้งหมดหรือไม่สำเร็จเลย (atomic = แบ่งครึ่งไม่ได้) · ถาม senior ได้ว่า "สองขั้นนี้ต้องอยู่ใน transaction เดียวกันไหม"
- commit · rollback
- ยืนยันเก็บจริง · ย้อนกลับทั้งชุด · พิมพ์บอก AI ได้ว่า "use save! / update! inside the transaction so a validation failure triggers a rollback"
Checklist ตรวจงาน AI (และงานตัวเอง)
เวลา AI (หรือเพื่อน หรือตัวคุณเอง) ส่งโค้ด model มา ไล่ถามทีละข้อ:
- เห็น
uniqueness: true→ "แล้ว unique index ใน migration อยู่ไหน" (สไลด์หน้า 26 บอกว่านี่คือคำถามแรก) - เห็น
presence: true→ "คอลัมน์นี้มีnull: falseไหม" - เห็น
belongs_to→ "migration มีforeign_key: trueไหม" - เห็นกฎตัวเลข (
numericality) ที่สำคัญเรื่องเงิน/จำนวน → "มี CHECK constraint ไหม" - เห็น unique index → "controller
rescue ActiveRecord::RecordNotUniqueไว้ไหม หรือคนที่แพ้แข่งจะเห็นหน้า error" - เห็น
save!ใน controller → "ถ้า validation ไม่ผ่าน ใครรับRecordInvalid" - เห็น
transaction do→ "ข้างในใช้ตัวที่มี!หรือยัง ไม่งั้นไม่ rollback"
ภารกิจ: ใส่คู่ "ห้ามว่าง" ให้ชื่อผู้ใช้
🎯 ภารกิจ 111 · presence + NOT NULL ให้ users.name (~10 นาที)
ตอนนี้ช่อง name ของ users มีกฎ presence ในโค้ดแล้ว (บท 110) แต่ในตู้ยังว่างได้ · ใส่ด่าน ④ ให้ครบคู่ตาม pattern ของบทนี้
- ใน console สร้างผู้ใช้ที่ไม่มีชื่อไว้หนึ่งคน (จำลองข้อมูลเก่าที่หลุดเข้ามาแล้ว):
User.new(email: "noname@example.com").save(validate: false)→ ควรได้true - สร้าง migration:
bin/rails generate migration ChangeUsersNameNotNullแล้วเขียนในdef changeว่าchange_column_null :users, :name, false("ช่อง name ของ users: ห้ามเป็น null") bin/rails db:migrate— ควรพัง · อ่าน error แล้วเดาว่าเพราะอะไร- ใน console แก้ข้อมูลเก่าให้มีชื่อ (ใบ้:
User.where(name: nil)หาแถวที่ชื่อว่าง แล้วต่อด้วยupdate_all(name: "…")จากตารางประตูข้าง) แล้วbin/rails db:migrateอีกรอบ จนผ่าน · ดูในdb/schema.rbว่าบรรทัดt.string "name"เปลี่ยนไปยังไง - ลองประตูข้างอีกรอบ:
User.new(email: "ghost2@example.com").save(validate: false)→ ตู้ต้องปฏิเสธ - ลองทางปกติ:
user = User.new(email: "ghost2@example.com")แล้วuser.saveกับuser.errors.full_messages→ กฎpresenceจากบท 110 ต้องให้ข้อความภาษาคน (ถ้าข้ามบท 110 มา ให้เติมvalidates :name, presence: trueในapp/models/user.rbก่อน)
ควรเห็น: ข้อ 3 พัง · ข้อ 4 migrate ผ่าน และ schema.rb มี null: false · ข้อ 5 ได้ ActiveRecord::NotNullViolation · ข้อ 6 ได้ false และ ["Name can't be blank"]
ถ้าเจอ error แปลว่า…
PG::NotNullViolation: ERROR: column "name" of relation "users" contains null valuesตอน migrate → มีแถวเก่าที่ชื่อว่างอยู่ ตู้ยังตั้งกฎ "ห้ามว่าง" ไม่ได้ (นี่คือข้อ 3 ที่ตั้งใจให้พัง)PG::UniqueViolation: ERROR: duplicate key value violates unique constraint "index_users_on_email"·Key (email)=(…) already existsตอนทำข้อ 1 หรือ 5 → อีเมลนี้มีในตู้แล้ว (เช่น รันข้อ 1 ซ้ำสองรอบ) — unique index จากบทนี้ทำงาน · เปลี่ยนอีเมลในคำสั่งเป็นอันใหม่
② ไฟล์ migration (เลขหน้าชื่อไฟล์ของคุณจะต่าง):
class ChangeUsersNameNotNull < ActiveRecord::Migration[8.1]
def change
change_column_null :users, :name, false
end
end
③ migrate รอบแรก — พังเพราะผู้ใช้ที่สร้างในข้อ 1:
$ bin/rails db:migrate
== 20261104115159 ChangeUsersNameNotNull: migrating ===========================
-- change_column_null(:users, :name, false)
bin/rails aborted!
StandardError: An error has occurred, this and all later migrations canceled: (StandardError)
PG::NotNullViolation: ERROR: column "name" of relation "users" contains null values
…/db/migrate/20261104115159_change_users_name_not_null.rb:3:in 'ChangeUsersNameNotNull#change'
…
④ ใส่ชื่อให้แถวที่ว่าง แล้ว migrate ใหม่:
wongnok-blog(dev):001> User.where(name: nil).count
User Count (1.5ms) SELECT COUNT(*) FROM "users" WHERE "users"."name" IS NULL /*application='WongnokBlog'*/
=> 1
wongnok-blog(dev):002> User.where(name: nil).update_all(name: "ไม่ระบุชื่อ")
User Update All (2.3ms) UPDATE "users" SET "name" = 'ไม่ระบุชื่อ' WHERE "users"."name" IS NULL /*application='WongnokBlog'*/
=> 1
WHERE "users"."name" IS NULL= "เฉพาะแผ่นที่ช่อง name ไม่มีค่า" (SQL ใช้IS NULLไม่ใช่= NULL)update_allแก้ทุกแถวที่ค้นเจอทีเดียว — และข้าม validation (อยู่ในตารางประตูข้างข้างบน) ซึ่งตรงนี้คือสิ่งที่ต้องการ · จะลบทิ้งแทนก็ได้ (destroy_all) — แล้วแต่ว่าข้อมูลเก่ามีค่าแค่ไหน
$ bin/rails db:migrate
== 20261104115159 ChangeUsersNameNotNull: migrating ===========================
-- change_column_null(:users, :name, false)
-> 0.0031s
== 20261104115159 ChangeUsersNameNotNull: migrated (0.0031s) ==================
ใน db/schema.rb บรรทัดของ name กลายเป็น t.string "name", null: false
⑤ ประตูข้างโดนตู้กัน:
wongnok-blog(dev):001> User.new(email: "ghost2@example.com").save(validate: false)
TRANSACTION (0.7ms) BEGIN /*application='WongnokBlog'*/
User Create (2.2ms) INSERT INTO "users" ("name", "email", "created_at", "updated_at") VALUES (NULL, 'ghost2@example.com', '2026-11-04 11:52:24.180852', '2026-11-04 11:52:24.180852') RETURNING "id" /*application='WongnokBlog'*/
TRANSACTION (0.5ms) ROLLBACK /*application='WongnokBlog'*/
(wongnok-blog):1:in '<main>': PG::NotNullViolation: ERROR: null value in column "name" of relation "users" violates not-null constraint (ActiveRecord::NotNullViolation)
…
⑥ app/models/user.rb ตอนจบบทนี้ (กฎ name มีมาตั้งแต่บท 110 · ภารกิจนี้ไม่ต้องแก้ไฟล์นี้):
# 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/ }
validates :email, uniqueness: true
end
wongnok-blog(dev):001> user = User.new(email: "ghost2@example.com")
=> #<User … (ผลยาว ตัดออก)
wongnok-blog(dev):002> user.save
TRANSACTION (2.0ms) BEGIN /*application='WongnokBlog'*/
User Exists? (3.9ms) SELECT 1 AS one FROM "users" WHERE "users"."email" = 'ghost2@example.com' LIMIT 1 /*application='WongnokBlog'*/
TRANSACTION (0.7ms) ROLLBACK /*application='WongnokBlog'*/
=> false
wongnok-blog(dev):003> user.errors.full_messages
=> ["Name can't be blank"]
สังเกต: Rails ยังไปถามตู้เรื่องอีเมลซ้ำ (User Exists?) แม้ชื่อจะว่างแล้ว — เพราะมันตรวจทุกกฎให้ครบก่อน แล้วค่อยบอกผลรวม
· แต่ไม่มี INSERT เลย คนดูแลสต็อกตีกลับก่อนถึงตู้ · คู่ presence + NOT NULL ครบแล้ว
Quiz
ข้อ 1 · model มี validates :email, uniqueness: true แต่ ไม่มี unique index · ลูกค้าสองคนกดสมัครด้วยอีเมลเดียวกันในเสี้ยววินาทีเดียวกัน — เกิดอะไรได้?
ตัวเลือกที่ 2 ถูก · การทดลอง race.rb รอบแรกได้อีเมลเดียวกัน 2 แถวจริง — ทั้งคู่ "ถามตู้" ก่อนที่ใครจะ "เก็บแฟ้ม"
ตัวเลือกที่ 1 ถูกแค่ตอนสมัครทีละคน — validation ตรวจจริง แต่ตรวจกับข้อมูล ณ ตอนที่ถาม ·
ตัวเลือกที่ 3 ผิด — validates อยู่ในโค้ด Ruby ล้วน ๆ ไม่ได้ไปสร้างอะไรในตู้ (ต่างจาก gorm:"unique") ·
ตัวเลือกที่ 4 ผิด — Rails ไม่รู้เลยว่ามีอีกคำขอวิ่งอยู่ แต่ละคำขอทำงานแยกกัน
จุดที่ต้องจำ: "ตรวจ" กับ "เขียน" เป็นสองจังหวะ มีช่องว่างเสมอ · ปิดช่องว่างได้ที่ database เท่านั้น
ข้อ 2 · ใน ActiveRecord::Base.transaction do … end มีสองบรรทัด: บรรทัดแรก update ผ่าน · บรรทัดที่สอง update (ไม่มี !) ไม่ผ่าน validation — จบแล้วใน database เป็นยังไง?
ตัวเลือกที่ 3 ถูก · การโอนวิวแบบแรกได้ COMMIT — ส้มตำเป็น 540 ข้าวมันไก่ยัง 85
ตัวเลือกที่ 1 คือสิ่งที่อยากให้เป็น แต่ Rails ROLLBACK เฉพาะเมื่อมี error ระเบิด · update คืน false เงียบ ๆ ไม่นับ ·
ตัวเลือกที่ 2 คือผลของ update! (แบบที่สอง) ไม่ใช่ update ·
ตัวเลือกที่ 4 ผิด — transaction ไม่ได้ปิด validation · บรรทัดที่สองไม่ผ่านจึงไม่มี SQL ส่งไปเลย
จุดที่ต้องจำ: ใน transaction ใช้ save! · update! · create! เสมอ — "ระเบิด = rollback"
ข้อ 3 · เพิ่ม migration add_index :users, :email, unique: true แล้ว db:migrate พังด้วย could not create unique index … Key (email)=(…) is duplicated — ต้องทำอะไร?
ตัวเลือกที่ 1 ถูก · ในบทนี้หาด้วย User.group(:email).having("COUNT(*) > 1").count ลบแถวเกิน แล้ว migrate ผ่าน
ตัวเลือกที่ 2 ผิด — validation อยู่ในโค้ด ไม่เกี่ยวกับการสร้าง index ในตู้เลย ลบไปก็พังเหมือนเดิม · ตัวเลือกที่ 3 ได้ index ธรรมดาที่ไม่กันซ้ำ — ไม่ได้แก้ปัญหาที่ตั้งใจแก้ · ตัวเลือกที่ 4 ผิด — error บอกชัดว่าข้อมูลในตารางซ้ำ รันกี่รอบก็ซ้ำเท่าเดิม
จุดที่ต้องจำ: ตู้ตั้งกฎใหม่ไม่ได้ ถ้าของที่อยู่ในตู้ตอนนี้ผิดกฎนั้นอยู่แล้ว (ใช้กับ null: false และ CHECK ด้วย — ภารกิจเจอแบบเดียวกัน)
ข้อ 4 · อยากได้กฎ "ยอดวิวห้ามติดลบ" ที่ทั้งข้ามไม่ได้และลูกค้าอ่านเข้าใจ — ควรใส่อะไร?
ตัวเลือกที่ 4 ถูก · ในบทนี้: มีแค่ CHECK → ได้ ActiveRecord::CheckViolation ภาษาเครื่อง · เติม numericality → ได้ Views must be greater than or equal to 0
ตัวเลือกที่ 1 ผิด — update_column · update_all · SQL ตรง ข้ามการตรวจของ model ได้ (ตารางประตูข้าง) ·
ตัวเลือกที่ 2 กันได้จริง แต่ลูกค้าได้หน้า error แทนข้อความ — ขาดครึ่ง "อ่านเข้าใจ" ·
ตัวเลือกที่ 3 คือด่าน ① — curl ข้ามได้ด้วยคำสั่งสองบรรทัดแบบที่ทำในบทนี้
จุดที่ต้องจำ: ของสำคัญต้องกัน 2 ชั้น — ด่าน ③ ให้ข้อความสวย + ด่าน ④ ให้การรับประกัน
ถามกลับได้เลย
ถ้าลองแล้วผลไม่ตรงกับในบท (เช่น race.rb ไม่ได้อีเมลซ้ำ หรือ migrate ผ่านทั้งที่ควรพัง) —
ส่งสามอย่างไปถาม senior หรือ AI: คำสั่งที่พิมพ์ · ผลที่ได้ทั้งก้อน · ส่วนของ users ใน db/schema.rb
· ส่วนใหญ่คำตอบอยู่ใน schema.rb ว่าตู้มีกฎอะไรอยู่จริง
ถ้าเปิดสไลด์ดูประกอบ
| หน้า | สไลด์เขียน | ของจริง | ต้องสนใจไหม |
|---|---|---|---|
| 26 | controller ใช้ @user.save! แล้ว rescue แค่ RecordNotUnique | อีเมลซ้ำแบบปกติ (validation จับได้) ได้ RecordInvalid ที่ไม่มีใครรับ → หน้า error แทนฟอร์ม | สนใจ — ใช้ if @user.save + rescue แบบในบทนี้ |
| 26 | เรียก user_params | สไลด์ไม่ได้แสดงว่านิยามไว้ที่ไหน · ต้องเขียน method นี้เอง (params.expect) | สนใจถ้าพิมพ์ตาม — ไม่งั้นได้ NameError · ละเอียดบท 117 |
| 21 | ด่าน HTML "ข้ามได้ด้วย curl 1 บรรทัด" | ฟอร์มของ Rails มีรหัสกัน CSRF · curl บรรทัดเดียวได้ 422 ต้องใช้สองคำสั่ง (เอารหัสก่อน) | ไม่ต้อง — แก่นเหมือนเดิม: ด่าน ① ข้ามได้ |
| 28 ข้อ 4 | Post.new แล้ว save(validate: false) "เขียนลง DB ได้เลย" | ในแอปเรา posts.user_id เป็น NOT NULL → ตู้ปฏิเสธ · ต้องใส่ user: ก่อนถึงจะเห็นว่าเขียนได้ | สนใจ — เป็นตัวอย่างที่ดีของด่าน ④ ด้วย |
| 28 ลองต่อ | "ล้างของทดสอบทิ้ง" Post.where("title LIKE ?", "%console%") | บรรทัดนี้แค่ค้น ไม่ได้ลบ · ต้องต่อท้าย .destroy_all | สนใจถ้าพิมพ์ตาม — ของทดสอบยังอยู่ |
| 25 | สอง request ห่างกัน 2 มิลลิวินาที | บทนี้ขยายช่องว่างเป็น 2 วินาทีด้วย sleep ใน race.rb เพื่อให้เห็นด้วยตา | ไม่ต้อง — กลไกเดียวกัน |
อ้างอิง
- Rails Guides — Active Record Validations · 1.4 Skipping Validations — อ่านรายการคำสั่งทั้งหมด ที่เก็บลง database โดยไม่ตรวจ (ต้นทางของตาราง "ประตูข้าง") แล้วต่อด้วย หัวข้อ 2.10 uniqueness ย่อหน้าที่บอกว่าต้องสร้าง unique index ที่ database เสมอ
- Rails API — validates_uniqueness_of · Concurrency and integrity —
อ่านหัวข้อย่อยนี้ทั้งหัวข้อ เอกสารทางการอธิบาย race condition แบบเดียวกับการทดลอง
race.rbและบอกให้รับActiveRecord::RecordNotUnique - Rails API — Active Record Transactions · Exception handling and rolling back —
อ่านย่อหน้าแรกของหัวข้อนี้ ที่บอกว่า exception คือสัญญาณ rollback (เหตุผลที่ต้องใช้
!) - Rails API — add_check_constraint · change_column_null · add_index · Creating a unique index — ดูตัวอย่างโค้ดสั้น ๆ ใต้แต่ละ method ที่ใช้ใน migration ของบทนี้
- PostgreSQL 17 — 5.5 Constraints — อ่าน 5.5.1 Check Constraints กับ 5.5.2 Not-Null Constraints ว่าตู้ฝั่ง PostgreSQL บังคับกฎยังไง (ข้ามไปส่วน unique และ foreign key ถ้ายังไม่ต้องการ)
- Rails Guides — Security · 4 Cross-Site Request Forgery (CSRF) —
อ่านแค่ย่อหน้าแรก ถ้าอยากรู้ว่ารหัส
authenticity_tokenที่ทำให้ curl บรรทัดเดียวได้ 422 มีไว้ทำไม - GORM — Declaring Models · Fields Tags —
ดูตาราง tag แถว
unique·not null·checkเพื่อเทียบว่า GORM วางกฎพวกนี้ไว้ที่ตัวตาราง (ด่าน ④)