PL/SQLでRFC4180の仕様を満たすCSVの1行をパースしたい

# 概要

- フィールドは`,`で区切る
- フィールドは`"..."`で囲んでも囲まなくてもよい
- フィールドが`,`を含む場合はフィールドを`"..."`で囲む
- フィールドが`"`を含む場合はフィールドを`"..."`で囲む。また`""`としてエスケープする

上記のようなRFC4180の仕様を満たすCSVの1行が与えられたとき、これをパースする関数をPL/SQLで作成したいとします。

# アルゴリズム

このようなCSV文字列をパースするアルゴリズムはさまざまあると思いますが、個人的に好きな方法をここでは紹介します。

まず、与えられた文字列を`,`でスプリットします。ただし、これではフィールドに`,`が含まれている場合、うまくパースできません。たとえば`aaa,"bbb,ccc",ddd`という文字列は以下のようにパースされてしまいます。

- `aaa`
- `"bbb`
- `ccc"`
- `ddd`

このようなケースに対応するため、スプリットしたあとの各要素の`"`の数を数えます。`"`の数が偶数ならOK。`"`の数が奇数なら、対応が取れていないので、後続の要素と連結します。

一連の作業が終わったら、エスケープされている`"`をアンエスケープし、フィールドを囲む`"..."`を削除します。

これをPythonで実装すると、以下のようになります。

```python
def csv_split(line):
    import re
    
    tokens = []
    for token in line.split(','):
        if len(tokens)==0 or tokens[-1].count('"')%2==0:
            tokens.append(token)
        else:
            tokens[-1] += "," + token
            
    return [re.sub(r'^"(.*)"$', r"\1", token).replace(r'""', '"') for token in tokens]
    
    
if __name__ == '__main__':
    line = ',abc,,"abc","a,b,c","""a"",""b"",""c""","a b c",'
    print(csv_split(line)) 
    #=> ['', 'abc', '', 'abc', 'a,b,c', '"a","b","c"', 'a b c', '']
```

# PL/SQL実装

上記のPythonコードをPL/SQLに翻訳します。ただし、Pythonの`str.split`に対応する関数がPL/SQLには用意されていないので、自前で実装する必要があります。今回は`raw_split`という名前で、Pythonの`str.split`によく似た関数を準備しました。

```plsql
CREATE OR REPLACE TYPE CSV_ARRAY AS TABLE OF VARCHAR2(32767);

CREATE OR REPLACE FUNCTION raw_split(line VARCHAR2) RETURN CSV_ARRAY
IS
    cur_pos INT;
    del_pos INT;
    tokens  CSV_ARRAY;
BEGIN
    cur_pos := 1;
    tokens := CSV_ARRAY();
    LOOP
        del_pos := INSTR(line, ',', cur_pos);
        IF del_pos = 0 THEN
            tokens.extend();
            tokens(tokens.last) := SUBSTR(line, cur_pos);
            EXIT;
        END IF;
        
        tokens.extend();
        tokens(tokens.last) := SUBSTR(line, cur_pos, del_pos - cur_pos);
        cur_pos := del_pos + 1;
    END LOOP;
    
    RETURN tokens;
END;

CREATE OR REPLACE FUNCTION csv_split(line VARCHAR2) RETURN CSV_ARRAY
IS
    tokens1 CSV_ARRAY;
    tokens2 CSV_ARRAY;
BEGIN
    tokens1 := raw_split(line);
    tokens2 := CSV_ARRAY();
    
    FOR i IN tokens1.FIRST..tokens1.LAST LOOP
        IF tokens2.count = 0 OR MOD(COALESCE(REGEXP_COUNT(tokens2(tokens2.last), '"'), 0), 2) = 0 THEN
            tokens2.extend();
            tokens2(tokens2.last) := tokens1(i);
        ELSE
            tokens2(tokens2.last) := tokens2(tokens2.last) || ',' || tokens1(i);
        END IF;
    END LOOP;
    
    FOR i IN tokens2.first..tokens2.last LOOP
        tokens2(i) := REPLACE(REGEXP_REPLACE(tokens2(i), '^"(.*)"$', '\1'), '""', '"');
    END LOOP;
    
    RETURN tokens2;
END;
```

実際に動かしてみましょう。

```plsql
SET SERVEROUTPUT ON;
DECLARE
    tokens CSV_ARRAY;
BEGIN 
    tokens := CSV_SPLIT(',abc,,"abc","a,b,c","""a"",""b"",""c""","a b c",');
    FOR i IN tokens.first..tokens.last LOOP
        DBMS_OUTPUT.PUT_LINE('tokens[' || i || '] --> ' || tokens(i));
    END LOOP;
END;
```

これを実行すると、コンソールに以下のような結果が表示されるはずです。

```
tokens[1] --> 
tokens[2] --> abc
tokens[3] --> 
tokens[4] --> abc
tokens[5] --> a,b,c
tokens[6] --> "a","b","c"
tokens[7] --> a b c
tokens[8] --> 
```

# 環境情報

```sql
SELECT * FROM V$VERSION;
```

|BANNER|BANNER_FULL|BANNER_LEGACY|CON_ID|
|:---|:---|:---|---:|
|Oracle Database 21c Express Edition Release 21.0.0.0.0 - Production|Oracle Database 21c Express Edition Release 21.0.0.0.0 - Production<br>Version 21.3.0.0.0|Oracle Database 21c Express Edition Release 21.0.0.0.0 - Production|0|