This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git
The following commit(s) were added to refs/heads/dev by this push:
new 0c20c86ef8 [Feature][Connector-V2][AmazonDynamoDB] Support multi-table
source reads (#12665)
0c20c86ef8 is described below
commit 0c20c86ef8d84ba2b65bbda8784d8ec8e17b7e20
Author: Goutam Adwant <[email protected]>
AuthorDate: Fri Oct 9 03:25:33 2026 +0000
[Feature][Connector-V2][AmazonDynamoDB] Support multi-table source reads
(#12665)
---
docs/en/connectors/source/AmazonDynamoDB.md | 81 ++-
docs/zh/connectors/source/AmazonDynamoDB.md | 74 ++-
.../source/AmazonDynamoDBSource.java | 31 +-
.../source/AmazonDynamoDBSourceFactory.java | 192 +++++++-
.../source/AmazonDynamoDBSourceReader.java | 61 ++-
.../source/AmazonDynamoDBSourceSplit.java | 8 +-
.../AmazonDynamoDBSourceSplitEnumerator.java | 32 +-
...ceSplit.java => AmazonDynamoDBSourceTable.java} | 31 +-
.../source/AmazonDynamoDBMultiTableSourceTest.java | 542 +++++++++++++++++++++
.../source/AmazonDynamoDBSourceReaderTest.java | 149 ++++++
.../connector-amazondynamodb-e2e/pom.xml | 6 +
.../connector/amazondynamodb/AmazondynamodbIT.java | 19 +
.../amazondynamodbIT_multi_table_source.conf | 104 ++++
13 files changed, 1269 insertions(+), 61 deletions(-)
diff --git a/docs/en/connectors/source/AmazonDynamoDB.md
b/docs/en/connectors/source/AmazonDynamoDB.md
index 5b7d2422cf..f9c76e3b57 100644
--- a/docs/en/connectors/source/AmazonDynamoDB.md
+++ b/docs/en/connectors/source/AmazonDynamoDB.md
@@ -12,6 +12,8 @@ The connector is a batch source. DynamoDB does not expose
field types in the sam
This source reads the current table data with scan requests. It does not read
DynamoDB Streams or CDC change events.
+Use `tables_configs` to read several DynamoDB tables with different schemas
through one source.
+
## Supported Engines
> Spark<br/>
@@ -26,6 +28,7 @@ This source reads the current table data with scan requests.
It does not read Dy
- [ ] [column projection](../../introduction/concepts/connector-v2-features.md)
- [x] [parallelism](../../introduction/concepts/connector-v2-features.md)
- [ ] [support user-defined
split](../../introduction/concepts/connector-v2-features.md)
+- [x] [multi-table](../../introduction/concepts/connector-v2-features.md)
## Options
@@ -35,8 +38,9 @@ This source reads the current table data with scan requests.
It does not read Dy
| region | string | yes | - | AWS region of
the DynamoDB service. |
| access_key_id | string | yes | - | AWS access key
ID. |
| secret_access_key | string | yes | - | AWS secret
access key. |
-| table | string | yes | - | DynamoDB table
name to scan. |
-| schema | config | yes | - | SeaTunnel fields
to read from DynamoDB items. |
+| table | string | no | - | Required in
single-table mode; mutually exclusive with `tables_configs`. |
+| schema | config | no | - | Required in
single-table mode; define it per entry in multi-table mode. |
+| tables_configs | list | no | - | Tables and
schemas to read in multi-table mode; see below. |
| scan_item_limit | int | no | 1 | Maximum items
returned by each scan request. |
| parallel_scan_threads | int | no | 2 | Number of
logical segments for parallel scan. |
| common-options | object | no | - | Source plugin
common parameters. |
@@ -61,7 +65,7 @@ The AWS secret access key used to connect to DynamoDB.
### table [string]
-The DynamoDB table name to scan.
+The DynamoDB table name to scan in single-table mode.
### schema [config]
@@ -91,6 +95,26 @@ schema = {
For more schema syntax, see [Schema
Feature](../../introduction/concepts/schema-feature.md).
+### tables_configs [list]
+
+An alternative to the root-level `table` and `schema` for reading several
tables. Each entry requires:
+
+- `table`: the DynamoDB table name to scan.
+- `schema`: the SeaTunnel fields to read from that table. `schema.table` sets
the output table
+ identity; when it is not set, the DynamoDB table name is used. A dot in the
name is read as a
+ `database.table` separator, so set `schema.table` when the DynamoDB table
name contains dots.
+
+An entry can also set `scan_item_limit` and `parallel_scan_threads`. When an
entry does not set
+them, the root-level values (or their defaults) are used. Each table is
scanned with its own
+`parallel_scan_threads` segments, and each row carries the identity of the
table it was read from.
+
+All entries share the root connection options `url`, `region`, `access_key_id`
and
+`secret_access_key`; connection options inside an entry are rejected. A
root-level `table` or
+`schema`, an empty list, an entry without `table` or `schema`, unsupported
entry options and
+duplicate output table identities are rejected before the job starts. Keep the
table identities
+stable when restoring a checkpoint; switching between single-table and
multi-table mode requires a
+fresh job.
+
### scan_item_limit [int]
The maximum number of items returned by each DynamoDB scan request.
@@ -139,6 +163,8 @@ Source plugin common parameters, please refer to [Source
Common Options](../comm
## Task Example
+### Read One Table
+
The following example reads rows from `source_table` and writes them to
`sink_table`.
```hocon
@@ -191,6 +217,55 @@ sink {
}
```
+### Read Multiple Tables
+
+The following example reads `orders` and `customers` with different schemas.
Rows from `customers`
+are routed downstream as `crm.customers`.
+
+```hocon
+env {
+ parallelism = 2
+ job.mode = "BATCH"
+}
+
+source {
+ AmazonDynamoDB {
+ url = "http://127.0.0.1:8000"
+ region = "us-east-1"
+ access_key_id = "dummy-key"
+ secret_access_key = "dummy-secret"
+ parallel_scan_threads = 2
+ tables_configs = [
+ {
+ table = "orders"
+ parallel_scan_threads = 4
+ schema = {
+ fields {
+ id = string
+ amount = int
+ }
+ }
+ },
+ {
+ table = "customers"
+ schema = {
+ table = "crm.customers"
+ fields {
+ id = string
+ name = string
+ vip = boolean
+ }
+ }
+ }
+ ]
+ }
+}
+
+sink {
+ Console {}
+}
+```
+
## Changelog
<ChangeLog />
diff --git a/docs/zh/connectors/source/AmazonDynamoDB.md
b/docs/zh/connectors/source/AmazonDynamoDB.md
index 44839930bb..593444dd79 100644
--- a/docs/zh/connectors/source/AmazonDynamoDB.md
+++ b/docs/zh/connectors/source/AmazonDynamoDB.md
@@ -12,6 +12,8 @@ Amazon DynamoDB 源连接器通过 DynamoDB scan 请求读取已有表中的数
该源连接器使用 scan 请求读取表中当前已有的数据,不读取 DynamoDB Streams 或 CDC 变更事件。
+使用 `tables_configs` 可以在一个 source 中读取多张 schema 不同的 DynamoDB 表。
+
## 支持的引擎
> Spark<br/>
@@ -26,6 +28,7 @@ Amazon DynamoDB 源连接器通过 DynamoDB scan 请求读取已有表中的数
- [ ] [列投影](../../introduction/concepts/connector-v2-features.md)
- [x] [并行度](../../introduction/concepts/connector-v2-features.md)
- [ ] [支持用户自定义分片](../../introduction/concepts/connector-v2-features.md)
+- [x] [多表读取](../../introduction/concepts/connector-v2-features.md)
## 选项
@@ -35,8 +38,9 @@ Amazon DynamoDB 源连接器通过 DynamoDB scan 请求读取已有表中的数
| region | string | 是 | - | DynamoDB 所在的 AWS 区域。 |
| access_key_id | string | 是 | - | AWS access key ID。 |
| secret_access_key | string | 是 | - | AWS secret access key。 |
-| table | string | 是 | - | 要扫描的 DynamoDB 表名。 |
-| schema | config | 是 | - | 要读取的 SeaTunnel 字段。 |
+| table | string | 否 | - | 单表模式必填,与 `tables_configs`
互斥。 |
+| schema | config | 否 | - | 单表模式必填;多表模式需要在每个条目中配置。 |
+| tables_configs | list | 否 | - | 多表模式要读取的表及 schema,详见下文。 |
| scan_item_limit | int | 否 | 1 | 每次 scan 请求返回的最大 item 数。 |
| parallel_scan_threads | int | 否 | 2 | parallel scan 的逻辑分片数。 |
| common-options | object | 否 | - | 源插件通用参数。 |
@@ -61,7 +65,7 @@ DynamoDB 所在的 AWS 区域,例如 `us-east-1`。
### table [string]
-要扫描的 DynamoDB 表名。
+单表模式下要扫描的 DynamoDB 表名。
### schema [config]
@@ -91,6 +95,20 @@ schema = {
更多 schema 写法请参考 [Schema 特性](../../introduction/concepts/schema-feature.md)。
+### tables_configs [list]
+
+用于替代根级别的 `table` 和 `schema`,读取多张表。每个条目需要配置:
+
+- `table`:要扫描的 DynamoDB 表名。
+- `schema`:从该表读取的 SeaTunnel 字段。`schema.table` 用于设置输出表标识;未配置时使用 DynamoDB
表名。表名中的点会被当作 `database.table` 的分隔符,因此当 DynamoDB 表名包含点时请设置 `schema.table`。
+
+每个条目还可以配置 `scan_item_limit` 和 `parallel_scan_threads`。条目中未配置时,使用根级别的值(或其默认值)。
+每张表按自身的 `parallel_scan_threads` 拆分 scan 分片,每行数据都会带上其来源表的标识。
+
+所有条目共享根级别的连接选项 `url`、`region`、`access_key_id` 和
`secret_access_key`,条目内的连接选项会被拒绝。
+根级别的 `table` 或 `schema`、空列表、缺少 `table` 或 `schema`
的条目、不支持的条目选项以及重复的输出表标识,都会在作业启动前报错。
+恢复 checkpoint 时请保持表标识不变;在单表模式和多表模式之间切换需要启动新作业。
+
### scan_item_limit [int]
每次 DynamoDB scan 请求最多返回的 item 数量。
@@ -139,6 +157,8 @@ DynamoDB parallel scan 使用的逻辑分片数量。
## 任务示例
+### 读取单张表
+
下面的示例从 `source_table` 读取数据,并写入 `sink_table`。
```hocon
@@ -191,6 +211,54 @@ sink {
}
```
+### 读取多张表
+
+下面的示例读取 schema 不同的 `orders` 和 `customers` 两张表。`customers` 的数据会以
`crm.customers` 作为表标识发送到下游。
+
+```hocon
+env {
+ parallelism = 2
+ job.mode = "BATCH"
+}
+
+source {
+ AmazonDynamoDB {
+ url = "http://127.0.0.1:8000"
+ region = "us-east-1"
+ access_key_id = "dummy-key"
+ secret_access_key = "dummy-secret"
+ parallel_scan_threads = 2
+ tables_configs = [
+ {
+ table = "orders"
+ parallel_scan_threads = 4
+ schema = {
+ fields {
+ id = string
+ amount = int
+ }
+ }
+ },
+ {
+ table = "customers"
+ schema = {
+ table = "crm.customers"
+ fields {
+ id = string
+ name = string
+ vip = boolean
+ }
+ }
+ }
+ ]
+ }
+}
+
+sink {
+ Console {}
+}
+```
+
## 变更日志
<ChangeLog />
diff --git
a/seatunnel-connectors-v2/connector-amazondynamodb/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSource.java
b/seatunnel-connectors-v2/connector-amazondynamodb/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSource.java
index 12f7f8435d..e6ff3791c8 100644
---
a/seatunnel-connectors-v2/connector-amazondynamodb/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSource.java
+++
b/seatunnel-connectors-v2/connector-amazondynamodb/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSource.java
@@ -29,6 +29,7 @@ import
org.apache.seatunnel.connectors.seatunnel.amazondynamodb.config.AmazonDyn
import lombok.extern.slf4j.Slf4j;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -39,13 +40,27 @@ public class AmazonDynamoDBSource
SupportParallelism,
SupportColumnProjection {
- private AmazonDynamoDBConfig amazondynamodbConfig;
- private CatalogTable catalogTable;
+ private final AmazonDynamoDBConfig amazondynamodbConfig;
+ private final List<CatalogTable> catalogTables;
+ private final List<AmazonDynamoDBSourceTable> tables;
public AmazonDynamoDBSource(
AmazonDynamoDBConfig amazondynamodbConfig, CatalogTable
catalogTable) {
+ this(
+ amazondynamodbConfig,
+ Collections.singletonList(catalogTable),
+ Collections.singletonList(
+ new AmazonDynamoDBSourceTable(
+ null, amazondynamodbConfig,
catalogTable.getSeaTunnelRowType())));
+ }
+
+ AmazonDynamoDBSource(
+ AmazonDynamoDBConfig amazondynamodbConfig,
+ List<CatalogTable> catalogTables,
+ List<AmazonDynamoDBSourceTable> tables) {
this.amazondynamodbConfig = amazondynamodbConfig;
- this.catalogTable = catalogTable;
+ this.catalogTables = Collections.unmodifiableList(new
ArrayList<>(catalogTables));
+ this.tables = Collections.unmodifiableList(new ArrayList<>(tables));
}
@Override
@@ -60,7 +75,7 @@ public class AmazonDynamoDBSource
@Override
public List<CatalogTable> getProducedCatalogTables() {
- return Collections.singletonList(catalogTable);
+ return new ArrayList<>(catalogTables);
}
@Override
@@ -68,7 +83,7 @@ public class AmazonDynamoDBSource
createEnumerator(
SourceSplitEnumerator.Context<AmazonDynamoDBSourceSplit>
enumeratorContext)
throws Exception {
- return new AmazonDynamoDBSourceSplitEnumerator(enumeratorContext,
amazondynamodbConfig);
+ return new AmazonDynamoDBSourceSplitEnumerator(enumeratorContext,
null, tables);
}
@Override
@@ -77,14 +92,12 @@ public class AmazonDynamoDBSource
SourceSplitEnumerator.Context<AmazonDynamoDBSourceSplit>
enumeratorContext,
AmazonDynamoDBSourceState checkpointState)
throws Exception {
- return new AmazonDynamoDBSourceSplitEnumerator(
- enumeratorContext, amazondynamodbConfig, checkpointState);
+ return new AmazonDynamoDBSourceSplitEnumerator(enumeratorContext,
checkpointState, tables);
}
@Override
public SourceReader<SeaTunnelRow, AmazonDynamoDBSourceSplit> createReader(
SourceReader.Context readerContext) throws Exception {
- return new AmazonDynamoDBSourceReader(
- readerContext, amazondynamodbConfig,
catalogTable.getSeaTunnelRowType());
+ return new AmazonDynamoDBSourceReader(readerContext,
amazondynamodbConfig, tables);
}
}
diff --git
a/seatunnel-connectors-v2/connector-amazondynamodb/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSourceFactory.java
b/seatunnel-connectors-v2/connector-amazondynamodb/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSourceFactory.java
index e0ad7db15d..bffdb06970 100644
---
a/seatunnel-connectors-v2/connector-amazondynamodb/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSourceFactory.java
+++
b/seatunnel-connectors-v2/connector-amazondynamodb/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSourceFactory.java
@@ -17,9 +17,18 @@
package org.apache.seatunnel.connectors.seatunnel.amazondynamodb.source;
+import org.apache.seatunnel.shade.com.typesafe.config.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.Conditions;
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.source.SeaTunnelSource;
import org.apache.seatunnel.api.source.SourceSplit;
+import org.apache.seatunnel.api.table.catalog.CatalogTable;
import org.apache.seatunnel.api.table.catalog.CatalogTableUtil;
import org.apache.seatunnel.api.table.connector.TableSource;
import org.apache.seatunnel.api.table.factory.Factory;
@@ -30,8 +39,16 @@ import
org.apache.seatunnel.connectors.seatunnel.amazondynamodb.config.AmazonDyn
import com.google.auto.service.AutoService;
import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
import static org.apache.seatunnel.api.options.ConnectorCommonOptions.SCHEMA;
+import static
org.apache.seatunnel.api.options.ConnectorCommonOptions.TABLE_CONFIGS;
import static
org.apache.seatunnel.connectors.seatunnel.amazondynamodb.config.AmazonDynamoDBSourceOptions.ACCESS_KEY_ID;
import static
org.apache.seatunnel.connectors.seatunnel.amazondynamodb.config.AmazonDynamoDBSourceOptions.PARALLEL_SCAN_THREADS;
import static
org.apache.seatunnel.connectors.seatunnel.amazondynamodb.config.AmazonDynamoDBSourceOptions.REGION;
@@ -42,6 +59,18 @@ import static
org.apache.seatunnel.connectors.seatunnel.amazondynamodb.config.Am
@AutoService(Factory.class)
public class AmazonDynamoDBSourceFactory implements TableSourceFactory {
+
+ private static final List<Option<?>> CONNECTION_OPTIONS =
+ Arrays.asList(URL, REGION, ACCESS_KEY_ID, SECRET_ACCESS_KEY);
+
+ private static final Set<String> TABLE_OPTION_KEYS =
+ new HashSet<>(
+ Arrays.asList(
+ TABLE.key(),
+ SCHEMA.key(),
+ SCAN_ITEM_LIMIT.key(),
+ PARALLEL_SCAN_THREADS.key()));
+
@Override
public String factoryIdentifier() {
return "AmazonDynamoDB";
@@ -50,23 +79,172 @@ public class AmazonDynamoDBSourceFactory implements
TableSourceFactory {
@Override
public OptionRule optionRule() {
return OptionRule.builder()
- .required(URL, REGION, ACCESS_KEY_ID, SECRET_ACCESS_KEY,
TABLE, SCHEMA)
- .optional(SCAN_ITEM_LIMIT, PARALLEL_SCAN_THREADS)
+ .required(URL, REGION, ACCESS_KEY_ID, SECRET_ACCESS_KEY)
+ .exclusive(TABLE, TABLE_CONFIGS)
+ .optional(TABLE, Conditions.extension(TABLE, new
SingleTableValidator()))
+ .optional(
+ TABLE_CONFIGS,
+ Conditions.notEmpty(TABLE_CONFIGS),
+ Conditions.extension(TABLE_CONFIGS, new
TableConfigsValidator()))
+ .optional(SCHEMA, SCAN_ITEM_LIMIT, PARALLEL_SCAN_THREADS)
.build();
}
@Override
public <T, SplitT extends SourceSplit, StateT extends Serializable>
TableSource<T, SplitT, StateT>
createSource(TableSourceFactoryContext context) {
- return () ->
- (SeaTunnelSource<T, SplitT, StateT>)
- new AmazonDynamoDBSource(
- new AmazonDynamoDBConfig(context.getOptions()),
-
CatalogTableUtil.buildWithConfig(context.getOptions()));
+ return () -> {
+ ReadonlyConfig config = context.getOptions();
+ if (config.getOptional(TABLE_CONFIGS).isPresent()) {
+ return (SeaTunnelSource<T, SplitT, StateT>)
createMultiTableSource(config);
+ }
+ return (SeaTunnelSource<T, SplitT, StateT>)
+ new AmazonDynamoDBSource(
+ new AmazonDynamoDBConfig(config),
+ CatalogTableUtil.buildWithConfig(config));
+ };
}
@Override
public Class<? extends SeaTunnelSource> getSourceClass() {
return AmazonDynamoDBSource.class;
}
+
+ /**
+ * Builds one table per tables_configs entry. Connection options come from
the source level;
+ * scan_item_limit and parallel_scan_threads fall back to the source level
when an entry does
+ * not set them. The table identity is schema.table, or the DynamoDB table
name when the schema
+ * does not name the table.
+ */
+ static AmazonDynamoDBSource createMultiTableSource(ReadonlyConfig config) {
+ if (config.getOptional(SCHEMA).isPresent()) {
+ throw new OptionValidationException(
+ "root-level 'schema' cannot be used with
'tables_configs'");
+ }
+ List<Map<String, Object>> entries = config.get(TABLE_CONFIGS);
+ if (entries.isEmpty()) {
+ throw new OptionValidationException("'tables_configs' must not be
empty");
+ }
+ Config sharedConfig =
config.toConfig().withoutPath(TABLE_CONFIGS.key());
+ List<CatalogTable> catalogTables = new ArrayList<>(entries.size());
+ List<AmazonDynamoDBSourceTable> tables = new
ArrayList<>(entries.size());
+ Set<String> tableIds = new HashSet<>();
+ for (int i = 0; i < entries.size(); i++) {
+ try {
+ Map<String, Object> entry = entries.get(i);
+ for (Option<?> option : CONNECTION_OPTIONS) {
+ if (entry.containsKey(option.key())) {
+ throw new OptionValidationException(
+ "tables_configs[%d]: '%s' must be configured
at source level",
+ i, option.key());
+ }
+ }
+ for (String key : entry.keySet()) {
+ if (!TABLE_OPTION_KEYS.contains(key)) {
+ throw new OptionValidationException(
+ "tables_configs[%d]: unsupported table option
'%s'", i, key);
+ }
+ }
+ Object tableValue = entry.get(TABLE.key());
+ // An unquoted all-digit table name is parsed as a number.
+ String tableName =
+ tableValue instanceof String || tableValue instanceof
Number
+ ? tableValue.toString()
+ : null;
+ if (tableName == null || tableName.trim().isEmpty()) {
+ throw new OptionValidationException(
+ "tables_configs[%d]: 'table' must be configured
and non-blank", i);
+ }
+ Object schemaValue = entry.get(SCHEMA.key());
+ if (!(schemaValue instanceof Map) || ((Map<?, ?>)
schemaValue).isEmpty()) {
+ throw new OptionValidationException(
+ "tables_configs[%d]: 'schema' must be configured
and non-empty", i);
+ }
+ Map<String, Object> schema = new LinkedHashMap<>((Map<String,
Object>) schemaValue);
+ Object schemaTable =
schema.get(ConnectorCommonOptions.TABLE.key());
+ if (schemaTable == null
+ || (schemaTable instanceof String
+ && ((String) schemaTable).trim().isEmpty())) {
+ schema.put(ConnectorCommonOptions.TABLE.key(), tableName);
+ }
+ Map<String, Object> tableEntry = new LinkedHashMap<>(entry);
+ tableEntry.put(TABLE.key(), tableName);
+ tableEntry.put(SCHEMA.key(), schema);
+ ReadonlyConfig tableConfig =
+ ReadonlyConfig.fromConfig(
+ ReadonlyConfig.fromMap(tableEntry)
+ .toConfig()
+ .withFallback(sharedConfig));
+
+ CatalogTable catalogTable;
+ try {
+ catalogTable =
CatalogTableUtil.buildWithConfig(tableConfig);
+ } catch (RuntimeException e) {
+ throw new OptionValidationException(
+ "tables_configs[%d]: invalid 'schema' (set
'schema.table' when the table name is not a valid table path): %s",
+ i, e.getMessage());
+ }
+ AmazonDynamoDBConfig tableReadConfig = new
AmazonDynamoDBConfig(tableConfig);
+ if (tableReadConfig.getScanItemLimit() <= 0
+ || tableReadConfig.getParallelScanThreads() <= 0) {
+ throw new OptionValidationException(
+ "tables_configs[%d]: '%s' and '%s' must be
positive",
+ i, SCAN_ITEM_LIMIT.key(),
PARALLEL_SCAN_THREADS.key());
+ }
+ String tableId =
catalogTable.getTableId().toTablePath().toString();
+ if (!tableIds.add(tableId)) {
+ throw new OptionValidationException(
+ "tables_configs[%d]: duplicate table identity
'%s'", i, tableId);
+ }
+ catalogTables.add(catalogTable);
+ tables.add(
+ new AmazonDynamoDBSourceTable(
+ tableId, tableReadConfig,
catalogTable.getSeaTunnelRowType()));
+ } catch (OptionValidationException e) {
+ throw e;
+ } catch (RuntimeException e) {
+ throw new OptionValidationException(
+ "tables_configs[%d]: invalid table entry: %s", i,
e.getMessage());
+ }
+ }
+ return new AmazonDynamoDBSource(new AmazonDynamoDBConfig(config),
catalogTables, tables);
+ }
+
+ static class SingleTableValidator implements ConditionExtension<String> {
+
+ @Override
+ public String description() {
+ return "'schema' must be configured when using a root-level
'table'";
+ }
+
+ @Override
+ public boolean evaluate(ReadonlyConfig config, String table)
+ throws OptionValidationException {
+ Map<String, Object> schema =
config.getOptional(SCHEMA).orElse(null);
+ if (schema == null || schema.isEmpty()) {
+ throw new OptionValidationException(
+ "'schema' must be configured when using a root-level
'table'");
+ }
+ return true;
+ }
+ }
+
+ static class TableConfigsValidator implements
ConditionExtension<List<Map<String, Object>>> {
+
+ @Override
+ public String description() {
+ return "each 'tables_configs' entry must contain a non-blank
'table' and a 'schema' with a unique table identity";
+ }
+
+ @Override
+ public boolean evaluate(ReadonlyConfig config, List<Map<String,
Object>> entries)
+ throws OptionValidationException {
+ if (entries == null || entries.isEmpty()) {
+ // reported by Conditions.notEmpty
+ return true;
+ }
+ createMultiTableSource(config);
+ return true;
+ }
+ }
}
diff --git
a/seatunnel-connectors-v2/connector-amazondynamodb/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSourceReader.java
b/seatunnel-connectors-v2/connector-amazondynamodb/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSourceReader.java
index a9e72d9977..ec886f3fbd 100644
---
a/seatunnel-connectors-v2/connector-amazondynamodb/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSourceReader.java
+++
b/seatunnel-connectors-v2/connector-amazondynamodb/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSourceReader.java
@@ -17,11 +17,13 @@
package org.apache.seatunnel.connectors.seatunnel.amazondynamodb.source;
+import org.apache.seatunnel.api.common.SeaTunnelAPIErrorCode;
import org.apache.seatunnel.api.source.Collector;
import org.apache.seatunnel.api.source.SourceReader;
import org.apache.seatunnel.api.table.type.SeaTunnelRow;
import org.apache.seatunnel.api.table.type.SeaTunnelRowType;
import
org.apache.seatunnel.connectors.seatunnel.amazondynamodb.config.AmazonDynamoDBConfig;
+import
org.apache.seatunnel.connectors.seatunnel.amazondynamodb.exception.AmazonDynamoDBConnectorException;
import
org.apache.seatunnel.connectors.seatunnel.amazondynamodb.serialize.DefaultSeaTunnelRowDeserializer;
import
org.apache.seatunnel.connectors.seatunnel.amazondynamodb.serialize.SeaTunnelRowDeserializer;
@@ -35,7 +37,10 @@ import
software.amazon.awssdk.services.dynamodb.paginators.ScanIterable;
import java.net.URI;
import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedDeque;
@@ -47,7 +52,8 @@ public class AmazonDynamoDBSourceReader
protected DynamoDbClient dynamoDbClient;
protected SourceReader.Context context;
protected AmazonDynamoDBConfig amazondynamodbConfig;
- protected SeaTunnelRowDeserializer seaTunnelRowDeserializer;
+ private final Map<String, AmazonDynamoDBSourceTable> tables = new
HashMap<>();
+ private final Map<String, SeaTunnelRowDeserializer> deserializers = new
HashMap<>();
Queue<AmazonDynamoDBSourceSplit> pendingSplits = new
ConcurrentLinkedDeque<>();
private volatile boolean noMoreSplit;
@@ -56,9 +62,26 @@ public class AmazonDynamoDBSourceReader
SourceReader.Context context,
AmazonDynamoDBConfig amazondynamodbConfig,
SeaTunnelRowType typeInfo) {
+ this(
+ context,
+ amazondynamodbConfig,
+ Collections.singletonList(
+ new AmazonDynamoDBSourceTable(null,
amazondynamodbConfig, typeInfo)));
+ }
+
+ AmazonDynamoDBSourceReader(
+ SourceReader.Context context,
+ AmazonDynamoDBConfig amazondynamodbConfig,
+ List<AmazonDynamoDBSourceTable> tables) {
this.context = context;
this.amazondynamodbConfig = amazondynamodbConfig;
- this.seaTunnelRowDeserializer = new
DefaultSeaTunnelRowDeserializer(typeInfo);
+ // A single-table source is keyed by null, which also matches splits
restored from
+ // checkpoints taken before splits carried a table identity.
+ for (AmazonDynamoDBSourceTable table : tables) {
+ this.tables.put(table.getTableId(), table);
+ this.deserializers.put(
+ table.getTableId(), new
DefaultSeaTunnelRowDeserializer(table.getRowType()));
+ }
}
@Override
@@ -121,24 +144,34 @@ public class AmazonDynamoDBSourceReader
}
private void read(AmazonDynamoDBSourceSplit split, Collector<SeaTunnelRow>
output) {
- ScanIterable scan;
+ String tableId = split.getTableId();
+ AmazonDynamoDBSourceTable table = tables.get(tableId);
+ if (table == null) {
+ throw new AmazonDynamoDBConnectorException(
+ SeaTunnelAPIErrorCode.CONFIG_VALIDATION_FAILED,
+ tableId == null
+ ? "Cannot restore a single-table AmazonDynamoDB
split with a tables_configs configuration"
+ : "Unknown table identity in AmazonDynamoDB split:
" + tableId);
+ }
+ SeaTunnelRowDeserializer deserializer = deserializers.get(tableId);
ScanRequest scanRequest =
ScanRequest.builder()
- .tableName(amazondynamodbConfig.getTable())
+ .tableName(table.getConfig().getTable())
.limit(split.getItemCount())
.segment(split.getSplitId())
.totalSegments(split.getTotalSegments())
.build();
- scan = dynamoDbClient.scanPaginator(scanRequest);
- do {
-
- scan.items()
- .forEach(
- item -> {
-
output.collect(seaTunnelRowDeserializer.deserialize(item));
- });
-
- } while (scan.iterator().hasNext() && !noMoreSplit);
+ // The paginator already follows every page of the segment, so iterate
it only once.
+ ScanIterable scan = dynamoDbClient.scanPaginator(scanRequest);
+ scan.items()
+ .forEach(
+ item -> {
+ SeaTunnelRow row = deserializer.deserialize(item);
+ if (tableId != null) {
+ row.setTableId(tableId);
+ }
+ output.collect(row);
+ });
}
@Override
diff --git
a/seatunnel-connectors-v2/connector-amazondynamodb/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSourceSplit.java
b/seatunnel-connectors-v2/connector-amazondynamodb/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSourceSplit.java
index 825cc3666d..7e1fe36105 100644
---
a/seatunnel-connectors-v2/connector-amazondynamodb/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSourceSplit.java
+++
b/seatunnel-connectors-v2/connector-amazondynamodb/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSourceSplit.java
@@ -32,9 +32,15 @@ public class AmazonDynamoDBSourceSplit implements
SourceSplit {
private Integer splitId;
private Integer totalSegments;
private Integer itemCount;
+ /** Table identity for tables_configs; null for single-table splits and
older checkpoints. */
+ private String tableId;
+
+ public AmazonDynamoDBSourceSplit(Integer splitId, Integer totalSegments,
Integer itemCount) {
+ this(splitId, totalSegments, itemCount, null);
+ }
@Override
public String splitId() {
- return splitId.toString();
+ return tableId == null ? splitId.toString() : tableId + ":" + splitId;
}
}
diff --git
a/seatunnel-connectors-v2/connector-amazondynamodb/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSourceSplitEnumerator.java
b/seatunnel-connectors-v2/connector-amazondynamodb/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSourceSplitEnumerator.java
index 7ba9f55f1c..29559fd35f 100644
---
a/seatunnel-connectors-v2/connector-amazondynamodb/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSourceSplitEnumerator.java
+++
b/seatunnel-connectors-v2/connector-amazondynamodb/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSourceSplitEnumerator.java
@@ -43,7 +43,7 @@ public class AmazonDynamoDBSourceSplitEnumerator
private final SourceSplitEnumerator.Context<AmazonDynamoDBSourceSplit>
enumeratorContext;
private final Map<Integer, List<AmazonDynamoDBSourceSplit>> pendingSplits;
- private final AmazonDynamoDBConfig amazonDynamoDBConfig;
+ private final List<AmazonDynamoDBSourceTable> tables;
private final AtomicInteger assignCount = new AtomicInteger(0);
private final Object stateLock = new Object();
@@ -59,8 +59,19 @@ public class AmazonDynamoDBSourceSplitEnumerator
Context<AmazonDynamoDBSourceSplit> enumeratorContext,
AmazonDynamoDBConfig amazonDynamoDBConfig,
AmazonDynamoDBSourceState sourceState) {
+ this(
+ enumeratorContext,
+ sourceState,
+ Collections.singletonList(
+ new AmazonDynamoDBSourceTable(null,
amazonDynamoDBConfig, null)));
+ }
+
+ AmazonDynamoDBSourceSplitEnumerator(
+ Context<AmazonDynamoDBSourceSplit> enumeratorContext,
+ AmazonDynamoDBSourceState sourceState,
+ List<AmazonDynamoDBSourceTable> tables) {
this.enumeratorContext = enumeratorContext;
- this.amazonDynamoDBConfig = amazonDynamoDBConfig;
+ this.tables = tables;
this.pendingSplits = new HashMap<>();
this.shouldEnumerate = sourceState == null;
if (sourceState != null) {
@@ -133,13 +144,16 @@ public class AmazonDynamoDBSourceSplitEnumerator
private Set<AmazonDynamoDBSourceSplit> discoverySplits() {
Set<AmazonDynamoDBSourceSplit> allSplit = new HashSet<>();
- int totalSegments = amazonDynamoDBConfig.parallelScanThreads;
- int itemLimit = amazonDynamoDBConfig.scanItemLimit;
- for (int i = 0; i < totalSegments; i++) {
- AmazonDynamoDBSourceSplit split =
- new AmazonDynamoDBSourceSplit(i, totalSegments, itemLimit);
-
- allSplit.add(split);
+ for (AmazonDynamoDBSourceTable table : tables) {
+ int totalSegments = table.getConfig().parallelScanThreads;
+ int itemLimit = table.getConfig().scanItemLimit;
+ for (int i = 0; i < totalSegments; i++) {
+ AmazonDynamoDBSourceSplit split =
+ new AmazonDynamoDBSourceSplit(
+ i, totalSegments, itemLimit,
table.getTableId());
+
+ allSplit.add(split);
+ }
}
return allSplit;
}
diff --git
a/seatunnel-connectors-v2/connector-amazondynamodb/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSourceSplit.java
b/seatunnel-connectors-v2/connector-amazondynamodb/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSourceTable.java
similarity index 61%
copy from
seatunnel-connectors-v2/connector-amazondynamodb/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSourceSplit.java
copy to
seatunnel-connectors-v2/connector-amazondynamodb/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSourceTable.java
index 825cc3666d..af50b95691 100644
---
a/seatunnel-connectors-v2/connector-amazondynamodb/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSourceSplit.java
+++
b/seatunnel-connectors-v2/connector-amazondynamodb/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSourceTable.java
@@ -17,24 +17,25 @@
package org.apache.seatunnel.connectors.seatunnel.amazondynamodb.source;
-import org.apache.seatunnel.api.source.SourceSplit;
+import org.apache.seatunnel.api.table.type.SeaTunnelRowType;
+import
org.apache.seatunnel.connectors.seatunnel.amazondynamodb.config.AmazonDynamoDBConfig;
import lombok.AllArgsConstructor;
import lombok.Getter;
-import lombok.Setter;
-@AllArgsConstructor
+import java.io.Serializable;
+
+/** A DynamoDB table read by the source, with the read options and row type
used for it. */
@Getter
-@Setter
-public class AmazonDynamoDBSourceSplit implements SourceSplit {
-
- private static final long serialVersionUID = -5148142613656330674L;
- private Integer splitId;
- private Integer totalSegments;
- private Integer itemCount;
-
- @Override
- public String splitId() {
- return splitId.toString();
- }
+@AllArgsConstructor
+class AmazonDynamoDBSourceTable implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ /** Identity carried by splits and rows; null for a single-table source. */
+ private final String tableId;
+
+ private final AmazonDynamoDBConfig config;
+
+ private final SeaTunnelRowType rowType;
}
diff --git
a/seatunnel-connectors-v2/connector-amazondynamodb/src/test/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBMultiTableSourceTest.java
b/seatunnel-connectors-v2/connector-amazondynamodb/src/test/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBMultiTableSourceTest.java
new file mode 100644
index 0000000000..30cbaf2d01
--- /dev/null
+++
b/seatunnel-connectors-v2/connector-amazondynamodb/src/test/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBMultiTableSourceTest.java
@@ -0,0 +1,542 @@
+/*
+ * 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.amazondynamodb.source;
+
+import org.apache.seatunnel.shade.com.typesafe.config.ConfigFactory;
+
+import org.apache.seatunnel.api.common.metrics.AbstractMetricsContext;
+import org.apache.seatunnel.api.common.metrics.MetricsContext;
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.configuration.util.ConfigValidator;
+import org.apache.seatunnel.api.configuration.util.OptionValidationException;
+import org.apache.seatunnel.api.event.EventListener;
+import org.apache.seatunnel.api.source.Boundedness;
+import org.apache.seatunnel.api.source.Collector;
+import org.apache.seatunnel.api.source.SourceEvent;
+import org.apache.seatunnel.api.source.SourceReader;
+import org.apache.seatunnel.api.source.SourceSplitEnumerator;
+import org.apache.seatunnel.api.table.catalog.CatalogTable;
+import org.apache.seatunnel.api.table.factory.TableSourceFactoryContext;
+import org.apache.seatunnel.api.table.type.SeaTunnelRow;
+import org.apache.seatunnel.common.exception.SeaTunnelRuntimeException;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+
+import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
+import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
+import software.amazon.awssdk.services.dynamodb.model.ScanRequest;
+import software.amazon.awssdk.services.dynamodb.model.ScanResponse;
+import software.amazon.awssdk.services.dynamodb.paginators.ScanIterable;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+class AmazonDynamoDBMultiTableSourceTest {
+
+ private static final String CONNECTION =
+ "url = \"http://localhost:8000\"\n"
+ + "region = \"us-east-1\"\n"
+ + "access_key_id = \"dummy-key\"\n"
+ + "secret_access_key = \"dummy-secret\"\n";
+
+ private static final String ORDERS =
+ "{ table = orders, parallel_scan_threads = 2, scan_item_limit =
10,"
+ + " schema { fields { id = string, amount = int } } }";
+
+ private static final String CUSTOMERS =
+ "{ table = customers,"
+ + " schema { table = \"crm.customer_view\", fields { id =
string, name = string, vip = boolean } } }";
+
+ private static final String SINGLE_TABLE =
+ "table = orders\nschema { fields { id = string, amount = int }
}\n";
+
+ /**
+ * {@link AmazonDynamoDBSourceState} serialized by the single-table source
before splits carried
+ * a table identity: segments 0 and 1 of 2 with a scan limit of 5, pending
for readers 0 and 1.
+ */
+ private static final String LEGACY_STATE =
+
"rO0ABXNyAFlvcmcuYXBhY2hlLnNlYXR1bm5lbC5jb25uZWN0b3JzLnNlYXR1bm5lbC5hbWF6b25keW5h"
+ +
"bW9kYi5zb3VyY2UuQW1hem9uRHluYW1vREJTb3VyY2VTdGF0ZYhyTqknexWFAgADSQALYXNzaWduQ291"
+ +
"bnRaAA9zaG91bGRFbnVtZXJhdGVMAA1wZW5kaW5nU3BsaXRzdAAPTGphdmEvdXRpbC9NYXA7eHAAAAAC"
+ +
"AHNyABFqYXZhLnV0aWwuSGFzaE1hcAUH2sHDFmDRAwACRgAKbG9hZEZhY3RvckkACXRocmVzaG9sZHhw"
+ +
"P0AAAAAAAAx3CAAAABAAAAACc3IAEWphdmEubGFuZy5JbnRlZ2VyEuKgpPeBhzgCAAFJAAV2YWx1ZXhy"
+ +
"ABBqYXZhLmxhbmcuTnVtYmVyhqyVHQuU4IsCAAB4cAAAAABzcgATamF2YS51dGlsLkFycmF5TGlzdHiB"
+ +
"0h2Zx2GdAwABSQAEc2l6ZXhwAAAAAXcEAAAAAXNyAFlvcmcuYXBhY2hlLnNlYXR1bm5lbC5jb25uZWN0"
+ +
"b3JzLnNlYXR1bm5lbC5hbWF6b25keW5hbW9kYi5zb3VyY2UuQW1hem9uRHluYW1vREJTb3VyY2VTcGxp"
+ +
"dLiOH5Gj9p5OAgADTAAJaXRlbUNvdW50dAATTGphdmEvbGFuZy9JbnRlZ2VyO0wAB3NwbGl0SWRxAH4A"
+ +
"C0wADXRvdGFsU2VnbWVudHNxAH4AC3hwc3EAfgAFAAAABXEAfgAHc3EAfgAFAAAAAnhzcQB+AAUAAAAB"
+ + "c3EAfgAIAAAAAXcEAAAAAXNxAH4ACnEAfgANcQB+AA9xAH4ADnh4";
+
+ @Test
+ void singleTableConfigKeepsOneTableAndLegacySplits() throws Exception {
+ AmazonDynamoDBSource source = source(SINGLE_TABLE +
"parallel_scan_threads = 3\n");
+
+ Assertions.assertEquals(1, source.getProducedCatalogTables().size());
+ List<AmazonDynamoDBSourceSplit> splits = enumerate(source);
+ Assertions.assertEquals(3, splits.size());
+ for (AmazonDynamoDBSourceSplit split : splits) {
+ Assertions.assertNull(split.getTableId());
+ Assertions.assertEquals(split.getSplitId().toString(),
split.splitId());
+ Assertions.assertEquals(3, split.getTotalSegments());
+ Assertions.assertEquals(1, split.getItemCount());
+ }
+ }
+
+ @Test
+ void producesOneCatalogTablePerConfiguredTable() {
+ AmazonDynamoDBSource source = source("tables_configs = [" + ORDERS +
"," + CUSTOMERS + "]");
+
+ List<CatalogTable> tables = source.getProducedCatalogTables();
+ Assertions.assertEquals(
+ Arrays.asList("orders", "crm.customer_view"),
+ tables.stream()
+ .map(table ->
table.getTableId().toTablePath().toString())
+ .collect(Collectors.toList()));
+ Assertions.assertArrayEquals(
+ new String[] {"id", "amount"},
tables.get(0).getSeaTunnelRowType().getFieldNames());
+ Assertions.assertArrayEquals(
+ new String[] {"id", "name", "vip"},
+ tables.get(1).getSeaTunnelRowType().getFieldNames());
+ }
+
+ @Test
+ void enumeratesParallelScanSegmentsPerTable() throws Exception {
+ AmazonDynamoDBSource source =
+ source(
+ "parallel_scan_threads = 3\nscan_item_limit = 7\n"
+ + "tables_configs = ["
+ + ORDERS
+ + ","
+ + CUSTOMERS
+ + "]");
+
+ List<AmazonDynamoDBSourceSplit> splits = enumerate(source);
+
+ Map<String, List<AmazonDynamoDBSourceSplit>> byTable =
+ splits.stream()
+
.collect(Collectors.groupingBy(AmazonDynamoDBSourceSplit::getTableId));
+ Assertions.assertEquals(2, byTable.get("orders").size());
+ Assertions.assertEquals(3, byTable.get("crm.customer_view").size());
+ for (AmazonDynamoDBSourceSplit split : byTable.get("orders")) {
+ Assertions.assertEquals(2, split.getTotalSegments());
+ Assertions.assertEquals(10, split.getItemCount());
+ }
+ for (AmazonDynamoDBSourceSplit split :
byTable.get("crm.customer_view")) {
+ Assertions.assertEquals(3, split.getTotalSegments());
+ Assertions.assertEquals(7, split.getItemCount());
+ }
+ Assertions.assertEquals(
+ Arrays.asList(
+ "crm.customer_view:0",
+ "crm.customer_view:1",
+ "crm.customer_view:2",
+ "orders:0",
+ "orders:1"),
+ splits.stream()
+ .map(AmazonDynamoDBSourceSplit::splitId)
+ .sorted()
+ .collect(Collectors.toList()));
+ }
+
+ @Test
+ void readerRoutesRowsToTheirTable() throws Exception {
+ AmazonDynamoDBSource source = source("tables_configs = [" + ORDERS +
"," + CUSTOMERS + "]");
+ List<AmazonDynamoDBSourceSplit> splits = roundTrip(enumerate(source));
+ Map<String, List<Map<String, AttributeValue>>> data = new HashMap<>();
+ data.put(
+ "orders",
+ Arrays.asList(
+ item("id", s("o1"), "amount", n("10")),
+ item("id", s("o2"), "amount", n("20"))));
+ data.put(
+ "customers",
+ Collections.singletonList(
+ item(
+ "id",
+ s("c1"),
+ "name",
+ s("Ada"),
+ "vip",
+ AttributeValue.builder().bool(true).build())));
+ DynamoDbClient client = client(data);
+
+ List<SeaTunnelRow> rows = read(source, client, splits);
+
+ Assertions.assertEquals(
+ Arrays.asList("[o1, 10]", "[o2, 20]"),
+ rows.stream()
+ .filter(row -> "orders".equals(row.getTableId()))
+ .map(row -> Arrays.toString(row.getFields()))
+ .sorted()
+ .collect(Collectors.toList()));
+ Assertions.assertEquals(
+ Collections.singletonList("[c1, Ada, true]"),
+ rows.stream()
+ .filter(row ->
"crm.customer_view".equals(row.getTableId()))
+ .map(row -> Arrays.toString(row.getFields()))
+ .collect(Collectors.toList()));
+ Assertions.assertEquals(
+ 0,
+ rows.stream()
+ .filter(
+ row ->
+ !"orders".equals(row.getTableId())
+ &&
!"crm.customer_view".equals(row.getTableId()))
+ .count());
+
+ ArgumentCaptor<ScanRequest> requests =
ArgumentCaptor.forClass(ScanRequest.class);
+ Mockito.verify(client,
Mockito.atLeastOnce()).scanPaginator(requests.capture());
+ Map<String, List<ScanRequest>> requestsByTable =
+ requests.getAllValues().stream()
+
.collect(Collectors.groupingBy(ScanRequest::tableName));
+ Assertions.assertEquals(2, requestsByTable.get("orders").size());
+ Assertions.assertEquals(2, requestsByTable.get("customers").size());
+ for (ScanRequest request : requestsByTable.get("orders")) {
+ Assertions.assertEquals(2, request.totalSegments());
+ Assertions.assertEquals(10, request.limit());
+ }
+ }
+
+ @Test
+ void restoresStateWrittenBeforeSplitsCarriedTableIdentity() throws
Exception {
+ AmazonDynamoDBSourceState state =
deserialize(Base64.getDecoder().decode(LEGACY_STATE));
+ List<AmazonDynamoDBSourceSplit> legacySplits = new ArrayList<>();
+ state.getPendingSplits().values().forEach(legacySplits::addAll);
+ Assertions.assertEquals(2, legacySplits.size());
+ for (AmazonDynamoDBSourceSplit split : legacySplits) {
+ Assertions.assertNull(split.getTableId());
+ Assertions.assertEquals(split.getSplitId().toString(),
split.splitId());
+ }
+
+ AmazonDynamoDBSource source = source(SINGLE_TABLE);
+ TestingContext context = new TestingContext();
+ SourceSplitEnumerator<AmazonDynamoDBSourceSplit,
AmazonDynamoDBSourceState> enumerator =
+ source.restoreEnumerator(context, state);
+ enumerator.open();
+ enumerator.run();
+ enumerator.registerReader(0);
+ enumerator.registerReader(1);
+ Assertions.assertEquals(1, context.assigned.get(0).size());
+ Assertions.assertEquals(1, context.assigned.get(1).size());
+ Assertions.assertEquals(2,
enumerator.snapshotState(1L).getAssignCount());
+
+ DynamoDbClient client =
+ client(
+ Collections.singletonMap(
+ "orders",
+ Collections.singletonList(item("id", s("o1"),
"amount", n("10")))));
+ AmazonDynamoDBSourceSplit firstSegment =
+ legacySplits.stream().filter(split -> split.getSplitId() ==
0).findFirst().get();
+ List<SeaTunnelRow> rows = read(source, client,
Collections.singletonList(firstSegment));
+ Assertions.assertEquals(1, rows.size());
+ // single-table rows keep the default table id, as before
+ Assertions.assertEquals(new SeaTunnelRow(0).getTableId(),
rows.get(0).getTableId());
+ Assertions.assertEquals("[o1, 10]",
Arrays.toString(rows.get(0).getFields()));
+ ArgumentCaptor<ScanRequest> request =
ArgumentCaptor.forClass(ScanRequest.class);
+ Mockito.verify(client,
Mockito.atLeastOnce()).scanPaginator(request.capture());
+ Assertions.assertEquals("orders", request.getValue().tableName());
+ Assertions.assertEquals(5, request.getValue().limit());
+ Assertions.assertEquals(2, request.getValue().totalSegments());
+ }
+
+ @Test
+ void readerRejectsSplitsThatDoNotMatchTheConfiguredTables() throws
Exception {
+ AmazonDynamoDBSource multiTable =
+ source("tables_configs = [" + ORDERS + "," + CUSTOMERS + "]");
+ DynamoDbClient client = client(Collections.emptyMap());
+
+ SeaTunnelRuntimeException legacySplit =
+ Assertions.assertThrows(
+ SeaTunnelRuntimeException.class,
+ () ->
+ read(
+ multiTable,
+ client,
+ Collections.singletonList(
+ new
AmazonDynamoDBSourceSplit(0, 1, 1))));
+ Assertions.assertTrue(
+ legacySplit.getMessage().contains("Cannot restore a
single-table"),
+ legacySplit.getMessage());
+
+ SeaTunnelRuntimeException unknownTable =
+ Assertions.assertThrows(
+ SeaTunnelRuntimeException.class,
+ () ->
+ read(
+ source(SINGLE_TABLE),
+ client,
+ Collections.singletonList(
+ new
AmazonDynamoDBSourceSplit(0, 1, 1, "orders"))));
+ Assertions.assertTrue(
+ unknownTable.getMessage().contains("Unknown table identity"),
+ unknownTable.getMessage());
+ }
+
+ @Test
+ void multiTableSplitsSurviveSerialization() throws Exception {
+ AmazonDynamoDBSourceSplit split = roundTrip(new
AmazonDynamoDBSourceSplit(1, 2, 3, "t"));
+ Assertions.assertEquals("t", split.getTableId());
+ Assertions.assertEquals("t:1", split.splitId());
+ }
+
+ @Test
+ void optionRuleAcceptsSingleTableOrTablesConfigs() {
+ validate(SINGLE_TABLE);
+ validate("tables_configs = [" + ORDERS + "," + CUSTOMERS + "]");
+ }
+
+ @Test
+ void acceptsUnquotedNumericTableName() {
+ AmazonDynamoDBSource source =
+ source("tables_configs = [{ table = 2024, schema { fields { id
= string } } }]");
+ Assertions.assertEquals(
+ "2024",
+
source.getProducedCatalogTables().get(0).getTableId().toTablePath().toString());
+ }
+
+ @Test
+ void optionRuleRejectsInvalidTableSelection() {
+ assertInvalid("", "exactly one option must be set");
+ assertInvalid(
+ SINGLE_TABLE + "tables_configs = [" + ORDERS + "]",
+ "mutually exclusive, but multiple are set");
+ assertInvalid("table = orders\n", "'schema' must be configured when
using a root-level");
+ assertInvalid("tables_configs = []\n", "is not empty");
+ assertInvalid(
+ "schema { fields { id = string } }\ntables_configs = [" +
ORDERS + "]",
+ "root-level 'schema' cannot be used with 'tables_configs'");
+ }
+
+ @Test
+ void optionRuleRejectsInvalidTableEntries() {
+ assertInvalid(
+ "tables_configs = [{ schema { fields { id = string } } }]",
+ "tables_configs[0]: 'table' must be configured and non-blank");
+ assertInvalid(
+ "tables_configs = ["
+ + ORDERS
+ + ", { table = \" \", schema { fields { id = string }
} }]",
+ "tables_configs[1]: 'table' must be configured and non-blank");
+ assertInvalid(
+ "tables_configs = [{ table = orders }]",
+ "tables_configs[0]: 'schema' must be configured and
non-empty");
+ assertInvalid(
+ "tables_configs = [" + ORDERS + "," + ORDERS + "]",
+ "tables_configs[1]: duplicate table identity 'orders'");
+ assertInvalid(
+ "tables_configs = ["
+ + ORDERS
+ + ", { table = orders_archive, schema { table =
orders, fields { id = string } } }]",
+ "tables_configs[1]: duplicate table identity 'orders'");
+ assertInvalid(
+ "tables_configs = [{ table = orders, region = \"eu-west-1\",
schema { fields { id = string } } }]",
+ "tables_configs[0]: 'region' must be configured at source
level");
+ assertInvalid(
+ "tables_configs = [{ table = orders, URL =
\"http://other:8000\", schema { fields { id = string } } }]",
+ "tables_configs[0]: unsupported table option 'URL'");
+ assertInvalid(
+ "tables_configs = [{ table = orders, batch_size = 5, schema {
fields { id = string } } }]",
+ "tables_configs[0]: unsupported table option 'batch_size'");
+ assertInvalid(
+ "tables_configs = [{ table = orders, parallel_scan_threads =
0, schema { fields { id = string } } }]",
+ "tables_configs[0]: 'scan_item_limit' and
'parallel_scan_threads' must be positive");
+ assertInvalid(
+ "tables_configs = [{ table = orders, scan_item_limit = many,
schema { fields { id = string } } }]",
+ "tables_configs[0]: invalid table entry");
+ }
+
+ private static void validate(String tableConfig) {
+ ConfigValidator.of(config(tableConfig))
+ .validate(new AmazonDynamoDBSourceFactory().optionRule());
+ }
+
+ private static void assertInvalid(String tableConfig, String
expectedMessage) {
+ OptionValidationException exception =
+ Assertions.assertThrows(
+ OptionValidationException.class, () ->
validate(tableConfig));
+ Assertions.assertTrue(
+ exception.getMessage().contains(expectedMessage),
exception.getMessage());
+
Assertions.assertFalse(exception.getMessage().contains("dummy-secret"));
+ }
+
+ private static ReadonlyConfig config(String tableConfig) {
+ return ReadonlyConfig.fromConfig(ConfigFactory.parseString(CONNECTION
+ tableConfig));
+ }
+
+ private static AmazonDynamoDBSource source(String tableConfig) {
+ ReadonlyConfig config = config(tableConfig);
+ AmazonDynamoDBSourceFactory factory = new
AmazonDynamoDBSourceFactory();
+ ConfigValidator.of(config).validate(factory.optionRule());
+ return (AmazonDynamoDBSource)
+ factory.<SeaTunnelRow, AmazonDynamoDBSourceSplit,
AmazonDynamoDBSourceState>
+ createSource(
+ new TableSourceFactoryContext(
+ config,
+
AmazonDynamoDBMultiTableSourceTest.class
+ .getClassLoader()))
+ .createSource();
+ }
+
+ private static List<AmazonDynamoDBSourceSplit>
enumerate(AmazonDynamoDBSource source)
+ throws Exception {
+ TestingContext context = new TestingContext();
+ SourceSplitEnumerator<AmazonDynamoDBSourceSplit,
AmazonDynamoDBSourceState> enumerator =
+ source.createEnumerator(context);
+ enumerator.open();
+ enumerator.run();
+ List<AmazonDynamoDBSourceSplit> splits = new ArrayList<>();
+ context.assigned.values().forEach(splits::addAll);
+ return splits;
+ }
+
+ private static List<SeaTunnelRow> read(
+ AmazonDynamoDBSource source,
+ DynamoDbClient client,
+ List<AmazonDynamoDBSourceSplit> splits)
+ throws Exception {
+ SourceReader.Context context =
Mockito.mock(SourceReader.Context.class);
+ Mockito.when(context.getBoundedness()).thenReturn(Boundedness.BOUNDED);
+ AmazonDynamoDBSourceReader reader =
+ (AmazonDynamoDBSourceReader) source.createReader(context);
+ reader.dynamoDbClient = client;
+ reader.addSplits(splits);
+ reader.handleNoMoreSplits();
+ List<SeaTunnelRow> rows = new ArrayList<>();
+ Collector<SeaTunnelRow> collector =
+ new Collector<SeaTunnelRow>() {
+ @Override
+ public void collect(SeaTunnelRow record) {
+ rows.add(record);
+ }
+
+ @Override
+ public Object getCheckpointLock() {
+ return this;
+ }
+ };
+ for (int i = 0; i < splits.size(); i++) {
+ reader.pollNext(collector);
+ }
+ return rows;
+ }
+
+ /** Returns each table's items from segment 0 and nothing from the other
segments. */
+ private static DynamoDbClient client(Map<String, List<Map<String,
AttributeValue>>> data) {
+ DynamoDbClient client = Mockito.mock(DynamoDbClient.class);
+ Mockito.when(client.scanPaginator(Mockito.any(ScanRequest.class)))
+ .thenAnswer(
+ invocation ->
+ new ScanIterable(
+ client, invocation.getArgument(0,
ScanRequest.class)));
+ Mockito.when(client.scan(Mockito.any(ScanRequest.class)))
+ .thenAnswer(
+ invocation -> {
+ ScanRequest request = invocation.getArgument(0,
ScanRequest.class);
+ List<Map<String, AttributeValue>> items =
+ request.segment() == 0
+ ? data.getOrDefault(
+ request.tableName(),
Collections.emptyList())
+ : Collections.emptyList();
+ return ScanResponse.builder().items(items).build();
+ });
+ return client;
+ }
+
+ private static Map<String, AttributeValue> item(Object... keyValues) {
+ Map<String, AttributeValue> item = new HashMap<>();
+ for (int i = 0; i < keyValues.length; i += 2) {
+ item.put((String) keyValues[i], (AttributeValue) keyValues[i + 1]);
+ }
+ return item;
+ }
+
+ private static AttributeValue s(String value) {
+ return AttributeValue.builder().s(value).build();
+ }
+
+ private static AttributeValue n(String value) {
+ return AttributeValue.builder().n(value).build();
+ }
+
+ private static <T> T roundTrip(T value) throws Exception {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ try (ObjectOutputStream output = new ObjectOutputStream(bytes)) {
+ output.writeObject(value);
+ }
+ return deserialize(bytes.toByteArray());
+ }
+
+ @SuppressWarnings("unchecked")
+ private static <T> T deserialize(byte[] bytes) throws Exception {
+ try (ObjectInputStream input = new ObjectInputStream(new
ByteArrayInputStream(bytes))) {
+ return (T) input.readObject();
+ }
+ }
+
+ private static class TestingContext
+ implements
SourceSplitEnumerator.Context<AmazonDynamoDBSourceSplit> {
+ private final Map<Integer, List<AmazonDynamoDBSourceSplit>> assigned =
new HashMap<>();
+ private final MetricsContext metricsContext = new
AbstractMetricsContext() {};
+
+ @Override
+ public int currentParallelism() {
+ return 2;
+ }
+
+ @Override
+ public Set<Integer> registeredReaders() {
+ return new HashSet<>(Arrays.asList(0, 1));
+ }
+
+ @Override
+ public void assignSplit(int subtaskId, List<AmazonDynamoDBSourceSplit>
splits) {
+ assigned.computeIfAbsent(subtaskId, ignored -> new
ArrayList<>()).addAll(splits);
+ }
+
+ @Override
+ public void signalNoMoreSplits(int subtask) {}
+
+ @Override
+ public void sendEventToSourceReader(int subtaskId, SourceEvent event)
{}
+
+ @Override
+ public MetricsContext getMetricsContext() {
+ return metricsContext;
+ }
+
+ @Override
+ public EventListener getEventListener() {
+ return event -> {};
+ }
+ }
+}
diff --git
a/seatunnel-connectors-v2/connector-amazondynamodb/src/test/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSourceReaderTest.java
b/seatunnel-connectors-v2/connector-amazondynamodb/src/test/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSourceReaderTest.java
new file mode 100644
index 0000000000..9cf7aa3853
--- /dev/null
+++
b/seatunnel-connectors-v2/connector-amazondynamodb/src/test/java/org/apache/seatunnel/connectors/seatunnel/amazondynamodb/source/AmazonDynamoDBSourceReaderTest.java
@@ -0,0 +1,149 @@
+/*
+ * 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.amazondynamodb.source;
+
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.source.Collector;
+import org.apache.seatunnel.api.source.SourceReader;
+import org.apache.seatunnel.api.table.type.BasicType;
+import org.apache.seatunnel.api.table.type.SeaTunnelDataType;
+import org.apache.seatunnel.api.table.type.SeaTunnelRow;
+import org.apache.seatunnel.api.table.type.SeaTunnelRowType;
+import
org.apache.seatunnel.connectors.seatunnel.amazondynamodb.config.AmazonDynamoDBConfig;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.mockito.Mockito;
+
+import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
+import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
+import software.amazon.awssdk.services.dynamodb.model.ScanRequest;
+import software.amazon.awssdk.services.dynamodb.model.ScanResponse;
+import software.amazon.awssdk.services.dynamodb.paginators.ScanIterable;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+
+class AmazonDynamoDBSourceReaderTest {
+
+ @Test
+ @Timeout(30)
+ void readsSegmentOnceBeforeNoMoreSplitsArrives() throws Exception {
+ AmazonDynamoDBSourceReader reader = reader();
+ AtomicInteger scans = new AtomicInteger();
+ DynamoDbClient client = Mockito.mock(DynamoDbClient.class);
+ Mockito.when(client.scan(Mockito.any(ScanRequest.class)))
+ .thenAnswer(
+ invocation -> {
+ // The enumerator's no-more-splits signal arrives
late.
+ if (scans.incrementAndGet() >= 3) {
+ reader.handleNoMoreSplits();
+ }
+ return ScanResponse.builder()
+ .items(Arrays.asList(item("o1"),
item("o2")))
+ .build();
+ });
+
+ List<SeaTunnelRow> rows = read(reader, client);
+
+ Assertions.assertEquals(2, rows.size());
+ Assertions.assertEquals(1, scans.get());
+ }
+
+ @Test
+ @Timeout(30)
+ void readsEveryPageOfSegment() throws Exception {
+ AmazonDynamoDBSourceReader reader = reader();
+ List<ScanRequest> requests = new ArrayList<>();
+ DynamoDbClient client = Mockito.mock(DynamoDbClient.class);
+ Mockito.when(client.scan(Mockito.any(ScanRequest.class)))
+ .thenAnswer(
+ invocation -> {
+ ScanRequest request = invocation.getArgument(0,
ScanRequest.class);
+ requests.add(request);
+ if (!request.hasExclusiveStartKey()) {
+ return ScanResponse.builder()
+ .items(Arrays.asList(item("o1"),
item("o2")))
+ .lastEvaluatedKey(item("o2"))
+ .build();
+ }
+ return ScanResponse.builder()
+
.items(Collections.singletonList(item("o3")))
+ .build();
+ });
+
+ List<SeaTunnelRow> rows = read(reader, client);
+
+ Assertions.assertEquals(
+ Arrays.asList("o1", "o2", "o3"),
+ rows.stream().map(row ->
row.getField(0)).collect(Collectors.toList()));
+ Assertions.assertEquals(2, requests.size());
+ Assertions.assertEquals(item("o2"),
requests.get(1).exclusiveStartKey());
+ }
+
+ private static AmazonDynamoDBSourceReader reader() {
+ Map<String, Object> options = new HashMap<>();
+ options.put("url", "http://localhost:8000");
+ options.put("region", "us-east-1");
+ options.put("access_key_id", "id");
+ options.put("secret_access_key", "key");
+ options.put("table", "orders");
+ AmazonDynamoDBConfig config = new
AmazonDynamoDBConfig(ReadonlyConfig.fromMap(options));
+ SeaTunnelRowType rowType =
+ new SeaTunnelRowType(
+ new String[] {"id"}, new SeaTunnelDataType<?>[]
{BasicType.STRING_TYPE});
+ return new AmazonDynamoDBSourceReader(
+ Mockito.mock(SourceReader.Context.class), config, rowType);
+ }
+
+ private static List<SeaTunnelRow> read(AmazonDynamoDBSourceReader reader,
DynamoDbClient client)
+ throws Exception {
+ Mockito.when(client.scanPaginator(Mockito.any(ScanRequest.class)))
+ .thenAnswer(
+ invocation ->
+ new ScanIterable(
+ client, invocation.getArgument(0,
ScanRequest.class)));
+ reader.dynamoDbClient = client;
+ reader.addSplits(Collections.singletonList(new
AmazonDynamoDBSourceSplit(0, 1, 10)));
+ List<SeaTunnelRow> rows = new ArrayList<>();
+ reader.pollNext(
+ new Collector<SeaTunnelRow>() {
+ @Override
+ public void collect(SeaTunnelRow record) {
+ rows.add(record);
+ }
+
+ @Override
+ public Object getCheckpointLock() {
+ return this;
+ }
+ });
+ return rows;
+ }
+
+ private static Map<String, AttributeValue> item(String id) {
+ return Collections.singletonMap("id",
AttributeValue.builder().s(id).build());
+ }
+}
diff --git
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-amazondynamodb-e2e/pom.xml
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-amazondynamodb-e2e/pom.xml
index ff0bcbe41c..dc14bc8280 100644
---
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-amazondynamodb-e2e/pom.xml
+++
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-amazondynamodb-e2e/pom.xml
@@ -36,6 +36,12 @@
</dependencies>
</dependencyManagement>
<dependencies>
+ <dependency>
+ <groupId>org.apache.seatunnel</groupId>
+ <artifactId>connector-assert</artifactId>
+ <version>${project.version}</version>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>org.apache.seatunnel</groupId>
<artifactId>connector-amazondynamodb</artifactId>
diff --git
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-amazondynamodb-e2e/src/test/java/org/apache/seatunnel/e2e/connector/amazondynamodb/AmazondynamodbIT.java
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-amazondynamodb-e2e/src/test/java/org/apache/seatunnel/e2e/connector/amazondynamodb/AmazondynamodbIT.java
index d6b09fb7bc..da4448410e 100644
---
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-amazondynamodb-e2e/src/test/java/org/apache/seatunnel/e2e/connector/amazondynamodb/AmazondynamodbIT.java
+++
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-amazondynamodb-e2e/src/test/java/org/apache/seatunnel/e2e/connector/amazondynamodb/AmazondynamodbIT.java
@@ -84,8 +84,11 @@ public class AmazondynamodbIT extends TestSuiteBase
implements TestResource {
private static final String AMAZONDYNAMODB_CONTAINER_HOST =
"dynamodb-host";
private static final int AMAZONDYNAMODB_CONTAINER_PORT = 8000;
private static final String AMAZONDYNAMODB_JOB_CONFIG =
"/amazondynamodbIT_source_to_sink.conf";
+ private static final String AMAZONDYNAMODB_MULTI_TABLE_JOB_CONFIG =
+ "/amazondynamodbIT_multi_table_source.conf";
private static final String SINK_TABLE = "sink_table";
private static final String SOURCE_TABLE = "source_table";
+ private static final String CUSTOMER_TABLE = "customer_table";
private static final String PARTITION_KEY = "id";
private GenericContainer<?> dynamoDB;
@@ -102,6 +105,13 @@ public class AmazondynamodbIT extends TestSuiteBase
implements TestResource {
clearSinkTable();
}
+ @TestTemplate
+ public void testMultiTableSource(TestContainer container) throws Exception
{
+ Container.ExecResult execResult =
+ container.executeJob(AMAZONDYNAMODB_MULTI_TABLE_JOB_CONFIG);
+ Assertions.assertEquals(0, execResult.getExitCode(),
execResult.getStderr());
+ }
+
@BeforeAll
@Override
public void startUp() throws Exception {
@@ -153,11 +163,20 @@ public class AmazondynamodbIT extends TestSuiteBase
implements TestResource {
createTable(dynamoDbClient, SOURCE_TABLE);
createTable(dynamoDbClient, SINK_TABLE);
+ createTable(dynamoDbClient, CUSTOMER_TABLE);
}
private void batchInsertData() {
dynamoDbClient.putItem(
PutItemRequest.builder().tableName(SOURCE_TABLE).item(randomRow()).build());
+ for (int i = 1; i <= 3; i++) {
+ Map<String, AttributeValue> customer = new HashMap<>();
+ customer.put(PARTITION_KEY, AttributeValue.builder().s("c" +
i).build());
+ customer.put("name", AttributeValue.builder().s("customer-" +
i).build());
+ customer.put("vip", AttributeValue.builder().bool(i % 2 ==
0).build());
+ dynamoDbClient.putItem(
+
PutItemRequest.builder().tableName(CUSTOMER_TABLE).item(customer).build());
+ }
}
private void clearSinkTable() {
diff --git
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-amazondynamodb-e2e/src/test/resources/amazondynamodbIT_multi_table_source.conf
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-amazondynamodb-e2e/src/test/resources/amazondynamodbIT_multi_table_source.conf
new file mode 100644
index 0000000000..cc6e679ca3
--- /dev/null
+++
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-amazondynamodb-e2e/src/test/resources/amazondynamodbIT_multi_table_source.conf
@@ -0,0 +1,104 @@
+#
+# 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 {
+ AmazonDynamoDB {
+ url = "http://dynamodb-host:8000"
+ region = "us-east-1"
+ access_key_id = "dummy-key"
+ secret_access_key = "dummy-secret"
+ scan_item_limit = 2
+ parallel_scan_threads = 2
+ tables_configs = [
+ {
+ table = "source_table"
+ parallel_scan_threads = 3
+ schema = {
+ fields {
+ id = string
+ c_string = string
+ c_int = int
+ }
+ }
+ },
+ {
+ table = "customer_table"
+ schema = {
+ table = "crm.customers"
+ fields {
+ id = string
+ name = string
+ vip = boolean
+ }
+ }
+ }
+ ]
+ }
+}
+
+sink {
+ Assert {
+ rules {
+ table-names = ["source_table", "crm.customers"]
+ tables_configs = [
+ {
+ table_path = "source_table"
+ row_rules = [
+ { rule_type = MAX_ROW, rule_value = 1 },
+ { rule_type = MIN_ROW, rule_value = 1 }
+ ]
+ field_rules = [
+ {
+ field_name = c_string
+ field_type = string
+ field_value = [{ equals_to = "string" }]
+ },
+ {
+ field_name = c_int
+ field_type = int
+ field_value = [{ equals_to = 1 }]
+ }
+ ]
+ },
+ {
+ table_path = "crm.customers"
+ row_rules = [
+ { rule_type = MAX_ROW, rule_value = 3 },
+ { rule_type = MIN_ROW, rule_value = 3 }
+ ]
+ field_rules = [
+ {
+ field_name = name
+ field_type = string
+ field_value = [{ rule_type = NOT_NULL }]
+ },
+ {
+ field_name = vip
+ field_type = boolean
+ field_value = [{ rule_type = NOT_NULL }]
+ }
+ ]
+ }
+ ]
+ }
+ }
+}