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

こんにちは！仕事で、改行を含む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の考察を経てスクリプトを作成しました。

```awk: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バイト！

```awk: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}}'
```

### 動作確認

```sh
$ 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)レコード終端"","","","
```

### 感想

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