DbUnitでテーブルデータをCSVでExport/Importするやり方と、varcharなどに改行コードを始めとする制御コードが混ざってきた場合の注意点について簡単にまとめます。
参考:
動作確認:
DbUnit 2.4.9
サンプル :
CSVでExportするには、 org.dbunit.dataset.csv.CsvDataSetWriter のwrite()メソッドが利用できます。
サンプルは色々混ざってるので、CsvDataSetWriterに関する部分だけ抜き出します。
File tmpDir; // ... tmpDir = FileDirHelper.createTmpDir(); //... // export only t1, t2 table to CSV QueryDataSet exportDataSet = new QueryDataSet(dbunit_conn); exportDataSet.addTable("t1"); exportDataSet.addTable("t2"); CsvDataSetWriter.write(exportDataSet, tmpDir);
write()の第一引数にはIDataSetの実装クラスのインスタンスを渡します。上の例では、対象テーブルを指定するため QueryDataSet を使っていますが、全体をダンプしたい場合は IDatabaseConnection.createDataSet() でも問題ないと思います。
第二引数は、出力先のディレクトリを指定したFileオブジェクトを指定します。出力に成功すると、各テーブル名 + ".csv"というファイルと、"table-orderning.txt"というテキストファイルが生成されます。"table-orderning.txt"は、後でCSVをimportする場合に、テーブルのインポート順序を制御出来ます。
CsvDataSetWriterクラスのrow()メソッドの中で、各行の各カラムの値ごとに以下のコードで""囲みの文字列を生成しています。
String stringValue = DataType.asString(value); final String quoted = quote(stringValue);
org.dbunit.dataset.datatype.DataType.asString() により文字列に変換しています。DataTypeは抽象クラスになっていますが、asString() メソッド自体は実装済みで、中身は以下の1行です。
return (String)DataType.VARCHAR.typeCast(value);
DataType.VARCHARは org.dbunit.dataset.datatype.StringDataType のインスタンスで、そのtypeCast()メソッドが、受け取ったObjectの実際の型に応じて文字列に変換しています。
結論からいうと、byte[]とBlobはbase64エンコードされ、それ以外は基本的に toString() の結果が採用されます。
CsvDataSetWriterで書きだしたCSVファイルセットは、CsvDataSetでそのままIDataSetのインスタンスとしてロード出来ます。CsvDataSetのコンストラクタの第一引数に、CsvDataSetWriterで書きだしたディレクトリのFileオブジェクトを渡せば、後はDatabaseOperationなどにそのまま渡せます。
IDataSet csvDataSet = new CsvDataSet(tmpDir); DatabaseOperation.CLEAN_INSERT.execute(dbunit_conn, csvDataSet);
なおCsvDataSetの実体は、CachedDataSetを継承し、以下のようにCsvProducerクラスをCachedDataSetのコンストラクタに渡しているだけです。そのため、CSVデータのインポート処理の実体はCsvProducerの方でコーディングされています。
public class CsvDataSet extends CachedDataSet { //... public CsvDataSet(File dir) throws DataSetException { super(new CsvProducer(dir)); } }
CsvDataSetWriterが出力するCSVは、一行目がカラム名のCSVレコードで、2行目から実際のデータのCSVレコードが始まります。型情報はCSVには出力されません。
CSVからImportする時、文字列として読み取った各カラムの値をどうやって各型に変換しているのか、ちょっとソース追って見ましたがすぐにはわかりませんでした。
org.dbunit.dataset.csv.CsvProducer.produceFromFile(File theDataFile) 中の以下で、parseしたCSVの各カラムごとに処理しているところまでは確認できたのですが、実際の処理は"_consumer"インスタンス、つまりIDataSetConsumerのインスタンスで、これは IDataSetProducer.setConsumer() により恐らくDatabaseOperationなどからセットされるため、ちょっと辿りきれませんでした・・・。
_consumer.startTable(metaData); for (int i = 1 ; i < readData.size(); i++) { List rowList = (List)readData.get(i); Object[] row = rowList.toArray(); for(int col = 0; col < row.length; col++) { row[col] = row[col].equals(CsvDataSetWriter.NULL) ? null : row[col]; } _consumer.row(row); } _consumer.endTable();
ただし、一応、export -> import した後のDB内容を確認してみたところ、標準的なカラムに当たり障りの無い値を入れてみた範囲では問題なくINSERTできていたので、多分「よしなに」処理してくれているのではないかなと・・・。
varcharなどの文字列フィールドに、制御コードなどCSVでの処理が怪しくなりそうな文字が入ってくるシステムの場合、ご想像の通り、まずCSVのexportで「文字化け」します。byte[], blobならexportまでなら少なくともbase64エンコードしてくれるのですが、varcharの場合は普通にStringとして処理され、CSV出力する際に1文字ずつエスケープ処理のフィルタを通過します。その際に、制御コードなどがおかしくなります。CsvDataSetWriter.escape(String) のコードを読んでみてください。
ではどうするか、ですが、色々考えてみたのですが、DataSetのexport/importの既存の仕組みを壊さずに安全に扱おうとしたら、CsvDataSetWriterで escape() するのではなく、もう一律にbase64エンコードしたのを""囲みにしてしまい、CsvProducerの方でbase64をデコードして文字列に直してから使う、というのがどうにか許容出来る内容になりました。
ではCsvDataSetWriter/CsvProducerを継承したクラスを用意して、ポイントとなるメソッドだけoverrideすれば良いのか・・・となりますが、別の問題が潜んでいました。CsvDataSetWriter/CsvProducerでは、重要なメンバの幾つかというか殆どがprivate宣言されていて、継承したクラスから参照できない状況です。メソッドのoverrideに支障が出る状況でした。
この辺りでいい加減ブチ切れて、今回は力技で、CsvDataSetWriter/CsvDataSet/CsvProducerのソースコードをまるっとコピーして、base64エンコード/デコードが必要な箇所のみを書き換えてしまうという暴挙に走らせてもらいました。(だからprivateは嫌いだ・・・)
それが以下のコードになります。
まずCsvBase64BinarySafeDataSetWriterですが、quote()メソッドで以下のように強制的にbase64エンコードしたのを""囲みにするようにしました。
private String quote(String stringValue) throws UnsupportedEncodingException { logger.debug("quote(stringValue={}) - start", stringValue); // encode to base64 string byte[] rawbytes = stringValue.getBytes(CharsetTool.BINARY); String base64encoded = Base64.encodeBytes(rawbytes); // quote surround return new StringBuffer(QUOTE).append(base64encoded).append(QUOTE).toString(); }
これで数値型も日付型も文字列型も、さらに制御コードが混入した文字列も、文字コードがおかしくなる前に、バイナリデータとしてbase64エンコードされます。blob/byte[]型は、既にbase64エンコードされた文字列が入ってきますが、それをもう一度base64エンコードすることになります。
続いて CsvBase64BinarySafeProducer では produceFromFile() のカラム列の処理を以下のように修正しました。NULL以外、全部base64デコードして使うようにしています。
_consumer.startTable(metaData); for (int i = 1 ; i < readData.size(); i++) { List rowList = (List)readData.get(i); Object[] row = rowList.toArray(); for(int col = 0; col < row.length; col++) { if (row[col].equals(CsvDataSetWriter.NULL)) { row[col] = null; } else { byte[] rawbytes = Base64.decode(row[col].toString()); row[col] = new String(rawbytes, CharsetTool.BINARY); } } _consumer.row(row); } _consumer.endTable();
以上のようにカスタマイズしたクラスを使って、実際に0x00 - 0xFFまでの文字列やバイナリデータをexport/importして最終的に戻せたことを確認しているのが、DbUnitCsvUsageTest.javaの customExportAndImportForControlCodeStrings() メソッドになります。
@Test public void customExportAndImportForControlCodeStrings() throws IOException, DatabaseUnitException, SQLException { IDatabaseConnection dbunit_conn = new DatabaseConnection(conn); // cache(save) expected data snap-shot. IDataSet expectedDataSet = new CachedDataSet( dbunit_conn.createDataSet()); // H2DB can store and load binary string to varchar column safely. Set<T4> a = new T4().findAll(conn); for (T4 a2 : a) { assertEquals(a2.stringField, UnsignedByte.create0x00to0xFFString()); } // export t4 using base64 binary safely csv dataset writer. QueryDataSet exportDataSet = new QueryDataSet(dbunit_conn); exportDataSet.addTable("t4"); CsvBase64BinarySafeDataSetWriter.write(exportDataSet, tmpDir); // clear & insert from exported CSV using base64 binary safely csv data producer. IDataSet csvDataSet = new CsvBase64BinarySafeDataSet(tmpDir); DatabaseOperation.CLEAN_INSERT.execute(dbunit_conn, csvDataSet); IDataSet actualDataSet = dbunit_conn.createDataSet(); Assertion.assertEquals(expectedDataSet.getTable("t4"), actualDataSet.getTable("t4")); }
0x00-0xFFが混入していることを意識させない、他のDataSet処理と同じ仕組を使って違和感のないコードに仕上がっています。