🔁 ทวนก่อนเริ่ม — คำจากบทก่อน ๆ ที่บทนี้ใช้
- model
Post(บท 106) — "คนดูแลสต็อก" ของลิ้นชักposts· เรียก method ของมัน แล้วมันไปคุยกับฐานข้อมูลให้ - SQL (บท 106) — ภาษาที่ใช้สั่งตู้เก็บแฟ้ม (ฐานข้อมูล) · Active Record แปลคำสั่ง Ruby เป็น SQL ให้เอง
bin/rails console(บท 104, 107) — ครัวทดลองหลังร้าน พิมพ์โค้ดทีละบรรทัดแล้วเห็นผลทันที · เห็น SQL ที่ Rails ยิงด้วยPost.where(...)·first·all(บท 107) — หาแฟ้มตามเงื่อนไข · เอาแผ่นแรก · เอาทั้งหมด- log ของ server (บท 100, 104) — ข้อความ
Started GET …/Completed 200 OK …ที่ขึ้นทุกครั้งที่มีใบสั่งเข้าร้าน
🧰 ตรวจด้วย Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 17 (สไลด์ใช้ Ruby 4.0.6 · Rails 8.1.3 — รุ่นแก้บั๊กในสายเดียวกัน ใช้แทนกันได้)
🗂 ตัวอย่างในบทนี้ใช้ตาราง users / posts / comments และข้อมูลตัวอย่าง (seeds) ชุดเดียวกับที่สร้างไว้ใน บท 107 —
บทความ 6 ชิ้น (published 4 · draft 2) ของสมชาย สมหญิง และมานะ
ผลทุกก้อนรันจริงแล้วก็อปมา · เวลา (ms) · วันที่ · เลข 0x… ของคุณจะไม่ตรงกับในบท และจอคุณจะมีสี ·
เลข 001 002 ใน prompt ของ console นับตามบรรทัดที่พิมพ์ในรอบนั้น บางก้อนในบทรันแยกรอบ เลขเลยเริ่มใหม่ — ไม่ต้องสนใจ
ทวน SQL ภาษาคน — คำที่จะเห็นทั้งบท
ทั้งบทนี้เราจะอ่าน SQL ที่ Rails ยิงออกไป · ไม่ต้องเขียน SQL เป็น แค่อ่านออกว่ามันขออะไรจากตู้ก็พอ
นี่คือคำทั้งหมดที่จะเจอ (ทวนจากบท 106 และเพิ่มอีกนิด):
| คำใน SQL | แปลเป็นภาษาคน |
|---|---|
SELECT "posts".* | ขอทุกช่องของแฟ้ม (* = ทุกช่อง) |
SELECT "posts"."title" | ขอแค่ช่อง title ช่องเดียว |
FROM "posts" | จากลิ้นชัก posts |
WHERE … | เฉพาะแผ่นที่ตรงเงื่อนไขนี้ |
… AND … | "และ" — ต้องตรงทั้งสองเงื่อนไข |
ORDER BY … DESC / ASC | เรียงตามช่องนี้ · DESC = มากไปน้อย · ASC = น้อยไปมาก |
LIMIT 10 | เอามาไม่เกิน 10 แผ่น |
COUNT(*) | ไม่ต้องหยิบแฟ้มมา แค่นับให้หน่อยว่ามีกี่แผ่น |
SUM(…) · AVG(…) | บวกตัวเลขในช่องนี้ของทุกแผ่น · หาค่าเฉลี่ย |
GROUP BY … | แยกกองตามช่องนี้ก่อน แล้วค่อยนับ/บวกทีละกอง |
… AS "ชื่อ" | ตั้งชื่อเล่นให้ผลลัพธ์ช่องนั้น (Rails ใส่ไว้ใช้เอง ข้ามได้) |
UPDATE … SET … | แก้ค่าในช่องนี้ ของทุกแผ่นที่ตรงเงื่อนไข |
ลองแปลหนึ่งประโยคเต็ม ๆ:
SELECT "posts".* FROM "posts" WHERE "posts"."published" = TRUE ORDER BY "posts"."views" DESC LIMIT 10
= "ขอแฟ้มทุกช่อง จากลิ้นชัก posts · เฉพาะแผ่นที่ช่อง published เป็นจริง ·
เรียงตามยอดวิวมากไปน้อย · เอามาไม่เกิน 10 แผ่น"
เครื่องหมาย "…" รอบชื่อเป็นแค่วิธีเขียนชื่อของ PostgreSQL · "posts"."views" อ่านว่า "ช่อง views ของลิ้นชัก posts"
Post.where(...) ไม่ใช่ข้อมูล — มันคือใบเบิกของ
ตอนพิมพ์ Post.where(published: true) หลายคนคิดว่า "Rails ไปเอาบทความที่ published มาให้แล้ว" ·
ความจริงคือยังไม่ได้ไปเลย
สิ่งที่ where คืนมาคือคำขอที่จดไว้ว่า "อยากได้อะไร" · Rails จะไปตู้จริงก็ต่อเมื่อเราเริ่มใช้ข้อมูล เช่น วนลูปโชว์ หรือนับจำนวน
ประโยชน์ของการออกแบบแบบนี้: เราต่อเงื่อนไขเพิ่มได้ทีละบรรทัด (where แล้ว order แล้ว limit) ·
Rails รวมทุกอย่างเป็น SQL ประโยคเดียว แล้วไปตู้ครั้งเดียวตอนท้าย ·
ไม่ใช่ไปตู้ทุกบรรทัด
Post.where(published: true) เหมือนหยิบใบเบิกของมาหนึ่งใบ แล้วเขียนว่า "เอาแฟ้มที่ published แล้ว" ·
.order(views: :desc) = เขียนต่อว่า "เรียงตามยอดวิว" · .limit(10) = "ขอแค่ 10 แผ่น"
ตลอดเวลาที่ยังเขียนอยู่ ไม่มีใครเดินไปตู้เลย · คนดูแลสต็อกจะเดินไปก็ต่อเมื่อเรา "ยื่นใบ" —
คือตอนที่เราต้องการของจริง เช่น จะเอาแฟ้มมาวางเรียงบนจาน (each)
ที่ไม่เหมือนกัน: ใบเบิกของจริงยื่นแล้วก็หมดหน้าที่ ·
แต่ใบเบิกของ Rails ยื่นซ้ำได้ และบางคำสั่ง (เช่น count) จะเดินไปตู้ใหม่ทุกครั้งที่เรียก แม้เคยยื่นไปแล้ว — เดี๋ยวเห็นจริงในหัวข้อ count vs size
เทียบกับ Go (GORM): GORM ก็ต่อเงื่อนไขทีละท่อนได้เหมือนกัน แต่ต้องปิดท้ายด้วยคำสั่งที่สั่งให้ไปตู้เอง เช่น Find ·
ตามเอกสาร GORM เรียกคำสั่งพวกนี้ว่า Finisher Methods และบอกว่ามันยิง SQL ทันที:
// Go + GORM — บรรทัดนี้ไปตู้ทันที เพราะลงท้ายด้วย Find
var posts []Post
db.Where("published = ?", true).Order("views desc").Find(&posts)
# Ruby + Rails — บรรทัดนี้ยังไม่ไปตู้ ไม่มีคำว่า Find ท้าย
posts = Post.where(published: true).order(views: :desc)
- ฝั่ง Go:
Find(&posts)= "ไปตู้เดี๋ยวนี้ แล้วเทแฟ้มที่ได้ใส่ sliceposts" · อ่านโค้ดแล้วเห็นชัดว่าไปตู้ตรงไหน - ฝั่ง Rails:
postsได้ใบเบิกของ · จะไปตู้ตอนไหนขึ้นกับว่าบรรทัดถัด ๆ ไปเอาpostsไปทำอะไร — อ่านบรรทัดเดียวไม่รู้ :desc— symbol (บท 92) แปลว่า "มากไปน้อย"
- ActiveRecord::Relation (relation)
- ชนิดของสิ่งที่
where·order·limitคืนมา = "ใบเบิกของ" ที่ยังไม่ยื่น · ใน console จะเห็นชื่อเต็มว่าPost::ActiveRecord_Relation· พิมพ์บอก AI ได้ว่า “this returns an ActiveRecord::Relation, when does it actually hit the database?” - lazy loading · lazy evaluation
- "ขี้เกียจไว้ก่อน" — ยังไม่ทำงานจริงจนกว่าจะต้องใช้ผล · คำว่า hit the database = ไปตู้จริง · ถาม senior ได้ว่า “relation นี้ถูก load ตรงไหน ใน controller หรือใน view”
- query chaining · finisher
- การต่อคำสั่ง
.where.order.limitเป็นสาย · ฝั่ง GORM คำสั่งปิดท้ายที่ไปตู้ (Find·First) เรียกว่า finisher · พิมพ์บอก AI ได้ว่า “in GORM I call Find to execute, what triggers execution in ActiveRecord?”
ลองใน console — แล้วเจอกับดักทันที
มาพิสูจน์ด้วยตาตัวเอง · เปิด bin/rails console แล้วพิมพ์ทีละบรรทัดตามสไลด์หน้า 31
ถ้า where ไม่ไปตู้จริง เราไม่ควรเห็น SQL โผล่เลยสักบรรทัด… ผลจริง:
wongnok-blog(dev):001> q = Post.where(published: true)
Post Load (1.7ms) SELECT "posts".* FROM "posts" WHERE "posts"."published" = TRUE /* loading for pp */ LIMIT 11 /*application='WongnokBlog'*/
=>
[#<Post:0x0000000127a4e458
...
wongnok-blog(dev):002> q = q.where("views > ?", 100)
Post Load (2.3ms) SELECT "posts".* FROM "posts" WHERE "posts"."published" = TRUE AND (views > 100) /* loading for pp */ LIMIT 11 /*application='WongnokBlog'*/
=>
[#<Post:0x000000012657d5d8
...
เห็น SQL ทุกบรรทัด! ขัดกับที่เพิ่งพูดไปเลย · แต่สังเกตคำแปลก ๆ ใน SQL: /* loading for pp */ กับ LIMIT 11
ทั้งสองอย่างนี้ console ใส่เอง ไม่ใช่ของ where:
- console มีนิสัยโชว์ผลของทุกบรรทัดหลังเครื่องหมาย
=>· จะโชว์ใบเบิกของได้ ต้องรู้ก่อนว่าได้แฟ้มอะไร → เลยยื่นใบให้เราเอง pp= "pretty print" คำสั่งพิมพ์สวย ๆ ที่ console ใช้โชว์ผล ·loading for pp= "ไปตู้เพราะจะพิมพ์ให้ดู"LIMIT 11= console ขอแค่ 11 แผ่นพอ เผื่อตารางใหญ่จะได้ไม่ขนมาทั้งลิ้นชัก/*application='WongnokBlog'*/ท้ายสุด = ป้ายชื่อแอปที่ Rails แปะไว้ท้าย SQL ทุกประโยค · ไม่ต้องสนใจ (เดี๋ยวเจออีกในหัวข้อ log)"views > ?", 100— เงื่อนไขแบบเขียนเอง ·?คือช่องว่างที่ Rails เอา100ไปเติมให้อย่างปลอดภัย (เหมือน?ในdb.Whereของ GORM)
ในแอปจริง (controller · view) ไม่มีนิสัยนี้ — ไม่มีใครมาโชว์ผลทุกบรรทัด · กับดักนี้มีแค่ใน console
วิธีแก้: ต่อท้ายบรรทัดด้วย ; nil · ; คือ "จบคำสั่งแรก ขึ้นคำสั่งใหม่ในบรรทัดเดียวกัน" ·
คำสั่งสุดท้ายเป็น nil console เลยโชว์แค่ nil ไม่ต้องยื่นใบเบิกของ
ครัวทดลอง (console) มีผู้ช่วยคนหนึ่งที่อยากให้คุณเห็นผลทุกอย่างที่เขียน ·
คุณเพิ่งเขียนใบเบิกของบรรทัดแรก เขาก็วิ่งไปตู้ หยิบแฟ้มมาโชว์ 11 แผ่นทันที · เขียนต่ออีกบรรทัด ก็วิ่งไปอีกรอบ
; nil = บอกผู้ช่วยว่า "ไม่ต้องโชว์ ฉันยังเขียนไม่เสร็จ"
ที่ไม่เหมือนกัน: ผู้ช่วยจริงถ้าไปตู้มาแล้วก็คงวางแฟ้มไว้ให้เราใช้ต่อ ·
แต่ผู้ช่วยใน console ถ่ายสำเนาใบของเราไปเบิกเอง แล้วทิ้งแฟ้มที่ได้หลังโชว์เสร็จ — ใบตัวจริง (q) ในมือเรายังนับว่า "ไม่เคยยื่น" ·
พอเราเอาไปใช้จริง Rails ก็ต้องไปตู้อีกรอบอยู่ดี
เทียบกับ Go: Go ไม่มีครัวทดลองแบบนี้ (ต้อง go run ทั้งไฟล์) จึงไม่มีใครมาแอบโชว์ผลให้ · กับดักนี้เป็นของ console ของ Rails ล้วน ๆ
ลองใหม่ด้วย ; nil ทุกบรรทัด:
q = Post.where(published: true); nil
q = q.where("views > ?", 100); nil
q = q.order(created_at: :desc); nil
q = q.limit(10); nil
พิมพ์ 4 บรรทัดนี้ จะเห็นบรรทัด Post Load (…) SELECT … โผล่กี่ครั้ง?
ผลจริง — ไม่โผล่เลยสักครั้ง:
wongnok-blog(dev):001> q = Post.where(published: true); nil
=> nil
wongnok-blog(dev):002> q = q.where("views > ?", 100); nil
=> nil
wongnok-blog(dev):003> q = q.order(created_at: :desc); nil
=> nil
wongnok-blog(dev):004> q = q.limit(10); nil
=> nil
สี่บรรทัดนี้แค่เขียนเพิ่มลงใบเบิกของ · ยังไม่มีใครไปตู้ · นี่คือพฤติกรรมเดียวกับในแอปจริง
ใบเบิกของยังอยู่ในมือ ถามมันได้ว่าเป็นอะไร และเขียนว่าอะไรไว้บ้าง (ผลจริง ต่อจากเมื่อกี้):
wongnok-blog(dev):005> q.class
=> Post::ActiveRecord_Relation
wongnok-blog(dev):006> q.to_sql
=> "SELECT \"posts\".* FROM \"posts\" WHERE \"posts\".\"published\" = TRUE AND (views > 100) ORDER BY \"posts\".\"created_at\" DESC LIMIT 10"
q.class— ชนิดของqคือPost::ActiveRecord_Relation= ใบเบิกของของลิ้นชัก posts ไม่ใช่ Array ของบทความq.to_sql— "อ่านใบเบิกของให้ฟังหน่อย" ได้ SQL ที่จะยิง โดยยังไม่ยิง ·\"คือเครื่องหมายคำพูดที่อยู่ใน string (console โชว์แบบนี้ ของจริงคือ")- แปล SQL: "ขอทุกช่องจากลิ้นชัก posts · เฉพาะแผ่นที่ published เป็นจริง และยอดวิวเกิน 100 · เรียงจากสร้างล่าสุดก่อน · ไม่เกิน 10 แผ่น" ·
สี่บรรทัด Ruby → SQL ประโยคเดียว ·
created_at= ช่องเวลาที่สร้างแฟ้ม (มาจากt.timestampsบท 106)
ทีนี้ "ยื่นใบ" จริงด้วย each (วนทีละแผ่น บท 93) แล้วลองยื่นซ้ำอีกครั้ง — ผลจริง ต่อจากเมื่อกี้:
wongnok-blog(dev):007> q.loaded?
=> nil
wongnok-blog(dev):008> q.each { |post| puts post.title }; nil
Post Load (4.2ms) SELECT "posts".* FROM "posts" WHERE "posts"."published" = TRUE AND (views > 100) ORDER BY "posts"."created_at" DESC LIMIT 10 /*application='WongnokBlog'*/
ผัดกะเพราหมูสับ
ส้มตำปูปลาร้า
ต้มยำกุ้งน้ำข้น
=> nil
wongnok-blog(dev):009> q.loaded?
=> true
wongnok-blog(dev):010> q.each { |post| puts post.title }; nil
ผัดกะเพราหมูสับ
ส้มตำปูปลาร้า
ต้มยำกุ้งน้ำข้น
=> nil
q.loaded?— "ยื่นใบนี้ไปหรือยัง" · ตอนแรกได้nil(= ยัง · Rails ตอบnilไม่ใช่falseแต่ความหมายเดียวกัน)eachครั้งแรก — SQL โผล่ตรงนี้ ไปตู้ครั้งเดียว ได้ 3 บทความที่ published และวิวเกิน 100q.loaded?หลังจากนั้น —trueแฟ้มวางอยู่บนโต๊ะแล้วeachครั้งที่สอง — ไม่มี SQL · ใช้แฟ้มที่วางบนโต๊ะอยู่แล้ว ไม่เดินไปตู้ซ้ำ
- inspect · pp (pretty print)
- สิ่งที่ console ทำกับผลของทุกบรรทัดเพื่อโชว์หลัง
=>· กับ relation มันทำให้ยิง SQL (loading for pp) · พิมพ์บอก AI ได้ว่า “in rails console, why does a where clause run SQL immediately? I see /* loading for pp */” - to_sql · loaded?
to_sql= ดู SQL ที่ relation จะยิง โดยไม่ยิง ·loaded?= relation นี้ไปเอาข้อมูลมาแล้วหรือยัง · ถาม senior ได้ว่า “ก่อนส่ง relation นี้ไป view ผมเช็ค to_sql แล้ว SQL หน้าตาแบบนี้ ถูกไหม”
⚠️ พิมพ์ชื่อคอลัมน์ผิดใน where จะยังไม่ error ตอนเขียน — จะ error ตอนยื่นใบ ซึ่งอาจอยู่ไกลออกไปหลายบรรทัด (ผลจริง: สะกด publish ขาด ed):
wongnok-blog(dev):001> bad = Post.where(publish: true); nil
=> nil
wongnok-blog(dev):002> bad.class
=> Post::ActiveRecord_Relation
wongnok-blog(dev):003> bad.size
Post Count (2.3ms) SELECT COUNT(*) FROM "posts" WHERE "posts"."publish" = TRUE /*application='WongnokBlog'*/
(wongnok-blog):3:in '<main>': PG::UndefinedColumn: ERROR: column posts.publish does not exist (ActiveRecord::StatementInvalid)
…
HINT: Perhaps you meant to reference the column "posts.published".
error ชี้ :3 (บรรทัดที่ยื่นใบ) ไม่ใช่บรรทัด 1 ที่เขียนผิด · ในแอปจริงอาจชี้ไปที่ไฟล์ view ทั้งที่ผิดใน controller ·
ActiveRecord::StatementInvalid = ชื่อ error ของ Rails แปลว่า "ตู้ไม่รับ SQL ประโยคนี้" ·
HINT ท้ายสุดคือ PostgreSQL เดาให้ว่าน่าจะหมายถึง published
ยื่นใบเมื่อไหร่ — คำสั่งไหนไปตู้จริง
ตารางนี้สรุปจากผลรันจริงใน console ทุกบรรทัด (เริ่มจาก Post.where(published: true) ที่ยังไม่ยื่น) ·
ไม่ต้องจำทั้งหมด จำแค่หลัก: อะไรที่ต้องรู้ "ของจริง" = ไปตู้
| พิมพ์ | ไปตู้ไหม | SQL ที่เห็นจริง (ภาษาคน) |
|---|---|---|
.where · .order · .limit | ❌ ไม่ไป | แค่เขียนเพิ่มลงใบ |
.class · .to_sql | ❌ ไม่ไป | ถามเรื่องตัวใบ ไม่ได้ถามเรื่องแฟ้ม |
.each { … } · .to_a | ✅ ไป | SELECT "posts".* … = หยิบทุกแผ่นที่ตรงมาวางบนโต๊ะ · to_a แปลงเป็น Array ธรรมดา |
.first | ✅ ไป (ถ้ายังไม่โหลด) | … ORDER BY "posts"."id" ASC LIMIT 1 = เรียงตามเลขที่แล้วหยิบแผ่นแรกแผ่นเดียว · ถ้าโหลดแล้วไม่ไปซ้ำ |
.count | ✅ ไป ทุกครั้ง | SELECT COUNT(*) … = ให้ตู้นับ · เรียกสองครั้งก็ไปสองครั้ง |
.any? · .empty? · .exists? | ✅ ไป | SELECT 1 AS one … LIMIT 1 = "มีสักแผ่นไหม" เจอแผ่นแรกก็หยุด |
<% @posts.each do |post| %> ใน view | ✅ ไป | เหมือน each ข้างบน · เดี๋ยวเห็นใน log หัวข้อถัดไป |
ใน controller เขียนบรรทัดเดียว @posts = Post.where(published: true).order(:views) · ตอนบรรทัดนี้ทำงานเสร็จ Rails ยิง SQL ไปแล้วกี่ครั้ง?
ตัวเลือกที่ 1 ถูก — where กับ order แค่เขียนเพิ่มลงใบเบิกของ · SQL จะโผล่ตอนที่ view เอา @posts ไปวน each
ตัวเลือกที่ 2 ผิดครึ่งเดียว — ถูกที่ "รวมเป็นประโยคเดียว" แต่ประโยคนั้นยังไม่ถูกส่งตอนนี้ · ตัวเลือกที่ 3 ผิด เพราะ Rails ไม่เคยยิงแยกทีละ method · ตัวเลือกที่ 4 ผิด เพราะจำนวนแถวไม่เกี่ยวกับว่าจะยิงหรือไม่ยิง
จุดที่ต้องจำ: where · order · limit = เขียนใบ · each · count · first = ยื่นใบ
เห็น SQL ที่แอปยิงจริง — สมุดบันทึกการเบิกของ
console ดีไว้ลองทีละบรรทัด · แต่เวลาแอปช้า เราอยากรู้ว่าหน้าเว็บจริงหนึ่งหน้าไปตู้กี่รอบ ด้วย SQL อะไร
คำตอบอยู่ในไฟล์ log/development.log · Rails จดทุกอย่างที่เห็นในเทอร์มินัลของ server ลงไฟล์นี้ด้วย รวมถึง SQL ทุกประโยค
ทุกครั้งที่คนดูแลสต็อกเดินไปตู้ เขาจดไว้หนึ่งบรรทัด: "เวลาเท่าไหร่ · เบิกอะไร · ใช้เวลากี่วินาที · ใครสั่งให้มาเบิก" · อยากรู้ว่าจานนี้ทำไมช้า ก็เปิดสมุดนับดูว่าเดินไปตู้กี่รอบ
ที่ไม่เหมือนกัน: สมุดจริงเปิดอ่านย้อนหลังสบาย ๆ ·
แต่ development.log ยาวขึ้นเรื่อย ๆ ไม่มีวันหยุด (ทุกใบสั่งต่อท้ายไฟล์เดิม) · จึงนิยมเปิดแบบ "ดูสด" ด้วย tail -f แทนการเปิดไฟล์ทั้งไฟล์
เทียบกับ Go: GORM ค่าเริ่มต้นจดแค่ SQL ที่ช้าหรือที่ error · อยากเห็นทุกประโยคต้องเรียก db.Debug() หรือตั้ง logger ของมันเอง ·
Rails เปิดสมุดนี้ไว้ให้ตั้งแต่ rails new ในโหมด development
มาทำหน้า /posts จิ๋ว ๆ ให้มีของให้ดู — 3 ไฟล์ (ถ้ามี PostsController จาก Day 1 อยู่แล้ว แก้ action index ให้เป็นแบบนี้):
# config/routes.rb — เพิ่มบรรทัดนี้ใน block (บท 101) · ถ้ามี resources :posts อยู่แล้ว ไม่ต้องเพิ่ม
resources :posts, only: :index
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
@posts = Post.where(published: true).order(views: :desc)
end
end
<%# app/views/posts/index.html.erb %>
<h1>บทความ <%= @posts.count %> ชิ้น</h1>
<ul>
<% @posts.each do |post| %>
<li><%= post.title %> (<%= post.views %> views)</li>
<% end %>
</ul>
only: :index— จาก 8 เส้นของresourcesเอาแค่เส้นGET /posts(เรื่องonlyละเอียดในบท 117)- controller วางใบเบิกของ (ยังไม่ยื่น) ไว้บนถาด
@posts - view บรรทัด 2:
@posts.count= นับจำนวน · บรรทัด 4:@posts.each= วนทีละแผ่น · บรรทัด 1 เป็นแค่โน้ตชื่อไฟล์
เปิดเทอร์มินัลสองหน้าต่าง · หน้าต่างแรกเปิด server · หน้าต่างที่สองเปิดสมุดแบบดูสด:
# หน้าต่างที่ 1
bin/rails server
# หน้าต่างที่ 2 — อยู่ในโฟลเดอร์แอปเหมือนกัน
tail -f log/development.log
tail = โชว์ท้ายไฟล์ · -f = "follow" มีบรรทัดใหม่ต่อท้ายเมื่อไหร่ โชว์ทันที (กด Ctrl-C เพื่อเลิกดู) ·
จากนั้นเปิด http://localhost:3000/posts ในเบราว์เซอร์ หน้าต่างที่ 2 จะขึ้นแบบนี้ (ผลจริง · ผมยิงด้วย curl):
Started GET "/posts" for ::1 at 2026-11-04 18:49:47 +0700
Processing by PostsController#index as */*
Rendering layout layouts/application.html.erb
Rendering posts/index.html.erb within layouts/application
Post Count (1.1ms) SELECT COUNT(*) FROM "posts" WHERE "posts"."published" = TRUE /*action='index',application='WongnokBlog',controller='posts'*/
↳ app/views/posts/index.html.erb:3
Post Load (0.8ms) SELECT "posts".* FROM "posts" WHERE "posts"."published" = TRUE ORDER BY "posts"."views" DESC /*action='index',application='WongnokBlog',controller='posts'*/
↳ app/views/posts/index.html.erb:5
Rendered posts/index.html.erb within layouts/application (Duration: 19.0ms | GC: 9.5ms)
Rendered layout layouts/application.html.erb (Duration: 26.4ms | GC: 10.8ms)
Completed 200 OK in 80ms (Views: 21.6ms | ActiveRecord: 20.7ms (2 queries, 0 cached) | GC: 10.8ms)
อ่านทีละส่วน:
Started GET "/posts" for ::1= ใบสั่งเข้าร้าน (::1= เครื่องตัวเอง) ·as */*เพราะผมใช้curl· เปิดด้วยเบราว์เซอร์จะเห็นas HTMLRendering …/Rendered …= คนจัดจานเริ่มจัด / จัดเสร็จ (layout = จานรองมาตรฐาน บท 103) ·GC= เวลาที่ Ruby เก็บกวาดหน่วยความจำ ข้ามได้Processing by PostsController#indexแล้วตามด้วยRendering …— ไม่มี SQL ระหว่างที่ controller ทำงาน · ยืนยันว่าwhereใน controller ยังไม่ยื่นใบPost Count (1.1ms) SELECT COUNT(*) …— ไปตู้รอบที่ 1: "นับให้หน่อยว่ามีบทความ published กี่แผ่น" ใช้เวลา 1.1 มิลลิวินาทีPost Load (0.8ms) SELECT "posts".* … ORDER BY "posts"."views" DESC— ไปตู้รอบที่ 2: "หยิบทุกแผ่นที่ published เรียงวิวมากไปน้อย"↳ app/views/posts/index.html.erb:3— บรรทัดที่สั่งให้ไปตู้ (ลูกศรงอ = "มาจากตรงนี้") · อันนี้แหละที่จะช่วยตามหาต้นเหตุในบท 114/*action='index',…,controller='posts'*/— ป้ายที่ Rails แปะท้าย SQL ว่าใบสั่งของแผนกไหน เมนูอะไร (ใน console เห็นแค่ชื่อแอป เพราะไม่มีใบสั่ง)ActiveRecord: 20.7ms (2 queries, 0 cached)— สรุปท้ายใบสั่ง: ไปตู้ 2 รอบ · ตัวเลขนี้คือสิ่งแรกที่ควรดูเวลาหน้าไหนช้า
⚠️ เลขบรรทัดของไฟล์ .erb ใน log เกินจริง 1 — log บอก :3 กับ :5 แต่ในไฟล์ count อยู่บรรทัด 2 · each อยู่บรรทัด 4 ·
เปิดไฟล์ view แล้วดูบรรทัดที่ log บอก ลบหนึ่ง (ไฟล์ .rb ไม่โดน)
สาเหตุ: แอปใหม่เปิดค่าตั้งที่แอบแปะโน้ตชื่อไฟล์ไว้บนสุดของ view ทุกไฟล์ (annotate_rendered_view_with_filenames) · ผมลองปิดแล้วเลขกลับมาเป็น :2 กับ :4 · ไม่ต้องไปปิดเอง
บรรทัด ↳ กับป้าย /*action=…*/ มาจากค่าตั้งสองตัว (verbose_query_logs · query_log_tags_enabled)
ที่ Rails 8.1 เปิดไว้ให้แล้ว ในไฟล์ config/environments/development.rb ของแอปที่สร้างใหม่ · ไม่ต้องแก้อะไร
อีกทางที่สไลด์แนะนำ (แค่รู้จัก): gem rack-mini-profiler ขึ้นกล่องเล็ก ๆ มุมจอทุกหน้า บอกว่าหน้านี้ไปตู้กี่รอบ ·
ไม่ได้มากับแอป และบทนี้ไม่ได้ติดตั้ง · ถ้าทีมใช้ จะเห็นตัวเลขเดียวกับบรรทัด (2 queries) ใน log
- development.log · tail -f
- ไฟล์ที่ Rails จดทุกใบสั่งและทุก SQL ในโหมด development ·
tail -f= เปิดดูบรรทัดใหม่แบบสด ๆ · พิมพ์บอก AI ได้ว่า “here is my development.log for one request to /posts, how many queries does it run?” - verbose query logs · query log tags
- บรรทัด
↳ ไฟล์:บรรทัดใต้ SQL = verbose query logs · ป้าย/*action='index'…*/ท้าย SQL = query log tags · ถาม senior ได้ว่า “บน production เราเปิด query log tags ไว้ไหม จะได้รู้ว่า SQL ช้าตัวนี้มาจาก controller ไหน”
count · size · length — นับสามแบบ ไปตู้ไม่เท่ากัน
ทั้งสามคำตอบ "มีกี่แผ่น" เหมือนกัน แต่วิธีหาคำตอบต่างกัน · สไลด์หน้า 35 ยกเรื่องนี้มาเป็น "คำถามสัมภาษณ์ยอดนิยม" — ลองใน console ทีละบรรทัดกับใบเบิกของที่ยังไม่ยื่น (ผลจริง):
wongnok-blog(dev):001> posts = Post.where(published: true); nil
=> nil
wongnok-blog(dev):002> posts.size
Post Count (2.6ms) SELECT COUNT(*) FROM "posts" WHERE "posts"."published" = TRUE /*application='WongnokBlog'*/
=> 4
wongnok-blog(dev):003> posts.length
Post Load (1.5ms) SELECT "posts".* FROM "posts" WHERE "posts"."published" = TRUE /*application='WongnokBlog'*/
=> 4
wongnok-blog(dev):004> posts.size
=> 4
wongnok-blog(dev):005> posts.count
Post Count (1.6ms) SELECT COUNT(*) FROM "posts" WHERE "posts"."published" = TRUE /*application='WongnokBlog'*/
=> 4
- บรรทัด 2
sizeตอนยังไม่ยื่นใบ — ใช้COUNT(*)ให้ตู้นับ - บรรทัด 3
length— หยิบแฟ้มทุกแผ่นมาวางบนโต๊ะ (SELECT "posts".*) แล้วนับเองบนโต๊ะ - บรรทัด 4
sizeอีกครั้ง — ไม่มี SQL เพราะแฟ้มอยู่บนโต๊ะแล้ว นับบนโต๊ะเลย - บรรทัด 5
count— แฟ้มอยู่บนโต๊ะแล้วแท้ ๆ ก็ยังไปตู้ ให้ตู้นับใหม่
count = โทรถามคนดูแลสต็อกว่า "มีกี่แผ่น" เขานับในตู้แล้วบอกตัวเลขกลับมา · โทรทุกครั้งที่อยากรู้ แม้แฟ้มกองอยู่บนโต๊ะตรงหน้า
length = ขนแฟ้มทั้งหมดมาวางบนโต๊ะแล้วนับเอง · เปลืองแรงถ้าแค่อยากรู้ตัวเลข แต่ถ้ายังไงก็จะใช้แฟ้มพวกนั้นอยู่แล้วก็คุ้ม
size = คนฉลาด: ดูก่อนว่าบนโต๊ะมีแฟ้มกองอยู่ไหม · มี → นับบนโต๊ะ · ไม่มี → โทรถาม
ที่ไม่เหมือนกัน: คนจริงเห็นว่าแฟ้มบนโต๊ะเก่าแล้วก็โทรถามใหม่ได้ ·
แต่ size กับ length ไม่รู้ว่ามีคนเพิ่มแฟ้มเข้าตู้ไปหลังจากที่ขนมาแล้ว — นับจากกองบนโต๊ะเสมอ · อยากได้ตัวเลขล่าสุดจริง ๆ ต้อง count
เทียบกับ Go: count ≈ db.Model(&Post{}).Where(…).Count(&n) ให้ DB นับ ·
length ≈ db.Where(…).Find(&posts) แล้ว len(posts) · ฝั่ง Go ไม่มี size ที่เลือกให้เอง ต้องตัดสินใจเองทุกครั้ง
ทีนี้มาดูในหน้าเว็บจริง · แก้ view ให้โชว์จำนวนทั้งบนและล่างของรายการ:
<%# app/views/posts/index.html.erb %>
<h1>บทความ <%= @posts.count %> ชิ้น</h1>
<ul>
<% @posts.each do |post| %>
<li><%= post.title %> (<%= post.views %> views)</li>
<% end %>
</ul>
<p>รวม <%= @posts.count %> ชิ้น</p>
หน้านี้ไปตู้กี่รอบ? แล้วถ้าเปลี่ยน count ทั้งสองที่เป็น size · หรือเป็น length ล่ะ?
ผลจริงจาก log ทีละแบบ (ตัดมาเฉพาะบรรทัด SQL กับบรรทัดสรุป · เลขบรรทัด view เกินจริง 1 ตามที่เตือนไว้):
# แบบ count ทั้งสองที่
Post Count (1.6ms) SELECT COUNT(*) FROM "posts" WHERE "posts"."published" = TRUE /*…*/
↳ app/views/posts/index.html.erb:3
Post Load (1.2ms) SELECT "posts".* FROM "posts" WHERE "posts"."published" = TRUE ORDER BY "posts"."views" DESC /*…*/
↳ app/views/posts/index.html.erb:5
CACHE Post Count (0.0ms) SELECT COUNT(*) FROM "posts" WHERE "posts"."published" = TRUE
↳ app/views/posts/index.html.erb:9
Completed 200 OK in 15ms (Views: 4.2ms | ActiveRecord: 8.8ms (3 queries, 1 cached) | GC: 0.0ms)
# แบบ size ทั้งสองที่
Post Count (1.5ms) SELECT COUNT(*) FROM "posts" WHERE "posts"."published" = TRUE /*…*/
↳ app/views/posts/index.html.erb:3
Post Load (1.2ms) SELECT "posts".* FROM "posts" WHERE "posts"."published" = TRUE ORDER BY "posts"."views" DESC /*…*/
↳ app/views/posts/index.html.erb:5
Completed 200 OK in 15ms (Views: 4.3ms | ActiveRecord: 8.1ms (2 queries, 0 cached) | GC: 0.0ms)
# แบบ length ทั้งสองที่
Post Load (2.2ms) SELECT "posts".* FROM "posts" WHERE "posts"."published" = TRUE ORDER BY "posts"."views" DESC /*…*/
↳ app/views/posts/index.html.erb:3
Completed 200 OK in 17ms (Views: 3.9ms | ActiveRecord: 11.4ms (1 query, 0 cached) | GC: 0.0ms)
- count = 3 รอบ · ตัวที่สาม
CACHE Post Count (0.0ms)= Rails จำได้ว่าใบสั่งนี้เพิ่งถามคำถามเดียวกันไป เลยตอบจากความจำ ไม่ได้ไปตู้จริง (ความจำนี้อยู่แค่ภายในใบสั่งเดียว ใบสั่งถัดไปเริ่มใหม่) · แต่countก็ยังพยายามไปตู้ทุกครั้ง - size = 2 รอบ · ตัวบน (บรรทัด 2) แฟ้มยังไม่มาบนโต๊ะ → ยังโทรถาม
COUNT(*)· ตัวล่าง (บรรทัด 8) อยู่หลังeachแฟ้มมาแล้ว → นับบนโต๊ะ ไม่มี SQL - length = 1 รอบ · ตัวบนขนแฟ้มมาเลยตั้งแต่บรรทัด 2 ·
eachกับตัวล่างใช้กองเดิมทั้งหมด
สรุปใช้จริง: ถ้าหน้านั้นจะวน each โชว์ทุกแผ่นอยู่แล้ว และแผ่นไม่เยอะ → length ไปตู้รอบเดียวคุ้มสุด ·
ถ้าแค่อยากรู้ตัวเลข ไม่ได้โชว์แฟ้ม → count ให้ตู้นับ อย่าขนมาทั้งลิ้นชัก ·
ไม่แน่ใจ → size เลือกให้เอง แต่ต้องรู้ว่ามันอยู่ก่อนหรือหลัง each
ถามแค่ว่า "มีไหม" — ใช้ exists?
บางทีไม่ได้อยากรู้ว่ากี่ชิ้น แค่อยากรู้ว่า "สมชายเคยเขียนบทความไหม" · ลองสองแบบ (ผลจริง ต่อจากก้อน count เมื่อกี้) ·
user_id = ช่อง "เป็นของผู้ใช้เลขที่ …" บนแฟ้มบทความ ที่สร้างไว้ในบท 107 (ละเอียดในบท 115) · สมชายคือเลขที่ 1:
wongnok-blog(dev):006> Post.where(user_id: 1).count > 0
Post Count (2.8ms) SELECT COUNT(*) FROM "posts" WHERE "posts"."user_id" = 1 /*application='WongnokBlog'*/
=> true
wongnok-blog(dev):007> Post.exists?(user_id: 1)
Post Exists? (2.6ms) SELECT 1 AS one FROM "posts" WHERE "posts"."user_id" = 1 LIMIT 1 /*application='WongnokBlog'*/
=> true
- แบบแรก
count > 0— ให้ตู้นับ (COUNT(*)) แล้วเอาตัวเลขมาเทียบว่าเกิน 0 ไหม · ไม่ได้หยิบแฟ้มมา แต่ตู้ต้องนับให้ครบทุกแผ่นก่อน — ถ้าสมชายมี 50,000 บทความ ต้องนับถึง 50,000 - แบบที่สอง
exists?—SELECT 1 AS one … LIMIT 1แปลว่า "ไม่ต้องหยิบช่องไหนมา เจอแผ่นแรกที่ตรงก็ตอบเลข 1 กลับมาพอ" · เจอปุ๊บหยุดปั๊บ - กับข้อมูล 6 แถวของเรา ความเร็วไม่ต่างกัน (เวลา ms ข้างบนแกว่งเอง) · ความต่างจะเห็นตอนตารางใหญ่
- แบบที่แย่ที่สุดคือ
.length(เช่นPost.all.length) — หยิบทุกแผ่นมาวางบนโต๊ะจริง (SELECT "posts".* FROM "posts") แค่เพื่อนับ
- count vs size vs length
count= ให้ DB นับเสมอ ·length= โหลดมาแล้วนับใน Ruby ·size= โหลดแล้วนับใน Ruby ยังไม่โหลดให้ DB นับ · ถาม senior ได้ว่า “ตรงนี้ควรใช้ size แทน count ไหม เพราะเดี๋ยว view จะวน each อยู่แล้ว”- exists? · query cache
exists?= ถามแค่ว่ามีสักแถวไหม (LIMIT 1) ·CACHEใน log = query cache ที่ Rails จำคำตอบของ SQL เดิมไว้ภายในหนึ่ง request · พิมพ์บอก AI ได้ว่า “I only need to know if any row exists, should I use exists? instead of count > 0?”
ใน view วน <% @posts.each do |post| %> เสร็จแล้ว · ใต้รายการอยากเขียน "รวม N ชิ้น" โดยไม่ไปตู้เพิ่ม ควรใช้อะไร?
ตัวเลือกที่ 2 ถูก — หลัง each แฟ้มอยู่บนโต๊ะแล้ว (loaded? เป็นจริง) · size นับบนโต๊ะ ไม่มี SQL · ตรงกับผลจริงแบบ size ในเฉลย predict (ตัวล่างไม่มี SQL) · length ก็ได้ผลเดียวกันตรงนี้
ตัวเลือกที่ 1 ผิด เพราะ count ส่ง COUNT(*) ไปถามตู้ใหม่ทุกครั้ง แม้แฟ้มอยู่บนโต๊ะแล้ว (ถ้าใบสั่งนี้เพิ่งถามคำถามเดียวกันไป log จะขึ้น CACHE แต่ถ้ายังไม่เคยถาม ก็ไปตู้จริง) ·
ตัวเลือกที่ 3 ผิดสองชั้น — ไปตู้ และนับบทความ draft ด้วย ได้ 6 ไม่ใช่ 4 ·
ตัวเลือกที่ 4 ผิด เพราะ exists? ตอบแค่จริง/เท็จ ไม่ได้บอกจำนวน
จุดที่ต้องจำ: หลัง each ใช้ size · ก่อน each แม้แต่ size ก็ยังไปตู้
ให้ตู้คำนวณให้ — ขอแค่ของที่ใช้
หลักเดียวกับ count: ถ้าอยากได้แค่ ตัวเลข หรือแค่ บางช่อง · บอกตู้ให้ทำมาให้เสร็จ
ดีกว่าขนแฟ้มทั้งแผ่นมาทำเองใน Ruby · ทุกตัวข้างล่างนี้ไปตู้ทันที (เป็นการยื่นใบ) — ผลจริงจากข้อมูลตัวอย่าง:
wongnok-blog(dev):001> Post.pluck(:title)
Post Pluck (1.3ms) SELECT "posts"."title" FROM "posts" /*application='WongnokBlog'*/
=>
["ต้มยำกุ้งน้ำข้น",
"ส้มตำปูปลาร้า",
"ข้าวมันไก่ประตูน้ำ",
"ก๋วยเตี๋ยวเรืออยุธยา",
"ผัดกะเพราหมูสับ",
"แกงเขียวหวานไก่"]
wongnok-blog(dev):002> Post.ids
Post Ids (0.8ms) SELECT "posts"."id" FROM "posts" /*application='WongnokBlog'*/
=> [1, 2, 3, 4, 5, 6]
pluck(:title)— "ขอแค่ช่อง title ของทุกแผ่น" · ได้ Array ของ String ธรรมดา ไม่ใช่ objectPost· เร็วและเบากว่าหยิบแฟ้มทั้งแผ่นids— ทางลัดของpluck(:id)· ได้เลขที่ของทุกแผ่น
อยากได้รายชื่อเมนูไปติดบอร์ด · ทางแรก: ขนแฟ้มสูตรทั้ง 6 แผ่นมาวางบนโต๊ะ แล้วนั่งลอกชื่อ (Post.all.map(&:title) — map กับ &: จากบท 92) ·
ทางที่สอง: บอกคนดูแลสต็อกว่า "จดแค่ชื่อเมนูมาให้กระดาษแผ่นเดียว" (pluck(:title))
ที่ไม่เหมือนกัน: กระดาษรายชื่อจริงยังเอาไปถามต่อได้ว่า "เมนูนี้ใส่อะไร" ·
แต่ผลของ pluck เป็นแค่ข้อความเปล่า ๆ — ไม่ใช่แฟ้ม เรียก .save หรือ .user ต่อไม่ได้ ·
ต้องใช้ทั้งแผ่นเมื่อไหร่ ต้องกลับไปเบิกแฟ้มจริง
ต่อไปเป็นการให้ตู้ "คิดเลข" (ผลจริง ต่อจากเมื่อกี้):
wongnok-blog(dev):003> Post.sum(:views)
Post Sum (2.3ms) SELECT SUM("posts"."views") FROM "posts" /*application='WongnokBlog'*/
=> 1055
wongnok-blog(dev):004> Post.average(:views)
Post Average (2.7ms) SELECT AVG("posts"."views") FROM "posts" /*application='WongnokBlog'*/
=> 0.1758333333333333333e3
wongnok-blog(dev):005> Post.where(published: true).average(:views)
Post Average (2.6ms) SELECT AVG("posts"."views") FROM "posts" WHERE "posts"."published" = TRUE /*application='WongnokBlog'*/
=> 0.26375e3
wongnok-blog(dev):006> Post.group(:user_id).count
Post Count (0.5ms) SELECT COUNT(*) AS "count_all", "posts"."user_id" AS "posts_user_id" FROM "posts" GROUP BY "posts"."user_id" /*application='WongnokBlog'*/
=> {3 => 2, 2 => 2, 1 => 2}
wongnok-blog(dev):007> Post.group(:user_id).sum(:views)
Post Sum (2.5ms) SELECT SUM("posts"."views") AS "sum_views", "posts"."user_id" AS "posts_user_id" FROM "posts" GROUP BY "posts"."user_id" /*application='WongnokBlog'*/
=> {3 => 510, 2 => 85, 1 => 460}
sum(:views)— ยอดวิวรวมทุกบทความ = 120 + 340 + 85 + 0 + 510 + 0 = 1055average(:views)— ได้0.1758333333333333333e3หน้าตาประหลาด · อ่านว่า 0.17583… × 10³ = 175.83 · เป็นตัวเลขทศนิยมแบบแม่นยำชื่อ BigDecimal · อยากได้หน้าตาปกติต่อท้าย.to_f(ลองแล้วได้175.83333333333334)- บรรทัด 5 ต่อ
whereก่อนได้ — เฉลี่ยเฉพาะที่ published =0.26375e3= 263.75 group(:user_id).count— "แยกกองตามเจ้าของ (user_id) ก่อน แล้วนับแต่ละกอง" · ได้ hash{user_id => จำนวน}· ทุกคนมี 2 บทความ · คำว่าAS "count_all"ใน SQL เป็นชื่อเล่นที่ Rails ตั้งไว้ใช้เอง ข้ามได้ · ลำดับใน hash ของคุณอาจไม่เหมือนนี้ (SQL ไม่ได้สั่งเรียง)group(:user_id).sum(:views)— ยอดวิวรวมต่อคน: มานะ (3) 510 · สมหญิง (2) 85 · สมชาย (1) 460
ขอบางช่องแต่ยังได้เป็น object Post ใช้ select — แต่ระวัง (ผลจริง):
wongnok-blog(dev):008> Post.select(:id, :title).first
Post Load (1.2ms) SELECT "posts"."id", "posts"."title" FROM "posts" ORDER BY "posts"."id" ASC LIMIT 1 /*application='WongnokBlog'*/
=> #<Post:0x000000012622a368 id: 1, title: "ต้มยำกุ้งน้ำข้น">
wongnok-blog(dev):009> Post.select(:id, :title).first.body
Post Load (2.3ms) SELECT "posts"."id", "posts"."title" FROM "posts" ORDER BY "posts"."id" ASC LIMIT 1 /*application='WongnokBlog'*/
(wongnok-blog):9:in '<main>': missing attribute 'body' for Post (ActiveModel::MissingAttributeError)
select(:id, :title)— "หยิบแฟ้มมา แต่กรอกแค่สองช่อง" · ได้Postที่มีแค่idกับtitle.body— ขอช่องที่ไม่ได้หยิบมา →missing attribute 'body'("ช่อง body ไม่มีในแฟ้มแผ่นนี้") · Rails ไม่ไปตู้ซ้ำให้เอง- ต่างจาก
pluck:selectได้ object (ใช้ใน view เหมือนแฟ้มปกติ) ·pluckได้ค่าดิบ
สุดท้าย ทวน update_all ที่เคยใช้ในภารกิจบท 111 — แก้หลายแถวในคำสั่งเดียว (ผลจริง):
wongnok-blog(dev):010> Post.where(published: false).update_all(views: 0)
Post Update All (4.7ms) UPDATE "posts" SET "views" = 0 WHERE "posts"."published" = FALSE /*application='WongnokBlog'*/
=> 2
- SQL ภาษาคน: "แก้ช่อง views ให้เป็น 0 ทุกแผ่นที่ยังไม่ published" · ได้
2= แก้ไป 2 แผ่น (draft สองชิ้น ซึ่งเป็น 0 อยู่แล้ว ข้อมูลเลยไม่เปลี่ยน) - ไปตู้ครั้งเดียว ไม่ว่าจะกี่แผ่น · ต่างจากวน
eachแล้วupdateทีละแผ่นที่ไปตู้แผ่นละรอบ - ⚠️ ข้ามคนดูแลสต็อกไปเลย — กฎตรวจแฟ้ม (validation บท 110) และขั้นตอนอัตโนมัติ (callback บท 112) ไม่ทำงาน และช่อง
updated_atก็ไม่ถูกแก้ให้ (เอกสาร API เขียนไว้) · ใช้เมื่อมั่นใจว่าค่าที่ใส่ถูกแน่
เทียบกับ Go: GORM มี Pluck("title", &titles) · Count(&n) คล้ายกัน ·
ส่วน sum/average ฝั่ง GORM ต้องเขียน Select("sum(views)") เอง · Rails มี method ตั้งชื่อไว้ให้
- pluck · select
pluck= ดึงค่าดิบของบางคอลัมน์ ไม่สร้าง object ·select= สร้าง object แต่โหลดแค่บางคอลัมน์ · ถาม senior ได้ว่า “หน้านี้ใช้แค่ title ควร pluck หรือ select ดี เพราะยังต้องใช้ link_to กับ post อยู่”- aggregate (sum · average · group)
- การให้ DB คำนวณสรุปจากหลายแถว (รวม · เฉลี่ย · นับแยกกลุ่ม) แทนการโหลดมาคำนวณใน Ruby · พิมพ์บอก AI ได้ว่า “rewrite this so the database does the aggregation instead of Ruby”
- update_all
- แก้หลายแถวด้วย
UPDATEประโยคเดียว · ข้าม validation และ callback · ถาม senior ได้ว่า “ใช้ update_all ตรงนี้ได้ไหม มี callback อะไรที่ต้องทำงานด้วยหรือเปล่า”
explain — แค่ให้เห็นว่ามีของแบบนี้
explain = ถาม PostgreSQL ว่า "ถ้าให้ทำใบเบิกนี้ จะไปหาแฟ้มยังไง" · ใส่ (:analyze) = ทำจริงแล้วจับเวลาด้วย ·
บทนี้แค่ให้เห็นหน้าตา ใช้จริงตอนเจอหน้าที่ช้า (ผลจริง):
wongnok-blog(dev):001> Post.where(published: true).explain(:analyze)
Post Load (1.0ms) SELECT "posts".* FROM "posts" WHERE "posts"."published" = TRUE /*application='WongnokBlog'*/
=>
EXPLAIN (ANALYZE) SELECT "posts".* FROM "posts" WHERE "posts"."published" = TRUE /*application='WongnokBlog'*/
QUERY PLAN
----------------------------------------------------------------------------------------------------
Seq Scan on posts (cost=0.00..16.30 rows=315 width=101) (actual time=0.008..0.009 rows=4 loops=1)
Filter: published
Rows Removed by Filter: 2
Planning Time: 0.038 ms
Execution Time: 0.015 ms
(5 rows)
Seq Scan on posts— "ไล่ดูทีละแผ่นทั้งลิ้นชัก" ·Filter: published= ดูทีละแผ่นว่า published ไหม ·Rows Removed by Filter: 2= ทิ้งไป 2 แผ่น (draft)actual … rows=4— ได้จริง 4 แผ่น · ส่วนrows=315ในวงเล็บแรกเป็นแค่การเดาล่วงหน้าของ PostgreSQL · ตัวเลขอื่นในวงเล็บข้ามได้Planning Time/Execution Time— เวลาที่ใช้วางแผน / ทำจริง (หน่วย ms)- ลิ้นชัก 6 แผ่นไล่ทีละแผ่นก็เร็ว · ถ้า 50 ล้านแผ่นจะช้ามาก — ทางแก้ที่พบบ่อยคือ index (สารบัญในลิ้นชัก บท 109) · ถ้า PostgreSQL เลือกใช้สารบัญนั้น explain จะเปลี่ยนจาก Seq Scan เป็นคำอื่น (เช่น Index Scan)
- สังเกตบรรทัด
Post Load—explainก็ยื่นใบจริงด้วย
🎯 ภารกิจ · เขียนใบเบิกของของสมชาย (~10 นาที)
ใช้ bin/rails console กับข้อมูลตัวอย่างชุดเดียวกับบท 107 · ทุกบรรทัดที่สร้างใบเบิกของ ต่อท้ายด้วย ; nil
- สร้าง
mine= บทความของสมชาย (user_id: 1) · เพิ่มเงื่อนไข published · เรียงยอดวิวมากไปน้อย — สามบรรทัด ต้องไม่เห็น SQL สักบรรทัด - พิมพ์
mine.to_sqlแล้วแปลเป็นภาษาคนในใจ - ขอแค่ชื่อเรื่อง เรียงตามที่ตั้งไว้ — ใช้ method ที่ไม่สร้าง object
Post - ยอดวิวรวมของบทความใน
mine - เช็คว่าสมหญิง (
user_id: 2) มีบทความที่ยังเป็น draft ไหม — ใช้วิธีที่ "เจอแผ่นแรกก็หยุด"
ควรเห็น: ข้อ 1 มีแต่ => nil · ข้อ 3 ได้ ["ส้มตำปูปลาร้า", "ต้มยำกุ้งน้ำข้น"] · ข้อ 4 ได้ 460 · ข้อ 5 ได้ true และ SQL มี LIMIT 1
ถ้าเจอ error นี้ แปลว่า…
- เห็น
Post Load … /* loading for pp */ LIMIT 11ในข้อ 1 — ไม่ใช่ error แต่ลืม; nil· console ยื่นใบให้เพื่อจะโชว์ผล PG::UndefinedColumn: ERROR: column "titel" does not exist— พิมพ์ชื่อคอลัมน์ผิด · อ่านบรรทัดHINT: Perhaps you meant …ข้างล่าง PostgreSQL เดาชื่อที่ถูกให้missing attribute 'body' for Post— ใช้select(:id, :title)แล้วไปขอช่องอื่น · เพิ่มช่องนั้นในselectหรือเลิกใช้select- ได้
[]หรือ0— เลขที่ของสมชายในเครื่องคุณอาจไม่ใช่ 1 (เช่น เคยลบแล้ว seed ใหม่) · เช็คด้วยUser.find_by(name: "สมชาย").id
ผลจริงทั้งหมด:
wongnok-blog(dev):001> mine = Post.where(user_id: 1); nil
=> nil
wongnok-blog(dev):002> mine = mine.where(published: true); nil
=> nil
wongnok-blog(dev):003> mine = mine.order(views: :desc); nil
=> nil
wongnok-blog(dev):004> mine.to_sql
=> "SELECT \"posts\".* FROM \"posts\" WHERE \"posts\".\"user_id\" = 1 AND \"posts\".\"published\" = TRUE ORDER BY \"posts\".\"views\" DESC"
wongnok-blog(dev):005> mine.pluck(:title)
Post Pluck (1.2ms) SELECT "posts"."title" FROM "posts" WHERE "posts"."user_id" = 1 AND "posts"."published" = TRUE ORDER BY "posts"."views" DESC /*application='WongnokBlog'*/
=> ["ส้มตำปูปลาร้า", "ต้มยำกุ้งน้ำข้น"]
wongnok-blog(dev):006> mine.sum(:views)
Post Sum (2.9ms) SELECT SUM("posts"."views") FROM "posts" WHERE "posts"."user_id" = 1 AND "posts"."published" = TRUE /*application='WongnokBlog'*/
=> 460
wongnok-blog(dev):007> Post.where(user_id: 2, published: false).exists?
Post Exists? (2.0ms) SELECT 1 AS one FROM "posts" WHERE "posts"."user_id" = 2 AND "posts"."published" = FALSE LIMIT 1 /*application='WongnokBlog'*/
=> true
- ข้อ 2 แปล: "ขอทุกช่องจากลิ้นชัก posts · เฉพาะแผ่นที่เจ้าของเลขที่ 1 และ published เป็นจริง · เรียงวิวมากไปน้อย" — สามบรรทัดกลายเป็น
ANDในประโยคเดียว - ข้อ 3:
pluckใช้ใบเบิกของเดิม แต่เปลี่ยนSELECT "posts".*เป็นSELECT "posts"."title"ให้เอง · เรียงตามORDER BYที่เขียนไว้ - ข้อ 4:
sumตัดORDER BYทิ้งให้เอง เพราะการรวมเลขไม่ต้องเรียง · 340 + 120 = 460 - ข้อ 5:
whereใส่สองเงื่อนไขในวงเล็บเดียวได้ ·exists?ได้LIMIT 1(ก๋วยเตี๋ยวเรืออยุธยาเป็น draft ของสมหญิง)
ลองต่อถ้ายังมีเวลา: พิมพ์ mine.count สองครั้งติดกัน แล้วพิมพ์ mine.size — นับดูว่าเห็น SQL กี่บรรทัด แล้วเทียบกับตารางในหัวข้อ count vs size
ผลของคุณไม่ตรงกับนี้ หรือเห็น SQL ในจังหวะที่ไม่คาดไว้ ถามกลับมาได้เลย พร้อมแปะทั้งก้อนที่ console ขึ้นมา
อยากรู้แค่ว่า "สมชายมีบทความบ้างไหม" (ไม่ต้องรู้ว่ากี่ชิ้น) · แบบไหนให้ตู้ทำงานน้อยที่สุด?
ตัวเลือกที่ 3 ถูก — SQL จริงคือ SELECT 1 AS one … LIMIT 1 เจอแผ่นแรกก็ตอบ ไม่ต้องหยิบแฟ้ม ไม่ต้องนับต่อ
ตัวเลือกที่ 1 กับ 4 ผิด เพราะหยิบทุกแผ่นของสมชายมาวางบนโต๊ะก่อน (SELECT "posts".*) แค่เพื่อตอบว่ามีหรือไม่ ·
ตัวเลือกที่ 2 ไม่ได้หยิบแฟ้ม (เป็น COUNT(*)) แต่ตู้ต้องนับให้ครบทุกแผ่นก่อน — ดีกว่า 1 และ 4 แต่ยังทำงานเกินจำเป็น
จุดที่ต้องจำ: ถามว่า "มีไหม" ใช้ exists? · ถาม "กี่ชิ้น" ใช้ count · อย่าขนแฟ้มมาเพื่อตอบคำถามที่ตอบเป็นตัวเลขได้
ใน console พิมพ์ q = Post.where(published: true) (ไม่มี ; nil) แล้วเห็น SQL ที่มี /* loading for pp */ LIMIT 11 · แปลว่าอะไร?
ตัวเลือกที่ 2 ถูก — console ต้องโชว์ผลหลัง => จึงไปตู้เพื่อ "pretty print" (pp) และขอแค่ 11 แผ่นกันตารางใหญ่ ·
ใน log ของหน้า /posts เห็นชัดว่า controller ที่มี where ไม่ได้ยิง SQL เลย
ตัวเลือกที่ 1 ผิด — ต่อท้าย ; nil แล้ว SQL หายไปเลย พิสูจน์ว่าไม่ใช่ where ที่ยิง ·
ตัวเลือกที่ 3 ผิด เพราะตารางเรามี 6 แถว แต่ยังได้ LIMIT 11 · ตัวเลือกที่ 4 ผิด เพราะในแอปจริงไม่มี LIMIT 11 โผล่ใน log
จุดที่ต้องจำ: ทดลองเรื่อง "ยิงตอนไหน" ใน console ต้องต่อ ; nil ไม่งั้นจะเห็นภาพผิด
สรุป 3 บรรทัด
where·order·limit= เขียนใบเบิกของ ·each·count·first·exists?·pluck·sum= ยื่นใบ- อยากรู้ว่าหน้าไหนไปตู้กี่รอบ อย่าเดาจากโค้ด — เปิด log ดูบรรทัด
(N queries)และ↳ที่ชี้ต้นเหตุ (นี่คือทักษะที่บท 114 ใช้หา N+1) - ให้ตู้ทำงานแทน: นับด้วย
count· ถามว่ามีไหมด้วยexists?· ขอบางช่องด้วยpluck· อย่าขนแฟ้มทั้งลิ้นชักมาทำใน Ruby
ถ้าเปิดสไลด์ดูประกอบ
| หน้า | สไลด์เขียน | ของจริง | ต้องสนใจไหม |
|---|---|---|---|
| 31 | พิมพ์ q = Post.where(…) ทีละบรรทัด "ยังไม่มี SQL วิ่งเลย" | ใน console เห็น SQL ทุกบรรทัด (loading for pp) · ต้องต่อ ; nil ถึงจะเห็นว่าไม่ยิง | สนใจ — พิมพ์ตามแล้วจะงง |
| 31 | เรียกใช้ relation เดิมซ้ำ "ยิง SQL ใหม่ทุกครั้ง" | จริงกับ count · แต่ each / first / size ครั้งที่สองบน relation ที่โหลดแล้วไม่ยิงซ้ำ | นิดหน่อย |
| 32 | ต้องเพิ่ม verbose_query_logs = true เอง | Rails 8.1 เปิดไว้ใน development แล้ว · เลขบรรทัดไฟล์ .erb เกินจริง 1 | สนใจ เรื่องเลขบรรทัด |
| 35 | Post.where(user_id: 1).count > 0 = "โหลดมาทั้งหมด" | ยิง COUNT(*) ไม่ได้โหลดแถว · exists? ยังดีกว่าเพราะหยุดที่แถวแรก | นิดหน่อย — ข้อสรุปถูก เหตุผลเกินจริง |
| 35 | "ใน view ที่วน each อยู่แล้ว ใช้ size" | ถูกเฉพาะ size ที่อยู่หลัง each · ถ้าอยู่บน each ยังยิง COUNT(*) | สนใจ |
| 35 | Post.where(old: true).update_all(…) | ตารางเราไม่มีช่อง old → PG::UndefinedColumn · บทนี้ใช้ published: false แทน | ไม่ต้อง — แค่ตัวอย่าง |
อ้างอิง
- Rails Guides — Active Record Query Interface § Calculations — อ่านหัวข้อ Calculations (count · average · sum) และหัวข้อ pluck · exists? · Running EXPLAIN · หัวข้อ joins / eager loading ข้ามไปก่อน (บท 114–115)
- Rails API — ActiveRecord::Relation#size —
ยืนยันพฤติกรรม "โหลดแล้วนับใน Ruby ยังไม่โหลดใช้ COUNT" · ในหน้าเดียวกันดู
load·to_sql·update_all(มีคำเตือนเรื่องข้าม callback/validation) - Rails Guides — Debugging § Verbose Query Logs — อ่านแค่หัวข้อนี้กับหัวข้อ SQL Query Comments ในหมวดเดียวกัน · ตัวอย่างในคู่มือเป็น N+1 พอดี ไว้อ่านคู่กับบท 114
- GORM — Method Chaining § Finisher Methods —
ฝั่ง Go: คู่มือบอกว่า
Find·Firstฯลฯ "immediate" คือยิง SQL ทันที · ต่างจาก Rails ที่ไม่มีคำสั่งปิดท้ายแบบนี้ - PostgreSQL 17 — EXPLAIN — ไว้อ่านทีหลังตอนเจอหน้าช้าจริง · อ่านแค่ย่อหน้า Description พอให้รู้ว่า ANALYZE รันคำสั่งจริง
- rack-mini-profiler (GitHub) — gem ที่สไลด์แนะนำ ขึ้นกล่องนับ query มุมจอ · บทนี้ไม่ได้ติดตั้ง อ่าน README ส่วนแรกพอรู้จัก