波ダッシュをいい感じにする Wavedash という gem を作ってます

これは何か?

波ダッシュのような文字を変換するための ruby 用ライブラリです。

takatoshiono/wavedash · GitHub

対象ユーザー

いるのかな…(もしいたら教えてください)

問題

たとえば文字コードが ujis の MySQL データベースを使用する Rails アプリケーションにおいて、 (U+301C WAVE DASH) をデータベースに保存しようとすると Mysql2::Error: Incorrect string value というエラーになる。

何が起きているか

MySQL

Incorrect string value というエラーを出しているのは MySQLMySQLsql_mode が strict モードのときに不正な文字を保存しようとするとエラーになる。

Ruby

Ruby から MySQL に接続するのに mysql2 を使ってる。mysql2 は Ruby 側の文字コードMySQL 側の文字コードに変換する仕事をしている。この仕事は C レベルで行われていて変換に失敗しても例外が発生しない。今回のケースでは U+301C を eucjp-ms に変換できないが、エラーが発生しないのでそのまま MySQL まで届いてしまって Incorrect string value になる。

再現テスト

charset=ujis のテーブルを作る。

CREATE TABLE `ujis_test` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `col1` varchar(255) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=ujis

テストスクリプトを作る。

#!/usr/bin/env ruby

require 'mysql2'

class Mysql2Client
  def initialize
    @client = Mysql2::Client.new(username: 'root', password: nil, host: 'localhost', database: 'test', encoding: 'ujis')
  end

  def sql_mode(sql_mode = nil)
    @client.query("set session sql_mode='#{sql_mode}'") if sql_mode
    @client.query('select @@sql_mode').first['@@sql_mode']
  end

  def insert(value)
    sql = "insert into ujis_test(col1) values('#{value}')"
    puts sql
    @client.query(sql)
  end
end

client = Mysql2Client.new
puts client.sql_mode(ARGV[1])
client.insert(ARGV[0])

実行する。

sql_mode = STRICT_ALL_TABLES のとき。

$ ./mysql2-client.rb "〜" STRICT_ALL_TABLES
STRICT_ALL_TABLES
insert into ujis_test(col1) values('〜')
./mysql2-client.rb:18:in `query': Incorrect string value: '\xE3\x80\x9C' for column 'col1' at row 1 (Mysql2::Error)
        from ./mysql2-client.rb:18:in `insert'
        from ./mysql2-client.rb:24:in `<main>'

エラーメッセージの '\xE3\x80\x9C’utf-8 バイト列。以下のように確認できる。

irb(main):016:0> "〜".bytes.map { |b| b.to_s(16) }
=> ["e3", "80", "9c"]

また sql_mode = NO_ENGINE_SUBSTITUTION ( MySQL 5.6.6 以降でのデフォルト ) のときはエラーにならない。

$ ./mysql2-client.rb "〜" NO_ENGINE_SUBSTITUTION
NO_ENGINE_SUBSTITUTION
insert into ujis_test(col1) values('〜')

どうすればいいのか

文字コードを変換する限り必ず変換できない文字はある。文字コードを変換しなくて済むならそれがベストだが、仕事だとそうもいかない。変換できない文字が含まれていたときに以下のようにすればいいと考えてる。

  • アプリケーションでバリデーションする
    • MySQL でエラーが発生して 500 エラーになるのはユーザーフレンドリーじゃない
    • バリデーションしてエラーメッセージを表示したい(「使用できない文字が含まれています。」)
  • 変換可能な文字に置換する
    • U+301C WAVE DASH は eucjp-ms に変換できないが、U+FF5E FULLWIDTH TILDE は 0xA1C1 に変換できる
  • エラーメッセージにどの文字が使用できないのか記載する
    • 「使用できない文字(〜)が含まれています」

これらを全部を実装できたらいいけどなかなか大変なので、上から順番にやっていけば、それぞれの対策がそれなりの効果を発揮すると思う。

Wavedash

というわけで「変換可能な文字に置換する」というのを行うためのライブラリとして takatoshiono/wavedash · GitHub というのを作っています。

また、「アプリケーションでバリデーションする」を行うために takatoshiono/character_encoding_validator · GitHub というのも作りました。

蛇足

上でこう書いた。

mysql2 は Ruby 側の文字コードMySQL 側の文字コードに変換する仕事をしている。この仕事は C レベルで行われていて変換に失敗しても例外が発生しない。今回のケースでは U+301C を eucjp-ms に変換できないが、エラーが発生しないのでそのまま MySQL まで届いてしまって Incorrect string value になる。

ujis のデータベースに対して、mysql2 は eucjp-msマッピングしている。それは mysql2/mysql_enc_to_ruby.rb at master · brianmario/mysql2 · GitHubを見るとわかると思う。個人的には euc-jp にするのが正しいのでは?と思っているけど、どうなんだろう?

とはいえ、どちらにマッピングしようと、今回の問題は依然として残るので放置している。

まとめ

ある問題に対する自分の考えと、それを実現するために作っているものについて書きました。

もっといい解決方法を持っていたり、ここに書いたことに間違いがあったら教えていただけるとありがたいです。

参考