From d5930ec0ae61a533400dd97a5f25c2fd448231f6 Mon Sep 17 00:00:00 2001 From: Tokuhiro Matsuno Date: Wed, 27 Apr 2022 19:46:43 +0900 Subject: [PATCH 1/4] Fix broken link in JsonIO.kt (#146) --- src/main/kotlin/krangl/JsonIO.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/krangl/JsonIO.kt b/src/main/kotlin/krangl/JsonIO.kt index e53ddc10..242751a5 100644 --- a/src/main/kotlin/krangl/JsonIO.kt +++ b/src/main/kotlin/krangl/JsonIO.kt @@ -174,5 +174,5 @@ fun DataFrame.toJsonString(prettyPrint: Boolean = false, asObject: Boolean = fal } fun main(args: Array) { - DataFrame.fromJson("https://raw.githubusercontent.com/vega/vega/master/test/data/movies.json") + DataFrame.fromJson("https://raw.githubusercontent.com/vega/vega/main/docs/data/movies.json") } From 1df2d09ad59324b3edc8a7474fa670c796af10de Mon Sep 17 00:00:00 2001 From: Kopilov Aleksandr Date: Tue, 3 May 2022 15:28:21 +0300 Subject: [PATCH 2/4] Excel fixes (#147) * Break test `readExcel - should read bigint value`, we do not actually get bigint * Do not convert Any? Null to String * Break test `writeExcel - should write to excel`, LongCol should be saved as numeric with nulls * Excel LongCol as numeric, null as empty cell --- src/main/kotlin/krangl/ExcelIO.kt | 28 +++++++++++++++-------- src/main/kotlin/krangl/TableIO.kt | 4 ++-- src/test/kotlin/krangl/test/ExcelTests.kt | 17 +++++++++++--- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/src/main/kotlin/krangl/ExcelIO.kt b/src/main/kotlin/krangl/ExcelIO.kt index c2c589ae..9a2b3e86 100644 --- a/src/main/kotlin/krangl/ExcelIO.kt +++ b/src/main/kotlin/krangl/ExcelIO.kt @@ -287,23 +287,33 @@ private fun DataFrame.createExcelDataRows(sheet: Sheet, headers: Boolean) { when (cols[columnIndex]) { is BooleanCol -> { - cell.cellType = CellType.BOOLEAN - cellValue?.let { cell.setCellValue(it as Boolean) } + cellValue?.let { + cell.cellType = CellType.BOOLEAN + cell.setCellValue(it as Boolean) + } } is DoubleCol -> { - cell.cellType = CellType.NUMERIC - cellValue?.let { cell.setCellValue(it as Double) } + cellValue?.let { + cell.cellType = CellType.NUMERIC + cell.setCellValue(it as Double) + } } is IntCol -> { - cell.cellType = CellType.NUMERIC - cellValue?.let { cell.setCellValue((it as Int).toDouble()) } + cellValue?.let { + cell.cellType = CellType.NUMERIC + cell.setCellValue((it as Int).toDouble()) + } + } + is LongCol -> { + cellValue?.let { + cell.setCellValue((it as Long).toDouble()) + cell.cellType = CellType.NUMERIC + } } -// is StringCol -> cell.cellType= CellType.STRING else -> { cellValue?.let { cell.setCellValue(cellValue.toString()) } } } -// cell.setCellValue(cell.toString()) } } } @@ -324,4 +334,4 @@ private fun DataFrame.createExcelHeaderRow( fun main() { DataFrame.readExcel("src/test/resources/krangl/data/ExcelReadExample.xlsx") -} \ No newline at end of file +} diff --git a/src/main/kotlin/krangl/TableIO.kt b/src/main/kotlin/krangl/TableIO.kt index c0fe8166..6e5e0344 100644 --- a/src/main/kotlin/krangl/TableIO.kt +++ b/src/main/kotlin/krangl/TableIO.kt @@ -445,7 +445,7 @@ internal fun peekCol(colIndex: Int, records: List, peekSize: Int = 10 internal fun peekCol(records: Array<*>, peekSize: Int = 100) = records .asSequence() - .map { it.toString() } + .map { it?.toString() } .filterNotNull() .take(peekSize) .toList() @@ -623,4 +623,4 @@ val flightsData by lazy { // consider to use progress bar here } -// todo support Read and write data using Tablesaw’s “.saw” format --> use dedicated artifact to minimize dependcies \ No newline at end of file +// todo support Read and write data using Tablesaw’s “.saw” format --> use dedicated artifact to minimize dependcies diff --git a/src/test/kotlin/krangl/test/ExcelTests.kt b/src/test/kotlin/krangl/test/ExcelTests.kt index b0a8eb81..73eab458 100644 --- a/src/test/kotlin/krangl/test/ExcelTests.kt +++ b/src/test/kotlin/krangl/test/ExcelTests.kt @@ -2,9 +2,13 @@ package krangl.test import io.kotest.matchers.shouldBe import krangl.* +import org.apache.poi.ss.usermodel.CellType import org.apache.poi.ss.util.CellRangeAddress import org.apache.poi.util.LocaleUtil +import org.apache.poi.xssf.streaming.SXSSFWorkbook +import org.apache.poi.xssf.usermodel.XSSFWorkbook import org.junit.Test +import java.io.FileInputStream import java.util.* @@ -133,14 +137,14 @@ class ExcelTests { } @Test - fun `readExcel - should read bigint value`() { + fun `readExcel - should read bigint value`() { val df = DataFrame.readExcel( "src/test/resources/krangl/data/ExcelReadExample.xlsx", "FirstSheet" ) - df["Activities"][1] shouldBe "432178937489174" + df["Activities"][1] shouldBe 432178937489174 } @Test @@ -178,6 +182,13 @@ class ExcelTests { ) writtenDF shouldBe df + + val writtenBook = XSSFWorkbook(FileInputStream("src/test/resources/krangl/data/ExcelWriteResult.xlsx")) + val longValueCell = writtenBook.getSheet("FirstSheet").getRow(2).getCell(4) + longValueCell.cellType.shouldBe(CellType.NUMERIC) + longValueCell.numericCellValue.shouldBe(432178937489174.0) + val emptyValueCell = writtenBook.getSheet("FirstSheet").getRow(6).getCell(4) + emptyValueCell.cellType.shouldBe(CellType.BLANK) } @Test @@ -219,4 +230,4 @@ class ExcelTests { LocaleUtil.setUserLocale(defaultLocale) } -} \ No newline at end of file +} From 58ca93d020a750017cc578683bce53f2f503dd44 Mon Sep 17 00:00:00 2001 From: Kopilov Aleksandr Date: Wed, 18 May 2022 22:34:04 +0300 Subject: [PATCH 3/4] Excel new empty cells logic (#149) * breakingTest * Null cells --- src/main/kotlin/krangl/ExcelIO.kt | 63 ++++++---------- src/main/kotlin/krangl/TableIO.kt | 2 +- src/test/kotlin/krangl/test/ExcelTests.kt | 69 ++++++++++++------ .../krangl/data/ExcelReadExample.xlsx | Bin 20514 -> 13980 bytes 4 files changed, 69 insertions(+), 65 deletions(-) diff --git a/src/main/kotlin/krangl/ExcelIO.kt b/src/main/kotlin/krangl/ExcelIO.kt index 9a2b3e86..38c3310c 100644 --- a/src/main/kotlin/krangl/ExcelIO.kt +++ b/src/main/kotlin/krangl/ExcelIO.kt @@ -84,23 +84,17 @@ private fun readExcelSheet( includeBlankLines: Boolean ): DataFrame { var df = emptyDataFrame() - val rowIterator = xlSheet.rowIterator() + val cellRange = range ?: getDefaultCellAddress(xlSheet) - if (!rowIterator.hasNext()) - return df + val rowsFromTo = cellRange.firstRow to cellRange.lastRow - // Skip lines until starting row number - var currentRow = rowIterator.next() - while (currentRow.rowNum < cellRange.firstRow - 1) { - if (!rowIterator.hasNext()) - return df - else { - currentRow = rowIterator.next() - } + val headerRow = xlSheet.getRow(rowsFromTo.first) + if (headerRow == null) { + return df } - val cellIterator = currentRow.iterator() + val cellIterator = headerRow.iterator() // Get column names val columnResults = getExcelColumnNames(cellIterator, df, cellRange) @@ -108,19 +102,15 @@ private fun readExcelSheet( cellRange.lastColumn = columnResults.second // Stops at first empty column header //Get rows - while (rowIterator.hasNext() && currentRow.rowNum < cellRange.lastRow) { - currentRow = rowIterator.next() - val values = readExcelRow(currentRow, cellRange, trim, na) + for (rowNumber in rowsFromTo.first + 1 .. rowsFromTo.second) { + val currentRow = xlSheet.getRow(rowNumber) + val values = currentRow?.let { readExcelRow(currentRow, cellRange, trim, na) } ?: arrayOfNulls(df.ncol).asList() //Prevent Excel reading blank lines (whose contents have been cleared but the lines weren't deleted) - if (values.filterNotNull().isNotEmpty()) + if (values.filterNotNull().isNotEmpty() || includeBlankLines) { df = df.addRow(values) - else - if (stopAtBlankLine) - break //Stops reading on first blank line - else - if (includeBlankLines) - df = df.addRow(values) + } else + if (stopAtBlankLine) break //Stops reading on first blank line } return assignColumnTypes(df, colTypes, guessMax) } @@ -161,12 +151,11 @@ private fun readExcelRow( if(floor(numValue) == numValue && !isInfinite(numValue)) numValue.toLong() else numValue } CellType.STRING -> currentCell.stringCellValue - CellType.BLANK -> null + CellType.BLANK -> "" CellType.BOOLEAN -> currentCell.booleanCellValue CellType._NONE, CellType.ERROR, CellType.FORMULA -> dataFormatter.formatCellValue(currentCell) } } -// var currentValue = currentCell?.let { dataFormatter.formatCellValue(currentCell) } if (currentValue is String) { if (trim) { @@ -177,7 +166,6 @@ private fun readExcelRow( currentValue = null } - currentValue = (currentValue as String?)?.ifBlank { null } } rowValues.add(currentValue) @@ -283,35 +271,26 @@ private fun DataFrame.createExcelDataRows(sheet: Sheet, headers: Boolean) { val nRow = sheet.createRow(rowIdx++) for ((columnIndex, cellValue) in dfRow.values.toMutableList().withIndex()) { + if (cellValue == null) { + continue + } val cell = nRow.createCell(columnIndex) when (cols[columnIndex]) { is BooleanCol -> { - cellValue?.let { - cell.cellType = CellType.BOOLEAN - cell.setCellValue(it as Boolean) - } + cell.setCellValue(cellValue as Boolean) } is DoubleCol -> { - cellValue?.let { - cell.cellType = CellType.NUMERIC - cell.setCellValue(it as Double) - } + cell.setCellValue(cellValue as Double) } is IntCol -> { - cellValue?.let { - cell.cellType = CellType.NUMERIC - cell.setCellValue((it as Int).toDouble()) - } + cell.setCellValue((cellValue as Int).toDouble()) } is LongCol -> { - cellValue?.let { - cell.setCellValue((it as Long).toDouble()) - cell.cellType = CellType.NUMERIC - } + cell.setCellValue((cellValue as Long).toDouble()) } else -> { - cellValue?.let { cell.setCellValue(cellValue.toString()) } + cell.setCellValue(cellValue.toString()) } } } diff --git a/src/main/kotlin/krangl/TableIO.kt b/src/main/kotlin/krangl/TableIO.kt index 6e5e0344..cde9d4cd 100644 --- a/src/main/kotlin/krangl/TableIO.kt +++ b/src/main/kotlin/krangl/TableIO.kt @@ -324,7 +324,7 @@ internal fun String?.nullAsNA(): String = this ?: MISSING_VALUE // } internal fun String?.cellValueAsBoolean(): Boolean? { - if (this == null) return null + if (this == null || this == "") return null var cellValue: String? = toUpperCase() diff --git a/src/test/kotlin/krangl/test/ExcelTests.kt b/src/test/kotlin/krangl/test/ExcelTests.kt index 73eab458..d2be0a61 100644 --- a/src/test/kotlin/krangl/test/ExcelTests.kt +++ b/src/test/kotlin/krangl/test/ExcelTests.kt @@ -14,11 +14,15 @@ import java.util.* class ExcelTests { + private val testReadingPath = "src/test/resources/krangl/data/ExcelReadExample.xlsx" + private val testWritingPath = "src/test/resources/krangl/data/ExcelWriteResult.xlsx" + + @Test fun `readExcel - should read excel file`() { val df = DataFrame.readExcel( - "src/test/resources/krangl/data/ExcelReadExample.xlsx", + testReadingPath, "FirstSheet" ) @@ -32,12 +36,12 @@ class ExcelTests { @Test fun `readExcel - sheet by name should match sheet by index`() { val nameDF = DataFrame.readExcel( - "src/test/resources/krangl/data/ExcelReadExample.xlsx", + testReadingPath, "FirstSheet" ) val indexDF = DataFrame.readExcel( - "src/test/resources/krangl/data/ExcelReadExample.xlsx", + testReadingPath, 0 ) @@ -47,7 +51,7 @@ class ExcelTests { @Test fun `readExcel - out of range test`() { val headerHigherThanContentDF = DataFrame.readExcel( - "src/test/resources/krangl/data/ExcelReadExample.xlsx", + testReadingPath, "FirstSheet", CellRangeAddress.valueOf("A105:A110") ) @@ -57,19 +61,19 @@ class ExcelTests { @Test fun `readExcel - range test`() { val df = DataFrame.readExcel( - "src/test/resources/krangl/data/ExcelReadExample.xlsx", + testReadingPath, "FirstSheet" ) // Test sheet by index + cell range val cellRangeTestDF = DataFrame.readExcel( - "src/test/resources/krangl/data/ExcelReadExample.xlsx", + testReadingPath, sheet = 1, cellRange = CellRangeAddress.valueOf("E5:J105"), trim = true ) // Test defaulted cellRange's correctness on sheet with empty rows/cols val defaultCellRangeTestDF = DataFrame.readExcel( - "src/test/resources/krangl/data/ExcelReadExample.xlsx", + testReadingPath, sheet = 1, trim = true ) @@ -80,11 +84,11 @@ class ExcelTests { @Test fun `readExcel - trim_ws should trim white space`() { val df = DataFrame.readExcel( - "src/test/resources/krangl/data/ExcelReadExample.xlsx", + testReadingPath, "FirstSheet" ) val trimmedDF = DataFrame.readExcel( - "src/test/resources/krangl/data/ExcelReadExample.xlsx", + testReadingPath, sheet = 1, trim = true ) @@ -94,7 +98,7 @@ class ExcelTests { @Test fun `readExcel - colTypes should work`() { val df = DataFrame.readExcel( - "src/test/resources/krangl/data/ExcelReadExample.xlsx", + testReadingPath, "FirstSheet", colTypes = NamedColumnSpec("Activities" to ColType.Int) ) @@ -105,7 +109,7 @@ class ExcelTests { @Test fun `readExcel - should stop at first blank line`() { val shouldStopAtBlankDF = DataFrame.readExcel( - "src/test/resources/krangl/data/ExcelReadExample.xlsx", + testReadingPath, sheet = 2, trim = true, cellRange = CellRangeAddress.valueOf("E3:J10") ) @@ -115,7 +119,7 @@ class ExcelTests { @Test fun `readExcel - should continue past blank line`() { val shouldContinueAtBlankDF = DataFrame.readExcel( - "src/test/resources/krangl/data/ExcelReadExample.xlsx", + testReadingPath, sheet = 2, trim = true, cellRange = CellRangeAddress.valueOf("E3:J10"), stopAtBlankLine = false ) @@ -125,7 +129,7 @@ class ExcelTests { @Test fun `readExcel - should include blank lines`() { val shouldContinueAtBlankDF = DataFrame.readExcel( - "src/test/resources/krangl/data/ExcelReadExample.xlsx", + testReadingPath, sheet = 2, trim = true, cellRange = CellRangeAddress.valueOf("E3:J10"), @@ -140,7 +144,7 @@ class ExcelTests { fun `readExcel - should read bigint value`() { val df = DataFrame.readExcel( - "src/test/resources/krangl/data/ExcelReadExample.xlsx", + testReadingPath, "FirstSheet" ) @@ -150,26 +154,26 @@ class ExcelTests { @Test fun `writeExcel - should write to excel`() { val df = DataFrame.readExcel( - "src/test/resources/krangl/data/ExcelReadExample.xlsx", + testReadingPath, "FirstSheet" ) df.writeExcel( - "src/test/resources/krangl/data/ExcelWriteResult.xlsx", + testWritingPath, "FirstSheet", headers = true, eraseFile = true, boldHeaders = false ) df.writeExcel( - "src/test/resources/krangl/data/ExcelWriteResult.xlsx", + testWritingPath, "SecondSheet", headers = true, eraseFile = false, boldHeaders = true ) df.writeExcel( - "src/test/resources/krangl/data/ExcelWriteResult.xlsx", + testWritingPath, "ThirdSheet", headers = false, eraseFile = false, @@ -177,18 +181,18 @@ class ExcelTests { ) val writtenDF = DataFrame.readExcel( - "src/test/resources/krangl/data/ExcelWriteResult.xlsx", + testWritingPath, "FirstSheet" ) writtenDF shouldBe df - val writtenBook = XSSFWorkbook(FileInputStream("src/test/resources/krangl/data/ExcelWriteResult.xlsx")) + val writtenBook = XSSFWorkbook(FileInputStream(testWritingPath)) val longValueCell = writtenBook.getSheet("FirstSheet").getRow(2).getCell(4) longValueCell.cellType.shouldBe(CellType.NUMERIC) longValueCell.numericCellValue.shouldBe(432178937489174.0) val emptyValueCell = writtenBook.getSheet("FirstSheet").getRow(6).getCell(4) - emptyValueCell.cellType.shouldBe(CellType.BLANK) + emptyValueCell shouldBe null } @Test @@ -203,7 +207,7 @@ class ExcelTests { df.print(maxWidth = 1000) df.schema() - df[1][4] shouldBe null + df[1][4] shouldBe "" df[3][1] shouldBe null df[5][3] shouldBe null @@ -230,4 +234,25 @@ class ExcelTests { LocaleUtil.setUserLocale(defaultLocale) } + + @Test + fun `it should distinguish empty strings and nulls`() { + val df1 = dataFrameOf(listOf( + mapOf("col" to "NotEmptyString1", "col2" to 1), + mapOf("col" to "", "col2" to 2), + mapOf("col" to null, "col2" to 3), + mapOf("col" to "NotEmptyString2", "col2" to 4), + )) + df1.writeExcel(testWritingPath, "test", eraseFile = true) + DataFrame.readExcel(testWritingPath, "test") shouldBe df1 + + val df2 = dataFrameOf(listOf( + mapOf("col" to "NotEmptyString1"), + mapOf("col" to ""), + mapOf("col" to null), + mapOf("col" to "NotEmptyString2"), + )) + df2.writeExcel("testNull.xlsx", "test", eraseFile = true) + DataFrame.readExcel("testNull.xlsx", "test", stopAtBlankLine = false, includeBlankLines = true) shouldBe df2 + } } diff --git a/src/test/resources/krangl/data/ExcelReadExample.xlsx b/src/test/resources/krangl/data/ExcelReadExample.xlsx index dfd139f1dcc8394c69b930f4b5c733ad8fa26f44..e145579183362d44382bea3dc627e73dd8eb74d2 100644 GIT binary patch literal 13980 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 From 1c6038f702b0bd7e1bde52347ae0cead5845adbb Mon Sep 17 00:00:00 2001 From: Kopilov Aleksandr Date: Mon, 30 May 2022 20:24:58 +0300 Subject: [PATCH 4/4] Added support for arrow (#150) Authored-by: Kopilov --- build.gradle | 4 +- src/main/kotlin/krangl/ArrowIO.kt | 318 ++++++++++++++++++++++ src/main/kotlin/krangl/ExcelIO.kt | 6 +- src/main/kotlin/krangl/JsonIO.kt | 27 +- src/main/kotlin/krangl/TableIO.kt | 4 +- src/test/kotlin/krangl/test/ArrowTests.kt | 41 +++ 6 files changed, 375 insertions(+), 25 deletions(-) create mode 100644 src/main/kotlin/krangl/ArrowIO.kt create mode 100644 src/test/kotlin/krangl/test/ArrowTests.kt diff --git a/build.gradle b/build.gradle index 59c3d443..fa58f425 100644 --- a/build.gradle +++ b/build.gradle @@ -23,6 +23,8 @@ dependencies { compileOnly 'org.jetbrains.kotlin:kotlin-script-runtime:1.6.20' api "org.apache.commons:commons-csv:1.6" // cant upgrade to 1.8 because of https://issues.apache.org/jira/browse/CSV-257 + api 'org.apache.arrow:arrow-vector:8.0.0' + implementation 'org.apache.arrow:arrow-memory-netty:8.0.0' api 'org.apache.poi:poi-ooxml:5.2.2' api 'com.beust:klaxon:5.6'// compile 'me.tongfei:progressbar:0.5.5' @@ -95,7 +97,7 @@ test { //http://stackoverflow.com/questions/34377367/why-is-gradle-install-replacing-my-version-with-unspecified group 'com.github.holgerbrandl' //version '0.16.95' -version '0.17.4-SNAPSHOT' +version '0.17.4' diff --git a/src/main/kotlin/krangl/ArrowIO.kt b/src/main/kotlin/krangl/ArrowIO.kt new file mode 100644 index 00000000..1318122f --- /dev/null +++ b/src/main/kotlin/krangl/ArrowIO.kt @@ -0,0 +1,318 @@ +package krangl + +import org.apache.arrow.memory.BufferAllocator +import org.apache.arrow.memory.RootAllocator +import org.apache.arrow.vector.BaseFixedWidthVector +import org.apache.arrow.vector.BigIntVector +import org.apache.arrow.vector.BitVector +import org.apache.arrow.vector.Float4Vector +import org.apache.arrow.vector.Float8Vector +import org.apache.arrow.vector.IntVector +import org.apache.arrow.vector.SmallIntVector +import org.apache.arrow.vector.TinyIntVector +import org.apache.arrow.vector.VarCharVector +import org.apache.arrow.vector.VectorSchemaRoot +import org.apache.arrow.vector.ipc.ArrowFileReader +import org.apache.arrow.vector.ipc.ArrowFileWriter +import org.apache.arrow.vector.types.FloatingPointPrecision +import org.apache.arrow.vector.types.pojo.ArrowType +import org.apache.arrow.vector.types.pojo.Schema +import org.apache.arrow.vector.util.ByteArrayReadableSeekableByteChannel +import org.apache.arrow.vector.util.Text +import java.io.ByteArrayOutputStream +import java.io.File +import java.nio.channels.* +import java.nio.file.StandardOpenOption +import java.util.* + +internal fun unwrapStringArrayFromArrow(vector: VarCharVector): ArrayList { + val result = ArrayList() + for (i in 0 until vector.valueCount) { + result.add(vector.getObject(i)?.toString()) + } + return result +} + +internal inline fun unwrapNumericVectorFromArrow(vector: BaseFixedWidthVector, elementClass: Class): List { + val elements = vector.valueCount + val outVector = ArrayList(elements) + for (i in 0 until elements) { + outVector.add(vector.getObject(i) as ELEMENT_TYPE?) + } + return outVector +} + +internal fun unwrapBooleanArrayFromArrow(vector: BitVector): ArrayList { + val result = ArrayList() + for (i in 0 until vector.valueCount) { + result.add(vector.getObject(i)) + } + return result +} + +fun DataFrame.Companion.arrowReader() = ArrowReader() + +class ArrowReader() { + /** + * Internal low-level function. + * Use this function if you are working with [VectorSchemaRoot]s directly in your project. + */ + fun fromVectorSchemaRoot(vectorSchemaRoot: VectorSchemaRoot): DataFrame { + val kranglVectors = vectorSchemaRoot.fieldVectors.map { fieldVector -> + when (fieldVector.field.type) { + is ArrowType.FixedSizeList, is ArrowType.List -> { + throw Exception("Matrices are not supported yet") + } + is ArrowType.Utf8 -> { + StringCol(fieldVector.name, unwrapStringArrayFromArrow(fieldVector as VarCharVector)) + } + is ArrowType.Int -> { + val bitWidth = (fieldVector.field.type as ArrowType.Int).bitWidth + when (bitWidth) { + 8 -> IntCol(fieldVector.name, unwrapNumericVectorFromArrow(fieldVector as TinyIntVector, Int::class.java)) + 16 -> IntCol(fieldVector.name, unwrapNumericVectorFromArrow(fieldVector as SmallIntVector, Int::class.java)) + 32 -> IntCol(fieldVector.name, unwrapNumericVectorFromArrow(fieldVector as IntVector, Int::class.java)) + 64 -> LongCol(fieldVector.name, unwrapNumericVectorFromArrow(fieldVector as BigIntVector, Long::class.java)) + else -> throw java.lang.Exception("Incorrect Int.bitWidth ($bitWidth, should never happen)") + } + } + is ArrowType.FloatingPoint -> { + val precision = (fieldVector.field.type as ArrowType.FloatingPoint).precision + when (precision) { + FloatingPointPrecision.HALF -> java.lang.Exception("HALF float not supported") + FloatingPointPrecision.SINGLE -> DoubleCol(fieldVector.name, unwrapNumericVectorFromArrow(fieldVector as Float4Vector, Double::class.java)) + FloatingPointPrecision.DOUBLE -> DoubleCol(fieldVector.name, unwrapNumericVectorFromArrow(fieldVector as Float8Vector, Double::class.java)) + else -> throw java.lang.Exception("Incorrect FloatingPoint.precision ($precision, should never happen)") + } + } + is ArrowType.Bool -> { + BooleanCol(fieldVector.name, unwrapBooleanArrayFromArrow(fieldVector as BitVector)) + } + else -> { + throw Exception("${fieldVector.field.type.typeID.name} is not supported yet") + } + } + } + + return dataFrameOf(*(kranglVectors as List).toTypedArray()) + } + + /** + * Read [VectorSchemaRoot] from existing [channel] and convert it to [DataFrame]. + * Use this function if you want to manage channels yourself, make in-memory IPC sharing and so on. + * If [allocator] is null, it will be created and closed inside. + */ + fun readFromChannel(channel: SeekableByteChannel, allocator: BufferAllocator?): DataFrame { + fun readFromChannelAllocating(channel: SeekableByteChannel, allocator: BufferAllocator?): DataFrame { + ArrowFileReader(channel, allocator).use { reader -> + reader.loadNextBatch() + return fromVectorSchemaRoot(reader.vectorSchemaRoot) + } + } + if (allocator == null ) { + RootAllocator().use { newAllocator -> + return readFromChannelAllocating(channel, newAllocator) + } + } else { + return readFromChannelAllocating(channel, allocator) + } + } + + /** + * Read [VectorSchemaRoot] from ByteArray and convert it to [DataFrame]. + */ + fun fromByteArray(byteArray: ByteArray): DataFrame { + return readFromChannel(ByteArrayReadableSeekableByteChannel(byteArray), null) + } + + /** + * Read [VectorSchemaRoot] from [file] by and convert it to [DataFrame]. + */ + fun fromFile(file: File): DataFrame { + if (!file.exists()) { + throw Exception("${file.path} does not exist") + } + if (file.isDirectory) { + throw Exception("${file.path} is directory") + } + FileChannel.open( + file.toPath(), + StandardOpenOption.READ + ).use { channel -> + return readFromChannel(channel, null) + } + } + + /** + * Read [VectorSchemaRoot] from file by [path] and convert it to [DataFrame]. + */ + fun fromFile(path: String): DataFrame { + return fromFile(File(path)) + } +} + +fun DataFrame.arrowWriter() = ArrowWriter(this) + +class ArrowWriter(val dataFrame: DataFrame) { + internal fun fromStringCol(column: StringCol, allocator: BufferAllocator): VarCharVector { + val fieldVector = VarCharVector(column.name, allocator) + fieldVector.allocateNew(column.length) + column.values.forEachIndexed { index, value -> + if (value == null) { + fieldVector.setNull(index) + } else { + fieldVector.setSafe(index, Text(value)) + } + } + fieldVector.valueCount = column.length + return fieldVector + } + + internal fun fromBooleanCol(column: BooleanCol, allocator: BufferAllocator): BitVector { + val fieldVector = BitVector(column.name, allocator) + fieldVector.allocateNew(column.length) + column.values.forEachIndexed { index, value -> + if (value == null) { + fieldVector.setNull(index) + } else { + fieldVector.setSafe(index, if (value) 1 else 0) + } + } + fieldVector.valueCount = column.length + return fieldVector + } + + internal fun fromIntCol(column: IntCol, allocator: BufferAllocator): IntVector { + val fieldVector = IntVector(column.name, allocator) + fieldVector.allocateNew(column.length) + column.values.forEachIndexed { index, value -> + if (value == null) { + fieldVector.setNull(index) + } else { + fieldVector.setSafe(index, value) + } + } + fieldVector.valueCount = column.length + return fieldVector + } + + internal fun fromLongCol(column: LongCol, allocator: BufferAllocator): BigIntVector { + val fieldVector = BigIntVector(column.name, allocator) + fieldVector.allocateNew(column.length) + column.values.forEachIndexed { index, value -> + if (value == null) { + fieldVector.setNull(index) + } else { + fieldVector.setSafe(index, value) + } + } + fieldVector.valueCount = column.length + return fieldVector + } + + internal fun fromDoubleCol(column: DoubleCol, allocator: BufferAllocator): Float8Vector { + val fieldVector = Float8Vector(column.name, allocator) + fieldVector.allocateNew(column.length) + column.values.forEachIndexed { index, value -> + if (value == null) { + fieldVector.setNull(index) + } else { + fieldVector.setSafe(index, value) + } + } + fieldVector.valueCount = column.length + return fieldVector + } + + internal fun fromAnyCol(column: AnyCol, allocator: BufferAllocator): VarCharVector { + val fieldVector = VarCharVector(column.name, allocator) + fieldVector.allocateNew(column.length) + column.values.forEachIndexed { index, value -> + if (value == null) { + fieldVector.setNull(index) + } else { + fieldVector.setSafe(index, Text(value.toString())) + } + } + fieldVector.valueCount = column.length + return fieldVector + } + + /** + * Internal low-level function. + * Use this function if you are working with [VectorSchemaRoot]s and [BufferAllocator]s directly in your project. + */ + fun allocateVectorSchemaRoot(allocator: BufferAllocator): VectorSchemaRoot { + val arrowVectors = dataFrame.cols.map { column -> + when (column) { + is StringCol -> fromStringCol(column, allocator) + is BooleanCol -> fromBooleanCol(column, allocator) + is IntCol -> fromIntCol(column, allocator) + is LongCol -> fromLongCol(column, allocator) + is DoubleCol -> fromDoubleCol(column, allocator) + is AnyCol -> fromAnyCol(column, allocator) + else -> { + throw Exception("Unknown column type ${column.javaClass.canonicalName}") + } + } + } + return VectorSchemaRoot(arrowVectors) + } + + /** + * Export [dataFrame] to [VectorSchemaRoot] and write it to any existing [channel]. + * Use this function if you want to manage channels yourself, make in-memory IPC sharing and so on + */ + fun writeToChannel(channel: WritableByteChannel) { + RootAllocator().use { allocator -> + this.allocateVectorSchemaRoot(allocator).use { vectorSchemaRoot -> + ArrowFileWriter(vectorSchemaRoot, null, channel).use { writer -> + writer.writeBatch(); + } + } + } + } + + /** + * Export [dataFrame] to [VectorSchemaRoot] and write it to new ByteArray. + */ + fun toByteArray(): ByteArray { + ByteArrayOutputStream().use { byteArrayStream -> + Channels.newChannel(byteArrayStream).use { channel -> + writeToChannel(channel) + return byteArrayStream.toByteArray() + } + } + } + + /** + * Export [dataFrame] to [VectorSchemaRoot] and write it to new or existing [file]. + * Temporary file is created if [file] argument is null. + */ + fun toFile(file: File?): File { + val saveToFile = file ?: File.createTempFile("DataFrame", ".arrow") + + FileChannel.open( + saveToFile.toPath(), + StandardOpenOption.WRITE, + StandardOpenOption.CREATE + ).use { channel -> + channel.truncate(0) + writeToChannel(channel) + } + return saveToFile + } + + /** + * Export [dataFrame] to [VectorSchemaRoot] and write it to new or existing file by [path]. + * Temporary file is created if [path] argument is null. + */ + fun toFile(path: String?): File { + val saveToFile = if (path != null) { + File(path) + } else { + File.createTempFile("DataFrame", ".arrow") + } + return toFile(saveToFile) + } +} diff --git a/src/main/kotlin/krangl/ExcelIO.kt b/src/main/kotlin/krangl/ExcelIO.kt index 38c3310c..192d4dc2 100644 --- a/src/main/kotlin/krangl/ExcelIO.kt +++ b/src/main/kotlin/krangl/ExcelIO.kt @@ -27,7 +27,7 @@ fun DataFrame.Companion.readExcel( cellRange: CellRangeAddress? = null, colTypes: ColumnTypeSpec = GuessSpec(), trim: Boolean = false, - guessMax: Int = 100, + guessMax: Int = GUESS_MAX, na: String = MISSING_VALUE, stopAtBlankLine: Boolean = true, includeBlankLines: Boolean = false, @@ -55,7 +55,7 @@ fun DataFrame.Companion.readExcel( cellRange: CellRangeAddress? = null, colTypes: ColumnTypeSpec = GuessSpec(), trim_ws: Boolean = false, - guessMax: Int = 100, + guessMax: Int = GUESS_MAX, na: String = MISSING_VALUE, stopAtBlankLine: Boolean = true, includeBlankLines: Boolean = false, @@ -198,7 +198,7 @@ private fun getExcelColumnNames( return Pair(df1, lastColumn) } -private fun assignColumnTypes(df: DataFrame, colTypes: ColumnTypeSpec, guessMax: Int = 100): DataFrame { +private fun assignColumnTypes(df: DataFrame, colTypes: ColumnTypeSpec, guessMax: Int = GUESS_MAX): DataFrame { val colList = mutableListOf() diff --git a/src/main/kotlin/krangl/JsonIO.kt b/src/main/kotlin/krangl/JsonIO.kt index 242751a5..470fb606 100644 --- a/src/main/kotlin/krangl/JsonIO.kt +++ b/src/main/kotlin/krangl/JsonIO.kt @@ -25,16 +25,14 @@ fun DataFrame.Companion.fromJson(fileOrUrl: String): DataFrame { return fromJson(url) } -const val ARRAY_ROWS_TYPE_DETECTING = 5 - @Suppress("UNCHECKED_CAST") -fun DataFrame.Companion.fromJson(url: URL, typeDetectingRows: Int? = ARRAY_ROWS_TYPE_DETECTING): DataFrame = - fromJsonArray(Parser.default().parse(url.openStream()) as JsonArray, typeDetectingRows) +fun DataFrame.Companion.fromJson(url: URL, guessMax: Int? = GUESS_MAX): DataFrame = + fromJsonArray(Parser.default().parse(url.openStream()) as JsonArray, guessMax) const val ARRAY_COL_ID = "_id" @Suppress("UNCHECKED_CAST") -fun DataFrame.Companion.fromJsonString(jsonData: String, typeDetectingRows: Int? = ARRAY_ROWS_TYPE_DETECTING): DataFrame { +fun DataFrame.Companion.fromJsonString(jsonData: String, guessMax: Int? = GUESS_MAX): DataFrame { val parsed = Parser.default().parse(StringReader(jsonData)) // var deparseJson = deparseJson(parsed) @@ -49,7 +47,7 @@ fun DataFrame.Companion.fromJsonString(jsonData: String, typeDetectingRows: Int? val jsonColDFs = jsonCol.values().map { colData -> when (colData) { - is JsonArray<*> -> fromJsonArray(colData as JsonArray, typeDetectingRows) + is JsonArray<*> -> fromJsonArray(colData as JsonArray, guessMax) is JsonObject -> when { colData.values.first() is JsonArray<*> -> { dataFrameOf( @@ -84,25 +82,14 @@ fun DataFrame.Companion.fromJsonString(jsonData: String, typeDetectingRows: Int? return df } -//Can this be removed? -private fun deparseJson(parsed: Any?, typeDetectingRows: Int? = ARRAY_ROWS_TYPE_DETECTING): DataFrame { - @Suppress("UNCHECKED_CAST") - return when (parsed) { - is JsonArray<*> -> fromJsonArray(parsed as JsonArray, typeDetectingRows) - is JsonObject -> dataFrameOf(parsed.keys)(parsed.values) - else -> throw IllegalArgumentException("Can not parse json. " + INTERNAL_ERROR_MSG) - } -} - - -internal fun fromJsonArray(records: JsonArray, typeDetectingRows: Int?): DataFrame { +internal fun fromJsonArray(records: JsonArray, guessMax: Int?): DataFrame { val colNames = records .map { it.keys.toList() } .reduceRight { acc, right -> acc + right.minus(acc) } val cols = colNames.map { colName -> - val firstRows = if (typeDetectingRows is Int) { - records.take(typeDetectingRows) + val firstRows = if (guessMax is Int) { + records.take(guessMax) } else { records } diff --git a/src/main/kotlin/krangl/TableIO.kt b/src/main/kotlin/krangl/TableIO.kt index cde9d4cd..cc5f8605 100644 --- a/src/main/kotlin/krangl/TableIO.kt +++ b/src/main/kotlin/krangl/TableIO.kt @@ -308,6 +308,8 @@ fun DataFrame.Companion.readFixedWidth( val MISSING_VALUE = "NA" +const val GUESS_MAX = 100 + // NA aware conversions internal fun String.naAsNull(): String? = if (this == MISSING_VALUE) null else this @@ -377,7 +379,7 @@ internal fun dataColFactory( // TODO add missing value support with user defined string (e.g. NA here) here -internal fun dataColFactory(colName: String, colType: ColType, records: Array<*>, guessMax: Int = 100): DataCol = +internal fun dataColFactory(colName: String, colType: ColType, records: Array<*>, guessMax: Int = GUESS_MAX): DataCol = when (colType) { // see https://github.com/holgerbrandl/krangl/issues/10 ColType.Int -> try { diff --git a/src/test/kotlin/krangl/test/ArrowTests.kt b/src/test/kotlin/krangl/test/ArrowTests.kt new file mode 100644 index 00000000..0df1dda4 --- /dev/null +++ b/src/test/kotlin/krangl/test/ArrowTests.kt @@ -0,0 +1,41 @@ +package krangl.test + +import io.kotest.matchers.shouldBe +import krangl.DataFrame +import krangl.arrowReader +import krangl.arrowWriter +import krangl.fromJsonString +import org.junit.Test + +class ArrowTests { + @Test + fun savingToArrow() { + val df1 = DataFrame.fromJsonString( + """ + { + "cars": { + "Nissan": [ + {"model":"Sentra", "doors":4, "weight":1, }, + {"model":"Maxima", "doors":4, "weight":1.3}, + {"model":"Leaf", "doors":4, "electrical":true}, + {"model":"Skyline", "doors":2, "electrical":false} + ], + "Ford": [ + {"model":"Taurus", "doors":4, "weight":2, "electrical":false}, + {"model":"Escort", "doors":4, "seats":5, "weight":1} + ], + "Tesla": [ + {"electrical":true} + ] + } + } + """ + ) + val data = df1.arrowWriter().toByteArray() + val df2 = DataFrame.arrowReader().fromByteArray(data) + + df2.shouldBe(df1) + //Save to file for test reading from another language (Python or R) + //df1.arrowWriter().toFile("test.arrow") + } +}