Subquery และ CTE
ประมาณ 30 นาที
เป้าหมายของบท
- ใช้ scalar/correlated subquery เทียบค่าของแถวกับค่าที่คำนวณแยกไว้
- กรองด้วย IN / EXISTS / NOT EXISTS และรู้กับดักของ NOT IN เมื่อข้อมูลมี NULL
- ใช้ CTE (WITH) ทำให้ query หลายขั้นตอนอ่านง่ายขึ้น
Subquery คืออะไร
Subquery คือ query ที่ซ้อนอยู่ในอีก query หนึ่ง ใช้ตอนต้องการค่าที่คำนวณแยกออกไปก่อน เช่น “ราคาเฉลี่ยของทั้งร้าน” แล้วเอามาเทียบกับแต่ละแถว:
SELECT product_name, price FROM products
WHERE price > (SELECT AVG(price) FROM products)
Correlated subquery
Subquery แบบพิเศษที่อ้างอิงค่าจากแถวข้างนอก เรียกว่า correlated subquery — มันจะถูกคำนวณใหม่ทุกแถว ต่างจาก subquery ธรรมดาที่คำนวณครั้งเดียวแล้วได้ค่าคงที่ตัวเดียว:
SELECT product_name, category, price FROM products p1
WHERE price > (SELECT AVG(price) FROM products p2 WHERE p2.category = p1.category)
สังเกต p2.category = p1.category — นี่คือจุดที่ subquery ข้างในอ้างอิงตาราง p1 จากข้างนอก
ทำให้ค่าเฉลี่ยที่คำนวณเปลี่ยนไปตามหมวดของแต่ละแถว
IN, EXISTS และกับดักของ NOT IN
| รูปแบบ | ความหมาย |
|---|---|
col IN (subquery) |
ค่าตรงกับค่าใดค่าหนึ่งที่ subquery คืนมา |
EXISTS (correlated subquery) |
มีแถวที่จับคู่เงื่อนไขได้ไหม (ไม่สนใจค่าจริงของแถวนั้น) |
NOT EXISTS (correlated subquery) |
ไม่มีแถวที่จับคู่เงื่อนไขได้เลย |
กับดักสำคัญ: NOT IN (subquery) ถ้า subquery มีค่า NULL ปนอยู่แม้แค่ตัวเดียว
ผลลัพธ์ทั้งหมดจะกลายเป็นว่างเปล่า (ไม่มีแถวไหนผ่านเงื่อนไขเลย) เพราะการเทียบ <> กับ NULL
ได้ผลเป็น “ไม่รู้” (unknown) เสมอ ไม่ใช่ true หรือ false
ทางที่ปลอดภัยกว่า: ใช้ NOT EXISTS แทน หรือถ้าจะใช้ NOT IN ต่อไป ต้องกรอง NULL ออกจาก subquery
ด้วย IS NOT NULL ก่อนเสมอ
CTE (Common Table Expression)
เขียนด้วย WITH — ตั้งชื่อผลลัพธ์ของ query ย่อยไว้ก่อน แล้วค่อยใช้ชื่อนั้นต่อในคำสั่งหลัก
ทำให้ query ที่มีหลายขั้นตอนอ่านง่ายขึ้นมาก เมื่อเทียบกับการซ้อน subquery หลายชั้น:
WITH order_total AS (
SELECT order_id, SUM(quantity * unit_price) AS total FROM order_items GROUP BY order_id
)
SELECT * FROM order_total WHERE total > 10000
ตั้ง CTE ได้หลายตัวในคำสั่งเดียว คั่นด้วยจุลภาค และ CTE ตัวหลังอ้างอิง CTE ตัวก่อนหน้าได้ด้วย:
WITH a AS (...), b AS (SELECT * FROM a WHERE ...)
SELECT * FROM bตัวอย่างแก้โจทย์
กด "ขั้นต่อไป" ทีละขั้น ลองรันและแก้โค้ดในแต่ละขั้นได้
สินค้าที่ราคาแพงกว่าราคาเฉลี่ยของหมวดตัวเอง
ทีมสินค้าอยากรู้ว่าในแต่ละหมวด มีสินค้าตัวไหนที่ราคาสูงกว่าค่าเฉลี่ยของหมวดนั้นเอง (ไม่ใช่เฉลี่ยทั้งร้าน)
ใช้ correlated subquery — subquery ข้างในอ้างอิงถึงแถวข้างนอก (
p1.category) ทำให้คำนวณค่าเฉลี่ยใหม่ทุกครั้งตามหมวดของแถวนั้นๆ
ลูกค้าที่เคยมีออเดอร์ถูกยกเลิก — เขียนได้ 2 แบบ
อยากนับลูกค้าที่เคยมีออเดอร์สถานะ cancelled อย่างน้อยหนึ่งครั้ง เปรียบเทียบ 2 วิธีเขียน
แบบที่ 1:
INกับ subquery ที่คืนลิสต์ของ customer_id
เติมช่องว่าง
แทน ___ ด้วยโค้ดที่ถูกต้อง แล้วกดตรวจคำตอบ
ใช้ CTE หาจำนวนออเดอร์ต่อสถานะก่อน แล้วเลือกเฉพาะสถานะที่มีมากกว่า 400 ออเดอร์
เติม ___ ให้ครบ
แบบฝึกท้ายบท
บทนี้จะนับว่าเสร็จเมื่อผ่านข้อพื้นฐานและประยุกต์ครบ ข้อท้าทายได้ดาวเพิ่ม
🟢 พื้นฐาน
จำนวนลูกค้าที่ไม่เคยมีออเดอร์เลย (ใช้ subquery และ NOT IN)
จำนวนสินค้าที่ราคาสูงกว่าราคาเฉลี่ยของสินค้าทั้งหมด (ไม่แยกหมวด)
จำนวนลูกค้าที่เคยสั่งซื้อด้วย credit_card (ใช้ EXISTS)
ใช้ CTE หารายได้ (quantity × unit_price) ต่อลูกค้าก่อน (เฉพาะ completed) แล้วนับว่ามีกี่คนที่รายได้เกิน 50000 บาท
🟡 ประยุกต์
จำนวนสินค้าที่ราคาต่ำกว่าราคาเฉลี่ยของหมวดตัวเอง (correlated subquery, ตรงข้ามกับตัวอย่าง x1)
จำนวนลูกค้าที่ไม่เคยมีออเดอร์ยกเลิกเลยสักครั้ง (ใช้ NOT EXISTS)
ใช้ CTE หา 2 อย่าง (รายได้รวมแต่ละหมวด และจำนวนสินค้าในหมวด) แล้วนับว่ามีกี่หมวดที่ "รายได้ ÷ จำนวนสินค้า" มากกว่า 100000 บาท
🔴 ท้าทาย
จำนวนลูกค้าที่ไม่เคยมีออเดอร์ยกเลิกเลย แต่มีออเดอร์อย่างน้อย 3 ครั้ง (รวมเทคนิค subquery/CTE เข้ากับ GROUP BY/HAVING)
Quiz
สรุป
- correlated subquery คำนวณค่าใหม่ทุกแถวโดยอ้างอิงค่าจากแถวข้างนอก (เช่น เฉลี่ยของหมวดตัวเอง)
- NOT IN อันตรายเมื่อ subquery มี NULL ปน (ได้ผลว่างเปล่าเสมอ) ใช้ NOT EXISTS แทนจะปลอดภัยกว่า
- CTE (WITH) ทำให้ query หลายขั้นตอนอ่านง่ายขึ้น โดยตั้งชื่อผลลัพธ์แต่ละขั้นไว้ก่อน
คำถามสัมภาษณ์
ทำไม NOT IN ถึงอันตรายเมื่อ subquery มี NULL ควรใช้อะไรแทน
NOT IN (subquery) เทียบค่าของแถวปัจจุบันกับทุกค่าที่ subquery คืนมาทีละตัวด้วย <>
ถ้าใน subquery มีแม้แต่ค่า NULL ปนอยู่ค่าเดียว การเทียบ x <> NULL จะได้ unknown เสมอ
และเมื่อรวม unknown เข้ากับผลลัพธ์อื่นด้วย AND ทั้งชุดจะกลายเป็น unknown (ไม่ผ่านเงื่อนไข) ทุกแถวไปเลย
แม้ว่าจริงๆ แถวนั้นควรจะผ่านก็ตาม
ทางแก้ที่ปลอดภัยกว่าคือใช้ NOT EXISTS แทน เพราะมันแค่เช็คว่า "มีแถวที่จับคู่ได้ไหม" ไม่ได้เทียบค่าทีละตัว
จึงไม่มีปัญหานี้ หรือถ้าจะใช้ NOT IN ต่อไป ต้องกรอง NULL ออกจาก subquery ด้วย IS NOT NULL เสมอ