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-12764-be8a310b251251e5dc76b9d49c488b06e38c0159 in repository https://gitbox.apache.org/repos/asf/seatunnel.git
commit 51f553cb45b4b2b37b87f7f654352bc0276daf6b Author: yuzhou <[email protected]> AuthorDate: Fri Oct 16 17:31:20 2026 +0000 [Improve][Connector-V2] Validate nonblank SQL Server CDC source URL (#12764) --- docs/en/connectors/source/SqlServer-CDC.md | 2 +- docs/zh/connectors/source/SqlServer-CDC.md | 2 +- .../source/SqlServerIncrementalSourceFactory.java | 7 +- .../SqlServerIncrementalSourceFactoryTest.java | 127 +++++++++++++++++++++ .../connector/cdc/sqlserver/SqlServerCDCIT.java | 29 +++++ .../src/test/resources/sqlservercdc_blank_url.conf | 35 ++++++ 6 files changed, 198 insertions(+), 4 deletions(-) diff --git a/docs/en/connectors/source/SqlServer-CDC.md b/docs/en/connectors/source/SqlServer-CDC.md index 40ca9315dd..cafa736078 100644 --- a/docs/en/connectors/source/SqlServer-CDC.md +++ b/docs/en/connectors/source/SqlServer-CDC.md @@ -85,7 +85,7 @@ case-sensitive databases, make sure the configured identifier case matches the d | table-names | List | Yes | - | Tables to monitor. Use the full table identifier: `databaseName.schemaName.tableName`. This option is mutually exclusive with `table-pattern`. [...] | table-pattern | String | Yes | - | Regular expression for tables to monitor. Use the full table identifier in the pattern, for example `column_type_test\\.dbo\\..*`. This option is mutually exclusive with `table-names`. [...] | table-names-config | List | No | - | Table config list. for example: [{"table": "db1.schema1.table1","primaryKeys": ["key1"],"snapshotSplitColumn": "key2"}]. The snapshotSplitColumn option must be configured with a unique key. If a non-unique column is provided, the configuration is ignored and SeaTunnel automatically selects an appropriate split column internally. [...] -| url | String | Yes | - | URL has to be with database, like "jdbc:sqlserver://localhost:1433;databaseName=test". SQL Server JDBC connection properties in this URL, such as `encrypt` and `trustServerCertificate`, are forwarded to the CDC connection. An explicitly configured property in `debezium`, such as `database.trustServerCertificate`, takes precedence. | +| url | String | Yes | - | Empty or whitespace-only values are rejected during configuration validation. Nonblank values are not trimmed. URL has to be with database, like "jdbc:sqlserver://localhost:1433;databaseName=test". SQL Server JDBC connection properties in this URL, such as `encrypt` and `trustServerCertificate`, are forwarded to the CDC connection. An explicitly configured property in `debezium`, such as `database.trustServerCe [...] | startup.mode | Enum | No | INITIAL | Optional startup mode for SqlServer CDC consumer. Valid values are `initial`, `earliest`, `latest`, and `timestamp`.<br/>`initial`: read the table snapshot first and then continue with incremental changes.<br/>`earliest`: start from the earliest available CDC LSN.<br/>`latest`: only read changes after the job starts.<br/>`timestamp`: start from the LSN resolved from `startup.timestamp`. [...] | startup.timestamp | Long | No | - | Start from the specified epoch timestamp (in milliseconds). This timestamp is converted with `server-time-zone` when `startup.mode = timestamp`.<br/> **Note, This option is required when** the **"startup.mode" option used `'timestamp'`.** [...] | stop.mode | Enum | No | NEVER | Optional stop mode for SqlServer CDC consumer, valid enumerations are "never". [...] diff --git a/docs/zh/connectors/source/SqlServer-CDC.md b/docs/zh/connectors/source/SqlServer-CDC.md index f98fa9118a..ec9c4c0cfe 100644 --- a/docs/zh/connectors/source/SqlServer-CDC.md +++ b/docs/zh/connectors/source/SqlServer-CDC.md @@ -79,7 +79,7 @@ Sql Server CDC 连接器允许从 SqlServer 数据库读取快照数据和增量 | table-names | List | 是 | - | 要监控的表,使用完整表标识:`databaseName.schemaName.tableName`。该参数与 `table-pattern` 互斥。 | | table-pattern | String | 是 | - | 要监控的表名正则表达式,需要匹配完整表标识,例如 `column_type_test\\.dbo\\..*`。该参数与 `table-names` 互斥。 | | table-names-config | List | 否 | - | 表配置列表。例如:[{"table": "db1.schema1.table1","primaryKeys": ["key1"],"snapshotSplitColumn": "key2"}]。 snapshotSplitColumn 选项必须配置为唯一键(主键或唯一索引)。 如果指定了非唯一列,该配置将被忽略,SeaTunnel 会在内部自动选择合适的拆分列。 | -| url | String | 是 | - | URL 必须包含数据库,如 "jdbc:sqlserver://localhost:1433;databaseName=test"。URL 中的 SQL Server JDBC 连接属性(如 `encrypt`、`trustServerCertificate`)会传递给 CDC 连接;`debezium` 中显式配置的属性(如 `database.trustServerCertificate`)优先。 | +| url | String | 是 | - | 配置校验会拒绝空字符串或仅包含空白字符的值,不会去除非空白值两端的空白字符。URL 必须包含数据库,如 "jdbc:sqlserver://localhost:1433;databaseName=test"。URL 中的 SQL Server JDBC 连接属性(如 `encrypt`、`trustServerCertificate`)会传递给 CDC 连接;`debezium` 中显式配置的属性(如 `database.trustServerCertificate`)优先。 | | startup.mode | Enum | 否 | INITIAL | SqlServer CDC 消费者的可选启动模式,有效值为 `initial`、`earliest`、`latest` 和 `timestamp`。<br/>`initial`:先读取表快照,再继续读取增量变更。<br/>`earliest`:从最早可用的 CDC LSN 开始读取。<br/>`latest`:只读取作业启动后的变更。<br/>`timestamp`:从 `startup.timestamp` 解析出的 LSN 开始读取。 | | startup.timestamp | Long | 否 | - | 从指定的纪元时间戳(以毫秒为单位)开始。当 `startup.mode = timestamp` 时,该时间戳会按 `server-time-zone` 转换。<br/> **注意,当 "startup.mode" 选项使用 `'timestamp'` 时,此选项是必需的。** | | stop.mode | Enum | 否 | NEVER | SqlServer CDC 消费者的可选停止模式,有效枚举为 "never"。 | diff --git a/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/source/SqlServerIncrementalSourceFactory.java b/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/source/SqlServerIncrementalSourceFactory.java index 02e40917b7..006d714468 100644 --- a/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/source/SqlServerIncrementalSourceFactory.java +++ b/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/source/SqlServerIncrementalSourceFactory.java @@ -17,6 +17,7 @@ package org.apache.seatunnel.connectors.seatunnel.cdc.sqlserver.source; +import org.apache.seatunnel.api.configuration.util.Conditions; import org.apache.seatunnel.api.configuration.util.OptionRule; import org.apache.seatunnel.api.options.ConnectorCommonOptions; import org.apache.seatunnel.api.source.SeaTunnelSource; @@ -55,8 +56,10 @@ public class SqlServerIncrementalSourceFactory implements TableSourceFactory { return SqlServerIncrementalSourceOptions.getBaseRule() .required( SqlServerIncrementalSourceOptions.USERNAME, - SqlServerIncrementalSourceOptions.PASSWORD, - SqlServerIncrementalSourceOptions.URL) + SqlServerIncrementalSourceOptions.PASSWORD) + .required( + SqlServerIncrementalSourceOptions.URL, + Conditions.notBlank(SqlServerIncrementalSourceOptions.URL)) .exclusive(ConnectorCommonOptions.TABLE_NAMES, ConnectorCommonOptions.TABLE_PATTERN) .optional( SqlServerIncrementalSourceOptions.DATABASE_NAMES, diff --git a/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/source/SqlServerIncrementalSourceFactoryTest.java b/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/source/SqlServerIncrementalSourceFactoryTest.java index bdf26b9718..4818685dd8 100644 --- a/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/source/SqlServerIncrementalSourceFactoryTest.java +++ b/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/source/SqlServerIncrementalSourceFactoryTest.java @@ -18,6 +18,9 @@ package org.apache.seatunnel.connectors.seatunnel.cdc.sqlserver.source; import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.api.configuration.util.ConfigValidator; +import org.apache.seatunnel.api.configuration.util.OptionRule; +import org.apache.seatunnel.api.configuration.util.OptionValidationException; import org.apache.seatunnel.api.table.catalog.Catalog; import org.apache.seatunnel.api.table.factory.FactoryUtil; import org.apache.seatunnel.connectors.seatunnel.cdc.sqlserver.config.SqlServerSourceConfig; @@ -25,19 +28,143 @@ import org.apache.seatunnel.connectors.seatunnel.cdc.sqlserver.config.SqlServerS import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.Properties; +import java.util.stream.Stream; class SqlServerIncrementalSourceFactoryTest { + private static final String VALID_URL = + "jdbc:sqlserver://localhost:1433;databaseName=seatunnel"; + + private final OptionRule optionRule = new SqlServerIncrementalSourceFactory().optionRule(); + @Test public void testOptionRule() { Assertions.assertNotNull((new SqlServerIncrementalSourceFactory()).optionRule()); } + @ParameterizedTest + @ValueSource(strings = {"url", "base-url"}) + void testNonblankUrlAccepted(String key) { + Map<String, Object> config = validConfig(); + config.remove("url"); + config.put(key, VALID_URL); + + ReadonlyConfig readonlyConfig = Assertions.assertDoesNotThrow(() -> validate(config)); + + Assertions.assertEquals( + VALID_URL, readonlyConfig.get(SqlServerIncrementalSourceOptions.URL)); + } + + @ParameterizedTest + @ValueSource(strings = {"url", "base-url"}) + void testPaddedNonblankUrlAcceptedWithoutTrimming(String key) { + Map<String, Object> config = validConfig(); + config.remove("url"); + String value = " \t" + VALID_URL + " \r\n"; + config.put(key, value); + + ReadonlyConfig readonlyConfig = Assertions.assertDoesNotThrow(() -> validate(config)); + + Assertions.assertEquals(value, readonlyConfig.get(SqlServerIncrementalSourceOptions.URL)); + } + + @ParameterizedTest + @MethodSource("blankUrls") + void testBlankUrlRejected(String key, String value) { + Map<String, Object> config = validConfig(); + config.remove("url"); + config.put(key, value); + + assertInvalidUrl(config); + } + + private static Stream<Arguments> blankUrls() { + return Stream.of("url", "base-url") + .flatMap( + key -> + Stream.of("", " ", "\t", "\n", "\r", " \t\r\n ") + .map(value -> Arguments.of(key, value))); + } + + @Test + void testMissingUrlRejected() { + Map<String, Object> config = validConfig(); + config.remove("url"); + + assertInvalidUrl(config); + } + + @Test + void testCanonicalUrlTakesPrecedenceOverFallback() { + Map<String, Object> config = validConfig(); + config.put("base-url", "jdbc:sqlserver://other-host:1433;databaseName=other"); + + ReadonlyConfig readonlyConfig = Assertions.assertDoesNotThrow(() -> validate(config)); + + Assertions.assertEquals( + VALID_URL, readonlyConfig.get(SqlServerIncrementalSourceOptions.URL)); + } + + @Test + void testValidCanonicalUrlIgnoresBlankFallback() { + Map<String, Object> config = validConfig(); + config.put("base-url", " \t "); + + Assertions.assertDoesNotThrow(() -> validate(config)); + } + + @Test + void testBlankCanonicalUrlDoesNotUseValidFallback() { + Map<String, Object> config = validConfig(); + config.put("url", " \t "); + config.put("base-url", VALID_URL); + + assertInvalidUrl(config); + } + + @Test + void testEmptyPasswordRemainsAccepted() { + Map<String, Object> config = validConfig(); + config.put("password", ""); + + ReadonlyConfig readonlyConfig = Assertions.assertDoesNotThrow(() -> validate(config)); + + Assertions.assertEquals("", readonlyConfig.get(SqlServerIncrementalSourceOptions.PASSWORD)); + } + + private Map<String, Object> validConfig() { + Map<String, Object> config = new HashMap<>(); + config.put("url", VALID_URL); + config.put("username", "sa"); + config.put("password", "Password!"); + config.put("table-names", Collections.singletonList("seatunnel.dbo.orders")); + return config; + } + + private ReadonlyConfig validate(Map<String, Object> config) { + ReadonlyConfig readonlyConfig = ReadonlyConfig.fromMap(config); + ConfigValidator.validateUnknownKeys(readonlyConfig, optionRule, "SqlServer-CDC"); + ConfigValidator.of(readonlyConfig).validate(optionRule); + return readonlyConfig; + } + + private void assertInvalidUrl(Map<String, Object> config) { + OptionValidationException error = + Assertions.assertThrows(OptionValidationException.class, () -> validate(config)); + + Assertions.assertTrue(error.getMessage().contains("url"), error.getMessage()); + } + /** * SQLServer CDC source creation must accept the driver-specific databaseName URL syntax during * the submission-time catalog validation step. diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/java/org/apache/seatunnel/e2e/connector/cdc/sqlserver/SqlServerCDCIT.java b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/java/org/apache/seatunnel/e2e/connector/cdc/sqlserver/SqlServerCDCIT.java index bfd1bc6ae3..5b5bb93da8 100644 --- a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/java/org/apache/seatunnel/e2e/connector/cdc/sqlserver/SqlServerCDCIT.java +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/java/org/apache/seatunnel/e2e/connector/cdc/sqlserver/SqlServerCDCIT.java @@ -28,6 +28,7 @@ import org.apache.seatunnel.e2e.common.container.EngineType; import org.apache.seatunnel.e2e.common.container.TestContainer; import org.apache.seatunnel.e2e.common.junit.DisabledOnContainer; import org.apache.seatunnel.e2e.common.junit.TestContainerExtension; +import org.apache.seatunnel.e2e.common.util.ContainerUtil; import org.apache.seatunnel.e2e.common.util.DependencyJar; import org.apache.seatunnel.e2e.common.util.JdbcUtil; import org.apache.seatunnel.e2e.common.util.JobIdGenerator; @@ -258,6 +259,34 @@ public class SqlServerCDCIT extends TestSuiteBase implements TestResource { }); } + @TestTemplate + @DisabledOnContainer( + value = {}, + type = {EngineType.FLINK}, + disabledReason = "The configuration-only --check command is provided by Zeta") + public void testBlankSourceUrlRejectedByConfigCheck(TestContainer container) + throws IOException, InterruptedException { + String configResource = "/sqlservercdc_blank_url.conf"; + container.executeExtraCommands( + engineContainer -> + ContainerUtil.copyConnectorJarToContainer( + engineContainer, + configResource, + "seatunnel-connectors-v2", + "connector-", + "seatunnel", + "/tmp/seatunnel")); + String configPath = "/tmp/seatunnel/config/sqlservercdc_blank_url.conf"; + container.copyFileToContainer(configResource, configPath); + + Container.ExecResult result = + container.executeBaseCommand(new String[] {"--config", configPath, "--check"}); + String output = result.getStdout() + result.getStderr(); + + Assertions.assertNotEquals(0, result.getExitCode(), output); + Assertions.assertTrue(output.contains("url") && output.contains("blank"), output); + } + /** * Verifies that a single SqlServer CDC source can capture multiple tables and route them to * different sink tables in the same database. diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/resources/sqlservercdc_blank_url.conf b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/resources/sqlservercdc_blank_url.conf new file mode 100644 index 0000000000..993e9b675b --- /dev/null +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/resources/sqlservercdc_blank_url.conf @@ -0,0 +1,35 @@ +# +# 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" +} + +source { + SqlServer-CDC { + username = "sa" + password = "Password!" + table-names = ["column_type_test.dbo.full_types"] + url = " \t\r\n " + } +} + +sink { + Console { + } +}
