Unicode Collation Algorithm

文字コードは面白いね! わーい! たのしー!

🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾🐾

MySQL で utf8mb4_unicode_ci コレーションを使用した時に「🍣」=「🍺」や「ハ」=「パ」になる問題があります。

この utf8mb4_unicode_ci ってなんぞや?と思ってマニュアルを見てみると、

MySQL は、http://www.unicode.org/reports/tr10/ で説明している Unicode 照合順序アルゴリズム (UCA) に従って xxx_unicode_ci 照合順序を実装します。照合順序は、バージョン 4.0.0 UCA 重みキー (http://www.unicode.org/Public/UCA/4.0.0/allkeys-4.0.0.txt) を使用します。

https://dev.mysql.com/doc/refman/5.6/ja/charset-unicode-sets.html

とあります。

Unicode には Unicode Collation Algorithm (UCA) という標準があり、MySQL の utf8mb4_unicode_ci は UCA のバージョン 4.0.0 を使用しています。

UCAのドキュメントをちゃんと読んだわけではないので以下の説明はテキトーです。

各文字の比較レベルを定義したテーブルは Default Unicode Collation Element Table (DUCET)と呼ばれて UCA のバージョン毎に提供されています。

UCA 4.0.0 の DUCET の中味はこんな感じです。

0061  ; [.0E33.0020.0002.0061] # LATIN SMALL LETTER A
FF41  ; [.0E33.0020.0003.FF41] # FULLWIDTH LATIN SMALL LETTER A; QQK
0363  ; [.0E33.0020.0004.0363] # COMBINING LATIN SMALL LETTER A; QQK
249C  ; [*0288.0020.0004.249C][.0E33.0020.0004.249C][*0289.0020.001F.249C] # PARENTHESIZED LATIN SMALL LETTER A; QQKN
1D41A ; [.0E33.0020.0005.1D41A] # MATHEMATICAL BOLD SMALL A; QQK

左端の16進数はUnicodeのコードポイントを表し、その次の [ ] で括られた4つの16進数は文字の比較レベルを表します。

レベルは左から順に次のようになっています。

L1 Base characters 基本文字
L2 Accents アクセント
L3 Case/Variants 大文字小文字/異体字
L4 Punctuation 句読点(?)

いくつか抜粋してみます。左に文字をつけました。

a 0061 ; [.0E33.0020.0002.0061] # LATIN SMALL LETTER A
FF41 ; [.0E33.0020.0003.FF41] # FULLWIDTH LATIN SMALL LETTER A; QQK
24D0 ; [.0E33.0020.0006.24D0] # CIRCLED LATIN SMALL LETTER A; QQK
A 0041 ; [.0E33.0020.0008.0041] # LATIN CAPITAL LETTER A
FF21 ; [.0E33.0020.0009.FF21] # FULLWIDTH LATIN CAPITAL LETTER A; QQK
å 00E5 ; [.0E33.0020.0002.0061][.0000.0043.0002.030A] # LATIN SMALL LETTER A WITH RING ABOVE; QQCM
Å 00C5 ; [.0E33.0020.0008.0041][.0000.0043.0002.030A] # LATIN CAPITAL LETTER A WITH RING ABOVE; QQCM
b 0062 ; [.0E4A.0020.0002.0062] # LATIN SMALL LETTER B
FF42 ; [.0E4A.0020.0003.FF42] # FULLWIDTH LATIN SMALL LETTER B; QQK
B 0042 ; [.0E4A.0020.0008.0042] # LATIN CAPITAL LETTER B

「a」っぽい文字は L1=0E33 で「b」っぽい文字は L1=0E4a になっています。

Å」は複数のレベルを持ち、1個目のレベルは「A」とまったく同じで、2個目のレベルは合成文字用の「˚」です。 NFD正規化された状態(?)でレベルが表されます。

L1 や L1+L2 で比較すると「a」「」「A」「」は同じ文字として扱われます。 L1+L2+L3 で比較すると異なる文字として扱われます。

文字の比較にどのレベルまで使用するかはアプリ次第で、MySQL の utf8mb4_unicode_ci では L1 しか使用していません。 そのため、英字は大文字/小文字/全角/半角は区別されません。

は=ぱ=ば=ハ=パ=バ

で、問題の「は」「ぱ」「ば」「ハ」「パ」「バ」ですが、次のようになっています。 濁点/半濁点つきの文字は正規化されて、清音文字+濁点文字の2つのレベルの組み合わせで表されてます。

306F ; [.1E6B.0020.000E.306F] # HIRAGANA LETTER HA
3071 ; [.1E6B.0020.000E.306F][.0000.0141.0002.309A] # HIRAGANA LETTER PA; QQCM
3070 ; [.1E6B.0020.000E.306F][.0000.0140.0002.3099] # HIRAGANA LETTER BA; QQCM
30CF ; [.1E6B.0020.0011.30CF] # KATAKANA LETTER HA
30D1 ; [.1E6B.0020.0011.30CF][.0000.0141.0002.309A] # KATAKANA LETTER PA; QQCM
30D0 ; [.1E6B.0020.0011.30CF][.0000.0140.0002.3099] # KATAKANA LETTER BA; QQCM

これらの文字は L1 レベルでは同じレベルなので、L1 でしか使用しない MySQL の utf8mb4_unicode_ci では区別されないことになります。

「は」「ぱ」「ば」だけでなく「か」「が」や「さ」「ざ」も区別されません。

日本語としては、清音、濁音、半濁音をそれぞれ区別するのが自然ですが、Unicode の標準の規則にしたがった Case insensitive だと区別できません。

utf8mb4_japanese_ci の登場に期待したいところです。

🍣=🍺

絵文字の比較はまた事情が異なります。DUCET には絵文字は定義されていないのです。実は漢字も定義されていません。

UCA では DUCET に定義されていない文字の扱い方も定めています。(7.1.3)

AAAA = BASE + (CP >> 15);
BBBB = (CP & 0x7FFF) | 0x8000;
CP => [.AAAA.0020.0002.][.BBBB.0000.0000.]

BASE:
FB40 CJK Ideograph
FB80 CJK Ideograph Extension A/B
FBC0 Any other code point

「漢」という文字のCP(Code point)はU+6F22なので、[.FB40.0020.0002.][.EF22.0000.0000] となります。 この2つのレベルを組みわせて使用します。

mysql> SELECT HEX(WEIGHT_STRING('漢'));
+---------------------------+
| HEX(WEIGHT_STRING('漢'))  |
+---------------------------+
| FB40EF22                  |
+---------------------------+

同じように「🍣」と「🍺」の値を求めると 「🍣」(U+1F363)は FBC3F363となり、「🍺」(U+1F37A)はFBC3F37Aとなるので、区別できるはずです。

ところが MySQL の utf8mb4_unicode_ci では、絵文字についてはそれに従わず、FFFD にしてしまっています。

一般的な照合順序の補助文字の場合、重みは 0xfffd REPLACEMENT CHARACTER の重みです。UCA 4.0.0 照合順序の補助文字の場合、照合重みは 0xfffd です。

https://dev.mysql.com/doc/refman/5.6/ja/charset-unicode-sets.html

mysql> SELECT HEX(WEIGHT_STRING('🍣'));
+-------------------------+
| HEX(WEIGHT_STRING('?')) |
+-------------------------+
| FFFD                    |
+-------------------------+

つまり、utf8mb4_unicode_ci で 🍣=🍺 となるのは Unicode のせいではなく、MySQL の問題です。

なお、utf8mb4_unicode_520_ci ではちゃんと計算された値を使用しています。

mysql> SET NAMES utf8mb4 COLLATE utf8mb4_unicode_520_ci;
mysql> SELECT HEX(WEIGHT_STRING('🍣'));
+-------------------------+
| HEX(WEIGHT_STRING('?')) |
+-------------------------+
| FBC3F363                |
+-------------------------+
mysql> SELECT HEX(WEIGHT_STRING('🍺'));
+-------------------------+
| HEX(WEIGHT_STRING('?')) |
+-------------------------+
| FBC3F37A                |
+-------------------------+