diff --git a/flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/JdbcExecutionOptions.java b/flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/JdbcExecutionOptions.java
index c19677b07..76882c66f 100644
--- a/flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/JdbcExecutionOptions.java
+++ b/flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/JdbcExecutionOptions.java
@@ -33,12 +33,15 @@ public class JdbcExecutionOptions implements Serializable {
private final long batchIntervalMs;
private final int batchSize;
private final int maxRetries;
+ private final boolean bulkInsertEnabled;
- private JdbcExecutionOptions(long batchIntervalMs, int batchSize, int maxRetries) {
+ private JdbcExecutionOptions(
+ long batchIntervalMs, int batchSize, int maxRetries, boolean bulkInsertEnabled) {
Preconditions.checkArgument(maxRetries >= 0);
this.batchIntervalMs = batchIntervalMs;
this.batchSize = batchSize;
this.maxRetries = maxRetries;
+ this.bulkInsertEnabled = bulkInsertEnabled;
}
public long getBatchIntervalMs() {
@@ -53,6 +56,16 @@ public int getMaxRetries() {
return maxRetries;
}
+ /**
+ * Returns whether the dialect's bulk insert optimization is enabled. The selected dialect must
+ * implement {@link
+ * org.apache.flink.connector.jdbc.core.database.dialect.JdbcBulkInsertDialect}; otherwise the
+ * sink fails fast at build time.
+ */
+ public boolean isBulkInsertEnabled() {
+ return bulkInsertEnabled;
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) {
@@ -64,12 +77,13 @@ public boolean equals(Object o) {
JdbcExecutionOptions that = (JdbcExecutionOptions) o;
return batchIntervalMs == that.batchIntervalMs
&& batchSize == that.batchSize
- && maxRetries == that.maxRetries;
+ && maxRetries == that.maxRetries
+ && bulkInsertEnabled == that.bulkInsertEnabled;
}
@Override
public int hashCode() {
- return Objects.hash(batchIntervalMs, batchSize, maxRetries);
+ return Objects.hash(batchIntervalMs, batchSize, maxRetries, bulkInsertEnabled);
}
public static Builder builder() {
@@ -86,6 +100,7 @@ public static final class Builder {
private long intervalMs = DEFAULT_INTERVAL_MILLIS;
private int size = DEFAULT_SIZE;
private int maxRetries = DEFAULT_MAX_RETRY_TIMES;
+ private boolean bulkInsertEnabled = false;
public Builder withBatchSize(int size) {
this.size = size;
@@ -102,8 +117,21 @@ public Builder withMaxRetries(int maxRetries) {
return this;
}
+ /**
+ * Enable or disable the dialect's bulk insert optimization. The selected dialect must
+ * implement {@link
+ * org.apache.flink.connector.jdbc.core.database.dialect.JdbcBulkInsertDialect}; when
+ * enabled with a dialect that does not, the sink fails fast at build time.
+ *
+ *
Default is {@code false}.
+ */
+ public Builder withBulkInsertEnabled(boolean enabled) {
+ this.bulkInsertEnabled = enabled;
+ return this;
+ }
+
public JdbcExecutionOptions build() {
- return new JdbcExecutionOptions(intervalMs, size, maxRetries);
+ return new JdbcExecutionOptions(intervalMs, size, maxRetries, bulkInsertEnabled);
}
}
}
diff --git a/flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/core/database/dialect/JdbcBulkInsertDialect.java b/flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/core/database/dialect/JdbcBulkInsertDialect.java
new file mode 100644
index 000000000..b122a1a2d
--- /dev/null
+++ b/flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/core/database/dialect/JdbcBulkInsertDialect.java
@@ -0,0 +1,129 @@
+/*
+ * 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.flink.connector.jdbc.core.database.dialect;
+
+import org.apache.flink.annotation.PublicEvolving;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.types.logical.DecimalType;
+import org.apache.flink.table.types.logical.LocalZonedTimestampType;
+import org.apache.flink.table.types.logical.LogicalType;
+import org.apache.flink.table.types.logical.TimestampType;
+
+import java.sql.Date;
+import java.sql.Time;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.util.Optional;
+
+/**
+ * Capability interface for dialects that support database-specific bulk insert optimizations (e.g.,
+ * PostgreSQL's {@code UNNEST}). A dialect that does not implement this interface cannot be used
+ * with bulk insert mode; the sink will fail fast at build time rather than silently falling back.
+ */
+@PublicEvolving
+public interface JdbcBulkInsertDialect extends JdbcDialect {
+
+ /**
+ * Generate a batch insert statement using the database's bulk insert optimization.
+ *
+ * @param tableName the target table name
+ * @param fieldNames array of field names
+ * @param fieldTypes array of database type names for each field
+ * @return Optional containing the optimized batch insert SQL, or empty if not applicable for
+ * the given inputs (e.g., no fields)
+ */
+ Optional getBatchInsertStatement(
+ String tableName, String[] fieldNames, String[] fieldTypes);
+
+ /**
+ * Generate a batch upsert statement using the database's bulk insert optimization with conflict
+ * handling.
+ *
+ * @param tableName the target table name
+ * @param fieldNames array of all field names
+ * @param fieldTypes array of database type names for each field
+ * @param uniqueKeyFields array of unique key field names for conflict detection
+ * @return Optional containing the optimized batch upsert SQL, or empty if not applicable
+ */
+ Optional getBatchUpsertStatement(
+ String tableName, String[] fieldNames, String[] fieldTypes, String[] uniqueKeyFields);
+
+ /**
+ * Database-specific type name used for array creation (e.g., {@code
+ * Connection.createArrayOf(typeName, values)} and SQL type casting).
+ *
+ * @param logicalType the Flink logical type
+ * @return the database-specific type name for array operations
+ * @throws UnsupportedOperationException if the type is not supported for array operations
+ */
+ String getArrayTypeName(LogicalType logicalType);
+
+ /**
+ * Extract a value from {@link RowData} and convert it to a JDBC-compatible Java object suitable
+ * for {@link java.sql.Connection#createArrayOf(String, Object[])}. Dialects may override this
+ * to handle database-specific types (e.g., PostgreSQL {@code JSONB}, {@code UUID}).
+ */
+ default Object toJdbcValue(RowData row, int pos, LogicalType type) {
+ if (row.isNullAt(pos)) {
+ return null;
+ }
+
+ switch (type.getTypeRoot()) {
+ case BOOLEAN:
+ return row.getBoolean(pos);
+ case TINYINT:
+ return (short) row.getByte(pos);
+ case SMALLINT:
+ return row.getShort(pos);
+ case INTEGER:
+ return row.getInt(pos);
+ case BIGINT:
+ return row.getLong(pos);
+ case FLOAT:
+ return row.getFloat(pos);
+ case DOUBLE:
+ return row.getDouble(pos);
+ case DECIMAL:
+ DecimalType decimalType = (DecimalType) type;
+ return row.getDecimal(pos, decimalType.getPrecision(), decimalType.getScale())
+ .toBigDecimal();
+ case CHAR:
+ case VARCHAR:
+ return row.getString(pos).toString();
+ case DATE:
+ return Date.valueOf(LocalDate.ofEpochDay(row.getInt(pos)));
+ case TIME_WITHOUT_TIME_ZONE:
+ return Time.valueOf(LocalTime.ofNanoOfDay(row.getInt(pos) * 1_000_000L));
+ case TIMESTAMP_WITHOUT_TIME_ZONE:
+ TimestampType tsType = (TimestampType) type;
+ return row.getTimestamp(pos, tsType.getPrecision()).toTimestamp();
+ case TIMESTAMP_WITH_LOCAL_TIME_ZONE:
+ LocalZonedTimestampType lztsType = (LocalZonedTimestampType) type;
+ return row.getTimestamp(pos, lztsType.getPrecision()).toTimestamp();
+ case VARBINARY:
+ return row.getBinary(pos);
+ default:
+ throw new UnsupportedOperationException(
+ String.format(
+ "Type %s is not supported for bulk insert. "
+ + "Please disable it by setting 'sink.bulk-insert.enabled' = 'false'.",
+ type));
+ }
+ }
+}
diff --git a/flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/core/table/JdbcConnectorOptions.java b/flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/core/table/JdbcConnectorOptions.java
index 37d5362ce..b4185d307 100644
--- a/flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/core/table/JdbcConnectorOptions.java
+++ b/flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/core/table/JdbcConnectorOptions.java
@@ -179,6 +179,16 @@ public class JdbcConnectorOptions {
.defaultValue(3)
.withDescription("The max retry times if writing records to database failed.");
+ public static final ConfigOption SINK_BULK_INSERT_ENABLED =
+ ConfigOptions.key("sink.bulk-insert.enabled")
+ .booleanType()
+ .defaultValue(false)
+ .withDescription(
+ "Enable the dialect's bulk insert optimization for batch writes (e.g., "
+ + "PostgreSQL's UNNEST). The selected dialect must implement "
+ + "JdbcBulkInsertDialect; otherwise the sink fails fast at build time. "
+ + "Default is false.");
+
public static final ConfigOption FILTER_HANDLING_POLICY =
ConfigOptions.key("filter.handling.policy")
.enumType(FilterHandlingPolicy.class)
diff --git a/flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/core/table/JdbcDynamicTableFactory.java b/flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/core/table/JdbcDynamicTableFactory.java
index 8c82f455a..746358280 100644
--- a/flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/core/table/JdbcDynamicTableFactory.java
+++ b/flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/core/table/JdbcDynamicTableFactory.java
@@ -69,6 +69,7 @@
import static org.apache.flink.connector.jdbc.core.table.JdbcConnectorOptions.SCAN_PARTITION_UPPER_BOUND;
import static org.apache.flink.connector.jdbc.core.table.JdbcConnectorOptions.SINK_BUFFER_FLUSH_INTERVAL;
import static org.apache.flink.connector.jdbc.core.table.JdbcConnectorOptions.SINK_BUFFER_FLUSH_MAX_ROWS;
+import static org.apache.flink.connector.jdbc.core.table.JdbcConnectorOptions.SINK_BULK_INSERT_ENABLED;
import static org.apache.flink.connector.jdbc.core.table.JdbcConnectorOptions.SINK_MAX_RETRIES;
import static org.apache.flink.connector.jdbc.core.table.JdbcConnectorOptions.SINK_PARALLELISM;
import static org.apache.flink.connector.jdbc.core.table.JdbcConnectorOptions.TABLE_NAME;
@@ -185,6 +186,7 @@ private JdbcExecutionOptions getJdbcExecutionOptions(ReadableConfig config) {
builder.withBatchSize(config.get(SINK_BUFFER_FLUSH_MAX_ROWS));
builder.withBatchIntervalMs(config.get(SINK_BUFFER_FLUSH_INTERVAL).toMillis());
builder.withMaxRetries(config.get(SINK_MAX_RETRIES));
+ builder.withBulkInsertEnabled(config.get(SINK_BULK_INSERT_ENABLED));
return builder.build();
}
@@ -258,6 +260,7 @@ public Set> optionalOptions() {
optionalOptions.add(SINK_BUFFER_FLUSH_MAX_ROWS);
optionalOptions.add(SINK_BUFFER_FLUSH_INTERVAL);
optionalOptions.add(SINK_MAX_RETRIES);
+ optionalOptions.add(SINK_BULK_INSERT_ENABLED);
optionalOptions.add(SINK_PARALLELISM);
optionalOptions.add(MAX_RETRY_TIMEOUT);
optionalOptions.add(LookupOptions.CACHE_TYPE);
diff --git a/flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/core/table/sink/JdbcOutputFormatBuilder.java b/flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/core/table/sink/JdbcOutputFormatBuilder.java
index 493cc8d22..01e8c15e0 100644
--- a/flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/core/table/sink/JdbcOutputFormatBuilder.java
+++ b/flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/core/table/sink/JdbcOutputFormatBuilder.java
@@ -19,6 +19,7 @@
package org.apache.flink.connector.jdbc.core.table.sink;
import org.apache.flink.connector.jdbc.JdbcExecutionOptions;
+import org.apache.flink.connector.jdbc.core.database.dialect.JdbcBulkInsertDialect;
import org.apache.flink.connector.jdbc.core.database.dialect.JdbcDialect;
import org.apache.flink.connector.jdbc.core.database.dialect.JdbcDialectConverter;
import org.apache.flink.connector.jdbc.datasource.connections.SimpleJdbcConnectionProvider;
@@ -28,6 +29,7 @@
import org.apache.flink.connector.jdbc.internal.executor.TableBufferedStatementExecutor;
import org.apache.flink.connector.jdbc.internal.executor.TableInsertOrUpdateStatementExecutor;
import org.apache.flink.connector.jdbc.internal.executor.TableSimpleStatementExecutor;
+import org.apache.flink.connector.jdbc.internal.executor.TableUnnestStatementExecutor;
import org.apache.flink.connector.jdbc.internal.options.InternalJdbcConnectionOptions;
import org.apache.flink.connector.jdbc.internal.options.JdbcDmlOptions;
import org.apache.flink.connector.jdbc.statement.FieldNamedPreparedStatement;
@@ -37,8 +39,12 @@
import org.apache.flink.table.types.logical.LogicalType;
import org.apache.flink.table.types.logical.RowType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
import java.io.Serializable;
import java.util.Arrays;
+import java.util.Optional;
import java.util.function.Function;
import static org.apache.flink.table.data.RowData.createFieldGetter;
@@ -49,6 +55,7 @@
public class JdbcOutputFormatBuilder implements Serializable {
private static final long serialVersionUID = 1L;
+ private static final Logger LOG = LoggerFactory.getLogger(JdbcOutputFormatBuilder.class);
private InternalJdbcConnectionOptions jdbcOptions;
private JdbcExecutionOptions executionOptions;
@@ -86,19 +93,15 @@ public JdbcOutputFormatBuilder setFieldDataTypes(DataType[] fieldDataTypes) {
Arrays.stream(fieldDataTypes)
.map(DataType::getLogicalType)
.toArray(LogicalType[]::new);
+
+ boolean useBulkInsert = shouldUseBulkInsert(executionOptions, dmlOptions.getDialect());
+
if (dmlOptions.getKeyFields().isPresent() && dmlOptions.getKeyFields().get().length > 0) {
- // upsert query
return new JdbcOutputFormat<>(
new SimpleJdbcConnectionProvider(jdbcOptions),
executionOptions,
- () -> createBufferReduceExecutor(dmlOptions, logicalTypes));
+ () -> createBufferReduceExecutor(dmlOptions, logicalTypes, useBulkInsert));
} else {
- // append only query
- final String sql =
- dmlOptions
- .getDialect()
- .getInsertIntoStatement(
- dmlOptions.getTableName(), dmlOptions.getFieldNames());
return new JdbcOutputFormat<>(
new SimpleJdbcConnectionProvider(jdbcOptions),
executionOptions,
@@ -107,12 +110,29 @@ public JdbcOutputFormatBuilder setFieldDataTypes(DataType[] fieldDataTypes) {
dmlOptions.getDialect(),
dmlOptions.getFieldNames(),
logicalTypes,
- sql));
+ dmlOptions.getTableName(),
+ useBulkInsert));
+ }
+ }
+
+ private static boolean shouldUseBulkInsert(
+ JdbcExecutionOptions executionOptions, JdbcDialect dialect) {
+ if (!executionOptions.isBulkInsertEnabled() || executionOptions.getBatchSize() <= 1) {
+ return false;
}
+ if (!(dialect instanceof JdbcBulkInsertDialect)) {
+ throw new IllegalStateException(
+ String.format(
+ "Bulk insert is enabled but dialect '%s' does not implement "
+ + "JdbcBulkInsertDialect. Either switch to a dialect that supports it "
+ + "or set 'sink.bulk-insert.enabled' = 'false'.",
+ dialect.dialectName()));
+ }
+ return true;
}
private static JdbcBatchStatementExecutor createBufferReduceExecutor(
- JdbcDmlOptions opt, LogicalType[] fieldTypes) {
+ JdbcDmlOptions opt, LogicalType[] fieldTypes, boolean useBulkInsert) {
checkArgument(opt.getKeyFields().isPresent());
JdbcDialect dialect = opt.getDialect();
String tableName = opt.getTableName();
@@ -132,16 +152,21 @@ private static JdbcBatchStatementExecutor createBufferReduceExecutor(
fieldTypes,
pkFields,
pkNames,
- pkTypes),
+ pkTypes,
+ useBulkInsert),
createDeleteExecutor(dialect, tableName, pkNames, pkTypes),
createRowKeyExtractor(fieldTypes, pkFields));
}
private static JdbcBatchStatementExecutor createSimpleBufferedExecutor(
- JdbcDialect dialect, String[] fieldNames, LogicalType[] fieldTypes, String sql) {
+ JdbcDialect dialect,
+ String[] fieldNames,
+ LogicalType[] fieldTypes,
+ String tableName,
+ boolean useBulkInsert) {
return new TableBufferedStatementExecutor(
- createSimpleRowExecutor(dialect, fieldNames, fieldTypes, sql));
+ createSimpleRowExecutor(dialect, fieldNames, fieldTypes, tableName, useBulkInsert));
}
private static JdbcBatchStatementExecutor createUpsertRowExecutor(
@@ -151,7 +176,28 @@ private static JdbcBatchStatementExecutor createUpsertRowExecutor(
LogicalType[] fieldTypes,
int[] pkFields,
String[] pkNames,
- LogicalType[] pkTypes) {
+ LogicalType[] pkTypes,
+ boolean useBulkInsert) {
+
+ if (useBulkInsert) {
+ JdbcBulkInsertDialect bulkDialect = (JdbcBulkInsertDialect) dialect;
+ String[] fieldTypeNames = getFieldTypeNames(fieldTypes, bulkDialect);
+ Optional bulkSql =
+ bulkDialect.getBatchUpsertStatement(
+ tableName, fieldNames, fieldTypeNames, pkNames);
+
+ if (bulkSql.isPresent()) {
+ return new TableUnnestStatementExecutor(
+ bulkSql.get(), RowType.of(fieldTypes), bulkDialect);
+ } else {
+ throw new IllegalStateException(
+ String.format(
+ "Bulk insert is enabled but dialect '%s' returned no upsert statement "
+ + "for the given inputs.",
+ dialect.dialectName()));
+ }
+ }
+
return dialect.getUpsertStatement(tableName, fieldNames, pkNames)
.map(sql -> createSimpleRowExecutor(dialect, fieldNames, fieldTypes, sql))
.orElseGet(
@@ -166,6 +212,35 @@ private static JdbcBatchStatementExecutor createUpsertRowExecutor(
pkTypes));
}
+ private static JdbcBatchStatementExecutor createSimpleRowExecutor(
+ JdbcDialect dialect,
+ String[] fieldNames,
+ LogicalType[] fieldTypes,
+ String tableName,
+ boolean useBulkInsert) {
+
+ if (useBulkInsert) {
+ JdbcBulkInsertDialect bulkDialect = (JdbcBulkInsertDialect) dialect;
+ String[] fieldTypeNames = getFieldTypeNames(fieldTypes, bulkDialect);
+ Optional bulkSql =
+ bulkDialect.getBatchInsertStatement(tableName, fieldNames, fieldTypeNames);
+
+ if (bulkSql.isPresent()) {
+ return new TableUnnestStatementExecutor(
+ bulkSql.get(), RowType.of(fieldTypes), bulkDialect);
+ } else {
+ throw new IllegalStateException(
+ String.format(
+ "Bulk insert is enabled but dialect '%s' returned no insert statement "
+ + "for the given inputs.",
+ dialect.dialectName()));
+ }
+ }
+
+ final String sql = dialect.getInsertIntoStatement(tableName, fieldNames);
+ return createSimpleRowExecutor(dialect, fieldNames, fieldTypes, sql);
+ }
+
private static JdbcBatchStatementExecutor createDeleteExecutor(
JdbcDialect dialect, String tableName, String[] pkNames, LogicalType[] pkTypes) {
String deleteSql = dialect.getDeleteStatement(tableName, pkNames);
@@ -181,6 +256,15 @@ private static JdbcBatchStatementExecutor createSimpleRowExecutor(
rowConverter);
}
+ private static String[] getFieldTypeNames(
+ LogicalType[] fieldTypes, JdbcBulkInsertDialect dialect) {
+ String[] typeNames = new String[fieldTypes.length];
+ for (int i = 0; i < fieldTypes.length; i++) {
+ typeNames[i] = dialect.getArrayTypeName(fieldTypes[i]);
+ }
+ return typeNames;
+ }
+
private static JdbcBatchStatementExecutor createInsertOrUpdateExecutor(
JdbcDialect dialect,
String tableName,
diff --git a/flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/internal/executor/TableUnnestStatementExecutor.java b/flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/internal/executor/TableUnnestStatementExecutor.java
new file mode 100644
index 000000000..a9eefdb33
--- /dev/null
+++ b/flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/internal/executor/TableUnnestStatementExecutor.java
@@ -0,0 +1,153 @@
+/*
+ * 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.flink.connector.jdbc.internal.executor;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.connector.jdbc.core.database.dialect.JdbcBulkInsertDialect;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.types.logical.LogicalType;
+import org.apache.flink.table.types.logical.RowType;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.sql.Array;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.apache.flink.util.Preconditions.checkNotNull;
+
+/**
+ * Table API specific bulk-insert batch statement executor.
+ *
+ *
Uses a {@link JdbcBulkInsertDialect} to generate the SQL and to convert column values into
+ * JDBC-compatible objects, which are then bound as SQL arrays (e.g., PostgreSQL's {@code UNNEST}).
+ * Compatible with both INSERT and UPSERT modes.
+ */
+@Internal
+public class TableUnnestStatementExecutor implements JdbcBatchStatementExecutor {
+
+ private static final Logger LOG = LoggerFactory.getLogger(TableUnnestStatementExecutor.class);
+
+ private final String sql;
+ private final RowType rowType;
+ private final JdbcBulkInsertDialect dialect;
+
+ private final List