⛳
KotlinでCSVをパースする方法と課題の解決策
自分の技術メモをAIで記事化しているので、一部間違いがある可能性があります。正確な情報を保証するわけではないことをご了承ください。
📚 CSVパースの問題点
CSV文字列をパースし、data classにマッピングする際に、以下のような課題が発生します。
- レコードのvalidationエラーをうまく返却するのが難しい
- レコードを跨いだvalidationをハンドリングすると複雑になる
- ヘッダーのvalidationとレコードのvalidationとレコードまたぎのvalidationが、ごちゃごちゃになりメンテナンスが困難になる
これらの課題を解消するために、次のようなアプローチを提案します。
🛠️ 解決方法
Before: 旧アプローチ
CSV文字列を直接data classのリストに変換していました。
フロー:
- CSV文字列 → レコードリストのdata class
After: 新アプローチ
CSV文字列を段階的に処理し、各段階でvalidationを行うようにします。
フロー:
- CSV文字列 → 文字列のMap
- Map → レコードのdata class(レコード自体のvalidationをする)
- レコード → レコードリストの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