This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/dev/pr-13037-e805677f731be93d254cd844355fd09c89225041 in repository https://gitbox.apache.org/repos/asf/seatunnel.git
commit 3ac2d1cec6aa7b3741c4dacdd23020f778e23c02 Author: JacobZheng0927 <[email protected]> AuthorDate: Fri Oct 30 05:38:46 2026 +0000 [Fix][Connector-V2] Preserve NULL transitions in DB2 upserts (#13037) --- docs/en/connectors/sink/Jdbc.md | 4 + docs/zh/connectors/sink/Jdbc.md | 4 + .../jdbc/internal/dialect/db2/DB2Dialect.java | 8 +- .../jdbc/internal/dialect/db2/DB2DialectTest.java | 2 +- .../connectors/seatunnel/jdbc/JdbcDb2UpsertIT.java | 141 +++++++++++++++++++++ 5 files changed, 154 insertions(+), 5 deletions(-) diff --git a/docs/en/connectors/sink/Jdbc.md b/docs/en/connectors/sink/Jdbc.md index fbebfdfbd4..7f81690643 100644 --- a/docs/en/connectors/sink/Jdbc.md +++ b/docs/en/connectors/sink/Jdbc.md @@ -8,6 +8,10 @@ The JDBC Sink connector writes SeaTunnel rows to databases through a JDBC driver If this is your first JDBC Sink job, start with [Choose a write mode](#choose-a-write-mode) and the [Quick start](#quick-start-postgresql). The complete option reference follows those sections. +### DB2 upsert NULL handling + +Generated DB2 upserts update matched rows when an incoming field changes, including NULL-to-value and value-to-NULL transitions. Equal values, including two NULLs, do not trigger an unnecessary matched update. + ## MySQL temporal values MySQL `DATETIME` maps to SeaTunnel `TIMESTAMP` (`LocalDateTime`). The MySQL JDBC sink binds its local date/time fields directly, preserving fractional seconds supported by the target column even when the worker JVM and JDBC connection time zones differ. MySQL `TIMESTAMP` maps to `TIMESTAMP_TZ` and retains its instant-based handling. diff --git a/docs/zh/connectors/sink/Jdbc.md b/docs/zh/connectors/sink/Jdbc.md index d826866884..9910178e15 100644 --- a/docs/zh/connectors/sink/Jdbc.md +++ b/docs/zh/connectors/sink/Jdbc.md @@ -8,6 +8,10 @@ JDBC Sink 通过数据库厂商提供的 JDBC 驱动写入数据。它支持批 第一次配置 JDBC Sink 时,请先阅读[选择写入模式](#选择写入模式)和[快速入门](#快速入门postgresql),再按需查阅后面的完整参数说明。 +### DB2 upsert 的 NULL 处理 + +生成的 DB2 upsert 会在传入字段发生变化时更新匹配行,包括 NULL 变为非 NULL 和非 NULL 变为 NULL。相等值(包括两端均为 NULL)不会触发多余的匹配更新。 + ## MySQL 时间值 MySQL `DATETIME` 映射为 SeaTunnel `TIMESTAMP`(`LocalDateTime`)。MySQL JDBC Sink 直接绑定本地日期和时间字段,即使 Worker JVM 与 JDBC 连接使用不同的时区,也会保留目标列支持的小数秒精度。MySQL `TIMESTAMP` 映射为 `TIMESTAMP_TZ`,仍按时间点处理。 diff --git a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/dialect/db2/DB2Dialect.java b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/dialect/db2/DB2Dialect.java index 80ec6709e2..cbb324c457 100644 --- a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/dialect/db2/DB2Dialect.java +++ b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/dialect/db2/DB2Dialect.java @@ -107,10 +107,10 @@ public class DB2Dialect implements JdbcDialect { Arrays.stream(fieldNames) .map( field -> - "target." - + quoteIdentifier(field) - + " <> source." - + quoteIdentifier(field)) + String.format( + "DECODE(%s, %s, 0, 1) = 1", + "target." + quoteIdentifier(field), + "source." + quoteIdentifier(field))) .collect(Collectors.joining(" OR ")); // Generate UPDATE SET clause diff --git a/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/dialect/db2/DB2DialectTest.java b/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/dialect/db2/DB2DialectTest.java index 253577dafa..5ff477efe7 100644 --- a/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/dialect/db2/DB2DialectTest.java +++ b/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/dialect/db2/DB2DialectTest.java @@ -42,7 +42,7 @@ public class DB2DialectTest { // DB2 JDBC URLs select the database, so schema-qualified table names must stay // schema.table in DML statements. Assertions.assertEquals( - "MERGE INTO \"test_schema\".\"role\" AS target USING (VALUES (?, ?, ?, ?, ?, ?, ?)) AS source (\"id\", \"type\", \"role_name\", \"description\", \"create_time\", \"update_time\", \"id_2\") ON target.\"id\" = source.\"id\" AND target.\"id_2\" = source.\"id_2\" WHEN MATCHED AND (target.\"id\" <> source.\"id\" OR target.\"type\" <> source.\"type\" OR target.\"role_name\" <> source.\"role_name\" OR target.\"description\" <> source.\"description\" OR target.\"create_time\" <> [...] + "MERGE INTO \"test_schema\".\"role\" AS target USING (VALUES (?, ?, ?, ?, ?, ?, ?)) AS source (\"id\", \"type\", \"role_name\", \"description\", \"create_time\", \"update_time\", \"id_2\") ON target.\"id\" = source.\"id\" AND target.\"id_2\" = source.\"id_2\" WHEN MATCHED AND (DECODE(target.\"id\", source.\"id\", 0, 1) = 1 OR DECODE(target.\"type\", source.\"type\", 0, 1) = 1 OR DECODE(target.\"role_name\", source.\"role_name\", 0, 1) = 1 OR DECODE(target.\"description\", [...] upsertSql); // test insert sql diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-jdbc-e2e/connector-jdbc-e2e-part-1/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/JdbcDb2UpsertIT.java b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-jdbc-e2e/connector-jdbc-e2e-part-1/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/JdbcDb2UpsertIT.java index 8de745e120..7eccf240d0 100644 --- a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-jdbc-e2e/connector-jdbc-e2e-part-1/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/JdbcDb2UpsertIT.java +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-jdbc-e2e/connector-jdbc-e2e-part-1/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/JdbcDb2UpsertIT.java @@ -20,16 +20,26 @@ package org.apache.seatunnel.connectors.seatunnel.jdbc; import org.apache.seatunnel.shade.com.google.common.collect.Lists; +import org.apache.seatunnel.api.table.catalog.PhysicalColumn; +import org.apache.seatunnel.api.table.catalog.TableSchema; +import org.apache.seatunnel.api.table.type.BasicType; +import org.apache.seatunnel.api.table.type.LocalTimeType; +import org.apache.seatunnel.api.table.type.PrimitiveByteArrayType; +import org.apache.seatunnel.api.table.type.SeaTunnelRow; +import org.apache.seatunnel.connectors.seatunnel.jdbc.internal.dialect.db2.DB2Dialect; import org.apache.seatunnel.e2e.common.container.TestContainer; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestTemplate; import org.testcontainers.containers.Container; import java.io.IOException; +import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; +import java.time.LocalDate; import java.util.ArrayList; import java.util.List; @@ -114,6 +124,137 @@ public class JdbcDb2UpsertIT extends JdbcDb2IT { } } + @Test + void testPreparedMergePreservesNullTransitionsAndSkipsUnchangedRows() throws Exception { + DB2Dialect dialect = new DB2Dialect(); + String table = dialect.tableIdentifier(DB2_DATABASE, "NULL_SAFE_UPSERT"); + String trigger = dialect.tableIdentifier(DB2_DATABASE, "NULL_SAFE_UPSERT_TRIGGER"); + try (Statement ddl = connection.createStatement()) { + ddl.execute( + "CREATE TABLE " + + table + + " (ID INT NOT NULL PRIMARY KEY, TXT VARCHAR(20), " + + "NUM INT, FLAG BOOLEAN, BIN VARBINARY(16), DAY_VALUE DATE, UPDATES INT DEFAULT 0)"); + try { + ddl.execute( + "CREATE TRIGGER " + + trigger + + " BEFORE UPDATE ON " + + table + + " REFERENCING OLD AS O NEW AS N FOR EACH ROW " + + "BEGIN ATOMIC SET N.UPDATES = O.UPDATES + 1; END"); + TableSchema schema = + TableSchema.builder() + .column( + PhysicalColumn.of( + "ID", BasicType.INT_TYPE, 0L, false, null, null)) + .column( + PhysicalColumn.of( + "TXT", + BasicType.STRING_TYPE, + 20L, + true, + null, + null)) + .column( + PhysicalColumn.of( + "NUM", BasicType.INT_TYPE, 0L, true, null, null)) + .column( + PhysicalColumn.of( + "FLAG", + BasicType.BOOLEAN_TYPE, + 0L, + true, + null, + null)) + .column( + PhysicalColumn.of( + "BIN", + PrimitiveByteArrayType.INSTANCE, + 16L, + true, + null, + null)) + .column( + PhysicalColumn.of( + "DAY_VALUE", + LocalTimeType.LOCAL_DATE_TYPE, + 0L, + true, + null, + null)) + .build(); + String merge = + dialect.getUpsertStatement( + DB2_DATABASE, + "NULL_SAFE_UPSERT", + schema.getFieldNames(), + new String[] {"ID"}) + .get(); + Object[] row = {1, null, null, null, null, null}; + Object[] values = {"value", 42, true, new byte[] {1, 2}, LocalDate.of(2024, 1, 2)}; + int updates = 0; + try (PreparedStatement statement = connection.prepareStatement(merge)) { + // First execution binds NULL markers, exercising native type inference. + mergeRow(dialect, schema, statement, row); + assertMergeRow(table, row, updates); + for (int i = 1; i < row.length; i++) { + row[i] = values[i - 1]; + mergeRow(dialect, schema, statement, row); + assertMergeRow(table, row, ++updates); + mergeRow(dialect, schema, statement, row); + assertMergeRow(table, row, updates); + row[i] = null; + mergeRow(dialect, schema, statement, row); + assertMergeRow(table, row, ++updates); + mergeRow(dialect, schema, statement, row); + assertMergeRow(table, row, updates); + } + row[1] = "old"; + mergeRow(dialect, schema, statement, row); + row[1] = "new"; + mergeRow(dialect, schema, statement, row); + assertMergeRow(table, row, updates + 2); + row[0] = 2; + mergeRow(dialect, schema, statement, row); + assertMergeRow(table, row, 0); + } + } finally { + ddl.execute("DROP TABLE " + table); + connection.commit(); + } + } + } + + private void mergeRow( + DB2Dialect dialect, TableSchema schema, PreparedStatement statement, Object[] row) + throws SQLException { + dialect.getRowConverter().toExternal(schema, null, new SeaTunnelRow(row), statement); + statement.executeUpdate(); + connection.commit(); + } + + private void assertMergeRow(String table, Object[] row, int updates) throws SQLException { + try (Statement statement = connection.createStatement(); + ResultSet result = + statement.executeQuery( + "SELECT ID, TXT, NUM, FLAG, BIN, DAY_VALUE, UPDATES FROM " + + table + + " WHERE ID = " + + row[0])) { + Assertions.assertTrue(result.next()); + Assertions.assertEquals(row[1], result.getString(2)); + Assertions.assertEquals(row[2], result.getObject(3)); + Assertions.assertEquals(row[3], result.getObject(4)); + Assertions.assertArrayEquals((byte[]) row[4], result.getBytes(5)); + Assertions.assertEquals( + row[5], result.getDate(6) == null ? null : result.getDate(6).toLocalDate()); + Assertions.assertEquals( + updates, result.getInt(7), "Unchanged values must not fire UPDATE triggers"); + Assertions.assertFalse(result.next()); + } + } + private List<List<Object>> query(String sql) { try (Statement statement = connection.createStatement(); ResultSet resultSet = statement.executeQuery(sql)) {
