LoginSignup
1
0

More than 3 years have passed since last update.

改行付きCSVをSQL*Loader書式に変換するawkワンライナー

Last updated at Posted at 2019-08-10

こんにちは!仕事で、改行を含むCSVをSQL*Loader書式に変換することになりました。そこでやったことをアウトプットします。

はじめに

SQL*Loader書式

SQL*Loaderで改行を含むレコードをロードする方法として、コントロールファイルにvar 10などと記述します。
先頭10バイトでレコード長を指定し、その後指定したバイト数だけレコードが続く、という書式のデータファイルをロードできます。

example.ctl
load data
infile 'example.dat'  "var 10"
into table example
fields terminated by ',' optionally enclosed by '"'
example.dat
00000000018"001","改行
あり"
00000000017"002","改行なし"

https://docs.oracle.com/en/database/oracle/oracle-database/19/sutil/oracle-sql-loader-concepts.html#GUID-0A3FE221-01B5-4CDD-9834-109B3BB3B16D から引用)

example.csv
"001","改行
あり"
"002","改行なし"

SQL*Loader書式に変換するために、CSVの各レコード行頭に10バイトのレコード長文字列を付加するスクリプトを作りました。

想定CSV仕様

  • 文字コードはShift JIS限定
  • 改行コードはLF限定
  • すべてのフィールドが"(ダブルクオート)で囲まれている。
  • 値に含まれる"は、エスケープして""(ダブルクオート2つ)で表現されている。

変換元のCSVはこの仕様を満たすものとし、そうでないCSVは考慮対象外とします。

CSVが改行ありかどうかの判定方法

CSVの仕様自体は簡単でも、改行ありかどうかのコンテキスト判定は意外に複雑です。

CSVの行の終端がダブルクオート以外の場合は、その値は改行して次行継続です。

  • "このときは改行継続確定

CSVの行の終端がダブルクオートの場合はダブルクオートの数によって改行コンテキストが変わります。

  • ダブルクオートが1つの場合は、値の開始もしくは値の終端の場合があります:
    • このときは改行なし"
    • このときは改行あり","
  • ダブルクオートが2つの場合はエスケープされたダブルクオートもしくは空の値の場合があります:
    • このときは改行あり""
    • このときは改行なし",""

値にカンマ文字やダブルクオート文字が含まれるとさらに複雑になります。

  • このときは改行なし"","""
  • このときは改行あり,""","
  • このときは改行あり"",""
  • このときは改行なし,",""""

これ以上考察を続けると混乱してしまうので結論を述べます。

行末がダブルクオートを1つ以上ずつカンマで区切った文字列の場合、
「カンマ奇数個の塊」が奇数個ある場合は、改行なし。
それ以外の場合は改行あり。

ただし、これはその行に普通の文字が含まれている場合の話です。

"次の行のようにダブルクオートとカンマだけの行も存在しうる。
",""

ダブルクオートを1つ以上ずつカンマで区切った文字列のみの行の場合
「カンマ奇数個の塊」が奇数個ある場合は、改行あり⇔改行なしのコンテキストが入れ替わる。
それ以外の場合は、改行あり⇔改行なしのコンテキストが入れ替わらない。

ダブルクオートとカンマだけの行は単独では、改行コンテキストを判定できない、ということです。

完成したスクリプト

上記CSVの考察を経てスクリプトを作成しました。

csv2dat.sh

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バイト!

csv2dat.sh
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

で実行できます。

test.csv
"001","改行
あり"
"002","改行なし"
"003","最後に空カラム",""
"004","カンマだけの文字列",","
"005","カンマで終わる文字列,"
"006","改行で始まるレコードの直前が空カラム","","
"
"007","(1)レコード継続"",""","","
"
"008","(2)レコード終端"","","","
output.dat
0000000018"001","改行
あり"
0000000017"002","改行なし"
0000000026"003","最後に空カラム",""
0000000031"004","カンマだけの文字列",","
0000000030"005","カンマで終わる文字列,"
0000000052"006","改行で始まるレコードの直前が空カラム","","
"
0000000036"007","(1)レコード継続"",""","","
"
0000000033"008","(2)レコード終端"","","","

感想

思ったよりロジックが複雑でした。でも試行錯誤を経て「仕様が美しい場合はロジックを突き詰めれば実装が美しくなる」と実感しました。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0