From 71b06f5ac58a31cc66099c83337069138a507d30 Mon Sep 17 00:00:00 2001 From: Florian Bernard Date: Wed, 24 Apr 2024 09:24:50 +0200 Subject: [PATCH] Add support for reading parquet file thanks to arrow-dataset #576 --- dataframe-arrow/build.gradle.kts | 1 + .../kotlinx/dataframe/io/arrowReading.kt | 9 ++++++ .../kotlinx/dataframe/io/arrowReadingImpl.kt | 29 ++++++++++++++++++ .../kotlinx/dataframe/io/ArrowKtTest.kt | 14 ++++++++- .../io/exampleEstimatesAssertions.kt | 24 ++++++++++++--- .../src/test/resources/test.arrow.parquet | Bin 0 -> 28203 bytes gradle/libs.versions.toml | 3 +- 7 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 dataframe-arrow/src/test/resources/test.arrow.parquet diff --git a/dataframe-arrow/build.gradle.kts b/dataframe-arrow/build.gradle.kts index a5d8304f2b..0c000431ec 100644 --- a/dataframe-arrow/build.gradle.kts +++ b/dataframe-arrow/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { implementation(libs.arrow.vector) implementation(libs.arrow.format) implementation(libs.arrow.memory) + implementation(libs.arrow.dataset) implementation(libs.commonsCompress) implementation(libs.kotlin.reflect) implementation(libs.kotlin.datetimeJvm) diff --git a/dataframe-arrow/src/main/kotlin/org/jetbrains/kotlinx/dataframe/io/arrowReading.kt b/dataframe-arrow/src/main/kotlin/org/jetbrains/kotlinx/dataframe/io/arrowReading.kt index dac1fe680e..bf34f0c83f 100644 --- a/dataframe-arrow/src/main/kotlin/org/jetbrains/kotlinx/dataframe/io/arrowReading.kt +++ b/dataframe-arrow/src/main/kotlin/org/jetbrains/kotlinx/dataframe/io/arrowReading.kt @@ -1,5 +1,6 @@ package org.jetbrains.kotlinx.dataframe.io +import org.apache.arrow.dataset.file.FileFormat import org.apache.arrow.memory.RootAllocator import org.apache.arrow.vector.ipc.ArrowReader import org.apache.commons.compress.utils.SeekableInMemoryByteChannel @@ -186,3 +187,11 @@ public fun DataFrame.Companion.readArrow( public fun ArrowReader.toDataFrame( nullability: NullabilityOptions = NullabilityOptions.Infer ): AnyFrame = DataFrame.Companion.readArrowImpl(this, nullability) + +/** + * Read [Parquet](https://parquet.apache.org/) data from existing [url] by using [Arrow Dataset](https://arrow.apache.org/docs/java/dataset.html) + */ +public fun DataFrame.Companion.readParquet( + url: URL, + nullability: NullabilityOptions = NullabilityOptions.Infer +): AnyFrame = readArrowDataset(url.toString(), FileFormat.PARQUET, nullability) diff --git a/dataframe-arrow/src/main/kotlin/org/jetbrains/kotlinx/dataframe/io/arrowReadingImpl.kt b/dataframe-arrow/src/main/kotlin/org/jetbrains/kotlinx/dataframe/io/arrowReadingImpl.kt index 842551d534..308aa2d215 100644 --- a/dataframe-arrow/src/main/kotlin/org/jetbrains/kotlinx/dataframe/io/arrowReadingImpl.kt +++ b/dataframe-arrow/src/main/kotlin/org/jetbrains/kotlinx/dataframe/io/arrowReadingImpl.kt @@ -1,5 +1,10 @@ package org.jetbrains.kotlinx.dataframe.io +import org.apache.arrow.dataset.file.FileFormat +import org.apache.arrow.dataset.file.FileSystemDatasetFactory +import org.apache.arrow.dataset.jni.DirectReservationListener +import org.apache.arrow.dataset.jni.NativeMemoryPool +import org.apache.arrow.dataset.scanner.ScanOptions import org.apache.arrow.memory.RootAllocator import org.apache.arrow.vector.BigIntVector import org.apache.arrow.vector.BitVector @@ -330,3 +335,27 @@ internal fun DataFrame.Companion.readArrowImpl( return flattened.concatKeepingSchema() } } + +internal fun DataFrame.Companion.readArrowDataset( + fileUri: String, + fileFormat: FileFormat, + nullability: NullabilityOptions = NullabilityOptions.Infer, +): AnyFrame { + val scanOptions = ScanOptions(32768) + RootAllocator().use { allocator -> + FileSystemDatasetFactory( + allocator, + NativeMemoryPool.createListenable(DirectReservationListener.instance()), + fileFormat, + fileUri + ).use { datasetFactory -> + datasetFactory.finish().use { dataset -> + dataset.newScan(scanOptions).use { scanner -> + scanner.scanBatches().use { reader -> + return readArrow(reader, nullability) + } + } + } + } + } +} diff --git a/dataframe-arrow/src/test/kotlin/org/jetbrains/kotlinx/dataframe/io/ArrowKtTest.kt b/dataframe-arrow/src/test/kotlin/org/jetbrains/kotlinx/dataframe/io/ArrowKtTest.kt index b095b16f52..81d46536c3 100644 --- a/dataframe-arrow/src/test/kotlin/org/jetbrains/kotlinx/dataframe/io/ArrowKtTest.kt +++ b/dataframe-arrow/src/test/kotlin/org/jetbrains/kotlinx/dataframe/io/ArrowKtTest.kt @@ -33,7 +33,6 @@ import org.jetbrains.kotlinx.dataframe.api.columnOf import org.jetbrains.kotlinx.dataframe.api.convertToBoolean import org.jetbrains.kotlinx.dataframe.api.copy import org.jetbrains.kotlinx.dataframe.api.dataFrameOf -import org.jetbrains.kotlinx.dataframe.api.describe import org.jetbrains.kotlinx.dataframe.api.map import org.jetbrains.kotlinx.dataframe.api.pathOf import org.jetbrains.kotlinx.dataframe.api.remove @@ -613,4 +612,17 @@ internal class ArrowKtTest { DataFrame.readArrow(dbArrowReader) shouldBe expected } } + + @Test + fun testReadParquet(){ + val path = testResource("test.arrow.parquet").path + val dataFrame = DataFrame.readParquet(URL("file:$path")) + dataFrame.rowsCount() shouldBe 300 + assertEstimations( + exampleFrame = dataFrame, + expectedNullable = false, + hasNulls = false, + fromParquet = true + ) + } } diff --git a/dataframe-arrow/src/test/kotlin/org/jetbrains/kotlinx/dataframe/io/exampleEstimatesAssertions.kt b/dataframe-arrow/src/test/kotlin/org/jetbrains/kotlinx/dataframe/io/exampleEstimatesAssertions.kt index 66a2713518..74b0bcb405 100644 --- a/dataframe-arrow/src/test/kotlin/org/jetbrains/kotlinx/dataframe/io/exampleEstimatesAssertions.kt +++ b/dataframe-arrow/src/test/kotlin/org/jetbrains/kotlinx/dataframe/io/exampleEstimatesAssertions.kt @@ -18,7 +18,12 @@ import kotlin.reflect.typeOf * Assert that we have got the same data that was originally saved on example creation. * Example generation project is currently located at https://github.com/Kopilov/arrow_example */ -internal fun assertEstimations(exampleFrame: AnyFrame, expectedNullable: Boolean, hasNulls: Boolean) { +internal fun assertEstimations( + exampleFrame: AnyFrame, + expectedNullable: Boolean, + hasNulls: Boolean, + fromParquet: Boolean = false +) { /** * In [exampleFrame] we get two concatenated batches. To assert the estimations, we should transform frame row number to batch row number */ @@ -129,10 +134,19 @@ internal fun assertEstimations(exampleFrame: AnyFrame, expectedNullable: Boolean assertValueOrNull(iBatch(i), element, LocalDate.ofEpochDay(iBatch(i).toLong() * 30)) } - val datetimeCol = exampleFrame["date64"] as DataColumn - datetimeCol.type() shouldBe typeOf().withNullability(expectedNullable) - datetimeCol.forEachIndexed { i, element -> - assertValueOrNull(iBatch(i), element, LocalDateTime.ofEpochSecond(iBatch(i).toLong() * 60 * 60 * 24 * 30, 0, ZoneOffset.UTC)) + if (fromParquet){ + //parquet format have only one type of date: https://github.com/apache/parquet-format/blob/master/LogicalTypes.md#date without time + val datetimeCol = exampleFrame["date64"] as DataColumn + datetimeCol.type() shouldBe typeOf().withNullability(expectedNullable) + datetimeCol.forEachIndexed { i, element -> + assertValueOrNull(iBatch(i), element, LocalDate.ofEpochDay(iBatch(i).toLong() * 30)) + } + }else { + val datetimeCol = exampleFrame["date64"] as DataColumn + datetimeCol.type() shouldBe typeOf().withNullability(expectedNullable) + datetimeCol.forEachIndexed { i, element -> + assertValueOrNull(iBatch(i), element, LocalDateTime.ofEpochSecond(iBatch(i).toLong() * 60 * 60 * 24 * 30, 0, ZoneOffset.UTC)) + } } val timeSecCol = exampleFrame["time32_seconds"] as DataColumn diff --git a/dataframe-arrow/src/test/resources/test.arrow.parquet b/dataframe-arrow/src/test/resources/test.arrow.parquet new file mode 100644 index 0000000000000000000000000000000000000000..cf78b1c2558abe750e188fcf8d1cd5d016726871 GIT binary patch literal 28203 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