This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/dev/pr-12746-613803559a11a63b0bd6a138122d323a935e5def
in repository https://gitbox.apache.org/repos/asf/seatunnel.git

commit c37823b9a3db30e7455acf11415a89d5b0a1c7e9
Author: Ryan Yang <[email protected]>
AuthorDate: Thu Oct 15 07:13:29 2026 +0000

    [Improve][Connector-V2][Vitess] Migrate CDC source option validation to 
declarative OptionRule (#12746)
---
 docs/en/connectors/source/Vitess-CDC.md            |   5 +
 docs/zh/connectors/source/Vitess-CDC.md            |   2 +
 .../vitess/config/VitessSchemaMetadataGuards.java  | 184 +++++++++++++++
 .../cdc/vitess/source/VitessSourceFactory.java     |  39 +++-
 .../cdc/vitess/source/VitessSourceFactoryTest.java | 247 +++++++++++++++++++++
 5 files changed, 467 insertions(+), 10 deletions(-)

diff --git a/docs/en/connectors/source/Vitess-CDC.md 
b/docs/en/connectors/source/Vitess-CDC.md
index 1e2202b0ec..301bc7bd83 100644
--- a/docs/en/connectors/source/Vitess-CDC.md
+++ b/docs/en/connectors/source/Vitess-CDC.md
@@ -82,6 +82,11 @@ separately.
 
 ## Notes
 
+- The schema metadata contract is validated when the job is submitted: a 
`schema` block must declare
+  `columns` or `metadata_table_id`, every `tables_configs` entry must contain 
its own `schema` block,
+  and `table-names` / `table-pattern` are mutually exclusive.
+- Checks that need the catalog - a declared table missing from the schema 
metadata, or a pattern that
+  matches no table - still run when the source is created.
 - The first delivery does not read an initial table snapshot.
 - Dynamic discovery of newly added tables is out of scope.
 - Schema evolution events are not emitted in this first delivery.
diff --git a/docs/zh/connectors/source/Vitess-CDC.md 
b/docs/zh/connectors/source/Vitess-CDC.md
index 7c4e9d5cae..6bb6f5dbe2 100644
--- a/docs/zh/connectors/source/Vitess-CDC.md
+++ b/docs/zh/connectors/source/Vitess-CDC.md
@@ -80,6 +80,8 @@ JDBC 做验证或示例下游,请额外准备 MySQL JDBC 驱动。
 
 ## 说明
 
+- schema 元数据契约会在作业提交时校验:`schema` 块必须声明 `columns` 或 `metadata_table_id`,每个 
`tables_configs` 元素都必须包含自己的 `schema` 块,`table-names` 与 `table-pattern` 互斥。
+- 需要访问 catalog 的检查(声明的表在 schema 元数据中不存在、正则未匹配到任何表)仍在创建 source 时执行。
 - 第一版不支持初始快照读取。
 - 第一版不支持运行时动态发现新表。
 - 第一版不发送 schema evolution 事件。
diff --git 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-vitess/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/vitess/config/VitessSchemaMetadataGuards.java
 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-vitess/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/vitess/config/VitessSchemaMetadataGuards.java
new file mode 100644
index 0000000000..4d55c0d530
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-vitess/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/vitess/config/VitessSchemaMetadataGuards.java
@@ -0,0 +1,184 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.seatunnel.connectors.seatunnel.cdc.vitess.config;
+
+import org.apache.seatunnel.api.configuration.Option;
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.configuration.util.ConditionExtension;
+import org.apache.seatunnel.api.configuration.util.OptionValidationException;
+import org.apache.seatunnel.api.options.ConnectorCommonOptions;
+import org.apache.seatunnel.api.options.table.ColumnOptions;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Reusable {@link ConditionExtension} guards for the Vitess CDC schema 
metadata contract.
+ *
+ * <p>Vitess does not provide a SeaTunnel catalog yet, so a source has to 
describe the tables it
+ * reads explicitly through {@code schema} or {@code tables_configs}, and the 
declarative rule
+ * cannot look inside those structures. Each guard below attaches one of the 
checks the source
+ * applies while the job is built to the option it belongs to, so a 
configuration that cannot
+ * produce deterministic table schemas is rejected when the job is submitted 
instead of when it
+ * starts. The predicate and the wording are shared with the source through 
{@link
+ * #declaresSchemaMetadata(Map)}, {@link #missingSchemaBlockMessage(String)} 
and {@link
+ * #missingMetadataMessage(String)}, so an operator sees the same message 
either way. The schema
+ * guards report that message instead of a plain description because the 
offending entry has to be
+ * named, while the selector guard returns {@code false} and lets the rule 
render its description.
+ *
+ * <p>Runtime behavior is unchanged. A configuration accepted here can still 
fail later when the
+ * declared tables are resolved against the source metadata, because that 
requires reading the
+ * source and is intentionally not part of the rule.
+ */
+public final class VitessSchemaMetadataGuards {
+
+    private VitessSchemaMetadataGuards() {}
+
+    /**
+     * Returns whether a schema block declares its columns directly or refers 
to the metadata table.
+     */
+    public static boolean declaresSchemaMetadata(Map<String, Object> schema) {
+        return schema != null
+                && (schema.containsKey(ColumnOptions.COLUMNS.key())
+                        || 
schema.containsKey(ColumnOptions.METADATA_TABLE_ID.key()));
+    }
+
+    /** Message for a schema block that declares neither columns nor a 
metadata table id. */
+    public static String missingMetadataMessage(String optionName) {
+        return String.format(
+                "Vitess CDC requires explicit columns or metadata_table_id in 
'%s' so table schemas stay deterministic.",
+                optionName);
+    }
+
+    /** Message for a {@code tables_configs} entry that does not carry a 
schema block. */
+    public static String missingSchemaBlockMessage(String optionName) {
+        return String.format(
+                "Vitess CDC requires explicit schema metadata. '%s' must 
contain a schema block.",
+                optionName);
+    }
+
+    /** Rejects a {@code schema} that declares neither columns nor a metadata 
table id. */
+    public static ConditionExtension<Map<String, Object>> rootSchemaGuard(
+            Option<Map<String, Object>> schemaOption) {
+        return new RootSchemaGuard(schemaOption.key());
+    }
+
+    /**
+     * Rejects {@code tables_configs} entries that do not carry a schema block 
declaring columns or
+     * a metadata table id. An empty list is left to the not-empty constraint 
of the same option.
+     */
+    public static ConditionExtension<List<Map<String, Object>>> 
tableConfigsGuard(
+            Option<List<Map<String, Object>>> tableConfigsOption) {
+        return new TableConfigsGuard(tableConfigsOption.key());
+    }
+
+    /** Rejects a configuration that sets both {@code table-names} and {@code 
table-pattern}. */
+    public static ConditionExtension<List<String>> tableNamesGuard(
+            Option<String> tablePatternOption) {
+        return new TableNamesGuard(tablePatternOption);
+    }
+
+    static final class RootSchemaGuard implements 
ConditionExtension<Map<String, Object>> {
+
+        private final String guardedKey;
+
+        RootSchemaGuard(String guardedKey) {
+            this.guardedKey = guardedKey;
+        }
+
+        @Override
+        public String description() {
+            return "must declare '"
+                    + ColumnOptions.COLUMNS.key()
+                    + "' or '"
+                    + ColumnOptions.METADATA_TABLE_ID.key()
+                    + "' so table schemas stay deterministic";
+        }
+
+        @Override
+        public boolean evaluate(ReadonlyConfig config, Map<String, Object> 
value) {
+            if (declaresSchemaMetadata(value)) {
+                return true;
+            }
+            throw new 
OptionValidationException(missingMetadataMessage(guardedKey));
+        }
+    }
+
+    static final class TableConfigsGuard implements 
ConditionExtension<List<Map<String, Object>>> {
+
+        private final String guardedKey;
+
+        TableConfigsGuard(String guardedKey) {
+            this.guardedKey = guardedKey;
+        }
+
+        @Override
+        public String description() {
+            return "must contain a '"
+                    + ConnectorCommonOptions.SCHEMA.key()
+                    + "' block declaring '"
+                    + ColumnOptions.COLUMNS.key()
+                    + "' or '"
+                    + ColumnOptions.METADATA_TABLE_ID.key()
+                    + "' for every entry";
+        }
+
+        @Override
+        @SuppressWarnings("unchecked")
+        public boolean evaluate(ReadonlyConfig config, List<Map<String, 
Object>> value) {
+            if (value == null) {
+                return true;
+            }
+            for (int index = 0; index < value.size(); index++) {
+                Map<String, Object> tableConfig = value.get(index);
+                Object schemaValue =
+                        tableConfig == null
+                                ? null
+                                : 
tableConfig.get(ConnectorCommonOptions.SCHEMA.key());
+                String optionName = guardedKey + "[" + index + "]";
+                if (!(schemaValue instanceof Map)) {
+                    throw new 
OptionValidationException(missingSchemaBlockMessage(optionName));
+                }
+                if (!declaresSchemaMetadata((Map<String, Object>) 
schemaValue)) {
+                    throw new OptionValidationException(
+                            missingMetadataMessage(optionName + ".schema"));
+                }
+            }
+            return true;
+        }
+    }
+
+    static final class TableNamesGuard implements 
ConditionExtension<List<String>> {
+
+        private final Option<String> tablePatternOption;
+
+        TableNamesGuard(Option<String> tablePatternOption) {
+            this.tablePatternOption = tablePatternOption;
+        }
+
+        @Override
+        public String description() {
+            return "must not be used together with '" + 
tablePatternOption.key() + "'";
+        }
+
+        @Override
+        public boolean evaluate(ReadonlyConfig config, List<String> value) {
+            return !config.getOptional(tablePatternOption).isPresent();
+        }
+    }
+}
diff --git 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-vitess/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/vitess/source/VitessSourceFactory.java
 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-vitess/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/vitess/source/VitessSourceFactory.java
index 3528cfb814..8917a8df8e 100644
--- 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-vitess/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/vitess/source/VitessSourceFactory.java
+++ 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-vitess/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/vitess/source/VitessSourceFactory.java
@@ -17,9 +17,9 @@
 
 package org.apache.seatunnel.connectors.seatunnel.cdc.vitess.source;
 
+import org.apache.seatunnel.api.configuration.util.Conditions;
 import org.apache.seatunnel.api.configuration.util.OptionRule;
 import org.apache.seatunnel.api.options.ConnectorCommonOptions;
-import org.apache.seatunnel.api.options.table.ColumnOptions;
 import org.apache.seatunnel.api.source.SeaTunnelSource;
 import org.apache.seatunnel.api.source.SourceSplit;
 import org.apache.seatunnel.api.table.catalog.CatalogTable;
@@ -30,6 +30,7 @@ import org.apache.seatunnel.api.table.factory.Factory;
 import org.apache.seatunnel.api.table.factory.TableSourceFactoryContext;
 import org.apache.seatunnel.connectors.cdc.base.option.SourceOptions;
 import org.apache.seatunnel.connectors.cdc.base.option.StartupMode;
+import 
org.apache.seatunnel.connectors.seatunnel.cdc.vitess.config.VitessSchemaMetadataGuards;
 import 
org.apache.seatunnel.connectors.seatunnel.cdc.vitess.config.VitessSourceConfig;
 import 
org.apache.seatunnel.connectors.seatunnel.cdc.vitess.config.VitessSourceOptions;
 import 
org.apache.seatunnel.connectors.seatunnel.cdc.vitess.source.split.VitessSourceSplit;
@@ -59,8 +60,26 @@ public class VitessSourceFactory implements 
ChangeStreamTableSourceFactory {
         return OptionRule.builder()
                 .required(VitessSourceOptions.HOSTNAME, 
VitessSourceOptions.KEYSPACE)
                 .exclusive(ConnectorCommonOptions.SCHEMA, 
ConnectorCommonOptions.TABLE_CONFIGS)
+                .optional(
+                        ConnectorCommonOptions.SCHEMA,
+                        Conditions.extension(
+                                ConnectorCommonOptions.SCHEMA,
+                                VitessSchemaMetadataGuards.rootSchemaGuard(
+                                        ConnectorCommonOptions.SCHEMA)))
+                .optional(
+                        ConnectorCommonOptions.TABLE_CONFIGS,
+                        
Conditions.notEmpty(ConnectorCommonOptions.TABLE_CONFIGS),
+                        Conditions.extension(
+                                ConnectorCommonOptions.TABLE_CONFIGS,
+                                VitessSchemaMetadataGuards.tableConfigsGuard(
+                                        ConnectorCommonOptions.TABLE_CONFIGS)))
                 .optional(
                         ConnectorCommonOptions.TABLE_NAMES,
+                        Conditions.extension(
+                                ConnectorCommonOptions.TABLE_NAMES,
+                                VitessSchemaMetadataGuards.tableNamesGuard(
+                                        ConnectorCommonOptions.TABLE_PATTERN)))
+                .optional(
                         ConnectorCommonOptions.TABLE_PATTERN,
                         ConnectorCommonOptions.METALAKE_TYPE,
                         VitessSourceOptions.PORT,
@@ -125,6 +144,11 @@ public class VitessSourceFactory implements 
ChangeStreamTableSourceFactory {
         return filterDeclaredTables(context, discoverTableSchemas(context));
     }
 
+    /**
+     * The declarative rule already covers this contract, including an empty 
{@code tables_configs}
+     * and a config that sets neither {@code schema} nor {@code 
tables_configs}. The checks below
+     * stay as a second line of defence for callers that build the source 
without running the rule.
+     */
     private void validateSchemaMetadataContract(TableSourceFactoryContext 
context) {
         Optional<Map<String, Object>> singleSchema =
                 
context.getOptions().getOptional(ConnectorCommonOptions.SCHEMA);
@@ -148,14 +172,10 @@ public class VitessSourceFactory implements 
ChangeStreamTableSourceFactory {
                 "Vitess CDC requires explicit schema metadata through either 
'schema' or 'tables_configs'.");
     }
 
-    @SuppressWarnings("unchecked")
     private void validateRootSchemaDefinition(Map<String, Object> schema, 
String optionName) {
-        if (!schema.containsKey(ColumnOptions.COLUMNS.key())
-                && !schema.containsKey(ColumnOptions.METADATA_TABLE_ID.key())) 
{
+        if (!VitessSchemaMetadataGuards.declaresSchemaMetadata(schema)) {
             throw new IllegalArgumentException(
-                    String.format(
-                            "Vitess CDC requires explicit columns or 
metadata_table_id in '%s' so table schemas stay deterministic.",
-                            optionName));
+                    
VitessSchemaMetadataGuards.missingMetadataMessage(optionName));
         }
     }
 
@@ -164,13 +184,12 @@ public class VitessSourceFactory implements 
ChangeStreamTableSourceFactory {
         Object schemaValue = config.get(ConnectorCommonOptions.SCHEMA.key());
         if (!(schemaValue instanceof Map)) {
             throw new IllegalArgumentException(
-                    String.format(
-                            "Vitess CDC requires explicit schema metadata. 
'%s' must contain a schema block.",
-                            optionName));
+                    
VitessSchemaMetadataGuards.missingSchemaBlockMessage(optionName));
         }
         validateRootSchemaDefinition((Map<String, Object>) schemaValue, 
optionName + ".schema");
     }
 
+    /** The rule rejects both selectors together, this keeps the runtime path 
equally explicit. */
     private void validateSelectionOptions(TableSourceFactoryContext context) {
         if 
(context.getOptions().getOptional(ConnectorCommonOptions.TABLE_NAMES).isPresent()
                 && context.getOptions()
diff --git 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-vitess/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/vitess/source/VitessSourceFactoryTest.java
 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-vitess/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/vitess/source/VitessSourceFactoryTest.java
index b1603127da..6039cfeeaa 100644
--- 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-vitess/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/vitess/source/VitessSourceFactoryTest.java
+++ 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-vitess/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/vitess/source/VitessSourceFactoryTest.java
@@ -18,6 +18,11 @@
 package org.apache.seatunnel.connectors.seatunnel.cdc.vitess.source;
 
 import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.configuration.util.Condition;
+import org.apache.seatunnel.api.configuration.util.ConfigValidator;
+import org.apache.seatunnel.api.configuration.util.OptionRule;
+import org.apache.seatunnel.api.configuration.util.OptionValidationException;
+import org.apache.seatunnel.api.options.ConnectorCommonOptions;
 import org.apache.seatunnel.api.table.catalog.CatalogTable;
 import org.apache.seatunnel.api.table.catalog.PhysicalColumn;
 import org.apache.seatunnel.api.table.catalog.PrimaryKey;
@@ -43,6 +48,7 @@ import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.stream.Collectors;
 
 /** Covers factory-path validation, schema selection, and restore semantics 
for Vitess CDC. */
 class VitessSourceFactoryTest {
@@ -248,6 +254,247 @@ class VitessSourceFactoryTest {
                 exception.getMessage().contains("does not belong to keyspace 
'test'"));
     }
 
+    /**
+     * The declarative rule must carry the schema metadata and selector guards 
the source relies on,
+     * so that dropping one of them fails this test instead of silently losing 
the early rejection.
+     */
+    @Test
+    void testOptionRuleCarriesSchemaMetadataGuards() {
+        OptionRule optionRule = factory.optionRule();
+
+        Assertions.assertNotNull(optionRule);
+        List<String> constraints =
+                optionRule.getValueConstraints().stream()
+                        .map(Condition::toString)
+                        .collect(Collectors.toList());
+
+        Assertions.assertTrue(
+                hasConstraint(constraints, "'schema' must declare"),
+                "missing root schema guard: " + constraints);
+        Assertions.assertTrue(
+                hasConstraint(constraints, "'tables_configs' is not empty"),
+                "missing not-empty constraint: " + constraints);
+        Assertions.assertTrue(
+                hasConstraint(constraints, "'tables_configs' must contain a 
'schema' block"),
+                "missing tables_configs guard: " + constraints);
+        Assertions.assertTrue(
+                hasConstraint(
+                        constraints,
+                        "'table-names' must not be used together with 
'table-pattern'"),
+                "missing table-names guard: " + constraints);
+        Assertions.assertTrue(
+                
optionRule.getOptionalOptions().contains(ConnectorCommonOptions.TABLE_NAMES));
+    }
+
+    /** A root schema that lists its columns stays valid. */
+    @Test
+    void testRuleAcceptsRootSchemaWithColumns() {
+        assertRuleAccepts(ruleOptionsWithRootSchema(rootSchemaWithColumns()));
+    }
+
+    /** A root schema may point at the metadata table instead of listing its 
columns. */
+    @Test
+    void testRuleAcceptsRootSchemaWithMetadataTableId() {
+        
assertRuleAccepts(ruleOptionsWithRootSchema(rootSchemaWithMetadataTableId()));
+    }
+
+    /** A root schema without columns or metadata_table_id must fail at 
submission time. */
+    @Test
+    void testRuleRejectsRootSchemaWithoutSchemaMetadata() {
+        Map<String, Object> schema = new HashMap<>();
+        schema.put("table", "test.products");
+
+        OptionValidationException exception = 
assertRuleRejects(ruleOptionsWithRootSchema(schema));
+
+        Assertions.assertTrue(exception.getMessage().contains("columns"));
+        
Assertions.assertTrue(exception.getMessage().contains("metadata_table_id"));
+    }
+
+    /** tables_configs entries with a complete schema block stay valid. */
+    @Test
+    void testRuleAcceptsTableConfigsWithSchemaBlocks() {
+        Map<String, Object> options = baseOptions(StartupMode.LATEST, null);
+        options.put("tables_configs", multiTableConfigs("test.products", 
"test.customers"));
+
+        assertRuleAccepts(options);
+    }
+
+    /** An empty tables_configs must fail before the source resolves any 
table. */
+    @Test
+    void testRuleRejectsEmptyTableConfigs() {
+        Map<String, Object> options = baseOptions(StartupMode.LATEST, null);
+        options.put("tables_configs", Collections.emptyList());
+
+        OptionValidationException exception = assertRuleRejects(options);
+
+        
Assertions.assertTrue(exception.getMessage().contains("tables_configs"));
+        Assertions.assertTrue(exception.getMessage().contains("is not empty"));
+    }
+
+    /** Every tables_configs entry must carry a schema block. */
+    @Test
+    void testRuleRejectsTableConfigsEntryWithoutSchemaBlock() {
+        Map<String, Object> options = baseOptions(StartupMode.LATEST, null);
+        options.put("tables_configs", 
Collections.singletonList(tableConfigWithoutSchema()));
+
+        OptionValidationException exception = assertRuleRejects(options);
+
+        
Assertions.assertTrue(exception.getMessage().contains("tables_configs[0]"));
+        Assertions.assertTrue(exception.getMessage().contains("must contain a 
schema block"));
+    }
+
+    /** A nested schema block must declare columns or metadata_table_id as 
well. */
+    @Test
+    void testRuleRejectsTableConfigsEntryWithoutSchemaMetadata() {
+        Map<String, Object> schema = new HashMap<>();
+        schema.put("table", "test.products");
+        Map<String, Object> tableConfig = new HashMap<>();
+        tableConfig.put("schema", schema);
+
+        Map<String, Object> options = baseOptions(StartupMode.LATEST, null);
+        options.put("tables_configs", Collections.singletonList(tableConfig));
+
+        OptionValidationException exception = assertRuleRejects(options);
+
+        
Assertions.assertTrue(exception.getMessage().contains("tables_configs[0].schema"));
+        
Assertions.assertTrue(exception.getMessage().contains("metadata_table_id"));
+    }
+
+    /** A tables_configs entry may point at the metadata table instead of 
listing its columns. */
+    @Test
+    void testRuleAcceptsTableConfigsEntryWithMetadataTableId() {
+        Map<String, Object> schema = new HashMap<>();
+        schema.put("table", "test.products");
+        schema.put("metadata_table_id", "test.products");
+        Map<String, Object> tableConfig = new HashMap<>();
+        tableConfig.put("schema", schema);
+
+        Map<String, Object> options = baseOptions(StartupMode.LATEST, null);
+        options.put("tables_configs", Collections.singletonList(tableConfig));
+
+        assertRuleAccepts(options);
+    }
+
+    /** The guard must name the failing entry, not just the first one. */
+    @Test
+    void testRuleRejectsLaterTableConfigsEntryWithoutSchemaMetadata() {
+        List<Map<String, Object>> tableConfigs =
+                new ArrayList<>(singleTableConfigs("test.products"));
+        Map<String, Object> incompleteSchema = new HashMap<>();
+        incompleteSchema.put("table", "test.customers");
+        Map<String, Object> incompleteTableConfig = new HashMap<>();
+        incompleteTableConfig.put("schema", incompleteSchema);
+        tableConfigs.add(incompleteTableConfig);
+
+        Map<String, Object> options = baseOptions(StartupMode.LATEST, null);
+        options.put("tables_configs", tableConfigs);
+
+        OptionValidationException exception = assertRuleRejects(options);
+
+        
Assertions.assertTrue(exception.getMessage().contains("tables_configs[1].schema"));
+        
Assertions.assertTrue(exception.getMessage().contains("metadata_table_id"));
+    }
+
+    /** table-names and table-pattern are alternatives, never a pair. */
+    @Test
+    void testRuleRejectsTableNamesTogetherWithTablePattern() {
+        Map<String, Object> options = 
ruleOptionsWithRootSchema(rootSchemaWithColumns());
+        options.put("table-names", Collections.singletonList("test.products"));
+        options.put("table-pattern", "test\\..*");
+
+        OptionValidationException exception = assertRuleRejects(options);
+
+        Assertions.assertTrue(
+                exception.getMessage().contains("must not be used together 
with 'table-pattern'"));
+    }
+
+    /** Either table selector on its own keeps working. */
+    @Test
+    void testRuleAcceptsSingleTableSelector() {
+        Map<String, Object> tableNamesOnly = 
ruleOptionsWithRootSchema(rootSchemaWithColumns());
+        tableNamesOnly.put("table-names", 
Collections.singletonList("test.products"));
+        assertRuleAccepts(tableNamesOnly);
+
+        Map<String, Object> tablePatternOnly = 
ruleOptionsWithRootSchema(rootSchemaWithColumns());
+        tablePatternOnly.put("table-pattern", "test\\..*");
+        assertRuleAccepts(tablePatternOnly);
+    }
+
+    /** The rule and the source path must report the same contract wording. */
+    @Test
+    void testRuleAndFactoryShareSchemaMetadataMessages() {
+        Map<String, Object> schema = new HashMap<>();
+        schema.put("table", "test.products");
+        Map<String, Object> rootSchemaOptions = 
ruleOptionsWithRootSchema(schema);
+
+        Map<String, Object> tableConfig = new HashMap<>();
+        tableConfig.put("schema", schema);
+        Map<String, Object> tableConfigsOptions = 
baseOptions(StartupMode.LATEST, null);
+        tableConfigsOptions.put("tables_configs", 
Collections.singletonList(tableConfig));
+
+        Assertions.assertTrue(
+                assertRuleRejects(rootSchemaOptions)
+                        .getMessage()
+                        .contains(runtimeFailureMessage(rootSchemaOptions)));
+        Assertions.assertTrue(
+                assertRuleRejects(tableConfigsOptions)
+                        .getMessage()
+                        .contains(runtimeFailureMessage(tableConfigsOptions)));
+    }
+
+    private boolean hasConstraint(List<String> constraints, String expected) {
+        return constraints.stream().anyMatch(constraint -> 
constraint.contains(expected));
+    }
+
+    private void assertRuleAccepts(Map<String, Object> options) {
+        
ConfigValidator.of(ReadonlyConfig.fromMap(options)).validate(factory.optionRule());
+    }
+
+    private OptionValidationException assertRuleRejects(Map<String, Object> 
options) {
+        return Assertions.assertThrows(
+                OptionValidationException.class,
+                () ->
+                        ConfigValidator.of(ReadonlyConfig.fromMap(options))
+                                .validate(factory.optionRule()));
+    }
+
+    private Map<String, Object> ruleOptionsWithRootSchema(Map<String, Object> 
schema) {
+        Map<String, Object> options = baseOptions(StartupMode.LATEST, null);
+        options.put("schema", schema);
+        return options;
+    }
+
+    private Map<String, Object> rootSchemaWithColumns() {
+        Map<String, Object> schema = new HashMap<>();
+        schema.put("table", "test.products");
+        schema.put("columns", Collections.singletonList(column("id", "int")));
+        return schema;
+    }
+
+    private Map<String, Object> rootSchemaWithMetadataTableId() {
+        Map<String, Object> schema = new HashMap<>();
+        schema.put("table", "test.products");
+        schema.put("metadata_table_id", "test.products");
+        return schema;
+    }
+
+    private Map<String, Object> tableConfigWithoutSchema() {
+        Map<String, Object> tableConfig = new HashMap<>();
+        tableConfig.put("table", "test.products");
+        return tableConfig;
+    }
+
+    private String runtimeFailureMessage(Map<String, Object> options) {
+        TableSourceFactoryContext context =
+                new TableSourceFactoryContext(
+                        ReadonlyConfig.fromMap(options), 
getClass().getClassLoader());
+        IllegalArgumentException exception =
+                Assertions.assertThrows(
+                        IllegalArgumentException.class,
+                        () -> factory.createSource(context).createSource());
+        return exception.getMessage();
+    }
+
     private VitessSource createVitessSource(TableSource<?, VitessSourceSplit, 
?> tableSource) {
         return (VitessSource) tableSource.createSource();
     }

Reply via email to