4. JOIN เชื่อมโยงข้อมูลข้ามตาราง

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

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

  1. อธิบายหลักการทำงานของ INNER JOIN
  2. อธิบายหลักการทำงานของ LEFT JOIN
  3. เขียนคำสั่ง JOIN เชื่อมหลายตาราง
  4. เลือกใช้ LEFT JOIN ได้ถูกต้องเมื่อต้องการเก็บแถวที่ไม่มีข้อมูลคู่

บทนำ

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

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

การวิเคราะห์คำถามลักษณะนี้จำเป็นต้องอาศัยข้อมูลจากหลายแหล่งร่วมกัน ได้แก่ transactions ซึ่งบอกว่าลูกค้าซื้อสินค้าที่สาขาใดและผ่านช่องทางใด, customers ซึ่งให้ข้อมูลด้านอายุและระดับสมาชิก, stores ซึ่งอธิบายรายละเอียดของสาขา, order_items ซึ่งแสดงรายการสินค้าที่อยู่ในแต่ละออเดอร์, และ menus ซึ่งระบุประเภทสินค้าและราคา

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

ดังนั้น ก่อนจะเริ่มใช้ JOIN อย่างมีประสิทธิภาพ ผู้เรียนจึงควรทำความเข้าใจโครงสร้างของตารางและความสัมพันธ์ระหว่างข้อมูลในฐานข้อมูล PiCha ให้ชัดเจนเสียก่อน

erDiagram
 CUSTOMERS ||--o{ TRANSACTIONS : places
 STORES ||--o{ TRANSACTIONS : receives
 TRANSACTIONS ||--o{ ORDER_ITEMS : contains
 MENUS ||--o{ ORDER_ITEMS : "listed in"

 CUSTOMERS {
 BIGINT customer_id PK
 VARCHAR age_group
 VARCHAR loyalty_tier
 VARCHAR discovery_channel
 BIGINT health_conscious
 }

 STORES {
 BIGINT store_id PK
 VARCHAR store_name
 VARCHAR branch_type
 VARCHAR district
 }

 TRANSACTIONS {
 BIGINT transaction_id PK
 BIGINT store_id FK
 BIGINT customer_id FK "nullable"
 VARCHAR channel
 DOUBLE net_sales_thb
 DOUBLE contribution_margin_thb
 BIGINT satisfaction_score
 }

 ORDER_ITEMS {
 BIGINT transaction_id PK,FK
 BIGINT sku_id FK
 BIGINT line_no PK
 DOUBLE net_line_sales_thb
 VARCHAR add_on
 }

 MENUS {
 BIGINT sku_id PK
 VARCHAR sku_name
 VARCHAR family
 VARCHAR tea_base
 }

รูปที่ 4.1: แผนผัง ER ของฐานข้อมูล PiCha (แบบย่อ) แสดงความสัมพันธ์ 4 ทางที่ใช้ในบทนี้

ความสัมพันธ์หลัก:

  • transactions.store_id เชื่อม stores.store_id ทุกออเดอร์ที่มีรหัสสาขาตรงกัน
  • transactions.customer_id เชื่อม customers.customer_id บางออเดอร์ไม่มี customer_id (เป็น NULL)
  • order_items.transaction_id เชื่อม transactions.transaction_id ทุก line item อยู่ในออเดอร์เดียวกัน
  • order_items.sku_id เชื่อม menus.sku_id ทุก line item อ้างอิงเมนูที่ถูกต้อง

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

JOIN

JOIN ใช้เชื่อมโยงแถวข้อมูลจากหลายตารางเข้าด้วยกันเพื่อให้ได้ผลลัพธ์ตามต้องการ ความสามารถนี้เป็นหัวใจสำคัญของฐานข้อมูลเชิงสัมพันธ์ เพราะการเชื่อมตารางต่าง ๆ คือการสร้างความสัมพันธ์ระหว่างข้อมูลหรือตารางนั่นเอง

การเชื่อมตารางอาศัยคอลัมน์ร่วม (common column) โดยทั่วไปใช้ คีย์นอก (foreign key) ของตารางหนึ่งเชื่อมกับ คีย์หลัก (primary key) ของอีกตาราง เช่น transactions.store_id เชื่อมกับ stores.store_id เป็นต้น

ในงานวิเคราะห์ข้อมูลทางธุรกิจ มักใช้ JOIN 2 ประเภทหลัก ได้แก่

ประเภท ลักษณะการทำงาน
INNER JOIN แสดงเฉพาะแถวที่ match กันทั้งสองตาราง
LEFT JOIN แสดงทุกแถวจากตารางซ้าย

ในงานจริง INNER JOIN และ LEFT JOIN ครอบคลุม use case ของนักวิเคราะห์มากกว่า 90% JOIN ประเภทอื่น เช่น RIGHT/FULL OUTER JOIN พบได้น้อยกว่าสำหรับงาน dashboard / analytics ทั่วไป

ในครึ่งแรกของบทนี้ เราจะเริ่มจาก INNER JOIN ซึ่งใช้บ่อยที่สุดเมื่อต้องการเฉพาะแถวที่มีข้อมูลครบทั้งสองฝั่ง จากนั้นครึ่งหลังจะขยายไปสู่ LEFT JOIN เมื่อเราต้องการรักษา “แถวที่ไม่มีคู่” เอาไว้เพื่อวิเคราะห์ช่องว่างของข้อมูล

INNER JOIN

INNER JOIN เชื่อมแถวจาก 2 ตารางขึ้นไป โดย แสดงเฉพาะแถวที่มีค่าคอลัมน์จับคู่ตรงกัน ตามเงื่อนไขที่ระบุใน ON clause

โดยทั่วไป

  • ฝั่งหนึ่งเป็นตาราง fact ที่มี foreign key (เช่น transactions.store_id)
  • อีกฝั่งเป็นตาราง dimension ที่มี primary key (เช่น stores.store_id)
  • ผลลัพธ์จะมีแถวเฉพาะเมื่อค่า key ตรงกันทั้งสองตาราง

เพื่อให้เห็นภาพการทำงานของ INNER JOIN ชัดเจน เราจะเริ่มจากตัวอย่างง่ายสุดคือการเชื่อม 2 ตาราง ก่อนขยายไปสู่หลายตาราง

ไวยากรณ์ INNER JOIN 2 ตาราง

SELECT
  a.key, a.metric, b.attribute
FROM fact_table a
INNER JOIN dim_table b
  ON a.key = b.key

โดยทั่วไปการเขียน JOIN โดยไม่ระบุคำว่า INNER จะถือว่าเป็น INNER JOIN ดังนั้น JOIN กับ INNER JOIN จึงให้ผลลัพธ์เหมือนกัน ต่างกันแค่ความชัดเจนในการอ่านโค้ดเท่านั้น

การเชื่อม 2 ตาราง: รายได้ตามสาขา

หากต้องการเห็นรายได้, จำนวนออเดอร์ และ margin ของแต่ละสาขา พร้อมประเภทสาขาและเขต จะต้องเชื่อมตาราง transactions และ stores เข้าด้วยกัน เพราะ

  • transactions มี store_id แต่ไม่มีชื่อสาขา/เขต
  • stores มี store_name, branch_type, district

จึงต้องใช้ INNER JOIN ดึงข้อมูลจากทั้งสองตารางเข้าด้วยกัน

SELECT                   -- เลือกคอลัมน์ที่ต้องการแสดงผล
 s.store_name,           -- ชื่อสาขา
 s.branch_type,          -- ประเภทสาขา
 s.district,             -- เขต/อำเภอ
 COUNT(*) AS order_count,           -- นับจำนวนออเดอร์ทั้งหมด
 ROUND(SUM(t.net_sales_thb), 0) AS total_revenue,    -- รายได้รวม
 ROUND(AVG(t.net_sales_thb), 0) AS avg_order_value,  -- มูลค่าออเดอร์เฉลี่ย
 ROUND(SUM(t.contribution_margin_thb), 0) AS total_cm  -- contribution margin
FROM transactions t                    -- จากตาราง transactions (ตั้งชื่อแฝง t)
INNER JOIN stores s ON t.store_id = s.store_id   -- เชื่อมตาราง stores ด้วย store_id
GROUP BY s.store_name, s.branch_type, s.district -- จัดกลุ่มตามชื่อ ประเภท และเขต
ORDER BY total_revenue DESC;           -- เรียงลำดับตามรายได้รวมจากมากไปน้อย
store_name branch_type district order_count total_revenue avg_order_value total_cm
PiCha centralwOrld mall Pathum Wan 11,935 2,451,113 205 1,318,149
PiCha Siam Square street Pathum Wan 10,738 2,311,860 215 1,205,243
PiCha ICONSIAM mall_flagship Khlong San 8,702 1,763,926 203 966,501
PiCha Vanit Village mixed_use Ratchathewi 7,257 1,465,764 202 797,878
PiCha Park Silom office Bang Rak 7,611 1,450,877 191 806,787
PiCha Exchange Tower office Khlong Toei 7,080 1,343,861 190 747,494

จะเห็นได้ว่า CentralWorld และ Siam Square ซึ่งอยู่ในเขต Pathum Wan สร้างรายได้รวมร่วมกัน 4.76 ล้านบาท คิดเป็นประมาณ 44% ของรายได้ทั้งหมด ขณะที่สาขาประเภท office เช่น Park Silom และ Exchange Tower มี AOV ต่ำกว่า (190–191 บาท) แต่จากบทก่อนเราทราบว่า CM% ของสาขากลุ่มนี้สูงที่สุด จึงเป็น “เครื่องยนต์กำไร” ที่มีโครงสร้างต้นทุน lean แม้รายได้รวมจะรองลงมา

ในแง่เทคนิค ตัวอย่างนี้สรุป pattern สำคัญของ INNER JOIN

  • ใช้ตาราง transactions เป็นฐาน แล้ว JOIN กับตาราง stores เพื่อ “แปลรหัสให้เป็นเนื้อหา”
  • SELECT ดึงคอลัมน์ทั้งจาก t และ s ได้หลัง JOIN
  • GROUP BY สามารถใช้คอลัมน์จากหลายตารางได้ แต่ทุกคอลัมน์ที่ไม่ใช่ aggregate ต้องระบุใน GROUP BY

หลังจากเข้าใจการ JOIN ข้ามตารางในมุม “สาขา” แล้ว ส่วนถัดไปจะใช้แนวคิดเดียวกันกับมุม “สินค้า”

การเชื่อม 2 ตาราง: Top เมนูสร้างรายได้

คราวนี้เปลี่ยนคำถาม: “เมนูใดสร้างรายได้สูงสุด และแต่ละเมนูอยู่ในตระกูลและฐานชาใด”

ตารางที่เกี่ยวข้องมีดังนี้

  • order_items บอกว่าแต่ละออเดอร์มีสินค้าใดบ้างและมูลค่าแถวละเท่าไร (net_line_sales_thb)
  • menus เก็บรายละเอียดของสินค้า เช่น sku_name, family, tea_base

จึงใช้ INNER JOIN เพื่อดึงรายละเอียดเมนูมาอธิบาย line item

SELECT                   -- เลือกคอลัมน์ที่ต้องการแสดงผล
 m.sku_name,             -- ชื่อสินค้า (SKU)
 m.family,               -- ตระกูลสินค้า
 m.tea_base,             -- ชาหลัก
 COUNT(*) AS times_ordered,        -- จำนวนครั้งที่ถูกสั่งซื้อ
 ROUND(SUM(oi.net_line_sales_thb), 0) AS total_revenue,    -- รายได้รวม
 ROUND(AVG(oi.net_line_sales_thb), 0) AS avg_line_revenue  -- รายได้เฉลี่ยต่อบรรทัด
FROM order_items oi                -- จากตาราง order_items (ตั้งชื่อแฝง oi)
INNER JOIN menus m ON oi.sku_id = m.sku_id    -- เชื่อมตาราง menus ด้วย sku_id
GROUP BY m.sku_name, m.family, m.tea_base     -- จัดกลุ่มตามชื่อ ตระกูล และฐานชา
ORDER BY total_revenue DESC         -- เรียงลำดับตามรายได้รวมจากมากไปน้อย
LIMIT 10;                    -- แสดงแค่ 10 อันดับแรก
sku_name family tea_base times_ordered total_revenue avg_line_revenue
Jasmine Milk Tea L milk_tea jasmine 9,698 1,151,147 119
Da Hong Pao Milk Tea L milk_tea da_hong_pao 7,811 1,047,280 134
White Peach Milk Tea L milk_tea white_peach 8,555 1,014,856 119
Fresh Orange Jasmine Tea L fruit_tea jasmine 4,636 718,110 155
Tie Guan Yin Milk Tea L milk_tea tie_guan_yin 4,168 558,680 134
Pu’er Milk Tea L milk_tea puer 4,384 521,230 119
Peach Jasmine Tea L fruit_tea jasmine 5,224 513,396 98
Glutinous Green Milk Tea L milk_tea glutinous_green 4,351 448,015 103
Tie Guan Yin Milk Tea R milk_tea tie_guan_yin 3,915 441,183 113
Jasmine Grapefruit Tea L fruit_tea jasmine 3,872 420,031 108

จากตารางจะเห็นว่า Top 10 SKU ที่สร้างรายได้สูงสุดเป็นกลุ่ม milk_tea ถึง 7 รายการ และ fruit_tea 3 รายการ โดย Jasmine Milk Tea L ทำรายได้สูงสุดกว่า 1.15 ล้านบาท และเมนูขนาด L มักมีรายได้รวมสูงกว่าขนาด R อย่างชัดเจน

ในมุมของ JOIN ตัวอย่างนี้เน้นว่า

  • เมื่อ JOIN order_items กับ menus แล้ว เราสามารถสร้าง “มุมมองตามสินค้า” ได้
  • การ GROUP BY พร้อมกันหลายคอลัมน์ได้ เช่น sku_name, family, tea_base

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

การเชื่อมมากกว่า 2 ตาราง: tea_base × สาขา

หากต้องการทราบว่า tea_base แต่ละประเภทมียอดสั่งซื้อเท่าไรในแต่ละสาขา?

ตารางที่เกี่ยวข้อง

  • order_items → รหัสเมนู (sku_id) และออเดอร์ (transaction_id)
  • menus → ฐานชา (tea_base)
  • transactions → สาขาที่ออเดอร์เกิด (store_id)
  • stores → ชื่อสาขา (store_name)
SELECT                   -- เลือกคอลัมน์ที่ต้องการแสดงผล
 s.store_name,           -- ชื่อสาขา
 m.tea_base,             -- ฐานชาหลัก
 COUNT(*) AS times_ordered  -- จำนวนครั้งที่ถูกสั่งซื้อ
FROM order_items oi                -- จากตาราง order_items (ตั้งชื่อแฝง oi)
INNER JOIN menus m ON oi.sku_id = m.sku_id    -- เชื่อมตาราง menus ด้วย sku_id
INNER JOIN transactions t ON oi.transaction_id = t.transaction_id  -- เชื่อมตาราง transactions ด้วย transaction_id
INNER JOIN stores s ON t.store_id = s.store_id  -- เชื่อมตาราง stores ด้วย store_id
GROUP BY s.store_name, m.tea_base;    -- จัดกลุ่มตามชื่อสาขาและฐานชา

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

  • order_items มีคอลัมน์ sku_id และ transaction_id
  • menus จับคู่ด้วย sku_id → แต่ละ line item รู้แล้วว่าตัวเองเป็นชาอะไร
  • transactions เข้ามาจับคู่ด้วย transaction_id → แต่ละ line item รู้แล้วว่าอยู่ในออเดอร์ไหน
  • stores เข้ามาจับคู่ด้วย store_id → ออเดอร์นั้นอยู่ที่สาขาไหน

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

ผลลัพธ์ที่ได้มีลักษณะดังนี้

store_name tea_base times_ordered
PiCha Exchange Tower jasmine 3,136
PiCha Exchange Tower glutinous_green 1,866
PiCha Exchange Tower white_peach 1,597
PiCha Exchange Tower da_hong_pao 1,486
PiCha Exchange Tower tie_guan_yin 1,444
PiCha Exchange Tower puer 1,019
PiCha Exchange Tower osmanthus 619
PiCha Exchange Tower mango 312
PiCha Exchange Tower thai_tea 29
PiCha ICONSIAM jasmine 4,405
PiCha ICONSIAM white_peach 2,096
PiCha ICONSIAM glutinous_green 2,053

เมื่อดูครบทั้งตารางจะพบว่า tea base ประเภท jasmine มียอดสั่งซื้อสูงสุดในทุกสาขา โดย CentralWorld และ Siam Square เป็นสาขาที่สั่ง jasmine สูงสุด

จุดสำคัญของตัวอย่างนี้คือ pattern ของ multi-table INNER JOIN

  1. เริ่มจาก fact ที่ละเอียดสุด (order_items)
  2. JOIN กับ dimension ตามสายโซ่ key → menus (ด้วย sku_id), transactions (ด้วย transaction_id), stores (ด้วย store_id)
  3. ผลลัพธ์สุดท้ายรวมทั้ง “สาขา × ฐานชา × จำนวนครั้ง” ในตารางเดียว ซึ่งไม่มีทางได้จากตารางใดตารางเดียว
Note

สำหรับ INNER JOIN การสลับลำดับของการ JOIN หลายตาราง (ตราบใดที่ ON ถูกต้อง) จะให้ผลลัพธ์เหมือนเดิม เพราะทุกขั้นจะตัดแถวที่ไม่มีคู่ทิ้ง

ครึ่งแรกของบทนี้แสดงให้เห็นแล้วว่า INNER JOIN ใช้เชื่อมข้อมูลเพื่อสร้างมุมมองข้ามตารางได้อย่างไร ต่อไปจะเห็นกรณีที่เรา “ตั้งใจเก็บแถวที่ไม่มีคู่” ด้วย LEFT JOIN

LEFT JOIN

มีหลายสถานการณ์ที่เราต้องการทั้งแถวที่มีคู่ (matching rows) และแถวที่ไม่มีคู่ (non-matching rows) จากการ JOIN เช่น

  • อยากรู้เมนูใดบ้าง “ไม่เคยถูกสั่ง” ในช่วงเวลาหนึ่ง
  • อยากเห็นลูกค้าระดับ gold ที่ “ยังไม่เคยทำรายการ”
  • อยากนับออเดอร์ทั้งหมด รวมทั้งออเดอร์ที่ไม่มีข้อมูลลูกค้า (anonymous)

ในกรณีเหล่านี้ INNER JOIN จะตัดแถวที่ไม่มีคู่ทิ้งทั้งหมด ทำให้เรามองไม่เห็น “ช่องว่างของข้อมูล” ที่บางครั้งสำคัญกว่าข้อมูลที่มีอยู่ด้วยซ้ำ ตรงนี้เองที่ LEFT JOIN มีบทบาทสำคัญ

การเชื่อมแบบ LEFT JOIN จะเก็บทุกแถวจากตารางฝั่งซ้าย ถ้าไม่เจอคู่ในตารางขวา ให้เติมค่า NULL ในคอลัมน์ฝั่งขวา

ในฐานข้อมูล PiCha ถ้าเราเขียน

FROM transactions t
LEFT JOIN customers c ON t.customer_id = c.customer_id
  • ตารางซ้าย (t) คือ transactions → มีออเดอร์ทั้งหมด 53,323 แถว
  • ตารางขวา (c) คือ customers → มีเฉพาะลูกค้าที่ลงทะเบียน
  • ออเดอร์ที่ไม่มี customer_id (anonymous ~ 74.3%) จะยังอยู่ในผลลัพธ์ แต่คอลัมน์จาก customers จะเป็น NULL ทั้งแถว
  • ถ้าใช้ INNER JOIN แทน ออเดอร์ anonymous ใน transaction จะหายไปจากผลลัพธ์ทั้งหมด

การเข้าใจความต่างนี้สำคัญมาก เพราะจะส่งผลโดยตรงต่อทั้งตัวเลขและการตีความ โดยเฉพาะเมื่อวิเคราะห์รายได้จากลูกค้า anonymous เทียบกับลูกค้าที่ลงทะเบียนแล้ว

มาดูตัวอย่างการใช้ LEFT JOIN เพื่อหาว่า “ลูกค้าแต่ละประเภท (รวม anonymous คือมี transactions.customer_id เป็น NULL) สร้างรายได้และมีความพึงพอใจเฉลี่ยเท่าใด” เราต้องการให้ทุกออเดอร์ที่อยู่ใน transactions ปรากฏในผลลัพธ์ แม้ออเดอร์นั้นจะไม่รู้ customer_id

SELECT
 CASE                                                  -- กำหนดเงื่อนไข
  WHEN c.customer_id IS NULL THEN 'Anonymous'          -- ถ้าไม่มีข้อมูลลูกค้า ให้เป็น Anonymous
  ELSE c.loyalty_tier                                  -- มิฉะนั้นใช้ระดับสมาชิก
 END AS customer_type,                                    -- สิ้นสุดเงื่อนไข ตั้งชื่อว่า customer_type
 COUNT(*) AS order_count,                                 -- นับจำนวนออร์เดอร์
 ROUND(SUM(t.net_sales_thb), 0) AS total_revenue,         -- รวมยอดขายสุทธิ (บาท)
 ROUND(AVG(t.net_sales_thb), 0) AS avg_order_value,       -- ค่าเฉลี่ยต่อออร์เดอร์ (บาท)
 ROUND(AVG(t.satisfaction_score), 2) AS avg_satisfaction  -- ค่าเฉลี่ยคะแนนความพึงพอใจ
FROM transactions t                                       -- จากตารางธุรกรรม
LEFT JOIN customers c ON t.customer_id = c.customer_id    -- เชื่อมกับตารางลูกค้า (รวมธุรกรรมที่ไม่มีข้อมูลลูกค้า)
GROUP BY                                                  -- จัดกลุ่มตาม
 CASE                                          -- กำหนดเงื่อนไขเดียวกันกับ SELECT
  WHEN c.customer_id IS NULL THEN 'Anonymous'  -- ถ้าไม่มีข้อมูลลูกค้า ให้เป็น Anonymous
  ELSE c.loyalty_tier                          -- มิฉะนั้นใช้ระดับสมาชิก
 END                                           -- สิ้นสุดเงื่อนไข
ORDER BY total_revenue DESC;                   -- เรียงตามยอดขายรวมจากมากไปน้อย

ตรรกะการทำงานเป็นขั้นตอนดังนี้

ขั้นที่ 1: FROM + LEFT JOIN

FROM transactions t -- จากตารางธุรกรรม
LEFT JOIN customers c ON t.customer_id = c.customer_id -- เชื่อมกับตารางลูกค้า (รวมธุรกรรมที่ไม่มีข้อมูลลูกค้า)
  • ตาราง transactions บางแถวมี customer_id = NULL (ลูกค้าไม่ได้สแกนสมาชิก)

ตัวอย่าง mini-table

t.transaction_id t.customer_id c.loyalty_tier
1001 305 gold
1002 NULL NULL
1003 812 silver
  • LEFT JOIN → เก็บทุกแถวจาก transactions ไว้ทั้งหมด แถวที่ไม่มีคู่ใน customers จะมีคอลัมน์ฝั่งลูกค้าเป็น NULL
  • INNER JOIN → แถวที่ customer_id เป็น NULL จะไม่ถูกนำมาแสดงเลย

กรณีนี้เราอยากนับทุกออเดอร์ จึงจำเป็นต้องใช้ LEFT JOIN

ขั้นที่ 2: CASE WHEN แปลง NULL ให้มีความหมาย

CASE                                          -- กำหนดเงื่อนไข
 WHEN c.customer_id IS NULL THEN 'Anonymous'  -- ถ้าไม่มีข้อมูลลูกค้า ให้เป็น Anonymous
 ELSE c.loyalty_tier                          -- มิฉะนั้นใช้ระดับสมาชิก
END AS customer_type,                         -- สิ้นสุดเงื่อนไข ตั้งชื่อว่า customer_type
  • ถ้า c.customer_id เป็น NULL → แสดงว่าออเดอร์นั้นเป็น anonymous → กำหนดเป็น 'Anonymous'
  • ถ้าไม่ NULL → ใช้ loyalty tier (gold, silver, member, none) เป็นประเภทลูกค้า

ผลลัพธ์คือคอลัมน์ใหม่ customer_type ที่มีค่าหนึ่งใน Anonymous, gold, silver, member, none

หากใช้ c.loyalty_tier ตรง ๆ โดยไม่แปลง anonymous จะกลายเป็นกลุ่มที่ชื่อเป็น NULL ซึ่งอ่านแล้วไม่สื่อความหมาย

ขั้นที่ 3: เลือกตัวชี้วัดจาก transactions

COUNT(*) AS order_count,                                 -- นับจำนวนออร์เดอร์
ROUND(SUM(t.net_sales_thb), 0) AS total_revenue,         -- รวมยอดขายสุทธิ (บาท)
ROUND(AVG(t.net_sales_thb), 0) AS avg_order_value,       -- ค่าเฉลี่ยต่อออร์เดอร์ (บาท)
ROUND(AVG(t.satisfaction_score), 2) AS avg_satisfaction  -- ค่าเฉลี่ยคะแนนความพึงพอใจ

ทุกตัวมาจากตาราง t ซึ่งเป็นแหล่งข้อมูลยอดขายและคะแนนความพึงพอใจ

ขั้นที่ 4: GROUP BY expression เดียวกับใน SELECT

GROUP BY                                       -- จัดกลุ่มตาม
 CASE                                          -- กำหนดเงื่อนไขเดียวกันกับ SELECT
  WHEN c.customer_id IS NULL THEN 'Anonymous'  -- ถ้าไม่มีข้อมูลลูกค้า ให้เป็น Anonymous
  ELSE c.loyalty_tier                          -- มิฉะนั้นใช้ระดับสมาชิก
 END                                           -- สิ้นสุดเงื่อนไข

จุดสำคัญ: ต้องเขียน CASE WHEN เหมือนกับใน SELECT ซ้ำอีกครั้ง เพราะ GROUP BY ทำงานก่อน SELECT และไม่สามารถอ้าง alias customer_type ได้ใน engine ส่วนใหญ่

ผลคือ การจัดกลุ่มทั้ง 53,323 ออเดอร์ให้เหลือเพียง 5 กลุ่ม customer_type

ขั้นที่ 5: ORDER BY ตามรายได้รวม

เรียงจากกลุ่มที่สร้างรายได้มากสุดลงมาด้วย ORDER BY total_revenue DESC

ผลลัพธ์

customer_type order_count total_revenue avg_order_value avg_satisfaction
Anonymous 39,621 8,003,465 202 4.02
member 4,698 968,686 206 4.05
silver 3,857 776,128 201 4.09
none 2,999 609,214 203 4.04
gold 2,148 429,908 200 4.10

ตารางนี้ชี้ให้เห็นว่า

  • Anonymous orders คิดเป็นประมาณ 74% ของจำนวนออเดอร์ทั้งหมด และสร้างรายได้กว่า 8.0 ล้านบาท มากกว่ากลุ่มสมาชิกทั้งหมดรวมกันประมาณ 2.9 เท่า
  • กลุ่ม gold มีจำนวนออเดอร์น้อยที่สุดในบรรดากลุ่มสมาชิก แต่มีความพึงพอใจเฉลี่ยสูงสุด (4.10) แม้ AOV กลับต่ำสุดในกลุ่มสมาชิก

ในภาพรวม PiCha พึ่งพารายได้จากลูกค้าที่ติดต่อไม่ได้โดยตรง (anonymous) ในระดับสูงมาก ซึ่งเป็นความเสี่ยงเชิงกลยุทธ์ หากแพลตฟอร์ม delivery หรือช่องทางที่ดึงลูกค้า anonymous เปลี่ยนนโยบายหรืออัลกอริทึม อาจกระทบต่อรายได้หลักของบริษัทได้ การออกแบบให้ลูกค้ากลุ่มนี้สมัครสมาชิก เช่น ผ่าน QR code หรือโปรโมชันสมัครครั้งแรก จึงเป็นแนวทางสำคัญที่ควรพิจารณา โดยต้องวิเคราะห์ต่อว่า Anonymous มาจาก delivery หรือ walk-in เป็นหลัก เพราะแนวทางแก้ต่างกัน

LEFT JOIN vs INNER JOIN แบบย่อ

มุมมอง INNER JOIN LEFT JOIN
แถวจากตารางซ้าย แสดงเฉพาะแถวที่มีคู่ในตารางขวา แสดงทุกแถวจากตารางซ้าย
แถวจากตารางขวา แสดงเฉพาะแถวที่มีคู่ในตารางซ้าย แสดงเมื่อมีคู่เท่านั้น
แถวที่ไม่เจอคู่ ถูกตัดทิ้งทั้งหมด แสดงคอลัมน์ฝั่งซ้าย + ใส่ NULL ให้ฝั่งขวา
ใช้เมื่อ… ทุกแถวควรมีข้อมูลครบทุกตาราง ต้องการเก็บ “ช่องว่างของข้อมูล” เช่น anonymous หรือสินค้าไม่เคยขาย
ตัวอย่างใน PiCha transactions JOIN stores transactions LEFT JOIN customers

ข้อผิดพลาดที่พบบ่อย

-- ผิด: ใช้ INNER JOIN แล้วไป WHERE c.customer_id IS NULL ❌
SELECT *
FROM transactions t
INNER JOIN customers c ON t.customer_id = c.customer_id
WHERE c.customer_id IS NULL;     -- จะไม่เคยได้แถวใดเลย

-- ถูก: ใช้ LEFT JOIN แล้วค่อยกรอง NULL ✅
SELECT *
FROM transactions t
LEFT JOIN customers c ON t.customer_id = c.customer_id
WHERE c.customer_id IS NULL;     -- ได้ anonymous orders

สรุป

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

ตารางต่อไปนี้สรุปความหมายของแนวคิดสำคัญที่ใช้ในบทนี้ พร้อมตัวอย่างการใช้งานในบริบทธุรกิจ

แนวคิด ความหมาย ตัวอย่าง
INNER JOIN แสดงเฉพาะแถวที่มีข้อมูลตรงกันในทั้งสองตาราง transactions INNER JOIN stores
LEFT JOIN เก็บทุกแถวจากตารางซ้าย และแสดง NULL เมื่อไม่พบข้อมูลที่จับคู่ได้ในตารางขวา transactions LEFT JOIN customers
Table Alias ใช้ชื่อย่อแทนชื่อตารางเพื่อลดความซ้ำซ้อนและช่วยให้ query อ่านง่ายขึ้น FROM transactions t
ON clause ระบุเงื่อนไขที่ใช้จับคู่ข้อมูลระหว่างตาราง ON t.store_id = s.store_id
Multi-table JOIN เชื่อมข้อมูลมากกว่าสองตารางโดยเขียน JOIN ต่อเนื่องหลายชุด order_items → menus → transactions → stores
NULL handling จัดการค่าที่ไม่พบจากการเชื่อมตาราง เช่น ใช้ CASE WHEN หรือ COALESCE เพื่อแทน NULL ด้วยค่าที่มีความหมาย COALESCE(c.loyalty_tier, 'Anonymous')

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

ลำดับการประมวลผล SQL เมื่อมี JOIN

graph TD
 A["1. FROM โหลดตารางหลัก"] --> B["2. JOIN เชื่อมตารางที่เกี่ยวข้อง"]
 B --> C["3. ON กำหนดเงื่อนไขการจับคู่"]
 C --> D["4. WHERE กรองแถวที่ต้องการ"]
 D --> E["5. GROUP BY จัดกลุ่มข้อมูล"]
 E --> F["6. HAVING กรองผลลัพธ์ระดับกลุ่ม"]
 F --> G["7. SELECT เลือกคอลัมน์ที่จะแสดง"]
 G --> H["8. ORDER BY เรียงลำดับผลลัพธ์"]

รูปที่ 4.2: ระบบประมวลผล JOIN หลัง FROM แต่ก่อน WHERE

จุดที่ผู้เรียนมักสับสนคือความแตกต่างระหว่างลำดับ “ที่เราเขียน” กับลำดับ “ที่ระบบทำงานจริง” โดยเราเขียนคำสั่งในรูปแบบดังนี้

  • SELECT ... FROM ... JOIN ... ON ... WHERE ... GROUP BY ... HAVING ... ORDER BY ...

แต่ระบบจะประมวลผลตามลำดับแนวคิดดังนี้

  • FROMJOINONWHEREGROUP BYHAVINGSELECTORDER BY

เมื่อเข้าใจลำดับนี้แล้ว ผู้เรียนจะสามารถอธิบายได้ชัดเจนขึ้นว่าทำไมบางคอลัมน์จึงยังไม่สามารถอ้างอิงได้ในบางช่วงของ query และจะเลือกวางเงื่อนไขใน ON, WHERE, หรือ HAVING ได้เหมาะสมยิ่งขึ้น

เพื่อเสริมความเข้าใจเชิงปฏิบัติ ควรทดลองทำแบบฝึกหัดที่ตามมาโดยเปรียบเทียบการใช้ INNER JOIN และ LEFT JOIN กับโจทย์หลายรูปแบบ เช่น กรณีที่ต้องการเฉพาะข้อมูลที่จับคู่ได้ครบทุกตาราง หรือกรณีที่ต้องการเก็บข้อมูลจากตารางหลักไว้ทั้งหมดแม้บางแถวจะไม่มีข้อมูลประกอบจากอีกตารางหนึ่ง การฝึกแยกแยะสถานการณ์เหล่านี้จะช่วยให้สามารถเลือกใช้ JOIN ได้อย่างเหมาะสมและแม่นยำมากขึ้นในการทำงานจริง

ในบทถัดไป (CASE Expressions) เราจะใช้ความเข้าใจลำดับนี้ร่วมกับ CASE เพื่อสร้างคอลัมน์ใหม่แบบมีเงื่อนไขและจัดกลุ่มข้อมูลในคำสั่งเดียว

คำถามท้ายบท

  1. ใช้ INNER JOIN ระหว่าง transactions และ stores เพื่อหาสาขาที่มี avg_satisfaction สูงสุด 3 อันดับแรก พร้อมแสดง branch_type และ district

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

store_name branch_type district avg_satisfaction
PiCha ICONSIAM mall_flagship Khlong San 4.53
PiCha CentralWorld mall Pathum Wan 4.19
PiCha Vanit Village mixed_use Ratchathewi 4.07
คลิกเพื่อดูเฉลย
SELECT
    s.store_name,
    s.branch_type,
    s.district,
    ROUND(AVG(t.satisfaction_score), 2) AS avg_satisfaction
FROM transactions t
INNER JOIN stores s ON t.store_id = s.store_id
GROUP BY s.store_id, s.store_name, s.branch_type, s.district
ORDER BY avg_satisfaction DESC
LIMIT 3;
  1. ใช้ LEFT JOIN ระหว่าง customers และ transactions เพื่อหาลูกค้าระดับ gold ที่ยังไม่เคยสั่งเลย (ไม่มี transaction) พร้อมแสดง age_group และ discovery_channel

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

customer_id loyalty_tier age_group discovery_channel
1044 gold Gen_Z_18_24 word_of_mouth
1059 gold Gen_Z_18_24 instagram
1083 gold Gen_Z_25_29 tiktok
1089 gold Gen_Z_18_24 tiktok
1100 gold Millennial_30_35 instagram
…. …. …. ….
คลิกเพื่อดูเฉลย
SELECT
 c.customer_id,
 c.loyalty_tier,
 c.age_group,
 c.discovery_channel
FROM customers c
LEFT JOIN transactions t ON c.customer_id = t.customer_id
WHERE c.loyalty_tier = 'gold'
  AND t.transaction_id IS NULL
ORDER BY c.customer_id;
  1. ใช้ 4-table JOIN (order_items, menus, transactions, stores) เพื่อหาว่าสาขาไหนขาย Osmanthus Milk Tea ได้มากที่สุด พร้อมแสดงจำนวนครั้งและรายได้รวม

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

store_name branch_type district times_sold total_revenue_thb
PiCha CentralWorld mall Pathum Wan 1,092 122,797.54
PiCha Siam Square street Pathum Wan 917 105,362.47
PiCha ICONSIAM mall_flagship Khlong San 831 92,191.27
PiCha Vanit Village mixed_use Ratchathewi 693 78,096.02
PiCha Park Silom office Bang Rak 663 73,293.44
PiCha Exchange Tower office Khlong Toei 619 68,787.75
คลิกเพื่อดูเฉลย
SELECT
    s.store_name,
    s.branch_type,
    s.district,
    SUM(oi.qty)                           AS units_sold,
    ROUND(SUM(oi.net_line_sales_thb), 2)  AS total_revenue_thb
FROM order_items oi
INNER JOIN menus m        ON oi.sku_id         = m.sku_id
INNER JOIN transactions t ON oi.transaction_id = t.transaction_id
INNER JOIN stores s       ON t.store_id        = s.store_id
WHERE m.sku_name LIKE 'Osmanthus Milk Tea%'
GROUP BY s.store_id, s.store_name, s.branch_type, s.district
ORDER BY total_revenue_thb DESC;
  1. ใช้ INNER JOIN วิเคราะห์ว่าลูกค้าที่ค้นพบผ่าน TikTok สั่ง fruit_tea สัดส่วนเท่าไหร่เทียบกับสั่ง milk_tea (ใช้ COUNT แยก family)

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

family orders pct
milk_tea 4,681 74.9%
fruit_tea 1,571 25.1%
คลิกเพื่อดูเฉลย
SELECT
    m.family,
    COUNT(*)                                                    AS orders,
    ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1)         AS pct
FROM order_items oi
INNER JOIN menus m        ON oi.sku_id         = m.sku_id
INNER JOIN transactions t ON oi.transaction_id = t.transaction_id
INNER JOIN customers c    ON t.customer_id     = c.customer_id
WHERE c.discovery_channel = 'tiktok'
  AND m.family IN ('fruit_tea', 'milk_tea')
GROUP BY m.family
ORDER BY orders DESC;
  1. ใช้ LEFT JOIN transactions กับ customers และ GROUP BY เพื่อแสดงจำนวนออเดอร์แยกตาม loyalty_tier (รวม Anonymous) พร้อม avg_basket

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

loyalty_tier orders avg_basket_thb
member 4,698 206.19
none 2,999 203.14
Anonymous 39,621 202.00
silver 3,857 201.23
gold 2,148 200.14
คลิกเพื่อดูเฉลย
SELECT
    COALESCE(c.loyalty_tier, 'Anonymous')   AS loyalty_tier,
    COUNT(*)                                AS orders,
    ROUND(AVG(t.net_sales_thb), 2)          AS avg_basket_thb
FROM transactions t
LEFT JOIN customers c ON t.customer_id = c.customer_id
GROUP BY loyalty_tier
ORDER BY avg_basket_thb DESC;