From 2ca59ce3b1c8f053a8a41631a8dbaf73cbb8d2aa Mon Sep 17 00:00:00 2001 From: "zhiwei.niu" Date: Fri, 29 May 2026 13:27:49 +0800 Subject: [PATCH 01/16] [Feature][API] Enhance OptionRule with declarative value constraints --- .../api/configuration/util/Condition.java | 220 +++- .../util/ConditionEvaluators.java | 229 +++++ .../configuration/util/ConditionOperator.java | 127 +++ .../configuration/util/ConfigValidator.java | 51 +- .../api/configuration/util/OptionRule.java | 158 ++- .../util/ConfigValidatorTest.java | 956 ++++++++++++++++++ .../command/MetadataExportCommand.java | 21 +- .../rest/response/OptionRuleResponse.java | 36 + .../rest/service/OptionRulesService.java | 32 +- 9 files changed, 1801 insertions(+), 29 deletions(-) create mode 100644 seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java create mode 100644 seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionOperator.java diff --git a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Condition.java b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Condition.java index 5a780685c22b..7517d23eb068 100644 --- a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Condition.java +++ b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Condition.java @@ -24,18 +24,180 @@ public class Condition { private final Option option; private final T expectValue; + private final ConditionOperator operator; + private final Option compareOption; private Boolean and = null; private Condition next = null; Condition(Option option, T expectValue) { + this(option, ConditionOperator.EQUAL, expectValue, null); + } + + Condition( + Option option, ConditionOperator operator, T expectValue, Option compareOption) { + if (option == null) { + throw new IllegalArgumentException("Condition option must not be null"); + } + if (operator == null) { + throw new IllegalArgumentException("Condition operator must not be null"); + } + if (operator.getSource() == ConditionOperator.Source.FIELD && compareOption == null) { + throw new IllegalArgumentException( + String.format( + "Operator %s requires a compareOption (cross-field comparison), but compareOption is null", + operator.name())); + } + if (operator.getArity() == ConditionOperator.Arity.BINARY + && operator.getSource() == ConditionOperator.Source.LITERAL + && expectValue == null) { + throw new IllegalArgumentException( + String.format( + "Operator %s requires an expectValue, but expectValue is null", + operator.name())); + } this.option = option; + this.operator = operator; this.expectValue = expectValue; + this.compareOption = compareOption; } + // ==================== Equality (backward-compatible) ==================== + public static Condition of(Option option, T expectValue) { return new Condition<>(option, expectValue); } + public static Condition of(Option option, ConditionOperator op, T expectValue) { + return new Condition<>(option, op, expectValue, null); + } + + // ==================== Numeric comparison ==================== + + public static Condition greaterThan(Option option, T value) { + return new Condition<>(option, ConditionOperator.GREATER_THAN, value, null); + } + + public static Condition greaterOrEqual(Option option, T value) { + return new Condition<>(option, ConditionOperator.GREATER_OR_EQUAL, value, null); + } + + public static Condition lessThan(Option option, T value) { + return new Condition<>(option, ConditionOperator.LESS_THAN, value, null); + } + + public static Condition lessOrEqual(Option option, T value) { + return new Condition<>(option, ConditionOperator.LESS_OR_EQUAL, value, null); + } + + // ==================== String validation ==================== + + public static Condition notBlank(Option option) { + return new Condition<>(option, ConditionOperator.NOT_BLANK, null, null); + } + + public static Condition startsWith(Option option, T prefix) { + return new Condition<>(option, ConditionOperator.STARTS_WITH, prefix, null); + } + + public static Condition startsWithIgnoreCase(Option option, String prefix) { + return new Condition<>(option, ConditionOperator.STARTS_WITH_IGNORE_CASE, prefix, null); + } + + public static Condition contains(Option option, T substring) { + return new Condition<>(option, ConditionOperator.CONTAINS, substring, null); + } + + public static Condition matches(Option option, T regex) { + return new Condition<>(option, ConditionOperator.MATCHES, regex, null); + } + + public static Condition upperCase(Option option) { + return new Condition<>(option, ConditionOperator.UPPER_CASE, null, null); + } + + public static Condition lowerCase(Option option) { + return new Condition<>(option, ConditionOperator.LOWER_CASE, null, null); + } + + // ==================== String length ==================== + + public static Condition lengthEqual(Option option, int length) { + return new Condition(option, ConditionOperator.LENGTH_EQUAL, length, null); + } + + public static Condition lengthGreaterOrEqual(Option option, int length) { + return new Condition(option, ConditionOperator.LENGTH_GREATER_OR_EQUAL, length, null); + } + + public static Condition lengthLessOrEqual(Option option, int length) { + return new Condition(option, ConditionOperator.LENGTH_LESS_OR_EQUAL, length, null); + } + + // ==================== String suffix ==================== + + public static Condition endsWith(Option option, T suffix) { + return new Condition<>(option, ConditionOperator.ENDS_WITH, suffix, null); + } + + public static Condition endsWithIgnoreCase(Option option, String suffix) { + return new Condition<>(option, ConditionOperator.ENDS_WITH_IGNORE_CASE, suffix, null); + } + + // ==================== Collection validation ==================== + + public static Condition notEmpty(Option option) { + return new Condition<>(option, ConditionOperator.NOT_EMPTY, null, null); + } + + public static Condition unique(Option option) { + return new Condition<>(option, ConditionOperator.COLLECTION_UNIQUE, null, null); + } + + public static Condition sizeEqual(Option option, int size) { + return new Condition(option, ConditionOperator.COLLECTION_SIZE_EQUAL, size, null); + } + + public static Condition sizeGreaterOrEqual(Option option, int size) { + return new Condition( + option, ConditionOperator.COLLECTION_SIZE_GREATER_OR_EQUAL, size, null); + } + + public static Condition sizeLessOrEqual(Option option, int size) { + return new Condition(option, ConditionOperator.COLLECTION_SIZE_LESS_OR_EQUAL, size, null); + } + + // ==================== Cross-field comparison ==================== + + public static Condition lessThanField(Option option, Option other) { + return new Condition<>(option, ConditionOperator.FIELD_LESS_THAN, null, other); + } + + public static Condition lessOrEqualField(Option option, Option other) { + return new Condition<>(option, ConditionOperator.FIELD_LESS_OR_EQUAL, null, other); + } + + public static Condition greaterThanField(Option option, Option other) { + return new Condition<>(option, ConditionOperator.FIELD_GREATER_THAN, null, other); + } + + public static Condition greaterOrEqualField(Option option, Option other) { + return new Condition<>(option, ConditionOperator.FIELD_GREATER_OR_EQUAL, null, other); + } + + public static Condition equalField(Option option, Option other) { + return new Condition<>(option, ConditionOperator.FIELD_EQUAL, null, other); + } + + public static Condition notEqualField(Option option, Option other) { + return new Condition<>(option, ConditionOperator.FIELD_NOT_EQUAL, null, other); + } + + public static Condition sizeEqualField(Option option, Option other) { + return new Condition<>(option, ConditionOperator.FIELD_SIZE_EQUAL, null, other); + } + + // ==================== Chain operations (existing API, unchanged) ==================== + public Condition and(Option option, E expectValue) { return and(of(option, expectValue)); } @@ -55,6 +217,21 @@ public Condition or(Condition next) { } private void addCondition(boolean and, Condition next) { + // Check: next chain must not contain any node already in this chain + Condition cur = next; + while (cur != null) { + Condition self = this; + while (self != null) { + if (self == cur) { + throw new IllegalArgumentException( + "Circular condition chain detected: '" + + cur.option.key() + + "' already exists in the chain"); + } + self = self.next; + } + cur = cur.next; + } Condition tail = getTailCondition(); tail.and = and; tail.next = next; @@ -74,6 +251,8 @@ Condition getTailCondition() { return hasNext() ? this.next.getTailCondition() : this; } + // ==================== Accessors ==================== + public boolean hasNext() { return this.next != null; } @@ -90,10 +269,20 @@ public T getExpectValue() { return expectValue; } + public ConditionOperator getOperator() { + return operator; + } + + public Option getCompareOption() { + return compareOption; + } + public Boolean and() { return this.and; } + // ==================== equals / hashCode ==================== + @Override public boolean equals(Object obj) { if (this == obj) { @@ -105,26 +294,32 @@ public boolean equals(Object obj) { Condition that = (Condition) obj; return Objects.equals(this.option, that.option) && Objects.equals(this.expectValue, that.expectValue) + && Objects.equals(this.operator, that.operator) + && Objects.equals(this.compareOption, that.compareOption) && Objects.equals(this.and, that.and) && Objects.equals(this.next, that.next); } @Override public int hashCode() { - return Objects.hash(this.option, this.expectValue, this.and, this.next); + return Objects.hash( + this.option, + this.expectValue, + this.operator, + this.compareOption, + this.and, + this.next); } + // ==================== toString ==================== + @Override public String toString() { Condition cur = this; StringBuilder builder = new StringBuilder(); boolean bracket = false; do { - builder.append("'") - .append(cur.option.key()) - // TODO: support another condition - .append("' == ") - .append(cur.expectValue); + builder.append(conditionToString(cur)); if (bracket) { builder = new StringBuilder(String.format("(%s)", builder)); bracket = false; @@ -139,4 +334,17 @@ public String toString() { } while (cur != null); return builder.toString(); } + + private static String conditionToString(Condition cond) { + ConditionOperator op = cond.operator; + String key = "'" + cond.option.key() + "'"; + + if (op.getSource() == ConditionOperator.Source.FIELD) { + return key + " " + op.getDisplaySymbol() + " '" + cond.compareOption.key() + "'"; + } + if (op.getArity() == ConditionOperator.Arity.UNARY) { + return key + " " + op.getSymbol(); + } + return key + " " + op.getDisplaySymbol() + " " + cond.expectValue; + } } diff --git a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java new file mode 100644 index 000000000000..7d73af675243 --- /dev/null +++ b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java @@ -0,0 +1,229 @@ +/* + * 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.api.configuration.util; + +import org.apache.seatunnel.api.configuration.ReadonlyConfig; + +import java.util.Collection; +import java.util.Collections; +import java.util.EnumMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; + +/** + * Registry of per-{@link ConditionOperator} evaluation logic. Stateless; every evaluator is a pure + * function of (value, condition, config). + */ +public final class ConditionEvaluators { + + @FunctionalInterface + interface Evaluator { + boolean evaluate(Object value, Condition condition, ReadonlyConfig config); + } + + private static final Map REGISTRY = createRegistry(); + + static boolean evaluate(Condition condition, ReadonlyConfig config) { + ConditionOperator operator = condition.getOperator(); + if (operator == null) { + throw new OptionValidationException( + "Condition for option '%s' has a null operator", condition.getOption().key()); + } + Object value = config.get(condition.getOption()); + Evaluator evaluator = REGISTRY.get(operator); + return evaluator.evaluate(value, condition, config); + } + + @SuppressWarnings({"rawtypes"}) + private static Map createRegistry() { + Map m = new EnumMap<>(ConditionOperator.class); + + // Equality + m.put(ConditionOperator.EQUAL, (v, c, cfg) -> Objects.equals(c.getExpectValue(), v)); + m.put(ConditionOperator.NOT_EQUAL, (v, c, cfg) -> !Objects.equals(c.getExpectValue(), v)); + + // Numeric + m.put( + ConditionOperator.GREATER_THAN, + (v, c, cfg) -> compareNumbers(v, c.getExpectValue()) > 0); + m.put( + ConditionOperator.GREATER_OR_EQUAL, + (v, c, cfg) -> compareNumbers(v, c.getExpectValue()) >= 0); + m.put( + ConditionOperator.LESS_THAN, + (v, c, cfg) -> compareNumbers(v, c.getExpectValue()) < 0); + m.put( + ConditionOperator.LESS_OR_EQUAL, + (v, c, cfg) -> compareNumbers(v, c.getExpectValue()) <= 0); + + // String + m.put( + ConditionOperator.NOT_BLANK, + (v, c, cfg) -> v instanceof String && !((String) v).trim().isEmpty()); + m.put( + ConditionOperator.STARTS_WITH, + (v, c, cfg) -> + v instanceof String + && ((String) v).startsWith(String.valueOf(c.getExpectValue()))); + m.put( + ConditionOperator.STARTS_WITH_IGNORE_CASE, + (v, c, cfg) -> + v instanceof String + && ((String) v) + .toLowerCase() + .startsWith( + String.valueOf(c.getExpectValue()).toLowerCase())); + m.put( + ConditionOperator.CONTAINS, + (v, c, cfg) -> + v instanceof String + && ((String) v).contains(String.valueOf(c.getExpectValue()))); + m.put( + ConditionOperator.MATCHES, + (v, c, cfg) -> + v instanceof String + && ((String) v).matches(String.valueOf(c.getExpectValue()))); + m.put( + ConditionOperator.UPPER_CASE, + (v, c, cfg) -> v instanceof String && v.equals(((String) v).toUpperCase())); + m.put( + ConditionOperator.LOWER_CASE, + (v, c, cfg) -> v instanceof String && v.equals(((String) v).toLowerCase())); + + // String length + m.put( + ConditionOperator.LENGTH_EQUAL, + (v, c, cfg) -> + v instanceof String + && ((String) v).length() + == ((Number) c.getExpectValue()).intValue()); + m.put( + ConditionOperator.LENGTH_GREATER_OR_EQUAL, + (v, c, cfg) -> + v instanceof String + && ((String) v).length() + >= ((Number) c.getExpectValue()).intValue()); + m.put( + ConditionOperator.LENGTH_LESS_OR_EQUAL, + (v, c, cfg) -> + v instanceof String + && ((String) v).length() + <= ((Number) c.getExpectValue()).intValue()); + + // String suffix + m.put( + ConditionOperator.ENDS_WITH, + (v, c, cfg) -> + v instanceof String + && ((String) v).endsWith(String.valueOf(c.getExpectValue()))); + m.put( + ConditionOperator.ENDS_WITH_IGNORE_CASE, + (v, c, cfg) -> + v instanceof String + && ((String) v) + .toLowerCase() + .endsWith( + String.valueOf(c.getExpectValue()).toLowerCase())); + + // Collection + m.put( + ConditionOperator.NOT_EMPTY, + (v, c, cfg) -> v instanceof Collection && !((Collection) v).isEmpty()); + m.put( + ConditionOperator.COLLECTION_UNIQUE, + (v, c, cfg) -> { + if (v instanceof Collection) { + Collection col = (Collection) v; + return col.size() == new HashSet<>(col).size(); + } + return false; + }); + m.put( + ConditionOperator.COLLECTION_SIZE_EQUAL, + (v, c, cfg) -> + v instanceof Collection + && ((Collection) v).size() + == ((Number) c.getExpectValue()).intValue()); + m.put( + ConditionOperator.COLLECTION_SIZE_GREATER_OR_EQUAL, + (v, c, cfg) -> + v instanceof Collection + && ((Collection) v).size() + >= ((Number) c.getExpectValue()).intValue()); + m.put( + ConditionOperator.COLLECTION_SIZE_LESS_OR_EQUAL, + (v, c, cfg) -> + v instanceof Collection + && ((Collection) v).size() + <= ((Number) c.getExpectValue()).intValue()); + + // Cross-field + m.put( + ConditionOperator.FIELD_LESS_THAN, + (v, c, cfg) -> compareNumbers(v, cfg.get(c.getCompareOption())) < 0); + m.put( + ConditionOperator.FIELD_LESS_OR_EQUAL, + (v, c, cfg) -> compareNumbers(v, cfg.get(c.getCompareOption())) <= 0); + m.put( + ConditionOperator.FIELD_GREATER_THAN, + (v, c, cfg) -> compareNumbers(v, cfg.get(c.getCompareOption())) > 0); + m.put( + ConditionOperator.FIELD_GREATER_OR_EQUAL, + (v, c, cfg) -> compareNumbers(v, cfg.get(c.getCompareOption())) >= 0); + m.put( + ConditionOperator.FIELD_EQUAL, + (v, c, cfg) -> Objects.equals(v, cfg.get(c.getCompareOption()))); + m.put( + ConditionOperator.FIELD_NOT_EQUAL, + (v, c, cfg) -> !Objects.equals(v, cfg.get(c.getCompareOption()))); + m.put( + ConditionOperator.FIELD_SIZE_EQUAL, + (v, c, cfg) -> { + Object other = cfg.get(c.getCompareOption()); + if (v instanceof Collection && other instanceof Collection) { + return ((Collection) v).size() == ((Collection) other).size(); + } + return false; + }); + + for (ConditionOperator op : ConditionOperator.values()) { + if (!m.containsKey(op)) { + throw new IllegalStateException( + "Missing evaluator for ConditionOperator." + op.name()); + } + } + return Collections.unmodifiableMap(m); + } + + @SuppressWarnings({"rawtypes"}) + static int compareNumbers(Object a, Object b) { + if (a == null || b == null) { + throw new OptionValidationException("Cannot compare null values in numeric comparison"); + } + if (a instanceof Number && b instanceof Number) { + return Double.compare(((Number) a).doubleValue(), ((Number) b).doubleValue()); + } + if (a instanceof Comparable && b instanceof Comparable) { + return ((Comparable) a).compareTo(b); + } + throw new OptionValidationException( + "Cannot compare values of type %s and %s", + a.getClass().getName(), b.getClass().getName()); + } +} diff --git a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionOperator.java b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionOperator.java new file mode 100644 index 000000000000..5331c5ba3e2a --- /dev/null +++ b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionOperator.java @@ -0,0 +1,127 @@ +/* + * 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.api.configuration.util; + +public enum ConditionOperator { + EQUAL("==", Category.EQUALITY, Arity.BINARY, Source.LITERAL, "=="), + NOT_EQUAL("!=", Category.EQUALITY, Arity.BINARY, Source.LITERAL, "!="), + + GREATER_THAN(">", Category.NUMERIC, Arity.BINARY, Source.LITERAL, ">"), + GREATER_OR_EQUAL(">=", Category.NUMERIC, Arity.BINARY, Source.LITERAL, ">="), + LESS_THAN("<", Category.NUMERIC, Arity.BINARY, Source.LITERAL, "<"), + LESS_OR_EQUAL("<=", Category.NUMERIC, Arity.BINARY, Source.LITERAL, "<="), + + NOT_BLANK("is not blank", Category.STRING, Arity.UNARY, Source.LITERAL, null), + STARTS_WITH("starts with", Category.STRING, Arity.BINARY, Source.LITERAL, "starts with"), + STARTS_WITH_IGNORE_CASE( + "starts with (ignore case)", + Category.STRING, + Arity.BINARY, + Source.LITERAL, + "starts with (ignore case)"), + CONTAINS("contains", Category.STRING, Arity.BINARY, Source.LITERAL, "contains"), + MATCHES("matches", Category.STRING, Arity.BINARY, Source.LITERAL, "matches"), + UPPER_CASE("is uppercase", Category.STRING, Arity.UNARY, Source.LITERAL, null), + LOWER_CASE("is lowercase", Category.STRING, Arity.UNARY, Source.LITERAL, null), + ENDS_WITH("ends with", Category.STRING, Arity.BINARY, Source.LITERAL, "ends with"), + ENDS_WITH_IGNORE_CASE( + "ends with (ignore case)", + Category.STRING, + Arity.BINARY, + Source.LITERAL, + "ends with (ignore case)"), + + LENGTH_EQUAL("length ==", Category.STRING_LENGTH, Arity.BINARY, Source.LITERAL, "length =="), + LENGTH_GREATER_OR_EQUAL( + "length >=", Category.STRING_LENGTH, Arity.BINARY, Source.LITERAL, "length >="), + LENGTH_LESS_OR_EQUAL( + "length <=", Category.STRING_LENGTH, Arity.BINARY, Source.LITERAL, "length <="), + + NOT_EMPTY("is not empty", Category.COLLECTION, Arity.UNARY, Source.LITERAL, null), + COLLECTION_UNIQUE( + "has unique elements", Category.COLLECTION, Arity.UNARY, Source.LITERAL, null), + COLLECTION_SIZE_EQUAL( + "size ==", Category.COLLECTION_SIZE, Arity.BINARY, Source.LITERAL, "size =="), + COLLECTION_SIZE_GREATER_OR_EQUAL( + "size >=", Category.COLLECTION_SIZE, Arity.BINARY, Source.LITERAL, "size >="), + COLLECTION_SIZE_LESS_OR_EQUAL( + "size <=", Category.COLLECTION_SIZE, Arity.BINARY, Source.LITERAL, "size <="), + + FIELD_LESS_THAN("< [field]", Category.NUMERIC, Arity.BINARY, Source.FIELD, "<"), + FIELD_LESS_OR_EQUAL("<= [field]", Category.NUMERIC, Arity.BINARY, Source.FIELD, "<="), + FIELD_GREATER_THAN("> [field]", Category.NUMERIC, Arity.BINARY, Source.FIELD, ">"), + FIELD_GREATER_OR_EQUAL(">= [field]", Category.NUMERIC, Arity.BINARY, Source.FIELD, ">="), + FIELD_EQUAL("== [field]", Category.EQUALITY, Arity.BINARY, Source.FIELD, "=="), + FIELD_NOT_EQUAL("!= [field]", Category.EQUALITY, Arity.BINARY, Source.FIELD, "!="), + FIELD_SIZE_EQUAL( + "size == [field]", Category.COLLECTION_SIZE, Arity.BINARY, Source.FIELD, "size =="); + + public enum Category { + EQUALITY, + NUMERIC, + STRING, + STRING_LENGTH, + COLLECTION, + COLLECTION_SIZE + } + + public enum Arity { + UNARY, + BINARY + } + + public enum Source { + LITERAL, + FIELD + } + + private final String symbol; + private final Category category; + private final Arity arity; + private final Source source; + private final String displaySymbol; + + ConditionOperator( + String symbol, Category category, Arity arity, Source source, String displaySymbol) { + this.symbol = symbol; + this.category = category; + this.arity = arity; + this.source = source; + this.displaySymbol = displaySymbol; + } + + public String getSymbol() { + return symbol; + } + + public Category getCategory() { + return category; + } + + public Arity getArity() { + return arity; + } + + public Source getSource() { + return source; + } + + public String getDisplaySymbol() { + return displaySymbol; + } +} 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 25d4d03655a0..b2ad4093f36d 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,10 +30,10 @@ 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.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -132,7 +132,7 @@ private static Set collectDeclaredKeys(OptionRule rule) { private static void collectKeys(Set keys, List> options) { for (Option option : options) { keys.add(option.key()); - option.getFallbackKeys().forEach(keys::add); + keys.addAll(option.getFallbackKeys()); } } @@ -170,6 +170,33 @@ public void validate(OptionRule rule, Expression expression) { validate(conditionRule.getOptionRule(), conditionRule.getExpression()); } } + + validateValueConstraints(rule); + } + + private void validateValueConstraints(OptionRule rule) { + for (Condition constraint : rule.getValueConstraints()) { + if (shouldValidate(constraint, rule)) { + if (!validate(constraint)) { + throw new OptionValidationException( + "Option validation failed: %s", constraint.toString()); + } + } + } + } + + private boolean shouldValidate(Condition condition, OptionRule rule) { + Option option = condition.getOption(); + if (hasOption(option)) { + return true; + } + for (RequiredOption requiredOption : rule.getRequiredOptions()) { + if (requiredOption instanceof RequiredOption.AbsolutelyRequiredOptions + && requiredOption.getOptions().contains(option)) { + return true; + } + } + return false; } void validateSingleChoice(Option option) { @@ -178,21 +205,23 @@ void validateSingleChoice(Option option) { if (CollectionUtils.isEmpty(optionValues)) { throw new OptionValidationException( "These options(%s) are SingleChoiceOption, the optionValues must not be null.", - getOptionKeys(Arrays.asList(singleChoiceOption))); + getOptionKeys(Collections.singletonList(singleChoiceOption))); } 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(Arrays.asList(singleChoiceOption)), o, optionValues); + getOptionKeys(Collections.singletonList(singleChoiceOption)), 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(Arrays.asList(singleChoiceOption)), value, optionValues); + getOptionKeys(Collections.singletonList(singleChoiceOption)), + value, + optionValues); } } @@ -291,11 +320,9 @@ void validate(RequiredOption.ExclusiveRequiredOptions exclusiveRequiredOptions) "There are unconfigured options, these options(%s) are mutually exclusive, allowing only one set(\"[] for a set\") of options to be configured.", getOptionKeys(exclusiveRequiredOptions.getExclusiveOptions())); } - if (count > 1) { - throw new OptionValidationException( - "These options(%s) are mutually exclusive, allowing only one set(\"[] for a set\") of options to be configured.", - getOptionKeys(presentOptions)); - } + throw new OptionValidationException( + "These options(%s) are mutually exclusive, allowing only one set(\"[] for a set\") of options to be configured.", + getOptionKeys(presentOptions)); } void validate(RequiredOption.ConditionalRequiredOptions conditionalRequiredOptions) { @@ -328,9 +355,7 @@ private boolean validate(Expression expression) { } private boolean validate(Condition condition) { - Option option = condition.getOption(); - - boolean match = Objects.equals(condition.getExpectValue(), config.get(option)); + boolean match = ConditionEvaluators.evaluate(condition, config); if (!condition.hasNext()) { return match; } diff --git a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionRule.java b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionRule.java index 048f3015ec6f..9f57404a0588 100644 --- a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionRule.java +++ b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionRule.java @@ -86,13 +86,24 @@ public class OptionRule { private final List conditionRules; + private final List> valueConstraints; + OptionRule( List> optionalOptions, List requiredOptions, List conditionRules) { + this(optionalOptions, requiredOptions, conditionRules, Collections.emptyList()); + } + + OptionRule( + List> optionalOptions, + List requiredOptions, + List conditionRules, + List> valueConstraints) { this.optionalOptions = optionalOptions; this.requiredOptions = requiredOptions; this.conditionRules = conditionRules; + this.valueConstraints = valueConstraints; } public List> getOptionalOptions() { @@ -107,10 +118,15 @@ public List getConditionRules() { return conditionRules; } + public List> getValueConstraints() { + return valueConstraints; + } + private boolean hasOptions() { return !(optionalOptions.isEmpty() && requiredOptions.isEmpty() - && conditionRules.isEmpty()); + && conditionRules.isEmpty() + && valueConstraints.isEmpty()); } @Override @@ -124,12 +140,13 @@ public boolean equals(Object o) { OptionRule that = (OptionRule) o; return Objects.equals(optionalOptions, that.optionalOptions) && Objects.equals(requiredOptions, that.requiredOptions) - && Objects.equals(conditionRules, that.conditionRules); + && Objects.equals(conditionRules, that.conditionRules) + && Objects.equals(valueConstraints, that.valueConstraints); } @Override public int hashCode() { - return Objects.hash(optionalOptions, requiredOptions, conditionRules); + return Objects.hash(optionalOptions, requiredOptions, conditionRules, valueConstraints); } public static OptionRule.Builder builder() { @@ -141,6 +158,7 @@ public static class Builder { private final List> optionalOptions = new ArrayList<>(); private final List requiredOptions = new ArrayList<>(); private final Map conditionRulesMap = new HashMap<>(); + private final List> valueConstraints = new ArrayList<>(); private Builder() {} @@ -194,7 +212,6 @@ public Builder conditional( conditionalOption.key())); } - /** Each parameter can only be controlled by one other parameter */ Expression expression = expectValues.stream() .map(v -> Expression.of(Condition.of(conditionalOption, v))) @@ -215,7 +232,6 @@ public Builder conditional( @NonNull Option... requiredOptions) { verifyConditionalExists(conditionalOption); - /** Each parameter can only be controlled by one other parameter */ Expression expression = Expression.of(Condition.of(conditionalOption, expectValue)); RequiredOption.ConditionalRequiredOptions conditionalRequiredOption = RequiredOption.ConditionalRequiredOptions.of( @@ -281,12 +297,142 @@ public Builder conditionalRule( conditionalOptionRule); } + /** Absolutely required option with value constraints (at least one condition). */ + public Builder required( + @NonNull Option option, + @NonNull Condition firstCondition, + @NonNull Condition... moreConditions) { + RequiredOption.AbsolutelyRequiredOptions requiredOption = + RequiredOption.AbsolutelyRequiredOptions.of(option); + verifyRequiredOptionDuplicate(requiredOption); + this.requiredOptions.add(requiredOption); + this.valueConstraints.add(firstCondition); + Collections.addAll(this.valueConstraints, moreConditions); + return this; + } + + /** + * Multiple absolutely required options with value constraints. The first Condition + * parameter is explicit (non-varargs) so that {@code required(opt1, opt2)} still + * unambiguously resolves to {@link #required(Option[])}. + */ + public Builder required( + @NonNull Option option1, + @NonNull Option option2, + @NonNull Condition firstCondition, + @NonNull Condition... moreConditions) { + RequiredOption.AbsolutelyRequiredOptions requiredOption = + RequiredOption.AbsolutelyRequiredOptions.of(option1, option2); + verifyRequiredOptionDuplicate(requiredOption); + this.requiredOptions.add(requiredOption); + this.valueConstraints.add(firstCondition); + Collections.addAll(this.valueConstraints, moreConditions); + return this; + } + + /** Optional option with value constraints (at least one condition). */ + public Builder optional( + @NonNull Option option, + @NonNull Condition firstCondition, + @NonNull Condition... moreConditions) { + verifyOptionOptionsDuplicate(option, "OptionsOption"); + this.optionalOptions.add(option); + this.valueConstraints.add(firstCondition); + Collections.addAll(this.valueConstraints, moreConditions); + return this; + } + + /** Multiple optional options with value constraints. */ + public Builder optional( + @NonNull Option option1, + @NonNull Option option2, + @NonNull Condition firstCondition, + @NonNull Condition... moreConditions) { + verifyOptionOptionsDuplicate(option1, "OptionsOption"); + verifyOptionOptionsDuplicate(option2, "OptionsOption"); + this.optionalOptions.add(option1); + this.optionalOptions.add(option2); + this.valueConstraints.add(firstCondition); + Collections.addAll(this.valueConstraints, moreConditions); + return this; + } + + /** + * Conditional value constraints: when {@code conditionalOption == expectValue}, the given + * conditions must hold. + */ + public Builder conditional( + @NonNull Option conditionalOption, + @NonNull T expectValue, + @NonNull Condition firstCondition, + @NonNull Condition... moreConditions) { + verifyConditionalExists(conditionalOption); + Expression expression = Expression.of(Condition.of(conditionalOption, expectValue)); + List> allConditions = new ArrayList<>(); + allConditions.add(firstCondition); + Collections.addAll(allConditions, moreConditions); + mergeConditionalRule(expression, Collections.emptyList(), allConditions); + return this; + } + + /** + * Conditional multi-field: when {@code conditionalOption == expectValue}, the given options + * are conditionally required and the conditions must hold. + */ + public Builder conditional( + @NonNull Option conditionalOption, + @NonNull T expectValue, + @NonNull Option requiredOption1, + @NonNull Option requiredOption2, + @NonNull Condition firstCondition, + @NonNull Condition... moreConditions) { + verifyConditionalExists(conditionalOption); + Expression expression = Expression.of(Condition.of(conditionalOption, expectValue)); + List> allConditions = new ArrayList<>(); + allConditions.add(firstCondition); + Collections.addAll(allConditions, moreConditions); + List reqList = new ArrayList<>(); + reqList.add( + RequiredOption.AbsolutelyRequiredOptions.of(requiredOption1, requiredOption2)); + mergeConditionalRule(expression, reqList, allConditions); + return this; + } + + private void mergeConditionalRule( + Expression expression, + List newRequired, + List> newConditions) { + if (conditionRulesMap.containsKey(expression)) { + OptionRule existing = conditionRulesMap.get(expression); + List mergedReq = new ArrayList<>(existing.getRequiredOptions()); + mergedReq.addAll(newRequired); + List> mergedCond = new ArrayList<>(existing.getValueConstraints()); + mergedCond.addAll(newConditions); + conditionRulesMap.put( + expression, + new OptionRule( + existing.getOptionalOptions(), + mergedReq, + existing.getConditionRules(), + mergedCond)); + } else { + conditionRulesMap.put( + expression, + new OptionRule( + Collections.emptyList(), + newRequired, + Collections.emptyList(), + newConditions)); + } + } + public OptionRule build() { List conditionRuleList = conditionRulesMap.entrySet().stream() .map(e -> new ConditionRule(e.getKey(), e.getValue())) .collect(Collectors.toList()); - return new OptionRule(optionalOptions, requiredOptions, conditionRuleList); + return new OptionRule( + optionalOptions, requiredOptions, conditionRuleList, valueConstraints); } private void verifyRequiredOptionDefaultValue(@NonNull Option option) { 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 23fe6b0c663c..6a838c62bf24 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 @@ -27,7 +27,9 @@ import org.junit.jupiter.api.function.Executable; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import static org.apache.seatunnel.api.configuration.OptionTest.TEST_MODE; @@ -405,4 +407,958 @@ public void testMultipleValueNestedRule() { "ErrorCode:[API-02], ErrorDescription:[Option item validate failed] - There are unconfigured options, the options('username', 'password') are required when ['single_choice_test' == A || 'single_choice_test' == B].", assertThrows(OptionValidationException.class, executable).getMessage()); } + + // ==================== Validation Rule Tests ==================== + + public static final Option PORT = + Options.key("port").intType().noDefaultValue().withDescription("port number"); + + public static final Option RATIO = + Options.key("ratio").doubleType().noDefaultValue().withDescription("ratio"); + + public static final Option HOST = + Options.key("host").stringType().noDefaultValue().withDescription("host name"); + + public static final Option ENDPOINT = + Options.key("endpoint").stringType().noDefaultValue().withDescription("endpoint"); + + public static final Option DB_NAME = + Options.key("db_name").stringType().noDefaultValue().withDescription("database name"); + + public static final Option DELIMITER = + Options.key("delimiter").stringType().noDefaultValue().withDescription("delimiter"); + + public static final Option START_TS = + Options.key("start_ts").longType().noDefaultValue().withDescription("start timestamp"); + + public static final Option END_TS = + Options.key("end_ts").longType().noDefaultValue().withDescription("end timestamp"); + + public static final Option ENABLE_TX = + Options.key("enable_tx") + .booleanType() + .defaultValue(false) + .withDescription("enable transaction"); + + public static final Option FILE_EXPR = + Options.key("file_expr") + .stringType() + .defaultValue("default") + .withDescription("file name expression"); + + public static final Option> TAGS = + Options.key("tags").listType().noDefaultValue().withDescription("tag list"); + + @Test + public void testGreaterThanValidation() { + OptionRule rule = + OptionRule.builder().required(PORT, Condition.greaterThan(PORT, 0)).build(); + + Map config = new HashMap<>(); + config.put(PORT.key(), 8080); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(PORT.key(), 0); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + + config.put(PORT.key(), -1); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testGreaterOrEqualValidation() { + OptionRule rule = + OptionRule.builder().required(PORT, Condition.greaterOrEqual(PORT, 0)).build(); + + Map config = new HashMap<>(); + config.put(PORT.key(), 0); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(PORT.key(), 100); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(PORT.key(), -1); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testRangeValidation() { + OptionRule rule = + OptionRule.builder() + .required( + PORT, + Condition.greaterOrEqual(PORT, 1) + .and(Condition.lessOrEqual(PORT, 65535))) + .build(); + + Map config = new HashMap<>(); + config.put(PORT.key(), 1); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(PORT.key(), 65535); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(PORT.key(), 8080); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(PORT.key(), 0); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + + config.put(PORT.key(), 65536); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testHalfOpenIntervalValidation() { + OptionRule rule = + OptionRule.builder() + .required( + RATIO, + Condition.greaterThan(RATIO, 0.0) + .and(Condition.lessOrEqual(RATIO, 1.0))) + .build(); + + Map config = new HashMap<>(); + config.put(RATIO.key(), 0.5); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(RATIO.key(), 1.0); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(RATIO.key(), 0.0); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + + config.put(RATIO.key(), 1.1); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testNotBlankValidation() { + OptionRule rule = OptionRule.builder().required(HOST, Condition.notBlank(HOST)).build(); + + Map config = new HashMap<>(); + config.put(HOST.key(), "localhost"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(HOST.key(), ""); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + + config.put(HOST.key(), " "); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testStartsWithValidation() { + OptionRule rule = + OptionRule.builder() + .required(ENDPOINT, Condition.startsWith(ENDPOINT, "jdbc:databend://")) + .build(); + + Map config = new HashMap<>(); + config.put(ENDPOINT.key(), "jdbc:databend://localhost:8123"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(ENDPOINT.key(), "jdbc:mysql://localhost:3306"); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testStartsWithIgnoreCaseValidation() { + Option WHERE = + Options.key("where").stringType().noDefaultValue().withDescription("where clause"); + OptionRule rule = + OptionRule.builder() + .required(WHERE, Condition.startsWithIgnoreCase(WHERE, "where")) + .build(); + + Map config = new HashMap<>(); + config.put(WHERE.key(), "WHERE id > 10"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(WHERE.key(), "where name = 'test'"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(WHERE.key(), "SELECT * FROM t"); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testContainsValidation() { + OptionRule rule = + OptionRule.builder() + .optional(FILE_EXPR, Condition.contains(FILE_EXPR, "#{transactionId}")) + .build(); + + Map config = new HashMap<>(); + config.put(FILE_EXPR.key(), "data_#{transactionId}.csv"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(FILE_EXPR.key(), "data_output.csv"); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testMatchesValidation() { + OptionRule rule = + OptionRule.builder() + .required(ENDPOINT, Condition.matches(ENDPOINT, "^[^:]+:\\d+$")) + .build(); + + Map config = new HashMap<>(); + config.put(ENDPOINT.key(), "localhost:8080"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(ENDPOINT.key(), "invalid-format"); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testUpperCaseValidation() { + OptionRule rule = + OptionRule.builder().required(DB_NAME, Condition.upperCase(DB_NAME)).build(); + + Map config = new HashMap<>(); + config.put(DB_NAME.key(), "ORACLE_DB"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(DB_NAME.key(), "Oracle_DB"); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testLowerCaseValidation() { + OptionRule rule = + OptionRule.builder().required(DB_NAME, Condition.lowerCase(DB_NAME)).build(); + + Map config = new HashMap<>(); + config.put(DB_NAME.key(), "my_database"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(DB_NAME.key(), "My_Database"); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testLengthEqualValidation() { + OptionRule rule = + OptionRule.builder() + .required(DELIMITER, Condition.lengthEqual(DELIMITER, 1)) + .build(); + + Map config = new HashMap<>(); + config.put(DELIMITER.key(), ","); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(DELIMITER.key(), "||"); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testCrossFieldComparison() { + OptionRule rule = + OptionRule.builder() + .required(START_TS, END_TS, Condition.lessThanField(START_TS, END_TS)) + .build(); + + Map config = new HashMap<>(); + config.put(START_TS.key(), 100L); + config.put(END_TS.key(), 200L); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(START_TS.key(), 200L); + config.put(END_TS.key(), 100L); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + + config.put(START_TS.key(), 100L); + config.put(END_TS.key(), 100L); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testCrossFieldLessOrEqual() { + OptionRule rule = + OptionRule.builder() + .required(START_TS, END_TS, Condition.lessOrEqualField(START_TS, END_TS)) + .build(); + + Map config = new HashMap<>(); + config.put(START_TS.key(), 100L); + config.put(END_TS.key(), 100L); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(START_TS.key(), 50L); + config.put(END_TS.key(), 100L); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(START_TS.key(), 200L); + config.put(END_TS.key(), 100L); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testNotEmptyCollectionValidation() { + OptionRule rule = OptionRule.builder().required(TAGS, Condition.notEmpty(TAGS)).build(); + + Map config = new HashMap<>(); + config.put(TAGS.key(), Arrays.asList("tag1", "tag2")); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(TAGS.key(), Collections.emptyList()); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testUniqueCollectionValidation() { + OptionRule rule = OptionRule.builder().required(TAGS, Condition.unique(TAGS)).build(); + + Map config = new HashMap<>(); + config.put(TAGS.key(), Arrays.asList("a", "b", "c")); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(TAGS.key(), Arrays.asList("a", "b", "a")); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testOrChainAtLeastOneNotBlank() { + OptionRule rule = + OptionRule.builder() + .optional(HOST, Condition.notBlank(HOST).or(Condition.notBlank(ENDPOINT))) + .optional(ENDPOINT) + .build(); + + Map config = new HashMap<>(); + config.put(HOST.key(), "localhost"); + config.put(ENDPOINT.key(), ""); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(HOST.key(), ""); + config.put(ENDPOINT.key(), "my-endpoint"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(HOST.key(), ""); + config.put(ENDPOINT.key(), ""); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testValidationSkippedForAbsentOptional() { + OptionRule rule = + OptionRule.builder() + .optional(ENDPOINT, Condition.matches(ENDPOINT, "^[^:]+:\\d+$")) + .build(); + + Map config = new HashMap<>(); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + } + + @Test + public void testConditionToString() { + assertEquals("'port' > 0", Condition.greaterThan(PORT, 0).toString()); + assertEquals( + "'port' >= 1 && 'port' <= 65535", + Condition.greaterOrEqual(PORT, 1) + .and(Condition.lessOrEqual(PORT, 65535)) + .toString()); + assertEquals("'host' is not blank", Condition.notBlank(HOST).toString()); + assertEquals("'start_ts' < 'end_ts'", Condition.lessThanField(START_TS, END_TS).toString()); + assertEquals("'db_name' is uppercase", Condition.upperCase(DB_NAME).toString()); + assertEquals("'tags' has unique elements", Condition.unique(TAGS).toString()); + } + + @Test + public void testMultipleValidationRules() { + OptionRule rule = + OptionRule.builder() + .required( + PORT, + Condition.greaterOrEqual(PORT, 1) + .and(Condition.lessOrEqual(PORT, 65535))) + .required(HOST, Condition.notBlank(HOST)) + .required(DB_NAME, Condition.upperCase(DB_NAME)) + .build(); + + Map config = new HashMap<>(); + config.put(PORT.key(), 8080); + config.put(HOST.key(), "localhost"); + config.put(DB_NAME.key(), "ORACLE"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(DB_NAME.key(), "oracle"); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testBackwardCompatibility() { + OptionRule rule = + OptionRule.builder() + .optional(OptionTest.TEST_MODE) + .conditional( + OptionTest.TEST_MODE, OptionTest.TestMode.TIMESTAMP, TEST_TIMESTAMP) + .build(); + Map config = new HashMap<>(); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(OptionTest.TEST_MODE.key(), "timestamp"); + config.put(TEST_TIMESTAMP.key(), "564231238596789"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + } + + @Test + public void testNotEqualOperator() { + OptionRule rule = + OptionRule.builder() + .required(HOST, Condition.of(HOST, ConditionOperator.NOT_EQUAL, "")) + .build(); + + Map config = new HashMap<>(); + config.put(HOST.key(), "localhost"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(HOST.key(), ""); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + // ==================== New Operator Tests ==================== + + @Test + public void testEndsWithValidation() { + OptionRule rule = + OptionRule.builder() + .required(ENDPOINT, Condition.endsWith(ENDPOINT, "/v0")) + .build(); + + Map config = new HashMap<>(); + config.put(ENDPOINT.key(), "https://api.airtable.com/v0"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(ENDPOINT.key(), "https://api.airtable.com/v1"); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testEndsWithIgnoreCaseValidation() { + OptionRule rule = + OptionRule.builder() + .required(ENDPOINT, Condition.endsWithIgnoreCase(ENDPOINT, ".csv")) + .build(); + + Map config = new HashMap<>(); + config.put(ENDPOINT.key(), "data.CSV"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(ENDPOINT.key(), "data.csv"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(ENDPOINT.key(), "data.json"); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testCollectionSizeEqual() { + OptionRule rule = OptionRule.builder().required(TAGS, Condition.sizeEqual(TAGS, 3)).build(); + + Map config = new HashMap<>(); + config.put(TAGS.key(), Arrays.asList("a", "b", "c")); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(TAGS.key(), Arrays.asList("a", "b")); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testCollectionSizeFixedOne() { + OptionRule rule = OptionRule.builder().required(TAGS, Condition.sizeEqual(TAGS, 1)).build(); + + Map config = new HashMap<>(); + config.put(TAGS.key(), Collections.singletonList("only_one")); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(TAGS.key(), Arrays.asList("a", "b")); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + public static final Option> COLLECTIONS = + Options.key("collections") + .listType() + .noDefaultValue() + .withDescription("collection list"); + + @Test + public void testFieldSizeEqual() { + OptionRule rule = + OptionRule.builder() + .required(TAGS, COLLECTIONS, Condition.sizeEqualField(TAGS, COLLECTIONS)) + .build(); + + Map config = new HashMap<>(); + config.put(TAGS.key(), Arrays.asList("a", "b", "c")); + config.put(COLLECTIONS.key(), Arrays.asList("x", "y", "z")); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(COLLECTIONS.key(), Arrays.asList("x", "y")); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + public static final Option SCHEMA_TABLE = + Options.key("schema_table") + .stringType() + .noDefaultValue() + .withDescription("schema table name"); + + public static final Option COLLECTION_NAME = + Options.key("collection_name") + .stringType() + .noDefaultValue() + .withDescription("collection name"); + + @Test + public void testFieldEqualValidation() { + OptionRule rule = + OptionRule.builder() + .required( + SCHEMA_TABLE, + COLLECTION_NAME, + Condition.equalField(SCHEMA_TABLE, COLLECTION_NAME)) + .build(); + + Map config = new HashMap<>(); + config.put(SCHEMA_TABLE.key(), "db.users"); + config.put(COLLECTION_NAME.key(), "db.users"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(COLLECTION_NAME.key(), "db.orders"); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testFieldNotEqualValidation() { + OptionRule rule = + OptionRule.builder() + .required( + SCHEMA_TABLE, + COLLECTION_NAME, + Condition.notEqualField(SCHEMA_TABLE, COLLECTION_NAME)) + .build(); + + Map config = new HashMap<>(); + config.put(SCHEMA_TABLE.key(), "source_db"); + config.put(COLLECTION_NAME.key(), "target_db"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(COLLECTION_NAME.key(), "source_db"); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testNewOperatorToString() { + assertEquals("'endpoint' ends with /v0", Condition.endsWith(ENDPOINT, "/v0").toString()); + assertEquals("'tags' size == 3", Condition.sizeEqual(TAGS, 3).toString()); + assertEquals( + "'tags' size == 'collections'", + Condition.sizeEqualField(TAGS, COLLECTIONS).toString()); + assertEquals( + "'schema_table' == 'collection_name'", + Condition.equalField(SCHEMA_TABLE, COLLECTION_NAME).toString()); + assertEquals( + "'schema_table' != 'collection_name'", + Condition.notEqualField(SCHEMA_TABLE, COLLECTION_NAME).toString()); + } + + // ==================== Missing Operator Coverage ==================== + + @Test + public void testLessThanValidation() { + OptionRule rule = + OptionRule.builder().required(PORT, Condition.lessThan(PORT, 100)).build(); + + Map config = new HashMap<>(); + config.put(PORT.key(), 50); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(PORT.key(), 99); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(PORT.key(), 100); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + + config.put(PORT.key(), 200); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testLengthGreaterOrEqualValidation() { + OptionRule rule = + OptionRule.builder() + .required(HOST, Condition.lengthGreaterOrEqual(HOST, 3)) + .build(); + + Map config = new HashMap<>(); + config.put(HOST.key(), "abc"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(HOST.key(), "localhost"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(HOST.key(), "ab"); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testLengthLessOrEqualValidation() { + OptionRule rule = + OptionRule.builder() + .required(DELIMITER, Condition.lengthLessOrEqual(DELIMITER, 2)) + .build(); + + Map config = new HashMap<>(); + config.put(DELIMITER.key(), ","); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(DELIMITER.key(), "||"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(DELIMITER.key(), "|||"); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testLengthRangeValidation() { + OptionRule rule = + OptionRule.builder() + .required( + HOST, + Condition.lengthGreaterOrEqual(HOST, 1) + .and(Condition.lengthLessOrEqual(HOST, 255))) + .build(); + + Map config = new HashMap<>(); + config.put(HOST.key(), "a"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(HOST.key(), ""); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testCollectionSizeGreaterOrEqualValidation() { + OptionRule rule = + OptionRule.builder().required(TAGS, Condition.sizeGreaterOrEqual(TAGS, 2)).build(); + + Map config = new HashMap<>(); + config.put(TAGS.key(), Arrays.asList("a", "b")); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(TAGS.key(), Arrays.asList("a", "b", "c")); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(TAGS.key(), Collections.singletonList("a")); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testCollectionSizeLessOrEqualValidation() { + OptionRule rule = + OptionRule.builder().required(TAGS, Condition.sizeLessOrEqual(TAGS, 3)).build(); + + Map config = new HashMap<>(); + config.put(TAGS.key(), Arrays.asList("a", "b", "c")); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(TAGS.key(), Arrays.asList("a", "b")); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(TAGS.key(), Arrays.asList("a", "b", "c", "d")); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testFieldGreaterThanValidation() { + OptionRule rule = + OptionRule.builder() + .required(END_TS, START_TS, Condition.greaterThanField(END_TS, START_TS)) + .build(); + + Map config = new HashMap<>(); + config.put(END_TS.key(), 200L); + config.put(START_TS.key(), 100L); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(END_TS.key(), 100L); + config.put(START_TS.key(), 100L); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + + config.put(END_TS.key(), 50L); + config.put(START_TS.key(), 100L); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testFieldGreaterOrEqualValidation() { + OptionRule rule = + OptionRule.builder() + .required(END_TS, START_TS, Condition.greaterOrEqualField(END_TS, START_TS)) + .build(); + + Map config = new HashMap<>(); + config.put(END_TS.key(), 200L); + config.put(START_TS.key(), 100L); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(END_TS.key(), 100L); + config.put(START_TS.key(), 100L); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(END_TS.key(), 50L); + config.put(START_TS.key(), 100L); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + // ==================== Builder Overload Coverage ==================== + + @Test + public void testConditionalWithValueConstraint() { + OptionRule rule = + OptionRule.builder() + .optional(TEST_MODE) + .conditional( + TEST_MODE, + OptionTest.TestMode.TIMESTAMP, + Condition.greaterThan(TEST_TIMESTAMP, 0L)) + .build(); + + Map config = new HashMap<>(); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(TEST_MODE.key(), "timestamp"); + config.put(TEST_TIMESTAMP.key(), 100L); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(TEST_TIMESTAMP.key(), 0L); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + + config.put(TEST_TIMESTAMP.key(), -1L); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + + config.put(TEST_MODE.key(), "EARLIEST"); + config.put(TEST_TIMESTAMP.key(), -1L); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + } + + @Test + public void testConditionalWithMultiFieldConstraint() { + OptionRule rule = + OptionRule.builder() + .optional(ENABLE_TX) + .conditional( + ENABLE_TX, + true, + START_TS, + END_TS, + Condition.lessThanField(START_TS, END_TS)) + .build(); + + Map config = new HashMap<>(); + config.put(ENABLE_TX.key(), false); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(ENABLE_TX.key(), true); + config.put(START_TS.key(), 100L); + config.put(END_TS.key(), 200L); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(START_TS.key(), 300L); + config.put(END_TS.key(), 200L); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testOptionalWithValueConstraint() { + OptionRule rule = + OptionRule.builder().optional(PORT, Condition.greaterOrEqual(PORT, 1)).build(); + + Map config = new HashMap<>(); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(PORT.key(), 8080); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(PORT.key(), 0); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testOptionalWithMultiFieldConstraint() { + OptionRule rule = + OptionRule.builder() + .optional(START_TS, END_TS, Condition.lessThanField(START_TS, END_TS)) + .build(); + + Map config = new HashMap<>(); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(START_TS.key(), 100L); + config.put(END_TS.key(), 200L); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(START_TS.key(), 200L); + config.put(END_TS.key(), 100L); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + // ==================== Condition Chain Coverage ==================== + + @Test + public void testNotEmptyAndUniqueChain() { + OptionRule rule = + OptionRule.builder() + .required(TAGS, Condition.notEmpty(TAGS).and(Condition.unique(TAGS))) + .build(); + + Map config = new HashMap<>(); + config.put(TAGS.key(), Arrays.asList("a", "b", "c")); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(TAGS.key(), Collections.emptyList()); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + + config.put(TAGS.key(), Arrays.asList("a", "a", "b")); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testCollectionSizeRangeChain() { + OptionRule rule = + OptionRule.builder() + .required( + TAGS, + Condition.sizeGreaterOrEqual(TAGS, 1) + .and(Condition.sizeLessOrEqual(TAGS, 5))) + .build(); + + Map config = new HashMap<>(); + config.put(TAGS.key(), Arrays.asList("a")); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(TAGS.key(), Arrays.asList("a", "b", "c", "d", "e")); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(TAGS.key(), Collections.emptyList()); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + + config.put(TAGS.key(), Arrays.asList("1", "2", "3", "4", "5", "6")); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testMultipleConditionsVarargs() { + OptionRule rule = + OptionRule.builder() + .required( + TAGS, + Condition.notEmpty(TAGS), + Condition.unique(TAGS), + Condition.sizeLessOrEqual(TAGS, 10)) + .build(); + + Map config = new HashMap<>(); + config.put(TAGS.key(), Arrays.asList("a", "b", "c")); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(TAGS.key(), Collections.emptyList()); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + + config.put(TAGS.key(), Arrays.asList("a", "a")); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + // ==================== toString Coverage for All Operators ==================== + + @Test + public void testAllOperatorToString() { + assertEquals("'port' < 100", Condition.lessThan(PORT, 100).toString()); + assertEquals("'port' <= 100", Condition.lessOrEqual(PORT, 100).toString()); + assertEquals("'port' > 0", Condition.greaterThan(PORT, 0).toString()); + assertEquals("'port' >= 0", Condition.greaterOrEqual(PORT, 0).toString()); + assertEquals("'host' is not blank", Condition.notBlank(HOST).toString()); + assertEquals( + "'endpoint' starts with jdbc:", Condition.startsWith(ENDPOINT, "jdbc:").toString()); + assertEquals( + "'endpoint' starts with (ignore case) jdbc:", + Condition.startsWithIgnoreCase(ENDPOINT, "jdbc:").toString()); + assertEquals("'endpoint' ends with .csv", Condition.endsWith(ENDPOINT, ".csv").toString()); + assertEquals( + "'endpoint' ends with (ignore case) .csv", + Condition.endsWithIgnoreCase(ENDPOINT, ".csv").toString()); + assertEquals("'endpoint' contains ://", Condition.contains(ENDPOINT, "://").toString()); + assertEquals("'endpoint' matches ^\\d+$", Condition.matches(ENDPOINT, "^\\d+$").toString()); + assertEquals("'db_name' is uppercase", Condition.upperCase(DB_NAME).toString()); + assertEquals("'db_name' is lowercase", Condition.lowerCase(DB_NAME).toString()); + assertEquals("'delimiter' length == 1", Condition.lengthEqual(DELIMITER, 1).toString()); + assertEquals("'host' length >= 3", Condition.lengthGreaterOrEqual(HOST, 3).toString()); + assertEquals("'host' length <= 255", Condition.lengthLessOrEqual(HOST, 255).toString()); + assertEquals("'tags' is not empty", Condition.notEmpty(TAGS).toString()); + assertEquals("'tags' has unique elements", Condition.unique(TAGS).toString()); + assertEquals("'tags' size == 3", Condition.sizeEqual(TAGS, 3).toString()); + assertEquals("'tags' size >= 1", Condition.sizeGreaterOrEqual(TAGS, 1).toString()); + assertEquals("'tags' size <= 10", Condition.sizeLessOrEqual(TAGS, 10).toString()); + assertEquals("'start_ts' < 'end_ts'", Condition.lessThanField(START_TS, END_TS).toString()); + assertEquals( + "'start_ts' <= 'end_ts'", Condition.lessOrEqualField(START_TS, END_TS).toString()); + assertEquals( + "'end_ts' > 'start_ts'", Condition.greaterThanField(END_TS, START_TS).toString()); + assertEquals( + "'end_ts' >= 'start_ts'", + Condition.greaterOrEqualField(END_TS, START_TS).toString()); + assertEquals( + "'schema_table' == 'collection_name'", + Condition.equalField(SCHEMA_TABLE, COLLECTION_NAME).toString()); + assertEquals( + "'schema_table' != 'collection_name'", + Condition.notEqualField(SCHEMA_TABLE, COLLECTION_NAME).toString()); + assertEquals( + "'tags' size == 'collections'", + Condition.sizeEqualField(TAGS, COLLECTIONS).toString()); + } + + @Test + public void testCircularConditionChainDetected() { + Condition a = Condition.greaterThan(PORT, 0); + assertThrows(IllegalArgumentException.class, () -> a.and(a)); + } + + @Test + public void testCircularConditionChainIndirect() { + Condition a = Condition.greaterThan(PORT, 0); + Condition b = Condition.lessThan(PORT, 100); + a.and(b); + assertThrows(IllegalArgumentException.class, () -> b.and(a)); + } + + @Test + public void testCircularConditionChainDuplicateAppend() { + Condition a = Condition.greaterThan(PORT, 0); + Condition b = Condition.lessThan(PORT, 100); + a.and(b); + assertThrows(IllegalArgumentException.class, () -> a.and(b)); + } + + @Test + public void testNullOperatorRejected() { + assertThrows(IllegalArgumentException.class, () -> Condition.of(PORT, null, 0)); + } + + @Test + public void testFieldOperatorWithoutCompareOptionRejected() { + assertThrows( + IllegalArgumentException.class, + () -> new Condition<>(PORT, ConditionOperator.FIELD_LESS_THAN, null, null)); + } + + @Test + public void testBinaryLiteralOperatorWithoutExpectValueRejected() { + assertThrows( + IllegalArgumentException.class, + () -> new Condition<>(PORT, ConditionOperator.GREATER_THAN, null, null)); + } } diff --git a/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/command/MetadataExportCommand.java b/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/command/MetadataExportCommand.java index 035c8c734f2c..70d234efa46d 100644 --- a/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/command/MetadataExportCommand.java +++ b/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/command/MetadataExportCommand.java @@ -26,6 +26,7 @@ import org.apache.seatunnel.api.configuration.Option; import org.apache.seatunnel.api.configuration.SingleChoiceOption; import org.apache.seatunnel.api.configuration.util.Condition; +import org.apache.seatunnel.api.configuration.util.ConditionOperator; import org.apache.seatunnel.api.configuration.util.ConditionRule; import org.apache.seatunnel.api.configuration.util.Expression; import org.apache.seatunnel.api.configuration.util.OptionRule; @@ -207,6 +208,15 @@ private ObjectNode exportConnector( } node.set("conditionRules", conditionRulesArray); + ArrayNode valueConstraintsArray = mapper.createArrayNode(); + for (Condition constraint : rule.getValueConstraints()) { + ObjectNode vcNode = mapper.createObjectNode(); + vcNode.put("expression", constraint.toString()); + vcNode.set("conditionTree", exportCondition(constraint)); + valueConstraintsArray.add(vcNode); + } + node.set("valueConstraints", valueConstraintsArray); + return node; } @@ -410,7 +420,16 @@ private ObjectNode exportCondition(Condition condition) { } ObjectNode node = mapper.createObjectNode(); node.put("key", condition.getOption().key()); - node.put("expectValue", String.valueOf(condition.getExpectValue())); + if (condition.getExpectValue() != null) { + node.put("expectValue", String.valueOf(condition.getExpectValue())); + } + ConditionOperator op = condition.getOperator(); + if (op != null && op != ConditionOperator.EQUAL) { + node.put("compareOperator", op.getSymbol()); + } + if (condition.getCompareOption() != null) { + node.put("compareOption", condition.getCompareOption().key()); + } if (condition.and() != null) { node.put("operator", condition.and() ? "AND" : "OR"); } diff --git a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/response/OptionRuleResponse.java b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/response/OptionRuleResponse.java index ada70fa7211d..a3d452980e84 100644 --- a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/response/OptionRuleResponse.java +++ b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/response/OptionRuleResponse.java @@ -46,14 +46,36 @@ public static class OptionRuleMetadata { private final List optionalOptions; private final List requiredOptions; private final List conditionRules; + private final List valueConstraints; public OptionRuleMetadata( List optionalOptions, List requiredOptions, List conditionRules) { + this(optionalOptions, requiredOptions, conditionRules, null); + } + + public OptionRuleMetadata( + List optionalOptions, + List requiredOptions, + List conditionRules, + List valueConstraints) { this.optionalOptions = optionalOptions; this.requiredOptions = requiredOptions; this.conditionRules = conditionRules; + this.valueConstraints = valueConstraints; + } + } + + @Getter + public static class ValueConstraintMetadata { + + private final String expression; + private final ConditionNode conditionTree; + + public ValueConstraintMetadata(String expression, ConditionNode conditionTree) { + this.expression = expression; + this.conditionTree = conditionTree; } } @@ -138,6 +160,8 @@ public static class ConditionNode { private final OptionMetadata option; private final Object expectValue; + private final String compareOperator; + private final OptionMetadata compareOption; private final LogicalOperator operator; private final ConditionNode next; @@ -146,8 +170,20 @@ public ConditionNode( Object expectValue, LogicalOperator operator, ConditionNode next) { + this(option, expectValue, null, null, operator, next); + } + + public ConditionNode( + OptionMetadata option, + Object expectValue, + String compareOperator, + OptionMetadata compareOption, + LogicalOperator operator, + ConditionNode next) { this.option = option; this.expectValue = expectValue; + this.compareOperator = compareOperator; + this.compareOption = compareOption; this.operator = operator; this.next = next; } diff --git a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesService.java b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesService.java index b904f3dffaad..92e3c4908b44 100644 --- a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesService.java +++ b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesService.java @@ -23,6 +23,7 @@ import org.apache.seatunnel.api.configuration.Option; import org.apache.seatunnel.api.configuration.SingleChoiceOption; import org.apache.seatunnel.api.configuration.util.Condition; +import org.apache.seatunnel.api.configuration.util.ConditionOperator; import org.apache.seatunnel.api.configuration.util.ConditionRule; import org.apache.seatunnel.api.configuration.util.Expression; import org.apache.seatunnel.api.configuration.util.OptionRule; @@ -32,6 +33,7 @@ import org.apache.seatunnel.plugin.discovery.PluginDiscovery; import org.apache.seatunnel.plugin.discovery.seatunnel.SeaTunnelSinkPluginDiscovery; import org.apache.seatunnel.plugin.discovery.seatunnel.SeaTunnelSourcePluginDiscovery; +import org.apache.seatunnel.plugin.discovery.seatunnel.SeaTunnelTransformPluginDiscovery; import com.hazelcast.spi.impl.NodeEngineImpl; import lombok.extern.slf4j.Slf4j; @@ -75,6 +77,7 @@ public OptionRulesService(NodeEngineImpl nodeEngine) { Map> discoveries = new EnumMap<>(PluginType.class); discoveries.put(PluginType.SOURCE, new SeaTunnelSourcePluginDiscovery()); discoveries.put(PluginType.SINK, new SeaTunnelSinkPluginDiscovery()); + discoveries.put(PluginType.TRANSFORM, new SeaTunnelTransformPluginDiscovery()); this.pluginDiscoveries = Collections.unmodifiableMap(discoveries); this.discoveredPluginsCache = new ConcurrentHashMap<>(); this.responseCache = new ConcurrentHashMap<>(); @@ -134,8 +137,12 @@ private OptionRuleResponse.OptionRuleMetadata toOptionRuleMetadata(OptionRule op optionRule.getConditionRules().stream() .map(this::toConditionRuleMetadata) .collect(Collectors.toList()); + List valueConstraints = + optionRule.getValueConstraints().stream() + .map(this::toValueConstraintMetadata) + .collect(Collectors.toList()); return new OptionRuleResponse.OptionRuleMetadata( - optionalOptions, requiredOptions, conditionRules); + optionalOptions, requiredOptions, conditionRules, valueConstraints); } private ConcurrentMap getPluginTypeCache(PluginType pluginType) { @@ -193,12 +200,16 @@ private PluginType parseSupportedPluginType(String pluginTypeText) { if (StringUtils.equalsIgnoreCase(normalizedPluginType, PluginType.SINK.getType())) { return PluginType.SINK; } + if (StringUtils.equalsIgnoreCase(normalizedPluginType, PluginType.TRANSFORM.getType())) { + return PluginType.TRANSFORM; + } throw new IllegalArgumentException( String.format( - "Unsupported plugin type '%s'. Only '%s' and '%s' are supported.", + "Unsupported plugin type '%s'. Only '%s', '%s' and '%s' are supported.", normalizedPluginType, PluginType.SOURCE.getType(), - PluginType.SINK.getType())); + PluginType.SINK.getType(), + PluginType.TRANSFORM.getType())); } private String normalizePluginName(String pluginName) { @@ -262,13 +273,28 @@ private OptionRuleResponse.ConditionNode toConditionNode(Condition condition) if (condition == null) { return null; } + ConditionOperator op = condition.getOperator(); + String compareOperatorSymbol = + (op != null && op != ConditionOperator.EQUAL) ? op.getSymbol() : null; + OptionRuleResponse.OptionMetadata compareOptionMeta = + condition.getCompareOption() != null + ? toOptionMetadata(condition.getCompareOption()) + : null; return new OptionRuleResponse.ConditionNode( toOptionMetadata(condition.getOption()), condition.getExpectValue(), + compareOperatorSymbol, + compareOptionMeta, toLogicalOperator(condition.and()), toConditionNode(condition.getNext())); } + private OptionRuleResponse.ValueConstraintMetadata toValueConstraintMetadata( + Condition condition) { + return new OptionRuleResponse.ValueConstraintMetadata( + condition.toString(), toConditionNode(condition)); + } + private OptionRuleResponse.LogicalOperator toLogicalOperator(Boolean and) { if (and == null) { return null; From 58bb8a73f868c4e1d730f33854e793c3ab7efbce Mon Sep 17 00:00:00 2001 From: "zhiwei.niu" Date: Fri, 29 May 2026 13:33:06 +0800 Subject: [PATCH 02/16] [Feature][API] Add reset API optionRule response test --- .../rest/service/OptionRulesServiceTest.java | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesServiceTest.java b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesServiceTest.java index 8a8a53a991f2..a523fd70f962 100644 --- a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesServiceTest.java +++ b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesServiceTest.java @@ -21,6 +21,7 @@ import org.apache.seatunnel.api.configuration.Option; import org.apache.seatunnel.api.configuration.Options; import org.apache.seatunnel.api.configuration.SingleChoiceOption; +import org.apache.seatunnel.api.configuration.util.Condition; import org.apache.seatunnel.api.configuration.util.OptionRule; import org.apache.seatunnel.engine.server.rest.response.OptionRuleResponse; @@ -296,6 +297,78 @@ void shouldThrowWhenPluginDoesNotExist() { () -> service.getOptionRules("source", "MissingPlugin")); } + @Test + void shouldPreserveValueConstraintMetadata() { + Option port = + Options.key("port").intType().noDefaultValue().withDescription("Port number"); + Option host = + Options.key("host").stringType().noDefaultValue().withDescription("Hostname"); + + OptionRule optionRule = + OptionRule.builder() + .required( + port, + Condition.greaterOrEqual(port, 1) + .and(Condition.lessOrEqual(port, 65535))) + .required(host, Condition.notBlank(host)) + .build(); + + OptionRuleResponse response = + service.buildResponse( + PluginIdentifier.of("seatunnel", "source", "ConstraintSource"), optionRule); + + List constraints = + response.getOptionRule().getValueConstraints(); + assertNotNull(constraints); + assertFalse(constraints.isEmpty()); + + OptionRuleResponse.ValueConstraintMetadata portConstraint = constraints.get(0); + assertNotNull(portConstraint.getExpression()); + assertTrue(portConstraint.getExpression().contains("port")); + assertNotNull(portConstraint.getConditionTree()); + assertEquals(">=", portConstraint.getConditionTree().getCompareOperator()); + assertEquals( + OptionRuleResponse.LogicalOperator.AND, + portConstraint.getConditionTree().getOperator()); + assertNotNull(portConstraint.getConditionTree().getNext()); + assertEquals("<=", portConstraint.getConditionTree().getNext().getCompareOperator()); + + OptionRuleResponse.ValueConstraintMetadata hostConstraint = constraints.get(1); + assertTrue(hostConstraint.getExpression().contains("is not blank")); + assertNotNull(hostConstraint.getConditionTree()); + } + + @Test + void shouldPreserveCrossFieldConstraintMetadata() { + Option startTs = + Options.key("start_ts").longType().noDefaultValue().withDescription("Start"); + Option endTs = + Options.key("end_ts").longType().noDefaultValue().withDescription("End"); + + OptionRule optionRule = + OptionRule.builder() + .required(startTs, endTs, Condition.lessThanField(startTs, endTs)) + .build(); + + OptionRuleResponse response = + service.buildResponse( + PluginIdentifier.of("seatunnel", "source", "CrossFieldSource"), optionRule); + + List constraints = + response.getOptionRule().getValueConstraints(); + assertEquals(1, constraints.size()); + + OptionRuleResponse.ValueConstraintMetadata constraint = constraints.get(0); + assertTrue(constraint.getExpression().contains("start_ts")); + assertTrue(constraint.getExpression().contains("end_ts")); + + OptionRuleResponse.ConditionNode tree = constraint.getConditionTree(); + assertNotNull(tree); + assertEquals("< [field]", tree.getCompareOperator()); + assertNotNull(tree.getCompareOption()); + assertEquals("end_ts", tree.getCompareOption().getKey()); + } + private enum AuthMode { PASSWORD, TOKEN From 9df4a6220c207367e9dea44030e6b6621f3ccd55 Mon Sep 17 00:00:00 2001 From: "zhiwei.niu" Date: Fri, 29 May 2026 14:29:00 +0800 Subject: [PATCH 03/16] [Feature][API] Optimize function naming conventions to maintain consistency --- .../configuration/util/ConfigValidator.java | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) 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 b2ad4093f36d..2510b835038d 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 @@ -171,21 +171,22 @@ public void validate(OptionRule rule, Expression expression) { } } - validateValueConstraints(rule); + for (Condition constraint : rule.getValueConstraints()) { + validate(constraint, rule); + } } - private void validateValueConstraints(OptionRule rule) { - for (Condition constraint : rule.getValueConstraints()) { - if (shouldValidate(constraint, rule)) { - if (!validate(constraint)) { - throw new OptionValidationException( - "Option validation failed: %s", constraint.toString()); - } - } + void validate(Condition constraint, OptionRule rule) { + if (!isConstraintApplicable(constraint, rule)) { + return; + } + if (!validate(constraint)) { + throw new OptionValidationException( + "Option validation failed: %s", constraint.toString()); } } - private boolean shouldValidate(Condition condition, OptionRule rule) { + private boolean isConstraintApplicable(Condition condition, OptionRule rule) { Option option = condition.getOption(); if (hasOption(option)) { return true; From ea6dc52e5d2f8982da5e93a38d6312b21cf78f14 Mon Sep 17 00:00:00 2001 From: "zhiwei.niu" Date: Fri, 29 May 2026 14:34:24 +0800 Subject: [PATCH 04/16] [Feature][API] OptionRule Add comments --- .../api/configuration/util/OptionRule.java | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionRule.java b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionRule.java index 9f57404a0588..ead173f37b86 100644 --- a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionRule.java +++ b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionRule.java @@ -34,7 +34,7 @@ /** * Validation rule for {@link Option}. * - *

The option rule is typically built in one of the following pattern: + *

The option rule is typically built in one of the following patterns: * *

{@code
  * // simple rule
@@ -52,11 +52,10 @@
  *     .build();
  *
  * // complex conditional rule
- * // moot expression
  * Expression expression = Expression.of(TOPIC_DISCOVERY_INTERVAL, 200)
  *     .and(Expression.of(Condition.of(CURSOR_STARTUP_MODE, StartMode.EARLIEST)
  *         .or(CURSOR_STARTUP_MODE, StartMode.LATEST)))
- *     .or(Expression.of(Condition.of(TOPIC_DISCOVERY_INTERVAL, 100)))
+ *     .or(Expression.of(Condition.of(TOPIC_DISCOVERY_INTERVAL, 100)));
  *
  * OptionRule complexRule = OptionRule.builder()
  *     .optional(POLL_TIMEOUT, POLL_INTERVAL, CURSOR_STARTUP_MODE)
@@ -64,6 +63,15 @@
  *     .exclusive(TOPIC_PATTERN, TOPIC)
  *     .conditional(expression, CURSOR_RESET_MODE)
  *     .build();
+ *
+ * // value constraints — attach Condition to required / optional / conditional
+ * OptionRule constrainedRule = OptionRule.builder()
+ *     .required(PORT, Condition.greaterThan(PORT, 0))
+ *     .optional(TIMEOUT, Condition.range(TIMEOUT, 1000, 60000))
+ *     .required(START_TS, END_TS, Condition.lessThanField(START_TS, END_TS))
+ *     .conditional(MODE, StartMode.TIMESTAMP,
+ *         Condition.greaterThan(TIMESTAMP_VALUE, 0))
+ *     .build();
  * }
*/ public class OptionRule { From d3863442a4ed45e123da797f63c109dfc87c7183 Mon Sep 17 00:00:00 2001 From: "zhiwei.niu" Date: Fri, 29 May 2026 15:29:00 +0800 Subject: [PATCH 05/16] [Feature][API] Remove outdated assertions from OptionRulesService Test and make it support transform --- .../server/rest/service/OptionRulesServiceTest.java | 9 --------- 1 file changed, 9 deletions(-) diff --git a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesServiceTest.java b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesServiceTest.java index a523fd70f962..61f4b95c21c0 100644 --- a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesServiceTest.java +++ b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesServiceTest.java @@ -272,15 +272,6 @@ void shouldReturnConsistentResponsesForConcurrentRequests() throws Exception { } } - @Test - void shouldRejectInvalidType() { - IllegalArgumentException error = - assertThrows( - IllegalArgumentException.class, - () -> service.getOptionRules("transform", "Replace")); - assertTrue(error.getMessage().contains("Unsupported plugin type")); - } - @Test void shouldRejectBlankPluginName() { IllegalArgumentException error = From 8de35567bba6ac2e672803c426e68e91e554784d Mon Sep 17 00:00:00 2001 From: "zhiwei.niu" Date: Sat, 30 May 2026 00:51:00 +0800 Subject: [PATCH 06/16] [Feature][API] Fix validateUnknownKeys missing valueConstraints keys and Long comparison precision --- .../util/ConditionEvaluators.java | 3 + .../configuration/util/ConfigValidator.java | 18 ++++ .../api/configuration/util/OptionRule.java | 6 +- .../util/ConfigValidatorTest.java | 91 +++++++++++++++++++ 4 files changed, 116 insertions(+), 2 deletions(-) diff --git a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java index 7d73af675243..21862fc9f17e 100644 --- a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java +++ b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java @@ -217,6 +217,9 @@ static int compareNumbers(Object a, Object b) { throw new OptionValidationException("Cannot compare null values in numeric comparison"); } if (a instanceof Number && b instanceof Number) { + if (a instanceof Long || b instanceof Long) { + return Long.compare(((Number) a).longValue(), ((Number) b).longValue()); + } return Double.compare(((Number) a).doubleValue(), ((Number) b).doubleValue()); } if (a instanceof Comparable && b instanceof Comparable) { 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 2510b835038d..3564f31adf05 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 @@ -125,10 +125,28 @@ private static Set collectDeclaredKeys(OptionRule rule) { for (ConditionRule conditionRule : rule.getConditionRules()) { keys.addAll(collectDeclaredKeys(conditionRule.getOptionRule())); } + for (Condition constraint : rule.getValueConstraints()) { + collectConditionKeys(keys, constraint); + } } return keys; } + private static void collectConditionKeys(Set keys, Condition condition) { + if (condition == null) { + return; + } + keys.add(condition.getOption().key()); + keys.addAll(condition.getOption().getFallbackKeys()); + if (condition.getCompareOption() != null) { + keys.add(condition.getCompareOption().key()); + keys.addAll(condition.getCompareOption().getFallbackKeys()); + } + if (condition.hasNext()) { + collectConditionKeys(keys, condition.getNext()); + } + } + private static void collectKeys(Set keys, List> options) { for (Option option : options) { keys.add(option.key()); diff --git a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionRule.java b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionRule.java index ead173f37b86..db8bbd74ab7a 100644 --- a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionRule.java +++ b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionRule.java @@ -66,8 +66,10 @@ * * // value constraints — attach Condition to required / optional / conditional * OptionRule constrainedRule = OptionRule.builder() - * .required(PORT, Condition.greaterThan(PORT, 0)) - * .optional(TIMEOUT, Condition.range(TIMEOUT, 1000, 60000)) + * .required(PORT, Condition.greaterOrEqual(PORT, 1) + * .and(Condition.lessOrEqual(PORT, 65535))) + * .optional(TIMEOUT, Condition.greaterOrEqual(TIMEOUT, 1000) + * .and(Condition.lessOrEqual(TIMEOUT, 60000))) * .required(START_TS, END_TS, Condition.lessThanField(START_TS, END_TS)) * .conditional(MODE, StartMode.TIMESTAMP, * Condition.greaterThan(TIMESTAMP_VALUE, 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 6a838c62bf24..dc1f84021c34 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 @@ -1361,4 +1361,95 @@ public void testBinaryLiteralOperatorWithoutExpectValueRejected() { IllegalArgumentException.class, () -> new Condition<>(PORT, ConditionOperator.GREATER_THAN, null, null)); } + + @Test + public void testUnknownKeysDoesNotRejectValueConstraintOptions() { + OptionRule rule = + OptionRule.builder() + .required(PORT, Condition.greaterOrEqual(PORT, 1)) + .required(START_TS, END_TS, Condition.lessThanField(START_TS, END_TS)) + .build(); + + Map config = new HashMap<>(); + config.put(PORT.key(), 8080); + config.put(START_TS.key(), 100L); + config.put(END_TS.key(), 200L); + + Assertions.assertDoesNotThrow( + () -> + ConfigValidator.validateUnknownKeys( + ReadonlyConfig.fromMap(config), rule, "TestConnector")); + } + + @Test + public void testUnknownKeysRejectsUndeclaredKey() { + OptionRule rule = + OptionRule.builder().required(PORT, Condition.greaterOrEqual(PORT, 1)).build(); + + Map config = new HashMap<>(); + config.put(PORT.key(), 8080); + config.put("typo_key", "oops"); + + assertThrows( + OptionValidationException.class, + () -> + ConfigValidator.validateUnknownKeys( + ReadonlyConfig.fromMap(config), rule, "TestConnector")); + } + + @Test + public void testUnknownKeysRecognizesChainedConditionOptions() { + OptionRule rule = + OptionRule.builder() + .required( + PORT, + Condition.greaterOrEqual(PORT, 1) + .and(Condition.lessOrEqual(PORT, 65535))) + .build(); + + Map config = new HashMap<>(); + config.put(PORT.key(), 443); + + Assertions.assertDoesNotThrow( + () -> + ConfigValidator.validateUnknownKeys( + ReadonlyConfig.fromMap(config), rule, "TestConnector")); + } + + @Test + public void testLargeTimestampComparisonPrecision() { + long tsA = (1L << 53) + 1; + long tsB = (1L << 53) + 2; + + OptionRule rule = + OptionRule.builder() + .required(START_TS, END_TS, Condition.lessThanField(START_TS, END_TS)) + .build(); + + Map config = new HashMap<>(); + config.put(START_TS.key(), tsA); + config.put(END_TS.key(), tsB); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(START_TS.key(), tsB); + config.put(END_TS.key(), tsA); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testLargeLongLiteralComparison() { + long bigValue = Long.MAX_VALUE - 1; + + OptionRule rule = + OptionRule.builder() + .required(START_TS, Condition.lessThan(START_TS, Long.MAX_VALUE)) + .build(); + + Map config = new HashMap<>(); + config.put(START_TS.key(), bigValue); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(START_TS.key(), Long.MAX_VALUE); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } } From ebc751e20b478c9d2c578678a8b69513159c7199 Mon Sep 17 00:00:00 2001 From: "zhiwei.niu" Date: Mon, 1 Jun 2026 15:15:05 +0800 Subject: [PATCH 07/16] [Feature][API] Add documentation for configuring the option system --- .../configuration-and-option-system.md | 246 ++++++++++++++++- docs/en/engines/zeta/rest-api-v2.md | 26 +- .../configuration-and-option-system.md | 254 +++++++++++++++++- docs/zh/engines/zeta/rest-api-v2.md | 26 +- .../util/ConditionEvaluators.java | 9 +- .../util/ConfigValidatorTest.java | 42 +++ 6 files changed, 586 insertions(+), 17 deletions(-) diff --git a/docs/en/architecture/configuration-and-option-system.md b/docs/en/architecture/configuration-and-option-system.md index caba6c62b817..94676c4a70df 100644 --- a/docs/en/architecture/configuration-and-option-system.md +++ b/docs/en/architecture/configuration-and-option-system.md @@ -37,6 +37,45 @@ An `Option` defines a single configuration field: This is the smallest reusable configuration contract in SeaTunnel. +Typical usage in a connector options class: + +```java +public static final Option PORT = + Options.key("port") + .intType() + .defaultValue(3306) + .withDescription("Database server port"); + +public static final Option HOST = + Options.key("host") + .stringType() + .noDefaultValue() + .withDescription("Database server hostname"); + +public static final Option> TABLES = + Options.key("tables") + .listType() + .noDefaultValue() + .withDescription("List of tables to read"); +``` + +`Options.key(...)` builder supports the following type methods: + +| Method | Java Type | +|--------|-----------| +| `stringType()` | `String` | +| `intType()` | `Integer` | +| `longType()` | `Long` | +| `doubleType()` | `Double` | +| `floatType()` | `Float` | +| `booleanType()` | `Boolean` | +| `listType()` | `List` | +| `listType(Class)` | `List` | +| `mapType()` | `Map` | +| `enumType(Class)` | `Enum` subclass | +| `singleChoice(Class, List)` | single-choice with allowed values | +| `type(new TypeReference() {})` | any custom type | + ### `OptionRule` An `OptionRule` describes how multiple options behave together. It can express rules such as: @@ -48,27 +87,228 @@ An `OptionRule` describes how multiple options behave together. It can express r This is how SeaTunnel moves beyond flat configuration and supports richer connector contracts. +A connector factory exposes its rules through the `optionRule()` method: + +```java +@Override +public OptionRule optionRule() { + return OptionRule.builder() + .required(HOST, PORT) // absolutely required + .exclusive(USERNAME, BEARER_TOKEN) // exactly one must be set + .bundled(USERNAME, PASSWORD) // all or none + .conditional(MODE, WriteMode.UPSERT, UPSERT_KEY) // required when MODE == UPSERT + .optional(BATCH_SIZE, RETRY_COUNT) // purely optional + .build(); +} +``` + +### Value Constraints (`Condition`) + +Beyond structural rules (required, exclusive, etc.), options can carry **value-level constraints** that the runtime validates before a job starts. The `Condition` API provides a fluent way to attach these constraints inside `OptionRule.builder()`. + +**Numeric range constraints:** + +```java +OptionRule.builder() + .required(PORT, + Condition.greaterOrEqual(PORT, 1) + .and(Condition.lessOrEqual(PORT, 65535))) + .build(); +``` + +**String constraints:** + +```java +OptionRule.builder() + .required(HOST, Condition.notBlank(HOST)) + .optional(DB_NAME, Condition.upperCase(DB_NAME)) + .build(); +``` + +**Cross-field comparison:** + +```java +OptionRule.builder() + .required(START_TS, END_TS, + Condition.lessThanField(START_TS, END_TS)) + .build(); +``` + +**Collection constraints:** + +```java +OptionRule.builder() + .required(TABLES, + Condition.notEmpty(TABLES) + .and(Condition.unique(TABLES))) + .build(); +``` + +Available operators: + +| Category | Operator | Description | +|----------|----------|-------------| +| Numeric | `greaterThan` | value > threshold | +| Numeric | `greaterOrEqual` | value >= threshold | +| Numeric | `lessThan` | value < threshold | +| Numeric | `lessOrEqual` | value <= threshold | +| String | `notBlank` | string is not empty or whitespace-only | +| String | `startsWith` | string starts with a given prefix | +| String | `endsWith` | string ends with a given suffix | +| String | `contains` | string contains a given substring | +| String | `matches` | string matches a regular expression | +| String | `upperCase` | string is all uppercase | +| String | `lowerCase` | string is all lowercase | +| String length | `lengthEqual` | string length == n | +| String length | `lengthGreaterOrEqual` | string length >= n | +| String length | `lengthLessOrEqual` | string length <= n | +| Collection | `notEmpty` | collection is not empty | +| Collection | `unique` | collection has no duplicate elements | +| Collection | `sizeEqual` | collection size == n | +| Collection | `sizeGreaterOrEqual` | collection size >= n | +| Collection | `sizeLessOrEqual` | collection size <= n | +| Cross-field | `lessThanField` | value < another option's value | +| Cross-field | `lessOrEqualField` | value <= another option's value | +| Cross-field | `greaterThanField` | value > another option's value | +| Cross-field | `greaterOrEqualField` | value >= another option's value | +| Cross-field | `fieldEqual` | value == another option's value | +| Cross-field | `fieldNotEqual` | value != another option's value | +| Cross-field | `fieldSizeEqual` | collection size == another collection's size | + +:::tip +Multiple conditions can be chained with `.and(...)` or `.or(...)` to form compound constraints. +::: + ### `ReadonlyConfig` `ReadonlyConfig` is the runtime container from which connectors and transforms read their resolved values. It gives plugin implementations a stable, typed access pattern after parsing and validation have already happened. -## End-To-End Flow +```java +@Override +public void prepare(Config pluginConfig) { + ReadonlyConfig config = ReadonlyConfig.fromConfig(pluginConfig); + String host = config.get(HOST); // typed access, never returns raw Object + int port = config.get(PORT); // default applied automatically if not set +} +``` + +## Validation Flow At a high level, configuration flows through the system like this: -1. A plugin defines `Option` and `OptionRule` metadata. +1. A plugin defines `Option` and `OptionRule` metadata (including value constraints). 2. A user writes HOCON, JSON, or SQL-based job configuration. 3. SeaTunnel parses the configuration into a runtime representation. -4. Validation applies the connector rules. +4. `ConfigValidator` checks structural rules (required, exclusive, bundled, conditional) and then evaluates value constraints by delegating each `Condition` to `ConditionEvaluators`. 5. The resolved values are exposed to the runtime through `ReadonlyConfig`. 6. The same metadata can also be exposed through REST for UI rendering and automation. +When validation fails, `OptionValidationException` is thrown with a message that includes the constraint expression and the option key involved. + +## OptionRule Pattern Guide + +Validation logic declared in `optionRule()` runs at job submission time, produces uniform error messages, and is automatically exposed to the REST API and Web UI. Placing validation in Config constructors or Writer/Reader code delays failure to task startup time and hides constraints from tooling. + +The following patterns cover common scenarios. Each one shows the recommended declarative form inside `OptionRule.builder()`. + +### Required fields + +Some fields must always be present. A job that omits them should be rejected at submission. + +```java +.required(HOST, PORT, DATABASE) +``` + +### Mutually exclusive options + +When only one of several options should be set at a time, `exclusive` enforces the constraint. + +```java +.exclusive(TOPIC, TOPIC_PATTERN) +``` + +### Bundled options + +A group of options that only make sense together. Either all of them are set or none. + +```java +.bundled(USERNAME, PASSWORD) +``` + +### Conditional required options driven by an enum + +When an enum option takes a specific value, additional fields become required. + +```java +.conditional(START_MODE, StartMode.TIMESTAMP, START_MODE_TIMESTAMP) +.conditional(START_MODE, StartMode.SPECIFIC_OFFSETS, START_MODE_OFFSETS) +``` + +### Conditional required options driven by a boolean + +A boolean toggle that activates different sets of required fields depending on its value. + +```java +.conditional(IS_EXACTLY_ONCE, true, XA_DATA_SOURCE_CLASS, TRANSACTION_TIMEOUT) +.conditional(IS_EXACTLY_ONCE, false, MAX_RETRIES) +``` + +### Numeric range + +Port numbers, batch sizes, ratios, and similar numeric fields often have valid ranges. + +```java +.required(PORT, + Condition.greaterOrEqual(PORT, 1) + .and(Condition.lessOrEqual(PORT, 65535))) +``` + +### String format and content + +Host names that must not be blank, identifiers that must be uppercase, or endpoints that must match a pattern. + +```java +.required(HOST, Condition.notBlank(HOST)) +.required(DATABASE, Condition.upperCase(DATABASE)) +.required(ENDPOINT, Condition.matches(ENDPOINT, "^[^:]+:\\d+$")) +``` + +### Cross-field comparison + +When the value of one option must be smaller or larger than another. + +```java +.required(START_TS, END_TS, + Condition.lessThanField(START_TS, END_TS)) +``` + +### Collection constraints + +Lists that must not be empty, or whose elements must be unique. + +```java +.required(TABLES, + Condition.notEmpty(TABLES) + .and(Condition.unique(TABLES))) +``` + +### Compound constraints + +Multiple conditions combined with `.and(...)` or `.or(...)`. + +```java +.required(RATIO, + Condition.greaterThan(RATIO, 0.0) + .and(Condition.lessOrEqual(RATIO, 1.0))) +``` + ## Why It Matters For Operators This architecture is also what makes the `option-rules` REST endpoint useful. Tools can inspect the runtime metadata of installed connectors and dynamically understand: - which fields are required - which fields are conditional +- what value constraints apply (numeric ranges, patterns, cross-field rules) - which defaults are active on the running server That is why the option system sits at the boundary of both developer experience and operations. diff --git a/docs/en/engines/zeta/rest-api-v2.md b/docs/en/engines/zeta/rest-api-v2.md index 22c013831e03..0232a7bcf93c 100644 --- a/docs/en/engines/zeta/rest-api-v2.md +++ b/docs/en/engines/zeta/rest-api-v2.md @@ -48,7 +48,7 @@ Please refer [security](security.md) > | name | type | data type | description | > |--------|----------|-----------|--------------------------------------------------------------------| -> | type | required | string | plugin type, currently supports `source` and `sink` | +> | type | required | string | plugin type, supports `source`, `sink` and `transform` | > | plugin | required | string | connector factory identifier, for example `FakeSource` or `Console` | #### Responses @@ -118,7 +118,27 @@ Please refer [security](security.md) } } ], - "conditionRules": [] + "conditionRules": [], + "valueConstraints": [ + { + "expression": "'row.num' >= 1", + "conditionTree": { + "option": { + "key": "row.num", + "type": "java.lang.Integer", + "defaultValue": 5, + "description": "The total number of data generated per degree of parallelism", + "fallbackKeys": [], + "optionValues": null + }, + "expectValue": 1, + "compareOperator": ">=", + "compareOption": null, + "operator": null, + "next": null + } + } + ] } } ``` @@ -128,6 +148,8 @@ Please refer [security](security.md) - `requiredOptions[].ruleType` can be `ABSOLUTELY_REQUIRED`, `EXCLUSIVE`, `BUNDLED`, or `CONDITIONAL`. - `optionRule.conditionRules` recursively exposes nested conditional option rules and is an empty array when the connector does not define nested rules. - For conditional rules, both `expression` and `expressionTree` are returned for dynamic form rendering. +- `optionRule.valueConstraints` exposes value-level validation rules (e.g. numeric ranges, string patterns, cross-field comparisons). Each entry contains a human-readable `expression` string and a structured `conditionTree`. The array is empty or `null` when the connector does not define value constraints. +- In `conditionTree`, `compareOperator` (e.g. `>=`, `<`, `>`) and `compareOption` are present for numeric and cross-field comparisons. For equality checks and non-comparison conditions these fields are `null`. diff --git a/docs/zh/architecture/configuration-and-option-system.md b/docs/zh/architecture/configuration-and-option-system.md index c1d58118c90a..db773a6cb4bd 100644 --- a/docs/zh/architecture/configuration-and-option-system.md +++ b/docs/zh/architecture/configuration-and-option-system.md @@ -37,6 +37,45 @@ SeaTunnel 通过以下几个核心构件把这三件事连接起来: 它是 SeaTunnel 配置契约中最小、最基础的单元。 +在 Connector 选项类中的典型用法: + +```java +public static final Option PORT = + Options.key("port") + .intType() + .defaultValue(3306) + .withDescription("Database server port"); + +public static final Option HOST = + Options.key("host") + .stringType() + .noDefaultValue() + .withDescription("Database server hostname"); + +public static final Option> TABLES = + Options.key("tables") + .listType() + .noDefaultValue() + .withDescription("List of tables to read"); +``` + +`Options.key(...)` 构建器支持以下类型方法: + +| 方法 | Java 类型 | +|------|-----------| +| `stringType()` | `String` | +| `intType()` | `Integer` | +| `longType()` | `Long` | +| `doubleType()` | `Double` | +| `floatType()` | `Float` | +| `booleanType()` | `Boolean` | +| `listType()` | `List` | +| `listType(Class)` | `List` | +| `mapType()` | `Map` | +| `enumType(Class)` | `Enum` 子类 | +| `singleChoice(Class, List)` | 单选,限定允许值列表 | +| `type(new TypeReference() {})` | 任意自定义类型 | + ### `OptionRule` `OptionRule` 用于表达多个配置项之间的组合规则,例如: @@ -48,20 +87,220 @@ SeaTunnel 通过以下几个核心构件把这三件事连接起来: 这也是 SeaTunnel 能够表达复杂连接器配置约束,而不仅仅是平铺参数列表的关键。 +Connector 的 Factory 通过 `optionRule()` 方法暴露其规则: + +```java +@Override +public OptionRule optionRule() { + return OptionRule.builder() + .required(HOST, PORT) // 必填项 + .exclusive(USERNAME, BEARER_TOKEN) // 互斥:只能设置其一 + .bundled(USERNAME, PASSWORD) // 成组:全部设置或全部不设 + .conditional(MODE, WriteMode.UPSERT, UPSERT_KEY) // 条件:当 MODE == UPSERT 时必填 + .optional(BATCH_SIZE, RETRY_COUNT) // 可选项 + .build(); +} +``` + +### 值约束(`Condition`) + +除了结构性规则(必填、互斥等),配置项还可以携带**值级别约束**,运行时会在作业启动前进行校验。`Condition` API 提供了一种流式方式,在 `OptionRule.builder()` 中附加这些约束。 + +**数值范围约束:** + +```java +OptionRule.builder() + .required(PORT, + Condition.greaterOrEqual(PORT, 1) + .and(Condition.lessOrEqual(PORT, 65535))) + .build(); +``` + +**字符串约束:** + +```java +OptionRule.builder() + .required(HOST, Condition.notBlank(HOST)) + .optional(DB_NAME, Condition.upperCase(DB_NAME)) + .build(); +``` + +**跨字段比较:** + +```java +OptionRule.builder() + .required(START_TS, END_TS, + Condition.lessThanField(START_TS, END_TS)) + .build(); +``` + +**集合约束:** + +```java +OptionRule.builder() + .required(TABLES, + Condition.notEmpty(TABLES) + .and(Condition.unique(TABLES))) + .build(); +``` + +可用的操作符: + +| 类别 | 操作符 | 说明 | +|------|--------|------| +| 数值 | `greaterThan` | 值 > 阈值 | +| 数值 | `greaterOrEqual` | 值 >= 阈值 | +| 数值 | `lessThan` | 值 < 阈值 | +| 数值 | `lessOrEqual` | 值 <= 阈值 | +| 字符串 | `notBlank` | 字符串非空且不全为空白字符 | +| 字符串 | `startsWith` | 字符串以指定前缀开头 | +| 字符串 | `endsWith` | 字符串以指定后缀结尾 | +| 字符串 | `contains` | 字符串包含指定子串 | +| 字符串 | `matches` | 字符串匹配正则表达式 | +| 字符串 | `upperCase` | 字符串全部大写 | +| 字符串 | `lowerCase` | 字符串全部小写 | +| 字符串长度 | `lengthEqual` | 字符串长度 == n | +| 字符串长度 | `lengthGreaterOrEqual` | 字符串长度 >= n | +| 字符串长度 | `lengthLessOrEqual` | 字符串长度 <= n | +| 集合 | `notEmpty` | 集合非空 | +| 集合 | `unique` | 集合元素无重复 | +| 集合 | `sizeEqual` | 集合大小 == n | +| 集合 | `sizeGreaterOrEqual` | 集合大小 >= n | +| 集合 | `sizeLessOrEqual` | 集合大小 <= n | +| 跨字段 | `lessThanField` | 值 < 另一个配置项的值 | +| 跨字段 | `lessOrEqualField` | 值 <= 另一个配置项的值 | +| 跨字段 | `greaterThanField` | 值 > 另一个配置项的值 | +| 跨字段 | `greaterOrEqualField` | 值 >= 另一个配置项的值 | +| 跨字段 | `fieldEqual` | 值 == 另一个配置项的值 | +| 跨字段 | `fieldNotEqual` | 值 != 另一个配置项的值 | +| 跨字段 | `fieldSizeEqual` | 集合大小 == 另一个集合的大小 | + +:::tip +多个条件可以通过 `.and(...)` 或 `.or(...)` 链式组合成复合约束。 +::: + ### `ReadonlyConfig` `ReadonlyConfig` 是运行时读取参数的统一容器。配置经过解析与校验后,Connector 和 Transform 会从这里以稳定、类型化的方式获取最终值。 -## 端到端的数据流 +```java +@Override +public void prepare(Config pluginConfig) { + ReadonlyConfig config = ReadonlyConfig.fromConfig(pluginConfig); + String host = config.get(HOST); // 类型化访问,不会返回原始 Object + int port = config.get(PORT); // 未设置时自动应用默认值 +} +``` + +## 校验流程 从整体上看,配置会沿着下面的链路在系统内流动: -1. 插件定义 `Option` 与 `OptionRule` -2. 用户编写 HOCON、JSON 或 SQL 配置 -3. SeaTunnel 解析配置 -4. 校验器根据规则执行校验 -5. 运行时通过 `ReadonlyConfig` 获取已解析参数 -6. 同一套元数据还可以通过 REST 暴露给 UI 或自动化系统 +1. 插件定义 `Option` 与 `OptionRule`(包括值约束)。 +2. 用户编写 HOCON、JSON 或 SQL 配置。 +3. SeaTunnel 解析配置。 +4. `ConfigValidator` 先检查结构性规则(必填、互斥、成组、条件),再将每个 `Condition` 委托给 `ConditionEvaluators` 执行值约束校验。 +5. 运行时通过 `ReadonlyConfig` 获取已解析参数。 +6. 同一套元数据还可以通过 REST 暴露给 UI 或自动化系统。 + +校验失败时,`OptionValidationException` 会被抛出,异常消息中包含约束表达式和涉及的配置项 key。 + +## OptionRule 模式编码指南 + +在 `optionRule()` 中声明的校验逻辑会在作业提交时执行,产出统一格式的错误消息,且自动暴露给 REST API 和 Web UI。如果把校验写在 Config 构造器或 Writer/Reader 中,失败时机会推迟到任务调度之后,工具侧也无法感知这些约束。 + +以下按常见场景列出推荐的声明式写法,均在 `OptionRule.builder()` 中使用。 + +### 必填字段 + +某些字段必须配置,缺少时作业在提交阶段即被拒绝。 + +```java +.required(HOST, PORT, DATABASE) +``` + +### 互斥选项 + +多个选项中只能选择一个,同时配置会报错。 + +```java +.exclusive(TOPIC, TOPIC_PATTERN) +``` + +### 成组选项 + +一组选项要么全部配置,要么全部留空。 + +```java +.bundled(USERNAME, PASSWORD) +``` + +### 条件必填(枚举驱动) + +当某个枚举字段取特定值时,另一些字段才变为必填。 + +```java +.conditional(START_MODE, StartMode.TIMESTAMP, START_MODE_TIMESTAMP) +.conditional(START_MODE, StartMode.SPECIFIC_OFFSETS, START_MODE_OFFSETS) +``` + +### 条件必填(布尔驱动) + +布尔开关控制不同的必填字段集合。 + +```java +.conditional(IS_EXACTLY_ONCE, true, XA_DATA_SOURCE_CLASS, TRANSACTION_TIMEOUT) +.conditional(IS_EXACTLY_ONCE, false, MAX_RETRIES) +``` + +### 数值范围 + +端口号、batch size、比率等数值字段通常有合法范围。 + +```java +.required(PORT, + Condition.greaterOrEqual(PORT, 1) + .and(Condition.lessOrEqual(PORT, 65535))) +``` + +### 字符串格式与内容 + +字段不能为空白、标识符必须全大写、或需要匹配特定格式。 + +```java +.required(HOST, Condition.notBlank(HOST)) +.required(DATABASE, Condition.upperCase(DATABASE)) +.required(ENDPOINT, Condition.matches(ENDPOINT, "^[^:]+:\\d+$")) +``` + +### 跨字段比较 + +一个字段的值必须小于或大于另一个字段。 + +```java +.required(START_TS, END_TS, + Condition.lessThanField(START_TS, END_TS)) +``` + +### 集合约束 + +列表不能为空,或元素不能重复。 + +```java +.required(TABLES, + Condition.notEmpty(TABLES) + .and(Condition.unique(TABLES))) +``` + +### 复合约束 + +多个条件通过 `.and(...)` 或 `.or(...)` 链式组合。 + +```java +.required(RATIO, + Condition.greaterThan(RATIO, 0.0) + .and(Condition.lessOrEqual(RATIO, 1.0))) +``` ## 为什么这对运维也重要 @@ -69,6 +308,7 @@ SeaTunnel 通过以下几个核心构件把这三件事连接起来: - 哪些字段是必填的 - 哪些字段受条件约束 +- 有哪些值约束(数值范围、字符串模式、跨字段规则) - 当前服务端实际安装 Connector 的默认值和规则 因此,配置与 Option 系统不仅是开发者能力,也是运维能力的一部分。 diff --git a/docs/zh/engines/zeta/rest-api-v2.md b/docs/zh/engines/zeta/rest-api-v2.md index bf5a5686197b..f91161e0cdc4 100644 --- a/docs/zh/engines/zeta/rest-api-v2.md +++ b/docs/zh/engines/zeta/rest-api-v2.md @@ -46,7 +46,7 @@ seatunnel: > | 参数名称 | 是否必传 | 参数类型 | 参数描述 | > |--------|------|------|-----------------------------------------| -> | type | 是 | string | 插件类型,当前支持 `source` 和 `sink` | +> | type | 是 | string | 插件类型,支持 `source`、`sink` 和 `transform` | > | plugin | 是 | string | connector 的 factory identifier,例如 `FakeSource` 或 `Console` | #### 响应 @@ -116,7 +116,27 @@ seatunnel: } } ], - "conditionRules": [] + "conditionRules": [], + "valueConstraints": [ + { + "expression": "'row.num' >= 1", + "conditionTree": { + "option": { + "key": "row.num", + "type": "java.lang.Integer", + "defaultValue": 5, + "description": "The total number of data generated per degree of parallelism", + "fallbackKeys": [], + "optionValues": null + }, + "expectValue": 1, + "compareOperator": ">=", + "compareOption": null, + "operator": null, + "next": null + } + } + ] } } ``` @@ -126,6 +146,8 @@ seatunnel: - `requiredOptions[].ruleType` 可能是 `ABSOLUTELY_REQUIRED`、`EXCLUSIVE`、`BUNDLED` 或 `CONDITIONAL`。 - `optionRule.conditionRules` 会递归返回嵌套条件规则;当 connector 未定义嵌套规则时,该字段返回空数组。 - 对于条件规则,会同时返回 `expression` 和 `expressionTree`,便于 Web 做动态表单渲染。 +- `optionRule.valueConstraints` 暴露值级别的校验规则(如数值范围、字符串模式、跨字段比较)。每个条目包含可读的 `expression` 字符串和结构化的 `conditionTree`。当 connector 未定义值约束时,该数组为空或 `null`。 +- 在 `conditionTree` 中,`compareOperator`(如 `>=`、`<`、`>`)和 `compareOption` 用于数值比较和跨字段比较;对于等值检查和非比较类条件,这两个字段为 `null`。 diff --git a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java index 21862fc9f17e..acccf083f407 100644 --- a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java +++ b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java @@ -217,10 +217,13 @@ static int compareNumbers(Object a, Object b) { throw new OptionValidationException("Cannot compare null values in numeric comparison"); } if (a instanceof Number && b instanceof Number) { - if (a instanceof Long || b instanceof Long) { - return Long.compare(((Number) a).longValue(), ((Number) b).longValue()); + if (a instanceof Double + || b instanceof Double + || a instanceof Float + || b instanceof Float) { + return Double.compare(((Number) a).doubleValue(), ((Number) b).doubleValue()); } - return Double.compare(((Number) a).doubleValue(), ((Number) b).doubleValue()); + return Long.compare(((Number) a).longValue(), ((Number) b).longValue()); } if (a instanceof Comparable && b instanceof Comparable) { return ((Comparable) a).compareTo(b); 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 dc1f84021c34..dcc98523ca26 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 @@ -1452,4 +1452,46 @@ public void testLargeLongLiteralComparison() { config.put(START_TS.key(), Long.MAX_VALUE); assertThrows(OptionValidationException.class, () -> validate(config, rule)); } + + @Test + public void testCompareNumbersLongVsDouble() { + Assertions.assertTrue(ConditionEvaluators.compareNumbers(1L, 1.5) < 0); + Assertions.assertTrue(ConditionEvaluators.compareNumbers(2L, 1.5) > 0); + Assertions.assertEquals(0, ConditionEvaluators.compareNumbers(1L, 1.0)); + } + + @Test + public void testCompareNumbers_FloatVsLong() { + Assertions.assertTrue(ConditionEvaluators.compareNumbers(2.7f, 2L) > 0); + Assertions.assertTrue(ConditionEvaluators.compareNumbers(1.3f, 2L) < 0); + Assertions.assertEquals(0, ConditionEvaluators.compareNumbers(3.0f, 3L)); + } + + @Test + public void testCompareNumbersIntegerVsDouble() { + Assertions.assertTrue(ConditionEvaluators.compareNumbers(1, 1.5) < 0); + Assertions.assertTrue(ConditionEvaluators.compareNumbers(2, 1.5) > 0); + Assertions.assertEquals(0, ConditionEvaluators.compareNumbers(1, 1.0)); + } + + @Test + public void testCompareNumbersFloatVsDouble() { + Assertions.assertTrue(ConditionEvaluators.compareNumbers(1.0f, 1.5) < 0); + Assertions.assertTrue(ConditionEvaluators.compareNumbers(2.0f, 1.5) > 0); + Assertions.assertEquals(0, ConditionEvaluators.compareNumbers(1.0f, 1.0)); + } + + @Test + public void testCompareNumbersIntegerVsLong() { + Assertions.assertTrue(ConditionEvaluators.compareNumbers(1, 2L) < 0); + Assertions.assertTrue(ConditionEvaluators.compareNumbers(3, 2L) > 0); + Assertions.assertEquals(0, ConditionEvaluators.compareNumbers(5, 5L)); + } + + @Test + public void testCompareNumbersIntegerVsFloat() { + Assertions.assertTrue(ConditionEvaluators.compareNumbers(1, 1.5f) < 0); + Assertions.assertTrue(ConditionEvaluators.compareNumbers(2, 1.5f) > 0); + Assertions.assertEquals(0, ConditionEvaluators.compareNumbers(1, 1.0f)); + } } From 267868700d207562d1aff8714502aabe34375a52 Mon Sep 17 00:00:00 2001 From: "zhiwei.niu" Date: Mon, 1 Jun 2026 15:44:03 +0800 Subject: [PATCH 08/16] [Feature][API] Optimization condition comparator --- .../util/ConditionEvaluators.java | 38 +++++++++++++---- .../util/ConfigValidatorTest.java | 42 ------------------- 2 files changed, 30 insertions(+), 50 deletions(-) diff --git a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java index acccf083f407..7f68fffa2f0f 100644 --- a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java +++ b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java @@ -19,6 +19,7 @@ import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import java.math.BigDecimal; import java.util.Collection; import java.util.Collections; import java.util.EnumMap; @@ -217,19 +218,40 @@ static int compareNumbers(Object a, Object b) { throw new OptionValidationException("Cannot compare null values in numeric comparison"); } if (a instanceof Number && b instanceof Number) { - if (a instanceof Double - || b instanceof Double - || a instanceof Float - || b instanceof Float) { - return Double.compare(((Number) a).doubleValue(), ((Number) b).doubleValue()); - } - return Long.compare(((Number) a).longValue(), ((Number) b).longValue()); + return compareNumberValues((Number) a, (Number) b); } if (a instanceof Comparable && b instanceof Comparable) { return ((Comparable) a).compareTo(b); } throw new OptionValidationException( - "Cannot compare values of type %s and %s", + "Cannot compare non-numeric values of type %s and %s", a.getClass().getName(), b.getClass().getName()); } + + private static int compareNumberValues(Number a, Number b) { + if (a instanceof Long && b instanceof Long) { + return Long.compare((Long) a, (Long) b); + } + if (a instanceof Integer && b instanceof Integer) { + return Integer.compare((Integer) a, (Integer) b); + } + if (a instanceof Short && b instanceof Short) { + return Short.compare((Short) a, (Short) b); + } + if (a instanceof Byte && b instanceof Byte) { + return Byte.compare((Byte) a, (Byte) b); + } + if (a instanceof Double && b instanceof Double) { + return Double.compare((Double) a, (Double) b); + } + if (a instanceof Float && b instanceof Float) { + return Float.compare((Float) a, (Float) b); + } + if (a instanceof BigDecimal && b instanceof BigDecimal) { + return ((BigDecimal) a).compareTo((BigDecimal) b); + } + throw new OptionValidationException( + "Numeric type mismatch: cannot compare %s with %s, both sides of a numeric condition must be the same type", + a.getClass().getSimpleName(), b.getClass().getSimpleName()); + } } 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 dcc98523ca26..dc1f84021c34 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 @@ -1452,46 +1452,4 @@ public void testLargeLongLiteralComparison() { config.put(START_TS.key(), Long.MAX_VALUE); assertThrows(OptionValidationException.class, () -> validate(config, rule)); } - - @Test - public void testCompareNumbersLongVsDouble() { - Assertions.assertTrue(ConditionEvaluators.compareNumbers(1L, 1.5) < 0); - Assertions.assertTrue(ConditionEvaluators.compareNumbers(2L, 1.5) > 0); - Assertions.assertEquals(0, ConditionEvaluators.compareNumbers(1L, 1.0)); - } - - @Test - public void testCompareNumbers_FloatVsLong() { - Assertions.assertTrue(ConditionEvaluators.compareNumbers(2.7f, 2L) > 0); - Assertions.assertTrue(ConditionEvaluators.compareNumbers(1.3f, 2L) < 0); - Assertions.assertEquals(0, ConditionEvaluators.compareNumbers(3.0f, 3L)); - } - - @Test - public void testCompareNumbersIntegerVsDouble() { - Assertions.assertTrue(ConditionEvaluators.compareNumbers(1, 1.5) < 0); - Assertions.assertTrue(ConditionEvaluators.compareNumbers(2, 1.5) > 0); - Assertions.assertEquals(0, ConditionEvaluators.compareNumbers(1, 1.0)); - } - - @Test - public void testCompareNumbersFloatVsDouble() { - Assertions.assertTrue(ConditionEvaluators.compareNumbers(1.0f, 1.5) < 0); - Assertions.assertTrue(ConditionEvaluators.compareNumbers(2.0f, 1.5) > 0); - Assertions.assertEquals(0, ConditionEvaluators.compareNumbers(1.0f, 1.0)); - } - - @Test - public void testCompareNumbersIntegerVsLong() { - Assertions.assertTrue(ConditionEvaluators.compareNumbers(1, 2L) < 0); - Assertions.assertTrue(ConditionEvaluators.compareNumbers(3, 2L) > 0); - Assertions.assertEquals(0, ConditionEvaluators.compareNumbers(5, 5L)); - } - - @Test - public void testCompareNumbersIntegerVsFloat() { - Assertions.assertTrue(ConditionEvaluators.compareNumbers(1, 1.5f) < 0); - Assertions.assertTrue(ConditionEvaluators.compareNumbers(2, 1.5f) > 0); - Assertions.assertEquals(0, ConditionEvaluators.compareNumbers(1, 1.0f)); - } } From 871e6a589b994fba1baea04798544471dc327b24 Mon Sep 17 00:00:00 2001 From: "zhiwei.niu" Date: Mon, 1 Jun 2026 19:03:42 +0800 Subject: [PATCH 09/16] [Fix][Api] Unified naming of option rule parameter list --- .../api/configuration/util/OptionRule.java | 55 +++++++++---------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionRule.java b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionRule.java index db8bbd74ab7a..a5e73f74fdd2 100644 --- a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionRule.java +++ b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionRule.java @@ -310,14 +310,14 @@ public Builder conditionalRule( /** Absolutely required option with value constraints (at least one condition). */ public Builder required( @NonNull Option option, - @NonNull Condition firstCondition, - @NonNull Condition... moreConditions) { + @NonNull Condition condition1, + @NonNull Condition... conditions) { RequiredOption.AbsolutelyRequiredOptions requiredOption = RequiredOption.AbsolutelyRequiredOptions.of(option); verifyRequiredOptionDuplicate(requiredOption); this.requiredOptions.add(requiredOption); - this.valueConstraints.add(firstCondition); - Collections.addAll(this.valueConstraints, moreConditions); + this.valueConstraints.add(condition1); + Collections.addAll(this.valueConstraints, conditions); return this; } @@ -329,26 +329,26 @@ public Builder required( public Builder required( @NonNull Option option1, @NonNull Option option2, - @NonNull Condition firstCondition, - @NonNull Condition... moreConditions) { + @NonNull Condition condition1, + @NonNull Condition... conditions) { RequiredOption.AbsolutelyRequiredOptions requiredOption = RequiredOption.AbsolutelyRequiredOptions.of(option1, option2); verifyRequiredOptionDuplicate(requiredOption); this.requiredOptions.add(requiredOption); - this.valueConstraints.add(firstCondition); - Collections.addAll(this.valueConstraints, moreConditions); + this.valueConstraints.add(condition1); + Collections.addAll(this.valueConstraints, conditions); return this; } /** Optional option with value constraints (at least one condition). */ public Builder optional( @NonNull Option option, - @NonNull Condition firstCondition, - @NonNull Condition... moreConditions) { + @NonNull Condition condition1, + @NonNull Condition... conditions) { verifyOptionOptionsDuplicate(option, "OptionsOption"); this.optionalOptions.add(option); - this.valueConstraints.add(firstCondition); - Collections.addAll(this.valueConstraints, moreConditions); + this.valueConstraints.add(condition1); + Collections.addAll(this.valueConstraints, conditions); return this; } @@ -356,14 +356,14 @@ public Builder optional( public Builder optional( @NonNull Option option1, @NonNull Option option2, - @NonNull Condition firstCondition, - @NonNull Condition... moreConditions) { + @NonNull Condition condition1, + @NonNull Condition... conditions) { verifyOptionOptionsDuplicate(option1, "OptionsOption"); verifyOptionOptionsDuplicate(option2, "OptionsOption"); this.optionalOptions.add(option1); this.optionalOptions.add(option2); - this.valueConstraints.add(firstCondition); - Collections.addAll(this.valueConstraints, moreConditions); + this.valueConstraints.add(condition1); + Collections.addAll(this.valueConstraints, conditions); return this; } @@ -374,13 +374,13 @@ public Builder optional( public Builder conditional( @NonNull Option conditionalOption, @NonNull T expectValue, - @NonNull Condition firstCondition, - @NonNull Condition... moreConditions) { + @NonNull Condition condition1, + @NonNull Condition... conditions) { verifyConditionalExists(conditionalOption); Expression expression = Expression.of(Condition.of(conditionalOption, expectValue)); List> allConditions = new ArrayList<>(); - allConditions.add(firstCondition); - Collections.addAll(allConditions, moreConditions); + allConditions.add(condition1); + Collections.addAll(allConditions, conditions); mergeConditionalRule(expression, Collections.emptyList(), allConditions); return this; } @@ -392,18 +392,17 @@ public Builder conditional( public Builder conditional( @NonNull Option conditionalOption, @NonNull T expectValue, - @NonNull Option requiredOption1, - @NonNull Option requiredOption2, - @NonNull Condition firstCondition, - @NonNull Condition... moreConditions) { + @NonNull Option option1, + @NonNull Option option2, + @NonNull Condition condition1, + @NonNull Condition... conditions) { verifyConditionalExists(conditionalOption); Expression expression = Expression.of(Condition.of(conditionalOption, expectValue)); List> allConditions = new ArrayList<>(); - allConditions.add(firstCondition); - Collections.addAll(allConditions, moreConditions); + allConditions.add(condition1); + Collections.addAll(allConditions, conditions); List reqList = new ArrayList<>(); - reqList.add( - RequiredOption.AbsolutelyRequiredOptions.of(requiredOption1, requiredOption2)); + reqList.add(RequiredOption.AbsolutelyRequiredOptions.of(option1, option2)); mergeConditionalRule(expression, reqList, allConditions); return this; } From 728e3ee40aada97769153bb46fc9ad54482e0796 Mon Sep 17 00:00:00 2001 From: "zhiwei.niu" Date: Tue, 2 Jun 2026 00:24:42 +0800 Subject: [PATCH 10/16] [Fix][Api] Enhance OptionRule with declarative value constraints --- .../configuration-and-option-system.md | 26 +- docs/en/engines/zeta/rest-api-v2.md | 7 +- .../concepts/incompatible-changes.md | 6 + .../configuration-and-option-system.md | 26 +- docs/zh/engines/zeta/rest-api-v2.md | 7 +- .../concepts/incompatible-changes.md | 6 + .../api/configuration/util/Condition.java | 130 +- .../util/ConditionEvaluators.java | 170 +-- .../configuration/util/ConditionOperator.java | 124 +- .../api/configuration/util/Conditions.java | 107 ++ .../configuration/util/ConfigValidator.java | 67 +- .../api/configuration/util/OptionRule.java | 12 +- .../util/ConfigValidatorTest.java | 1176 +++++++++++------ .../rest/response/OptionRuleResponse.java | 8 +- .../rest/service/OptionRulesService.java | 4 + .../rest/service/OptionRulesServiceTest.java | 12 +- 16 files changed, 1069 insertions(+), 819 deletions(-) create mode 100644 seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Conditions.java diff --git a/docs/en/architecture/configuration-and-option-system.md b/docs/en/architecture/configuration-and-option-system.md index 94676c4a70df..5185d5782019 100644 --- a/docs/en/architecture/configuration-and-option-system.md +++ b/docs/en/architecture/configuration-and-option-system.md @@ -111,8 +111,8 @@ Beyond structural rules (required, exclusive, etc.), options can carry **value-l ```java OptionRule.builder() .required(PORT, - Condition.greaterOrEqual(PORT, 1) - .and(Condition.lessOrEqual(PORT, 65535))) + Conditions.greaterOrEqual(PORT, 1) + .and(Conditions.lessOrEqual(PORT, 65535))) .build(); ``` @@ -120,7 +120,7 @@ OptionRule.builder() ```java OptionRule.builder() - .required(HOST, Condition.notBlank(HOST)) + .required(HOST, Conditions.notBlank(HOST)) .optional(DB_NAME, Condition.upperCase(DB_NAME)) .build(); ``` @@ -139,8 +139,8 @@ OptionRule.builder() ```java OptionRule.builder() .required(TABLES, - Condition.notEmpty(TABLES) - .and(Condition.unique(TABLES))) + Conditions.notEmpty(TABLES) + .and(Conditions.unique(TABLES))) .build(); ``` @@ -259,8 +259,8 @@ Port numbers, batch sizes, ratios, and similar numeric fields often have valid r ```java .required(PORT, - Condition.greaterOrEqual(PORT, 1) - .and(Condition.lessOrEqual(PORT, 65535))) + Conditions.greaterOrEqual(PORT, 1) + .and(Conditions.lessOrEqual(PORT, 65535))) ``` ### String format and content @@ -268,9 +268,9 @@ Port numbers, batch sizes, ratios, and similar numeric fields often have valid r Host names that must not be blank, identifiers that must be uppercase, or endpoints that must match a pattern. ```java -.required(HOST, Condition.notBlank(HOST)) +.required(HOST, Conditions.notBlank(HOST)) .required(DATABASE, Condition.upperCase(DATABASE)) -.required(ENDPOINT, Condition.matches(ENDPOINT, "^[^:]+:\\d+$")) +.required(ENDPOINT, Conditions.matches(ENDPOINT, "^[^:]+:\\d+$")) ``` ### Cross-field comparison @@ -288,8 +288,8 @@ Lists that must not be empty, or whose elements must be unique. ```java .required(TABLES, - Condition.notEmpty(TABLES) - .and(Condition.unique(TABLES))) + Conditions.notEmpty(TABLES) + .and(Conditions.unique(TABLES))) ``` ### Compound constraints @@ -298,8 +298,8 @@ Multiple conditions combined with `.and(...)` or `.or(...)`. ```java .required(RATIO, - Condition.greaterThan(RATIO, 0.0) - .and(Condition.lessOrEqual(RATIO, 1.0))) + Conditions.greaterThan(RATIO, 0.0) + .and(Conditions.lessOrEqual(RATIO, 1.0))) ``` ## Why It Matters For Operators diff --git a/docs/en/engines/zeta/rest-api-v2.md b/docs/en/engines/zeta/rest-api-v2.md index 0232a7bcf93c..ffbd57591bf9 100644 --- a/docs/en/engines/zeta/rest-api-v2.md +++ b/docs/en/engines/zeta/rest-api-v2.md @@ -134,6 +134,8 @@ Please refer [security](security.md) "expectValue": 1, "compareOperator": ">=", "compareOption": null, + "conditionOperator": "GREATER_OR_EQUAL", + "conditionOperatorCategory": "NUMERIC", "operator": null, "next": null } @@ -148,8 +150,9 @@ Please refer [security](security.md) - `requiredOptions[].ruleType` can be `ABSOLUTELY_REQUIRED`, `EXCLUSIVE`, `BUNDLED`, or `CONDITIONAL`. - `optionRule.conditionRules` recursively exposes nested conditional option rules and is an empty array when the connector does not define nested rules. - For conditional rules, both `expression` and `expressionTree` are returned for dynamic form rendering. -- `optionRule.valueConstraints` exposes value-level validation rules (e.g. numeric ranges, string patterns, cross-field comparisons). Each entry contains a human-readable `expression` string and a structured `conditionTree`. The array is empty or `null` when the connector does not define value constraints. -- In `conditionTree`, `compareOperator` (e.g. `>=`, `<`, `>`) and `compareOption` are present for numeric and cross-field comparisons. For equality checks and non-comparison conditions these fields are `null`. +- `optionRule.valueConstraints` describes value-level validation rules such as numeric ranges, string patterns, and cross-field comparisons. Each entry provides a human-readable `expression` string alongside a structured `conditionTree` for programmatic use. This array is empty when the connector does not define any value constraints. +- Within `conditionTree`, the `compareOperator` field (e.g. `>=`, `<`, `>`) and `compareOption` field are populated for numeric and cross-field comparisons. For equality checks and other non-comparison conditions, these fields are `null`. +- The `conditionOperator` field provides a stable, machine-readable operator identifier (e.g. `GREATER_OR_EQUAL`, `NOT_BLANK`, `FIELD_LESS_THAN`), while `conditionOperatorCategory` indicates the operator's category (e.g. `NUMERIC`, `STRING`, `COLLECTION`, `EQUALITY`). These two fields are designed for programmatic consumption by frontend applications and automation tools. diff --git a/docs/en/introduction/concepts/incompatible-changes.md b/docs/en/introduction/concepts/incompatible-changes.md index 93fb3b75eb29..55577fd6f8a3 100644 --- a/docs/en/introduction/concepts/incompatible-changes.md +++ b/docs/en/introduction/concepts/incompatible-changes.md @@ -31,6 +31,12 @@ You need to check this document before you upgrade to related version. } ``` +- **Breaking Change: `Condition.of(option, null)` no longer allowed** + - **Affected component**: `seatunnel-api` — `org.apache.seatunnel.api.configuration.util.Condition` + - **Description**: The `Condition` constructor now validates that binary literal operators (such as `EQUAL`, `NOT_EQUAL`, `GREATER_THAN`, etc.) must have a non-null `expectValue`. Previously, `Condition.of(option, null)` was silently accepted; it now throws `IllegalArgumentException` at construction time. + - **Impact**: No production code in the main repository uses `Condition.of(option, null)`, so the practical impact is zero. However, any custom or third-party connector code that relied on this pattern will need to be updated. + - **Migration Guide**: If you need to check whether an option is absent or unset, use `Condition.notBlank(option)` (for strings) or handle the absence at the `OptionRule.Builder` level with `optional(...)` instead of passing `null` as the expected value. + ### Configuration Changes ### Connector Changes diff --git a/docs/zh/architecture/configuration-and-option-system.md b/docs/zh/architecture/configuration-and-option-system.md index db773a6cb4bd..35b125655ce2 100644 --- a/docs/zh/architecture/configuration-and-option-system.md +++ b/docs/zh/architecture/configuration-and-option-system.md @@ -111,8 +111,8 @@ public OptionRule optionRule() { ```java OptionRule.builder() .required(PORT, - Condition.greaterOrEqual(PORT, 1) - .and(Condition.lessOrEqual(PORT, 65535))) + Conditions.greaterOrEqual(PORT, 1) + .and(Conditions.lessOrEqual(PORT, 65535))) .build(); ``` @@ -120,7 +120,7 @@ OptionRule.builder() ```java OptionRule.builder() - .required(HOST, Condition.notBlank(HOST)) + .required(HOST, Conditions.notBlank(HOST)) .optional(DB_NAME, Condition.upperCase(DB_NAME)) .build(); ``` @@ -139,8 +139,8 @@ OptionRule.builder() ```java OptionRule.builder() .required(TABLES, - Condition.notEmpty(TABLES) - .and(Condition.unique(TABLES))) + Conditions.notEmpty(TABLES) + .and(Conditions.unique(TABLES))) .build(); ``` @@ -259,8 +259,8 @@ public void prepare(Config pluginConfig) { ```java .required(PORT, - Condition.greaterOrEqual(PORT, 1) - .and(Condition.lessOrEqual(PORT, 65535))) + Conditions.greaterOrEqual(PORT, 1) + .and(Conditions.lessOrEqual(PORT, 65535))) ``` ### 字符串格式与内容 @@ -268,9 +268,9 @@ public void prepare(Config pluginConfig) { 字段不能为空白、标识符必须全大写、或需要匹配特定格式。 ```java -.required(HOST, Condition.notBlank(HOST)) +.required(HOST, Conditions.notBlank(HOST)) .required(DATABASE, Condition.upperCase(DATABASE)) -.required(ENDPOINT, Condition.matches(ENDPOINT, "^[^:]+:\\d+$")) +.required(ENDPOINT, Conditions.matches(ENDPOINT, "^[^:]+:\\d+$")) ``` ### 跨字段比较 @@ -288,8 +288,8 @@ public void prepare(Config pluginConfig) { ```java .required(TABLES, - Condition.notEmpty(TABLES) - .and(Condition.unique(TABLES))) + Conditions.notEmpty(TABLES) + .and(Conditions.unique(TABLES))) ``` ### 复合约束 @@ -298,8 +298,8 @@ public void prepare(Config pluginConfig) { ```java .required(RATIO, - Condition.greaterThan(RATIO, 0.0) - .and(Condition.lessOrEqual(RATIO, 1.0))) + Conditions.greaterThan(RATIO, 0.0) + .and(Conditions.lessOrEqual(RATIO, 1.0))) ``` ## 为什么这对运维也重要 diff --git a/docs/zh/engines/zeta/rest-api-v2.md b/docs/zh/engines/zeta/rest-api-v2.md index f91161e0cdc4..6443574024df 100644 --- a/docs/zh/engines/zeta/rest-api-v2.md +++ b/docs/zh/engines/zeta/rest-api-v2.md @@ -132,6 +132,8 @@ seatunnel: "expectValue": 1, "compareOperator": ">=", "compareOption": null, + "conditionOperator": "GREATER_OR_EQUAL", + "conditionOperatorCategory": "NUMERIC", "operator": null, "next": null } @@ -146,8 +148,9 @@ seatunnel: - `requiredOptions[].ruleType` 可能是 `ABSOLUTELY_REQUIRED`、`EXCLUSIVE`、`BUNDLED` 或 `CONDITIONAL`。 - `optionRule.conditionRules` 会递归返回嵌套条件规则;当 connector 未定义嵌套规则时,该字段返回空数组。 - 对于条件规则,会同时返回 `expression` 和 `expressionTree`,便于 Web 做动态表单渲染。 -- `optionRule.valueConstraints` 暴露值级别的校验规则(如数值范围、字符串模式、跨字段比较)。每个条目包含可读的 `expression` 字符串和结构化的 `conditionTree`。当 connector 未定义值约束时,该数组为空或 `null`。 -- 在 `conditionTree` 中,`compareOperator`(如 `>=`、`<`、`>`)和 `compareOption` 用于数值比较和跨字段比较;对于等值检查和非比较类条件,这两个字段为 `null`。 +- `optionRule.valueConstraints` 描述值级别的校验规则,包括数值范围、字符串模式匹配以及跨字段比较等。每个条目同时提供人类可读的 `expression` 字符串和便于程序处理的结构化 `conditionTree`。当连接器未定义值约束时,该数组为空。 +- 在 `conditionTree` 中,`compareOperator` 字段(如 `>=`、`<`、`>`)和 `compareOption` 字段用于数值比较和跨字段比较场景;对于等值判断及其他非比较类条件,这两个字段为 `null`。 +- `conditionOperator` 字段提供稳定的、机器可读的操作符标识(如 `GREATER_OR_EQUAL`、`NOT_BLANK`、`FIELD_LESS_THAN`),`conditionOperatorCategory` 字段标明操作符所属分类(如 `NUMERIC`、`STRING`、`COLLECTION`、`EQUALITY`)。这两个字段专为前端应用和自动化工具的程序化消费而设计。 diff --git a/docs/zh/introduction/concepts/incompatible-changes.md b/docs/zh/introduction/concepts/incompatible-changes.md index 900fdfd126a2..16e84b2342f4 100644 --- a/docs/zh/introduction/concepts/incompatible-changes.md +++ b/docs/zh/introduction/concepts/incompatible-changes.md @@ -30,6 +30,12 @@ } ``` +- **破坏性变更:`Condition.of(option, null)` 不再允许** + - **影响范围**:`seatunnel-api` — `org.apache.seatunnel.api.configuration.util.Condition` + - **变更说明**:`Condition` 构造器新增校验:二元字面量操作符(如 `EQUAL`、`NOT_EQUAL`、`GREATER_THAN` 等)的 `expectValue` 不能为 null。此前 `Condition.of(option, null)` 会被静默接受,现在会在构造时抛出 `IllegalArgumentException`。 + - **影响**:主仓库中没有任何生产代码使用 `Condition.of(option, null)`,实际影响为零。但如果自定义或第三方连接器代码依赖了这一用法,则需要修改。 + - **迁移指南**:如需检测某个 option 是否缺省或未配置,请使用 `Condition.notBlank(option)`(针对字符串类型)或在 `OptionRule.Builder` 层面使用 `optional(...)` 来处理缺失情况,而不是将 `null` 作为期望值传入。 + ### 配置变更 ### 连接器变更 diff --git a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Condition.java b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Condition.java index 7517d23eb068..6a6fc52ced85 100644 --- a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Condition.java +++ b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Condition.java @@ -71,131 +71,6 @@ public static Condition of(Option option, ConditionOperator op, T expe return new Condition<>(option, op, expectValue, null); } - // ==================== Numeric comparison ==================== - - public static Condition greaterThan(Option option, T value) { - return new Condition<>(option, ConditionOperator.GREATER_THAN, value, null); - } - - public static Condition greaterOrEqual(Option option, T value) { - return new Condition<>(option, ConditionOperator.GREATER_OR_EQUAL, value, null); - } - - public static Condition lessThan(Option option, T value) { - return new Condition<>(option, ConditionOperator.LESS_THAN, value, null); - } - - public static Condition lessOrEqual(Option option, T value) { - return new Condition<>(option, ConditionOperator.LESS_OR_EQUAL, value, null); - } - - // ==================== String validation ==================== - - public static Condition notBlank(Option option) { - return new Condition<>(option, ConditionOperator.NOT_BLANK, null, null); - } - - public static Condition startsWith(Option option, T prefix) { - return new Condition<>(option, ConditionOperator.STARTS_WITH, prefix, null); - } - - public static Condition startsWithIgnoreCase(Option option, String prefix) { - return new Condition<>(option, ConditionOperator.STARTS_WITH_IGNORE_CASE, prefix, null); - } - - public static Condition contains(Option option, T substring) { - return new Condition<>(option, ConditionOperator.CONTAINS, substring, null); - } - - public static Condition matches(Option option, T regex) { - return new Condition<>(option, ConditionOperator.MATCHES, regex, null); - } - - public static Condition upperCase(Option option) { - return new Condition<>(option, ConditionOperator.UPPER_CASE, null, null); - } - - public static Condition lowerCase(Option option) { - return new Condition<>(option, ConditionOperator.LOWER_CASE, null, null); - } - - // ==================== String length ==================== - - public static Condition lengthEqual(Option option, int length) { - return new Condition(option, ConditionOperator.LENGTH_EQUAL, length, null); - } - - public static Condition lengthGreaterOrEqual(Option option, int length) { - return new Condition(option, ConditionOperator.LENGTH_GREATER_OR_EQUAL, length, null); - } - - public static Condition lengthLessOrEqual(Option option, int length) { - return new Condition(option, ConditionOperator.LENGTH_LESS_OR_EQUAL, length, null); - } - - // ==================== String suffix ==================== - - public static Condition endsWith(Option option, T suffix) { - return new Condition<>(option, ConditionOperator.ENDS_WITH, suffix, null); - } - - public static Condition endsWithIgnoreCase(Option option, String suffix) { - return new Condition<>(option, ConditionOperator.ENDS_WITH_IGNORE_CASE, suffix, null); - } - - // ==================== Collection validation ==================== - - public static Condition notEmpty(Option option) { - return new Condition<>(option, ConditionOperator.NOT_EMPTY, null, null); - } - - public static Condition unique(Option option) { - return new Condition<>(option, ConditionOperator.COLLECTION_UNIQUE, null, null); - } - - public static Condition sizeEqual(Option option, int size) { - return new Condition(option, ConditionOperator.COLLECTION_SIZE_EQUAL, size, null); - } - - public static Condition sizeGreaterOrEqual(Option option, int size) { - return new Condition( - option, ConditionOperator.COLLECTION_SIZE_GREATER_OR_EQUAL, size, null); - } - - public static Condition sizeLessOrEqual(Option option, int size) { - return new Condition(option, ConditionOperator.COLLECTION_SIZE_LESS_OR_EQUAL, size, null); - } - - // ==================== Cross-field comparison ==================== - - public static Condition lessThanField(Option option, Option other) { - return new Condition<>(option, ConditionOperator.FIELD_LESS_THAN, null, other); - } - - public static Condition lessOrEqualField(Option option, Option other) { - return new Condition<>(option, ConditionOperator.FIELD_LESS_OR_EQUAL, null, other); - } - - public static Condition greaterThanField(Option option, Option other) { - return new Condition<>(option, ConditionOperator.FIELD_GREATER_THAN, null, other); - } - - public static Condition greaterOrEqualField(Option option, Option other) { - return new Condition<>(option, ConditionOperator.FIELD_GREATER_OR_EQUAL, null, other); - } - - public static Condition equalField(Option option, Option other) { - return new Condition<>(option, ConditionOperator.FIELD_EQUAL, null, other); - } - - public static Condition notEqualField(Option option, Option other) { - return new Condition<>(option, ConditionOperator.FIELD_NOT_EQUAL, null, other); - } - - public static Condition sizeEqualField(Option option, Option other) { - return new Condition<>(option, ConditionOperator.FIELD_SIZE_EQUAL, null, other); - } - // ==================== Chain operations (existing API, unchanged) ==================== public Condition and(Option option, E expectValue) { @@ -217,7 +92,6 @@ public Condition or(Condition next) { } private void addCondition(boolean and, Condition next) { - // Check: next chain must not contain any node already in this chain Condition cur = next; while (cur != null) { Condition self = this; @@ -340,11 +214,11 @@ private static String conditionToString(Condition cond) { String key = "'" + cond.option.key() + "'"; if (op.getSource() == ConditionOperator.Source.FIELD) { - return key + " " + op.getDisplaySymbol() + " '" + cond.compareOption.key() + "'"; + return key + " " + op.getSymbol() + " '" + cond.compareOption.key() + "'"; } if (op.getArity() == ConditionOperator.Arity.UNARY) { return key + " " + op.getSymbol(); } - return key + " " + op.getDisplaySymbol() + " " + cond.expectValue; + return key + " " + op.getSymbol() + " " + cond.expectValue; } } diff --git a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java index 7f68fffa2f0f..503408991677 100644 --- a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java +++ b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java @@ -46,9 +46,22 @@ static boolean evaluate(Condition condition, ReadonlyConfig config) { throw new OptionValidationException( "Condition for option '%s' has a null operator", condition.getOption().key()); } - Object value = config.get(condition.getOption()); - Evaluator evaluator = REGISTRY.get(operator); - return evaluator.evaluate(value, condition, config); + try { + Object value = config.get(condition.getOption()); + Evaluator evaluator = REGISTRY.get(operator); + return evaluator.evaluate(value, condition, config); + } catch (OptionValidationException e) { + String innerMsg = extractInnerMessage(e); + throw new OptionValidationException( + "Failed to evaluate constraint '%s' on option '%s': %s", + condition.toString(), condition.getOption().key(), innerMsg); + } + } + + private static String extractInnerMessage(OptionValidationException e) { + String msg = e.getMessage(); + int idx = msg.indexOf(" - "); + return idx >= 0 ? msg.substring(idx + 3) : msg; } @SuppressWarnings({"rawtypes"}) @@ -59,19 +72,19 @@ private static Map createRegistry() { m.put(ConditionOperator.EQUAL, (v, c, cfg) -> Objects.equals(c.getExpectValue(), v)); m.put(ConditionOperator.NOT_EQUAL, (v, c, cfg) -> !Objects.equals(c.getExpectValue(), v)); - // Numeric + // Numeric (null value -> false, preserving or() short-circuit) m.put( ConditionOperator.GREATER_THAN, - (v, c, cfg) -> compareNumbers(v, c.getExpectValue()) > 0); + (v, c, cfg) -> v != null && compareNumbers(v, c.getExpectValue()) > 0); m.put( ConditionOperator.GREATER_OR_EQUAL, - (v, c, cfg) -> compareNumbers(v, c.getExpectValue()) >= 0); + (v, c, cfg) -> v != null && compareNumbers(v, c.getExpectValue()) >= 0); m.put( ConditionOperator.LESS_THAN, - (v, c, cfg) -> compareNumbers(v, c.getExpectValue()) < 0); + (v, c, cfg) -> v != null && compareNumbers(v, c.getExpectValue()) < 0); m.put( ConditionOperator.LESS_OR_EQUAL, - (v, c, cfg) -> compareNumbers(v, c.getExpectValue()) <= 0); + (v, c, cfg) -> v != null && compareNumbers(v, c.getExpectValue()) <= 0); // String m.put( @@ -82,14 +95,6 @@ private static Map createRegistry() { (v, c, cfg) -> v instanceof String && ((String) v).startsWith(String.valueOf(c.getExpectValue()))); - m.put( - ConditionOperator.STARTS_WITH_IGNORE_CASE, - (v, c, cfg) -> - v instanceof String - && ((String) v) - .toLowerCase() - .startsWith( - String.valueOf(c.getExpectValue()).toLowerCase())); m.put( ConditionOperator.CONTAINS, (v, c, cfg) -> @@ -107,41 +112,6 @@ private static Map createRegistry() { ConditionOperator.LOWER_CASE, (v, c, cfg) -> v instanceof String && v.equals(((String) v).toLowerCase())); - // String length - m.put( - ConditionOperator.LENGTH_EQUAL, - (v, c, cfg) -> - v instanceof String - && ((String) v).length() - == ((Number) c.getExpectValue()).intValue()); - m.put( - ConditionOperator.LENGTH_GREATER_OR_EQUAL, - (v, c, cfg) -> - v instanceof String - && ((String) v).length() - >= ((Number) c.getExpectValue()).intValue()); - m.put( - ConditionOperator.LENGTH_LESS_OR_EQUAL, - (v, c, cfg) -> - v instanceof String - && ((String) v).length() - <= ((Number) c.getExpectValue()).intValue()); - - // String suffix - m.put( - ConditionOperator.ENDS_WITH, - (v, c, cfg) -> - v instanceof String - && ((String) v).endsWith(String.valueOf(c.getExpectValue()))); - m.put( - ConditionOperator.ENDS_WITH_IGNORE_CASE, - (v, c, cfg) -> - v instanceof String - && ((String) v) - .toLowerCase() - .endsWith( - String.valueOf(c.getExpectValue()).toLowerCase())); - // Collection m.put( ConditionOperator.NOT_EMPTY, @@ -155,52 +125,39 @@ private static Map createRegistry() { } return false; }); - m.put( - ConditionOperator.COLLECTION_SIZE_EQUAL, - (v, c, cfg) -> - v instanceof Collection - && ((Collection) v).size() - == ((Number) c.getExpectValue()).intValue()); - m.put( - ConditionOperator.COLLECTION_SIZE_GREATER_OR_EQUAL, - (v, c, cfg) -> - v instanceof Collection - && ((Collection) v).size() - >= ((Number) c.getExpectValue()).intValue()); - m.put( - ConditionOperator.COLLECTION_SIZE_LESS_OR_EQUAL, - (v, c, cfg) -> - v instanceof Collection - && ((Collection) v).size() - <= ((Number) c.getExpectValue()).intValue()); - // Cross-field + // Cross-field (null on either side -> false, preserving or() short-circuit) m.put( ConditionOperator.FIELD_LESS_THAN, - (v, c, cfg) -> compareNumbers(v, cfg.get(c.getCompareOption())) < 0); + (v, c, cfg) -> { + if (v == null) return false; + Object other = cfg.get(c.getCompareOption()); + if (other == null) return false; + return compareNumbers(v, other) < 0; + }); m.put( ConditionOperator.FIELD_LESS_OR_EQUAL, - (v, c, cfg) -> compareNumbers(v, cfg.get(c.getCompareOption())) <= 0); + (v, c, cfg) -> { + if (v == null) return false; + Object other = cfg.get(c.getCompareOption()); + if (other == null) return false; + return compareNumbers(v, other) <= 0; + }); m.put( ConditionOperator.FIELD_GREATER_THAN, - (v, c, cfg) -> compareNumbers(v, cfg.get(c.getCompareOption())) > 0); + (v, c, cfg) -> { + if (v == null) return false; + Object other = cfg.get(c.getCompareOption()); + if (other == null) return false; + return compareNumbers(v, other) > 0; + }); m.put( ConditionOperator.FIELD_GREATER_OR_EQUAL, - (v, c, cfg) -> compareNumbers(v, cfg.get(c.getCompareOption())) >= 0); - m.put( - ConditionOperator.FIELD_EQUAL, - (v, c, cfg) -> Objects.equals(v, cfg.get(c.getCompareOption()))); - m.put( - ConditionOperator.FIELD_NOT_EQUAL, - (v, c, cfg) -> !Objects.equals(v, cfg.get(c.getCompareOption()))); - m.put( - ConditionOperator.FIELD_SIZE_EQUAL, (v, c, cfg) -> { + if (v == null) return false; Object other = cfg.get(c.getCompareOption()); - if (v instanceof Collection && other instanceof Collection) { - return ((Collection) v).size() == ((Collection) other).size(); - } - return false; + if (other == null) return false; + return compareNumbers(v, other) >= 0; }); for (ConditionOperator op : ConditionOperator.values()) { @@ -215,7 +172,8 @@ private static Map createRegistry() { @SuppressWarnings({"rawtypes"}) static int compareNumbers(Object a, Object b) { if (a == null || b == null) { - throw new OptionValidationException("Cannot compare null values in numeric comparison"); + throw new OptionValidationException( + "Cannot compare null values in numeric comparison: left=%s, right=%s", a, b); } if (a instanceof Number && b instanceof Number) { return compareNumberValues((Number) a, (Number) b); @@ -224,34 +182,24 @@ static int compareNumbers(Object a, Object b) { return ((Comparable) a).compareTo(b); } throw new OptionValidationException( - "Cannot compare non-numeric values of type %s and %s", - a.getClass().getName(), b.getClass().getName()); + "Cannot compare values of type %s(%s) and %s(%s)", + a.getClass().getSimpleName(), a, b.getClass().getSimpleName(), b); } private static int compareNumberValues(Number a, Number b) { - if (a instanceof Long && b instanceof Long) { - return Long.compare((Long) a, (Long) b); + if (a instanceof BigDecimal || b instanceof BigDecimal) { + BigDecimal bdA = + a instanceof BigDecimal ? (BigDecimal) a : new BigDecimal(a.toString()); + BigDecimal bdB = + b instanceof BigDecimal ? (BigDecimal) b : new BigDecimal(b.toString()); + return bdA.compareTo(bdB); } - if (a instanceof Integer && b instanceof Integer) { - return Integer.compare((Integer) a, (Integer) b); + if (a instanceof Double + || a instanceof Float + || b instanceof Double + || b instanceof Float) { + return Double.compare(a.doubleValue(), b.doubleValue()); } - if (a instanceof Short && b instanceof Short) { - return Short.compare((Short) a, (Short) b); - } - if (a instanceof Byte && b instanceof Byte) { - return Byte.compare((Byte) a, (Byte) b); - } - if (a instanceof Double && b instanceof Double) { - return Double.compare((Double) a, (Double) b); - } - if (a instanceof Float && b instanceof Float) { - return Float.compare((Float) a, (Float) b); - } - if (a instanceof BigDecimal && b instanceof BigDecimal) { - return ((BigDecimal) a).compareTo((BigDecimal) b); - } - throw new OptionValidationException( - "Numeric type mismatch: cannot compare %s with %s, both sides of a numeric condition must be the same type", - a.getClass().getSimpleName(), b.getClass().getSimpleName()); + return Long.compare(a.longValue(), b.longValue()); } } diff --git a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionOperator.java b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionOperator.java index 5331c5ba3e2a..965f12843f95 100644 --- a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionOperator.java +++ b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionOperator.java @@ -17,67 +17,51 @@ package org.apache.seatunnel.api.configuration.util; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter public enum ConditionOperator { - EQUAL("==", Category.EQUALITY, Arity.BINARY, Source.LITERAL, "=="), - NOT_EQUAL("!=", Category.EQUALITY, Arity.BINARY, Source.LITERAL, "!="), - - GREATER_THAN(">", Category.NUMERIC, Arity.BINARY, Source.LITERAL, ">"), - GREATER_OR_EQUAL(">=", Category.NUMERIC, Arity.BINARY, Source.LITERAL, ">="), - LESS_THAN("<", Category.NUMERIC, Arity.BINARY, Source.LITERAL, "<"), - LESS_OR_EQUAL("<=", Category.NUMERIC, Arity.BINARY, Source.LITERAL, "<="), - - NOT_BLANK("is not blank", Category.STRING, Arity.UNARY, Source.LITERAL, null), - STARTS_WITH("starts with", Category.STRING, Arity.BINARY, Source.LITERAL, "starts with"), - STARTS_WITH_IGNORE_CASE( - "starts with (ignore case)", - Category.STRING, - Arity.BINARY, - Source.LITERAL, - "starts with (ignore case)"), - CONTAINS("contains", Category.STRING, Arity.BINARY, Source.LITERAL, "contains"), - MATCHES("matches", Category.STRING, Arity.BINARY, Source.LITERAL, "matches"), - UPPER_CASE("is uppercase", Category.STRING, Arity.UNARY, Source.LITERAL, null), - LOWER_CASE("is lowercase", Category.STRING, Arity.UNARY, Source.LITERAL, null), - ENDS_WITH("ends with", Category.STRING, Arity.BINARY, Source.LITERAL, "ends with"), - ENDS_WITH_IGNORE_CASE( - "ends with (ignore case)", - Category.STRING, - Arity.BINARY, - Source.LITERAL, - "ends with (ignore case)"), - - LENGTH_EQUAL("length ==", Category.STRING_LENGTH, Arity.BINARY, Source.LITERAL, "length =="), - LENGTH_GREATER_OR_EQUAL( - "length >=", Category.STRING_LENGTH, Arity.BINARY, Source.LITERAL, "length >="), - LENGTH_LESS_OR_EQUAL( - "length <=", Category.STRING_LENGTH, Arity.BINARY, Source.LITERAL, "length <="), - - NOT_EMPTY("is not empty", Category.COLLECTION, Arity.UNARY, Source.LITERAL, null), - COLLECTION_UNIQUE( - "has unique elements", Category.COLLECTION, Arity.UNARY, Source.LITERAL, null), - COLLECTION_SIZE_EQUAL( - "size ==", Category.COLLECTION_SIZE, Arity.BINARY, Source.LITERAL, "size =="), - COLLECTION_SIZE_GREATER_OR_EQUAL( - "size >=", Category.COLLECTION_SIZE, Arity.BINARY, Source.LITERAL, "size >="), - COLLECTION_SIZE_LESS_OR_EQUAL( - "size <=", Category.COLLECTION_SIZE, Arity.BINARY, Source.LITERAL, "size <="), - - FIELD_LESS_THAN("< [field]", Category.NUMERIC, Arity.BINARY, Source.FIELD, "<"), - FIELD_LESS_OR_EQUAL("<= [field]", Category.NUMERIC, Arity.BINARY, Source.FIELD, "<="), - FIELD_GREATER_THAN("> [field]", Category.NUMERIC, Arity.BINARY, Source.FIELD, ">"), - FIELD_GREATER_OR_EQUAL(">= [field]", Category.NUMERIC, Arity.BINARY, Source.FIELD, ">="), - FIELD_EQUAL("== [field]", Category.EQUALITY, Arity.BINARY, Source.FIELD, "=="), - FIELD_NOT_EQUAL("!= [field]", Category.EQUALITY, Arity.BINARY, Source.FIELD, "!="), - FIELD_SIZE_EQUAL( - "size == [field]", Category.COLLECTION_SIZE, Arity.BINARY, Source.FIELD, "size =="); + + // ==================== Equality ==================== + + EQUAL("==", Category.EQUALITY, Arity.BINARY, Source.LITERAL), + NOT_EQUAL("!=", Category.EQUALITY, Arity.BINARY, Source.LITERAL), + + // ==================== Numeric (literal) ==================== + + GREATER_THAN(">", Category.NUMERIC, Arity.BINARY, Source.LITERAL), + GREATER_OR_EQUAL(">=", Category.NUMERIC, Arity.BINARY, Source.LITERAL), + LESS_THAN("<", Category.NUMERIC, Arity.BINARY, Source.LITERAL), + LESS_OR_EQUAL("<=", Category.NUMERIC, Arity.BINARY, Source.LITERAL), + + // ==================== String ==================== + + NOT_BLANK("is not blank", Category.STRING, Arity.UNARY, Source.LITERAL), + STARTS_WITH("starts with", Category.STRING, Arity.BINARY, Source.LITERAL), + CONTAINS("contains", Category.STRING, Arity.BINARY, Source.LITERAL), + MATCHES("matches", Category.STRING, Arity.BINARY, Source.LITERAL), + UPPER_CASE("is uppercase", Category.STRING, Arity.UNARY, Source.LITERAL), + LOWER_CASE("is lowercase", Category.STRING, Arity.UNARY, Source.LITERAL), + + // ==================== Collection ==================== + + NOT_EMPTY("is not empty", Category.COLLECTION, Arity.UNARY, Source.LITERAL), + COLLECTION_UNIQUE("has unique elements", Category.COLLECTION, Arity.UNARY, Source.LITERAL), + + // ==================== Cross-field comparison ==================== + + FIELD_LESS_THAN("<", Category.NUMERIC, Arity.BINARY, Source.FIELD), + FIELD_LESS_OR_EQUAL("<=", Category.NUMERIC, Arity.BINARY, Source.FIELD), + FIELD_GREATER_THAN(">", Category.NUMERIC, Arity.BINARY, Source.FIELD), + FIELD_GREATER_OR_EQUAL(">=", Category.NUMERIC, Arity.BINARY, Source.FIELD); public enum Category { EQUALITY, NUMERIC, STRING, - STRING_LENGTH, - COLLECTION, - COLLECTION_SIZE + COLLECTION } public enum Arity { @@ -94,34 +78,4 @@ public enum Source { private final Category category; private final Arity arity; private final Source source; - private final String displaySymbol; - - ConditionOperator( - String symbol, Category category, Arity arity, Source source, String displaySymbol) { - this.symbol = symbol; - this.category = category; - this.arity = arity; - this.source = source; - this.displaySymbol = displaySymbol; - } - - public String getSymbol() { - return symbol; - } - - public Category getCategory() { - return category; - } - - public Arity getArity() { - return arity; - } - - public Source getSource() { - return source; - } - - public String getDisplaySymbol() { - return displaySymbol; - } } diff --git a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Conditions.java b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Conditions.java new file mode 100644 index 000000000000..9f5dda5800be --- /dev/null +++ b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Conditions.java @@ -0,0 +1,107 @@ +/* + * 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.api.configuration.util; + +import org.apache.seatunnel.api.configuration.Option; + +/** + * Unified factory for creating {@link Condition} instances. + * + *
{@code
+ * static *
+ * OptionRule.builder()
+ *     .required(PORT, greaterOrEqual(PORT, 1).and(lessOrEqual(PORT, 65535)))
+ *     .required(HOST, notBlank(HOST))
+ *     .required(START_TS, END_TS, lessThanField(START_TS, END_TS))
+ *     .build();
+ * }
+ */ +public final class Conditions { + + // ==================== Numeric comparison ==================== + + public static Condition greaterThan(Option option, T value) { + return new Condition<>(option, ConditionOperator.GREATER_THAN, value, null); + } + + public static Condition greaterOrEqual(Option option, T value) { + return new Condition<>(option, ConditionOperator.GREATER_OR_EQUAL, value, null); + } + + public static Condition lessThan(Option option, T value) { + return new Condition<>(option, ConditionOperator.LESS_THAN, value, null); + } + + public static Condition lessOrEqual(Option option, T value) { + return new Condition<>(option, ConditionOperator.LESS_OR_EQUAL, value, null); + } + + // ==================== String validation ==================== + + public static Condition notBlank(Option option) { + return new Condition<>(option, ConditionOperator.NOT_BLANK, null, null); + } + + public static Condition startsWith(Option option, T prefix) { + return new Condition<>(option, ConditionOperator.STARTS_WITH, prefix, null); + } + + public static Condition matches(Option option, T regex) { + return new Condition<>(option, ConditionOperator.MATCHES, regex, null); + } + + public static Condition contains(Option option, T substring) { + return new Condition<>(option, ConditionOperator.CONTAINS, substring, null); + } + + public static Condition upperCase(Option option) { + return new Condition<>(option, ConditionOperator.UPPER_CASE, null, null); + } + + public static Condition lowerCase(Option option) { + return new Condition<>(option, ConditionOperator.LOWER_CASE, null, null); + } + + // ==================== Collection validation ==================== + + public static Condition notEmpty(Option option) { + return new Condition<>(option, ConditionOperator.NOT_EMPTY, null, null); + } + + public static Condition unique(Option option) { + return new Condition<>(option, ConditionOperator.COLLECTION_UNIQUE, null, null); + } + + // ==================== Cross-field comparison ==================== + + public static Condition lessThanField(Option option, Option other) { + return new Condition<>(option, ConditionOperator.FIELD_LESS_THAN, null, other); + } + + public static Condition lessOrEqualField(Option option, Option other) { + return new Condition<>(option, ConditionOperator.FIELD_LESS_OR_EQUAL, null, other); + } + + public static Condition greaterThanField(Option option, Option other) { + return new Condition<>(option, ConditionOperator.FIELD_GREATER_THAN, null, other); + } + + public static Condition greaterOrEqualField(Option option, Option other) { + return new Condition<>(option, ConditionOperator.FIELD_GREATER_OR_EQUAL, null, other); + } +} 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 3564f31adf05..d88bd3cd7893 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 @@ -189,33 +189,66 @@ public void validate(OptionRule rule, Expression expression) { } } + List> failedConstraints = new ArrayList<>(); for (Condition constraint : rule.getValueConstraints()) { - validate(constraint, rule); + if (isConstraintApplicable(constraint, rule) && !validate(constraint)) { + failedConstraints.add(constraint); + } + } + if (!failedConstraints.isEmpty()) { + StringBuilder sb = new StringBuilder(); + sb.append( + String.format( + "Option validation failed (%d error%s):", + failedConstraints.size(), failedConstraints.size() > 1 ? "s" : "")); + for (int i = 0; i < failedConstraints.size(); i++) { + Condition c = failedConstraints.get(i); + sb.append( + String.format( + "\n [%d] option: %s\n constraint: %s", + i + 1, c.getOption().key(), c.toString())); + } + throw new OptionValidationException(sb.toString()); } } - void validate(Condition constraint, OptionRule rule) { - if (!isConstraintApplicable(constraint, rule)) { - return; + /** + * Determines whether a value constraint should be evaluated. Walks the entire condition chain + * (including compareOption for cross-field operators) and collects all referenced options. + * + *

If any referenced option is absolutely required, the constraint is always applicable. For + * optional constraints, ALL referenced options must be present — this prevents false-positive + * violations when only a subset of cross-field options is provided. + */ + private boolean isConstraintApplicable(Condition condition, OptionRule rule) { + Set> allOptions = collectAllConditionOptions(condition); + for (Option opt : allOptions) { + for (RequiredOption requiredOption : rule.getRequiredOptions()) { + if (requiredOption instanceof RequiredOption.AbsolutelyRequiredOptions + && requiredOption.getOptions().contains(opt)) { + return true; + } + } } - if (!validate(constraint)) { - throw new OptionValidationException( - "Option validation failed: %s", constraint.toString()); + for (Option opt : allOptions) { + if (!hasOption(opt)) { + return false; + } } + return true; } - private boolean isConstraintApplicable(Condition condition, OptionRule rule) { - Option option = condition.getOption(); - if (hasOption(option)) { - return true; - } - for (RequiredOption requiredOption : rule.getRequiredOptions()) { - if (requiredOption instanceof RequiredOption.AbsolutelyRequiredOptions - && requiredOption.getOptions().contains(option)) { - return true; + private Set> collectAllConditionOptions(Condition condition) { + Set> options = new HashSet<>(); + Condition cur = condition; + while (cur != null) { + options.add(cur.getOption()); + if (cur.getCompareOption() != null) { + options.add(cur.getCompareOption()); } + cur = cur.hasNext() ? cur.getNext() : null; } - return false; + return options; } void validateSingleChoice(Option option) { diff --git a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionRule.java b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionRule.java index a5e73f74fdd2..4f91864c391f 100644 --- a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionRule.java +++ b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionRule.java @@ -66,13 +66,13 @@ * * // value constraints — attach Condition to required / optional / conditional * OptionRule constrainedRule = OptionRule.builder() - * .required(PORT, Condition.greaterOrEqual(PORT, 1) - * .and(Condition.lessOrEqual(PORT, 65535))) - * .optional(TIMEOUT, Condition.greaterOrEqual(TIMEOUT, 1000) - * .and(Condition.lessOrEqual(TIMEOUT, 60000))) - * .required(START_TS, END_TS, Condition.lessThanField(START_TS, END_TS)) + * .required(PORT, Conditions.greaterOrEqual(PORT, 1) + * .and(Conditions.lessOrEqual(PORT, 65535))) + * .optional(TIMEOUT, Conditions.greaterOrEqual(TIMEOUT, 1000) + * .and(Conditions.lessOrEqual(TIMEOUT, 60000))) + * .required(START_TS, END_TS, Conditions.lessThanField(START_TS, END_TS)) * .conditional(MODE, StartMode.TIMESTAMP, - * Condition.greaterThan(TIMESTAMP_VALUE, 0)) + * Conditions.greaterThan(TIMESTAMP_VALUE, 0)) * .build(); * } */ 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 dc1f84021c34..fc374327911a 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 @@ -33,6 +33,22 @@ import java.util.Map; import static org.apache.seatunnel.api.configuration.OptionTest.TEST_MODE; +import static org.apache.seatunnel.api.configuration.util.Conditions.contains; +import static org.apache.seatunnel.api.configuration.util.Conditions.greaterOrEqual; +import static org.apache.seatunnel.api.configuration.util.Conditions.greaterOrEqualField; +import static org.apache.seatunnel.api.configuration.util.Conditions.greaterThan; +import static org.apache.seatunnel.api.configuration.util.Conditions.greaterThanField; +import static org.apache.seatunnel.api.configuration.util.Conditions.lessOrEqual; +import static org.apache.seatunnel.api.configuration.util.Conditions.lessOrEqualField; +import static org.apache.seatunnel.api.configuration.util.Conditions.lessThan; +import static org.apache.seatunnel.api.configuration.util.Conditions.lessThanField; +import static org.apache.seatunnel.api.configuration.util.Conditions.lowerCase; +import static org.apache.seatunnel.api.configuration.util.Conditions.matches; +import static org.apache.seatunnel.api.configuration.util.Conditions.notBlank; +import static org.apache.seatunnel.api.configuration.util.Conditions.notEmpty; +import static org.apache.seatunnel.api.configuration.util.Conditions.startsWith; +import static org.apache.seatunnel.api.configuration.util.Conditions.unique; +import static org.apache.seatunnel.api.configuration.util.Conditions.upperCase; import static org.apache.seatunnel.api.configuration.util.OptionRuleTest.TEST_PORTS; import static org.apache.seatunnel.api.configuration.util.OptionRuleTest.TEST_TIMESTAMP; import static org.apache.seatunnel.api.configuration.util.OptionRuleTest.TEST_TOPIC; @@ -446,13 +462,15 @@ public void testMultipleValueNestedRule() { .defaultValue("default") .withDescription("file name expression"); + public static final Option MODE = + Options.key("mode").stringType().defaultValue("batch").withDescription("run mode"); + public static final Option> TAGS = Options.key("tags").listType().noDefaultValue().withDescription("tag list"); @Test public void testGreaterThanValidation() { - OptionRule rule = - OptionRule.builder().required(PORT, Condition.greaterThan(PORT, 0)).build(); + OptionRule rule = OptionRule.builder().required(PORT, greaterThan(PORT, 0)).build(); Map config = new HashMap<>(); config.put(PORT.key(), 8080); @@ -467,8 +485,7 @@ public void testGreaterThanValidation() { @Test public void testGreaterOrEqualValidation() { - OptionRule rule = - OptionRule.builder().required(PORT, Condition.greaterOrEqual(PORT, 0)).build(); + OptionRule rule = OptionRule.builder().required(PORT, greaterOrEqual(PORT, 0)).build(); Map config = new HashMap<>(); config.put(PORT.key(), 0); @@ -485,10 +502,7 @@ public void testGreaterOrEqualValidation() { public void testRangeValidation() { OptionRule rule = OptionRule.builder() - .required( - PORT, - Condition.greaterOrEqual(PORT, 1) - .and(Condition.lessOrEqual(PORT, 65535))) + .required(PORT, greaterOrEqual(PORT, 1).and(lessOrEqual(PORT, 65535))) .build(); Map config = new HashMap<>(); @@ -512,10 +526,7 @@ public void testRangeValidation() { public void testHalfOpenIntervalValidation() { OptionRule rule = OptionRule.builder() - .required( - RATIO, - Condition.greaterThan(RATIO, 0.0) - .and(Condition.lessOrEqual(RATIO, 1.0))) + .required(RATIO, greaterThan(RATIO, 0.0).and(lessOrEqual(RATIO, 1.0))) .build(); Map config = new HashMap<>(); @@ -534,7 +545,7 @@ public void testHalfOpenIntervalValidation() { @Test public void testNotBlankValidation() { - OptionRule rule = OptionRule.builder().required(HOST, Condition.notBlank(HOST)).build(); + OptionRule rule = OptionRule.builder().required(HOST, notBlank(HOST)).build(); Map config = new HashMap<>(); config.put(HOST.key(), "localhost"); @@ -551,7 +562,7 @@ public void testNotBlankValidation() { public void testStartsWithValidation() { OptionRule rule = OptionRule.builder() - .required(ENDPOINT, Condition.startsWith(ENDPOINT, "jdbc:databend://")) + .required(ENDPOINT, startsWith(ENDPOINT, "jdbc:databend://")) .build(); Map config = new HashMap<>(); @@ -562,31 +573,11 @@ public void testStartsWithValidation() { assertThrows(OptionValidationException.class, () -> validate(config, rule)); } - @Test - public void testStartsWithIgnoreCaseValidation() { - Option WHERE = - Options.key("where").stringType().noDefaultValue().withDescription("where clause"); - OptionRule rule = - OptionRule.builder() - .required(WHERE, Condition.startsWithIgnoreCase(WHERE, "where")) - .build(); - - Map config = new HashMap<>(); - config.put(WHERE.key(), "WHERE id > 10"); - Assertions.assertDoesNotThrow(() -> validate(config, rule)); - - config.put(WHERE.key(), "where name = 'test'"); - Assertions.assertDoesNotThrow(() -> validate(config, rule)); - - config.put(WHERE.key(), "SELECT * FROM t"); - assertThrows(OptionValidationException.class, () -> validate(config, rule)); - } - @Test public void testContainsValidation() { OptionRule rule = OptionRule.builder() - .optional(FILE_EXPR, Condition.contains(FILE_EXPR, "#{transactionId}")) + .optional(FILE_EXPR, contains(FILE_EXPR, "#{transactionId}")) .build(); Map config = new HashMap<>(); @@ -600,9 +591,7 @@ public void testContainsValidation() { @Test public void testMatchesValidation() { OptionRule rule = - OptionRule.builder() - .required(ENDPOINT, Condition.matches(ENDPOINT, "^[^:]+:\\d+$")) - .build(); + OptionRule.builder().required(ENDPOINT, matches(ENDPOINT, "^[^:]+:\\d+$")).build(); Map config = new HashMap<>(); config.put(ENDPOINT.key(), "localhost:8080"); @@ -614,8 +603,7 @@ public void testMatchesValidation() { @Test public void testUpperCaseValidation() { - OptionRule rule = - OptionRule.builder().required(DB_NAME, Condition.upperCase(DB_NAME)).build(); + OptionRule rule = OptionRule.builder().required(DB_NAME, upperCase(DB_NAME)).build(); Map config = new HashMap<>(); config.put(DB_NAME.key(), "ORACLE_DB"); @@ -627,8 +615,7 @@ public void testUpperCaseValidation() { @Test public void testLowerCaseValidation() { - OptionRule rule = - OptionRule.builder().required(DB_NAME, Condition.lowerCase(DB_NAME)).build(); + OptionRule rule = OptionRule.builder().required(DB_NAME, lowerCase(DB_NAME)).build(); Map config = new HashMap<>(); config.put(DB_NAME.key(), "my_database"); @@ -638,26 +625,11 @@ public void testLowerCaseValidation() { assertThrows(OptionValidationException.class, () -> validate(config, rule)); } - @Test - public void testLengthEqualValidation() { - OptionRule rule = - OptionRule.builder() - .required(DELIMITER, Condition.lengthEqual(DELIMITER, 1)) - .build(); - - Map config = new HashMap<>(); - config.put(DELIMITER.key(), ","); - Assertions.assertDoesNotThrow(() -> validate(config, rule)); - - config.put(DELIMITER.key(), "||"); - assertThrows(OptionValidationException.class, () -> validate(config, rule)); - } - @Test public void testCrossFieldComparison() { OptionRule rule = OptionRule.builder() - .required(START_TS, END_TS, Condition.lessThanField(START_TS, END_TS)) + .required(START_TS, END_TS, lessThanField(START_TS, END_TS)) .build(); Map config = new HashMap<>(); @@ -678,7 +650,7 @@ public void testCrossFieldComparison() { public void testCrossFieldLessOrEqual() { OptionRule rule = OptionRule.builder() - .required(START_TS, END_TS, Condition.lessOrEqualField(START_TS, END_TS)) + .required(START_TS, END_TS, lessOrEqualField(START_TS, END_TS)) .build(); Map config = new HashMap<>(); @@ -697,7 +669,7 @@ public void testCrossFieldLessOrEqual() { @Test public void testNotEmptyCollectionValidation() { - OptionRule rule = OptionRule.builder().required(TAGS, Condition.notEmpty(TAGS)).build(); + OptionRule rule = OptionRule.builder().required(TAGS, notEmpty(TAGS)).build(); Map config = new HashMap<>(); config.put(TAGS.key(), Arrays.asList("tag1", "tag2")); @@ -709,7 +681,7 @@ public void testNotEmptyCollectionValidation() { @Test public void testUniqueCollectionValidation() { - OptionRule rule = OptionRule.builder().required(TAGS, Condition.unique(TAGS)).build(); + OptionRule rule = OptionRule.builder().required(TAGS, unique(TAGS)).build(); Map config = new HashMap<>(); config.put(TAGS.key(), Arrays.asList("a", "b", "c")); @@ -723,7 +695,7 @@ public void testUniqueCollectionValidation() { public void testOrChainAtLeastOneNotBlank() { OptionRule rule = OptionRule.builder() - .optional(HOST, Condition.notBlank(HOST).or(Condition.notBlank(ENDPOINT))) + .optional(HOST, notBlank(HOST).or(notBlank(ENDPOINT))) .optional(ENDPOINT) .build(); @@ -744,9 +716,7 @@ public void testOrChainAtLeastOneNotBlank() { @Test public void testValidationSkippedForAbsentOptional() { OptionRule rule = - OptionRule.builder() - .optional(ENDPOINT, Condition.matches(ENDPOINT, "^[^:]+:\\d+$")) - .build(); + OptionRule.builder().optional(ENDPOINT, matches(ENDPOINT, "^[^:]+:\\d+$")).build(); Map config = new HashMap<>(); Assertions.assertDoesNotThrow(() -> validate(config, rule)); @@ -754,28 +724,23 @@ public void testValidationSkippedForAbsentOptional() { @Test public void testConditionToString() { - assertEquals("'port' > 0", Condition.greaterThan(PORT, 0).toString()); + assertEquals("'port' > 0", greaterThan(PORT, 0).toString()); assertEquals( "'port' >= 1 && 'port' <= 65535", - Condition.greaterOrEqual(PORT, 1) - .and(Condition.lessOrEqual(PORT, 65535)) - .toString()); - assertEquals("'host' is not blank", Condition.notBlank(HOST).toString()); - assertEquals("'start_ts' < 'end_ts'", Condition.lessThanField(START_TS, END_TS).toString()); - assertEquals("'db_name' is uppercase", Condition.upperCase(DB_NAME).toString()); - assertEquals("'tags' has unique elements", Condition.unique(TAGS).toString()); + greaterOrEqual(PORT, 1).and(lessOrEqual(PORT, 65535)).toString()); + assertEquals("'host' is not blank", notBlank(HOST).toString()); + assertEquals("'start_ts' < 'end_ts'", lessThanField(START_TS, END_TS).toString()); + assertEquals("'db_name' is uppercase", upperCase(DB_NAME).toString()); + assertEquals("'tags' has unique elements", unique(TAGS).toString()); } @Test public void testMultipleValidationRules() { OptionRule rule = OptionRule.builder() - .required( - PORT, - Condition.greaterOrEqual(PORT, 1) - .and(Condition.lessOrEqual(PORT, 65535))) - .required(HOST, Condition.notBlank(HOST)) - .required(DB_NAME, Condition.upperCase(DB_NAME)) + .required(PORT, greaterOrEqual(PORT, 1).and(lessOrEqual(PORT, 65535))) + .required(HOST, notBlank(HOST)) + .required(DB_NAME, upperCase(DB_NAME)) .build(); Map config = new HashMap<>(); @@ -819,158 +784,11 @@ public void testNotEqualOperator() { assertThrows(OptionValidationException.class, () -> validate(config, rule)); } - // ==================== New Operator Tests ==================== - - @Test - public void testEndsWithValidation() { - OptionRule rule = - OptionRule.builder() - .required(ENDPOINT, Condition.endsWith(ENDPOINT, "/v0")) - .build(); - - Map config = new HashMap<>(); - config.put(ENDPOINT.key(), "https://api.airtable.com/v0"); - Assertions.assertDoesNotThrow(() -> validate(config, rule)); - - config.put(ENDPOINT.key(), "https://api.airtable.com/v1"); - assertThrows(OptionValidationException.class, () -> validate(config, rule)); - } - - @Test - public void testEndsWithIgnoreCaseValidation() { - OptionRule rule = - OptionRule.builder() - .required(ENDPOINT, Condition.endsWithIgnoreCase(ENDPOINT, ".csv")) - .build(); - - Map config = new HashMap<>(); - config.put(ENDPOINT.key(), "data.CSV"); - Assertions.assertDoesNotThrow(() -> validate(config, rule)); - - config.put(ENDPOINT.key(), "data.csv"); - Assertions.assertDoesNotThrow(() -> validate(config, rule)); - - config.put(ENDPOINT.key(), "data.json"); - assertThrows(OptionValidationException.class, () -> validate(config, rule)); - } - - @Test - public void testCollectionSizeEqual() { - OptionRule rule = OptionRule.builder().required(TAGS, Condition.sizeEqual(TAGS, 3)).build(); - - Map config = new HashMap<>(); - config.put(TAGS.key(), Arrays.asList("a", "b", "c")); - Assertions.assertDoesNotThrow(() -> validate(config, rule)); - - config.put(TAGS.key(), Arrays.asList("a", "b")); - assertThrows(OptionValidationException.class, () -> validate(config, rule)); - } - - @Test - public void testCollectionSizeFixedOne() { - OptionRule rule = OptionRule.builder().required(TAGS, Condition.sizeEqual(TAGS, 1)).build(); - - Map config = new HashMap<>(); - config.put(TAGS.key(), Collections.singletonList("only_one")); - Assertions.assertDoesNotThrow(() -> validate(config, rule)); - - config.put(TAGS.key(), Arrays.asList("a", "b")); - assertThrows(OptionValidationException.class, () -> validate(config, rule)); - } - - public static final Option> COLLECTIONS = - Options.key("collections") - .listType() - .noDefaultValue() - .withDescription("collection list"); - - @Test - public void testFieldSizeEqual() { - OptionRule rule = - OptionRule.builder() - .required(TAGS, COLLECTIONS, Condition.sizeEqualField(TAGS, COLLECTIONS)) - .build(); - - Map config = new HashMap<>(); - config.put(TAGS.key(), Arrays.asList("a", "b", "c")); - config.put(COLLECTIONS.key(), Arrays.asList("x", "y", "z")); - Assertions.assertDoesNotThrow(() -> validate(config, rule)); - - config.put(COLLECTIONS.key(), Arrays.asList("x", "y")); - assertThrows(OptionValidationException.class, () -> validate(config, rule)); - } - - public static final Option SCHEMA_TABLE = - Options.key("schema_table") - .stringType() - .noDefaultValue() - .withDescription("schema table name"); - - public static final Option COLLECTION_NAME = - Options.key("collection_name") - .stringType() - .noDefaultValue() - .withDescription("collection name"); - - @Test - public void testFieldEqualValidation() { - OptionRule rule = - OptionRule.builder() - .required( - SCHEMA_TABLE, - COLLECTION_NAME, - Condition.equalField(SCHEMA_TABLE, COLLECTION_NAME)) - .build(); - - Map config = new HashMap<>(); - config.put(SCHEMA_TABLE.key(), "db.users"); - config.put(COLLECTION_NAME.key(), "db.users"); - Assertions.assertDoesNotThrow(() -> validate(config, rule)); - - config.put(COLLECTION_NAME.key(), "db.orders"); - assertThrows(OptionValidationException.class, () -> validate(config, rule)); - } - - @Test - public void testFieldNotEqualValidation() { - OptionRule rule = - OptionRule.builder() - .required( - SCHEMA_TABLE, - COLLECTION_NAME, - Condition.notEqualField(SCHEMA_TABLE, COLLECTION_NAME)) - .build(); - - Map config = new HashMap<>(); - config.put(SCHEMA_TABLE.key(), "source_db"); - config.put(COLLECTION_NAME.key(), "target_db"); - Assertions.assertDoesNotThrow(() -> validate(config, rule)); - - config.put(COLLECTION_NAME.key(), "source_db"); - assertThrows(OptionValidationException.class, () -> validate(config, rule)); - } - - @Test - public void testNewOperatorToString() { - assertEquals("'endpoint' ends with /v0", Condition.endsWith(ENDPOINT, "/v0").toString()); - assertEquals("'tags' size == 3", Condition.sizeEqual(TAGS, 3).toString()); - assertEquals( - "'tags' size == 'collections'", - Condition.sizeEqualField(TAGS, COLLECTIONS).toString()); - assertEquals( - "'schema_table' == 'collection_name'", - Condition.equalField(SCHEMA_TABLE, COLLECTION_NAME).toString()); - assertEquals( - "'schema_table' != 'collection_name'", - Condition.notEqualField(SCHEMA_TABLE, COLLECTION_NAME).toString()); - } - // ==================== Missing Operator Coverage ==================== @Test public void testLessThanValidation() { - OptionRule rule = - OptionRule.builder().required(PORT, Condition.lessThan(PORT, 100)).build(); + OptionRule rule = OptionRule.builder().required(PORT, lessThan(PORT, 100)).build(); Map config = new HashMap<>(); config.put(PORT.key(), 50); @@ -986,97 +804,11 @@ public void testLessThanValidation() { assertThrows(OptionValidationException.class, () -> validate(config, rule)); } - @Test - public void testLengthGreaterOrEqualValidation() { - OptionRule rule = - OptionRule.builder() - .required(HOST, Condition.lengthGreaterOrEqual(HOST, 3)) - .build(); - - Map config = new HashMap<>(); - config.put(HOST.key(), "abc"); - Assertions.assertDoesNotThrow(() -> validate(config, rule)); - - config.put(HOST.key(), "localhost"); - Assertions.assertDoesNotThrow(() -> validate(config, rule)); - - config.put(HOST.key(), "ab"); - assertThrows(OptionValidationException.class, () -> validate(config, rule)); - } - - @Test - public void testLengthLessOrEqualValidation() { - OptionRule rule = - OptionRule.builder() - .required(DELIMITER, Condition.lengthLessOrEqual(DELIMITER, 2)) - .build(); - - Map config = new HashMap<>(); - config.put(DELIMITER.key(), ","); - Assertions.assertDoesNotThrow(() -> validate(config, rule)); - - config.put(DELIMITER.key(), "||"); - Assertions.assertDoesNotThrow(() -> validate(config, rule)); - - config.put(DELIMITER.key(), "|||"); - assertThrows(OptionValidationException.class, () -> validate(config, rule)); - } - - @Test - public void testLengthRangeValidation() { - OptionRule rule = - OptionRule.builder() - .required( - HOST, - Condition.lengthGreaterOrEqual(HOST, 1) - .and(Condition.lengthLessOrEqual(HOST, 255))) - .build(); - - Map config = new HashMap<>(); - config.put(HOST.key(), "a"); - Assertions.assertDoesNotThrow(() -> validate(config, rule)); - - config.put(HOST.key(), ""); - assertThrows(OptionValidationException.class, () -> validate(config, rule)); - } - - @Test - public void testCollectionSizeGreaterOrEqualValidation() { - OptionRule rule = - OptionRule.builder().required(TAGS, Condition.sizeGreaterOrEqual(TAGS, 2)).build(); - - Map config = new HashMap<>(); - config.put(TAGS.key(), Arrays.asList("a", "b")); - Assertions.assertDoesNotThrow(() -> validate(config, rule)); - - config.put(TAGS.key(), Arrays.asList("a", "b", "c")); - Assertions.assertDoesNotThrow(() -> validate(config, rule)); - - config.put(TAGS.key(), Collections.singletonList("a")); - assertThrows(OptionValidationException.class, () -> validate(config, rule)); - } - - @Test - public void testCollectionSizeLessOrEqualValidation() { - OptionRule rule = - OptionRule.builder().required(TAGS, Condition.sizeLessOrEqual(TAGS, 3)).build(); - - Map config = new HashMap<>(); - config.put(TAGS.key(), Arrays.asList("a", "b", "c")); - Assertions.assertDoesNotThrow(() -> validate(config, rule)); - - config.put(TAGS.key(), Arrays.asList("a", "b")); - Assertions.assertDoesNotThrow(() -> validate(config, rule)); - - config.put(TAGS.key(), Arrays.asList("a", "b", "c", "d")); - assertThrows(OptionValidationException.class, () -> validate(config, rule)); - } - @Test public void testFieldGreaterThanValidation() { OptionRule rule = OptionRule.builder() - .required(END_TS, START_TS, Condition.greaterThanField(END_TS, START_TS)) + .required(END_TS, START_TS, greaterThanField(END_TS, START_TS)) .build(); Map config = new HashMap<>(); @@ -1097,7 +829,7 @@ public void testFieldGreaterThanValidation() { public void testFieldGreaterOrEqualValidation() { OptionRule rule = OptionRule.builder() - .required(END_TS, START_TS, Condition.greaterOrEqualField(END_TS, START_TS)) + .required(END_TS, START_TS, greaterOrEqualField(END_TS, START_TS)) .build(); Map config = new HashMap<>(); @@ -1124,7 +856,7 @@ public void testConditionalWithValueConstraint() { .conditional( TEST_MODE, OptionTest.TestMode.TIMESTAMP, - Condition.greaterThan(TEST_TIMESTAMP, 0L)) + greaterThan(TEST_TIMESTAMP, 0L)) .build(); Map config = new HashMap<>(); @@ -1151,11 +883,7 @@ public void testConditionalWithMultiFieldConstraint() { OptionRule.builder() .optional(ENABLE_TX) .conditional( - ENABLE_TX, - true, - START_TS, - END_TS, - Condition.lessThanField(START_TS, END_TS)) + ENABLE_TX, true, START_TS, END_TS, lessThanField(START_TS, END_TS)) .build(); Map config = new HashMap<>(); @@ -1174,8 +902,7 @@ public void testConditionalWithMultiFieldConstraint() { @Test public void testOptionalWithValueConstraint() { - OptionRule rule = - OptionRule.builder().optional(PORT, Condition.greaterOrEqual(PORT, 1)).build(); + OptionRule rule = OptionRule.builder().optional(PORT, greaterOrEqual(PORT, 1)).build(); Map config = new HashMap<>(); Assertions.assertDoesNotThrow(() -> validate(config, rule)); @@ -1191,7 +918,7 @@ public void testOptionalWithValueConstraint() { public void testOptionalWithMultiFieldConstraint() { OptionRule rule = OptionRule.builder() - .optional(START_TS, END_TS, Condition.lessThanField(START_TS, END_TS)) + .optional(START_TS, END_TS, lessThanField(START_TS, END_TS)) .build(); Map config = new HashMap<>(); @@ -1206,14 +933,10 @@ public void testOptionalWithMultiFieldConstraint() { assertThrows(OptionValidationException.class, () -> validate(config, rule)); } - // ==================== Condition Chain Coverage ==================== - @Test public void testNotEmptyAndUniqueChain() { OptionRule rule = - OptionRule.builder() - .required(TAGS, Condition.notEmpty(TAGS).and(Condition.unique(TAGS))) - .build(); + OptionRule.builder().required(TAGS, notEmpty(TAGS).and(unique(TAGS))).build(); Map config = new HashMap<>(); config.put(TAGS.key(), Arrays.asList("a", "b", "c")); @@ -1226,119 +949,49 @@ public void testNotEmptyAndUniqueChain() { assertThrows(OptionValidationException.class, () -> validate(config, rule)); } - @Test - public void testCollectionSizeRangeChain() { - OptionRule rule = - OptionRule.builder() - .required( - TAGS, - Condition.sizeGreaterOrEqual(TAGS, 1) - .and(Condition.sizeLessOrEqual(TAGS, 5))) - .build(); - - Map config = new HashMap<>(); - config.put(TAGS.key(), Arrays.asList("a")); - Assertions.assertDoesNotThrow(() -> validate(config, rule)); - - config.put(TAGS.key(), Arrays.asList("a", "b", "c", "d", "e")); - Assertions.assertDoesNotThrow(() -> validate(config, rule)); - - config.put(TAGS.key(), Collections.emptyList()); - assertThrows(OptionValidationException.class, () -> validate(config, rule)); - - config.put(TAGS.key(), Arrays.asList("1", "2", "3", "4", "5", "6")); - assertThrows(OptionValidationException.class, () -> validate(config, rule)); - } - - @Test - public void testMultipleConditionsVarargs() { - OptionRule rule = - OptionRule.builder() - .required( - TAGS, - Condition.notEmpty(TAGS), - Condition.unique(TAGS), - Condition.sizeLessOrEqual(TAGS, 10)) - .build(); - - Map config = new HashMap<>(); - config.put(TAGS.key(), Arrays.asList("a", "b", "c")); - Assertions.assertDoesNotThrow(() -> validate(config, rule)); - - config.put(TAGS.key(), Collections.emptyList()); - assertThrows(OptionValidationException.class, () -> validate(config, rule)); - - config.put(TAGS.key(), Arrays.asList("a", "a")); - assertThrows(OptionValidationException.class, () -> validate(config, rule)); - } - // ==================== toString Coverage for All Operators ==================== @Test public void testAllOperatorToString() { - assertEquals("'port' < 100", Condition.lessThan(PORT, 100).toString()); - assertEquals("'port' <= 100", Condition.lessOrEqual(PORT, 100).toString()); - assertEquals("'port' > 0", Condition.greaterThan(PORT, 0).toString()); - assertEquals("'port' >= 0", Condition.greaterOrEqual(PORT, 0).toString()); - assertEquals("'host' is not blank", Condition.notBlank(HOST).toString()); - assertEquals( - "'endpoint' starts with jdbc:", Condition.startsWith(ENDPOINT, "jdbc:").toString()); - assertEquals( - "'endpoint' starts with (ignore case) jdbc:", - Condition.startsWithIgnoreCase(ENDPOINT, "jdbc:").toString()); - assertEquals("'endpoint' ends with .csv", Condition.endsWith(ENDPOINT, ".csv").toString()); - assertEquals( - "'endpoint' ends with (ignore case) .csv", - Condition.endsWithIgnoreCase(ENDPOINT, ".csv").toString()); - assertEquals("'endpoint' contains ://", Condition.contains(ENDPOINT, "://").toString()); - assertEquals("'endpoint' matches ^\\d+$", Condition.matches(ENDPOINT, "^\\d+$").toString()); - assertEquals("'db_name' is uppercase", Condition.upperCase(DB_NAME).toString()); - assertEquals("'db_name' is lowercase", Condition.lowerCase(DB_NAME).toString()); - assertEquals("'delimiter' length == 1", Condition.lengthEqual(DELIMITER, 1).toString()); - assertEquals("'host' length >= 3", Condition.lengthGreaterOrEqual(HOST, 3).toString()); - assertEquals("'host' length <= 255", Condition.lengthLessOrEqual(HOST, 255).toString()); - assertEquals("'tags' is not empty", Condition.notEmpty(TAGS).toString()); - assertEquals("'tags' has unique elements", Condition.unique(TAGS).toString()); - assertEquals("'tags' size == 3", Condition.sizeEqual(TAGS, 3).toString()); - assertEquals("'tags' size >= 1", Condition.sizeGreaterOrEqual(TAGS, 1).toString()); - assertEquals("'tags' size <= 10", Condition.sizeLessOrEqual(TAGS, 10).toString()); - assertEquals("'start_ts' < 'end_ts'", Condition.lessThanField(START_TS, END_TS).toString()); - assertEquals( - "'start_ts' <= 'end_ts'", Condition.lessOrEqualField(START_TS, END_TS).toString()); - assertEquals( - "'end_ts' > 'start_ts'", Condition.greaterThanField(END_TS, START_TS).toString()); - assertEquals( - "'end_ts' >= 'start_ts'", - Condition.greaterOrEqualField(END_TS, START_TS).toString()); - assertEquals( - "'schema_table' == 'collection_name'", - Condition.equalField(SCHEMA_TABLE, COLLECTION_NAME).toString()); - assertEquals( - "'schema_table' != 'collection_name'", - Condition.notEqualField(SCHEMA_TABLE, COLLECTION_NAME).toString()); - assertEquals( - "'tags' size == 'collections'", - Condition.sizeEqualField(TAGS, COLLECTIONS).toString()); + // Core operators + assertEquals("'port' < 100", lessThan(PORT, 100).toString()); + assertEquals("'port' <= 100", lessOrEqual(PORT, 100).toString()); + assertEquals("'port' > 0", greaterThan(PORT, 0).toString()); + assertEquals("'port' >= 0", greaterOrEqual(PORT, 0).toString()); + assertEquals("'host' is not blank", notBlank(HOST).toString()); + assertEquals("'endpoint' starts with jdbc:", startsWith(ENDPOINT, "jdbc:").toString()); + assertEquals("'endpoint' matches ^\\d+$", matches(ENDPOINT, "^\\d+$").toString()); + assertEquals("'tags' is not empty", notEmpty(TAGS).toString()); + assertEquals("'tags' has unique elements", unique(TAGS).toString()); + + // Extended operators + assertEquals("'endpoint' contains ://", contains(ENDPOINT, "://").toString()); + assertEquals("'db_name' is uppercase", upperCase(DB_NAME).toString()); + assertEquals("'db_name' is lowercase", lowerCase(DB_NAME).toString()); + assertEquals("'start_ts' < 'end_ts'", lessThanField(START_TS, END_TS).toString()); + assertEquals("'start_ts' <= 'end_ts'", lessOrEqualField(START_TS, END_TS).toString()); + assertEquals("'end_ts' > 'start_ts'", greaterThanField(END_TS, START_TS).toString()); + assertEquals("'end_ts' >= 'start_ts'", greaterOrEqualField(END_TS, START_TS).toString()); } @Test public void testCircularConditionChainDetected() { - Condition a = Condition.greaterThan(PORT, 0); + Condition a = greaterThan(PORT, 0); assertThrows(IllegalArgumentException.class, () -> a.and(a)); } @Test public void testCircularConditionChainIndirect() { - Condition a = Condition.greaterThan(PORT, 0); - Condition b = Condition.lessThan(PORT, 100); + Condition a = greaterThan(PORT, 0); + Condition b = lessThan(PORT, 100); a.and(b); assertThrows(IllegalArgumentException.class, () -> b.and(a)); } @Test public void testCircularConditionChainDuplicateAppend() { - Condition a = Condition.greaterThan(PORT, 0); - Condition b = Condition.lessThan(PORT, 100); + Condition a = greaterThan(PORT, 0); + Condition b = lessThan(PORT, 100); a.and(b); assertThrows(IllegalArgumentException.class, () -> a.and(b)); } @@ -1366,8 +1019,8 @@ public void testBinaryLiteralOperatorWithoutExpectValueRejected() { public void testUnknownKeysDoesNotRejectValueConstraintOptions() { OptionRule rule = OptionRule.builder() - .required(PORT, Condition.greaterOrEqual(PORT, 1)) - .required(START_TS, END_TS, Condition.lessThanField(START_TS, END_TS)) + .required(PORT, greaterOrEqual(PORT, 1)) + .required(START_TS, END_TS, lessThanField(START_TS, END_TS)) .build(); Map config = new HashMap<>(); @@ -1383,8 +1036,7 @@ public void testUnknownKeysDoesNotRejectValueConstraintOptions() { @Test public void testUnknownKeysRejectsUndeclaredKey() { - OptionRule rule = - OptionRule.builder().required(PORT, Condition.greaterOrEqual(PORT, 1)).build(); + OptionRule rule = OptionRule.builder().required(PORT, greaterOrEqual(PORT, 1)).build(); Map config = new HashMap<>(); config.put(PORT.key(), 8080); @@ -1401,10 +1053,7 @@ public void testUnknownKeysRejectsUndeclaredKey() { public void testUnknownKeysRecognizesChainedConditionOptions() { OptionRule rule = OptionRule.builder() - .required( - PORT, - Condition.greaterOrEqual(PORT, 1) - .and(Condition.lessOrEqual(PORT, 65535))) + .required(PORT, greaterOrEqual(PORT, 1).and(lessOrEqual(PORT, 65535))) .build(); Map config = new HashMap<>(); @@ -1423,7 +1072,7 @@ public void testLargeTimestampComparisonPrecision() { OptionRule rule = OptionRule.builder() - .required(START_TS, END_TS, Condition.lessThanField(START_TS, END_TS)) + .required(START_TS, END_TS, lessThanField(START_TS, END_TS)) .build(); Map config = new HashMap<>(); @@ -1441,9 +1090,7 @@ public void testLargeLongLiteralComparison() { long bigValue = Long.MAX_VALUE - 1; OptionRule rule = - OptionRule.builder() - .required(START_TS, Condition.lessThan(START_TS, Long.MAX_VALUE)) - .build(); + OptionRule.builder().required(START_TS, lessThan(START_TS, Long.MAX_VALUE)).build(); Map config = new HashMap<>(); config.put(START_TS.key(), bigValue); @@ -1452,4 +1099,663 @@ public void testLargeLongLiteralComparison() { config.put(START_TS.key(), Long.MAX_VALUE); assertThrows(OptionValidationException.class, () -> validate(config, rule)); } + + @Test + public void testOptionalCrossFieldOnlyStartPresent() { + OptionRule rule = + OptionRule.builder() + .optional(START_TS, END_TS, lessThanField(START_TS, END_TS)) + .build(); + + Map config = new HashMap<>(); + config.put(START_TS.key(), 100L); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + } + + @Test + public void testOptionalCrossFieldOnlyEndPresent() { + OptionRule rule = + OptionRule.builder() + .optional(START_TS, END_TS, lessThanField(START_TS, END_TS)) + .build(); + + Map config = new HashMap<>(); + config.put(END_TS.key(), 200L); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + } + + @Test + public void testOrChainNumericNullWithStringFallback() { + OptionRule rule = + OptionRule.builder() + .optional(PORT, greaterOrEqual(PORT, 1).or(notBlank(HOST))) + .optional(HOST) + .build(); + + // PORT absent, HOST present -> or chain should pass + Map config = new HashMap<>(); + config.put(HOST.key(), "localhost"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + // PORT present and valid, HOST absent -> pass + config.clear(); + config.put(PORT.key(), 8080); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + // PORT present but invalid, HOST present -> or chain second branch saves it + config.clear(); + config.put(PORT.key(), 0); + config.put(HOST.key(), "localhost"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + // PORT present but invalid, HOST blank -> both branches fail + config.clear(); + config.put(PORT.key(), 0); + config.put(HOST.key(), ""); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testOrChainAllOptionalAllAbsent() { + OptionRule rule = + OptionRule.builder() + .optional(PORT, greaterOrEqual(PORT, 1).or(notBlank(HOST))) + .optional(HOST) + .build(); + + Map config = new HashMap<>(); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + } + + @Test + public void testNumericTypeMismatchIntVsLong() { + OptionRule rule = + OptionRule.builder().required(START_TS, greaterThan(START_TS, 0L)).build(); + + Map config = new HashMap<>(); + config.put(START_TS.key(), 100L); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(START_TS.key(), 0L); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testNumericTypeMismatchDoubleVsInt() { + OptionRule rule = OptionRule.builder().required(RATIO, lessOrEqual(RATIO, 100.0)).build(); + + Map config = new HashMap<>(); + // Integer in config vs Double in constraint (parser may return Integer for whole numbers) + config.put(RATIO.key(), 50); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(RATIO.key(), 100); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + // Double in config vs Double in constraint (fractional values) + config.put(RATIO.key(), 50.5); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(RATIO.key(), 100.0); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(RATIO.key(), 100.1); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + + // Integer exceeding constraint + config.put(RATIO.key(), 101); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testConditionalNestedValueConstraint() { + OptionRule rule = + OptionRule.builder() + .optional(MODE) + .conditional( + MODE, "stream", START_TS, END_TS, lessThanField(START_TS, END_TS)) + .build(); + + // Mode is "stream", both timestamps present and valid + Map config = new HashMap<>(); + config.put(MODE.key(), "stream"); + config.put(START_TS.key(), 100L); + config.put(END_TS.key(), 200L); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + // Mode is "stream", timestamps violate constraint + config.put(START_TS.key(), 300L); + config.put(END_TS.key(), 100L); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + + // Mode is not "stream", constraint should not apply + config.clear(); + config.put(MODE.key(), "batch"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + } + + @Test + public void testConditionWithNullExpectValueThrows() { + assertThrows(IllegalArgumentException.class, () -> Condition.of(HOST, null)); + assertThrows( + IllegalArgumentException.class, + () -> Condition.of(HOST, ConditionOperator.NOT_EQUAL, null)); + assertThrows(IllegalArgumentException.class, () -> greaterThan(PORT, null)); + assertThrows(IllegalArgumentException.class, () -> startsWith(HOST, null)); + } + + // ==================== Supplementary Operator & Edge-case Coverage ==================== + + @Test + public void testEqualOperatorValidation() { + OptionRule rule = + OptionRule.builder().required(HOST, Condition.of(HOST, "expected_host")).build(); + + Map config = new HashMap<>(); + config.put(HOST.key(), "expected_host"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(HOST.key(), "other_host"); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testLessOrEqualValidationStandalone() { + OptionRule rule = OptionRule.builder().required(PORT, lessOrEqual(PORT, 1024)).build(); + + Map config = new HashMap<>(); + config.put(PORT.key(), 1024); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(PORT.key(), 80); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(PORT.key(), 1025); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testNotEqualToString() { + assertEquals( + "'host' != blocked", + Condition.of(HOST, ConditionOperator.NOT_EQUAL, "blocked").toString()); + } + + @Test + public void testEqualToString() { + assertEquals("'host' == localhost", Condition.of(HOST, "localhost").toString()); + } + + @Test + public void testAndChainShortCircuit() { + OptionRule rule = + OptionRule.builder() + .required(PORT, greaterOrEqual(PORT, 1).and(lessOrEqual(PORT, 100))) + .build(); + + Map config = new HashMap<>(); + config.put(PORT.key(), 0); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + + config.put(PORT.key(), 50); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(PORT.key(), 101); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testOrChainShortCircuitFirstTrue() { + OptionRule rule = + OptionRule.builder() + .required(HOST, notBlank(HOST).or(notBlank(ENDPOINT))) + .optional(ENDPOINT) + .build(); + + Map config = new HashMap<>(); + config.put(HOST.key(), "valid-host"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + } + + @Test + public void testOrChainShortCircuitFirstFalseSecondTrue() { + OptionRule rule = + OptionRule.builder() + .required(HOST, notBlank(HOST).or(notBlank(ENDPOINT))) + .optional(ENDPOINT) + .build(); + + Map config = new HashMap<>(); + config.put(HOST.key(), ""); + config.put(ENDPOINT.key(), "valid-endpoint"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + } + + @Test + public void testOptionalWithDefaultValueAndConstraint() { + OptionRule rule = OptionRule.builder().optional(FILE_EXPR, notBlank(FILE_EXPR)).build(); + + Map config = new HashMap<>(); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(FILE_EXPR.key(), "my_file.csv"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(FILE_EXPR.key(), " "); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testThreeArgConditionFactory() { + Condition cond = Condition.of(PORT, ConditionOperator.GREATER_THAN, 0); + OptionRule rule = OptionRule.builder().required(PORT, cond).build(); + + Map config = new HashMap<>(); + config.put(PORT.key(), 10); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(PORT.key(), 0); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testUnknownKeysWithFallbackKeys() { + Option hostWithFallback = + Options.key("host") + .stringType() + .noDefaultValue() + .withFallbackKeys("hostname") + .withDescription("host"); + + OptionRule rule = + OptionRule.builder().required(hostWithFallback, notBlank(hostWithFallback)).build(); + + Map config = new HashMap<>(); + config.put("hostname", "localhost"); + + Assertions.assertDoesNotThrow( + () -> + ConfigValidator.validateUnknownKeys( + ReadonlyConfig.fromMap(config), rule, "TestConnector")); + } + + @Test + public void testRequiredCrossFieldBothEqual() { + OptionRule rule = + OptionRule.builder() + .required(START_TS, END_TS, lessOrEqualField(START_TS, END_TS)) + .build(); + + Map config = new HashMap<>(); + config.put(START_TS.key(), 500L); + config.put(END_TS.key(), 500L); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + } + + @Test + public void testAndChainToString() { + assertEquals( + "'port' >= 1 && 'port' <= 65535", + greaterOrEqual(PORT, 1).and(lessOrEqual(PORT, 65535)).toString()); + } + + @Test + public void testOrChainToString() { + assertEquals( + "'host' is not blank || 'endpoint' is not blank", + notBlank(HOST).or(notBlank(ENDPOINT)).toString()); + } + + @Test + public void testMixedAndOrChainToString() { + assertEquals( + "('port' >= 1 && 'port' <= 65535) || 'host' is not blank", + greaterOrEqual(PORT, 1) + .and(lessOrEqual(PORT, 65535)) + .or(notBlank(HOST)) + .toString()); + } + + @Test + public void testOptionalCrossFieldBothPresent() { + OptionRule rule = + OptionRule.builder() + .optional(START_TS, END_TS, lessThanField(START_TS, END_TS)) + .build(); + + Map config = new HashMap<>(); + config.put(START_TS.key(), 100L); + config.put(END_TS.key(), 200L); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(START_TS.key(), 300L); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testOptionalCrossFieldNonePresent() { + OptionRule rule = + OptionRule.builder() + .optional(START_TS, END_TS, lessThanField(START_TS, END_TS)) + .build(); + + Map config = new HashMap<>(); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + } + + @Test + public void testLongThreeNodeAndChain() { + OptionRule rule = + OptionRule.builder() + .required( + PORT, + greaterOrEqual(PORT, 1) + .and(lessOrEqual(PORT, 65535)) + .and(Condition.of(PORT, ConditionOperator.NOT_EQUAL, 22))) + .build(); + + Map config = new HashMap<>(); + config.put(PORT.key(), 8080); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(PORT.key(), 22); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + + config.put(PORT.key(), 0); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testCollectionNotEmptyWithNonCollectionValue() { + OptionRule rule = OptionRule.builder().required(TAGS, notEmpty(TAGS)).build(); + + Map config = new HashMap<>(); + config.put(TAGS.key(), "not_a_list"); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testCollectionUniqueWithNonCollectionValue() { + OptionRule rule = OptionRule.builder().required(TAGS, unique(TAGS)).build(); + + Map config = new HashMap<>(); + config.put(TAGS.key(), "not_a_list"); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testNotBlankWithNonStringValue() { + OptionRule rule = OptionRule.builder().required(HOST, notBlank(HOST)).build(); + + Map config = new HashMap<>(); + config.put(HOST.key(), 12345); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testStartsWithNonMatch() { + OptionRule rule = + OptionRule.builder().required(ENDPOINT, startsWith(ENDPOINT, "https://")).build(); + + Map config = new HashMap<>(); + config.put(ENDPOINT.key(), "http://example.com"); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + + config.put(ENDPOINT.key(), "https://example.com"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + } + + @Test + public void testMatchesNonMatch() { + OptionRule rule = OptionRule.builder().required(HOST, matches(HOST, "^[a-z]+$")).build(); + + Map config = new HashMap<>(); + config.put(HOST.key(), "localhost"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(HOST.key(), "Local-Host"); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + // ==================== isConstraintApplicable — partial optional coverage ==================== + + @Test + public void testOptionalCrossFieldOnlyStartPresentNoFalsePositive() { + OptionRule rule = + OptionRule.builder() + .optional(START_TS, END_TS, lessThanField(START_TS, END_TS)) + .build(); + + Map config = new HashMap<>(); + config.put(START_TS.key(), 999L); + Assertions.assertDoesNotThrow( + () -> validate(config, rule), "partial cross-field must not cause false-positive"); + } + + @Test + public void testOptionalCrossFieldOnlyEndPresentNoFalsePositive() { + OptionRule rule = + OptionRule.builder() + .optional(START_TS, END_TS, lessThanField(START_TS, END_TS)) + .build(); + + Map config = new HashMap<>(); + config.put(END_TS.key(), 999L); + Assertions.assertDoesNotThrow( + () -> validate(config, rule), "partial cross-field must not cause false-positive"); + } + + @Test + public void testOptionalSingleFieldAbsentSkipsConstraint() { + OptionRule rule = OptionRule.builder().optional(PORT, greaterOrEqual(PORT, 1)).build(); + + Map config = new HashMap<>(); + Assertions.assertDoesNotThrow( + () -> validate(config, rule), "absent optional should skip constraint"); + } + + @Test + public void testOptionalSingleFieldPresentEnforcesConstraint() { + OptionRule rule = OptionRule.builder().optional(PORT, greaterOrEqual(PORT, 1)).build(); + + Map config = new HashMap<>(); + config.put(PORT.key(), 0); + assertThrows( + OptionValidationException.class, + () -> validate(config, rule), + "present optional should enforce constraint"); + } + + @Test + public void testOrChainPartialAbsentSkipsConstraint() { + OptionRule rule = + OptionRule.builder() + .optional(PORT, greaterOrEqual(PORT, 1).or(notBlank(HOST))) + .optional(HOST) + .build(); + + Map config = new HashMap<>(); + config.put(PORT.key(), 8080); + Assertions.assertDoesNotThrow( + () -> validate(config, rule), + "OR chain with one side absent should skip when other side absent"); + } + + @Test + public void testOrChainBothPresentFirstFails() { + OptionRule rule = + OptionRule.builder() + .optional(PORT, greaterOrEqual(PORT, 1).or(notBlank(HOST))) + .optional(HOST) + .build(); + + Map config = new HashMap<>(); + config.put(PORT.key(), 0); + config.put(HOST.key(), "localhost"); + Assertions.assertDoesNotThrow( + () -> validate(config, rule), "OR chain should pass when second branch succeeds"); + } + + @Test + public void testOrChainBothPresentBothFail() { + OptionRule rule = + OptionRule.builder() + .optional(PORT, greaterOrEqual(PORT, 1).or(notBlank(HOST))) + .optional(HOST) + .build(); + + Map config = new HashMap<>(); + config.put(PORT.key(), 0); + config.put(HOST.key(), " "); + assertThrows( + OptionValidationException.class, + () -> validate(config, rule), + "OR chain should fail when both branches fail"); + } + + @Test + public void testRequiredCrossFieldOneAbsentFailsRequired() { + OptionRule rule = + OptionRule.builder() + .required(START_TS, END_TS, lessThanField(START_TS, END_TS)) + .build(); + + Map config = new HashMap<>(); + config.put(START_TS.key(), 100L); + assertThrows( + OptionValidationException.class, + () -> validate(config, rule), + "required cross-field should fail when one option is absent"); + } + + // ==================== Error message quality & aggregation ==================== + + @Test + public void testSingleConstraintErrorMessageContainsActualValue() { + OptionRule rule = + OptionRule.builder() + .required(PORT, greaterOrEqual(PORT, 1).and(lessOrEqual(PORT, 65535))) + .build(); + + Map config = new HashMap<>(); + config.put(PORT.key(), -1); + OptionValidationException ex = + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + String msg = ex.getMessage(); + Assertions.assertTrue(msg.contains("option: port"), "should contain option label"); + Assertions.assertTrue(msg.contains("constraint:"), "should contain constraint label"); + Assertions.assertTrue(msg.contains(">= 1"), "should contain constraint expression"); + } + + @Test + public void testMultipleConstraintErrorsAggregated() { + OptionRule rule = + OptionRule.builder() + .required(PORT, greaterOrEqual(PORT, 1)) + .required(HOST, notBlank(HOST)) + .build(); + + Map config = new HashMap<>(); + config.put(PORT.key(), -1); + config.put(HOST.key(), " "); + OptionValidationException ex = + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + String msg = ex.getMessage(); + Assertions.assertTrue(msg.contains("2 errors"), "should report 2 errors"); + Assertions.assertTrue(msg.contains("[1] option: port"), "should have numbered port"); + Assertions.assertTrue(msg.contains("[2] option: host"), "should have numbered host"); + } + + @Test + public void testThreeConstraintErrorsAggregated() { + OptionRule rule = + OptionRule.builder() + .required(PORT, greaterOrEqual(PORT, 1).and(lessOrEqual(PORT, 65535))) + .required(HOST, notBlank(HOST)) + .required(ENDPOINT, matches(ENDPOINT, "^[^:]+:\\d+$")) + .build(); + + Map config = new HashMap<>(); + config.put(PORT.key(), 99999); + config.put(HOST.key(), ""); + config.put(ENDPOINT.key(), "no-port-here"); + OptionValidationException ex = + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + String msg = ex.getMessage(); + Assertions.assertTrue(msg.contains("3 errors"), "should report 3 errors"); + Assertions.assertTrue(msg.contains("[1] option: port")); + Assertions.assertTrue(msg.contains("[2] option: host")); + Assertions.assertTrue(msg.contains("[3] option: endpoint")); + } + + @Test + public void testMixedNumericStringCollectionErrors() { + OptionRule rule = + OptionRule.builder() + .required(RATIO, greaterThan(RATIO, 0.0).and(lessOrEqual(RATIO, 1.0))) + .required(DB_NAME, notBlank(DB_NAME)) + .required(TAGS, notEmpty(TAGS)) + .build(); + + Map config = new HashMap<>(); + config.put(RATIO.key(), -0.5); + config.put(DB_NAME.key(), " "); + config.put(TAGS.key(), Collections.emptyList()); + OptionValidationException ex = + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + String msg = ex.getMessage(); + Assertions.assertTrue(msg.contains("3 errors"), "should report 3 errors"); + Assertions.assertTrue(msg.contains("option: ratio")); + Assertions.assertTrue(msg.contains("option: db_name")); + Assertions.assertTrue(msg.contains("option: tags")); + } + + @Test + public void testCrossFieldAndLiteralErrorsAggregated() { + OptionRule rule = + OptionRule.builder() + .required(START_TS, END_TS, lessThanField(START_TS, END_TS)) + .required(PORT, greaterOrEqual(PORT, 1)) + .build(); + + Map config = new HashMap<>(); + config.put(START_TS.key(), 2000L); + config.put(END_TS.key(), 1000L); + config.put(PORT.key(), 0); + OptionValidationException ex = + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + String msg = ex.getMessage(); + Assertions.assertTrue(msg.contains("2 errors"), "should report 2 errors"); + Assertions.assertTrue(msg.contains("option: start_ts")); + Assertions.assertTrue(msg.contains("option: port")); + } + + @Test + public void testPassingConstraintsNoAggregation() { + OptionRule rule = + OptionRule.builder() + .required(PORT, greaterOrEqual(PORT, 1).and(lessOrEqual(PORT, 65535))) + .required(HOST, notBlank(HOST)) + .required(TAGS, notEmpty(TAGS).and(unique(TAGS))) + .build(); + + Map config = new HashMap<>(); + config.put(PORT.key(), 8080); + config.put(HOST.key(), "localhost"); + config.put(TAGS.key(), Arrays.asList("a", "b", "c")); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + } + + @Test + public void testSingleErrorNoPlural() { + OptionRule rule = OptionRule.builder().required(PORT, greaterOrEqual(PORT, 1)).build(); + + Map config = new HashMap<>(); + config.put(PORT.key(), 0); + OptionValidationException ex = + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + String msg = ex.getMessage(); + Assertions.assertTrue(msg.contains("1 error)"), "single error should not be plural"); + Assertions.assertFalse(msg.contains("1 errors"), "should not say '1 errors'"); + } } diff --git a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/response/OptionRuleResponse.java b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/response/OptionRuleResponse.java index a3d452980e84..2fd445b82abc 100644 --- a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/response/OptionRuleResponse.java +++ b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/response/OptionRuleResponse.java @@ -162,6 +162,8 @@ public static class ConditionNode { private final Object expectValue; private final String compareOperator; private final OptionMetadata compareOption; + private final String conditionOperator; + private final String conditionOperatorCategory; private final LogicalOperator operator; private final ConditionNode next; @@ -170,7 +172,7 @@ public ConditionNode( Object expectValue, LogicalOperator operator, ConditionNode next) { - this(option, expectValue, null, null, operator, next); + this(option, expectValue, null, null, null, null, operator, next); } public ConditionNode( @@ -178,12 +180,16 @@ public ConditionNode( Object expectValue, String compareOperator, OptionMetadata compareOption, + String conditionOperator, + String conditionOperatorCategory, LogicalOperator operator, ConditionNode next) { this.option = option; this.expectValue = expectValue; this.compareOperator = compareOperator; this.compareOption = compareOption; + this.conditionOperator = conditionOperator; + this.conditionOperatorCategory = conditionOperatorCategory; this.operator = operator; this.next = next; } diff --git a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesService.java b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesService.java index 92e3c4908b44..002a9530033d 100644 --- a/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesService.java +++ b/seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesService.java @@ -280,11 +280,15 @@ private OptionRuleResponse.ConditionNode toConditionNode(Condition condition) condition.getCompareOption() != null ? toOptionMetadata(condition.getCompareOption()) : null; + String conditionOperator = (op != null) ? op.name() : null; + String conditionOperatorCategory = (op != null) ? op.getCategory().name() : null; return new OptionRuleResponse.ConditionNode( toOptionMetadata(condition.getOption()), condition.getExpectValue(), compareOperatorSymbol, compareOptionMeta, + conditionOperator, + conditionOperatorCategory, toLogicalOperator(condition.and()), toConditionNode(condition.getNext())); } diff --git a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesServiceTest.java b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesServiceTest.java index 61f4b95c21c0..151c3ec3e9d5 100644 --- a/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesServiceTest.java +++ b/seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/rest/service/OptionRulesServiceTest.java @@ -21,7 +21,7 @@ import org.apache.seatunnel.api.configuration.Option; import org.apache.seatunnel.api.configuration.Options; import org.apache.seatunnel.api.configuration.SingleChoiceOption; -import org.apache.seatunnel.api.configuration.util.Condition; +import org.apache.seatunnel.api.configuration.util.Conditions; import org.apache.seatunnel.api.configuration.util.OptionRule; import org.apache.seatunnel.engine.server.rest.response.OptionRuleResponse; @@ -299,9 +299,9 @@ void shouldPreserveValueConstraintMetadata() { OptionRule.builder() .required( port, - Condition.greaterOrEqual(port, 1) - .and(Condition.lessOrEqual(port, 65535))) - .required(host, Condition.notBlank(host)) + Conditions.greaterOrEqual(port, 1) + .and(Conditions.lessOrEqual(port, 65535))) + .required(host, Conditions.notBlank(host)) .build(); OptionRuleResponse response = @@ -338,7 +338,7 @@ void shouldPreserveCrossFieldConstraintMetadata() { OptionRule optionRule = OptionRule.builder() - .required(startTs, endTs, Condition.lessThanField(startTs, endTs)) + .required(startTs, endTs, Conditions.lessThanField(startTs, endTs)) .build(); OptionRuleResponse response = @@ -355,7 +355,7 @@ void shouldPreserveCrossFieldConstraintMetadata() { OptionRuleResponse.ConditionNode tree = constraint.getConditionTree(); assertNotNull(tree); - assertEquals("< [field]", tree.getCompareOperator()); + assertEquals("<", tree.getCompareOperator()); assertNotNull(tree.getCompareOption()); assertEquals("end_ts", tree.getCompareOption().getKey()); } From c4d8a1a4c7d6c0fae7bcccaa000e20c0d369226d Mon Sep 17 00:00:00 2001 From: "zhiwei.niu" Date: Tue, 2 Jun 2026 01:35:14 +0800 Subject: [PATCH 11/16] [Fix][Api] Improve testing scenarios --- .../configuration-and-option-system.md | 185 ++-- .../configuration-and-option-system.md | 185 ++-- .../api/configuration/util/Condition.java | 96 +- .../configuration/util/ConfigValidator.java | 240 +++-- .../util/ConfigValidatorTest.java | 910 ++++++++++++++++-- 5 files changed, 1272 insertions(+), 344 deletions(-) diff --git a/docs/en/architecture/configuration-and-option-system.md b/docs/en/architecture/configuration-and-option-system.md index 5185d5782019..b78920a2b7de 100644 --- a/docs/en/architecture/configuration-and-option-system.md +++ b/docs/en/architecture/configuration-and-option-system.md @@ -104,79 +104,33 @@ public OptionRule optionRule() { ### Value Constraints (`Condition`) -Beyond structural rules (required, exclusive, etc.), options can carry **value-level constraints** that the runtime validates before a job starts. The `Condition` API provides a fluent way to attach these constraints inside `OptionRule.builder()`. - -**Numeric range constraints:** - -```java -OptionRule.builder() - .required(PORT, - Conditions.greaterOrEqual(PORT, 1) - .and(Conditions.lessOrEqual(PORT, 65535))) - .build(); -``` - -**String constraints:** - -```java -OptionRule.builder() - .required(HOST, Conditions.notBlank(HOST)) - .optional(DB_NAME, Condition.upperCase(DB_NAME)) - .build(); -``` - -**Cross-field comparison:** - -```java -OptionRule.builder() - .required(START_TS, END_TS, - Condition.lessThanField(START_TS, END_TS)) - .build(); -``` - -**Collection constraints:** - -```java -OptionRule.builder() - .required(TABLES, - Conditions.notEmpty(TABLES) - .and(Conditions.unique(TABLES))) - .build(); -``` - -Available operators: - -| Category | Operator | Description | -|----------|----------|-------------| -| Numeric | `greaterThan` | value > threshold | -| Numeric | `greaterOrEqual` | value >= threshold | -| Numeric | `lessThan` | value < threshold | -| Numeric | `lessOrEqual` | value <= threshold | -| String | `notBlank` | string is not empty or whitespace-only | -| String | `startsWith` | string starts with a given prefix | -| String | `endsWith` | string ends with a given suffix | -| String | `contains` | string contains a given substring | -| String | `matches` | string matches a regular expression | -| String | `upperCase` | string is all uppercase | -| String | `lowerCase` | string is all lowercase | -| String length | `lengthEqual` | string length == n | -| String length | `lengthGreaterOrEqual` | string length >= n | -| String length | `lengthLessOrEqual` | string length <= n | -| Collection | `notEmpty` | collection is not empty | -| Collection | `unique` | collection has no duplicate elements | -| Collection | `sizeEqual` | collection size == n | -| Collection | `sizeGreaterOrEqual` | collection size >= n | -| Collection | `sizeLessOrEqual` | collection size <= n | -| Cross-field | `lessThanField` | value < another option's value | -| Cross-field | `lessOrEqualField` | value <= another option's value | -| Cross-field | `greaterThanField` | value > another option's value | -| Cross-field | `greaterOrEqualField` | value >= another option's value | -| Cross-field | `fieldEqual` | value == another option's value | -| Cross-field | `fieldNotEqual` | value != another option's value | -| Cross-field | `fieldSizeEqual` | collection size == another collection's size | +Beyond structural rules (required, exclusive, etc.), options can carry **value-level constraints** that the runtime validates before a job starts. The `Condition` API provides a fluent way to attach these constraints inside `OptionRule.builder()`. See the [OptionRule Pattern Guide](#optionrule-pattern-guide) below for usage examples. + +Available operators (all accessed via the `Conditions` factory class): + +| Category | Method | Description | +|----------|--------|-------------| +| Equality | `Condition.of(option, value)` | value == expected (legacy API) | +| Equality | `Condition.of(option, NOT_EQUAL, value)` | value != expected | +| Numeric | `greaterThan(option, threshold)` | value > threshold | +| Numeric | `greaterOrEqual(option, threshold)` | value >= threshold | +| Numeric | `lessThan(option, threshold)` | value < threshold | +| Numeric | `lessOrEqual(option, threshold)` | value <= threshold | +| String | `notBlank(option)` | string is not empty or whitespace-only | +| String | `startsWith(option, prefix)` | string starts with a given prefix | +| String | `contains(option, substring)` | string contains a given substring | +| String | `matches(option, regex)` | string matches a regular expression | +| String | `upperCase(option)` | string is all uppercase | +| String | `lowerCase(option)` | string is all lowercase | +| Collection | `notEmpty(option)` | collection is not empty | +| Collection | `unique(option)` | collection has no duplicate elements | +| Cross-field | `lessThanField(option, other)` | value < another option's value | +| Cross-field | `lessOrEqualField(option, other)` | value <= another option's value | +| Cross-field | `greaterThanField(option, other)` | value > another option's value | +| Cross-field | `greaterOrEqualField(option, other)` | value >= another option's value | :::tip -Multiple conditions can be chained with `.and(...)` or `.or(...)` to form compound constraints. +Multiple conditions can be chained with `.and(...)` or `.or(...)` to form compound constraints. AND binds tighter than OR, so `A.or(B).and(C)` evaluates as `A || (B && C)`. ::: ### `ReadonlyConfig` @@ -203,7 +157,34 @@ At a high level, configuration flows through the system like this: 5. The resolved values are exposed to the runtime through `ReadonlyConfig`. 6. The same metadata can also be exposed through REST for UI rendering and automation. -When validation fails, `OptionValidationException` is thrown with a message that includes the constraint expression and the option key involved. +When validation fails, `OptionValidationException` is thrown with a structured error message. See the [Validation Error Messages](#validation-error-messages) section below for details. + +## Validation Error Messages + +Option validation errors are thrown as `OptionValidationException`, a subclass of `SeaTunnelRuntimeException`, carrying the error code `API-02`. The message always begins with: + +``` +ErrorCode:[API-02], ErrorDescription:[Option item validate failed] +``` + +Structural (required, bundled, exclusive, conditional) and value constraint errors are aggregated into a single numbered list. Each entry follows a consistent three-line format with a `type` label (`required` / `bundled` / `exclusive` / `conditional` / `value`) for easy identification. Structural errors come first. If a required option is absent, its value constraint is automatically suppressed to avoid redundant noise. + +``` +ErrorCode:[API-02], ErrorDescription:[Option item validate failed] - +Option validation failed (4 errors): + [1] option: 'host' + type: required + constraint: required option is not configured + [2] options: 'username', 'password' + type: bundled + constraint: bundled options must be present or absent together (present: ['username'], absent: ['password']) + [3] option: port + type: value + constraint: 'port' >= 1 + [4] option: start_ts + type: value + constraint: 'start_ts' < 'end_ts' +``` ## OptionRule Pattern Guide @@ -269,7 +250,7 @@ Host names that must not be blank, identifiers that must be uppercase, or endpoi ```java .required(HOST, Conditions.notBlank(HOST)) -.required(DATABASE, Condition.upperCase(DATABASE)) +.required(DATABASE, Conditions.upperCase(DATABASE)) .required(ENDPOINT, Conditions.matches(ENDPOINT, "^[^:]+:\\d+$")) ``` @@ -279,7 +260,7 @@ When the value of one option must be smaller or larger than another. ```java .required(START_TS, END_TS, - Condition.lessThanField(START_TS, END_TS)) + Conditions.lessThanField(START_TS, END_TS)) ``` ### Collection constraints @@ -292,9 +273,9 @@ Lists that must not be empty, or whose elements must be unique. .and(Conditions.unique(TABLES))) ``` -### Compound constraints +### Compound constraints with AND -Multiple conditions combined with `.and(...)` or `.or(...)`. +Multiple conditions combined with `.and(...)`. All conditions must hold. ```java .required(RATIO, @@ -302,6 +283,58 @@ Multiple conditions combined with `.and(...)` or `.or(...)`. .and(Conditions.lessOrEqual(RATIO, 1.0))) ``` +### OR chain — at least one alternative must pass + +When the user can satisfy the constraint through any one of several options, use `.or(...)`. The constraint passes as long as at least one branch succeeds. + +```java +// At least one of HOST or ENDPOINT must be non-blank +.optional(HOST, Conditions.notBlank(HOST).or(Conditions.notBlank(ENDPOINT))) +.optional(ENDPOINT) +``` + +### Mixed AND / OR chain + +AND binds tighter than OR, so `A.or(B.and(C))` evaluates as `A || (B && C)`. This is useful when one simple condition can serve as a fallback for a stricter compound check. + +```java +// Valid if HOST is non-blank, OR if PORT is within range [1, 65535] +.optional(HOST, + Conditions.notBlank(HOST) + .or(Conditions.greaterOrEqual(PORT, 1) + .and(Conditions.lessOrEqual(PORT, 65535)))) +.optional(PORT) +``` + +### Conditional required with value constraint + +When a conditional option also needs value validation, pass the constraint as the last argument. The constraint is only enforced when the trigger condition is met. + +```java +// When START_MODE == TIMESTAMP, START_TIMESTAMP is required and must be > 0 +.conditional(START_MODE, StartMode.TIMESTAMP, + Conditions.greaterThan(START_TIMESTAMP, 0L)) +``` + +### Optional with value constraint + +An optional field that, when present, must satisfy a constraint. If the field is absent, the constraint is skipped entirely. + +```java +.optional(BATCH_SIZE, + Conditions.greaterOrEqual(BATCH_SIZE, 1) + .and(Conditions.lessOrEqual(BATCH_SIZE, 10000))) +``` + +### Optional cross-field constraint + +When two optional fields are provided together, their values must satisfy a cross-field rule. If either field is absent, the constraint is skipped. + +```java +.optional(START_TS, END_TS, + Conditions.lessThanField(START_TS, END_TS)) +``` + ## Why It Matters For Operators This architecture is also what makes the `option-rules` REST endpoint useful. Tools can inspect the runtime metadata of installed connectors and dynamically understand: diff --git a/docs/zh/architecture/configuration-and-option-system.md b/docs/zh/architecture/configuration-and-option-system.md index 35b125655ce2..cebf9adbd372 100644 --- a/docs/zh/architecture/configuration-and-option-system.md +++ b/docs/zh/architecture/configuration-and-option-system.md @@ -104,79 +104,33 @@ public OptionRule optionRule() { ### 值约束(`Condition`) -除了结构性规则(必填、互斥等),配置项还可以携带**值级别约束**,运行时会在作业启动前进行校验。`Condition` API 提供了一种流式方式,在 `OptionRule.builder()` 中附加这些约束。 - -**数值范围约束:** - -```java -OptionRule.builder() - .required(PORT, - Conditions.greaterOrEqual(PORT, 1) - .and(Conditions.lessOrEqual(PORT, 65535))) - .build(); -``` - -**字符串约束:** - -```java -OptionRule.builder() - .required(HOST, Conditions.notBlank(HOST)) - .optional(DB_NAME, Condition.upperCase(DB_NAME)) - .build(); -``` - -**跨字段比较:** - -```java -OptionRule.builder() - .required(START_TS, END_TS, - Condition.lessThanField(START_TS, END_TS)) - .build(); -``` - -**集合约束:** - -```java -OptionRule.builder() - .required(TABLES, - Conditions.notEmpty(TABLES) - .and(Conditions.unique(TABLES))) - .build(); -``` - -可用的操作符: - -| 类别 | 操作符 | 说明 | -|------|--------|------| -| 数值 | `greaterThan` | 值 > 阈值 | -| 数值 | `greaterOrEqual` | 值 >= 阈值 | -| 数值 | `lessThan` | 值 < 阈值 | -| 数值 | `lessOrEqual` | 值 <= 阈值 | -| 字符串 | `notBlank` | 字符串非空且不全为空白字符 | -| 字符串 | `startsWith` | 字符串以指定前缀开头 | -| 字符串 | `endsWith` | 字符串以指定后缀结尾 | -| 字符串 | `contains` | 字符串包含指定子串 | -| 字符串 | `matches` | 字符串匹配正则表达式 | -| 字符串 | `upperCase` | 字符串全部大写 | -| 字符串 | `lowerCase` | 字符串全部小写 | -| 字符串长度 | `lengthEqual` | 字符串长度 == n | -| 字符串长度 | `lengthGreaterOrEqual` | 字符串长度 >= n | -| 字符串长度 | `lengthLessOrEqual` | 字符串长度 <= n | -| 集合 | `notEmpty` | 集合非空 | -| 集合 | `unique` | 集合元素无重复 | -| 集合 | `sizeEqual` | 集合大小 == n | -| 集合 | `sizeGreaterOrEqual` | 集合大小 >= n | -| 集合 | `sizeLessOrEqual` | 集合大小 <= n | -| 跨字段 | `lessThanField` | 值 < 另一个配置项的值 | -| 跨字段 | `lessOrEqualField` | 值 <= 另一个配置项的值 | -| 跨字段 | `greaterThanField` | 值 > 另一个配置项的值 | -| 跨字段 | `greaterOrEqualField` | 值 >= 另一个配置项的值 | -| 跨字段 | `fieldEqual` | 值 == 另一个配置项的值 | -| 跨字段 | `fieldNotEqual` | 值 != 另一个配置项的值 | -| 跨字段 | `fieldSizeEqual` | 集合大小 == 另一个集合的大小 | +除了结构性规则(必填、互斥等),配置项还可以携带**值级别约束**,运行时会在作业启动前进行校验。`Condition` API 提供了一种流式方式,在 `OptionRule.builder()` 中附加这些约束。具体用法参见下方 [OptionRule 模式编码指南](#optionrule-模式编码指南)。 + +可用的操作符(均通过 `Conditions` 工厂类调用): + +| 类别 | 方法 | 说明 | +|------|------|------| +| 相等性 | `Condition.of(option, value)` | 值 == 期望值(兼容旧 API) | +| 相等性 | `Condition.of(option, NOT_EQUAL, value)` | 值 != 期望值 | +| 数值 | `greaterThan(option, threshold)` | 值 > 阈值 | +| 数值 | `greaterOrEqual(option, threshold)` | 值 >= 阈值 | +| 数值 | `lessThan(option, threshold)` | 值 < 阈值 | +| 数值 | `lessOrEqual(option, threshold)` | 值 <= 阈值 | +| 字符串 | `notBlank(option)` | 字符串非空且不全为空白字符 | +| 字符串 | `startsWith(option, prefix)` | 字符串以指定前缀开头 | +| 字符串 | `contains(option, substring)` | 字符串包含指定子串 | +| 字符串 | `matches(option, regex)` | 字符串匹配正则表达式 | +| 字符串 | `upperCase(option)` | 字符串全部大写 | +| 字符串 | `lowerCase(option)` | 字符串全部小写 | +| 集合 | `notEmpty(option)` | 集合非空 | +| 集合 | `unique(option)` | 集合元素无重复 | +| 跨字段 | `lessThanField(option, other)` | 值 < 另一个配置项的值 | +| 跨字段 | `lessOrEqualField(option, other)` | 值 <= 另一个配置项的值 | +| 跨字段 | `greaterThanField(option, other)` | 值 > 另一个配置项的值 | +| 跨字段 | `greaterOrEqualField(option, other)` | 值 >= 另一个配置项的值 | :::tip -多个条件可以通过 `.and(...)` 或 `.or(...)` 链式组合成复合约束。 +多个条件可以通过 `.and(...)` 或 `.or(...)` 链式组合成复合约束。AND 优先级高于 OR,因此 `A.or(B).and(C)` 等价于 `A || (B && C)`。 ::: ### `ReadonlyConfig` @@ -203,7 +157,34 @@ public void prepare(Config pluginConfig) { 5. 运行时通过 `ReadonlyConfig` 获取已解析参数。 6. 同一套元数据还可以通过 REST 暴露给 UI 或自动化系统。 -校验失败时,`OptionValidationException` 会被抛出,异常消息中包含约束表达式和涉及的配置项 key。 +校验失败时,`OptionValidationException` 会被抛出,携带结构化的错误消息。详见下方 [校验错误消息](#校验错误消息) 章节。 + +## 校验错误消息 + +选项校验错误以 `OptionValidationException` 抛出,它是 `SeaTunnelRuntimeException` 的子类,携带错误码 `API-02`。消息始终以下列前缀开头: + +``` +ErrorCode:[API-02], ErrorDescription:[Option item validate failed] +``` + +选项值与结构校验(必填、成组、互斥、条件、值约束)的错误统一聚合为编号列表。每条记录使用一致的三行格式,带 `type` 标签(`required` / `bundled` / `exclusive` / `conditional` / `value`)以便识别分类。结构性错误排在前面,若某个必填项缺失,其值约束会自动跳过以避免冗余。 + +``` +ErrorCode:[API-02], ErrorDescription:[Option item validate failed] - +Option validation failed (4 errors): + [1] option: 'host' + type: required + constraint: required option is not configured + [2] options: 'username', 'password' + type: bundled + constraint: bundled options must be present or absent together (present: ['username'], absent: ['password']) + [3] option: port + type: value + constraint: 'port' >= 1 + [4] option: start_ts + type: value + constraint: 'start_ts' < 'end_ts' +``` ## OptionRule 模式编码指南 @@ -269,7 +250,7 @@ public void prepare(Config pluginConfig) { ```java .required(HOST, Conditions.notBlank(HOST)) -.required(DATABASE, Condition.upperCase(DATABASE)) +.required(DATABASE, Conditions.upperCase(DATABASE)) .required(ENDPOINT, Conditions.matches(ENDPOINT, "^[^:]+:\\d+$")) ``` @@ -279,7 +260,7 @@ public void prepare(Config pluginConfig) { ```java .required(START_TS, END_TS, - Condition.lessThanField(START_TS, END_TS)) + Conditions.lessThanField(START_TS, END_TS)) ``` ### 集合约束 @@ -292,9 +273,9 @@ public void prepare(Config pluginConfig) { .and(Conditions.unique(TABLES))) ``` -### 复合约束 +### AND 复合约束 -多个条件通过 `.and(...)` 或 `.or(...)` 链式组合。 +多个条件通过 `.and(...)` 组合,所有条件必须同时满足。 ```java .required(RATIO, @@ -302,6 +283,58 @@ public void prepare(Config pluginConfig) { .and(Conditions.lessOrEqual(RATIO, 1.0))) ``` +### OR 链 — 至少一个分支通过 + +当用户可以通过满足多个选项中的任意一个来通过约束时,使用 `.or(...)`。只要有一个分支成功,整个约束即通过。 + +```java +// HOST 或 ENDPOINT 至少有一个非空 +.optional(HOST, Conditions.notBlank(HOST).or(Conditions.notBlank(ENDPOINT))) +.optional(ENDPOINT) +``` + +### 混合 AND / OR 链 + +AND 优先级高于 OR,因此 `A.or(B.and(C))` 等价于 `A || (B && C)`。适用于一个简单条件作为更严格复合检查的备选。 + +```java +// HOST 非空即可,或者 PORT 在 [1, 65535] 范围内 +.optional(HOST, + Conditions.notBlank(HOST) + .or(Conditions.greaterOrEqual(PORT, 1) + .and(Conditions.lessOrEqual(PORT, 65535)))) +.optional(PORT) +``` + +### 条件必填 + 值约束 + +条件必填项除了判断是否存在,还可以附加值约束。约束仅在触发条件满足时才会执行。 + +```java +// 当 START_MODE == TIMESTAMP 时,START_TIMESTAMP 必须存在且 > 0 +.conditional(START_MODE, StartMode.TIMESTAMP, + Conditions.greaterThan(START_TIMESTAMP, 0L)) +``` + +### 可选项 + 值约束 + +可选字段在用户提供时必须满足约束;若字段缺失则跳过校验。 + +```java +.optional(BATCH_SIZE, + Conditions.greaterOrEqual(BATCH_SIZE, 1) + .and(Conditions.lessOrEqual(BATCH_SIZE, 10000))) +``` + +### 可选跨字段约束 + +两个可选字段同时提供时,它们的值必须满足跨字段规则。若任一字段缺失则跳过校验。 + +```java +.optional(START_TS, END_TS, + Conditions.lessThanField(START_TS, END_TS)) +``` + ## 为什么这对运维也重要 这套设计也是 `option-rules` REST 接口能够成立的原因。运维平台或 UI 可以通过运行时元数据动态获知: diff --git a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Condition.java b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Condition.java index 6a6fc52ced85..5511b7ce7dd2 100644 --- a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Condition.java +++ b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Condition.java @@ -19,9 +19,15 @@ import org.apache.seatunnel.api.configuration.Option; +import lombok.Getter; + +import java.util.ArrayList; +import java.util.List; import java.util.Objects; +@Getter public class Condition { + private final Option option; private final T expectValue; private final ConditionOperator operator; @@ -61,8 +67,6 @@ public class Condition { this.compareOption = compareOption; } - // ==================== Equality (backward-compatible) ==================== - public static Condition of(Option option, T expectValue) { return new Condition<>(option, expectValue); } @@ -71,8 +75,6 @@ public static Condition of(Option option, ConditionOperator op, T expe return new Condition<>(option, op, expectValue, null); } - // ==================== Chain operations (existing API, unchanged) ==================== - public Condition and(Option option, E expectValue) { return and(of(option, expectValue)); } @@ -125,38 +127,14 @@ Condition getTailCondition() { return hasNext() ? this.next.getTailCondition() : this; } - // ==================== Accessors ==================== - public boolean hasNext() { return this.next != null; } - public Condition getNext() { - return this.next; - } - - public Option getOption() { - return option; - } - - public T getExpectValue() { - return expectValue; - } - - public ConditionOperator getOperator() { - return operator; - } - - public Option getCompareOption() { - return compareOption; - } - public Boolean and() { return this.and; } - // ==================== equals / hashCode ==================== - @Override public boolean equals(Object obj) { if (this == obj) { @@ -185,28 +163,56 @@ public int hashCode() { this.next); } - // ==================== toString ==================== - + /** + * Renders this condition chain as a human-readable string using AND-first precedence grouping, + * consistent with the evaluation semantics in {@code ConfigValidator}. + * + *

Multi-node AND segments are wrapped in parentheses when mixed with OR: {@code A || (B && + * C)} rather than {@code (A || B) && C}. + */ @Override public String toString() { + List orSegments = new ArrayList<>(); + List orSegmentSizes = new ArrayList<>(); Condition cur = this; - StringBuilder builder = new StringBuilder(); - boolean bracket = false; - do { - builder.append(conditionToString(cur)); - if (bracket) { - builder = new StringBuilder(String.format("(%s)", builder)); - bracket = false; - } - if (cur.hasNext()) { - if (cur.next.hasNext() && !cur.and.equals(cur.next.and)) { - bracket = true; + while (cur != null) { + StringBuilder segment = new StringBuilder(); + int count = 0; + while (cur != null) { + if (count > 0) { + segment.append(" && "); + } + segment.append(conditionToString(cur)); + count++; + if (!cur.hasNext()) { + cur = null; + break; + } + if (Boolean.TRUE.equals(cur.and)) { + cur = cur.next; + } else { + cur = cur.next; + break; } - builder.append(cur.and ? " && " : " || "); } - cur = cur.next; - } while (cur != null); - return builder.toString(); + orSegments.add(segment.toString()); + orSegmentSizes.add(count); + } + if (orSegments.size() == 1) { + return orSegments.get(0); + } + StringBuilder result = new StringBuilder(); + for (int i = 0; i < orSegments.size(); i++) { + if (i > 0) { + result.append(" || "); + } + if (orSegmentSizes.get(i) > 1) { + result.append("(").append(orSegments.get(i)).append(")"); + } else { + result.append(orSegments.get(i)); + } + } + return result.toString(); } private static String conditionToString(Condition cond) { 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 d88bd3cd7893..ef33772d3a7e 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 @@ -159,13 +159,33 @@ public void validate(OptionRule rule) { } public void validate(OptionRule rule, Expression expression) { - List requiredOptions = rule.getRequiredOptions(); - for (RequiredOption requiredOption : requiredOptions) { - validate(requiredOption, expression); + List errors = new ArrayList<>(); + collectErrors(rule, expression, errors); + if (!errors.isEmpty()) { + StringBuilder sb = new StringBuilder(); + sb.append( + String.format( + "Option validation failed (%d error%s):", + errors.size(), errors.size() > 1 ? "s" : "")); + for (int i = 0; i < errors.size(); i++) { + sb.append(String.format("\n [%d] %s", i + 1, errors.get(i))); + } + throw new OptionValidationException(sb.toString()); + } + } + + private void collectErrors(OptionRule rule, Expression expression, List errors) { + Set structurallyAbsentKeys = new HashSet<>(); + + for (RequiredOption requiredOption : rule.getRequiredOptions()) { + String error = checkRequiredOption(requiredOption, expression); + if (error != null) { + errors.add(error); + collectAbsentKeys(requiredOption, structurallyAbsentKeys); + } for (Option option : requiredOption.getOptions()) { if (SingleChoiceOption.class.isAssignableFrom(option.getClass())) { - // is required option and not match condition, skip validate if (isConditionOption(requiredOption) && !matchCondition( (RequiredOption.ConditionalRequiredOptions) requiredOption)) { @@ -182,43 +202,58 @@ public void validate(OptionRule rule, Expression expression) { } } - List conditionRules = rule.getConditionRules(); - for (ConditionRule conditionRule : conditionRules) { + for (ConditionRule conditionRule : rule.getConditionRules()) { if (validate(conditionRule.getExpression())) { - validate(conditionRule.getOptionRule(), conditionRule.getExpression()); + collectErrors(conditionRule.getOptionRule(), conditionRule.getExpression(), errors); } } - List> failedConstraints = new ArrayList<>(); for (Condition constraint : rule.getValueConstraints()) { + if (structurallyAbsentKeys.contains(constraint.getOption().key())) { + continue; + } if (isConstraintApplicable(constraint, rule) && !validate(constraint)) { - failedConstraints.add(constraint); + errors.add( + String.format( + "option: %s\n type: value\n constraint: %s", + constraint.getOption().key(), constraint.toString())); } } - if (!failedConstraints.isEmpty()) { - StringBuilder sb = new StringBuilder(); - sb.append( - String.format( - "Option validation failed (%d error%s):", - failedConstraints.size(), failedConstraints.size() > 1 ? "s" : "")); - for (int i = 0; i < failedConstraints.size(); i++) { - Condition c = failedConstraints.get(i); - sb.append( - String.format( - "\n [%d] option: %s\n constraint: %s", - i + 1, c.getOption().key(), c.toString())); + } + + private void collectAbsentKeys(RequiredOption requiredOption, Set keys) { + if (requiredOption instanceof RequiredOption.AbsolutelyRequiredOptions) { + for (Option opt : + getAbsentOptions( + ((RequiredOption.AbsolutelyRequiredOptions) requiredOption) + .getRequiredOption())) { + keys.add(opt.key()); + } + } else if (isConditionOption(requiredOption)) { + RequiredOption.ConditionalRequiredOptions cond = + (RequiredOption.ConditionalRequiredOptions) requiredOption; + if (matchCondition(cond)) { + for (Option opt : getAbsentOptions(cond.getRequiredOption())) { + keys.add(opt.key()); + } } - throw new OptionValidationException(sb.toString()); } } /** - * Determines whether a value constraint should be evaluated. Walks the entire condition chain - * (including compareOption for cross-field operators) and collects all referenced options. + * Determines whether a value constraint should be evaluated. + * + *

If any referenced option is absolutely required, the constraint is always applicable. * - *

If any referenced option is absolutely required, the constraint is always applicable. For - * optional constraints, ALL referenced options must be present — this prevents false-positive - * violations when only a subset of cross-field options is provided. + *

For optional constraints, the chain is split into OR-separated AND segments. Each AND + * segment requires ALL its options to be present. The constraint is applicable if ANY segment + * has all options present. This ensures: + * + *

    + *
  • Cross-field within one AND segment: both fields must exist (no false positive) + *
  • OR chains: each branch evaluated independently (no false negative) + *
  • All absent: every segment fails → constraint skipped + *
*/ private boolean isConstraintApplicable(Condition condition, OptionRule rule) { Set> allOptions = collectAllConditionOptions(condition); @@ -230,12 +265,45 @@ private boolean isConstraintApplicable(Condition condition, OptionRule rule) } } } - for (Option opt : allOptions) { - if (!hasOption(opt)) { - return false; + return anyOrSegmentFullyPresent(condition); + } + + /** + * Splits the condition chain at OR boundaries into AND segments, and returns true if any + * segment has all of its referenced options present in the config. + */ + private boolean anyOrSegmentFullyPresent(Condition condition) { + Condition cur = condition; + while (cur != null) { + Set> segmentOptions = new HashSet<>(); + while (cur != null) { + segmentOptions.add(cur.getOption()); + if (cur.getCompareOption() != null) { + segmentOptions.add(cur.getCompareOption()); + } + if (!cur.hasNext()) { + cur = null; + break; + } + if (Boolean.TRUE.equals(cur.and())) { + cur = cur.getNext(); + } else { + cur = cur.getNext(); + break; + } + } + boolean allPresent = true; + for (Option opt : segmentOptions) { + if (!hasOption(opt)) { + allPresent = false; + break; + } + } + if (allPresent) { + return true; } } - return true; + return false; } private Set> collectAllConditionOptions(Condition condition) { @@ -277,22 +345,19 @@ void validateSingleChoice(Option option) { } } - void validate(RequiredOption requiredOption, Expression expression) { + String checkRequiredOption(RequiredOption requiredOption, Expression expression) { if (requiredOption instanceof RequiredOption.AbsolutelyRequiredOptions) { - validate((RequiredOption.AbsolutelyRequiredOptions) requiredOption, expression); - return; + return checkAbsolutelyRequired( + (RequiredOption.AbsolutelyRequiredOptions) requiredOption, expression); } if (requiredOption instanceof RequiredOption.BundledRequiredOptions) { - validate((RequiredOption.BundledRequiredOptions) requiredOption); - return; + return checkBundled((RequiredOption.BundledRequiredOptions) requiredOption); } if (requiredOption instanceof RequiredOption.ExclusiveRequiredOptions) { - validate((RequiredOption.ExclusiveRequiredOptions) requiredOption); - return; + return checkExclusive((RequiredOption.ExclusiveRequiredOptions) requiredOption); } if (isConditionOption(requiredOption)) { - validate((RequiredOption.ConditionalRequiredOptions) requiredOption); - return; + return checkConditional((RequiredOption.ConditionalRequiredOptions) requiredOption); } throw new UnsupportedOperationException( String.format( @@ -303,7 +368,6 @@ void validate(RequiredOption requiredOption, Expression expression) { private List> getAbsentOptions(List> requiredOption) { List> absent = new ArrayList<>(); for (Option option : requiredOption) { - // If the required option have default values, we will take the default values if (!hasOption(option) && option.defaultValue() == null) { absent.add(option); } @@ -311,29 +375,23 @@ private List> getAbsentOptions(List> requiredOption) { return absent; } - void validate(RequiredOption.AbsolutelyRequiredOptions requiredOption, Expression expression) { + String checkAbsolutelyRequired( + RequiredOption.AbsolutelyRequiredOptions requiredOption, Expression expression) { List> absentOptions = getAbsentOptions(requiredOption.getRequiredOption()); - if (absentOptions.size() == 0) { - return; - } - throw new OptionValidationException( - "There are unconfigured options, the options(%s) are required%s", - getOptionKeys(absentOptions), getExpressionExceptionHintMessage(expression)); - } - - String getExpressionExceptionHintMessage(Expression expression) { - if (expression == null) { - return "."; - } else { - return " when [" + expression + "]."; + if (absentOptions.isEmpty()) { + 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); } boolean hasOption(Option option) { return config.getOptional(option).isPresent(); } - boolean validate(RequiredOption.BundledRequiredOptions bundledRequiredOptions) { + String checkBundled(RequiredOption.BundledRequiredOptions bundledRequiredOptions) { List> bundledOptions = bundledRequiredOptions.getRequiredOption(); List> present = new ArrayList<>(); List> absent = new ArrayList<>(); @@ -344,20 +402,16 @@ boolean validate(RequiredOption.BundledRequiredOptions bundledRequiredOptions) { absent.add(option); } } - if (present.size() == bundledOptions.size()) { - return true; - } - if (absent.size() == bundledOptions.size()) { - return false; + if (present.size() == bundledOptions.size() || absent.size() == bundledOptions.size()) { + return null; } - throw new OptionValidationException( - "These options(%s) are bundled, must be present or absent together. The options present are: %s. The options absent are %s.", + 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)); } - void validate(RequiredOption.ExclusiveRequiredOptions exclusiveRequiredOptions) { + String checkExclusive(RequiredOption.ExclusiveRequiredOptions exclusiveRequiredOptions) { List> presentOptions = new ArrayList<>(); - for (Option option : exclusiveRequiredOptions.getExclusiveOptions()) { if (hasOption(option)) { presentOptions.add(option); @@ -365,30 +419,31 @@ void validate(RequiredOption.ExclusiveRequiredOptions exclusiveRequiredOptions) } int count = presentOptions.size(); if (count == 1) { - return; + return null; } if (count == 0) { - throw new OptionValidationException( - "There are unconfigured options, these options(%s) are mutually exclusive, allowing only one set(\"[] for a set\") of options to be configured.", + return String.format( + "options: %s\n type: exclusive\n constraint: exactly one option must be set, but none are configured", getOptionKeys(exclusiveRequiredOptions.getExclusiveOptions())); } - throw new OptionValidationException( - "These options(%s) are mutually exclusive, allowing only one set(\"[] for a set\") of options to be configured.", + return String.format( + "options: %s\n type: exclusive\n constraint: mutually exclusive, but multiple are set: [%s]", + getOptionKeys(exclusiveRequiredOptions.getExclusiveOptions()), getOptionKeys(presentOptions)); } - void validate(RequiredOption.ConditionalRequiredOptions conditionalRequiredOptions) { + String checkConditional(RequiredOption.ConditionalRequiredOptions conditionalRequiredOptions) { boolean match = matchCondition(conditionalRequiredOptions); if (!match) { - return; + return null; } List> absentOptions = getAbsentOptions(conditionalRequiredOptions.getRequiredOption()); - if (absentOptions.size() == 0) { - return; + if (absentOptions.isEmpty()) { + return null; } - throw new OptionValidationException( - "There are unconfigured options, the options(%s) are required because [%s] is true.", + return String.format( + "option: %s\n type: conditional\n constraint: required because [%s] is true", getOptionKeys(absentOptions), conditionalRequiredOptions.getExpression().toString()); } @@ -406,16 +461,33 @@ private boolean validate(Expression expression) { } } + /** + * Evaluates a condition chain with standard boolean precedence: AND binds tighter than OR. The + * chain {@code A.and(B).or(C)} evaluates as {@code (A && B) || C}, matching the output of + * {@link Condition#toString()}. + */ private boolean validate(Condition condition) { - boolean match = ConditionEvaluators.evaluate(condition, config); - if (!condition.hasNext()) { - return match; - } - if (condition.and()) { - return match && validate(condition.getNext()); - } else { - return match || validate(condition.getNext()); + Condition cur = condition; + while (cur != null) { + boolean andGroupResult = true; + while (cur != null) { + andGroupResult = andGroupResult && ConditionEvaluators.evaluate(cur, config); + if (!cur.hasNext()) { + cur = null; + break; + } + if (Boolean.TRUE.equals(cur.and())) { + cur = cur.getNext(); + } else { + cur = cur.getNext(); + break; + } + } + if (andGroupResult) { + return true; + } } + return false; } private boolean isConditionOption(RequiredOption requiredOption) { 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 fc374327911a..a49b757744c4 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 @@ -106,14 +106,15 @@ public void testAbsolutelyRequiredOption() { // absent config.put(TEST_PORTS.key(), "[9090]"); - assertEquals( - "ErrorCode:[API-02], ErrorDescription:[Option item validate failed] - There are unconfigured options, the options('username', 'password') are required.", - assertThrows(OptionValidationException.class, executable).getMessage()); + String msg = assertThrows(OptionValidationException.class, executable).getMessage(); + Assertions.assertTrue(msg.contains("1 error)")); + Assertions.assertTrue(msg.contains("[1] option: 'username', 'password'")); + Assertions.assertTrue(msg.contains("constraint: required option is not configured")); config.put(KEY_USERNAME.key(), "asuka"); - assertEquals( - "ErrorCode:[API-02], ErrorDescription:[Option item validate failed] - There are unconfigured options, the options('password') are required.", - assertThrows(OptionValidationException.class, executable).getMessage()); + msg = assertThrows(OptionValidationException.class, executable).getMessage(); + Assertions.assertTrue(msg.contains("[1] option: 'password'")); + Assertions.assertTrue(msg.contains("constraint: required option is not configured")); // all present config.put(KEY_PASSWORD.key(), "saitou"); @@ -131,12 +132,13 @@ public void testBundledRequiredOptions() { // case2: some present config.put(KEY_USERNAME.key(), "asuka"); - assertEquals( - "ErrorCode:[API-02], ErrorDescription:[Option item validate failed] - These options('username', 'password') are bundled, must be present or absent together." - + " The options present are: 'username'. The options absent are 'password'.", - assertThrows(OptionValidationException.class, executable).getMessage()); + String msg = assertThrows(OptionValidationException.class, executable).getMessage(); + Assertions.assertTrue(msg.contains("[1] options: 'username', 'password'")); + Assertions.assertTrue(msg.contains("bundled options must be present or absent together")); + Assertions.assertTrue(msg.contains("present: ['username']")); + Assertions.assertTrue(msg.contains("absent: ['password']")); - // case2: all present + // case3: all present config.put(KEY_PASSWORD.key(), "saitou"); Assertions.assertDoesNotThrow(executable); } @@ -148,10 +150,10 @@ public void testSimpleExclusiveRequiredOptions() { Executable executable = () -> validate(config, rule); // all absent - assertEquals( - "ErrorCode:[API-02], ErrorDescription:[Option item validate failed] - There are unconfigured options, these options('option.topic-pattern', 'option.topic') are mutually exclusive," - + " allowing only one set(\"[] for a set\") of options to be configured.", - assertThrows(OptionValidationException.class, executable).getMessage()); + String msg = assertThrows(OptionValidationException.class, executable).getMessage(); + Assertions.assertTrue(msg.contains("'option.topic-pattern', 'option.topic'")); + Assertions.assertTrue( + msg.contains("exactly one option must be set, but none are configured")); // only one present config.put(TEST_TOPIC_PATTERN.key(), "asuka"); @@ -159,10 +161,8 @@ public void testSimpleExclusiveRequiredOptions() { // present > 1 config.put(TEST_TOPIC.key(), "[\"saitou\"]"); - assertEquals( - "ErrorCode:[API-02], ErrorDescription:[Option item validate failed] - These options('option.topic-pattern', 'option.topic') are mutually exclusive, " - + "allowing only one set(\"[] for a set\") of options to be configured.", - assertThrows(OptionValidationException.class, executable).getMessage()); + msg = assertThrows(OptionValidationException.class, executable).getMessage(); + Assertions.assertTrue(msg.contains("mutually exclusive, but multiple are set")); } @Test @@ -174,10 +174,10 @@ public void testComplexExclusiveRequiredOptions() { Executable executable = () -> validate(config, rule); // all absent - assertEquals( - "ErrorCode:[API-02], ErrorDescription:[Option item validate failed] - There are unconfigured options, these options('bearer-token', 'kerberos-ticket') are mutually exclusive," - + " allowing only one set(\"[] for a set\") of options to be configured.", - assertThrows(OptionValidationException.class, executable).getMessage()); + String msg = assertThrows(OptionValidationException.class, executable).getMessage(); + Assertions.assertTrue(msg.contains("'bearer-token', 'kerberos-ticket'")); + Assertions.assertTrue( + msg.contains("exactly one option must be set, but none are configured")); // set one config.put(KEY_BEARER_TOKEN.key(), "ashulin"); @@ -185,10 +185,8 @@ public void testComplexExclusiveRequiredOptions() { // all set config.put(KEY_KERBEROS_TICKET.key(), "zongwen"); - assertEquals( - "ErrorCode:[API-02], ErrorDescription:[Option item validate failed] - These options('bearer-token', 'kerberos-ticket') are mutually exclusive," - + " allowing only one set(\"[] for a set\") of options to be configured.", - assertThrows(OptionValidationException.class, executable).getMessage()); + msg = assertThrows(OptionValidationException.class, executable).getMessage(); + Assertions.assertTrue(msg.contains("mutually exclusive, but multiple are set")); } @Test @@ -206,10 +204,10 @@ public void testSimpleConditionalRequiredOptionsWithDefaultValue() { // Expression match, and required options absent config.put(TEST_MODE.key(), "timestamp"); - assertEquals( - "ErrorCode:[API-02], ErrorDescription:[Option item validate failed] - There are unconfigured options, the options('option.timestamp') are required" - + " because ['option.mode' == TIMESTAMP] is true.", - assertThrows(OptionValidationException.class, executable).getMessage()); + String msg = assertThrows(OptionValidationException.class, executable).getMessage(); + Assertions.assertTrue(msg.contains("option: 'option.timestamp'")); + Assertions.assertTrue( + msg.contains("required because ['option.mode' == TIMESTAMP] is true")); // Expression match, and required options all present config.put(TEST_TIMESTAMP.key(), "564231238596789"); @@ -235,10 +233,9 @@ public void testSimpleConditionalRequiredOptionsWithoutDefaultValue() { // Expression match, and required options absent config.put(KEY_USERNAME.key(), "ashulin"); - assertEquals( - "ErrorCode:[API-02], ErrorDescription:[Option item validate failed] - There are unconfigured options, the options('option.timestamp') are required" - + " because ['username' == ashulin] is true.", - assertThrows(OptionValidationException.class, executable).getMessage()); + String msg = assertThrows(OptionValidationException.class, executable).getMessage(); + Assertions.assertTrue(msg.contains("option: 'option.timestamp'")); + Assertions.assertTrue(msg.contains("required because ['username' == ashulin] is true")); // Expression match, and required options all present config.put(TEST_TIMESTAMP.key(), "564231238596789"); @@ -265,17 +262,19 @@ public void testComplexConditionalRequiredOptions() { // 'username' == ashulin, and required options absent config.put(KEY_USERNAME.key(), "ashulin"); - assertEquals( - "ErrorCode:[API-02], ErrorDescription:[Option item validate failed] - There are unconfigured options, the options('option.timestamp') are required" - + " because ['username' == ashulin || 'username' == asuka] is true.", - assertThrows(OptionValidationException.class, executable).getMessage()); + String msg = assertThrows(OptionValidationException.class, executable).getMessage(); + Assertions.assertTrue(msg.contains("option: 'option.timestamp'")); + Assertions.assertTrue( + msg.contains( + "required because ['username' == ashulin || 'username' == asuka] is true")); // 'username' == asuka, and required options absent config.put(KEY_USERNAME.key(), "asuka"); - assertEquals( - "ErrorCode:[API-02], ErrorDescription:[Option item validate failed] - There are unconfigured options, the options('option.timestamp') are required" - + " because ['username' == ashulin || 'username' == asuka] is true.", - assertThrows(OptionValidationException.class, executable).getMessage()); + msg = assertThrows(OptionValidationException.class, executable).getMessage(); + Assertions.assertTrue(msg.contains("option: 'option.timestamp'")); + Assertions.assertTrue( + msg.contains( + "required because ['username' == ashulin || 'username' == asuka] is true")); // Expression match, and required options all present config.put(TEST_TIMESTAMP.key(), "564231238596789"); @@ -347,9 +346,11 @@ public void testNestedOptionRule() { config.put(SINGLE_CHOICE_VALUE_TEST.key(), "A"); executable = () -> validate(config, optionRule); - assertEquals( - "ErrorCode:[API-02], ErrorDescription:[Option item validate failed] - There are unconfigured options, the options('username', 'password') are required when ['single_choice_test' == A].", - assertThrows(OptionValidationException.class, executable).getMessage()); + String msg = assertThrows(OptionValidationException.class, executable).getMessage(); + Assertions.assertTrue(msg.contains("option: 'username', 'password'")); + Assertions.assertTrue(msg.contains("type: required")); + Assertions.assertTrue( + msg.contains("required option is not configured when ['single_choice_test' == A]")); config.put(KEY_USERNAME.key(), "root"); config.put(KEY_PASSWORD.key(), "111"); @@ -358,9 +359,11 @@ public void testNestedOptionRule() { config.put(KEY_USERNAME.key(), "admin"); executable = () -> validate(config, optionRule); - assertEquals( - "ErrorCode:[API-02], ErrorDescription:[Option item validate failed] - There are unconfigured options, the options('test_key') are required when ['username' == admin].", - assertThrows(OptionValidationException.class, executable).getMessage()); + msg = assertThrows(OptionValidationException.class, executable).getMessage(); + Assertions.assertTrue(msg.contains("option: 'test_key'")); + Assertions.assertTrue(msg.contains("type: required")); + Assertions.assertTrue( + msg.contains("required option is not configured when ['username' == admin]")); config.put(test_key.key(), "111"); executable = () -> validate(config, optionRule); @@ -413,15 +416,21 @@ public void testMultipleValueNestedRule() { config.put(KEY_KERBEROS_TICKET.key(), "A"); config.put(SINGLE_CHOICE_VALUE_TEST.key(), "B"); Executable executable = () -> validate(config, optionRule); - assertEquals( - "ErrorCode:[API-02], ErrorDescription:[Option item validate failed] - There are unconfigured options, the options('username', 'password') are required when ['single_choice_test' == A || 'single_choice_test' == B].", - assertThrows(OptionValidationException.class, executable).getMessage()); + String msg = assertThrows(OptionValidationException.class, executable).getMessage(); + Assertions.assertTrue(msg.contains("option: 'username', 'password'")); + Assertions.assertTrue(msg.contains("type: required")); + Assertions.assertTrue( + msg.contains( + "required option is not configured when ['single_choice_test' == A || 'single_choice_test' == B]")); config.put(SINGLE_CHOICE_VALUE_TEST.key(), "B"); executable = () -> validate(config, optionRule); - assertEquals( - "ErrorCode:[API-02], ErrorDescription:[Option item validate failed] - There are unconfigured options, the options('username', 'password') are required when ['single_choice_test' == A || 'single_choice_test' == B].", - assertThrows(OptionValidationException.class, executable).getMessage()); + msg = assertThrows(OptionValidationException.class, executable).getMessage(); + Assertions.assertTrue(msg.contains("option: 'username', 'password'")); + Assertions.assertTrue(msg.contains("type: required")); + Assertions.assertTrue( + msg.contains( + "required option is not configured when ['single_choice_test' == A || 'single_choice_test' == B]")); } // ==================== Validation Rule Tests ==================== @@ -1466,30 +1475,36 @@ public void testLongThreeNodeAndChain() { } @Test - public void testCollectionNotEmptyWithNonCollectionValue() { + public void testCollectionNotEmptyWithScalarValue() { OptionRule rule = OptionRule.builder().required(TAGS, notEmpty(TAGS)).build(); Map config = new HashMap<>(); config.put(TAGS.key(), "not_a_list"); - assertThrows(OptionValidationException.class, () -> validate(config, rule)); + Assertions.assertDoesNotThrow( + () -> validate(config, rule), + "ReadonlyConfig normalizes scalar to single-element list, which is non-empty"); } @Test - public void testCollectionUniqueWithNonCollectionValue() { + public void testCollectionUniqueWithScalarValue() { OptionRule rule = OptionRule.builder().required(TAGS, unique(TAGS)).build(); Map config = new HashMap<>(); config.put(TAGS.key(), "not_a_list"); - assertThrows(OptionValidationException.class, () -> validate(config, rule)); + Assertions.assertDoesNotThrow( + () -> validate(config, rule), + "ReadonlyConfig normalizes scalar to single-element list, which is unique"); } @Test - public void testNotBlankWithNonStringValue() { + public void testNotBlankWithNumericValue() { OptionRule rule = OptionRule.builder().required(HOST, notBlank(HOST)).build(); Map config = new HashMap<>(); config.put(HOST.key(), 12345); - assertThrows(OptionValidationException.class, () -> validate(config, rule)); + Assertions.assertDoesNotThrow( + () -> validate(config, rule), + "ReadonlyConfig converts integer to string '12345', which is not blank"); } @Test @@ -1567,7 +1582,7 @@ public void testOptionalSingleFieldPresentEnforcesConstraint() { } @Test - public void testOrChainPartialAbsentSkipsConstraint() { + public void testOrChainFirstSegmentValidSecondAbsent() { OptionRule rule = OptionRule.builder() .optional(PORT, greaterOrEqual(PORT, 1).or(notBlank(HOST))) @@ -1577,8 +1592,82 @@ public void testOrChainPartialAbsentSkipsConstraint() { Map config = new HashMap<>(); config.put(PORT.key(), 8080); Assertions.assertDoesNotThrow( + () -> validate(config, rule), "first OR segment present and valid -> pass"); + } + + @Test + public void testOrChainFirstSegmentInvalidSecondAbsentFails() { + OptionRule rule = + OptionRule.builder() + .optional(PORT, greaterOrEqual(PORT, 1).or(notBlank(HOST))) + .optional(HOST) + .build(); + + Map config = new HashMap<>(); + config.put(PORT.key(), 0); + assertThrows( + OptionValidationException.class, + () -> validate(config, rule), + "first OR segment invalid + second absent -> fail"); + } + + @Test + public void testOrChainFirstAbsentSecondPresent() { + OptionRule rule = + OptionRule.builder() + .optional(PORT, greaterOrEqual(PORT, 1).or(notBlank(HOST))) + .optional(HOST) + .build(); + + Map config = new HashMap<>(); + config.put(HOST.key(), "localhost"); + Assertions.assertDoesNotThrow( + () -> validate(config, rule), + "first OR segment absent + second present and valid -> pass"); + } + + @Test + public void testOrChainFirstAbsentSecondInvalidFails() { + OptionRule rule = + OptionRule.builder() + .optional(PORT, greaterOrEqual(PORT, 1).or(notBlank(HOST))) + .optional(HOST) + .build(); + + Map config = new HashMap<>(); + config.put(HOST.key(), " "); + assertThrows( + OptionValidationException.class, + () -> validate(config, rule), + "first OR segment absent + second present but invalid -> fail"); + } + + @Test + public void testOrChainCrossFieldOrLiteral() { + OptionRule rule = + OptionRule.builder() + .optional( + START_TS, + END_TS, + lessThanField(START_TS, END_TS).or(greaterOrEqual(START_TS, 0L))) + .build(); + + Map config = new HashMap<>(); + config.put(START_TS.key(), 100L); + Assertions.assertDoesNotThrow( + () -> validate(config, rule), + "cross-field OR absent + literal segment valid -> pass"); + + config.put(START_TS.key(), -1L); + assertThrows( + OptionValidationException.class, () -> validate(config, rule), - "OR chain with one side absent should skip when other side absent"); + "cross-field OR absent + literal segment invalid -> fail"); + + config.put(START_TS.key(), 200L); + config.put(END_TS.key(), 300L); + Assertions.assertDoesNotThrow( + () -> validate(config, rule), "cross-field segment present and valid -> pass"); } @Test @@ -1758,4 +1847,699 @@ public void testSingleErrorNoPlural() { Assertions.assertTrue(msg.contains("1 error)"), "single error should not be plural"); Assertions.assertFalse(msg.contains("1 errors"), "should not say '1 errors'"); } + + @Test + public void testUnknownKeysWithConditionalValueConstraint() { + OptionRule rule = + OptionRule.builder() + .optional(MODE) + .conditional(MODE, "stream", greaterThan(START_TS, 0L)) + .build(); + + Map config = new HashMap<>(); + config.put(MODE.key(), "stream"); + config.put(START_TS.key(), 100L); + + Assertions.assertDoesNotThrow( + () -> + ConfigValidator.validateUnknownKeys( + ReadonlyConfig.fromMap(config), rule, "TestConnector"), + "conditional value constraint option should be in declared keys"); + } + + @Test + public void testUnknownKeysWithConditionalMultiFieldConstraint() { + OptionRule rule = + OptionRule.builder() + .optional(ENABLE_TX) + .conditional( + ENABLE_TX, true, START_TS, END_TS, lessThanField(START_TS, END_TS)) + .build(); + + Map config = new HashMap<>(); + config.put(ENABLE_TX.key(), true); + config.put(START_TS.key(), 100L); + config.put(END_TS.key(), 200L); + + Assertions.assertDoesNotThrow( + () -> + ConfigValidator.validateUnknownKeys( + ReadonlyConfig.fromMap(config), rule, "TestConnector"), + "conditional multi-field constraint options should be in declared keys"); + } + + @Test + public void testUnknownKeysWithOrChainMultipleOptions() { + OptionRule rule = + OptionRule.builder() + .optional(HOST, notBlank(HOST).or(notBlank(ENDPOINT))) + .optional(ENDPOINT) + .build(); + + Map config = new HashMap<>(); + config.put(HOST.key(), "localhost"); + config.put(ENDPOINT.key(), "my-endpoint"); + + Assertions.assertDoesNotThrow( + () -> + ConfigValidator.validateUnknownKeys( + ReadonlyConfig.fromMap(config), rule, "TestConnector"), + "OR chain options should all be in declared keys"); + } + + // ==================== Additional OR / AND chain edge cases ==================== + + @Test + public void testThreeWayOrChain() { + OptionRule rule = + OptionRule.builder() + .optional(HOST, notBlank(HOST).or(notBlank(ENDPOINT)).or(notBlank(DB_NAME))) + .optional(ENDPOINT) + .optional(DB_NAME) + .build(); + + Map config = new HashMap<>(); + config.put(DB_NAME.key(), "mydb"); + Assertions.assertDoesNotThrow( + () -> validate(config, rule), "third OR segment present and valid -> pass"); + + config.clear(); + config.put(HOST.key(), ""); + config.put(ENDPOINT.key(), ""); + config.put(DB_NAME.key(), ""); + assertThrows( + OptionValidationException.class, + () -> validate(config, rule), + "all three OR branches blank -> fail"); + + config.clear(); + Assertions.assertDoesNotThrow(() -> validate(config, rule), "all three absent -> skip"); + } + + @Test + public void testAndOrMixedChainAndBindsTighter() { + // A.and(B).or(C) evaluates as (A && B) || C — AND has higher precedence than OR + OptionRule rule = + OptionRule.builder() + .optional( + START_TS, + END_TS, + lessThanField(START_TS, END_TS) + .and(greaterOrEqual(START_TS, 0L)) + .or(notBlank(HOST))) + .optional(HOST) + .build(); + + // (A && B) = true -> pass + Map config = new HashMap<>(); + config.put(START_TS.key(), 100L); + config.put(END_TS.key(), 200L); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + // (A && B) both fail, C = true -> OR rescues + config.clear(); + config.put(START_TS.key(), 300L); + config.put(END_TS.key(), 100L); + config.put(HOST.key(), "fallback"); + Assertions.assertDoesNotThrow( + () -> validate(config, rule), "AND group fails but OR fallback rescues"); + + // Only HOST present and valid -> C alone passes via OR + config.clear(); + config.put(HOST.key(), "fallback"); + Assertions.assertDoesNotThrow( + () -> validate(config, rule), "only OR fallback present and valid -> pass"); + + // (A && B) fail, C blank -> both OR branches fail + config.clear(); + config.put(START_TS.key(), 300L); + config.put(END_TS.key(), 100L); + config.put(HOST.key(), ""); + assertThrows( + OptionValidationException.class, + () -> validate(config, rule), + "AND group fails + OR fallback blank -> fail"); + + // All absent -> constraint skipped + config.clear(); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + } + + @Test + public void testMultipleVarargsConstraints() { + OptionRule rule = + OptionRule.builder() + .required(PORT, greaterOrEqual(PORT, 1), lessOrEqual(PORT, 65535)) + .build(); + + Map config = new HashMap<>(); + config.put(PORT.key(), 8080); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(PORT.key(), 0); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + + config.put(PORT.key(), 70000); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testConditionalValueOnlyConstraintSkipsWhenTargetAbsent() { + OptionRule rule = + OptionRule.builder() + .optional(MODE) + .conditional(MODE, "stream", greaterThan(START_TS, 0L)) + .build(); + + Map config = new HashMap<>(); + config.put(MODE.key(), "stream"); + Assertions.assertDoesNotThrow( + () -> validate(config, rule), + "value-only conditional with target absent -> constraint skipped by design"); + } + + @Test + public void testConditionalValueOnlyConstraintEnforcesWhenTargetPresent() { + OptionRule rule = + OptionRule.builder() + .optional(MODE) + .conditional(MODE, "stream", greaterThan(START_TS, 0L)) + .build(); + + Map config = new HashMap<>(); + config.put(MODE.key(), "stream"); + config.put(START_TS.key(), 100L); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(START_TS.key(), 0L); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testEmptyStringTreatedAsPresent() { + OptionRule rule = OptionRule.builder().optional(HOST, notBlank(HOST)).build(); + + Map config = new HashMap<>(); + config.put(HOST.key(), ""); + assertThrows( + OptionValidationException.class, + () -> validate(config, rule), + "empty string is present but blank -> constraint should fail"); + } + + @Test + public void testRequiredPrimaryWithAbsentOptionalCompareField() { + OptionRule rule = + OptionRule.builder() + .required(START_TS, lessThanField(START_TS, END_TS)) + .optional(END_TS) + .build(); + + Map config = new HashMap<>(); + config.put(START_TS.key(), 100L); + assertThrows( + OptionValidationException.class, + () -> validate(config, rule), + "required primary -> constraint applicable; compare field null -> evaluator returns false -> fail"); + } + + @Test + public void testRequiredPrimaryWithCompareFieldBothPresent() { + OptionRule rule = + OptionRule.builder() + .required(START_TS, lessThanField(START_TS, END_TS)) + .optional(END_TS) + .build(); + + Map config = new HashMap<>(); + config.put(START_TS.key(), 100L); + config.put(END_TS.key(), 200L); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + } + + @Test + public void testOrThenAndChainEvaluation() { + // A.or(B).and(C) evaluates as A || (B && C) + OptionRule rule = + OptionRule.builder() + .required( + PORT, + notBlank(HOST) + .or(greaterOrEqual(PORT, 1).and(lessOrEqual(PORT, 100)))) + .required(HOST) + .build(); + + Map config = new HashMap<>(); + config.put(HOST.key(), "localhost"); + config.put(PORT.key(), 200); + Assertions.assertDoesNotThrow( + () -> validate(config, rule), + "A (notBlank HOST) true -> OR short-circuits, (B && C) skipped"); + + config.put(HOST.key(), ""); + config.put(PORT.key(), 50); + Assertions.assertDoesNotThrow( + () -> validate(config, rule), + "A false, (B && C) = (50>=1 && 50<=100) = true -> pass"); + + config.put(HOST.key(), ""); + config.put(PORT.key(), 200); + assertThrows( + OptionValidationException.class, + () -> validate(config, rule), + "A false, (B && C) = (200>=1 && 200<=100) = false -> fail"); + } + + @Test + public void testInvalidRegexPatternThrowsPatternSyntaxException() { + OptionRule rule = OptionRule.builder().required(HOST, matches(HOST, "[invalid")).build(); + + Map config = new HashMap<>(); + config.put(HOST.key(), "test"); + assertThrows( + java.util.regex.PatternSyntaxException.class, + () -> validate(config, rule), + "invalid regex escapes as PatternSyntaxException from String.matches()"); + } + + @Test + public void testNullOptionRejected() { + assertThrows( + IllegalArgumentException.class, + () -> Condition.of(null, "value"), + "null option should throw IAE"); + } + + @Test + public void testCircularChainViaOr() { + Condition a = greaterThan(PORT, 0); + Condition b = notBlank(HOST); + a.or(b); + assertThrows( + IllegalArgumentException.class, + () -> b.or(a), + "circular chain via or() should be detected"); + } + + @Test + public void testUpperCaseEmptyString() { + OptionRule rule = OptionRule.builder().required(DB_NAME, upperCase(DB_NAME)).build(); + + Map config = new HashMap<>(); + config.put(DB_NAME.key(), ""); + Assertions.assertDoesNotThrow( + () -> validate(config, rule), "empty string equals its uppercase form"); + } + + @Test + public void testLowerCaseEmptyString() { + OptionRule rule = OptionRule.builder().required(DB_NAME, lowerCase(DB_NAME)).build(); + + Map config = new HashMap<>(); + config.put(DB_NAME.key(), ""); + Assertions.assertDoesNotThrow( + () -> validate(config, rule), "empty string equals its lowercase form"); + } + + @Test + public void testStructuralAndConstraintErrorsAggregated() { + OptionRule rule = + OptionRule.builder() + .required(PORT, greaterOrEqual(PORT, 1)) + .required(HOST, notBlank(HOST)) + .build(); + + Map config = new HashMap<>(); + config.put(PORT.key(), 0); + OptionValidationException ex = + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + String msg = ex.getMessage(); + Assertions.assertTrue(msg.contains("2 errors"), "should aggregate both errors"); + Assertions.assertTrue( + msg.contains("[1] option: 'host'"), "structural error for host should come first"); + Assertions.assertTrue(msg.contains("type: required")); + Assertions.assertTrue( + msg.contains("required option is not configured"), + "should describe the structural rule"); + Assertions.assertTrue( + msg.contains("[2] option: port"), "constraint error for port should come second"); + Assertions.assertTrue(msg.contains("type: value")); + Assertions.assertTrue(msg.contains("'port' >= 1"), "should include constraint expression"); + Assertions.assertFalse( + msg.contains("[3]"), + "host constraint should be suppressed since host is structurally absent"); + } + + @Test + public void testMergedConditionalConstraints() { + OptionRule rule = + OptionRule.builder() + .optional(MODE) + .conditional(MODE, "stream", greaterThan(START_TS, 0L)) + .conditional(MODE, "stream", greaterThan(END_TS, 0L)) + .build(); + + Map config = new HashMap<>(); + config.put(MODE.key(), "stream"); + config.put(START_TS.key(), 100L); + config.put(END_TS.key(), 200L); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(START_TS.key(), 0L); + config.put(END_TS.key(), 0L); + OptionValidationException ex = + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + Assertions.assertTrue( + ex.getMessage().contains("2 errors"), + "merged conditional constraints should aggregate both failures"); + } + + @Test + public void testConstraintErrorMessageHasErrorCodePrefix() { + OptionRule rule = OptionRule.builder().required(PORT, greaterOrEqual(PORT, 1)).build(); + + Map config = new HashMap<>(); + config.put(PORT.key(), 0); + OptionValidationException ex = + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + Assertions.assertTrue( + ex.getMessage().startsWith("ErrorCode:[API-02]"), + "constraint error should carry standard ErrorCode prefix"); + } + + @Test + public void testThreeWayOrChainToString() { + assertEquals( + "'host' is not blank || 'endpoint' is not blank || 'db_name' is not blank", + notBlank(HOST).or(notBlank(ENDPOINT)).or(notBlank(DB_NAME)).toString()); + } + + @Test + public void testOrThenAndChainToString() { + // AND-first precedence grouping, consistent with evaluation semantics: + // notBlank(HOST) -or-> greaterOrEqual(PORT,1) -and-> lessOrEqual(PORT,100) + // evaluates as: HOST || (PORT>=1 && PORT<=100) + assertEquals( + "'host' is not blank || ('port' >= 1 && 'port' <= 100)", + notBlank(HOST).or(greaterOrEqual(PORT, 1).and(lessOrEqual(PORT, 100))).toString()); + } + + @Test + public void testContainsEmptySubstring() { + OptionRule rule = OptionRule.builder().required(HOST, contains(HOST, "")).build(); + + Map config = new HashMap<>(); + config.put(HOST.key(), "anything"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(HOST.key(), ""); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + } + + @Test + public void testStartsWithEmptyPrefix() { + OptionRule rule = OptionRule.builder().required(ENDPOINT, startsWith(ENDPOINT, "")).build(); + + Map config = new HashMap<>(); + config.put(ENDPOINT.key(), "anything"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(ENDPOINT.key(), ""); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + } + + @Test + public void testUpperCaseWithDigitsAndSpecialChars() { + OptionRule rule = OptionRule.builder().required(DB_NAME, upperCase(DB_NAME)).build(); + + Map config = new HashMap<>(); + config.put(DB_NAME.key(), "ABC123"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(DB_NAME.key(), "DB_NAME_01"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(DB_NAME.key(), "ABC-123_OK"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(DB_NAME.key(), "Abc123"); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testLowerCaseWithDigitsAndSpecialChars() { + OptionRule rule = OptionRule.builder().required(DB_NAME, lowerCase(DB_NAME)).build(); + + Map config = new HashMap<>(); + config.put(DB_NAME.key(), "abc123"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(DB_NAME.key(), "db_name_01"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(DB_NAME.key(), "abc-123_ok"); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + + config.put(DB_NAME.key(), "Abc123"); + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + } + + @Test + public void testUniqueEmptyCollection() { + OptionRule rule = OptionRule.builder().required(TAGS, unique(TAGS)).build(); + + Map config = new HashMap<>(); + config.put(TAGS.key(), Collections.emptyList()); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + } + + @Test + public void testUniqueSingleElement() { + OptionRule rule = OptionRule.builder().required(TAGS, unique(TAGS)).build(); + + Map config = new HashMap<>(); + config.put(TAGS.key(), Collections.singletonList("only")); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + } + + @Test + public void testNotEmptySingleElement() { + OptionRule rule = OptionRule.builder().required(TAGS, notEmpty(TAGS)).build(); + + Map config = new HashMap<>(); + config.put(TAGS.key(), Collections.singletonList("one")); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + } + + @Test + public void testRequiredMissingErrorFormat() { + OptionRule rule = OptionRule.builder().required(HOST).build(); + + Map config = new HashMap<>(); + OptionValidationException ex = + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + String msg = ex.getMessage(); + Assertions.assertTrue(msg.contains("Option validation failed (1 error):")); + Assertions.assertTrue(msg.contains("[1] option: 'host'")); + Assertions.assertTrue(msg.contains("type: required")); + Assertions.assertTrue(msg.contains("constraint: required option is not configured")); + } + + @Test + public void testBundledErrorFormat() { + OptionRule rule = OptionRule.builder().bundled(KEY_USERNAME, KEY_PASSWORD).build(); + + Map config = new HashMap<>(); + config.put(KEY_USERNAME.key(), "admin"); + OptionValidationException ex = + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + String msg = ex.getMessage(); + Assertions.assertTrue(msg.contains("Option validation failed (1 error):")); + Assertions.assertTrue(msg.contains("[1] options: 'username', 'password'")); + Assertions.assertTrue(msg.contains("type: bundled")); + Assertions.assertTrue( + msg.contains("constraint: bundled options must be present or absent together")); + Assertions.assertTrue(msg.contains("present: ['username']")); + Assertions.assertTrue(msg.contains("absent: ['password']")); + } + + @Test + public void testExclusiveNoneSetErrorFormat() { + OptionRule rule = OptionRule.builder().exclusive(KEY_USERNAME, KEY_BEARER_TOKEN).build(); + + Map config = new HashMap<>(); + OptionValidationException ex = + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + String msg = ex.getMessage(); + Assertions.assertTrue(msg.contains("Option validation failed (1 error):")); + Assertions.assertTrue(msg.contains("[1] options: 'username', 'bearer-token'")); + Assertions.assertTrue(msg.contains("type: exclusive")); + Assertions.assertTrue( + msg.contains( + "constraint: exactly one option must be set, but none are configured")); + } + + @Test + public void testExclusiveMultipleSetErrorFormat() { + OptionRule rule = OptionRule.builder().exclusive(KEY_USERNAME, KEY_BEARER_TOKEN).build(); + + Map config = new HashMap<>(); + config.put(KEY_USERNAME.key(), "admin"); + config.put(KEY_BEARER_TOKEN.key(), "token123"); + OptionValidationException ex = + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + String msg = ex.getMessage(); + Assertions.assertTrue(msg.contains("Option validation failed (1 error):")); + Assertions.assertTrue(msg.contains("type: exclusive")); + Assertions.assertTrue(msg.contains("mutually exclusive, but multiple are set")); + } + + @Test + public void testConditionalMissingErrorFormat() { + OptionRule rule = + OptionRule.builder() + .optional(TEST_MODE) + .conditional(TEST_MODE, OptionTest.TestMode.TIMESTAMP, TEST_TIMESTAMP) + .build(); + + Map config = new HashMap<>(); + config.put(TEST_MODE.key(), "timestamp"); + OptionValidationException ex = + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + String msg = ex.getMessage(); + Assertions.assertTrue(msg.contains("Option validation failed (1 error):")); + Assertions.assertTrue(msg.contains("[1] option: 'option.timestamp'")); + Assertions.assertTrue(msg.contains("type: conditional")); + Assertions.assertTrue( + msg.contains("constraint: required because ['option.mode' == TIMESTAMP] is true")); + } + + @Test + public void testMultipleStructuralErrorsAggregated() { + OptionRule rule = + OptionRule.builder().required(HOST).required(PORT).required(DB_NAME).build(); + + Map config = new HashMap<>(); + OptionValidationException ex = + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + String msg = ex.getMessage(); + Assertions.assertTrue(msg.contains("3 errors"), "should report all 3 missing"); + Assertions.assertTrue(msg.contains("[1] option: 'host'")); + Assertions.assertTrue(msg.contains("[2] option: 'port'")); + Assertions.assertTrue(msg.contains("[3] option: 'db_name'")); + } + + @Test + public void testBundledAndConstraintAggregated() { + OptionRule rule = + OptionRule.builder() + .bundled(KEY_USERNAME, KEY_PASSWORD) + .required(PORT, greaterOrEqual(PORT, 1)) + .build(); + + Map config = new HashMap<>(); + config.put(KEY_USERNAME.key(), "admin"); + config.put(PORT.key(), 0); + OptionValidationException ex = + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + String msg = ex.getMessage(); + Assertions.assertTrue(msg.contains("2 errors")); + Assertions.assertTrue(msg.contains("[1] options: 'username', 'password'")); + Assertions.assertTrue(msg.contains("bundled")); + Assertions.assertTrue(msg.contains("[2] option: port")); + Assertions.assertTrue(msg.contains("'port' >= 1")); + } + + @Test + public void testExclusiveAndRequiredAggregated() { + OptionRule rule = + OptionRule.builder() + .exclusive(KEY_USERNAME, KEY_BEARER_TOKEN) + .required(HOST) + .build(); + + Map config = new HashMap<>(); + config.put(KEY_USERNAME.key(), "admin"); + config.put(KEY_BEARER_TOKEN.key(), "token"); + OptionValidationException ex = + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + String msg = ex.getMessage(); + Assertions.assertTrue(msg.contains("2 errors")); + Assertions.assertTrue(msg.contains("mutually exclusive")); + Assertions.assertTrue(msg.contains("required option is not configured")); + } + + @Test + public void testAbsentRequiredSuppressesValueConstraint() { + OptionRule rule = OptionRule.builder().required(HOST, notBlank(HOST)).build(); + + Map config = new HashMap<>(); + OptionValidationException ex = + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + String msg = ex.getMessage(); + Assertions.assertTrue(msg.contains("1 error)"), "should only report 1 error"); + Assertions.assertTrue(msg.contains("type: required")); + Assertions.assertTrue(msg.contains("required option is not configured")); + Assertions.assertFalse( + msg.contains("type: value"), + "value constraint should be suppressed for absent required option"); + } + + @Test + public void testAllStructuralTypesAggregated() { + OptionRule rule = + OptionRule.builder() + .required(HOST) + .bundled(KEY_USERNAME, KEY_PASSWORD) + .exclusive(KEY_BEARER_TOKEN, KEY_KERBEROS_TICKET) + .build(); + + Map config = new HashMap<>(); + config.put(KEY_USERNAME.key(), "admin"); + config.put(KEY_BEARER_TOKEN.key(), "token"); + config.put(KEY_KERBEROS_TICKET.key(), "ticket"); + OptionValidationException ex = + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + String msg = ex.getMessage(); + Assertions.assertTrue(msg.contains("3 errors")); + Assertions.assertTrue(msg.contains("required option is not configured")); + Assertions.assertTrue(msg.contains("bundled")); + Assertions.assertTrue(msg.contains("mutually exclusive")); + } + + @Test + public void testConditionalAbsentSuppressesValueConstraint() { + OptionRule nestedRule = + OptionRule.builder() + .required(TEST_TIMESTAMP, greaterThan(TEST_TIMESTAMP, 0L)) + .build(); + OptionRule rule = + OptionRule.builder() + .optional(TEST_MODE) + .conditionalRule(TEST_MODE, OptionTest.TestMode.TIMESTAMP, nestedRule) + .build(); + + Map config = new HashMap<>(); + config.put(TEST_MODE.key(), "timestamp"); + OptionValidationException ex = + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + String msg = ex.getMessage(); + Assertions.assertTrue(msg.contains("1 error)")); + Assertions.assertTrue(msg.contains("type: required")); + Assertions.assertFalse( + msg.contains("> 0"), + "value constraint should be suppressed for absent conditional option"); + } + + @Test + public void testErrorCodePrefixInUnifiedFormat() { + OptionRule rule = OptionRule.builder().required(HOST).build(); + + Map config = new HashMap<>(); + OptionValidationException ex = + assertThrows(OptionValidationException.class, () -> validate(config, rule)); + Assertions.assertTrue( + ex.getMessage().startsWith("ErrorCode:[API-02]"), + "unified format should still carry standard ErrorCode prefix"); + } } From ff583fbcea99d4e43258308cd06e01cd65917855 Mon Sep 17 00:00:00 2001 From: "zhiwei.niu" Date: Tue, 2 Jun 2026 01:45:18 +0800 Subject: [PATCH 12/16] [Fix][Api] Improve doc --- .../configuration-and-option-system.md | 47 ++++++++++++++-- .../configuration-and-option-system.md | 53 ++++++++++++++++--- 2 files changed, 87 insertions(+), 13 deletions(-) diff --git a/docs/en/architecture/configuration-and-option-system.md b/docs/en/architecture/configuration-and-option-system.md index b78920a2b7de..a3d63b4d1032 100644 --- a/docs/en/architecture/configuration-and-option-system.md +++ b/docs/en/architecture/configuration-and-option-system.md @@ -192,6 +192,18 @@ Validation logic declared in `optionRule()` runs at job submission time, produce The following patterns cover common scenarios. Each one shows the recommended declarative form inside `OptionRule.builder()`. +Quick reference: + +| Scenario | Recommended API | +|----------|------------------| +| Always required fields | `.required(opt...)` | +| Exactly one in a set | `.exclusive(opt...)` | +| All-or-none group | `.bundled(opt...)` | +| Required only when trigger matches | `.conditional(trigger, value, requiredOpt...)` | +| Validate value only when trigger matches | `.conditional(trigger, value, condition...)` | +| Optional field with value check when present | `.optional(opt, condition...)` | +| Cross-field comparisons | `Conditions.lessThanField/greaterThanField(...)` | + ### Required fields Some fields must always be present. A job that omits them should be rejected at submission. @@ -218,19 +230,29 @@ A group of options that only make sense together. Either all of them are set or ### Conditional required options driven by an enum -When an enum option takes a specific value, additional fields become required. +When an enum option takes a specific value, additional fields become required. The method signature is: + +``` +.conditional(triggerOption, triggerValue, requiredOption...) +``` + +Meaning: when the user sets `triggerOption` to `triggerValue`, all listed `requiredOption` fields become mandatory. ```java +// When START_MODE = TIMESTAMP, START_MODE_TIMESTAMP becomes required .conditional(START_MODE, StartMode.TIMESTAMP, START_MODE_TIMESTAMP) +// When START_MODE = SPECIFIC_OFFSETS, START_MODE_OFFSETS becomes required .conditional(START_MODE, StartMode.SPECIFIC_OFFSETS, START_MODE_OFFSETS) ``` ### Conditional required options driven by a boolean -A boolean toggle that activates different sets of required fields depending on its value. +Same pattern as enum-driven, but the trigger value is a boolean. ```java +// When IS_EXACTLY_ONCE = true, XA_DATA_SOURCE_CLASS and TRANSACTION_TIMEOUT become required .conditional(IS_EXACTLY_ONCE, true, XA_DATA_SOURCE_CLASS, TRANSACTION_TIMEOUT) +// When IS_EXACTLY_ONCE = false, MAX_RETRIES becomes required .conditional(IS_EXACTLY_ONCE, false, MAX_RETRIES) ``` @@ -306,12 +328,27 @@ AND binds tighter than OR, so `A.or(B.and(C))` evaluates as `A || (B && C)`. Thi .optional(PORT) ``` -### Conditional required with value constraint +### Conditional required vs conditional value constraint + +:::tip + +These two forms look similar but mean different things: + +- `conditional(trigger, value, option...)` makes options conditionally required. +- `conditional(trigger, value, condition...)` only validates values when the target option is present; it does not make that option required. -When a conditional option also needs value validation, pass the constraint as the last argument. The constraint is only enforced when the trigger condition is met. +::: ```java -// When START_MODE == TIMESTAMP, START_TIMESTAMP is required and must be > 0 +// A) Conditionally required field +.conditional(START_MODE, StartMode.TIMESTAMP, START_TIMESTAMP) + +// B) Optional field with conditional value validation +.conditional(START_MODE, StartMode.TIMESTAMP, + Conditions.greaterThan(START_TIMESTAMP, 0L)) + +// C) Required + value constraint (combine A and B) +.conditional(START_MODE, StartMode.TIMESTAMP, START_TIMESTAMP) .conditional(START_MODE, StartMode.TIMESTAMP, Conditions.greaterThan(START_TIMESTAMP, 0L)) ``` diff --git a/docs/zh/architecture/configuration-and-option-system.md b/docs/zh/architecture/configuration-and-option-system.md index cebf9adbd372..2c58ec4a77f9 100644 --- a/docs/zh/architecture/configuration-and-option-system.md +++ b/docs/zh/architecture/configuration-and-option-system.md @@ -32,7 +32,7 @@ SeaTunnel 通过以下几个核心构件把这三件事连接起来: - key - type -- 默认值 +- 默认值(如适用) - 描述 它是 SeaTunnel 配置契约中最小、最基础的单元。 @@ -104,7 +104,7 @@ public OptionRule optionRule() { ### 值约束(`Condition`) -除了结构性规则(必填、互斥等),配置项还可以携带**值级别约束**,运行时会在作业启动前进行校验。`Condition` API 提供了一种流式方式,在 `OptionRule.builder()` 中附加这些约束。具体用法参见下方 [OptionRule 模式编码指南](#optionrule-模式编码指南)。 +除了结构性规则(必填、互斥等),配置项还可以携带**值级别约束**,运行时会在作业启动前进行校验。`Condition` API 提供了一种流式方式,在 `OptionRule.builder()` 中附加这些约束。具体用法参见下方 [OptionRule 使用模式指南](#optionrule-使用模式指南)。 可用的操作符(均通过 `Conditions` 工厂类调用): @@ -186,12 +186,24 @@ Option validation failed (4 errors): constraint: 'start_ts' < 'end_ts' ``` -## OptionRule 模式编码指南 +## OptionRule 使用模式指南 在 `optionRule()` 中声明的校验逻辑会在作业提交时执行,产出统一格式的错误消息,且自动暴露给 REST API 和 Web UI。如果把校验写在 Config 构造器或 Writer/Reader 中,失败时机会推迟到任务调度之后,工具侧也无法感知这些约束。 以下按常见场景列出推荐的声明式写法,均在 `OptionRule.builder()` 中使用。 +速查表: + +| 场景 | 推荐 API | +|------|----------| +| 始终必填字段 | `.required(opt...)` | +| 多选一(且仅一个) | `.exclusive(opt...)` | +| 成组全有或全无 | `.bundled(opt...)` | +| 条件触发的必填字段 | `.conditional(trigger, value, requiredOpt...)` | +| 条件触发的值校验 | `.conditional(trigger, value, condition...)` | +| 可选字段(提供时校验) | `.optional(opt, condition...)` | +| 跨字段比较 | `Conditions.lessThanField/greaterThanField(...)` | + ### 必填字段 某些字段必须配置,缺少时作业在提交阶段即被拒绝。 @@ -218,19 +230,29 @@ Option validation failed (4 errors): ### 条件必填(枚举驱动) -当某个枚举字段取特定值时,另一些字段才变为必填。 +当某个枚举字段取特定值时,另一些字段才变为必填。方法签名为: + +``` +.conditional(触发字段, 触发值, 必填字段...) +``` + +含义:当用户把「触发字段」设为「触发值」时,后面列出的字段自动变为必填。 ```java +// 当 START_MODE = TIMESTAMP 时,必须提供 START_MODE_TIMESTAMP .conditional(START_MODE, StartMode.TIMESTAMP, START_MODE_TIMESTAMP) +// 当 START_MODE = SPECIFIC_OFFSETS 时,必须提供 START_MODE_OFFSETS .conditional(START_MODE, StartMode.SPECIFIC_OFFSETS, START_MODE_OFFSETS) ``` ### 条件必填(布尔驱动) -布尔开关控制不同的必填字段集合。 +与枚举驱动相同,只是触发值是布尔值。 ```java +// 当 IS_EXACTLY_ONCE = true 时,XA_DATA_SOURCE_CLASS 和 TRANSACTION_TIMEOUT 变为必填 .conditional(IS_EXACTLY_ONCE, true, XA_DATA_SOURCE_CLASS, TRANSACTION_TIMEOUT) +// 当 IS_EXACTLY_ONCE = false 时,MAX_RETRIES 变为必填 .conditional(IS_EXACTLY_ONCE, false, MAX_RETRIES) ``` @@ -306,12 +328,27 @@ AND 优先级高于 OR,因此 `A.or(B.and(C))` 等价于 `A || (B && C)`。适 .optional(PORT) ``` -### 条件必填 + 值约束 +### 条件必填与条件值约束(区别很重要) + +:::tip -条件必填项除了判断是否存在,还可以附加值约束。约束仅在触发条件满足时才会执行。 +这两种写法外观接近,但语义不同: + +- `conditional(trigger, value, option...)`:把字段声明为条件必填。 +- `conditional(trigger, value, condition...)`:只做条件值校验;若目标字段缺失,不会因此报“缺失必填”。 + +::: ```java -// 当 START_MODE == TIMESTAMP 时,START_TIMESTAMP 必须存在且 > 0 +// A) 条件必填 +.conditional(START_MODE, StartMode.TIMESTAMP, START_TIMESTAMP) + +// B) 条件值校验(不等价于必填) +.conditional(START_MODE, StartMode.TIMESTAMP, + Conditions.greaterThan(START_TIMESTAMP, 0L)) + +// C) 同时要求“必填 + 值约束”(A + B 组合) +.conditional(START_MODE, StartMode.TIMESTAMP, START_TIMESTAMP) .conditional(START_MODE, StartMode.TIMESTAMP, Conditions.greaterThan(START_TIMESTAMP, 0L)) ``` From 894091d6c20e5d56fef8ddcce3f8471141702df6 Mon Sep 17 00:00:00 2001 From: "zhiwei.niu" Date: Tue, 2 Jun 2026 10:21:50 +0800 Subject: [PATCH 13/16] [Fix][Api] fix ConditionTest Assertion failed --- .../apache/seatunnel/api/configuration/util/ConditionTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/seatunnel-api/src/test/java/org/apache/seatunnel/api/configuration/util/ConditionTest.java b/seatunnel-api/src/test/java/org/apache/seatunnel/api/configuration/util/ConditionTest.java index 727a9e9b9845..58dc2361ef32 100644 --- a/seatunnel-api/src/test/java/org/apache/seatunnel/api/configuration/util/ConditionTest.java +++ b/seatunnel-api/src/test/java/org/apache/seatunnel/api/configuration/util/ConditionTest.java @@ -34,7 +34,7 @@ public class ConditionTest { @Test public void testToString() { Assertions.assertEquals( - "('option.mode' == EARLIEST || 'option.mode' == LATEST) && 'option.num' == 1000", + "'option.mode' == EARLIEST || ('option.mode' == LATEST && 'option.num' == 1000)", TEST_CONDITION.toString()); } From 5bfe0f9e530c8f56ad0adff33542c0eec305ac0c Mon Sep 17 00:00:00 2001 From: "zhiwei.niu" Date: Tue, 2 Jun 2026 12:58:17 +0800 Subject: [PATCH 14/16] [Fix][Api] Optimize documentation and error handling --- .../concepts/incompatible-changes.md | 24 +++++++++- .../concepts/incompatible-changes.md | 24 +++++++++- .../util/ConditionEvaluators.java | 16 ++----- .../api/configuration/util/Conditions.java | 17 +++++++ .../configuration/util/ConfigValidator.java | 31 ++++--------- .../util/OptionValidationException.java | 17 +++++++ .../util/ConfigValidatorTest.java | 46 +++++++++++++++++++ .../command/MetadataExportCommand.java | 4 ++ 8 files changed, 145 insertions(+), 34 deletions(-) diff --git a/docs/en/introduction/concepts/incompatible-changes.md b/docs/en/introduction/concepts/incompatible-changes.md index 55577fd6f8a3..e5db071e740a 100644 --- a/docs/en/introduction/concepts/incompatible-changes.md +++ b/docs/en/introduction/concepts/incompatible-changes.md @@ -35,7 +35,29 @@ You need to check this document before you upgrade to related version. - **Affected component**: `seatunnel-api` — `org.apache.seatunnel.api.configuration.util.Condition` - **Description**: The `Condition` constructor now validates that binary literal operators (such as `EQUAL`, `NOT_EQUAL`, `GREATER_THAN`, etc.) must have a non-null `expectValue`. Previously, `Condition.of(option, null)` was silently accepted; it now throws `IllegalArgumentException` at construction time. - **Impact**: No production code in the main repository uses `Condition.of(option, null)`, so the practical impact is zero. However, any custom or third-party connector code that relied on this pattern will need to be updated. - - **Migration Guide**: If you need to check whether an option is absent or unset, use `Condition.notBlank(option)` (for strings) or handle the absence at the `OptionRule.Builder` level with `optional(...)` instead of passing `null` as the expected value. + - **Migration Guide**: If you need to check whether an option is absent or unset, use `Conditions.notBlank(option)` (for strings) or handle the absence at the `OptionRule.Builder` level with `optional(...)` instead of passing `null` as the expected value. + +- **Breaking Change: `OptionValidationException` message format changed to structured aggregation** + - **Affected component**: `seatunnel-api` — `org.apache.seatunnel.api.configuration.util.ConfigValidator` + - **Description**: `ConfigValidator.validate(OptionRule)` now collects all structural and value constraint errors and throws a single `OptionValidationException` with a structured multi-line message instead of failing on the first error. + + **Before (fail-fast, single error)** + ``` + ErrorCode:[API-02], ErrorDescription:[Option item validate failed] - There are unconfigured options, the options('host') are required. + ``` + + **After (aggregated, structured)** + ``` + ErrorCode:[API-02], ErrorDescription:[Option item validate failed] - Option validation failed (2 errors): + [1] option: 'host' + type: required + constraint: required option is not configured + [2] option: 'port' + type: value + constraint: 'port' >= 1 + ``` + - **Impact**: Code that parses the exception message by matching substrings like `"are required"` or assumes a single-error format will need to be updated. The error code (`API-02`) and the `" - "` separator between the code prefix and the body remain unchanged. + - **Migration Guide**: Update any string-matching logic on `OptionValidationException.getMessage()` to handle the new multi-line numbered format. Use `getRawMessage()` to get the body without the `ErrorCode` prefix if needed. ### Configuration Changes diff --git a/docs/zh/introduction/concepts/incompatible-changes.md b/docs/zh/introduction/concepts/incompatible-changes.md index 16e84b2342f4..97907ef653f4 100644 --- a/docs/zh/introduction/concepts/incompatible-changes.md +++ b/docs/zh/introduction/concepts/incompatible-changes.md @@ -34,7 +34,29 @@ - **影响范围**:`seatunnel-api` — `org.apache.seatunnel.api.configuration.util.Condition` - **变更说明**:`Condition` 构造器新增校验:二元字面量操作符(如 `EQUAL`、`NOT_EQUAL`、`GREATER_THAN` 等)的 `expectValue` 不能为 null。此前 `Condition.of(option, null)` 会被静默接受,现在会在构造时抛出 `IllegalArgumentException`。 - **影响**:主仓库中没有任何生产代码使用 `Condition.of(option, null)`,实际影响为零。但如果自定义或第三方连接器代码依赖了这一用法,则需要修改。 - - **迁移指南**:如需检测某个 option 是否缺省或未配置,请使用 `Condition.notBlank(option)`(针对字符串类型)或在 `OptionRule.Builder` 层面使用 `optional(...)` 来处理缺失情况,而不是将 `null` 作为期望值传入。 + - **迁移指南**:如需检测某个 option 是否缺省或未配置,请使用 `Conditions.notBlank(option)`(针对字符串类型)或在 `OptionRule.Builder` 层面使用 `optional(...)` 来处理缺失情况,而不是将 `null` 作为期望值传入。 + +- **破坏性变更:`OptionValidationException` 消息格式变为结构化聚合** + - **影响范围**:`seatunnel-api` — `org.apache.seatunnel.api.configuration.util.ConfigValidator` + - **变更说明**:`ConfigValidator.validate(OptionRule)` 现在会收集所有结构性错误和值约束错误,一次性抛出包含结构化多行消息的 `OptionValidationException`,而非遇到第一个错误就失败。 + + **变更前(快速失败,单条错误)** + ``` + ErrorCode:[API-02], ErrorDescription:[Option item validate failed] - There are unconfigured options, the options('host') are required. + ``` + + **变更后(聚合、结构化)** + ``` + ErrorCode:[API-02], ErrorDescription:[Option item validate failed] - Option validation failed (2 errors): + [1] option: 'host' + type: required + constraint: required option is not configured + [2] option: 'port' + type: value + constraint: 'port' >= 1 + ``` + - **影响**:通过子字符串匹配(如 `"are required"`)解析异常消息,或假定单行错误格式的代码需要更新。错误码(`API-02`)和代码前缀与消息体之间的 `" - "` 分隔符保持不变。 + - **迁移指南**:更新对 `OptionValidationException.getMessage()` 的字符串匹配逻辑以适配新的多行编号格式。可使用 `getRawMessage()` 获取不含 `ErrorCode` 前缀的消息体。 ### 配置变更 diff --git a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java index 503408991677..1bb231b28704 100644 --- a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java +++ b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/ConditionEvaluators.java @@ -51,19 +51,12 @@ static boolean evaluate(Condition condition, ReadonlyConfig config) { Evaluator evaluator = REGISTRY.get(operator); return evaluator.evaluate(value, condition, config); } catch (OptionValidationException e) { - String innerMsg = extractInnerMessage(e); throw new OptionValidationException( "Failed to evaluate constraint '%s' on option '%s': %s", - condition.toString(), condition.getOption().key(), innerMsg); + condition.toString(), condition.getOption().key(), e.getRawMessage()); } } - private static String extractInnerMessage(OptionValidationException e) { - String msg = e.getMessage(); - int idx = msg.indexOf(" - "); - return idx >= 0 ? msg.substring(idx + 3) : msg; - } - @SuppressWarnings({"rawtypes"}) private static Map createRegistry() { Map m = new EnumMap<>(ConditionOperator.class); @@ -173,7 +166,8 @@ private static Map createRegistry() { static int compareNumbers(Object a, Object b) { if (a == null || b == null) { throw new OptionValidationException( - "Cannot compare null values in numeric comparison: left=%s, right=%s", a, b); + "Cannot compare null values in numeric comparison: leftPresent=%s, rightPresent=%s", + a != null, b != null); } if (a instanceof Number && b instanceof Number) { return compareNumberValues((Number) a, (Number) b); @@ -182,8 +176,8 @@ static int compareNumbers(Object a, Object b) { return ((Comparable) a).compareTo(b); } throw new OptionValidationException( - "Cannot compare values of type %s(%s) and %s(%s)", - a.getClass().getSimpleName(), a, b.getClass().getSimpleName(), b); + "Cannot compare values of type %s and %s", + a.getClass().getSimpleName(), b.getClass().getSimpleName()); } private static int compareNumberValues(Number a, Number b) { diff --git a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Conditions.java b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Conditions.java index 9f5dda5800be..ff2e550cc43a 100644 --- a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Conditions.java +++ b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/Conditions.java @@ -22,6 +22,8 @@ /** * Unified factory for creating {@link Condition} instances. * + *

Usage example: + * *

{@code
  * static *
  * OptionRule.builder()
@@ -30,6 +32,21 @@
  *     .required(START_TS, END_TS, lessThanField(START_TS, END_TS))
  *     .build();
  * }
+ * + *

Currently supported operators (17 total, 4 categories): + * + *

    + *
  • Numeric: {@code greaterThan}, {@code greaterOrEqual}, {@code lessThan}, {@code + * lessOrEqual} + *
  • String: {@code notBlank}, {@code startsWith}, {@code contains}, {@code matches}, + * {@code upperCase}, {@code lowerCase} + *
  • Collection: {@code notEmpty}, {@code unique} + *
  • Cross-field: {@code lessThanField}, {@code lessOrEqualField}, {@code + * greaterThanField}, {@code greaterOrEqualField} + *
+ * + *

Additionally, equality checks are available via {@link Condition#of(Option, Object)} (EQUAL) + * and {@link Condition#of(Option, ConditionOperator, Object)} (NOT_EQUAL). */ public final class Conditions { 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 ef33772d3a7e..ae7e4450f90f 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 @@ -243,7 +243,11 @@ private void collectAbsentKeys(RequiredOption requiredOption, Set keys) /** * Determines whether a value constraint should be evaluated. * - *

If any referenced option is absolutely required, the constraint is always applicable. + *

If the constraint's head option is absolutely required, the constraint is always + * applicable. Only the head option (the option the constraint is "about") is checked — compare + * fields referenced by cross-field operators do not force applicability. This ensures that + * {@code optional(MAX, lessThanField(MAX, START_TS))} correctly skips when MAX is absent, even + * if START_TS is required elsewhere. * *

For optional constraints, the chain is split into OR-separated AND segments. Each AND * segment requires ALL its options to be present. The constraint is applicable if ANY segment @@ -256,13 +260,11 @@ private void collectAbsentKeys(RequiredOption requiredOption, Set keys) * */ private boolean isConstraintApplicable(Condition condition, OptionRule rule) { - Set> allOptions = collectAllConditionOptions(condition); - for (Option opt : allOptions) { - for (RequiredOption requiredOption : rule.getRequiredOptions()) { - if (requiredOption instanceof RequiredOption.AbsolutelyRequiredOptions - && requiredOption.getOptions().contains(opt)) { - return true; - } + Option headOption = condition.getOption(); + for (RequiredOption requiredOption : rule.getRequiredOptions()) { + if (requiredOption instanceof RequiredOption.AbsolutelyRequiredOptions + && requiredOption.getOptions().contains(headOption)) { + return true; } } return anyOrSegmentFullyPresent(condition); @@ -306,19 +308,6 @@ private boolean anyOrSegmentFullyPresent(Condition condition) { return false; } - private Set> collectAllConditionOptions(Condition condition) { - Set> options = new HashSet<>(); - Condition cur = condition; - while (cur != null) { - options.add(cur.getOption()); - if (cur.getCompareOption() != null) { - options.add(cur.getCompareOption()); - } - cur = cur.hasNext() ? cur.getNext() : null; - } - return options; - } - void validateSingleChoice(Option option) { SingleChoiceOption singleChoiceOption = (SingleChoiceOption) option; List optionValues = singleChoiceOption.getOptionValues(); diff --git a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionValidationException.java b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionValidationException.java index 891c8629888f..08f1c8638055 100644 --- a/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionValidationException.java +++ b/seatunnel-api/src/main/java/org/apache/seatunnel/api/configuration/util/OptionValidationException.java @@ -24,16 +24,21 @@ /** Exception for all errors occurring during option validation phase. */ public class OptionValidationException extends SeaTunnelRuntimeException { + private final String rawMessage; + public OptionValidationException(String message, Throwable cause) { super(SeaTunnelAPIErrorCode.OPTION_VALIDATION_FAILED, message, cause); + this.rawMessage = message; } public OptionValidationException(String message) { super(SeaTunnelAPIErrorCode.OPTION_VALIDATION_FAILED, message); + this.rawMessage = message; } public OptionValidationException(String formatMessage, Object... args) { super(SeaTunnelAPIErrorCode.OPTION_VALIDATION_FAILED, String.format(formatMessage, args)); + this.rawMessage = String.format(formatMessage, args); } public OptionValidationException(Option option) { @@ -42,5 +47,17 @@ public OptionValidationException(Option option) { String.format( "The option(\"%s\") is incorrectly configured, please refer to the doc: %s", option.key(), option.getDescription())); + this.rawMessage = + String.format( + "The option(\"%s\") is incorrectly configured, please refer to the doc: %s", + option.key(), option.getDescription()); + } + + /** + * Returns the raw validation message without the ErrorCode prefix. Use this instead of parsing + * {@link #getMessage()} to avoid coupling to the error code format. + */ + public String getRawMessage() { + return rawMessage; } } 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 a49b757744c4..068e1e4d7413 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 @@ -2077,6 +2077,52 @@ public void testRequiredPrimaryWithCompareFieldBothPresent() { Assertions.assertDoesNotThrow(() -> validate(config, rule)); } + @Test + public void testOptionalHeadWithRequiredCompareFieldHeadAbsent() { + OptionRule rule = + OptionRule.builder() + .optional(START_TS, lessThanField(START_TS, END_TS)) + .required(END_TS) + .build(); + + Map config = new HashMap<>(); + config.put(END_TS.key(), 200L); + Assertions.assertDoesNotThrow( + () -> validate(config, rule), + "head (START_TS) is optional and absent -> constraint skipped regardless of required compareField"); + } + + @Test + public void testOptionalHeadWithRequiredCompareFieldBothPresent() { + OptionRule rule = + OptionRule.builder() + .optional(START_TS, lessThanField(START_TS, END_TS)) + .required(END_TS) + .build(); + + Map config = new HashMap<>(); + config.put(START_TS.key(), 100L); + config.put(END_TS.key(), 200L); + Assertions.assertDoesNotThrow(() -> validate(config, rule)); + } + + @Test + public void testOptionalHeadWithRequiredCompareFieldViolation() { + OptionRule rule = + OptionRule.builder() + .optional(START_TS, lessThanField(START_TS, END_TS)) + .required(END_TS) + .build(); + + Map config = new HashMap<>(); + config.put(START_TS.key(), 300L); + config.put(END_TS.key(), 200L); + assertThrows( + OptionValidationException.class, + () -> validate(config, rule), + "both present but start > end -> constraint fails"); + } + @Test public void testOrThenAndChainEvaluation() { // A.or(B).and(C) evaluates as A || (B && C) diff --git a/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/command/MetadataExportCommand.java b/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/command/MetadataExportCommand.java index 70d234efa46d..69d6f78b6b73 100644 --- a/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/command/MetadataExportCommand.java +++ b/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/command/MetadataExportCommand.java @@ -424,6 +424,10 @@ private ObjectNode exportCondition(Condition condition) { node.put("expectValue", String.valueOf(condition.getExpectValue())); } ConditionOperator op = condition.getOperator(); + if (op != null) { + node.put("conditionOperator", op.name()); + node.put("conditionOperatorCategory", op.getCategory().name()); + } if (op != null && op != ConditionOperator.EQUAL) { node.put("compareOperator", op.getSymbol()); } From 04373176c3de222891492f53f561f5c535952540 Mon Sep 17 00:00:00 2001 From: "zhiwei.niu" Date: Tue, 2 Jun 2026 18:37:40 +0800 Subject: [PATCH 15/16] [Test][Transform-V2] Update FilterFieldTransformTest for new OptionRule validation messages --- .../filter/FilterFieldTransformTest.java | 70 +++++++++++-------- 1 file changed, 42 insertions(+), 28 deletions(-) diff --git a/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/filter/FilterFieldTransformTest.java b/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/filter/FilterFieldTransformTest.java index cc1719cc0a5c..e1ca9b88276b 100644 --- a/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/filter/FilterFieldTransformTest.java +++ b/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/filter/FilterFieldTransformTest.java @@ -18,6 +18,7 @@ package org.apache.seatunnel.transform.filter; import org.apache.seatunnel.api.configuration.ReadonlyConfig; +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; @@ -99,36 +100,49 @@ static void setUp() { @Test void testConfig() { // test both not set - try { - new FilterFieldTransform(ReadonlyConfig.fromMap(new HashMap<>()), catalogTable); - } catch (Exception e) { - Assertions.assertEquals( - "ErrorCode:[API-02], ErrorDescription:[Option item validate failed] - There are unconfigured options, these options('include_fields', 'exclude_fields') are mutually exclusive, allowing only one set(\"[] for a set\") of options to be configured.", - e.getMessage()); - } + OptionValidationException noneSetEx = + Assertions.assertThrows( + OptionValidationException.class, + () -> + new FilterFieldTransform( + ReadonlyConfig.fromMap(new HashMap<>()), catalogTable)); + Assertions.assertTrue( + noneSetEx.getMessage().contains("'include_fields'"), + "Should mention include_fields: " + noneSetEx.getMessage()); + Assertions.assertTrue( + noneSetEx.getMessage().contains("'exclude_fields'"), + "Should mention exclude_fields: " + noneSetEx.getMessage()); + Assertions.assertTrue( + noneSetEx.getMessage().contains("exclusive"), + "Should mention exclusive: " + noneSetEx.getMessage()); // test both include and exclude set - try { - new FilterFieldTransform( - ReadonlyConfig.fromMap( - new HashMap() { - { - put( - FilterFieldTransformConfig.INCLUDE_FIELDS.key(), - filterKeys); - put( - FilterFieldTransformConfig.EXCLUDE_FIELDS.key(), - filterKeys); - } - }), - catalogTable); - } catch (Exception e) { - Assertions.assertEquals( - "ErrorCode:[API-02], ErrorDescription:[Option item validate failed] - These options('include_fields', 'exclude_fields') are mutually exclusive, allowing only one set(\"[] for a set\") of options to be configured.", - e.getMessage()); - } - - // not exception should be thrown now + OptionValidationException bothSetEx = + Assertions.assertThrows( + OptionValidationException.class, + () -> + new FilterFieldTransform( + ReadonlyConfig.fromMap( + new HashMap() { + { + put( + FilterFieldTransformConfig + .INCLUDE_FIELDS + .key(), + filterKeys); + put( + FilterFieldTransformConfig + .EXCLUDE_FIELDS + .key(), + filterKeys); + } + }), + catalogTable)); + Assertions.assertTrue( + bothSetEx.getMessage().contains("mutually exclusive"), + "Should mention mutually exclusive: " + bothSetEx.getMessage()); + + // no exception should be thrown now new FilterFieldTransform( ReadonlyConfig.fromMap( new HashMap() { From c399847c0684b10b53b6161476b98138ea3bcbf9 Mon Sep 17 00:00:00 2001 From: "zhiwei.niu" Date: Tue, 2 Jun 2026 20:23:07 +0800 Subject: [PATCH 16/16] [Fix][Api] Fix UT that asserts abnormal information when used --- .../seatunnel/command/SeaTunnelConfValidateCommandTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/command/SeaTunnelConfValidateCommandTest.java b/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/command/SeaTunnelConfValidateCommandTest.java index 3266c4d2c2ca..89ca914b3970 100644 --- a/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/command/SeaTunnelConfValidateCommandTest.java +++ b/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/command/SeaTunnelConfValidateCommandTest.java @@ -101,7 +101,7 @@ public void testMissingRequiredKeyFailsValidation() { ConfigCheckException exception = Assertions.assertThrows(ConfigCheckException.class, command::execute); Assertions.assertTrue( - exception.getMessage().contains("unconfigured options"), + exception.getMessage().contains("Option validation failed"), "Should detect missing required option. Actual: " + exception.getMessage()); }