This is an automated email from the ASF dual-hosted git repository.
davidzollo 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 a7cde22db3 [Improve][Connector-V2] Migrate Redis validation to
declarative OptionRule (#11225)
a7cde22db3 is described below
commit a7cde22db3eab22a223b90bcf07a2305ae962949
Author: Shuai Liu <[email protected]>
AuthorDate: Fri Jul 3 18:07:36 2026 +0800
[Improve][Connector-V2] Migrate Redis validation to declarative OptionRule
(#11225)
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
---
.../seatunnel/redis/config/RedisBaseOptions.java | 4 +
.../redis/config/RedisNodesValidator.java | 72 ++++++
.../seatunnel/redis/config/RedisParameters.java | 12 -
.../config/RedisSingleTableDataTypeValidator.java} | 21 +-
.../seatunnel/redis/config/RedisTableConfig.java | 20 --
.../redis/config/RedisTableConfigsValidator.java | 51 ++++
.../seatunnel/redis/sink/RedisSinkFactory.java | 15 ++
.../seatunnel/redis/source/RedisSourceFactory.java | 28 +++
.../seatunnel/redis/RedisFactoryTest.java | 257 ++++++++++++++++++++-
.../redis/config/RedisTableConfigTest.java | 31 ---
.../e2e/connector/redis/RedisValidationIT.java | 58 +++++
.../redis-validation-cluster-malformed-node.conf | 48 ++++
.../redis-validation-single-port-out-of-range.conf | 47 ++++
13 files changed, 589 insertions(+), 75 deletions(-)
diff --git
a/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/config/RedisBaseOptions.java
b/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/config/RedisBaseOptions.java
index b834ebb487..690f69fd92 100644
---
a/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/config/RedisBaseOptions.java
+++
b/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/config/RedisBaseOptions.java
@@ -41,6 +41,10 @@ public class RedisBaseOptions extends ConnectorCommonOptions
{
public static final Option<Integer> PORT =
Options.key("port").intType().defaultValue(6379).withDescription("redis port");
+ public static final int MIN_PORT = 1;
+
+ public static final int MAX_PORT = 65535;
+
public static final Option<String> AUTH =
Options.key("auth")
.stringType()
diff --git
a/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/config/RedisNodesValidator.java
b/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/config/RedisNodesValidator.java
new file mode 100644
index 0000000000..b76fa1448d
--- /dev/null
+++
b/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/config/RedisNodesValidator.java
@@ -0,0 +1,72 @@
+/*
+ * 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.seatunnel.connectors.seatunnel.redis.config;
+
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.configuration.util.ConditionExtension;
+import org.apache.seatunnel.api.configuration.util.OptionValidationException;
+
+import java.util.List;
+import java.util.regex.Pattern;
+
+public class RedisNodesValidator implements ConditionExtension<List<String>> {
+
+ private static final Pattern NODE_PATTERN =
Pattern.compile("^[^:]+:\\d+$");
+
+ @Override
+ public String description() {
+ return "each node must be in 'host:port' format with port in ["
+ + RedisBaseOptions.MIN_PORT
+ + ", "
+ + RedisBaseOptions.MAX_PORT
+ + "]";
+ }
+
+ @Override
+ public boolean evaluate(ReadonlyConfig config, List<String> value)
+ throws OptionValidationException {
+ for (int i = 0; i < value.size(); i++) {
+ String node = value.get(i);
+ if (node == null || !NODE_PATTERN.matcher(node).matches()) {
+ throw new OptionValidationException(
+ "nodes[%d]: must be in 'host:port' format, got: %s",
i, node);
+ }
+ int colon = node.indexOf(':');
+ String host = node.substring(0, colon);
+ if (host.trim().isEmpty()) {
+ throw new OptionValidationException(
+ "nodes[%d]: host must not be blank in 'host:port',
got: %s", i, node);
+ }
+ String portStr = node.substring(colon + 1);
+ int port;
+ try {
+ port = Integer.parseInt(portStr);
+ } catch (NumberFormatException e) {
+ throw new OptionValidationException(
+ "nodes[%d]: port '%s' is out of range [%d, %d]",
+ i, portStr, RedisBaseOptions.MIN_PORT,
RedisBaseOptions.MAX_PORT);
+ }
+ if (port < RedisBaseOptions.MIN_PORT || port >
RedisBaseOptions.MAX_PORT) {
+ throw new OptionValidationException(
+ "nodes[%d]: port %d is out of range [%d, %d]",
+ i, port, RedisBaseOptions.MIN_PORT,
RedisBaseOptions.MAX_PORT);
+ }
+ }
+ return true;
+ }
+}
diff --git
a/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/config/RedisParameters.java
b/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/config/RedisParameters.java
index a53a3d2afa..cac06d2b47 100644
---
a/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/config/RedisParameters.java
+++
b/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/config/RedisParameters.java
@@ -39,8 +39,6 @@ import java.util.HashSet;
import java.util.List;
import static
org.apache.seatunnel.connectors.seatunnel.redis.exception.RedisErrorCode.GET_REDIS_VERSION_INFO_FAILED;
-import static
org.apache.seatunnel.connectors.seatunnel.redis.exception.RedisErrorCode.INVALID_CONFIG;
-import static
org.apache.seatunnel.connectors.seatunnel.redis.exception.RedisErrorCode.REDIS_NODE_EMPTY_ERROR;
@Data
@Slf4j
@@ -196,18 +194,8 @@ public class RedisParameters implements Serializable {
return jedis;
case CLUSTER:
HashSet<HostAndPort> nodes = new HashSet<>();
- if (redisNodes.isEmpty()) {
- throw new RedisConnectorException(
- REDIS_NODE_EMPTY_ERROR, "Redis nodes parameter
must not be empty");
- }
for (String redisNode : redisNodes) {
String[] splits = redisNode.split(":");
- if (splits.length != 2) {
- throw new RedisConnectorException(
- INVALID_CONFIG,
- "Invalid redis node information,"
- + "redis node information must like as
the following: [host:port]");
- }
HostAndPort hostAndPort =
new HostAndPort(splits[0],
Integer.parseInt(splits[1]));
nodes.add(hostAndPort);
diff --git
a/seatunnel-connectors-v2/connector-redis/src/test/java/org/apache/seatunnel/connectors/seatunnel/redis/RedisFactoryTest.java
b/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/config/RedisSingleTableDataTypeValidator.java
similarity index 59%
copy from
seatunnel-connectors-v2/connector-redis/src/test/java/org/apache/seatunnel/connectors/seatunnel/redis/RedisFactoryTest.java
copy to
seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/config/RedisSingleTableDataTypeValidator.java
index ef2b3f0367..42cfa87f62 100644
---
a/seatunnel-connectors-v2/connector-redis/src/test/java/org/apache/seatunnel/connectors/seatunnel/redis/RedisFactoryTest.java
+++
b/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/config/RedisSingleTableDataTypeValidator.java
@@ -15,19 +15,20 @@
* limitations under the License.
*/
-package org.apache.seatunnel.connectors.seatunnel.redis;
+package org.apache.seatunnel.connectors.seatunnel.redis.config;
-import org.apache.seatunnel.connectors.seatunnel.redis.sink.RedisSinkFactory;
-import
org.apache.seatunnel.connectors.seatunnel.redis.source.RedisSourceFactory;
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.configuration.util.ConditionExtension;
-import org.junit.jupiter.api.Assertions;
-import org.junit.jupiter.api.Test;
+public class RedisSingleTableDataTypeValidator implements
ConditionExtension<String> {
-class RedisFactoryTest {
+ @Override
+ public String description() {
+ return "'data_type' must be configured when 'keys' is set";
+ }
- @Test
- void optionRule() {
- Assertions.assertNotNull((new RedisSourceFactory()).optionRule());
- Assertions.assertNotNull((new RedisSinkFactory()).optionRule());
+ @Override
+ public boolean evaluate(ReadonlyConfig config, String keys) {
+ return config.getOptional(RedisBaseOptions.DATA_TYPE).isPresent();
}
}
diff --git
a/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/config/RedisTableConfig.java
b/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/config/RedisTableConfig.java
index 2f90ff5aad..ab61339603 100644
---
a/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/config/RedisTableConfig.java
+++
b/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/config/RedisTableConfig.java
@@ -135,9 +135,6 @@ public class RedisTableConfig implements Serializable {
* @return Fully initialized RedisTableConfig with runtime objects
*/
private static RedisTableConfig buildTableConfig(ReadonlyConfig config) {
- // Validate required fields are present
- validateRequiredFields(config);
-
// Build catalog table and deserialization schema
TableConfigResult result = buildCatalogTableAndSchema(config,
config.get(KEY_PATTERN));
@@ -158,23 +155,6 @@ public class RedisTableConfig implements Serializable {
.build();
}
- /**
- * Validate required fields in a (table-level) configuration.
- *
- * @param config ReadonlyConfig to validate
- */
- private static void validateRequiredFields(ReadonlyConfig config) {
- String keys = config.getOptional(KEY_PATTERN).orElse(null);
- if (keys == null || keys.trim().isEmpty()) {
- throw new IllegalArgumentException(
- "Redis table configuration requires 'keys' parameter.");
- }
- if (!config.getOptional(DATA_TYPE).isPresent()) {
- throw new IllegalArgumentException(
- "Redis table configuration requires 'data_type'
parameter.");
- }
- }
-
/** Result class containing catalog table and deserialization schema. */
private static class TableConfigResult {
CatalogTable catalogTable;
diff --git
a/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/config/RedisTableConfigsValidator.java
b/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/config/RedisTableConfigsValidator.java
new file mode 100644
index 0000000000..0aeadd7c3a
--- /dev/null
+++
b/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/config/RedisTableConfigsValidator.java
@@ -0,0 +1,51 @@
+/*
+ * 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.seatunnel.connectors.seatunnel.redis.config;
+
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.configuration.util.ConditionExtension;
+import org.apache.seatunnel.api.configuration.util.OptionValidationException;
+
+import java.util.List;
+import java.util.Map;
+
+public class RedisTableConfigsValidator implements
ConditionExtension<List<Map<String, Object>>> {
+
+ @Override
+ public String description() {
+ return "each 'tables_configs' entry must declare a non-blank 'keys'
and a 'data_type'";
+ }
+
+ @Override
+ public boolean evaluate(ReadonlyConfig config, List<Map<String, Object>>
entries)
+ throws OptionValidationException {
+ for (int i = 0; i < entries.size(); i++) {
+ ReadonlyConfig entry = ReadonlyConfig.fromMap(entries.get(i));
+ String keys =
entry.getOptional(RedisBaseOptions.KEY_PATTERN).orElse(null);
+ if (keys == null || keys.trim().isEmpty()) {
+ throw new OptionValidationException(
+ "tables_configs[%d]: 'keys' must be configured and
non-blank", i);
+ }
+ if (!entry.getOptional(RedisBaseOptions.DATA_TYPE).isPresent()) {
+ throw new OptionValidationException(
+ "tables_configs[%d]: 'data_type' must be configured",
i);
+ }
+ }
+ return true;
+ }
+}
diff --git
a/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/sink/RedisSinkFactory.java
b/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/sink/RedisSinkFactory.java
index 2bfbd09733..7730fec61a 100644
---
a/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/sink/RedisSinkFactory.java
+++
b/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/sink/RedisSinkFactory.java
@@ -17,6 +17,7 @@
package org.apache.seatunnel.connectors.seatunnel.redis.sink;
+import org.apache.seatunnel.api.configuration.util.Conditions;
import org.apache.seatunnel.api.configuration.util.OptionRule;
import org.apache.seatunnel.api.options.SinkConnectorCommonOptions;
import org.apache.seatunnel.api.table.catalog.CatalogTable;
@@ -25,6 +26,7 @@ import org.apache.seatunnel.api.table.factory.Factory;
import org.apache.seatunnel.api.table.factory.TableSinkFactory;
import org.apache.seatunnel.api.table.factory.TableSinkFactoryContext;
import org.apache.seatunnel.connectors.seatunnel.redis.config.RedisBaseOptions;
+import
org.apache.seatunnel.connectors.seatunnel.redis.config.RedisNodesValidator;
import org.apache.seatunnel.connectors.seatunnel.redis.config.RedisSinkOptions;
import com.google.auto.service.AutoService;
@@ -69,6 +71,19 @@ public class RedisSinkFactory implements TableSinkFactory {
RedisBaseOptions.MODE,
RedisBaseOptions.RedisMode.CLUSTER,
RedisBaseOptions.NODES)
+ .conditional(
+ RedisBaseOptions.MODE,
+ RedisBaseOptions.RedisMode.SINGLE,
+ Conditions.notBlank(RedisBaseOptions.HOST),
+ Conditions.greaterOrEqual(RedisBaseOptions.PORT,
RedisBaseOptions.MIN_PORT)
+ .and(
+ Conditions.lessOrEqual(
+ RedisBaseOptions.PORT,
RedisBaseOptions.MAX_PORT)))
+ .conditional(
+ RedisBaseOptions.MODE,
+ RedisBaseOptions.RedisMode.CLUSTER,
+ Conditions.notEmpty(RedisBaseOptions.NODES),
+ Conditions.extension(RedisBaseOptions.NODES, new
RedisNodesValidator()))
.build();
}
}
diff --git
a/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/source/RedisSourceFactory.java
b/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/source/RedisSourceFactory.java
index ad4dd6a79c..c44700e56e 100644
---
a/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/source/RedisSourceFactory.java
+++
b/seatunnel-connectors-v2/connector-redis/src/main/java/org/apache/seatunnel/connectors/seatunnel/redis/source/RedisSourceFactory.java
@@ -17,6 +17,7 @@
package org.apache.seatunnel.connectors.seatunnel.redis.source;
+import org.apache.seatunnel.api.configuration.util.Conditions;
import org.apache.seatunnel.api.configuration.util.OptionRule;
import org.apache.seatunnel.api.source.SeaTunnelSource;
import org.apache.seatunnel.api.source.SourceSplit;
@@ -25,7 +26,10 @@ import org.apache.seatunnel.api.table.factory.Factory;
import org.apache.seatunnel.api.table.factory.TableSourceFactory;
import org.apache.seatunnel.api.table.factory.TableSourceFactoryContext;
import org.apache.seatunnel.connectors.seatunnel.redis.config.RedisBaseOptions;
+import
org.apache.seatunnel.connectors.seatunnel.redis.config.RedisNodesValidator;
+import
org.apache.seatunnel.connectors.seatunnel.redis.config.RedisSingleTableDataTypeValidator;
import
org.apache.seatunnel.connectors.seatunnel.redis.config.RedisSourceOptions;
+import
org.apache.seatunnel.connectors.seatunnel.redis.config.RedisTableConfigsValidator;
import com.google.auto.service.AutoService;
@@ -48,6 +52,17 @@ public class RedisSourceFactory implements
TableSourceFactory {
public OptionRule optionRule() {
return OptionRule.builder()
.exclusive(RedisBaseOptions.TABLE_CONFIGS,
RedisBaseOptions.KEY_PATTERN)
+ .optional(
+ RedisBaseOptions.KEY_PATTERN,
+ Conditions.notBlank(RedisBaseOptions.KEY_PATTERN),
+ Conditions.extension(
+ RedisBaseOptions.KEY_PATTERN,
+ new RedisSingleTableDataTypeValidator()))
+ .optional(
+ RedisBaseOptions.TABLE_CONFIGS,
+ Conditions.notEmpty(RedisBaseOptions.TABLE_CONFIGS),
+ Conditions.extension(
+ RedisBaseOptions.TABLE_CONFIGS, new
RedisTableConfigsValidator()))
.optional(
RedisBaseOptions.DATA_TYPE,
RedisBaseOptions.MODE,
@@ -71,6 +86,19 @@ public class RedisSourceFactory implements
TableSourceFactory {
RedisBaseOptions.RedisMode.SINGLE,
RedisBaseOptions.HOST,
RedisBaseOptions.PORT)
+ .conditional(
+ RedisBaseOptions.MODE,
+ RedisBaseOptions.RedisMode.SINGLE,
+ Conditions.notBlank(RedisBaseOptions.HOST),
+ Conditions.greaterOrEqual(RedisBaseOptions.PORT,
RedisBaseOptions.MIN_PORT)
+ .and(
+ Conditions.lessOrEqual(
+ RedisBaseOptions.PORT,
RedisBaseOptions.MAX_PORT)))
+ .conditional(
+ RedisBaseOptions.MODE,
+ RedisBaseOptions.RedisMode.CLUSTER,
+ Conditions.notEmpty(RedisBaseOptions.NODES),
+ Conditions.extension(RedisBaseOptions.NODES, new
RedisNodesValidator()))
.conditional(
RedisSourceOptions.READ_KEY_ENABLED,
true,
diff --git
a/seatunnel-connectors-v2/connector-redis/src/test/java/org/apache/seatunnel/connectors/seatunnel/redis/RedisFactoryTest.java
b/seatunnel-connectors-v2/connector-redis/src/test/java/org/apache/seatunnel/connectors/seatunnel/redis/RedisFactoryTest.java
index ef2b3f0367..cf2ca61d79 100644
---
a/seatunnel-connectors-v2/connector-redis/src/test/java/org/apache/seatunnel/connectors/seatunnel/redis/RedisFactoryTest.java
+++
b/seatunnel-connectors-v2/connector-redis/src/test/java/org/apache/seatunnel/connectors/seatunnel/redis/RedisFactoryTest.java
@@ -17,17 +17,270 @@
package org.apache.seatunnel.connectors.seatunnel.redis;
+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.CatalogTable;
+import org.apache.seatunnel.api.table.catalog.PhysicalColumn;
+import org.apache.seatunnel.api.table.catalog.TableIdentifier;
+import org.apache.seatunnel.api.table.catalog.TableSchema;
+import org.apache.seatunnel.api.table.factory.TableSinkFactoryContext;
+import org.apache.seatunnel.api.table.factory.TableSourceFactoryContext;
+import org.apache.seatunnel.api.table.type.BasicType;
import org.apache.seatunnel.connectors.seatunnel.redis.sink.RedisSinkFactory;
import
org.apache.seatunnel.connectors.seatunnel.redis.source.RedisSourceFactory;
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 java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
class RedisFactoryTest {
+ private static final OptionRule SOURCE_RULE = new
RedisSourceFactory().optionRule();
+ private static final OptionRule SINK_RULE = new
RedisSinkFactory().optionRule();
+
@Test
void optionRule() {
- Assertions.assertNotNull((new RedisSourceFactory()).optionRule());
- Assertions.assertNotNull((new RedisSinkFactory()).optionRule());
+ Assertions.assertNotNull(SOURCE_RULE);
+ Assertions.assertNotNull(SINK_RULE);
+ }
+
+ // accepted configs
+
+ @Test
+ void sourceSingleModeValid() {
+ Assertions.assertDoesNotThrow(() ->
validateSource(singleSourceConfig()));
+ }
+
+ @Test
+ void sourceClusterModeValid() {
+ Map<String, Object> config = clusterSourceConfig();
+ config.put("nodes", Arrays.asList("127.0.0.1:7000", "127.0.0.1:7001"));
+ Assertions.assertDoesNotThrow(() -> validateSource(config));
+ }
+
+ @Test
+ void sourceMultiTableValid() {
+ Assertions.assertDoesNotThrow(() ->
validateSource(multiTableSourceConfig()));
+ }
+
+ @Test
+ void sinkSingleModeValid() {
+ Assertions.assertDoesNotThrow(() -> validateSink(singleSinkConfig()));
+ }
+
+ // rejected configs
+
+ @ParameterizedTest(name = "source single-mode connection rejected: {0}")
+ @MethodSource("invalidSingleConnections")
+ void sourceSingleModeInvalidConnectionRejected(String name, Map<String,
Object> config) {
+ Assertions.assertThrows(OptionValidationException.class, () ->
validateSource(config));
+ }
+
+ @ParameterizedTest(name = "source cluster-mode nodes rejected: {0}")
+ @MethodSource("invalidClusterNodes")
+ void sourceClusterModeInvalidNodesRejected(String name, Map<String,
Object> config) {
+ Assertions.assertThrows(OptionValidationException.class, () ->
validateSource(config));
+ }
+
+ @ParameterizedTest(name = "source table config rejected: {0}")
+ @MethodSource("invalidTableConfigs")
+ void sourceTableConfigRejected(String name, Map<String, Object> config) {
+ Assertions.assertThrows(OptionValidationException.class, () ->
validateSource(config));
+ }
+
+ @ParameterizedTest(name = "sink config rejected: {0}")
+ @MethodSource("invalidSinkConfigs")
+ void sinkConfigRejected(String name, Map<String, Object> config) {
+ Assertions.assertThrows(OptionValidationException.class, () ->
validateSink(config));
+ }
+
+ @Test
+ void sourceSingleModeAggregatesMultipleErrors() {
+ Map<String, Object> config = singleSourceConfig();
+ config.put("host", " ");
+ config.put("port", 70000);
+ OptionValidationException ex =
+ Assertions.assertThrows(
+ OptionValidationException.class, () ->
validateSource(config));
+ String message = ex.getMessage();
+ Assertions.assertTrue(message.contains("(2 error"), message);
+ Assertions.assertTrue(message.contains("host"), message);
+ Assertions.assertTrue(message.contains("port"), message);
+ }
+
+ // factory-context regression
+
+ @Test
+ void sourceCreatedViaFactoryContext() {
+ ReadonlyConfig config = ReadonlyConfig.fromMap(singleSourceConfig());
+ ConfigValidator.of(config).validate(SOURCE_RULE);
+ TableSourceFactoryContext context =
+ new TableSourceFactoryContext(
+ config,
Thread.currentThread().getContextClassLoader());
+ Assertions.assertDoesNotThrow(
+ () -> new
RedisSourceFactory().createSource(context).createSource());
+ }
+
+ @Test
+ void sinkCreatedViaFactoryContext() {
+ ReadonlyConfig config = ReadonlyConfig.fromMap(singleSinkConfig());
+ ConfigValidator.of(config).validate(SINK_RULE);
+ TableSinkFactoryContext context =
+ new TableSinkFactoryContext(
+ catalogTable(), config,
Thread.currentThread().getContextClassLoader());
+ Assertions.assertDoesNotThrow(
+ () -> new RedisSinkFactory().createSink(context).createSink());
+ }
+
+ // parameterized-case providers
+
+ static Stream<Arguments> invalidSingleConnections() {
+ Map<String, Object> blankHost = singleSourceConfig();
+ blankHost.put("host", " ");
+ Map<String, Object> portTooSmall = singleSourceConfig();
+ portTooSmall.put("port", 0);
+ Map<String, Object> portTooLarge = singleSourceConfig();
+ portTooLarge.put("port", 70000);
+ return Stream.of(
+ Arguments.of("blank host", blankHost),
+ Arguments.of("port below range", portTooSmall),
+ Arguments.of("port above range", portTooLarge));
+ }
+
+ static Stream<Arguments> invalidClusterNodes() {
+ return Stream.of(
+ Arguments.of("empty nodes",
clusterNodes(Collections.emptyList())),
+ Arguments.of(
+ "malformed node",
+ clusterNodes(Arrays.asList("127.0.0.1:7000",
"host-without-port"))),
+ Arguments.of(
+ "port out of range",
+ clusterNodes(Arrays.asList("127.0.0.1:7000",
"127.0.0.1:99999"))),
+ Arguments.of(
+ "blank host in node",
+ clusterNodes(Arrays.asList("127.0.0.1:7000", "
:7001"))));
+ }
+
+ static Stream<Arguments> invalidTableConfigs() {
+ Map<String, Object> singleBlankKeys = singleSourceConfig();
+ singleBlankKeys.put("keys", " ");
+ Map<String, Object> singleMissingDataType = singleSourceConfig();
+ singleMissingDataType.remove("data_type");
+ Map<String, Object> multiBlankKeysInEntry = multiTableSourceConfig();
+ multiBlankKeysInEntry.put(
+ "tables_configs", Arrays.asList(tableEntry("key_a*"),
tableEntry("")));
+ Map<String, Object> entryMissingDataType = new HashMap<>();
+ entryMissingDataType.put("keys", "key_a*");
+ Map<String, Object> multiMissingDataType = multiTableSourceConfig();
+ multiMissingDataType.put("tables_configs",
Collections.singletonList(entryMissingDataType));
+ Map<String, Object> multiEmptyList = multiTableSourceConfig();
+ multiEmptyList.put("tables_configs", Collections.emptyList());
+ return Stream.of(
+ Arguments.of("single: blank keys", singleBlankKeys),
+ Arguments.of("single: missing data_type",
singleMissingDataType),
+ Arguments.of("multi: blank keys in entry",
multiBlankKeysInEntry),
+ Arguments.of("multi: missing data_type in entry",
multiMissingDataType),
+ Arguments.of("multi: empty list", multiEmptyList));
+ }
+
+ static Stream<Arguments> invalidSinkConfigs() {
+ Map<String, Object> portOutOfRange = singleSinkConfig();
+ portOutOfRange.put("port", -1);
+ Map<String, Object> clusterMalformedNode = singleSinkConfig();
+ clusterMalformedNode.remove("host");
+ clusterMalformedNode.remove("port");
+ clusterMalformedNode.put("mode", "CLUSTER");
+ clusterMalformedNode.put("nodes",
Collections.singletonList("bad_node"));
+ return Stream.of(
+ Arguments.of("single: port out of range", portOutOfRange),
+ Arguments.of("cluster: malformed node", clusterMalformedNode));
+ }
+
+ // helpers
+
+ private static void validateSource(Map<String, Object> config) {
+
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(SOURCE_RULE);
+ }
+
+ private static void validateSink(Map<String, Object> config) {
+ ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(SINK_RULE);
+ }
+
+ private static Map<String, Object> clusterNodes(List<String> nodes) {
+ Map<String, Object> config = clusterSourceConfig();
+ config.put("nodes", nodes);
+ return config;
+ }
+
+ private static CatalogTable catalogTable() {
+ TableSchema schema =
+ TableSchema.builder()
+ .column(PhysicalColumn.of("id", BasicType.LONG_TYPE,
22, false, null, "id"))
+ .column(
+ PhysicalColumn.of(
+ "name", BasicType.STRING_TYPE, 128,
false, null, "name"))
+ .build();
+ return CatalogTable.of(
+ TableIdentifier.of("catalog", "default", null, "test_table"),
+ schema,
+ new HashMap<>(),
+ new ArrayList<>(),
+ null,
+ "catalog");
+ }
+
+ private static Map<String, Object> singleSourceConfig() {
+ Map<String, Object> config = new HashMap<>();
+ config.put("mode", "SINGLE");
+ config.put("host", "localhost");
+ config.put("port", 6379);
+ config.put("keys", "key_test*");
+ config.put("data_type", "KEY");
+ return config;
+ }
+
+ private static Map<String, Object> clusterSourceConfig() {
+ Map<String, Object> config = new HashMap<>();
+ config.put("mode", "CLUSTER");
+ config.put("keys", "key_test*");
+ config.put("data_type", "KEY");
+ return config;
+ }
+
+ private static Map<String, Object> multiTableSourceConfig() {
+ Map<String, Object> config = new HashMap<>();
+ config.put("host", "localhost");
+ config.put("port", 6379);
+ config.put("tables_configs", Arrays.asList(tableEntry("key_a*"),
tableEntry("key_b*")));
+ return config;
+ }
+
+ private static Map<String, Object> tableEntry(String keys) {
+ Map<String, Object> entry = new HashMap<>();
+ entry.put("keys", keys);
+ entry.put("data_type", "KEY");
+ return entry;
+ }
+
+ private static Map<String, Object> singleSinkConfig() {
+ Map<String, Object> config = new HashMap<>();
+ config.put("mode", "SINGLE");
+ config.put("host", "localhost");
+ config.put("port", 6379);
+ config.put("key", "my_key");
+ config.put("data_type", "KEY");
+ return config;
}
}
diff --git
a/seatunnel-connectors-v2/connector-redis/src/test/java/org/apache/seatunnel/connectors/seatunnel/redis/config/RedisTableConfigTest.java
b/seatunnel-connectors-v2/connector-redis/src/test/java/org/apache/seatunnel/connectors/seatunnel/redis/config/RedisTableConfigTest.java
index 8d9edfb35f..70c96f6e60 100644
---
a/seatunnel-connectors-v2/connector-redis/src/test/java/org/apache/seatunnel/connectors/seatunnel/redis/config/RedisTableConfigTest.java
+++
b/seatunnel-connectors-v2/connector-redis/src/test/java/org/apache/seatunnel/connectors/seatunnel/redis/config/RedisTableConfigTest.java
@@ -23,7 +23,6 @@ import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
-import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -47,36 +46,6 @@ class RedisTableConfigTest {
Assertions.assertEquals(RedisDataType.STRING,
tables.get(0).getDataType());
}
- @Test
- void singleTableMissingDataTypeThrows() {
- Map<String, Object> map = new HashMap<>();
- map.put("keys", "key_test*");
-
- Assertions.assertThrows(
- IllegalArgumentException.class, () ->
RedisTableConfig.of(config(map)));
- }
-
- @Test
- void singleTableBlankKeysThrows() {
- Map<String, Object> map = new HashMap<>();
- map.put("keys", " ");
- map.put("data_type", "string");
-
- Assertions.assertThrows(
- IllegalArgumentException.class, () ->
RedisTableConfig.of(config(map)));
- }
-
- @Test
- void multiTableAppliesRequiredFieldValidationPerItem() {
- Map<String, Object> tableItem = new HashMap<>();
- tableItem.put("keys", "key_test*");
- Map<String, Object> map = new HashMap<>();
- map.put("tables_configs", Collections.singletonList(tableItem));
-
- Assertions.assertThrows(
- IllegalArgumentException.class, () ->
RedisTableConfig.of(config(map)));
- }
-
@Test
void multiTableBuildsEachItem() {
Map<String, Object> t1 = new HashMap<>();
diff --git
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-redis-e2e/src/test/java/org/apache/seatunnel/e2e/connector/redis/RedisValidationIT.java
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-redis-e2e/src/test/java/org/apache/seatunnel/e2e/connector/redis/RedisValidationIT.java
new file mode 100644
index 0000000000..f53064ac83
--- /dev/null
+++
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-redis-e2e/src/test/java/org/apache/seatunnel/e2e/connector/redis/RedisValidationIT.java
@@ -0,0 +1,58 @@
+/*
+ * 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.seatunnel.e2e.connector.redis;
+
+import org.apache.seatunnel.e2e.common.TestSuiteBase;
+import org.apache.seatunnel.e2e.common.container.TestContainer;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.TestTemplate;
+import org.testcontainers.containers.Container;
+
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.IOException;
+
+@Slf4j
+public class RedisValidationIT extends TestSuiteBase {
+
+ @TestTemplate
+ public void testSingleModePortOutOfRangeRejected(TestContainer container)
+ throws IOException, InterruptedException {
+ Container.ExecResult execResult =
+
container.executeJob("/redis-validation-single-port-out-of-range.conf");
+ Assertions.assertNotEquals(0, execResult.getExitCode());
+ String stderr = execResult.getStderr();
+ Assertions.assertNotNull(stderr, "stderr should not be null");
+ Assertions.assertTrue(
+ stderr.contains("Option validation failed") &&
stderr.contains("port"),
+ "stderr should report the failed port validation, but was: " +
stderr);
+ }
+
+ @TestTemplate
+ public void testClusterModeMalformedNodeRejected(TestContainer container)
+ throws IOException, InterruptedException {
+ Container.ExecResult execResult =
+
container.executeJob("/redis-validation-cluster-malformed-node.conf");
+ Assertions.assertNotEquals(0, execResult.getExitCode());
+ String stderr = execResult.getStderr();
+ Assertions.assertNotNull(stderr, "stderr should not be null");
+ Assertions.assertTrue(
+ stderr.contains("nodes") && stderr.contains("host:port"),
+ "stderr should report the failed nodes validation, but was: "
+ stderr);
+ }
+}
diff --git
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-redis-e2e/src/test/resources/redis-validation-cluster-malformed-node.conf
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-redis-e2e/src/test/resources/redis-validation-cluster-malformed-node.conf
new file mode 100644
index 0000000000..dd587c8353
--- /dev/null
+++
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-redis-e2e/src/test/resources/redis-validation-cluster-malformed-node.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.
+#
+
+# Negative case for declarative validation (#11007): cluster-mode `nodes`
contains a
+# malformed entry (no `host:port`), so the job must be rejected at submission
time by
+# RedisSourceFactory.optionRule() (RedisNodesValidator) before any connection
is
+# attempted. No Redis instance is required.
+
+env {
+ parallelism = 1
+ job.mode = "BATCH"
+}
+
+source {
+ Redis {
+ nodes = ["redis-cluster-0:6379", "host-without-port"]
+ mode = "CLUSTER"
+ auth = "SeaTunnel"
+ keys = "key_test*"
+ data_type = string
+ batch_size = 33
+ }
+}
+
+sink {
+ Redis {
+ nodes = ["redis-cluster-0:6379", "redis-cluster-1:6379"]
+ mode = "CLUSTER"
+ auth = "SeaTunnel"
+ key = "key_list"
+ data_type = list
+ batch_size = 33
+ }
+}
diff --git
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-redis-e2e/src/test/resources/redis-validation-single-port-out-of-range.conf
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-redis-e2e/src/test/resources/redis-validation-single-port-out-of-range.conf
new file mode 100644
index 0000000000..f021b6ab6e
--- /dev/null
+++
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-redis-e2e/src/test/resources/redis-validation-single-port-out-of-range.conf
@@ -0,0 +1,47 @@
+#
+# 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.
+#
+
+# Negative case for declarative validation (#11007): single-mode `port` is out
of the
+# [1, 65535] range, so the job must be rejected at submission time by
RedisSourceFactory
+# .optionRule() before any connection is attempted. No Redis instance is
required.
+
+env {
+ parallelism = 1
+ job.mode = "BATCH"
+}
+
+source {
+ Redis {
+ host = "redis-e2e"
+ port = 70000
+ auth = "U2VhVHVubmVs"
+ keys = "key_test*"
+ data_type = string
+ batch_size = 33
+ }
+}
+
+sink {
+ Redis {
+ host = "redis-e2e"
+ port = 6379
+ auth = "U2VhVHVubmVs"
+ key = "key_list"
+ data_type = list
+ batch_size = 33
+ }
+}