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-12355-c6ddef67e4f7e799e012ea2bf73ab9cc869f82f9 in repository https://gitbox.apache.org/repos/asf/seatunnel.git
commit d91e26a3368f59a37bb6a285b36cde071728e726 Author: Steven Zhao <[email protected]> AuthorDate: Fri Oct 9 09:25:47 2026 +0000 [Fix][Connector-V2][MySQL-CDC] Keep the SET/ENUM option list when rebuilding the source type (#12355) --- .../parser/CustomAlterTableParserListener.java | 18 +++ .../seatunnel/cdc/mysql/utils/MySqlTypeUtils.java | 58 ++++++++++ .../parser/CustomMySqlAntlrDdlParserTest.java | 126 +++++++++++++++++++++ 3 files changed, 202 insertions(+) diff --git a/seatunnel-connectors-v2/connector-cdc/connector-cdc-mysql/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/mysql/source/parser/CustomAlterTableParserListener.java b/seatunnel-connectors-v2/connector-cdc/connector-cdc-mysql/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/mysql/source/parser/CustomAlterTableParserListener.java index 60c391f7be..707338db6a 100644 --- a/seatunnel-connectors-v2/connector-cdc/connector-cdc-mysql/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/mysql/source/parser/CustomAlterTableParserListener.java +++ b/seatunnel-connectors-v2/connector-cdc/connector-cdc-mysql/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/mysql/source/parser/CustomAlterTableParserListener.java @@ -243,4 +243,22 @@ public class CustomAlterTableParserListener extends MySqlParserBaseListener public org.apache.seatunnel.api.table.catalog.Column toSeatunnelColumn(Column column) { return MySqlTypeUtils.convertToSeaTunnelColumn(column, dbzConnectorConfig); } + + /** + * {@code SET} and {@code ENUM} do not take a length: Debezium keeps their option list in {@link + * Column#enumValues()} and the value of {@link Column#length()} is only a bookkeeping count + * ({@code 2 * options - 1}). Appending it would produce a type such as {@code SET(5)}, which is + * not valid DDL, and that value reaches generated auto-create statements through {@code + * org.apache.seatunnel.api.table.catalog.Column#getSourceType()} (see <a + * href="https://github.com/apache/seatunnel/issues/12354">issue #12354</a>). Render the option + * list instead, the way the base implementation renders length and scale for other types. + */ + @Override + public String getSourceColumnTypeWithLengthScale(Column column) { + List<String> enumValues = column.enumValues(); + if (enumValues != null && !enumValues.isEmpty()) { + return String.format("%s(%s)", column.typeName(), String.join(",", enumValues)); + } + return SeatunnelDDLParser.super.getSourceColumnTypeWithLengthScale(column); + } } diff --git a/seatunnel-connectors-v2/connector-cdc/connector-cdc-mysql/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/mysql/utils/MySqlTypeUtils.java b/seatunnel-connectors-v2/connector-cdc/connector-cdc-mysql/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/mysql/utils/MySqlTypeUtils.java index 54f352ab73..9a571ec36a 100644 --- a/seatunnel-connectors-v2/connector-cdc/connector-cdc-mysql/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/mysql/utils/MySqlTypeUtils.java +++ b/seatunnel-connectors-v2/connector-cdc/connector-cdc-mysql/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/mysql/utils/MySqlTypeUtils.java @@ -31,6 +31,7 @@ import io.debezium.relational.Column; import io.debezium.relational.RelationalDatabaseConnectorConfig; import lombok.extern.slf4j.Slf4j; +import java.util.List; import java.util.Optional; /** Utilities for converting from MySQL types to SeaTunnel types. */ @@ -152,6 +153,20 @@ public class MySqlTypeUtils { builder.columnType(String.format("TINYINT(%s)", column.length())); } break; + case MySqlTypeConverter.MYSQL_ENUM: + case MySqlTypeConverter.MYSQL_SET: + // Debezium reports a bookkeeping length for these types (options * 2 - 1 for SET, + // always 1 for ENUM) rather than the length of the stored value. Forwarding it + // makes MySqlTypeConverter produce a column length of 1 for an ENUM and options * + // 2 - 1 for a SET, which sinks that rebuild the type from the column length (any + // non-MySQL sink, via JdbcDialect#applySchemaChange -> reconvert) turn into an + // undersized column. Derive the real length from the option list instead; it is + // available on the DDL path and, for completeness, whenever the column carries it. + long optionListLength = maxOptionListLength(column); + if (optionListLength > 0) { + builder.length(optionListLength).precision(optionListLength); + } + break; default: break; } @@ -162,4 +177,47 @@ public class MySqlTypeUtils { return (intTypeNarrowing ? MySqlTypeConverter.DEFAULT_INSTANCE : NO_INT_NARROWING_CONVERTER) .convert(builder.build()); } + + /** + * Computes the longest value a {@code SET} / {@code ENUM} column can store, in characters: the + * longest member for an {@code ENUM}, and the commas plus the sum of all members for a {@code + * SET}. + * + * @param column Debezium column, whose option list is populated on the DDL-parsing path + * @return the derived length, or {@code -1} when no option list is available + */ + private static long maxOptionListLength(Column column) { + List<String> enumValues = column.enumValues(); + if (enumValues == null || enumValues.isEmpty()) { + return -1L; + } + boolean isSet = MySqlTypeConverter.MYSQL_SET.equalsIgnoreCase(column.typeName()); + long totalLength = 0L; + long maxLength = 0L; + for (String enumValue : enumValues) { + long length = unquotedValueLength(enumValue); + totalLength += length; + maxLength = Math.max(maxLength, length); + } + return isSet ? totalLength + enumValues.size() - 1L : maxLength; + } + + /** + * Returns the number of characters of a member of an option list as it appears in the DDL, so + * the surrounding quotes and any doubled quote escape are removed before measuring. + * + * @param enumValue raw option text collected by the DDL parser + * @return the stored length of that member + */ + private static long unquotedValueLength(String enumValue) { + if (enumValue == null) { + return 0L; + } + if (enumValue.length() >= 2 + && enumValue.charAt(0) == '\'' + && enumValue.charAt(enumValue.length() - 1) == '\'') { + return enumValue.substring(1, enumValue.length() - 1).replace("''", "'").length(); + } + return enumValue.length(); + } } diff --git a/seatunnel-connectors-v2/connector-cdc/connector-cdc-mysql/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/mysql/source/parser/CustomMySqlAntlrDdlParserTest.java b/seatunnel-connectors-v2/connector-cdc/connector-cdc-mysql/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/mysql/source/parser/CustomMySqlAntlrDdlParserTest.java index d8caa91bd9..515d8706fd 100644 --- a/seatunnel-connectors-v2/connector-cdc/connector-cdc-mysql/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/mysql/source/parser/CustomMySqlAntlrDdlParserTest.java +++ b/seatunnel-connectors-v2/connector-cdc/connector-cdc-mysql/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/mysql/source/parser/CustomMySqlAntlrDdlParserTest.java @@ -17,9 +17,14 @@ package org.apache.seatunnel.connectors.seatunnel.cdc.mysql.source.parser; +import org.apache.seatunnel.api.table.catalog.Column; +import org.apache.seatunnel.api.table.schema.event.AlterTableAddColumnEvent; +import org.apache.seatunnel.api.table.schema.event.AlterTableChangeColumnEvent; import org.apache.seatunnel.api.table.schema.event.AlterTableColumnEvent; import org.apache.seatunnel.api.table.schema.event.AlterTableCommentEvent; import org.apache.seatunnel.api.table.schema.event.AlterTableEvent; +import org.apache.seatunnel.api.table.schema.event.AlterTableModifyColumnEvent; +import org.apache.seatunnel.api.table.type.BasicType; import org.apache.seatunnel.connectors.seatunnel.cdc.mysql.config.MySqlSourceConfig; import org.apache.seatunnel.connectors.seatunnel.cdc.mysql.config.MySqlSourceConfigFactory; @@ -91,6 +96,127 @@ public class CustomMySqlAntlrDdlParserTest { Assertions.assertEquals("Product catalog table", event.getNewComment()); } + /** + * Regression test for <a href="https://github.com/apache/seatunnel/issues/12354">#12354</a>. + * + * <p>Debezium reports the bare type name for {@code SET} / {@code ENUM} and keeps the option + * list on the column, but the base {@code getSourceColumnTypeWithLengthScale} renders the + * bookkeeping length as {@code SET(5)} / {@code ENUM(1)}. That string is what {@code + * org.apache.seatunnel.api.table.catalog.Column#getSourceType()} exposes, and {@code + * MysqlCreateTableSqlBuilder} emits it verbatim into generated auto-create DDL, which MySQL + * rejects. The option list therefore has to survive as part of the rebuilt type expression, and + * the column length must not stay on the bookkeeping value either, since a sink that rebuilds + * the type from the length would create an undersized column. + */ + @Test + void testParseAlterTableAddSetAndEnumColumnKeepsOptionList() { + MySqlSourceConfigFactory factory = new MySqlSourceConfigFactory(); + factory.hostname("localhost"); + factory.username("test"); + factory.password("test"); + CustomMySqlAntlrDdlParser parser = + new CustomMySqlAntlrDdlParser(factory.create(0).getDbzConnectorConfig()); + parser.setCurrentDatabase("test_db"); + parser.parse( + "ALTER TABLE products ADD COLUMN c_set SET('a','b','c') NULL, " + + "ADD COLUMN c_enum ENUM('x','y') NULL, " + + "ADD COLUMN c_enum_multi ENUM('active','inactive') NULL, " + + "ADD COLUMN c_set_single SET('only') NULL, " + + "ADD COLUMN c_set_escaped SET('a,b','it''s') NULL, " + + "ADD COLUMN c_enum_charset ENUM('x','y') CHARACTER SET utf8mb4 NULL", + new Tables()); + + List<AlterTableColumnEvent> events = parser.getAndClearParsedColumnEvents(); + Assertions.assertEquals(6, events.size()); + + Column setColumn = ((AlterTableAddColumnEvent) events.get(0)).getColumn(); + Assertions.assertEquals("c_set", setColumn.getName()); + Assertions.assertEquals("SET('a','b','c')", setColumn.getSourceType()); + Assertions.assertEquals(BasicType.STRING_TYPE, setColumn.getDataType()); + // The longest value of this SET is "a,b,c": three members plus two separators. + Assertions.assertEquals(5L, setColumn.getColumnLength()); + + Column enumColumn = ((AlterTableAddColumnEvent) events.get(1)).getColumn(); + Assertions.assertEquals("c_enum", enumColumn.getName()); + Assertions.assertEquals("ENUM('x','y')", enumColumn.getSourceType()); + Assertions.assertEquals(BasicType.STRING_TYPE, enumColumn.getDataType()); + Assertions.assertEquals(1L, enumColumn.getColumnLength()); + + // Multi-character members are the case where the real length and Debezium's bookkeeping + // count (options * 2 - 1 for SET, 1 for ENUM) differ, so the length assertion below only + // passes for the right reason with members like these. + Column multiEnumColumn = ((AlterTableAddColumnEvent) events.get(2)).getColumn(); + Assertions.assertEquals("c_enum_multi", multiEnumColumn.getName()); + Assertions.assertEquals("ENUM('active','inactive')", multiEnumColumn.getSourceType()); + Assertions.assertEquals(BasicType.STRING_TYPE, multiEnumColumn.getDataType()); + Assertions.assertEquals(8L, multiEnumColumn.getColumnLength()); + + // A single-option SET would also look plausible with a wrong fallback: its bookkeeping + // length is 1, not the four characters of its only member. + Column singleSetColumn = ((AlterTableAddColumnEvent) events.get(3)).getColumn(); + Assertions.assertEquals("c_set_single", singleSetColumn.getName()); + Assertions.assertEquals("SET('only')", singleSetColumn.getSourceType()); + Assertions.assertEquals(4L, singleSetColumn.getColumnLength()); + + // The option list is re-emitted verbatim, so an embedded comma and an escaped quote have to + // round-trip exactly; the stored length counts the unescaped members plus the separator. + Column escapedSetColumn = ((AlterTableAddColumnEvent) events.get(4)).getColumn(); + Assertions.assertEquals("c_set_escaped", escapedSetColumn.getName()); + Assertions.assertEquals("SET('a,b','it''s')", escapedSetColumn.getSourceType()); + Assertions.assertEquals(BasicType.STRING_TYPE, escapedSetColumn.getDataType()); + Assertions.assertEquals(8L, escapedSetColumn.getColumnLength()); + + // The rebuilt expression carries the option list only: a CHARACTER SET clause on the + // source column is not part of it. Pinned here so the behaviour is explicit rather than + // accidental; the sink then applies its own default charset for the added column. + Column charsetEnumColumn = ((AlterTableAddColumnEvent) events.get(5)).getColumn(); + Assertions.assertEquals("c_enum_charset", charsetEnumColumn.getName()); + Assertions.assertEquals("ENUM('x','y')", charsetEnumColumn.getSourceType()); + Assertions.assertEquals(1L, charsetEnumColumn.getColumnLength()); + } + + /** + * Same regression as {@link #testParseAlterTableAddSetAndEnumColumnKeepsOptionList()}, reached + * through the other two DDL forms that share {@code toSeatunnelColumnWithFullTypeInfo}: {@code + * MODIFY COLUMN} and {@code CHANGE COLUMN}. {@code CHANGE} additionally renames the column, so + * the rebuilt type has to survive that too. A length-bearing type is kept in the same statement + * to pin the fallback to the base implementation. + */ + @Test + void testParseAlterTableModifyAndChangeSetAndEnumColumnKeepOptionList() { + MySqlSourceConfigFactory factory = new MySqlSourceConfigFactory(); + factory.hostname("localhost"); + factory.username("test"); + factory.password("test"); + CustomMySqlAntlrDdlParser parser = + new CustomMySqlAntlrDdlParser(factory.create(0).getDbzConnectorConfig()); + parser.setCurrentDatabase("test_db"); + parser.parse( + "ALTER TABLE products MODIFY COLUMN c_set SET('a','b','c') NULL, " + + "CHANGE COLUMN c_enum_src c_enum ENUM('x','y') NULL, " + + "MODIFY COLUMN c_varchar VARCHAR(64) NULL", + new Tables()); + + List<AlterTableColumnEvent> events = parser.getAndClearParsedColumnEvents(); + Assertions.assertEquals(3, events.size()); + + Column modifiedColumn = ((AlterTableModifyColumnEvent) events.get(0)).getColumn(); + Assertions.assertEquals("c_set", modifiedColumn.getName()); + Assertions.assertEquals("SET('a','b','c')", modifiedColumn.getSourceType()); + Assertions.assertEquals(BasicType.STRING_TYPE, modifiedColumn.getDataType()); + + AlterTableChangeColumnEvent changeColumnEvent = (AlterTableChangeColumnEvent) events.get(1); + Assertions.assertEquals("c_enum_src", changeColumnEvent.getOldColumn()); + Column renamedColumn = changeColumnEvent.getColumn(); + Assertions.assertEquals("c_enum", renamedColumn.getName()); + Assertions.assertEquals("ENUM('x','y')", renamedColumn.getSourceType()); + Assertions.assertEquals(BasicType.STRING_TYPE, renamedColumn.getDataType()); + + Column varcharColumn = ((AlterTableModifyColumnEvent) events.get(2)).getColumn(); + Assertions.assertEquals("c_varchar", varcharColumn.getName()); + Assertions.assertEquals("VARCHAR(64)", varcharColumn.getSourceType()); + } + @Test void testParsePropagatesDdlErrors() { CustomMySqlAntlrDdlParser parser = new ParserWithTreeWalkError();
