วิธีเรียงลำดับภาษาไทยใน PHP ให้ถูกต้องทั้ง MySQL และ PostgreSQL

คอมพิวเตอร์เรียงข้อความตามลำดับตัวอักษรที่พิมพ์ ชื่อที่ขึ้นต้นด้วยสระหน้าจึงไปกองท้ายรายการ โพสต์นี้ลองเรียงชื่อชุดเดียวกันใน PHP, PostgreSQL, MySQL และ JavaScript ให้เห็นว่าตัวไหนเรียงถูก ตัวไหนต้องตั้งเพิ่ม

เผยแพร่
เวลาอ่าน
6 นาที
ในโพสต์นี้ 7 หัวข้อ
  1. ภาษาไทยเรียงตามพยัญชนะตัวแรก
  2. แต่ละที่เรียงชื่อชุดเดียวกันได้ไม่เหมือนกัน
  3. เรียงใน PHP ด้วย Collator
  4. เรียงในฐานข้อมูล PostgreSQL ด้วย ICU collation
  5. บน MySQL เก็บ sort key ไว้อีกคอลัมน์
  6. เรียงในเบราว์เซอร์ด้วย Intl.Collator
  7. กรณีที่อาจเจอในงานจริง

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

ชื่อในตัวอย่างทั้งโพสต์เป็นชื่อสมมติ 14 ชื่อ: สมชาย เกศินี กมล แก้วใจ โชติ ใจดี ไพโรจน์ ขวัญใจ ประเสริฐ เพ็ญศรี ธนา อรุณ จันทร์เพ็ญ ชนิดา

ภาษาไทยเรียงตามพยัญชนะตัวแรก

ภาษาไทยมีสระห้าตัวที่เขียนไว้หน้าพยัญชนะแต่ออกเสียงทีหลัง คือ เ แ โ ใ ไ เวลาเปิดพจนานุกรม เราหา "เกศินี" ในหมวด ก เพราะพยัญชนะตัวแรกของคำคือ ก สระหน้าแค่มาก่อนเวลาเขียน

คอมพิวเตอร์เก็บข้อความตามลำดับที่พิมพ์ "เกศินี" จึงเริ่มด้วยตัว เ แล้วค่อยเป็น ก ในตาราง Unicode สระหน้าทั้งห้าตัวอยู่ต่อจาก ฮ (เ คือ U+0E40 ส่วน ฮ คือ U+0E2E) พอเรียงตามรหัสตัวอักษรตรง ๆ ทุกชื่อที่ขึ้นต้นด้วยสระหน้าจึงไปกองอยู่หลังชื่อที่ขึ้นต้นด้วยพยัญชนะทั้งหมด

เรียงตามรหัสตัวอักษร เรียงแบบพจนานุกรมไทย
กมล ขวัญใจ จันทร์เพ็ญ ชนิดา ธนา ประเสริฐ สมชาย อรุณ เกศินี เพ็ญศรี แก้วใจ โชติ ใจดี ไพโรจน์ กมล เกศินี แก้วใจ ขวัญใจ จันทร์เพ็ญ ใจดี ชนิดา โชติ ธนา ประเสริฐ เพ็ญศรี ไพโรจน์ สมชาย อรุณ

การเรียงแบบภาษาไทยต้องรู้กติกาของภาษา ว่าสระหน้าต้องสลับไปไว้หลังพยัญชนะก่อนเทียบ กติกาแบบนี้เรียกว่า collation และไลบรารีที่ใส่กติกาของแต่ละภาษาไว้ครบคือ ICU (International Components for Unicode) ซึ่ง PHP, PostgreSQL, Chrome และ Node.js ใช้อยู่ข้างในแล้ว

แต่ละที่เรียงชื่อชุดเดียวกันได้ไม่เหมือนกัน

เราลองเรียง 14 ชื่อข้างบนในแต่ละที่ (PHP 8.5 กับ ICU 78, PostgreSQL 17 และ 18, MySQL 8.4, Node.js) ได้ผลแบบนี้

เรียงที่ไหน ด้วยอะไร ผล
PHP sort() ผิด
PHP Collator('th_TH') ถูก
JavaScript .sort() ผิด
JavaScript Intl.Collator('th') ถูก
MySQL utf8mb4_general_ci, utf8mb4_unicode_ci, utf8mb4_unicode_520_ci, utf8mb4_0900_ai_ci, utf8mb4_bin ผิดทุกตัว
MySQL tis620_thai_ci (ต้องแปลงข้อความเป็น TIS-620 ก่อน) ถูก
PostgreSQL ที่สร้างฐานข้อมูลด้วย C หรือ C.UTF-8 ผิด
PostgreSQL บน macOS ที่สร้างด้วย en_US.UTF-8 ผิด
PostgreSQL บน Linux (glibc) ที่สร้างด้วย en_US.UTF-8 ถูก
PostgreSQL COLLATE "th-TH-x-icu" ถูก

ข้อที่น่าสนใจคือ PostgreSQL เพราะผลขึ้นกับ collation ที่ตั้งไว้ตอนสร้างฐานข้อมูล และ en_US.UTF-8 ชื่อเดียวกันยังเรียงต่างกันระหว่าง macOS กับ Linux โค้ดชุดเดียวกันจึงเรียงชื่อถูกบนเซิร์ฟเวอร์ แต่ผิดบนเครื่องที่ใช้พัฒนาได้ หรือกลับกันถ้าเซิร์ฟเวอร์สร้างฐานข้อมูลด้วย C.UTF-8 ดู collation ของฐานข้อมูลที่ใช้อยู่ได้จากคอลัมน์ datcollate ในตาราง pg_database

ฝั่ง MySQL ตรงไปตรงมากว่า collation ของ utf8mb4 ทุกตัวเรียงสระหน้าไว้ท้าย รวมถึง utf8mb4_0900_ai_ci ซึ่งเป็นค่าเริ่มต้นของ MySQL 8 และตารางภาษาที่มี collation เฉพาะของ MySQL 8.4ก็ไม่มีภาษาไทย ตัวที่เรียงถูกมีแค่ tis620_thai_ci ซึ่งเป็นของชุดตัวอักษร TIS-620 รุ่นเก่า

เรียงใน PHP ด้วย Collator

ถ้าข้อมูลมาอยู่ใน PHP แล้ว เช่นผลจาก API หรือ collection ที่ประกอบขึ้นเอง ให้เรียงด้วย Collator ของ extension intl โฮสต์และ Docker image ของ PHP ส่วนใหญ่เปิด intl ไว้แล้ว เช็กได้ด้วย php -m | grep intl

$names = ['สมชาย', 'เกศินี', 'กมล', 'แก้วใจ', 'โชติ', 'ใจดี', 'ไพโรจน์'];

// เรียงตามรหัสตัวอักษร
sort($names);

// เรียงแบบภาษาไทย
$collator = new Collator('th_TH');
$collator->sort($names);
sort():           กมล ขวัญใจ จันทร์เพ็ญ ชนิดา ธนา ประเสริฐ สมชาย อรุณ เกศินี เพ็ญศรี แก้วใจ โชติ ใจดี ไพโรจน์
Collator th_TH:   กมล เกศินี แก้วใจ ขวัญใจ จันทร์เพ็ญ ใจดี ชนิดา โชติ ธนา ประเสริฐ เพ็ญศรี ไพโรจน์ สมชาย อรุณ

ใน Laravel ส่ง compare() ของ Collator ให้ sort() ของ collection ได้เลย

$collator = new Collator('th_TH');

$customers = $customers->sort(
    fn ($a, $b) => $collator->compare($a->name, $b->name)
)->values();

setlocale() กับ SORT_LOCALE_STRING ใช้แทนไม่ได้ เพราะขึ้นกับ locale ที่ติดตั้งในเครื่อง บน macOS ที่เราลอง setlocale(LC_COLLATE, 'th_TH.UTF-8') ไม่สำเร็จ และผลยังเรียงผิดเหมือน sort()

th_TH ต่างจาก locale อื่นของ ICU อีกสองอย่างที่ควรรู้ อย่างแรก ชื่อไทยมาก่อนชื่อภาษาอังกฤษ (กมล สมชาย Anan Somchai) ส่วน locale อื่นเอาภาษาอังกฤษขึ้นก่อน อย่างที่สอง ช่องว่างและเครื่องหมายมีน้ำหนักน้อยกว่าตัวอักษร "สมศรี" กับ "สม ศรี" จึงอยู่ติดกัน ส่วนตัวเลขในชื่อ เช่น "ห้อง 2" กับ "ห้อง 10" ปกติเรียงทีละหลัก (ห้อง 1, ห้อง 10, ห้อง 2) ถ้าอยากให้เรียงตามค่าตัวเลข ให้ตั้ง $collator->setAttribute(Collator::NUMERIC_COLLATION, Collator::ON)

เรียงในฐานข้อมูล PostgreSQL ด้วย ICU collation

ถ้า PostgreSQL ติดตั้งมาพร้อม ICU (แพ็กเกจของ Ubuntu, Debian และ Homebrew มีให้) จะมี collation ภาษาไทยชื่อ th-TH-x-icu ให้ใช้เลย ใส่ไว้ท้าย ORDER BY ได้ทันทีโดยไม่ต้องแก้ตาราง

Customer::query()
    ->orderByRaw('name collate "th-TH-x-icu"')
    ->paginate(25);
default (C.UTF-8):  กมล ขวัญใจ จันทร์เพ็ญ ชนิดา ธนา ประเสริฐ สมชาย อรุณ เกศินี เพ็ญศรี แก้วใจ โชติ ใจดี ไพโรจน์
th-TH-x-icu:        กมล เกศินี แก้วใจ ขวัญใจ จันทร์เพ็ญ ใจดี ชนิดา โชติ ธนา ประเสริฐ เพ็ญศรี ไพโรจน์ สมชาย อรุณ

ถ้าคอลัมน์นั้นควรเรียงแบบไทยเสมอ ตั้ง collation ให้คอลัมน์ใน migration ไปเลย ORDER BY name ธรรมดาก็จะเรียงถูก และ index ของคอลัมน์นั้นก็ใช้ลำดับเดียวกัน

$table->string('name')->collation('th-TH-x-icu');

ถ้า th-TH-x-icu ไม่มีในเครื่อง (เช็กได้ในตาราง pg_collation) สร้างเองได้ด้วย CREATE COLLATION thai (provider = icu, locale = 'th-TH') ผลที่ได้เหมือนกัน ดูรายละเอียดได้ที่หน้า Collation Support ของ PostgreSQL

แบบ orderByRaw ใช้ index ปกติของคอลัมน์ไม่ได้ เพราะ index เรียงไว้ตาม collation ของคอลัมน์ ตารางเล็กไม่ต่างกัน แต่ถ้าตารางใหญ่และหน้านี้เปิดบ่อย ตั้ง collation ที่คอลัมน์ หรือสร้าง index ที่ระบุ collation เดียวกัน

บน MySQL เก็บ sort key ไว้อีกคอลัมน์

MySQL ยังไม่มี collation ภาษาไทยของ utf8mb4 ทางที่เห็นกันบ่อยคือแปลงเป็น TIS-620 ตอนเรียง

ORDER BY CONVERT(name USING tis620) COLLATE tis620_thai_ci

ก็เรียงถูกนะ แต่ TIS-620 มีแค่ตัวอักษรไทยกับภาษาอังกฤษ ตัวอักษรอื่นในชื่อ เช่นอีโมจิหรือภาษาอื่น จะกลายเป็น ? และ MySQL ต้องแปลงทุกแถวก่อนเรียง จึงใช้ index ไม่ได้

อีกทางที่ใช้ index ได้คือให้ PHP คำนวณ sort key ของชื่อเก็บไว้อีกคอลัมน์ Collator::getSortKey() คืนค่าเป็น binary สั้น ๆ (ชื่อยาวเจ็ดตัวอักษรได้ key ราว 13 byte) ที่เรียงแบบ byte ต่อ byte แล้วได้ลำดับเดียวกับ Collator เก็บในคอลัมน์ VARBINARY แล้ว ORDER BY name_sort_key เราลองให้ MySQL เรียง key ของ 14 ชื่อแบบ binary ได้ลำดับตรงกับ Collator('th_TH') ทุกตัว

// app/Models/Customer.php
protected static function booted(): void
{
    static::saving(function (Customer $customer) {
        $customer->name_sort_key = (new Collator('th_TH'))->getSortKey($customer->name);
    });
}

key นี้ผูกกับรุ่นของ ICU ถ้าอัปเกรด PHP แล้ว ICU เปลี่ยนรุ่นใหญ่ ควรคำนวณ key ใหม่ทั้งตารางครั้งหนึ่ง ถ้าข้อมูลไม่ได้เยอะ จะดึงมาเรียงใน PHP ตามหัวข้อก่อนหน้าก็ได้ ง่ายกว่า

เรียงในเบราว์เซอร์ด้วย Intl.Collator

ตารางที่กดหัวคอลัมน์แล้วเรียงในเบราว์เซอร์เจอปัญหาเดียวกัน เพราะ .sort() ของ JavaScript เรียงตามรหัสตัวอักษร ให้ใช้ Intl.Collator('th') ซึ่งมีใน Chrome, Safari, Firefox และ Node.js ทุกรุ่นที่ใช้กันอยู่

const collator = new Intl.Collator('th');

rows.sort((a, b) => collator.compare(a.name, b.name));

กรณีที่อาจเจอในงานจริง

  • เรียงใน PHP หลังแบ่งหน้าแล้ว ถ้าดึงมาหน้าละ 25 แถวแล้วค่อยเรียงด้วย Collator จะได้แค่ 25 แถวนั้นเรียงถูก ลำดับข้ามหน้ายังเป็นของฐานข้อมูล ต้องเรียงในฐานข้อมูล หรือดึงมาทั้งชุด เรียง แล้วค่อยตัดเป็นหน้า อย่างตอนที่เราทำหน้ารายการสต็อกของระบบหลังร้านตัวหนึ่ง ตารางประกอบข้อมูลใน PHP ทั้งชุดอยู่แล้ว จึงเรียงชื่อสินค้าด้วย Collator('th_TH') ก่อนแบ่งหน้า
  • เครื่องที่ใช้พัฒนาเรียงไม่เหมือนเซิร์ฟเวอร์ อย่างในตาราง PostgreSQL บน macOS กับ Linux ให้ผลต่างกันแม้ตั้ง en_US.UTF-8 เหมือนกัน ระบุ th-TH-x-icu ให้ชัดจะได้ลำดับเดียวกันทุกเครื่อง
  • PostgreSQL เตือนว่า collation version mismatch หลังอัปเกรดระบบปฏิบัติการหรือ ICU รุ่นใหม่อาจเรียงบางตัวอักษรต่างจากเดิม PostgreSQL จึงเตือนให้สร้าง index ที่ใช้ collation นั้นใหม่ด้วย REINDEX
  • ชื่อที่มีคำนำหน้า "นาย" "นาง" "บริษัท" ทำให้ชื่อไปรวมกันอยู่ในหมวด น หรือ บ ถ้าอยากเรียงตามชื่อจริง ให้เก็บคำนำหน้าแยกคอลัมน์ แล้วเรียงที่ชื่อ

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

อ่านต่อ

ล่าสุด

ดูทั้งหมด →