MySQLの正規表現がGrapheme Clusterに対応していた

「竈門禰󠄀豆子」を MySQL に保存できるとかできないとかいう話題を見て、そう言えば MySQL の Grapheme Cluster 対応ってどうなってるんだっけ…と思ってググってみたら、MySQL 8.0.28 のリリースノートにこんな文を見つけた。

International Components for Unicode version 67 introduced a new implementation for \X (match a grapheme cluster), which requires locale data not currently included with MySQL. This means that, when using the version of ICU bundled with MySQL, a query using \X raises the error ER_REGEXP_MISSING_RESOURCE; when using ICU supplied by the system, we report ER_WARN_REGEXP_USING_DEFAULT as a Note. (Bug #33290090)

これは MySQL 8.0.28 で Grapheme Cluster の正規表現の \X がエラーまたは警告になるようになったということで、実はどうやら、MySQL 8.0 の正規表現はリリース時から Grapheme Cluster に対応していたらしい。知らなかった。

MySQL 8.0 から正規表現ライブラリが変わってマルチバイト文字に対応するようになったので、そのときに \X の機能も入っていたようだ。

Grapheme Cluster

Grapheme Cluster は日本語では「書記素クラスタ」というらしい。 テキトーに言うと、コンピュータの都合の「文字」ではなくて人間が見たときの「文字」を扱うための仕様で Unicode で決められている。

普通にプログラムで Unicode 文字を扱う場合はコードポイントという単位を1文字として扱うんだけど、Unicode では複数のコードポイントなのに1文字に見える文字がある。

かな文字の濁点の正規化に NFC/NFD というのがあって、「」は NFC だと U+304C の1文字だけど、NFD だと2文字の「が」 U+304B U+3099 となる。昔の MacOS のファイル名が NFD に似た何かだったような気がする(今も?)。

囲み専用の文字というのもあって、 1⃞1 と U+20DE の2文字だったりとか。

あと国旗の絵文字「🇯🇵」は「🇯」と「🇵」が2つ並べられたものだったり、「👨‍👩‍👦‍👦」は「👨」「👩」「👦」「👦」を U+200D で連結した7文字(UTF-8 では25バイト)だったりとか。

「竈門禰󠄀豆子」の「禰󠄀」も「禰」とその異体字を表すための U+E0100 がついた2文字なのだった。

人間が見ると1文字なのに実際には複数のコードポイントから構成されている文字は普通にコードポイント単位で扱うとおかしなことになる。

「禰󠄀豆子」の文字数が4文字になったり、「🇯🇵🇺🇸」から左の1文字を取り出すと「🇯」になったりする。

人に見える1文字単位で文字を扱いたい場合に Grapheme Cluster で扱うのが良い。

MySQL での Grapheme Cluster

MySQL では CHAR_LENGTH() とか LEFT() は Grapheme Cluster には対応してない。

mysql> SELECT CHAR_LENGTH('禰󠄀豆子') LEN;
+-----+
| LEN |
+-----+
|   4 |
+-----+

mysql> SELECT LEFT('🇯🇵🇺🇸', 1) C;
+------+
| C    |
+------+
| 🇯     |
+------+

SELECT RIGHT('🇯🇵🇺🇸', 1) C;
+------+
| C    |
+------+
| 🇸     |
+------+

正規表現の \X は対応しているんだけど、MySQL の正規表現関数はしょぼいのであまりたいしたことはできないんだけど、頑張ればある程度はできなくもない。

mysql> SELECT CHAR_LENGTH(REGEXP_REPLACE('禰󠄀豆子', '\\X', '*')) LEN;
+------+
| LEN  |
+------+
|    3 |
+------+
1 row in set, 1 warning (0.00 sec)
Note (Code 4077): Regular expression library used default (root) locale.

mysql> SELECT REGEXP_SUBSTR('🇯🇵🇺🇸', '\\X') C;
+----------+
| C        |
+----------+
| 🇯🇵         |
+----------+
1 row in set, 2 warnings (0.00 sec)
Note (Code 4077): Regular expression library used default (root) locale.
Note (Code 4077): Regular expression library used default (root) locale.

mysql> SELECT REGEXP_SUBSTR('🇯🇵🇺🇸', '\\X$') C;
+----------+
| C        |
+----------+
| 🇺🇸         |
+----------+
1 row in set, 2 warnings (0.00 sec)
Note (Code 4077): Regular expression library used default (root) locale.
Note (Code 4077): Regular expression library used default (root) locale.

warning は出るけど無視しよう!

Grapheme Cluster 対応の SUBSTR() はこんな感じで作れなくもない。結構強引だけども。

mysql> CREATE FUNCTION GRAPHEME_SUBSTR(str VARCHAR(1024), pos INT, len INT)
    -> RETURNS VARCHAR(1024) DETERMINISTIC
    -> RETURN REGEXP_SUBSTR(SUBSTR(str, IF(pos>1, CHAR_LENGTH(REGEXP_SUBSTR(str, REPEAT('\\X', pos-1)))+1, 1)), CONCAT('\\X{1,', len, '}'));

mysql> SELECT SUBSTR('竈門禰󠄀豆子', 3, 2);
+----------------------------------+
| SUBSTR('竈門禰?豆子', 3, 2)      |
+----------------------------------+
| 禰󠄀                               |
+----------------------------------+

mysql> SELECT GRAPHEME_SUBSTR('竈門禰󠄀豆子', 3, 2);
+-------------------------------------------+
| GRAPHEME_SUBSTR('竈門禰?豆子', 3, 2)      |
+-------------------------------------------+
| 禰󠄀豆                                      |
+-------------------------------------------+