🔁 ทวนก่อนเริ่ม — คำจากบทก่อน ๆ ที่บทนี้ใช้
- model (บท 99, 106) — class อย่าง
Postที่เป็น “คนดูแลสต็อก” ของลิ้นชักposts - ตาราง · แถว · คอลัมน์ · SQL (บท 106) — ลิ้นชัก · แฟ้มหนึ่งแผ่น · ช่องบนแฟ้ม (เช่นช่อง
title) · SQL = ภาษาที่คนดูแลสต็อกใช้สั่งตู้ bin/rails console(บท 104) — ครัวทดลองหลังร้าน พิมพ์ Ruby ทีละบรรทัด ·=>นำหน้าผลลัพธ์nil(บท 94) — “ไม่มีค่า” · เรียก method บนnilจะได้NoMethodErrortitle: "…"(บท 92) — ส่งค่าเป็นคู่ “ชื่อช่อง: ค่า” (hash ที่ใช้ symbol เป็นชื่อ)
🧰 ตรวจด้วย Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 17 (สไลด์ใช้ Ruby 4.0.6 · Rails 8.1.3 — รุ่นแก้บั๊กในสายเดียวกัน ใช้แทนกันได้)
📦 ข้อมูลที่บทนี้ใช้: ครึ่งแรกใช้ตาราง posts ที่มีแค่ title กับ body (แบบเดียวกับบท 99) ·
ครึ่งหลังเราจะสร้างข้อมูลกลางของวันที่ 2 ที่บท 108–119 ใช้ร่วมกัน
ผลทุกก้อนรันจริงแล้วก็อปมา · เวลา (ms) · วันเวลาในแฟ้ม · เลข 0x… · เลขบรรทัดของ prompt ของคุณจะไม่ตรงกับในบท และจอคุณจะมีสี
CRUD — งานสี่อย่างของคนดูแลสต็อก
ไม่ว่าเว็บไหน งานที่ทำกับข้อมูลมีแค่สี่แบบ: เก็บของใหม่ · หยิบมาดู · แก้ · ทิ้ง
คนในวงการเรียกรวมว่า CRUD (อ่านว่า “ครัด”) ย่อมาจาก Create · Read · Update · Delete
บล็อกของเราก็คือ CRUD ของบทความ: เขียนบทความใหม่ · เปิดอ่าน · แก้คำผิด · ลบทิ้ง
| ตัวอักษร | ภาษาคน | คำสั่ง Rails ที่จะได้ลอง | คำ SQL ที่ console จะโชว์ |
|---|---|---|---|
| Create | เก็บแฟ้มใหม่ลงลิ้นชัก | Post.new + save · Post.create | INSERT |
| Read | หยิบแฟ้มมาอ่าน | find · find_by · where · first · last · all · count | SELECT |
| Update | แก้แฟ้มที่อยู่ในลิ้นชัก | update | UPDATE |
| Delete | ทิ้งแฟ้มออกจากลิ้นชัก | destroy | DELETE |
เทียบกับ Go: ในบท 27 คุณเขียน repository (ไฟล์ที่รวมคำสั่งคุยกับฐานข้อมูลไว้ที่เดียว) ที่มี Create · FindByID · FindAll ด้วย GORM —
นั่นคือตัว C กับ R ของ CRUD · บทนี้คือของชุดเดียวกันฝั่ง Rails แต่ไม่ต้องเขียน repository เอง เพราะ model ทำให้หมดแล้ว
ห้องเก็บของของร้าน มีตู้เก็บแฟ้ม (database) และลิ้นชัก posts ·
คนดูแลสต็อก (model Post) ทั้งวันทำแค่สี่อย่าง: รับแฟ้มใหม่มาเก็บ · หยิบแฟ้มให้คนที่ขอ · แก้ข้อความบนแฟ้ม · ทิ้งแฟ้มที่ไม่ใช้
ทุกครั้งที่ทำ เขาสั่งตู้ด้วยภาษาเฉพาะของตู้ชื่อ SQL · เราพูด Ruby กับเขา เขาแปลเป็น SQL ให้เอง
ที่ไม่เหมือนกัน: คนจริงทิ้งแฟ้มลงถังขยะ ยังคุ้ยคืนได้ ·
destroy ใน Rails ลบออกจากตู้จริง ไม่มีถังขยะให้คุ้ย (ยกเว้นเปิด console แบบ --sandbox จากบท 104 ที่ยกเลิกทุกอย่างตอนออก)
- CRUD
- Create · Read · Update · Delete = งานพื้นฐานสี่อย่างกับข้อมูล · หน้าเว็บส่วนใหญ่คือ CRUD ของอะไรสักอย่าง · พิมพ์บอก AI ได้ว่า “build basic CRUD for posts in Rails” แล้วจะได้ของครบสี่อย่าง (บท 118 จะสอนอ่านสิ่งที่ได้มา)
- SQL
- ภาษาที่ใช้สั่งฐานข้อมูล เช่น PostgreSQL · Active Record แปลคำสั่ง Ruby เป็น SQL ให้ · เราไม่ต้องเขียนเอง แต่ต้องอ่านออก · ถาม senior ได้ว่า “บรรทัดนี้ Active Record ยิง SQL อะไรออกไป”
เปิดครัวทดลอง แล้วถาม Post ว่ารู้อะไรบ้าง
ทำในโปรเจกต์ wongnok_blog เดิมจากบท 106 · เปิด terminal หน้าต่างใหม่ต้อง export DATABASE_URL=… ก่อนทุกครั้ง (แบบบท 105)
ต้องมี model Post กับตาราง posts ก่อน · เช็คด้วย ls app/models ในโฟลเดอร์โปรเจกต์
ถ้ายังไม่มีไฟล์ post.rb ให้สร้างแบบเดียวกับบท 99 (ถ้ามีแล้วจากบท 106 ข้ามได้เลย):
$ bin/rails generate model Post title:string body:text
$ bin/rails db:migrate
- บรรทัดแรก = สร้างไฟล์ model กับ “ใบสั่งช่าง” (migration) ที่ขอลิ้นชัก
postsมีช่องtitleกับbody - บรรทัดสอง = สั่งช่างลงมือตามใบสั่ง · ลิ้นชักเกิดขึ้นจริงในตู้ (บท 108 อธิบายเต็ม)
- ถ้าเผลอสั่งบรรทัดแรกซ้ำ จะได้
The name 'Post' is either already used in your application…— ไม่มีอะไรเสีย แค่บอกว่ามีอยู่แล้ว
เปิด console แล้วถามว่า “ตอนนี้ลิ้นชักมีกี่แผ่น” (ผลจริง):
$ bin/rails console
Loading development environment (Rails 8.1.4)
wongnok-blog(dev):001> Post.count
Post Count (54.6ms) SELECT COUNT(*) FROM "posts" /*application='WongnokBlog'*/
=> 0
บรรทัดกลางคือของใหม่ที่ Day 1 ยังไม่เคยเห็น (บท 106 แอบเห็นมาแล้วหนึ่งครั้ง) — console บอกเราว่ามันเพิ่งไปคุยกับตู้มา · แยกทีละส่วน:
Post Count— ใครทำอะไร: “คนดูแลสต็อกของ Post กำลังนับ”(54.6ms)— ใช้เวลากี่มิลลิวินาที · ครั้งแรกหลังเปิด console มักช้ากว่าครั้งต่อ ๆ ไป · ของคุณจะได้เลขอื่นSELECT COUNT(*) FROM "posts"— SQL ที่ส่งไปจริง ·SELECT= “ขอ/หยิบ” ·COUNT(*)= “นับจำนวนแผ่น” ·FROM "posts"= “จากลิ้นชัก posts”
→ อ่านรวมว่า “นับให้หน่อยว่าลิ้นชัก posts มีแฟ้มกี่แผ่น”/*application='WongnokBlog'*/— ป้ายชื่อแอปที่ Rails แปะท้ายทุกคำสั่ง (/* */คือโน้ตใน SQL ตู้ไม่สนใจ) · ข้ามไปได้เลย=> 0— คำตอบ: ยังไม่มีแฟ้มสักแผ่น
ทุกครั้งที่คนดูแลสต็อกเดินไปที่ตู้ เขาจดลงสมุดหนึ่งบรรทัด: ใครไป · ไปทำอะไร · ใช้เวลาเท่าไหร่ · พูดกับตู้ว่าอะไร ·
บรรทัดที่ขึ้นต้นด้วย Post Count (…ms) คือหน้าสมุดเล่มนั้น
อยากรู้ว่าคำสั่ง Ruby หนึ่งบรรทัดทำให้เดินไปตู้กี่รอบ → นับบรรทัดในสมุด
ที่ไม่เหมือนกัน: สมุดจริงต้องมีคนนั่งจด · ที่นี่ Rails จดให้เองทุกครั้งโดยไม่ต้องสั่ง · และจดเป็นภาษา SQL ไม่ใช่ภาษาคน — งานของเราคืออ่านให้ออก (บทนี้จะแปลให้ทุกบรรทัด)
คำถามเรื่องโครงของลิ้นชัก (Post.table_name · Post.column_names · Post.columns_hash) คุณลองไปแล้วในภารกิจบท 106 ·
บทนี้จึงข้ามไปที่ของใหม่เลย: สั่งให้แฟ้มเข้าออกลิ้นชักจริง แล้วอ่านสมุดบันทึกทุกบรรทัด
- SQL log (log ของ query)
- บรรทัดที่ Rails พิมพ์บอกทุกครั้งที่คุยกับฐานข้อมูล (ใครทำ · ใช้เวลาเท่าไหร่ · SQL ที่ส่งไป) · ในบทนี้เห็นใน console · ตอนรันเว็บจะเห็นในหน้าต่าง server · พิมพ์บอก AI ได้ว่า “here is the SQL log from rails console” แล้วแปะทั้งก้อน — ได้คำตอบตรงกว่าเล่าเป็นคำพูดมาก
COUNT(*)- คำ SQL “นับจำนวนแถว” · ฝั่ง Ruby คือ
Post.count· ใช้เช็คเร็ว ๆ ว่ามีข้อมูลอยู่กี่แถวโดยไม่ต้องหยิบออกมาทั้งหมด · ถาม senior ได้ว่า “ตารางนี้ตอนนี้มีกี่ row ใน dev กับใน production”
C — สร้าง: new ยังอยู่ในมือ · save ถึงลงลิ้นชัก
สร้างบทความมีสองจังหวะ: กรอกแฟ้ม กับ เอาแฟ้มเข้าลิ้นชัก
Post.new(...) ทำแค่จังหวะแรก · ลองดู (ผลจริง):
wongnok-blog(dev):002> post = Post.new(title: "ต้มยำกุ้งน้ำข้น", body: "สูตรต้มยำกุ้งแบบร้านดัง")
=>
#<Post:0x0000000127dc4650
...
wongnok-blog(dev):003> post
=>
#<Post:0x0000000127dc4650
id: nil,
title: "ต้มยำกุ้งน้ำข้น",
body: "สูตรต้มยำกุ้งแบบร้านดัง",
created_at: nil,
updated_at: nil>
wongnok-blog(dev):004> post.new_record?
=> true
wongnok-blog(dev):005> post.persisted?
=> false
wongnok-blog(dev):006> post.id
=> nil
post = Post.new(title: …, body: …)— สร้างบทความหนึ่งชิ้นแล้วกรอกช่อง · เก็บไว้ในตัวแปรpost
console ย่อผลเหลือ...เพราะเป็นบรรทัดตั้งตัวแปร · พิมพ์postเฉย ๆ อีกบรรทัดเพื่อดูเต็ม#<Post:0x0000000127dc4650 … >— วิธีที่ Ruby โชว์ object หนึ่งตัว: “นี่คือบทความ (object ของ classPost)” · เลข0x…คือที่อยู่ในหน่วยความจำ ไม่ต้องสนใจ (ของคุณจะต่าง) · บรรทัดข้างในคือช่องบนแฟ้มทีละช่องid: nil·created_at: nil— ยังไม่มีเลขที่แฟ้ม ยังไม่มีเวลาเก็บnew_record?— “เป็นแฟ้มใหม่ที่ยังไม่เคยเข้าลิ้นชักใช่ไหม” →true· ชื่อลงท้ายด้วย?= ถามตอบใช่/ไม่ใช่ (บท 93)persisted?— “อยู่ในลิ้นชักแล้วใช่ไหม” →false· เป็นคำถามกลับด้านของnew_record?- ไม่มีบรรทัด SQL สักบรรทัด — ยังไม่มีใครเดินไปตู้
wongnok-blog(dev):007> Post.count
ก่อนหน้านี้ Post.count ได้ 0 · หลังจาก Post.new(...) ไปหนึ่งชิ้น ตอนนี้จะได้เท่าไหร่?
ผลจริง — ยังเป็น 0:
Post Count (1.6ms) SELECT COUNT(*) FROM "posts" /*application='WongnokBlog'*/
=> 0
count ไปนับที่ลิ้นชักจริง · แฟ้มที่ new ไว้ยังอยู่ในมือเรา (ในหน่วยความจำของ console) ไม่ได้อยู่ในลิ้นชัก ·
ถ้าปิด console ตอนนี้ แฟ้มนี้หายไปเลย ไม่มีใครรู้ว่าเคยมี
จังหวะที่สอง — save คือ “เดินเอาแฟ้มเข้าลิ้นชัก” (ผลจริง):
wongnok-blog(dev):008> post.save
TRANSACTION (1.4ms) BEGIN /*application='WongnokBlog'*/
Post Create (3.1ms) INSERT INTO "posts" ("title", "body", "created_at", "updated_at") VALUES ('ต้มยำกุ้งน้ำข้น', 'สูตรต้มยำกุ้งแบบร้านดัง', '2026-11-04 12:17:23.004987', '2026-11-04 12:17:23.004987') RETURNING "id" /*application='WongnokBlog'*/
TRANSACTION (1.7ms) COMMIT /*application='WongnokBlog'*/
=> true
wongnok-blog(dev):009> post.id
=> 1
wongnok-blog(dev):010> post.new_record?
=> false
wongnok-blog(dev):011> post.persisted?
=> true
สมุดบันทึกมีสามบรรทัด · แปลทีละบรรทัด:
BEGIN— “เริ่มกรอบงาน” · ทุกอย่างหลังจากนี้ ถ้าสำเร็จหมดค่อยนับ ถ้ามีอะไรพังกลางทางให้ยกเลิกทั้งหมดINSERT INTO "posts" ("title", "body", "created_at", "updated_at") VALUES ('ต้มยำกุ้งน้ำข้น', …)— “ใส่แฟ้มใหม่ลงลิ้นชัก posts ช่อง title เขียนว่า ต้มยำกุ้งน้ำข้น · ช่อง body เขียนว่า … · ช่องเวลาสร้างกับเวลาแก้เขียนเวลานี้”INSERT INTO= ใส่เข้าไปใน · วงเล็บแรก = ชื่อช่อง ·VALUES (…)= ค่าของแต่ละช่อง เรียงตามลำดับเดียวกัน
สังเกตเครื่องหมายคำพูด: ใน SQL ชื่อลิ้นชักกับชื่อช่องอยู่ใน"…"ส่วนค่าที่เป็นข้อความอยู่ใน'…'(ต่างจาก Ruby ที่ใช้ได้ทั้งคู่)- เวลาสองช่องนั้น Rails เติมให้เอง · เป็นเวลาสากล (UTC) ช้ากว่าเวลาไทย 7 ชั่วโมง — ตั้งเปลี่ยนได้ ยังไม่ต้องสนใจ
RETURNING "id"— “เก็บเสร็จแล้วบอกเลขที่แฟ้มกลับมาด้วย” · ตู้เป็นคนแจกเลข ไม่ใช่เราCOMMIT— “ยืนยัน ปิดกรอบงาน” · ตั้งแต่ตอนนี้แฟ้มอยู่ในลิ้นชักจริง คนอื่นเห็นแล้ว=> true—saveตอบว่าสำเร็จ · จากนั้นidกลายเป็น1และคำถามสองข้อกลับด้านหมด
กรอบ BEGIN … COMMIT ชื่อทางการคือ transaction = “ทำทั้งหมด หรือไม่ทำเลย” ·
เทียบกับ Go: คือ tx, _ := db.Begin() … tx.Commit() ในบท 8 (คู่กับ defer tx.Rollback()) — Rails ครอบให้ทุกครั้งที่ save โดยไม่ต้องเขียนเอง ·
ทำไมต้องครอบแม้มีคำสั่งเดียว และใช้เองยังไง อยู่บท 111
Post.new = หยิบแฟ้มเปล่ามากรอกข้อความ แล้วถือไว้ในมือ · ยังไม่มีเลขที่แฟ้ม ลิ้นชักไม่รู้ว่ามีแฟ้มนี้ ·
save = แฟ้มเดินเข้าลิ้นชักเอง (บท 106) ตู้ประทับเลขที่ให้ แล้วแฟ้มก็จำเลขนั้นไว้
new_record? คือถามว่า “ยังถืออยู่ในมือใช่ไหม” · persisted? คือถามว่า “เข้าลิ้นชักแล้วใช่ไหม”
ที่ไม่เหมือนกัน: แฟ้มกระดาษในมือ วางลืมไว้ก็ยังอยู่บนโต๊ะ ·
แฟ้มที่ new แล้วไม่ได้ save อยู่แค่ในหน่วยความจำ — ปิด console หรือ request จบ ก็หายไปทันทีโดยไม่มีอะไรเตือน
ถ้าไม่ต้องการถือแฟ้มไว้ก่อน มีคำสั่งที่ทำสองจังหวะในคำเดียว — Post.create = new แล้ว save ทันที (ผลจริง):
wongnok-blog(dev):012> Post.create(title: "ส้มตำปูปลาร้า", body: "ตำให้นัวต้องใส่อะไร")
TRANSACTION (2.4ms) BEGIN /*application='WongnokBlog'*/
Post Create (4.8ms) INSERT INTO "posts" ("title", "body", "created_at", "updated_at") VALUES ('ส้มตำปูปลาร้า', 'ตำให้นัวต้องใส่อะไร', '2026-11-04 12:17:24.245476', '2026-11-04 12:17:24.245476') RETURNING "id" /*application='WongnokBlog'*/
TRANSACTION (2.4ms) COMMIT /*application='WongnokBlog'*/
=>
#<Post:0x0000000124efb7d8
id: 2,
title: "ส้มตำปูปลาร้า",
body: "ตำให้นัวต้องใส่อะไร",
created_at: "2026-11-04 12:17:24.245476000 +0000",
updated_at: "2026-11-04 12:17:24.245476000 +0000">
wongnok-blog(dev):013> Post.count
Post Count (2.1ms) SELECT COUNT(*) FROM "posts" /*application='WongnokBlog'*/
=> 2
- SQL หน้าตาเหมือน
saveทุกอย่าง — เพราะข้างในมันคือsaveนั่นเอง - ต่างกันที่สิ่งที่ตอบกลับ:
saveตอบtrue/false·createตอบตัวบทความกลับมา (ได้id: 2แล้ว) +0000ท้ายเวลา = ห่างจากเวลาสากล 0 ชั่วโมง (เวลาไทยคือ+0700)
⚠️ บทนี้ใช้ bin/rails console ธรรมดา ไม่ใช่ --sandbox เพราะอยากให้ข้อมูลลงตู้จริง ·
ถ้าเปิดแบบ --sandbox (บท 104) คุณจะเห็นคำว่า SAVEPOINT กับ RELEASE SAVEPOINT แทน COMMIT (ผมลองแล้ว) —
ไม่ได้พัง แค่หน้าตาไม่ตรงกับในบท เพราะ sandbox ครอบกรอบงานใหญ่ไว้อีกชั้นเพื่อยกเลิกทุกอย่างตอนออก
INSERT·RETURNING- คำ SQL สำหรับ “ใส่แถวใหม่” ·
RETURNING "id"= ขอเลขที่ได้กลับมา (ของ PostgreSQL) · ถาม senior ได้ว่า “save หนึ่งครั้งยิง INSERT กี่ครั้ง ดูจาก log ตรงไหน” - transaction (
BEGIN/COMMIT) - กรอบงาน “ทำทั้งหมดหรือไม่ทำเลย” ·
BEGIN= เริ่ม ·COMMIT= ยืนยัน ·ROLLBACK= ยกเลิกทั้งกรอบ · พิมพ์บอก AI ได้ว่า “wrap these two saves in a transaction” (เรื่องเต็มบท 111) new_record?·persisted?- ถามสถานะของแฟ้ม: ยังอยู่ในมือ / อยู่ในลิ้นชักแล้ว · ใช้ตอนงงว่า “ตกลง save ไปหรือยัง” · ถาม senior ได้ว่า “ตรงนี้ object persisted แล้วหรือยัง” — คนทำ Rails ใช้คำนี้คุยกันจริง
R — อ่าน: find · find_by · where · first · last · all
ตอนนี้ลิ้นชักมีสองแผ่น · มีหลายวิธีขอแฟ้มจากคนดูแลสต็อก ต่างกันที่บอกเงื่อนไขยังไง และได้กลับมากี่แผ่น
เริ่มจากขอตามเลขที่แฟ้ม (ผลจริง):
wongnok-blog(dev):014> Post.find(1)
Post Load (2.2ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 1 LIMIT 1 /*application='WongnokBlog'*/
=>
#<Post:0x0000000124c90a20
id: 1,
title: "ต้มยำกุ้งน้ำข้น",
body: "สูตรต้มยำกุ้งแบบร้านดัง",
created_at: "2026-11-04 12:17:23.004987000 +0000",
updated_at: "2026-11-04 12:17:23.004987000 +0000">
SQL บรรทัดนี้คือแม่แบบของการอ่านเกือบทั้งหมด · อ่านทีละท่อน:
SELECT "posts".*— “ขอทุกช่องของแฟ้มในลิ้นชัก posts” ·*= ทุกช่อง ·"posts".ข้างหน้าแค่บอกว่าช่องของลิ้นชักไหนFROM "posts"— “จากลิ้นชัก posts”WHERE "posts"."id" = 1— “เฉพาะแผ่นที่ช่อง id เป็น 1” ·"posts"."id"อ่านว่า “ช่อง id ของลิ้นชัก posts” (จุดคั่นเหมือนpost.idใน Ruby)LIMIT 1— “เอามาแผ่นเดียวพอ”
→ รวมกัน: “ขอแฟ้มทุกช่องจากลิ้นชัก posts เฉพาะแผ่นเลขที่ 1 เอามาแผ่นเดียว”
ขอตามช่องอื่น และขอแบบรายการ (ผลจริง):
wongnok-blog(dev):015> Post.find_by(title: "ส้มตำปูปลาร้า")
Post Load (2.4ms) SELECT "posts".* FROM "posts" WHERE "posts"."title" = 'ส้มตำปูปลาร้า' LIMIT 1 /*application='WongnokBlog'*/
=>
#<Post:0x0000000124808fc0
id: 2,
title: "ส้มตำปูปลาร้า",
…
wongnok-blog(dev):016> Post.where(title: "ส้มตำปูปลาร้า")
Post Load (2.2ms) SELECT "posts".* FROM "posts" WHERE "posts"."title" = 'ส้มตำปูปลาร้า' /* loading for pp */ LIMIT 11 /*application='WongnokBlog'*/
=>
[#<Post:0x000000012454f3d8
id: 2,
title: "ส้มตำปูปลาร้า",
…
find_by(title: …)— “หาแผ่นแรกที่ช่อง title ตรงกับคำนี้” · SQL เหมือนfindแค่เปลี่ยนช่องในเงื่อนไข · ได้กลับมาหนึ่งแผ่นwhere(title: …)— “หาทุกแผ่นที่ตรงเงื่อนไข” · ได้กลับมาเป็นรายการ สังเกตวงเล็บ[ … ]ครอบ แม้มีแผ่นเดียว/* loading for pp */ LIMIT 11— console แอบเติมเองเพื่อโชว์บนจอ (ppคือคำสั่งพิมพ์ผลแบบจัดบรรทัดสวย ๆ ที่ console ใช้ · ขอมาแค่พอโชว์) ไม่ใช่คำสั่งของคุณ · ทำไมwhereถึงรอให้ถูกโชว์ก่อนค่อยไปตู้ เป็นเรื่องสนุกของบท 113- ผมตัดบรรทัดท้ายของผลเป็น
…เพราะซ้ำกับข้างบน
อีกสามคำที่ใช้บ่อย (ผลจริง ตัดเหลือบรรทัดที่สำคัญ):
wongnok-blog(dev):017> Post.first
Post Load (3.2ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."id" ASC LIMIT 1 /*application='WongnokBlog'*/
=>
#<Post:0x00000001051b52a0
id: 1,
…
wongnok-blog(dev):018> Post.last
Post Load (2.6ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."id" DESC LIMIT 1 /*application='WongnokBlog'*/
=>
#<Post:0x0000000127caed88
id: 2,
…
wongnok-blog(dev):019> Post.all
Post Load (2.2ms) SELECT "posts".* FROM "posts" /* loading for pp */ LIMIT 11 /*application='WongnokBlog'*/
=>
[#<Post:0x0000000127c0b408
id: 1,
…
#<Post:0x0000000127c0b2c8
id: 2,
…
first—ORDER BY "posts"."id" ASC= “เรียงตามเลขที่ จากน้อยไปมาก” แล้วLIMIT 1เอาแผ่นแรก → ได้แผ่นเลขน้อยสุดlast— เหมือนกันแต่DESC= เรียงจากมากไปน้อย → ได้แผ่นเลขมากสุดall— ไม่มีWHERE= ไม่มีเงื่อนไข เอาทุกแผ่น · ได้รายการ
ถ้าหาไม่เจอ — สามคำตอบที่ต่างกัน
ลองขอของที่ไม่มีในลิ้นชัก ด้วยสามวิธี (ผลจริง):
wongnok-blog(dev):020> Post.find(99)
Post Load (2.7ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 99 LIMIT 1 /*application='WongnokBlog'*/
(wongnok-blog):20:in '<main>': Couldn't find Post with 'id'=99 (ActiveRecord::RecordNotFound)
wongnok-blog(dev):021> Post.find_by(title: "ไม่มีเรื่องนี้")
Post Load (2.5ms) SELECT "posts".* FROM "posts" WHERE "posts"."title" = 'ไม่มีเรื่องนี้' LIMIT 1 /*application='WongnokBlog'*/
=> nil
wongnok-blog(dev):022> Post.where(title: "ไม่มีเรื่องนี้")
Post Load (1.8ms) SELECT "posts".* FROM "posts" WHERE "posts"."title" = 'ไม่มีเรื่องนี้' /* loading for pp */ LIMIT 11 /*application='WongnokBlog'*/
=> []
find(99)— ระเบิดเป็น error ชื่อActiveRecord::RecordNotFound· ข้อความแปลว่า “หาบทความเลขที่ 99 ไม่เจอ” ·(wongnok-blog):20= บรรทัดที่ 20 ของ console (ตรงกับเลขใน prompt)find_by(…)— ไม่ระเบิด ตอบnil= “ไม่มี” (บท 94)where(…)— ไม่ระเบิด ตอบ[]= รายการเปล่า- ทั้งสามวิธีไปตู้เหมือนกันทุกอย่าง (SQL หน้าตาเดียวกัน) · ต่างกันแค่ตอนกลับมาบอกเรา
find(99) = “เอาแฟ้มเลขที่ 99 มาให้ที” · คนดูแลสต็อกถือว่าคุณมั่นใจว่ามี — ไม่มีก็โวยวายเสียงดัง ให้รู้ว่ามีอะไรผิดปกติ
find_by(…) = “มีแฟ้มชื่อนี้ไหม ถ้ามีเอาแผ่นแรกมา” · ไม่มีก็ยักไหล่ตอบ “ไม่มีครับ” (nil)
where(…) = “เอาทุกแผ่นที่ตรงเงื่อนไขใส่ตะกร้ามา” · ไม่มีก็ยื่นตะกร้าเปล่ามาให้ ([])
ที่ไม่เหมือนกัน: คนจริงตอบ “ไม่มีครับ” แล้วเราก็รู้ทันที ·
แต่ nil ไม่ร้องเตือน ถ้าเราไม่เช็คแล้วเอาไปใช้ต่อ เช่น .title จะพังทีหลังด้วย NoMethodError ในบรรทัดที่ห่างจากต้นเหตุ (เจอจริงในภารกิจท้ายบท)
เทียบกับ Go: บท 27 เจอเรื่องเดียวกันใน GORM — db.First(&missing, 9999) คืน gorm.ErrRecordNotFound (ตรงกับ find) ·
ส่วน db.Where(…).Find(&list) ไม่ถือว่าเป็น error แค่ได้ slice ว่าง (ตรงกับ where) · ต่างกันที่ Go คืน error มาให้เช็ค ส่วน Ruby โยน error ออกมา (rescue บท 96)
SELECT·WHERE·ORDER BY·LIMIT- สี่คำที่จะเจอในทุก query อ่าน: ขอช่องไหน · เฉพาะแผ่นที่… · เรียงตาม… · เอามากี่แผ่น · ถาม senior ได้ว่า “query นี้มี WHERE ด้วยช่องไหน ช่องนั้นมี index ไหม” (index คือสารบัญของลิ้นชัก บท 109)
ActiveRecord::RecordNotFound- error ของ
findเมื่อหาไม่เจอ · ใน controller Rails แปลงเป็นหน้า 404 ให้ในเว็บจริง · พิมพ์บอก AI ได้ว่า “use find_by and handle nil instead of find here” ถ้าการไม่เจอเป็นเรื่องปกติของหน้านั้น
หน้าค้นหาบทความ: ผู้ใช้พิมพ์ชื่อเรื่องมา ซึ่งอาจไม่มีอยู่จริง · ถ้าไม่เจอ อยากโชว์ข้อความ “ไม่พบบทความ” แทนที่หน้าเว็บจะพัง ควรใช้คำสั่งไหน?
ตัวเลือกที่ 2 ถูก — find_by ค้นตามช่องไหนก็ได้ ไม่เจอก็ตอบ nil เงียบ ๆ (ผลจริง: Post.find_by(title: "ไม่มีเรื่องนี้") → => nil) · เราเช็ค nil แล้วโชว์ข้อความเองได้
ตัวเลือกที่ 1 ผิด เพราะ find รับเลขที่แฟ้ม ไม่ใช่ชื่อเรื่อง · ไม่เจอมันระเบิดเป็น RecordNotFound ไม่เคยคืน nil (ผมลองส่งชื่อเรื่องเข้าไป ได้ RecordNotFound จริง) ·
ตัวเลือกที่ 3 ผิด เพราะ first รับได้แค่ “จำนวนแผ่น” เช่น first(3) ไม่รับเงื่อนไข · ลองแล้วได้ TypeError ·
ตัวเลือกที่ 4 ผิด เพราะ count คิดว่าคำที่ส่งไปเป็นชื่อช่อง · ลองแล้วพังเป็น PG::UndefinedColumn และต่อให้ไม่พังก็ได้แค่ตัวเลข ไม่ได้ตัวบทความไปโชว์
จุดที่ต้องจำ: มั่นใจว่ามีแน่ (เลขที่จากลิงก์) → find · อาจไม่มีก็ได้ → find_by แล้วเช็ค nil · อยากได้หลายแผ่น → where
U กับ D — แก้ด้วย update · ทิ้งด้วย destroy
จะแก้หรือทิ้ง ต้องหยิบแฟ้มแผ่นนั้นออกมาก่อน แล้วสั่งที่ตัวแฟ้ม (ผลจริง):
wongnok-blog(dev):023> post = Post.find(1)
Post Load (2.7ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 1 LIMIT 1 /*application='WongnokBlog'*/
=>
#<Post:0x0000000127c05008
...
wongnok-blog(dev):024> post.update(title: "ต้มยำกุ้งน้ำใส")
TRANSACTION (2.0ms) BEGIN /*application='WongnokBlog'*/
Post Update (3.9ms) UPDATE "posts" SET "title" = 'ต้มยำกุ้งน้ำใส', "updated_at" = '2026-11-04 12:17:28.032069' WHERE "posts"."id" = 1 /*application='WongnokBlog'*/
TRANSACTION (3.6ms) COMMIT /*application='WongnokBlog'*/
=> true
wongnok-blog(dev):025> post.title
=> "ต้มยำกุ้งน้ำใส"
post.update(title: …)— แก้ช่องที่บอก แล้ว save ให้ทันทีในคำเดียว · ตอบtrue= สำเร็จUPDATE "posts" SET "title" = 'ต้มยำกุ้งน้ำใส', "updated_at" = '…' WHERE "posts"."id" = 1— “แก้แฟ้มในลิ้นชัก posts: ช่อง title เปลี่ยนเป็น ต้มยำกุ้งน้ำใส · ช่องเวลาแก้ล่าสุดเปลี่ยนเป็นเวลานี้ · เฉพาะแผ่นเลขที่ 1”SET= เปลี่ยนช่องนี้เป็นค่านี้- สังเกต: ไม่มีช่อง body ใน SQL — Rails ส่งเฉพาะช่องที่เปลี่ยนจริง · และเติม
updated_atให้เอง WHERE "posts"."id" = 1คือสิ่งที่ทำให้แก้แค่แผ่นเดียว · Rails ใส่ให้เสมอเพราะแฟ้มจำเลขที่ของตัวเองได้
ทิ้งแฟ้มแผ่นเดิม (ผลจริง):
wongnok-blog(dev):026> post.destroy
TRANSACTION (1.4ms) BEGIN /*application='WongnokBlog'*/
Post Destroy (2.6ms) DELETE FROM "posts" WHERE "posts"."id" = 1 /*application='WongnokBlog'*/
TRANSACTION (1.4ms) COMMIT /*application='WongnokBlog'*/
=>
#<Post:0x0000000127c05008
id: 1,
title: "ต้มยำกุ้งน้ำใส",
…
wongnok-blog(dev):027> Post.count
Post Count (1.5ms) SELECT COUNT(*) FROM "posts" /*application='WongnokBlog'*/
=> 1
DELETE FROM "posts" WHERE "posts"."id" = 1— “ทิ้งแฟ้มเลขที่ 1 ออกจากลิ้นชัก posts” ·DELETE FROM= เอาออกจากdestroyตอบตัวบทความที่เพิ่งทิ้งกลับมา ·Post.countเหลือ1
wongnok-blog(dev):028> post.persisted?
wongnok-blog(dev):029> post.title
wongnok-blog(dev):030> Post.find(1)
แฟ้มเลขที่ 1 เพิ่งถูกทิ้งไป · ตัวแปร post ยังอยู่ใน console · สามบรรทัดนี้จะได้อะไร?
ผลจริง:
wongnok-blog(dev):028> post.persisted?
=> false
wongnok-blog(dev):029> post.title
=> "ต้มยำกุ้งน้ำใส"
wongnok-blog(dev):030> Post.find(1)
Post Load (2.5ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 1 LIMIT 1 /*application='WongnokBlog'*/
(wongnok-blog):30:in '<main>': Couldn't find Post with 'id'=1 (ActiveRecord::RecordNotFound)
ตัวแปร post เหมือนสำเนาของแฟ้มที่ถือค้างไว้ในมือ · ทิ้งแฟ้มตัวจริงในลิ้นชักไปแล้ว สำเนาในมือก็ยังอ่านได้ (title ยังอยู่) ·
แต่มันรู้ตัวว่าไม่ได้อยู่ในลิ้นชักแล้ว (persisted? เป็น false) ·
ถ้าไปขอจากลิ้นชักใหม่ด้วย find(1) จึงไม่เจอ
เทียบกับ Go: GORM ใช้ db.Model(&p).Update("Title", "ต้มยำกุ้งน้ำใส") และ db.Delete(&p) (บท 27 เคยเห็น Update/Delete แบบนี้) —
ต้องเรียกผ่าน db แล้วส่ง pointer ของ struct (&p) เข้าไปทุกครั้ง · Rails สั่งที่ตัวแฟ้มตรง ๆ post.update(…) · post.destroy เพราะแฟ้มรู้ทางไปลิ้นชักเอง (บท 106)
UPDATE … SET … WHERE·DELETE FROM … WHERE- คำ SQL สำหรับแก้และลบ ·
WHEREตัวท้ายคือตัวกำหนดว่าโดนกี่แผ่น — ถ้าไม่มีWHERE= โดนทั้งลิ้นชัก · ถาม senior ได้ว่า “ก่อนรัน UPDATE/DELETE เองใน production ทีมนี้ตรวจกันยังไง” - dirty tracking (Rails จำว่าช่องไหนเปลี่ยน)
- เหตุผลที่
UPDATEมีแค่ช่องที่เปลี่ยน · Rails จำค่าเดิมของแต่ละช่องไว้ · สไลด์หน้า 8 มีpost.changed?ไว้ถามว่ามีช่องไหนเปลี่ยนแต่ยังไม่ save · พิมพ์บอก AI ได้ว่า “only update the title column, don't touch body” — แล้วตรวจจาก SQL log ว่าจริงไหม
เทียบกับ GORM ทั้งชุด
สไลด์หน้า 10 ให้จับคู่ GORM กับ Active Record · รวมของทั้งบทไว้ตารางเดียว (ฝั่ง Go มาจากบท 27):
| อยากทำ | Go · GORM | Ruby · Active Record |
|---|---|---|
| สร้างแล้วเก็บ | db.Create(&p) | post.save · Post.create(…) |
| หยิบตามเลขที่ | db.First(&p, 1) | Post.find(1) |
| หยิบตามเงื่อนไข | db.Where("title = ?", x).Find(&list) | Post.where(title: x) |
| หยิบทั้งหมด | db.Find(&list) | Post.all |
| แก้ | db.Model(&p).Update("Title", x) | post.update(title: x) |
| ลบ | db.Delete(&p) | post.destroy |
| หาตามเลขที่ไม่เจอ | First คืน gorm.ErrRecordNotFound | find โยน ActiveRecord::RecordNotFound |
| ชื่อตาราง | struct Post → ตาราง posts เอง · ชื่อไม่ตามกติกาค่อยเขียน method TableName() | class Post → ลิ้นชัก posts เอง · ชื่อไม่ตามกติกาค่อยเขียน self.table_name = "…" |
| ช่องในตาราง | เขียนเองใน struct (หรือ embed gorm.Model ได้ ID + เวลา) | ไม่ต้องเขียนใน class · Rails อ่านจากตารางจริงตอนรัน |
&p·&listฝั่ง Go = ส่ง pointer ของ struct/slice ให้ GORM เติมค่าลงไป · ฝั่ง Ruby ไม่มี เพราะfindคืนแฟ้มกลับมาให้เลย"title = ?"ฝั่ง Go —?คือช่องว่างที่ GORM เติมค่าxให้ · ฝั่ง Ruby เขียนเป็น hashtitle: xแทน (SQL ที่ได้หน้าตาเดียวกัน)
ใน GORM ถ้าอยากใช้ชื่อตารางแปลก ๆ ต้องเขียน method TableName() · ใน Rails ถ้า class ชื่อ Post และตารางชื่อ posts (ตามกติกา) ต้องเขียนอะไรเพิ่มใน post.rb?
ตัวเลือกที่ 4 ถูก — ผลจริง: post.rb มีแค่ class Post < ApplicationRecord กับ end แต่ Post.table_name ได้ => "posts" · นี่คือ Convention over Configuration (ร้านแฟรนไชส์ ทุกสาขาวางของที่เดิม)
ตัวเลือกที่ 1 ผิด เพราะเขียนได้แต่ไม่จำเป็น — ใช้เฉพาะตอนชื่อตารางไม่ตามกติกา เช่นต่อกับฐานข้อมูลเก่าที่ตั้งชื่อไว้แล้ว ·
ตัวเลือกที่ 2 ผิด เพราะชื่อตารางเป็นเรื่องของทั้ง class ไม่ใช่ของแฟ้มแต่ละแผ่น · method แบบนี้จึงไม่ได้ช่วยให้ Post.table_name เปลี่ยน ·
ตัวเลือกที่ 3 ผิด เพราะบรรทัดนั้นบอกแค่ว่าสืบทอดจากใคร Ruby ไม่ได้มีที่ให้ใส่ชื่อตาราง
จุดที่ต้องจำ: ตั้งชื่อตามกติกา = ไม่ต้องเขียนอะไร · self.table_name มีไว้สำหรับกรณีพิเศษเท่านั้น (ทั้งสองภาษาคิดแบบเดียวกัน)
เพื่อนแปะ log มาให้ดูหนึ่งบรรทัด (ตารางของเพื่อนมีช่อง views = ยอดวิว ซึ่งเราจะเพิ่มกันในหัวข้อถัดไป):
UPDATE "posts" SET "views" = 121, "updated_at" = '…' WHERE "posts"."id" = 1
— คำสั่ง Ruby ที่ทำให้เกิดบรรทัดนี้น่าจะเป็นอันไหน?
ตัวเลือกที่ 3 ถูก — UPDATE … SET "views" = 121 … WHERE "posts"."id" = 1 แปลว่า “แก้แฟ้มแผ่นเลขที่ 1 ให้ช่อง views เป็น 121” · ตรงกับ update บนแฟ้มเลขที่ 1
ตัวเลือกที่ 1 ผิด เพราะสร้างใหม่จะเห็น INSERT INTO ไม่ใช่ UPDATE ·
ตัวเลือกที่ 2 ผิด เพราะการหาจะเห็น SELECT … WHERE "posts"."views" = 121 · ไม่มีอะไรถูกแก้ ·
ตัวเลือกที่ 4 ผิด เพราะการทิ้งจะเห็น DELETE FROM และเลข 121 จะอยู่หลัง "id" =
จุดที่ต้องจำ: อ่านคำแรกของ SQL ก่อน — INSERT สร้าง · SELECT อ่าน · UPDATE แก้ · DELETE ลบ · แล้วดู WHERE ว่าโดนแผ่นไหน
เตรียมตู้ของวันที่ 2 — ข้อมูลตัวอย่างที่ทุกบทหลังจากนี้ใช้
ตั้งแต่บท 108 ถึง 119 ทุกบทใช้ข้อมูลชุดเดียวกัน: ผู้ใช้ 3 คน (สมชาย · สมหญิง · มานะ) · บทความ 6 ชิ้น · คอมเมนต์ 7 อัน ·
ถ้าคุณมีชุดเดียวกันในเครื่อง ผลที่พิมพ์ตามบทจะตรงกับในบท
ตอนนี้ทำตามไปก่อน · ทุกคำสั่งในหัวข้อนี้มีคำอธิบายเต็มในบท 108 (migration) และบท 115 (ความสัมพันธ์ระหว่างตาราง)
⚠️ ขั้นที่ 6 ล้างข้อมูลทั้งหมดในฐานข้อมูลของแอปนี้ (ในเครื่องคุณ) แล้วสร้างใหม่ ·
ถ้าในแอปนี้มีข้อมูลที่อยากเก็บ ให้ทำในแอปใหม่ที่สร้างด้วย rails new แทน
ขั้นที่ 1 · ปิด console ก่อน — พิมพ์ exit · ถ้าเปิดค้างไว้ ขั้นที่ 6 จะพัง (ดู error ในภารกิจ)
ขั้นที่ 2 · สั่งสร้างใบสั่งช่างอีก 4 ใบ พิมพ์ทีละบรรทัดในโฟลเดอร์โปรเจกต์ (ผลจริง · เลขเวลาหน้าชื่อไฟล์ของคุณจะต่าง):
$ bin/rails generate migration AddPublishedAndViewsToPosts published:boolean views:integer
invoke active_record
create db/migrate/20261104114643_add_published_and_views_to_posts.rb
$ bin/rails generate model User name:string email:string
invoke active_record
create db/migrate/20261104114645_create_users.rb
create app/models/user.rb
invoke test_unit
create test/models/user_test.rb
create test/fixtures/users.yml
$ bin/rails generate migration AddUserToPosts user:references
invoke active_record
create db/migrate/20261104114646_add_user_to_posts.rb
$ bin/rails generate model Comment body:text post:references user:references
invoke active_record
create db/migrate/20261104114648_create_comments.rb
create app/models/comment.rb
invoke test_unit
create test/models/comment_test.rb
create test/fixtures/comments.yml
- ใบที่ 1 — เพิ่มสองช่องให้บทความ:
published(เผยแพร่แล้วหรือยัง ·boolean= จริง/เท็จ) กับviews(ยอดวิว ·integer= จำนวนเต็ม) - ใบที่ 2 — model ใหม่
User(ผู้เขียน) พร้อมลิ้นชักusersช่องชื่อกับอีเมล - ใบที่ 3 — เพิ่มช่อง
user_idบนแฟ้มบทความ = “บทความนี้เป็นของผู้ใช้เลขที่ …” ·referencesคือวิธีบอก Rails ว่า “ช่องนี้ชี้ไปหาแฟ้มในลิ้นชักอื่น”
ในไฟล์ที่ได้ generator เขียนnull: false, foreign_key: trueให้เองด้วย = “ห้ามว่าง และเลขที่ใส่ต้องมีผู้ใช้คนนั้นอยู่จริง” (เรื่องเต็มบท 115–116) - ใบที่ 4 — model ใหม่
Comment(คอมเมนต์) ที่มีช่องชี้ไปหาทั้งบทความและผู้ใช้
ขั้นที่ 3 · แก้ใบสั่งใบแรกให้มีค่าเริ่มต้น — เปิดไฟล์ db/migrate/…_add_published_and_views_to_posts.rb แล้วเติมท้ายสองบรรทัดให้เป็นแบบนี้:
# db/migrate/…_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 < ActiveRecord::Migration[8.1]— หัวใบสั่งช่าง: ชื่อใบ + “เขียนตามแบบฟอร์มของ Rails 8.1” · generator เขียนให้ ไม่ต้องแตะdef change…end— “งานที่ช่างต้องทำ” อยู่ข้างใน (บท 108 อธิบายเต็ม)add_column :posts, :published, :boolean— “เพิ่มช่อง published ชนิดจริง/เท็จ ให้ลิ้นชัก posts” (ส่วนนี้ generator เขียนให้แล้ว)default: false— ส่วนที่เราเติม: ถ้าไม่บอก ให้ถือว่ายังไม่เผยแพร่ ·default: 0= ยอดวิวเริ่มที่ 0null: false— ห้ามเว้นช่องนี้ว่าง (nullคือ “ว่าง” ของฐานข้อมูล คล้ายnil) · ทำไมต้องมีกฎที่ตู้ อยู่บท 111
ขั้นที่ 4 · บอก model ว่าใครเป็นของใคร — comment.rb generator เขียนให้ครบแล้ว · อีกสองไฟล์แก้ให้เป็นแบบนี้:
# app/models/post.rb
class Post < ApplicationRecord
belongs_to :user
has_many :comments
end
# app/models/user.rb
class User < ApplicationRecord
has_many :posts
has_many :comments
end
# app/models/comment.rb — generator เขียนให้แล้ว แค่เปิดดู
class Comment < ApplicationRecord
belongs_to :post
belongs_to :user
end
belongs_to :user— “บทความเป็นของผู้ใช้หนึ่งคน” · ทำให้ถามpost.userได้ และห้ามมีบทความที่ไม่มีเจ้าของ (เดี๋ยวเห็นผล)has_many :comments— “บทความหนึ่งชิ้นมีได้หลายคอมเมนต์” · ทำให้ถามpost.commentsได้- ทั้งหมดนี้เรื่องเต็มอยู่บท 115 · ตอนนี้แค่รู้ว่ามันเชื่อมแฟ้มต่างลิ้นชักเข้าหากัน
ขั้นที่ 5 · ใส่ข้อมูลตัวอย่าง — เปิดไฟล์ db/seeds.rb (มีอยู่แล้วตั้งแต่ rails new) ลบของเดิมทิ้งทั้งไฟล์ แล้ววางอันนี้แทน:
# ข้อมูลตัวอย่างของ wongnok_blog (Day 2)
somchai = User.create!(name: "สมชาย", email: "somchai@example.com")
somying = User.create!(name: "สมหญิง", email: "somying@example.com")
mana = User.create!(name: "มานะ", email: "mana@example.com")
p1 = somchai.posts.create!(title: "ต้มยำกุ้งน้ำข้น", body: "สูตรต้มยำกุ้งแบบร้านดัง", published: true, views: 120)
p2 = somchai.posts.create!(title: "ส้มตำปูปลาร้า", body: "ตำให้นัวต้องใส่อะไร", published: true, views: 340)
p3 = somying.posts.create!(title: "ข้าวมันไก่ประตูน้ำ", body: "ร้านเด็ดย่านประตูน้ำ", published: true, views: 85)
p4 = somying.posts.create!(title: "ก๋วยเตี๋ยวเรืออยุธยา", body: "ยังเขียนไม่เสร็จ", published: false, views: 0)
p5 = mana.posts.create!(title: "ผัดกะเพราหมูสับ", body: "ไข่ดาวต้องกรอบ", published: true, views: 510)
p6 = mana.posts.create!(title: "แกงเขียวหวานไก่", body: "ร่างไว้ก่อน", published: false, views: 0)
p1.comments.create!(user: somying, body: "น่ากินมาก")
p1.comments.create!(user: mana, body: "ขอสูตรน้ำพริกเผาด้วย")
p2.comments.create!(user: mana, body: "เผ็ดแค่ไหนดี")
p3.comments.create!(user: somchai, body: "ร้านนี้คิวยาวไหม")
p5.comments.create!(user: somchai, body: "ไข่ดาวกรอบคือที่สุด")
p5.comments.create!(user: somying, body: "ใส่ถั่วฝักยาวด้วยไหม")
p5.comments.create!(user: mana, body: "ขอบคุณทุกคนที่อ่าน")
User.create!(…)—createที่เพิ่งเรียน แต่มี!ต่อท้าย = “ถ้าเก็บไม่ได้ ให้ระเบิดทันที” แทนที่จะเงียบ (เรื่องเต็มบท 110) · ไฟล์ข้อมูลตัวอย่างควรพังให้เห็น ไม่ใช่ใส่ไม่ครบแบบเงียบ ๆsomchai.posts.create!(…)— สร้างบทความในชื่อของสมชาย · Rails เติมuser_idให้เอง (ได้มาจากhas_many :posts· บท 115)p1.comments.create!(user: somying, …)— คอมเมนต์บนบทความp1เขียนโดยสมหญิง
ขั้นที่ 6 · ให้ช่างสร้างตู้ใหม่ทั้งตู้ แล้วใส่ข้อมูลตัวอย่าง (ผลจริง ตัดกลางเป็น …):
$ bin/rails db:migrate:reset
Dropped database 'a107'
Created database 'a107'
== 20261104123046 CreatePosts: migrating ======================================
-- create_table(:posts)
-> 0.0019s
== 20261104123046 CreatePosts: migrated (0.0019s) =============================
…
== 20261104123156 CreateComments: migrating ===================================
-- create_table(:comments)
-> 0.0039s
== 20261104123156 CreateComments: migrated (0.0039s) ==========================
$ bin/rails db:seed
$ bin/rails runner 'p [User.count, Post.count, Comment.count]'
[3, 6, 7]
db:migrate:reset— “ทิ้งตู้เก่าทั้งตู้ ตั้งตู้ใหม่ แล้วให้ช่างทำใบสั่งทุกใบใหม่ตั้งแต่ใบแรก” · ห้าใบรันเรียงกันตามเลขเวลาหน้าชื่อไฟล์
อ่านผล:Dropped/Created database= ทิ้งตู้ / ตั้งตู้ใหม่ ·== … CreatePosts: migrating= ช่างเริ่มทำใบนี้ ·-- create_table(:posts)= งานย่อยในใบ ·-> 0.0019s= ใช้เวลา (วินาที) ·migrated= ใบนี้เสร็จ
ชื่อในบรรทัดDropped/Createdคือชื่อท้ายDATABASE_URLของคุณ (ของผมคือa107) · ถ้าคุณไม่ได้ตั้งDATABASE_URLแต่ใช้ค่าในไฟล์config/database.ymlจะเห็นสองชื่อwongnok_blog_developmentกับwongnok_blog_test(ตู้สำรองไว้เขียนเทส Day 4)db:seed— รันไฟล์db/seeds.rb· ไม่มีอะไรพิมพ์ออกมา = สำเร็จbin/rails runner '…'— รัน Ruby บรรทัดเดียวโดยโหลดแอปให้ (บท 99) · ได้[3, 6, 7]= ผู้ใช้ 3 · บทความ 6 · คอมเมนต์ 7 ✅- ทำไมไม่ใช้
db:migrateธรรมดา? สองเหตุผล: บทความที่เราสร้างครึ่งแรกยังไม่มีเจ้าของ ใบสั่งที่ 3 ที่ห้ามช่องuser_idว่างจะพัง (error จริงอยู่ในภารกิจ) · และตู้ใหม่ทำให้เลขที่แฟ้มเริ่มจาก 1 ตรงกับทุกบทหลังจากนี้ · คู่มือทางการของ Rails ก็แนะนำคู่db:migrate:reset+db:seedนี้ (ลิงก์ท้ายบท)
ร้านเก็บใบสั่งช่างทุกใบไว้ในแฟ้ม (db/migrate/) ตั้งแต่วันเปิดร้าน ·
db:migrate:reset คือยกตู้เก่าทิ้ง ยกตู้เปล่ามาตั้ง แล้วยื่นแฟ้มใบสั่งทั้งหมดให้ช่างทำตามทีละใบ ได้ตู้หน้าตาเดียวกับตู้ล่าสุดเป๊ะ ·
db:seed คือเอาของตัวอย่างใส่ตู้ ให้มีอะไรหยิบลองเล่น
ที่ไม่เหมือนกัน: ช่างจริงรื้อตู้แล้ว แฟ้มเก่ายังกองอยู่ในกล่องให้ย้ายกลับ ·
db:migrate:reset ทิ้งข้อมูลทั้งหมดจริง ไม่มีกล่องพักของ — ทำได้สบายใจกับแอปฝึกในเครื่อง แต่ห้ามสั่งกับฐานข้อมูลที่มีข้อมูลจริง
ลองของใหม่ที่ได้มา · เปิด bin/rails console ใหม่ แล้วลองสร้างบทความแบบที่ทำได้ในครึ่งแรก (ผลจริง):
wongnok-blog(dev):001> post = Post.new(title: "ข้าวซอยไก่", body: "ของดีเชียงใหม่")
=>
#<Post:0x0000000122946d58
...
wongnok-blog(dev):002> post.save
TRANSACTION (2.0ms) BEGIN /*application='WongnokBlog'*/
TRANSACTION (0.8ms) ROLLBACK /*application='WongnokBlog'*/
=> false
wongnok-blog(dev):003> post.errors.full_messages
=> ["User must exist"]
wongnok-blog(dev):004> post.id
=> nil
post.saveได้=> falseและไม่มีบรรทัดINSERT— คนดูแลสต็อกตรวจแฟ้มแล้วไม่รับ ไม่ได้ใส่อะไรลงลิ้นชักBEGINแล้วROLLBACKทันที = เปิดกรอบงาน แล้วยกเลิกทั้งกรอบ (คำที่สามของ transaction) · ไม่มีอะไรเปลี่ยนในตู้ · ถ้าสั่งpost.saveซ้ำอีกรอบ ผมได้=> falseโดยไม่เห็นแม้แต่สองบรรทัดนี้ — ของคุณอาจเห็นหรือไม่เห็นก็ได้ สิ่งที่ต้องดูคือไม่มี INSERTpost.errors.full_messages— ถามว่า “ไม่รับเพราะอะไร” ·User must exist= “ต้องมีผู้เขียน” · มาจากbelongs_to :userที่เพิ่งเขียน- การตรวจก่อนเก็บแบบนี้ชื่อ validation (บท 110) · ทำไม
belongs_toถึงตรวจให้เอง (บท 115)
บอกว่าใครเป็นคนเขียน แล้ว save ใหม่ (ผลจริง):
wongnok-blog(dev):005> somchai = User.find_by(name: "สมชาย")
User Load (1.6ms) SELECT "users".* FROM "users" WHERE "users"."name" = 'สมชาย' LIMIT 1 /*application='WongnokBlog'*/
=>
#<User:0x0000000122fa5910
...
wongnok-blog(dev):006> post.user = somchai
=>
#<User:0x0000000122fa5910
...
wongnok-blog(dev):007> post.save
TRANSACTION (1.3ms) BEGIN /*application='WongnokBlog'*/
Post Create (3.2ms) INSERT INTO "posts" ("title", "body", "created_at", "updated_at", "published", "views", "user_id") VALUES ('ข้าวซอยไก่', 'ของดีเชียงใหม่', '2026-11-04 12:17:34.499959', '2026-11-04 12:17:34.499959', FALSE, 0, 1) RETURNING "id" /*application='WongnokBlog'*/
TRANSACTION (1.5ms) COMMIT /*application='WongnokBlog'*/
=> true
wongnok-blog(dev):008> post.id
=> 7
wongnok-blog(dev):009> post.user.name
=> "สมชาย"
somchai = User.find_by(name: "สมชาย")— หยิบแฟ้มของสมชายจากลิ้นชักusers(SQL แบบเดียวกับfind_byของบทความ)post.user = somchai— ใส่ผู้เขียนให้บทความ (ได้จากbelongs_to)- ใน
INSERTตอนนี้มีช่องเพิ่ม:"published"=FALSEและ"views"=0(ค่าเริ่มต้นจากขั้นที่ 3) ·"user_id"=1= “เป็นของผู้ใช้เลขที่ 1” คือสมชาย post.idได้7เพราะข้อมูลตัวอย่างใช้เลข 1–6 ไปแล้ว
- seeds ·
db:seed - ข้อมูลตัวอย่างที่เขียนเป็นโค้ดไว้ใน
db/seeds.rbให้ทุกคนในทีมได้ข้อมูลชุดเดียวกันด้วยคำสั่งเดียว · ถาม senior ได้ว่า “seeds ของโปรเจกต์นี้รันซ้ำได้ไหม หรือต้อง reset ก่อน” db:migrate:reset- ลบฐานข้อมูลทิ้ง สร้างใหม่ แล้วรัน migration ทุกไฟล์ตั้งแต่ไฟล์แรก · ใช้กับเครื่องตัวเองเท่านั้น (คำสั่งตระกูล reset อื่น ๆ บท 108) · พิมพ์บอก AI ได้ว่า “my dev database is messed up, how do I rebuild it from migrations and seeds”
belongs_to· foreign keybelongs_to :user= แฟ้มนี้เป็นของผู้ใช้หนึ่งคน · ช่องuser_idที่ชี้ไปหาแฟ้มในลิ้นชักอื่นเรียกว่า foreign key (บท 115) · ถาม senior ได้ว่า “belongs_to ตัวนี้ควรเป็น optional ไหม” (optional = ยอมให้ไม่มีเจ้าของได้) — เจอUser must existเมื่อไหร่ ให้นึกถึงคำนี้
หลังเพิ่ม belongs_to :user ใน Post แล้ว · post.save ตอบ => false และ full_messages ได้ ["User must exist"] · ข้อไหนถูก?
ตัวเลือกที่ 1 ถูก — ผลจริงหลัง post.save ไม่มีบรรทัด INSERT เลย · คนดูแลสต็อกตรวจแล้วไม่รับตั้งแต่ก่อนใส่ลิ้นชัก · post.id จึงยังเป็น nil
ตัวเลือกที่ 2 ผิด เพราะ false แปลว่าไม่ได้เก็บ และช่อง user_id ถูกตั้งห้ามว่างไว้ด้วย ·
ตัวเลือกที่ 3 ผิด เพราะไม่มี INSERT ถูกส่งไปให้ตู้ปฏิเสธเลย — ROLLBACK ที่อาจเห็นคือ Rails ยกเลิกกรอบงานเองหลังตรวจแฟ้มไม่ผ่าน ไม่ใช่ตู้ปฏิเสธ ·
ตัวเลือกที่ 4 ผิด เพราะ console ยังใช้ได้ปกติ ใส่ post.user = … แล้ว save ใหม่ก็ได้ true
จุดที่ต้องจำ: save ตอบ false = ไม่ได้เก็บ · ถามเหตุผลด้วย errors.full_messages · ดู log ว่ามี INSERT หรือเปล่า
🎯 ภารกิจ — CRUD ครบวงบนข้อมูลของวันที่ 2 (~10 นาที)
ทำหลังเตรียมตู้ครบ 6 ขั้นแล้ว · ข้อ 1 ห้ามข้าม เพราะบทความ “ข้าวซอยไก่” ที่เพิ่งลองสร้างกินเลขที่ 7 ไปแล้ว:
- ล้างตู้ก่อน:
exitออกจาก console · สั่งขั้นที่ 6 ซ้ำทั้งสองบรรทัด (bin/rails db:migrate:resetแล้วbin/rails db:seed) · เปิดbin/rails consoleใหม่ แล้วพิมพ์ข้อถัดไปทีละบรรทัด Post.countและUser.count— เช็คว่าข้อมูลตัวอย่างครบPost.find_by(title: "ผัดกะเพราหมูสับ").views— บทความนี้มีคนอ่านกี่ครั้งPost.where(published: false).count— มีบทความที่ยังไม่เผยแพร่กี่ชิ้นmana = User.find_by(name: "มานะ")แล้วpost = Post.create(title: "ข้าวผัดปู", body: "ใส่ปูเยอะ ๆ", user: mana)แล้วpost.idpost.update(title: "ข้าวผัดปูก้อน")— ดู SQL ว่ามีช่องไหนบ้างpost.destroy·Post.count· แล้วPost.find(7)
ก่อนกดเฉลย: ข้อ 4 ได้เท่าไหร่ (ย้อนดูข้อมูลตัวอย่าง) · ข้อ 5 post.id เป็นเลขอะไร · และข้อ 7 บรรทัดสุดท้ายได้อะไร?
สิ่งที่ควรเห็น (ผลจริง ตัดผลยาวเป็น …):
wongnok-blog(dev):001> Post.count
Post Count (41.1ms) SELECT COUNT(*) FROM "posts" /*application='WongnokBlog'*/
=> 6
wongnok-blog(dev):002> User.count
User Count (2.6ms) SELECT COUNT(*) FROM "users" /*application='WongnokBlog'*/
=> 3
wongnok-blog(dev):003> Post.find_by(title: "ผัดกะเพราหมูสับ").views
Post Load (1.0ms) SELECT "posts".* FROM "posts" WHERE "posts"."title" = 'ผัดกะเพราหมูสับ' LIMIT 1 /*application='WongnokBlog'*/
=> 510
wongnok-blog(dev):004> Post.where(published: false).count
Post Count (1.8ms) SELECT COUNT(*) FROM "posts" WHERE "posts"."published" = FALSE /*application='WongnokBlog'*/
=> 2
…
wongnok-blog(dev):007> post.id
=> 7
wongnok-blog(dev):008> post.update(title: "ข้าวผัดปูก้อน")
TRANSACTION (1.4ms) BEGIN /*application='WongnokBlog'*/
Post Update (3.0ms) UPDATE "posts" SET "title" = 'ข้าวผัดปูก้อน', "updated_at" = '2026-11-04 11:50:49.801073' WHERE "posts"."id" = 7 /*application='WongnokBlog'*/
TRANSACTION (1.7ms) COMMIT /*application='WongnokBlog'*/
=> true
…
wongnok-blog(dev):010> Post.count
Post Count (1.4ms) SELECT COUNT(*) FROM "posts" /*application='WongnokBlog'*/
=> 6
wongnok-blog(dev):011> Post.find(7)
Post Load (1.3ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 7 LIMIT 1 /*application='WongnokBlog'*/
(wongnok-blog):11:in '<main>': Couldn't find Post with 'id'=7 (ActiveRecord::RecordNotFound)
- ข้อ 2 — บทความ
6· ผู้ใช้3· ข้อ 3 — ยอดวิวผัดกะเพรา510 - ข้อ 4 —
2(ก๋วยเตี๋ยวเรือ กับ แกงเขียวหวาน) · SQL มีWHERE "posts"."published" = FALSE= “นับเฉพาะแผ่นที่ช่อง published เป็นเท็จ” - ข้อ 5 —
7· ถ้าข้ามข้อ 1 จะได้8หรือมากกว่า · ตู้ไม่เอาเลขที่เคยแจกไปแล้วกลับมาแจกซ้ำ แม้แฟ้มนั้นจะถูกทิ้งไปแล้ว (ผมลองทิ้งเลข 7 แล้วสร้างใหม่ ได้8) - ข้อ 6 — SQL มีแค่
titleกับupdated_atไม่มีbody - ข้อ 7 —
RecordNotFoundเพราะทิ้งไปแล้ว ·Post.countกลับมาเป็น6เท่าเดิม ✅ ข้อมูลพร้อมสำหรับบท 108
ถ้าเจอ error นี้ แปลว่า…
PG::NotNullViolation: ERROR: column "user_id" of relation "posts" contains null valuesตอนเตรียมตู้ — คุณสั่งbin/rails db:migrateแทนdb:migrate:reset· บทความจากครึ่งแรกไม่มีเจ้าของ ใบสั่งที่ 3 ห้ามช่องนี้ว่างเลยพัง (ผมลองแล้ว ได้ข้อความนี้จริง) → สั่งขั้นที่ 6 ตามที่เขียนPG::ObjectInUse: ERROR: database "…" is being accessed by other users— ยังมี console (หรือbin/rails server) เปิดค้างอยู่ จึงทิ้งตู้ไม่ได้ →exitทุกหน้าต่างแล้วสั่งใหม่Post.countได้12และUser.countได้6— สั่งdb:seedไปสองรอบ ข้อมูลเลยเบิ้ล (ผมลองแล้ว ได้[6, 12, 14]) → สั่งขั้นที่ 6 ใหม่ทั้งสองบรรทัด- ข้อ 5 ได้
post.idเป็นnil— พิมพ์ชื่อผิด เช่น"มานา"·find_byตอบnilเงียบ ๆ แล้วcreateไม่ได้เก็บเพราะไม่มีผู้เขียน · ลองpost.errors.full_messagesจะได้["User must exist"](ผลจริง) ·createไม่ระเบิด มันแค่คืนแฟ้มที่ยังไม่ได้เก็บมาให้ undefined method 'name' for nil (NoMethodError)— ตัวแปรที่ได้จากfind_byเป็นnilแล้วคุณเรียก.nameต่อ (ผลจริงตอนผมลองพิมพ์ชื่อผิดแล้วเรียก.nameต่อ) · ย้อนไปเช็คบรรทัดที่find_by- เห็น
email: "[FILTERED]"ตอนโชว์ผู้ใช้ — ไม่ใช่ error · Rails ซ่อนอีเมลเวลาพิมพ์ทั้งแฟ้มออกจอ (กันข้อมูลส่วนตัวหลุดลง log) ·User.find(3).emailยังได้"mana@example.com"ตามจริง
อยากได้ข้อมูลกลับมาสะอาดเหมือนเดิมเมื่อไหร่ ก็สั่งขั้นที่ 6 ซ้ำได้เสมอ · ติดตรงไหน หรือผลของคุณไม่ตรงกับที่เขียนไว้ ถามกลับมาได้เลย พร้อมแปะสิ่งที่จอขึ้นมาทั้งก้อน
ถ้าเปิดสไลด์ดูประกอบ
| หน้า | สไลด์เขียน | ของจริง | ต้องสนใจไหม |
|---|---|---|---|
| 9 | “พังไม่ได้ เพราะไม่มีการเขียนอะไรลง DB” | จริงสำหรับคำสั่งในหน้านั้น (ถามโครงตาราง · Post.new) · แต่ save · update · destroy ในบทนี้เขียนลงตู้จริง | นิดหน่อย |
| 9 | Post.where(published: true).to_sql | ต้องมีช่อง published ก่อน (ได้หลังเตรียมตู้ของวันที่ 2) · to_sql อยู่บท 113 | ไม่ต้อง — ลองในบท 113 |
| 9 | คอมเมนต์ “ลบ model ทิ้งยังถามได้” ข้าง Post.ancestors | ancestors คือรายชื่อ class แม่และ module ที่ Post สืบทอดมา · บทนี้ข้ามไปเพราะไม่ต้องใช้ทำ CRUD | ไม่ต้อง |
| 10 | จับคู่ gorm.Model embed ↔ < ApplicationRecord | gorm.Model ให้ช่อง (ID · เวลา) · < ApplicationRecord ให้ความสามารถ (save · find) ส่วนช่องมาจากตาราง | นิดหน่อย — คู่กันแบบหลวม ๆ |
| 10 | GORM “struct tag: table name” | GORM เปลี่ยนชื่อตารางด้วย method TableName() ไม่ใช่ struct tag (struct tag ใช้กับชื่อคอลัมน์) | ไม่ต้อง — คำตอบยังคู่กับ self.table_name เหมือนเดิม |
| 8 | save! · valid? · changed? · reload · pluck · exists? | บทนี้ยังไม่ใช้ · save! valid? บท 110 · pluck exists? บท 113 | ไม่ต้อง — สไลด์บอกเองว่าไม่ต้องจำทุกคำ |
อ้างอิง
- Rails Guides — Active Record Basics § CRUD: Reading and Writing Data —
อ่านหัวข้อย่อย Create · Read · Update · Delete · ตัวอย่างในคู่มือใช้
UserแทนPostแต่คำสั่งชุดเดียวกับบทนี้ทุกตัว - Rails Guides — Active Record Query Interface § Retrieving a Single Record —
อ่านแค่
find·first·last·find_by· คู่มือโชว์ SQL ของแต่ละคำ และยืนยันว่าfindไม่เจอจะโยนRecordNotFoundส่วนfind_byคืนnil - Rails API — ActiveRecord::Persistence —
หน้ารวม method ของการเก็บ/แก้/ลบ · ดู
new_record?·persisted?·save·update·destroyที่เหลือข้ามไปก่อน - Rails Guides — Active Record Migrations § Resetting the Database —
อ่านแค่ย่อหน้าท้ายของหัวข้อ ที่แนะนำ
bin/rails db:migrate:resetตามด้วยbin/rails db:seed· ที่เหลือของหน้านี้คือบท 108 - GORM — Query § Retrieving a single object —
ฝั่ง Go ที่ใช้เทียบ · ดูตัวอย่างใต้หัวข้อ Traditional API (แบบที่บท 27 ใช้) ว่า
Firstไม่เจอได้ErrRecordNotFound - GORM — Conventions § Pluralized Table Name —
ยืนยันว่า GORM ก็เดาชื่อตารางเป็นพหูพจน์ให้เหมือน Rails (
User→users) และเปลี่ยนได้ด้วยTableName()