This is an automated email from the ASF dual-hosted git repository.
davidzollo pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git
The following commit(s) were added to refs/heads/dev by this push:
new 6b9075f0e6 [Improve][API] Add Map condition validators for
configuration option … (#11010)
6b9075f0e6 is described below
commit 6b9075f0e6b22fcb0cf9ff7f17474faeaabd5245
Author: zhiwei.niu <[email protected]>
AuthorDate: Sun Jun 7 12:28:22 2026 +0800
[Improve][API] Add Map condition validators for configuration option …
(#11010)
Co-authored-by: Doyeon Kim <[email protected]>
---
.../configuration-and-option-system.md | 23 ++++
.../configuration-and-option-system.md | 23 ++++
.../configuration/util/ConditionEvaluators.java | 16 +++
.../api/configuration/util/ConditionOperator.java | 9 +-
.../api/configuration/util/Conditions.java | 20 +++-
.../configuration/util/ConfigValidatorTest.java | 128 +++++++++++++++++++++
6 files changed, 217 insertions(+), 2 deletions(-)
diff --git a/docs/en/architecture/configuration-and-option-system.md
b/docs/en/architecture/configuration-and-option-system.md
index a3d63b4d10..c63fe1fb26 100644
--- a/docs/en/architecture/configuration-and-option-system.md
+++ b/docs/en/architecture/configuration-and-option-system.md
@@ -124,6 +124,9 @@ Available operators (all accessed via the `Conditions`
factory class):
| String | `lowerCase(option)` | string is all lowercase |
| Collection | `notEmpty(option)` | collection is not empty |
| Collection | `unique(option)` | collection has no duplicate elements |
+| Map | `mapNotEmpty(option)` | map is not empty |
+| Map | `mapContainsKey(option, key)` | map contains the specified key |
+| Map | `mapContainsKeys(option, key1, key2, ...)` | map contains all
specified keys |
| 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 |
@@ -295,6 +298,26 @@ Lists that must not be empty, or whose elements must be
unique.
.and(Conditions.unique(TABLES)))
```
+### Map constraints
+
+Map must not be empty:
+
+```java
+.required(PROPERTIES, Conditions.mapNotEmpty(PROPERTIES))
+```
+
+Map must contain a specific key:
+
+```java
+.required(KAFKA_CONFIG, Conditions.mapContainsKey(KAFKA_CONFIG,
"bootstrap.servers"))
+```
+
+Map must contain multiple keys simultaneously:
+
+```java
+.required(JDBC_PROPS, Conditions.mapContainsKeys(JDBC_PROPS, "url", "driver",
"user"))
+```
+
### Compound constraints with AND
Multiple conditions combined with `.and(...)`. All conditions must hold.
diff --git a/docs/zh/architecture/configuration-and-option-system.md
b/docs/zh/architecture/configuration-and-option-system.md
index 2c58ec4a77..e9b956a998 100644
--- a/docs/zh/architecture/configuration-and-option-system.md
+++ b/docs/zh/architecture/configuration-and-option-system.md
@@ -124,6 +124,9 @@ public OptionRule optionRule() {
| 字符串 | `lowerCase(option)` | 字符串全部小写 |
| 集合 | `notEmpty(option)` | 集合非空 |
| 集合 | `unique(option)` | 集合元素无重复 |
+| Map | `mapNotEmpty(option)` | Map 非空 |
+| Map | `mapContainsKey(option, key)` | Map 包含指定 key |
+| Map | `mapContainsKeys(option, key1, key2, ...)` | Map 同时包含所有指定 key |
| 跨字段 | `lessThanField(option, other)` | 值 < 另一个配置项的值 |
| 跨字段 | `lessOrEqualField(option, other)` | 值 <= 另一个配置项的值 |
| 跨字段 | `greaterThanField(option, other)` | 值 > 另一个配置项的值 |
@@ -295,6 +298,26 @@ Option validation failed (4 errors):
.and(Conditions.unique(TABLES)))
```
+### Map 约束
+
+Map 必须非空:
+
+```java
+.required(PROPERTIES, Conditions.mapNotEmpty(PROPERTIES))
+```
+
+Map 必须包含指定 key:
+
+```java
+.required(KAFKA_CONFIG, Conditions.mapContainsKey(KAFKA_CONFIG,
"bootstrap.servers"))
+```
+
+Map 必须同时包含多个 key:
+
+```java
+.required(JDBC_PROPS, Conditions.mapContainsKeys(JDBC_PROPS, "url", "driver",
"user"))
+```
+
### AND 复合约束
多个条件通过 `.and(...)` 组合,所有条件必须同时满足。
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 1bb231b287..82a72d08e9 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
@@ -119,6 +119,22 @@ public final class ConditionEvaluators {
return false;
});
+ // Map
+ m.put(
+ ConditionOperator.MAP_NOT_EMPTY,
+ (v, c, cfg) -> v instanceof Map && !((Map) v).isEmpty());
+ m.put(
+ ConditionOperator.MAP_CONTAINS_KEY,
+ (v, c, cfg) -> v instanceof Map && ((Map)
v).containsKey(c.getExpectValue()));
+ m.put(
+ ConditionOperator.MAP_CONTAINS_KEYS,
+ (v, c, cfg) -> {
+ if (!(v instanceof Map)) return false;
+ Object expect = c.getExpectValue();
+ if (!(expect instanceof Collection)) return false;
+ return ((Map) v).keySet().containsAll((Collection) expect);
+ });
+
// Cross-field (null on either side -> false, preserving or()
short-circuit)
m.put(
ConditionOperator.FIELD_LESS_THAN,
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 965f12843f..8dbd07288f 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
@@ -50,6 +50,12 @@ public enum ConditionOperator {
NOT_EMPTY("is not empty", Category.COLLECTION, Arity.UNARY,
Source.LITERAL),
COLLECTION_UNIQUE("has unique elements", Category.COLLECTION, Arity.UNARY,
Source.LITERAL),
+ // ==================== Map ====================
+
+ MAP_NOT_EMPTY("is not empty", Category.MAP, Arity.UNARY, Source.LITERAL),
+ MAP_CONTAINS_KEY("contains key", Category.MAP, Arity.BINARY,
Source.LITERAL),
+ MAP_CONTAINS_KEYS("contains keys", Category.MAP, Arity.BINARY,
Source.LITERAL),
+
// ==================== Cross-field comparison ====================
FIELD_LESS_THAN("<", Category.NUMERIC, Arity.BINARY, Source.FIELD),
@@ -61,7 +67,8 @@ public enum ConditionOperator {
EQUALITY,
NUMERIC,
STRING,
- COLLECTION
+ COLLECTION,
+ MAP
}
public enum Arity {
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 ff2e550cc4..a510abbf17 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
@@ -19,6 +19,9 @@ package org.apache.seatunnel.api.configuration.util;
import org.apache.seatunnel.api.configuration.Option;
+import java.util.Arrays;
+import java.util.List;
+
/**
* Unified factory for creating {@link Condition} instances.
*
@@ -33,7 +36,7 @@ import org.apache.seatunnel.api.configuration.Option;
* .build();
* }</pre>
*
- * <p>Currently supported operators (17 total, 4 categories):
+ * <p>Currently supported operators (19 total, 5 categories):
*
* <ul>
* <li><b>Numeric</b>: {@code greaterThan}, {@code greaterOrEqual}, {@code
lessThan}, {@code
@@ -41,6 +44,7 @@ import org.apache.seatunnel.api.configuration.Option;
* <li><b>String</b>: {@code notBlank}, {@code startsWith}, {@code
contains}, {@code matches},
* {@code upperCase}, {@code lowerCase}
* <li><b>Collection</b>: {@code notEmpty}, {@code unique}
+ * <li><b>Map</b>: {@code mapNotEmpty}, {@code mapContainsKey}, {@code
mapContainsKeys}
* <li><b>Cross-field</b>: {@code lessThanField}, {@code lessOrEqualField},
{@code
* greaterThanField}, {@code greaterOrEqualField}
* </ul>
@@ -100,6 +104,20 @@ public final class Conditions {
return new Condition<>(option, ConditionOperator.NOT_EMPTY, null,
null);
}
+ // ==================== Map validation ====================
+ public static <T> Condition<T> mapNotEmpty(Option<T> option) {
+ return new Condition<>(option, ConditionOperator.MAP_NOT_EMPTY, null,
null);
+ }
+
+ public static <T> Condition<T> mapContainsKey(Option<T> option, String
key) {
+ return new Condition<>(option, ConditionOperator.MAP_CONTAINS_KEY, (T)
key, null);
+ }
+
+ public static <T> Condition<T> mapContainsKeys(Option<T> option, String...
keys) {
+ List<String> keyList = Arrays.asList(keys);
+ return new Condition<>(option, ConditionOperator.MAP_CONTAINS_KEYS,
(T) keyList, null);
+ }
+
public static <T> Condition<T> unique(Option<T> option) {
return new Condition<>(option, ConditionOperator.COLLECTION_UNIQUE,
null, null);
}
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 068e1e4d74..ef9536ea41 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
@@ -43,6 +43,9 @@ import static
org.apache.seatunnel.api.configuration.util.Conditions.lessOrEqual
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.mapContainsKey;
+import static
org.apache.seatunnel.api.configuration.util.Conditions.mapContainsKeys;
+import static
org.apache.seatunnel.api.configuration.util.Conditions.mapNotEmpty;
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;
@@ -2588,4 +2591,129 @@ public class ConfigValidatorTest {
ex.getMessage().startsWith("ErrorCode:[API-02]"),
"unified format should still carry standard ErrorCode prefix");
}
+
+ private static final Option<Map<String, String>> MAP_OPTION =
+
Options.key("properties").mapType().noDefaultValue().withDescription("test map
option");
+
+ @Test
+ public void testMapNotEmpty() {
+ OptionRule rule =
+ OptionRule.builder().required(MAP_OPTION,
mapNotEmpty(MAP_OPTION)).build();
+ Map<String, Object> config = new HashMap<>();
+
+ // empty map -> fail
+ config.put(MAP_OPTION.key(), Collections.emptyMap());
+ String msg =
+ assertThrows(OptionValidationException.class, () ->
validate(config, rule))
+ .getMessage();
+ Assertions.assertTrue(msg.contains("properties"));
+ Assertions.assertTrue(msg.contains("is not empty"));
+
+ // non-empty map -> pass
+ Map<String, String> props = new HashMap<>();
+ props.put("k1", "v1");
+ config.put(MAP_OPTION.key(), props);
+ Assertions.assertDoesNotThrow(() -> validate(config, rule));
+ }
+
+ @Test
+ public void testMapContainsKey() {
+ OptionRule rule =
+ OptionRule.builder()
+ .required(MAP_OPTION, mapContainsKey(MAP_OPTION,
"bootstrap.servers"))
+ .build();
+ Map<String, Object> config = new HashMap<>();
+
+ // map without required key -> fail
+ Map<String, String> props = new HashMap<>();
+ props.put("group.id", "test-group");
+ config.put(MAP_OPTION.key(), props);
+ String msg =
+ assertThrows(OptionValidationException.class, () ->
validate(config, rule))
+ .getMessage();
+ Assertions.assertTrue(msg.contains("properties"));
+ Assertions.assertTrue(msg.contains("contains key"));
+
+ // map with required key -> pass
+ props.put("bootstrap.servers", "localhost:9092");
+ config.put(MAP_OPTION.key(), props);
+ Assertions.assertDoesNotThrow(() -> validate(config, rule));
+ }
+
+ @Test
+ public void testMapContainsKeys() {
+ OptionRule rule =
+ OptionRule.builder()
+ .required(
+ MAP_OPTION, mapContainsKeys(MAP_OPTION,
"host", "port", "database"))
+ .build();
+ Map<String, Object> config = new HashMap<>();
+
+ // map missing some keys -> fail
+ Map<String, String> props = new HashMap<>();
+ props.put("host", "localhost");
+ props.put("port", "3306");
+ config.put(MAP_OPTION.key(), props);
+ String msg =
+ assertThrows(OptionValidationException.class, () ->
validate(config, rule))
+ .getMessage();
+ Assertions.assertTrue(msg.contains("properties"));
+ Assertions.assertTrue(msg.contains("contains keys"));
+
+ // map with all required keys -> pass
+ props.put("database", "mydb");
+ config.put(MAP_OPTION.key(), props);
+ Assertions.assertDoesNotThrow(() -> validate(config, rule));
+
+ // map with extra keys beyond required -> still pass
+ props.put("username", "root");
+ config.put(MAP_OPTION.key(), props);
+ Assertions.assertDoesNotThrow(() -> validate(config, rule));
+ }
+
+ @Test
+ public void testMapContainsKeyWithNullValue() {
+ OptionRule rule =
+ OptionRule.builder()
+ .required(MAP_OPTION, mapContainsKey(MAP_OPTION,
"token"))
+ .build();
+ Map<String, Object> config = new HashMap<>();
+
+ // non-map value -> fail
+ config.put(MAP_OPTION.key(), "not-a-map");
+ assertThrows(Exception.class, () -> validate(config, rule));
+
+ // map with the key but null value -> still pass (containsKey only
checks key presence)
+ Map<String, String> props = new HashMap<>();
+ props.put("token", null);
+ config.put(MAP_OPTION.key(), props);
+ Assertions.assertDoesNotThrow(() -> validate(config, rule));
+ }
+
+ @Test
+ public void testMapNotEmptyAndContainsKeyCombined() {
+ OptionRule rule =
+ OptionRule.builder()
+ .required(
+ MAP_OPTION,
+ mapNotEmpty(MAP_OPTION)
+ .and(mapContainsKey(MAP_OPTION,
"bootstrap.servers")))
+ .build();
+ Map<String, Object> config = new HashMap<>();
+
+ // empty map -> fail (mapNotEmpty)
+ config.put(MAP_OPTION.key(), Collections.emptyMap());
+ assertThrows(OptionValidationException.class, () -> validate(config,
rule));
+
+ // non-empty map without required key -> fail (containsKey)
+ Map<String, String> props = new HashMap<>();
+ props.put("group.id", "test");
+ config.put(MAP_OPTION.key(), props);
+ assertThrows(OptionValidationException.class, () -> validate(config,
rule));
+
+ // non-empty map with required key -> pass
+ props.put("bootstrap.servers", "localhost:9092");
+ config.put(MAP_OPTION.key(), props);
+ Assertions.assertDoesNotThrow(() -> validate(config, rule));
+ }
}