feat(dataframe): add join, joinOn, and JoinType (#72)
diff --git a/core/src/main/java/org/apache/datafusion/DataFrame.java b/core/src/main/java/org/apache/datafusion/DataFrame.java
index 2ae5ade..3669b55 100644
--- a/core/src/main/java/org/apache/datafusion/DataFrame.java
+++ b/core/src/main/java/org/apache/datafusion/DataFrame.java
@@ -335,6 +335,121 @@
   }
 
   /**
+   * Equi-join this DataFrame with {@code right} on the named columns, using the given {@link
+   * JoinType}. The receiver and {@code right} both remain usable and must still be closed
+   * independently.
+   *
+   * <p>Equivalent to SQL {@code left <type> JOIN right ON l.leftCols[0] = r.rightCols[0] AND ...}.
+   * {@code leftCols} and {@code rightCols} must have the same length.
+   *
+   * @throws IllegalArgumentException if any argument is {@code null} or {@code leftCols.length !=
+   *     rightCols.length}.
+   * @throws IllegalStateException if either DataFrame is closed or already collected.
+   * @throws RuntimeException if join planning fails (column collision in the combined schema,
+   *     unknown column names, etc.).
+   */
+  public DataFrame join(DataFrame right, JoinType type, String[] leftCols, String[] rightCols) {
+    checkJoinArgs(right, type, leftCols, rightCols);
+    return new DataFrame(
+        joinDataFrame(nativeHandle, right.nativeHandle, type.code(), leftCols, rightCols, null));
+  }
+
+  /**
+   * Equi-join this DataFrame with {@code right}, restricting the result with a residual SQL filter
+   * parsed against the <em>combined</em> schema (left columns followed by right columns; columns
+   * may be qualified with the relation alias when ambiguous). The receiver and {@code right} both
+   * remain usable and must still be closed independently.
+   *
+   * <p>For outer joins, the filter is applied only to matched rows; unmatched rows are passed
+   * through with nulls on the unmatched side, matching DataFusion's semantics.
+   *
+   * @throws IllegalArgumentException if any argument is {@code null} or {@code leftCols.length !=
+   *     rightCols.length}.
+   * @throws IllegalStateException if either DataFrame is closed or already collected.
+   * @throws RuntimeException if join planning or filter parsing fails.
+   */
+  public DataFrame join(
+      DataFrame right, JoinType type, String[] leftCols, String[] rightCols, String filter) {
+    checkJoinArgs(right, type, leftCols, rightCols);
+    if (filter == null) {
+      throw new IllegalArgumentException("join filter must be non-null");
+    }
+    return new DataFrame(
+        joinDataFrame(nativeHandle, right.nativeHandle, type.code(), leftCols, rightCols, filter));
+  }
+
+  /**
+   * Join this DataFrame with {@code right} using arbitrary SQL predicates parsed against the
+   * <em>combined</em> schema. Each predicate is parsed independently and the join evaluates their
+   * conjunction. Predicates may reference columns from either side and may be qualified with the
+   * relation alias when ambiguous (e.g. {@code "left.x = right.x"}). The receiver and {@code right}
+   * both remain usable and must still be closed independently.
+   *
+   * <p>DataFusion's optimiser identifies and rewrites equality predicates into hash-join keys
+   * automatically, so {@code joinOn(right, INNER, "l.id = r.id")} plans equivalently to {@link
+   * #join(DataFrame, JoinType, String[], String[])} with a single key. Use {@code joinOn} when the
+   * predicate is not a simple equality, e.g. inequality joins or range conditions.
+   *
+   * @throws IllegalArgumentException if {@code right} or {@code type} is {@code null}, or {@code
+   *     predicates} is {@code null} or empty, or any predicate is {@code null}.
+   * @throws IllegalStateException if either DataFrame is closed or already collected.
+   * @throws RuntimeException if predicate parsing or join planning fails.
+   */
+  public DataFrame joinOn(DataFrame right, JoinType type, String... predicates) {
+    if (right == null) {
+      throw new IllegalArgumentException("joinOn right must be non-null");
+    }
+    if (type == null) {
+      throw new IllegalArgumentException("joinOn type must be non-null");
+    }
+    if (predicates == null || predicates.length == 0) {
+      throw new IllegalArgumentException("joinOn predicates must be non-null and non-empty");
+    }
+    for (String p : predicates) {
+      if (p == null) {
+        throw new IllegalArgumentException("joinOn predicates must not contain null");
+      }
+    }
+    if (nativeHandle == 0) {
+      throw new IllegalStateException("DataFrame is closed or already collected");
+    }
+    if (right.nativeHandle == 0) {
+      throw new IllegalStateException("right DataFrame is closed or already collected");
+    }
+    return new DataFrame(
+        joinOnDataFrame(nativeHandle, right.nativeHandle, type.code(), predicates));
+  }
+
+  private void checkJoinArgs(
+      DataFrame right, JoinType type, String[] leftCols, String[] rightCols) {
+    if (right == null) {
+      throw new IllegalArgumentException("join right must be non-null");
+    }
+    if (type == null) {
+      throw new IllegalArgumentException("join type must be non-null");
+    }
+    if (leftCols == null) {
+      throw new IllegalArgumentException("join leftCols must be non-null");
+    }
+    if (rightCols == null) {
+      throw new IllegalArgumentException("join rightCols must be non-null");
+    }
+    if (leftCols.length != rightCols.length) {
+      throw new IllegalArgumentException(
+          "join leftCols and rightCols must have the same length, got "
+              + leftCols.length
+              + " and "
+              + rightCols.length);
+    }
+    if (nativeHandle == 0) {
+      throw new IllegalStateException("DataFrame is closed or already collected");
+    }
+    if (right.nativeHandle == 0) {
+      throw new IllegalStateException("right DataFrame is closed or already collected");
+    }
+  }
+
+  /**
    * 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.
@@ -472,6 +587,17 @@
 
   private static native long unnestColumns(long handle, String[] columns, boolean preserveNulls);
 
+  private static native long joinDataFrame(
+      long leftHandle,
+      long rightHandle,
+      byte joinType,
+      String[] leftCols,
+      String[] rightCols,
+      String filter);
+
+  private static native long joinOnDataFrame(
+      long leftHandle, long rightHandle, byte joinType, String[] predicates);
+
   private static native void writeParquetWithOptions(
       long handle,
       String path,
diff --git a/core/src/main/java/org/apache/datafusion/JoinType.java b/core/src/main/java/org/apache/datafusion/JoinType.java
new file mode 100644
index 0000000..3c3334a
--- /dev/null
+++ b/core/src/main/java/org/apache/datafusion/JoinType.java
@@ -0,0 +1,61 @@
+/*
+ * 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;
+
+/**
+ * Join algorithm requested for {@link DataFrame#join} / {@link DataFrame#joinOn}. Mirrors
+ * DataFusion's {@code JoinType} enum one-to-one.
+ *
+ * <ul>
+ *   <li>{@link #INNER} — rows where the join condition matches in both sides.
+ *   <li>{@link #LEFT} / {@link #RIGHT} — outer joins; unmatched rows on the named side are kept and
+ *       padded with nulls on the other side.
+ *   <li>{@link #FULL} — full outer join; unmatched rows from either side are kept with nulls.
+ *   <li>{@link #LEFT_SEMI} / {@link #RIGHT_SEMI} — returns rows from the named side that have at
+ *       least one match on the other; only the named side's columns appear in the output.
+ *   <li>{@link #LEFT_ANTI} / {@link #RIGHT_ANTI} — returns rows from the named side that have no
+ *       match on the other; only the named side's columns appear in the output.
+ *   <li>{@link #LEFT_MARK} / {@link #RIGHT_MARK} — returns one row per row of the named side, with
+ *       an additional boolean {@code mark} column indicating whether the join condition matched.
+ * </ul>
+ */
+public enum JoinType {
+  INNER((byte) 0),
+  LEFT((byte) 1),
+  RIGHT((byte) 2),
+  FULL((byte) 3),
+  LEFT_SEMI((byte) 4),
+  RIGHT_SEMI((byte) 5),
+  LEFT_ANTI((byte) 6),
+  RIGHT_ANTI((byte) 7),
+  LEFT_MARK((byte) 8),
+  RIGHT_MARK((byte) 9);
+
+  private final byte code;
+
+  JoinType(byte code) {
+    this.code = code;
+  }
+
+  /** Stable byte code for FFI. */
+  public byte code() {
+    return code;
+  }
+}
diff --git a/core/src/test/java/org/apache/datafusion/DataFrameJoinTest.java b/core/src/test/java/org/apache/datafusion/DataFrameJoinTest.java
new file mode 100644
index 0000000..b118ea2
--- /dev/null
+++ b/core/src/test/java/org/apache/datafusion/DataFrameJoinTest.java
@@ -0,0 +1,294 @@
+/*
+ * 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.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+
+class DataFrameJoinTest {
+
+  // Two relations with one matching key (1, 2) and a few unmatched rows on each side.
+  // left: (1,'a'), (2,'b'), (3,'c'); right: (1,10), (2,20), (4,40).
+  private static final String LEFT_SQL =
+      "SELECT * FROM (VALUES (1, 'a'), (2, 'b'), (3, 'c')) AS l(id, s)";
+  private static final String RIGHT_SQL =
+      "SELECT * FROM (VALUES (1, 10), (2, 20), (4, 40)) AS r(id, v)";
+
+  @Test
+  void innerJoinOnSingleColumn() {
+    try (SessionContext ctx = new SessionContext();
+        DataFrame left = ctx.sql(LEFT_SQL);
+        DataFrame right = ctx.sql(RIGHT_SQL);
+        DataFrame joined =
+            left.join(right, JoinType.INNER, new String[] {"id"}, new String[] {"id"})) {
+      assertEquals(2L, joined.count()); // (1,'a',1,10) and (2,'b',2,20)
+    }
+  }
+
+  @Test
+  void innerJoinOnMultipleColumns() {
+    try (SessionContext ctx = new SessionContext();
+        DataFrame l =
+            ctx.sql("SELECT * FROM (VALUES (1, 'x', 100), (2, 'y', 200)) AS t(a, b, l_other)");
+        DataFrame r =
+            ctx.sql("SELECT * FROM (VALUES (1, 'x', 'p'), (2, 'z', 'q')) AS t(a2, b2, r_other)");
+        DataFrame joined =
+            l.join(r, JoinType.INNER, new String[] {"a", "b"}, new String[] {"a2", "b2"})) {
+      assertEquals(1L, joined.count()); // only (1,'x') matches on both keys
+    }
+  }
+
+  @Test
+  void leftJoinPreservesUnmatchedLeft() {
+    try (SessionContext ctx = new SessionContext();
+        DataFrame left = ctx.sql(LEFT_SQL);
+        DataFrame right = ctx.sql(RIGHT_SQL);
+        DataFrame joined =
+            left.join(right, JoinType.LEFT, new String[] {"id"}, new String[] {"id"})) {
+      // 3 left rows; unmatched (3,'c') gets nulls on the right side.
+      assertEquals(3L, joined.count());
+    }
+  }
+
+  @Test
+  void rightJoinPreservesUnmatchedRight() {
+    try (SessionContext ctx = new SessionContext();
+        DataFrame left = ctx.sql(LEFT_SQL);
+        DataFrame right = ctx.sql(RIGHT_SQL);
+        DataFrame joined =
+            left.join(right, JoinType.RIGHT, new String[] {"id"}, new String[] {"id"})) {
+      // 3 right rows; unmatched (4,40) gets nulls on the left side.
+      assertEquals(3L, joined.count());
+    }
+  }
+
+  @Test
+  void fullJoinPreservesBothSides() {
+    try (SessionContext ctx = new SessionContext();
+        DataFrame left = ctx.sql(LEFT_SQL);
+        DataFrame right = ctx.sql(RIGHT_SQL);
+        DataFrame joined =
+            left.join(right, JoinType.FULL, new String[] {"id"}, new String[] {"id"})) {
+      // 2 matched rows + 1 unmatched-left + 1 unmatched-right = 4.
+      assertEquals(4L, joined.count());
+    }
+  }
+
+  @Test
+  void leftSemiJoinReturnsLeftMatchedOnly() {
+    try (SessionContext ctx = new SessionContext();
+        DataFrame left = ctx.sql(LEFT_SQL);
+        DataFrame right = ctx.sql(RIGHT_SQL);
+        DataFrame joined =
+            left.join(right, JoinType.LEFT_SEMI, new String[] {"id"}, new String[] {"id"})) {
+      // Only the 2 left rows that have a matching right row.
+      // Output projects left side only (id, s) — right columns dropped.
+      assertEquals(2L, joined.count());
+    }
+  }
+
+  @Test
+  void leftAntiJoinReturnsLeftUnmatchedOnly() {
+    try (SessionContext ctx = new SessionContext();
+        DataFrame left = ctx.sql(LEFT_SQL);
+        DataFrame right = ctx.sql(RIGHT_SQL);
+        DataFrame joined =
+            left.join(right, JoinType.LEFT_ANTI, new String[] {"id"}, new String[] {"id"})) {
+      // Only the 1 left row (3,'c') with no right match. Output projects left side only.
+      assertEquals(1L, joined.count());
+    }
+  }
+
+  @Test
+  void rightSemiJoinReturnsRightMatchedOnly() {
+    try (SessionContext ctx = new SessionContext();
+        DataFrame left = ctx.sql(LEFT_SQL);
+        DataFrame right = ctx.sql(RIGHT_SQL);
+        DataFrame joined =
+            left.join(right, JoinType.RIGHT_SEMI, new String[] {"id"}, new String[] {"id"})) {
+      // Output projects right side only (id, v) — left columns dropped.
+      assertEquals(2L, joined.count());
+    }
+  }
+
+  @Test
+  void rightAntiJoinReturnsRightUnmatchedOnly() {
+    try (SessionContext ctx = new SessionContext();
+        DataFrame left = ctx.sql(LEFT_SQL);
+        DataFrame right = ctx.sql(RIGHT_SQL);
+        DataFrame joined =
+            left.join(right, JoinType.RIGHT_ANTI, new String[] {"id"}, new String[] {"id"})) {
+      assertEquals(1L, joined.count()); // (4, 40)
+    }
+  }
+
+  @Test
+  void leftMarkJoinAddsMarkColumn() {
+    try (SessionContext ctx = new SessionContext();
+        DataFrame left = ctx.sql(LEFT_SQL);
+        DataFrame right = ctx.sql(RIGHT_SQL);
+        DataFrame joined =
+            left.join(right, JoinType.LEFT_MARK, new String[] {"id"}, new String[] {"id"})) {
+      // One row per left row, plus a 'mark' boolean column.
+      assertEquals(3L, joined.count());
+    }
+  }
+
+  @Test
+  void joinWithResidualFilter() {
+    try (SessionContext ctx = new SessionContext();
+        DataFrame left = ctx.sql(LEFT_SQL);
+        DataFrame right = ctx.sql(RIGHT_SQL);
+        DataFrame joined =
+            left.join(right, JoinType.INNER, new String[] {"id"}, new String[] {"id"}, "v >= 20")) {
+      // Without the filter: 2 matched rows. With v >= 20: only (2,'b',2,20).
+      assertEquals(1L, joined.count());
+    }
+  }
+
+  @Test
+  void joinOnSingleEqualityPredicate() {
+    try (SessionContext ctx = new SessionContext();
+        DataFrame left = ctx.sql(LEFT_SQL);
+        DataFrame right = ctx.sql(RIGHT_SQL);
+        DataFrame joined = left.joinOn(right, JoinType.INNER, "l.id = r.id")) {
+      assertEquals(2L, joined.count());
+    }
+  }
+
+  @Test
+  void joinOnInequalityPredicate() {
+    try (SessionContext ctx = new SessionContext();
+        DataFrame left = ctx.sql(LEFT_SQL);
+        DataFrame right = ctx.sql(RIGHT_SQL);
+        DataFrame joined = left.joinOn(right, JoinType.INNER, "l.id < r.id")) {
+      // Pairs (1,'a')<(2,20), (1,'a')<(4,40), (2,'b')<(4,40), (3,'c')<(4,40) = 4.
+      assertEquals(4L, joined.count());
+    }
+  }
+
+  @Test
+  void joinOnMultiplePredicates() {
+    try (SessionContext ctx = new SessionContext();
+        DataFrame left = ctx.sql(LEFT_SQL);
+        DataFrame right = ctx.sql(RIGHT_SQL);
+        DataFrame joined = left.joinOn(right, JoinType.INNER, "l.id = r.id", "r.v > 15")) {
+      // Equality narrows to (1,'a',1,10) and (2,'b',2,20); v > 15 leaves only the second.
+      assertEquals(1L, joined.count());
+    }
+  }
+
+  @Test
+  void semiJoinWithFilterToleratesSharedUnqualifiedColumn() {
+    // Regression for issue surfaced in code review: when both inputs carry an unqualified
+    // column with the same name (here, `tag`) that the residual filter does NOT reference,
+    // the join must still plan. Earlier the Rust side merged the schemas via
+    // DFSchema::join, whose check_names rejected the duplicate before parsing the filter.
+    try (SessionContext ctx = new SessionContext();
+        DataFrame left = ctx.sql("SELECT 1 AS id, 'l' AS tag");
+        DataFrame right = ctx.sql("SELECT 1 AS rid, 99 AS rv, 'r' AS tag");
+        DataFrame joined =
+            left.join(
+                right, JoinType.LEFT_SEMI, new String[] {"id"}, new String[] {"rid"}, "rv > 0")) {
+      assertEquals(1L, joined.count());
+    }
+  }
+
+  @Test
+  void joinOnToleratesSharedUnqualifiedColumn() {
+    // Same regression as the previous test, but exercises the joinOn predicate path.
+    // Uses LEFT_SEMI so the output schema is one-sided -- INNER joins on inputs that share
+    // an unqualified column name are genuinely ambiguous in the result and rejected by
+    // upstream's build_join_schema, which is not specific to our code.
+    try (SessionContext ctx = new SessionContext();
+        DataFrame left = ctx.sql("SELECT 1 AS id, 'l' AS tag");
+        DataFrame right = ctx.sql("SELECT 1 AS rid, 99 AS rv, 'r' AS tag");
+        DataFrame joined = left.joinOn(right, JoinType.LEFT_SEMI, "id = rid", "rv > 0")) {
+      assertEquals(1L, joined.count());
+    }
+  }
+
+  @Test
+  void joinPreservesReceivers() {
+    try (SessionContext ctx = new SessionContext();
+        DataFrame left = ctx.sql(LEFT_SQL);
+        DataFrame right = ctx.sql(RIGHT_SQL)) {
+      try (DataFrame joined =
+          left.join(right, JoinType.INNER, new String[] {"id"}, new String[] {"id"})) {
+        assertEquals(2L, joined.count());
+      }
+      // Both receivers still usable after join().
+      assertEquals(3L, left.count());
+      assertEquals(3L, right.count());
+    }
+  }
+
+  @Test
+  void joinThrowsWhenLeftClosed() {
+    try (SessionContext ctx = new SessionContext();
+        DataFrame right = ctx.sql(RIGHT_SQL)) {
+      DataFrame left = ctx.sql(LEFT_SQL);
+      left.close();
+      assertThrows(
+          IllegalStateException.class,
+          () -> left.join(right, JoinType.INNER, new String[] {"id"}, new String[] {"id"}));
+    }
+  }
+
+  @Test
+  void joinThrowsWhenRightClosed() {
+    try (SessionContext ctx = new SessionContext();
+        DataFrame left = ctx.sql(LEFT_SQL)) {
+      DataFrame right = ctx.sql(RIGHT_SQL);
+      right.close();
+      assertThrows(
+          IllegalStateException.class,
+          () -> left.join(right, JoinType.INNER, new String[] {"id"}, new String[] {"id"}));
+    }
+  }
+
+  @Test
+  void joinNullArgumentValidation() {
+    try (SessionContext ctx = new SessionContext();
+        DataFrame left = ctx.sql(LEFT_SQL);
+        DataFrame right = ctx.sql(RIGHT_SQL)) {
+      String[] cols = new String[] {"id"};
+      assertThrows(
+          IllegalArgumentException.class, () -> left.join(null, JoinType.INNER, cols, cols));
+      assertThrows(IllegalArgumentException.class, () -> left.join(right, null, cols, cols));
+      assertThrows(
+          IllegalArgumentException.class, () -> left.join(right, JoinType.INNER, null, cols));
+      assertThrows(
+          IllegalArgumentException.class, () -> left.join(right, JoinType.INNER, cols, null));
+      assertThrows(
+          IllegalArgumentException.class,
+          () -> left.join(right, JoinType.INNER, new String[] {"id", "id"}, cols));
+      assertThrows(
+          IllegalArgumentException.class, () -> left.join(right, JoinType.INNER, cols, cols, null));
+      assertThrows(IllegalArgumentException.class, () -> left.joinOn(null, JoinType.INNER, "1=1"));
+      assertThrows(IllegalArgumentException.class, () -> left.joinOn(right, null, "1=1"));
+      assertThrows(IllegalArgumentException.class, () -> left.joinOn(right, JoinType.INNER));
+      assertThrows(
+          IllegalArgumentException.class, () -> left.joinOn(right, JoinType.INNER, (String) null));
+    }
+  }
+}
diff --git a/native/src/lib.rs b/native/src/lib.rs
index cebcb22..191c6f1 100644
--- a/native/src/lib.rs
+++ b/native/src/lib.rs
@@ -41,18 +41,19 @@
 use datafusion::arrow::ffi_stream::FFI_ArrowArrayStream;
 use datafusion::arrow::ipc::writer::StreamWriter;
 use datafusion::arrow::record_batch::{RecordBatchIterator, RecordBatchReader};
-use datafusion::common::UnnestOptions;
+use datafusion::common::{JoinType, UnnestOptions};
 use datafusion::config::TableParquetOptions;
 use datafusion::dataframe::DataFrame;
 use datafusion::dataframe::DataFrameWriteOptions;
 use datafusion::error::DataFusionError;
 use datafusion::execution::runtime_env::RuntimeEnvBuilder;
 use datafusion::execution::SendableRecordBatchStream;
+use datafusion::logical_expr::Expr;
 use datafusion::logical_expr::{ScalarUDF, Signature};
 use datafusion::prelude::{ParquetReadOptions, SessionConfig, SessionContext};
 use futures::StreamExt;
 use jni::objects::{JByteArray, JClass, JObject, JObjectArray, JString};
-use jni::sys::{jboolean, jbyteArray, jint, jlong};
+use jni::sys::{jboolean, jbyte, jbyteArray, jint, jlong};
 use jni::JNIEnv;
 use jni::JavaVM;
 use prost::Message;
@@ -596,6 +597,135 @@
     })
 }
 
+/// Map a Java {@code JoinType.code()} byte back to upstream's enum.
+fn join_type_from_byte(byte: u8) -> JniResult<JoinType> {
+    match byte {
+        0 => Ok(JoinType::Inner),
+        1 => Ok(JoinType::Left),
+        2 => Ok(JoinType::Right),
+        3 => Ok(JoinType::Full),
+        4 => Ok(JoinType::LeftSemi),
+        5 => Ok(JoinType::RightSemi),
+        6 => Ok(JoinType::LeftAnti),
+        7 => Ok(JoinType::RightAnti),
+        8 => Ok(JoinType::LeftMark),
+        9 => Ok(JoinType::RightMark),
+        other => Err(format!("unknown join type byte: {other}").into()),
+    }
+}
+
+/// Build a combined DFSchema for SQL parsing of a join filter or `joinOn` predicate.
+/// Mirrors how upstream's `LogicalPlanBuilder::join_detailed` normalises the parsed Expr
+/// against `&[&[left_schema, right_schema]]`: tolerate unrelated duplicate-named columns
+/// rather than rejecting them via `DFSchema::join`'s `check_names`. `DFSchema::merge`
+/// skips duplicates (left side wins for unqualified collisions), which is fine for the
+/// SQL-to-Expr step -- the subsequent join planner runs the real ambiguity check.
+fn combine_schemas(
+    left: &datafusion::common::DFSchema,
+    right: &datafusion::common::DFSchema,
+) -> datafusion::common::DFSchema {
+    let mut combined = left.clone();
+    combined.merge(right);
+    combined
+}
+
+/// Drain a Java {@code String[]} into an owned {@code Vec<String>}.
+fn collect_jstring_array(env: &mut JNIEnv, arr: &JObjectArray) -> JniResult<Vec<String>> {
+    let len = env.get_array_length(arr)?;
+    let mut owned: Vec<String> = Vec::with_capacity(len as usize);
+    for i in 0..len {
+        let elem = env.get_object_array_element(arr, i)?;
+        let jstr: JString = elem.into();
+        owned.push(env.get_string(&jstr)?.into());
+    }
+    Ok(owned)
+}
+
+#[no_mangle]
+#[allow(clippy::too_many_arguments)]
+pub extern "system" fn Java_org_apache_datafusion_DataFrame_joinDataFrame<'local>(
+    mut env: JNIEnv<'local>,
+    _class: JClass<'local>,
+    left_handle: jlong,
+    right_handle: jlong,
+    join_type: jbyte,
+    left_cols: JObjectArray<'local>,
+    right_cols: JObjectArray<'local>,
+    filter: JString<'local>,
+) -> jlong {
+    try_unwrap_or_throw(&mut env, 0, |env| -> JniResult<jlong> {
+        if left_handle == 0 {
+            return Err("left DataFrame handle is null".into());
+        }
+        if right_handle == 0 {
+            return Err("right DataFrame handle is null".into());
+        }
+        let left = unsafe { &*(left_handle as *const DataFrame) }.clone();
+        let right = unsafe { &*(right_handle as *const DataFrame) }.clone();
+        let join_type = join_type_from_byte(join_type as u8)?;
+
+        let left_owned: Vec<String> = collect_jstring_array(env, &left_cols)?;
+        let right_owned: Vec<String> = collect_jstring_array(env, &right_cols)?;
+        let left_refs: Vec<&str> = left_owned.iter().map(String::as_str).collect();
+        let right_refs: Vec<&str> = right_owned.iter().map(String::as_str).collect();
+
+        // The optional residual filter spans both sides and must be parsed against the
+        // combined schema. parse_sql_expr only sees one DataFrame's schema, so reach into
+        // the SessionState via into_parts() on a clone. Use DFSchema::merge rather than
+        // DFSchema::join so the parser tolerates unrelated duplicate unqualified columns
+        // shared by both sides (e.g. both inputs carrying a `created_at` field) -- merge
+        // skips the duplicates while join's check_names rejects them. Upstream's join
+        // path normalises the parsed Expr against both schemas as a precedence list, so
+        // ambiguous references genuinely used in the filter are still surfaced after
+        // parsing.
+        let filter_expr: Option<Expr> = if filter.is_null() {
+            None
+        } else {
+            let filter_sql: String = env.get_string(&filter)?.into();
+            let combined = combine_schemas(left.schema(), right.schema());
+            let (state, _plan) = left.clone().into_parts();
+            Some(state.create_logical_expr(&filter_sql, &combined)?)
+        };
+
+        let new_df = left.join(right, join_type, &left_refs, &right_refs, filter_expr)?;
+        Ok(Box::into_raw(Box::new(new_df)) as jlong)
+    })
+}
+
+#[no_mangle]
+pub extern "system" fn Java_org_apache_datafusion_DataFrame_joinOnDataFrame<'local>(
+    mut env: JNIEnv<'local>,
+    _class: JClass<'local>,
+    left_handle: jlong,
+    right_handle: jlong,
+    join_type: jbyte,
+    predicates: JObjectArray<'local>,
+) -> jlong {
+    try_unwrap_or_throw(&mut env, 0, |env| -> JniResult<jlong> {
+        if left_handle == 0 {
+            return Err("left DataFrame handle is null".into());
+        }
+        if right_handle == 0 {
+            return Err("right DataFrame handle is null".into());
+        }
+        let left = unsafe { &*(left_handle as *const DataFrame) }.clone();
+        let right = unsafe { &*(right_handle as *const DataFrame) }.clone();
+        let join_type = join_type_from_byte(join_type as u8)?;
+
+        let predicates_owned: Vec<String> = collect_jstring_array(env, &predicates)?;
+        // See joinDataFrame for the rationale behind combine_schemas vs DFSchema::join.
+        let combined = combine_schemas(left.schema(), right.schema());
+        let (state, _plan) = left.clone().into_parts();
+        let exprs: Vec<Expr> = predicates_owned
+            .iter()
+            .map(|sql| state.create_logical_expr(sql, &combined))
+            .collect::<datafusion::error::Result<Vec<_>>>()?;
+
+        let new_df = left.join_on(right, join_type, exprs)?;
+        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>,