こんにちは!仕事で、改行を含むCSVをSQL*Loader書式に変換することになりました。そこでやったことをアウトプットします。
はじめに
SQL*Loader書式
SQL*Loaderで改行を含むレコードをロードする方法として、コントロールファイルにvar 10
などと記述します。
先頭10バイトでレコード長を指定し、その後指定したバイト数だけレコードが続く、という書式のデータファイルをロードできます。
load data
infile 'example.dat' "var 10"
into table example
fields terminated by ',' optionally enclosed by '"'
00000000018"001","改行
あり"
00000000017"002","改行なし"
"001","改行
あり"
"002","改行なし"
SQL*Loader書式に変換するために、CSVの各レコード行頭に10バイトのレコード長文字列を付加するスクリプトを作りました。
想定CSV仕様
- 文字コードはShift JIS限定
- 改行コードはLF限定
- すべてのフィールドが
"
(ダブルクオート)で囲まれている。 - 値に含まれる
"
は、エスケープして""
(ダブルクオート2つ)で表現されている。
変換元のCSVはこの仕様を満たすものとし、そうでないCSVは考慮対象外とします。
CSVが改行ありかどうかの判定方法
CSVの仕様自体は簡単でも、改行ありかどうかのコンテキスト判定は意外に複雑です。
CSVの行の終端がダブルクオート以外の場合は、その値は改行して次行継続です。
"このときは改行継続確定
CSVの行の終端がダブルクオートの場合はダブルクオートの数によって改行コンテキストが変わります。
- ダブルクオートが1つの場合は、値の開始もしくは値の終端の場合があります:
このときは改行なし"
このときは改行あり","
- ダブルクオートが2つの場合はエスケープされたダブルクオートもしくは空の値の場合があります:
このときは改行あり""
このときは改行なし",""
値にカンマ文字やダブルクオート文字が含まれるとさらに複雑になります。
このときは改行なし"","""
このときは改行あり,""","
このときは改行あり"",""
このときは改行なし,",""""
これ以上考察を続けると混乱してしまうので結論を述べます。
行末がダブルクオートを1つ以上ずつカンマで区切った文字列の場合、
「カンマ奇数個の塊」が奇数個ある場合は、改行なし。
それ以外の場合は改行あり。
ただし、これはその行に普通の文字が含まれている場合の話です。
"次の行のようにダブルクオートとカンマだけの行も存在しうる。
",""
ダブルクオートを1つ以上ずつカンマで区切った文字列のみの行の場合
「カンマ奇数個の塊」が奇数個ある場合は、改行あり⇔改行なしのコンテキストが入れ替わる。
それ以外の場合は、改行あり⇔改行なしのコンテキストが入れ替わらない。
ダブルクオートとカンマだけの行は単独では、改行コンテキストを判定できない、ということです。
完成したスクリプト
上記CSVの考察を経てスクリプトを作成しました。
LANG=C awk '{
len=len+length+1 # 長さを1行分追加(改行文字分も込み)
str=str$0"\n" # 文字列を1行分追加(改行文字分も込み)
if (match($0, /"+(,"+)*$/)) { # ダブルクオート群(カンマ区切り)で終わる
if(RSTART>1) nl=1 # ダブルクオート群のみの行以外はレコード継続状態を設定
split(substr($0,RSTART), a, ",")
cnt=nl
for (i in a) if(length(a[i])%2) cnt++
if (cnt%2) {nl=1}
else { # レコード終端の場合
printf ("%010d%s",len,str) # 出力
len=0 # 長さを初期化
str="" # 内容を初期化
nl=0 # 改行なしを表す
}
} else {
nl=1 # 上記以外は改行ありと判定
}
}'
-
LANG=C
でロケール処理の無い状態となり、マルチバイト文字も全て1バイトずつ処理されます。こうすることでShift JIS文字のバイト数を正しく数えることができます。 - ちなみに、Shift JISは以下のような都合のよい特徴を持っています:
- マルチバイト文字は必ず2バイトのためバイト数を数えるのが容易です。
- マルチバイト文字は2バイト目にもASCII文字で使っているコード値が登場しないので、
"
や'
を誤判定することはありません。
- 変数
len
はレコード長(length)を格納し、変数str
はレコード文字列そのもの(string)を格納します。毎行レコード長とレコード文字列を更新します。length+1
は改行コード(LF
)の1バイトを考慮しています。 - match文で、1文字以上のダブルクオート(をカンマで区切ったもの)で終わっているか判定します。変数
nl
は改行あり(new line)かどうかのフラグです。 - 「奇数個のカンマの塊」が奇数個あるかどうかで改行ありかどうかの判定をします。
- レコード終端と判定したときにレコード長とレコード文字列をprintした上で変数群を初期化します。
- 終端がダブルクオートでない場合は改行ありと判定します。
このスクリプトはPOSIX awkで使える組み込み関数のみを使っているため、どの環境でも動作します!
おわりに
最後に無理やりワンライナー化してみました(笑)。210バイト!
LANG=C awk '{l=l+length+1;s=s$0"\n";if(match($0,/"+(,"+)*$/)){if(RSTART>1)n=1;split(substr($0,RSTART),a,",");c=n;for(i in a)if(length(a[i])%2)c++;if(c%2){n=1}else{printf("%010d%s",l,s);l=0;s="";n=0}}else{n=1}}'
動作確認
$ csv2dat.sh < test.csv > output.dat
で実行できます。
"001","改行
あり"
"002","改行なし"
"003","最後に空カラム",""
"004","カンマだけの文字列",","
"005","カンマで終わる文字列,"
"006","改行で始まるレコードの直前が空カラム","","
"
"007","(1)レコード継続"",""","","
"
"008","(2)レコード終端"","","","
0000000018"001","改行
あり"
0000000017"002","改行なし"
0000000026"003","最後に空カラム",""
0000000031"004","カンマだけの文字列",","
0000000030"005","カンマで終わる文字列,"
0000000052"006","改行で始まるレコードの直前が空カラム","","
"
0000000036"007","(1)レコード継続"",""","","
"
0000000033"008","(2)レコード終端"","","","
感想
思ったよりロジックが複雑でした。でも試行錯誤を経て「仕様が美しい場合はロジックを突き詰めれば実装が美しくなる」と実感しました。