This is an automated email from the ASF dual-hosted git repository.

andygrove pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-java.git


The following commit(s) were added to refs/heads/main by this push:
     new 678eee1  feat(dataframe): expose withColumn and unnestColumns (#54)
678eee1 is described below

commit 678eee128992dcd9399abd7c0df05a455087d86a
Author: Lantao Jin <[email protected]>
AuthorDate: Mon May 18 05:20:33 2026 +0800

    feat(dataframe): expose withColumn and unnestColumns (#54)
---
 .../main/java/org/apache/datafusion/DataFrame.java |  53 +++++++
 .../java/org/apache/datafusion/UnnestOptions.java  |  46 +++++++
 .../datafusion/DataFrameTransformationsTest.java   | 152 +++++++++++++++++++++
 .../org/apache/datafusion/UnnestOptionsTest.java   |  41 ++++++
 native/src/lib.rs                                  |  51 +++++++
 5 files changed, 343 insertions(+)

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
@@ -171,6 +171,55 @@ public final class DataFrame implements AutoCloseable {
     return new DataFrame(renameColumn(nativeHandle, oldName, newName));
   }
 
+  /**
+   * 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
@@ -231,6 +280,10 @@ public final class DataFrame implements AutoCloseable {
 
   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 @@ class DataFrameTransformationsTest {
       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 @@ class DataFrameTransformationsTest {
       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 @@ class DataFrameTransformationsTest {
           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 std::sync::{Arc, OnceLock};
 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;
@@ -359,6 +360,56 @@ pub extern "system" fn 
Java_org_apache_datafusion_DataFrame_renameColumn<'local>
     })
 }
 
+#[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>,


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to