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 e8846b1011 [Fix][API] Fix validateSingleChoice and extension 
exceptions bypassing error aggregation (#11121)
e8846b1011 is described below

commit e8846b10112849a5e350ddbcecf9654c4a3f7099
Author: zhiwei.niu <[email protected]>
AuthorDate: Mon Jun 29 22:49:33 2026 +0800

    [Fix][API] Fix validateSingleChoice and extension exceptions bypassing 
error aggregation (#11121)
---
 .../api/configuration/util/ConditionExtension.java |  13 ++-
 .../api/configuration/util/ConfigValidator.java    |  99 ++++++++++------
 .../api/configuration/util/OptionUtil.java         |  13 ++-
 .../configuration/util/ConfigValidatorTest.java    | 127 ++++++++++++++++++++-
 4 files changed, 207 insertions(+), 45 deletions(-)

diff --git 
a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionExtension.java
 
b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionExtension.java
index 2048059678..df27000637 100644
--- 
a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionExtension.java
+++ 
b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionExtension.java
@@ -50,14 +50,19 @@ public interface ConditionExtension<T> {
      * Evaluates whether {@code value} passes this validation rule.
      *
      * <p>Return {@code false} for simple failure — the framework composes the 
error from {@link
-     * #description()} automatically. Throw {@link OptionValidationException} 
when a richer,
-     * context-specific message is needed. Avoid other unchecked exceptions — 
they propagate
-     * unwrapped.
+     * #description()} automatically and continues collecting other validation 
errors.
+     *
+     * <p>Throw {@link OptionValidationException} when a richer, 
context-specific message is needed.
+     * The framework catches the exception, extracts its message, and adds it 
to the aggregated
+     * error list — subsequent validations still run. Use this when you need a 
more descriptive
+     * error message than {@link #description()} alone provides.
+     *
+     * <p>Avoid other unchecked exceptions — they propagate unwrapped.
      *
      * @param config full configuration context (read-only), available for 
cross-field checks
      * @param value the resolved option value; may be {@code null}
      * @return {@code true} if valid
-     * @throws OptionValidationException for detailed error reporting
+     * @throws OptionValidationException for detailed, context-specific error 
reporting
      */
     boolean evaluate(ReadonlyConfig config, T value) throws 
OptionValidationException;
 }
diff --git 
a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConfigValidator.java
 
b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConfigValidator.java
index ae7e4450f9..42d1b1c7b9 100644
--- 
a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConfigValidator.java
+++ 
b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConfigValidator.java
@@ -30,18 +30,28 @@ import org.apache.commons.collections4.CollectionUtils;
 
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.stream.Collectors;
 
+import static 
org.apache.seatunnel.api.configuration.util.OptionUtil.formatError;
+import static 
org.apache.seatunnel.api.configuration.util.OptionUtil.formatOptionsError;
 import static 
org.apache.seatunnel.api.configuration.util.OptionUtil.getOptionKeys;
 
 public class ConfigValidator {
     private final ReadonlyConfig config;
 
+    /** Closed set of validation error categories used in formatted error 
messages. */
+    private static final String TYPE_REQUIRED = "required";
+
+    private static final String TYPE_VALUE = "value";
+    private static final String TYPE_BUNDLED = "bundled";
+    private static final String TYPE_EXCLUSIVE = "exclusive";
+    private static final String TYPE_CONDITIONAL = "conditional";
+    private static final String TYPE_SINGLE_CHOICE = "singleChoice";
+
     private static final Set<String> COMMON_KEYS = new HashSet<>();
 
     static {
@@ -191,14 +201,14 @@ public class ConfigValidator {
                                     
(RequiredOption.ConditionalRequiredOptions) requiredOption)) {
                         continue;
                     }
-                    validateSingleChoice(option);
+                    validateSingleChoice(option, errors);
                 }
             }
         }
 
         for (Option option : rule.getOptionalOptions()) {
             if (SingleChoiceOption.class.isAssignableFrom(option.getClass())) {
-                validateSingleChoice(option);
+                validateSingleChoice(option, errors);
             }
         }
 
@@ -212,11 +222,20 @@ public class ConfigValidator {
             if (structurallyAbsentKeys.contains(constraint.getOption().key())) 
{
                 continue;
             }
-            if (isConstraintApplicable(constraint, rule) && 
!validate(constraint)) {
+            if (!isConstraintApplicable(constraint, rule)) {
+                continue;
+            }
+            try {
+                if (!validate(constraint)) {
+                    errors.add(
+                            formatError(
+                                    constraint.getOption().key(),
+                                    TYPE_VALUE,
+                                    constraint.toString()));
+                }
+            } catch (OptionValidationException e) {
                 errors.add(
-                        String.format(
-                                "option: %s\n      type: value\n      
constraint: %s",
-                                constraint.getOption().key(), 
constraint.toString()));
+                        formatError(constraint.getOption().key(), TYPE_VALUE, 
e.getRawMessage()));
             }
         }
     }
@@ -308,29 +327,32 @@ public class ConfigValidator {
         return false;
     }
 
-    void validateSingleChoice(Option option) {
+    void validateSingleChoice(Option option, List<String> errors) {
         SingleChoiceOption singleChoiceOption = (SingleChoiceOption) option;
         List optionValues = singleChoiceOption.getOptionValues();
         if (CollectionUtils.isEmpty(optionValues)) {
-            throw new OptionValidationException(
-                    "These options(%s) are SingleChoiceOption, the 
optionValues must not be null.",
-                    
getOptionKeys(Collections.singletonList(singleChoiceOption)));
+            errors.add(
+                    formatError(
+                            option.key(), TYPE_SINGLE_CHOICE, "optionValues 
must not be empty"));
+            return;
         }
 
         Object o = singleChoiceOption.defaultValue();
         if (o != null && !optionValues.contains(o)) {
-            throw new OptionValidationException(
-                    "These options(%s) are SingleChoiceOption, the 
defaultValue(%s) must be one of the optionValues(%s).",
-                    
getOptionKeys(Collections.singletonList(singleChoiceOption)), o, optionValues);
+            errors.add(
+                    formatError(
+                            option.key(),
+                            TYPE_SINGLE_CHOICE,
+                            String.format("defaultValue(%s) must be one of 
%s", o, optionValues)));
         }
 
         Object value = config.get(option);
         if (value != null && !optionValues.contains(value)) {
-            throw new OptionValidationException(
-                    "These options(%s) are SingleChoiceOption, the value(%s) 
must be one of the optionValues(%s).",
-                    
getOptionKeys(Collections.singletonList(singleChoiceOption)),
-                    value,
-                    optionValues);
+            errors.add(
+                    formatError(
+                            option.key(),
+                            TYPE_SINGLE_CHOICE,
+                            String.format("value(%s) must be one of %s", 
value, optionValues)));
         }
     }
 
@@ -371,9 +393,10 @@ public class ConfigValidator {
             return null;
         }
         String hint = expression == null ? "" : " when [" + expression + "]";
-        return String.format(
-                "option: %s\n      type: required\n      constraint: required 
option is not configured%s",
-                getOptionKeys(absentOptions), hint);
+        return formatError(
+                getOptionKeys(absentOptions),
+                TYPE_REQUIRED,
+                "required option is not configured" + hint);
     }
 
     boolean hasOption(Option<?> option) {
@@ -394,9 +417,12 @@ public class ConfigValidator {
         if (present.size() == bundledOptions.size() || absent.size() == 
bundledOptions.size()) {
             return null;
         }
-        return String.format(
-                "options: %s\n      type: bundled\n      constraint: bundled 
options must be present or absent together (present: [%s], absent: [%s])",
-                getOptionKeys(bundledOptions), getOptionKeys(present), 
getOptionKeys(absent));
+        return formatOptionsError(
+                getOptionKeys(bundledOptions),
+                TYPE_BUNDLED,
+                String.format(
+                        "bundled options must be present or absent together 
(present: [%s], absent: [%s])",
+                        getOptionKeys(present), getOptionKeys(absent)));
     }
 
     String checkExclusive(RequiredOption.ExclusiveRequiredOptions 
exclusiveRequiredOptions) {
@@ -411,14 +437,17 @@ public class ConfigValidator {
             return null;
         }
         if (count == 0) {
-            return String.format(
-                    "options: %s\n      type: exclusive\n      constraint: 
exactly one option must be set, but none are configured",
-                    
getOptionKeys(exclusiveRequiredOptions.getExclusiveOptions()));
+            return formatOptionsError(
+                    
getOptionKeys(exclusiveRequiredOptions.getExclusiveOptions()),
+                    TYPE_EXCLUSIVE,
+                    "exactly one option must be set, but none are configured");
         }
-        return String.format(
-                "options: %s\n      type: exclusive\n      constraint: 
mutually exclusive, but multiple are set: [%s]",
+        return formatOptionsError(
                 getOptionKeys(exclusiveRequiredOptions.getExclusiveOptions()),
-                getOptionKeys(presentOptions));
+                TYPE_EXCLUSIVE,
+                String.format(
+                        "mutually exclusive, but multiple are set: [%s]",
+                        getOptionKeys(presentOptions)));
     }
 
     String checkConditional(RequiredOption.ConditionalRequiredOptions 
conditionalRequiredOptions) {
@@ -431,10 +460,12 @@ public class ConfigValidator {
         if (absentOptions.isEmpty()) {
             return null;
         }
-        return String.format(
-                "option: %s\n      type: conditional\n      constraint: 
required because [%s] is true",
+        return formatError(
                 getOptionKeys(absentOptions),
-                conditionalRequiredOptions.getExpression().toString());
+                TYPE_CONDITIONAL,
+                String.format(
+                        "required because [%s] is true",
+                        
conditionalRequiredOptions.getExpression().toString()));
     }
 
     private boolean validate(Expression expression) {
diff --git 
a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionUtil.java
 
b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionUtil.java
index 7a4a838e00..128d59a091 100644
--- 
a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionUtil.java
+++ 
b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionUtil.java
@@ -22,15 +22,18 @@ import 
org.apache.seatunnel.shade.org.apache.commons.lang3.StringUtils;
 
 import org.apache.seatunnel.api.configuration.Option;
 
+import lombok.experimental.UtilityClass;
+
 import java.lang.reflect.Field;
 import java.lang.reflect.Type;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 
+@UtilityClass
 public class OptionUtil {
 
-    private OptionUtil() {}
+    private static final String ERROR_TEMPLATE = "%s: %s\n      type: %s\n     
 constraint: %s";
 
     public static String getOptionKeys(List<Option<?>> options) {
         StringBuilder builder = new StringBuilder();
@@ -93,6 +96,14 @@ public class OptionUtil {
         return options;
     }
 
+    public static String formatError(String optionKey, String type, String 
constraint) {
+        return String.format(ERROR_TEMPLATE, "option", optionKey, type, 
constraint);
+    }
+
+    public static String formatOptionsError(String optionKeys, String type, 
String constraint) {
+        return String.format(ERROR_TEMPLATE, "options", optionKeys, type, 
constraint);
+    }
+
     private static String formatUnderScoreCase(String camel) {
         StringBuilder underScore =
                 new 
StringBuilder(String.valueOf(Character.toLowerCase(camel.charAt(0))));
diff --git 
a/seatunnel-api/src/test/java/org/apache/seatunnel/api/configuration/util/ConfigValidatorTest.java
 
b/seatunnel-api/src/test/java/org/apache/seatunnel/api/configuration/util/ConfigValidatorTest.java
index 2b859bb059..a0bcd46ff1 100644
--- 
a/seatunnel-api/src/test/java/org/apache/seatunnel/api/configuration/util/ConfigValidatorTest.java
+++ 
b/seatunnel-api/src/test/java/org/apache/seatunnel/api/configuration/util/ConfigValidatorTest.java
@@ -296,9 +296,13 @@ public class ConfigValidatorTest {
         Map<String, Object> config = new HashMap<>();
         config.put(SINGLE_CHOICE_TEST.key(), "A");
         Executable executable = () -> validate(config, optionRule);
-        assertEquals(
-                "ErrorCode:[API-02], ErrorDescription:[Option item validate 
failed] - These options('single_choice_test') are SingleChoiceOption, the 
defaultValue(M) must be one of the optionValues([A, B, C]).",
-                assertThrows(OptionValidationException.class, 
executable).getMessage());
+        OptionValidationException ex = 
assertThrows(OptionValidationException.class, executable);
+        String msg = ex.getMessage();
+        Assertions.assertTrue(
+                msg.contains("single_choice_test"), "Should mention option 
key: " + msg);
+        Assertions.assertTrue(
+                msg.contains("defaultValue(M) must be one of"),
+                "Should mention invalid defaultValue: " + msg);
     }
 
     @Test
@@ -311,9 +315,12 @@ public class ConfigValidatorTest {
 
         config.put(SINGLE_CHOICE_VALUE_TEST.key(), "N");
         executable = () -> validate(config, optionRule);
-        assertEquals(
-                "ErrorCode:[API-02], ErrorDescription:[Option item validate 
failed] - These options('single_choice_test') are SingleChoiceOption, the 
value(N) must be one of the optionValues([A, B, C]).",
-                assertThrows(OptionValidationException.class, 
executable).getMessage());
+        OptionValidationException ex = 
assertThrows(OptionValidationException.class, executable);
+        String msg = ex.getMessage();
+        Assertions.assertTrue(
+                msg.contains("single_choice_test"), "Should mention option 
key: " + msg);
+        Assertions.assertTrue(
+                msg.contains("value(N) must be one of"), "Should mention 
invalid value: " + msg);
     }
 
     @Test
@@ -3483,4 +3490,112 @@ public class ConfigValidatorTest {
         config5.put(TEST_TOPIC.key(), Collections.emptyList());
         assertThrows(OptionValidationException.class, () -> validate(config5, 
rule));
     }
+
+    // ==================== collectErrors contract tests ====================
+
+    @Test
+    public void testMultipleSingleChoiceErrorsCollected() {
+        Option<String> choice1 =
+                Options.key("mode1")
+                        .singleChoice(String.class, Arrays.asList("A", "B", 
"C"))
+                        .defaultValue("A")
+                        .withDescription("mode1");
+        Option<String> choice2 =
+                Options.key("mode2")
+                        .singleChoice(String.class, Arrays.asList("X", "Y", 
"Z"))
+                        .defaultValue("X")
+                        .withDescription("mode2");
+
+        OptionRule rule = OptionRule.builder().required(choice1, 
choice2).build();
+        Map<String, Object> config = new HashMap<>();
+        config.put("mode1", "INVALID1");
+        config.put("mode2", "INVALID2");
+
+        OptionValidationException ex =
+                assertThrows(OptionValidationException.class, () -> 
validate(config, rule));
+        String msg = ex.getMessage();
+        Assertions.assertTrue(msg.contains("mode1"), "Should report mode1 
error: " + msg);
+        Assertions.assertTrue(msg.contains("mode2"), "Should report mode2 
error: " + msg);
+        Assertions.assertTrue(
+                msg.contains("INVALID1") && msg.contains("INVALID2"),
+                "Should report both invalid values: " + msg);
+    }
+
+    @Test
+    public void testMixedErrorTypesAllCollected() {
+        Option<String> choice =
+                Options.key("format")
+                        .singleChoice(String.class, Arrays.asList("json", 
"csv", "avro"))
+                        .defaultValue("json")
+                        .withDescription("format");
+        Option<String> host =
+                
Options.key("host").stringType().noDefaultValue().withDescription("host");
+        Option<Integer> port =
+                
Options.key("port").intType().noDefaultValue().withDescription("port");
+
+        OptionRule rule =
+                OptionRule.builder()
+                        .required(choice, host)
+                        .required(port, greaterOrEqual(port, 1))
+                        .build();
+
+        Map<String, Object> config = new HashMap<>();
+        config.put("format", "xml");
+        config.put("port", 0);
+
+        OptionValidationException ex =
+                assertThrows(OptionValidationException.class, () -> 
validate(config, rule));
+        String msg = ex.getMessage();
+        Assertions.assertTrue(msg.contains("host"), "Should report missing 
host: " + msg);
+        Assertions.assertTrue(
+                msg.contains("format") && msg.contains("singleChoice"),
+                "Should report single_choice error: " + msg);
+        Assertions.assertTrue(
+                msg.contains("port") && msg.contains("value"),
+                "Should report value constraint error: " + msg);
+    }
+
+    @Test
+    public void testExtensionExceptionIsAggregatedNotFailFast() {
+        ConditionExtension<Integer> throwingExtension =
+                new ConditionExtension<Integer>() {
+                    @Override
+                    public String description() {
+                        return "must be positive";
+                    }
+
+                    @Override
+                    public boolean evaluate(ReadonlyConfig config, Integer 
value)
+                            throws OptionValidationException {
+                        if (value != null && value <= 0) {
+                            throw new OptionValidationException(
+                                    "port value %d is not positive", value);
+                        }
+                        return true;
+                    }
+                };
+
+        OptionRule rule =
+                OptionRule.builder()
+                        .required(HOST, notBlank(HOST))
+                        .required(PORT, Conditions.extension(PORT, 
throwingExtension))
+                        .build();
+
+        Map<String, Object> config = new HashMap<>();
+        config.put(HOST.key(), "");
+        config.put(PORT.key(), -1);
+
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        String msg = ex.getMessage();
+        Assertions.assertTrue(msg.contains("2 errors"), "both errors should be 
aggregated: " + msg);
+        Assertions.assertTrue(
+                msg.contains("host") && msg.contains("is not blank"),
+                "host notBlank error should appear: " + msg);
+        Assertions.assertTrue(
+                msg.contains("port value -1 is not positive"),
+                "extension exception message should be preserved: " + msg);
+    }
 }

Reply via email to