This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git
The following commit(s) were added to refs/heads/dev by this push:
new 42d2aab3f8 [Fix][Connector-V2][CDC] Do not use a nullable column as
the snapshot split column (#12597)
42d2aab3f8 is described below
commit 42d2aab3f841821753b415d568cc5884d6f13bbd
Author: Pravin Jaywantrao Maske
<[email protected]>
AuthorDate: Wed Oct 14 12:52:55 2026 +0000
[Fix][Connector-V2][CDC] Do not use a nullable column as the snapshot split
column (#12597)
---
docs/en/connectors/source/MySQL-CDC.md | 3 +-
docs/en/connectors/source/Oracle-CDC.md | 3 +-
docs/en/connectors/source/PostgreSQL-CDC.md | 3 +-
.../introduction/concepts/incompatible-changes.md | 7 +
docs/zh/connectors/source/MySQL-CDC.md | 2 +-
docs/zh/connectors/source/Oracle-CDC.md | 2 +-
docs/zh/connectors/source/PostgreSQL-CDC.md | 2 +-
.../introduction/concepts/incompatible-changes.md | 7 +
.../cdc/base/dialect/JdbcDataSourceDialect.java | 52 +++++
.../splitter/AbstractJdbcSourceChunkSplitter.java | 36 ++-
.../jdbc/source/JdbcSourceChunkSplitterTest.java | 258 +++++++++++++++++++++
.../cdc/mysql/AbstractMysqlCDCITBase.java | 28 +++
.../src/test/resources/ddl/nullable_unique_key.sql | 75 ++++++
...mysqlcdc_to_mysql_with_nullable_unique_key.conf | 48 ++++
14 files changed, 517 insertions(+), 9 deletions(-)
diff --git a/docs/en/connectors/source/MySQL-CDC.md
b/docs/en/connectors/source/MySQL-CDC.md
index 0ecc20852d..625ef48d23 100644
--- a/docs/en/connectors/source/MySQL-CDC.md
+++ b/docs/en/connectors/source/MySQL-CDC.md
@@ -484,7 +484,8 @@ Pick the path that matches what the source table guarantees:
row identity. Without a usable key, the connector cannot apply UPDATE/DELETE
events safely.
- **Unique non-primary column is available**: declare it via
`table-names-config.primaryKeys` and
set `exactly_once = true` so the snapshot and binlog phases both use the
configured key for
- consistent row identity.
+ consistent row identity. The column must be NOT NULL in the database: a
nullable column is not
+ used to split the snapshot, and with `exactly_once = true` the job fails at
startup.
```hocon
env {
diff --git a/docs/en/connectors/source/Oracle-CDC.md
b/docs/en/connectors/source/Oracle-CDC.md
index 7cbe579abc..11786b13af 100644
--- a/docs/en/connectors/source/Oracle-CDC.md
+++ b/docs/en/connectors/source/Oracle-CDC.md
@@ -429,7 +429,8 @@ Pick the path that matches what the source table guarantees:
row identity. Without a usable key, the connector cannot apply UPDATE/DELETE
events safely.
- **Unique non-primary column is available**: declare it via
`table-names-config.primaryKeys` and
set `exactly_once = true` so the snapshot and redo-log phases both use the
configured key for
- consistent row identity.
+ consistent row identity. The column must be NOT NULL in the database: a
nullable column is not
+ used to split the snapshot, and with `exactly_once = true` the job fails at
startup.
```hocon
source {
diff --git a/docs/en/connectors/source/PostgreSQL-CDC.md
b/docs/en/connectors/source/PostgreSQL-CDC.md
index 1cd408eaec..caf02983f1 100644
--- a/docs/en/connectors/source/PostgreSQL-CDC.md
+++ b/docs/en/connectors/source/PostgreSQL-CDC.md
@@ -299,7 +299,8 @@ Pick the path that matches what the source table guarantees:
row identity. Without a usable key, the connector cannot apply UPDATE/DELETE
events safely.
- **Unique non-primary column is available**: declare it via
`table-names-config.primaryKeys` and
set `exactly_once = true` so the snapshot and WAL phases both use the
configured key for
- consistent row identity.
+ consistent row identity. The column must be NOT NULL in the database: a
nullable column is not
+ used to split the snapshot, and with `exactly_once = true` the job fails at
startup.
```hocon
source {
diff --git a/docs/en/introduction/concepts/incompatible-changes.md
b/docs/en/introduction/concepts/incompatible-changes.md
index 42c3cc4a2f..09d4fa1e45 100644
--- a/docs/en/introduction/concepts/incompatible-changes.md
+++ b/docs/en/introduction/concepts/incompatible-changes.md
@@ -5,6 +5,13 @@ You need to check this document before you upgrade to related
version.
## dev
+### CDC: nullable columns are no longer used as the snapshot split column
+
+- **Behavior change: JDBC-based CDC sources (MySQL, PostgreSQL, Oracle, SQL
Server, DB2) no longer split the snapshot on a nullable column**
+ - **Description**: For a table without a primary key, the snapshot split
column was taken from a unique key even when that column was nullable. Rows
with NULL in it matched no snapshot chunk and were silently skipped. A nullable
column is now skipped, whether it comes from a unique key, from
`snapshotSplitColumn` or from a primary key configured in
`table-names-config.primaryKeys`; if no non-nullable key column remains, the
table is read as a single split. Nullability is read from the [...]
+ - **Impact**: Such tables are now read completely, but with one snapshot
split instead of several. With `exactly_once = true`, a table whose only key is
a nullable unique key, or a nullable column configured in
`table-names-config.primaryKeys`, now fails at startup with `Exactly once is
enabled, but not found non-nullable primary key or unique key for table ...`
instead of running and losing rows. This also applies to an existing job whose
configured `primaryKeys` column is nullable in [...]
+ - **Migration Guide**: Declare a NOT NULL unique column, or configure
`table-names-config.primaryKeys` / `snapshotSplitColumn` with a non-nullable
column, to keep parallel snapshot reading. For `exactly_once = true`, add a
primary key or a non-nullable unique key to the table, or make the configured
`primaryKeys` column NOT NULL in the database.
+
### Runtime Requirements
- **Breaking Change: Minimum Java runtime raised from Java 8 to Java 11**
diff --git a/docs/zh/connectors/source/MySQL-CDC.md
b/docs/zh/connectors/source/MySQL-CDC.md
index 9621c75282..f33ebebde1 100644
--- a/docs/zh/connectors/source/MySQL-CDC.md
+++ b/docs/zh/connectors/source/MySQL-CDC.md
@@ -477,7 +477,7 @@ sink {
根据源表能够提供的保证来选择合适的路径:
- **仅追加(append-only)场景**:源表不会产生 UPDATE/DELETE 事件,保持 `exactly_once = false`
且不声明主键,源端会退回到尽力而为的行标识。在没有可用主键的情况下,connector 无法安全地应用 UPDATE/DELETE 事件。
-- **存在唯一非主键列**:通过 `table-names-config.primaryKeys` 显式声明该列,并设置 `exactly_once =
true`,让快照阶段与 binlog 阶段都使用同一配置主键作为稳定的行标识。
+- **存在唯一非主键列**:通过 `table-names-config.primaryKeys` 显式声明该列,并设置 `exactly_once =
true`,让快照阶段与 binlog 阶段都使用同一配置主键作为稳定的行标识。该列在数据库中必须为 NOT NULL:可空列不会用于切分快照,开启
`exactly_once = true` 时作业会在启动时失败。
```hocon
env {
diff --git a/docs/zh/connectors/source/Oracle-CDC.md
b/docs/zh/connectors/source/Oracle-CDC.md
index 37396fa0f5..bfe2acf683 100644
--- a/docs/zh/connectors/source/Oracle-CDC.md
+++ b/docs/zh/connectors/source/Oracle-CDC.md
@@ -424,7 +424,7 @@ source {
根据源表能够提供的保证来选择合适的路径:
- **仅追加(append-only)场景**:源表不会产生 UPDATE/DELETE 事件,保持 `exactly_once = false`
且不声明主键,源端会退回到尽力而为的行标识。在没有可用主键的情况下,connector 无法安全地应用 UPDATE/DELETE 事件。
-- **存在唯一非主键列**:通过 `table-names-config.primaryKeys` 显式声明该列,并设置 `exactly_once =
true`,让快照阶段与 redo log 阶段都使用同一配置主键作为稳定的行标识。
+- **存在唯一非主键列**:通过 `table-names-config.primaryKeys` 显式声明该列,并设置 `exactly_once =
true`,让快照阶段与 redo log 阶段都使用同一配置主键作为稳定的行标识。该列在数据库中必须为 NOT NULL:可空列不会用于切分快照,开启
`exactly_once = true` 时作业会在启动时失败。
```hocon
source {
diff --git a/docs/zh/connectors/source/PostgreSQL-CDC.md
b/docs/zh/connectors/source/PostgreSQL-CDC.md
index 1b1f6906d4..438b9ac78e 100644
--- a/docs/zh/connectors/source/PostgreSQL-CDC.md
+++ b/docs/zh/connectors/source/PostgreSQL-CDC.md
@@ -290,7 +290,7 @@ sink {
根据源表能够提供的保证来选择合适的路径:
- **仅追加(append-only)场景**:源表不会产生 UPDATE/DELETE 事件,保持 `exactly_once = false`
且不声明主键,源端会退回到尽力而为的行标识。在没有可用主键的情况下,connector 无法安全地应用 UPDATE/DELETE 事件。
-- **存在唯一非主键列**:通过 `table-names-config.primaryKeys` 显式声明该列,并设置 `exactly_once =
true`,让快照阶段与 WAL 阶段都使用同一配置主键作为稳定的行标识。
+- **存在唯一非主键列**:通过 `table-names-config.primaryKeys` 显式声明该列,并设置 `exactly_once =
true`,让快照阶段与 WAL 阶段都使用同一配置主键作为稳定的行标识。该列在数据库中必须为 NOT NULL:可空列不会用于切分快照,开启
`exactly_once = true` 时作业会在启动时失败。
```hocon
source {
diff --git a/docs/zh/introduction/concepts/incompatible-changes.md
b/docs/zh/introduction/concepts/incompatible-changes.md
index 454b9848bd..b0c4680c9e 100644
--- a/docs/zh/introduction/concepts/incompatible-changes.md
+++ b/docs/zh/introduction/concepts/incompatible-changes.md
@@ -4,6 +4,13 @@
## dev
+### CDC:可空列不再用作快照分片列
+
+- **行为变更:基于 JDBC 的 CDC 源(MySQL、PostgreSQL、Oracle、SQL Server、DB2)不再使用可空列切分快照**
+ - **描述**:对于没有主键的表,此前即使唯一键列可空,也会将其作为快照分片列。该列为 NULL
的行不会匹配任何快照分片,会被静默跳过。现在会跳过可空列,无论它来自唯一键、`snapshotSplitColumn`,还是
`table-names-config.primaryKeys` 中配置的主键;如果没有可用的非空键列,则整张表作为单个分片读取。可空性从数据库元数据中读取。
+ - **影响**:这类表现在会被完整读取,但快照只有一个分片而不是多个。开启 `exactly_once = true`
时,若表唯一的键是可空唯一键,或 `table-names-config.primaryKeys` 中配置的是可空列,作业会在启动时报错 `Exactly
once is enabled, but not found non-nullable primary key or unique key for table
...`,而不是继续运行并丢失数据。对于已有作业,即使配置的 `primaryKeys` 列中实际没有 NULL,只要该列在数据库中可空,也会如此。
+ - **迁移指南**:如需保持快照并行读取,请声明一个 NOT NULL 的唯一列,或通过
`table-names-config.primaryKeys` / `snapshotSplitColumn` 配置非空列。对于 `exactly_once
= true`,请为表添加主键或非空唯一键,或将配置的 `primaryKeys` 列在数据库中改为 NOT NULL。
+
### 运行环境要求
- **破坏性变更:最低 Java 运行时从 Java 8 提升到 Java 11**
diff --git
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/dialect/JdbcDataSourceDialect.java
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/dialect/JdbcDataSourceDialect.java
index 4cc5883662..b384187402 100644
---
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/dialect/JdbcDataSourceDialect.java
+++
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/dialect/JdbcDataSourceDialect.java
@@ -29,7 +29,10 @@ import
org.apache.seatunnel.connectors.cdc.base.source.split.SourceSplitBase;
import org.apache.commons.collections4.CollectionUtils;
+import org.slf4j.LoggerFactory;
+
import io.debezium.jdbc.JdbcConnection;
+import io.debezium.relational.Column;
import io.debezium.relational.TableId;
import io.debezium.relational.history.TableChanges;
@@ -123,6 +126,55 @@ public interface JdbcDataSourceDialect extends
DataSourceDialect<JdbcSourceConfi
.collect(Collectors.toList());
}
+ /**
+ * Whether the column can hold NULL, according to the database metadata.
+ *
+ * <p>The parsed {@link Column} is not reliable for this: for a MySQL
table without a primary
+ * key, the column of a nullable unique key is reported as not optional.
+ *
+ * <p>Called while generating the snapshot splits of a table, for each
candidate split column
+ * (the configured split column, the first primary key column and the
first column of each
+ * unique key). The default implementation runs one {@link
DatabaseMetaData#getColumns} query
+ * per call; a dialect may override it with a cheaper source of the same
information.
+ *
+ * @param jdbcConnection the JDBC connection
+ * @param tableId the table the column belongs to
+ * @param column the column to check
+ * @return true if the column is nullable, or the metadata does not
describe it and the parsed
+ * column is optional
+ */
+ default boolean isColumnNullable(JdbcConnection jdbcConnection, TableId
tableId, Column column)
+ throws SQLException {
+ DatabaseMetaData metaData = jdbcConnection.connection().getMetaData();
+ String ignoreCaseMatch = null;
+ try (ResultSet rs =
+ metaData.getColumns(
+ tableId.catalog(), tableId.schema(), tableId.table(),
column.name())) {
+ while (rs.next()) {
+ // table and column names are LIKE patterns, so match them by
name
+ String tableName = rs.getString("TABLE_NAME");
+ String columnName = rs.getString("COLUMN_NAME");
+ if (tableId.table().equals(tableName) &&
column.name().equals(columnName)) {
+ return !"NO".equalsIgnoreCase(rs.getString("IS_NULLABLE"));
+ }
+ if (ignoreCaseMatch == null
+ && tableId.table().equalsIgnoreCase(tableName)
+ && column.name().equalsIgnoreCase(columnName)) {
+ ignoreCaseMatch = rs.getString("IS_NULLABLE");
+ }
+ }
+ }
+ if (ignoreCaseMatch != null) {
+ return !"NO".equalsIgnoreCase(ignoreCaseMatch);
+ }
+ LoggerFactory.getLogger(JdbcDataSourceDialect.class)
+ .warn(
+ "No column metadata found for column {} of table {},
use the parsed column nullability",
+ column.name(),
+ tableId);
+ return column.isOptional();
+ }
+
default List<ConstraintKey> getConstraintKeys(JdbcConnection
jdbcConnection, TableId tableId)
throws SQLException {
DatabaseMetaData metaData = jdbcConnection.connection().getMetaData();
diff --git
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/splitter/AbstractJdbcSourceChunkSplitter.java
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/splitter/AbstractJdbcSourceChunkSplitter.java
index 6d1667674f..a90f070720 100644
---
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/splitter/AbstractJdbcSourceChunkSplitter.java
+++
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/source/enumerator/splitter/AbstractJdbcSourceChunkSplitter.java
@@ -51,6 +51,10 @@ import static
org.apache.seatunnel.connectors.cdc.base.utils.ObjectUtils.doubleC
@Slf4j
public abstract class AbstractJdbcSourceChunkSplitter implements
JdbcSourceChunkSplitter {
+ private static final String NULLABLE_SPLIT_COLUMN_HINT =
+ "To read the snapshot in parallel, declare a NOT NULL unique
column or configure a "
+ + "non-nullable primaryKeys or snapshotSplitColumn in
table-names-config.";
+
private final JdbcSourceConfig sourceConfig;
private final JdbcDataSourceDialect dialect;
@@ -86,7 +90,7 @@ public abstract class AbstractJdbcSourceChunkSplitter
implements JdbcSourceChunk
if (sourceConfig.isExactlyOnce()) {
throw new UnsupportedOperationException(
String.format(
- "Exactly once is enabled, but not found
primary key or unique key for table %s",
+ "Exactly once is enabled, but not found
non-nullable primary key or unique key for table %s",
tableId));
}
SnapshotSplit singleSplit = createSnapshotSplit(jdbc, tableId,
0, null, null, null);
@@ -489,7 +493,14 @@ public abstract class AbstractJdbcSourceChunkSplitter
implements JdbcSourceChunk
if (isPrimaryOrUniqueKey.get()) {
Column column = table.columnWithName(tableSc);
- if (isEvenlySplitColumn(column)) {
+ if (dialect.isColumnNullable(jdbc, tableId, column)) {
+ log.warn(
+ "Config snapshotSplitColumn {} of table {} is
nullable, rows with NULL in it "
+ + "cannot be assigned to any snapshot
chunk, ignore it. "
+ + NULLABLE_SPLIT_COLUMN_HINT,
+ tableSc,
+ tableId);
+ } else if (isEvenlySplitColumn(column)) {
return column;
} else {
log.warn(
@@ -507,7 +518,15 @@ public abstract class AbstractJdbcSourceChunkSplitter
implements JdbcSourceChunk
if (primaryKey.isPresent()) {
Column firstColumn =
table.columnWithName(primaryKey.get().getColumnNames().get(0));
- if (isEvenlySplitColumn(firstColumn)) {
+ // A primary key configured in table-names-config may name a
nullable column
+ if (dialect.isColumnNullable(jdbc, tableId, firstColumn)) {
+ log.warn(
+ "Skip primary key {} of table {} as snapshot split
column: column {} is nullable. "
+ + NULLABLE_SPLIT_COLUMN_HINT,
+ primaryKey.get().getColumnNames(),
+ tableId,
+ firstColumn.name());
+ } else if (isEvenlySplitColumn(firstColumn)) {
splitColumn = columnComparable(splitColumn, firstColumn);
if (sqlTypePriority(splitColumn) == 1) {
return splitColumn;
@@ -522,6 +541,17 @@ public abstract class AbstractJdbcSourceChunkSplitter
implements JdbcSourceChunk
for (ConstraintKey uniqueKey : uniqueKeys) {
Column firstColumn =
table.columnWithName(uniqueKey.getColumnNames().get(0).getColumnName());
+ // A unique key may hold any number of NULLs, and NULL never
matches a chunk
+ // range predicate, so such rows would be silently skipped by
the snapshot.
+ if (dialect.isColumnNullable(jdbc, tableId, firstColumn)) {
+ log.warn(
+ "Skip unique key {} of table {} as snapshot split
column: column {} is nullable. "
+ + NULLABLE_SPLIT_COLUMN_HINT,
+ uniqueKey.getConstraintName(),
+ tableId,
+ firstColumn.name());
+ continue;
+ }
if (isEvenlySplitColumn(firstColumn)) {
splitColumn = columnComparable(splitColumn, firstColumn);
if (sqlTypePriority(splitColumn) == 1) {
diff --git
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/test/java/jdbc/source/JdbcSourceChunkSplitterTest.java
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/test/java/jdbc/source/JdbcSourceChunkSplitterTest.java
index 734e98d25f..6e88182987 100644
---
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/test/java/jdbc/source/JdbcSourceChunkSplitterTest.java
+++
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/test/java/jdbc/source/JdbcSourceChunkSplitterTest.java
@@ -41,13 +41,18 @@ import io.debezium.relational.Table;
import io.debezium.relational.TableId;
import io.debezium.relational.history.TableChanges;
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
+import java.util.Iterator;
import java.util.List;
import java.util.Optional;
+import java.util.concurrent.atomic.AtomicReference;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -98,6 +103,169 @@ class JdbcSourceChunkSplitterTest {
Assertions.assertEquals("bigint", splitColumn.typeName());
}
+ @Test
+ void splitColumnSkipsNullableUniqueKey() throws SQLException {
+ TestSourceDialectWithNullableUniqueKey dialect =
+ new TestSourceDialectWithNullableUniqueKey();
+ TestJdbcSourceChunkSplitter testJdbcSourceChunkSplitter =
+ new TestJdbcSourceChunkSplitter(null, dialect);
+
+ // NULLs in a unique key never match a chunk range, so no split column
is safe here and
+ // the table falls back to a single full-scan split.
+ Assertions.assertNull(
+ testJdbcSourceChunkSplitter.getSplitColumn(null, dialect, new
TableId("", "", "")));
+ }
+
+ @Test
+ void splitColumnPrefersNotNullUniqueKeyOverNullableOne() throws
SQLException {
+ TestSourceDialectWithNullableAndNotNullUniqueKeys dialect =
+ new TestSourceDialectWithNullableAndNotNullUniqueKeys();
+ TestJdbcSourceChunkSplitter testJdbcSourceChunkSplitter =
+ new TestJdbcSourceChunkSplitter(null, dialect);
+
+ Column splitColumn =
+ testJdbcSourceChunkSplitter.getSplitColumn(null, dialect, new
TableId("", "", ""));
+
+ Assertions.assertEquals("int", splitColumn.name());
+ }
+
+ @Test
+ void splitColumnIgnoresConfiguredNullableColumn() throws SQLException {
+ JdbcSourceConfig sourceConfig = mock(JdbcSourceConfig.class);
+ when(sourceConfig.getSplitColumn())
+ .thenReturn(Collections.singletonMap(".", "nullable_int"));
+ TestSourceDialectWithNullableAndNotNullUniqueKeys dialect =
+ new TestSourceDialectWithNullableAndNotNullUniqueKeys();
+ TestJdbcSourceChunkSplitter testJdbcSourceChunkSplitter =
+ new TestJdbcSourceChunkSplitter(sourceConfig, dialect);
+
+ Column splitColumn =
+ testJdbcSourceChunkSplitter.getSplitColumn(null, dialect, new
TableId("", "", ""));
+
+ Assertions.assertEquals("int", splitColumn.name());
+ }
+
+ @Test
+ void splitColumnSkipsNullableConfiguredPrimaryKey() throws SQLException {
+ // table-names-config.primaryKeys may name a nullable column
+ TestSourceDialectWithNullablePrimaryKey dialect =
+ new TestSourceDialectWithNullablePrimaryKey();
+ TestJdbcSourceChunkSplitter testJdbcSourceChunkSplitter =
+ new TestJdbcSourceChunkSplitter(null, dialect);
+
+ Assertions.assertNull(
+ testJdbcSourceChunkSplitter.getSplitColumn(null, dialect, new
TableId("", "", "")));
+ }
+
+ @Test
+ void splitColumnPrefersNotNullUniqueKeyOverNullablePrimaryKey() throws
SQLException {
+ TestSourceDialectWithNullablePrimaryKeyAndNotNullUniqueKey dialect =
+ new
TestSourceDialectWithNullablePrimaryKeyAndNotNullUniqueKey();
+ TestJdbcSourceChunkSplitter testJdbcSourceChunkSplitter =
+ new TestJdbcSourceChunkSplitter(null, dialect);
+
+ Column splitColumn =
+ testJdbcSourceChunkSplitter.getSplitColumn(null, dialect, new
TableId("", "", ""));
+
+ Assertions.assertEquals("int", splitColumn.name());
+ }
+
+ @Test
+ void isColumnNullableReadsDatabaseMetadata() throws SQLException {
+ TestSourceDialect dialect = new TestSourceDialect();
+ TableId tableId = new TableId("db", null, "no_pk");
+ // the parsed column says NOT NULL, the database metadata decides
+ Column code = intColumn("code", false);
+
+ Assertions.assertTrue(
+ dialect.isColumnNullableFromMetadata(
+ jdbcWithColumns(tableId, "code", row("no_pk", "code",
"YES")),
+ tableId,
+ code));
+ Assertions.assertFalse(
+ dialect.isColumnNullableFromMetadata(
+ jdbcWithColumns(tableId, "code", row("no_pk", "code",
"NO")),
+ tableId,
+ intColumn("code", true)));
+ }
+
+ @Test
+ void isColumnNullableFallsBackToParsedColumnWithoutMetadataRow() throws
SQLException {
+ TestSourceDialect dialect = new TestSourceDialect();
+ TableId tableId = new TableId("db", null, "no_pk");
+
+ Assertions.assertTrue(
+ dialect.isColumnNullableFromMetadata(
+ jdbcWithColumns(tableId, "code"), tableId,
intColumn("code", true)));
+ Assertions.assertFalse(
+ dialect.isColumnNullableFromMetadata(
+ jdbcWithColumns(tableId, "code"), tableId,
intColumn("code", false)));
+ }
+
+ @Test
+ void isColumnNullableIgnoresRowsMatchedOnlyByNamePattern() throws
SQLException {
+ TestSourceDialect dialect = new TestSourceDialect();
+ TableId tableId = new TableId("db", null, "no_pk");
+ // getColumns takes LIKE patterns: `_` also matches the column of
table `noXpk`
+ JdbcConnection jdbc = jdbcWithColumns(tableId, "code", row("noXpk",
"code", "YES"));
+
+ Assertions.assertFalse(
+ dialect.isColumnNullableFromMetadata(jdbc, tableId,
intColumn("code", false)));
+ }
+
+ @Test
+ void isColumnNullableMatchesNameIgnoringCaseWithoutExactRow() throws
SQLException {
+ TestSourceDialect dialect = new TestSourceDialect();
+ TableId tableId = new TableId("db", null, "No_Pk");
+ // for example MySQL with lower_case_table_names=1 returns the name in
lower case
+ JdbcConnection jdbc = jdbcWithColumns(tableId, "Code", row("no_pk",
"code", "YES"));
+
+ Assertions.assertTrue(
+ dialect.isColumnNullableFromMetadata(jdbc, tableId,
intColumn("Code", false)));
+ }
+
+ private static Column intColumn(String name, boolean optional) {
+ return Column.editor()
+ .name(name)
+ .jdbcType(Types.INTEGER)
+ .type("int")
+ .optional(optional)
+ .create();
+ }
+
+ private static String[] row(String tableName, String columnName, String
isNullable) {
+ return new String[] {tableName, columnName, isNullable};
+ }
+
+ /** A connection whose metadata returns the given TABLE_NAME, COLUMN_NAME,
IS_NULLABLE rows. */
+ private static JdbcConnection jdbcWithColumns(
+ TableId tableId, String columnName, String[]... rows) throws
SQLException {
+ Iterator<String[]> iterator = Arrays.asList(rows).iterator();
+ AtomicReference<String[]> current = new AtomicReference<>();
+ ResultSet resultSet = mock(ResultSet.class);
+ when(resultSet.next())
+ .thenAnswer(
+ invocation -> {
+ if (!iterator.hasNext()) {
+ return false;
+ }
+ current.set(iterator.next());
+ return true;
+ });
+ when(resultSet.getString("TABLE_NAME")).thenAnswer(invocation ->
current.get()[0]);
+ when(resultSet.getString("COLUMN_NAME")).thenAnswer(invocation ->
current.get()[1]);
+ when(resultSet.getString("IS_NULLABLE")).thenAnswer(invocation ->
current.get()[2]);
+
+ DatabaseMetaData metaData = mock(DatabaseMetaData.class);
+ when(metaData.getColumns(tableId.catalog(), tableId.schema(),
tableId.table(), columnName))
+ .thenReturn(resultSet);
+ Connection connection = mock(Connection.class);
+ when(connection.getMetaData()).thenReturn(metaData);
+ JdbcConnection jdbc = mock(JdbcConnection.class);
+ when(jdbc.connection()).thenReturn(connection);
+ return jdbc;
+ }
+
private class TestJdbcSourceChunkSplitter extends
AbstractJdbcSourceChunkSplitter {
public TestJdbcSourceChunkSplitter(
@@ -223,31 +391,43 @@ class JdbcSourceChunkSplitterTest {
.name("string_col")
.jdbcType(Types.VARCHAR)
.type("varchar")
+ .optional(false)
.create(),
Column.editor()
.name("smallint")
.jdbcType(Types.SMALLINT)
.type("smallint")
+ .optional(false)
.create(),
Column.editor()
.name("int")
.jdbcType(Types.INTEGER)
.type("int")
+ .optional(false)
.create(),
Column.editor()
.name("decimal")
.jdbcType(Types.DECIMAL)
.type("decimal")
+ .optional(false)
.create(),
Column.editor()
.name("tinyint_col")
.jdbcType(Types.TINYINT)
.type("tinyint")
+ .optional(false)
.create(),
Column.editor()
.name("bigint_col")
.jdbcType(Types.BIGINT)
.type("bigint")
+ .optional(false)
+ .create(),
+ Column.editor()
+ .name("nullable_int")
+ .jdbcType(Types.INTEGER)
+ .type("int")
+ .optional(true)
.create())
.create();
return new
TableChanges.TableChange(TableChanges.TableChangeType.CREATE, table);
@@ -284,6 +464,19 @@ class JdbcSourceChunkSplitterTest {
throws SQLException {
return new ArrayList<ConstraintKey>();
}
+
+ @Override
+ public boolean isColumnNullable(
+ JdbcConnection jdbcConnection, TableId tableId, Column column)
{
+ // no database here, the fixture columns carry the nullability
+ return column.isOptional();
+ }
+
+ /** Runs the default, metadata based implementation of the dialect. */
+ boolean isColumnNullableFromMetadata(
+ JdbcConnection jdbcConnection, TableId tableId, Column column)
throws SQLException {
+ return
JdbcDataSourceDialect.super.isColumnNullable(jdbcConnection, tableId, column);
+ }
}
private class TestSourceDialectWithUniqueKey extends TestSourceDialect {
@@ -347,4 +540,69 @@ class JdbcSourceChunkSplitterTest {
return keys;
}
}
+
+ private class TestSourceDialectWithNullableUniqueKey extends
TestSourceDialect {
+
+ @Override
+ public Optional<PrimaryKey> getPrimaryKey(JdbcConnection
jdbcConnection, TableId tableId)
+ throws SQLException {
+ return Optional.empty();
+ }
+
+ @Override
+ public List<ConstraintKey> getUniqueKeys(JdbcConnection
jdbcConnection, TableId tableId)
+ throws SQLException {
+ return Collections.singletonList(
+ ConstraintKey.of(
+ ConstraintKey.ConstraintType.UNIQUE_KEY,
+ "uk_nullable",
+ Collections.singletonList(
+ ConstraintKey.ConstraintKeyColumn.of(
+ "nullable_int",
ConstraintKey.ColumnSortType.ASC))));
+ }
+ }
+
+ private class TestSourceDialectWithNullableAndNotNullUniqueKeys
+ extends TestSourceDialectWithNullableUniqueKey {
+
+ @Override
+ public List<ConstraintKey> getUniqueKeys(JdbcConnection
jdbcConnection, TableId tableId)
+ throws SQLException {
+ List<ConstraintKey> keys =
+ new ArrayList<>(super.getUniqueKeys(jdbcConnection,
tableId));
+ keys.add(
+ ConstraintKey.of(
+ ConstraintKey.ConstraintType.UNIQUE_KEY,
+ "uk_not_null",
+ Collections.singletonList(
+ ConstraintKey.ConstraintKeyColumn.of(
+ "int",
ConstraintKey.ColumnSortType.ASC))));
+ return keys;
+ }
+ }
+
+ private class TestSourceDialectWithNullablePrimaryKey extends
TestSourceDialect {
+
+ @Override
+ public Optional<PrimaryKey> getPrimaryKey(JdbcConnection
jdbcConnection, TableId tableId)
+ throws SQLException {
+ return Optional.of(PrimaryKey.of("pkName",
Collections.singletonList("nullable_int")));
+ }
+ }
+
+ private class TestSourceDialectWithNullablePrimaryKeyAndNotNullUniqueKey
+ extends TestSourceDialectWithNullablePrimaryKey {
+
+ @Override
+ public List<ConstraintKey> getUniqueKeys(JdbcConnection
jdbcConnection, TableId tableId)
+ throws SQLException {
+ return Collections.singletonList(
+ ConstraintKey.of(
+ ConstraintKey.ConstraintType.UNIQUE_KEY,
+ "uk_not_null",
+ Collections.singletonList(
+ ConstraintKey.ConstraintKeyColumn.of(
+ "int",
ConstraintKey.ColumnSortType.ASC))));
+ }
+ }
}
diff --git
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/mysql/AbstractMysqlCDCITBase.java
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/mysql/AbstractMysqlCDCITBase.java
index f3a470c48b..55fa1d9662 100644
---
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/mysql/AbstractMysqlCDCITBase.java
+++
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/mysql/AbstractMysqlCDCITBase.java
@@ -358,6 +358,34 @@ public abstract class AbstractMysqlCDCITBase extends
TestSuiteBase implements Te
});
}
+ @TestTemplate
+ public void testMysqlCdcSnapshotWithNullableUniqueKey(TestContainer
container) {
+ // A table without a primary key whose only unique key is nullable:
rows with NULL in it
+ // must not be skipped by the snapshot chunks.
+
inventoryDatabase.setTemplateName("nullable_unique_key").createAndInitialize();
+
+ CompletableFuture.supplyAsync(
+ () -> {
+ try {
+
container.executeJob("/mysqlcdc_to_mysql_with_nullable_unique_key.conf");
+ } catch (Exception e) {
+ log.error("Commit task exception :" + e.getMessage());
+ throw new RuntimeException(e);
+ }
+ return null;
+ });
+
+ // Compare every column except code: NULL codes are emitted as 0 by
the MySQL schema
+ // parsing, a separate issue (#12599) from the skipped rows.
+ String rowQuery = "select id, name from " + MYSQL_DATABASE + ".%s
order by id";
+ await().atMost(60000, TimeUnit.MILLISECONDS)
+ .untilAsserted(
+ () ->
+ Assertions.assertIterableEquals(
+ query(String.format(rowQuery,
"nullable_uk_src")),
+ query(String.format(rowQuery,
"nullable_uk_sink"))));
+ }
+
@TestTemplate
@DisabledOnContainer(
value = {},
diff --git
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/resources/ddl/nullable_unique_key.sql
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/resources/ddl/nullable_unique_key.sql
new file mode 100644
index 0000000000..189817f179
--- /dev/null
+++
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/resources/ddl/nullable_unique_key.sql
@@ -0,0 +1,75 @@
+--
+-- 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.
+--
+
+-- Tables for the snapshot split E2E test of a table without a primary key
whose only unique key
+-- is nullable. Rows with NULL in the unique key must still be read by the
snapshot.
+
+CREATE DATABASE IF NOT EXISTS `mysql_cdc`;
+
+use mysql_cdc;
+
+DROP TABLE IF EXISTS nullable_uk_src;
+CREATE TABLE nullable_uk_src
+(
+ `id` int NOT NULL,
+ `code` int DEFAULT NULL,
+ `name` varchar(32) DEFAULT NULL,
+ UNIQUE KEY `uk_code` (`code`)
+) ENGINE = InnoDB;
+
+DROP TABLE IF EXISTS nullable_uk_sink;
+CREATE TABLE nullable_uk_sink
+(
+ `id` int NOT NULL,
+ `code` int DEFAULT NULL,
+ `name` varchar(32) DEFAULT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE = InnoDB;
+
+-- 20 rows with a unique code and 10 rows with a NULL code
+INSERT INTO nullable_uk_src (id, code, name)
+VALUES
+ (1, 1, 'row-1'),
+ (2, 2, 'row-2'),
+ (3, 3, 'row-3'),
+ (4, 4, 'row-4'),
+ (5, 5, 'row-5'),
+ (6, 6, 'row-6'),
+ (7, 7, 'row-7'),
+ (8, 8, 'row-8'),
+ (9, 9, 'row-9'),
+ (10, 10, 'row-10'),
+ (11, 11, 'row-11'),
+ (12, 12, 'row-12'),
+ (13, 13, 'row-13'),
+ (14, 14, 'row-14'),
+ (15, 15, 'row-15'),
+ (16, 16, 'row-16'),
+ (17, 17, 'row-17'),
+ (18, 18, 'row-18'),
+ (19, 19, 'row-19'),
+ (20, 20, 'row-20'),
+ (21, NULL, 'row-21'),
+ (22, NULL, 'row-22'),
+ (23, NULL, 'row-23'),
+ (24, NULL, 'row-24'),
+ (25, NULL, 'row-25'),
+ (26, NULL, 'row-26'),
+ (27, NULL, 'row-27'),
+ (28, NULL, 'row-28'),
+ (29, NULL, 'row-29'),
+ (30, NULL, 'row-30');
diff --git
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/resources/mysqlcdc_to_mysql_with_nullable_unique_key.conf
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/resources/mysqlcdc_to_mysql_with_nullable_unique_key.conf
new file mode 100644
index 0000000000..7bf04f2ee5
--- /dev/null
+++
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-mysql-e2e/src/test/resources/mysqlcdc_to_mysql_with_nullable_unique_key.conf
@@ -0,0 +1,48 @@
+#
+# 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.
+#
+
+env {
+ parallelism = 1
+ job.mode = "STREAMING"
+ checkpoint.interval = 5000
+}
+
+source {
+ MySQL-CDC {
+ server-id = 5720-5725
+ username = "st_user_source"
+ password = "mysqlpw"
+ table-names = ["mysql_cdc.nullable_uk_src"]
+ url = "jdbc:mysql://mysql_cdc_e2e:3306/mysql_cdc"
+ exactly_once = false
+ # small chunks so the snapshot of the 30 row table is split into several
chunks
+ snapshot.split.size = 5
+ }
+}
+
+sink {
+ jdbc {
+ url = "jdbc:mysql://mysql_cdc_e2e:3306/mysql_cdc"
+ driver = "com.mysql.cj.jdbc.Driver"
+ user = "st_user_sink"
+ password = "mysqlpw"
+ generate_sink_sql = true
+ database = mysql_cdc
+ table = nullable_uk_sink
+ primary_keys = ["id"]
+ }
+}