KotlinでCSVをパースする方法と課題の解決策

に公開

自分の技術メモをAIで記事化しているので、一部間違いがある可能性があります。正確な情報を保証するわけではないことをご了承ください。

📚 CSVパースの問題点

CSV文字列をパースし、data classにマッピングする際に、以下のような課題が発生します。

  • レコードのvalidationエラーをうまく返却するのが難しい
  • レコードを跨いだvalidationをハンドリングすると複雑になる
  • ヘッダーのvalidationとレコードのvalidationとレコードまたぎのvalidationが、ごちゃごちゃになりメンテナンスが困難になる

これらの課題を解消するために、次のようなアプローチを提案します。

🛠️ 解決方法

Before: 旧アプローチ

CSV文字列を直接data classのリストに変換していました。

フロー:

  • CSV文字列 → レコードリストのdata class

After: 新アプローチ

CSV文字列を段階的に処理し、各段階でvalidationを行うようにします。

フロー:

  1. CSV文字列 → 文字列のMap
  2. Map → レコードのdata class(レコード自体のvalidationをする)
  3. レコード → レコードリストのdata class(レコード全体のvalidationをする)

🖥️ サンプルコード

以下に、提案する新しいアプローチを実現するKotlinコードを示します。

package com.example.playlist

import com.github.doyaaaaaken.kotlincsv.dsl.csvReader

fun createPlaylistCSV(csv: String): Result<PlaylistCSV> {
    // read raw CSV
    val rawCSV: List<Map<String, String>> = csvReader().readAllWithHeader(csv)
    if (rawCSV.isEmpty()) {
        return Result.failure(IllegalArgumentException("CSV must contain at least one data row"))
    }

    // Validate that required headers are present (case‑insensitive)
    val headerKeys = rawCSV[0].keys
    val playlistHeader = headerKeys.firstOrNull { it.equals("playlist", ignoreCase = true) }
        ?: return Result.failure(IllegalArgumentException("CSV must have header 'playlist'"))
    val songHeader = headerKeys.firstOrNull { it.equals("song", ignoreCase = true) }
        ?: return Result.failure(IllegalArgumentException("CSV must have header 'song'"))

    val records = mutableListOf<PlaylistCSVRecord>()
    for (row in rawCSV) {
        // Retrieve values using the resolved header names
        val playlistRaw = row[playlistHeader]
            ?: return Result.failure(IllegalArgumentException("Missing value for playlist in a CSV row"))
        val songRaw = row[songHeader]
            ?: return Result.failure(IllegalArgumentException("Missing value for song in a CSV row"))

        // create validated record
        val playlist = PlaylistCSVRecord.Playlist.validateAndCreate(playlistRaw).getOrElse {
            return Result.failure(it)
        }
        val song = PlaylistCSVRecord.Song.validateAndCreate(songRaw).getOrElse {
            return Result.failure(it)
        }
        records.add(PlaylistCSVRecord(playlist, song))
    }

    // create validated records
    return PlaylistCSV.validateAndCreate(records)
}

data class PlaylistCSVRecord(
    val playlist: Playlist,
    val song: Song,
) {
    @JvmInline
    value class Playlist(val value: String) {
        companion object {
            fun validateAndCreate(value: String): Result<Playlist> {
                return if (value.isNotBlank()) {
                    Result.success(Playlist(value))
                } else {
                    Result.failure(IllegalArgumentException("Playlist name must not be blank"))
                }
            }
        }
    }

    @JvmInline
    value class Song(val value: String) {
        companion object {
            fun validateAndCreate(value: String): Result<Song> {
                return if (value.isNotBlank()) {
                    Result.success(Song(value))
                } else {
                    Result.failure(IllegalArgumentException("Song name must not be blank"))
                }
            }
        }
    }
}

data class PlaylistCSV(val records: List<PlaylistCSVRecord>) {
    companion object {
        fun validateAndCreate(records: List<PlaylistCSVRecord>): Result<PlaylistCSV> {
            return if (records.size <= 10) {
                Result.success(PlaylistCSV(records))
            } else {
                Result.failure(IllegalArgumentException("Record count must be 10 or fewer"))
            }
        }
    }
}

🧪 テストコードで検証

以下のテストコードは、実装が正しく動作することを確認するためのものです。

package com.example.playlist

import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test

class PlaylistCSVTest {

    @Test
    fun `should fail playlist validation when value is blank`() {
        val result = PlaylistCSVRecord.Playlist.validateAndCreate("")
        assertTrue(result.isFailure)
        val exception = result.exceptionOrNull()
        assertTrue(exception is IllegalArgumentException)
        assertEquals("Playlist name must not be blank", exception?.message)
    }

    @Test
    fun `should succeed playlist validation when value is non-blank`() {
        val result = PlaylistCSVRecord.Playlist.validateAndCreate("My Playlist")
        assertTrue(result.isSuccess)
        val playlist = result.getOrThrow()
        assertEquals("My Playlist", playlist.value)
    }

    @Test
    fun `should fail song validation when value is blank`() {
        val result = PlaylistCSVRecord.Song.validateAndCreate("   ")
        assertTrue(result.isFailure)
        val exception = result.exceptionOrNull()
        assertTrue(exception is IllegalArgumentException)
        assertEquals("Song name must not be blank", exception?.message)
    }

    @Test
    fun `should succeed song validation when value is non-blank`() {
        val result = PlaylistCSVRecord.Song.validateAndCreate("Song Title")
        assertTrue(result.isSuccess)
        val song = result.getOrThrow()
        assertEquals("Song Title", song.value)
    }

    @Test
    fun `should create PlaylistCSV when record count is at most 10`() {
        // 有効な PlaylistCSVRecord を 10 個生成
        val validPlaylist = PlaylistCSVRecord.Playlist.validateAndCreate("P").getOrThrow()
        val validSong = PlaylistCSVRecord.Song.validateAndCreate("S").getOrThrow()
        val records = List(10) { PlaylistCSVRecord(validPlaylist, validSong) }

        val result = PlaylistCSV.validateAndCreate(records)
        assertTrue(result.isSuccess)
        val csv = result.getOrThrow()
        assertEquals(10, csv.records.size)
    }

    @Test
    fun `should fail to create PlaylistCSV when record count is more than 10`() {
        val validPlaylist = PlaylistCSVRecord.Playlist.validateAndCreate("P").getOrThrow()
        val validSong = PlaylistCSVRecord.Song.validateAndCreate("S").getOrThrow()
        val records = List(11) { PlaylistCSVRecord(validPlaylist, validSong) }

        val result = PlaylistCSV.validateAndCreate(records)
        assertTrue(result.isFailure)
        val exception = result.exceptionOrNull()
        assertTrue(exception is IllegalArgumentException)
        assertEquals("Record count must be 10 or fewer", exception?.message)
    }

    @Nested
    inner class PlaylistCSVCreateTest {

        @Test
        fun `should succeed createPlaylistCSV with valid csv`() {
            val csv = """
            playlist,song
            Rock Hits,Song A
            Jazz List,Song B
        """.trimIndent()

            val result = createPlaylistCSV(csv)

            assertTrue(result.isSuccess)
            val csvObj = result.getOrThrow()
            assertEquals(2, csvObj.records.size)
            assertEquals("Rock Hits", csvObj.records[0].playlist.value)
            assertEquals("Song A", csvObj.records[0].song.value)
        }

        @Test
        fun `should fail when playlist header missing`() {
            val csv = """
            lists,song
            foo,bar
        """.trimIndent()

            val result = createPlaylistCSV(csv)

            assertTrue(result.isFailure)
        }

        @Test
        fun `should fail when song header missing`() {
            val csv = """
            playlist,track
            foo,bar
        """.trimIndent()

            val result = createPlaylistCSV(csv)

            assertTrue(result.isFailure)
        }

        @Test
        fun `should fail when blank playlist value`() {
            val csv = """
            playlist,song
            ,Song 1
        """.trimIndent()

            val result = createPlaylistCSV(csv)

            assertTrue(result.isFailure)
        }

        @Test
        fun `should fail when record count exceeds limit`() {
            val rows = (1..11).joinToString("\\n") { "PL$it,Song$it" }
            val csv = "playlist,song\\n$rows"

            val result = createPlaylistCSV(csv)

            assertTrue(result.isFailure)
        }
    }
}

✨ 結論

この新しいアプローチでは、段階的なvalidationを行うことで、CSVデータの信頼性とメンテナンス性を向上させることができます。Kotlinを活用し、シンプルかつ堅牢な実装を構築する際の参考にしてください。

Discussion