hkoba blog

プログラマーです。プログラミング言語ミーハーです。ツッコミ歓迎です。よろしくどうぞ(能代口調)

メモ:Zsh で TSV を読みたいとき、どうするか

(いつも通り、ツッコミ歓迎です)

問題

例えば TSV (タブ区切りテキスト)形式の正誤表があり、そこから SQLite のデータベースファイルへ更新をかけたい… そんな時、皆さんだったらどうしますか? なお、レコード数は1,000行程度とします。

私はこういうケースだと Zsh を使って雑に UPDATE 文の列を作ることが多いです。 ただ、雑に書きすぎると入力の形式によっては上手く行かないので、その辺りをまとめてみました。

TL;DR IFS で分けるな、一行丸々 IFS= read -r line で読み込んでから col=( "${(@ps:\t:)line}" ) で配列に分けよう。

なお、SQLite は文字列をシングルクォートで囲って表現しますが、その中にシングルクォートが出現する時は '' のように二重にする必要があります。 今回は、この処理に以下の関数を使います。この SQL文字列をクォートする仕組みの解説は以前の日記を参照して下さい

function zqsql {
  local val=$1
  print -nr "${${(qq)val}//'\''/''}"
}

部分的にしか動かない方法: IFS を使う

まず、read と IFS の組み合わせを使う方法を書いてみました。 入力ファイル input1.tsv はこんな内容です。

id   old new
1111    ああああ    かかかか
2222    いいいい    きききき
3333    うううう    くくくく

実行

% i=0; while IFS=$'\t' read -r id old new; do
  ((i++)) || continue
  print "update t set name = $(zqsql $new) where id = $(zqsql $id);"
done < input1.tsv

update t set name = 'かかかか' where id = '1111';
update t set name = 'きききき' where id = '2222';
update t set name = 'くくくく' where id = '3333';

残念ながら、このやり方だと次の input2.tsv のように、入力に空文字列が入るケースで困ります。

id   flag1   old flag2   new
1111    foo ああああ    bar かかかか
2222    1   いいいい        きききき
3333        うううう    2   くくくく

実行の様子です。 1111 は正常ですが、 2222, 3333 の update 文は期待と違います。

% i=0; while IFS=$'\t' read -r id _ old _ new; do
  ((i++)) || continue
  print "update t set name = $(zqsql $new) where id = $(zqsql $id);"
done < input2.tsv

update t set name = 'かかかか' where id = '1111';
update t set name = '' where id = '2222';
update t set name = '' where id = '3333';

これは $IFS に(タブなどの)空白文字を設定した場合、複数の空白文字の連なりを一つのフィールド区切りとして認識しているから、でしょう。

# IFS にタブを設定した場合
% IFS=$'\t' read -A foo <<<$'foo\t\t\tbar'
# 配列の中は2要素
% print $#foo
2
% print -l "$foo[@]"
foo
bar
%

# IFS にカンマを設定した場合
% IFS=, read -A foo <<<"foo,,,bar"
# 配列の中は 4 要素
% print $#foo
4
% print -l "$foo[@]"
foo


bar
%

解決策:IFS では分割せずに、読み込み後に分割する

変数展開フラグの s:string: を使いました。 以下 zsh 公式マニュアルの引用です。

s:STRING:
     Force field splitting at the separator STRING.  Note that a STRING
     of two or more characters means that all of them must match in
     sequence; this differs from the treatment of two or more characters
     in the IFS parameter.  See also the = flag and the SH_WORD_SPLIT
     option.  An empty string may also be given in which case every
     character will be a separate element.

     For historical reasons, the usual behaviour that empty array
     elements are retained inside double quotes is disabled for arrays
     generated by splitting; hence the following:

          line="one::three"
          print -l "${(s.:.)line}"

     produces two lines of output for one and three and elides the empty
     field.  To override this behaviour, supply the '(@)' flag as well,
     i.e.  "${(@s.:.)line}".

今回の例に適用すると、こんな感じになります。

% i=0; while IFS= read -r line; do
  ((i++)) || continue

  # 変数 $line の中身を s(split) フラグで分割
  # タブをエスケープシーケンスで \t と書きたいので p (print) フラグを立てる
  # 空文字列を残したいので全体を "" で囲みつつ @ フラグも立てる
  col=("${(@ps:\t:)line}")

  print "update t set name = $(zqsql $col[5]) where id = $(zqsql $col[1]);"
done < input2.tsv

update t set name = 'かかかか' where id = '1111';
update t set name = 'きききき' where id = '2222';
update t set name = 'くくくく' where id = '3333';