📝

jackson-dataformat-csvの2と3でCSVヘッダーに関する挙動が違った

に公開

環境

  • JDK 25
  • jackson-dataformat-csv
    • 2.20.1
    • 3.0.3

やりたいこと

こんな感じで、ヘッダー付きのCSVを読み込む。

product.csv
製品番号,製品名
P00001,ノートパソコン
P00002,デスクトップパソコン

CSVから変換されるクラスはこんな感じ。

Product.java
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;

@JsonPropertyOrder({
        "製品番号",
        "製品名"
})
public record Product(
        @JsonProperty("製品番号")
        String productId,
        @JsonProperty("製品名")
        String productName
) {
}

もし、次のようにヘッダー名が異なるCSVが異なる場合、例外InvalidCsvExceptionをスローしたい。

product_invalid_header.csv
商品ID,商品名
P00001,ノートパソコン
P00002,デスクトップパソコン
InvalidCsvException.java
public class InvalidCsvException extends RuntimeException {
    public InvalidCsvException(String message, Throwable cause) {
        super(message, cause);
    }
}

2の場合

jackson-databind-csv 2系の場合は、下記のコードで実現できます。

ProductCsvParser2.java
import com.fasterxml.jackson.databind.MappingIterator;
import com.fasterxml.jackson.dataformat.csv.CsvMapper;
import com.fasterxml.jackson.dataformat.csv.CsvSchema;

import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;

public class ProductCsvParser2 {
    private final CsvMapper csvMapper;

    private final CsvSchema csvSchema;

    public ProductCsvParser2() {
        this.csvMapper = new CsvMapper();
        this.csvSchema = CsvSchema.emptySchema()
                .withHeader()  // ヘッダーあり
                .withColumnSeparator(',')  // 区切り文字カンマ
                .withLineSeparator("\n")  // 改行コードLF
                .withoutQuoteChar();  // 囲み文字なし
    }

    public List<Product> parse(Path csvFile) {
        try (
                BufferedReader br = Files.newBufferedReader(csvFile, StandardCharsets.UTF_8);
                MappingIterator<Product> iterator = csvMapper
                        .readerFor(Product.class)
                        .with(csvSchema)
                        .readValues(br)
        ) {
            List<Product> productList = iterator.readAll();
            return productList;
        } catch (IOException e) {
            throw new InvalidCsvException("CSVのパースに失敗しました。", e);
        }
    }
}

テストクラスはこんな感じ。これでテストはすべてOKになる。

ProductCsvParser2Test.java
class ProductCsvParser2Test {
    ProductCsvParser2 productCsvParser2 = new ProductCsvParser2();

    @Nested
    @DisplayName("parse()")
    class Parse {
        @Test
        @DisplayName("仕様通りのCSVを読み込むと、製品のリストが返る")
        void success() {
            // テスト用CSVファイルのパスを取得
            Path csvFile = Path.of("src/test/resources/product.csv");

            // CSVをパース
            List<Product> actual = productCsvParser2.parse(csvFile);

            // 結果の検証
            assertEquals(List.of(
                    new Product("P00001", "ノートパソコン"),
                    new Product("P00002", "デスクトップパソコン")
            ), actual);
        }

        @Test
        @DisplayName("ヘッダーが仕様と異なるCSVを読み込むと、InvalidCsvException")
        void invalidHeader() {
            // テスト用CSVファイルのパスを取得
            Path csvFile = Path.of("src/test/resources/product_invalid_header.csv");

            // CSVをパースして例外がスローされることを検証
            assertThrows(InvalidCsvException.class, () -> {
                productCsvParser2.parse(csvFile);
            });
        }
    }
}

3の場合(うまく行かないケース)

jackson-databind-csv 3系にアップグレードするために、とりあえずパッケージ名のみ変更してテストします。

// パッケージ名を変えるだけ
import tools.jackson.core.JacksonException;
import tools.jackson.databind.MappingIterator;
import tools.jackson.dataformat.csv.CsvMapper;
import tools.jackson.dataformat.csv.CsvSchema;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;

// パッケージ名以外はほぼ同じ
public class ProductCsvParser3 {
    private final CsvMapper csvMapper;

    private final CsvSchema csvSchema;

    public ProductCsvParser3() {
        this.csvMapper = new CsvMapper();
        this.csvSchema = CsvSchema.emptySchema()
                .withHeader()
                .withColumnSeparator(',')
                .withLineSeparator("\n")
                .withoutQuoteChar();
    }

    public List<Product> parse(Path csvFile) {
        try (
                BufferedReader br = Files.newBufferedReader(csvFile, StandardCharsets.UTF_8);
                MappingIterator<Product> iterator = csvMapper
                        .readerFor(Product.class)
                        .with(csvSchema)
                        .readValues(br)
        ) {
            List<Product> productList = iterator.readAll();
            return productList;
        } catch (JacksonException e) {
            throw new InvalidCsvException("CSVのパースに失敗しました。", e);
        } catch (IOException e) {
            throw new UncheckedIOException("ファイルの読み込みに失敗しました。", e);
        }
    }
}

テストします。

// 2のテストとほぼ同じ
class ProductCsvParser3Test {
    ProductCsvParser3 productCsvParser3 = new ProductCsvParser3();

    @Nested
    @DisplayName("parse()")
    class Parse {
        @Test
        @DisplayName("仕様通りのCSVを読み込むと、製品のリストが返る")
        void success() {
            // テスト用CSVファイルのパスを取得
            Path csvFile = Path.of("src/test/resources/product.csv");

            // CSVをパース
            List<Product> actual = productCsvParser3.parse(csvFile);

            // 結果の検証
            assertEquals(List.of(
                    new Product("P00001", "ノートパソコン"),
                    new Product("P00002", "デスクトップパソコン")
            ), actual);
        }

        // このテストがNGになる
        @Test
        @DisplayName("ヘッダーが仕様と異なるCSVを読み込むと、InvalidCsvException")
        void invalidHeader() {
            // テスト用CSVファイルのパスを取得
            Path csvFile = Path.of("src/test/resources/product_invalid_header.csv");

            // CSVをパースして例外がスローされることを検証
            assertThrows(InvalidCsvException.class, () -> {
                productCsvParser3.parse(csvFile);
            });
        }
    }
}

すると、2つ目のテストで例外が全くスローされずNGとなります。

テスト実行結果
org.opentest4j.AssertionFailedError:
 Expected com.example.InvalidCsvException to be thrown,
 but nothing was thrown.

3の場合(修正版)

ProductCsvParser3.java(修正版、一部)
    public ProductCsvParser3() {
        this.csvMapper = CsvMapper.builder()
                // これを追加!!!
                .enable(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES)
                .build();
        // あとは同じ
    }

これで想定通り動きました。

Discussion