You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@arrow.apache.org by li...@apache.org on 2022/07/07 15:04:32 UTC

[arrow-adbc] branch main updated: Refactor the Derby driver into a JDBC driver (#30)

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

lidavidm pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-adbc.git


The following commit(s) were added to refs/heads/main by this push:
     new 308907b  Refactor the Derby driver into a JDBC driver (#30)
308907b is described below

commit 308907b9e29362c4c708622f3fb5545a7d590df8
Author: David Li <li...@gmail.com>
AuthorDate: Thu Jul 7 11:04:27 2022 -0400

    Refactor the Derby driver into a JDBC driver (#30)
---
 .gitignore                                         |   2 +
 .../org/apache/arrow/adbc/core/AdbcConnection.java |  22 +
 .../org/apache/arrow/adbc/core/AdbcException.java  |  64 ++-
 .../{AdbcConnection.java => AdbcStatusCode.java}   |  22 +-
 .../adbc/drivermanager/AdbcDriverManager.java      |   4 +-
 java/driver/{derby => jdbc-util}/pom.xml           |  28 +-
 .../arrow/adbc/driver/jdbc/util/ColumnBinder.java  |  57 +++
 .../jdbc/util/ColumnBinderArrowTypeVisitor.java    | 170 +++++++
 .../arrow/adbc/driver/jdbc/util/ColumnBinders.java | 202 ++++++++
 .../adbc/driver/jdbc/util/JdbcParameterBinder.java | 158 ++++++
 .../driver/jdbc/util/JdbcParameterBinderTest.java  | 310 ++++++++++++
 .../driver/jdbc/util/MockPreparedStatement.java    | 530 +++++++++++++++++++++
 java/driver/{derby => jdbc}/pom.xml                |  10 +-
 .../arrow/adbc/driver/jdbc}/JdbcArrowReader.java   |  15 +-
 .../arrow/adbc/driver/jdbc/JdbcConnection.java}    |  27 +-
 .../arrow/adbc/driver/jdbc/JdbcDatabase.java}      |  30 +-
 .../apache/arrow/adbc/driver/jdbc/JdbcDriver.java} |  10 +-
 .../arrow/adbc/driver/jdbc/JdbcDriverUtil.java}    |  25 +-
 .../arrow/adbc/driver/jdbc/JdbcStatement.java}     |  38 +-
 .../arrow/adbc/driver/jdbc/JdbcDatabaseTest.java}  |  18 +-
 java/pom.xml                                       |  21 +-
 21 files changed, 1654 insertions(+), 109 deletions(-)

diff --git a/.gitignore b/.gitignore
index 7925456..0bd7eef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -88,3 +88,5 @@ java-dist/
 java-native-c/
 java-native-cpp/
 target/
+
+*.log
diff --git a/java/core/src/main/java/org/apache/arrow/adbc/core/AdbcConnection.java b/java/core/src/main/java/org/apache/arrow/adbc/core/AdbcConnection.java
index 22bf6fa..f6cb228 100644
--- a/java/core/src/main/java/org/apache/arrow/adbc/core/AdbcConnection.java
+++ b/java/core/src/main/java/org/apache/arrow/adbc/core/AdbcConnection.java
@@ -18,6 +18,28 @@ package org.apache.arrow.adbc.core;
 
 /** A connection to a {@link AdbcDatabase}. */
 public interface AdbcConnection extends AutoCloseable {
+  /**
+   * Commit the pending transaction.
+   *
+   * @throws AdbcException if a database error occurs
+   * @throws IllegalStateException if autocommit is enabled
+   * @throws UnsupportedOperationException if the database does not support transactions
+   */
+  default void commit() throws AdbcException {
+    throw new UnsupportedOperationException("Connection does not support transactions");
+  }
+
   /** Create a new statement that can be executed. */
   AdbcStatement createStatement() throws AdbcException;
+
+  /**
+   * Rollback the pending transaction.
+   *
+   * @throws AdbcException if a database error occurs
+   * @throws IllegalStateException if autocommit is enabled
+   * @throws UnsupportedOperationException if the database does not support transactions
+   */
+  default void rollback() throws AdbcException {
+    throw new UnsupportedOperationException("Connection does not support transactions");
+  }
 }
diff --git a/java/core/src/main/java/org/apache/arrow/adbc/core/AdbcException.java b/java/core/src/main/java/org/apache/arrow/adbc/core/AdbcException.java
index f0845c1..af412af 100644
--- a/java/core/src/main/java/org/apache/arrow/adbc/core/AdbcException.java
+++ b/java/core/src/main/java/org/apache/arrow/adbc/core/AdbcException.java
@@ -16,18 +16,68 @@
  */
 package org.apache.arrow.adbc.core;
 
+/**
+ * An error in the database or ADBC driver.
+ *
+ * <p>The exception contains up to five types of information about the error:
+ *
+ * <ul>
+ *   <li>An error message
+ *   <li>An exception cause
+ *   <li>An ADBC status code
+ *   <li>A SQLSTATE string
+ *   <li>A vendor-specific status code
+ * </ul>
+ *
+ * Driver implementations should also use the following standard exception classes to indicate
+ * invalid API usage:
+ *
+ * <ul>
+ *   <li>{@link IllegalArgumentException} for invalid argument values
+ *   <li>{@link UnsupportedOperationException} for unimplemented operations
+ *   <li>{@link IllegalStateException} for other invalid use of the API (e.g. preconditions not met)
+ * </ul>
+ */
 public class AdbcException extends Exception {
-  public AdbcException() {}
+  private final AdbcStatusCode status;
+  private final String sqlState;
+  private final int vendorCode;
 
-  public AdbcException(String message) {
-    super(message);
+  // TODO: do we also want to support a multi-exception akin to SQLException#setNextException
+  public AdbcException(
+      String message, Throwable cause, AdbcStatusCode status, String sqlState, int vendorCode) {
+    super(message, cause);
+    this.status = status;
+    this.sqlState = sqlState;
+    this.vendorCode = vendorCode;
   }
 
-  public AdbcException(String message, Throwable cause) {
-    super(message, cause);
+  public AdbcStatusCode getStatus() {
+    return status;
+  }
+
+  public String getSqlState() {
+    return sqlState;
+  }
+
+  public int getVendorCode() {
+    return vendorCode;
   }
 
-  public AdbcException(Throwable cause) {
-    super(cause);
+  @Override
+  public String toString() {
+    return "AdbcException{"
+        + "message="
+        + getMessage()
+        + ", status="
+        + status
+        + ", sqlState='"
+        + sqlState
+        + '\''
+        + ", vendorCode="
+        + vendorCode
+        + ", cause="
+        + getCause()
+        + '}';
   }
 }
diff --git a/java/core/src/main/java/org/apache/arrow/adbc/core/AdbcConnection.java b/java/core/src/main/java/org/apache/arrow/adbc/core/AdbcStatusCode.java
similarity index 67%
copy from java/core/src/main/java/org/apache/arrow/adbc/core/AdbcConnection.java
copy to java/core/src/main/java/org/apache/arrow/adbc/core/AdbcStatusCode.java
index 22bf6fa..c97a384 100644
--- a/java/core/src/main/java/org/apache/arrow/adbc/core/AdbcConnection.java
+++ b/java/core/src/main/java/org/apache/arrow/adbc/core/AdbcStatusCode.java
@@ -16,8 +16,22 @@
  */
 package org.apache.arrow.adbc.core;
 
-/** A connection to a {@link AdbcDatabase}. */
-public interface AdbcConnection extends AutoCloseable {
-  /** Create a new statement that can be executed. */
-  AdbcStatement createStatement() throws AdbcException;
+/**
+ * A status code indicating the general category of error that occurred.
+ *
+ * <p>Also see the ADBC C API definition, which has similar status codes (except here we use
+ * standard Java exceptions to indicate API misuse).
+ */
+public enum AdbcStatusCode {
+  UNKNOWN,
+  NOT_FOUND,
+  ALREADY_EXISTS,
+  INVALID_DATA,
+  INTEGRITY,
+  INTERNAL,
+  IO,
+  CANCELLED,
+  TIMEOUT,
+  UNAUTHENTICATED,
+  UNAUTHORIZED,
 }
diff --git a/java/driver-manager/src/main/java/org/apache/arrow/adbc/drivermanager/AdbcDriverManager.java b/java/driver-manager/src/main/java/org/apache/arrow/adbc/drivermanager/AdbcDriverManager.java
index f253e7c..0c8c8f6 100644
--- a/java/driver-manager/src/main/java/org/apache/arrow/adbc/drivermanager/AdbcDriverManager.java
+++ b/java/driver-manager/src/main/java/org/apache/arrow/adbc/drivermanager/AdbcDriverManager.java
@@ -23,6 +23,7 @@ import java.util.concurrent.ConcurrentMap;
 import org.apache.arrow.adbc.core.AdbcDatabase;
 import org.apache.arrow.adbc.core.AdbcDriver;
 import org.apache.arrow.adbc.core.AdbcException;
+import org.apache.arrow.adbc.core.AdbcStatusCode;
 
 public final class AdbcDriverManager {
   private static final AdbcDriverManager INSTANCE = new AdbcDriverManager();
@@ -45,7 +46,8 @@ public final class AdbcDriverManager {
       throws AdbcException {
     final AdbcDriver driver = lookupDriver(driverName);
     if (driver == null) {
-      throw new AdbcException("Driver not found for '" + driverName + "'");
+      throw new AdbcException(
+          "Driver not found for '" + driverName + "'", null, AdbcStatusCode.NOT_FOUND, null, 0);
     }
     return driver.connect(parameters);
   }
diff --git a/java/driver/derby/pom.xml b/java/driver/jdbc-util/pom.xml
similarity index 71%
copy from java/driver/derby/pom.xml
copy to java/driver/jdbc-util/pom.xml
index f0f357a..8aa20f2 100644
--- a/java/driver/derby/pom.xml
+++ b/java/driver/jdbc-util/pom.xml
@@ -18,10 +18,10 @@
     <relativePath>../../pom.xml</relativePath>
   </parent>
 
-  <artifactId>adbc-driver-derby</artifactId>
+  <artifactId>adbc-driver-jdbc-util</artifactId>
   <packaging>jar</packaging>
-  <name>Arrow ADBC Core</name>
-  <description>An example ADBC driver based on Apache Derby.</description>
+  <name>Arrow ADBC Driver JDBC Util</name>
+  <description>Utilities for working with Arrow and JDBC.</description>
 
   <dependencies>
     <!-- Arrow -->
@@ -38,28 +38,6 @@
       <artifactId>arrow-vector</artifactId>
     </dependency>
 
-    <dependency>
-      <groupId>org.apache.arrow.adbc</groupId>
-      <artifactId>adbc-core</artifactId>
-    </dependency>
-    <dependency>
-      <groupId>org.apache.arrow.adbc</groupId>
-      <artifactId>adbc-driver-manager</artifactId>
-    </dependency>
-
-    <!-- Derby -->
-    <!-- Cannot upgrade beyond this version for Java 8 support -->
-    <dependency>
-      <groupId>org.apache.derby</groupId>
-      <artifactId>derby</artifactId>
-      <version>10.14.2.0</version>
-    </dependency>
-    <dependency>
-      <groupId>org.apache.derby</groupId>
-      <artifactId>derbytools</artifactId>
-      <version>10.14.2.0</version>
-    </dependency>
-
     <!-- Testing -->
     <dependency>
       <groupId>org.assertj</groupId>
diff --git a/java/driver/jdbc-util/src/main/java/org/apache/arrow/adbc/driver/jdbc/util/ColumnBinder.java b/java/driver/jdbc-util/src/main/java/org/apache/arrow/adbc/driver/jdbc/util/ColumnBinder.java
new file mode 100644
index 0000000..3f64fc9
--- /dev/null
+++ b/java/driver/jdbc-util/src/main/java/org/apache/arrow/adbc/driver/jdbc/util/ColumnBinder.java
@@ -0,0 +1,57 @@
+/*
+ * 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.arrow.adbc.driver.jdbc.util;
+
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import org.apache.arrow.vector.FieldVector;
+import org.apache.arrow.vector.types.pojo.ArrowType;
+import org.apache.arrow.vector.types.pojo.Field;
+
+/** A helper to bind values from an Arrow vector to a JDBC PreparedStatement. */
+@FunctionalInterface
+public interface ColumnBinder {
+  /**
+   * Bind the given row to the given parameter.
+   *
+   * @param statement The staement to bind to
+   * @param vector The vector to pull data from
+   * @param parameterIndex The parameter to bind to
+   * @param rowIndex The row to bind
+   * @throws SQLException if an error occurs
+   */
+  void bind(PreparedStatement statement, FieldVector vector, int parameterIndex, int rowIndex)
+      throws SQLException;
+
+  /**
+   * Get an instance of the default binder for a type.
+   *
+   * @throws UnsupportedOperationException if the type is not supported.
+   */
+  static ColumnBinder forType(ArrowType type) {
+    return type.accept(new ColumnBinderArrowTypeVisitor(/*nullable=*/ true));
+  }
+
+  /**
+   * Get an instance of the default binder for a type, accounting for nullability.
+   *
+   * @throws UnsupportedOperationException if the type is not supported.
+   */
+  static ColumnBinder forField(Field field) {
+    return field.getType().accept(new ColumnBinderArrowTypeVisitor(field.isNullable()));
+  }
+}
diff --git a/java/driver/jdbc-util/src/main/java/org/apache/arrow/adbc/driver/jdbc/util/ColumnBinderArrowTypeVisitor.java b/java/driver/jdbc-util/src/main/java/org/apache/arrow/adbc/driver/jdbc/util/ColumnBinderArrowTypeVisitor.java
new file mode 100644
index 0000000..976a87b
--- /dev/null
+++ b/java/driver/jdbc-util/src/main/java/org/apache/arrow/adbc/driver/jdbc/util/ColumnBinderArrowTypeVisitor.java
@@ -0,0 +1,170 @@
+/*
+ * 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.arrow.adbc.driver.jdbc.util;
+
+import org.apache.arrow.vector.types.pojo.ArrowType;
+
+final class ColumnBinderArrowTypeVisitor implements ArrowType.ArrowTypeVisitor<ColumnBinder> {
+  private final boolean nullable;
+
+  public ColumnBinderArrowTypeVisitor(boolean nullable) {
+    this.nullable = nullable;
+  }
+
+  @Override
+  public ColumnBinder visit(ArrowType.Null type) {
+    throw new UnsupportedOperationException("No column binder implemented for type " + type);
+  }
+
+  @Override
+  public ColumnBinder visit(ArrowType.Struct type) {
+    throw new UnsupportedOperationException("No column binder implemented for type " + type);
+  }
+
+  @Override
+  public ColumnBinder visit(ArrowType.List type) {
+    throw new UnsupportedOperationException("No column binder implemented for type " + type);
+  }
+
+  @Override
+  public ColumnBinder visit(ArrowType.LargeList type) {
+    throw new UnsupportedOperationException("No column binder implemented for type " + type);
+  }
+
+  @Override
+  public ColumnBinder visit(ArrowType.FixedSizeList type) {
+    throw new UnsupportedOperationException("No column binder implemented for type " + type);
+  }
+
+  @Override
+  public ColumnBinder visit(ArrowType.Union type) {
+    throw new UnsupportedOperationException("No column binder implemented for type " + type);
+  }
+
+  @Override
+  public ColumnBinder visit(ArrowType.Map type) {
+    throw new UnsupportedOperationException("No column binder implemented for type " + type);
+  }
+
+  @Override
+  public ColumnBinder visit(ArrowType.Int type) {
+    switch (type.getBitWidth()) {
+      case 8:
+        if (type.getIsSigned()) {
+          return nullable
+              ? ColumnBinders.NullableTinyIntBinder.INSTANCE
+              : ColumnBinders.TinyIntBinder.INSTANCE;
+        } else {
+          throw new UnsupportedOperationException(
+              "No column binder implemented for unsigned type " + type);
+        }
+      case 16:
+        if (type.getIsSigned()) {
+          return nullable
+              ? ColumnBinders.NullableSmallIntBinder.INSTANCE
+              : ColumnBinders.SmallIntBinder.INSTANCE;
+        } else {
+          throw new UnsupportedOperationException(
+              "No column binder implemented for unsigned type " + type);
+        }
+      case 32:
+        if (type.getIsSigned()) {
+          return nullable
+              ? ColumnBinders.NullableIntBinder.INSTANCE
+              : ColumnBinders.IntBinder.INSTANCE;
+        } else {
+          throw new UnsupportedOperationException(
+              "No column binder implemented for unsigned type " + type);
+        }
+      case 64:
+        if (type.getIsSigned()) {
+          return nullable
+              ? ColumnBinders.NullableBigIntBinder.INSTANCE
+              : ColumnBinders.BigIntBinder.INSTANCE;
+        } else {
+          throw new UnsupportedOperationException(
+              "No column binder implemented for unsigned type " + type);
+        }
+    }
+    throw new UnsupportedOperationException("No column binder implemented for type " + type);
+  }
+
+  @Override
+  public ColumnBinder visit(ArrowType.FloatingPoint type) {
+    throw new UnsupportedOperationException("No column binder implemented for type " + type);
+  }
+
+  @Override
+  public ColumnBinder visit(ArrowType.Utf8 type) {
+    return ColumnBinders.NullableVarCharBinder.INSTANCE;
+  }
+
+  @Override
+  public ColumnBinder visit(ArrowType.LargeUtf8 type) {
+    return ColumnBinders.NullableLargeVarCharBinder.INSTANCE;
+  }
+
+  @Override
+  public ColumnBinder visit(ArrowType.Binary type) {
+    throw new UnsupportedOperationException("No column binder implemented for type " + type);
+  }
+
+  @Override
+  public ColumnBinder visit(ArrowType.LargeBinary type) {
+    throw new UnsupportedOperationException("No column binder implemented for type " + type);
+  }
+
+  @Override
+  public ColumnBinder visit(ArrowType.FixedSizeBinary type) {
+    throw new UnsupportedOperationException("No column binder implemented for type " + type);
+  }
+
+  @Override
+  public ColumnBinder visit(ArrowType.Bool type) {
+    throw new UnsupportedOperationException("No column binder implemented for type " + type);
+  }
+
+  @Override
+  public ColumnBinder visit(ArrowType.Decimal type) {
+    throw new UnsupportedOperationException("No column binder implemented for type " + type);
+  }
+
+  @Override
+  public ColumnBinder visit(ArrowType.Date type) {
+    throw new UnsupportedOperationException("No column binder implemented for type " + type);
+  }
+
+  @Override
+  public ColumnBinder visit(ArrowType.Time type) {
+    throw new UnsupportedOperationException("No column binder implemented for type " + type);
+  }
+
+  @Override
+  public ColumnBinder visit(ArrowType.Timestamp type) {
+    throw new UnsupportedOperationException("No column binder implemented for type " + type);
+  }
+
+  @Override
+  public ColumnBinder visit(ArrowType.Interval type) {
+    throw new UnsupportedOperationException("No column binder implemented for type " + type);
+  }
+
+  @Override
+  public ColumnBinder visit(ArrowType.Duration type) {
+    throw new UnsupportedOperationException("No column binder implemented for type " + type);
+  }
+}
diff --git a/java/driver/jdbc-util/src/main/java/org/apache/arrow/adbc/driver/jdbc/util/ColumnBinders.java b/java/driver/jdbc-util/src/main/java/org/apache/arrow/adbc/driver/jdbc/util/ColumnBinders.java
new file mode 100644
index 0000000..2ab5117
--- /dev/null
+++ b/java/driver/jdbc-util/src/main/java/org/apache/arrow/adbc/driver/jdbc/util/ColumnBinders.java
@@ -0,0 +1,202 @@
+/*
+ * 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.arrow.adbc.driver.jdbc.util;
+
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.sql.Types;
+import org.apache.arrow.vector.BigIntVector;
+import org.apache.arrow.vector.FieldVector;
+import org.apache.arrow.vector.IntVector;
+import org.apache.arrow.vector.LargeVarCharVector;
+import org.apache.arrow.vector.SmallIntVector;
+import org.apache.arrow.vector.TinyIntVector;
+import org.apache.arrow.vector.VarCharVector;
+
+/** Column binder implementations. */
+final class ColumnBinders {
+  // While tedious to define all of these, this avoids unnecessary boxing/unboxing and limits the
+  // call depth compared to trying to be generic with lambdas (hopefully makes inlining easier).
+  // We can consider code templating like arrow-vector for maintenance.
+
+  // ------------------------------------------------------------
+  // Int8
+
+  enum NullableTinyIntBinder implements ColumnBinder {
+    INSTANCE;
+
+    @Override
+    public void bind(
+        PreparedStatement statement, FieldVector vector, int parameterIndex, int rowIndex)
+        throws SQLException {
+      if (vector.isNull(rowIndex)) {
+        statement.setNull(parameterIndex, Types.TINYINT);
+      } else {
+        statement.setByte(parameterIndex, ((TinyIntVector) vector).get(rowIndex));
+      }
+    }
+  }
+
+  enum TinyIntBinder implements ColumnBinder {
+    INSTANCE;
+
+    private static final long BYTE_WIDTH = 1;
+
+    @Override
+    public void bind(
+        PreparedStatement statement, FieldVector vector, int parameterIndex, int rowIndex)
+        throws SQLException {
+      final byte value = vector.getDataBuffer().getByte(rowIndex * BYTE_WIDTH);
+      statement.setByte(parameterIndex, value);
+    }
+  }
+
+  // ------------------------------------------------------------
+  // Int16
+
+  enum NullableSmallIntBinder implements ColumnBinder {
+    INSTANCE;
+
+    @Override
+    public void bind(
+        PreparedStatement statement, FieldVector vector, int parameterIndex, int rowIndex)
+        throws SQLException {
+      if (vector.isNull(rowIndex)) {
+        statement.setNull(parameterIndex, Types.SMALLINT);
+      } else {
+        statement.setShort(parameterIndex, ((SmallIntVector) vector).get(rowIndex));
+      }
+    }
+  }
+
+  enum SmallIntBinder implements ColumnBinder {
+    INSTANCE;
+
+    private static final long BYTE_WIDTH = 2;
+
+    @Override
+    public void bind(
+        PreparedStatement statement, FieldVector vector, int parameterIndex, int rowIndex)
+        throws SQLException {
+      final byte value = vector.getDataBuffer().getByte(rowIndex * BYTE_WIDTH);
+      statement.setByte(parameterIndex, value);
+    }
+  }
+
+  // ------------------------------------------------------------
+  // Int32
+
+  enum NullableIntBinder implements ColumnBinder {
+    INSTANCE;
+
+    @Override
+    public void bind(
+        PreparedStatement statement, FieldVector vector, int parameterIndex, int rowIndex)
+        throws SQLException {
+      if (vector.isNull(rowIndex)) {
+        statement.setNull(parameterIndex, Types.INTEGER);
+      } else {
+        statement.setInt(parameterIndex, ((IntVector) vector).get(rowIndex));
+      }
+    }
+  }
+
+  enum IntBinder implements ColumnBinder {
+    INSTANCE;
+
+    private static final long BYTE_WIDTH = 4;
+
+    @Override
+    public void bind(
+        PreparedStatement statement, FieldVector vector, int parameterIndex, int rowIndex)
+        throws SQLException {
+      final int value = vector.getDataBuffer().getInt(rowIndex * BYTE_WIDTH);
+      statement.setInt(parameterIndex, value);
+    }
+  }
+
+  // ------------------------------------------------------------
+  // Int64
+
+  enum NullableBigIntBinder implements ColumnBinder {
+    INSTANCE;
+
+    @Override
+    public void bind(
+        PreparedStatement statement, FieldVector vector, int parameterIndex, int rowIndex)
+        throws SQLException {
+      if (vector.isNull(rowIndex)) {
+        statement.setNull(parameterIndex, Types.BIGINT);
+      } else {
+        statement.setLong(parameterIndex, ((BigIntVector) vector).get(rowIndex));
+      }
+    }
+  }
+
+  enum BigIntBinder implements ColumnBinder {
+    INSTANCE;
+
+    private static final long BYTE_WIDTH = 4;
+
+    @Override
+    public void bind(
+        PreparedStatement statement, FieldVector vector, int parameterIndex, int rowIndex)
+        throws SQLException {
+      final long value = vector.getDataBuffer().getLong(rowIndex * BYTE_WIDTH);
+      statement.setLong(parameterIndex, value);
+    }
+  }
+
+  // ------------------------------------------------------------
+  // String, LargeString
+
+  // TODO: we may be able to do this generically via ElementAddressableVector
+  // TODO: make this non-singleton so we don't have to allocate when using getDataPointer?
+  // TODO: avoid getObject and just use the byte[] directly (or, can we directly get a String from
+  // an ArrowBuf?)
+  enum NullableVarCharBinder implements ColumnBinder {
+    INSTANCE;
+
+    @Override
+    public void bind(
+        PreparedStatement statement, FieldVector vector, int parameterIndex, int rowIndex)
+        throws SQLException {
+      if (vector.isNull(rowIndex)) {
+        statement.setNull(parameterIndex, Types.VARCHAR);
+      } else {
+        statement.setString(
+            parameterIndex, ((VarCharVector) vector).getObject(rowIndex).toString());
+      }
+    }
+  }
+
+  enum NullableLargeVarCharBinder implements ColumnBinder {
+    INSTANCE;
+
+    @Override
+    public void bind(
+        PreparedStatement statement, FieldVector vector, int parameterIndex, int rowIndex)
+        throws SQLException {
+      if (vector.isNull(rowIndex)) {
+        statement.setNull(parameterIndex, Types.VARCHAR);
+      } else {
+        statement.setString(
+            parameterIndex, ((LargeVarCharVector) vector).getObject(rowIndex).toString());
+      }
+    }
+  }
+}
diff --git a/java/driver/jdbc-util/src/main/java/org/apache/arrow/adbc/driver/jdbc/util/JdbcParameterBinder.java b/java/driver/jdbc-util/src/main/java/org/apache/arrow/adbc/driver/jdbc/util/JdbcParameterBinder.java
new file mode 100644
index 0000000..f67622a
--- /dev/null
+++ b/java/driver/jdbc-util/src/main/java/org/apache/arrow/adbc/driver/jdbc/util/JdbcParameterBinder.java
@@ -0,0 +1,158 @@
+/*
+ * 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.arrow.adbc.driver.jdbc.util;
+
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Stream;
+import org.apache.arrow.util.Preconditions;
+import org.apache.arrow.vector.FieldVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+
+/**
+ * Helper to bind values from an Arrow {@link VectorSchemaRoot} to a JDBC {@link PreparedStatement}.
+ */
+public class JdbcParameterBinder {
+  private final PreparedStatement statement;
+  private final VectorSchemaRoot root;
+  private final ColumnBinder[] binders;
+  private final int[] parameterIndices;
+  private final int[] columnIndices;
+  private int nextRowIndex;
+
+  JdbcParameterBinder(
+      final PreparedStatement statement,
+      final VectorSchemaRoot root,
+      final ColumnBinder[] binders,
+      int[] parameterIndices,
+      int[] columnIndices) {
+    Preconditions.checkArgument(
+        parameterIndices.length == columnIndices.length,
+        "Length of parameter indices and column indices must match");
+    this.statement = statement;
+    this.root = root;
+    this.binders = binders;
+    this.parameterIndices = parameterIndices;
+    this.columnIndices = columnIndices;
+    this.nextRowIndex = 0;
+  }
+
+  /**
+   * Create a builder.
+   *
+   * @param statement The statement to bind to.
+   * @param root The root to pull data from.
+   */
+  public static Builder builder(final PreparedStatement statement, final VectorSchemaRoot root) {
+    return new Builder(statement, root);
+  }
+
+  /** Reset the binder (so the root can be updated with new data). */
+  public void reset() {
+    nextRowIndex = 0;
+  }
+
+  /**
+   * Bind the next row to the statement.
+   *
+   * @return true if a row was bound, false if rows were exhausted
+   */
+  public boolean next() throws SQLException {
+    if (nextRowIndex >= root.getRowCount()) {
+      return false;
+    }
+    for (int i = 0; i < parameterIndices.length; i++) {
+      final int parameterIndex = parameterIndices[i];
+      final int columnIndex = columnIndices[i];
+      final FieldVector vector = root.getVector(columnIndex);
+      binders[i].bind(statement, vector, parameterIndex, nextRowIndex);
+    }
+    nextRowIndex++;
+    return true;
+  }
+
+  private static class Binding {
+    int columnIndex;
+    ColumnBinder binder;
+
+    Binding(int columnIndex, ColumnBinder binder) {
+      this.columnIndex = columnIndex;
+      this.binder = binder;
+    }
+  }
+
+  /** A builder for a {@link JdbcParameterBinder}. */
+  public static class Builder {
+    private final PreparedStatement statement;
+    private final VectorSchemaRoot root;
+    // Parameter index -> (Column Index, Binder)
+    private final Map<Integer, Binding> bindings;
+
+    Builder(PreparedStatement statement, VectorSchemaRoot root) {
+      this.statement = statement;
+      this.root = root;
+      this.bindings = new HashMap<>();
+    }
+
+    /** Bind each column to the corresponding parameter in order. */
+    public Builder bindAll() {
+      for (int i = 0; i < root.getFieldVectors().size(); i++) {
+        bind(/*parameterIndex=*/ i + 1, /*columnIndex=*/ i);
+      }
+      return this;
+    }
+
+    /** Bind the given parameter to the given column using the default binder. */
+    public Builder bind(int parameterIndex, int columnIndex) {
+      return bind(
+          parameterIndex,
+          columnIndex,
+          ColumnBinder.forField(root.getVector(columnIndex).getField()));
+    }
+
+    /** Bind the given parameter to the given column using the given binder. */
+    public Builder bind(int parameterIndex, int columnIndex, ColumnBinder binder) {
+      Preconditions.checkArgument(
+          parameterIndex > 0, "parameterIndex %d must be positive", parameterIndex);
+      bindings.put(parameterIndex, new Binding(columnIndex, binder));
+      return this;
+    }
+
+    /** Build the binder. */
+    public JdbcParameterBinder build() {
+      ColumnBinder[] binders = new ColumnBinder[bindings.size()];
+      int[] parameterIndices = new int[bindings.size()];
+      int[] columnIndices = new int[bindings.size()];
+      final Stream<Map.Entry<Integer, Binding>> sortedBindings =
+          bindings.entrySet().stream()
+              .sorted(Comparator.comparingInt(entry -> entry.getValue().columnIndex));
+      int index = 0;
+      for (Map.Entry<Integer, Binding> entry :
+          (Iterable<Map.Entry<Integer, Binding>>) sortedBindings::iterator) {
+        binders[index] = entry.getValue().binder;
+        parameterIndices[index] = entry.getKey();
+        columnIndices[index] = entry.getValue().columnIndex;
+        index++;
+      }
+      return new JdbcParameterBinder(statement, root, binders, parameterIndices, columnIndices);
+    }
+  }
+}
diff --git a/java/driver/jdbc-util/src/test/java/org/apache/arrow/adbc/driver/jdbc/util/JdbcParameterBinderTest.java b/java/driver/jdbc-util/src/test/java/org/apache/arrow/adbc/driver/jdbc/util/JdbcParameterBinderTest.java
new file mode 100644
index 0000000..f08750c
--- /dev/null
+++ b/java/driver/jdbc-util/src/test/java/org/apache/arrow/adbc/driver/jdbc/util/JdbcParameterBinderTest.java
@@ -0,0 +1,310 @@
+/*
+ * 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.arrow.adbc.driver.jdbc.util;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.sql.SQLException;
+import java.sql.Types;
+import java.util.Arrays;
+import java.util.Collections;
+import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.BigIntVector;
+import org.apache.arrow.vector.IntVector;
+import org.apache.arrow.vector.SmallIntVector;
+import org.apache.arrow.vector.TinyIntVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.types.pojo.ArrowType;
+import org.apache.arrow.vector.types.pojo.Field;
+import org.apache.arrow.vector.types.pojo.Schema;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class JdbcParameterBinderTest {
+  BufferAllocator allocator;
+
+  @BeforeEach
+  void beforeEach() {
+    allocator = new RootAllocator();
+  }
+
+  @AfterEach
+  void afterEach() {
+    allocator.close();
+  }
+
+  @Test
+  void bindOrder() throws SQLException {
+    final Schema schema =
+        new Schema(
+            Arrays.asList(
+                Field.nullable("ints0", new ArrowType.Int(32, true)),
+                Field.nullable("ints1", new ArrowType.Int(32, true)),
+                Field.nullable("ints2", new ArrowType.Int(32, true))));
+    try (final MockPreparedStatement statement = new MockPreparedStatement();
+        final VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator)) {
+      final JdbcParameterBinder binder =
+          JdbcParameterBinder.builder(statement, root)
+              .bind(/*paramIndex=*/ 1, /*colIndex=*/ 2)
+              .bind(/*paramIndex=*/ 2, /*colIndex=*/ 0)
+              .build();
+      assertThat(binder.next()).isFalse();
+
+      final IntVector ints0 = (IntVector) root.getVector(0);
+      final IntVector ints1 = (IntVector) root.getVector(1);
+      final IntVector ints2 = (IntVector) root.getVector(2);
+      ints0.setSafe(0, 4);
+      ints0.setNull(1);
+      ints1.setNull(0);
+      ints1.setSafe(1, -8);
+      ints2.setNull(0);
+      ints2.setSafe(1, 12);
+      root.setRowCount(2);
+
+      assertThat(binder.next()).isTrue();
+      assertThat(statement.getParamValue(1)).isNull();
+      assertThat(statement.getParamType(1)).isEqualTo(Types.INTEGER);
+      assertThat(statement.getParamValue(2)).isInstanceOf(Integer.class).isEqualTo(4);
+      assertThat(statement.getParam(3)).isNull();
+      assertThat(binder.next()).isTrue();
+      assertThat(statement.getParamValue(1)).isInstanceOf(Integer.class).isEqualTo(12);
+      assertThat(statement.getParamValue(2)).isNull();
+      assertThat(statement.getParamType(2)).isEqualTo(Types.INTEGER);
+      assertThat(statement.getParam(3)).isNull();
+      assertThat(binder.next()).isFalse();
+
+      binder.reset();
+
+      ints0.setNull(0);
+      ints0.setSafe(1, -2);
+      ints2.setNull(0);
+      ints2.setSafe(1, 6);
+      root.setRowCount(2);
+
+      assertThat(binder.next()).isTrue();
+      assertThat(statement.getParamValue(1)).isNull();
+      assertThat(statement.getParamType(1)).isEqualTo(Types.INTEGER);
+      assertThat(statement.getParamValue(2)).isNull();
+      assertThat(statement.getParamType(2)).isEqualTo(Types.INTEGER);
+      assertThat(binder.next()).isTrue();
+      assertThat(statement.getParamValue(1)).isInstanceOf(Integer.class).isEqualTo(6);
+      assertThat(statement.getParamValue(2)).isInstanceOf(Integer.class).isEqualTo(-2);
+      assertThat(statement.getParam(3)).isNull();
+      assertThat(binder.next()).isFalse();
+    }
+  }
+
+  @Test
+  void customBinder() throws SQLException {
+    final Schema schema =
+        new Schema(Collections.singletonList(Field.nullable("ints0", new ArrowType.Int(32, true))));
+
+    try (final MockPreparedStatement statement = new MockPreparedStatement();
+        final VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator)) {
+      final JdbcParameterBinder binder =
+          JdbcParameterBinder.builder(statement, root)
+              .bind(
+                  /*paramIndex=*/ 1,
+                  /*colIndex=*/ 0,
+                  (statement1, vector, parameterIndex, rowIndex) -> {
+                    Integer value = ((IntVector) vector).getObject(rowIndex);
+                    if (value == null) {
+                      statement1.setString(parameterIndex, "null");
+                    } else {
+                      statement1.setString(parameterIndex, Integer.toString(value));
+                    }
+                  })
+              .build();
+      assertThat(binder.next()).isFalse();
+
+      final IntVector ints = (IntVector) root.getVector(0);
+      ints.setSafe(0, 4);
+      ints.setNull(1);
+
+      root.setRowCount(2);
+
+      assertThat(binder.next()).isTrue();
+      assertThat(statement.getParamValue(1)).isEqualTo("4");
+      assertThat(binder.next()).isTrue();
+      assertThat(statement.getParamValue(1)).isEqualTo("null");
+      assertThat(binder.next()).isFalse();
+    }
+  }
+
+  @Test
+  void int8() throws SQLException {
+    final Schema schema =
+        new Schema(Collections.singletonList(Field.nullable("ints", new ArrowType.Int(8, true))));
+    try (final MockPreparedStatement statement = new MockPreparedStatement();
+        final VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator)) {
+      final JdbcParameterBinder binder =
+          JdbcParameterBinder.builder(statement, root).bindAll().build();
+      assertThat(binder.next()).isFalse();
+
+      final TinyIntVector ints = (TinyIntVector) root.getVector(0);
+      ints.setSafe(0, Byte.MIN_VALUE);
+      ints.setSafe(1, Byte.MAX_VALUE);
+      ints.setNull(2);
+      root.setRowCount(3);
+
+      assertThat(binder.next()).isTrue();
+      assertThat(statement.getParamValue(1)).isEqualTo(Byte.MIN_VALUE);
+      assertThat(binder.next()).isTrue();
+      assertThat(statement.getParamValue(1)).isEqualTo(Byte.MAX_VALUE);
+      assertThat(binder.next()).isTrue();
+      assertThat(statement.getParamValue(1)).isNull();
+      assertThat(statement.getParamType(1)).isEqualTo(Types.TINYINT);
+      assertThat(binder.next()).isFalse();
+
+      binder.reset();
+
+      ints.setNull(0);
+      ints.setSafe(1, 2);
+      root.setRowCount(2);
+
+      assertThat(binder.next()).isTrue();
+      assertThat(statement.getParamValue(1)).isNull();
+      assertThat(statement.getParamType(1)).isEqualTo(Types.TINYINT);
+      assertThat(binder.next()).isTrue();
+      assertThat(statement.getParamValue(1)).isEqualTo((byte) 2);
+      assertThat(binder.next()).isFalse();
+    }
+  }
+
+  @Test
+  void int16() throws SQLException {
+    final Schema schema =
+        new Schema(Collections.singletonList(Field.nullable("ints", new ArrowType.Int(16, true))));
+    try (final MockPreparedStatement statement = new MockPreparedStatement();
+        final VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator)) {
+      final JdbcParameterBinder binder =
+          JdbcParameterBinder.builder(statement, root).bindAll().build();
+      assertThat(binder.next()).isFalse();
+
+      final SmallIntVector ints = (SmallIntVector) root.getVector(0);
+      ints.setSafe(0, Short.MIN_VALUE);
+      ints.setSafe(1, Short.MAX_VALUE);
+      ints.setNull(2);
+      root.setRowCount(3);
+
+      assertThat(binder.next()).isTrue();
+      assertThat(statement.getParamValue(1)).isEqualTo(Short.MIN_VALUE);
+      assertThat(binder.next()).isTrue();
+      assertThat(statement.getParamValue(1)).isEqualTo(Short.MAX_VALUE);
+      assertThat(binder.next()).isTrue();
+      assertThat(statement.getParamValue(1)).isNull();
+      assertThat(statement.getParamType(1)).isEqualTo(Types.SMALLINT);
+      assertThat(binder.next()).isFalse();
+
+      binder.reset();
+
+      ints.setNull(0);
+      ints.setSafe(1, 2);
+      root.setRowCount(2);
+
+      assertThat(binder.next()).isTrue();
+      assertThat(statement.getParamValue(1)).isNull();
+      assertThat(statement.getParamType(1)).isEqualTo(Types.SMALLINT);
+      assertThat(binder.next()).isTrue();
+      assertThat(statement.getParamValue(1)).isEqualTo((short) 2);
+      assertThat(binder.next()).isFalse();
+    }
+  }
+
+  @Test
+  void int32() throws SQLException {
+    final Schema schema =
+        new Schema(Collections.singletonList(Field.nullable("ints", new ArrowType.Int(32, true))));
+    try (final MockPreparedStatement statement = new MockPreparedStatement();
+        final VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator)) {
+      final JdbcParameterBinder binder =
+          JdbcParameterBinder.builder(statement, root).bindAll().build();
+      assertThat(binder.next()).isFalse();
+
+      final IntVector ints = (IntVector) root.getVector(0);
+      ints.setSafe(0, Integer.MIN_VALUE);
+      ints.setSafe(1, Integer.MAX_VALUE);
+      ints.setNull(2);
+      root.setRowCount(3);
+
+      assertThat(binder.next()).isTrue();
+      assertThat(statement.getParamValue(1)).isEqualTo(Integer.MIN_VALUE);
+      assertThat(binder.next()).isTrue();
+      assertThat(statement.getParamValue(1)).isEqualTo(Integer.MAX_VALUE);
+      assertThat(binder.next()).isTrue();
+      assertThat(statement.getParamValue(1)).isNull();
+      assertThat(statement.getParamType(1)).isEqualTo(Types.INTEGER);
+      assertThat(binder.next()).isFalse();
+
+      binder.reset();
+
+      ints.setNull(0);
+      ints.setSafe(1, 2);
+      root.setRowCount(2);
+
+      assertThat(binder.next()).isTrue();
+      assertThat(statement.getParamValue(1)).isNull();
+      assertThat(statement.getParamType(1)).isEqualTo(Types.INTEGER);
+      assertThat(binder.next()).isTrue();
+      assertThat(statement.getParamValue(1)).isEqualTo(2);
+      assertThat(binder.next()).isFalse();
+    }
+  }
+
+  @Test
+  void int64() throws SQLException {
+    final Schema schema =
+        new Schema(Collections.singletonList(Field.nullable("ints", new ArrowType.Int(64, true))));
+    try (final MockPreparedStatement statement = new MockPreparedStatement();
+        final VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator)) {
+      final JdbcParameterBinder binder =
+          JdbcParameterBinder.builder(statement, root).bindAll().build();
+      assertThat(binder.next()).isFalse();
+
+      final BigIntVector ints = (BigIntVector) root.getVector(0);
+      ints.setSafe(0, Long.MIN_VALUE);
+      ints.setSafe(1, Long.MAX_VALUE);
+      ints.setNull(2);
+      root.setRowCount(3);
+
+      assertThat(binder.next()).isTrue();
+      assertThat(statement.getParamValue(1)).isEqualTo(Long.MIN_VALUE);
+      assertThat(binder.next()).isTrue();
+      assertThat(statement.getParamValue(1)).isEqualTo(Long.MAX_VALUE);
+      assertThat(binder.next()).isTrue();
+      assertThat(statement.getParamValue(1)).isNull();
+      assertThat(statement.getParamType(1)).isEqualTo(Types.BIGINT);
+      assertThat(binder.next()).isFalse();
+
+      binder.reset();
+
+      ints.setNull(0);
+      ints.setSafe(1, 2);
+      root.setRowCount(2);
+
+      assertThat(binder.next()).isTrue();
+      assertThat(statement.getParamValue(1)).isNull();
+      assertThat(statement.getParamType(1)).isEqualTo(Types.BIGINT);
+      assertThat(binder.next()).isTrue();
+      assertThat(statement.getParamValue(1)).isEqualTo(2L);
+      assertThat(binder.next()).isFalse();
+    }
+  }
+}
diff --git a/java/driver/jdbc-util/src/test/java/org/apache/arrow/adbc/driver/jdbc/util/MockPreparedStatement.java b/java/driver/jdbc-util/src/test/java/org/apache/arrow/adbc/driver/jdbc/util/MockPreparedStatement.java
new file mode 100644
index 0000000..bbaea4b
--- /dev/null
+++ b/java/driver/jdbc-util/src/test/java/org/apache/arrow/adbc/driver/jdbc/util/MockPreparedStatement.java
@@ -0,0 +1,530 @@
+/*
+ * 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.arrow.adbc.driver.jdbc.util;
+
+import java.io.InputStream;
+import java.io.Reader;
+import java.math.BigDecimal;
+import java.net.URL;
+import java.sql.Array;
+import java.sql.Blob;
+import java.sql.Clob;
+import java.sql.Connection;
+import java.sql.Date;
+import java.sql.NClob;
+import java.sql.ParameterMetaData;
+import java.sql.PreparedStatement;
+import java.sql.Ref;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.RowId;
+import java.sql.SQLException;
+import java.sql.SQLWarning;
+import java.sql.SQLXML;
+import java.sql.Time;
+import java.sql.Timestamp;
+import java.util.Calendar;
+import java.util.HashMap;
+import java.util.Map;
+
+class MockPreparedStatement implements PreparedStatement {
+  /** A PreparedStatement that just stores parameters set on it. */
+  static class ParameterHolder {
+    final Object value;
+    final Integer sqlType;
+
+    ParameterHolder(Object value, Integer sqlType) {
+      this.value = value;
+      this.sqlType = sqlType;
+    }
+  }
+
+  private final Map<Integer, ParameterHolder> parameters;
+
+  MockPreparedStatement() {
+    parameters = new HashMap<>();
+  }
+
+  ParameterHolder getParam(int index) {
+    return parameters.get(index);
+  }
+
+  Object getParamValue(int index) {
+    return parameters.get(index).value;
+  }
+
+  Integer getParamType(int index) {
+    return parameters.get(index).sqlType;
+  }
+
+  @Override
+  public ResultSet executeQuery() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public int executeUpdate() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void setNull(int parameterIndex, int sqlType) throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(null, sqlType));
+  }
+
+  @Override
+  public void setBoolean(int parameterIndex, boolean x) throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(x, null));
+  }
+
+  @Override
+  public void setByte(int parameterIndex, byte x) throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(x, null));
+  }
+
+  @Override
+  public void setShort(int parameterIndex, short x) throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(x, null));
+  }
+
+  @Override
+  public void setInt(int parameterIndex, int x) throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(x, null));
+  }
+
+  @Override
+  public void setLong(int parameterIndex, long x) throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(x, null));
+  }
+
+  @Override
+  public void setFloat(int parameterIndex, float x) throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(x, null));
+  }
+
+  @Override
+  public void setDouble(int parameterIndex, double x) throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(x, null));
+  }
+
+  @Override
+  public void setBigDecimal(int parameterIndex, BigDecimal x) throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(x, null));
+  }
+
+  @Override
+  public void setString(int parameterIndex, String x) throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(x, null));
+  }
+
+  @Override
+  public void setBytes(int parameterIndex, byte[] x) throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(x, null));
+  }
+
+  @Override
+  public void setDate(int parameterIndex, Date x) throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(x, null));
+  }
+
+  @Override
+  public void setTime(int parameterIndex, Time x) throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(x, null));
+  }
+
+  @Override
+  public void setTimestamp(int parameterIndex, Timestamp x) throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(x, null));
+  }
+
+  @Override
+  public void setAsciiStream(int parameterIndex, InputStream x, int length) throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(x, null));
+  }
+
+  @Override
+  public void setUnicodeStream(int parameterIndex, InputStream x, int length) throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(x, null));
+  }
+
+  @Override
+  public void setBinaryStream(int parameterIndex, InputStream x, int length) throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(x, null));
+  }
+
+  @Override
+  public void clearParameters() throws SQLException {
+    parameters.clear();
+  }
+
+  @Override
+  public void setObject(int parameterIndex, Object x, int targetSqlType) throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(x, targetSqlType));
+  }
+
+  @Override
+  public void setObject(int parameterIndex, Object x) throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(x, null));
+  }
+
+  @Override
+  public boolean execute() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void addBatch() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void setCharacterStream(int parameterIndex, Reader reader, int length)
+      throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(reader, null));
+  }
+
+  @Override
+  public void setRef(int parameterIndex, Ref x) throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(x, null));
+  }
+
+  @Override
+  public void setBlob(int parameterIndex, Blob x) throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(x, null));
+  }
+
+  @Override
+  public void setClob(int parameterIndex, Clob x) throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(x, null));
+  }
+
+  @Override
+  public void setArray(int parameterIndex, Array x) throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(x, null));
+  }
+
+  @Override
+  public ResultSetMetaData getMetaData() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void setDate(int parameterIndex, Date x, Calendar cal) throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(x, null));
+  }
+
+  @Override
+  public void setTime(int parameterIndex, Time x, Calendar cal) throws SQLException {}
+
+  @Override
+  public void setTimestamp(int parameterIndex, Timestamp x, Calendar cal) throws SQLException {}
+
+  @Override
+  public void setNull(int parameterIndex, int sqlType, String typeName) throws SQLException {}
+
+  @Override
+  public void setURL(int parameterIndex, URL x) throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(x, null));
+  }
+
+  @Override
+  public ParameterMetaData getParameterMetaData() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void setRowId(int parameterIndex, RowId x) throws SQLException {
+    parameters.put(parameterIndex, new ParameterHolder(x, null));
+  }
+
+  @Override
+  public void setNString(int parameterIndex, String value) throws SQLException {}
+
+  @Override
+  public void setNCharacterStream(int parameterIndex, Reader value, long length)
+      throws SQLException {}
+
+  @Override
+  public void setNClob(int parameterIndex, NClob value) throws SQLException {}
+
+  @Override
+  public void setClob(int parameterIndex, Reader reader, long length) throws SQLException {}
+
+  @Override
+  public void setBlob(int parameterIndex, InputStream inputStream, long length)
+      throws SQLException {}
+
+  @Override
+  public void setNClob(int parameterIndex, Reader reader, long length) throws SQLException {}
+
+  @Override
+  public void setSQLXML(int parameterIndex, SQLXML xmlObject) throws SQLException {}
+
+  @Override
+  public void setObject(int parameterIndex, Object x, int targetSqlType, int scaleOrLength)
+      throws SQLException {}
+
+  @Override
+  public void setAsciiStream(int parameterIndex, InputStream x, long length) throws SQLException {}
+
+  @Override
+  public void setBinaryStream(int parameterIndex, InputStream x, long length) throws SQLException {}
+
+  @Override
+  public void setCharacterStream(int parameterIndex, Reader reader, long length)
+      throws SQLException {}
+
+  @Override
+  public void setAsciiStream(int parameterIndex, InputStream x) throws SQLException {}
+
+  @Override
+  public void setBinaryStream(int parameterIndex, InputStream x) throws SQLException {}
+
+  @Override
+  public void setCharacterStream(int parameterIndex, Reader reader) throws SQLException {}
+
+  @Override
+  public void setNCharacterStream(int parameterIndex, Reader value) throws SQLException {}
+
+  @Override
+  public void setClob(int parameterIndex, Reader reader) throws SQLException {}
+
+  @Override
+  public void setBlob(int parameterIndex, InputStream inputStream) throws SQLException {}
+
+  @Override
+  public void setNClob(int parameterIndex, Reader reader) throws SQLException {}
+
+  @Override
+  public ResultSet executeQuery(String sql) throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public int executeUpdate(String sql) throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void close() throws SQLException {}
+
+  @Override
+  public int getMaxFieldSize() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void setMaxFieldSize(int max) throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public int getMaxRows() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void setMaxRows(int max) throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void setEscapeProcessing(boolean enable) throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public int getQueryTimeout() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void setQueryTimeout(int seconds) throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void cancel() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public SQLWarning getWarnings() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void clearWarnings() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void setCursorName(String name) throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean execute(String sql) throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public ResultSet getResultSet() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public int getUpdateCount() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean getMoreResults() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void setFetchDirection(int direction) throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public int getFetchDirection() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void setFetchSize(int rows) throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public int getFetchSize() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public int getResultSetConcurrency() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public int getResultSetType() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void addBatch(String sql) throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void clearBatch() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public int[] executeBatch() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public Connection getConnection() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean getMoreResults(int current) throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public ResultSet getGeneratedKeys() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public int executeUpdate(String sql, int autoGeneratedKeys) throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public int executeUpdate(String sql, int[] columnIndexes) throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public int executeUpdate(String sql, String[] columnNames) throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean execute(String sql, int autoGeneratedKeys) throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean execute(String sql, int[] columnIndexes) throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean execute(String sql, String[] columnNames) throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public int getResultSetHoldability() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean isClosed() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void setPoolable(boolean poolable) throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean isPoolable() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void closeOnCompletion() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean isCloseOnCompletion() throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public <T> T unwrap(Class<T> iface) throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public boolean isWrapperFor(Class<?> iface) throws SQLException {
+    throw new UnsupportedOperationException();
+  }
+}
diff --git a/java/driver/derby/pom.xml b/java/driver/jdbc/pom.xml
similarity index 90%
rename from java/driver/derby/pom.xml
rename to java/driver/jdbc/pom.xml
index f0f357a..1437f59 100644
--- a/java/driver/derby/pom.xml
+++ b/java/driver/jdbc/pom.xml
@@ -20,8 +20,8 @@
 
   <artifactId>adbc-driver-derby</artifactId>
   <packaging>jar</packaging>
-  <name>Arrow ADBC Core</name>
-  <description>An example ADBC driver based on Apache Derby.</description>
+  <name>Arrow ADBC Driver JDBC</name>
+  <description>An ADBC driver wrapping the JDBC API.</description>
 
   <dependencies>
     <!-- Arrow -->
@@ -42,6 +42,10 @@
       <groupId>org.apache.arrow.adbc</groupId>
       <artifactId>adbc-core</artifactId>
     </dependency>
+    <dependency>
+      <groupId>org.apache.arrow.adbc</groupId>
+      <artifactId>adbc-driver-jdbc-util</artifactId>
+    </dependency>
     <dependency>
       <groupId>org.apache.arrow.adbc</groupId>
       <artifactId>adbc-driver-manager</artifactId>
@@ -53,11 +57,13 @@
       <groupId>org.apache.derby</groupId>
       <artifactId>derby</artifactId>
       <version>10.14.2.0</version>
+      <scope>test</scope>
     </dependency>
     <dependency>
       <groupId>org.apache.derby</groupId>
       <artifactId>derbytools</artifactId>
       <version>10.14.2.0</version>
+      <scope>test</scope>
     </dependency>
 
     <!-- Testing -->
diff --git a/java/driver/derby/src/main/java/org/apache/arrow/adbc/driver/derby/JdbcArrowReader.java b/java/driver/jdbc/src/main/java/org/apache/arrow/adbc/driver/jdbc/JdbcArrowReader.java
similarity index 85%
rename from java/driver/derby/src/main/java/org/apache/arrow/adbc/driver/derby/JdbcArrowReader.java
rename to java/driver/jdbc/src/main/java/org/apache/arrow/adbc/driver/jdbc/JdbcArrowReader.java
index bf6b76a..f4b8e73 100644
--- a/java/driver/derby/src/main/java/org/apache/arrow/adbc/driver/derby/JdbcArrowReader.java
+++ b/java/driver/jdbc/src/main/java/org/apache/arrow/adbc/driver/jdbc/JdbcArrowReader.java
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.arrow.adbc.driver.derby;
+package org.apache.arrow.adbc.driver.jdbc;
 
 import java.io.IOException;
 import java.sql.ResultSet;
@@ -25,6 +25,7 @@ import org.apache.arrow.adapter.jdbc.JdbcToArrowConfig;
 import org.apache.arrow.adapter.jdbc.JdbcToArrowConfigBuilder;
 import org.apache.arrow.adapter.jdbc.JdbcToArrowUtils;
 import org.apache.arrow.adbc.core.AdbcException;
+import org.apache.arrow.adbc.core.AdbcStatusCode;
 import org.apache.arrow.memory.BufferAllocator;
 import org.apache.arrow.vector.VectorSchemaRoot;
 import org.apache.arrow.vector.VectorUnloader;
@@ -41,8 +42,11 @@ public class JdbcArrowReader extends ArrowReader {
     super(allocator);
     try {
       this.delegate = JdbcToArrow.sqlToArrowVectorIterator(resultSet, allocator);
-    } catch (SQLException | IOException e) {
-      throw new AdbcException(e);
+    } catch (SQLException e) {
+      throw JdbcDriverUtil.fromSqlException(e);
+    } catch (IOException e) {
+      throw new AdbcException(
+          JdbcDriverUtil.prefixExceptionMessage(e.getMessage()), e, AdbcStatusCode.IO, null, -1);
     }
     final JdbcToArrowConfig config =
         new JdbcToArrowConfigBuilder()
@@ -52,14 +56,15 @@ public class JdbcArrowReader extends ArrowReader {
     try {
       this.schema = JdbcToArrowUtils.jdbcToArrowSchema(resultSet.getMetaData(), config);
     } catch (SQLException e) {
-      throw new AdbcException(e);
+      throw JdbcDriverUtil.fromSqlException(e);
     }
     this.bytesRead = 0;
 
     try {
       this.ensureInitialized();
     } catch (IOException e) {
-      throw new AdbcException(e);
+      throw new AdbcException(
+          JdbcDriverUtil.prefixExceptionMessage(e.getMessage()), e, AdbcStatusCode.IO, null, 0);
     }
   }
 
diff --git a/java/driver/derby/src/main/java/org/apache/arrow/adbc/driver/derby/DerbyConnection.java b/java/driver/jdbc/src/main/java/org/apache/arrow/adbc/driver/jdbc/JdbcConnection.java
similarity index 68%
rename from java/driver/derby/src/main/java/org/apache/arrow/adbc/driver/derby/DerbyConnection.java
rename to java/driver/jdbc/src/main/java/org/apache/arrow/adbc/driver/jdbc/JdbcConnection.java
index 4dd3a5a..c2e4091 100644
--- a/java/driver/derby/src/main/java/org/apache/arrow/adbc/driver/derby/DerbyConnection.java
+++ b/java/driver/jdbc/src/main/java/org/apache/arrow/adbc/driver/jdbc/JdbcConnection.java
@@ -14,26 +14,45 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.arrow.adbc.driver.derby;
+package org.apache.arrow.adbc.driver.jdbc;
 
 import java.sql.Connection;
+import java.sql.SQLException;
 import org.apache.arrow.adbc.core.AdbcConnection;
 import org.apache.arrow.adbc.core.AdbcException;
 import org.apache.arrow.adbc.core.AdbcStatement;
 import org.apache.arrow.memory.BufferAllocator;
 
-public class DerbyConnection implements AdbcConnection {
+public class JdbcConnection implements AdbcConnection {
   private final BufferAllocator allocator;
   private final Connection connection;
 
-  DerbyConnection(BufferAllocator allocator, Connection connection) {
+  JdbcConnection(BufferAllocator allocator, Connection connection) {
     this.allocator = allocator;
     this.connection = connection;
   }
 
+  @Override
+  public void commit() throws AdbcException {
+    try {
+      connection.commit();
+    } catch (SQLException e) {
+      throw JdbcDriverUtil.fromSqlException(e);
+    }
+  }
+
   @Override
   public AdbcStatement createStatement() throws AdbcException {
-    return new DerbyStatement(allocator, connection);
+    return new JdbcStatement(allocator, connection);
+  }
+
+  @Override
+  public void rollback() throws AdbcException {
+    try {
+      connection.rollback();
+    } catch (SQLException e) {
+      throw JdbcDriverUtil.fromSqlException(e);
+    }
   }
 
   @Override
diff --git a/java/driver/derby/src/main/java/org/apache/arrow/adbc/driver/derby/DerbyDatabase.java b/java/driver/jdbc/src/main/java/org/apache/arrow/adbc/driver/jdbc/JdbcDatabase.java
similarity index 67%
rename from java/driver/derby/src/main/java/org/apache/arrow/adbc/driver/derby/DerbyDatabase.java
rename to java/driver/jdbc/src/main/java/org/apache/arrow/adbc/driver/jdbc/JdbcDatabase.java
index b8fd4f5..eded48e 100644
--- a/java/driver/derby/src/main/java/org/apache/arrow/adbc/driver/derby/DerbyDatabase.java
+++ b/java/driver/jdbc/src/main/java/org/apache/arrow/adbc/driver/jdbc/JdbcDatabase.java
@@ -15,32 +15,33 @@
  * limitations under the License.
  */
 
-package org.apache.arrow.adbc.driver.derby;
+package org.apache.arrow.adbc.driver.jdbc;
 
 import java.sql.Connection;
 import java.sql.DriverManager;
 import java.sql.SQLException;
+import java.util.concurrent.atomic.AtomicInteger;
 import org.apache.arrow.adbc.core.AdbcConnection;
 import org.apache.arrow.adbc.core.AdbcDatabase;
 import org.apache.arrow.adbc.core.AdbcException;
 import org.apache.arrow.memory.BufferAllocator;
 
 /** An instance of a database (e.g. a handle to an in-memory database). */
-public final class DerbyDatabase implements AdbcDatabase {
+public final class JdbcDatabase implements AdbcDatabase {
   private final BufferAllocator allocator;
   private final String target;
   private final Connection connection;
+  private final AtomicInteger counter;
 
-  DerbyDatabase(BufferAllocator allocator, final String target) throws AdbcException {
+  JdbcDatabase(BufferAllocator allocator, final String target) throws AdbcException {
+    this.allocator = allocator;
+    this.target = target;
     try {
-      new org.apache.derby.jdbc.EmbeddedDriver();
-
-      this.allocator = allocator;
-      this.target = target;
-      this.connection = DriverManager.getConnection(target + ";create=true");
+      this.connection = DriverManager.getConnection(target);
     } catch (SQLException e) {
-      throw new AdbcException(e);
+      throw JdbcDriverUtil.fromSqlException(e);
     }
+    this.counter = new AtomicInteger();
   }
 
   @Override
@@ -49,11 +50,12 @@ public final class DerbyDatabase implements AdbcDatabase {
     try {
       connection = DriverManager.getConnection(target);
     } catch (SQLException e) {
-      throw new AdbcException(e);
+      throw JdbcDriverUtil.fromSqlException(e);
     }
-    // TODO: better naming of child allocator
-    return new DerbyConnection(
-        allocator.newChildAllocator("derby-connection", 0, allocator.getLimit()), connection);
+    final int count = counter.getAndIncrement();
+    return new JdbcConnection(
+        allocator.newChildAllocator("adbc-jdbc-connection-" + count, 0, allocator.getLimit()),
+        connection);
   }
 
   @Override
@@ -63,6 +65,6 @@ public final class DerbyDatabase implements AdbcDatabase {
 
   @Override
   public String toString() {
-    return "DerbyDatabase{" + "target='" + target + '\'' + '}';
+    return "JdbcDatabase{" + "target='" + target + '\'' + '}';
   }
 }
diff --git a/java/driver/derby/src/main/java/org/apache/arrow/adbc/driver/derby/DerbyDriver.java b/java/driver/jdbc/src/main/java/org/apache/arrow/adbc/driver/jdbc/JdbcDriver.java
similarity index 86%
rename from java/driver/derby/src/main/java/org/apache/arrow/adbc/driver/derby/DerbyDriver.java
rename to java/driver/jdbc/src/main/java/org/apache/arrow/adbc/driver/jdbc/JdbcDriver.java
index 446a109..4a88503 100644
--- a/java/driver/derby/src/main/java/org/apache/arrow/adbc/driver/derby/DerbyDriver.java
+++ b/java/driver/jdbc/src/main/java/org/apache/arrow/adbc/driver/jdbc/JdbcDriver.java
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.arrow.adbc.driver.derby;
+package org.apache.arrow.adbc.driver.jdbc;
 
 import java.util.Map;
 import org.apache.arrow.adbc.core.AdbcDatabase;
@@ -24,18 +24,18 @@ import org.apache.arrow.adbc.drivermanager.AdbcDriverManager;
 import org.apache.arrow.memory.BufferAllocator;
 import org.apache.arrow.memory.RootAllocator;
 
-public enum DerbyDriver implements AdbcDriver {
+public enum JdbcDriver implements AdbcDriver {
   INSTANCE;
 
   private final BufferAllocator allocator;
 
-  DerbyDriver() {
+  JdbcDriver() {
     allocator = new RootAllocator();
-    AdbcDriverManager.getInstance().registerDriver("org.apache.arrow.adbc.driver.derby", this);
+    AdbcDriverManager.getInstance().registerDriver("org.apache.arrow.adbc.driver.jdbc", this);
   }
 
   @Override
   public AdbcDatabase connect(Map<String, String> parameters) throws AdbcException {
-    return new DerbyDatabase(allocator, "jdbc:derby:" + parameters.get("path"));
+    return new JdbcDatabase(allocator, "jdbc:derby:" + parameters.get("path"));
   }
 }
diff --git a/java/core/src/main/java/org/apache/arrow/adbc/core/AdbcException.java b/java/driver/jdbc/src/main/java/org/apache/arrow/adbc/driver/jdbc/JdbcDriverUtil.java
similarity index 56%
copy from java/core/src/main/java/org/apache/arrow/adbc/core/AdbcException.java
copy to java/driver/jdbc/src/main/java/org/apache/arrow/adbc/driver/jdbc/JdbcDriverUtil.java
index f0845c1..80fcec6 100644
--- a/java/core/src/main/java/org/apache/arrow/adbc/core/AdbcException.java
+++ b/java/driver/jdbc/src/main/java/org/apache/arrow/adbc/driver/jdbc/JdbcDriverUtil.java
@@ -14,20 +14,27 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.arrow.adbc.core;
+package org.apache.arrow.adbc.driver.jdbc;
 
-public class AdbcException extends Exception {
-  public AdbcException() {}
+import java.sql.SQLException;
+import org.apache.arrow.adbc.core.AdbcException;
+import org.apache.arrow.adbc.core.AdbcStatusCode;
 
-  public AdbcException(String message) {
-    super(message);
+final class JdbcDriverUtil {
+  private JdbcDriverUtil() {
+    throw new AssertionError("Do not instantiate this class");
   }
 
-  public AdbcException(String message, Throwable cause) {
-    super(message, cause);
+  static String prefixExceptionMessage(final String s) {
+    return "[JDBC] " + s;
   }
 
-  public AdbcException(Throwable cause) {
-    super(cause);
+  static AdbcException fromSqlException(final SQLException e) {
+    return new AdbcException(
+        prefixExceptionMessage(e.getMessage()),
+        e.getCause(),
+        AdbcStatusCode.UNKNOWN,
+        e.getSQLState(),
+        e.getErrorCode());
   }
 }
diff --git a/java/driver/derby/src/main/java/org/apache/arrow/adbc/driver/derby/DerbyStatement.java b/java/driver/jdbc/src/main/java/org/apache/arrow/adbc/driver/jdbc/JdbcStatement.java
similarity index 83%
rename from java/driver/derby/src/main/java/org/apache/arrow/adbc/driver/derby/DerbyStatement.java
rename to java/driver/jdbc/src/main/java/org/apache/arrow/adbc/driver/jdbc/JdbcStatement.java
index 6abd69e..d21fc80 100644
--- a/java/driver/derby/src/main/java/org/apache/arrow/adbc/driver/derby/DerbyStatement.java
+++ b/java/driver/jdbc/src/main/java/org/apache/arrow/adbc/driver/jdbc/JdbcStatement.java
@@ -15,25 +15,23 @@
  * limitations under the License.
  */
 
-package org.apache.arrow.adbc.driver.derby;
+package org.apache.arrow.adbc.driver.jdbc;
 
 import java.sql.Connection;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.sql.Statement;
-import java.sql.Types;
 import java.util.Objects;
 import org.apache.arrow.adbc.core.AdbcException;
 import org.apache.arrow.adbc.core.AdbcStatement;
+import org.apache.arrow.adbc.driver.jdbc.util.JdbcParameterBinder;
 import org.apache.arrow.memory.BufferAllocator;
-import org.apache.arrow.vector.FieldVector;
-import org.apache.arrow.vector.IntVector;
 import org.apache.arrow.vector.VectorSchemaRoot;
 import org.apache.arrow.vector.ipc.ArrowReader;
 import org.apache.arrow.vector.types.pojo.Field;
 
-public class DerbyStatement implements AdbcStatement {
+public class JdbcStatement implements AdbcStatement {
   private final BufferAllocator allocator;
   private final Connection connection;
 
@@ -44,7 +42,7 @@ public class DerbyStatement implements AdbcStatement {
   private String bulkTargetTable;
   private VectorSchemaRoot bindRoot;
 
-  DerbyStatement(BufferAllocator allocator, Connection connection) {
+  JdbcStatement(BufferAllocator allocator, Connection connection) {
     this.allocator = allocator;
     this.connection = connection;
     this.sqlQuery = null;
@@ -132,10 +130,11 @@ public class DerbyStatement implements AdbcStatement {
     try (final Statement statement = connection.createStatement()) {
       statement.execute(create.toString());
     } catch (SQLException e) {
-      throw new AdbcException(e);
+      throw JdbcDriverUtil.fromSqlException(e);
     }
 
     // XXX: potential injection
+    // TODO: consider (optionally?) depending on jOOQ to generate SQL and support different dialects
     final StringBuilder insert = new StringBuilder("INSERT INTO ");
     insert.append(bulkTargetTable);
     insert.append(" VALUES (");
@@ -148,26 +147,15 @@ public class DerbyStatement implements AdbcStatement {
     insert.append(")");
 
     try (final PreparedStatement statement = connection.prepareStatement(insert.toString())) {
+      final JdbcParameterBinder binder =
+          JdbcParameterBinder.builder(statement, bindRoot).bindAll().build();
       statement.clearBatch();
-      for (int row = 0; row < bindRoot.getRowCount(); row++) {
-        for (int col = 0; col < bindRoot.getFieldVectors().size(); col++) {
-          final int parameterIndex = col + 1;
-          final FieldVector vector = bindRoot.getVector(col);
-          if (vector instanceof IntVector) {
-            if (vector.isNull(row)) {
-              statement.setNull(parameterIndex, Types.BIGINT);
-            } else {
-              statement.setLong(parameterIndex, ((IntVector) vector).get(row));
-            }
-          } else {
-            throw new UnsupportedOperationException("Unsupported Arrow type: " + vector.getField());
-          }
-        }
+      while (binder.next()) {
         statement.addBatch();
       }
       statement.executeBatch();
     } catch (SQLException e) {
-      throw new AdbcException(e);
+      throw JdbcDriverUtil.fromSqlException(e);
     }
   }
 
@@ -180,7 +168,7 @@ public class DerbyStatement implements AdbcStatement {
           connection.createStatement(ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY);
       resultSet = statement.executeQuery(sqlQuery);
     } catch (SQLException e) {
-      throw new AdbcException(e);
+      throw JdbcDriverUtil.fromSqlException(e);
     }
   }
 
@@ -195,7 +183,9 @@ public class DerbyStatement implements AdbcStatement {
   }
 
   @Override
-  public void prepare() {}
+  public void prepare() {
+    throw new UnsupportedOperationException("prepare");
+  }
 
   @Override
   public void close() throws Exception {
diff --git a/java/driver/derby/src/test/java/org/apache/arrow/adbc/driver/derby/DerbyDatabaseTest.java b/java/driver/jdbc/src/test/java/org/apache/arrow/adbc/driver/jdbc/JdbcDatabaseTest.java
similarity index 88%
rename from java/driver/derby/src/test/java/org/apache/arrow/adbc/driver/derby/DerbyDatabaseTest.java
rename to java/driver/jdbc/src/test/java/org/apache/arrow/adbc/driver/jdbc/JdbcDatabaseTest.java
index 3bf6459..f80649f 100644
--- a/java/driver/derby/src/test/java/org/apache/arrow/adbc/driver/derby/DerbyDatabaseTest.java
+++ b/java/driver/jdbc/src/test/java/org/apache/arrow/adbc/driver/jdbc/JdbcDatabaseTest.java
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-package org.apache.arrow.adbc.driver.derby;
+package org.apache.arrow.adbc.driver.jdbc;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -35,15 +35,21 @@ import org.apache.arrow.vector.ipc.ArrowReader;
 import org.apache.arrow.vector.types.pojo.ArrowType;
 import org.apache.arrow.vector.types.pojo.Field;
 import org.apache.arrow.vector.types.pojo.Schema;
+import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
-class DerbyDatabaseTest {
+class JdbcDatabaseTest {
+  @BeforeAll
+  static void beforeAll() {
+    new org.apache.derby.jdbc.EmbeddedDriver();
+  }
+
   @Test
   void simpleQuery(@TempDir Path tempDir) throws Exception {
     final Map<String, String> parameters = new HashMap<>();
-    parameters.put("path", tempDir.toString() + "/db");
-    try (final AdbcDatabase database = DerbyDriver.INSTANCE.connect(parameters);
+    parameters.put("path", tempDir.toString() + "/db;create=true");
+    try (final AdbcDatabase database = JdbcDriver.INSTANCE.connect(parameters);
         final AdbcConnection connection = database.connect();
         final AdbcStatement statement = connection.createStatement()) {
       statement.setSqlQuery("SELECT * FROM SYS.SYSTABLES");
@@ -57,13 +63,13 @@ class DerbyDatabaseTest {
   @Test
   void bulkInsert(@TempDir Path tempDir) throws Exception {
     final Map<String, String> parameters = new HashMap<>();
-    parameters.put("path", tempDir.toString() + "/db");
+    parameters.put("path", tempDir.toString() + "/db;create=true");
     final Schema schema =
         new Schema(
             Collections.singletonList(
                 Field.nullable("ints", new ArrowType.Int(32, /*signed=*/ true))));
     try (final BufferAllocator allocator = new RootAllocator();
-        final AdbcDatabase database = DerbyDriver.INSTANCE.connect(parameters);
+        final AdbcDatabase database = JdbcDriver.INSTANCE.connect(parameters);
         final AdbcConnection connection = database.connect()) {
       try (final AdbcStatement statement = connection.createStatement();
           final VectorSchemaRoot ingest = VectorSchemaRoot.create(schema, allocator)) {
diff --git a/java/pom.xml b/java/pom.xml
index 5e11b85..8645c11 100644
--- a/java/pom.xml
+++ b/java/pom.xml
@@ -29,6 +29,7 @@
 
   <properties>
     <dep.arrow.version>8.0.0</dep.arrow.version>
+    <adbc.version>9.0.0-SNAPSHOT</adbc.version>
   </properties>
 
   <scm>
@@ -67,7 +68,8 @@
 
   <modules>
     <module>core</module>
-    <module>driver/derby</module>
+    <module>driver/jdbc</module>
+    <module>driver/jdbc-util</module>
     <module>driver-manager</module>
   </modules>
 
@@ -93,12 +95,17 @@
       <dependency>
         <groupId>org.apache.arrow.adbc</groupId>
         <artifactId>adbc-core</artifactId>
-        <version>9.0.0-SNAPSHOT</version>
+        <version>${adbc.version}</version>
+      </dependency>
+      <dependency>
+        <groupId>org.apache.arrow.adbc</groupId>
+        <artifactId>adbc-driver-jdbc-util</artifactId>
+        <version>${adbc.version}</version>
       </dependency>
       <dependency>
         <groupId>org.apache.arrow.adbc</groupId>
         <artifactId>adbc-driver-manager</artifactId>
-        <version>9.0.0-SNAPSHOT</version>
+        <version>${adbc.version}</version>
       </dependency>
 
       <!-- Testing -->
@@ -156,6 +163,14 @@
             </goals>
           </execution>
         </executions>
+        <configuration>
+          <excludeSubProjects>false</excludeSubProjects>
+          <excludes>
+            <exclude>**/*.iml</exclude>
+            <exclude>**/*.log</exclude>
+            <exclude>**/target/**</exclude>
+          </excludes>
+        </configuration>
       </plugin>
     </plugins>
   </build>