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

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

  1. ใช้ INTERSECT เพื่อหาส่วนที่ซ้อนกันของสองชุดข้อมูล
  2. ใช้ EXCEPT เพื่อหาส่วนต่างของสองชุดข้อมูล
  3. ใช้ UNION เพื่อรวมผลลัพธ์จากหลาย query ให้เป็นชุดเดียว

บทนำ

INTERSECT, EXCEPT และ UNION เป็น set operators ในภาษา SQL ที่ใช้ทำงานกับ “ชุดผลลัพธ์” ของ query แทนการมองข้อมูลแบบทีละตารางเหมือนในคำสั่งพื้นฐาน แต่ละคำสั่งมีแนวคิดสอดคล้องกับการดำเนินการบนเซตในคณิตศาสตร์ จึงเหมาะสำหรับการเปรียบเทียบความเหมือน ความต่าง และการรวมข้อมูลจากหลายผลลัพธ์เข้าด้วยกัน

ตัวอย่างเช่น INTERSECT ใช้หาข้อมูลที่ปรากฏร่วมกันในทั้งสองชุด เช่น ลูกค้าที่ซื้อสินค้าทั้งสองเดือน EXCEPT ใช้หาข้อมูลที่อยู่ในชุดหนึ่งแต่ไม่อยู่ในอีกชุดหนึ่ง เช่น ลูกค้าที่หายไปในเดือนถัดมา หรือกลุ่มลูกค้าที่เพิ่งเข้ามาใหม่ ส่วน UNION ใช้รวมผลลัพธ์จากหลาย query ให้กลายเป็นชุดข้อมูลเดียว เพื่อเตรียมนำไปวิเคราะห์ต่อในขั้นถัดไป

ประเด็นสำคัญคือ set operations ทำงานกับ “ผลลัพธ์ของคำสั่ง SELECT” ไม่ใช่ทำงานกับตารางโดยตรง ดังนั้น query ทั้งสองด้านจึงต้องส่งกลับจำนวนคอลัมน์เท่ากัน และชนิดข้อมูลของคอลัมน์ในตำแหน่งที่ตรงกันต้องเหมือนกันหรือสามารถใช้งานร่วมกันได้

ไวยากรณ์พื้นฐานของ INTERSECT, EXCEPT และ UNION มีรูปแบบดังนี้

SELECT column_list FROM table_a WHERE condition
INTERSECT | EXCEPT | UNION
SELECT column_list FROM table_b WHERE condition

INTERSECT “เอาเฉพาะที่ซ้ำกัน”

INTERSECT คืนค่าเฉพาะแถวที่ ปรากฏอยู่ในทั้งสองชุดผลลัพธ์ เช่น ต้องการหาลูกค้าที่สั่งซื้อทั้งสาขา 1 (CentralWorld) และสาขา 6 (ICONSIAM) ตั้งแต่วันที่ 8-15 กุมภาพันธ์ 2026

-- หาลูกค้าที่สั่งซื้อทั้งสาขา 1 (CentralWorld) และสาขา 6 (ICONSIAM) ในช่วงเวลาเดียวกัน (ลูกค้าที่ใช้บริการข้ามสาขา)
SELECT customer_id -- รหัสลูกค้าที่สั่งซื้อที่สาขา 1
FROM transactions 
WHERE store_id = 1 AND customer_id IS NOT NULL AND order_date BETWEEN '2026-02-08' AND '2026-02-15' -- กรองเฉพาะสาขา 1 ช่วง 8–15 ก.พ. 2026 และมี customer_id
INTERSECT -- คืนเฉพาะ customer_id ที่ปรากฏในทั้งสองชุดข้อมูล
SELECT customer_id -- รหัสลูกค้าที่สั่งซื้อที่สาขา 6
FROM transactions 
WHERE store_id = 6 AND customer_id IS NOT NULL AND order_date BETWEEN '2026-02-08' AND '2026-02-15'; -- กรองเฉพาะสาขา 6 ช่วงเวลาเดียวกัน
customer_id
3189
3293
3108

ผลที่ได้แสดงว่าในช่วงวันที่ 8-15 กุมภาพันธ์ 2026 มีลูกค้าแบบสมาชิกเพียง 3 ราย ที่เคยไปทั้ง 2 สาขา ไม่ได้แค่คนที่ไปสาขาใดสาขาหนึ่ง

หากต้องการทราบว่ามีลูกค้ากี่คนที่มาใช้บริการทั้งกุมภาพันธ์และมีนาคม ตัวเลขลูกค้าที่กลับมาซื้อซ้ำ (retained customers) สำคัญกว่าจำนวนออเดอร์รวม เพราะบอกความภักดีของลูกค้า ลูกค้าคนหนึ่งอาจสั่ง 10 ครั้ง แต่ถ้าลูกค้าอีก 100 คนหายไป ภาพรวมธุรกิจเสียหายกว่า

INTERSECT ช่วยตอบคำถามนี้โดยหาลูกค้าที่ปรากฏในทั้งสองช่วงเวลา

-- นับจำนวนลูกค้าที่กลับมาซื้อซ้ำทั้งในเดือนกุมภาพันธ์และมีนาคม
SELECT COUNT(*) AS repeat_customers -- จำนวนลูกค้าที่ซื้อซ้ำในทั้งสองเดือน
FROM (
 SELECT DISTINCT customer_id -- รหัสลูกค้าที่ซื้อในเดือนกุมภาพันธ์ (ไม่ซ้ำ)
 FROM transactions
 WHERE customer_id IS NOT NULL 
   AND DATE_PART('year', order_date) = 2026 AND month = 2 -- กรองเฉพาะเดือนกุมภาพันธ์และมี customer_id
 INTERSECT -- คืนเฉพาะ customer_id ที่ปรากฏในทั้งสองเดือน
 SELECT DISTINCT customer_id -- รหัสลูกค้าที่ซื้อในเดือนมีนาคม (ไม่ซ้ำ)
 FROM transactions
 WHERE customer_id IS NOT NULL 
   AND DATE_PART('year', order_date) = 2026 AND month = 3 -- กรองเฉพาะเดือนมีนาคมและมี customer_id
) AS t; -- Subquery หาลูกค้าที่ซื้อทั้งสองเดือน

หากดูเฉพาะผลลัพธ์ที่ได้จากคำสั่ง SELECT (ลูกค้าที่เป็นสมาชิกที่ใช้บริการในเดือนกุมภาพันธ์ 2026) เฉพาะ 10 แถวแรก

customer_id
1001
1002
1003
1004
1005
1009
1010
1011
1012
1013

หากดูเฉพาะผลลัพธ์ที่ได้จากคำสั่ง SELECT (ลูกค้าที่ใช้บริการในเดือนมีนาคม 2026) เฉพาะ 10 แถวแรก

customer_id
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009

แล้วเอาผลลัพธ์ทั้ง 2 มา INTERSECT แล้วนับจำนวนแถวจะได้ผลดังนี้

repeat_customers
1,361

นับจำนวนที่เป็นสมาชิกที่ใช้บริการในเดือนกุมภาพันธ์ 2026 มีจำนวน 1,480 คน

SELECT COUNT(*) from (
 SELECT DISTINCT customer_id -- รหัสลูกค้าที่ซื้อในเดือนกุมภาพันธ์ (ไม่ซ้ำ)
 FROM transactions
 WHERE customer_id IS NOT NULL 
   AND DATE_PART('year', order_date) = 2026 AND month = 2 -- กรองเฉพาะเดือนกุมภาพันธ์และมี customer_id
);

มีลูกค้า 1,361 คนที่มาใช้บริการทั้งกุมภาพันธ์และมีนาคม 2026 จากลูกค้าทั้งหมดที่สั่งในเดือนกุมภาพันธ์ 1,480 คน คิดเป็นอัตรา retention \(1{,}361 / 1{,}480 = 92.0\%\) ซึ่งสูงสำหรับธุรกิจ F&B

แสดงว่าลูกค้า PiCha มีความภักดีสูง แต่ยังมี 119 คน (8.0%) ที่หายไปหลังเดือนกุมภาพันธ์ ตัวเลข 8% อาจดูเล็ก แต่ถ้า churn rate คงที่ทุกเดือน ฐานลูกค้าจะหดตัวแบบทบต้น

\[0.92^6 \approx 0.606\]

หลัง 6 เดือนจะเหลือเพียงประมาณ 61% ของฐานเดิม เกือบสูญเสียลูกค้าเดิมไป 4 ใน 10 คน

การมี churn 8% ต่อเดือนเป็นความเสี่ยงที่ต้องจัดการ ก่อนออกแบบ win-back campaign ควรวิเคราะห์ก่อนว่าลูกค้า 119 คนที่หายไปอยู่ใน loyalty tier ใดและมี average order value เท่าไหร่ ถ้าส่วนใหญ่เป็นลูกค้า high-value ผลกระทบจะรุนแรงกว่าตัวเลข 8% ที่เห็น อย่าสรุปว่า 92% “ดีพอ” โดยไม่ดูมูลค่าลูกค้าที่สูญเสีย

เมื่อรู้แล้วว่ามีลูกค้ากี่คนที่ยัง “อยู่ทั้งสองเดือน” คำถามถัดไปคือ “ใครหายไป” และ “ใครเพิ่งเข้ามา” ซึ่งเป็นโจทย์ตามธรรมชาติของ EXCEPT ที่มองหา “ส่วนต่างของสองเซต”

EXCEPT

EXCEPT เป็นคำสั่งสำหรับเปรียบเทียบผลลัพธ์ของสอง query โดยจะคืนเฉพาะแถวที่ปรากฏใน query แรก แต่ ไม่ปรากฏ ใน query ที่สอง กล่าวอีกแบบหนึ่งคือเป็นการหาส่วนต่างของสองเซตข้อมูล

1. A EXCEPT B
Query A ──────────────────► [1, 2, 3, 4, 5]
Query B ──────────────────► [3, 4, 5, 6, 7]
                                    ↓ A EXCEPT B
ผลลัพธ์ ─────────────────► [1, 2]   ← มีเฉพาะใน Query A
2. B EXCEPT A
Query B ──────────────────► [3, 4, 5, 6, 7]
Query A ──────────────────► [1, 2, 3, 4, 5]
                                    ↓ B EXCEPT A
ผลลัพธ์ ─────────────────► [6, 7]   ← มีเฉพาะใน Query B

ประเด็นสำคัญคือ EXCEPT เป็นคำสั่งที่ ลำดับมีความสำคัญ ดังนั้น A EXCEPT B จึงไม่เท่ากับ B EXCEPT A เพราะแต่ละแบบใช้คนละฝั่งเป็น “จุดตั้งต้น” ในการตัดข้อมูลออก หากสลับตำแหน่งกัน ผลลัพธ์และความหมายทางธุรกิจก็จะเปลี่ยนตามไปด้วย

ในมุมมองทางธุรกิจ EXCEPT มีประโยชน์มากเมื่อต้องการหากลุ่มที่ “เคยมี แต่ตอนนี้ไม่มีแล้ว” หรือ “อยู่ในกลุ่มหนึ่ง แต่ไม่อยู่ในอีกกลุ่มหนึ่ง” ตัวอย่างเช่น หากต้องการหาลูกค้าที่ซื้อสินค้าในเดือนกุมภาพันธ์ 2026 แต่ไม่กลับมาซื้อในเดือนมีนาคม เราสามารถนำรายชื่อลูกค้าเดือนกุมภาพันธ์มา EXCEPT กับรายชื่อลูกค้าเดือนมีนาคมได้ ผลลัพธ์ที่เหลืออยู่ก็คือลูกค้าที่อาจจัดเป็น churned customers นั่นเอง

อีกวิธีหนึ่งในการทำความเข้าใจคือมองว่า query แรกคือ “กลุ่มเป้าหมายตั้งต้น” ส่วน query ที่สองคือ “กลุ่มที่ต้องตัดออก” ดังนั้น EXCEPT ไม่ได้เพียงเปรียบเทียบข้อมูลสองชุดเท่านั้น แต่ยังสะท้อนคำถามทางธุรกิจอย่างชัดเจนว่า เรากำลังต้องการดูว่า “ใครหายไปจากกลุ่มเดิม” หรือ “ใครเพิ่มเข้ามาในกลุ่มใหม่”

ตัวอย่างการใช้ EXCEPT ในการหาลูกค้าแบบที่เป็นสมาชิกที่ซื้อสินค้าในเดือนกุมภาพันธ์ 2026 แต่ไม่กลับมาซื้อในเดือนมีนาคม

-- นับจำนวนลูกค้าที่ซื้อในเดือนกุมภาพันธ์ 2026 แต่ไม่กลับมาซื้อในเดือนมีนาคม (Churned Customers)
SELECT COUNT(*) AS churned_feb_customers -- จำนวนลูกค้าที่หายไปในเดือนมีนาคม
FROM (
 -- ลูกค้าที่ซื้อเดือน ก.พ.
 SELECT DISTINCT customer_id -- รหัสลูกค้าที่ซื้อในเดือนกุมภาพันธ์ (ไม่ซ้ำ)
 FROM transactions
 WHERE customer_id IS NOT NULL AND DATE_PART('year', order_date) = 2026 AND month = 2 -- กรองเฉพาะเดือนกุมภาพันธ์และมี customer_id

 EXCEPT   -- ลบออกคนที่กลับมาซื้อเดือน มี.ค.
 
 -- ลูกค้าที่ซื้อเดือน มี.ค.
 SELECT DISTINCT customer_id -- รหัสลูกค้าที่ซื้อในเดือนมีนาคม (ไม่ซ้ำ)
 FROM transactions
 WHERE customer_id IS NOT NULL AND DATE_PART('year', order_date) = 2026 AND month = 3 -- กรองเฉพาะเดือนมีนาคมและมี customer_id
) AS t; -- Subquery หาลูกค้าที่ไม่กลับมาซื้อซ้ำ
churned_feb_customers
119

หากสลับมุมมองแล้วถามกลับกันว่า “มีลูกค้าใหม่เข้ามาในเดือนมีนาคมเท่าไหร่ที่ไม่เคยซื้อในเดือนกุมภาพันธ์?” ก็เพียงสลับลำดับของสองชุดใน EXCEPT เดิม

-- นับจำนวนลูกค้าใหม่ในเดือนมีนาคมที่ไม่เคยซื้อในเดือนกุมภาพันธ์ (New Customers)
SELECT COUNT(*) AS new_mar_customers -- จำนวนลูกค้าใหม่ที่เริ่มซื้อในเดือนมีนาคม
FROM (
 -- ลูกค้าที่ซื้อเดือน มี.ค.
 SELECT DISTINCT customer_id -- รหัสลูกค้าที่ซื้อในเดือนมีนาคม (ไม่ซ้ำ)
 FROM transactions
 WHERE customer_id IS NOT NULL AND month = 3 -- กรองเฉพาะเดือนมีนาคมและมี customer_id
 EXCEPT -- ลบออกคนที่มาใช้บริการในเดือน ก.พ.
 -- ลูกค้าที่ซื้อเดือน ก.พ.
 SELECT DISTINCT customer_id -- รหัสลูกค้าที่ซื้อในเดือนกุมภาพันธ์ (ไม่ซ้ำ)
 FROM transactions
 WHERE customer_id IS NOT NULL AND month = 2 -- กรองเฉพาะเดือนกุมภาพันธ์และมี customer_id
) AS t; -- Subquery หาลูกค้าที่ไม่เคยซื้อในเดือนก่อนหน้า
new_mar_customers
626

มีนาคมมีลูกค้าใหม่ 626 คนที่ไม่เคยสั่งในเดือนกุมภาพันธ์ ซึ่งมากกว่าลูกค้าที่หายไป (119 คน) ถึง 5.3 เท่า net customer growth = 626 − 119 = +507 คน

อัตรา churn 8% ใน 1 เดือนถือว่าต่ำสำหรับธุรกิจชา และการได้มาซึ่งลูกค้าใหม่ (acquisition) ทำได้ดีกว่าการสูญเสีย (churn) อย่างมาก แต่ในระยะยาว retention สำคัญกว่า acquisition เพราะต้นทุนการหาลูกค้าใหม่สูงกว่าการรักษาลูกค้าเดิมเสมอ

PiCha กำลังขยายฐานลูกค้าได้สำเร็จ แต่ต้องติดตามว่าลูกค้าใหม่ 626 คนนี้จะ retained ในเดือนถัดไปหรือไม่ ถ้าลูกค้าใหม่ churn เร็วกว่าลูกค้าเดิม อัตราส่วน 5.3:1 จะไม่ยั่งยืน ลำดับ EXCEPT มีความสำคัญ: สลับ SELECT ให้ผลลัพธ์คนละชุดโดยสิ้นเชิง (churned vs. new)

จากตัวอย่าง INTERSECT และ EXCEPT ที่ผ่านมา เราสามารถแบ่งลูกค้าออกเป็นเซตกย่อยตามพฤติกรรมที่สนใจได้ เช่น retained, churned, new หรือช่องทางที่ใช้บริการ เซตเหล่านี้เป็นฐานสำคัญในการออกแบบแคมเปญและวัดประสิทธิภาพเชิงกลยุทธ์ในบทถัดไป ซึ่งจะขยายไปถึงการใช้ UNION เพื่อประกอบรายงานจากหลาย query เข้าด้วยกัน

UNION

UNION ใช้สำหรับรวมผลลัพธ์จากหลาย query เข้าด้วยกัน โดยคืนแถวที่ปรากฏใน query ใด query หนึ่ง ตัวอย่างเช่น หากต้องการหาเมนูที่ควรนำเสนอเป็นพิเศษ ธุรกิจอาจพิจารณาจากสองเกณฑ์ คือ เมนูที่ขายดีที่สุด 5 อันดับแรก หรือเมนูที่มีราคาพรีเมียมตั้งแต่ 130 บาทขึ้นไป จากนั้นใช้ UNION เพื่อรวมผลลัพธ์ของทั้งสองกลุ่มให้เป็นรายการเดียวสำหรับการวิเคราะห์หรือวางแผนโปรโมชัน ทั้งนี้ UNION จะตัดข้อมูลซ้ำออกโดยอัตโนมัติ ทำให้เมนูที่เข้าได้ทั้งสองเกณฑ์ปรากฏเพียงครั้งเดียว

แนวคิดสำคัญคือ UNION จะคืนผลลัพธ์ที่ปรากฏอยู่ใน query แรก หรือ query ที่สอง ดังนั้นเมนูบางรายการอาจถูกเลือกเพราะขายดีมาก แม้ราคาไม่ถึง 130 บาท ขณะที่บางรายการอาจไม่ได้ติดอันดับยอดขายสูงสุด แต่ยังควรถูกนำเสนอเพราะเป็นสินค้าพรีเมียมที่สร้างภาพลักษณ์และมาร์จินที่ดีให้ธุรกิจ

นอกจากนี้ UNION จะตัดแถวที่ซ้ำกันออกโดยอัตโนมัติ ดังนั้นหากมีเมนูใดเป็นทั้ง “สินค้าขายดี” และ “สินค้าพรีเมียม” เมนูนั้นจะปรากฏเพียงครั้งเดียวในผลลัพธ์สุดท้าย คุณสมบัตินี้ช่วยให้ผลลัพธ์มีความกระชับและเหมาะสำหรับนำไปใช้ทำรายงานหรือสร้างรายการโปรโมชัน

ในเชิงธุรกิจ UNION จึงเหมาะกับโจทย์ที่ต้องการรวม “หลายเหตุผลในการคัดเลือก” เข้าด้วยกัน เช่น การรวมลูกค้ากลุ่มมูลค่าสูงกับลูกค้าที่ซื้อซ้ำบ่อย การรวมสินค้าทำกำไรสูงกับสินค้ายอดนิยม หรือการรวมรายการที่ผ่านเกณฑ์จากคนละมิติ เพื่อใช้ประกอบการตัดสินใจทางการตลาด

ตัวอย่างการใช้ UNION ในการตอบคำถามว่า เมนูใดบ้างที่ควรนำเสนอเป็นพิเศษ โดยใช้สองเกณฑ์ร่วมกัน ได้แก่ เมนูที่ขายดีที่สุด 5 อันดับแรก หรือ เมนูที่มีราคาพรีเมียมตั้งแต่ 130 บาทขึ้นไป ดังนั้น query นี้ไม่ได้มองหาเฉพาะเมนูที่ “ขายดีและแพง” พร้อมกัน แต่ต้องการรวมเมนูที่ผ่านเกณฑ์ข้อใดข้อหนึ่งเข้าไว้ด้วยกัน

-- เมนูขายดี 5 อันดับแรก (วัดจากจำนวนที่ขายได้)
SELECT m.sku_name, m.family, m.list_price_thb  -- ชื่อเมนู, ประเภท, และราคา
FROM menus m                        -- จากตารางเมนู
JOIN (                              -- เชื่อมกับ subquery
 SELECT sku_id                      -- ดึงรหัสเมนู
 FROM order_items                   -- จากตารางรายการออร์เดอร์
 GROUP BY sku_id                    -- จัดกลุ่มตามรหัสเมนู
 ORDER BY COUNT(*) DESC             -- เรียงตามจำนวนที่ขายได้จากมากไปน้อย
 LIMIT 5                            -- เอาเพียง 5 อันดับแรก
) top5 ON m.sku_id = top5.sku_id    -- ตั้งชื่อ subquery ว่า top5 และเชื่อมด้วย sku_id

UNION  -- รวมผลลัพธ์และตัดแถวที่ซ้ำออก

-- เมนูราคาพรีเมียม (list_price_thb ≥ 130 บาท)
SELECT sku_name, family, list_price_thb
FROM menus
WHERE list_price_thb >= 130

ORDER BY list_price_thb DESC; -- อยู่ได้เพียงที่เดียว และต้องอยู่ใน SELECT ตัวสุดท้าย

ผลลัพธ์ของ SELECT ตัวแรก แสดงเมนูขายดี 5 อันดับแรก (วัดจากจำนวนที่ขายได้)

sku_name family list_price_thb แถวซ้ำ
Da Hong Pao Milk Tea L milk_tea 130 Y
Jasmine Milk Tea L milk_tea 115
White Peach Milk Tea L milk_tea 115
Peach Jasmine Tea L fruit_tea 95
Fresh Orange Jasmine Tea L fruit_tea 150 Y

ผลลัพธ์ของ SELECT ตัวที่สอง แสดงเมนูราคาพรีเมียม (list_price_thb ≥ 130 บาท)

sku_name family list_price_thb แถวซ้ำ
Tie Guan Yin Milk Tea L milk_tea 130
Da Hong Pao Milk Tea L milk_tea 130 Y
Fresh Orange Jasmine Tea L fruit_tea 150 Y
Mango Sticky Rice Tea L fruit_tea 135

เมื่อเอามา UNION รวมกัน (โดยตัดแถวที่ซ้ำกันออก) จะได้ผลลัพธ์ทั้งสิ้น 7 แถว

sku_name family list_price_thb
Fresh Orange Jasmine Tea L fruit_tea 150
Mango Sticky Rice Tea L fruit_tea 135
Tie Guan Yin Milk Tea L milk_tea 130
Da Hong Pao Milk Tea L milk_tea 130
Jasmine Milk Tea L milk_tea 115
White Peach Milk Tea L milk_tea 115
Peach Jasmine Tea L fruit_tea 95
Note

จุดที่ต้องระวังเป็นพิเศษคือ ORDER BY สามารถเขียนได้ เพียงครั้งเดียว และต้องอยู่ ท้ายสุดของคำสั่งทั้งหมดเท่านั้น เพราะการเรียงลำดับจะเกิดขึ้นหลังจากที่ได้รวมผลลัพธ์ของทุก SELECT เสร็จเรียบร้อยแล้ว ไม่ใช่เรียงทีละส่วนแล้วค่อยนำมารวม

ดังนั้นในตัวอย่างนี้ ORDER BY list_price_thb DESC จึงหมายถึงให้เรียงผลลัพธ์สุดท้ายทั้งหมดจากราคาสูงไปต่ำ หลังจากรวมทั้งเมนูขายดีและเมนูพรีเมียมเข้าด้วยกันแล้ว ถ้านำ ORDER BY ไปใส่ไว้กลางคำสั่งหลัง SELECT แรก ระบบส่วนใหญ่จะมองว่า syntax ไม่ถูกต้อง หรือไม่ให้ผลตามเจตนาที่ต้องการ

ตัวอย่างสุดท้ายนี้แสดงการใช้ UNION เพื่อรวบรวมผลลัพธ์จากหลายคำถามให้เป็นรายงานสรุปการเปลี่ยนแปลงของฐานลูกค้าระหว่างเดือนกุมภาพันธ์และมีนาคม 2026 โดยอาศัยแนวคิดจากตัวอย่างก่อนหน้าเกี่ยวกับ INTERSECT และ EXCEPT ในการวิเคราะห์ลูกค้าที่หายไปและลูกค้าใหม่ ทั้งนี้ แต่ละแถวในผลลัพธ์สุดท้ายล้วนมาจากคำถามที่แตกต่างกัน แล้วถูกรวมเข้าด้วยกันด้วย UNION เช่น

period total_customers query
ลูกค้าที่ซื้อในเดือน ก.พ. 1,480 ← มาจาก Query 1
ลูกค้าที่ซื้อในเดือน มี.ค. 1,987 ← มาจาก Query 2
ลูกค้าที่ churn ในเดือน มี.ค. 119 ← มาจาก Query 3
ลูกค้าใหม่ในเดือน มี.ค. 626 ← มาจาก Query 4

มาดูวิธีการใช้ UNION นำผลลัพธ์จาก Query หลายตัวมา ต่อกันในแนวตั้ง เป็นตารางเดียว (ตัวอย่างนี้ใช้เทคนิคการเขียน CTE ซึ่งจะกล่าวโดยละเอียดในบทที่ 10 เพื่อเก็บผลลัพธ์ของแต่ละ query ก่อนที่จะนำมาต่อเข้าด้วยกันด้วย UNION)

-- วิเคราะห์การเปลี่ยนแปลงฐานลูกค้าระหว่างเดือน ก.พ. และ มี.ค. 2026
WITH feb_customers AS (
  -- ลูกค้าทั้งหมดที่เคยซื้อในเดือนกุมภาพันธ์ (ไม่นับซ้ำ)
  SELECT DISTINCT customer_id
  FROM transactions
  WHERE customer_id IS NOT NULL        -- กรองเฉพาะที่ระบุตัวตนได้
    AND DATE_PART('year', order_date) = 2026
    AND month = 2                      -- เดือนกุมภาพันธ์
),
mar_customers AS (
  -- ลูกค้าทั้งหมดที่เคยซื้อในเดือนมีนาคม (ไม่นับซ้ำ)
  SELECT DISTINCT customer_id
  FROM transactions
  WHERE customer_id IS NOT NULL        -- กรองเฉพาะที่ระบุตัวตนได้
    AND DATE_PART('year', order_date) = 2026
    AND month = 3                      -- เดือนมีนาคม
),
churned_customers AS (
  -- ลูกค้าที่ซื้อใน ก.พ. แต่ไม่กลับมาซื้อใน มี.ค. (EXCEPT = ลบชุด มี.ค. ออก)
  SELECT customer_id
  FROM feb_customers
  EXCEPT
  SELECT customer_id
  FROM mar_customers
),
new_customers_mar AS (
  -- ลูกค้าใหม่ที่ซื้อใน มี.ค. แต่ไม่เคยซื้อใน ก.พ. (EXCEPT = ลบชุด ก.พ. ออก)
  SELECT customer_id
  FROM mar_customers
  EXCEPT
  SELECT customer_id
  FROM feb_customers
)

-- รวมผลลัพธ์ทั้ง 4 กลุ่มเป็นตารางสรุปเดียว
SELECT '1. ลูกค้าที่ซื้อในเดือน ก.พ.'    AS period, COUNT(*) AS total_customers
FROM feb_customers

UNION

SELECT '2. ลูกค้าที่ซื้อในเดือน มี.ค.'   AS period, COUNT(*) AS total_customers
FROM mar_customers

UNION

SELECT '3. ลูกค้าที่ churn ในเดือน มี.ค.' AS period, COUNT(*) AS total_customers
FROM churned_customers

UNION

SELECT '4. ลูกค้าใหม่ในเดือน มี.ค.'       AS period, COUNT(*) AS total_customers
FROM new_customers_mar

ORDER BY period;
period total_customers
1. ลูกค้าที่ซื้อในเดือน ก.พ. 1480
2. ลูกค้าที่ซื้อในเดือน มี.ค. 1987
3. ลูกค้าที่ churn ในเดือน มี.ค. 119
4. ลูกค้าใหม่ในเดือน มี.ค. 626

สรุป

ในบทนี้ เราได้เรียนรู้ set operations ซึ่งเป็นเครื่องมือสำคัญสำหรับการเปรียบเทียบและรวมผลลัพธ์จากหลาย query เข้าด้วยกัน โดยแต่ละคำสั่งเหมาะกับโจทย์คนละลักษณะ ตารางต่อไปนี้สรุปการใช้งานของคำสั่งหลักที่พบได้บ่อย

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

ในบทถัดไป เราจะเรียนรู้เรื่อง subqueries ซึ่งช่วยให้สามารถซ้อน query ไว้ภายในอีก query หนึ่งได้ แนวทางนี้เปิดโอกาสให้เราตอบคำถามที่ซับซ้อนยิ่งขึ้น และขยายความสามารถของ SQL ไปไกลกว่าการใช้ set operations หรือ JOIN เพียงอย่างเดียว

คำถามท้ายบท

  1. ใช้ INTERSECT หาลูกค้าที่สั่งทั้ง delivery และ dine_in อย่างน้อยคนละ 1 ออเดอร์
คลิกเพื่อดูเฉลย
SELECT customer_id FROM transactions WHERE channel = 'delivery'
INTERSECT
SELECT customer_id FROM transactions WHERE channel = 'dine_in'
ORDER BY customer_id;

ผลลัพธ์ที่คาดหวัง (10 แถวแรก จากทั้งหมด 1,079 คน)

customer_id
1001
1002
1004
1005
1010
1012
1015
1019
1023
1024
  1. ใช้ EXCEPT หาเมนูที่ไม่เคยถูกสั่งเลยในเดือนมีนาคม

ผลลัพธ์ที่คาดหวัง

sku_id sku_name
9 Osmanthus Milk Tea R
10 Osmanthus Milk Tea L
คลิกเพื่อดูเฉลย
-- หาเมนูที่ไม่มีการสั่งซื้อเลยในเดือนมีนาคม (เมนูที่ขายไม่ได้)
SELECT sku_id, sku_name FROM menus -- รหัสและชื่อเมนูทั้งหมดในระบบ
EXCEPT -- ลบออกเมนูที่มีการสั่งซื้อในเดือนมีนาคม
SELECT DISTINCT oi.sku_id, m.sku_name -- รหัสและชื่อเมนูที่ถูกสั่งในเดือนมีนาคม (ไม่ซ้ำ)
FROM order_items oi
INNER JOIN menus m ON oi.sku_id = m.sku_id -- เชื่อมตารางเมนูเพื่อดึงชื่อ sku
INNER JOIN transactions t ON oi.transaction_id = t.transaction_id -- เชื่อมตารางธุรกรรมเพื่อกรองตามเดือน
WHERE t.month = 3 -- กรองเฉพาะออเดอร์ในเดือนมีนาคม
ORDER BY sku_id; -- เรียงตามรหัสเมนู
  1. ใช้ UNION รวมสรุป record count, unique orders, และ revenue จาก 3 มุมมอง: transactions, order_items, และ menus

ผลลัพธ์ที่คาดหวัง

source_table record_count unique_orders total_revenue_thb
transactions 53,323 53,323 10,787,401
order_items 89,731 53,323 10,301,361
menus 22 NULL 2,415
คลิกเพื่อดูเฉลย
-- สรุปข้อมูลเบื้องต้นของแต่ละตารางเพื่อตรวจสอบความครบถ้วนของข้อมูล (Data Profiling)
SELECT 
 'transactions'  AS source_table, -- ชื่อตาราง
 COUNT(*)        AS record_count, -- จำนวนแถวทั้งหมดในตาราง transactions
 COUNT(DISTINCT transaction_id) AS unique_orders, -- จำนวนออเดอร์ที่ไม่ซ้ำ
 ROUND(SUM(net_sales_thb), 0) AS total_revenue_thb -- รายได้สุทธิรวม (บาท)
FROM transactions
UNION -- รวมผลลัพธ์ทุกตารางเข้าด้วยกัน (ไม่ตัดแถวซ้ำ)
SELECT
 'order_items'   AS source_table, -- ชื่อตาราง
 COUNT(*)        AS record_count, -- จำนวนแถวทั้งหมดในตาราง order_items
 COUNT(DISTINCT transaction_id) AS unique_orders, -- จำนวนออเดอร์ที่ไม่ซ้ำในรายการสินค้า
 ROUND(SUM(net_line_sales_thb), 0) AS total_revenue_thb -- รายได้สุทธิรวมระดับ line item (บาท)
FROM order_items
UNION ALL -- รวมผลลัพธ์ตารางที่สาม
SELECT
 'menus'         AS source_table, -- ชื่อตาราง
 COUNT(*)        AS record_count, -- จำนวนเมนูทั้งหมดในระบบ
 NULL            AS unique_orders, -- ไม่มีคอลัมน์ transaction_id ในตาราง menus
 ROUND(SUM(list_price_thb), 0) AS total_revenue_thb -- ราคาตั้งรวมของทุกเมนู (บาท)
FROM menus;