วัตถุประสงค์การเรียนรู้

เมื่อจบบทนี้ ผู้เรียนจะสามารถ

  1. เขียน subquery แบบต่าง ๆ ได้
  2. เลือกใช้ subquery ให้สอดคล้องกับคำถามธุรกิจ เช่น เปรียบเทียบกับค่าเฉลี่ย เลือกกลุ่มลูกค้าเฉพาะ หรือเช็กว่ามีพฤติกรรมบางอย่างเกิดขึ้นหรือไม่

บทนำ

คำถามทางธุรกิจจำนวนมากไม่สามารถตอบได้จากการดูข้อมูลดิบเพียงอย่างเดียว แต่ต้องเริ่มจากการ “คำนวณค่าอ้างอิง” ขึ้นมาก่อน แล้วจึงนำค่านั้นมาใช้เปรียบเทียบ คัดเลือก หรืออธิบายข้อมูลที่สนใจ ตัวอย่างเช่น สาขาใดมีค่าใช้จ่ายเฉลี่ยต่อออเดอร์ (AOV) สูงกว่าค่าเฉลี่ยของทั้งกิจการ หรือลูกค้ารายใดเคยสั่ง milk tea แต่ไม่เคยสั่ง fruit tea เลย การเขียนคำสั่งลักษณะนี้ใน SQL ภายในคำสั่งเดียวจึงมักต้องอาศัย subquery เข้ามาช่วย

Subquery คือคำสั่ง SQL ที่ถูกเขียนซ้อนอยู่ภายในอีกคำสั่งหนึ่ง โดยผลลัพธ์จาก subquery จะถูกนำไปใช้ต่อใน main query เพื่อช่วยคำนวณ เปรียบเทียบ หรือกรองข้อมูลตามเงื่อนไขที่ซับซ้อนขึ้น

ในทางปฏิบัติ subquery สามารถส่งผลลัพธ์กลับมาได้หลายรูปแบบ ขึ้นอยู่กับว่าผลลัพธ์นั้นจะถูกนำไปใช้ใน main query อย่างไร หากพิจารณาตามลักษณะของผลลัพธ์ สามารถแบ่งได้เป็น 3 ประเภทหลัก

  1. Scalar subquery ส่งผลลัพธ์กลับมาเป็นค่าเดียว เช่น การหาค่าเฉลี่ยยอดขายของทั้งกิจการ แล้วนำค่านั้นไปเปรียบเทียบกับยอดขายของแต่ละสาขา
  2. Column subquery ส่งผลลัพธ์กลับมาเป็นคอลัมน์เดียวที่มีหลายค่า เช่น การหารายชื่อลูกค้าที่เคยสั่งเครื่องดื่มประเภทหนึ่ง แล้วใช้รายการนั้นร่วมกับ IN หรือ NOT IN เพื่อกรองข้อมูล
  3. 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 วิธีคิดแบบตรงไปตรงมาคือ ต้องคำนวณค่าเฉลี่ยของมูลค่าออเดอร์ทั้งกิจการออกมาก่อน แล้วจึงนำค่าที่ได้ไปใช้เป็นเงื่อนไขในการกรองข้อมูล

SELECT AVG(net_sales_thb) FROM transactions; -- ใหัผลลัพธ์ 202.30297395120368

เมื่อได้ค่าเฉลี่ยแล้ว ขั้นตอนถัดไปคือการนำค่าดังกล่าวมาใส่ในเงื่อนไข 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 instagram
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 instagram
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 instagram
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 อย่าง

  1. 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 ได้

  2. Older_36plus เป็น Purists ที่แน่วแน่ที่สุด ทุก tier ของ Older_36plus มีจำนวนน้อยที่สุดในกลุ่มนั้น เช่น gold × Older_36plus มีเพียง 13 คน เทียบกับ gold × Millennial ที่ 28 คน แสดงว่ากลุ่ม 36+ ที่ยังเป็น Purist มีแนวโน้มสูงที่จะ “ตัดสินใจแล้วว่าไม่ลอง” การ push cross-sell กลุ่มนี้อาจได้ผลน้อยและเสี่ยง irritate ลูกค้า

  3. 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 มากขึ้น ผ่านข้อเสนอหรือสิทธิประโยชน์ที่ออกแบบอย่างเฉพาะเจาะจง

Correlated subquery

Correlated subquery คือ subquery ที่อ้างอิงคอลัมน์จาก query ภายนอก ทำให้ subquery ไม่สามารถทำงานอย่างอิสระเพียงลำพังได้ แต่ต้องอาศัยค่าจากแถวปัจจุบันของ outer query ในการประมวลผล

กล่าวอีกแบบหนึ่งคือ subquery ชนิดนี้จะถูกประเมินซ้ำตามบริบทของแต่ละแถวใน query ภายนอก แทนที่จะคำนวณครั้งเดียวแล้วใช้ผลลัพธ์เดิมทั้งหมดเหมือน subquery แบบทั่วไป

เปรียบเทียบสั้น ๆ

  • 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 ที่ยาวและซับซ้อนมากขึ้น

คำถามท้ายบท

  1. ใช้ 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 อันดับแรก
  1. ใช้ 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; -- เรียงจากกลุ่มที่มีลูกค้ามากสุดไปน้อยสุด
  1. ใช้ IN subquery หาวัน (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; -- เรียงจากวันที่มีรายได้สูงสุดไปต่ำสุด
  1. เมนูที่ไม่มีคนสั่งที่ 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;