diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e01d93..d06aaac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 1.0 - 2025.06.07 + +- Update to Kotlin 2.2.0 +- Change: Final API refinement (incompatible, see readme and tests for usage examples) + ## 0.17 - 2025-05-04 ### Fixed - Issue #24 (Improve headers and rows detection) diff --git a/README.md b/README.md index d07224e..9aeb3ab 100644 --- a/README.md +++ b/README.md @@ -9,43 +9,59 @@ Small, fast and convenient multiplatform CSV parser and builder written for one # Architecture - + # Usage Here is what you can do with the library: ```kotlin -// (1) Build a CSV file using simple DSL +// (1) build csv val csv = buildCsv { - row { - value("Code") - value("Name") + header { + column("Code") + column("Name") } - row { + data { value("DE") - value("Germany") + value("Deutschland") } - row { + data { value("BY") value("Belarus") } -} +} as CsvWithHeader + +val code = csv.header.columnByName("Code") as CsvColumn +val name = csv.header.columnByName("Name") as CsvColumn + +assertEquals(code, CsvColumn(0, "Code")) +assertEquals(name, CsvColumn(1, "Name")) +assertEquals(csv.data[0][code], "DE") +assertEquals(csv.data[0][name], "Deutschland") +assertEquals(csv.data[1][code], "BY") +assertEquals(csv.data[1][name], "Belarus") -// (2) Export a CSV object to a CSV string +// (2) csv to text val csvText = csv.toCsvText() +assertEquals("Code,Name\nDE,Deutschland\nBY,Belarus\n", csvText) -// (3) Parse a CSV string to get a CSV object -val csv2 = Csv.parseCsvText(csvText) +// (3) parse csv text +val csv2 = CsvWithHeader.parseCsvText(csvText) as CsvWithHeader -// There are the data structures supported by the library -val allRows: List = csv2.rows -val header: HeaderRow = csv2.header -val data: List = csv2.data +assertEquals(csv.header, csv2.header) +assertEquals(csv.data, csv2.data) +assertEquals(csv.allRows, csv2.allRows) -// (4) Transform CSV data -val codes = data.map { it.value("Code") } // ["DE", "BY"] -val names = data.map { it.value("Name") } // ["Germany", "Belarus"] +// (4) transform csv +val csv3 = csv.copy( + data = csv.data.map { row -> + row.mapValueOf(name) { value -> + if (value == "Belarus") "Weißrussland" else value + } + } +) +assertEquals("Code,Name\nDE,Deutschland\nBY,Weißrussland\n", csv3.toCsvText()) ``` # Dependencies @@ -53,8 +69,8 @@ val names = data.map { it.value("Name") } // ["Germany", "Belarus"] In `gradle/libs.versions.toml` ```toml [versions] -kotlin = "2.1.20" -csv = "0.17" +kotlin = "2.2.0" +csv = "1.0" [libraries] csv = { module = "de.halfbit:csv", version.ref = "csv" } diff --git a/build.gradle.kts b/build.gradle.kts index c1d7eec..efbf122 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,7 +8,7 @@ plugins { } group = "de.halfbit" -version = "0.17" +version = "1.0" repositories { mavenCentral() @@ -67,15 +67,7 @@ if (canPublishToMaven) { } maven { name = "central" - url = uri("https://oss.sonatype.org/service/local/staging/deploy/maven2/") - credentials { - username = project.getPropertyOrEmptyString("publishing.nexus.user") - password = project.getPropertyOrEmptyString("publishing.nexus.password") - } - } - maven { - name = "snapshot" - url = uri("https://oss.sonatype.org/content/repositories/snapshots") + url = uri("https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/") credentials { username = project.getPropertyOrEmptyString("publishing.nexus.user") password = project.getPropertyOrEmptyString("publishing.nexus.password") diff --git a/documentation/architecture.v3.iuml b/documentation/architecture.v3.iuml new file mode 100644 index 0000000..64b1107 --- /dev/null +++ b/documentation/architecture.v3.iuml @@ -0,0 +1,31 @@ +@startuml +skinparam packageStyle rectangle +hide empty members +hide stereotype +hide circle + +class Csv { + allRows: List + --- + toCsvText(): String +} + +class CsvNoHeader { + data: List + -- + fromCsvText(): CsvNoHeader + fromLists(): CsvNoHeader +} + +class CsvWithHeader { + header: CsvHeaderRow + data: List + -- + fromCsvText(): CsvWithHeader? + fromLists(): CsvWithHeader? +} + +CsvWithHeader -up-|> Csv +CsvNoHeader -up-|> Csv + +@enduml \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index caf2236..40602e4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -kotlin = "2.1.20" +kotlin = "2.2.0" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 29d2731..40957b2 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists \ No newline at end of file diff --git a/kotlin-js-store/yarn.lock b/kotlin-js-store/yarn.lock index b6f76bb..2af455a 100644 --- a/kotlin-js-store/yarn.lock +++ b/kotlin-js-store/yarn.lock @@ -1871,11 +1871,6 @@ type-is@~1.6.18: media-typer "0.3.0" mime-types "~2.1.24" -typescript@5.5.4: - version "5.5.4" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba" - integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q== - ua-parser-js@^0.7.30: version "0.7.40" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.40.tgz#c87d83b7bb25822ecfa6397a0da5903934ea1562" diff --git a/src/commonMain/kotlin/Csv.kt b/src/commonMain/kotlin/Csv.kt index 8c57d13..10595d9 100644 --- a/src/commonMain/kotlin/Csv.kt +++ b/src/commonMain/kotlin/Csv.kt @@ -1,18 +1,23 @@ -/** Copyright 2023 Halfbit GmbH, Sergej Shafarenka */ +/** Copyright 2023-2025 Halfbit GmbH, Sergej Shafarenka */ package de.halfbit.csv -import de.halfbit.csv.BaseCsv.Row - /** - * Object with comma-separated values stored as list of [Row]'s. Each row is a list - * of strings. Empty values are empty strings. + * Base class for CSV data, containing all rows of the CSV file. * - * If your CSV-data has header row, use [BaseCsv] instead of this base type. + * @property allRows all rows in the CSV file, including header if present */ -public interface BaseCsv { - public val allRows: List +public abstract class Csv( + public val allRows: List, +) { + public abstract val data: List - // Multiline issue: https://stackoverflow.com/questions/2668678/importing-csv-with-line-breaks-in-excel-2007 + /** + * Converts the CSV data to a CSV-formatted string. + * + * @param newLine the line separator to use + * @param escapeWhitespaces whether to escape whitespaces in values + * @return the CSV-formatted string representation + */ public fun toCsvText( newLine: NewLine = NewLine.LF, escapeWhitespaces: Boolean = false, @@ -28,61 +33,28 @@ public interface BaseCsv { append(newLine.value) } } - - public interface Row : List { - public fun replaceValue(valueIndex: Int, newValue: String): Row - } } -/** - * CSV-object by with a mandatory header row. It has more convenient methods - * for working with columns by their names. - */ -public interface Csv : BaseCsv { - public val header: HeaderRow - public val data: List - - public interface HeaderRow : Row { - public fun indexOfColumn(name: String): Int - } - - public interface DataRow : Row { - public fun value(columnName: String): String - public fun replaceValue(columnName: String, newValue: String): DataRow - } - - public companion object { - /** Use it for parsing a cvs-formatted text. */ - public fun parserCsvText(csvText: String): Csv = parseCsv(csvText) +/** Represents a single row in a CSV file as a list of string values. */ +public typealias CsvRow = List - @Deprecated( - message = "Replaced with the more consistently named 'parserCsvText()' method." + - " This method will be removed in 0.18, please migrate.", - replaceWith = ReplaceWith("parserCsvText(csvText)"), - ) - public fun parserText(csvText: String): Csv = parseCsv(csvText) - - public fun fromLists(allRows: List>): BaseCsv { - return BaseCsv(allRows.map { DefaultRow(it) }) - } - - public fun fromLists(header: List, data: List>): Csv { - val headerRow = DefaultHeaderRow(header) - return Csv( - header = headerRow, - data = data.map { DefaultDataRow(it, headerRow) }, - ) - } - } -} +/** Represents a data row in a CSV file, excluding the header row. */ +public typealias CsvDataRow = List +/** + * Specifies the line separator used in CSV output. + * + * @property value the string value of the line separator + */ public enum class NewLine( public val value: String, ) { /** Line feed as the line terminator */ LF("\n"), + /** Carriage Return + Line feed as the line terminator */ CRLF("\r\n"), + /** Carriage Return as the line terminator */ CR("\r") } diff --git a/src/commonMain/kotlin/CsvNoHeader.kt b/src/commonMain/kotlin/CsvNoHeader.kt new file mode 100644 index 0000000..1a61245 --- /dev/null +++ b/src/commonMain/kotlin/CsvNoHeader.kt @@ -0,0 +1,44 @@ +/** Copyright 2023-2025 Halfbit GmbH, Sergej Shafarenka */ +package de.halfbit.csv + +/** + * Represents CSV data without a header row. + * + * @property data the data rows + */ +public data class CsvNoHeader( + public override val data: List, +) : Csv(data) { + + override fun toString(): String = + buildString { + data.forEach { data -> + append(data) + append("\n") + } + } + + public companion object { + + /** + * Parses CSV text into a [CsvNoHeader] instance. + * + * @param csvText the CSV-formatted string + * @return a [CsvNoHeader] instance + */ + public fun fromCsvText(csvText: String): CsvNoHeader { + val (_, data) = parseCsv(csvText, false) + return CsvNoHeader(data) + } + + /** + * Creates a [CsvNoHeader] from a list of rows. + * + * @param allRows all data rows + * @return a [CsvNoHeader] instance + */ + public fun fromLists(allRows: List>): CsvNoHeader { + return CsvNoHeader(allRows) + } + } +} diff --git a/src/commonMain/kotlin/CsvWithHeader.kt b/src/commonMain/kotlin/CsvWithHeader.kt new file mode 100644 index 0000000..b5cbeea --- /dev/null +++ b/src/commonMain/kotlin/CsvWithHeader.kt @@ -0,0 +1,171 @@ +/** Copyright 2023-2025 Halfbit GmbH, Sergej Shafarenka */ +package de.halfbit.csv + +/** + * Represents CSV data with a header row. + * + * @property header the header row of the CSV + * @property data the data rows + */ +public data class CsvWithHeader( + public val header: CsvHeaderRow, + public override val data: List, +) : Csv(listOf(header.map { it.name }) + data) { + + override fun toString(): String = + buildString { + append(header) + append("\n") + data.forEach { data -> + append(data) + append("\n") + } + } + + public companion object { + + /** + * Parses CSV text into a [CsvWithHeader] instance. + * + * @param csvText the CSV-formatted string + * @return a [CsvWithHeader] if parsing succeeds and the header row is present, or `null` otherwise. + */ + public fun fromCsvText(csvText: String): CsvWithHeader? { + val (header, data) = parseCsv(csvText, true) + return if (header == null) null else { + CsvWithHeader(header, data) + } + } + + /** + * Creates a [CsvWithHeader] from a list of rows, where the first row is the header. + * + * @param allRows all rows, with the first row as header + * @return a [CsvWithHeader] if header is present, or `null` otherwise. + */ + public fun fromLists(allRows: List>): CsvWithHeader? { + val headerRowNames = allRows.getOrNull(0) + return if (headerRowNames == null) null else { + val headerRow = headerRowNames.toCsvHeaderRow() + CsvWithHeader(headerRow, allRows.subList(1, allRows.size)) + } + } + + /** + * Creates a [CsvWithHeader] from a list of header and data rows. + * + * @param header the header rows + * @param header data rows + * @return a [CsvWithHeader] if header row is not empty, or `null` otherwise. + */ + public fun fromLists(header: List, data: List>): CsvWithHeader? { + return if (header.isEmpty()) null else { + val headerRow = header.toCsvHeaderRow() + CsvWithHeader(headerRow, data) + } + } + } +} + +/** + * Represents the header row of a CSV file, containing column definitions. + * + * @constructor Creates a header row from a list of [CsvColumn]s. + * @property columns list of columns in the header row + */ +public data class CsvHeaderRow( + @PublishedApi internal val columns: List, +) : List by columns { + private val columnsByName = columns.associateBy { it.name } + + /** Returns the [CsvColumn] with the given [name], or `null` if not found. */ + public fun columnByName(name: String): CsvColumn? = columnsByName[name] + + /** Returns the [CsvColumn] at the given [index], or `null` if out of bounds. */ + public fun columnByIndex(index: Int): CsvColumn? = columns.getOrNull(index) +} + +/** + * Represents a column in a [CsvHeaderRow] of a CSV file. + * + * @property index Zero-based index of the column inside its [CsvHeaderRow]. + * @property name Name (label) of the column as defined in [CsvHeaderRow]. + */ +public data class CsvColumn( + + /** Zero-based index of the column inside its [CsvHeaderRow]. */ + public val index: Int, + + /** Name (label) of the column as it is defined in [CsvHeaderRow]. */ + public val name: String, +) + +/** + * Returns the value from this row corresponding to the given [column]. + * + * @param column the column to retrieve the value for + * @return the value at the column's index + */ +public operator fun CsvDataRow.get(column: CsvColumn): String = + get(column.index) + +/** + * Returns the value from this row for the given [column], or `null` if + * the index is out of bounds. + * + * @param column the column to retrieve the value for + * @return the value at the column's index, or `null` if not present + */ +public fun CsvDataRow.getOrNull(column: CsvColumn): String? = + getOrNull(column.index) + +/** + * Returns the value from this row for the given [column], or an + * empty string if not present. + * + * @param column the column to retrieve the value for + * @return the value at the column's index, or an empty string if not present + */ +public fun CsvDataRow.getOrEmpty(column: CsvColumn): String? = + getOrNull(column.index) ?: "" + +/** + * Returns the value from this row for the given [column], or computes a + * default value if not present. + * + * @param column the column to retrieve the value for + * @param defaultValue function to compute a default value if the column is + * missing from the dataset + * @return the value at the column's index, or the result of [defaultValue] + */ +public inline fun CsvDataRow.getOrElse(column: CsvColumn, defaultValue: (CsvColumn) -> String): String = + getOrElse(column.index) { defaultValue(column) } + +/** + * Applies [transform] to the value at the given [column], returning + * a new list with the transformed value. + * + * @param column the column whose value will be transformed + * @param transform function to apply to the value + * @return a new list with the transformed value at the specified column + * + * Usage example: replace value "Belarus" with "Weißrussland" in the "Name" column + * ``` + * val name = csv.header.columnByName("Name") as CsvColumn + * val transformedCsv = csv.copy( + * data = csv.data.map { row -> + * row.mapValueOf(name) { value -> + * if (value == "Belarus") "Weißrussland" else value + * } + * } + * ) + * ``` + */ +public inline fun CsvDataRow.mapValueOf(column: CsvColumn, transform: (String) -> String): List = + mapIndexed { index, value -> + if (index == column.index) { + transform(value) + } else { + value + } + } diff --git a/src/commonMain/kotlin/buildCsv.kt b/src/commonMain/kotlin/buildCsv.kt index 6fd603e..695a3bf 100644 --- a/src/commonMain/kotlin/buildCsv.kt +++ b/src/commonMain/kotlin/buildCsv.kt @@ -1,134 +1,64 @@ -/** Copyright 2023 Halfbit GmbH, Sergej Shafarenka */ +/** Copyright 2023-2025 Halfbit GmbH, Sergej Shafarenka */ package de.halfbit.csv -import de.halfbit.csv.BaseCsv.Row -import de.halfbit.csv.Csv.DataRow -import de.halfbit.csv.Csv.HeaderRow -import kotlin.math.max - @DslMarker public annotation class CsvDsl -public fun buildCsv( +public inline fun buildCsv( block: CsvBuilder.() -> Unit ): Csv { - val header = mutableListOf() - val data = mutableListOf() - block(CsvBuilder(header, data)) - val headerRow = header.getOrNull(0) ?: DefaultHeaderRow(emptyList()) - return Csv(headerRow, data) + val headerRows = mutableListOf() + val dataRows = mutableListOf() + block(CsvBuilder(headerRows, dataRows)) + val headerRow = headerRows.getOrNull(0) + return if (headerRow == null) { + CsvNoHeader(dataRows) + } else { + CsvWithHeader(headerRow, dataRows) + } } @CsvDsl -public class CsvBuilder internal constructor( - private val header: MutableList, - private val data: MutableList, +public class CsvBuilder @PublishedApi internal constructor( + @PublishedApi internal val headerRows: MutableList, + @PublishedApi internal val dataRows: MutableList, ) { - public fun row(block: CsvRowBuilder.() -> Unit) { + public inline fun header(block: CsvDataHeaderBuilder.() -> Unit) { val row = mutableListOf() - val scope = CsvRowBuilder(row) - block(scope) - if (header.isEmpty()) { - header += DefaultHeaderRow(row) - } else { - data += DefaultDataRow(row, header[0]) - } + block(CsvDataHeaderBuilder(row)) + headerRows.clear() + headerRows += row.toCsvHeaderRow() + } + + public inline fun data(block: CsvDataRowBuilder.() -> Unit) { + val row = mutableListOf() + block(CsvDataRowBuilder(row)) + dataRows += row } } @CsvDsl -public class CsvRowBuilder internal constructor( +public class CsvDataHeaderBuilder @PublishedApi internal constructor( private val row: MutableList, ) { - public fun value(value: String) { + public fun column(value: String) { row.add(value) } } -// implementations - -public fun BaseCsv(allRows: List): BaseCsv = object : BaseCsv { - override val allRows: List get() = allRows - override fun toString(): String = - buildString { - allRows.forEach { data -> - append(data) - append("\n") - } - } -} - -public fun Csv( - header: HeaderRow, - data: List, -): Csv = object : Csv { - override val header: HeaderRow get() = header - override val data: List get() = data - override val allRows: List by lazy { - if (header.isEmpty()) data else listOf(header) + data - } - - override fun toString(): String = - buildString { - append(header) - append("\n") - data.forEach { data -> - append(data) - append("\n") - } - } -} - -internal open class DefaultRow(protected val row: List) : Row, List by row { - - override fun replaceValue(valueIndex: Int, newValue: String): Row { - val thisRow = this - val newRow = buildList { - for (index in 0..max(thisRow.size, valueIndex)) { - if (index == valueIndex) { - add(newValue) - } else { - add(thisRow.getOrNull(index) ?: "") - } - } - } - return DefaultRow(newRow) +@CsvDsl +public class CsvDataRowBuilder @PublishedApi internal constructor( + private val row: MutableList, +) { + public fun value(value: String) { + row.add(value) } - - override fun toString(): String = row.toString() } -internal class DefaultHeaderRow(row: List) : HeaderRow, DefaultRow(row) { - private val indexOfColumn: MutableMap by lazy { mutableMapOf() } - - override fun indexOfColumn(name: String): Int = - indexOfColumn[name] ?: run { - val index = indexOf(name) - indexOfColumn[name] = index - index - } -} - -internal class DefaultDataRow( - row: List, - private val header: HeaderRow -) : DataRow, DefaultRow(row) { - - override fun value(columnName: String): String = - row.getOrNull(header.indexOfColumn(columnName)) ?: "" - - override fun replaceValue(columnName: String, newValue: String): DataRow { - val replaceIndex = header.indexOfColumn(columnName) - if (replaceIndex < 0) return this - val newRow = buildList { - for (index in 0..max(row.lastIndex, replaceIndex)) { - if (index == replaceIndex) { - add(newValue) - } else { - add(row.getOrNull(index) ?: "") - } - } +@PublishedApi +internal fun List.toCsvHeaderRow(): CsvHeaderRow = + CsvHeaderRow( + mapIndexed { index, name -> + CsvColumn(index, name) } - return DefaultDataRow(newRow, header) - } -} + ) diff --git a/src/commonMain/kotlin/parseCsv.kt b/src/commonMain/kotlin/parseCsv.kt index c01a38d..5cb5040 100644 --- a/src/commonMain/kotlin/parseCsv.kt +++ b/src/commonMain/kotlin/parseCsv.kt @@ -1,11 +1,12 @@ -/** Copyright 2023 Halfbit GmbH, Sergej Shafarenka */ +/** Copyright 2023-2025 Halfbit GmbH, Sergej Shafarenka */ package de.halfbit.csv -import de.halfbit.csv.Csv.DataRow -import de.halfbit.csv.Csv.HeaderRow import de.halfbit.csv.Lexer.* -internal fun parseCsv(csvText: String): Csv { +internal fun parseCsv( + csvText: String, + withHeaderRow: Boolean = true, +): Pair> { var pos = 0 var lexer: Lexer = BeforeValue @@ -16,8 +17,8 @@ internal fun parseCsv(csvText: String): Csv { val value = StringBuilder() val row = mutableListOf() - val header = mutableListOf() - val data = mutableListOf() + var header: CsvHeaderRow? = null + val data = mutableListOf() fun completeValue() { row.add(value.toString()) @@ -25,10 +26,10 @@ internal fun parseCsv(csvText: String): Csv { } fun completeRow() { - if (header.isEmpty()) { - header += DefaultHeaderRow(row.toList()) + if (withHeaderRow && header == null) { + header = row.toCsvHeaderRow() } else { - data += DefaultDataRow(row.toList(), header[0]) + data += row.toList() } row.clear() } @@ -150,8 +151,7 @@ internal fun parseCsv(csvText: String): Csv { completeRow() } - val headerRow = header.getOrNull(0) ?: DefaultHeaderRow(emptyList()) - return Csv(headerRow, data) + return header to data } private enum class Lexer { diff --git a/src/commonTest/kotlin/BuildCsvTest.kt b/src/commonTest/kotlin/BuildCsvTest.kt index 677e2fe..fa5c776 100644 --- a/src/commonTest/kotlin/BuildCsvTest.kt +++ b/src/commonTest/kotlin/BuildCsvTest.kt @@ -1,7 +1,9 @@ +/** Copyright 2023-2025 Halfbit GmbH, Sergej Shafarenka */ package de.halfbit.csv import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull class BuildCsvTest { @@ -9,77 +11,80 @@ class BuildCsvTest { fun buildCsv() { val csv = buildCsv { - row { - value("NAME") - value("CODE") - value("DESCRIPTION") + header { + column("NAME") + column("CODE") + column("DESCRIPTION") } - row { + data { value("BEVERLY HILLS BLACK") value("BVH.1-NAR.S8") } - row { + data { value("BOAVISTA BLACK GRADIENT") value("BVS.1-NAR.S5") value("") } - row { + data { value("GXP EYEWEAR PACKAGING") value("PKG.2") value("PU pouch for eyewear in black cardboard box") } - } + } as CsvWithHeader val header = csv.header - checkNotNull(header) - assertEquals(header.indexOf("NAME"), 0) - assertEquals(header.indexOf("CODE"), 1) - assertEquals(header.indexOf("DESCRIPTION"), 2) + val name = header.columnByName("NAME") as CsvColumn + assertEquals(CsvColumn(0, "NAME"), name) + + val code = header.columnByName("CODE") as CsvColumn + assertEquals(CsvColumn(1, "CODE"), code) + + val description = header.columnByName("DESCRIPTION") as CsvColumn + assertEquals(CsvColumn(2, "DESCRIPTION"), description) assertEquals(csv.data.size, 3) - assertEquals("BEVERLY HILLS BLACK", csv.data[0].value("NAME")) - assertEquals("BVH.1-NAR.S8", csv.data[0].value("CODE")) - assertEquals("", csv.data[0].value("DESCRIPTION")) - assertEquals("BOAVISTA BLACK GRADIENT", csv.data[1].value("NAME")) - assertEquals("BVS.1-NAR.S5", csv.data[1].value("CODE")) - assertEquals("", csv.data[1].value("DESCRIPTION")) + assertEquals("BEVERLY HILLS BLACK", csv.data[0].getOrEmpty(name)) + assertEquals("BVH.1-NAR.S8", csv.data[0].getOrEmpty(code)) + assertNull(csv.data[0].getOrNull(description)) - assertEquals("GXP EYEWEAR PACKAGING", csv.data[2].value("NAME")) - assertEquals("PKG.2", csv.data[2].value("CODE")) - assertEquals("PU pouch for eyewear in black cardboard box", csv.data[2].value("DESCRIPTION")) + assertEquals("BOAVISTA BLACK GRADIENT", csv.data[1].getOrEmpty(name)) + assertEquals("BVS.1-NAR.S5", csv.data[1].getOrEmpty(code)) + assertEquals("", csv.data[1].get(description)) + + assertEquals("GXP EYEWEAR PACKAGING", csv.data[2].getOrEmpty(name)) + assertEquals("PKG.2", csv.data[2].getOrEmpty(code)) + assertEquals("PU pouch for eyewear in black cardboard box", csv.data[2].get(description)) } @Test fun mapCsvNotNull() { val csv = buildCsv { - row { - value("NAME") - value("CODE") - value("DESCRIPTION") + header { + column("NAME") + column("CODE") + column("DESCRIPTION") } - row { + data { value("BEVERLY HILLS BLACK") value("BVH.1-NAR.S8") } - row { + data { value("BOAVISTA BLACK GRADIENT") value("BVS.1-NAR.S5") value("") } - row { + data { value("GXP EYEWEAR PACKAGING") value("PKG.2") value("PU pouch for eyewear in black cardboard box") } - } - - val descriptions = csv.data.map { - it.value("DESCRIPTION") - } + } as CsvWithHeader - val expectedDescriptions = listOf("", "", "PU pouch for eyewear in black cardboard box") + val description = csv.header.columnByName("DESCRIPTION") as CsvColumn + val descriptions = csv.data.map { it.getOrNull(description) } + val expectedDescriptions = listOf(null, "", "PU pouch for eyewear in black cardboard box") assertEquals(expectedDescriptions, descriptions) } } \ No newline at end of file diff --git a/src/commonTest/kotlin/CsvTest.kt b/src/commonTest/kotlin/CsvTest.kt index d8ca318..696e772 100644 --- a/src/commonTest/kotlin/CsvTest.kt +++ b/src/commonTest/kotlin/CsvTest.kt @@ -1,4 +1,4 @@ -/** Copyright 2023 Halfbit GmbH, Sergej Shafarenka */ +/** Copyright 2023-2025 Halfbit GmbH, Sergej Shafarenka */ package de.halfbit.csv import kotlin.test.Test @@ -21,7 +21,7 @@ class CsvTest { Western Sahara,EH """.trimIndent() - val csv = parseCsv(givenCsvString) + val csv = CsvNoHeader.fromCsvText(givenCsvString) val csvString = csv.toCsvText().trim() assertEquals(givenCsvString, csvString) @@ -35,7 +35,7 @@ class CsvTest { Western Sahara,EH """.trimIndent() - val csv = parseCsv(givenCsvString) + val csv = CsvNoHeader.fromCsvText(givenCsvString) val csvString = csv.toCsvText().trim() assertEquals(givenCsvString, csvString) @@ -49,7 +49,7 @@ class CsvTest { VE,"Venezuela, Bolivarian Republic of" """.trimIndent() - val csv = parseCsv(givenCsvString) + val csv = CsvNoHeader.fromCsvText(givenCsvString) val csvString = csv.toCsvText().trim() assertEquals(givenCsvString, csvString) @@ -66,7 +66,7 @@ class CsvTest { """.trimIndent() .replace("\n", "\r\n") - val csv = parseCsv(givenCsvString) + val csv = CsvWithHeader.fromCsvText(givenCsvString) as CsvWithHeader val header = csv.allRows[0] println(csv.toCsvText()) @@ -90,7 +90,7 @@ class CsvTest { "Venezuela, Bolivarian Republic of",VE """.trimIndent() - val csv = parseCsv(givenCsvString) + val csv = CsvNoHeader.fromCsvText(givenCsvString) val csvString = csv.toCsvText().trim() assertEquals(givenCsvString, csvString) @@ -352,7 +352,7 @@ class CsvTest { ZW,Zimbabwe """.trimIndent() - val csv = parseCsv(givenCsvString) + val csv = CsvWithHeader.fromCsvText(givenCsvString) as CsvWithHeader val csvString = csv.toCsvText(escapeWhitespaces = true).trim() assertEquals(givenCsvString, csvString) diff --git a/src/commonTest/kotlin/Issue1Test.kt b/src/commonTest/kotlin/Issue1Test.kt index 4a561a8..97ec9cd 100644 --- a/src/commonTest/kotlin/Issue1Test.kt +++ b/src/commonTest/kotlin/Issue1Test.kt @@ -1,4 +1,4 @@ -/** Copyright 2023 Halfbit GmbH, Sergej Shafarenka */ +/** Copyright 2023-2025 Halfbit GmbH, Sergej Shafarenka */ package de.halfbit.csv import kotlin.test.Test @@ -17,7 +17,7 @@ class Issue1Test { 1999,Chevy,Venture, """.trimIndent() - val csv = parseCsv(givenCsvString) + val csv = CsvWithHeader.fromCsvText(givenCsvString) as CsvWithHeader assertEquals(3, csv.allRows.size) assertContentEquals(listOf("1999", "Chevy", "Venture", ""), csv.allRows[2]) } diff --git a/src/commonTest/kotlin/Issue24Test.kt b/src/commonTest/kotlin/Issue24Test.kt index af65a62..1d32bfa 100644 --- a/src/commonTest/kotlin/Issue24Test.kt +++ b/src/commonTest/kotlin/Issue24Test.kt @@ -1,7 +1,6 @@ -/** Copyright 2025 Halfbit GmbH, Sergej Shafarenka */ +/** Copyright 2023-2025 Halfbit GmbH, Sergej Shafarenka */ package de.halfbit.csv -import de.halfbit.csv.Csv.Companion.parserCsvText import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -12,7 +11,7 @@ class Issue24Test { fun rn_can_be_used_as_a_line_terminator() { val givenCsvString = "date,amount,user_id,id\r\n2025-04-14,250.00,2,2\r\n2025-04-15,100.50,1,1\r\n" - val csv = parserCsvText(givenCsvString) + val csv = CsvNoHeader.fromCsvText(givenCsvString) assertEquals(3, csv.allRows.size) assertContentEquals(listOf("date", "amount", "user_id", "id"), csv.allRows[0]) @@ -24,7 +23,7 @@ class Issue24Test { fun r_can_be_used_as_a_line_terminator() { val givenCsvString = "date,amount,user_id,id\r2025-04-14,250.00,2,2\r2025-04-15,100.50,1,1\r" - val csv = parserCsvText(givenCsvString) + val csv = CsvNoHeader.fromCsvText(givenCsvString) assertEquals(3, csv.allRows.size) assertContentEquals(listOf("date", "amount", "user_id", "id"), csv.allRows[0]) @@ -36,7 +35,7 @@ class Issue24Test { fun last_row_can_have_no_line_terminators() { val givenCsvString = "date,amount,user_id,id\r\n2025-04-14,250.00,2,2" - val csv = parserCsvText(givenCsvString) + val csv = CsvNoHeader.fromCsvText(givenCsvString) assertEquals(2, csv.allRows.size) assertContentEquals(listOf("date", "amount", "user_id", "id"), csv.allRows[0]) diff --git a/src/commonTest/kotlin/MapColumnTest.kt b/src/commonTest/kotlin/MapColumnTest.kt new file mode 100644 index 0000000..7f4347b --- /dev/null +++ b/src/commonTest/kotlin/MapColumnTest.kt @@ -0,0 +1,42 @@ +/** Copyright 2023-2025 Halfbit GmbH, Sergej Shafarenka */ +package de.halfbit.csv + +import kotlin.test.Test +import kotlin.test.assertEquals + +class MapColumnTest { + + @Test + fun mapValue() { + + val givenCsvString = + """ + NAME,CODE,PRICE,DESCRIPTION + BURGUNDY PINK,4PK.GD-S7,100,Pink burgundy + HILLS PINK,BVH.5-NAR.S8,120,Pink hills + PACKAGING,PKG.1,0.55,Eyewear packages. Models: all + DARK BLUE,EVG.6,101,"Matte stainless steel. + Line 2. + Line 3" + """.trimIndent() + + val csv = CsvWithHeader.fromCsvText(givenCsvString) as CsvWithHeader + val code = csv.header.columnByName("CODE") as CsvColumn + val description = csv.header.columnByName("DESCRIPTION") as CsvColumn + + val csv2 = csv.copy( + data = csv.data.map { row -> + if (!row[code].startsWith("PKG.")) { + row.mapValueOf(description) { "" } + } else { + row + } + } + ) + println(csv2.toCsvText()) + + assertEquals("", csv2.data[0][description.index]) + assertEquals("", csv2.data[1].getOrElse(description.index) { "" }) + assertEquals("Eyewear packages. Models: all", csv2.data[2][description.index]) + } +} diff --git a/src/commonTest/kotlin/ReadmeExampleTest.kt b/src/commonTest/kotlin/ReadmeExampleTest.kt new file mode 100644 index 0000000..f11e6a5 --- /dev/null +++ b/src/commonTest/kotlin/ReadmeExampleTest.kt @@ -0,0 +1,58 @@ +package de.halfbit.csv + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ReadmeExampleTest { + + @Test + fun buildCsvObject() { + + // (1) build csv + val csv = buildCsv { + header { + column("Code") + column("Name") + } + data { + value("DE") + value("Deutschland") + } + data { + value("BY") + value("Belarus") + } + } as CsvWithHeader + + val code = csv.header.columnByName("Code") as CsvColumn + val name = csv.header.columnByName("Name") as CsvColumn + + assertEquals(code, CsvColumn(0, "Code")) + assertEquals(name, CsvColumn(1, "Name")) + assertEquals(csv.data[0][code], "DE") + assertEquals(csv.data[0][name], "Deutschland") + assertEquals(csv.data[1][code], "BY") + assertEquals(csv.data[1][name], "Belarus") + + // (2) csv to text + val csvText = csv.toCsvText() + assertEquals("Code,Name\nDE,Deutschland\nBY,Belarus\n", csvText) + + // (3) parse csv text + val csv2 = CsvWithHeader.fromCsvText(csvText) as CsvWithHeader + + assertEquals(csv.header, csv2.header) + assertEquals(csv.data, csv2.data) + assertEquals(csv.allRows, csv2.allRows) + + // (4) transform csv + val csv3 = csv.copy( + data = csv.data.map { row -> + row.mapValueOf(name) { value -> + if (value == "Belarus") "Weißrussland" else value + } + } + ) + assertEquals("Code,Name\nDE,Deutschland\nBY,Weißrussland\n", csv3.toCsvText()) + } +} \ No newline at end of file diff --git a/src/commonTest/kotlin/ReplaceValueTest.kt b/src/commonTest/kotlin/ReplaceValueTest.kt deleted file mode 100644 index 5030ef7..0000000 --- a/src/commonTest/kotlin/ReplaceValueTest.kt +++ /dev/null @@ -1,45 +0,0 @@ -package de.halfbit.csv - -import de.halfbit.csv.Csv.Companion.parserCsvText -import kotlin.test.Test -import kotlin.test.assertEquals - -class ReplaceValueTest { - - @Test - fun replaceValue() { - - val givenCsvString = - """ - NAME,CODE,PRICE,DESCRIPTION - BURGUNDY PINK,4PK.GD-S7,100,Pink burgundy - HILLS PINK,BVH.5-NAR.S8,120Pink hills - PACKAGING,PKG.1,0.55,Eyewear packages. Models: all - DARK BLUE,EVG.6,101,"Matte stainless steel. - Line 2. - Line 3" - """.trimIndent() - - val csv = parserCsvText(givenCsvString) - val data = csv.data - .map { - val code = it.code - if (!code.startsWith("PKG.")) { - it.replaceValue("DESCRIPTION", "") - } else it - } - - val csv2 = Csv(csv.header, data) - println(csv2.toCsvText()) - - assertEquals("", csv2.data[0].description) - assertEquals("", csv2.data[1].description) - assertEquals("Eyewear packages. Models: all", csv2.data[2].description) - } -} - -private val Csv.DataRow.code: String - get() = value("CODE") - -private val Csv.DataRow.description: String - get() = value("DESCRIPTION") \ No newline at end of file