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-12015-e71e5178e0d7f20ec952ff5d09e963d21659026a in repository https://gitbox.apache.org/repos/asf/seatunnel.git
commit 5ba57fcbba337f153d60fd57b50bd6cf3a56f80b Author: luozihen <[email protected]> AuthorDate: Wed Oct 21 13:44:45 2026 +0000 [Feature][Connector-V2] Support per-table primary key mapping via multi_table_config.primary_keys for JDBC sink (#12015) --- docs/en/connectors/sink/Jdbc.md | 59 ++++ .../configuration/sink-options-placeholders.md | 9 + docs/zh/connectors/sink/Jdbc.md | 59 ++++ .../configuration/sink-options-placeholders.md | 8 + .../common/multitable/MultiTableFailureHelper.java | 14 +- .../multitable/MultiTableFailureHelperTest.java | 66 ++++ .../api/configuration/ReadableConfigTest.java | 11 + .../seatunnel/jdbc/config/JdbcSinkOptions.java | 13 + .../jdbc/exception/JdbcConnectorErrorCode.java | 3 +- .../JdbcMultiTableConfigConditionExtension.java | 69 ++++ .../seatunnel/jdbc/sink/JdbcSinkFactory.java | 378 ++++++++++++++++++--- .../seatunnel/jdbc/sink/JdbcSinkFactoryTest.java | 343 +++++++++++++++++++ .../core/starter/utils/ConfigBuilder.java | 22 +- .../core/starter/utils/ConfigShadeUtils.java | 2 +- .../core/starter/utils/ConfigBuilderTest.java | 77 +++++ .../core/starter/utils/ConfigShadeTest.java | 59 ++++ .../seatunnel/jdbc/JdbcMysqlMultipleTablesIT.java | 114 +++++++ ...ql_source_and_sink_with_multi_table_config.conf | 79 +++++ 18 files changed, 1340 insertions(+), 45 deletions(-) diff --git a/docs/en/connectors/sink/Jdbc.md b/docs/en/connectors/sink/Jdbc.md index 4ede4637bf..c4bd2e5639 100644 --- a/docs/en/connectors/sink/Jdbc.md +++ b/docs/en/connectors/sink/Jdbc.md @@ -176,6 +176,7 @@ Exactly-once delivery uses XA transactions and therefore requires XA support fro | tablePrefix | String | No | - | | tableSuffix | String | No | - | | primary_keys | Array | No | - | +| multi_table_config | Object | No | - | | connection_check_timeout_sec | Int | No | 30 | | connect_timeout_ms | Int | No | 86400000 | | socket_timeout_ms | Int | No | 86400000 | @@ -295,6 +296,64 @@ Deprecated. Use `table` with table placeholders instead. For example, use `table The target key columns used to generate database-native UPSERT, UPDATE, and DELETE statements. If omitted, SeaTunnel attempts to inherit a primary key or the first unique key from upstream catalog metadata. If no key is available, generated SQL uses plain INSERT. +### multi_table_config [object] + +Per-table primary key mapping for multi-table generated-SQL jobs. It has higher precedence than the top-level `primary_keys` option: when an upstream table name matches one of the patterns declared under `primary_keys`, that mapping is used; otherwise SeaTunnel falls back to the existing `primary_keys` / catalog metadata logic. + +The value must follow this shape: + +```hocon +multi_table_config { + primary_keys { + "<table-name-regex>" = ["key1", "key2"] + } +} +``` + +- `primary_keys` is required once `multi_table_config` is set. If it is missing, empty, or not a map, the job fails during option validation instead of falling back to the top-level `primary_keys` / catalog logic. +- Each key is a Java regular expression matched against the upstream table name using full match semantics (`tableName.matches(pattern)`). For example, use `"^t_nova_.*$"` or `"t_nova_.*"`, not a glob such as `"t_nova_*"`. +- Each value is a list of column names. The `${primary_key}` and `${unique_key}` placeholders are supported inside this option only, and can be mixed with static columns. `${primary_key}` expands to the upstream primary key columns, `${unique_key}` expands to the first upstream unique key columns. +- If a table matches multiple patterns, the first pattern in declaration order wins. +- If a matched table uses `${primary_key}` (or `${unique_key}`) but has no upstream primary key (or unique key), the job fails with a clear error. +- The two placeholder passes run in a fixed order: the engine-level table placeholder replacement runs first and leaves this option untouched (it only rewrites top-level `String` values and single-element `String` lists), and the JDBC sink then expands `${primary_key}` / `${unique_key}` inside `multi_table_config.primary_keys` while the sink is created. See [sink option placeholders](../../introduction/configuration/sink-options-placeholders.md) for the general rules. +- Regex keys are supported when the job is submitted from a config file or with the HOCON body of the REST submit-job API. The JSON body of that API does not support them yet and rejects such a configuration while parsing it; until the follow-up fix is available, submit the job from a config file or with the HOCON body. + +Example: use the upstream primary key plus a shared `DATA_SOURCE` column as the composite key for tables whose names start with `t_nova_`. + +```hocon +sink { + jdbc { + url = "jdbc:mysql://localhost:3306/test" + driver = "com.mysql.cj.jdbc.Driver" + generate_sink_sql = true + multi_table_config { + primary_keys { + "^t_nova_.*$" = ["${primary_key}", "DATA_SOURCE"] + } + } + } +} +``` + +Example: mixing `multi_table_config` with the top-level `primary_keys`. Tables matched by the mapping use the mapping; unmatched tables fall back to `primary_keys = ["merchant_id"]`. + +```hocon +sink { + jdbc { + url = "jdbc:mysql://localhost:3306/test" + driver = "com.mysql.cj.jdbc.Driver" + generate_sink_sql = true + primary_keys = ["merchant_id"] + multi_table_config { + primary_keys { + "t_tyuen_txn_ext.*" = ["id_txn_ctrl", "DATA_SOURCE"] + "t_nova_merge_settle_serial" = ["${primary_key}", "DATA_SOURCE"] + } + } + } +} +``` + ### connection_check_timeout_sec [int] The time in seconds to wait for the database operation used to validate the connection to complete. diff --git a/docs/en/introduction/configuration/sink-options-placeholders.md b/docs/en/introduction/configuration/sink-options-placeholders.md index 69684d4f41..7cea42e369 100644 --- a/docs/en/introduction/configuration/sink-options-placeholders.md +++ b/docs/en/introduction/configuration/sink-options-placeholders.md @@ -136,6 +136,15 @@ primary_keys = ["${primary_key}", "tenant_id"] The placeholder replacement for list values is only applied when `${primary_key}` is the only element in the list. +The restriction above applies to the top-level `primary_keys` option. The JDBC sink additionally supports +mixing `${primary_key}` (and `${unique_key}`) with static column names inside +`multi_table_config.primary_keys`; see the JDBC sink documentation for details. + +The two passes run in a fixed order. The engine-level replacement described above runs first and only +rewrites top-level `String` values and single-element `String` lists, so the nested +`multi_table_config.primary_keys` map reaches the sink untouched. The JDBC sink then expands +`${primary_key}` / `${unique_key}` inside that map once per matched table, while the sink is created. + The behavior is the same for both single-table and multi-table jobs. We will complete the placeholder replacement before the connector is started, ensuring that the sink options is ready before use. diff --git a/docs/zh/connectors/sink/Jdbc.md b/docs/zh/connectors/sink/Jdbc.md index cac905ba4b..51b08b3eb7 100644 --- a/docs/zh/connectors/sink/Jdbc.md +++ b/docs/zh/connectors/sink/Jdbc.md @@ -176,6 +176,7 @@ Exactly-once 依赖 XA 事务,因此数据库和 JDBC 驱动都必须支持 XA | tablePrefix | String | 否 | - | | tableSuffix | String | 否 | - | | primary_keys | Array | 否 | - | +| multi_table_config | Object | 否 | - | | connection_check_timeout_sec | Int | 否 | 30 | | connect_timeout_ms | Int | 否 | 86400000 | | socket_timeout_ms | Int | 否 | 86400000 | @@ -296,6 +297,64 @@ Tip: 如果目标数据库有 SCHEMA 的概念,则表参数必须写成 `xxx.x 生成数据库原生 UPSERT、UPDATE 和 DELETE 语句时使用的目标键列。未配置时,SeaTunnel 会尝试从上游 catalog 元数据继承主键或第一组唯一键;仍无可用键时,自动生成的 SQL 退回普通 INSERT。 +### multi_table_config [object] + +用于多表自动生成 SQL 场景的按表主键映射,优先级高于顶层 `primary_keys` 选项:当上游表名匹配到 `primary_keys` 下声明的某个模式时,使用该映射;否则回退到现有 `primary_keys` / catalog 元数据逻辑。 + +配置结构如下: + +```hocon +multi_table_config { + primary_keys { + "<表名正则>" = ["key1", "key2"] + } +} +``` + +- 一旦配置了 `multi_table_config`,就必须配置 `primary_keys`。若缺失、为空或不是 map,任务会在选项校验阶段直接失败,而不会回退到顶层 `primary_keys` / catalog 逻辑。 +- 每个 key 是匹配上游表名的 Java 正则表达式,使用全匹配语义(`tableName.matches(pattern)`)。例如使用 `"^t_nova_.*$"` 或 `"t_nova_.*"`,不要使用 `"t_nova_*"` 这类 glob 写法。 +- 每个 value 是列名列表。仅在该选项内支持 `${primary_key}` 和 `${unique_key}` 占位符,且可与静态列混用。`${primary_key}` 展开为上游主键列,`${unique_key}` 展开为上游第一组唯一键列。 +- 若一张表同时匹配多个模式,按声明顺序取第一个匹配。 +- 若命中的表使用了 `${primary_key}`(或 `${unique_key}`)但上游没有主键(或唯一键),任务会以清晰错误信息失败。 +- 两个占位符替换阶段的顺序是固定的:引擎层的表占位符替换先执行,且不会修改该选项(它只处理顶层的 `String` 值和单元素 `String` 列表);随后由 JDBC Sink 在创建每个表时展开 `multi_table_config.primary_keys` 中的 `${primary_key}` / `${unique_key}`。通用规则见 [Sink 占位符说明](../../introduction/configuration/sink-options-placeholders.md)。 +- 通过配置文件提交,或通过 REST submit-job 接口的 HOCON body 提交时,支持正则 key;该接口的 JSON body 暂不支持,会在解析阶段直接拒绝。在该问题修复前,请改用配置文件或 HOCON body 提交。 + +示例:为表名以 `t_nova_` 开头的表,使用上游主键加上共享的 `DATA_SOURCE` 列作为复合主键。 + +```hocon +sink { + jdbc { + url = "jdbc:mysql://localhost:3306/test" + driver = "com.mysql.cj.jdbc.Driver" + generate_sink_sql = true + multi_table_config { + primary_keys { + "^t_nova_.*$" = ["${primary_key}", "DATA_SOURCE"] + } + } + } +} +``` + +示例:`multi_table_config` 与顶层 `primary_keys` 混用。被映射命中的表使用映射值,未命中的表回退到 `primary_keys = ["merchant_id"]`。 + +```hocon +sink { + jdbc { + url = "jdbc:mysql://localhost:3306/test" + driver = "com.mysql.cj.jdbc.Driver" + generate_sink_sql = true + primary_keys = ["merchant_id"] + multi_table_config { + primary_keys { + "t_tyuen_txn_ext.*" = ["id_txn_ctrl", "DATA_SOURCE"] + "t_nova_merge_settle_serial" = ["${primary_key}", "DATA_SOURCE"] + } + } + } +} +``` + ### connection_check_timeout_sec [int] 用于验证数据库连接的有效性时等待数据库操作完成所需的时间,单位是秒 diff --git a/docs/zh/introduction/configuration/sink-options-placeholders.md b/docs/zh/introduction/configuration/sink-options-placeholders.md index 624f0928bc..41f5898d8c 100644 --- a/docs/zh/introduction/configuration/sink-options-placeholders.md +++ b/docs/zh/introduction/configuration/sink-options-placeholders.md @@ -137,6 +137,14 @@ primary_keys = ["${primary_key}", "tenant_id"] 只有当 `${primary_key}` 是列表中的唯一元素时,才会执行列表占位符替换。 +上述限制仅针对顶层 `primary_keys` 选项。JDBC Sink 额外支持在 +`multi_table_config.primary_keys` 中将 `${primary_key}`(以及 `${unique_key}`)与静态列名混用, +详见 JDBC Sink 文档。 + +两个阶段的执行顺序是固定的:上文的引擎层替换先执行,且只处理顶层的 `String` 值和单元素 `String` 列表, +因此嵌套的 `multi_table_config.primary_keys` 映射会原样传给 Sink;随后由 JDBC Sink 在创建每个表时 +展开其中的 `${primary_key}` / `${unique_key}`。 + 单表任务和多表任务中的行为保持一致。 占位符的替换将在连接器启动之前完成,确保 Sink 参数在使用前已准备就绪。 diff --git a/seatunnel-api/src/main/java/org/apache/seatunnel/api/common/multitable/MultiTableFailureHelper.java b/seatunnel-api/src/main/java/org/apache/seatunnel/api/common/multitable/MultiTableFailureHelper.java index ca87980ef0..ba1d25dd2d 100644 --- a/seatunnel-api/src/main/java/org/apache/seatunnel/api/common/multitable/MultiTableFailureHelper.java +++ b/seatunnel-api/src/main/java/org/apache/seatunnel/api/common/multitable/MultiTableFailureHelper.java @@ -52,6 +52,15 @@ public final class MultiTableFailureHelper { .continueOtherTables(); } + /** + * Merges two option sets with primary precedence. The merge is shallow/top-level: on a key + * collision, {@code primary}'s value replaces {@code fallback}'s value wholesale, and nested + * objects are not merged recursively. + * + * @param primary the options that take precedence + * @param fallback the options used to fill in keys absent from {@code primary} + * @return the merged options + */ public static ReadonlyConfig mergeOptions(ReadonlyConfig primary, ReadonlyConfig fallback) { if (primary == null) { return fallback; @@ -59,7 +68,10 @@ public final class MultiTableFailureHelper { if (fallback == null) { return primary; } - return ReadonlyConfig.fromConfig(primary.toConfig().withFallback(fallback.toConfig())); + Map<String, Object> merged = new HashMap<>(); + merged.putAll(fallback.getSourceMap()); + merged.putAll(primary.getSourceMap()); + return ReadonlyConfig.fromMap(merged); } public static ReadonlyConfig withMultiTableFailurePolicy( diff --git a/seatunnel-api/src/test/java/org/apache/seatunnel/api/common/multitable/MultiTableFailureHelperTest.java b/seatunnel-api/src/test/java/org/apache/seatunnel/api/common/multitable/MultiTableFailureHelperTest.java new file mode 100644 index 0000000000..228f5edde7 --- /dev/null +++ b/seatunnel-api/src/test/java/org/apache/seatunnel/api/common/multitable/MultiTableFailureHelperTest.java @@ -0,0 +1,66 @@ +/* + * 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.common.multitable; + +import org.apache.seatunnel.api.configuration.ReadonlyConfig; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +class MultiTableFailureHelperTest { + + @Test + void testMergeOptionsPreservesSpecialCharacterKeys() { + Map<String, Object> primaryKeys = new HashMap<>(); + primaryKeys.put("^t_nova_.*$", Arrays.asList("${primary_key}", "DATA_SOURCE")); + + Map<String, Object> multiTableConfig = new HashMap<>(); + multiTableConfig.put("primary_keys", primaryKeys); + + Map<String, Object> primaryMap = new HashMap<>(); + primaryMap.put("multi_table_config", multiTableConfig); + primaryMap.put("url", "jdbc:mysql://localhost:3306/primary"); + + Map<String, Object> fallbackMap = new HashMap<>(); + fallbackMap.put("url", "jdbc:mysql://localhost:3306/fallback"); + fallbackMap.put("username", "u"); + + ReadonlyConfig merged = + MultiTableFailureHelper.mergeOptions( + ReadonlyConfig.fromMap(primaryMap), ReadonlyConfig.fromMap(fallbackMap)); + + Map<String, Object> mergedMap = merged.getSourceMap(); + Assertions.assertEquals("jdbc:mysql://localhost:3306/primary", mergedMap.get("url")); + Assertions.assertEquals("u", mergedMap.get("username")); + + Map<String, Object> mergedMultiTableConfig = + (Map<String, Object>) mergedMap.get("multi_table_config"); + Assertions.assertNotNull(mergedMultiTableConfig); + Map<String, Object> mergedPrimaryKeys = + (Map<String, Object>) mergedMultiTableConfig.get("primary_keys"); + Assertions.assertNotNull(mergedPrimaryKeys); + Assertions.assertTrue(mergedPrimaryKeys.containsKey("^t_nova_.*$")); + Assertions.assertEquals( + Arrays.asList("${primary_key}", "DATA_SOURCE"), + mergedPrimaryKeys.get("^t_nova_.*$")); + } +} diff --git a/seatunnel-api/src/test/java/org/apache/seatunnel/api/configuration/ReadableConfigTest.java b/seatunnel-api/src/test/java/org/apache/seatunnel/api/configuration/ReadableConfigTest.java index 24bd843ee7..d10450feea 100644 --- a/seatunnel-api/src/test/java/org/apache/seatunnel/api/configuration/ReadableConfigTest.java +++ b/seatunnel-api/src/test/java/org/apache/seatunnel/api/configuration/ReadableConfigTest.java @@ -378,4 +378,15 @@ public class ReadableConfigTest { ReadonlyConfig readonlyConfig = ReadonlyConfig.fromMap(map); Assertions.assertNull(readonlyConfig.toMap().get("user")); } + + @Test + public void testToConfigPreservesDottedKeyExpansion() { + Map<String, Object> map = new HashMap<>(); + map.put("a.b", 1); + + ReadonlyConfig readonlyConfig = ReadonlyConfig.fromMap(map); + Config config = readonlyConfig.toConfig(); + + Assertions.assertEquals(1, config.getInt("a.b")); + } } diff --git a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/config/JdbcSinkOptions.java b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/config/JdbcSinkOptions.java index 22c9c4dfc4..d96fc9f98d 100644 --- a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/config/JdbcSinkOptions.java +++ b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/config/JdbcSinkOptions.java @@ -24,6 +24,7 @@ import org.apache.seatunnel.api.sink.SchemaSaveMode; import org.apache.seatunnel.connectors.seatunnel.jdbc.internal.dialect.dialectenum.FieldIdeEnum; import java.util.List; +import java.util.Map; public class JdbcSinkOptions extends JdbcCommonOptions { @@ -108,6 +109,18 @@ public class JdbcSinkOptions extends JdbcCommonOptions { public static final Option<List<String>> PRIMARY_KEYS = Options.key("primary_keys").listType().noDefaultValue().withDescription("primary keys"); + public static final Option<Map<String, Object>> MULTI_TABLE_CONFIG = + Options.key("multi_table_config") + .mapObjectType() + .noDefaultValue() + .withDescription( + "Per-table primary key mapping for multi-table generated-SQL sinks. " + + "When a table matches one of the patterns under primary_keys, " + + "that mapping takes precedence over the top-level `primary_keys` " + + "option. Value shape: { primary_keys = { \"<regex>\" = " + + "[\"col1\", \"${primary_key}\"] } }. `primary_keys` is required " + + "once this option is set."); + public static final Option<Boolean> IS_PRIMARY_KEY_UPDATED = Options.key("is_primary_key_updated") .booleanType() diff --git a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/exception/JdbcConnectorErrorCode.java b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/exception/JdbcConnectorErrorCode.java index 2c2cac27b4..7a7c63f944 100644 --- a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/exception/JdbcConnectorErrorCode.java +++ b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/exception/JdbcConnectorErrorCode.java @@ -33,7 +33,8 @@ public enum JdbcConnectorErrorCode implements SeaTunnelErrorCode { NO_SUPPORT_OPERATION_FAILED("JDBC-09", "The jdbc driver not support operation."), DATA_TYPE_CAST_FAILED("JDBC-10", "Data type cast failed"), REFRESH_PHYSICAL_TABLESCHEMA_BY_SCHEMA_CHANGE_EVENT( - "JDBC-11", "Refresh the table with schema change failed"); + "JDBC-11", "Refresh the table with schema change failed"), + INVALID_MULTI_TABLE_CONFIG("JDBC-12", "Invalid multi_table_config primary key mapping"); private final String code; diff --git a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/JdbcMultiTableConfigConditionExtension.java b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/JdbcMultiTableConfigConditionExtension.java new file mode 100644 index 0000000000..f9569b0e14 --- /dev/null +++ b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/JdbcMultiTableConfigConditionExtension.java @@ -0,0 +1,69 @@ +/* + * 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.jdbc.sink; + +import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.api.configuration.util.ConditionExtension; +import org.apache.seatunnel.api.configuration.util.OptionValidationException; + +import java.util.Map; + +/** + * Early validation for the JDBC sink {@code multi_table_config} option. + * + * <p>Configuring {@code multi_table_config} is an explicit customization request, so it is only + * meaningful together with a {@code primary_keys} mapping. A missing, empty or non-map {@code + * primary_keys} is rejected during option-rule validation instead of being silently ignored while + * the sink is created, which would fall back to the top-level {@code primary_keys} / catalog + * primary key and write with an unexpected key. + */ +public class JdbcMultiTableConfigConditionExtension + implements ConditionExtension<Map<String, Object>> { + + public static final JdbcMultiTableConfigConditionExtension INSTANCE = + new JdbcMultiTableConfigConditionExtension(); + + private static final String PRIMARY_KEYS = "primary_keys"; + + private JdbcMultiTableConfigConditionExtension() {} + + @Override + public String description() { + return "must contain a non-empty primary_keys map when it is configured, for example " + + "primary_keys { \"^t_.*$\" = [\"key1\"] }"; + } + + @Override + public boolean evaluate(ReadonlyConfig config, Map<String, Object> value) + throws OptionValidationException { + // An absent or empty option means the user did not ask for per-table keys; keep the + // existing fallback behavior in that case. + if (value == null || value.isEmpty()) { + return true; + } + Object primaryKeys = value.get(PRIMARY_KEYS); + if (!(primaryKeys instanceof Map) || ((Map<?, ?>) primaryKeys).isEmpty()) { + throw new OptionValidationException( + "multi_table_config is configured but multi_table_config.primary_keys is missing " + + "or empty. Configure at least one pattern-to-columns mapping under " + + "multi_table_config.primary_keys, or remove multi_table_config to keep " + + "the fallback behavior."); + } + return true; + } +} diff --git a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/JdbcSinkFactory.java b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/JdbcSinkFactory.java index b77843556e..c6ba1a65f7 100644 --- a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/JdbcSinkFactory.java +++ b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/JdbcSinkFactory.java @@ -28,6 +28,7 @@ import org.apache.seatunnel.api.options.ConnectorCommonOptions; import org.apache.seatunnel.api.options.SinkConnectorCommonOptions; import org.apache.seatunnel.api.sink.DataSaveMode; import org.apache.seatunnel.api.sink.SchemaSaveMode; +import org.apache.seatunnel.api.sink.TablePlaceholder; import org.apache.seatunnel.api.table.catalog.Catalog; import org.apache.seatunnel.api.table.catalog.CatalogTable; import org.apache.seatunnel.api.table.catalog.Column; @@ -55,16 +56,35 @@ import org.apache.commons.collections4.CollectionUtils; import com.google.auto.service.AutoService; import java.sql.Connection; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; import java.util.stream.Collectors; @AutoService(Factory.class) public class JdbcSinkFactory implements TableSinkFactory, SupportSinkDryRunValidation { + private static final int MAX_CACHED_PATTERNS = 256; + + // Bounded LRU so dynamically-generated regex patterns cannot grow without limit. Patterns are + // immutable, so a benign duplicate compile under concurrent access has no correctness impact. + private static final Map<String, Pattern> COMPILED_PATTERN_CACHE = + Collections.synchronizedMap( + new LinkedHashMap<String, Pattern>(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry<String, Pattern> eldest) { + return size() > MAX_CACHED_PATTERNS; + } + }); + @Override public String factoryIdentifier() { return "Jdbc"; @@ -87,6 +107,9 @@ public class JdbcSinkFactory implements TableSinkFactory, SupportSinkDryRunValid Map<String, String> sinkTableOptions = config.get(SinkConnectorCommonOptions.TABLE_OPTIONS); CatalogTable catalogTable = context.getCatalogTable(); ReadonlyConfig catalogOptions = getCatalogOptions(context); + // Keep the upstream table reference for pattern matching: catalogTable is rebuilt below + // with the resolved sink table path, whose table name no longer represents the source. + CatalogTable upstreamTable = catalogTable; // source table info TableIdentifier tableId = catalogTable.getTableId(); // sink table info @@ -118,48 +141,12 @@ public class JdbcSinkFactory implements TableSinkFactory, SupportSinkDryRunValid map.put(JdbcSinkOptions.TABLE.key(), catalogTable.getTableId().getTableName()); } map.put(JdbcSinkOptions.DATABASE.key(), catalogTable.getTableId().getDatabaseName()); - PrimaryKey primaryKey = catalogTable.getTableSchema().getPrimaryKey(); - if (CollectionUtils.isEmpty(config.get(JdbcSinkOptions.PRIMARY_KEYS))) { - if (primaryKey != null && !CollectionUtils.isEmpty(primaryKey.getColumnNames())) { - map.put( - JdbcSinkOptions.PRIMARY_KEYS.key(), - String.join(",", primaryKey.getColumnNames())); - } else { - Optional<ConstraintKey> keyOptional = - catalogTable.getTableSchema().getConstraintKeys().stream() - .filter( - key -> - ConstraintKey.ConstraintType.UNIQUE_KEY.equals( - key.getConstraintType())) - .findFirst(); - keyOptional.ifPresent( - constraintKey -> - map.put( - JdbcSinkOptions.PRIMARY_KEYS.key(), - constraintKey.getColumnNames().stream() - .map( - ConstraintKey.ConstraintKeyColumn - ::getColumnName) - .collect(Collectors.joining(",")))); - } + Optional<List<String>> multiTablePrimaryKeys = + resolveMultiTablePrimaryKeys(config, upstreamTable); + if (multiTablePrimaryKeys.isPresent()) { + catalogTable = applyPrimaryKeys(map, catalogTable, multiTablePrimaryKeys.get()); } else { - PrimaryKey configPk = - PrimaryKey.of( - catalogTable.getTablePath().getTableName() + "_config_pk", - config.get(JdbcSinkOptions.PRIMARY_KEYS)); - TableSchema tableSchema = catalogTable.getTableSchema(); - catalogTable = - CatalogTable.of( - catalogTable.getTableId(), - TableSchema.builder() - .primaryKey(configPk) - .constraintKey(tableSchema.getConstraintKeys()) - .columns(tableSchema.getColumns()) - .build(), - catalogTable.getOptions(), - catalogTable.getPartitionKeys(), - catalogTable.getComment(), - catalogTable.getCatalogName()); + catalogTable = applyFallbackPrimaryKeys(config, map, catalogTable); } config = ReadonlyConfig.fromMap(new HashMap<>(map)); final ReadonlyConfig options = config; @@ -192,6 +179,310 @@ public class JdbcSinkFactory implements TableSinkFactory, SupportSinkDryRunValid finalCatalogTable); } + /** + * Writes the resolved primary key columns into the sink config map and rebuilds the catalog + * table so that auto-created tables and generated upsert/update/delete statements use the given + * key columns. + * + * @param map the sink config map that is later turned back into a {@link ReadonlyConfig} + * @param catalogTable the table being processed + * @param primaryKeys the resolved key columns + * @return a new catalog table whose primary key is replaced with the resolved columns + */ + private CatalogTable applyPrimaryKeys( + Map<String, String> map, CatalogTable catalogTable, List<String> primaryKeys) { + validatePrimaryKeyColumns(primaryKeys, catalogTable.getTablePath().getTableName()); + map.put(JdbcSinkOptions.PRIMARY_KEYS.key(), String.join(",", primaryKeys)); + return buildCatalogTableWithPrimaryKey(catalogTable, primaryKeys); + } + + /** + * Rebuilds the catalog table so that its primary key is the given columns. The option map is + * left untouched, so a caller that resolved the columns from the existing configuration keeps + * the value the user wrote. + * + * @param catalogTable the table being processed + * @param primaryKeys the resolved key columns + * @return a new catalog table whose primary key is replaced with the resolved columns + */ + private CatalogTable buildCatalogTableWithPrimaryKey( + CatalogTable catalogTable, List<String> primaryKeys) { + PrimaryKey configPk = + PrimaryKey.of( + catalogTable.getTablePath().getTableName() + "_config_pk", primaryKeys); + TableSchema tableSchema = catalogTable.getTableSchema(); + return CatalogTable.of( + catalogTable.getTableId(), + TableSchema.builder() + .primaryKey(configPk) + .constraintKey(tableSchema.getConstraintKeys()) + .columns(tableSchema.getColumns()) + .build(), + catalogTable.getOptions(), + catalogTable.getPartitionKeys(), + catalogTable.getComment(), + catalogTable.getCatalogName()); + } + + /** + * Resolves the primary key columns using the pre-existing fallback logic when no multi-table + * mapping matches: explicit top-level {@code primary_keys}, otherwise the catalog primary key, + * otherwise the first unique key. When no key can be determined, the config map is left + * unchanged and the sink falls back to plain INSERT. + * + * @param config the sink config + * @param map the sink config map that is later turned back into a {@link ReadonlyConfig} + * @param catalogTable the table being processed + * @return the (possibly rebuilt) catalog table + */ + private CatalogTable applyFallbackPrimaryKeys( + ReadonlyConfig config, Map<String, String> map, CatalogTable catalogTable) { + PrimaryKey primaryKey = catalogTable.getTableSchema().getPrimaryKey(); + if (CollectionUtils.isEmpty(config.get(JdbcSinkOptions.PRIMARY_KEYS))) { + if (primaryKey != null && !CollectionUtils.isEmpty(primaryKey.getColumnNames())) { + map.put( + JdbcSinkOptions.PRIMARY_KEYS.key(), + String.join(",", primaryKey.getColumnNames())); + } else { + Optional<ConstraintKey> keyOptional = + catalogTable.getTableSchema().getConstraintKeys().stream() + .filter( + key -> + ConstraintKey.ConstraintType.UNIQUE_KEY.equals( + key.getConstraintType())) + .findFirst(); + keyOptional.ifPresent( + constraintKey -> + map.put( + JdbcSinkOptions.PRIMARY_KEYS.key(), + constraintKey.getColumnNames().stream() + .map( + ConstraintKey.ConstraintKeyColumn + ::getColumnName) + .collect(Collectors.joining(",")))); + } + return catalogTable; + } + // The legacy explicit primary_keys branch keeps the pre-existing behaviour: only the + // catalog primary key is rebuilt, the option value is neither rewritten nor re-validated. + return buildCatalogTableWithPrimaryKey( + catalogTable, config.get(JdbcSinkOptions.PRIMARY_KEYS)); + } + + /** + * Validates that each resolved primary key column is a non-empty plain identifier that does not + * contain a comma, so it can be safely comma-joined into {@code PRIMARY_KEYS} and used in + * generated SQL. Generated statements quote identifiers through the dialect, so a blank name + * and a comma (the delimiter of the comma-joined {@code PRIMARY_KEYS} value) are the only + * inputs that cannot be represented safely. + * + * @param primaryKeys the resolved key columns + * @param tableName the table being processed, used in the error message + * @throws JdbcConnectorException when a column name is blank or contains a comma + */ + private void validatePrimaryKeyColumns(List<String> primaryKeys, String tableName) { + for (String key : primaryKeys) { + if (StringUtils.isBlank(key)) { + throw new JdbcConnectorException( + JdbcConnectorErrorCode.INVALID_MULTI_TABLE_CONFIG, + String.format( + "Resolved primary key column for table '%s' is empty.", tableName)); + } + if (key.contains(",")) { + throw new JdbcConnectorException( + JdbcConnectorErrorCode.INVALID_MULTI_TABLE_CONFIG, + String.format( + "Resolved primary key column '%s' for table '%s' must not contain a comma.", + key, tableName)); + } + } + } + + /** + * Resolves the per-table primary key mapping from {@code multi_table_config.primary_keys}. + * + * <p>Each key is a Java regular expression matched against the upstream table name using full + * match semantics; the first matching pattern in declaration order wins. Every pattern is + * compiled eagerly so an invalid expression fails fast with {@code JDBC-12} instead of + * surfacing lazily once a matching table is processed. An unmatched table returns {@link + * Optional#empty()}, leaving the fallback logic to run. + * + * @param config the sink config + * @param catalogTable the upstream table; its name is matched against the patterns and its + * schema is used to expand {@code ${primary_key}} / {@code ${unique_key}} + * @return the resolved key columns, or {@link Optional#empty()} when no pattern matches + */ + Optional<List<String>> resolveMultiTablePrimaryKeys( + ReadonlyConfig config, CatalogTable catalogTable) { + Map<String, Object> multiTableConfig = config.get(JdbcSinkOptions.MULTI_TABLE_CONFIG); + if (multiTableConfig == null || multiTableConfig.isEmpty()) { + return Optional.empty(); + } + Object primaryKeysObj = multiTableConfig.get("primary_keys"); + if (!(primaryKeysObj instanceof Map)) { + return Optional.empty(); + } + LinkedHashMap<Pattern, List<String>> primaryKeyMap = + toCompiledPatternMap((Map<?, ?>) primaryKeysObj); + String tableName = catalogTable.getTableId().getTableName(); + for (Map.Entry<Pattern, List<String>> entry : primaryKeyMap.entrySet()) { + if (entry.getKey().matcher(tableName).matches()) { + return Optional.of( + expandPrimaryKeyPlaceholder( + entry.getValue(), catalogTable, entry.getKey().pattern())); + } + } + return Optional.empty(); + } + + /** + * Converts the configured {@code primary_keys} map into an ordered map of compiled patterns to + * key-column lists. A {@link LinkedHashMap} is used so the first matching pattern in + * declaration order wins. + * + * @throws JdbcConnectorException when a pattern is not a valid regular expression + */ + private LinkedHashMap<Pattern, List<String>> toCompiledPatternMap(Map<?, ?> primaryKeyMap) { + LinkedHashMap<Pattern, List<String>> ordered = new LinkedHashMap<>(); + for (Map.Entry<?, ?> entry : primaryKeyMap.entrySet()) { + ordered.put( + compilePattern(String.valueOf(entry.getKey())), + toPrimaryKeyList(entry.getValue())); + } + return ordered; + } + + /** + * Compiles a user-supplied regular expression, memoizing the result so each distinct pattern is + * compiled only once and reused across tables. Raises {@code JDBC-12} up front when the pattern + * is invalid. + * + * @throws JdbcConnectorException when the pattern is not a valid regular expression + */ + Pattern compilePattern(String pattern) { + Pattern compiled = COMPILED_PATTERN_CACHE.get(pattern); + if (compiled != null) { + return compiled; + } + try { + compiled = Pattern.compile(pattern); + } catch (PatternSyntaxException e) { + throw new JdbcConnectorException( + JdbcConnectorErrorCode.INVALID_MULTI_TABLE_CONFIG, + String.format( + "Invalid regular expression '%s' in multi_table_config.primary_keys.", + pattern), + e); + } + COMPILED_PATTERN_CACHE.put(pattern, compiled); + return compiled; + } + + /** + * Converts a configured primary key value into a list of column names. A list value is used + * as-is; a string value is split by comma. + * + * @throws JdbcConnectorException when the value is neither a list nor a string + */ + private List<String> toPrimaryKeyList(Object value) { + if (value instanceof List) { + List<String> keys = new ArrayList<>(); + for (Object element : (List<?>) value) { + keys.add(String.valueOf(element)); + } + return keys; + } + if (value instanceof String) { + String stringValue = (String) value; + if (StringUtils.isBlank(stringValue)) { + return Collections.emptyList(); + } + return Arrays.stream(stringValue.split(",")) + .map(String::trim) + .collect(Collectors.toList()); + } + throw new JdbcConnectorException( + JdbcConnectorErrorCode.INVALID_MULTI_TABLE_CONFIG, + "multi_table_config.primary_keys values must be a string or a list of strings."); + } + + /** + * Expands {@code ${primary_key}} and {@code ${unique_key}} placeholders in a key-column list. + * Each placeholder must be a whole element and is replaced by the corresponding upstream key + * columns. + * + * @throws JdbcConnectorException when a placeholder is used but the upstream table has no + * matching key + */ + private List<String> expandPrimaryKeyPlaceholder( + List<String> keys, CatalogTable catalogTable, String pattern) { + String primaryKeyPlaceholder = + "${" + TablePlaceholder.REPLACE_PRIMARY_KEY.getPlaceholder() + "}"; + String uniqueKeyPlaceholder = + "${" + TablePlaceholder.REPLACE_UNIQUE_KEY.getPlaceholder() + "}"; + List<String> primaryKeyColumns = getPrimaryKeyColumns(catalogTable); + List<String> uniqueKeyColumns = getUniqueKeyColumns(catalogTable); + List<String> resolved = new ArrayList<>(); + for (String key : keys) { + if (primaryKeyPlaceholder.equals(key)) { + if (primaryKeyColumns.isEmpty()) { + throw new JdbcConnectorException( + JdbcConnectorErrorCode.INVALID_MULTI_TABLE_CONFIG, + String.format( + "Table '%s' matched pattern '%s' in multi_table_config.primary_keys " + + "which uses '${primary_key}', but the upstream table has no primary key.", + catalogTable.getTableId().getTableName(), pattern)); + } + resolved.addAll(primaryKeyColumns); + } else if (uniqueKeyPlaceholder.equals(key)) { + if (uniqueKeyColumns.isEmpty()) { + throw new JdbcConnectorException( + JdbcConnectorErrorCode.INVALID_MULTI_TABLE_CONFIG, + String.format( + "Table '%s' matched pattern '%s' in multi_table_config.primary_keys " + + "which uses '${unique_key}', but the upstream table has no unique key.", + catalogTable.getTableId().getTableName(), pattern)); + } + resolved.addAll(uniqueKeyColumns); + } else { + resolved.add(key); + } + } + return resolved; + } + + /** + * Returns the upstream primary key column names, or an empty list when there is no primary key. + */ + private List<String> getPrimaryKeyColumns(CatalogTable catalogTable) { + PrimaryKey primaryKey = catalogTable.getTableSchema().getPrimaryKey(); + if (primaryKey == null || CollectionUtils.isEmpty(primaryKey.getColumnNames())) { + return Collections.emptyList(); + } + return new ArrayList<>(primaryKey.getColumnNames()); + } + + /** + * Returns the first upstream unique key column names, or an empty list when there is no unique + * key. + */ + private List<String> getUniqueKeyColumns(CatalogTable catalogTable) { + Optional<ConstraintKey> keyOptional = + catalogTable.getTableSchema().getConstraintKeys().stream() + .filter( + key -> + ConstraintKey.ConstraintType.UNIQUE_KEY.equals( + key.getConstraintType())) + .findFirst(); + return keyOptional + .map( + constraintKey -> + constraintKey.getColumnNames().stream() + .map(ConstraintKey.ConstraintKeyColumn::getColumnName) + .collect(Collectors.toList())) + .orElseGet(Collections::emptyList); + } + @Override public OptionRule optionRule() { return OptionRule.builder() @@ -245,6 +536,11 @@ public class JdbcSinkFactory implements TableSinkFactory, SupportSinkDryRunValid JdbcSinkOptions.TABLE_SUFFIX, SinkConnectorCommonOptions.MULTI_TABLE_SINK_REPLICA, JdbcSinkOptions.DIALECT) + .optional( + JdbcSinkOptions.MULTI_TABLE_CONFIG, + Conditions.extension( + JdbcSinkOptions.MULTI_TABLE_CONFIG, + JdbcMultiTableConfigConditionExtension.INSTANCE)) .optional( SinkConnectorCommonOptions.TABLE_OPTIONS, Conditions.extension( diff --git a/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/JdbcSinkFactoryTest.java b/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/JdbcSinkFactoryTest.java index bdcd346fa4..3f2260322d 100644 --- a/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/JdbcSinkFactoryTest.java +++ b/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/sink/JdbcSinkFactoryTest.java @@ -17,10 +17,15 @@ package org.apache.seatunnel.connectors.seatunnel.jdbc.sink; +import org.apache.seatunnel.shade.com.typesafe.config.Config; +import org.apache.seatunnel.shade.com.typesafe.config.ConfigFactory; + import org.apache.seatunnel.api.configuration.ReadonlyConfig; import org.apache.seatunnel.api.configuration.util.ConfigValidator; import org.apache.seatunnel.api.configuration.util.OptionRule; import org.apache.seatunnel.api.configuration.util.OptionValidationException; +import org.apache.seatunnel.api.sink.SeaTunnelSink; +import org.apache.seatunnel.api.sink.TablePlaceholderProcessor; import org.apache.seatunnel.api.table.catalog.CatalogTable; import org.apache.seatunnel.api.table.catalog.PhysicalColumn; import org.apache.seatunnel.api.table.catalog.PrimaryKey; @@ -29,14 +34,21 @@ import org.apache.seatunnel.api.table.catalog.TableSchema; import org.apache.seatunnel.api.table.connector.TableSink; import org.apache.seatunnel.api.table.factory.TableSinkFactoryContext; import org.apache.seatunnel.api.table.type.BasicType; +import org.apache.seatunnel.connectors.seatunnel.jdbc.config.JdbcSinkOptions; +import org.apache.seatunnel.connectors.seatunnel.jdbc.exception.JdbcConnectorException; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.regex.Pattern; class JdbcSinkFactoryTest { @@ -77,6 +89,41 @@ class JdbcSinkFactoryTest { "catalog"); } + private CatalogTable createCatalogTable(String tableName, List<String> primaryKeyColumns) { + TableSchema.Builder schemaBuilder = + TableSchema.builder() + .column(PhysicalColumn.of("id", BasicType.LONG_TYPE, 22, false, null, "id")) + .column( + PhysicalColumn.of( + "name", BasicType.STRING_TYPE, 128, false, null, "name")) + .column( + PhysicalColumn.of( + "DATA_SOURCE", + BasicType.STRING_TYPE, + 32, + false, + null, + "DATA_SOURCE")); + if (primaryKeyColumns != null && !primaryKeyColumns.isEmpty()) { + schemaBuilder.primaryKey(PrimaryKey.of("pk_id", primaryKeyColumns)); + } + return CatalogTable.of( + TableIdentifier.of("catalog", "ORCL", null, tableName), + schemaBuilder.build(), + new HashMap<>(), + new ArrayList<>(), + null, + "catalog"); + } + + private ReadonlyConfig multiTableReadonlyConfig(Map<String, Object> primaryKeys) { + Map<String, Object> cfg = baseConfig(); + Map<String, Object> multiTableConfig = new LinkedHashMap<>(); + multiTableConfig.put("primary_keys", primaryKeys); + cfg.put("multi_table_config", multiTableConfig); + return ReadonlyConfig.fromMap(cfg); + } + /** * Simulates the FactoryUtil.createAndPrepareSink entry path: OptionRule validation followed by * factory.createSink(context). This covers the real submission-time path end-to-end. @@ -285,4 +332,300 @@ class JdbcSinkFactoryTest { + "CatalogTable has primary keys; the PK + APPEND_VALUES conflict " + "is guarded at runtime by JdbcOutputFormatBuilder.validateOracleInsertMode"); } + + @Test + void testMultiTableConfigWithoutPrimaryKeysFailsOptionValidation() { + Map<String, Object> cfg = baseConfig(); + Map<String, Object> multiTableConfig = new LinkedHashMap<>(); + multiTableConfig.put("primary_key", Collections.singletonMap("^TEST_.*$", "id")); + cfg.put("multi_table_config", multiTableConfig); + + OptionValidationException exception = + Assertions.assertThrows( + OptionValidationException.class, + () -> createSinkViaFactoryContext(cfg, true)); + Assertions.assertTrue( + exception.getMessage().contains("multi_table_config.primary_keys"), + "The error must name the missing option, but was: " + exception.getMessage()); + } + + @Test + void testMultiTableConfigWithEmptyPrimaryKeysFailsOptionValidation() { + Map<String, Object> cfg = baseConfig(); + Map<String, Object> multiTableConfig = new LinkedHashMap<>(); + multiTableConfig.put("primary_keys", Collections.emptyMap()); + cfg.put("multi_table_config", multiTableConfig); + + Assertions.assertThrows(OptionValidationException.class, () -> validate(cfg)); + } + + @Test + void testMultiTableConfigWithNonMapPrimaryKeysFailsOptionValidation() { + Map<String, Object> cfg = baseConfig(); + Map<String, Object> multiTableConfig = new LinkedHashMap<>(); + multiTableConfig.put("primary_keys", Collections.singletonList("id")); + cfg.put("multi_table_config", multiTableConfig); + + Assertions.assertThrows(OptionValidationException.class, () -> validate(cfg)); + } + + @Test + void testMultiTableConfigWithPrimaryKeysPassesOptionValidation() { + Map<String, Object> cfg = baseConfig(); + Map<String, Object> multiTableConfig = new LinkedHashMap<>(); + multiTableConfig.put( + "primary_keys", + Collections.singletonMap("^TEST_.*$", Collections.singletonList("id"))); + cfg.put("multi_table_config", multiTableConfig); + + Assertions.assertDoesNotThrow(() -> validate(cfg)); + } + + @Test + void testEmptyMultiTableConfigSkipsOptionValidation() { + Map<String, Object> cfg = baseConfig(); + cfg.put("multi_table_config", Collections.emptyMap()); + + Assertions.assertDoesNotThrow( + () -> validate(cfg), "An empty multi_table_config is not a customization request"); + } + + @Test + void testResolveMultiTablePrimaryKeysMapped() { + Map<String, Object> primaryKeys = new LinkedHashMap<>(); + primaryKeys.put("^TEST_.*$", Arrays.asList("${primary_key}", "DATA_SOURCE")); + ReadonlyConfig config = multiTableReadonlyConfig(primaryKeys); + CatalogTable table = createCatalogTable("TEST_TABLE", Collections.singletonList("id")); + + Optional<List<String>> resolved = factory.resolveMultiTablePrimaryKeys(config, table); + + Assertions.assertTrue(resolved.isPresent()); + Assertions.assertEquals(Arrays.asList("id", "DATA_SOURCE"), resolved.get()); + } + + @Test + void testResolveMultiTablePrimaryKeysUnmatched() { + Map<String, Object> primaryKeys = new LinkedHashMap<>(); + primaryKeys.put("^OTHER_.*$", Arrays.asList("id_txn_ctrl")); + ReadonlyConfig config = multiTableReadonlyConfig(primaryKeys); + CatalogTable table = createCatalogTable("TEST_TABLE", Collections.singletonList("id")); + + Assertions.assertFalse(factory.resolveMultiTablePrimaryKeys(config, table).isPresent()); + } + + @Test + void testResolveMultiTablePrimaryKeysMissingPrimaryKeyFails() { + Map<String, Object> primaryKeys = new LinkedHashMap<>(); + primaryKeys.put("^TEST_.*$", Arrays.asList("${primary_key}", "DATA_SOURCE")); + ReadonlyConfig config = multiTableReadonlyConfig(primaryKeys); + CatalogTable table = createCatalogTable("TEST_TABLE", Collections.emptyList()); + + Assertions.assertThrows( + JdbcConnectorException.class, + () -> factory.resolveMultiTablePrimaryKeys(config, table)); + } + + @Test + void testResolveMultiTablePrimaryKeysFirstMatchWins() { + Map<String, Object> primaryKeys = new LinkedHashMap<>(); + primaryKeys.put("^TEST_.*$", Arrays.asList("id")); + primaryKeys.put("^T.*$", Arrays.asList("other")); + ReadonlyConfig config = multiTableReadonlyConfig(primaryKeys); + CatalogTable table = createCatalogTable("TEST_TABLE", Collections.singletonList("id")); + + Assertions.assertEquals( + Collections.singletonList("id"), + factory.resolveMultiTablePrimaryKeys(config, table).get()); + } + + /** + * Parses the same option from a real HOCON string (like the engine does) and keeps the pattern + * set non-alphabetical on purpose: {@code ^table.*$} is declared last although it matches first + * alphabetically, so a map that does not preserve the declaration order makes it win for both + * tables and fails this test. + */ + @Test + void testResolveMultiTablePrimaryKeysFromHoconFirstMatchWins() { + // Shaped like the config file used by the E2E test. The SeaTunnel HOCON parser turns a top + // level `sink` block into a list whose entries carry `plugin_name`, hence the list access. + Config hoconConfig = + ConfigFactory.parseString( + "sink {\n" + + " Jdbc {\n" + + " url = \"jdbc:mysql://localhost:3306/seatunnel\"\n" + + " database = \"sink\"\n" + + " table = \"${table_name}\"\n" + + " generate_sink_sql = true\n" + + " primary_keys = [\"c_bigint\"]\n" + + " multi_table_config {\n" + + " primary_keys {\n" + + " \"^table2$\" = [\"c_mediumint\"]\n" + + " \"^table1$\" = [\"c_int\", \"c_integer\"]\n" + + " \"^table.*$\" = [\"c_smallint\"]\n" + + " }\n" + + " }\n" + + " }\n" + + "}"); + ReadonlyConfig sinkConfig = + ReadonlyConfig.fromConfig(hoconConfig.getConfigList("sink").get(0)); + + Assertions.assertEquals( + Arrays.asList("c_int", "c_integer"), + factory.resolveMultiTablePrimaryKeys( + sinkConfig, + createCatalogTable("table1", Collections.singletonList("id"))) + .get()); + Assertions.assertEquals( + Collections.singletonList("c_mediumint"), + factory.resolveMultiTablePrimaryKeys( + sinkConfig, + createCatalogTable("table2", Collections.singletonList("id"))) + .get()); + } + + @Test + void testResolveMultiTablePrimaryKeysStringValue() { + Map<String, Object> primaryKeys = new LinkedHashMap<>(); + primaryKeys.put("^TEST_.*$", "id, DATA_SOURCE"); + ReadonlyConfig config = multiTableReadonlyConfig(primaryKeys); + CatalogTable table = createCatalogTable("TEST_TABLE", Collections.singletonList("id")); + + Assertions.assertEquals( + Arrays.asList("id", "DATA_SOURCE"), + factory.resolveMultiTablePrimaryKeys(config, table).get()); + } + + @Test + void testResolveMultiTablePrimaryKeysInvalidRegexFails() { + Map<String, Object> primaryKeys = new LinkedHashMap<>(); + primaryKeys.put("^[unclosed", Arrays.asList("id")); + ReadonlyConfig config = multiTableReadonlyConfig(primaryKeys); + CatalogTable table = createCatalogTable("TEST_TABLE", Collections.singletonList("id")); + + Assertions.assertThrows( + JdbcConnectorException.class, + () -> factory.resolveMultiTablePrimaryKeys(config, table)); + } + + @Test + void testCompilePatternCachesCompiledPattern() { + Pattern first = factory.compilePattern("^TEST_.*$"); + Pattern second = factory.compilePattern("^TEST_.*$"); + + Assertions.assertSame(first, second); + } + + @Test + void testCompilePatternInvalidFails() { + Assertions.assertThrows( + JdbcConnectorException.class, () -> factory.compilePattern("^[unclosed")); + } + + @Test + void testFactoryContextWithMultiTableConfigInvalidColumnFails() { + Map<String, Object> cfg = baseConfig(); + Map<String, Object> primaryKeys = new LinkedHashMap<>(); + primaryKeys.put("^TEST_.*$", Arrays.asList("id", "")); + Map<String, Object> multiTableConfig = new LinkedHashMap<>(); + multiTableConfig.put("primary_keys", primaryKeys); + cfg.put("multi_table_config", multiTableConfig); + + Assertions.assertThrows( + JdbcConnectorException.class, () -> createSinkViaFactoryContext(cfg, true)); + } + + @Test + void testFactoryContextWithMultiTableConfigValid() { + Map<String, Object> cfg = baseConfig(); + Map<String, Object> primaryKeys = new LinkedHashMap<>(); + primaryKeys.put("^TEST_.*$", Arrays.asList("${primary_key}", "DATA_SOURCE")); + Map<String, Object> multiTableConfig = new LinkedHashMap<>(); + multiTableConfig.put("primary_keys", primaryKeys); + cfg.put("multi_table_config", multiTableConfig); + + Assertions.assertDoesNotThrow(() -> createSinkViaFactoryContext(cfg, true)); + } + + /** + * The patterns must match the upstream table name even when the resolved sink table name + * differs, e.g. via a static {@code table} remap. Otherwise the mapping silently falls back to + * catalog metadata and the wrong key columns end up in the generated SQL. + */ + @Test + void testFactoryContextWithMultiTableConfigMatchesUpstreamTableName() { + Map<String, Object> cfg = baseConfig(); + cfg.put("table", "target_TEST_TABLE"); + + Map<String, Object> primaryKeys = new LinkedHashMap<>(); + primaryKeys.put("^TEST_TABLE$", Collections.singletonList("id")); + Map<String, Object> multiTableConfig = new LinkedHashMap<>(); + multiTableConfig.put("primary_keys", primaryKeys); + cfg.put("multi_table_config", multiTableConfig); + + TableSink tableSink = createSinkViaFactoryContext(cfg, false); + SeaTunnelSink<?, ?, ?, ?> sink = tableSink.createSink(); + CatalogTable writeTable = sink.getWriteCatalogTable().get(); + + Assertions.assertNotNull(writeTable.getTableSchema().getPrimaryKey()); + Assertions.assertEquals( + Collections.singletonList("id"), + writeTable.getTableSchema().getPrimaryKey().getColumnNames()); + } + + /** + * Guards the legacy top-level {@code primary_keys} branch: it keeps rebuilding the catalog + * primary key without rewriting the option value and without applying the new column + * validation, which is what this branch did before {@code multi_table_config} was added. + */ + @Test + void testLegacyPrimaryKeysStillRebuildCatalogPrimaryKey() { + Map<String, Object> cfg = baseConfig(); + cfg.put("primary_keys", Collections.singletonList("name")); + + TableSink tableSink = createSinkViaFactoryContext(cfg, false); + SeaTunnelSink<?, ?, ?, ?> sink = tableSink.createSink(); + CatalogTable writeTable = sink.getWriteCatalogTable().get(); + + Assertions.assertNotNull(writeTable.getTableSchema().getPrimaryKey()); + Assertions.assertEquals( + Collections.singletonList("name"), + writeTable.getTableSchema().getPrimaryKey().getColumnNames()); + } + + /** + * Pins the order between the engine-level TablePlaceholder pass and the connector-level + * expansion. The engine pass rewrites only top-level {@code String} values and single-element + * {@code String} lists, so a top-level {@code primary_keys = ["${primary_key}"]} is expanded + * there, while the nested {@code multi_table_config.primary_keys} map reaches the sink + * untouched and is expanded afterwards by {@link JdbcSinkFactory#resolveMultiTablePrimaryKeys}. + */ + @Test + void testPlaceholderOrderBetweenEngineAndConnectorPasses() { + Map<String, Object> cfg = baseConfig(); + cfg.put("primary_keys", Collections.singletonList("${primary_key}")); + Map<String, Object> primaryKeys = new LinkedHashMap<>(); + primaryKeys.put("^TEST_.*$", Arrays.asList("${primary_key}", "DATA_SOURCE")); + Map<String, Object> multiTableConfig = new LinkedHashMap<>(); + multiTableConfig.put("primary_keys", primaryKeys); + cfg.put("multi_table_config", multiTableConfig); + + CatalogTable upstream = createCatalogTable("TEST_TABLE", Collections.singletonList("id")); + ReadonlyConfig afterEnginePass = + TablePlaceholderProcessor.replaceTablePlaceholder( + ReadonlyConfig.fromMap(cfg), upstream); + + // The engine pass expands the top-level single-element placeholder list ... + Assertions.assertEquals( + Collections.singletonList("id"), afterEnginePass.get(JdbcSinkOptions.PRIMARY_KEYS)); + // ... leaves the nested multi_table_config map untouched ... + Map<?, ?> nestedPrimaryKeys = + (Map<?, ?>) + afterEnginePass.get(JdbcSinkOptions.MULTI_TABLE_CONFIG).get("primary_keys"); + Assertions.assertEquals( + Arrays.asList("${primary_key}", "DATA_SOURCE"), nestedPrimaryKeys.get("^TEST_.*$")); + // ... and the connector expands it afterwards, per matched table. + Assertions.assertEquals( + Arrays.asList("id", "DATA_SOURCE"), + factory.resolveMultiTablePrimaryKeys(afterEnginePass, upstream).get()); + } } diff --git a/seatunnel-core/seatunnel-core-starter/src/main/java/org/apache/seatunnel/core/starter/utils/ConfigBuilder.java b/seatunnel-core/seatunnel-core-starter/src/main/java/org/apache/seatunnel/core/starter/utils/ConfigBuilder.java index 754695826d..97e12c35e1 100644 --- a/seatunnel-core/seatunnel-core-starter/src/main/java/org/apache/seatunnel/core/starter/utils/ConfigBuilder.java +++ b/seatunnel-core/seatunnel-core-starter/src/main/java/org/apache/seatunnel/core/starter/utils/ConfigBuilder.java @@ -258,7 +258,27 @@ public class ConfigBuilder { processVariablesMap(resolvedConfigMap, userConfigMap); - return ConfigFactory.parseMap(resolvedConfigMap); + return rebuildWithLiteralKeys(resolvedConfigMap) + .resolve(ConfigResolveOptions.defaults().setAllowUnresolved(true)); + } + + /** + * Rebuilds a config from a map whose keys must be taken literally, for example the regex keys + * of {@code multi_table_config.primary_keys}. {@link ConfigFactory#parseMap} treats every key + * as a path expression and rejects such keys with {@code ConfigException.BadPath}, so the map + * is rendered as JSON and parsed with the JSON syntax instead, which keeps the keys as they + * are. + * + * <p>This is shared with {@link ConfigShadeUtils#processConfig} so that both rebuilds of a + * loaded config keep literal keys and cannot drift apart. + * + * @param configMap the config map to rebuild + * @return the rebuilt config, with the keys of {@code configMap} kept literal + */ + static Config rebuildWithLiteralKeys(Map<String, Object> configMap) { + return ConfigFactory.parseString( + JsonUtils.toJsonString(configMap), + ConfigParseOptions.defaults().setSyntax(ConfigSyntax.JSON)); } public static Config substituteUserConfig( diff --git a/seatunnel-core/seatunnel-core-starter/src/main/java/org/apache/seatunnel/core/starter/utils/ConfigShadeUtils.java b/seatunnel-core/seatunnel-core-starter/src/main/java/org/apache/seatunnel/core/starter/utils/ConfigShadeUtils.java index 8ec4b875a3..957a4cc1d3 100644 --- a/seatunnel-core/seatunnel-core-starter/src/main/java/org/apache/seatunnel/core/starter/utils/ConfigShadeUtils.java +++ b/seatunnel-core/seatunnel-core-starter/src/main/java/org/apache/seatunnel/core/starter/utils/ConfigShadeUtils.java @@ -225,7 +225,7 @@ public final class ConfigShadeUtils { configMap.put(Constants.SOURCE, sources); configMap.put(Constants.SINK, sinks); configMap.put(Constants.TRANSFORM, transforms); - return ConfigFactory.parseMap(configMap); + return ConfigBuilder.rebuildWithLiteralKeys(configMap); } catch (Exception e) { // Log desensitized error information log.error( diff --git a/seatunnel-core/seatunnel-core-starter/src/test/java/org/apache/seatunnel/core/starter/utils/ConfigBuilderTest.java b/seatunnel-core/seatunnel-core-starter/src/test/java/org/apache/seatunnel/core/starter/utils/ConfigBuilderTest.java index 9a86c43b98..56f2c0c7b2 100644 --- a/seatunnel-core/seatunnel-core-starter/src/test/java/org/apache/seatunnel/core/starter/utils/ConfigBuilderTest.java +++ b/seatunnel-core/seatunnel-core-starter/src/test/java/org/apache/seatunnel/core/starter/utils/ConfigBuilderTest.java @@ -17,11 +17,18 @@ package org.apache.seatunnel.core.starter.utils; +import org.apache.seatunnel.shade.com.typesafe.config.Config; +import org.apache.seatunnel.shade.com.typesafe.config.ConfigFactory; + import org.apache.seatunnel.core.starter.exception.ConfigCheckException; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedHashMap; @@ -30,6 +37,76 @@ import java.util.Map; public class ConfigBuilderTest { + /** + * Every starter loads a job config through {@code ConfigBuilder.of(path, variables)}, which + * rebuilds the config after substituting user variables. That rebuild must keep option keys + * literal: a regex key such as {@code ^t_nova_.*$}, used by {@code + * multi_table_config.primary_keys}, is not a valid HOCON path, so rebuilding the map with + * {@code ConfigFactory.parseMap} rejected the whole config with {@code ConfigException.BadPath} + * (see #12491). + */ + @Test + public void testOfPreservesLiteralRegexOptionKeys(@TempDir Path tempDir) throws IOException { + Path conf = writeMultiTableConfigConf(tempDir); + + // The starters pass null when no -i variable is given. + assertLoadedConfig(conf, ConfigBuilder.of(conf, null), "jdbc:mysql://${db_host}/sink"); + + // A -i variable is substituted, and must not disturb the shape of the config. + assertLoadedConfig( + conf, + ConfigBuilder.of(conf, Arrays.asList("db_host=localhost:3306")), + "jdbc:mysql://localhost:3306/sink"); + } + + private static void assertLoadedConfig(Path conf, Config config, String expectedUrl) { + Assertions.assertEquals(expectedUrl, config.getConfigList("sink").get(0).getString("url")); + + // Ordinary dotted keys keep the shape the file parser produced, so nothing changes for the + // options that already worked. + Assertions.assertEquals( + ConfigFactory.parseFile(conf.toFile()).getConfig("env").root().unwrapped(), + config.getConfig("env").root().unwrapped()); + + // The regex key survives as one literal key, instead of being rejected as an invalid path + // expression. + Map<?, ?> primaryKeys = + config.getConfigList("sink") + .get(0) + .getConfig("multi_table_config") + .getConfig("primary_keys") + .root() + .unwrapped(); + Assertions.assertEquals( + Arrays.asList("${primary_key}", "DATA_SOURCE"), primaryKeys.get("^t_nova_.*$")); + } + + private static Path writeMultiTableConfigConf(Path tempDir) throws IOException { + Path conf = tempDir.resolve("multi_table_config.conf"); + Files.write( + conf, + Arrays.asList( + "env {", + " job.mode = \"BATCH\"", + "}", + "source {", + " FakeSource {", + " plugin_output = \"fake\"", + " }", + "}", + "sink {", + " Jdbc {", + " url = \"jdbc:mysql://${db_host}/sink\"", + " multi_table_config {", + " primary_keys {", + " \"^t_nova_.*$\" = [\"${primary_key}\", \"DATA_SOURCE\"]", + " }", + " }", + " }", + "}")); + return conf; + } + @Test public void testConfigDesensitizationSort() { Map<String, Object> config = new LinkedHashMap<>(); diff --git a/seatunnel-core/seatunnel-core-starter/src/test/java/org/apache/seatunnel/core/starter/utils/ConfigShadeTest.java b/seatunnel-core/seatunnel-core-starter/src/test/java/org/apache/seatunnel/core/starter/utils/ConfigShadeTest.java index a463ad50cc..478d0f5a23 100644 --- a/seatunnel-core/seatunnel-core-starter/src/test/java/org/apache/seatunnel/core/starter/utils/ConfigShadeTest.java +++ b/seatunnel-core/seatunnel-core-starter/src/test/java/org/apache/seatunnel/core/starter/utils/ConfigShadeTest.java @@ -573,6 +573,65 @@ public class ConfigShadeTest { token, encryptedConfig.getConfigList("sink").get(0).getString("token")); } + @Test + public void testDecryptPreservesSpecialCharacterKeys() { + Config input = + ConfigFactory.parseString( + "source { FakeSource { plugin_output = \"fake\" } }\n" + + "sink { Jdbc { url = \"jdbc:mysql://localhost:3306/db\", " + + "username = \"u\", password = \"p\", " + + "multi_table_config { primary_keys { " + + "\"^t_nova_.*$\" = [\"${primary_key}\", \"DATA_SOURCE\"], " + + "\"^t_tyuen_txn_.*$\" = [\"id_txn_ctrl\", \"DATA_SOURCE\"] " + + "} } } }"); + + Config decrypted = ConfigShadeUtils.decryptConfig(input); + + Map<String, Object> primaryKeys = + decrypted + .getConfigList("sink") + .get(0) + .getConfig("multi_table_config") + .getConfig("primary_keys") + .root() + .unwrapped(); + + Assertions.assertTrue(primaryKeys.containsKey("^t_nova_.*$")); + Assertions.assertTrue(primaryKeys.containsKey("^t_tyuen_txn_.*$")); + Assertions.assertEquals( + Arrays.asList("${primary_key}", "DATA_SOURCE"), primaryKeys.get("^t_nova_.*$")); + Assertions.assertEquals( + Arrays.asList("id_txn_ctrl", "DATA_SOURCE"), primaryKeys.get("^t_tyuen_txn_.*$")); + } + + /** + * Guards the other half of the JSON round trip in {@code processConfig}: ordinary config shapes + * must survive the rebuild unchanged. The key below is unquoted (so HOCON expands it to a + * nested object), {@code dfs.replication} is a quoted literal key inside a nested map, and both + * values are numbers. + */ + @Test + public void testDecryptPreservesOrdinaryConfigShapes() { + Config input = + ConfigFactory.parseString( + "env { job.mode = \"BATCH\" }\n" + + "source { FakeSource { plugin_output = \"fake\", " + + "split.size = 8096, \"dfs.replication\" = 3 } }\n" + + "sink { Jdbc { url = \"jdbc:mysql://localhost:3306/db\" } }"); + + Config decrypted = ConfigShadeUtils.decryptConfig(input); + + Assertions.assertEquals( + input.getConfig("env").root().unwrapped(), + decrypted.getConfig("env").root().unwrapped()); + Assertions.assertEquals( + input.getConfigList("source").get(0).root().unwrapped(), + decrypted.getConfigList("source").get(0).root().unwrapped()); + Assertions.assertEquals( + input.getConfigList("sink").get(0).root().unwrapped(), + decrypted.getConfigList("sink").get(0).root().unwrapped()); + } + public static class ConfigShadeWithProps implements ConfigShade { private String suffix; diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-jdbc-e2e/connector-jdbc-e2e-part-1/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/JdbcMysqlMultipleTablesIT.java b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-jdbc-e2e/connector-jdbc-e2e-part-1/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/JdbcMysqlMultipleTablesIT.java index 88256d8442..89702eeb63 100644 --- a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-jdbc-e2e/connector-jdbc-e2e-part-1/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/JdbcMysqlMultipleTablesIT.java +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-jdbc-e2e/connector-jdbc-e2e-part-1/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/JdbcMysqlMultipleTablesIT.java @@ -80,6 +80,25 @@ public class JdbcMysqlMultipleTablesIT extends TestSuiteBase implements TestReso private static final List<String> SINK_TABLES = TABLES.stream().map(table -> SINK_DATABASE + "." + table).collect(Collectors.toList()); + // Dedicated fixture for the multi_table_config phase. Its key columns are NOT NULL because + // MySQL rejects a column that is explicitly declared NULL and is part of a PRIMARY KEY; the + // shared source.table1/source.table2 fixture used by the other tests is left untouched. + private static final List<String> MULTI_TABLE_CONFIG_TABLES = + Arrays.asList("mtc_table1", "mtc_table2"); + private static final List<String> MULTI_TABLE_CONFIG_SOURCE_TABLES = + MULTI_TABLE_CONFIG_TABLES.stream() + .map(table -> SOURCE_DATABASE + "." + table) + .collect(Collectors.toList()); + private static final int MULTI_TABLE_CONFIG_ROW_COUNT = 3; + private static final String CREATE_MULTI_TABLE_CONFIG_TABLE_SQL = + "CREATE TABLE IF NOT EXISTS %s\n" + + "(\n" + + " `id` bigint(20) NOT NULL,\n" + + " `c_int` int(11) NOT NULL,\n" + + " `c_integer` int(11) NOT NULL,\n" + + " `c_mediumint` mediumint(9) NOT NULL,\n" + + " `c_varchar` varchar(64) DEFAULT NULL\n" + + ");"; private static final String CREATE_TABLE_SQL = "CREATE TABLE IF NOT EXISTS %s\n" + "(\n" @@ -146,6 +165,8 @@ public class JdbcMysqlMultipleTablesIT extends TestSuiteBase implements TestReso createTables(SOURCE_DATABASE, TABLES); createTables(SINK_DATABASE, TABLES); initSourceTablesData(); + createMultiTableConfigSourceTables(); + initMultiTableConfigSourceTablesData(); } @TestTemplate @@ -197,6 +218,43 @@ public class JdbcMysqlMultipleTablesIT extends TestSuiteBase implements TestReso container.executeJob("/jdbc_mysql_source_and_sink_with_multiple_tables.sql"); Assertions.assertEquals( 0, sqlConfEexecResult.getExitCode(), sqlConfEexecResult.getStderr()); + + // The dedicated sink tables are dropped so that the job creates them itself: only then the + // primary keys resolved from multi_table_config.primary_keys end up in the generated + // create-table DDL and can be asserted below. + dropMultiTableConfigSinkTables(); + try { + Container.ExecResult multiTableConfigExecResult = + container.executeJob( + "/jdbc_mysql_source_and_sink_with_multi_table_config.conf"); + Assertions.assertEquals( + 0, + multiTableConfigExecResult.getExitCode(), + multiTableConfigExecResult.getStderr()); + + // The catch-all pattern is declared last on purpose, so first-match-wins gives + // mtc_table1 a composite key and mtc_table2 a single key instead of the catch-all or + // the fallback key. + Assertions.assertAll( + () -> + Assertions.assertIterableEquals( + Arrays.asList("c_int", "c_integer"), + queryPrimaryKeyColumns(SINK_DATABASE, "mtc_table1")), + () -> + Assertions.assertIterableEquals( + Arrays.asList("c_mediumint"), + queryPrimaryKeyColumns(SINK_DATABASE, "mtc_table2")), + () -> + Assertions.assertEquals( + MULTI_TABLE_CONFIG_ROW_COUNT, + queryRowCount(SINK_DATABASE, "mtc_table1")), + () -> + Assertions.assertEquals( + MULTI_TABLE_CONFIG_ROW_COUNT, + queryRowCount(SINK_DATABASE, "mtc_table2"))); + } finally { + dropMultiTableConfigSinkTables(); + } } @TestTemplate @@ -328,6 +386,44 @@ public class JdbcMysqlMultipleTablesIT extends TestSuiteBase implements TestReso } } + private void createMultiTableConfigSourceTables() throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.execute("create database if not exists " + SOURCE_DATABASE); + for (String table : MULTI_TABLE_CONFIG_TABLES) { + statement.execute( + String.format( + CREATE_MULTI_TABLE_CONFIG_TABLE_SQL, + SOURCE_DATABASE + "." + table)); + } + } + } + + private void initMultiTableConfigSourceTablesData() throws SQLException { + String sql = + "INSERT INTO %s (`id`, `c_int`, `c_integer`, `c_mediumint`, `c_varchar`)" + + " VALUES (?, ?, ?, ?, ?)"; + for (String table : MULTI_TABLE_CONFIG_SOURCE_TABLES) { + try (PreparedStatement statement = + connection.prepareStatement(String.format(sql, table))) { + for (int i = 0; i < MULTI_TABLE_CONFIG_ROW_COUNT; i++) { + statement.setLong(1, i + 1); + statement.setInt(2, 100 + i); + statement.setInt(3, 200 + i); + statement.setInt(4, 300 + i); + statement.setString(5, "row_" + i); + statement.addBatch(); + } + statement.executeBatch(); + } + } + } + + private void dropMultiTableConfigSinkTables() throws SQLException { + for (String table : MULTI_TABLE_CONFIG_TABLES) { + dropTable(SINK_DATABASE, table); + } + } + private List<List<Object>> query(String sql) { try (Statement statement = connection.createStatement(); ResultSet resultSet = statement.executeQuery(sql)) { @@ -347,6 +443,24 @@ public class JdbcMysqlMultipleTablesIT extends TestSuiteBase implements TestReso } } + private List<String> queryPrimaryKeyColumns(String database, String table) { + return query( + String.format( + "SELECT COLUMN_NAME FROM information_schema.STATISTICS" + + " WHERE TABLE_SCHEMA = '%s' AND TABLE_NAME = '%s'" + + " AND INDEX_NAME = 'PRIMARY' ORDER BY SEQ_IN_INDEX", + database, table)) + .stream() + .map(row -> String.valueOf(row.get(0))) + .collect(Collectors.toList()); + } + + private int queryRowCount(String database, String table) { + List<List<Object>> result = + query(String.format("SELECT COUNT(*) FROM %s.%s", database, table)); + return Integer.parseInt(String.valueOf(result.get(0).get(0))); + } + private void clearSinkTables() throws SQLException { for (String table : SINK_TABLES) { String sql = "truncate table " + table; diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-jdbc-e2e/connector-jdbc-e2e-part-1/src/test/resources/jdbc_mysql_source_and_sink_with_multi_table_config.conf b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-jdbc-e2e/connector-jdbc-e2e-part-1/src/test/resources/jdbc_mysql_source_and_sink_with_multi_table_config.conf new file mode 100644 index 0000000000..531a800cfc --- /dev/null +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-jdbc-e2e/connector-jdbc-e2e-part-1/src/test/resources/jdbc_mysql_source_and_sink_with_multi_table_config.conf @@ -0,0 +1,79 @@ +# +# 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. +# + +env { + parallelism = 1 + job.mode = "BATCH" +} + +source { + jdbc { + url = "jdbc:mysql://mysql-e2e:3306/seatunnel" + driver = "com.mysql.cj.jdbc.Driver" + connection_check_timeout_sec = 100 + username = "root" + password = "Abc!@#135_seatunnel" + + table_list = [ + { + table_path = "source.mtc_table1" + }, + { + table_path = "source.mtc_table2" + } + ] + where_condition = "where c_int >= 0" + split.size = 8096 + split.even-distribution.factor.upper-bound = 100 + split.even-distribution.factor.lower-bound = 0.05 + split.sample-sharding.threshold = 1000 + split.inverse-sampling.rate = 1000 + } +} + +sink { + jdbc { + url = "jdbc:mysql://mysql-e2e:3306/seatunnel" + driver = "com.mysql.cj.jdbc.Driver" + username = "root" + password = "Abc!@#135_seatunnel" + + database = "sink" + table = "${table_name}" + generate_sink_sql = true + # The E2E test drops the sink tables before this job starts, so the sink creates them here and + # the primary keys resolved from multi_table_config.primary_keys end up in the generated DDL. + schema_save_mode = "CREATE_SCHEMA_WHEN_NOT_EXIST" + create_index = true + + # Fallback for tables that match no pattern in multi_table_config.primary_keys. It must not be + # used by mtc_table1/mtc_table2, otherwise their keys would resolve to `id` instead of the + # mapped columns. + primary_keys = ["id"] + + multi_table_config { + primary_keys { + # The first matching pattern in declaration order wins. The catch-all is declared last on + # purpose: it also matches mtc_table1/mtc_table2 and it sorts first alphabetically, so it + # would win if the declaration order were not preserved. + "^mtc_table2$" = ["c_mediumint"] + "^mtc_table1$" = ["c_int", "c_integer"] + "^mtc_.*$" = ["c_int"] + } + } + } +}
