Join
ปัญหาที่เจอจริง: ข้อมูลที่เกี่ยวข้องกันมักถูกแยกเก็บไว้คนละ table เพื่อลดความซ้ำซ้อน แต่งานจริงมักต้องเห็นข้อมูลรวมกัน — join รับสอง table มาจับคู่ row ตามกฎที่คุณเขียนใน ON clause เพราะหนังสือเก็บ id ของผู้เขียนไว้ คุณจึงสั่งให้ PostgreSQL เดินตามการเชื่อมโยงนั้นและผลิต row ที่บรรจุ column จากทั้งสอง table พร้อมกันได้ ทางเลือกเดียวที่คุณต้องตัดสินใจจริง ๆ คือจะทำอย่างไรกับ row ที่ไม่มีคู่ที่ตรงกันในอีกฝั่ง — และนั่นแหละคือสิ่งที่แยกประเภทของ join ออกจากกัน
เรายังคงใช้ table authors, books, และ sales ต่อไป
INNER JOIN: เฉพาะ row ที่ตรงกัน
หัวข้อที่มีชื่อว่า “INNER JOIN: เฉพาะ row ที่ตรงกัน”INNER JOIN คืน row ที่รวมกันหนึ่ง row สำหรับทุกคู่ที่เงื่อนไข ON เป็นจริง row ฝั่งใดก็ตามที่ไม่เจอคู่จะถูกทิ้งไปเฉย ๆ นี่คือ join ที่คุณหยิบมาใช้บ่อยที่สุด
SELECT b.title, a.name AS authorFROM books AS bINNER JOIN authors AS a ON a.id = b.author_idORDER BY b.title;const res = await pool.query(` SELECT b.title, a.name AS author FROM books AS b INNER JOIN authors AS a ON a.id = b.author_id ORDER BY b.title`);console.log(res.rows);cur.execute(""" SELECT b.title, a.name AS author FROM books AS b INNER JOIN authors AS a ON a.id = b.author_id ORDER BY b.title""")rows = cur.fetchall()rows, err := conn.Query(ctx, ` SELECT b.title, a.name AS author FROM books AS b INNER JOIN authors AS a ON a.id = b.author_id ORDER BY b.title`)let rows = sqlx::query( "SELECT b.title, a.name AS author FROM books AS b INNER JOIN authors AS a ON a.id = b.author_id ORDER BY b.title",).fetch_all(&pool).await?; title | author------------------+--------------- Quiet Harbors | Mara Linde The Glass Orchard| Mara Linde Tidal Notes | Owen Pryce(3 rows)ผู้เขียนที่ไม่มีหนังสือ หรือหนังสือที่ author_id ไม่ตรงกับผู้เขียนคนใดเลย จะไม่มีวันปรากฏในผลลัพธ์นี้
LEFT JOIN: เก็บทุก row ทางฝั่งซ้าย
หัวข้อที่มีชื่อว่า “LEFT JOIN: เก็บทุก row ทางฝั่งซ้าย”LEFT OUTER JOIN (คีย์เวิร์ด OUTER ใส่หรือไม่ก็ได้) เก็บทุก row จาก table ฝั่งซ้ายไว้แม้จะไม่มีคู่ที่ตรงกันในฝั่งขวา column ฝั่งขวาจะถูกเติมด้วย NULL สำหรับ row ที่ไม่มีคู่เหล่านั้น นี่คือวิธีที่คุณจะแสดงรายชื่อผู้เขียนและดูว่าใครยังไม่มีหนังสือเลย
SELECT a.name AS author, b.titleFROM authors AS aLEFT JOIN books AS b ON b.author_id = a.idORDER BY a.name;const res = await pool.query(` SELECT a.name AS author, b.title FROM authors AS a LEFT JOIN books AS b ON b.author_id = a.id ORDER BY a.name`);cur.execute(""" SELECT a.name AS author, b.title FROM authors AS a LEFT JOIN books AS b ON b.author_id = a.id ORDER BY a.name""")rows = cur.fetchall()rows, err := conn.Query(ctx, ` SELECT a.name AS author, b.title FROM authors AS a LEFT JOIN books AS b ON b.author_id = a.id ORDER BY a.name`)let rows = sqlx::query( "SELECT a.name AS author, b.title FROM authors AS a LEFT JOIN books AS b ON b.author_id = a.id ORDER BY a.name",).fetch_all(&pool).await?; author | title---------------+------------------- Mara Linde | Quiet Harbors Mara Linde | The Glass Orchard Owen Pryce | Tidal Notes Sela Vance |(4 rows)Sela Vance ยังไม่ได้เขียนหนังสือเลย row ของเธอจึงยังอยู่รอดโดยมี title เป็น NULL เจ้า NULL นั้นคือ join ที่กำลังบอกว่า “row ฝั่งซ้ายมีอยู่ แต่ไม่มีอะไรตรงกันในฝั่งขวา”
RIGHT และ FULL OUTER JOIN
หัวข้อที่มีชื่อว่า “RIGHT และ FULL OUTER JOIN”RIGHT JOIN คือภาพสะท้อนกลับด้าน คือเก็บทุก row จาก table ฝั่งขวาและเติมฝั่งซ้ายด้วย NULL ตรงที่ไม่มีคู่ที่ตรงกัน A RIGHT JOIN B ผลิต row ออกมาเหมือนกับ B LEFT JOIN A หลายคนจึงเขียนแต่ LEFT JOIN เท่านั้น ส่วน FULL OUTER JOIN เก็บ row ที่ไม่มีคู่จากทั้งสองฝั่งพร้อมกัน
SELECT a.name AS author, b.titleFROM authors AS aFULL OUTER JOIN books AS b ON b.author_id = a.idORDER BY a.name;const res = await pool.query(` SELECT a.name AS author, b.title FROM authors AS a FULL OUTER JOIN books AS b ON b.author_id = a.id ORDER BY a.name`);cur.execute(""" SELECT a.name AS author, b.title FROM authors AS a FULL OUTER JOIN books AS b ON b.author_id = a.id ORDER BY a.name""")rows = cur.fetchall()rows, err := conn.Query(ctx, ` SELECT a.name AS author, b.title FROM authors AS a FULL OUTER JOIN books AS b ON b.author_id = a.id ORDER BY a.name`)let rows = sqlx::query( "SELECT a.name AS author, b.title FROM authors AS a FULL OUTER JOIN books AS b ON b.author_id = a.id ORDER BY a.name",).fetch_all(&pool).await?; author | title---------------+------------------- Mara Linde | Quiet Harbors Mara Linde | The Glass Orchard Owen Pryce | Tidal Notes Sela Vance | | Unlinked Draft(5 rows)row สุดท้ายคือหนังสือที่ author_id ไม่ได้ชี้ไปยังผู้เขียนคนใด ฝั่งผู้เขียนจึงเป็น NULL full join เป็น join แบบเดียวที่เผย row แบบนี้ออกมาพร้อมกับผู้เขียนที่ไม่มีหนังสือ
แต่ละ join เก็บ row ไหนบ้าง
หัวข้อที่มีชื่อว่า “แต่ละ join เก็บ row ไหนบ้าง”join ทั้งสี่แบบต่างกันแค่วิธีจัดการกับ row ที่ไม่ผ่านการทดสอบ ON เท่านั้น ภาพนี้สรุปว่าใครรอด
flowchart TD M[Matching rows on both sides] --> INNER[INNER JOIN keeps these only] M --> LEFT[LEFT JOIN keeps these plus unmatched left rows] M --> RIGHT[RIGHT JOIN keeps these plus unmatched right rows] M --> FULL[FULL OUTER JOIN keeps these plus unmatched rows from both sides]
เคล็ดลับและข้อควรระวัง
หัวข้อที่มีชื่อว่า “เคล็ดลับและข้อควรระวัง”- การลืม
ONclause จะเปลี่ยน join ให้กลายเป็น cross join (CROSS JOIN) ที่จับคู่ทุก row ฝั่งซ้ายกับทุก row ฝั่งขวา ด้วยสิบ row กับอีกสิบ row ก็ได้ผลลัพธ์ออกมาหนึ่งร้อย row — ซึ่งแทบไม่ใช่สิ่งที่คุณต้องการเลย - ตั้ง alias สั้น ๆ ให้แต่ละ table เช่น
aและbแล้วใส่นำหน้าทุก column วิธีนี้ทำให้ query อ่านง่ายและเลี่ยงความกำกวมเมื่อทั้งสอง table มี column ชื่อเดียวกันอย่างid NULLในผลลัพธ์ของ outer join หมายถึง “ไม่พบ row ที่ตรงกัน” ไม่ได้หมายถึง “ค่าเป็นศูนย์หรือว่างเปล่า” ทดสอบด้วยIS NULLห้ามใช้= NULLเด็ดขาด- การใส่ตัวกรองให้ table ฝั่งขวาใน
WHEREแทนที่จะเป็นONอาจเปลี่ยนLEFT JOINกลับให้เป็น inner join อย่างเงียบ ๆ เพราะNULLไม่ผ่านการเปรียบเทียบในWHEREส่วนใหญ่
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
| Join table ที่ normalize ไว้ตอน query | ข้อมูลไม่ซ้ำ (no duplication) เสมอเป็นข้อมูลล่าสุด | query ซับซ้อนขึ้น และต้องคำนวณ join ทุกครั้งที่ query |
| Denormalize/คัดลอกข้อมูลไว้ล่วงหน้า | อ่านเร็วกว่า ไม่ต้อง join ตอน query | เสี่ยงข้อมูลไม่ตรงกัน (stale/inconsistent) ต้องคอยซิงค์เอง |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”- ลืมใส่
ONclause — PostgreSQL จะไม่เตือนว่าคุณพลาด แต่จะทำ cross join จับคู่ทุก row กับทุก row ทำให้ผลลัพธ์ระเบิด (row explosion) ตรวจสอบว่าทุก join มีONเสมอ - ไม่ตั้ง alias ให้ table — เมื่อสอง table มี column ชื่อเดียวกันอย่าง
idquery จะโยน ambiguous column error หรือแม้ไม่ error ก็อ่านยากจนดูแลต่อไม่ได้ ใช้ alias สั้น ๆ อย่างa,bเสมอ - ใส่ filter ของ table ฝั่งขวาไว้ใน
WHEREแทนON— กับLEFT JOINแล้วพฤติกรรมจะกลายเป็นINNER JOINอย่างเงียบ ๆ เพราะWHEREจะทิ้ง row ที่มีค่าNULLทางฝั่งขวาไปด้วย ถ้าอยากกรองแต่ยังเก็บ row ฝั่งซ้ายไว้ครบ ต้องใส่เงื่อนไขนั้นในON
💡 ตัวอย่างจากของจริง
Discord — activity feed และหน้าประวัติข้อความอาศัย join ระหว่าง table ผู้ใช้, server และข้อความ แทนที่จะคัดลอกชื่อผู้ใช้ไปฝังไว้ในทุกข้อความ ทำให้เปลี่ยนชื่อครั้งเดียวก็อัปเดตทุกที่
GitLab — หน้าประวัติ merge request และ pipeline รวมข้อมูลจากหลาย table (users, projects, commits) ด้วย join ตอน query เพื่อให้ single source of truth อยู่ที่เดียว ไม่ต้องกังวลเรื่องข้อมูลซ้ำที่หลุดซิงค์กัน