วัตถุประสงค์การเรียนรู้
เมื่อจบบทนี้ ผู้เรียนจะสามารถ
- เขียน subquery แบบต่าง ๆ ได้
- เลือกใช้ subquery ให้สอดคล้องกับคำถามธุรกิจ เช่น เปรียบเทียบกับค่าเฉลี่ย เลือกกลุ่มลูกค้าเฉพาะ หรือเช็กว่ามีพฤติกรรมบางอย่างเกิดขึ้นหรือไม่
บทนำ
คำถามทางธุรกิจจำนวนมากไม่สามารถตอบได้จากการดูข้อมูลดิบเพียงอย่างเดียว แต่ต้องเริ่มจากการ “คำนวณค่าอ้างอิง” ขึ้นมาก่อน แล้วจึงนำค่านั้นมาใช้เปรียบเทียบ คัดเลือก หรืออธิบายข้อมูลที่สนใจ ตัวอย่างเช่น สาขาใดมีค่าใช้จ่ายเฉลี่ยต่อออเดอร์ (AOV) สูงกว่าค่าเฉลี่ยของทั้งกิจการ หรือลูกค้ารายใดเคยสั่ง milk tea แต่ไม่เคยสั่ง fruit tea เลย การเขียนคำสั่งลักษณะนี้ใน SQL ภายในคำสั่งเดียวจึงมักต้องอาศัย subquery เข้ามาช่วย
Subquery คือคำสั่ง SQL ที่ถูกเขียนซ้อนอยู่ภายในอีกคำสั่งหนึ่ง โดยผลลัพธ์จาก subquery จะถูกนำไปใช้ต่อใน main query เพื่อช่วยคำนวณ เปรียบเทียบ หรือกรองข้อมูลตามเงื่อนไขที่ซับซ้อนขึ้น
ในทางปฏิบัติ subquery สามารถส่งผลลัพธ์กลับมาได้หลายรูปแบบ ขึ้นอยู่กับว่าผลลัพธ์นั้นจะถูกนำไปใช้ใน main query อย่างไร หากพิจารณาตามลักษณะของผลลัพธ์ สามารถแบ่งได้เป็น 3 ประเภทหลัก
- Scalar subquery ส่งผลลัพธ์กลับมาเป็นค่าเดียว เช่น การหาค่าเฉลี่ยยอดขายของทั้งกิจการ แล้วนำค่านั้นไปเปรียบเทียบกับยอดขายของแต่ละสาขา
- Column subquery ส่งผลลัพธ์กลับมาเป็นคอลัมน์เดียวที่มีหลายค่า เช่น การหารายชื่อลูกค้าที่เคยสั่งเครื่องดื่มประเภทหนึ่ง แล้วใช้รายการนั้นร่วมกับ
INหรือNOT INเพื่อกรองข้อมูล - Table subquery ส่งผลลัพธ์กลับมาในลักษณะของตารางย่อย ซึ่งสามารถนำไปใช้ต่อใน
FROMหรือเชื่อมกับ query อื่นเพื่อการวิเคราะห์เพิ่มเติม
นอกจากการแบ่งตามรูปแบบผลลัพธ์แล้ว ยังมีอีกแนวคิดหนึ่งที่สำคัญ คือการแบ่งตาม “ความสัมพันธ์กับ query ภายนอก” ในกรณีนี้ subquery อาจเป็น correlated subquery ได้ หากภายใน subquery มีการอ้างอิงคอลัมน์จากแถวปัจจุบันของ main query ทำให้ subquery ไม่ได้ประมวลผลเพียงครั้งเดียว แต่จะถูกประเมินใหม่ตามบริบทของแต่ละแถว ตัวอย่างที่พบบ่อยคือการใช้ร่วมกับ EXISTS หรือ NOT EXISTS เพื่อตรวจสอบว่าพฤติกรรมบางอย่างเคยเกิดขึ้นหรือไม่ในระดับรายลูกค้า
ในบทนี้ เราจะเน้นการใช้ subquery ในรูปแบบที่พบได้บ่อยในงานวิเคราะห์ข้อมูลเชิงธุรกิจ ได้แก่ scalar subquery, column subquery ที่ใช้ร่วมกับ IN / NOT IN, และ correlated subquery ที่ใช้ร่วมกับ EXISTS / NOT EXISTS ซึ่งครอบคลุมโจทย์สำคัญจำนวนมากที่นักวิเคราะห์ข้อมูลต้องเผชิญในทางปฏิบัติ ส่วนการใช้ subquery ในลักษณะของตารางย่อยจะขยายความต่อในบทถัดไปผ่านแนวคิดของ Common Table Expressions หรือ CTE
Scalar subquery
เริ่มจากกรณีพื้นฐานที่สุดของ subquery นั่นคือการใช้ผลลัพธ์เพียง “ค่าเดียว” เป็นค่าอ้างอิงหรือ benchmark เพื่อนำไปเปรียบเทียบกับแถวหรือกลุ่มข้อมูลอื่นในตาราง แนวทางนี้เหมาะอย่างยิ่งสำหรับคำถามทางธุรกิจที่ต้องการพิจารณาว่าค่าของรายการใด “สูงกว่า” หรือ “ต่ำกว่า” ระดับมาตรฐานที่กำหนดไว้
ลองพิจารณาคำถามว่า “ออเดอร์ใดมีมูลค่าสูงกว่าค่าเฉลี่ยทั้งกิจการ” คำถามนี้มีเป้าหมายเพื่อระบุออเดอร์ที่มีมูลค่าสูงกว่าระดับเฉลี่ยของธุรกิจทั้งหมด เพื่อนำไปใช้วิเคราะห์กลุ่มออเดอร์มูลค่าสูง (high-value orders) และศึกษาต่อว่าออเดอร์เหล่านี้มักเกิดขึ้นในสาขาใด หรือผ่านช่องทางการขายใดเป็นพิเศษ
หากยังไม่ใช้ subquery วิธีคิดแบบตรงไปตรงมาคือ ต้องคำนวณค่าเฉลี่ยของมูลค่าออเดอร์ทั้งกิจการออกมาก่อน แล้วจึงนำค่าที่ได้ไปใช้เป็นเงื่อนไขในการกรองข้อมูล
เมื่อได้ค่าเฉลี่ยแล้ว ขั้นตอนถัดไปคือการนำค่าดังกล่าวมาใส่ในเงื่อนไข WHERE เพื่อเลือกเฉพาะออเดอร์ที่มีมูลค่าสูงกว่าค่าเฉลี่ยของทั้งกิจการ
-- ดึงออเดอร์ที่มีรายได้สุทธิสูงกว่าค่าเฉลี่ย (202.30 บาท) 10 อันดับแรก
SELECT
transaction_id, -- รหัสธุรกรรม
store_id, -- รหัสสาขา
channel, -- ช่องทางการสั่งซื้อ
net_sales_thb, -- รายได้สุทธิ (บาท)
contribution_margin_thb -- Contribution Margin (บาท)
FROM transactions
WHERE net_sales_thb > 202.30297395120368 -- กรองเฉพาะออเดอร์ที่มีรายได้สูงกว่าค่าเฉลี่ย
ORDER BY net_sales_thb DESC -- เรียงจากรายได้สูงสุดไปต่ำสุด
LIMIT 10; -- แสดงเพียง 10 อันดับแรกแนวทางนี้ช่วยให้เห็นหลักการทำงานได้ชัดเจน แต่มีข้อจำกัดตรงที่ผู้ใช้ต้องนำค่าที่คำนวณได้มาใส่เองในคำสั่งหลัก ซึ่งทำให้ query ไม่ยืดหยุ่น และไม่เหมาะเมื่อข้อมูลมีการเปลี่ยนแปลงอยู่ตลอดเวลา ด้วยเหตุนี้ subquery จึงมีประโยชน์อย่างมาก เพราะช่วยให้เราคำนวณค่าเฉลี่ยและนำไปใช้เปรียบเทียบได้ภายในคำสั่งเดียว
หากใช้ subquery เราสามารถนำคำสั่ง SELECT AVG(net_sales_thb) FROM transactions ไปวางไว้ภายในเงื่อนไข WHERE ได้โดยตรง โดย subquery ต้องเขียนอยู่ภายในวงเล็บ เพื่อให้ระบบประมวลผลค่าที่ได้จากคำสั่งย่อยก่อน แล้วจึงนำผลลัพธ์นั้นมาใช้เปรียบเทียบกับค่า net_sales_thb ของแต่ละออเดอร์
การเขียนในลักษณะนี้ช่วยให้ query มีความยืดหยุ่นมากขึ้น เพราะไม่จำเป็นต้องคำนวณค่าเฉลี่ยแยกออกมาก่อนแล้วค่อยนำค่าที่ได้มาใส่ด้วยตนเอง อีกทั้งยังทำให้คำสั่งสามารถปรับตามข้อมูลที่เปลี่ยนแปลงอยู่เสมอได้โดยอัตโนมัติ ซึ่งเป็นข้อได้เปรียบสำคัญของ scalar subquery ในงานวิเคราะห์ข้อมูล
-- ดึงออเดอร์ที่มีรายได้สุทธิสูงกว่าค่าเฉลี่ยของทั้งระบบ 10 อันดับแรก
SELECT
transaction_id, -- รหัสธุรกรรม
store_id, -- รหัสสาขา
channel, -- ช่องทางการสั่งซื้อ
net_sales_thb, -- รายได้สุทธิ (บาท)
contribution_margin_thb -- Contribution Margin (บาท)
FROM transactions
WHERE net_sales_thb > ( -- กรองเฉพาะออเดอร์ที่มีรายได้สูงกว่าค่าเฉลี่ย
SELECT AVG(net_sales_thb) FROM transactions -- Subquery คำนวณค่าเฉลี่ยรายได้สุทธิของทุกออเดอร์
)
ORDER BY net_sales_thb DESC -- เรียงจากรายได้สูงสุดไปต่ำสุด
LIMIT 10; -- แสดงเพียง 10 อันดับแรก| transaction_id | store_id | channel | net_sales_thb | contribution_margin_thb |
|---|---|---|---|---|
| 41627 | 6 | delivery | 695 | 260.77 |
| 46797 | 2 | delivery | 640 | 273.07 |
| 48503 | 2 | delivery | 634 | 261.58 |
| 28940 | 6 | delivery | 633 | 287.03 |
| 52852 | 5 | delivery | 630 | 268.11 |
| 17854 | 1 | delivery | 628 | 249.89 |
| 39100 | 6 | delivery | 627 | 281.38 |
| 14594 | 5 | delivery | 618 | 232.12 |
| 27651 | 5 | delivery | 615 | 257.33 |
| 50221 | 2 | delivery | 612 | 261.81 |
ผลลัพธ์ที่ได้แสดงให้เห็นว่า ออเดอร์ที่สูงกว่าค่าเฉลี่ย 10 อันดับแรกทั้งหมดเป็น delivery มี net_sales_thb ตั้งแต่ 612–695 บาท สูงกว่าค่าเฉลี่ย 3–3.4 เท่า ทุกออเดอร์มี basket_qty = 4 (เต็มตะกร้า) และสาขา 6 (ICONSIAM) ปรากฏ 3 ครั้งใน Top 10
ออเดอร์มูลค่าสูงทั้งหมดมาจาก delivery channel เท่านั้น ไม่มี dine_in หรือ to_go เลย สะท้อนว่าลูกค้า delivery มีแนวโน้มสั่งหลายแก้วต่อออเดอร์ ในขณะที่ dine_in/to_go มักสั่งแก้วเดียวหรือสองแก้วเป็นหลัก
high-value orders กระจุกอยู่ใน delivery หมายความว่า PiCha กำลังพึ่งพาแพลตฟอร์ม delivery สำหรับรายได้ก้อนใหญ่ แต่ก็ต้องจ่ายค่าคอมมิชชัน 17–23% ด้วย ถ้าแพลตฟอร์มมีปัญหาหรือขึ้นราคา PiCha จะเสีย revenue stream สำคัญ ทางเลือกหนึ่งคือสร้าง incentive ให้ลูกค้าสั่ง “full basket” (4 items) ผ่าน direct ordering เพื่อลดการพึ่งพาแพลตฟอร์ม แต่ก่อนตัดสินใจควรวิเคราะห์ก่อนว่าลูกค้า delivery มีพฤติกรรมสั่งรวม (group order) หรือเป็นการซื้อส่วนตัวจริง เพราะกลยุทธ์จะต่างกัน
ตัวอย่างแรกแสดงการใช้ scalar subquery เพื่อเปรียบเทียบออเดอร์แต่ละรายการกับค่าเฉลี่ยของทั้งกิจการ ส่วนตัวอย่างถัดไปยังคงใช้แนวคิดเดียวกัน แต่ขยับจากระดับรายออเดอร์ไปสู่ระดับรายสาขา โดยนำค่าเฉลี่ยของทั้งกิจการมาแสดงควบคู่กับค่าเฉลี่ยต่อออเดอร์ของแต่ละสาขา คำถามนี้มีเป้าหมายเพื่อเปรียบเทียบประสิทธิภาพของแต่ละสาขากับมาตรฐานโดยรวมของกิจการ ช่วยให้ระบุได้ว่าสาขาใดมีผลการดำเนินงานสูงกว่าค่าเฉลี่ย และสาขาใดมีผลการดำเนินงานต่ำกว่าระดับที่ควรเป็น
-- เปรียบเทียบค่าเฉลี่ยต่อออเดอร์ของแต่ละสาขากับค่าเฉลี่ยรวมทั้งกิจการ
SELECT
s.store_name, -- ชื่อสาขา
COUNT(*) AS order_count, -- จำนวนออเดอร์ของสาขานั้น
ROUND(AVG(t.net_sales_thb), 0) AS store_avg_aov, -- ค่าเฉลี่ยต่อออเดอร์ของสาขา (บาท)
ROUND((SELECT AVG(net_sales_thb) FROM transactions), 0) AS company_avg_aov, -- ค่าเฉลี่ยต่อออเดอร์รวมทั้งกิจการ (Scalar Subquery)
ROUND(AVG(t.net_sales_thb) - (SELECT AVG(net_sales_thb) FROM transactions), 0) AS diff_from_avg -- ส่วนต่างระหว่างค่าเฉลี่ยสาขากับค่าเฉลี่ยกิจการ (บาท)
FROM transactions t
JOIN stores s ON t.store_id = s.store_id -- เชื่อมตารางสาขาเพื่อดึงชื่อสาขา
GROUP BY s.store_name -- จัดกลุ่มตามสาขา
ORDER BY store_avg_aov DESC; -- เรียงจากสาขาที่มี AOV สูงสุดไปต่ำสุดคำสั่งนี้ใช้ scalar subquery เพื่อคำนวณค่าเฉลี่ยต่อออเดอร์ของทั้งกิจการ แล้วนำค่าดังกล่าวมาใช้เป็น benchmark สำหรับเปรียบเทียบกับค่าเฉลี่ยของแต่ละสาขา ผลลัพธ์ที่ได้จึงไม่ได้บอกเพียงว่าสาขาใดมี AOV เท่าใดเท่านั้น แต่ยังช่วยให้เห็นด้วยว่าสาขานั้นสูงหรือต่ำกว่าค่าเฉลี่ยรวมของกิจการอยู่มากน้อยเพียงใด
| store_name | order_count | store_avg_aov | company_avg_aov | diff_from_avg |
|---|---|---|---|---|
| PiCha Siam Square | 10,738 | 215 | 202 | 13 |
| PiCha centralwOrld | 11,935 | 205 | 202 | 3 |
| PiCha ICONSIAM | 8,702 | 203 | 202 | 0 |
| PiCha Vanit Village | 7,257 | 202 | 202 | 0 |
| PiCha Park Silom | 7,611 | 191 | 202 | -12 |
| PiCha Exchange Tower | 7,080 | 190 | 202 | -12 |
ผลลัพธ์แสดงให้เห็นว่า Siam Square มี AOV สูงสุด 215 บาท สูงกว่าค่าเฉลี่ยกิจการ 13 บาท (ประมาณ 6.4%) ในขณะที่ Park Silom และ Exchange Tower ต่ำกว่าค่าเฉลี่ย 12 บาท (ประมาณ 6%) ICONSIAM และ Vanit Village ใกล้เคียงค่าเฉลี่ยที่ 202–203 บาท
สาขาในเขต Pathum Wan (Siam Square, centralwOrld) มี AOV สูงกว่าสาขา office อย่างชัดเจน ส่วนต่าง 13 vs -12 บาท สะท้อนกำลังซื้อที่แตกต่างกันตามประเภทพื้นที่
จากการวิเคราะห์จะพบว่า สาขา office มีโอกาสเพิ่ม AOV ได้ผ่าน upsizing และ add-on recommendations เพราะลูกค้าออฟฟิศอาจสั่งเพียงแก้วเดียวตอนพักกลางวัน การเสนอ combo เช่น “Office Lunch Combo” (ชา + ขนม) ที่ Park Silom และ Exchange Tower อาจช่วยเพิ่ม AOV จาก 190 เป็น 200 บาทได้ ก่อนตัดสินใจ ควรตรวจสอบก่อนว่า AOV ต่ำของสาขา office เป็นเพราะพฤติกรรมการสั่งครั้งเดียวต่อมื้อ หรือเพราะเมนูที่เลือกมีราคาต่ำกว่าสาขาอื่น
เมื่อเข้าใจการใช้ scalar subquery ซึ่งให้ผลลัพธ์เป็น “ค่าเดียว” แล้ว ขั้นถัดไปคือการขยับไปสู่ subquery ที่คืนผลลัพธ์เป็น “หลายค่า” ในคอลัมน์เดียว โดยมักใช้งานร่วมกับ IN และ NOT IN เพื่อใช้ตรวจสอบการเป็นสมาชิกของข้อมูลในชุดที่สนใจ
Column subquery
Column subquery เหมาะสำหรับคำถามที่ต้องการตรวจสอบว่า “ข้อมูลรายการใดอยู่ในชุดที่สนใจ” หรือ “ไม่ได้อยู่ในชุดนั้น” โดย subquery จะคืนผลลัพธ์เป็นคอลัมน์เดียวหลายแถว แล้วให้ query ภายนอกใช้ IN หรือ NOT IN เพื่อตรวจสอบการเป็นสมาชิกของค่าในชุดดังกล่าว
ตัวอย่างในหัวข้อนี้ต้องการระบุว่าลูกค้ากลุ่มใดเคยสั่งเมนู signature ซึ่งเป็นเมนูเด่นของแบรนด์ เพื่อประเมินว่าเมนู signature เข้าถึงลูกค้าได้อย่างทั่วถึง หรือกระจุกตัวอยู่ในลูกค้าบางกลุ่มเท่านั้น ในกรณีนี้ subquery จะสร้างรายการ customer_id ของลูกค้าที่เคยสั่งเมนู signature แล้ว query ภายนอกจึงนำรายการนั้นมาใช้กรองข้อมูลลูกค้า
-- ดึงข้อมูลลูกค้าที่เคยสั่งเมนู Signature อย่างน้อยหนึ่งครั้ง
SELECT DISTINCT
c.customer_id, -- รหัสลูกค้า
c.age_group, -- กลุ่มอายุ
c.loyalty_tier, -- ระดับสมาชิก
c.discovery_channel -- ช่องทางที่ลูกค้าค้นพบแบรนด์
FROM customers c
WHERE c.customer_id IN ( -- กรองเฉพาะลูกค้าที่ปรากฏใน Subquery
SELECT t.customer_id -- รหัสลูกค้าที่สั่งเมนู Signature
FROM transactions t
JOIN order_items oi ON t.transaction_id = oi.transaction_id -- เชื่อมตารางรายการสินค้าในออเดอร์
JOIN menus m ON oi.sku_id = m.sku_id -- เชื่อมตารางเมนูเพื่อตรวจสอบ signature_flag
WHERE m.signature_flag = 1 -- กรองเฉพาะเมนูที่เป็น Signature
AND t.customer_id IS NOT NULL -- ไม่รวมออเดอร์ที่ไม่มีข้อมูลลูกค้า
)
ORDER BY c.customer_id -- เรียงตามรหัสลูกค้า
LIMIT 15; -- แสดงเพียง 15 แถวแรก| customer_id | age_group | loyalty_tier | discovery_channel |
|---|---|---|---|
| 1000 | Millennial_30_35 | silver | |
| 1001 | Gen_Z_18_24 | none | delivery_app |
| 1002 | Gen_Z_25_29 | member | tiktok |
| 1003 | Gen_Z_25_29 | none | tiktok |
| 1004 | Gen_Z_18_24 | silver | delivery_app |
| 1005 | Gen_Z_25_29 | silver | delivery_app |
| 1006 | Gen_Z_25_29 | silver | |
| 1007 | Millennial_30_35 | silver | walk_in |
| 1008 | Millennial_30_35 | gold | word_of_mouth |
| 1009 | Gen_Z_18_24 | gold | tiktok |
| 1010 | Gen_Z_18_24 | silver | walk_in |
| 1011 | Gen_Z_25_29 | member | tiktok |
| 1012 | Gen_Z_18_24 | member | delivery_app |
| 1013 | Older_36plus | member | |
| 1014 | Millennial_30_35 | member | tiktok |
ผลลัพธ์ 15 แถวแรกเผยให้เห็นว่าลูกค้าที่สั่ง signature drinks ไม่ได้กระจุกอยู่เฉพาะใน gold tier โดยครอบคลุมทุก age_group และช่องทางการค้นพบ ตั้งแต่ TikTok ไปจนถึง walk_in ในแง่ธุรกิจ เมนู signature จึงทำหน้าที่เป็น “brand ambassador” ที่เข้าถึงทุกกลุ่ม ไม่จำกัดเฉพาะลูกค้าระดับสูง การโปรโมตผ่านทุกช่องทาง โดยเฉพาะ TikTok ที่ปรากฏบ่อยในกลุ่มตัวอย่าง อาจช่วยขยายฐานลูกค้าได้ดี อย่างไรก็ตาม ตัวอย่าง 15 คนแรกยังไม่เพียงพอสำหรับสรุปในระดับประชากร ก่อนจัดสรรงบโปรโมต ควร query จำนวนลูกค้าทั้งหมดแยกตาม loyalty_tier เพื่อยืนยันว่าการกระจายตัวนี้เป็นจริงในข้อมูลชุดเต็ม
คำถามต่อยอดที่ซับซ้อนขึ้นอาจเป็นดังนี้ “ลูกค้ากลุ่มใด (loyalty tier × age group) เป็น Milk Tea Purists มากที่สุด และมีศักยภาพสำหรับการ cross-sell ไปยัง fruit_tea ได้บ้าง”
ในการตอบคำถามนี้ เราจะใช้ทั้ง IN และ NOT IN ร่วมกัน เพื่อระบุกลุ่มลูกค้าที่ เคยสั่ง milk_tea แต่ไม่เคยสั่ง fruit_tea เลย ตลอดช่วงเวลาของข้อมูล ลูกค้ากลุ่มนี้สามารถตีความได้ว่าเป็น “Milk Tea Purists” และอาจเป็นกลุ่มเป้าหมายที่เหมาะสำหรับการออกแบบแคมเปญ cross-sell ไปยังเมนูชาผลไม้ในอนาคต
จากนั้นเราจะสรุปจำนวนลูกค้าในกลุ่มดังกล่าวโดยจำแนกตาม loyalty_tier และ age_group เพื่อดูว่ากลุ่มใดมีสัดส่วนของ Milk Tea Purists มากที่สุด
-- นับจำนวนลูกค้าที่สั่งชานม (milk_tea) แต่ไม่เคยสั่งชาผลไม้ (fruit_tea) แยกตามระดับสมาชิกและกลุ่มอายุ
SELECT
c.loyalty_tier, -- ระดับสมาชิก
c.age_group, -- กลุ่มอายุ
COUNT(DISTINCT c.customer_id) AS num_customers -- จำนวนลูกค้าที่ไม่ซ้ำในกลุ่มนั้น
FROM customers c
WHERE c.customer_id IN ( -- กรองเฉพาะลูกค้าที่เคยสั่ง milk_tea
SELECT t.customer_id -- รหัสลูกค้าที่สั่งเมนูในกลุ่ม milk_tea
FROM transactions t
JOIN order_items oi ON t.transaction_id = oi.transaction_id -- เชื่อมตารางรายการสินค้า
JOIN menus m ON oi.sku_id = m.sku_id -- เชื่อมตารางเมนูเพื่อตรวจสอบ family
WHERE m.family = 'milk_tea' -- กรองเฉพาะเมนูกลุ่มชานม
AND t.customer_id IS NOT NULL -- ไม่รวมออเดอร์ที่ไม่มีข้อมูลลูกค้า
)
AND c.customer_id NOT IN ( -- ตัดออกลูกค้าที่เคยสั่ง fruit_tea ด้วย
SELECT t.customer_id -- รหัสลูกค้าที่สั่งเมนูในกลุ่ม fruit_tea
FROM transactions t
JOIN order_items oi ON t.transaction_id = oi.transaction_id -- เชื่อมตารางรายการสินค้า
JOIN menus m ON oi.sku_id = m.sku_id -- เชื่อมตารางเมนูเพื่อตรวจสอบ family
WHERE m.family = 'fruit_tea' -- กรองเฉพาะเมนูกลุ่มชาผลไม้
AND t.customer_id IS NOT NULL -- ไม่รวมออเดอร์ที่ไม่มีข้อมูลลูกค้า
)
GROUP BY c.loyalty_tier, c.age_group -- จัดกลุ่มตามระดับสมาชิกและกลุ่มอายุ
ORDER BY num_customers DESC; -- เรียงจากกลุ่มที่มีลูกค้ามากสุดไปน้อยสุดคำสั่งนี้สะท้อนการใช้ column subquery ในขั้นที่ซับซ้อนขึ้น เพราะไม่ได้เพียงตรวจว่าลูกค้า “อยู่ใน” ชุดใดชุดหนึ่งเท่านั้น แต่ยังใช้ NOT IN เพื่อตัดลูกค้าที่มีพฤติกรรมอีกแบบออกไปด้วย ผลลัพธ์ที่ได้จึงช่วยให้ธุรกิจมองเห็นกลุ่มลูกค้าที่มีความชอบชัดเจนต่อ milk tea และสามารถนำไปใช้วางแผนการ cross-sell ได้อย่างตรงเป้าหมายมากขึ้น
| loyalty_tier | age_group | num_customers |
|---|---|---|
| member | Gen_Z_25_29 | 58 |
| member | Gen_Z_18_24 | 51 |
| silver | Gen_Z_25_29 | 44 |
| member | Millennial_30_35 | 43 |
| silver | Millennial_30_35 | 42 |
| silver | Gen_Z_18_24 | 33 |
| none | Gen_Z_25_29 | 29 |
| gold | Millennial_30_35 | 28 |
| none | Gen_Z_18_24 | 27 |
| none | Millennial_30_35 | 26 |
| gold | Gen_Z_18_24 | 26 |
| silver | Older_36plus | 20 |
| gold | Gen_Z_25_29 | 20 |
| gold | Older_36plus | 13 |
| member | Older_36plus | 12 |
| none | Older_36plus | 6 |
Gen Z ครองทุก tier แต่มีนัยต่างกันในแต่ละกลุ่ม
เมื่อดูภาพรวม 16 กลุ่ม พบรูปแบบที่ชัดเจน 3 อย่าง
Member + Gen Z คือกลุ่มใหญ่สุด (109 คน)
member × Gen_Z_25_29(58 คน) และmember × Gen_Z_18_24(51 คน) รวมกันเป็นกลุ่มใหญ่ที่สุด เป็นลูกค้าที่สมัครสมาชิกแล้ว มาสม่ำเสมอพอ แต่ยังไม่ได้ upgrade เป็น silver/gold กลุ่มนี้เหมาะมากสำหรับ cross-sell เพราะยังอยู่ในช่วง “explore brand” และการลองfruit_teaอาจเป็นตัวกระตุ้นให้ engagement สูงขึ้นจนขยับ tier ได้Older_36plus เป็น Purists ที่แน่วแน่ที่สุด ทุก tier ของ
Older_36plusมีจำนวนน้อยที่สุดในกลุ่มนั้น เช่นgold × Older_36plusมีเพียง 13 คน เทียบกับgold × Millennialที่ 28 คน แสดงว่ากลุ่ม 36+ ที่ยังเป็น Purist มีแนวโน้มสูงที่จะ “ตัดสินใจแล้วว่าไม่ลอง” การ push cross-sell กลุ่มนี้อาจได้ผลน้อยและเสี่ยง irritate ลูกค้าGold × Millennial_30_35 คือกลุ่มที่น่าลงทุนที่สุด (28 คน) แม้จะเป็นกลุ่มเล็ก แต่ Gold + Millennial มี AOV สูงสุดจากการวิเคราะห์ก่อนหน้า และ satisfaction สูง ถ้า convert ได้แม้แค่ครึ่งหนึ่ง รายได้ต่อหัวที่เพิ่มขึ้นจะคุ้มค่า campaign มาก
Table subquery
Subquery ที่ return table คือการเขียน SELECT ซ้อนไว้ใน FROM clause โดย DuckDB/SQL จะมองว่า subquery นั้นเป็น “ตารางชั่วคราว” (derived table) ที่ query หลักนำไปใช้ต่อได้เลย
ตัวอย่างต่อไปนี้แสดงการใช้ table subquery เพื่อกรองเฉพาะสาขาที่มียอดขายรวมสูงกว่าค่าเฉลี่ยของทุกสาขา
-- หา store ที่มียอดขายสูงกว่าค่าเฉลี่ยของทุกสาขา
SELECT
store_summary.store_name,
store_summary.branch_type,
store_summary.revenue_thb,
store_summary.orders,
ROUND(store_summary.revenue_thb / store_summary.orders, 2) AS aov_thb
FROM (
-- subquery ชั้นนอก: รวมยอดขายและนับออเดอร์ต่อสาขา → ได้เป็น derived table ชื่อ store_summary
SELECT
s.store_name,
s.branch_type,
ROUND(SUM(t.net_sales_thb), 2) AS revenue_thb,
COUNT(*) AS orders
FROM transactions t
JOIN stores s ON t.store_id = s.store_id
GROUP BY s.store_id, s.store_name, s.branch_type
) AS store_summary
WHERE store_summary.revenue_thb > (
-- subquery สำหรับ WHERE: คำนวณค่าเฉลี่ยยอดขายของทุกสาขา
SELECT AVG(store_rev)
FROM (
-- subquery ชั้นในสุด: รวมยอดขายรายสาขาก่อน แล้วค่อยเอาไป AVG
SELECT SUM(net_sales_thb) AS store_rev
FROM transactions
GROUP BY store_id
) AS avg_sub
)
ORDER BY revenue_thb DESC;คำสั่งนี้สามารถทำความเข้าใจเป็นลำดับขั้นได้ดังนี้
- subquery แรก
store_summaryทำหน้าที่สรุปยอดขายรวมและจำนวนออเดอร์ของแต่ละสาขา - query ภายนอกนำผลลัพธ์จาก
store_summaryมาใช้ต่อ โดยคำนวณaov_thbและกรองเฉพาะสาขาที่ยอดขายรวมสูงกว่าค่าเฉลี่ย - ส่วนเงื่อนไขใน
WHEREใช้ subquery อีกชั้นเพื่อคำนวณค่าเฉลี่ยของยอดขายรวมรายสาขา แล้วนำค่านั้นมาเป็นเกณฑ์เปรียบเทียบ
| store_name | branch_type | revenue_thb | orders | aov_thb |
|---|---|---|---|---|
| PiCha CentralWorld | mall | 2,451,113 | 11,935 | 205.37 |
| PiCha Siam Square | street | 2,311,860 | 10,738 | 215.30 |
จากทั้งหมด 6 สาขา มีเพียง 2 สาขาที่มียอดขายสูงกว่าค่าเฉลี่ยประมาณ 2 ล้านบาท ได้แก่ CentralWorld และ Siam Square ซึ่งเป็นสาขาในทำเลที่มีทราฟฟิกสูง ผลลัพธ์นี้ชี้ให้เห็นว่าประเภททำเลมีความสัมพันธ์กับยอดขายอย่างชัดเจน โดยเฉพาะสาขาในห้างและสาขาริมถนนในย่านการค้า ขณะที่สาขาในอาคารสำนักงาน เช่น Park Silom และ Exchange Tower มียอดขายต่ำกว่าค่าเฉลี่ย จึงอาจตีความได้ว่าการขยายสาขาในทำเลแบบ mall format มีศักยภาพทางธุรกิจที่น่าสนใจมากกว่า
มาดูอีกตัวอย่างหนึ่งที่แสดงการนำผลลัพธ์จาก table subquery มา JOIN กับตารางหลัก เพื่อผสานข้อมูลสรุปเชิงพฤติกรรมเข้ากับข้อมูลโปรไฟล์ของลูกค้า
SELECT
cust_spend.customer_id,
c.loyalty_tier,
c.age_group,
c.income_segment,
cust_spend.total_thb,
cust_spend.visit_count,
ROUND(cust_spend.total_thb / cust_spend.visit_count, 2) AS aov_thb
FROM (
SELECT
customer_id,
ROUND(SUM(net_sales_thb), 2) AS total_thb,
COUNT(*) AS visit_count
FROM transactions
GROUP BY customer_id
) AS cust_spend -- derived table สรุปยอดใช้จ่ายและจำนวนครั้งที่ซื้อของลูกค้าแต่ละราย
JOIN customers c ON cust_spend.customer_id = c.customer_id
ORDER BY total_thb DESC
LIMIT 10;คำสั่งนี้สามารถทำความเข้าใจได้เป็น 2 ขั้นตอนหลัก
- subquery ชื่อ
cust_spendทำหน้าที่สรุปยอดใช้จ่ายรวม (total_thb) และจำนวนครั้งที่ซื้อ (visit_count) ของลูกค้าแต่ละราย - query หลักนำผลลัพธ์ดังกล่าวมา
JOINกับตารางcustomersเพื่อดึงข้อมูลโปรไฟล์ของลูกค้า เช่น ระดับสมาชิก กลุ่มอายุ และระดับรายได้ มาแสดงร่วมกัน
แนวทางนี้มีประโยชน์มากในงานวิเคราะห์เชิงธุรกิจ เพราะช่วยให้เราเชื่อม “ข้อมูลสรุปพฤติกรรม” เข้ากับ “ข้อมูลลักษณะของลูกค้า” ได้ภายในคำสั่งเดียว ส่งผลให้สามารถวิเคราะห์ต่อได้ว่าลูกค้าที่ใช้จ่ายสูงมีลักษณะร่วมกันอย่างไร และควรออกแบบกลยุทธ์การตลาดหรือโปรแกรมสมาชิกแบบใดเพื่อดูแลลูกค้ากลุ่มนี้อย่างเหมาะสม
จากผลลัพธ์ หากพบว่าลูกค้าที่มียอดใช้จ่ายสูงสุดกระจายอยู่ในหลาย loyalty_tier ไม่ได้จำกัดอยู่เฉพาะกลุ่มสมาชิกระดับสูง ก็อาจสะท้อนว่ายังมีโอกาสในการผลักดันลูกค้ากลุ่ม high-value ที่ยังไม่ได้อยู่ในระดับ Silver หรือ Gold ให้เข้าสู่ loyalty program มากขึ้น ผ่านข้อเสนอหรือสิทธิประโยชน์ที่ออกแบบอย่างเฉพาะเจาะจง
เปรียบเทียบสั้น ๆ
- Subquery ทั่วไป: คำนวณได้เอง และมักประมวลผลครั้งเดียว
- Correlated subquery: ต้องอาศัยค่าจาก outer query และมักถูกประเมินซ้ำตามแต่ละแถว
ตัวอย่างต่อไปนี้แสดงการใช้ correlated subquery เพื่อวิเคราะห์ว่ามีลูกค้ากี่คนที่เคยสั่งผ่านช่องทาง delivery โดยแยกตามกลุ่มลูกค้า
โจทย์นี้มีเป้าหมายเพื่อศึกษาระดับการยอมรับช่องทาง delivery ในกลุ่มสมาชิกแต่ละประเภท เพื่อนำไปใช้วางกลยุทธ์กระตุ้นการใช้งานช่องทางดังกล่าวให้มากขึ้น ในตัวอย่างนี้ เราใช้ EXISTS เพื่อกรองเฉพาะลูกค้าที่เคยมีออเดอร์ผ่าน delivery อย่างน้อยหนึ่งครั้ง และใช้ correlated subquery เพื่อคำนวณจำนวนออเดอร์ delivery ของลูกค้าแต่ละราย ก่อนนำมาสรุปรวมในระดับกลุ่ม
-- นับจำนวนลูกค้าและออเดอร์เดลิเวอรีรวม แยกตามระดับสมาชิกและกลุ่มอายุ (เฉพาะลูกค้าที่เคยสั่ง delivery)
SELECT
c.loyalty_tier, -- ระดับสมาชิก
c.age_group, -- กลุ่มอายุ
COUNT(DISTINCT c.customer_id) AS num_customers, -- จำนวนลูกค้าที่ไม่ซ้ำในกลุ่มนั้น
SUM(
(SELECT COUNT(*) -- Correlated Subquery นับจำนวนออเดอร์ delivery ของลูกค้าแต่ละราย
FROM transactions t
WHERE t.customer_id = c.customer_id -- อ้างอิง customer_id จาก query ภายนอก
AND t.channel = 'delivery') -- กรองเฉพาะช่องทาง delivery
) AS total_delivery_orders -- จำนวนออเดอร์ delivery รวมทุกลูกค้าในกลุ่ม
FROM customers c
WHERE EXISTS ( -- กรองเฉพาะลูกค้าที่มีออเดอร์ delivery อย่างน้อย 1 รายการ
SELECT 1
FROM transactions t
WHERE t.customer_id = c.customer_id -- อ้างอิง customer_id จาก query ภายนอก
AND t.channel = 'delivery' -- ตรวจสอบว่าเคยสั่ง delivery
)
GROUP BY c.loyalty_tier, c.age_group -- จัดกลุ่มตามระดับสมาชิกและกลุ่มอายุ
ORDER BY num_customers DESC -- เรียงจากกลุ่มที่มีลูกค้ามากสุดไปน้อยสุด
LIMIT 10; -- แสดงเพียง 10 อันดับแรกคำสั่งนี้ช่วยให้เห็นทั้ง “จำนวนลูกค้าที่เคยใช้ delivery” และ “จำนวนออเดอร์ delivery รวม” ภายในแต่ละกลุ่มลูกค้า ทำให้สามารถประเมินได้ว่ากลุ่มใดมีการยอมรับช่องทาง delivery สูง และกลุ่มใดยังมีโอกาสในการกระตุ้นการใช้งานเพิ่มเติม
| loyalty_tier | age_group | num_customers | total_delivery_orders |
|---|---|---|---|
| member | Gen_Z_18_24 | 185 | 574 |
| member | Gen_Z_25_29 | 163 | 499 |
| member | Millennial_30_35 | 156 | 486 |
| silver | Gen_Z_25_29 | 155 | 444 |
| silver | Gen_Z_18_24 | 147 | 416 |
| silver | Millennial_30_35 | 128 | 348 |
| none | Gen_Z_25_29 | 121 | 366 |
| none | Gen_Z_18_24 | 98 | 294 |
| none | Millennial_30_35 | 94 | 258 |
| gold | Gen_Z_25_29 | 77 | 231 |
มีสมาชิกที่สั่ง delivery อย่างน้อย 1 ครั้งรวม 1,324 คน (จาก 10 กลุ่มที่แสดง) member tier มี adoption สูงสุด 504 คน แต่ละคนสั่ง delivery เฉลี่ย 3–3.1 ครั้งในช่วง 2 เดือน Gen_Z_18_24 member มี delivery orders สูงสุด 574 ครั้งจาก 185 คน (เฉลี่ย 3.1 ครั้งต่อคน)
Gen_Z เป็นกลุ่มที่ใช้ delivery มากที่สุด ทั้ง 18–24 และ 25–29 ปีครอง top positions ทุก loyalty tier อัตราการสั่ง delivery ต่อคนอยู่ที่ประมาณ 3–3.1 ครั้งต่อคนในช่วง 2 เดือน ซึ่งค่อนข้างสม่ำเสมอข้ามกลุ่ม
กล่าวได้ว่า Gen_Z คุ้นเคยกับ delivery apps มากกว่ากลุ่มอายุอื่น ซึ่งสอดคล้องกับพฤติกรรมผู้บริโภคทั่วไปในไทย การทำตลาดผ่าน delivery platforms ควรเน้น content ที่ตรงกับ Gen_Z สิ่งที่ต้องพิจารณาคือ อัตราเฉลี่ย 3 ครั้งต่อคนใน 2 เดือนอาจยังไม่ถือว่า “ใช้ประจำ” สำหรับผู้บริโภคกลุ่มนี้ ควรเปรียบเทียบกับค่าเฉลี่ยอุตสาหกรรมก่อนสรุปว่า delivery adoption แข็งแกร่งหรือยังมี upside
ในทางกลับกัน คำถามบางประเภทไม่ได้มุ่งหาว่า “ใครเคยมีพฤติกรรมนี้” แต่ต้องการระบุว่า “ใคร ไม่เคย มีพฤติกรรมดังกล่าวเลย” ตัวอย่างเช่น การหาลูกค้าที่สั่งเฉพาะผ่านหน้าร้านหรือซื้อกลับบ้าน (dine-in / to-go) โดยไม่เคยใช้ช่องทาง delivery เลย เพื่อประเมินโอกาสในการดึงลูกค้ากลุ่มนี้เข้าสู่บริการจัดส่ง ในกรณีเช่นนี้ NOT EXISTS เป็นเครื่องมือที่เหมาะสม เพราะใช้ตรวจสอบว่าลูกค้ารายนั้นไม่มีออเดอร์ delivery แม้แต่ครั้งเดียว
-- นับจำนวนลูกค้าที่ไม่เคยสั่ง delivery เลย แยกตามระดับสมาชิกและกลุ่มอายุ
SELECT
c.loyalty_tier, -- ระดับสมาชิก
c.age_group, -- กลุ่มอายุ
COUNT(DISTINCT c.customer_id) AS num_customers -- จำนวนลูกค้าที่ไม่ซ้ำในกลุ่มนั้น
FROM customers c
WHERE NOT EXISTS ( -- กรองเฉพาะลูกค้าที่ไม่มีออเดอร์ delivery แม้แต่รายการเดียว
SELECT 1
FROM transactions t
WHERE t.customer_id = c.customer_id -- อ้างอิง customer_id จาก query ภายนอก
AND t.channel = 'delivery' -- ตรวจสอบว่าเคยสั่ง delivery หรือไม่
)
GROUP BY c.loyalty_tier, c.age_group -- จัดกลุ่มตามระดับสมาชิกและกลุ่มอายุ
ORDER BY num_customers DESC -- เรียงจากกลุ่มที่มีลูกค้ามากสุดไปน้อยสุด
LIMIT 10; -- แสดงเพียง 10 อันดับแรก| loyalty_tier | age_group | num_customers |
|---|---|---|
| member | Gen_Z_18_24 | 95 |
| member | Gen_Z_25_29 | 92 |
| silver | Gen_Z_25_29 | 90 |
| none | Gen_Z_25_29 | 75 |
| member | Millennial_30_35 | 70 |
| silver | Millennial_30_35 | 68 |
| none | Gen_Z_18_24 | 54 |
| silver | Gen_Z_18_24 | 54 |
| gold | Millennial_30_35 | 53 |
| none | Millennial_30_35 | 44 |
มีลูกค้าที่ไม่เคยสั่ง delivery เลย 865 คน (10 กลุ่มแรกแสดง 695 คน) กลุ่มใหญ่สุดคือ member + Gen_Z_18_24 จำนวน 95 คน ที่น่าสนใจคือ gold tier มี 53 คน (Millennial) ที่ไม่เคยสั่ง delivery
ลูกค้าที่ไม่ใช้ delivery กระจายทุกกลุ่มอายุ ไม่เฉพาะ Older_36plus อย่างที่อาจคาด แสดงว่าบางคนเลือก dine_in/to_go โดย preference ไม่ใช่เพราะไม่คุ้นเคยกับเทคโนโลยี
สิ่งที่ต้องระวังคือ ข้อมูล transaction อย่างเดียวบอกได้เพียง “เคย/ไม่เคยสั่ง delivery” แต่ยังบอกไม่ได้ว่า “ทำไม” ลูกค้าเหล่านี้ไม่ใช้ delivery การเสนอ “free delivery ครั้งแรก” เป็นวิธีทดสอบที่ต้นทุนต่ำ แต่ไม่ควรคาดหวังว่าลูกค้าทั้ง 865 คนจะเปลี่ยนพฤติกรรมทั้งหมด โดยเฉพาะกลุ่มที่ตั้งใจเลือกประสบการณ์ในร้านเป็นหลัก
เมื่อได้เห็นการใช้ IN / NOT IN และ EXISTS / NOT EXISTS ในบริบทจริงแล้ว ขั้นถัดไปคือการเปรียบเทียบความแตกต่างของทั้งสองแนวทาง เพื่อให้สามารถเลือกใช้ได้เหมาะสมกับลักษณะของโจทย์และข้อมูล
INกับEXISTSต่างกันอย่างไร
INเหมาะสำหรับกรณีที่ subquery ส่งผลลัพธ์กลับมาเป็นคอลัมน์เดียวอย่างชัดเจน และ query ภายนอกต้องการตรวจสอบว่าค่าของแถวนั้นอยู่ใน “รายการ” ที่ได้จาก subquery หรือไม่EXISTSเหมาะสำหรับกรณีที่ต้องการตรวจสอบเพียงว่ามีแถวที่เข้าเงื่อนไขอยู่จริงหรือไม่ โดยเฉพาะเมื่อ subquery มีการอ้างอิงคอลัมน์จาก query ภายนอกในลักษณะของ correlated subquery- ในทางปฏิบัติ
EXISTSมักเหมาะกับโจทย์ที่มีเงื่อนไขการจับคู่ซับซ้อน หรือกรณีที่ต้องตรวจสอบความสัมพันธ์ระหว่างข้อมูลหลายคอลัมน์ มากกว่าการเปรียบเทียบกับรายการค่าจากคอลัมน์เดียว- สำหรับเงื่อนไขเชิงปฏิเสธ
NOT EXISTSมักเป็นทางเลือกที่ปลอดภัยกว่าNOT INโดยเฉพาะเมื่อผลลัพธ์จาก subquery อาจมีค่าNULLปะปนอยู่
สรุป
ในบทนี้ เราได้เห็น 4 รูปแบบหลักของการใช้ subquery เพื่อตอบคำถามทางธุรกิจ โดยแต่ละรูปแบบเหมาะกับลักษณะโจทย์ที่แตกต่างกัน
- ใช้ scalar subquery เมื่อต้องการเปรียบเทียบข้อมูลกับค่าอ้างอิงเพียงค่าเดียว เช่น ค่าเฉลี่ยทั้งกิจการ หรือค่าเฉลี่ยภายในแต่ละช่องทางการขาย
- ใช้ column subquery ร่วมกับ
IN/NOT INเมื่อต้องการเลือกหรือคัดออกจาก “รายการ” ของค่า เช่น ลูกค้าที่เคยสั่งเมนู signature หรือกลุ่มลูกค้าที่ไม่เคยสั่งเมนูบางประเภท - ใช้ **table subquery ร่วมกับ
JOINเพื่อสรุปข้อมูลเป็นตารางย่อยก่อน แล้วเชื่อมกับตารางหลักเพื่อวิเคราะห์ข้อมูลเชิงสรุปร่วมกับข้อมูลรายละเอียดอื่น - ใช้ correlated subquery ร่วมกับ
EXISTS/NOT EXISTSเมื่อต้องการตรวจสอบว่าพฤติกรรมบางอย่างเคยเกิดขึ้นอย่างน้อยหนึ่งครั้ง หรือไม่เคยเกิดขึ้นเลย โดยเฉพาะในข้อมูลขนาดใหญ่ที่ต้องตรวจเป็นรายแถว
แม้ subquery ทั้ง 4 กลุ่มที่เรียนในบทนี้จะมีรูปแบบต่างกัน แต่ล้วนตอบโจทย์เดียวกัน คือ “คำนวณหรือระบุค่าอ้างอิงก่อน แล้วจึงนำมาใช้เปรียบเทียบหรือคัดเลือกข้อมูล” ความต่างอยู่ที่ลักษณะของค่าอ้างอิงนั้น ว่าเป็นตัวเลขเพียงค่าเดียวแบบ scalar, เป็นรายการของค่าหลายแถวแบบ column subquery, หรือเป็นการตรวจสอบการมีอยู่ของข้อมูลแบบ EXISTS ผลลัพธ์คือเราสามารถเขียน query ที่ตอบคำถามธุรกิจซับซ้อนได้ในคำสั่งเดียว โดยไม่ต้องคัดลอกค่ามาใส่เอง และไม่ต้องแยก query ออกเป็นหลายรอบโดยไม่จำเป็น
ตารางต่อไปนี้สรุป syntax และตัวอย่างการประยุกต์ใช้ทางธุรกิจของ subquery แต่ละแบบ
| แนวคิด | Syntax | การใช้ธุรกิจ |
|---|---|---|
| Scalar subquery | WHERE col > (SELECT AVG(col) FROM t) |
เปรียบเทียบค่ากับค่าเฉลี่ยหรือ benchmark |
| Column subquery | WHERE col IN (SELECT col FROM t) |
หาลูกค้าที่เคยสั่งเมนูหรืออยู่ในกลุ่มที่สนใจ |
| Table subquery | FROM (SELECT ... FROM ... GROUP BY ...) AS sub |
สร้างตารางสรุปชั่วคราว เช่น สรุปยอดใช้จ่ายต่อลูกค้า แล้ว JOIN กับตารางลูกค้าเพื่อวิเคราะห์ตามระดับสมาชิกหรือกลุ่มอายุ |
| Correlated subquery | WHERE EXISTS (SELECT 1 FROM t WHERE ...) |
ตรวจว่าพฤติกรรมหนึ่งเคยเกิดขึ้นอย่างน้อย 1 ครั้ง |
ในบทถัดไป เราจะเรียนรู้เรื่อง Common Table Expressions หรือ CTE ซึ่งช่วยให้สามารถตั้งชื่อ subquery และอ้างอิงซ้ำได้ภายใน query เดียวกัน ทำให้โค้ดมีโครงสร้างชัดเจนขึ้น อ่านง่ายขึ้น และบำรุงรักษาได้สะดวกกว่าเดิม โดยเฉพาะเมื่อเริ่มทำงานกับ query ที่ยาวและซับซ้อนมากขึ้น
คำถามท้ายบท
- ใช้ scalar subquery หาออเดอร์ที่มี
net_sales_thbสูงกว่า AOV เฉพาะของช่องทางdelivery(คล้ายตัวอย่างแรกของบท แต่จำกัดที่ช่องทางเดียว)
ผลลัพธ์ที่คาดหวัง
| transaction_id | store_id | net_sales_thb |
|---|---|---|
| 41627 | 6 | 695.0 |
| 46797 | 2 | 640.0 |
| 48503 | 2 | 634.0 |
| 28940 | 6 | 633.0 |
| 52852 | 5 | 630.0 |
| 17854 | 1 | 628.0 |
| 39100 | 6 | 627.0 |
| 14594 | 5 | 618.0 |
| 27651 | 5 | 615.0 |
| 50221 | 2 | 612.0 |
คลิกเพื่อดูเฉลย
-- ดึงออเดอร์ delivery ที่มีรายได้สุทธิสูงกว่าค่าเฉลี่ยของช่องทาง delivery 10 อันดับแรก
SELECT
transaction_id, -- รหัสธุรกรรม
store_id, -- รหัสสาขา
net_sales_thb -- รายได้สุทธิ (บาท)
FROM transactions
WHERE channel = 'delivery' -- กรองเฉพาะออเดอร์ช่องทาง delivery
AND net_sales_thb > ( -- กรองเฉพาะออเดอร์ที่มีรายได้สูงกว่าค่าเฉลี่ย delivery
SELECT AVG(net_sales_thb) -- Subquery คำนวณค่าเฉลี่ยรายได้สุทธิเฉพาะช่องทาง delivery
FROM transactions
WHERE channel = 'delivery' -- คำนวณค่าเฉลี่ยจาก delivery เท่านั้น
)
ORDER BY net_sales_thb DESC -- เรียงจากรายได้สูงสุดไปต่ำสุด
LIMIT 10; -- แสดงเพียง 10 อันดับแรก- ใช้
NOT EXISTSหาลูกค้า gold tier ที่ยังไม่เคยสั่งfruit_teaเลย (ต่อยอดจากตัวอย่าง “Milk Tea Purists” แต่โฟกัสเฉพาะ gold และ family =fruit_tea)
ผลลัพธ์ที่คาดหวัง
| age_group | num_customers |
|---|---|
| Millennial_30_35 | 49 |
| Gen_Z_25_29 | 43 |
| Gen_Z_18_24 | 41 |
| Older_36plus | 21 |
คลิกเพื่อดูเฉลย
-- นับลูกค้า Gold tier ที่ไม่เคยสั่งเมนูกลุ่ม fruit_tea เลย แยกตามกลุ่มอายุ
SELECT
c.age_group, -- กลุ่มอายุ
COUNT(DISTINCT c.customer_id) AS num_customers -- จำนวนลูกค้าที่ไม่ซ้ำในกลุ่มนั้น
FROM customers c
WHERE c.loyalty_tier = 'gold' -- กรองเฉพาะลูกค้าระดับ Gold
AND NOT EXISTS ( -- ตัดออกลูกค้าที่เคยสั่ง fruit_tea แม้แต่ครั้งเดียว
SELECT 1
FROM transactions t
JOIN order_items oi ON t.transaction_id = oi.transaction_id -- เชื่อมตารางรายการสินค้า
JOIN menus m ON oi.sku_id = m.sku_id -- เชื่อมตารางเมนูเพื่อตรวจสอบ family
WHERE t.customer_id = c.customer_id -- อ้างอิง customer_id จาก query ภายนอก
AND m.family = 'fruit_tea' -- ตรวจสอบว่าเคยสั่งเมนูกลุ่ม fruit_tea หรือไม่
)
GROUP BY c.age_group -- จัดกลุ่มตามกลุ่มอายุ
ORDER BY num_customers DESC; -- เรียงจากกลุ่มที่มีลูกค้ามากสุดไปน้อยสุด- ใช้
INsubquery หาวัน (order_date) ที่ทุกสาขาทำรายได้รวมกันได้มากกว่า 300,000 บาท (คล้ายกับตัวอย่างIN+GROUP BYที่ใช้หา days ที่ผ่าน threshold)
ผลลัพธ์ที่คาดหวัง
| order_date | daily_total |
|---|---|
| 2026-02-15 | 318,449 |
คลิกเพื่อดูเฉลย
เฉลยด้วยวิธี subquery
-- ดึงยอดขายรายวันเฉพาะวันที่มีรายได้รวมสูงกว่า 300,000 บาท
SELECT
order_date, -- วันที่
ROUND(SUM(net_sales_thb), 0) AS daily_total -- รายได้สุทธิรวมในวันนั้น (บาท)
FROM transactions
WHERE order_date IN ( -- กรองเฉพาะวันที่ผ่านเกณฑ์รายได้
SELECT order_date -- Subquery หาวันที่มีรายได้รวมสูงกว่า 300,000 บาท
FROM transactions
GROUP BY order_date -- จัดกลุ่มตามวัน
HAVING SUM(net_sales_thb) > 300000 -- กรองเฉพาะวันที่มียอดรวมเกิน 300,000 บาท
)
GROUP BY order_date -- จัดกลุ่มตามวันในชุดข้อมูลหลัก
ORDER BY daily_total DESC; -- เรียงจากวันที่มีรายได้สูงสุดไปต่ำสุดเฉลยด้วยวิธี HAVING
-- ดึงยอดขายรายวันเฉพาะวันที่มีรายได้รวมสูงกว่า 300,000 บาท (ใช้ HAVING แทน Subquery)
SELECT
order_date, -- วันที่
ROUND(SUM(net_sales_thb), 0) AS daily_total -- รายได้สุทธิรวมในวันนั้น (บาท)
FROM transactions -- ตารางธุรกรรมทั้งหมด
GROUP BY order_date -- จัดกลุ่มตามวัน
HAVING SUM(net_sales_thb) > 300000 -- กรองเฉพาะวันที่มียอดรวมเกิน 300,000 บาท
ORDER BY daily_total DESC; -- เรียงจากวันที่มีรายได้สูงสุดไปต่ำสุด- เมนูที่ไม่มีคนสั่งที่ Park Silom (store_id = 3) วันที่ 14 มี.ค. 2026
ผลลัพธ์ที่คาดหวัง
| sku_id | sku_name | family | size | list_price |
|---|---|---|---|---|
| 22 | Thai Tea Frappe L | blended | L | 125 |
| 3 | Tie Guan Yin Milk Tea R | milk_tea | R | 110 |
| 9 | Osmanthus Milk Tea R | milk_tea | R | 95 |
| 10 | Osmanthus Milk Tea L | milk_tea | L | 115 |
| 20 | Glutinous Green Pure Tea R | pure_tea | R | 85 |
คลิกเพื่อดูเฉลย
SELECT m.sku_id, m.sku_name, m.family, m.size_code, m.list_price_thb
FROM menus m
WHERE m.sku_id IN (
-- กลุ่ม A: sku ทั้งหมดในระบบ
SELECT sku_id FROM menus
EXCEPT
-- กลุ่ม B: sku ที่มีคนสั่งที่สาขา 3 วันที่ 14 มี.ค.
SELECT oi.sku_id
FROM order_items oi
JOIN transactions t ON oi.transaction_id = t.transaction_id
WHERE t.store_id = 3
AND t.order_date = '2026-03-14'
)
ORDER BY m.family, m.sku_id;