Reline.readmultilineの練習: mysqlコマンドのラッパーを作ってちょっといい感じにしてみる

5 min read

Reline.readmultiline の基本的な使い方を前回調べましたので、練習で何か作ってみようかと。

何でもよかったのですが、ためしに mysql コマンド(MySQLクライアント)のラッパーを作ってみました。mysql コマンドの対話的インターフェイス、複数行入力はできますが、履歴を遡ると1行にされてちょっと不便ですよね(という体でやってみました)。

使い方

先にテスト用の MySQL サーバを Docker で起動しておいて mysql_wrapper.rb を実行します。

$ docker run -d --rm --name mysql_test \
  -e MYSQL_ALLOW_EMPTY_PASSWORD=yes \
  mysql:8.0.23

# サーバが起動するのを待ってから
$ ruby mysql_wrapper.rb

mysql_wrapper.rb

require "json"
require "shellwords"

require "bundler/inline"
gemfile do
  source "https://rubygems.org"
  gem "reline", "0.2.5" # 最新バージョンで試すため
end

HISTORY_FILE = "mysql_history"
PROMPT = "*mysql> "

def add_history(text)
  File.open(HISTORY_FILE, "a") { |f| f.puts JSON.generate(text) }
end

def load_history
  return unless File.exist?(HISTORY_FILE)

  File.read(HISTORY_FILE).each_line do |json|
    Reline::HISTORY << JSON.parse(json)
  end
end

def execute_sql(sql)
  cmd = Shellwords.shelljoin([
    "docker", "exec", "-it", "mysql_test",
    "/usr/bin/mysql", "-e", sql
  ])

  output = `#{cmd}`
  unless $?.success?
    puts output
    return
  end

  if output.empty? # use, create table などの場合
    puts "(empty output)"
    puts "----"
    return
  end

  puts output
end

Reline.prompt_proc =
  Proc.new do |lines|
    lines.each_with_index.map do |line, i|
      i == 0 ? PROMPT : "*    -> "
    end
  end

load_history

loop do
  text =
    Reline.readmultiline(PROMPT, true) do |input|
      input.strip.end_with?(";")
    end

  add_history text

  if text.start_with?("exit")
    puts "bye"
    break
  end

  execute_sql text
end

実行の例

$ ruby mysql_wrapper.rb 
*mysql> create database db1;
(empty output)
----
*mysql> create table db1.t1 (
*    ->   c1 int
*    -> , c2 text
*    -> , c3 text
*    -> );
(empty output)
----
*mysql> insert into db1.t1 values
*    ->   (   1, 'null' , 'NULL')
*    -> , (  12, ''     , '  ')
*    -> , ( 123, '寿司ビール🍣🍺', ' x ')
*    -> , (1234, 'xml[&<>"] sq[''] bs[\\] t[\t] n[\n] r[\r]', null)
*    -> ;
(empty output)
----
*mysql> select * from db1.t1;
+------+--------------------------------------+------+
| c1   | c2                                   | c3   |
+------+--------------------------------------+------+
|    1 | null                                 | NULL |
|   12 |                                      |      |
|  123 | 寿司ビール🍣🍺              |  x   |
| 1234 | xml[&<>"] sq['] bs[\] t[       ] n[
] | NULL |
+------+--------------------------------------+------+
*mysql> exit;
bye
$

履歴を遡ってもちゃんと複数行のまま復元されます。

1回ごとに mysql コマンドを実行しているので、 use 文などで状態を変えても毎回リセットされる点に注意。mysql プロセスとパイプでやりとりするというのも一瞬考えましたが、サンプルにしては大げさになるかなと思ってやめました。

「末尾が ; になっていること」を編集完了の条件にしているので、 exit ではなく exit; のように末尾にセミコロンを付ける必要があります。

今回のこれはお遊び程度のものですが、入力されたクエリをどこかのサーバに送って記録するとか、MySQL に渡す前にチェックして drop や delete などの文の実行を禁止するとか、いろいろ応用できそうな気がします。

おまけ

+------+--------------------------------------+------+
| c1   | c2                                   | c3   |
+------+--------------------------------------+------+
|    1 | null                                 | NULL |
|   12 |                                      |      |
|  123 | 寿司ビール🍣🍺              |  x   |
| 1234 | xml[&<>"] sq['] bs[\] t[       ] n[
] | NULL |
+------+--------------------------------------+------+

この MySQL の標準の表示ももうちょっといい感じになってほしいんですよね……。

  • 文字列の NULL と null が区別できない
  • 空文字なのか半角スペースなのか分からない
  • 半角じゃない文字があるとずれる
  • 改行があると表が崩れる
  • 上の例だと CR の前の部分が見えない

というわけで、結果をパースしていい感じにしてみます。

mysql コマンドのオプションに --xml を付けて、出力された XML をパースして pp で表示。
CR, LF の変換は今回の例をうまく動かすための適当なものです。適切な対処方ではない気がします。

require "rexml/document"

def parse_mysql_xml(xml)
  doc = REXML::Document.new(
    xml
      .gsub("\n", "&#x0A;")
      .gsub("\r", "&#x0D;")
  )

  row_els = REXML::XPath.match(doc, "resultset/row")

  colnames = row_els[0].elements.map { |field_el| field_el["name"] }

  body_rows =
    row_els.map do |row_el|
      row_el.elements.map do |field_el|
        if field_el["xsi:nil"] == "true"
          nil
        else
          if field_el.text.nil?
            ""
          else
            field_el.text.gsub("\r\n", "\n")
          end
        end
      end
    end

  [colnames, *body_rows]
end

# ...
rows = parse_mysql_xml(output)
pp rows
# 出力:
[["c1", "c2", "c3"],
 ["1", "null", "NULL"],
 ["12", "", "  "],
 ["123", "寿司ビール🍣🍺", " x "],
 ["1234", "xml[&<>\"] sq['] bs[\\] t[\t] n[\n" + "] r[\r]", nil]]

さらにおまけとして、以前書いた これ を使って列を揃えてみました。

  require_relative "json_array_table"
  # ...
  rows = parse_mysql_xml(output)
  puts JsonArrayTable.generate(rows)
// 出力:
[ "c1"   , "c2"                                        , "c3"   ]
[ "1"    , "null"                                      , "NULL" ]
[ "12"   , ""                                          , "  "   ]
[ "123"  , "寿司ビール🍣🍺"                            , " x "  ]
[ "1234" , "xml[&<>\"] sq['] bs[\\] t[\t] n[\n] r[\r]" , null   ]

割と満足。

Ruby関連で他に書いたもの

https://memo88.hatenablog.com/archive/category/Ruby