ruby.wasm で MySQL Parameters を作り直した

プライベートでは基本的に誰の役にも立たないプログラムを作ってるんだけど、たまにうっかり MySQL Parameters みたいな役に立つものを作ってしまう。

MySQL Parameters は5年くらい前に Vue.js の勉強のために作ってみたんだけど、結局そのまま Vue.js は触らず放置状態だった。MySQL の新しいバージョンが出るたびにデータは更新してたけど。

ruby.wasm で Ruby が WebAssembly 上で動くようになり、ブラウザ上で JavaScript の代わりに使えるようになったんで、MySQL Parameters を Ruby で作り直してみた。

ruby.wasm

ruby.wasm のページに載ってるけど、これだけでブラウザ上で Ruby が動く。簡単。

<html>
  <script src="https://cdn.jsdelivr.net/npm/ruby-3_2-wasm-wasi@1.0.1/dist/browser.script.iife.js"></script>
  <script type="text/ruby">
    puts "Hello, world!"
  </script>
</html>

HTML内の <script>〜</script> にベタに Ruby を書くのもつらいので、別ファイルにしてそれを読み込むようにした。

<html>
  <head>
    <script src="https://cdn.jsdelivr.net/npm/ruby-3_2-wasm-wasi@1.0.1/dist/browser.script.iife.js"></script>
    <script type="text/ruby" src="hoge.rb"></script>
  </head>
  <body>
    ...
    <script type="text/ruby">
      fuga
    </script>
  </body>
</html>

こんな風にしたら hoge.rb を読み込んだ状態で fuga を実行してくれる。

p や puts 等の標準出力への出力はブラウザのコンソールに出力される。デバッグに便利。

JavaScript の機能を使う

require 'js' とすると JavaScript の機能を使うことができるようになる。

JS.eval で JavaScript を実行できる。

require 'js'
JS.eval('alert("hoge")')

JS.global 経由で JavaScript のグローバルなオブジェクトや関数を取得できる。

JS.global.alert('hoge')

JavaScript のオブジェクトや戻り値は Ruby ではすべて JS::Object クラスのインスタンスになってる。

n = JS.eval('return 123')     #=> JS::Object (123)
n.typeof                      #=> "number"
s = JS.eval('return "hoge"')  #=> JS::Object ("hoge")
s.typeof                      #=> "string"

そのままでは Ruby では扱いにくいので必要に応じて to_ito_s 等で変換する。

JS::Object#[] でプロパティを取得&設定できる。プロパティ名は文字列でもシンボルでも可能っぽい。JavaScript みたいに obj.propname では参照できない。Ruby だとこれはメソッド呼び出しになっちゃうので。

JS::Object#call で JavaScript の関数を呼ぶことができる。Ruby オブジェクトに存在しないメソッドを呼んだ場合は JavaScript の同名関数に変換してくれるので便利。

s = JS.eval('return "hoge"')  #=> JS::Object ("hoge")
s[:length]                    #=> JS::Object (4)
s.call(:charAt, 2)            #=> JS::Object ("g")
s.charAt(2)                   #=> JS::Object ("g")

あと JavaScript の null は Ruby は nil じゃなくて JS::Null として見える。同様に undefinedJS::Undefined。これらも JS::Object のインスタンス。

DOM 操作

Ruby にブラウザフロントエンドのフレームワークなんて当然あるわけないので、いにしえのコテコテな DOM 操作。メソッド名が JavaScript 風のキャメルケースなのでちょっと気持ち悪い。

require 'js'
Document = JS.global[:document]
hoge = Document.getElementById('hoge') # id=hoge 要素を取得
fuga = Document.createElement('div')   # div 要素を作成
fuga[:id] = 'fuga'                     # id=fuga を設定
hoge.appendChild(fuga)                 # fuga を hoge の子とする

上に書いたように戻り値は全部 JS::Object なので、たとえばある要素の子要素リストに Ruby からアクセスするには、こんな風にしないといけない。

children = JS.global[:document].getElementById('hoge')[:children]
children[:length].to_i.times do |i|
  p children[i][:tagName]
end

あんまり Ruby ぽくないので、何かしらのラッパークラスとかを用意したほうがいいかもしれない。

イベント処理

要素にイベントを設定する場合はこんな感じ。JavaScript で関数型の引数は Ruby では Proc で渡す。

element.addEventListener('change', ->(event){p event[:target][:value]})

Proc の代わりにブロックで指定することもできる。

element.addEventListener('change') do |event|
  p event[:target][:value]
end

Ruby っぽくて良い。

Promise

Promise も JavaScript オブジェクトなので、Ruby から使うことができる。

JS::Object.undef_method(:then)   # then メソッドを削除
Promise = JS.global[:Promise]
Promise.resolve(123).then do |a|
  pp a  #=> 123
end

そのままだと then で Ruby の Object#then が呼ばれちゃうので削除してる。まあ削除しなくても .call(:then) とすれば呼ぶことはできるんだけども。

fetch で外部の JSON ファイルを読み込んで Ruby の Hash にするにはこんな感じ。

require 'json'
JS.global.fetch('hoge.json').then do |res|
  # ①
  res.json.then do |obj|
    # ②
    pp JSON.parse(JS.global[:JSON].stringify(obj).to_s)
  end
  # ③
end
# ④

Promise は非同期処理なので、④→①→③→② の順に実行される

JS::Object#await を使えば JavaScript の await と同じように Promise 処理を待つことができる。ただし、rubyVM.evalAsync() 内で動かす必要がある。

def hoge
  res = JS.global.fetch('hoge.json').await
  obj = res.json.await
  p JSON.parse(JS.global[:JSON].stringify(obj).to_s)
end
JS.global[:rubyVM].evalAsync('hoge')

でもすぐに SystemStackError: stack level too deep エラーが出てしまう。たとえば pp メソッドを使うだけでもエラーになる。

https://github.com/ruby/ruby.wasm/issues/133 によると、evalAsync は Fiber を使って await を実装してて、ruby.wasm では Fiber のスタックサイズが小さいために発生しやすいらしい。

回避策はあるみたいだけど面倒そうなので MySQL Parameters では await は使わなかった。

おまけ

さすがに Vue.js のときより遅くなったので、処理に時間が掛かる場合にはイルカをくるくる回すようにしてみた。

JavaScript の時はなんとなく面倒であんまりいじる気が起きなかったんだけど、Ruby になったので色々いじってみるかなーと思ってたりする。

ウィンドウ関数

これは MySQL アドベントカレンダー の11日目の記事です。

qiita.com


MySQL 村で育ってきたので、ウィンドウ関数のことはあまり知らなかったんだけど、最近会社で使うことがあったのでメモ。なお会社では PostgreSQL を使ってる。

MySQL は 8.0 からウィンドウ関数が使えるようになったけど、PostgreSQL は 8.4 から使えてた。 PostgreSQL 8.4 は 2009年リリースで、MySQL 8.0 は 2018年リリースなので 9年遅れでサポートされたんだな。

ウィンドウ関数は GROUP BY みたいなもんだけど、グループング対象のレコードをひとつにまとめるんじゃなくて、各レコードにウィンドウ関数の列を追加する…という雑な理解。

GROUP BY とウィンドウ関数を組み合わせたときの評価順がややこしかったんだけど、たぶんこんな感じ。

テーブル
  ↓
WHERE
  ↓
SELECT に書いた普通の式や関数
  ↓
GROUP BY (集約関数)
  ↓
HAVING
  ↓
ウィンドウ関数 👈
  ↓
DISTINCT
  ↓
ORDER BY
  ↓
LIMIT

こんなテーブルがあるとして(まったく正規化してないし、テーブル名やカラム名はテキトー)、1

mysql> SELECT * FROM city ORDER BY city_code;
+-----------+--------------+-----------+--------------------------+------+----------+------------+---------+
| pref_code | pref_name    | city_code | city_name                | type | pop_male | pop_female | area    |
+-----------+--------------+-----------+--------------------------+------+----------+------------+---------+
|         1 | 北海道       |      1000 | 北海道                   | a    |  2465088 |    2759526 | 83424.4 |
|         1 | 北海道       |      1100 | 札幌市                   | 1    |   918682 |    1054713 | 1121.26 |
|         1 | 北海道       |      1101 | 札幌市中央区             | 0    |   112853 |     135827 |   46.42 |
|         1 | 北海道       |      1102 | 札幌市北区               | 0    |   136596 |     152727 |   63.57 |
|         1 | 北海道       |      1103 | 札幌市東区               | 0    |   126023 |     139356 |   56.97 |
|         1 | 北海道       |      1104 | 札幌市白石区             | 0    |   100062 |     111773 |   34.47 |
〜〜
|        47 | 沖縄県       |     47360 | 伊是名村                 | 3    |      718 |        604 |   15.43 |
|        47 | 沖縄県       |     47361 | 久米島町                 | 3    |     3823 |       3369 |   63.65 |
|        47 | 沖縄県       |     47362 | 八重瀬町                 | 3    |    15244 |      15697 |   26.96 |
|        47 | 沖縄県       |     47375 | 多良間村                 | 3    |      575 |        483 |      22 |
|        47 | 沖縄県       |     47381 | 竹富町                   | 3    |     2033 |       1909 |   334.4 |
|        47 | 沖縄県       |     47382 | 与那国町                 | 3    |      923 |        753 |    28.9 |
+-----------+--------------+-----------+--------------------------+------+----------+------------+---------+
1964 rows in set (0.00 sec)

RANK() で人口の多い順に順位をつけてみる。

mysql> SELECT city_name, pop_male+pop_female 人口,
    -> RANK() OVER(ORDER BY pop_male+pop_female desc) 順位 FROM city
    -> ORDER BY 順位;
+--------------------------+----------+--------+
| city_name                | 人口     | 順位   |
+--------------------------+----------+--------+
| 東京都                   | 14047594 |      1 |
| 特別区部                 |  9733276 |      2 |
| 神奈川県                 |  9237337 |      3 |
| 大阪府                   |  8837685 |      4 |
| 愛知県                   |  7542415 |      5 |
| 埼玉県                   |  7344765 |      6 |
| 千葉県                   |  6284480 |      7 |
| 兵庫県                   |  5465002 |      8 |
| 北海道                   |  5224614 |      9 |
| 福岡県                   |  5135214 |     10 |
| 横浜市                   |  3777491 |     11 |
| 静岡県                   |  3633202 |     12 |
〜〜

東京23区(特別区部)はともかく、横浜市もかなり人口多いんだな…。

ここで長野県の順位を見ようとして、WHERE で絞り込もうと思っても見れない。ウィンドウ関数は WHERE よりも後に評価されるので。

mysql> SELECT city_name, pop_male+pop_female 人口,
    -> RANK() OVER(ORDER BY pop_male+pop_female desc) 順位 FROM city
    -> WHERE city_name='長野県';
+-----------+---------+--------+
| city_name | 人口    | 順位   |
+-----------+---------+--------+
| 長野県    | 2048011 |      1 |
+-----------+---------+--------+

GROUP BY HAVING を使っても同じ。ウィンドウ関数は GROUP BY よりも後に評価されるので。

mysql> SELECT city_name, pop_male+pop_female 人口,
    -> RANK() OVER(ORDER BY pop_male+pop_female desc) 順位 FROM city
    -> GROUP BY city_name, 人口 HAVING city_name='長野県';
+-----------+---------+--------+
| city_name | 人口    | 順位   |
+-----------+---------+--------+
| 長野県    | 2048011 |      1 |
+-----------+---------+--------+

こういう場合はサブクエリを使うしかないのかな。

mysql> SELECT * FROM (
    ->   SELECT city_name, pop_male+pop_female 人口,
    ->   RANK() OVER(ORDER BY pop_male+pop_female desc) 順位 FROM city
    -> ) t WHERE city_name='長野県';
+-----------+---------+--------+
| city_name | 人口    | 順位   |
+-----------+---------+--------+
| 長野県    | 2048011 |     20 |
+-----------+---------+--------+

RANK() のようなウィンドウ関数用の関数でなくても、普通の集約関数も OVER をつけるとウィンドウ関数として動く。

各都道府県の市町村の数は GROUP BY を使うとこんな感じで取得できるけど、

mysql> SELECT pref_name, COUNT(*) 市町村数
    -> FROM city
    -> WHERE type IN ('1','2','3')
    -> GROUP BY pref_name, pref_code ORDER BY pref_code;
+--------------+--------------+
| pref_name    | 市町村数     |
+--------------+--------------+
| 北海道       |          179 |
| 青森県       |           40 |
| 岩手県       |           33 |
| 宮城県       |           35 |
| 秋田県       |           25 |
| 山形県       |           35 |
〜〜
| 長崎県       |           21 |
| 熊本県       |           45 |
| 大分県       |           18 |
| 宮崎県       |           26 |
| 鹿児島県     |           43 |
| 沖縄県       |           41 |
+--------------+--------------+

ウィンドウ関数で似たようなことをしてみる。

mysql> SELECT pref_name,
    -> COUNT(*) OVER(PARTITION BY pref_name) 市町村数
    -> FROM city
    -> WHERE type IN ('1','2','3')
    -> ORDER BY pref_code;
+--------------+--------------+
| pref_name    | 市町村数     |
+--------------+--------------+
| 北海道       |          179 |
| 北海道       |          179 |
| 北海道       |          179 |
| 北海道       |          179 |
| 北海道       |          179 |
| 北海道       |          179 |
〜〜
| 沖縄県       |           41 |
| 沖縄県       |           41 |
| 沖縄県       |           41 |
| 沖縄県       |           41 |
| 沖縄県       |           41 |
| 沖縄県       |           41 |
+--------------+--------------+

これを DISTINCT すると GROUP BY と同じ結果になる。 (DISTINCT と ORDER BY を組み合わせたときは ORDER BY に指定したカラムを SELECT に入れないといけないので追加してる)

mysql> SELECT DISTINCT pref_code, pref_name,
    -> COUNT(*) OVER(PARTITION BY pref_name) 市町村
    -> FROM city
    -> WHERE type IN ('1','2','3')
    -> ORDER BY pref_code;
+-----------+--------------+-----------+
| pref_code | pref_name    | 市町村    |
+-----------+--------------+-----------+
|         1 | 北海道       |       179 |
|         2 | 青森県       |        40 |
|         3 | 岩手県       |        33 |
|         4 | 宮城県       |        35 |
|         5 | 秋田県       |        25 |
|         6 | 山形県       |        35 |
〜〜

集約関数をウィンドウ関数として使えるかどうかはものによるみたいで、たとえば COUNT(DISTINCT *) OVER() は動かない。これは PostgreSQL でも同じ。

MySQL:

mysql> SELECT COUNT(DISTINCT pref_name) OVER() FROM city;
ERROR 1235 (42000): This version of MySQL doesn't yet support '<window function>(DISTINCT ..)'

PostgreSQL:

test=# SELECT count(DISTINCT pref_name) over() FROM city;
ERROR:  DISTINCT is not implemented for window functions
LINE 1: SELECT count(DISTINCT pref_name) over() FROM city;
               ^

ところで GROUP BY とウィンドウ関数を同時に使うとこんな感じ:

mysql> SELECT pref_name,
    -> COUNT(*) g_count,
    -> COUNT(*) OVER(PARTITION BY pref_name) w_count
    -> FROM city
    -> WHERE type IN ('1','2','3')
    -> GROUP BY pref_name, pref_code
    -> ORDER BY pref_code;
+--------------+---------+---------+
| pref_name    | g_count | w_count |
+--------------+---------+---------+
| 北海道       |     179 |       1 |
| 青森県       |      40 |       1 |
| 岩手県       |      33 |       1 |
| 宮城県       |      35 |       1 |
| 秋田県       |      25 |       1 |
| 山形県       |      35 |       1 |
〜〜

GROUP BY した結果に対してウィンドウ関数が働くので、ウィンドウ関数の方の COUNT は 1 になってる。 このあたりが直感的にわかりにくい。慣れなのかな。

しかし、SELECT に同じように並べてるのに、通常の関数と集約関数とウィンドウ関数で評価タイミングが異なるのが SQL はイマイチなんだよなぁ…。

SELECT の評価順はこんな感じ。うむー。

SELECT
  DISTINCT         -- ⑦
  hoge             -- ③
  LENGTH(hoge),    -- ③
  COUNT(*),        -- ④
  COUNT(*) OVER()  -- ⑥
FROM table         -- ①
WHERE condition    -- ②
GROUP BY hoge      -- ④
HAVING condition   -- ⑤
ORDER BY hoge      -- ⑧
LIMIT x            -- ⑨


  1. データは 統計局ホームページ/令和2年国勢調査/調査の結果 より取得したので正しいはず

ruby-mysql と ruby-mysql2

これはMySQLアドベントカレンダーとRubyアドベントカレンダーの3日目の記事です。

qiita.com

qiita.com


ruby-mysql

誰も使わないだろうけど、ruby-mysql 4.0 をリリースした。

ruby-mysql | RubyGems.org | コミュニティのGemホスティングサービス

ruby-mysql は Ruby で書かれた MySQL 用のクライアントライブラリ。

3.0 に対する大きな変更点

Mysql#query の結果の値オブジェクトのクラス

今まではプリペアドステートメント(Mysql#prepare)ではない通常のクエリ(Mysql#query)の結果の値はすべて String で返していた。 プリペアドステートメントの場合は、MySQL の型に応じたオブジェクトを返していた。 4.0 で通常のクエリでもプリペアドステートメントと同様のオブジェクトで結果を返すようにした。 あと DECIMAL と DATE の型も変更した。

MySQL type Ruby class
NULL NilClass
INT Integer
DECIMAL BigDecimal (3.0 までは String)
FLOAT, DOUBLE Float
DATE Date (3.0 までは Time)
DATETIME, TIMESTAMP Time
TIME Float (秒単位)
YEAR Integer
CHAR, VARCHAR String
BIT String
TEXT, BLOB, JSON String

3.0:

pp my.query('select 123,123.45,now(),cast(now() as date)').fetch.map{[_1, _1.class]}
#=> [["123", String],
#    ["123.45", String],
#    ["2022-11-15 00:17:11", String],
#    ["2022-11-15", String]]

4.0:

pp my.query('select 123,123.45,now(),cast(now() as date)').fetch.map{[_1, _1.class]}
#=> [[123, Integer],
#    [0.12345e3, BigDecimal],
#    [2022-11-15 00:17:17 +0900, Time],
#    [#<Date: 2022-11-15 ((2459899j,0s,0n),+0s,2299161j)>, Date]]

今までと同じように String で返すには、Mysql.new とか Mysql.connect とか Mysql#query とか Mysql#each とか Mysql#fetch とかに cast: false を指定する。

Mysql.new(cast: false).connect.query('select 123').fetch   #=> ["123"]
Mysql.new.connect(cast: false).query('select 123').fetch   #=> ["123"]
Mysql.connect(cast: false).query('select 123').fetch       #=> ["123"]
Mysql.connect.query('select 123', cast: false).fetch       #=> ["123"]
Mysql.connect.query('select 123').each(cast: false).first  #=> ["123"]
Mysql.connect.query('select 123').fetch(cast: false)       #=> ["123"]

または、Mysql.default_options を変更すると、それ以降そのプロセスで生成された Mysql オブジェクトの振る舞いが変更される。

m1 = Mysql.connect
Mysql.default_options[:cast] = false
m2 = Mysql.connect
m1.query('select 123').fetch  #=> [123]
m2.query('select 123').fetch  #=> ["123"]

Mysql::Result#each が毎回先頭から繰り返す

3.0 までは each は前回の続きから結果を返すが、4.0 では最初から繰り返す。

3.0:

res = my.query('select 1 union select 2 union select 3')
res.entries  #=> [["1"], ["2"], ["3"]]
res.entries  #=> []

res = my.query('select 1 union select 2 union select 3')
res.each.first  #=> ["1"]
res.each.first  #=> ["2"]
res.each.first  #=> ["3"]
res.each.first  #=> nil

4.0:

res = my.query('select 1 union select 2 union select 3')
res.entries  #=> [[1], [2], [3]]
res.entries  #=> [[1], [2], [3]]

res = my.query('select 1 union select 2 union select 3')
res.each.first  #=> [1]
res.each.first  #=> [1]
res.each.first  #=> [1]

3.0 と同じ振る舞いにしたいことはないと思うけど、もししたい場合は、こんな感じで:

require 'mysql'
class Mysql::Result
  def each(**opts)
    while (r = fetch(**opts))
      yield r
    end
  end
end
my = Mysql.connect
res = my.query('select 1 union select 2 union select 3')
pp res.entries  #=> [[1], [2], [3]]
pp res.entries  #=> []

その他

RSpec

テストコードは test-unit で書いてたんだけど、RSpec を使うようにした。慣れてるので。

GitHub から GitLab に移行

https://github.com/tmtm/ruby-mysql から https://gitlab.com/tmtms/ruby-mysql に移行した。なんとなく。

ruby-mysql2

mysql2 は C ライブラリの libmysqlclient を使ってるんだけど、その代わりに ruby-mysql を使うと面白いかと思って、mysql2 をベースに ruby-mysql2 を作ってみた。

gitlab.com

mysql2 のテストコードはかなり充実してて、ruby-mysql の開発にも役立った。

テストコードはだいたい通ったので、普通に mysql2 の代わりとして使えると思う。

mysql2 との非互換は、これくらい。

  • my.cnf 等を読まない
  • caching_sha2_password, mysql_native_password, sha256_password 以外の認証方式はサポートしない
  • Mysql2::EM がない

試しに ActiveRecord で使ってみようと思ったんだけど、mysql2_adapter.rb 中で gem "mysql2" と書かれてたので、ダメだった。

Sequel では普通に使えた。

まあでも、特に ruby-mysql2 を使う理由はないな。普通に mysql2 を使えばいいんだし。mysql2 に比べたらかなり遅いし。

また誰の役に立たないものを作ってしまった…。

MySQLに独自charsetを追加する

MySQL に独自 charset を追加できる…という話はずっと前に聞いたことあったけど、やったことなかったんでやってみた。

詳しくは MySQL :: MySQL 8.0 リファレンスマニュアル :: 10.13 文字セットの追加 を。

マルチバイト charset は C でプログラムを書いてコンパイルする必要があるけど、1バイト charset はファイルを置くだけで追加できる。

1バイトの charset と言えばみんなご存知の JIS X 0201 ですよね。ということで、jisx0201 という charset を作ってみる。

JIS X 0201 のコードはこんな感じ:

x0 x1 x2 x3 x4 x5 x6 x7 x8 x9 xA xB xC xD xE xF
0x NUL SOH STX ETX EOT ENQ ACK BEL BS HT LF VT FF CR SO SI
1x DLE DC1 DC2 DC3 DC4 NAK SYN ETB CAN EM SUB ESC FS GS RS US
2x SP ! " # $ % & ' ( ) * + , - . /
3x 0 1 2 3 4 5 6 7 8 9 : ; < = > ?
4x @ A B C D E F G H I J K L M N O
5x P Q R S T U V W X Y Z [ ¥ ] ^ _
6x ` a b c d e f g h i j k l m n o
7x p q r s t u v w x y z { | } DEL
8x
9x
Ax
Bx ソ
Cx
Dx
Ex
Fx

0x00〜0x7F は ASCII とほぼ同じだけど、違いは 0x5C が「\」ではなくて「¥」、0x7E が「~」ではなくて「」なこと。 0xA1〜0xDF に半角カナが割り当てられてる。

/usr/local/mysql/share/charsets/Index.xml に次を追記:

<charset name="jisx0201">
  <description>JIS X 0201</description>
  <collation name="jisx0201_general_ci" id="1234">
    <flag>primary</flag>
  </collation>
  <collation name="jisx0201_bin" id="1235">
    <flag>binary</flag>
  </collation>
</charset>

collation は jisx0201_general_cijisx0201_bin の2つ。 collation の id は 1024〜2047 がユーザー定義用のものなので適当に。

同じディレクトリに jisx0201.xml を追加:

<?xml version='1.0' encoding="utf-8"?>

<charsets>

<charset name="jisx0201">

<ctype>
<map>
 00
 20 20 20 20 20 20 20 20 20 28 28 28 28 28 20 20
 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20
 48 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10
 84 84 84 84 84 84 84 84 84 84 10 10 10 10 10 10
 10 81 81 81 81 81 81 01 01 01 01 01 01 01 01 01
 01 01 01 01 01 01 01 01 01 01 01 10 10 10 10 10
 10 82 82 82 82 82 82 02 02 02 02 02 02 02 02 02
 02 02 02 02 02 02 02 02 02 02 02 10 10 10 10 20
 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
 00 10 10 10 10 10 01 02 02 02 02 02 02 02 02 02
 10 01 01 01 01 01 01 01 01 01 01 01 01 01 01 01
 01 01 01 01 01 01 01 01 01 01 01 01 01 01 01 01
 01 01 01 01 01 01 01 01 01 01 01 01 01 01 10 10
 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
</map>
</ctype>

<lower>
<map>
 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
 10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F
 20 21 22 23 24 25 26 27 28 29 2A 2B 2C 2D 2E 2F
 30 31 32 33 34 35 36 37 38 39 3A 3B 3C 3D 3E 3F
 40 61 62 63 64 65 66 67 68 69 6A 6B 6C 6D 6E 6F
 70 71 72 73 74 75 76 77 78 79 7A 5B 5C 5D 5E 5F
 60 61 62 63 64 65 66 67 68 69 6A 6B 6C 6D 6E 6F
 70 71 72 73 74 75 76 77 78 79 7A 7B 7C 7D 7E 7F
 80 81 82 83 84 85 86 87 88 89 8A 8B 8C 8D 8E 8F
 90 91 92 93 94 95 96 97 98 99 9A 9B 9C 9D 9E 9F
 A0 A1 A2 A3 A4 A5 A6 A7 A8 A9 AA AB AC AD AE AF
 B0 A7 A8 A9 AA AB B6 B7 B8 B9 BA BB BC BD BE BF
 C0 C1 AF C3 C4 C5 C6 C7 C8 C9 CA CB CC CD CE CF
 D0 D1 D2 D3 AC AD AE D7 D8 D9 DA DB DC DD DE DF
 E0 E1 E2 E3 E4 E5 E6 E7 E8 E9 EA EB EC ED EE EF
 F0 F1 F2 F3 F4 F5 F6 F7 F8 F9 FA FB FC FD FE FF
</map>
</lower>

<upper>
<map>
 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
 10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F
 20 21 22 23 24 25 26 27 28 29 2A 2B 2C 2D 2E 2F
 30 31 32 33 34 35 36 37 38 39 3A 3B 3C 3D 3E 3F
 40 41 42 43 44 45 46 47 48 49 4A 4B 4C 4D 4E 4F
 50 51 52 53 54 55 56 57 58 59 5A 5B 5C 5D 5E 5F
 60 41 42 43 44 45 46 47 48 49 4A 4B 4C 4D 4E 4F
 50 51 52 53 54 55 56 57 58 59 5A 7B 7C 7D 7E 7F
 80 81 82 83 84 85 86 87 88 89 8A 8B 8C 8D 8E 8F
 90 91 92 93 94 95 96 97 98 99 9A 9B 9C 9D 9E 9F
 A0 A1 A2 A3 A4 A5 A6 B1 B2 B3 B4 B5 D4 D5 D6 C2
 B0 B1 B2 B3 B4 B5 B6 B7 B8 B9 BA BB BC BD BE BF
 C0 C1 C2 C3 C4 C5 C6 C7 C8 C9 CA CB CC CD CE CF
 D0 D1 D2 D3 D4 D5 D6 D7 D8 D9 DA DB DC DD DE DF
 E0 E1 E2 E3 E4 E5 E6 E7 E8 E9 EA EB EC ED EE EF
 F0 F1 F2 F3 F4 F5 F6 F7 F8 F9 FA FB FC FD FE FF
</map>
</upper>


<unicode>
<map>
0000 0001 0002 0003 0004 0005 0006 0007 0008 0009 000A 000B 000C 000D 000E 000F
0010 0011 0012 0013 0014 0015 0016 0017 0018 0019 001A 001B 001C 001D 001E 001F
0020 0021 0022 0023 0024 0025 0026 0027 0028 0029 002A 002B 002C 002D 002E 002F
0030 0031 0032 0033 0034 0035 0036 0037 0038 0039 003A 003B 003C 003D 003E 003F
0040 0041 0042 0043 0044 0045 0046 0047 0048 0049 004A 004B 004C 004D 004E 004F
0050 0051 0052 0053 0054 0055 0056 0057 0058 0059 005A 005B 00A5 005D 005E 005F
0060 0061 0062 0063 0064 0065 0066 0067 0068 0069 006A 006B 006C 006D 006E 006F
0070 0071 0072 0073 0074 0075 0076 0077 0078 0079 007A 007B 007C 007D 203E 007F
0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000
0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000
0000 FF61 FF62 FF63 FF64 FF65 FF66 FF67 FF68 FF69 FF6A FF6B FF6C FF6D FF6E FF6F
FF70 FF71 FF72 FF73 FF74 FF75 FF76 FF77 FF78 FF79 FF7A FF7B FF7C FF7D FF7E FF7F
FF80 FF81 FF82 FF83 FF84 FF85 FF86 FF87 FF88 FF89 FF8A FF8B FF8C FF8D FF8E FF8F
FF90 FF91 FF92 FF93 FF94 FF95 FF96 FF97 FF98 FF99 FF9A FF9B FF9C FF9D FF9E FF9F
0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000
0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000
</map>
</unicode>


<collation name="jisx0201_general_ci">
<map>
 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
 10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F
 20 21 22 23 24 25 26 27 28 29 2A 2B 2C 2D 2E 2F
 30 31 32 33 34 35 36 37 38 39 3A 3B 3C 3D 3E 3F
 40 41 42 43 44 45 46 47 48 49 4A 4B 4C 4D 4E 4F
 50 51 52 53 54 55 56 57 58 59 5A 5B 5C 5D 5E 5F
 60 41 42 43 44 45 46 47 48 49 4A 4B 4C 4D 4E 4F
 50 51 52 53 54 55 56 57 58 59 5A 7B 7C 7D 7E 7F
 80 81 82 83 84 85 86 87 88 89 8A 8B 8C 8D 8E 8F
 90 91 92 93 94 95 96 97 98 99 9A 9B 9C 9D 9E 9F
 A0 A1 A2 A3 A4 A5 A6 B1 B2 B3 B4 B5 D4 D5 D6 C2
 B0 B1 B2 B3 B4 B5 B6 B7 B8 B9 BA BB BC BD BE BF
 C0 C1 C2 C3 C4 C5 C6 C7 C8 C9 CA CB CC CD CE CF
 D0 D1 D2 D3 D4 D5 D6 D7 D8 D9 DA DB DC DD DE DF
 E0 E1 E2 E3 E4 E5 E6 E7 E8 E9 EA EB EC ED EE EF
 F0 F1 F2 F3 F4 F5 F6 F7 F8 F9 FA FB FC FD FE FF
</map>
</collation>

<collation name="jisx0201_bin" flag="binary"/>

</charset>

</charsets>

<ctype> は各文字のタイプ。次のビットを論理和で指定。

ビット 意味
01 大文字
02 小文字
04 数字
08 空白(TABや改行コード等)
10 記号
20 制御文字
40 空白(SPACE)
80 16進数文字

ア〜ン は大文字として 01ァ ィ ゥ ェ ォ ャ ュ ョ ッ は小文字として 02。 「 」 、 ・ ー ゙ ゚ は記号として 10 を指定してる。

<lower>, <upper> は小文字化、大文字化したときのコード。 たとえば <lower> の「」(0xB1) の位置に「」(0xA7) を書いて、<upper> の「」(0xA7) の位置には「」(0xB1) を書いてある。

<unicode> は各文字に対応するユニコードのコードポイントを書く。 ちゃんと 0x5C は「¥」(U+00A5)、0x7E は「」(U+203E)にしてる。

mysqld を再起動するとこのファイルが読み込まれ、charset と collation に反映される。

mysql> SHOW CHARSET LIKE 'jis%';
+----------+-------------+---------------------+--------+
| Charset  | Description | Default collation   | Maxlen |
+----------+-------------+---------------------+--------+
| jisx0201 | JIS X 0201  | jisx0201_general_ci |      1 |
+----------+-------------+---------------------+--------+
1 row in set (0.00 sec)

mysql> SHOW COLLATION LIKE 'jis%';
+---------------------+----------+------+---------+----------+---------+---------------+
| Collation           | Charset  | Id   | Default | Compiled | Sortlen | Pad_attribute |
+---------------------+----------+------+---------+----------+---------+---------------+
| jisx0201_bin        | jisx0201 | 1235 |         |          |       0 | PAD SPACE     |
| jisx0201_general_ci | jisx0201 | 1234 | Yes     |          |       0 | PAD SPACE     |
+---------------------+----------+------+---------+----------+---------+---------------+
2 rows in set (0.00 sec)

mysql> CREATE TABLE x (c varchar(255)) CHARSET jisx0201;
Query OK, 0 rows affected (0.08 sec)

数字や英字や半角カタカナは保存できるけど:

mysql> INSERT INTO x VALUES ('012'),('ABC'),('アイウ');
Query OK, 3 rows affected (0.02 sec)
Records: 3  Duplicates: 0  Warnings: 0

バックスラッシュやチルダは保存できない:

mysql> INSERT INTO x VALUES ('\\');
ERROR 1366 (HY000): Incorrect string value: '\' for column 'c' at row 1

mysql> INSERT INTO x VALUES ('~');
ERROR 1366 (HY000): Incorrect string value: '~' for column 'c' at row 1

円マークやオーバーラインは保存できる:

mysql> INSERT INTO x VALUES ('¥‾');
Query OK, 1 row affected (0.02 sec)

ちゃんと JIS X 0201 のコードとして保存されてる:

mysql> SELECT c,HEX(c) FROM x;
+-----------+--------+
| c         | HEX(c) |
+-----------+--------+
| 012       | 303132 |
| ABC       | 414243 |
| アイウ       | B1B2B3 |
| ¥‾        | 5C7E   |
+-----------+--------+
4 rows in set (0.00 sec)

対応している文字は LOWER() で小文字化できる:

mysql> SELECT LOWER(c) FROM x;
+-----------+
| LOWER(c)  |
+-----------+
| 012       |
| abc       |
| ァィゥ       |
| ¥‾        |
+-----------+
4 rows in set (0.00 sec)

「ア」と「ァ」は同じ:

mysql> SELECT 'ア'='ァ';
+-------------+
| 'ア'='ァ'     |
+-------------+
|           1 |
+-------------+
1 row in set (0.00 sec)

ちゃんと動いてそう! お手軽ですね!

MySQLのシノニム

sakaik さんが MySQL: desc は explain !? - sakaikの日々雑感~(T)編 という記事を上げてたのを見て、そういや最近はほかにどんなのがあるんだっけ…と思って調べてみた。

以下はMySQL 8.0.29 の sql/lex.h から抽出したもの。字句解析上のシノニム。

<> !=
ANY SOME
BIGINT INT8
CHAR CHARACTER
COLUMNS FIELDS
CURDATE* CURRENT_DATE
CURTIME* CURRENT_TIME
DATABASE SCHEMA
DATABASES SCHEMAS
DAY SQL_TSI_DAY
DECIMAL DEC
DESCRIBE EXPLAIN
DISTINCT DISTINCTROW
DOUBLE FLOAT8
FLOAT FLOAT4
GEOMETRYCOLLECTION GEOMCOLLECTION
HOUR SQL_TSI_HOUR
INT INT4 INTEGER
MEDIUMINT INT3 MIDDLEINT
MINUTE SQL_TSI_MINUTE
MONTH SQL_TSI_MONTH
NDBCLUSTER NDB
NOW* CURRENT_TIMESTAMP LOCALTIME LOCALTIMESTAMP
QUARTER SQL_TSI_QUARTER
REGEXP RLIKE
RELAY_THREAD IO_THREAD
SECOND SQL_TSI_SECOND
SMALLINT INT2
STD* STDDEV* STDDEV_POP*
SUBSTRING* MID* SUBSTR*
TINYINT INT1
USER SESSION_USER* SYSTEM_USER*
VARCHAR VARCHARACTER
VARIANCE* VAR_POP*
WEEK SQL_TSI_WEEK
YEAR SQL_TSI_YEAR

* がついてるのは関数でしか使えない単語。 たとえば、 SELECT CURRENT_TIMESTAMPSELECT CURRENT_TIMESTAMP() はどちらも使えるけど、SELECT NOW はダメで、SELECT NOW() はOK。みたいな。

USER は関数形式じゃなくても使えるけど、SELECT USER みたいに使えるというわけじゃない。CREATE USER とかで使えるということ。

CHARSET を書けるところは CHARACTER SET とも書けるんだけど、これは字句解析じゃなくて構文解析の処理の方なのでここには書いてない。構文解析を調べるのはめんどくさい。

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)      |
+-------------------------------------------+
| 禰󠄀豆                                      |
+-------------------------------------------+

ruby-mysql

これはMySQLアドベントカレンダーRubyアドベントカレンダーの12日目の記事です。

qiita.com qiita.com

ruby-mysql は Ruby だけで書かれた MySQL 用のクライアントライブラリです。 今は Ruby から MySQL を使う場合は普通は mysql2 を使うだろうから、たぶん誰も使ってない。

誰も使ってないだろうし、6年ほど放置してたんだけど、なぜかその気になったのでまたいじり始めた。退職前の有給消化期間で暇だったからかも。

MySQL 8.0 対応

MySQL 8.0 でデフォルトの認証方式が変更になって、そのままでは接続できなくなったので対応。

認証方式はユーザーごとに異なる場合があるので、サーバーのデフォルト認証方式、クライアントのデフォルト認証方式、ユーザーの認証方式が異なっていた場合のプロトコルに対応。

とりあえず、MySQL 5.7 でデフォルトの mysql_native_password と MySQL 8.0 でデフォルトの caching_sha2_password,と、あと sha256_password に対応してみた。

あと、「MySQL 8.0のcaching_sha2_password + 非SSL接続が転ける」ので、TLS 接続にも対応。

caching_sha2_passwordsha256_password は、接続が TLS の場合は特にハッシュ化せずにそのまま送るようになってる。合理的。

認証プロトコルについては「MySQL の認証プロトコル」に書いた。

Ruby ぽい API

ruby-mysql は libmysqlclient の C API に合わせて作ったので、Ruby らしくないところがあった。 たとえば、MySQL サーバーに接続しないでオブジェクトを作るには Mysql.init とか。普通は Mysql.new ですよねぇ。

今まで Mysql.new は接続までしていたが、それはやめてオブジェクトを作るだけにした。 Mysql.connect がオブジェクト生成&接続をするのはかわらない。

接続パラメータは new でも connect でも指定可

m = Mysql.new('hostname', 'user', 'passwd', 'dbname')
m.connect

m = Mysql.new
m.connect('hostname', 'user', 'passwd', 'dbname')

m = Mysql.connect('hostname', 'user', 'passwd', 'dbname')

Mysql.connect() を使ってれば互換はあるはず。

URI でも指定できるようにした

m = Mysql.connect('mysql://user:passwd@hostname/dbname')

uri = URI.parse('mysql://user:passwd@hostname/dbname')
m = Mysql.connect(uri)

Hash やキーワード引数もOK

m = Mysql.connect(host: 'hostname', username: 'user', password: 'passwd', database: 'dbname')

m = Mysql.connect({host: 'hostname', username: 'user', password: 'passwd', database: 'dbname'})

接続用のオプションも

m = Mysql.init
m.options(Mysql::OPT_LOCAL_INFILE, true)
m.connect('hostname', 'user', 'passwd', 'dbname')

みたいにしないといけなかったのを

m = Mysql.new
m.local_infile = true
m.connect('hostname', 'user', 'passwd', 'dbname')

みたいに書ける。 オプションは newconnect 時のキーワード引数や URI のクエリパラメータでも指定可能。

m = Mysql.new('hostname', 'user', 'passwd', 'dbname', local_infile: true)

m = Mysql.new(host: 'hostname', username: 'user', password: 'passwd', database: 'dbname', local_infile: true)

m = Mysql.new('mysql://user:passwd@hostname/dbname?local_infile=true')

Ruby っぽい!(たぶん)

あと、ストアドプロシジャで 0000-00-00 みたいな不正な日付値と、TIME値(日付なしの時刻値)を扱うために Mysql::Time があったんだけど廃止して Time を使うようにした。 不正な日付値は nil として返す。今どきは不正は日付を使うことはないだろうから別に問題ないだろう。 TIME値は秒換算の Numeric を使うようにした。

こんなテーブルがあった場合

mysql> select * from test.t;
+------------+---------------------+-----------+
| date       | datetime            | time      |
+------------+---------------------+-----------+
| 2021-12-12 | 2021-12-12 01:23:45 | 01:23:45  |
| 0000-00-00 | 0000-00-00 01:23:45 | -01:23:45 |
+------------+---------------------+-----------+
2 rows in set (0.00 sec)

こうなる

irb(main):002:0> m.query("select * from test.t").entries
=> 
[["2021-12-12", "2021-12-12 01:23:45", "01:23:45"],
 ["0000-00-00", "0000-00-00 01:23:45", "-01:23:45"]]
irb(main):003:0> m.prepare("select * from test.t").execute.entries
=> 
[[2021-12-12 00:00:00 +0900, 2021-12-12 01:23:45 +0900, 5025.0],
 [nil, nil, -5025.0]]

古い Ruby は非対応

Ruby 2.5 未満は非対応。さすがにもういいだろうってことで。 Encoding がない Ruby 1.8 用のコードとかが残ってたので削除したり。

MySQL で非推奨の API は削除

list_dbs, list_tables, list_fields, list_processes など。 これらの情報は SHOW DATABASES, SHOW TABLES, SHOW COLUMNS, SHOW PROCESSLIST で取得すればいい。

あと、真偽値を返すメソッドは ? ありと無しの2つあったけど、無しなのは削除。

GitHub Actions でテスト

GitHub Actions で自動化っぽいことができそうだったので、テストを実行するようにしてみた。

git push するたびに Ruby 2.5, 2.6, 2.7, 3.0 と MySQL 5.5, 5.6, 5.7, 8.0 の組み合わせでテストする。 (全然関係ないけど、Ruby と MySQL のバージョンの飛び方が似てるな)

mysql2

mysql2 は MySQL の C API である libmysqlclient を使ってるんだけど、この ruby-mysql を使うようにすれば面白いかもなーと思って作業中。

目標は mysql2 のテストコードをパスすること。いざやってみると ruby-mysql のバグがいくつか見つかったりして、mysql2 のテストコード便利。

ほんとはこのアドベントカレンダーを書くまでに作りたかったんだけど、終わらなかった。残念…。

MySQL の認証プロトコル

認証がプラグイン化された最近のMySQL(5.5くらい?)の認証時のプロトコルをちゃんと理解してなかったので調べてみた。

基本的にはこんな感じ

クライアントが接続するとサーバー(mysqld)から次の内容のパケットが送られる:

  • プロトコルバージョン: 現在のところ "10"
  • サーバーバージョン: "8.0.27" とか
  • スレッドID
  • パスワードハッシュ化のためのデータ(チャレンジ)
  • サーバーの機能(ケイパビリティ)
  • サーバーのデフォルト文字コード(collation)
  • 認証方式: caching_sha2_password とか

それの応答としてクライアントが次のパケットを送る:

  • クライアントフラグ
  • クライアント側が受けられるパケットの最大長(max_allowed_packet)
  • クライアントの文字コード(collation)
  • ユーザー名
  • パスワードをチャレンジでハッシュ化したデータ
  • 認証方式: caching_sha2_password とか
  • その他、クライアント名/クライアントのバージョン/プロセスID/OSの種類等々

サーバーから送られたチャレンジを用いてクライアント側でパスワードをハッシュ化した値が、サーバー側で計算した値と一致してれば認証OKとみなす。チャレンジ・レスポンス認証というやつ。

現在の MySQL のデフォルトの認証方式は caching_sha2_password なので、サーバーとクライアントがそれに従ってハッシュ値を計算すればいい。

ユーザーの認証方式がサーバーのデフォルトと異なっている場合

ところが MySQL はユーザーごとに認証方式を設定できるので、ユーザーによっては caching_sha2_password じゃないことがある。 初期状態ではサーバーはどのユーザーが接続してくるかわからないし、クライアントはユーザーの認証方式が何なのかを知らない。

サーバーのデフォルトの認証方式とユーザーの認証方式が異なっている場合は、ユーザー名が判明した時点でもう一度チャンレジ・レスポンス認証を行う。

たとえばユーザーの認証方式が mysql_native_password だった場合はこんな感じ(認証に関するもの以外の情報は省略)。

サーバー:

  • 認証方式: caching_sha2_password
  • チャレンジデータ

クライアント:

  • ユーザー名
  • 認証方式: caching_sha2_password
  • パスワードを caching_sha2_password でハッシュ化した値

(ここまでは同じだけど、サーバーはここでユーザーの認証方式が mysql_native_password だとわかったのでもう一度)

サーバー:

  • 認証方式: mysql_native_password
  • チャレンジデータ

クライアント:

  • パスワードを mysql_native_password でハッシュ化した値

クライアントのデフォルトの認証方式がサーバーのデフォルトと異なっている場合

クライアントが MySQL 5.7 とかだとデフォルトの認証方式が mysql_native_password なので、サーバーの初期パケットの認証方式と異なる。 そのような場合は最初の応答パケットにハッシュ値を含めない。

サーバー:

  • 認証方式: caching_sha2_password
  • チャレンジデータ

クライアント:

  • ユーザー名
  • 認証方式: mysql_native_password
  • ハッシュ値なし。サーバーが指定した認証方式じゃないので。

サーバー:

  • 認証方式: caching_sha2_password
  • チャレンジデータ

クライアント:

  • パスワードを caching_sha2_password でハッシュ化した値

おまけ

調査のため、こんなプログラムをサーバーとクライアントの間に挟んでパケットデータを眺めてた。

require 'socket'

class MysqlProxy
  MAX_PACKET_LENGTH = 2**24-1

  def initialize(local_port, server_name, server_port)
    @local_port, @server_name, @server_port = local_port, server_name, server_port
  end

  def start
    Socket.tcp_server_loop(@local_port) do |client_socket, _peer|
      puts "START"
      server_socket = Socket.tcp(@server_name, @server_port)
      sockets = {
        client_socket => [server_socket, "Client:"],
        server_socket => [client_socket, "Server:"],
      }
      while true
        rr, = IO.select([client_socket, server_socket], nil, nil, nil)
        r = rr[0]
        puts sockets[r][1]
        break if r.eof?
        raw, data = read_packet(r)
        p data
        sockets[r][0].write(raw)
      end
      server_socket.close
      client_socket.close
      puts "END"
    end
  end

  def read_packet(socket)
    raw = ''
    data = ''
    while true
      header = socket.read(4)
      raise Errno::ECONNRESET unless header && header.length == 4
      len1, len2, seq = header.unpack("CvC")
      len = (len2 << 8) + len1
      puts "length: #{len}, seq: #{seq}"
      ret = socket.read(len)
      raise Errno::ECONNRESET unless ret && ret.length == len
      raw.concat header + ret
      data.concat ret
      break if len < MAX_PACKET_LENGTH
    end
    return raw, data
  end
end

MysqlProxy.new(*ARGV).start if $0 == __FILE__

これだけ見てもなんだかわからないと思うけど、https://www.slideshare.net/tmtm/mysql-protocol を見たらわかるかもしれない。

MySQL で max_allowed_packet を超過した場合

MySQL で max_allowed_packet を超過した場合にどうなるんだっけ…と思って試してみた結果。

サーバーの max_allowed_packet をクエリが超過した場合

MySQL 5.6

サーバー起動

% docker run --name mysql56 -e MYSQL_ALLOW_EMPTY_PASSWORD=yes -d mysql:5.6 --max-allowed-packet=100000

クライアントは原因がわからないけど切断される

% ruby -e 'puts "set @a="+"1"*1000000' | docker exec -i mysql56 mysql
ERROR 2006 (HY000) at line 1: MySQL server has gone away

サーバー側はログ出力なし

% docker logs mysql56
...

MySQL 5.7

サーバー起動

% docker run --name mysql57 -e MYSQL_ALLOW_EMPTY_PASSWORD=yes -d mysql:5.7 --max-allowed-packet=100000

クライアントは原因がわからないけど切断される

% ruby -e 'puts "set @a="+"1"*1000000' | docker exec -i mysql57 mysql
ERROR 2006 (HY000) at line 1: MySQL server has gone away

サーバー側のログで max_allowed_packet を超えて接続を切断したことがわかる

% docker logs mysql56
...
2021-09-08T13:19:10.767604Z 2 [Note] Aborted connection 2 to db: 'unconnected' user: 'root' host: 'localhost' (Got a packet bigger than 'max_allowed_packet' bytes)

MySQL 8.0

サーバー起動

% docker run --name mysql80 -e MYSQL_ALLOW_EMPTY_PASSWORD=yes -d mysql:8.0 --max-allowed-packet=100000

クライアントで max_allowed_packet を超えたことがわかる

% ruby -e 'puts "set @a="+"1"*1000000' | docker exec -i mysql80 mysql
ERROR 1153 (08S01) at line 1: Got a packet bigger than 'max_allowed_packet' bytes

サーバー側はログ出力なし

% docker logs mysql80
...

クライアントの max_allowed_packet を結果が超過した場合

MySQL 5.6

クライアントがエラー

% ruby -e 'puts "select "+"1"*50000' | docker exec -i mysql56 mysql --max-allowed-packet=10000
ERROR 2020 (HY000) at line 1: Got packet bigger than 'max_allowed_packet' bytes

サーバー側はログ出力なし

% docker logs mysql56
...

MySQL 5.7

クライアントがエラー

% ruby -e 'puts "select "+"1"*50000' | docker exec -i mysql57 mysql --max-allowed-packet=10000
ERROR 2020 (HY000) at line 1: Got packet bigger than 'max_allowed_packet' bytes

サーバー側にもログが出る

% docker logs mysql57
...
2021-09-08T13:49:45.856283Z 4 [Note] Aborted connection 4 to db: 'unconnected' user: 'root' host: 'localhost' (Got an error reading communication packets)

MySQL 8.0

クライアントがエラー

% ruby -e 'puts "select "+"1"*50000' | docker exec -i mysql80 mysql --max-allowed-packet=10000
ERROR 2020 (HY000) at line 1: Got packet bigger than 'max_allowed_packet' bytes

サーバー側はログ出力なし

% docker logs mysql80
...

MySQL 8.0.24 の文字コードまわり

2021/4/20 にリリースされた MySQL 8.0.24 について私が気になったものについて。 まあ文字コードまわりだけなんだけど。

utf8utf8mb3 として出力する

Client applications and test suite plugins now report utf8mb3 rather than utf8 when writing character set names. (Bug #32164079, Bug #32164125)

Important Note: When a utf8mb3 collation was specified in a CREATE TABLE statement, SHOW CREATE TABLE, DEFAULT CHARSET, the values of system variables containing character set names, and the binary log all subsequently displayed the character set as utf8 which is becoming a synonym for utf8mb4. Now in such cases, utf8mb3 is shown instead, and CREATE TABLE raises the warning 'collation_name' is a collation of the deprecated character set UTF8MB3. Please consider using UTF8MB4 with an appropriate collation instead. (Bug #27225287, Bug #32085357, Bug #32122844)

tmtms.hatenablog.com にも書いたんだけど(う、もう3年も前なのか…)、

なお、utf8mb3 を使えという割には、参照時には utf8 と表示されるのがイマイチ。show create table の結果をそのまま使うと warning が出るという…。

ということだったんだけど、やっと SHOW CREATE TBALE でも utf8mb3 と出るようになった。

8.0.23

mysql> create table t (i int) charset utf8mb3;
Query OK, 0 rows affected, 1 warning (0.14 sec)

mysql> show create table t\G
*************************** 1. row ***************************
       Table: t
Create Table: CREATE TABLE `t` (
  `i` int DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8
1 row in set (0.02 sec)

8.0.24

mysql> create table t (i int) charset utf8mb3;
Query OK, 0 rows affected, 1 warning (0.11 sec)

mysql> show create table t\G
*************************** 1. row ***************************
       Table: t
Create Table: CREATE TABLE `t` (
  `i` int DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3
1 row in set (0.03 sec)

そのうち(8.1 とか 9.0 とか?)で utf8utf8mb4 の別名になるんでその準備っぽい(さすがに 8.0.x でそうなることはないと思いたい)。 もっと早くやっておいても良かったと思うが…。

8.0.24 より前のバージョンをマスター、utf8utf8mb4 の別名になった後のバージョンをスレーブとしてレプリケーション組むと、マスターでは utf8mb3 でスレーブは utf8mb4 になっちゃうんで、気をつけたほうが良さそう。

mysql --default-character-set=utf8mb4 で日本語が入力できなかったのが直った

For builds compiled using the libedit library, if the mysql client was invoked with the --default-character-set=utf8 option, libedit rejected input of multibyte characters. (Bug #32329078, Bug #32583436, Bug #102806)

tmtms.hatenablog.com に似たようなことを書いたんだけど、この記事で書いたのはロケールが設定されてないと日本語入力できないってものだったのでちょっと違う。

こっちのツイートの方

8.0.23

% LC_ALL=C.UTF-8 mysql --default-character-set=utf8mb4
mysql> (←日本語が入力できない)

8.0.24

% LC_ALL=C.UTF-8 mysql --default-character-set=utf8mb4
mysql> select 'あ';
+-----+
| あ  |
+-----+
| あ  |
+-----+
1 row in set (0.00 sec)

ascii charset で 8bit 文字が不正扱いになった

It was possible to insert illegal ASCII values (outside 7-bit range) into character columns that used the ascii character set. This is now prohibited. (Bug #24847620)

ASCII は 7bit の文字コードなんだけど、MySQL の ascii charset は 8bit 文字も受け付けていた。 前に「大文字小文字を区別したいんだけど 8bit データも入れたい」という用途で時々使ってたんだけど、とうとう不正文字扱いになってしまった。

8.0.23 は warnings が出つつもデータは格納できていた。

mysql> create table t (c varchar(10)) charset ascii;
Query OK, 0 rows affected (0.15 sec)

mysql> insert into t value (0x414243ff303132);
Query OK, 1 row affected, 1 warning (0.03 sec)

mysql> show warnings;
+---------+------+------------------------------------------------+
| Level   | Code | Message                                        |
+---------+------+------------------------------------------------+
| Warning | 1300 | Invalid ascii character string: 'ABC\xFF01...' |
+---------+------+------------------------------------------------+
1 row in set (0.00 sec)

mysql> select * from t;
+---------+
| c       |
+---------+
| ABC?012 |
+---------+
1 row in set (0.00 sec)

mysql> select c,hex(c) from t;
+---------+----------------+
| c       | hex(c)         |
+---------+----------------+
| ABC?012 | 414243FF303132 |
+---------+----------------+
1 row in set (0.00 sec)

8.0.24 はエラーになる。sql_mode を変更すると warnings が出て格納できる…ように見えるんだけど、8bit データ以降はちょん切られちゃってる。

mysql> create table t (c varchar(10)) charset ascii;
Query OK, 0 rows affected (0.15 sec)

mysql> insert into t value (0x414243ff303132);
ERROR 1366 (HY000): Incorrect string value: '\xFF012' for column 'c' at row 1

mysql> set sql_mode='';
Query OK, 0 rows affected (0.00 sec)

mysql> insert into t value (0x414243ff303132);
Query OK, 1 row affected, 1 warning (0.03 sec)

mysql> show warnings;
+---------+------+-----------------------------------------------------------+
| Level   | Code | Message                                                   |
+---------+------+-----------------------------------------------------------+
| Warning | 1366 | Incorrect string value: '\xFF012' for column 'c' at row 1 |
+---------+------+-----------------------------------------------------------+
1 row in set (0.00 sec)

mysql> select c,hex(c) from t;
+------+--------+
| c    | hex(c) |
+------+--------+
| ABC  | 414243 |
+------+--------+
1 row in set (0.00 sec)

これバグ修正扱いでいいのかなぁ。非互換な気がするんだけど。まあ変な使い方してる方が悪いか。

UPDATE1文だけでもデッドロックするという話

デッドロックは複数のトランザクションが複数のレコードをロックする場合にタイミングによって発生する。

  1. トランザクションAがレコード1をロック→成功
  2. トランザクションBがレコード2をロック→成功
  3. トランザクションAがレコード2をロック→2のロックの解放待ち
  4. トランザクションBがレコード1をロック→1のロックの解放待ち

AもBも互いのロックの解放を待つことになってデッドロックする。 MySQLはデッドロックを検出すると、片方のトランザクションでデッドロックエラーを発生させて強制的にトランザクションを終了させる。

たとえば、次のような2つのトランザクションを同時に動かすとデッドロックエラーが発生する。

CREATE TABLE t (a INT, val INT, INDEX(a));
INSERT INTO t VALUES (1,0),(2,0),(3,0),(4,0),(5,0),(6,0),(7,0),(8,0),(9,0);

トランザクションA

BEGIN;
UPDATE t SET val=0 WHERE a=1;  # レコード1をロック
UPDATE t SET val=0 WHERE a=9;  # レコード9をロック
COMMIT;

トランザクションB

BEGIN;
UPDATE t SET val=0 WHERE a=9;  # レコード9をロック
UPDATE t SET val=0 WHERE a=1;  # レコード1をロック
COMMIT;

こんな感じ

端末A

% while mysql test -e 'begin; update t set val=1 where a=1; update t set val=1 where a=9; commit;'; do echo -n .; done
.........................................................................................................

端末B

% while mysql test -e 'begin; update t set val=1 where a=9; update t set val=1 where a=1; commit;'; do echo -n .; done
.................................................ERROR 1213 (40001) at line 1: Deadlock found when trying to get lock; try restarting transaction

明に BEGIN でトランザクションを書かずに UPDATE 文一つだけであっても複数レコードをロックするような場合であればデッドロックが発生し得る。

CREATE TABLE x (a INT, b INT, val INT, INDEX(a), INDEX(b));
INSERT INTO x VALUES (1,9,0),(2,8,0),(3,7,0),(4,6,0),(5,5,0),(6,4,0),(7,3,0),(8,2,0),(9,1,0);

トランザクションA

UPDATE x SET val=0 WHERE a IN (1,9);

トランザクションB

UPDATE x SET val=0 WHERE b IN (1,9);

端末A

% while mysql test -e 'update x set val=0 where a in (1,9)'; do echo -n .; done
.........................................................................................................

端末B

% while mysql test -e 'update x set val=0 where b in (1,9)'; do echo -n .; done
.....ERROR 1213 (40001) at line 1: Deadlock found when trying to get lock; try restarting transaction

この場合レコードをロックする順番はクエリが使用しているインデックスの順番なので、

  1. A が (a,b)=(1,9) のレコードをロック→成功
  2. B が (a,b)=(9,1) のレコードをロック→成功
  3. A が (a,b)=(9,1) のレコードをロック→2のロックの解放待ち
  4. B が (a,b)=(1,9) のレコードをロック→1のロックの解放待ち

…となってデッドロックするという感じ。

なお、インデックス b を逆順にすると、同じデータ&同じクエリでも、

CREATE TABLE x (a INT, b INT, val INT, INDEX(a), INDEX(b DESC));
INSERT INTO x VALUES (1,9,0),(2,8,0),(3,7,0),(4,6,0),(5,5,0),(6,4,0),(7,3,0),(8,2,0),(9,1,0);
  1. A が (a,b)=(1,9) のレコードをロック→成功
  2. B が (a,b)=(1,9) のレコードをロック→1のロックの解放待ち
  3. A が (a,b)=(9,1) のレコードをロック→成功
  4. A が終了(=COMMIT)して1のロックが解放される
  5. B が (a,b)=(9,1) のレコードをロック→成功

…みたいな感じになりデッドロックは発生しなくなる。

ただし、インデックスに DESC が効くのは MySQL 8.0 からなので、5.7 では無意味。

MySQLマニュアルの差分

MySQL 8.0.x はパッチレベルのリリースで機能追加がされていくんだけど、マニュアルは常に 8.0 の最新版しか公開されてない。 MySQL は OSS だけど、MySQL のマニュアルは自由なライセンスではないしリポジトリも公開されてない。 ライセンスは自由でなくてもせめてリポジトリが公開されてれば、古いバージョンのマニュアルを見れたり差分を表示できるんだけどなー。

しかたないので自分でなんとかしてみる。

MySQL のマニュアルは何かの形式(XMLと聞いたような気がする)から変換して作られているのだけど、ソースは公開されてない。 HTML は https://downloads.mysql.com/docs/refman-8.0-en.html-chapter.tar.gz で一括でダウンロードできるので、これを定期的にダウンロードして Git のリポジトリに突っ込めばどうにかなるだろう。

% mkdir mysql-refman
% cd mysql-refman
% git init
% git commit -m 'Initial commit' --allow-empty

としておいて、定期的にダウンロードして Git に入れるように cron に設定しとく。

wget -q -N https://downloads.mysql.com/docs/refman-8.0-en.html-chapter.tar.gz && cd mysql-refman && rm -rf * && tar xf ../refman-8.0-en.html-chapter.tar.gz && git add . && git commit -m "$(date '+\%F \%T')" > /dev/null

wget に -N を指定すると更新されてなければダウンロードしないのでそんなに負荷にはならないはず。

これでリポジトリが定期的に更新されるようになったんだけど、差分を見ようとすると意味のないものが大量に出てきちゃう。 こんな感じ:

@@ -161,7 +161,7 @@ MEDIUMINT, BIGINT</a></span></dt><dt><span class="section"><a href="data-types.h
 </div>
 </div>
 </div>
-<a class="indexterm" name="idm45408272163120"></a><a class="indexterm" name="idm45408272162080"></a><a class="indexterm" name="idm45408272161008"></a><p>
+<a class="indexterm" name="idm46446106154096"></a><a class="indexterm" name="idm46446106153056"></a><a class="indexterm" name="idm46446106151984"></a><p>
         For integer data types, <em class="replaceable"><code>M</code></em> indicates
         the maximum display width. The maximum display width is 255.
         Display width is unrelated to the range of values a type can
@@ -226,9 +226,9 @@ Warning
 </div>
 <div class="itemizedlist">
 <ul class="itemizedlist" style="list-style-type: disc; "><li class="listitem"><p>
-            <a class="indexterm" name="idm45408272132704"></a>
+            <a class="indexterm" name="idm46446106123680"></a>
 
-            <a class="indexterm" name="idm45408272131632"></a>
+            <a class="indexterm" name="idm46446106122608"></a>
 
             <a class="link" href="data-types.html#bit-type" title="11.1.5 Bit-Value Type - BIT"><code class="literal">BIT[(<em class="replaceable"><code>M</code></em>)]</code></a>
           </p><p>

どうやら HTML が生成される時に a 要素の name 属性の値が自動で生成されるらしく、実際には変更がないところまで差分として表示されてしまう。

まあ、そうでなくても HTML のまま差分が表示されても厳しいものがあるので、次のようにしてみる。

.git/config に次を追加:

[diff "html2text"]
    textconv = html2text

.gitattributes を次で作成:

*.html diff=html2text

これで git diff 時に HTML そのままではなく、html2text でプレーンテキストに変換した結果で差分が表示されるようになる。見やすい。

diff --git a/refman-8.0-en.html-chapter/mysql-cluster.html b/refman-8.0-en.html-
chapter/mysql-cluster.html
index 1197b89..b0ad15f 100644
--- a/refman-8.0-en.html-chapter/mysql-cluster.html
+++ b/refman-8.0-en.html-chapter/mysql-cluster.html
@@ -1577,17 +1577,21 @@ interest are shown in the following list:
             option. For ndb_restore, use the option together with the --decrypt
             option; for ndb_print_backup_file, use the option in place of the -
             P option.
-          o For ndb_mgm, the same option is supported together with --execute
-            "START_BACKUP_[options]" for starting a cluster backup from the
-            system shell.
+          o For ndb_mgm the option --backup-password-from-stdin, is supported
+            together with --execute_"START_BACKUP_[options]" for starting a
+            cluster backup from the system shell.
           o Two ndbxfrm options, --encrypt-password-from-stdin and --decrypt-
             password-from-stdin, cause similar behavior when using that program
             to encrypt or to decrypt a backup file.
       See the descriptions of the programs just listed for more information.
-      This feature also implements the ability to enforce encrypted backups by
-      setting RequireEncryptedBackup=1 in the [ndbd default] section of the
-      cluster global configuration file. When this is done, the ndb_mgm client
-      rejects any attempt to perform a backup that is not encrypted.
+      It is also possible, beginning with NDB 8.0.22, to enforce encryption of
+      backups by setting RequireEncryptedBackup=1 in the [ndbd default] section
+      of the cluster global configuration file. When this is done, the ndb_mgm
+      client rejects any attempt to perform a backup that is not encrypted.
+      Beginning with NDB 8.0.24, you can cause ndb_mgm to use encryption
+      whenever it creates a backup by starting it with --encrypt-backup. In
+      this case, the user is prompted for a password when invoking START_BACKUP
+      if none is supplied.
     * IPv6 support.  Beginning with NDB 8.0.22, IPv6 addressing is supported
       for connections to management and data nodes; this includes connections
       between management and data nodes with SQL nodes. When configuring a
@@ -15118,13 +15122,18 @@ stdin[=value]           STDIN; use together with -- AD
DED: NDB 8.0.24
 --connect-retries=#     giving up; 0 means 1        releases based on MySQL
                         attempt only (and no        8.0)
                         retries)
+                        Cause START BACKUP to
+--encrypt-backup        encrypt whenever making a
+[=TRUE|FALSE]           backup, prompting for       ADDED: NDB 8.0.24
+                        password if not supplied by
+                        user
+--execute=name,                                     (Supported in all NDB
+-e                      Execute command and exit    releases based on MySQL
+                                                    8.0)
                         Set number of times to      (Supported in all NDB
 --try-reconnect=#,      retry connection before     releases based on MySQL
 -t                      giving up; synonym for --   8.0)
                         connect-retries
---execute=name,                                     (Supported in all NDB
--e                      Execute command and exit    releases based on MySQL
-                                                    8.0)
 
     *  --backup-password-from-stdin[=TRUE|FALSE]
       Command-Line Format --backup-password-from-stdin[=value]

このリポジトリを GitHub とかで公開できるといいのかも知れないけど、MySQL のマニュアルは自由なライセンスではないので出来ないのであった。残念。

ていうか、公式が MySQL のマニュアルのリポジトリを公開してくれればいいんだけどなー。

MySQL徹底入門第4版からの差分

2020年7月に出た「MySQL徹底入門 第4版」という本は MySQL 8.0.20 をベースとしてたんですが、ご存知の通り MySQL 8.0 はパッチリリースでどんどん機能が追加されてったりするんで、同じ 8.0 でも最新版とは機能の差分が出てしまってます。

ということで、自分が執筆した分について、8.0.23 までで変わった分についてまとめてみます。

5.1 アカウントの作成と権限の付与

8.0.21 から CREATE USERALTER USER 時に COMMENTATTRIBUTE でコメントや属性を付与できるようになりました。

5.2 アカウント識別

クライアントから接続した場合は、ワイルドカードを含まない接続元が一致するもの、その後ワイルドカードを含むもの、そして最後に空文字のアカウントの順に検索されます。つまり 192.168.1.0/255.255.255.0 が 192.168.% よりも優先されます。なお、 192.168.1.1 と 192.168.1.0/255.255.255.0 は優先度に違いはありません。

と書いたんですが、8.0.23 で変わりました。

  1. IPアドレスまたはホスト名
  2. CIDR 形式のIPアドレス (例: 192.168.1.0/24)
  3. サブネット形式のIPアドレス (例: 192.168.1.0/255.255.255.0)
  4. ワイルドカードを含むもの
  5. 空文字列

この変更によって実害を受けることはおそらくあまりないと思いますが、パッチレベルでこんな変更を入れてくるところがさすが MySQL。

5.5 権限

INNODB_REDO_LOG_ENABLE

8.0.21 で追加されました。

ALTER INSTANCE {ENABLE|DISABLE} INNODB REDO_LOG 文の実行権です。

FLUSH_OPTIMIZER_COSTS
FLUSH_STATUS
FLUSH_TABLES
FLUSH_USER_RESOURCES

8.0.23 で追加されました。

それぞれ、FLUSH OPTIMIZER_COSTS, FLUSH STATUS, FLUSH TABLES, FLUSH USER_RESOURCES 文の実行権です。

今まで RELOAD 権に含まれていたのが個別に指定できるようになりました。

MySQL Parameters のデータの作り方

これは MySQL Advent Calendar 2020 の 25日目の記事です。

MySQL Parameters というページのメンテをしてます。

MySQL Paramters は MySQL のバージョン間の差分を表示できるものです。 現在次のバージョンを比較できます。

  • 5.0.96
  • 5.1.72
  • 5.5.58〜62
  • 5.6.34〜50
  • 5.7.16〜32
  • 8.0.11〜22

比較できる値の種類は次のものです。

  • mysqld のコマンドラインパラメータ
  • mysql のコマンドラインパラメータ
  • システム変数
  • ステータス変数
  • キャラクタセット
  • コレーション
  • 権限
  • 関数/演算子
  • information_schema データベースのテーブル名とカラム名
  • performance_schema データベースのテーブル名とカラム名
  • エラーメッセージ

たとえばシステム変数の 8.0.* のバージョン間の差分は https://mysql-params.tmtms.net/variable/?vers=8.0.11,8.0.12,8.0.13,8.0.14,8.0.15,8.0.16,8.0.17,8.0.18,8.0.19,8.0.20,8.0.21,8.0.22&diff=true で表示できます。

黄色のセルはその左隣のセルの値と違いがあることを示しています。

元々 Vue.js の勉強のつもりで作ってて、だいたいそのままなのでフロントエンドは酷い作りです。作り直したい。

フロントエンドには触れたくないので、データをどのように作ってるのかを説明します。

mysqld のコマンドラインパラメータ

基本的には mysqld --no-defaults --user mysql --help -v の出力の後半からパラメータ名と値を拾ってます。

Variables (--variable-name=value)
and boolean options {FALSE|TRUE}                             Value (after reading options)
------------------------------------------------------------ -------------
abort-slave-event-count                                      0
activate-all-roles-on-login                                  FALSE
admin-address                                                (No default value)
admin-port                                                   33062
admin-ssl                                                    TRUE
admin-ssl-ca                                                 (No default value)
admin-ssl-capath                                             (No default value)
admin-ssl-cert                                               (No default value)
admin-ssl-cipher                                             (No default value)
admin-ssl-crl                                                (No default value)
admin-ssl-crlpath                                            (No default value)
...

--plugin-load でできるだけ多くのプラグインを読み込んで値を表示してるので、 exmaple ストレージエンジンのようなパラメータも表示されてます。

mysql のコマンドラインパラメータ

mysqld と同じように mysql --no-defaults --help -v の出力の後半を使用しています。

Variables (--variable-name=value)
and boolean options {FALSE|TRUE}  Value (after reading options)
--------------------------------- ----------------------------------------
auto-rehash                       TRUE
auto-vertical-output              FALSE
bind-address                      (No default value)
binary-as-hex                     FALSE
character-sets-dir                (No default value)
column-type-info                  FALSE
comments                          FALSE
compress                          FALSE
database                          (No default value)
default-character-set             auto
delimiter                         ;
...

システム変数

mysqld --help -v で出力されるパラメータと、mysqld 起動後に SHOW VARIABLES で表示される変数がビミョーに異なっているので、追加しました。

mysqld 起動後に全プラグインとコンポーネントを読み込んでから、SHOW GLOBAL VARIABLES の出力を使用しています。

activate_all_roles_on_login OFF
admin_address   
admin_port  33062
admin_ssl_ca    
admin_ssl_capath    
admin_ssl_cert  
admin_ssl_cipher    
admin_ssl_crl   
admin_ssl_crlpath   
admin_ssl_key   
admin_tls_ciphersuites  
admin_tls_version   TLSv1,TLSv1.1,TLSv1.2,TLSv1.3
auto_generate_certs ON
auto_increment_increment    1
auto_increment_offset   1
autocommit  ON
...

ステータス変数

SHOW GLOBAL STATUS の出力の1番目のカラムだけを使用しています。

Aborted_clients 0
Aborted_connects        0
Acl_cache_items_count   0
Audit_null_authorization_column 0
Audit_null_authorization_db     0
Audit_null_authorization_procedure      0
Audit_null_authorization_proxy  0
Audit_null_authorization_table  0
Audit_null_authorization_user   0
Audit_null_called       2222
Audit_null_command_end  8
Audit_null_command_start        9
Audit_null_connection_change_user       0
Audit_null_connection_connect   2
Audit_null_connection_disconnect        1
...

キャラクタセット

mysql --no-defaults -e 'SHOW CHARSET' の出力の1番目のカラムを使用しています。

Charset Description Default collation   Maxlen
armscii8    ARMSCII-8 Armenian  armscii8_general_ci 1
ascii   US ASCII    ascii_general_ci    1
big5    Big5 Traditional Chinese    big5_chinese_ci 2
binary  Binary pseudo charset   binary  1
cp1250  Windows Central European    cp1250_general_ci   1
cp1251  Windows Cyrillic    cp1251_general_ci   1
cp1256  Windows Arabic  cp1256_general_ci   1
cp1257  Windows Baltic  cp1257_general_ci   1
cp850   DOS West European   cp850_general_ci    1
cp852   DOS Central European    cp852_general_ci    1
cp866   DOS Russian cp866_general_ci    1
...

コレーション

mysql --no-defaults -e 'SHOW COLLATION' の出力の1番目のカラムを使用しています。

Collation   Charset Id  Default Compiled    Sortlen Pad_attribute
armscii8_bin    armscii8    64      Yes 1   PAD SPACE
armscii8_general_ci armscii8    32  Yes Yes 1   PAD SPACE
ascii_bin   ascii   65      Yes 1   PAD SPACE
ascii_general_ci    ascii   11  Yes Yes 1   PAD SPACE
big5_bin    big5    84      Yes 1   PAD SPACE
big5_chinese_ci big5    1   Yes Yes 1   PAD SPACE
binary  binary  63  Yes Yes 1   NO PAD
cp1250_bin  cp1250  66      Yes 1   PAD SPACE
cp1250_croatian_ci  cp1250  44      Yes 1   PAD SPACE
cp1250_czech_cs cp1250  34      Yes 2   PAD SPACE
cp1250_general_ci   cp1250  26  Yes Yes 1   PAD SPACE
cp1250_polish_ci    cp1250  99      Yes 1   PAD SPACE
cp1251_bin  cp1251  50      Yes 1   PAD SPACE
cp1251_bulgarian_ci cp1251  14      Yes 1   PAD SPACE
...

権限

DESC mysql.user のカラム名(*_priv)から権限を得ています。

Field   Type    Null    Key Default Extra
Host    char(255)   NO  PRI     
User    char(32)    NO  PRI     
Select_priv enum('N','Y')   NO      N   
Insert_priv enum('N','Y')   NO      N   
Update_priv enum('N','Y')   NO      N   
Delete_priv enum('N','Y')   NO      N   
Create_priv enum('N','Y')   NO      N   
Drop_priv   enum('N','Y')   NO      N   
Reload_priv enum('N','Y')   NO      N   
...

カラム名と権限名がビミョーに異なるので(Create_tmp_tableCREATE TEMPORARY TABLES 等)補正したりしてます。

あと 8.0 では PROXY という権限があるんですが、これは mysql.user に含まれていないので、mysql.proxies_priv を見ています。

Field   Type    Null    Key Default Extra
Host    char(255)   NO  PRI     
User    char(32)    NO  PRI     
Proxied_host    char(255)   NO  PRI     
Proxied_user    char(32)    NO  PRI     
...

さらに 8.0 では動的権限(Dynamic Privileges)という(個人的には名前が良くないと思う)、プラグインやコンポーネントによって追加される権限があります。

これは ALL 権限をつけたユーザーを作って SHOW GRANTS FOR ユーザー名 で出力されるのでそれを使用しています。

Grants for test@%
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, RELOAD, SHUTDOWN, PROCESS, FILE, REFERENCES, INDEX, ALTER, SHOW DATABASES, SUPER, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE, REPLICATION SLAVE, REPLICATION CLIENT, CREATE VIEW, SHOW VIEW, CREATE ROUTINE, ALTER ROUTINE, CREATE USER, EVENT, TRIGGER, CREATE TABLESPACE, CREATE ROLE, DROP ROLE ON *.* TO `test`@`%` WITH GRANT OPTION
GRANT APPLICATION_PASSWORD_ADMIN,AUDIT_ADMIN,BACKUP_ADMIN,BINLOG_ADMIN,BINLOG_ENCRYPTION_ADMIN,CLONE_ADMIN,CONNECTION_ADMIN,ENCRYPTION_KEY_ADMIN,GROUP_REPLICATION_ADMIN,INNODB_REDO_LOG_ARCHIVE,INNODB_REDO_LOG_ENABLE,PERSIST_RO_VARIABLES_ADMIN,REPLICATION_APPLIER,REPLICATION_SLAVE_ADMIN,RESOURCE_GROUP_ADMIN,RESOURCE_GROUP_USER,ROLE_ADMIN,SERVICE_CONNECTION_ADMIN,SESSION_VARIABLES_ADMIN,SET_USER_ID,SHOW_ROUTINE,SYSTEM_USER,SYSTEM_VARIABLES_ADMIN,TABLE_ENCRYPTION_ADMIN,XA_RECOVER_ADMIN ON *.* TO `test`@`%` WITH GRANT OPTION

実はこの出力にすべての権限が含まれてるので mysql.user を見なくても良さそうなもんなのですが、このような出力は 8.0 だけで、5.7 以下だと次のように出力されるので、これだけじゃわからないのでした。

Grants for test@%
GRANT ALL PRIVILEGES ON *.* TO 'test'@'%' WITH GRANT OPTION

関数/演算子

mysql で help Functions でカテゴリが表示され、さらにそのカテゴリ名を help すると関数名の一覧が表示されます。

mysql> help Functions;
You asked for help about help category: "Functions"
For more information, type 'help <item>', where <item> is one of the following
categories:
   Aggregate Functions and Modifiers
   Bit Functions
   Cast Functions and Operators
   Comparison Operators
   Control Flow Functions
   Date and Time Functions
   Encryption Functions
   Enterprise Encryption Functions
...
mysql> help Aggregate Functions and Modifiers;
You asked for help about help category: "Aggregate Functions and Modifiers"
For more information, type 'help <item>', where <item> is one of the following
topics:
   AVG
   BIT_AND
   BIT_OR
   BIT_XOR
   COUNT
   COUNT DISTINCT
   GROUP_CONCAT
   JSON_ARRAYAGG
   JSON_OBJECTAGG
   MAX
...

初めはこれを使ってたんですが、出力のパースが面倒だったので、今は mysql.help_categorymysql.help_topic テーブルから名前を得ています。

mysql> select help_category_id,name,parent_category_id from mysql.help_category;
+------------------+---------------------------------+--------------------+
| help_category_id | name                            | parent_category_id |
+------------------+---------------------------------+--------------------+
|                0 | Contents                        |                  0 |
|                1 | Help Metadata                   |                  0 |
|                2 | Data Types                      |                  0 |
|                3 | Administration                  |                  0 |
|                4 | Functions                       |                  0 |
...
mysql> select help_topic_id,name,help_category_id from mysql.help_topic;
+---------------+----------------------------+------------------+
| help_topic_id | name                       | help_category_id |
+---------------+----------------------------+------------------+
|             0 | HELP_DATE                  |                1 |
|             1 | HELP_VERSION               |                1 |
|             2 | AUTO_INCREMENT             |                2 |
|             3 | HELP COMMAND               |                3 |
|             4 | ASYMMETRIC_DECRYPT         |                5 |
|             5 | ASYMMETRIC_DERIVE          |                5 |
|             6 | ASYMMETRIC_ENCRYPT         |                5 |
|             7 | ASYMMETRIC_SIGN            |                5 |
...

この中から関数/演算子っぽいカテゴリ配下にある name を次のクエリで取り出しています。

select t.name from mysql.help_topic t join mysql.help_category c using (help_category_id)
  where c.name rlike "functions|operators|geometry (constructors|relations)|properties|mbr|wkt|wkb|xml|gtid"
    and c.name != "user-defined functions" and t.name not rlike "definition"
  order by t.name

information_schema データベースのテーブル名とカラム名

SELECT TABLE_NAME,COLUMN_NAME FROM COLUMNS WHERE TABLE_SCHEMA='information_schema' の出力を使用しています。

ADMINISTRABLE_ROLE_AUTHORIZATIONS   GRANTEE
ADMINISTRABLE_ROLE_AUTHORIZATIONS   GRANTEE_HOST
ADMINISTRABLE_ROLE_AUTHORIZATIONS   HOST
ADMINISTRABLE_ROLE_AUTHORIZATIONS   IS_DEFAULT
ADMINISTRABLE_ROLE_AUTHORIZATIONS   IS_GRANTABLE
ADMINISTRABLE_ROLE_AUTHORIZATIONS   IS_MANDATORY
ADMINISTRABLE_ROLE_AUTHORIZATIONS   ROLE_HOST
ADMINISTRABLE_ROLE_AUTHORIZATIONS   ROLE_NAME
ADMINISTRABLE_ROLE_AUTHORIZATIONS   USER
APPLICABLE_ROLES    GRANTEE
APPLICABLE_ROLES    GRANTEE_HOST
APPLICABLE_ROLES    HOST
APPLICABLE_ROLES    IS_DEFAULT
...

performance_schema データベースのテーブル名とカラム名

SELECT TABLE_NAME,COLUMN_NAME FROM COLUMNS WHERE TABLE_SCHEMA='performance_schema' の出力を使用しています。

accounts    CURRENT_CONNECTIONS
accounts    HOST
accounts    TOTAL_CONNECTIONS
accounts    USER
binary_log_transaction_compression_stats    COMPRESSED_BYTES_COUNTER
binary_log_transaction_compression_stats    COMPRESSION_PERCENTAGE
binary_log_transaction_compression_stats    COMPRESSION_TYPE
binary_log_transaction_compression_stats    FIRST_TRANSACTION_COMPRESSED_BYTES
binary_log_transaction_compression_stats    FIRST_TRANSACTION_ID
binary_log_transaction_compression_stats    FIRST_TRANSACTION_TIMESTAMP
binary_log_transaction_compression_stats    FIRST_TRANSACTION_UNCOMPRESSED_BYTES
binary_log_transaction_compression_stats    LAST_TRANSACTION_COMPRESSED_BYTES
binary_log_transaction_compression_stats    LAST_TRANSACTION_ID
binary_log_transaction_compression_stats    LAST_TRANSACTION_TIMESTAMP
binary_log_transaction_compression_stats    LAST_TRANSACTION_UNCOMPRESSED_BYTES
...

エラーメッセージ

エラーメッセージは perror コマンドで出力されます。

% perror 1022
MySQL error code MY-001022 (ER_DUP_KEY): Can't write; duplicate key in table '%-.192s'

ですが、エラー番号の一覧はわからないので、ソースコードを見るしかないかと思ってたのですが、include/mysqld_error.h から取れることがわかりました。

//#define OBSOLETE_ER_HASHCHK 1000
//#define OBSOLETE_ER_NISAMCHK 1001
#define ER_NO 1002
#define ER_YES 1003
#define ER_CANT_CREATE_FILE 1004
#define ER_CANT_CREATE_TABLE 1005
#define ER_CANT_CREATE_DB 1006
#define ER_DB_CREATE_EXISTS 1007
#define ER_DB_DROP_EXISTS 1008
//#define OBSOLETE_ER_DB_DROP_DELETE 1009
#define ER_DB_DROP_RMDIR 1010
//#define OBSOLETE_ER_CANT_DELETE_FILE 1011

これを使ってひたすら perror を実行して出力を得ています。

おわり

とまあ、こんなことを MySQL がリリースされる3ヶ月毎にやってます。

元々作り始めたのは 8.0 GA リリースよりも前だったのですが、これを作っておいたおかげで、パッチレベルリリースなのになぜか色々変更される MySQL 8.0.x の差分を確認することが簡単にできるようになりました。(GA とはいったい…?)

投げ銭はzenn.devの右の「¥サポートする」から受け付けております。

MySQL 8.0.22 で `ORDER BY ?` のプリペアドステートメントがフリーズする原因

tmtms.hatenablog.com の続き。

C API だと ORDER BY ? のプリペアドステートメントでクライアントがフリーズするというのを書いたんだけど、Go だと問題ない という話があったので、MySQL のプロトコルを追ってみた。

プリペアドステートメントを発行すると通常は次のようにパケットが流れる。

=> COM_STMT_PREPARE "SELECT ?,?,? FROM tbl"
<= ステートメントID, 結果セットのカラム数(3), プレースホルダの数(3)
<= 謎パケット
<= 謎パケット
<= 謎パケット
<= EOF パケット

「謎パケット」はプレースホルダの数だけ流れるんだけど読み捨てられるだけなので本当に謎。

ところが 8.0.22 で ORDER BY ? の場合は次のようになる。

=> COM_STMT_PREPARE "SELECT a,b,c FROM tbl ORDER BY ?"
<= ステートメントID, 結果カラムの数(3), プレースホルダの数(1)
<= EOF パケット

プレースホルダの数が 1 なのに謎パケットが流れない。

C API mysql_stmt_prepare() の実装は、プレースホルダの数だけ謎パケットを読み飛ばして EOF パケットを待つようになっているので、謎パケットのつもりで EOF パケットを読み込んで、その後 EOF パケットを待ってしまう。

Go の実装は、プレースホルダの数は関係なく、EOF パケットが来るまでパケットを読み飛ばすという風になっているので、謎パケットが1つもなくても EOF パケットがくれば OK とみなして次に進むので問題が発生しない。

まあ、C API も Go と同じように EOF パケットが来るまで読み飛ばすというようにすればいいんだろうけど、これはたぶんサーバー側の応答が間違ってると思うんで、サーバー側の問題なんだろうなぁ(サーバーの実装は読んでない)。

英語を書くのがめんどうだし、自分では使わないからバグレポートを書く気がおきない…。