Reline.readmultilineの練習: mysqlコマンドのラッパーを作ってちょっといい感じにしてみる
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", "
")
.gsub("\r", "
")
)
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関連で他に書いたもの
Discussion