feat(dataframe): expose withColumn and unnestColumns (#54)
diff --git a/core/src/main/java/org/apache/datafusion/DataFrame.java b/core/src/main/java/org/apache/datafusion/DataFrame.java
index dceb497..b263564 100644
--- a/core/src/main/java/org/apache/datafusion/DataFrame.java
+++ b/core/src/main/java/org/apache/datafusion/DataFrame.java
@@ -172,6 +172,55 @@
}
/**
+ * Add a column to this DataFrame computed from a SQL expression. If a column with the given name
+ * already exists, it is replaced in place; otherwise the new column is appended. The expression
+ * is parsed against this DataFrame's own schema, matching the convention used by {@link
+ * #filter(String)}. The receiver remains usable and must still be closed independently.
+ *
+ * @throws IllegalArgumentException if {@code name} or {@code expr} is {@code null}.
+ */
+ public DataFrame withColumn(String name, String expr) {
+ if (nativeHandle == 0) {
+ throw new IllegalStateException("DataFrame is closed or already collected");
+ }
+ if (name == null) {
+ throw new IllegalArgumentException("withColumn name must be non-null");
+ }
+ if (expr == null) {
+ throw new IllegalArgumentException("withColumn expr must be non-null");
+ }
+ return new DataFrame(withColumnExpr(nativeHandle, name, expr));
+ }
+
+ /**
+ * Expand list or struct columns into rows or fields, with default {@link UnnestOptions} (i.e.
+ * {@code preserveNulls = true}). The receiver remains usable and must still be closed
+ * independently.
+ */
+ public DataFrame unnestColumns(String... columns) {
+ return unnestColumns(new UnnestOptions(), columns);
+ }
+
+ /**
+ * Expand list or struct columns into rows or fields with the supplied {@link UnnestOptions}. The
+ * receiver remains usable and must still be closed independently.
+ *
+ * @throws IllegalArgumentException if {@code options} or {@code columns} is {@code null}.
+ */
+ public DataFrame unnestColumns(UnnestOptions options, String... columns) {
+ if (nativeHandle == 0) {
+ throw new IllegalStateException("DataFrame is closed or already collected");
+ }
+ if (options == null) {
+ throw new IllegalArgumentException("unnestColumns options must be non-null");
+ }
+ if (columns == null) {
+ throw new IllegalArgumentException("unnestColumns columns must be non-null");
+ }
+ return new DataFrame(unnestColumns(nativeHandle, columns, options.preserveNulls()));
+ }
+
+ /**
* Materialize this DataFrame as Parquet at {@code path}. The path is treated as a directory
* unless overridden via {@link ParquetWriteOptions#singleFileOutput(boolean)}. The receiver
* remains usable and must still be closed independently.
@@ -231,6 +280,10 @@
private static native long renameColumn(long handle, String oldName, String newName);
+ private static native long withColumnExpr(long handle, String name, String expr);
+
+ private static native long unnestColumns(long handle, String[] columns, boolean preserveNulls);
+
private static native void writeParquetWithOptions(
long handle,
String path,
diff --git a/core/src/main/java/org/apache/datafusion/UnnestOptions.java b/core/src/main/java/org/apache/datafusion/UnnestOptions.java
new file mode 100644
index 0000000..0dad58b
--- /dev/null
+++ b/core/src/main/java/org/apache/datafusion/UnnestOptions.java
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.datafusion;
+
+/**
+ * Configuration knobs for {@link DataFrame#unnestColumns(UnnestOptions, String...)}, mirroring
+ * DataFusion's {@code UnnestOptions}. Defaults match upstream: {@code preserveNulls = true}.
+ *
+ * <p>Per-column recursion (DataFusion's {@code recursions}) is intentionally not exposed yet — it
+ * needs a richer column-pair representation and is filed separately.
+ */
+public final class UnnestOptions {
+
+ private boolean preserveNulls = true;
+
+ /**
+ * When {@code true} (the default), nulls in the input column are preserved as null rows in the
+ * output. When {@code false}, nulls and empty lists are dropped.
+ */
+ public UnnestOptions preserveNulls(boolean v) {
+ this.preserveNulls = v;
+ return this;
+ }
+
+ /** The current {@code preserveNulls} setting. */
+ public boolean preserveNulls() {
+ return preserveNulls;
+ }
+}
diff --git a/core/src/test/java/org/apache/datafusion/DataFrameTransformationsTest.java b/core/src/test/java/org/apache/datafusion/DataFrameTransformationsTest.java
index cb5c9ef..52e9afa 100644
--- a/core/src/test/java/org/apache/datafusion/DataFrameTransformationsTest.java
+++ b/core/src/test/java/org/apache/datafusion/DataFrameTransformationsTest.java
@@ -133,6 +133,8 @@
assertThrows(IllegalStateException.class, df::distinct);
assertThrows(IllegalStateException.class, () -> df.dropColumns("x"));
assertThrows(IllegalStateException.class, () -> df.withColumnRenamed("x", "y"));
+ assertThrows(IllegalStateException.class, () -> df.withColumn("y", "x + 1"));
+ assertThrows(IllegalStateException.class, () -> df.unnestColumns("x"));
assertThrows(IllegalStateException.class, df::count);
assertThrows(IllegalStateException.class, df::show);
assertThrows(IllegalStateException.class, () -> df.show(5));
@@ -154,6 +156,8 @@
assertThrows(IllegalStateException.class, df::distinct);
assertThrows(IllegalStateException.class, () -> df.dropColumns("x"));
assertThrows(IllegalStateException.class, () -> df.withColumnRenamed("x", "y"));
+ assertThrows(IllegalStateException.class, () -> df.withColumn("y", "x + 1"));
+ assertThrows(IllegalStateException.class, () -> df.unnestColumns("x"));
assertThrows(IllegalStateException.class, df::count);
assertThrows(IllegalStateException.class, df::show);
assertThrows(IllegalStateException.class, () -> df.show(5));
@@ -353,4 +357,152 @@
root.getSchema().getFields().stream().map(f -> f.getName()).toArray(String[]::new));
}
}
+
+ @Test
+ void withColumnAppendsNewColumn() throws Exception {
+ try (BufferAllocator allocator = new RootAllocator();
+ SessionContext ctx = new SessionContext();
+ DataFrame source = ctx.sql("SELECT 1 AS a, 2 AS b");
+ DataFrame extended = source.withColumn("c", "a + b");
+ ArrowReader reader = extended.collect(allocator)) {
+ assertTrue(reader.loadNextBatch());
+ VectorSchemaRoot root = reader.getVectorSchemaRoot();
+ assertEquals(1, root.getRowCount());
+ assertArrayEquals(
+ new String[] {"a", "b", "c"},
+ root.getSchema().getFields().stream().map(f -> f.getName()).toArray(String[]::new));
+ assertEquals(3L, ((BigIntVector) root.getVector("c")).get(0));
+ }
+ }
+
+ @Test
+ void withColumnReplacesExistingColumn() throws Exception {
+ try (BufferAllocator allocator = new RootAllocator();
+ SessionContext ctx = new SessionContext();
+ DataFrame source = ctx.sql("SELECT 1 AS a, 2 AS b");
+ DataFrame replaced = source.withColumn("b", "a * 10");
+ ArrowReader reader = replaced.collect(allocator)) {
+ assertTrue(reader.loadNextBatch());
+ VectorSchemaRoot root = reader.getVectorSchemaRoot();
+ assertArrayEquals(
+ new String[] {"a", "b"},
+ root.getSchema().getFields().stream().map(f -> f.getName()).toArray(String[]::new));
+ assertEquals(10L, ((BigIntVector) root.getVector("b")).get(0));
+ }
+ }
+
+ @Test
+ void withColumnIsNonDestructive() {
+ try (SessionContext ctx = new SessionContext();
+ DataFrame source = ctx.sql("SELECT 1 AS a, 2 AS b")) {
+ try (DataFrame extended = source.withColumn("c", "a + b")) {
+ assertEquals(1L, extended.count());
+ }
+ assertEquals(1L, source.count());
+ }
+ }
+
+ @Test
+ void withColumnUnknownColumnRefThrows() {
+ try (SessionContext ctx = new SessionContext();
+ DataFrame df = ctx.sql("SELECT 1 AS x")) {
+ assertThrows(RuntimeException.class, () -> df.withColumn("y", "not_a_column + 1"));
+ }
+ }
+
+ @Test
+ void withColumnRejectsNullArgs() {
+ try (SessionContext ctx = new SessionContext();
+ DataFrame df = ctx.sql("SELECT 1 AS x")) {
+ assertThrows(IllegalArgumentException.class, () -> df.withColumn(null, "x + 1"));
+ assertThrows(IllegalArgumentException.class, () -> df.withColumn("y", null));
+ }
+ }
+
+ @Test
+ void unnestColumnsExpandsList() throws Exception {
+ try (BufferAllocator allocator = new RootAllocator();
+ SessionContext ctx = new SessionContext();
+ DataFrame source = ctx.sql("SELECT 1 AS id, [10, 20, 30] AS vals");
+ DataFrame unnested = source.unnestColumns("vals");
+ ArrowReader reader = unnested.collect(allocator)) {
+ int rows = 0;
+ while (reader.loadNextBatch()) {
+ rows += reader.getVectorSchemaRoot().getRowCount();
+ }
+ assertEquals(3, rows);
+ }
+ }
+
+ @Test
+ void unnestColumnsPreserveNullsTrueKeepsNullRow() throws Exception {
+ String sql =
+ "SELECT id, vals FROM (VALUES "
+ + "(1, [10, 20]), "
+ + "(2, CAST(NULL AS BIGINT[])), "
+ + "(3, [30])) AS t(id, vals)";
+ try (BufferAllocator allocator = new RootAllocator();
+ SessionContext ctx = new SessionContext();
+ DataFrame source = ctx.sql(sql);
+ DataFrame unnested = source.unnestColumns(new UnnestOptions().preserveNulls(true), "vals");
+ ArrowReader reader = unnested.collect(allocator)) {
+ int rows = 0;
+ while (reader.loadNextBatch()) {
+ rows += reader.getVectorSchemaRoot().getRowCount();
+ }
+ assertEquals(4, rows);
+ }
+ }
+
+ @Test
+ void unnestColumnsPreserveNullsFalseDropsNullRow() throws Exception {
+ String sql =
+ "SELECT id, vals FROM (VALUES "
+ + "(1, [10, 20]), "
+ + "(2, CAST(NULL AS BIGINT[])), "
+ + "(3, [30])) AS t(id, vals)";
+ try (BufferAllocator allocator = new RootAllocator();
+ SessionContext ctx = new SessionContext();
+ DataFrame source = ctx.sql(sql);
+ DataFrame unnested =
+ source.unnestColumns(new UnnestOptions().preserveNulls(false), "vals");
+ ArrowReader reader = unnested.collect(allocator)) {
+ int rows = 0;
+ while (reader.loadNextBatch()) {
+ rows += reader.getVectorSchemaRoot().getRowCount();
+ }
+ assertEquals(3, rows);
+ }
+ }
+
+ @Test
+ void unnestColumnsIsNonDestructive() {
+ try (SessionContext ctx = new SessionContext();
+ DataFrame source = ctx.sql("SELECT 1 AS id, [10, 20] AS vals")) {
+ try (DataFrame unnested = source.unnestColumns("vals")) {
+ assertEquals(2L, unnested.count());
+ }
+ assertEquals(1L, source.count());
+ }
+ }
+
+ @Test
+ void unnestColumnsUnknownColumnThrows() {
+ try (SessionContext ctx = new SessionContext();
+ DataFrame df = ctx.sql("SELECT 1 AS x")) {
+ assertThrows(RuntimeException.class, () -> df.unnestColumns("not_a_column"));
+ }
+ }
+
+ @Test
+ void unnestColumnsRejectsNullArgs() {
+ try (SessionContext ctx = new SessionContext();
+ DataFrame df = ctx.sql("SELECT 1 AS x, [1, 2] AS vals")) {
+ assertThrows(
+ IllegalArgumentException.class, () -> df.unnestColumns((UnnestOptions) null, "vals"));
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> df.unnestColumns(new UnnestOptions(), (String[]) null));
+ }
+ }
}
diff --git a/core/src/test/java/org/apache/datafusion/UnnestOptionsTest.java b/core/src/test/java/org/apache/datafusion/UnnestOptionsTest.java
new file mode 100644
index 0000000..e80301a
--- /dev/null
+++ b/core/src/test/java/org/apache/datafusion/UnnestOptionsTest.java
@@ -0,0 +1,41 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.datafusion;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+class UnnestOptionsTest {
+
+ @Test
+ void defaultPreserveNullsIsTrue() {
+ assertTrue(new UnnestOptions().preserveNulls());
+ }
+
+ @Test
+ void preserveNullsSetterRoundTrips() {
+ UnnestOptions opts = new UnnestOptions().preserveNulls(false);
+ assertFalse(opts.preserveNulls());
+ opts.preserveNulls(true);
+ assertTrue(opts.preserveNulls());
+ }
+}
diff --git a/native/src/lib.rs b/native/src/lib.rs
index efd7801..b8ec53f 100644
--- a/native/src/lib.rs
+++ b/native/src/lib.rs
@@ -31,6 +31,7 @@
use datafusion::arrow::datatypes::SchemaRef;
use datafusion::arrow::ffi_stream::FFI_ArrowArrayStream;
use datafusion::arrow::record_batch::RecordBatchIterator;
+use datafusion::common::UnnestOptions;
use datafusion::config::TableParquetOptions;
use datafusion::dataframe::DataFrame;
use datafusion::dataframe::DataFrameWriteOptions;
@@ -360,6 +361,56 @@
}
#[no_mangle]
+pub extern "system" fn Java_org_apache_datafusion_DataFrame_withColumnExpr<'local>(
+ mut env: JNIEnv<'local>,
+ _class: JClass<'local>,
+ handle: jlong,
+ name: JString<'local>,
+ expr: JString<'local>,
+) -> jlong {
+ try_unwrap_or_throw(&mut env, 0, |env| -> JniResult<jlong> {
+ if handle == 0 {
+ return Err("DataFrame handle is null".into());
+ }
+ let df = unsafe { &*(handle as *const DataFrame) }.clone();
+ let name: String = env.get_string(&name)?.into();
+ let expr: String = env.get_string(&expr)?.into();
+ let parsed = df.parse_sql_expr(&expr)?;
+ let new_df = df.with_column(&name, parsed)?;
+ Ok(Box::into_raw(Box::new(new_df)) as jlong)
+ })
+}
+
+#[no_mangle]
+pub extern "system" fn Java_org_apache_datafusion_DataFrame_unnestColumns<'local>(
+ mut env: JNIEnv<'local>,
+ _class: JClass<'local>,
+ handle: jlong,
+ columns: JObjectArray<'local>,
+ preserve_nulls: jboolean,
+) -> jlong {
+ try_unwrap_or_throw(&mut env, 0, |env| -> JniResult<jlong> {
+ if handle == 0 {
+ return Err("DataFrame handle is null".into());
+ }
+ let df = unsafe { &*(handle as *const DataFrame) }.clone();
+
+ let len = env.get_array_length(&columns)?;
+ let mut owned: Vec<String> = Vec::with_capacity(len as usize);
+ for i in 0..len {
+ let elem = env.get_object_array_element(&columns, i)?;
+ let jstr: JString = elem.into();
+ owned.push(env.get_string(&jstr)?.into());
+ }
+ let refs: Vec<&str> = owned.iter().map(String::as_str).collect();
+
+ let opts = UnnestOptions::new().with_preserve_nulls(preserve_nulls != 0);
+ let new_df = df.unnest_columns_with_options(&refs, opts)?;
+ Ok(Box::into_raw(Box::new(new_df)) as jlong)
+ })
+}
+
+#[no_mangle]
pub extern "system" fn Java_org_apache_datafusion_DataFrame_writeParquetWithOptions<'local>(
mut env: JNIEnv<'local>,
_class: JClass<'local>,