🔁 ทวนก่อนเริ่ม — คำจากบทก่อน ๆ ที่บทนี้ใช้
- ตาราง · แถว · คอลัมน์ (บท 106) — ลิ้นชักในตู้เก็บแฟ้ม · แฟ้มหนึ่งแผ่น · ช่องบนแฟ้ม
- model
Post(บท 99, 106) — คนดูแลสต็อกของลิ้นชักposts· ช่องต่าง ๆ ของ Post ไม่ได้เขียนในไฟล์ model แต่ Rails อ่านจากตารางตอนรัน bin/rails db:migrate(บท 99) — สั่งให้ "ช่าง" ลงมือทำตามใบสั่งที่ยังไม่ได้ทำ ·db:rollback— ถอยใบล่าสุดออก (เคยใช้ในภารกิจบท 99)- console (บท 104, 107) — ครัวทดลองหลังร้าน พิมพ์ Ruby ทีละบรรทัดได้
- goose (บท 31 ฝั่ง Go) — เครื่องมือ migration ที่เราเคยใช้กับ PostgreSQL · ไฟล์
.sqlมีท่อน Up กับ Down
🧰 ตรวจด้วย Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 17 (สไลด์ใช้ Ruby 4.0.6 · Rails 8.1.3 — รุ่นแก้บั๊กในสายเดียวกัน ใช้แทนกันได้)
📦 ตัวอย่างในบทนี้ใช้แอป wongnok_blog ที่มีตาราง users / posts / comments และข้อมูลตัวอย่าง (seeds) ชุดเดียวกับบท 107 ·
ผลทุกก้อนรันจริงแล้วก็อปมา · ตัวเลขหน้าชื่อไฟล์ migration (เช่น 20261104114634) และเวลา (0.0033s) ของคุณจะไม่ตรงกับในบท เพราะมันคือวันเวลาที่คุณสั่ง
ปัญหา: ร้าน 5 สาขา ช่างต่อเติมตู้แบบปากเปล่า
schema คือ "หน้าตาของฐานข้อมูล" — มีตารางอะไร แต่ละตารางมีคอลัมน์ชื่ออะไร เก็บข้อมูลชนิดไหน ·
โค้ดของเราพึ่ง schema ตลอด เช่น ถ้าโค้ดอ่าน post.subtitle แต่ตารางไม่มีคอลัมน์ subtitle แอปพังทันที
ปัญหาคือ schema อยู่ในฐานข้อมูล ไม่ได้อยู่ในโค้ด · สไลด์หน้า 12 ยกสถานการณ์ให้: ทีม 5 คน · เครื่อง 5 เครื่อง · ฐานข้อมูลของเว็บจริง 1 ตัว
- พี่ A เพิ่มคอลัมน์ในเครื่องตัวเองด้วยโปรแกรมจัดการฐานข้อมูล (คลิก ๆ เอา) แล้ว push (ส่งโค้ดขึ้นที่เก็บกลางของทีม) โค้ดที่ใช้คอลัมน์นั้น
- พี่ B ดึงโค้ดมา → ในเครื่องพี่ B ไม่มีคอลัมน์นั้น → แอปพัง · ไม่มีใครบอก
- ขึ้นเว็บจริงก็ลืมเพิ่มคอลัมน์อีก → เว็บล่ม
- สามเดือนผ่านไป มีคนถาม "คอลัมน์นี้ใครเพิ่ม เพิ่มทำไม" — ไม่มีใครตอบได้
(เครื่องของเราแต่ละคนเรียกว่า development · เว็บจริงที่ลูกค้าใช้อยู่เรียกว่า production · แต่ละที่มีฐานข้อมูลของตัวเองแยกกัน)
migration แก้ทุกข้อด้วยวิธีเดียว: ทุกการเปลี่ยน schema ต้องเขียนเป็นไฟล์หนึ่งไฟล์เก็บใน git ·
ใครดึงโค้ดไปก็ได้ไฟล์ไปด้วย สั่ง bin/rails db:migrate ทีเดียว ฐานข้อมูลในเครื่องเขาก็หน้าตาเหมือนของเรา ·
และ git log (ดูประวัติ commit) ตอบได้ว่าใครเพิ่ม เพิ่มเมื่อไหร่ (คำว่า "ทำไม" ตอบได้ถ้าเขียน commit message ดี)
ร้านเรามี 5 สาขา ทุกสาขาต้องมีตู้เก็บแฟ้ม (ฐานข้อมูล) หน้าตาเหมือนกันเป๊ะ เพราะพนักงาน (โค้ด) ย้ายสาขาไปมาแล้วต้องหาของเจอที่เดิม · ถ้าสาขาหนึ่งเรียกช่างมาเพิ่มช่องบนแฟ้มแบบบอกปากเปล่า สาขาอื่นไม่มีทางรู้ · ตู้แต่ละสาขาค่อย ๆ ไม่เหมือนกัน
migration คือใบสั่งช่าง — ทุกการต่อเติมต้องเขียนเป็นใบ มีวันที่ มีลำดับ เก็บรวมในแฟ้มกลางของแฟรนไชส์ (git) · สาขาไหนอยากเป็นปัจจุบัน ก็ไล่ทำใบที่ยังไม่ได้ทำจนครบ
ที่ไม่เหมือนกัน: ช่างจริงอ่านใบสั่งแล้วตีความเองได้ ("น่าจะหมายถึงลิ้นชักนี้") · แต่ Rails ทำตามที่เขียนเป๊ะ ๆ ไม่เดาให้ — เขียนให้ลบช่องไหน ข้อมูลในช่องนั้นหายจริง ไม่มีใครทักสักคำ
- schema
- หน้าตาของฐานข้อมูล: มีตารางอะไร คอลัมน์อะไร ชนิดไหน · ไม่รวมตัวข้อมูลข้างใน · ถาม senior ได้ว่า “PR นี้เปลี่ยน schema ไหม ถ้าเปลี่ยนมี migration มาด้วยหรือยัง”
- migration
- ไฟล์หนึ่งไฟล์ = การเปลี่ยน schema หนึ่งครั้ง · อยู่ใน
db/migrate/· เก็บใน git เหมือนโค้ด · พิมพ์บอก AI ได้ว่า “write a Rails migration that adds a subtitle string column to posts” - environment · production
- ชุดเครื่อง + ฐานข้อมูลแต่ละที่: development = เครื่องเรา (ครัวทดลอง) · production = เว็บจริง หรือร้านจริงที่ลูกค้ากำลังนั่งกินอยู่ · (ระหว่างกลางบางทีมมี staging = ร้านซ้อมก่อนเปิดจริง) · ฐานข้อมูลแต่ละ environment แยกกัน ต้องสั่ง migrate แยกกัน · deploy = เอาโค้ดชุดใหม่ขึ้นร้านจริง · ถาม senior ได้ว่า “ตอน deploy ขึ้น production ใครเป็นคนสั่ง db:migrate หรือมันรันเอง”
เทียบกับ goose ที่เคยใช้ใน Go
ในบท 31 คุณเขียน migration ของ Go ด้วย goose มาแล้ว · แนวคิดเหมือนกันทุกอย่าง ต่างแค่ภาษาที่เขียน: goose ให้เขียน SQL ตรง ๆ สองท่อน (ทำ · ย้อน) ส่วน Rails ให้เขียน Ruby ท่อนเดียว แล้วมันคิดวิธีย้อนให้เอง
Go · goose — …_add_subtitle_to_posts.sql
-- +goose Up
ALTER TABLE posts ADD COLUMN subtitle VARCHAR;
-- +goose Down
ALTER TABLE posts DROP COLUMN subtitle;
เขียนเองทั้งขาไป (Up) และขากลับ (Down) · ลืมเขียน Down = ย้อนไม่ได้
Rails — …_add_subtitle_to_posts.rb
class AddSubtitleToPosts < ActiveRecord::Migration[8.1]
def change
add_column :posts, :subtitle, :string
end
end
เขียนแค่ขาไปใน change · Rails รู้เองว่าขากลับของ add_column คือ remove_column
อ่านฝั่ง goose ทีละบรรทัด (SQL แปลเป็นภาษาคน):
-- +goose Up/-- +goose Down— ป้ายบอก goose ว่าท่อนไหนขาไป ท่อนไหนขากลับ (--คือคอมเมนต์ของ SQL)ALTER TABLE posts ADD COLUMN subtitle VARCHAR;— "ขอดัดแปลงลิ้นชัก posts: เพิ่มช่องชื่อ subtitle เก็บข้อความ" ·ALTER TABLE= ดัดแปลงตาราง ·ADD COLUMN= เพิ่มคอลัมน์ ·VARCHAR= ข้อความ (ตรงกับ:stringของ Rails)ALTER TABLE posts DROP COLUMN subtitle;— "ขอดัดแปลงลิ้นชัก posts: รื้อช่อง subtitle ทิ้ง" ·DROP= รื้อทิ้ง
ฝั่ง Rails อ่านทีละคำในหัวข้อถัดไป · ตอนนี้ดูแค่ว่ามันสั้นกว่า และไม่มีท่อนขากลับ (ไฟล์ goose ผมเขียนให้ดูเทียบ ไม่ได้รันในบทนี้ · ไฟล์ Rails คือไฟล์ที่ Rails สร้างให้จริง — เดี๋ยวได้สร้างเอง)
| เรื่อง | goose (บท 31) | Rails |
|---|---|---|
| ไฟล์อยู่ไหน | migrations/*.sql | db/migrate/*.rb |
| สร้างไฟล์ใหม่ | goose create add_x sql | bin/rails generate migration AddXToY |
| ทำใบที่ค้าง | goose up | bin/rails db:migrate |
| ถอยหนึ่งใบ | goose down | bin/rails db:rollback |
| ดูว่าใบไหนทำแล้ว | goose status (Pending / วันที่) | bin/rails db:migrate:status (down / up) |
| สมุดจดในฐานข้อมูล | ตาราง goose_db_version | ตาราง schema_migrations |
| แปลนตู้ฉบับล่าสุด | ไม่มี | db/schema.rb (Rails เขียนให้เอง — หัวข้อถัดไป) |
ส่วน AutoMigrate ของ GORM (บท 27) ไม่ใช่ migration แบบนี้ —
มันดู struct แล้ว "เติมของที่ขาด" อย่างเดียว ไม่มีไฟล์ ไม่มีประวัติ (บท 31 เห็นแล้วว่าเปลี่ยนชื่อ field แล้วได้คอลัมน์ว่างเพิ่มมาเฉย ๆ)
- up · down · change
- up = ขาไป (ทำตามใบ) · down = ขากลับ (รื้อออก) ·
change= method เดียวที่ Rails อ่านได้ทั้งสองทาง · Rails ยังให้เขียนdef up/def downแยกแบบ goose ได้ด้วย ใช้ตอนที่ Rails เดาขากลับเองไม่ได้ (บท 109) · ถาม senior ได้ว่า “migration ตัวนี้ควรเขียน change หรือแยก up/down” - reversible (ย้อนกลับได้)
- migration ที่ถอยกลับได้โดยไม่พัง ·
add_column·create_tableย้อนเองได้ · พิมพ์บอก AI ได้ว่า “make sure this migration is reversible”
เปิดแฟ้มใบสั่งช่าง: db/migrate/ ของจริง
ในแอปที่ทำต่อจากบท 107 มีใบสั่งช่างอยู่ 5 ใบ · ลองสั่ง ls ดู (ผลจริง):
$ ls db/migrate
20261104113601_create_posts.rb
20261104113603_add_published_and_views_to_posts.rb
20261104113604_create_users.rb
20261104113606_add_user_to_posts.rb
20261104113607_create_comments.rb
- ตัวเลข 14 หลักข้างหน้าคือ วันเวลาที่สร้างไฟล์ อ่านว่า ปี 2026 · เดือน 11 · วันที่ 04 · 11:36:01 (เวลาสากล UTC — เวลาไทยต้องบวก 7 ชั่วโมง เลขเลยไม่ตรงนาฬิกาในเครื่อง ไม่ต้องตกใจ)
- เพราะเรียงตามเวลา
lsจึงเรียงตามลำดับที่สร้างให้เอง และ Rails ก็ทำตามลำดับนี้ — ตาราง posts ต้องเกิดก่อน ถึงจะเพิ่มคอลัมน์ให้มันได้ - ตัวเลขนี้ยังเป็น เลขประจำตัวของใบนั้นด้วย (Rails เรียกว่า version) — จำไว้ อีกสองหัวข้อจะเห็นมันในฐานข้อมูล
- ส่วนหลังตัวเลข เช่น
add_published_and_views_to_postsคือชื่อใบ อ่านเป็นภาษาอังกฤษได้: "เพิ่ม published กับ views ให้ posts"
เปิดใบที่สองดู — ใบที่เพิ่มสองช่องให้ตาราง posts:
# db/migrate/20261104113603_add_published_and_views_to_posts.rb
class AddPublishedAndViewsToPosts < ActiveRecord::Migration[8.1]
def change
add_column :posts, :published, :boolean, default: false, null: false
add_column :posts, :views, :integer, default: 0, null: false
end
end
class AddPublishedAndViewsToPosts— ชื่อ class ตรงกับชื่อไฟล์ (กฎตั้งชื่อ บท 99) · ห้ามมีสองใบชื่อซ้ำกัน< ActiveRecord::Migration[8.1]— สืบทอดความสามารถ "แก้ตู้" มาจาก Rails ·[8.1]= เขียนตามกติกาของ Rails รุ่น 8.1def change— สิ่งที่ใบนี้สั่งให้ทำadd_column :posts, :published, :boolean— เพิ่มคอลัมน์ในตารางpostsชื่อpublishedเก็บค่า จริง/เท็จdefault: false— แฟ้มที่ไม่ได้บอกค่ามา ให้เป็นfalse(ยังไม่เผยแพร่) · รวมถึงแฟ้มเก่าที่มีอยู่แล้วในลิ้นชักด้วยnull: false— ห้ามว่าง (ห้ามเป็นnil) · เป็นกฎที่ตัวตู้บังคับเอง เรื่องเต็มอยู่บท 111- บรรทัดที่สองเหมือนกัน แต่เป็นตัวเลข (
:integer) ชื่อviewsเริ่มที่ 0
schema.rb — แปลนตู้ฉบับล่าสุด
ถ้าอยากรู้ว่า "ตอนนี้ตาราง posts มีช่องอะไรบ้าง" ต้องไล่อ่านใบสั่งทั้ง 5 ใบแล้วรวมในหัวเอง — เหนื่อย ·
Rails เลยเขียนไฟล์ db/schema.rb ให้ทุกครั้งที่ migrate เสร็จ: สรุปหน้าตาฐานข้อมูล ณ ตอนนี้ ไว้ในไฟล์เดียว
ไฟล์จริงของแอปเรา (ตัดคอมเมนต์ภาษาอังกฤษ 11 บรรทัดแรกออก — ใจความคือ "ไฟล์นี้สร้างอัตโนมัติ อย่าแก้เอง ให้ใช้ migration และควร commit เข้า git"):
# db/schema.rb (ตัดคอมเมนต์ด้านบนออก)
ActiveRecord::Schema[8.1].define(version: 2026_11_04_113607) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
create_table "comments", force: :cascade do |t|
t.text "body"
t.bigint "post_id", null: false
t.bigint "user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["post_id"], name: "index_comments_on_post_id"
t.index ["user_id"], name: "index_comments_on_user_id"
end
create_table "posts", force: :cascade do |t|
t.string "title"
t.text "body"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.boolean "published", default: false, null: false
t.integer "views", default: 0, null: false
t.bigint "user_id", null: false
t.index ["user_id"], name: "index_posts_on_user_id"
end
create_table "users", force: :cascade do |t|
t.string "name"
t.string "email"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
add_foreign_key "comments", "posts"
add_foreign_key "comments", "users"
add_foreign_key "posts", "users"
end
อ่านทีละส่วน:
version: 2026_11_04_113607— เลขของใบสั่งช่างใบล่าสุดที่ทำแล้ว (ตรงกับ…113607_create_comments.rb) · ขีด_แค่ช่วยให้อ่านง่ายenable_extension "pg_catalog.plpgsql"(และคอมเมนต์ภาษาอังกฤษข้างบนมัน ที่แปลว่า "ส่วนเสริมที่ต้องเปิดไว้ให้ฐานข้อมูลนี้ทำงานได้") — ส่วนเสริมมาตรฐานของ PostgreSQL ที่ Rails จดไว้ให้ · ไม่ต้องสนใจcreate_table "posts", force: :cascade do |t|— สร้างตาราง posts ·force: :cascade= "ถ้ามีตารางชื่อนี้อยู่แล้วให้รื้อทิ้งสร้างใหม่" (ใช้ตอนสร้างฐานข้อมูลใหม่จากแปลน)|t|— block ที่รู้จักจาก Day 1 ·tคือ "ลิ้นชักที่กำลังสร้าง" · ทุกบรรทัดt.อะไร "ชื่อ"ข้างใน = เพิ่มช่องหนึ่งช่องให้ลิ้นชักนั้นt.string "title"·t.text "body"— คอลัมน์ข้อความสั้น กับข้อความยาว · บรรทัดพวกนี้มาจากใบแรกCreatePostspublished·views— มาจากใบที่สอง ·user_id— มาจากใบที่สี่AddUserToPosts· แปลนรวมทุกใบไว้ในตารางเดียว ไม่สนว่ามาจากใบไหนt.bigint= ตัวเลขจำนวนเต็มขนาดใหญ่ (ใช้เก็บเลข id ของแถวอื่น) ·t.datetime= วันและเวลา ·t.boolean= จริง/เท็จ ·t.integer= จำนวนเต็มcreated_at·updated_at— เวลาสร้าง/แก้ล่าสุด Rails ใส่ให้เองทุกตาราง (มาจากบรรทัดt.timestampsในใบ create)- ไม่เห็นคอลัมน์
id— Rails สร้างให้ทุกตารางโดยอัตโนมัติ เลยไม่ต้องเขียน t.index [...]— สารบัญที่ช่วยให้หาแฟ้มตามช่องนั้นได้เร็ว (index — เรื่องเต็มบท 109)add_foreign_key "posts", "users"— กฎของตู้ว่าuser_idในแฟ้มบทความต้องชี้ไปหาผู้ใช้ที่มีอยู่จริง (foreign key — บท 115)
db/migrate/ = แฟ้มใบสั่งช่างทุกใบตั้งแต่วันเปิดร้าน เรียงตามวันที่ · ตอบคำถาม "ตู้นี้ถูกต่อเติมอะไรมาบ้าง ใครสั่ง เมื่อไหร่"
db/schema.rb = แปลนตู้ฉบับล่าสุด เหมือนถ่ายรูปตู้ตอนนี้แปะไว้ · ตอบคำถาม "ตอนนี้ตู้หน้าตาเป็นยังไง" ·
ช่างวาดแปลนใหม่ให้ทุกครั้งที่ทำงานเสร็จ (ทุก db:migrate) — เราไม่วาดเอง
สองอย่างนี้ไม่ซ้ำซ้อนกัน ตอบคนละคำถาม · และเปิดสาขาใหม่ก็ไม่ต้องไล่ทำใบสั่งย้อนไป 5 ปี แค่สร้างตู้ตามแปลนล่าสุดทีเดียว (หัวข้อ "เพื่อนใหม่เข้าทีม" ข้างล่าง)
ที่ไม่เหมือนกัน: รูปถ่ายตู้จริงถ่ายจากตู้ · แต่ schema.rb เขียนจากฐานข้อมูลในเครื่องคุณตอนที่ migrate เสร็จ · ถ้าฐานข้อมูลในเครื่องคุณมีของแปลก ๆ (เช่นตารางที่ลองสร้างเล่นแล้วลืมลบ) มันก็ไปโผล่ใน schema.rb ด้วย — ก่อน commit จึงต้องเปิดดูว่าที่เปลี่ยนมีแค่ที่ตั้งใจ
- schema.rb · schema dump
- ไฟล์สรุปหน้าตาฐานข้อมูลล่าสุด · Rails "dump" (เทออกมาเป็นไฟล์) ให้ทุกครั้งหลัง migrate · ห้ามแก้มือ แต่ต้อง commit · ถาม senior ได้ว่า “schema.rb ใน PR นี้มีบรรทัดที่ไม่เกี่ยวกับ migration ของผมโผล่มา ควรทำยังไง”
- snapshot กับ history
- snapshot = ภาพถ่าย ณ ตอนนี้ (schema.rb) · history หรือ event log = รายการทุกครั้งที่เปลี่ยน (db/migrate/) · พิมพ์บอก AI ได้ว่า “read db/schema.rb to see the current columns, don't guess from the migrations” — AI จะได้ไม่เดาคอลัมน์ผิด
schema_migrations — สมุดเซ็นรับงานช่าง
คำถามต่อมา: สั่ง db:migrate ซ้ำสิบรอบ ทำไม Rails ไม่เพิ่มคอลัมน์ published ซ้ำสิบรอบ?
เพราะในฐานข้อมูลมีตารางลับอีกตัวชื่อ schema_migrations เก็บเลขประจำตัวของใบที่ทำไปแล้ว ใบละหนึ่งแถว ·
ก่อนทำใบไหน Rails เปิดดูตารางนี้ก่อน ถ้ามีเลขนั้นแล้วก็ข้าม
ดูข้างในด้วย console (bin/rails console) · บรรทัดนี้ยาวหน่อย เพราะ schema_migrations ไม่มี model ให้เรียกแบบ Post.all
เลยต้องส่งคำสั่ง SQL ไปตรง ๆ (ผลจริง):
wongnok-blog(dev):001> ActiveRecord::Base.connection.execute("SELECT * FROM schema_migrations ORDER BY version").to_a
(60.2ms) SELECT * FROM schema_migrations ORDER BY version /*application='WongnokBlog'*/
=>
[{"version" => "20261104113601"},
{"version" => "20261104113603"},
{"version" => "20261104113604"},
{"version" => "20261104113606"},
{"version" => "20261104113607"}]
ActiveRecord::Base.connection— สายตรงไปฐานข้อมูล ·.execute("…")— ส่งคำสั่ง SQL ไปทั้งก้อน ·.to_a— แปลงผลเป็น array ให้อ่านง่ายSELECT * FROM schema_migrations ORDER BY version— คำสั่ง SQL แปลเป็นภาษาคน: "ขอทุกช่อง (*) ของทุกแผ่น จากลิ้นชักschema_migrationsเรียงตามช่อง version จากน้อยไปมาก" ·SELECT= ขอดู ·FROM= จากลิ้นชักไหน ·ORDER BY= เรียงตามอะไร- บรรทัด
(60.2ms) SELECT …— console โชว์ SQL ที่ยิงจริงพร้อมเวลา (ของคุณจะได้เลขอื่น) · ท่อน/*application='WongnokBlog'*/คือป้ายที่ Rails แปะท้าย SQL บอกว่ามาจากแอปไหน ไม่ต้องสนใจ - ผลคือ 5 แถว ช่องเดียวชื่อ
version· ตัวเลขตรงกับหน้าชื่อไฟล์ทั้ง 5 ใบในdb/migrate/= ทำครบทุกใบแล้ว
แต่ละสาขามีสมุดแขวนข้างตู้ ช่างทำใบไหนเสร็จก็จดเลขใบนั้นลงสมุด ·
ช่างคนใหม่มาถึง เปิดแฟ้มใบสั่ง (db/migrate/) เทียบกับสมุด (schema_migrations) → ใบไหนยังไม่มีในสมุด ทำเฉพาะใบนั้น
ที่ไม่เหมือนกัน: ช่างจริงเห็นใบสั่งที่ถูกแก้ข้อความก็อาจสงสัย · แต่ Rails ดูแค่เลขหน้าใบ ไม่อ่านเนื้อหาซ้ำ — เลขอยู่ในสมุดแล้วคือ "ทำแล้ว" จบ ต่อให้เนื้อหาในใบเปลี่ยนไปหมดก็ตาม (กับดักท้ายบท)
เทียบกับ Go: นี่คือตาราง goose_db_version ของ goose ในบท 31 นั่นเอง · ของ goose มีหลายช่อง (version_id · is_applied · tstamp)
ส่วนของ Rails มีช่องเดียวคือ version — มีแถว = ทำแล้ว · ไม่มีแถว = ยังไม่ทำ
- schema_migrations · migration version
- ตารางที่ Rails จดว่าทำ migration ใบไหนไปแล้ว · version = ตัวเลข 14 หลักหน้าชื่อไฟล์ · ถาม senior ได้ว่า “migration ตัวนี้ขึ้น up แล้วบน staging หรือยัง เช็คจาก schema_migrations ได้ไหม”
- pending migration
- ใบสั่งที่มีไฟล์แล้วแต่ยังไม่ได้ทำ (เลขยังไม่อยู่ในสมุด) · Rails จะไม่ยอมเปิดหน้าเว็บในเครื่องเราจนกว่าจะ migrate (เจอในภารกิจ) · พิมพ์บอก AI ได้ว่า “I got ActiveRecord::PendingMigrationError after pulling, what should I run?”
ลงมือ: เพิ่มคำโปรยใต้ชื่อบทความ
โจทย์: บทความแต่ละชิ้นอยากมี คำโปรยสั้น ๆ ใต้ชื่อเรื่อง เช่น "ต้มยำกุ้งน้ำข้น" — "สูตรเข้มข้นแบบร้านดัง" ·
ต้องเพิ่มคอลัมน์ subtitle (ข้อความสั้น) ในตาราง posts
ก่อนเริ่ม: commit ของที่ได้จากบท 107 ไว้ก่อน · ขั้น ③ จะใช้ git diff ดูว่า schema.rb เปลี่ยนตรงไหน
ซึ่งเทียบกับ commit ล่าสุด — ถ้า schema.rb ยังไม่เคย commit มันจะไม่โชว์อะไรเลย
(&& = ทำคำสั่งแรกสำเร็จแล้วค่อยทำคำสั่งถัดไป เหมือนบท 105):
$ git add . && git commit -m "schema และ seeds จากบท 107"
…(ผลของ commit ตัดออก)…
$ git status
On branch main
nothing to commit, working tree clean
nothing to commit, working tree clean = "ไม่มีอะไรค้าง ทุกอย่างบันทึกแล้ว" — ต่อจากนี้ทุกอย่างที่เปลี่ยนจะเห็นใน git diff · git status ·
(ถ้า git commit ตอบ nothing to commit ตั้งแต่แรก = คุณ commit ไว้แล้ว ไปต่อได้เลย · บรรทัด On branch main ของคุณอาจเป็น master ไม่ต่างกัน)
① ให้ Rails เขียนใบสั่งให้
$ bin/rails generate migration AddSubtitleToPosts subtitle:string
invoke active_record
create db/migrate/20261104114634_add_subtitle_to_posts.rb
generate migration— "เขียนใบสั่งช่างเปล่า ๆ ให้หนึ่งใบ" · เหมือนgenerate modelในบท 99 แต่สร้างแค่ไฟล์ migration ไฟล์เดียวAddSubtitleToPosts— ชื่อใบ · ตั้งชื่อตามแบบAdd…To…แล้ว Rails เดาเนื้อหาให้: Add = เพิ่มคอลัมน์ · ToPosts = ในตาราง postssubtitle:string— ชื่อคอลัมน์ : ชนิดข้อมูล (string = ข้อความสั้น)invoke active_record— Rails บอกว่า "ส่งงานให้ส่วน Active Record (บท 106) เป็นคนเขียนไฟล์" · ไม่ต้องทำอะไรcreate db/migrate/20261104114634_…— ได้ไฟล์ใหม่ เลขข้างหน้าคือเวลาที่คุณสั่ง
เปิดไฟล์ที่ได้ — Rails เขียนเนื้อหาให้ครบแล้ว ไม่ต้องพิมพ์อะไรเพิ่ม:
# db/migrate/20261104114634_add_subtitle_to_posts.rb
class AddSubtitleToPosts < ActiveRecord::Migration[8.1]
def change
add_column :posts, :subtitle, :string
end
end
ตอนนี้ มีแค่ใบสั่ง ยังไม่มีช่างลงมือ · ฐานข้อมูลยังไม่มีคอลัมน์ subtitle · และ schema.rb ยังไม่เปลี่ยนสักตัวอักษร
$ bin/rails db:migrate:status
db:migrate:status = "ขอดูว่าใบไหนทำแล้ว ใบไหนยัง" · หลัง generate แต่ยังไม่ migrate — ใบใหม่จะขึ้นว่าอะไร? และขึ้นไหม?
ผลจริง — ใบใหม่ขึ้นแล้ว สถานะ down:
database: w108
Status Migration ID Migration Name
--------------------------------------------------
up 20261104113601 Create posts
up 20261104113603 Add published and views to posts
up 20261104113604 Create users
up 20261104113606 Add user to posts
up 20261104113607 Create comments
down 20261104114634 Add subtitle to posts
database: w108— ชื่อฐานข้อมูลที่เครื่องผมใช้ตรวจ · ของคุณน่าจะเป็นwongnok_blog_developmentup= ทำแล้ว (เลขอยู่ในสมุด schema_migrations) ·down= มีไฟล์แต่ยังไม่ได้ทำ- Rails สร้างรายการนี้จากสองที่รวมกัน: ไฟล์ใน
db/migrate/กับ แถวใน schema_migrations
② สั่งช่างลงมือ: db:migrate
$ bin/rails db:migrate
== 20261104114634 AddSubtitleToPosts: migrating ===============================
-- add_column(:posts, :subtitle, :string)
-> 0.0033s
== 20261104114634 AddSubtitleToPosts: migrated (0.0033s) ======================
== … migrating— เริ่มทำใบนี้ · ทำแค่ใบที่ down อีก 5 ใบที่ up แล้วข้ามไปเงียบ ๆ-- add_column(…)— คำสั่งที่ทำ ·-> 0.0033s— ใช้เวลา 0.0033 วินาที (ตารางเล็กมาก เลยเร็ว — ตารางที่มีเป็นล้านแถวจะเป็นอีกเรื่อง บท 109)migrated— เสร็จ · Rails จดเลข20261104114634ลง schema_migrations แล้วเขียน schema.rb ใหม่ให้ทันที
③ แปลนตู้เปลี่ยนตรงไหน
ใช้ git diff ดูว่า schema.rb ต่างจากก่อน migrate ยังไง (ผลจริง ตัดบรรทัดที่ไม่เปลี่ยนรอบ ๆ ออกบางส่วน):
$ git diff db/schema.rb
-ActiveRecord::Schema[8.1].define(version: 2026_11_04_113607) do
+ActiveRecord::Schema[8.1].define(version: 2026_11_04_114634) do
…
t.boolean "published", default: false, null: false
t.integer "views", default: 0, null: false
t.bigint "user_id", null: false
+ t.string "subtitle"
t.index ["user_id"], name: "index_posts_on_user_id"
end
git diff ไฟล์= ดูว่าไฟล์ต่างจากตอน commit ล่าสุดตรงไหน · บรรทัดขึ้นต้น-= ของเดิมที่หายไป ·+= ของใหม่versionเลื่อนจาก…113607เป็น…114634= ใบล่าสุดคือใบ subtitle+ t.string "subtitle"— คอลัมน์ใหม่ต่อท้ายตาราง posts · มีสองบรรทัดนี้เท่านั้น = ตรงกับที่ตั้งใจ พร้อม commit
เช็คสถานะอีกรอบ — ใบ subtitle กลายเป็น up (ผลจริง ตัดบรรทัดบนออก):
$ bin/rails db:migrate:status
…
up 20261104113607 Create comments
up 20261104114634 Add subtitle to posts
④ เรียกช่างมารื้อ: db:rollback
$ bin/rails db:rollback
== 20261104114634 AddSubtitleToPosts: reverting ===============================
-- remove_column(:posts, :subtitle, :string)
-> 0.0040s
== 20261104114634 AddSubtitleToPosts: reverted (0.0046s) ======================
reverting— กำลังย้อนใบล่าสุด (ใบเดียว)remove_column(:posts, :subtitle, :string)— เราไม่ได้เขียนบรรทัดนี้ Rails กลับด้านadd_columnในchangeให้เองreverted— เสร็จ · Rails ลบเลขใบนี้ออกจาก schema_migrations และเขียน schema.rb ใหม่ (บรรทัด subtitle หายไปgit diffกลับมาว่างเปล่า)
สถานะตอนนี้กลับเป็น down — ไฟล์ยังอยู่ แค่ยังไม่ได้ทำ (ผลจริง):
$ bin/rails db:migrate:status
…
up 20261104113607 Create comments
down 20261104114634 Add subtitle to posts
⚠️ rollback ลบข้อมูลในคอลัมน์นั้นทิ้งจริง · ผมลองใส่คำโปรย "อร่อยมาก" ให้ทั้ง 6 บทความ แล้ว rollback → migrate กลับมาใหม่ ·
คอลัมน์กลับมา แต่ค่าทุกแผ่นเป็น nil หมด · ในเครื่องตัวเองไม่เป็นไร แต่บน production = ข้อมูลลูกค้าหาย
rollback = เรียกช่างกลับมารื้อสิ่งที่ใบล่าสุดต่อเติมไว้ แล้วขีดเลขใบนั้นออกจากสมุด · ใบสั่งยังอยู่ในแฟ้ม พร้อมทำใหม่เมื่อสั่ง
ที่ไม่เหมือนกัน: ช่างจริงรื้อช่องออกจากแฟ้มแล้ว ของที่เขียนในช่องนั้นอาจถ่ายเอกสารเก็บไว้ได้ · rollback ไม่เก็บอะไรไว้ให้เลย ข้อมูลในช่องหายไปพร้อมช่อง · และถ้าใบสั่งถูกแก้ไขไปแล้ว ช่างจะรื้อ "ตามใบที่แก้" ไม่ใช่ตามสิ่งที่ต่อเติมจริง (กับดักท้ายบท)
- migration generator
- คำสั่ง
bin/rails generate migration ชื่อ คอลัมน์:ชนิดที่เขียนไฟล์ให้ · ชื่อแบบAddXToY/RemoveXFromY/CreateYได้เนื้อหาให้เลย · พิมพ์บอก AI ได้ว่า “give me the rails generate migration command for adding a subtitle string to posts” - migrate · rollback · migration status
- migrate = ทำใบที่ down · rollback = ย้อนใบล่าสุด · status = ดูว่าใบไหน up/down · ถาม senior ได้ว่า “ก่อนเปิด PR ผมควรลอง rollback แล้ว migrate ใหม่ให้ดูว่าย้อนได้จริงไหม” — ทีมส่วนใหญ่ชอบคำถามนี้
คลังคำที่เขียนใน change ได้
ไม่ต้องจำ — ให้รู้ว่ามีคำอะไรบ้าง แล้วกลับมาเปิดตารางนี้ (หรือ cheat sheet) ตอนใช้:
| คำสั่ง | ภาษาคน · ในร้านอาหาร | ตัวอย่าง |
|---|---|---|
create_table | ต่อลิ้นชักใหม่ทั้งลิ้นชัก (ตารางใหม่) | create_table :tags do |t| … end |
drop_table | รื้อลิ้นชักทิ้งทั้งลิ้นชัก พร้อมแฟ้มข้างใน | drop_table :tags |
add_column | เพิ่มช่องบนแฟ้ม | add_column :posts, :subtitle, :string |
remove_column | ลบช่องบนแฟ้ม (ของที่เขียนในช่องหายด้วย) | remove_column :posts, :subtitle, :string |
rename_column | เปลี่ยนชื่อช่อง ข้อมูลยังอยู่ | rename_column :posts, :body, :content |
add_index | ติดป้ายสารบัญให้หาตามช่องนี้เร็วขึ้น (บท 109) | add_index :posts, :subtitle |
add_reference | เพิ่มช่อง "เป็นของ … เลขที่" เช่น user_id (บท 115) | add_reference :posts, :user, null: false, foreign_key: true |
สไลด์หน้า 14 มีคำอื่นอีก เช่น rename_table (เปลี่ยนชื่อลิ้นชัก) · remove_index (แกะป้ายสารบัญออก) — ไม่ต้องจำตอนนี้ ·
คำที่ใช้ตั้งกฎให้ตัวตู้ (เช่น "ช่องนี้ห้ามว่าง") จะได้ใช้จริงในบท 111
และชื่อใบแบบไหน generator เดาเนื้อหาให้ได้ — ผมลองสั่งทีละแบบ (ผลจริง ตัดมาเฉพาะบรรทัดใน change):
สั่ง bin/rails generate migration … | ได้ใน change |
|---|---|
AddSubtitleToPosts subtitle:string | add_column :posts, :subtitle, :string |
RemoveSubtitleFromPosts subtitle:string | remove_column :posts, :subtitle, :string |
CreateTags name:string | create_table :tags do |t| + t.string :name + t.timestamps |
AddSlugToPosts slug:string:index | add_column :posts, :slug, :string + add_index :posts, :slug |
AddCategoryToPosts category:references | add_reference :posts, :category, null: false, foreign_key: true |
AddReadingMinutesPosts reading_minutes:integer (ลืมคำว่า To) | ว่างเปล่า — def change กับ end เฉย ๆ ไม่มี error |
แถวสุดท้ายสำคัญ: ชื่อผิดแบบ generator ไม่ฟ้อง แค่ให้ใบเปล่า · ถ้าไม่เปิดดูแล้วสั่ง migrate ไปเลย ก็จะ "สำเร็จ" โดยไม่ได้เพิ่มอะไร ·
เปิดไฟล์ดูทุกครั้งหลัง generate ·
(ส่วน AddSubtitleToPost ที่ลืม s ไม่เป็นไร ผมลองแล้ว Rails ยังเติม :posts ให้ถูก)
คำสั่ง db: ที่ใช้บ่อย และอันไหนห้ามแตะบนร้านจริง
| คำสั่ง | ทำอะไร (ภาษาคน) | บนเครื่องเรา | บน production |
|---|---|---|---|
bin/rails db:migrate | ทำทุกใบที่ down ตามลำดับ แล้วเขียน schema.rb ใหม่ | ใช้บ่อย | ใช้ตอน deploy (ตามขั้นตอนของทีม) |
bin/rails db:rollback | ย้อนใบล่าสุด 1 ใบ | ใช้ได้ | อันตราย — ข้อมูลในช่องที่ถูกรื้อหายจริง |
bin/rails db:rollback STEP=3 | ย้อน 3 ใบล่าสุด (เปลี่ยนเลขได้) | ใช้ได้ | อันตราย |
bin/rails db:migrate:status | ดูว่าใบไหน up / down · แค่ดู ไม่เปลี่ยนอะไร | ใช้บ่อย | ปลอดภัย |
bin/rails db:prepare | ฐานข้อมูลยังไม่มี → สร้าง + โหลด schema.rb + ใส่ seeds · มีอยู่แล้ว → ทำใบที่ down | ใช้ตอนเพิ่ง clone โปรเจกต์ | บางทีมให้ระบบ deploy เรียกให้ |
bin/rails db:seed | รัน db/seeds.rb ใส่ข้อมูลตัวอย่าง · สั่งซ้ำ = ข้อมูลเบิ้ล (ผมลองแล้ว ได้ 6 users จาก 3) | ใช้ได้ระวังเบิ้ล | แล้วแต่ทีม |
bin/rails db:reset | ลบฐานข้อมูลทิ้งทั้งก้อน → สร้างใหม่จาก schema.rb → ใส่ seeds | ใช้ตอนอยากเริ่มใหม่สะอาด ๆ | ห้ามเด็ดขาด — ลบข้อมูลลูกค้าทั้งร้าน |
bin/rails db:migrate:reset | ตัวที่ใช้ในบท 107 · ลบฐานข้อมูลทิ้ง → สร้างใหม่ → ไล่ทำใบสั่งทุกใบตั้งแต่ใบแรก (จอขึ้น == … migrating ครบทุกใบ) · ไม่ใส่ seeds ให้ | ใช้ตอนอยากเช็คว่าใบสั่งทุกใบยังทำต่อกันได้ | ห้ามเด็ดขาด — เหมือน db:reset |
วิธีจำง่าย ๆ: production คือร้านจริงที่ลูกค้ากำลังนั่งกินอยู่ · คำสั่งไหน "รื้อ" หรือ "ล้าง" ห้ามทำบนร้านจริง · บนร้านจริงถ้าใบสั่งผิด ให้เขียนใบใหม่มาแก้ ไม่ย้อนใบเก่า (กับดักท้ายบท)
เพื่อนใหม่เข้าทีม: ทำไม schema.rb ต้อง commit
เพื่อนใหม่ clone โปรเจกต์ไป เครื่องเขามีไฟล์ครบ แต่ฐานข้อมูลยังว่างเปล่า · สไลด์หน้า 12 บอกว่า bin/rails db:prepare จบ — จริง ·
ผมลองกับฐานข้อมูลใหม่เอี่ยม (ผลจริง):
$ bin/rails db:prepare
Created database 'w108_new'
ขึ้นบรรทัดเดียว — ไม่มีบรรทัด == … migrating สักใบ (w108_new คือชื่อฐานข้อมูลทดลองของผม) ·
แต่ตารางครบ status ขึ้น up ทั้ง 5 ใบ และข้อมูลตัวอย่างครบ (นับด้วย bin/rails runner แบบบท 107 ได้ users 3 · posts 6 · comments 7)
เพราะบนฐานข้อมูลใหม่ Rails ไม่ไล่ทำใบสั่งทีละใบ แต่สร้างตู้ตามแปลน schema.rb ทีเดียว · จดเลขทุกใบลงสมุดให้ แล้วค่อยรัน seeds
# ฐานข้อมูลใหม่อีกก้อน คราวนี้สั่งแยกทีละขั้น
$ bin/rails db:create
Created database 'w108_new'
$ bin/rails db:migrate
ฐานข้อมูลว่าง มีใบสั่ง 5 ใบ · สั่ง db:migrate แล้วจอจะขึ้น == … migrating 5 ก้อนเหมือนตอนเพิ่ม subtitle ไหม?
ผลจริง — ไม่ขึ้นอะไรเลยสักบรรทัด · แต่ตารางครบ status up ทั้ง 5 ใบ (และไม่มีข้อมูลตัวอย่าง เพราะ migrate ไม่รัน seeds)
ตั้งแต่ Rails 8.0 db:migrate บนฐานข้อมูลเปล่า (ยังไม่มีสมุด schema_migrations) ก็สร้างตู้ตามแปลน schema.rb ก่อนเหมือนกัน ·
ใบสั่งทุกใบเลยถือว่าทำแล้ว ไม่มีอะไรให้พิมพ์ (บันทึกการเปลี่ยนแปลงของ Rails ยืนยัน — ลิงก์ท้ายบท)
ผมลองย้าย schema.rb ออกไปชั่วคราว แล้วทำแบบเดิม — คราวนี้ขึ้น == … migrating ครบ 5 ใบ เพราะไม่มีแปลนให้ใช้ ·
ส่วน db:migrate:reset ที่ใช้ในบท 107 ตั้งใจไล่ทำทุกใบใหม่ จึงเห็นครบ 5 ใบเหมือนกัน (ผมลองแล้ว)
ข้อสรุป: เครื่องใหม่ทุกเครื่องสร้างฐานข้อมูลจาก schema.rb ไม่ใช่จากใบสั่ง · ถ้าลืม commit schema.rb หรือ commit ฉบับเก่า เพื่อนจะได้ตู้ไม่ตรงกับเรา
✅ ดังนั้นทุกครั้งที่สร้าง migration ใหม่ commit สองไฟล์คู่กันเสมอ: ไฟล์ใน db/migrate/ (ประวัติ) + db/schema.rb ที่ Rails เขียนให้หลัง migrate (แปลนล่าสุด)
กับดัก: แก้ไฟล์ migration ที่รันไปแล้ว
สมมติเราสั่ง db:migrate ให้ใบ subtitle กลับมาเป็น up แล้ว · จากนั้นนึกได้ว่า "อยากให้ค้นหาตาม subtitle ได้เร็วด้วย"
มือใหม่ส่วนใหญ่จะเปิดไฟล์เดิมแล้วเติมบรรทัด add_index ลงไป:
# db/migrate/20261104114634_add_subtitle_to_posts.rb — แก้หลังจาก migrate ไปแล้ว
class AddSubtitleToPosts < ActiveRecord::Migration[8.1]
def change
add_column :posts, :subtitle, :string
add_index :posts, :subtitle # ← เติมบรรทัดนี้
end
end
$ bin/rails db:migrate
สั่ง migrate หลังแก้ไฟล์ — จะเห็น -- add_index(:posts, :subtitle) ไหม?
ผลจริง — ไม่มีอะไรขึ้นเลย ไม่มี error · schema.rb ไม่เปลี่ยน · ไม่มี index
เพราะเลข 20261104114634 อยู่ในสมุด schema_migrations แล้ว Rails ถือว่า "ทำแล้ว" จบ ไม่เปิดอ่านเนื้อหาใหม่ (คู่มือของ Rails ก็เขียนไว้แบบนี้ — ลิงก์ท้ายบท)
แย่กว่านั้น: ถ้าคิดว่า "งั้น rollback แล้ว migrate ใหม่" ตอนนี้เลย — rollback พัง (ผลจริง ตัดบรรทัดที่ไม่จำเป็นเป็น …):
$ bin/rails db:rollback
== 20261104114634 AddSubtitleToPosts: reverting ===============================
-- remove_index(:posts, :subtitle)
bin/rails aborted!
StandardError: An error has occurred, this and all later migrations canceled: (StandardError)
No indexes found on posts with the options provided.
…
Tasks: TOP => db:rollback
- Rails ย้อนตามไฟล์ที่แก้แล้ว: บรรทัดสุดท้ายคือ
add_index→ ขากลับคือremove_index→ ทำก่อน - แต่ index นั้นไม่เคยถูกสร้างจริง (ข้อที่แล้ว) →
No indexes found on posts= "หา index ใน posts ไม่เจอ" bin/rails aborted!— คำสั่งหยุดกลางทาง ·this and all later migrations canceled— ยกเลิกทั้งใบ ไม่มีอะไรเปลี่ยน · ใบยังเป็นupค้างอยู่Tasks: TOP => db:rollback— บรรทัดท้ายบอกแค่ว่าพังระหว่างคำสั่งdb:rollback· ใจความอยู่บรรทัดNo indexes found…ข้างบน
⚠️ ถ้าเจอแบบนี้: แก้ไฟล์กลับให้เหมือนตอนที่รันไปก่อนเสมอ (ลบบรรทัด add_index ออก) แล้วค่อยเลือกวิธี ① หรือ ② ข้างล่าง ·
ถ้าปล่อยบรรทัดนั้นค้างไว้แล้วไปทำวิธีที่ ② เครื่องเพื่อนที่ยังไม่เคยรันทั้งสองใบจะสร้างสารบัญซ้ำสองรอบแล้วพัง
(ผมลองแล้ว ได้ PG::DuplicateTable: ERROR: relation "index_posts_on_subtitle" already exists = "มีสารบัญชื่อนี้อยู่แล้ว")
วิธีที่ถูก มีสองทาง เลือกตามว่าใบนี้ไปถึงใครแล้ว (ทั้งสองทางเริ่มจากไฟล์ใบเดิมที่ยังไม่ได้แก้):
① ยังไม่ push ยังไม่มีใครเอาไปรัน — rollback ก่อน แล้วค่อยแก้
$ bin/rails db:rollback # ไฟล์ยังเป็นแบบเดิม → ผ่าน
# ค่อยเติม add_index ในไฟล์
$ bin/rails db:migrate
== 20261104114634 AddSubtitleToPosts: migrating ===
-- add_column(:posts, :subtitle, :string)
-> 0.0021s
-- add_index(:posts, :subtitle)
-> 0.0025s
== 20261104114634 AddSubtitleToPosts: migrated (0.0047s) ===
ลำดับสำคัญ: ถอยก่อน แก้ทีหลัง · ใช้ได้แค่บนเครื่องตัวเอง
② push ไปแล้ว หรือขึ้นร้านจริงแล้ว — เขียนใบใหม่
$ bin/rails generate migration AddIndexToPostsSubtitle
create db/migrate/20261104115037_add_index_to_posts_subtitle.rb
# ได้ change เปล่า → พิมพ์ add_index :posts, :subtitle เอง
$ bin/rails db:migrate
== 20261104115037 AddIndexToPostsSubtitle: migrating ===
-- add_index(:posts, :subtitle)
-> 0.0063s
== 20261104115037 AddIndexToPostsSubtitle: migrated (0.0063s) ===
ใบเก่าไม่แตะ · ทุกเครื่องได้ใบใหม่ไปทำต่อเอง · ปลอดภัยเสมอ
(ผลทั้งสองฝั่งรันจริง · ผมตัดเส้น ===== ท้ายบรรทัดให้สั้นลง และตัดบรรทัด invoke active_record ออก เพื่อให้พอดีกล่อง · ชื่อ AddIndexToPostsSubtitle ไม่ตรงแบบที่ generator เดาได้ จึงได้ใบเปล่า ต้องพิมพ์บรรทัดเอง)
ใบสั่งที่จดในสมุดแล้ว = ช่างทำเสร็จแล้ว · ถ้าเราแอบแก้ข้อความในใบ ช่างไม่กลับมาทำเพิ่ม เพราะเลขใบอยู่ในสมุดแล้ว · ที่แย่คืออีก 4 สาขาที่ยังไม่ได้ทำใบนี้ จะทำตามใบที่แก้ — ตู้สาขาเราไม่มีสารบัญ แต่สาขาอื่นมี · ตู้ไม่เหมือนกันอีกแล้ว
วิธีที่ถูกคือออกใบสั่งใบใหม่ "ติดสารบัญเพิ่ม" · ทุกสาขาได้ใบเดียวกัน ทำตามลำดับเดียวกัน
ที่ไม่เหมือนกัน: ในชีวิตจริงใบสั่งที่ถูกแก้มักมีรอยลบให้เห็น · ไฟล์ migration ไม่มีรอยอะไรเลย
ถ้าไม่มีใครเปิด git log ของไฟล์นั้นดู ก็ไม่มีใครรู้ว่าใบถูกแก้
- edit a migration that already ran
- กับดักคลาสสิก · ผล: ไม่มีอะไรเกิดในเครื่องเรา + เครื่องอื่นได้ผลต่างจากเรา + rollback อาจพัง · ประโยคที่ได้ยินใน code review บ่อย: “อย่าแก้ migration เดิม เขียนไฟล์ใหม่” — ถ้า AI เสนอให้แก้ไฟล์เก่า ให้พิมพ์ตอบว่า “that migration already ran, write a new one instead”
- schema drift
- อาการที่ฐานข้อมูลแต่ละเครื่อง/แต่ละ environment ค่อย ๆ ไม่ตรงกันโดยไม่มีใครรู้ตัว — ผลของการแก้ใบเก่าหรือแก้ฐานข้อมูลด้วยมือ (เคยเจอคำนี้ในบท 31) · ถาม senior ได้ว่า “สงสัยว่าเครื่องผมกับ staging drift กัน เช็คยังไงดี”
เก็บกวาดก่อนไปบทหน้า
บทหลังจากนี้ใช้ตาราง posts แบบเดิม (ไม่มี subtitle) · ดูก่อนว่าคุณหยุดอยู่ตรงไหน:
- ทำแค่ถึงขั้น ④ (ใบ subtitle เป็น
downอยู่) → สั่งbin/rails destroy migration AddSubtitleToPostsคำสั่งเดียวพอ - ทำวิธีที่ ② มาจนมีสองใบใหม่ → ย้อนสองใบแล้วลบไฟล์ทิ้ง ตามนี้ (ผลจริง):
$ bin/rails db:rollback STEP=2
== 20261104115037 AddIndexToPostsSubtitle: reverting ==========================
-- remove_index(:posts, :subtitle)
-> 0.0069s
== 20261104115037 AddIndexToPostsSubtitle: reverted (0.0083s) =================
== 20261104114634 AddSubtitleToPosts: reverting ===============================
-- remove_column(:posts, :subtitle, :string)
-> 0.0027s
== 20261104114634 AddSubtitleToPosts: reverted (0.0028s) ======================
$ bin/rails destroy migration AddIndexToPostsSubtitle
invoke active_record
remove db/migrate/20261104115037_add_index_to_posts_subtitle.rb
$ bin/rails destroy migration AddSubtitleToPosts
invoke active_record
remove db/migrate/20261104114634_add_subtitle_to_posts.rb
STEP=2— ย้อนสองใบล่าสุด · ย้อนจากใบใหม่สุดก่อน (index ออกก่อน แล้วค่อยรื้อคอลัมน์)destroy migration ชื่อ— ตรงข้ามกับ generate: ลบไฟล์ที่ generate สร้าง (เหมือนdestroy modelในบท 99)- ลำดับต้องเป็น rollback ก่อน แล้วค่อย destroy · สลับกัน = ลบใบสั่งทิ้งทั้งที่ช่างยังไม่ได้รื้อ (ดูในภารกิจ)
- ถ้าคุณทำวิธีที่ ① (มีใบเดียวที่มีทั้ง add_column และ add_index) ให้สั่ง
bin/rails db:rollbackครั้งเดียว แล้วbin/rails destroy migration AddSubtitleToPosts - เสร็จแล้ว
git statusต้องว่าง ·db:migrate:statusเหลือup5 ใบเหมือนตอนเริ่ม
🎯 ภารกิจ — เพิ่มช่อง "เวลาอ่าน (นาที)" แล้วถอยออก (~10 นาที)
ทำในแอป wongnok_blog ของคุณ (สถานะเริ่มต้น: db:migrate:status ขึ้น up 5 ใบ · git status ว่าง)
- สร้างใบสั่ง:
bin/rails generate migration AddReadingMinutesToPosts reading_minutes:integerแล้วเปิดไฟล์ดูว่าในchangeมีบรรทัดอะไร bin/rails db:migrate:status— ใบใหม่ขึ้นว่าอะไรbin/rails db:migrateแล้วgit diff db/schema.rb— บรรทัดที่ขึ้นต้น+มีอะไรบ้างbin/rails runner 'p Post.column_names'— มี"reading_minutes"อยู่ท้ายสุดไหม (runner= รัน Ruby หนึ่งบรรทัดในแอป ไม่ต้องเปิด console)- เก็บกวาด:
bin/rails db:rollback→bin/rails destroy migration AddReadingMinutesToPosts→git statusต้องว่าง
ก่อนกดเฉลย: ข้อ 3 schema.rb เปลี่ยนกี่จุด? และถ้าข้อ 5 สลับเอา destroy ขึ้นก่อน rollback จะเกิดอะไร?
สิ่งที่ควรเห็น (ผลจริงจากเครื่องที่ใช้ตรวจ · เลขของคุณต่าง):
- ข้อ 1 — ได้
add_column :posts, :reading_minutes, :integerบรรทัดเดียว - ข้อ 2 — บรรทัดสุดท้ายเป็น
down … Add reading minutes to posts - ข้อ 3 — จอขึ้น
-- add_column(:posts, :reading_minutes, :integer)· schema.rb เปลี่ยน 2 จุด: เลขversionและบรรทัด+ t.integer "reading_minutes" - ข้อ 4 —
["id", "title", "body", "created_at", "updated_at", "published", "views", "user_id", "reading_minutes"]· model Post ได้ช่องใหม่ทันทีโดยไม่ต้องแก้ไฟล์ model (Rails อ่านจากตาราง บท 106) - ข้อ 5 — rollback ขึ้น
remove_column(:posts, :reading_minutes, :integer)· destroy ขึ้นremove db/migrate/…·git statusว่าง
ถ้าเจอ error นี้ แปลว่า…
Could not generate field 'reading_minutes' with unknown type 'integr'.— พิมพ์ชนิดข้อมูลผิด (ผมลองพิมพ์ผิดแบบนี้แล้ว ได้ข้อความนี้จริง) · ไม่มีไฟล์ถูกสร้าง สั่งใหม่ให้ถูกได้เลย- ไฟล์ที่ได้มี
def changeว่างเปล่า — ชื่อใบผิดแบบ เช่นลืมคำว่าTo·destroyทิ้งแล้ว generate ใหม่ หรือพิมพ์add_columnเอง - เปิดหน้าเว็บแล้วเจอ
ActiveRecord::PendingMigrationError·Migrations are pending. To resolve this issue, run: bin/rails db:migrate— มีใบที่ยัง down อยู่ (ข้าม ข้อ 3 ไป) · Rails ไม่ยอมเปิดหน้าเว็บในเครื่องเราจนกว่าจะ migrate · ทำตามที่มันบอก db:migrate:statusขึ้นup … ********** NO FILE **********และ rollback ขึ้นActiveRecord::UnknownMigrationVersionError·No migration with version number …— คุณ destroy ก่อน rollback: สมุดบอกว่าทำแล้ว แต่ใบสั่งหายไป ช่างเลยไม่รู้จะรื้อยังไง (ผมลองแล้ว ได้สองข้อความนี้จริง) · แก้: สร้างไฟล์ใบเดิมกลับมาด้วยเลขเดิมเป๊ะ (db/migrate/<เลขที่ขึ้นใน status>_add_reading_minutes_to_posts.rbเนื้อหาตามข้อ 1) → rollback → destroy
ติดตรงไหน หรือผลของคุณไม่ตรงกับที่เขียนไว้ ถามกลับมาได้เลย พร้อมแปะสิ่งที่จอขึ้นมาทั้งก้อน
อยากรู้ว่า "ตอนนี้ตาราง posts มีคอลัมน์อะไรบ้าง" ให้เร็วที่สุด ควรเปิดอะไร?
ตัวเลือกที่ 2 ถูก — schema.rb คือแปลนตู้ฉบับล่าสุด รวมผลของทุกใบสั่งไว้ที่เดียว
ตัวเลือกที่ 1 ผิด เพราะไฟล์ model ไม่ได้เขียนรายชื่อคอลัมน์ไว้ (Rails อ่านจากตารางตอนรัน) · ตัวเลือกที่ 3 ผิด เพราะใบล่าสุดบอกแค่สิ่งที่เปลี่ยนครั้งล่าสุด ไม่ใช่ทั้งตาราง · ตัวเลือกที่ 4 ผิด เพราะ schema_migrations เก็บแค่เลขใบที่ทำแล้ว ไม่มีชื่อคอลัมน์
จุดที่ต้องจำ: ตอนนี้หน้าตาเป็นยังไง → schema.rb · เปลี่ยนมายังไง → db/migrate/ · ทำใบไหนแล้ว → schema_migrations
สั่ง bin/rails db:migrate ซ้ำหลายรอบ ทำไมคอลัมน์ไม่ถูกเพิ่มซ้ำ?
ตัวเลือกที่ 3 ถูก — schema_migrations คือสมุดเซ็นรับงาน เลขใบอยู่ในสมุด = ทำแล้ว ข้าม
ตัวเลือกที่ 1 ผิด เพราะบนฐานข้อมูลที่มีสมุดแล้ว Rails ไม่ได้เอา schema.rb มาเทียบทีละบรรทัด (มันแค่เขียน schema.rb ใหม่หลังทำเสร็จ · schema.rb ถูกใช้ตอนสร้างฐานข้อมูลใหม่เท่านั้น) · ตัวเลือกที่ 2 ผิด เพราะ Rails ไม่สนวันที่แก้ไฟล์ — นี่แหละเหตุผลที่แก้ไฟล์เก่าแล้วไม่มีผล · ตัวเลือกที่ 4 ผิด เพราะ Rails ไม่ได้ส่งคำสั่งไปเลยตั้งแต่แรก (จอว่างเปล่า ไม่ใช่ error ที่ถูกซ่อน)
จุดที่ต้องจำ: Rails ดูแค่ "เลขใบอยู่ในสมุดไหม" ไม่อ่านเนื้อหาใบซ้ำ
migration เพิ่มคอลัมน์ของคุณ push ไปแล้ว และขึ้นเว็บจริงไปแล้ว · เพิ่งเห็นว่าลืม add_index ควรทำอย่างไร?
ตัวเลือกที่ 1 ถูก — ใบใหม่ไม่แตะของเดิม ทุกเครื่องทุก environment ได้ไปทำต่อตามลำดับเดียวกัน
ตัวเลือกที่ 2 ผิด เพราะใบนี้ up แล้ว migrate ซ้ำไม่เกิดอะไร (เห็นแล้วในกับดัก) และเครื่องอื่นจะได้ผลต่างจากเรา · ตัวเลือกที่ 3 ผิด เพราะ rollback บนร้านจริงรื้อคอลัมน์ทิ้งพร้อมข้อมูลลูกค้า · ตัวเลือกที่ 4 ผิด เพราะ schema.rb เป็นไฟล์ที่ Rails เขียน ไม่ได้สร้าง index จริงในฐานข้อมูล และจะถูกเขียนทับรอบหน้า
จุดที่ต้องจำ: ใบที่ออกจากเครื่องเราไปแล้ว = แก้ด้วยใบใหม่เสมอ
คำสั่งไหนห้ามรันบน production (ร้านจริงที่ลูกค้ากำลังใช้) มากที่สุด?
ตัวเลือกที่ 4 ถูก — db:reset ลบฐานข้อมูลทั้งก้อนแล้วสร้างใหม่จาก schema.rb + seeds = ข้อมูลลูกค้าหายหมดทั้งร้าน
ตัวเลือกที่ 1 ผิด เพราะ status แค่ดู ไม่เปลี่ยนอะไร · ตัวเลือกที่ 2 ผิด เพราะ generate แค่สร้างไฟล์ในเครื่อง ไม่แตะฐานข้อมูล (และปกติไม่มีใครสั่งบนเครื่อง production) · ตัวเลือกที่ 3 ผิด เพราะ migrate คือขั้นตอนปกติตอน deploy · แค่ต้องระวังว่าใบนั้นทำร้านค้างหรือเปล่า (เรื่องของบท 109)
จุดที่ต้องจำ: คำสั่งที่ "รื้อ" หรือ "ล้าง" (reset · rollback) ใช้ได้แค่ในครัวทดลอง
ถ้าเปิดสไลด์ดูประกอบ
| หน้า | สไลด์เขียน | ของจริง | ต้องสนใจไหม |
|---|---|---|---|
| 15 | db:prepare = "สร้าง DB + รัน migration ที่ขาด" | บนฐานข้อมูลใหม่: สร้าง + โหลด schema.rb + ใส่ seeds ไม่ไล่ทำใบทีละใบ · ฐานข้อมูลที่มีตารางแล้ว: ถึงจะทำใบที่ขาด | สนใจ — เหตุผลที่ schema.rb ต้อง commit |
| 14 | แก้ไฟล์ที่รันแล้ว → "ต้อง rollback ก่อน หรือเขียนไฟล์ใหม่ทับ" | ต้อง rollback ก่อนแก้ · แก้ก่อนแล้วค่อย rollback = พัง (No indexes found…) · "ไฟล์ใหม่" คือเพิ่มใบใหม่ ไม่ใช่เขียนทับใบเดิม | สนใจมาก |
| 14 | SELECT * FROM schema_migrations; พร้อมผลเป็นตาราง | ผลหน้าตาแบบนั้นได้จากโปรแกรม psql (เปิดด้วย bin/rails dbconsole ถ้ามี psql ในเครื่อง) · บทนี้ใช้ console ส่ง SQL แทน ข้อมูลเหมือนกัน | ไม่ต้อง |
| 13 | ตัวอย่าง schema มี published_at · ใบ RemoveSubtitle | เป็นตัวอย่างของสไลด์ · แอปเราใช้ published (จริง/เท็จ) และไม่มีใบ RemoveSubtitle | ไม่ต้อง |
| 12 | เครื่องใหม่เข้าทีม: bin/rails db:prepare จบ | จริง · แต่ต้องเปิด PostgreSQL ไว้ก่อน และ schema.rb ใน git ต้องเป็นฉบับล่าสุด | สนใจนิดหน่อย |
| 15 | ไม่ได้บอกว่า db:seed สั่งซ้ำแล้วเป็นยังไง | สั่งซ้ำ = ข้อมูลเบิ้ล (seeds ของเราไม่ได้เช็คว่ามีอยู่แล้ว) | สนใจ — ใช้ db:reset ในเครื่องตัวเองแทนถ้าอยากเริ่มใหม่ |
อ้างอิง
- Rails Guides — Active Record Migrations § Generating Migration Files —
อ่านหัวข้อย่อย Adding Columns กับ Removing Columns · ยืนยันว่าชื่อ
AddXToY/RemoveXFromYทำให้ generator เขียนเนื้อหาให้ - Rails Guides — § Rails Migration Version Control — ย่อหน้าสั้น ๆ อธิบายว่า schema_migrations เก็บเลขจากชื่อไฟล์ · อ่านต่อ § Changing Existing Migrations ที่บอกตรง ๆ ว่าแก้ไฟล์ที่รันแล้ว Rails "will do nothing" และควรเขียนใบใหม่
- Rails Guides — § Preparing the Database และ § Schema Dumps and Source Control — db:prepare บนฐานข้อมูลใหม่ทำอะไร และทำไม "strongly recommended" ให้ commit schema.rb
- Rails — activerecord/CHANGELOG.md (v8.0.0) — ค้นคำว่า "fresh database" · บรรทัดที่ยืนยันว่า db:migrate บนฐานข้อมูลใหม่โหลด schema ก่อน (เหตุผลที่ predict ข้างบนจอว่าง)
- Rails API — SchemaStatements —
รายการคำที่ใช้ใน
changeครบทุกตัว · ดูแค่add_column·remove_column·rename_column·add_indexพอ - pressly/goose (GitHub) —
เครื่องมือ migration ของ Go ที่ใช้ในบท 31 · ดูตัวอย่างไฟล์
-- +goose Up/Downใน README เทียบกับchangeของ Rails