tag:crieit.net,2005:https://crieit.net/tags/Reline/feed 「Reline」の記事 - Crieit Crieitでタグ「Reline」に投稿された最近の記事 2021-05-04T02:23:29+09:00 https://crieit.net/tags/Reline/feed tag:crieit.net,2005:PublicArticle/17054 2021-05-04T02:12:42+09:00 2021-05-04T02:23:29+09:00 https://crieit.net/posts/ruby-reline-readmultiline-mysql-wrapper Reline.readmultilineの練習: mysqlコマンドのラッパーを作ってちょっといい感じにしてみる <p><code>Reline.readmultiline</code> の基本的な使い方を<a target="_blank" rel="nofollow noopener" href="https://memo88.hatenablog.com/entry/20210417_reline_readmultiline">前回調べました</a>ので、練習で何か作ってみようかと。</p> <p>何でもよかったのですが、ためしに mysql コマンド(MySQLクライアント)のラッパーを作ってみました。mysql コマンドの対話的インターフェイス、複数行入力はできますが、履歴を遡ると1行にされてちょっと不便ですよね(という体でやってみました)。</p> <h1 id="使い方"><a href="#%E4%BD%BF%E3%81%84%E6%96%B9">使い方</a></h1> <p>先にテスト用の MySQL サーバを Docker で起動しておいて <code>mysql_wrapper.rb</code> を実行します。</p> <pre><code class="sh">$ docker run -d --rm --name mysql_test \ -e MYSQL_ALLOW_EMPTY_PASSWORD=yes \ mysql:8.0.23 # サーバが起動するのを待ってから $ ruby mysql_wrapper.rb </code></pre> <h1 id="mysql_wrapper.rb"><a href="#mysql_wrapper.rb">mysql_wrapper.rb</a></h1> <pre><code class="ruby">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 </code></pre> <h1 id="実行の例"><a href="#%E5%AE%9F%E8%A1%8C%E3%81%AE%E4%BE%8B">実行の例</a></h1> <pre><code>$ 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 $ </code></pre> <p>履歴を遡ってもちゃんと複数行のまま復元されます。</p> <p>1回ごとに mysql コマンドを実行しているので、 use 文などで状態を変えても毎回リセットされる点に注意。mysql プロセスとパイプでやりとりするというのも一瞬考えましたが、サンプルにしては大げさになるかなと思ってやめました。</p> <p>「末尾が <code>;</code> になっていること」を編集完了の条件にしているので、 <code>exit</code> ではなく <code>exit;</code> のように末尾にセミコロンを付ける必要があります。</p> <p>今回のこれはお遊び程度のものですが、入力されたクエリをどこかのサーバに送って記録するとか、MySQL に渡す前にチェックして drop や delete などの文の実行を禁止するとか、いろいろ応用できそうな気がします。</p> <h1 id="おまけ"><a href="#%E3%81%8A%E3%81%BE%E3%81%91">おまけ</a></h1> <pre><code>+------+--------------------------------------+------+ | c1 | c2 | c3 | +------+--------------------------------------+------+ | 1 | null | NULL | | 12 | | | | 123 | 寿司ビール🍣🍺 | x | | 1234 | xml[&<>"] sq['] bs[\] t[ ] n[ ] | NULL | +------+--------------------------------------+------+ </code></pre> <p>この MySQL の標準の表示ももうちょっといい感じになってほしいんですよね……。</p> <ul> <li>文字列の <code>NULL</code> と null が区別できない</li> <li>空文字なのか半角スペースなのか分からない</li> <li>半角じゃない文字があるとずれる</li> <li>改行があると表が崩れる</li> <li>上の例だと CR の前の部分が見えない</li> </ul> <p>というわけで、結果をパースしていい感じにしてみます。</p> <p>mysql コマンドのオプションに <code>--xml</code> を付けて、出力された XML をパースして pp で表示。<br /> CR, LF の変換は今回の例をうまく動かすための適当なものです。適切な対処方ではない気がします。</p> <pre><code class="ruby">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 </code></pre> <pre><code class="ruby"># 出力: [["c1", "c2", "c3"], ["1", "null", "NULL"], ["12", "", " "], ["123", "寿司ビール🍣🍺", " x "], ["1234", "xml[&<>\"] sq['] bs[\\] t[\t] n[\n" + "] r[\r]", nil]] </code></pre> <hr /> <p>さらにおまけとして、以前書いた <a target="_blank" rel="nofollow noopener" href="https://memo88.hatenablog.com/entry/2019/04/20/101713">これ</a> を使って列を揃えてみました。</p> <pre><code class="ruby"> require_relative "json_array_table" # ... rows = parse_mysql_xml(output) puts JsonArrayTable.generate(rows) </code></pre> <pre><code class="javascript">// 出力: [ "c1" , "c2" , "c3" ] [ "1" , "null" , "NULL" ] [ "12" , "" , " " ] [ "123" , "寿司ビール🍣🍺" , " x " ] [ "1234" , "xml[&<>\"] sq['] bs[\\] t[\t] n[\n] r[\r]" , null ] </code></pre> <p>割と満足。</p> <h1 id="Ruby関連で他に書いたもの"><a href="#Ruby%E9%96%A2%E9%80%A3%E3%81%A7%E4%BB%96%E3%81%AB%E6%9B%B8%E3%81%84%E3%81%9F%E3%82%82%E3%81%AE">Ruby関連で他に書いたもの</a></h1> <p><a target="_blank" rel="nofollow noopener" href="https://memo88.hatenablog.com/archive/category/Ruby">https://memo88.hatenablog.com/archive/category/Ruby</a></p> sonota486 tag:crieit.net,2005:PublicArticle/17017 2021-04-28T08:43:35+09:00 2021-04-28T08:46:47+09:00 https://crieit.net/posts/ruby-reline-readmultiline Reline.readmultiline ちょっと調べたメモ <p><a target="_blank" rel="nofollow noopener" href="https://github.com/ruby/reline">Reline</a> を使うと複数行編集ができるようなので、自分が使いそうな基本的な部分について調べてみました。</p> <p>このリッチなのが標準で使えるの嬉しいですよね。ありがたや……。</p> <pre><code>RUBY_VERSION #=> 3.0.0 Reline::VERSION #=> 0.2.5 </code></pre> <h1 id="最初の雛形"><a href="#%E6%9C%80%E5%88%9D%E3%81%AE%E9%9B%9B%E5%BD%A2">最初の雛形</a></h1> <p>ブロックは必須。</p> <pre><code class="ruby">require "reline" PROMPT = "> " loop do text = Reline.readmultiline(PROMPT) do |_| # このブロックについては後述 true end # 編集が完了した複数行文字列を使った処理 puts "text (#{text})" end </code></pre> <p>これだけだとまだ複数行編集できないんですが、デバッグ用のログと履歴まわりを準備しておくと捗るので先にそっちを片付けます。</p> <h1 id="挙動確認のためのデバッグログ出力"><a href="#%E6%8C%99%E5%8B%95%E7%A2%BA%E8%AA%8D%E3%81%AE%E3%81%9F%E3%82%81%E3%81%AE%E3%83%87%E3%83%90%E3%83%83%E3%82%B0%E3%83%AD%E3%82%B0%E5%87%BA%E5%8A%9B">挙動確認のためのデバッグログ出力</a></h1> <p>デバッグ用の表示を同じターミナルに出力すると混ざって分かりにくいので、ファイルに出力して別ターミナルで <code>tail -F</code> することに。</p> <pre><code class="ruby">$log = File.open("debug.log", "a") def debug(*args) $log.puts *args $log.flush end </code></pre> <h1 id="履歴の保存・復元"><a href="#%E5%B1%A5%E6%AD%B4%E3%81%AE%E4%BF%9D%E5%AD%98%E3%83%BB%E5%BE%A9%E5%85%83">履歴の保存・復元</a></h1> <p>同じ入力を繰り返すのは面倒なので履歴まわりを用意しておきます。</p> <p><code>Reline.readmultiline</code> の第2引数 <code>add_hist</code> を true にすると Reline が履歴を覚えてくれて、カーソルキー上下や <code>ctrl-n</code> <code>ctrl-p</code> で履歴を辿れるようになります。</p> <pre><code class="ruby"> text = Reline.readmultiline(PROMPT, true) do |input| ... </code></pre> <p>一度終了して次回実行したときに前回までの履歴が復元されてほしいので、シリアライズしてファイルに保存します。とりあえず JSON で適当に。</p> <pre><code class="ruby">require "json" HISTORY_FILE = "history" 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 </code></pre> <h1 id="Reline.readmultiline のブロック"><a href="#Reline.readmultiline+%E3%81%AE%E3%83%96%E3%83%AD%E3%83%83%E3%82%AF">Reline.readmultiline のブロック</a></h1> <pre><code class="ruby"> text = Reline.readmultiline(PROMPT, true) do |input| debug "" debug "-->> readmultiline block" debug input.inspect true end </code></pre> <p>こんな感じでデバッグ出力して動作を見てみると、<code>Enter</code> キーが押されたタイミングでこのブロックが呼び出されているようだぞ、ということが確認できます。</p> <p><code>11</code> Enter <code>22</code> Enter と入力したときのデバッグ出力:</p> <pre><code>-->> readmultiline block "11\n" -->> readmultiline block "22\n" </code></pre> <hr /> <p>このブロックは <code>LineEditor#confirm_multiline_termination_proc</code> にセットされ、</p> <pre><code>ed_newline => confirm_multiline_termination => @confirm_multiline_termination_proc </code></pre> <p>という流れで呼び出されるようです。 <code>ed_newline</code> という名前のメソッドから呼ばれているので、改行の入力がトリガーになっていると考えて良さそうな雰囲気です。</p> <p>ブロックの呼び出しはこのようになっていて、</p> <pre><code class="ruby">@confirm_multiline_termination_proc.(temp_buffer.join("\n") + "\n") </code></pre> <p>各行の末尾に LF の付いた文字列がブロックの引数に渡ってくることが分かります。</p> <hr /> <p>ブロックの評価値の扱いも見てみます。</p> <pre><code class="ruby">class Reline::LineEditor # ... private def ed_newline(key) # ... if confirm_multiline_termination finish else key_newline(key) end </code></pre> <p>真だったら編集完了、偽だったら編集継続となるようです(見た感じでは)。</p> <hr /> <p>というわけで、</p> <ol> <li>編集中の複数行文字列が引数としてブロックに渡ってくる</li> <li>編集が完了しているかを判断し、完了している場合はブロックの評価値を真にする。途中だったら偽にする。</li> </ol> <p>というあたりを踏まえて、ブロックの中身を修正します。例として、末尾が <code>;</code> になっていたら編集完了というルールにします。</p> <pre><code class="ruby"> text = Reline.readmultiline(PROMPT, true) do |input| debug "" debug "--> readmultiline block" debug input.inspect finished = input.strip.end_with?(";") debug "finished (#{finished})" finished end </code></pre> <p><code>11</code> Enter <code>;22</code> Enter <code>;</code> Enter と入力したときのデバッグ出力:</p> <pre><code>-->> readmultiline block "11\n" finished (false) ... まだ編集の途中 -->> readmultiline block "11\n;22\n" finished (false) ... まだ編集の途中 -->> readmultiline block "11\n;22\n;\n" finished (true) ... 末尾が ; なので編集が完了したと判断 </code></pre> <p>なるほど。基本的なことがやりたいだけであればこのくらい分かっていれば良さそうですね。</p> <h1 id="プロンプトをカスタマイズする"><a href="#%E3%83%97%E3%83%AD%E3%83%B3%E3%83%97%E3%83%88%E3%82%92%E3%82%AB%E3%82%B9%E3%82%BF%E3%83%9E%E3%82%A4%E3%82%BA%E3%81%99%E3%82%8B">プロンプトをカスタマイズする</a></h1> <p>たとえば、最初の行とそれ以外で異なるプロンプトを表示したいといった場合、 <code>Reline.prompt_proc</code> に Proc オブジェクトをセットすることでカスタマイズできるようです。</p> <pre><code class="ruby">PROMPT = "> " Reline.prompt_proc = Proc.new do |lines| lines.each_with_index.map do |line, i| i == 0 ? PROMPT : "| " end end </code></pre> <pre><code>$ ruby sample.rb > 11 | 22 | ; text (11 22 ;) > </code></pre> <p>この場合、<code>Reline.prompt_proc</code> で生成したプロンプト文字列が優先して使われ、<code>Reline.readmultiline</code> の第一引数で渡したプロンプト文字列は使われなくなるようです(表面的な挙動を見た感じでは)。</p> <p>ただし、<code>Reline.readmultiline</code> の第一引数で渡したプロンプト文字列と長さが異なっていると履歴を移動した際にカーソル位置がずれるので、とりあえず同じ長さにしておくと良いようです。</p> <h1 id="まとめたもの"><a href="#%E3%81%BE%E3%81%A8%E3%82%81%E3%81%9F%E3%82%82%E3%81%AE">まとめたもの</a></h1> <pre><code class="ruby">require "reline" require "json" HISTORY_FILE = "history" PROMPT = "> " $log = File.open("debug.log", "a") def debug(*args) $log.puts *args $log.flush end 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 finished?(input) stripped = input.strip return true if stripped == "exit" return true if stripped.end_with?(";") false 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| debug "" debug "-->> readmultiline block" debug input.inspect finished = finished?(input) debug "finished (#{finished})" finished end add_history text # 編集が完了した複数行文字列を使った処理 puts "text (#{text})" break if text == "exit" end </code></pre> <h1 id="メモ"><a href="#%E3%83%A1%E3%83%A2">メモ</a></h1> <ul> <li>使用例については irb のソースも見てみると良さそう <ul> <li>たとえば v3.0.1 だとここで使われています<br /> <a target="_blank" rel="nofollow noopener" href="https://github.com/ruby/ruby/blob/v3_0_1/lib/irb/input-method.rb#L319">https://github.com/ruby/ruby/blob/v3_0_1/lib/irb/input-method.rb#L319</a></li> </ul></li> </ul> <h1 id="参考"><a href="#%E5%8F%82%E8%80%83">参考</a></h1> <ul> <li>2019-12-26 <a target="_blank" rel="nofollow noopener" href="https://eh-career.com/engineerhub/entry/2019/12/26/103000">Ruby 2.7のここがすごい! パターンマッチ、コンパクションGCなどをリリースマネージャーに聞いた - エンジニアHub|Webエンジニアのキャリアを考える!</a></li> </ul> <h1 id="関連"><a href="#%E9%96%A2%E9%80%A3">関連</a></h1> <ul> <li><a target="_blank" rel="nofollow noopener" href="https://memo88.hatenablog.com/entry/20210418_reline_readmultiline_mysql_wrapper">Reline.readmultilineの練習: mysqlコマンドのラッパーを作ってちょっといい感じにしてみる</a></li> <li><a target="_blank" rel="nofollow noopener" href="https://memo88.hatenablog.com/archive/category/Ruby">他に Ruby 関連で書いたもの</a></li> </ul> sonota486