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

4 ชั้นป้องกันข้อมูล — ทำไมกฎในโมเดลยังกันอีเมลซ้ำไม่ได้

~25 นาที (ยาวกว่าบทอื่นนิดหนึ่ง เพราะมีการทดลองจริง 3 อย่าง) · ควรผ่าน บท 108 (migration) และ บท 110 (validation) มาก่อน

บทที่แล้วเราใส่กฎใน model ไว้ตรวจข้อมูลก่อนเก็บ — บทนี้จะพิสูจน์ด้วยมือตัวเองว่ากฎพวกนั้นยังมีรูรั่ว สองรู: มีประตูข้างที่ข้ามการตรวจได้ และคนสองคนกดสมัครพร้อมกันก็ได้อีเมลซ้ำ
อ่านจบแล้วคุณจะรู้ว่าข้อมูลถูกกันได้ 4 ด่าน ด่านไหนทำอะไร · ใส่ กฎที่ตัว database (ห้ามว่าง · ห้ามซ้ำ · ตัวเลขต้องอยู่ในช่วงที่กำหนด — ชื่อจริงของมันคือ NOT NULL · unique index · CHECK เดี๋ยวรู้จักทีละตัว) คู่กับกฎใน model ได้เอง · และพอ AI เขียน uniqueness: true มาให้ คุณจะรู้ว่าต้องถามหาอะไรต่อ

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

📋 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 ด่าน · แต่ละด่านกันได้คนละแบบ — ด่านที่อยู่ใกล้ลูกค้า บริการดี (บอกเร็ว บอกสวย) · ด่านที่อยู่ลึก รับประกันได้จริง (ข้ามไม่ได้)

ลูกค้ากรอกฟอร์มสมัครสมาชิก แล้วกดส่ง │ ▼ ① ป้ายหน้าร้าน HTML: required · type="email" เบราว์เซอร์เตือน "กรอกช่องนี้ก่อน" ทันที · แต่ใครส่งด้วย curl จะไม่ผ่านป้ายนี้เลย │ ▼ ② พนักงานต้อนรับ controller: params.expect(user: [:name, :email]) รับเฉพาะช่องที่อยู่บนแบบฟอร์ม ช่องที่แอบเขียนเพิ่มถูกตัดทิ้ง · ไม่ได้ดูว่าค่าในช่องถูกไหม │ ▼ ③ คนดูแลสต็อก model: validates :email, uniqueness: true ตรวจแล้วบอกเหตุผลเป็นภาษาคน "Email has already been taken" · แต่มีประตูข้าง │ ▼ ④ ตัวตู้ database: NOT NULL · unique index · CHECK · foreign key ใส่ผิดแบบไม่ได้เลย ไม่ว่ามาทางไหน · แต่ตอบเป็น error ภาษาเครื่อง ลูกค้าอ่านไม่รู้เรื่อง │ ▼ แฟ้มเข้าลิ้นชัก users

ใน 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 ตอนสร้างตาราง

🍽 เทียบให้เห็นภาพ — ร้านอาหารที่มี 4 ด่าน

① ป้ายหน้าร้าน เขียนว่า "กรุณากรอกชื่อในใบสั่ง" — ลูกค้าส่วนใหญ่ทำตาม แต่คนที่เดินเข้าทางหลังร้านไม่เห็นป้ายนี้เลย
② พนักงานต้อนรับ รับเฉพาะช่องที่อยู่บนแบบฟอร์ม ใครแอบเขียน "ขอฟรี" ไว้ขอบกระดาษ ตัดทิ้ง
③ คนดูแลสต็อก ตรวจแฟ้มก่อนเก็บ ไม่ผ่านก็บอกเหตุผลสุภาพ ๆ "ไม่มีชื่อเรื่อง ไม่รับครับ"
④ ตัวตู้ ออกแบบมาให้ใส่แฟ้มผิดแบบไม่ได้ทางกายภาพ — ไม่ว่าใครถือแฟ้มมา ทางประตูไหน ก็ยัดไม่เข้า

ที่ไม่เหมือนกัน: ป้ายหน้าร้านจริงอย่างน้อยก็ติดอยู่ที่ร้านเรา · แต่ด่าน ① ของแอปอยู่ในเบราว์เซอร์ของลูกค้า — เราควบคุมไม่ได้เลย ลูกค้าจะลบป้ายทิ้งก็ได้ ส่วนด่าน ② ③ คือโค้ดของเราเอง
และตัวตู้จริงไม่พูด แต่ตู้ของเรา "พูด" ได้ — แค่พูดเป็นภาษาเครื่อง (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

③ 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>

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

ได้ 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&#39;t be blank</p>
  <p class="error">Email is invalid</p>
…

ส่วนใน 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 … และข้อความเดิมซ้ำอีกรอบ ตัดออก)

ทำไมรอบนี้ตู้กันได้ แต่รอบที่แล้วไม่กัน? เพราะตอนบท 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

ปัญหาอยู่ตรงนี้: "ถามตู้" กับ "เก็บแฟ้ม" เป็นสองคำสั่งแยกกัน และมีช่องว่างระหว่างสองคำสั่งเสมอ · ถ้ามีอีกคนแทรกเข้ามาในช่องว่างนั้นพอดี — ทั้งสองคนจะได้คำตอบว่า "ยังไม่มีใครใช้" ทั้งคู่

สองคนกดสมัครด้วยอีเมล nan@example.com ห่างกันแค่เสี้ยววินาที t1 A ─ ถามตู้ "มีใครใช้ nan@example.com ไหม" (SELECT) t2 B ─ ถามตู้คำถามเดียวกัน (SELECT) t3 A ─ ตู้ตอบ "ไม่มี" → ผ่านการตรวจ (valid? = true) t4 B ─ ตู้ตอบ "ไม่มี" → ผ่านการตรวจ (valid? = true) ← ตอนที่ถาม ยังไม่มีใครเก็บแฟ้มจริง ๆ t5 A ─ เก็บแฟ้ม (INSERT) สำเร็จ t6 B ─ เก็บแฟ้ม (INSERT) สำเร็จด้วย ✗ ลิ้นชักมีอีเมลซ้ำ 2 แผ่น validation ทำงาน "ถูก" ทั้งสองคน — แค่ข้อมูลที่มันเห็นตอน t3 · t4 เก่าไปแล้วตอน t5 · t6
🍽 เทียบให้เห็นภาพ — ลูกค้าสองคนโทรจองโต๊ะเดียวกัน

ร้านมีโต๊ะริมหน้าต่างตัวเดียว · ลูกค้าสองคนโทรเข้ามาพร้อมกัน พนักงานสองคนรับสายคนละเครื่อง
ทั้งคู่เปิดสมุดจองดูพร้อมกัน — "โต๊ะริมหน้าต่างคืนนี้ ว่างครับ" · แล้วทั้งคู่ก็เขียนชื่อลูกค้าของตัวเองลงไป
คืนนั้นลูกค้าสองคนมาถึงโต๊ะเดียวกัน — พนักงานไม่มีใครผิด แต่ระบบ "ดูก่อนแล้วค่อยเขียน" ผิด

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

ถ้าจำบท 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}"

สั่งรันสองตัวพร้อมกัน แล้วนับว่าได้อีเมลนี้กี่แถว:

$ 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"]]
🗣 ศัพท์ของเรื่องนี้
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"]]

ลบแถวที่ไม่ต้องการ: 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"]]

เทียบ 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

เอาโค้ดของสไลด์ไปแทน 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 … RecordNotUniquecontrollerรับ 1% ที่หลุด ไม่ให้เป็นหน้า errorคนที่แพ้การแข่งเห็นหน้า error

เรื่องอีเมลเป็นแค่ตัวอย่างเดียว · กฎสำคัญทุกข้อ ให้ใส่เป็นคู่: ด่าน ③ ไว้บอกสวย + ด่าน ④ ไว้รับประกัน (สไลด์หน้า 26):

กฎที่อยากได้ด่าน ③ ใน modelด่าน ④ ใน migrationตู้ปฏิเสธแล้วได้ error
ห้ามว่างvalidates :name, presence: truenull: false · change_column_nullActiveRecord::NotNullViolation
ห้ามซ้ำvalidates :email, uniqueness: trueadd_index … unique: trueActiveRecord::RecordNotUnique
ต้องมีเจ้าของจริงbelongs_to :post (ตรวจให้เอง)foreign_key: trueActiveRecord::InvalidForeignKey
ตัวเลขห้ามติดลบvalidates :views, numericality: { greater_than_or_equal_to: 0 }add_check_constraintActiveRecord::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".
…

ตัวเลขห้ามติดลบ — 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
$ 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 ว่า "คำสั่งชุดนี้ ให้นับเป็นงานเดียว — สำเร็จทั้งหมด หรือยกเลิกทั้งหมด"

🍽 เทียบให้เห็นภาพ — โอนเงินผ่านแอปธนาคาร

คุณโอน 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

คืนยอดส้มตำเป็น 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

เทียบ Go: db.Transaction ของ GORM (บท 27) ใช้กฎ "คืน error = rollback · คืน nil = commit" · ใน Rails สัญญาณเดียวกันคือ "ระเบิด = rollback · จบปกติ = commit" — update ที่คืน false เหมือนฟังก์ชัน Go ที่ลืม return err (สไลด์หน้า 23 ส่วนล่างจึงเขียนว่า ใน transaction ต้องใช้ตัวที่มี !)

🗣 ศัพท์ของ 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 มา ไล่ถามทีละข้อ:

  1. เห็น uniqueness: true → "แล้ว unique index ใน migration อยู่ไหน" (สไลด์หน้า 26 บอกว่านี่คือคำถามแรก)
  2. เห็น presence: true → "คอลัมน์นี้มี null: false ไหม"
  3. เห็น belongs_to → "migration มี foreign_key: true ไหม"
  4. เห็นกฎตัวเลข (numericality) ที่สำคัญเรื่องเงิน/จำนวน → "มี CHECK constraint ไหม"
  5. เห็น unique index → "controller rescue ActiveRecord::RecordNotUnique ไว้ไหม หรือคนที่แพ้แข่งจะเห็นหน้า error"
  6. เห็น save! ใน controller → "ถ้า validation ไม่ผ่าน ใครรับ RecordInvalid"
  7. เห็น transaction do → "ข้างในใช้ตัวที่มี ! หรือยัง ไม่งั้นไม่ rollback"

ภารกิจ: ใส่คู่ "ห้ามว่าง" ให้ชื่อผู้ใช้

🎯 ภารกิจ 111 · presence + NOT NULL ให้ users.name (~10 นาที)

ตอนนี้ช่อง name ของ users มีกฎ presence ในโค้ดแล้ว (บท 110) แต่ในตู้ยังว่างได้ · ใส่ด่าน ④ ให้ครบคู่ตาม pattern ของบทนี้

  1. ใน console สร้างผู้ใช้ที่ไม่มีชื่อไว้หนึ่งคน (จำลองข้อมูลเก่าที่หลุดเข้ามาแล้ว): User.new(email: "noname@example.com").save(validate: false) → ควรได้ true
  2. สร้าง migration: bin/rails generate migration ChangeUsersNameNotNull แล้วเขียนใน def change ว่า change_column_null :users, :name, false ("ช่อง name ของ users: ห้ามเป็น null")
  3. bin/rails db:migrate — ควรพัง · อ่าน error แล้วเดาว่าเพราะอะไร
  4. ใน console แก้ข้อมูลเก่าให้มีชื่อ (ใบ้: User.where(name: nil) หาแถวที่ชื่อว่าง แล้วต่อด้วย update_all(name: "…") จากตารางประตูข้าง) แล้ว bin/rails db:migrate อีกรอบ จนผ่าน · ดูใน db/schema.rb ว่าบรรทัด t.string "name" เปลี่ยนไปยังไง
  5. ลองประตูข้างอีกรอบ: User.new(email: "ghost2@example.com").save(validate: false) → ตู้ต้องปฏิเสธ
  6. ลองทางปกติ: 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 แปลว่า…

② ไฟล์ 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 ว่าตู้มีกฎอะไรอยู่จริง

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

หน้าสไลด์เขียนของจริงต้องสนใจไหม
26controller ใช้ @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 ข้อ 4Post.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 เพื่อให้เห็นด้วยตาไม่ต้อง — กลไกเดียวกัน

อ้างอิง