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-12303-2f92ff7559dcc782427926756baf3614581c4b2d
in repository https://gitbox.apache.org/repos/asf/seatunnel.git

commit 71860b4469bbd83722c81aa85ddd7e57f5f440de
Author: Goutam Adwant <[email protected]>
AuthorDate: Sat Sep 19 14:49:58 2026 +0000

    [Feature][Connector-V2] Support OpenMLDB multi-table source (#12303)
    
    Signed-off-by: Goutam Adwant <[email protected]>
---
 docs/en/connectors/source/OpenMldb.md              |  88 ++++-
 docs/zh/connectors/source/OpenMldb.md              |  81 ++++-
 .../openmldb/config/OpenMldbSqlExecutor.java       |  71 ++--
 .../openmldb/source/OpenMldbReadClient.java        | 209 +++++++++++
 .../seatunnel/openmldb/source/OpenMldbSource.java  |  45 ++-
 .../openmldb/source/OpenMldbSourceFactory.java     | 125 ++++++-
 .../openmldb/source/OpenMldbSourceReader.java      | 121 ++++---
 .../seatunnel/openmldb/OpenMldbFactoryTest.java    | 133 +++++++
 .../openmldb/source/OpenMldbSourceReaderTest.java  | 238 +++++++++++++
 .../openmldb/source/OpenMldbSourceTest.java        | 143 ++++++++
 .../openmldb/source/TestOpenMldbSourceIT.java      | 386 +++++++++++++++++++++
 11 files changed, 1526 insertions(+), 114 deletions(-)

diff --git a/docs/en/connectors/source/OpenMldb.md 
b/docs/en/connectors/source/OpenMldb.md
index 1487c0994c..4946bcf898 100644
--- a/docs/en/connectors/source/OpenMldb.md
+++ b/docs/en/connectors/source/OpenMldb.md
@@ -16,6 +16,10 @@ Used to read data from OpenMLDB. The connector executes the 
configured SQL state
 OpenMLDB and turns the result rows into SeaTunnel records. Both standalone and 
cluster deployment
 modes are supported.
 
+Queries read the online tables directly. Cluster reads do not submit offline 
Spark jobs, return
+job metadata, or change OpenMLDB session/global execution-mode settings. 
Reading offline feature
+data is not supported by this connector.
+
 ## Key features
 
 - [x] [batch](../../introduction/concepts/connector-v2-features.md)
@@ -24,12 +28,14 @@ modes are supported.
 - [x] [column projection](../../introduction/concepts/connector-v2-features.md)
 - [ ] [parallelism](../../introduction/concepts/connector-v2-features.md)
 - [ ] [support user-defined 
split](../../introduction/concepts/connector-v2-features.md)
+- [x] [support multiple table 
read](../../introduction/concepts/connector-v2-features.md)
 
 ## Data Type Mapping
 
-OpenMLDB types are mapped to SeaTunnel types according to the result schema of 
the configured `sql`
-statement. Columns whose types are not natively understood by SeaTunnel will 
cause the read to fail with
-an `UNSUPPORTED_DATA_TYPE` error.
+In multi-table mode, `schema.fields` declares the names and types returned by 
each query.
+The connector validates the query result against this schema before emitting 
rows.
+SQL `NULL` values remain `null`, including nullable numeric and boolean 
columns; they are not
+converted to zero or `false`.
 
 | OpenMLDB Data Type | SeaTunnel Data Type |
 |--------------------|---------------------|
@@ -47,7 +53,8 @@ an `UNSUPPORTED_DATA_TYPE` error.
 |      name       |  type   | required | default value | description           
                                                                 |
 
|-----------------|---------|----------|---------------|----------------------------------------------------------------------------------------|
 | cluster_mode    | boolean | yes      | -             | Whether to connect to 
OpenMLDB in cluster mode. Set to `false` for standalone mode.    |
-| sql             | string  | yes      | -             | SQL statement to 
execute against OpenMLDB. Column names and types follow the result.   |
+| sql             | string  | conditional | -          | Single-query SQL. 
Configure either this option or `tables_configs`, not both. |
+| tables_configs  | list    | conditional | -          | Queries and explicit 
result schemas for a multi-table read. |
 | database        | string  | yes      | -             | The OpenMLDB database 
name to connect to.                                              |
 | host            | string  | no       | -             | Required when 
`cluster_mode` is `false`. Host of the standalone OpenMLDB server.       |
 | port            | int     | no       | -             | Required when 
`cluster_mode` is `false`. Port of the standalone OpenMLDB server.       |
@@ -64,10 +71,32 @@ When it is `true`, configure `zk_host` and `zk_path`.
 
 ### sql [string]
 
-The required `sql` must not be empty or whitespace-only in either standalone 
or cluster mode.
+When `tables_configs` is absent, `sql` is required and must not be empty or 
whitespace-only.
+
+This legacy mode discovers the schema using the SDK's input-schema API. The 
result columns must
+match that input schema in count, order and type. Use `tables_configs` with an 
explicit result
+schema for projected or aliased query results.
+
+### tables_configs [list]
+
+A non-empty list of queries on the same OpenMLDB instance. Each entry contains:
+
+- `sql`: a non-blank SQL query.
+- `database`: an optional override of the required root-level database.
+- `schema.table`: a unique output table identity for downstream routing.
+- `schema.fields`: all query output column names and their supported SeaTunnel 
types.
+
+Field names must exactly match the result column names, including case. Use 
SQL aliases where
+needed. Fields are matched by name, so the order of `schema.fields` does not 
need to match the
+SQL projection order. Missing, duplicate, extra or incorrectly typed result 
columns fail the read.
+
+Keep connection and timeout options at source level. Do not combine 
`tables_configs` with
+root-level `sql` or `schema`. Each entry may use a different result schema.
 
-The SQL statement to execute against OpenMLDB. The result set columns become 
the schema of the
-emitted SeaTunnel rows.
+One reader executes the queries sequentially. In batch mode, completion is 
signalled only after
+all queries succeed, including empty results. In streaming mode, each poll 
executes the queries
+again; this is not CDC or incremental polling and can produce duplicate 
records. Multi-table
+reading does not add parallelism, a cross-table consistent snapshot, or 
exactly-once guarantees.
 
 ### database [string]
 
@@ -159,6 +188,51 @@ sink {
 }
 ```
 
+### Multi-table read
+
+```hocon
+env {
+  parallelism = 1
+  job.mode = "BATCH"
+}
+
+source {
+  OpenMldb {
+    cluster_mode = false
+    host = "openmldb"
+    port = 6527
+    database = "shop"
+    tables_configs = [
+      {
+        sql = "select id, amount from orders"
+        schema {
+          table = "shop.orders"
+          fields {
+            id = STRING
+            amount = INT
+          }
+        }
+      },
+      {
+        database = "crm"
+        sql = "select id, name from customers"
+        schema {
+          table = "crm.customers"
+          fields {
+            id = STRING
+            name = STRING
+          }
+        }
+      }
+    ]
+  }
+}
+
+sink {
+  Console {}
+}
+```
+
 ## Changelog
 
 <ChangeLog />
diff --git a/docs/zh/connectors/source/OpenMldb.md 
b/docs/zh/connectors/source/OpenMldb.md
index f36349aac3..1f5eaf20ab 100644
--- a/docs/zh/connectors/source/OpenMldb.md
+++ b/docs/zh/connectors/source/OpenMldb.md
@@ -15,6 +15,9 @@ import ChangeLog from '../changelog/connector-openmldb.md';
 用于从 OpenMLDB 读取数据。连接器会执行配置的 SQL 语句并把结果转换为 SeaTunnel 记录,同时支持
 单机版和集群版两种部署模式。
 
+查询直接读取在线表。集群读取不会提交离线 Spark 作业、返回作业元数据或修改 OpenMLDB 会话及全局执行模式。
+此连接器不支持读取离线特征数据。
+
 ## 关键特性
 
 - [x] [批处理](../../introduction/concepts/connector-v2-features.md)
@@ -23,11 +26,12 @@ import ChangeLog from '../changelog/connector-openmldb.md';
 - [x] [列投影](../../introduction/concepts/connector-v2-features.md)
 - [ ] [并行度](../../introduction/concepts/connector-v2-features.md)
 - [ ] [支持用户自定义分片](../../introduction/concepts/connector-v2-features.md)
+- [x] [支持多表读取](../../introduction/concepts/connector-v2-features.md)
 
 ## 数据类型映射
 
-OpenMLDB 类型会按照所配置 `sql` 语句的结果集映射为 SeaTunnel 类型。SeaTunnel 不原生支持的类型会直接
-导致读取失败,并抛出 `UNSUPPORTED_DATA_TYPE` 错误。
+多表模式下,`schema.fields` 声明每个查询返回的字段名称和类型,连接器在输出数据前校验查询结果。
+SQL `NULL` 值保留为 `null`,包括数值和布尔类型,不会被转换为零或 `false`。
 
 | OpenMLDB 数据类型 | SeaTunnel 数据类型 |
 |-------------------|--------------------|
@@ -45,7 +49,8 @@ OpenMLDB 类型会按照所配置 `sql` 语句的结果集映射为 SeaTunnel 
 |      名称       |  类型   | 必需 | 默认值 | 描述                                        
                                                      |
 
|-----------------|---------|------|--------|---------------------------------------------------------------------------------------------------|
 | cluster_mode    | boolean | 是   | -      | 是否以 OpenMLDB 集群模式连接。`false` 
表示单机模式,`true` 表示集群模式。                          |
-| sql             | string  | 是   | -      | 用于读取数据的 SQL 语句,列名和类型按结果集定义。       
                                           |
+| sql             | string  | 条件必填 | -   | 单查询 SQL。与 `tables_configs` 必须二选一。 |
+| tables_configs  | list    | 条件必填 | -   | 多表读取的查询及显式结果结构。 |
 | database        | string  | 是   | -      | 要连接的 OpenMLDB 数据库名称。              
                                                      |
 | host            | string  | 否   | -      | 当 `cluster_mode` 为 `false` 
时必填,OpenMLDB 单机版主机地址。                                     |
 | port            | int     | 否   | -      | 当 `cluster_mode` 为 `false` 
时必填,OpenMLDB 单机版端口。                                         |
@@ -62,9 +67,30 @@ OpenMLDB 类型会按照所配置 `sql` 语句的结果集映射为 SeaTunnel 
 
 ### sql [string]
 
-无论使用单机模式还是集群模式,必填项 `sql` 都不能为空字符串或仅包含空白字符。
+未配置 `tables_configs` 时,`sql` 必填,且不能为空字符串或仅包含空白字符。
+
+该兼容模式使用 SDK 的输入结构接口获取表结构,结果列的数量、顺序和类型必须与输入结构一致。
+需要列投影或别名时,请使用 `tables_configs` 并显式配置结果结构。
+
+### tables_configs [list]
+
+同一个 OpenMLDB 实例上的非空查询列表。每个配置项包含:
+
+- `sql`:非空 SQL 查询。
+- `database`:可选,用于覆盖必填的根级数据库配置。
+- `schema.table`:唯一的输出表标识,用于下游路由。
+- `schema.fields`:查询返回的全部字段名称及受支持的 SeaTunnel 类型。
+
+字段名称必须与查询结果一致,区分大小写,可通过 SQL 别名进行匹配。
+字段按名称映射,`schema.fields` 的顺序不必与 SQL 投影顺序一致。
+结果列缺失、重复、多余或类型不匹配时,读取失败。
+
+连接和超时选项必须配置在源级别。不得同时配置 `tables_configs` 与根级 `sql` 或 `schema`。
+每个配置项可以使用不同的结果结构。
 
-针对 OpenMLDB 执行的 SQL 语句,结果集的列会成为连接器输出行的字段。
+单个读取器依次执行所有查询。批模式下,所有查询成功后才报告完成,空结果不会跳过后续查询。
+流模式下,每次轮询都会重新执行全部查询,因此可能产生重复记录;这不是 CDC 或增量轮询。
+多表读取不增加并行度,也不提供跨表一致性快照或精确一次保证。
 
 ### database [string]
 
@@ -155,6 +181,51 @@ sink {
 }
 ```
 
+### 多表读取
+
+```hocon
+env {
+  parallelism = 1
+  job.mode = "BATCH"
+}
+
+source {
+  OpenMldb {
+    cluster_mode = false
+    host = "openmldb"
+    port = 6527
+    database = "shop"
+    tables_configs = [
+      {
+        sql = "select id, amount from orders"
+        schema {
+          table = "shop.orders"
+          fields {
+            id = STRING
+            amount = INT
+          }
+        }
+      },
+      {
+        database = "crm"
+        sql = "select id, name from customers"
+        schema {
+          table = "crm.customers"
+          fields {
+            id = STRING
+            name = STRING
+          }
+        }
+      }
+    ]
+  }
+}
+
+sink {
+  Console {}
+}
+```
+
 ## 变更日志
 
 <ChangeLog />
diff --git 
a/seatunnel-connectors-v2/connector-openmldb/src/main/java/org/apache/seatunnel/connectors/seatunnel/openmldb/config/OpenMldbSqlExecutor.java
 
b/seatunnel-connectors-v2/connector-openmldb/src/main/java/org/apache/seatunnel/connectors/seatunnel/openmldb/config/OpenMldbSqlExecutor.java
index 0f8154f035..f3787c3ffd 100644
--- 
a/seatunnel-connectors-v2/connector-openmldb/src/main/java/org/apache/seatunnel/connectors/seatunnel/openmldb/config/OpenMldbSqlExecutor.java
+++ 
b/seatunnel-connectors-v2/connector-openmldb/src/main/java/org/apache/seatunnel/connectors/seatunnel/openmldb/config/OpenMldbSqlExecutor.java
@@ -17,48 +17,61 @@
 
 package org.apache.seatunnel.connectors.seatunnel.openmldb.config;
 
+import com._4paradigm.openmldb.SQLRouter;
+import com._4paradigm.openmldb.SQLRouterOptions;
+import com._4paradigm.openmldb.StandaloneOptions;
 import com._4paradigm.openmldb.sdk.SdkOption;
 import com._4paradigm.openmldb.sdk.SqlException;
 import com._4paradigm.openmldb.sdk.impl.SqlClusterExecutor;
+import com._4paradigm.openmldb.sql_router_sdk;
 
 public class OpenMldbSqlExecutor {
-    private static final SdkOption SDK_OPTION = new SdkOption();
-    private static volatile SqlClusterExecutor SQL_EXECUTOR;
-
     private OpenMldbSqlExecutor() {}
 
-    public static void initSdkOption(OpenMldbParameters openMldbParameters) {
-        if (openMldbParameters.getClusterMode()) {
-            SDK_OPTION.setZkCluster(openMldbParameters.getZkHost());
-            SDK_OPTION.setZkPath(openMldbParameters.getZkPath());
-        } else {
-            SDK_OPTION.setHost(openMldbParameters.getHost());
-            SDK_OPTION.setPort(openMldbParameters.getPort());
-            SDK_OPTION.setClusterMode(false);
-        }
-        SDK_OPTION.setSessionTimeout(openMldbParameters.getSessionTimeout());
-        SDK_OPTION.setRequestTimeout(openMldbParameters.getRequestTimeout());
+    /** Creates an executor owned by a single schema discovery operation. */
+    public static SqlClusterExecutor create(OpenMldbParameters 
openMldbParameters)
+            throws SqlException {
+        return new SqlClusterExecutor(options(openMldbParameters));
     }
 
-    public static SqlClusterExecutor getSqlExecutor() throws SqlException {
-        if (SQL_EXECUTOR == null) {
-            synchronized (OpenMldbSqlExecutor.class) {
-                if (SQL_EXECUTOR == null) {
-                    SQL_EXECUTOR = new SqlClusterExecutor(SDK_OPTION);
-                }
+    /** Creates a reader-owned router to access the result set's native null 
checks. */
+    public static SQLRouter createReader(OpenMldbParameters parameters) throws 
SqlException {
+        SqlClusterExecutor.initJavaSdkLibrary("sql_jsdk");
+        SdkOption option = options(parameters);
+        SQLRouter router;
+        if (option.isClusterMode()) {
+            SQLRouterOptions nativeOptions = option.buildSQLRouterOptions();
+            try {
+                router = sql_router_sdk.NewClusterSQLRouter(nativeOptions);
+            } finally {
+                nativeOptions.delete();
+            }
+        } else {
+            StandaloneOptions nativeOptions = option.buildStandaloneOptions();
+            try {
+                router = sql_router_sdk.NewStandaloneSQLRouter(nativeOptions);
+            } finally {
+                nativeOptions.delete();
             }
         }
-        return SQL_EXECUTOR;
+        if (router == null) {
+            throw new SqlException("Failed to create OpenMldb reader");
+        }
+        return router;
     }
 
-    public static void close() {
-        if (SQL_EXECUTOR != null) {
-            synchronized (OpenMldbParameters.class) {
-                if (SQL_EXECUTOR != null) {
-                    SQL_EXECUTOR.close();
-                    SQL_EXECUTOR = null;
-                }
-            }
+    private static SdkOption options(OpenMldbParameters openMldbParameters) {
+        SdkOption sdkOption = new SdkOption();
+        if (openMldbParameters.getClusterMode()) {
+            sdkOption.setZkCluster(openMldbParameters.getZkHost());
+            sdkOption.setZkPath(openMldbParameters.getZkPath());
+        } else {
+            sdkOption.setHost(openMldbParameters.getHost());
+            sdkOption.setPort(openMldbParameters.getPort());
+            sdkOption.setClusterMode(false);
         }
+        sdkOption.setSessionTimeout(openMldbParameters.getSessionTimeout());
+        sdkOption.setRequestTimeout(openMldbParameters.getRequestTimeout());
+        return sdkOption;
     }
 }
diff --git 
a/seatunnel-connectors-v2/connector-openmldb/src/main/java/org/apache/seatunnel/connectors/seatunnel/openmldb/source/OpenMldbReadClient.java
 
b/seatunnel-connectors-v2/connector-openmldb/src/main/java/org/apache/seatunnel/connectors/seatunnel/openmldb/source/OpenMldbReadClient.java
new file mode 100644
index 0000000000..4f3d669f3f
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-openmldb/src/main/java/org/apache/seatunnel/connectors/seatunnel/openmldb/source/OpenMldbReadClient.java
@@ -0,0 +1,209 @@
+/*
+ * 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.openmldb.source;
+
+import org.apache.seatunnel.api.table.type.SeaTunnelRow;
+import org.apache.seatunnel.api.table.type.SeaTunnelRowType;
+import org.apache.seatunnel.api.table.type.SqlType;
+import 
org.apache.seatunnel.connectors.seatunnel.openmldb.config.OpenMldbParameters;
+import 
org.apache.seatunnel.connectors.seatunnel.openmldb.config.OpenMldbSqlExecutor;
+
+import com._4paradigm.openmldb.DataType;
+import com._4paradigm.openmldb.Date;
+import com._4paradigm.openmldb.ResultSet;
+import com._4paradigm.openmldb.SQLRouter;
+import com._4paradigm.openmldb.Schema;
+import com._4paradigm.openmldb.Status;
+import com._4paradigm.openmldb.sdk.SqlException;
+
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.time.LocalDate;
+import java.util.HashMap;
+import java.util.Map;
+
+/** Owns the native resources needed to read nullable query results with SDK 
0.6.3. */
+class OpenMldbReadClient implements AutoCloseable {
+    private SQLRouter router;
+
+    OpenMldbReadClient(OpenMldbParameters parameters) throws SqlException {
+        router = OpenMldbSqlExecutor.createReader(parameters);
+    }
+
+    synchronized Query execute(
+            String database, String sql, SeaTunnelRowType rowType, boolean 
matchByName)
+            throws SQLException {
+        if (router == null) {
+            throw new SQLException("OpenMldb reader is closed");
+        }
+        Status status = new Status();
+        ResultSet result = null;
+        try {
+            // Query the online tables directly; ExecuteSQL can submit an 
offline job in cluster
+            // mode.
+            result = router.ExecuteSQLParameterized(database, sql, null, 
status);
+            if (status.getCode() != 0 || result == null) {
+                throw new SQLException("OpenMldb query failed: " + 
status.getMsg());
+            }
+            Query query = new Query(result, rowType, matchByName);
+            result = null; // Ownership transfers only after schema validation 
succeeds.
+            return query;
+        } finally {
+            if (result != null) {
+                result.delete();
+            }
+            status.delete();
+        }
+    }
+
+    @Override
+    public synchronized void close() {
+        if (router != null) {
+            router.delete();
+            router = null;
+        }
+    }
+
+    static class Query implements AutoCloseable {
+        private ResultSet result;
+        private final SeaTunnelRowType rowType;
+        private final int[] columnIndexes;
+
+        private Query(ResultSet result, SeaTunnelRowType rowType, boolean 
matchByName)
+                throws SQLException {
+            this.result = result;
+            this.rowType = rowType;
+            this.columnIndexes = new int[rowType.getTotalFields()];
+            Schema schema = result.GetSchema();
+            if (schema == null) {
+                throw new SQLException("OpenMldb query did not return a 
schema");
+            }
+            try {
+                if (schema.GetColumnCnt() != rowType.getTotalFields()) {
+                    throw new SQLException("Query column count does not match 
source schema");
+                }
+                Map<String, Integer> columns = new HashMap<>();
+                if (matchByName) {
+                    for (int i = 0; i < schema.GetColumnCnt(); i++) {
+                        String name = schema.GetColumnName(i);
+                        if (columns.put(name, i) != null) {
+                            throw new SQLException("Ambiguous query column: " 
+ name);
+                        }
+                    }
+                }
+                for (int i = 0; i < rowType.getTotalFields(); i++) {
+                    Integer index =
+                            matchByName ? columns.get(rowType.getFieldName(i)) 
: Integer.valueOf(i);
+                    if (index == null) {
+                        throw new SQLException(
+                                "Query is missing configured field: " + 
rowType.getFieldName(i));
+                    }
+                    columnIndexes[i] = index;
+                    if (schema.GetColumnType(columnIndexes[i])
+                            != 
nativeType(rowType.getFieldType(i).getSqlType())) {
+                        throw new SQLException(
+                                "Query column "
+                                        + (i + 1)
+                                        + " does not match source schema type "
+                                        + 
rowType.getFieldType(i).getSqlType());
+                    }
+                }
+            } finally {
+                schema.delete();
+            }
+        }
+
+        boolean next() {
+            return result.Next();
+        }
+
+        SeaTunnelRow readRow() {
+            Object[] fields = new Object[rowType.getTotalFields()];
+            for (int i = 0; i < fields.length; i++) {
+                // JDBC primitive getters return zero/false for NULL, and 
wasNull is unsupported.
+                if (!result.IsNULL(columnIndexes[i])) {
+                    fields[i] = readValue(columnIndexes[i], 
rowType.getFieldType(i).getSqlType());
+                }
+            }
+            return new SeaTunnelRow(fields);
+        }
+
+        private Object readValue(int index, SqlType type) {
+            switch (type) {
+                case BOOLEAN:
+                    return result.GetBoolUnsafe(index);
+                case SMALLINT:
+                    return result.GetInt16Unsafe(index);
+                case INT:
+                    return result.GetInt32Unsafe(index);
+                case BIGINT:
+                    return result.GetInt64Unsafe(index);
+                case FLOAT:
+                    return result.GetFloatUnsafe(index);
+                case DOUBLE:
+                    return result.GetDoubleUnsafe(index);
+                case STRING:
+                    return result.GetStringUnsafe(index);
+                case DATE:
+                    Date date = result.GetStructDateUnsafe(index);
+                    try {
+                        return LocalDate.of(date.getYear(), date.getMonth(), 
date.getDay());
+                    } finally {
+                        date.delete();
+                    }
+                case TIMESTAMP:
+                    return new 
Timestamp(result.GetTimeUnsafe(index)).toLocalDateTime();
+                default:
+                    throw new IllegalArgumentException("Unsupported OpenMldb 
type: " + type);
+            }
+        }
+
+        @Override
+        public void close() {
+            if (result != null) {
+                result.delete();
+                result = null;
+            }
+        }
+    }
+
+    private static DataType nativeType(SqlType type) throws SQLException {
+        switch (type) {
+            case BOOLEAN:
+                return DataType.kTypeBool;
+            case SMALLINT:
+                return DataType.kTypeInt16;
+            case INT:
+                return DataType.kTypeInt32;
+            case BIGINT:
+                return DataType.kTypeInt64;
+            case FLOAT:
+                return DataType.kTypeFloat;
+            case DOUBLE:
+                return DataType.kTypeDouble;
+            case STRING:
+                return DataType.kTypeString;
+            case DATE:
+                return DataType.kTypeDate;
+            case TIMESTAMP:
+                return DataType.kTypeTimestamp;
+            default:
+                throw new SQLException("Unsupported OpenMldb type: " + type);
+        }
+    }
+}
diff --git 
a/seatunnel-connectors-v2/connector-openmldb/src/main/java/org/apache/seatunnel/connectors/seatunnel/openmldb/source/OpenMldbSource.java
 
b/seatunnel-connectors-v2/connector-openmldb/src/main/java/org/apache/seatunnel/connectors/seatunnel/openmldb/source/OpenMldbSource.java
index 7b038544eb..9acb77b8d3 100644
--- 
a/seatunnel-connectors-v2/connector-openmldb/src/main/java/org/apache/seatunnel/connectors/seatunnel/openmldb/source/OpenMldbSource.java
+++ 
b/seatunnel-connectors-v2/connector-openmldb/src/main/java/org/apache/seatunnel/connectors/seatunnel/openmldb/source/OpenMldbSource.java
@@ -44,32 +44,48 @@ import com._4paradigm.openmldb.sdk.impl.SqlClusterExecutor;
 
 import java.sql.SQLException;
 import java.sql.Types;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 
 public class OpenMldbSource extends AbstractSingleSplitSource<SeaTunnelRow>
         implements SupportColumnProjection {
-    private final OpenMldbParameters openMldbParameters;
-    private final CatalogTable catalogTable;
+    private final List<OpenMldbParameters> tableParameters;
+    private final List<CatalogTable> catalogTables;
+    private final boolean multiTable;
     private JobContext jobContext;
 
     public OpenMldbSource(OpenMldbParameters openMldbParameters) {
-        this.openMldbParameters = openMldbParameters;
-        OpenMldbSqlExecutor.initSdkOption(openMldbParameters);
+        this.tableParameters = Collections.singletonList(openMldbParameters);
+        this.multiTable = false;
+        SqlClusterExecutor sqlExecutor = null;
         try {
-            SqlClusterExecutor sqlExecutor = 
OpenMldbSqlExecutor.getSqlExecutor();
+            sqlExecutor = OpenMldbSqlExecutor.create(openMldbParameters);
             Schema inputSchema =
                     sqlExecutor.getInputSchema(
                             openMldbParameters.getDatabase(), 
openMldbParameters.getSql());
             List<Column> columnList = inputSchema.getColumnList();
-            this.catalogTable = convert(columnList);
+            this.catalogTables =
+                    Collections.singletonList(
+                            convert(columnList, 
openMldbParameters.getDatabase()));
         } catch (SQLException | SqlException e) {
             throw new OpenMldbConnectorException(
                     CommonErrorCodeDeprecated.TABLE_SCHEMA_GET_FAILED,
-                    "Failed to initialize data schema");
+                    "Failed to initialize data schema",
+                    e);
+        } finally {
+            if (sqlExecutor != null) {
+                sqlExecutor.close();
+            }
         }
     }
 
+    OpenMldbSource(List<OpenMldbParameters> tableParameters, 
List<CatalogTable> catalogTables) {
+        this.tableParameters = Collections.unmodifiableList(new 
ArrayList<>(tableParameters));
+        this.catalogTables = Collections.unmodifiableList(new 
ArrayList<>(catalogTables));
+        this.multiTable = true;
+    }
+
     @Override
     public String getPluginName() {
         return "OpenMldb";
@@ -84,14 +100,13 @@ public class OpenMldbSource extends 
AbstractSingleSplitSource<SeaTunnelRow>
 
     @Override
     public List<CatalogTable> getProducedCatalogTables() {
-        return Collections.singletonList(catalogTable);
+        return catalogTables;
     }
 
     @Override
     public AbstractSingleSplitReader<SeaTunnelRow> createReader(
             SingleSplitReaderContext readerContext) throws Exception {
-        return new OpenMldbSourceReader(
-                openMldbParameters, catalogTable.getSeaTunnelRowType(), 
readerContext);
+        return new OpenMldbSourceReader(tableParameters, catalogTables, 
multiTable, readerContext);
     }
 
     @Override
@@ -126,7 +141,7 @@ public class OpenMldbSource extends 
AbstractSingleSplitSource<SeaTunnelRow>
         }
     }
 
-    private CatalogTable convert(List<Column> columnList) {
+    private CatalogTable convert(List<Column> columnList, String database) {
         TableSchema.Builder builder = TableSchema.builder();
         for (int i = 0; i < columnList.size(); i++) {
             Column column = columnList.get(i);
@@ -135,15 +150,15 @@ public class OpenMldbSource extends 
AbstractSingleSplitSource<SeaTunnelRow>
                             column.getColumnName(),
                             convertSeaTunnelDataType(column.getSqlType()),
                             (Long) null,
-                            column.isNotNull(),
+                            !column.isNotNull(),
                             null,
                             null));
         }
         return CatalogTable.of(
-                TableIdentifier.of("OpenMldb", 
openMldbParameters.getDatabase(), "default"),
+                TableIdentifier.of("OpenMldb", database, "default"),
                 builder.build(),
-                null,
-                null,
+                Collections.emptyMap(),
+                Collections.emptyList(),
                 null);
     }
 }
diff --git 
a/seatunnel-connectors-v2/connector-openmldb/src/main/java/org/apache/seatunnel/connectors/seatunnel/openmldb/source/OpenMldbSourceFactory.java
 
b/seatunnel-connectors-v2/connector-openmldb/src/main/java/org/apache/seatunnel/connectors/seatunnel/openmldb/source/OpenMldbSourceFactory.java
index 0ff80bbac2..a45dbf39e0 100644
--- 
a/seatunnel-connectors-v2/connector-openmldb/src/main/java/org/apache/seatunnel/connectors/seatunnel/openmldb/source/OpenMldbSourceFactory.java
+++ 
b/seatunnel-connectors-v2/connector-openmldb/src/main/java/org/apache/seatunnel/connectors/seatunnel/openmldb/source/OpenMldbSourceFactory.java
@@ -17,19 +17,34 @@
 
 package org.apache.seatunnel.connectors.seatunnel.openmldb.source;
 
+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.ConfigValidator;
 import org.apache.seatunnel.api.configuration.util.OptionRule;
+import org.apache.seatunnel.api.configuration.util.OptionValidationException;
+import org.apache.seatunnel.api.options.ConnectorCommonOptions;
 import org.apache.seatunnel.api.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;
 import org.apache.seatunnel.api.table.factory.TableSourceFactory;
 import org.apache.seatunnel.api.table.factory.TableSourceFactoryContext;
+import org.apache.seatunnel.api.table.type.SeaTunnelDataType;
 import 
org.apache.seatunnel.connectors.seatunnel.openmldb.config.OpenMldbParameters;
 import 
org.apache.seatunnel.connectors.seatunnel.openmldb.config.OpenMldbSourceOptions;
 
 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.List;
+import java.util.Map;
+import java.util.Set;
 
 import static org.apache.seatunnel.api.configuration.util.Conditions.notBlank;
 
@@ -44,7 +59,13 @@ public class OpenMldbSourceFactory implements 
TableSourceFactory {
     public OptionRule optionRule() {
         return OptionRule.builder()
                 .required(OpenMldbSourceOptions.CLUSTER_MODE)
-                .required(OpenMldbSourceOptions.SQL, 
notBlank(OpenMldbSourceOptions.SQL))
+                .exclusive(OpenMldbSourceOptions.SQL, 
ConnectorCommonOptions.TABLE_CONFIGS)
+                .optional(OpenMldbSourceOptions.SQL, 
notBlank(OpenMldbSourceOptions.SQL))
+                .optional(
+                        ConnectorCommonOptions.TABLE_CONFIGS,
+                        
Conditions.notEmpty(ConnectorCommonOptions.TABLE_CONFIGS),
+                        Conditions.extension(
+                                ConnectorCommonOptions.TABLE_CONFIGS, new 
TablesValidator()))
                 .required(OpenMldbSourceOptions.DATABASE)
                 .optional(OpenMldbSourceOptions.SESSION_TIMEOUT)
                 .optional(OpenMldbSourceOptions.REQUEST_TIMEOUT)
@@ -69,8 +90,110 @@ public class OpenMldbSourceFactory implements 
TableSourceFactory {
     @Override
     public <T, SplitT extends SourceSplit, StateT extends Serializable>
             TableSource<T, SplitT, StateT> 
createSource(TableSourceFactoryContext context) {
+        ConfigValidator.of(context.getOptions()).validate(optionRule());
+        if 
(context.getOptions().getOptional(ConnectorCommonOptions.TABLE_CONFIGS).isPresent())
 {
+            List<OpenMldbParameters> tables = 
buildTables(context.getOptions());
+            List<CatalogTable> catalogTables = new ArrayList<>();
+            for (Map<String, Object> entry :
+                    
context.getOptions().get(ConnectorCommonOptions.TABLE_CONFIGS)) {
+                catalogTables.add(
+                        CatalogTableUtil.buildWithConfig(
+                                "OpenMldb", ReadonlyConfig.fromMap(entry)));
+            }
+            return () ->
+                    (SeaTunnelSource<T, SplitT, StateT>) new 
OpenMldbSource(tables, catalogTables);
+        }
         OpenMldbParameters openMldbParameters =
                 
OpenMldbParameters.buildWithConfig(context.getOptions().toConfig());
         return () -> (SeaTunnelSource<T, SplitT, StateT>) new 
OpenMldbSource(openMldbParameters);
     }
+
+    private static List<OpenMldbParameters> buildTables(ReadonlyConfig config) 
{
+        List<OpenMldbParameters> tables = new ArrayList<>();
+        for (Map<String, Object> entry : 
config.get(ConnectorCommonOptions.TABLE_CONFIGS)) {
+            tables.add(
+                    OpenMldbParameters.buildWithConfig(
+                            ReadonlyConfig.fromMap(entry)
+                                    .toConfig()
+                                    .withFallback(
+                                            config.toConfig()
+                                                    .withoutPath(
+                                                            
ConnectorCommonOptions.TABLE_CONFIGS
+                                                                    .key()))));
+        }
+        return tables;
+    }
+
+    static class TablesValidator implements 
ConditionExtension<List<Map<String, Object>>> {
+        @Override
+        public String description() {
+            return "each table requires non-blank sql and an explicit schema 
with a unique table identity";
+        }
+
+        @Override
+        public boolean evaluate(ReadonlyConfig config, List<Map<String, 
Object>> entries) {
+            if (config.getOptional(ConnectorCommonOptions.SCHEMA).isPresent()) 
{
+                throw new OptionValidationException(
+                        "With tables_configs, configure schema.table inside 
each entry");
+            }
+            Set<String> ids = new HashSet<>();
+            Set<String> supported = new HashSet<>(Arrays.asList("sql", 
"database", "schema"));
+            for (int i = 0; i < entries.size(); i++) {
+                Map<String, Object> entry = entries.get(i);
+                if (entry == null || !supported.containsAll(entry.keySet())) {
+                    throw new OptionValidationException(
+                            "tables_configs[%d]: only sql, database and schema 
are supported; connection options belong at source level",
+                            i);
+                }
+                Object schema = entry.get("schema");
+                if (!(schema instanceof Map)
+                        || !(((Map<?, ?>) schema).get("fields") instanceof Map)
+                        || ((Map<?, ?>) ((Map<?, ?>) 
schema).get("fields")).isEmpty()) {
+                    throw new OptionValidationException(
+                            "tables_configs[%d]: schema.table and non-empty 
schema.fields are required",
+                            i);
+                }
+                Object table = ((Map<?, ?>) schema).get("table");
+                Object database =
+                        entry.containsKey("database")
+                                ? entry.get("database")
+                                : config.get(OpenMldbSourceOptions.DATABASE);
+                if (!nonBlank(entry.get("sql")) || !nonBlank(table) || 
!nonBlank(database)) {
+                    throw new OptionValidationException(
+                            "tables_configs[%d]: sql, database and 
schema.table must be non-blank strings",
+                            i);
+                }
+                CatalogTable catalogTable =
+                        CatalogTableUtil.buildWithConfig("OpenMldb", 
ReadonlyConfig.fromMap(entry));
+                for (SeaTunnelDataType<?> type :
+                        catalogTable.getSeaTunnelRowType().getFieldTypes()) {
+                    switch (type.getSqlType()) {
+                        case BOOLEAN:
+                        case SMALLINT:
+                        case INT:
+                        case BIGINT:
+                        case FLOAT:
+                        case DOUBLE:
+                        case STRING:
+                        case DATE:
+                        case TIMESTAMP:
+                            break;
+                        default:
+                            throw new OptionValidationException(
+                                    "tables_configs[%d]: unsupported OpenMldb 
type '%s'", i, type);
+                    }
+                }
+                String id = catalogTable.getTableId().toTablePath().toString();
+                if (!ids.add(id)) {
+                    throw new OptionValidationException(
+                            "tables_configs[%d]: duplicate table identity 
'%s'", i, id);
+                }
+            }
+            return true;
+        }
+
+        private boolean nonBlank(Object value) {
+            return value instanceof String && !((String) 
value).trim().isEmpty();
+        }
+    }
 }
diff --git 
a/seatunnel-connectors-v2/connector-openmldb/src/main/java/org/apache/seatunnel/connectors/seatunnel/openmldb/source/OpenMldbSourceReader.java
 
b/seatunnel-connectors-v2/connector-openmldb/src/main/java/org/apache/seatunnel/connectors/seatunnel/openmldb/source/OpenMldbSourceReader.java
index c034fd68eb..9082756b52 100644
--- 
a/seatunnel-connectors-v2/connector-openmldb/src/main/java/org/apache/seatunnel/connectors/seatunnel/openmldb/source/OpenMldbSourceReader.java
+++ 
b/seatunnel-connectors-v2/connector-openmldb/src/main/java/org/apache/seatunnel/connectors/seatunnel/openmldb/source/OpenMldbSourceReader.java
@@ -19,101 +19,108 @@ package 
org.apache.seatunnel.connectors.seatunnel.openmldb.source;
 
 import org.apache.seatunnel.api.source.Boundedness;
 import org.apache.seatunnel.api.source.Collector;
-import org.apache.seatunnel.api.table.type.SeaTunnelDataType;
+import org.apache.seatunnel.api.table.catalog.CatalogTable;
 import org.apache.seatunnel.api.table.type.SeaTunnelRow;
 import org.apache.seatunnel.api.table.type.SeaTunnelRowType;
 import org.apache.seatunnel.common.exception.CommonErrorCodeDeprecated;
 import 
org.apache.seatunnel.connectors.seatunnel.common.source.AbstractSingleSplitReader;
 import 
org.apache.seatunnel.connectors.seatunnel.common.source.SingleSplitReaderContext;
 import 
org.apache.seatunnel.connectors.seatunnel.openmldb.config.OpenMldbParameters;
-import 
org.apache.seatunnel.connectors.seatunnel.openmldb.config.OpenMldbSqlExecutor;
 import 
org.apache.seatunnel.connectors.seatunnel.openmldb.exception.OpenMldbConnectorException;
 
-import com._4paradigm.openmldb.sdk.impl.SqlClusterExecutor;
 import lombok.extern.slf4j.Slf4j;
 
 import java.io.IOException;
-import java.sql.Date;
-import java.sql.ResultSet;
 import java.sql.SQLException;
-import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
 
 @Slf4j
 public class OpenMldbSourceReader extends 
AbstractSingleSplitReader<SeaTunnelRow> {
-    private final OpenMldbParameters openMldbParameters;
-    private final SeaTunnelRowType seaTunnelRowType;
+    private final List<OpenMldbParameters> tableParameters;
+    private final List<SeaTunnelRowType> rowTypes;
+    private final List<String> tableIds;
     private final SingleSplitReaderContext readerContext;
+    private OpenMldbReadClient client;
+    private boolean finished;
 
     public OpenMldbSourceReader(
             OpenMldbParameters openMldbParameters,
             SeaTunnelRowType seaTunnelRowType,
             SingleSplitReaderContext readerContext) {
-        this.openMldbParameters = openMldbParameters;
-        this.seaTunnelRowType = seaTunnelRowType;
+        this.tableParameters = Collections.singletonList(openMldbParameters);
+        this.rowTypes = Collections.singletonList(seaTunnelRowType);
+        this.tableIds = Collections.singletonList(null);
+        this.readerContext = readerContext;
+    }
+
+    OpenMldbSourceReader(
+            List<OpenMldbParameters> tableParameters,
+            List<CatalogTable> catalogTables,
+            boolean multiTable,
+            SingleSplitReaderContext readerContext) {
+        this.tableParameters = new ArrayList<>(tableParameters);
+        this.rowTypes = new ArrayList<>();
+        this.tableIds = new ArrayList<>();
+        for (CatalogTable table : catalogTables) {
+            rowTypes.add(table.getSeaTunnelRowType());
+            tableIds.add(multiTable ? 
table.getTableId().toTablePath().toString() : null);
+        }
         this.readerContext = readerContext;
     }
 
     @Override
     public void open() throws Exception {
-        OpenMldbSqlExecutor.initSdkOption(openMldbParameters);
+        client = new OpenMldbReadClient(tableParameters.get(0));
     }
 
     @Override
     public void close() throws IOException {
-        OpenMldbSqlExecutor.close();
+        if (client != null) {
+            client.close();
+            client = null;
+        }
     }
 
     @Override
     public void pollNext(Collector<SeaTunnelRow> output) throws Exception {
-        int totalFields = seaTunnelRowType.getTotalFields();
-        Object[] objects = new Object[totalFields];
-        SqlClusterExecutor sqlExecutor = OpenMldbSqlExecutor.getSqlExecutor();
-        try (ResultSet resultSet =
-                sqlExecutor.executeSQL(
-                        openMldbParameters.getDatabase(), 
openMldbParameters.getSql())) {
-            while (resultSet.next()) {
-                for (int i = 0; i < totalFields; i++) {
-                    objects[i] = getObject(resultSet, i, 
seaTunnelRowType.getFieldType(i));
-                }
-                output.collect(new SeaTunnelRow(objects));
-            }
-        } finally {
-            if (Boundedness.BOUNDED.equals(readerContext.getBoundedness())) {
-                // signal to the source that we have reached the end of the 
data.
-                log.info("Closed the bounded openmldb source");
-                readerContext.signalNoMoreElement();
-            }
+        if (finished) {
+            return;
+        }
+        for (int i = 0; i < tableParameters.size(); i++) {
+            readTable(output, tableParameters.get(i), rowTypes.get(i), 
tableIds.get(i));
+        }
+        if (Boundedness.BOUNDED.equals(readerContext.getBoundedness())) {
+            finished = true;
+            log.info("Finished reading the bounded OpenMldb source");
+            readerContext.signalNoMoreElement();
         }
     }
 
-    private Object getObject(ResultSet resultSet, int index, 
SeaTunnelDataType<?> dataType)
+    private void readTable(
+            Collector<SeaTunnelRow> output,
+            OpenMldbParameters parameters,
+            SeaTunnelRowType rowType,
+            String tableId)
             throws SQLException {
-        index = index + 1;
-        switch (dataType.getSqlType()) {
-            case BOOLEAN:
-                return resultSet.getBoolean(index);
-            case INT:
-                return resultSet.getInt(index);
-            case SMALLINT:
-                return resultSet.getShort(index);
-            case BIGINT:
-                return resultSet.getLong(index);
-            case FLOAT:
-                return resultSet.getFloat(index);
-            case DOUBLE:
-                return resultSet.getDouble(index);
-            case STRING:
-                return resultSet.getString(index);
-            case DATE:
-                Date date = resultSet.getDate(index);
-                return date.toLocalDate();
-            case TIMESTAMP:
-                Timestamp timestamp = resultSet.getTimestamp(index);
-                return timestamp.toLocalDateTime();
-            default:
-                throw new OpenMldbConnectorException(
-                        CommonErrorCodeDeprecated.UNSUPPORTED_DATA_TYPE,
-                        "Unsupported this data type");
+        try (OpenMldbReadClient.Query query =
+                client.execute(
+                        parameters.getDatabase(), parameters.getSql(), 
rowType, tableId != null)) {
+            while (query.next()) {
+                SeaTunnelRow row = query.readRow();
+                if (tableId != null) {
+                    row.setTableId(tableId);
+                }
+                output.collect(row);
+            }
+        } catch (SQLException | RuntimeException e) {
+            throw new OpenMldbConnectorException(
+                    CommonErrorCodeDeprecated.READER_OPERATION_FAILED,
+                    "Failed to read OpenMldb table '"
+                            + (tableId == null ? parameters.getDatabase() : 
tableId)
+                            + "'",
+                    e);
         }
     }
 }
diff --git 
a/seatunnel-connectors-v2/connector-openmldb/src/test/java/org/apache/seatunnel/connectors/seatunnel/openmldb/OpenMldbFactoryTest.java
 
b/seatunnel-connectors-v2/connector-openmldb/src/test/java/org/apache/seatunnel/connectors/seatunnel/openmldb/OpenMldbFactoryTest.java
index daccae7527..a1a51954d7 100644
--- 
a/seatunnel-connectors-v2/connector-openmldb/src/test/java/org/apache/seatunnel/connectors/seatunnel/openmldb/OpenMldbFactoryTest.java
+++ 
b/seatunnel-connectors-v2/connector-openmldb/src/test/java/org/apache/seatunnel/connectors/seatunnel/openmldb/OpenMldbFactoryTest.java
@@ -28,6 +28,8 @@ import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
 
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -35,6 +37,137 @@ class OpenMldbFactoryTest {
 
     private final OptionRule optionRule = new 
OpenMldbSourceFactory().optionRule();
 
+    @Test
+    void testMultiTableConfigAccepted() {
+        Map<String, Object> config = requiredConfig(false);
+        config.remove("sql");
+        config.put(
+                "tables_configs",
+                Arrays.asList(
+                        table("orders", "select * from orders"),
+                        table("customers", "select * from customers")));
+        Assertions.assertDoesNotThrow(() -> validate(config));
+    }
+
+    private Map<String, Object> table(String name, String sql) {
+        Map<String, Object> table = new HashMap<>();
+        table.put("sql", sql);
+        Map<String, Object> schema = new HashMap<>();
+        schema.put("table", name);
+        schema.put("fields", Collections.singletonMap("id", "STRING"));
+        table.put("schema", schema);
+        return table;
+    }
+
+    @Test
+    void testSqlAndTablesAreMutuallyExclusive() {
+        Map<String, Object> config = requiredConfig(false);
+        config.put(
+                "tables_configs",
+                Collections.singletonList(table("orders", "select * from 
orders")));
+        Assertions.assertThrows(OptionValidationException.class, () -> 
validate(config));
+    }
+
+    @Test
+    void testEmptyTablesRejected() {
+        Map<String, Object> config = multiConfig();
+        config.put("tables_configs", Collections.emptyList());
+        Assertions.assertThrows(OptionValidationException.class, () -> 
validate(config));
+    }
+
+    @Test
+    void testDuplicateTableIdentityRejected() {
+        Map<String, Object> config = multiConfig();
+        config.put(
+                "tables_configs",
+                Arrays.asList(
+                        table("orders", "select * from orders"),
+                        table("orders", "select * from archived")));
+        Assertions.assertThrows(OptionValidationException.class, () -> 
validate(config));
+    }
+
+    @Test
+    void testEntrySqlRequiredAndNotBlank() {
+        for (String sql : new String[] {null, "", " \t\n"}) {
+            Map<String, Object> config = multiConfig();
+            config.put("tables_configs", 
Collections.singletonList(table("orders", sql)));
+            Assertions.assertThrows(OptionValidationException.class, () -> 
validate(config));
+        }
+    }
+
+    @Test
+    void testSchemaFieldsRequired() {
+        for (Object fields : new Object[] {null, Collections.emptyMap(), "id 
STRING"}) {
+            Map<String, Object> config = multiConfig();
+            Map<String, Object> entry = table("orders", "select * from 
orders");
+            ((Map<String, Object>) entry.get("schema")).put("fields", fields);
+            config.put("tables_configs", Collections.singletonList(entry));
+            Assertions.assertThrows(OptionValidationException.class, () -> 
validate(config));
+        }
+    }
+
+    @Test
+    void testTableIdentityRequired() {
+        for (String tableId : new String[] {null, "", " \t"}) {
+            Map<String, Object> config = multiConfig();
+            config.put(
+                    "tables_configs",
+                    Collections.singletonList(table(tableId, "select * from 
orders")));
+            Assertions.assertThrows(OptionValidationException.class, () -> 
validate(config));
+        }
+    }
+
+    @Test
+    void testRootSchemaRejectedInMultiTableMode() {
+        Map<String, Object> config = multiConfig();
+        config.put("schema", table("root", "select * from 
orders").get("schema"));
+        Assertions.assertThrows(OptionValidationException.class, () -> 
validate(config));
+    }
+
+    @Test
+    void testEntryConnectionOverridesRejected() {
+        for (String key :
+                new String[] {"host", "port", "cluster_mode", "zk_host", 
"request_timeout"}) {
+            Map<String, Object> config = multiConfig();
+            Map<String, Object> entry = table("orders", "select * from 
orders");
+            entry.put(key, "override");
+            config.put("tables_configs", Collections.singletonList(entry));
+            Assertions.assertThrows(OptionValidationException.class, () -> 
validate(config));
+        }
+    }
+
+    @Test
+    void testEntryDatabaseOverrideValidated() {
+        Map<String, Object> config = multiConfig();
+        Map<String, Object> entry = table("orders", "select * from orders");
+        config.put("tables_configs", Collections.singletonList(entry));
+        entry.put("database", "another_db");
+        Assertions.assertDoesNotThrow(() -> validate(config));
+        entry.put("database", " ");
+        Assertions.assertThrows(OptionValidationException.class, () -> 
validate(config));
+    }
+
+    private Map<String, Object> multiConfig() {
+        Map<String, Object> config = requiredConfig(false);
+        config.remove("sql");
+        config.put(
+                "tables_configs",
+                Collections.singletonList(table("orders", "select * from 
orders")));
+        return config;
+    }
+
+    @Test
+    void testUnsupportedFieldTypesRejectedBeforeConnecting() {
+        for (String type : new String[] {"DECIMAL(10,2)", "TINYINT", 
"ARRAY<INT>", "BYTES"}) {
+            Map<String, Object> config = multiConfig();
+            Map<String, Object> entry = table("orders", "select * from 
orders");
+            ((Map<String, Object>) entry.get("schema"))
+                    .put("fields", Collections.singletonMap("id", type));
+            config.put("tables_configs", Collections.singletonList(entry));
+            Assertions.assertThrows(OptionValidationException.class, () -> 
validate(config));
+        }
+    }
+
     @ParameterizedTest
     @ValueSource(booleans = {false, true})
     void testNonblankSqlAccepted(boolean clusterMode) {
diff --git 
a/seatunnel-connectors-v2/connector-openmldb/src/test/java/org/apache/seatunnel/connectors/seatunnel/openmldb/source/OpenMldbSourceReaderTest.java
 
b/seatunnel-connectors-v2/connector-openmldb/src/test/java/org/apache/seatunnel/connectors/seatunnel/openmldb/source/OpenMldbSourceReaderTest.java
new file mode 100644
index 0000000000..8cad021ff7
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-openmldb/src/test/java/org/apache/seatunnel/connectors/seatunnel/openmldb/source/OpenMldbSourceReaderTest.java
@@ -0,0 +1,238 @@
+/*
+ * 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.openmldb.source;
+
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.source.Boundedness;
+import org.apache.seatunnel.api.source.Collector;
+import org.apache.seatunnel.api.table.catalog.CatalogTable;
+import org.apache.seatunnel.api.table.catalog.CatalogTableUtil;
+import org.apache.seatunnel.api.table.type.SeaTunnelRow;
+import 
org.apache.seatunnel.connectors.seatunnel.common.source.SingleSplitReaderContext;
+import 
org.apache.seatunnel.connectors.seatunnel.openmldb.config.OpenMldbParameters;
+import 
org.apache.seatunnel.connectors.seatunnel.openmldb.exception.OpenMldbConnectorException;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedConstruction;
+
+import java.sql.SQLException;
+import java.util.Arrays;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+class OpenMldbSourceReaderTest {
+    private final CatalogTable first = table("first", "id = STRING");
+    private final CatalogTable second = table("second", "value = INT");
+    private final SingleSplitReaderContext context = 
mock(SingleSplitReaderContext.class);
+    private final Collector<SeaTunnelRow> output = mock(Collector.class);
+
+    @Test
+    void readsDifferentSchemasAndCompletesOnlyOnce() throws Exception {
+        OpenMldbReadClient.Query firstQuery = query(new SeaTunnelRow(new 
Object[] {"key"}));
+        OpenMldbReadClient.Query secondQuery = query(new SeaTunnelRow(new 
Object[] {null}));
+        when(context.getBoundedness()).thenReturn(Boundedness.BOUNDED);
+        try (MockedConstruction<OpenMldbReadClient> clients =
+                mockConstruction(
+                        OpenMldbReadClient.class,
+                        (client, ignored) -> {
+                            when(client.execute(
+                                            "one",
+                                            "select * from first",
+                                            first.getSeaTunnelRowType(),
+                                            true))
+                                    .thenReturn(firstQuery);
+                            when(client.execute(
+                                            "two",
+                                            "select * from second",
+                                            second.getSeaTunnelRowType(),
+                                            true))
+                                    .thenReturn(secondQuery);
+                        })) {
+            OpenMldbSourceReader reader = reader();
+            reader.open();
+            reader.pollNext(output);
+            reader.pollNext(output);
+            ArgumentCaptor<SeaTunnelRow> rows = 
ArgumentCaptor.forClass(SeaTunnelRow.class);
+            verify(output, times(2)).collect(rows.capture());
+            assertEquals(
+                    first.getTableId().toTablePath().toString(),
+                    rows.getAllValues().get(0).getTableId());
+            assertEquals(
+                    second.getTableId().toTablePath().toString(),
+                    rows.getAllValues().get(1).getTableId());
+            assertNull(rows.getAllValues().get(1).getField(0));
+            verify(context).signalNoMoreElement();
+            verify(firstQuery).close();
+            verify(secondQuery).close();
+            reader.close();
+            reader.close();
+            verify(clients.constructed().get(0)).close();
+        }
+    }
+
+    @Test
+    void emptyFirstTableDoesNotSkipSecondTable() throws Exception {
+        OpenMldbReadClient.Query empty = mock(OpenMldbReadClient.Query.class);
+        OpenMldbReadClient.Query populated = query(new SeaTunnelRow(new 
Object[] {7}));
+        try (MockedConstruction<OpenMldbReadClient> ignored =
+                mockConstruction(
+                        OpenMldbReadClient.class,
+                        (client, construction) -> {
+                            when(client.execute(anyString(), anyString(), 
any(), anyBoolean()))
+                                    .thenReturn(empty, populated);
+                        })) {
+            try (OpenMldbSourceReader reader = reader()) {
+                reader.open();
+                reader.pollNext(output);
+            }
+            verify(output).collect(any(SeaTunnelRow.class));
+            verify(empty).close();
+            verify(populated).close();
+        }
+    }
+
+    @Test
+    void queryFailureDoesNotReportSuccessfulCompletion() throws Exception {
+        when(context.getBoundedness()).thenReturn(Boundedness.BOUNDED);
+        try (MockedConstruction<OpenMldbReadClient> ignored =
+                mockConstruction(
+                        OpenMldbReadClient.class,
+                        (client, construction) -> {
+                            when(client.execute(anyString(), anyString(), 
any(), anyBoolean()))
+                                    .thenThrow(new SQLException("query 
rejected"));
+                        })) {
+            try (OpenMldbSourceReader reader = reader()) {
+                reader.open();
+                OpenMldbConnectorException error =
+                        assertThrows(
+                                OpenMldbConnectorException.class, () -> 
reader.pollNext(output));
+                assertTrue(error.getMessage().contains("first"));
+                assertInstanceOf(SQLException.class, error.getCause());
+            }
+            verify(context, never()).signalNoMoreElement();
+            verifyNoInteractions(output);
+        }
+    }
+
+    @Test
+    void collectorFailureClosesQueryAndDoesNotComplete() throws Exception {
+        OpenMldbReadClient.Query query = query(new SeaTunnelRow(new Object[] 
{"key"}));
+        doThrow(new IllegalStateException("collector failed"))
+                .when(output)
+                .collect(any(SeaTunnelRow.class));
+        try (MockedConstruction<OpenMldbReadClient> ignored =
+                mockConstruction(
+                        OpenMldbReadClient.class,
+                        (client, construction) -> {
+                            when(client.execute(anyString(), anyString(), 
any(), anyBoolean()))
+                                    .thenReturn(query);
+                        })) {
+            try (OpenMldbSourceReader reader = reader()) {
+                reader.open();
+                assertThrows(OpenMldbConnectorException.class, () -> 
reader.pollNext(output));
+            }
+            verify(query).close();
+            verify(context, never()).signalNoMoreElement();
+        }
+    }
+
+    @Test
+    void streamingRepeatsQueriesWithoutCompleting() throws Exception {
+        when(context.getBoundedness()).thenReturn(Boundedness.UNBOUNDED);
+        try (MockedConstruction<OpenMldbReadClient> clients =
+                mockConstruction(
+                        OpenMldbReadClient.class,
+                        (client, construction) -> {
+                            when(client.execute(anyString(), anyString(), 
any(), anyBoolean()))
+                                    .thenAnswer(invocation -> 
mock(OpenMldbReadClient.Query.class));
+                        })) {
+            try (OpenMldbSourceReader reader = reader()) {
+                reader.open();
+                reader.pollNext(output);
+                reader.pollNext(output);
+                verify(clients.constructed().get(0), times(4))
+                        .execute(anyString(), anyString(), any(), 
anyBoolean());
+            }
+            verify(context, never()).signalNoMoreElement();
+        }
+    }
+
+    @Test
+    void closingOneReaderDoesNotCloseAnother() throws Exception {
+        try (MockedConstruction<OpenMldbReadClient> clients =
+                mockConstruction(OpenMldbReadClient.class)) {
+            OpenMldbSourceReader firstReader = reader();
+            OpenMldbSourceReader secondReader = reader();
+            firstReader.open();
+            secondReader.open();
+            firstReader.close();
+            verify(clients.constructed().get(0)).close();
+            verifyNoInteractions(clients.constructed().get(1));
+            secondReader.close();
+            verify(clients.constructed().get(1)).close();
+        }
+    }
+
+    private OpenMldbSourceReader reader() {
+        return new OpenMldbSourceReader(
+                Arrays.asList(parameters("one", "first"), parameters("two", 
"second")),
+                Arrays.asList(first, second),
+                true,
+                context);
+    }
+
+    private static OpenMldbReadClient.Query query(SeaTunnelRow row) {
+        OpenMldbReadClient.Query query = mock(OpenMldbReadClient.Query.class);
+        when(query.next()).thenReturn(true, false);
+        when(query.readRow()).thenReturn(row);
+        return query;
+    }
+
+    private static CatalogTable table(String name, String fields) {
+        return CatalogTableUtil.buildWithConfig(
+                ReadonlyConfig.fromConfig(
+                        
org.apache.seatunnel.shade.com.typesafe.config.ConfigFactory.parseString(
+                                "schema { table = " + name + ", fields { " + 
fields + " } }")));
+    }
+
+    private static OpenMldbParameters parameters(String database, String 
table) {
+        return OpenMldbParameters.buildWithConfig(
+                
org.apache.seatunnel.shade.com.typesafe.config.ConfigFactory.parseString(
+                        "cluster_mode = false\nhost = localhost\nport = 
6527\ndatabase = "
+                                + database
+                                + "\nsql = \"select * from "
+                                + table
+                                + "\""));
+    }
+}
diff --git 
a/seatunnel-connectors-v2/connector-openmldb/src/test/java/org/apache/seatunnel/connectors/seatunnel/openmldb/source/OpenMldbSourceTest.java
 
b/seatunnel-connectors-v2/connector-openmldb/src/test/java/org/apache/seatunnel/connectors/seatunnel/openmldb/source/OpenMldbSourceTest.java
new file mode 100644
index 0000000000..80509d2fac
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-openmldb/src/test/java/org/apache/seatunnel/connectors/seatunnel/openmldb/source/OpenMldbSourceTest.java
@@ -0,0 +1,143 @@
+/*
+ * 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.openmldb.source;
+
+import org.apache.seatunnel.shade.com.typesafe.config.ConfigFactory;
+
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.table.factory.TableSourceFactoryContext;
+import org.apache.seatunnel.api.table.type.SeaTunnelRow;
+import org.apache.seatunnel.connectors.seatunnel.common.source.SingleSplit;
+import 
org.apache.seatunnel.connectors.seatunnel.common.source.SingleSplitEnumeratorState;
+import 
org.apache.seatunnel.connectors.seatunnel.openmldb.config.OpenMldbParameters;
+import 
org.apache.seatunnel.connectors.seatunnel.openmldb.exception.OpenMldbConnectorException;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedConstruction;
+
+import com._4paradigm.openmldb.sdk.Column;
+import com._4paradigm.openmldb.sdk.Schema;
+import com._4paradigm.openmldb.sdk.impl.SqlClusterExecutor;
+
+import java.io.ByteArrayOutputStream;
+import java.io.ObjectOutputStream;
+import java.sql.SQLException;
+import java.sql.Types;
+import java.util.Arrays;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class OpenMldbSourceTest {
+    @Test
+    void discoveryPreservesNullabilityAndClosesExecutor() throws Exception {
+        Schema schema =
+                new Schema(
+                        Arrays.asList(
+                                new Column("id", Types.VARCHAR, true, false),
+                                new Column("value", Types.INTEGER, false, 
false)));
+        try (MockedConstruction<SqlClusterExecutor> executors =
+                mockConstruction(
+                        SqlClusterExecutor.class,
+                        (executor, context) ->
+                                when(executor.getInputSchema(anyString(), 
anyString()))
+                                        .thenReturn(schema))) {
+            OpenMldbSource source = new OpenMldbSource(parameters());
+            assertEquals(1, source.getProducedCatalogTables().size());
+            assertFalse(
+                    source.getProducedCatalogTables()
+                            .get(0)
+                            .getTableSchema()
+                            .getColumns()
+                            .get(0)
+                            .isNullable());
+            assertTrue(
+                    source.getProducedCatalogTables()
+                            .get(0)
+                            .getTableSchema()
+                            .getColumns()
+                            .get(1)
+                            .isNullable());
+            verify(executors.constructed().get(0)).close();
+        }
+    }
+
+    @Test
+    void failedDiscoveryClosesExecutorAndPreservesCause() throws Exception {
+        SQLException cause = new SQLException("invalid query");
+        try (MockedConstruction<SqlClusterExecutor> executors =
+                mockConstruction(
+                        SqlClusterExecutor.class,
+                        (executor, context) ->
+                                when(executor.getInputSchema(anyString(), 
anyString()))
+                                        .thenThrow(cause))) {
+            OpenMldbConnectorException error =
+                    assertThrows(
+                            OpenMldbConnectorException.class,
+                            () -> new OpenMldbSource(parameters()));
+            assertEquals(cause, error.getCause());
+            verify(executors.constructed().get(0)).close();
+        }
+    }
+
+    @Test
+    void multiTableMetadataDoesNotConnectAndSourceIsSerializable() throws 
Exception {
+        ReadonlyConfig config =
+                ReadonlyConfig.fromConfig(
+                        ConfigFactory.parseString(
+                                
"cluster_mode=false\nhost=unused\nport=6527\ndatabase=test\n"
+                                        + "tables_configs=["
+                                        + "{sql=\"select id from 
orders\",schema{table=orders,fields{id=STRING}}},"
+                                        + "{sql=\"select amount from 
sales\",database=other,schema{table=sales,fields{amount=INT}}}"
+                                        + "]"));
+        try (MockedConstruction<SqlClusterExecutor> executors =
+                mockConstruction(SqlClusterExecutor.class)) {
+            OpenMldbSource source =
+                    (OpenMldbSource)
+                            new OpenMldbSourceFactory()
+                                    .<SeaTunnelRow, SingleSplit, 
SingleSplitEnumeratorState>
+                                            createSource(
+                                                    new 
TableSourceFactoryContext(
+                                                            config, 
getClass().getClassLoader()))
+                                    .createSource();
+            assertEquals(2, source.getProducedCatalogTables().size());
+            assertEquals(
+                    "orders",
+                    
source.getProducedCatalogTables().get(0).getTableId().toTablePath().toString());
+            assertEquals(
+                    "sales",
+                    
source.getProducedCatalogTables().get(1).getTableId().toTablePath().toString());
+            assertTrue(executors.constructed().isEmpty());
+            try (ObjectOutputStream out = new ObjectOutputStream(new 
ByteArrayOutputStream())) {
+                out.writeObject(source);
+            }
+        }
+    }
+
+    private OpenMldbParameters parameters() {
+        return OpenMldbParameters.buildWithConfig(
+                ConfigFactory.parseString(
+                        
"cluster_mode=false\nhost=localhost\nport=6527\ndatabase=test\nsql=\"select * 
from values_table\""));
+    }
+}
diff --git 
a/seatunnel-connectors-v2/connector-openmldb/src/test/java/org/apache/seatunnel/connectors/seatunnel/openmldb/source/TestOpenMldbSourceIT.java
 
b/seatunnel-connectors-v2/connector-openmldb/src/test/java/org/apache/seatunnel/connectors/seatunnel/openmldb/source/TestOpenMldbSourceIT.java
new file mode 100644
index 0000000000..98c2b52087
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-openmldb/src/test/java/org/apache/seatunnel/connectors/seatunnel/openmldb/source/TestOpenMldbSourceIT.java
@@ -0,0 +1,386 @@
+/*
+ * 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.openmldb.source;
+
+import org.apache.seatunnel.shade.com.typesafe.config.ConfigFactory;
+
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.source.Boundedness;
+import org.apache.seatunnel.api.source.Collector;
+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.api.table.type.SeaTunnelRowType;
+import org.apache.seatunnel.connectors.seatunnel.common.source.SingleSplit;
+import 
org.apache.seatunnel.connectors.seatunnel.common.source.SingleSplitEnumeratorState;
+import 
org.apache.seatunnel.connectors.seatunnel.common.source.SingleSplitReaderContext;
+import 
org.apache.seatunnel.connectors.seatunnel.openmldb.config.OpenMldbParameters;
+import 
org.apache.seatunnel.connectors.seatunnel.openmldb.config.OpenMldbSqlExecutor;
+import 
org.apache.seatunnel.connectors.seatunnel.openmldb.exception.OpenMldbConnectorException;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
+
+import com._4paradigm.openmldb.sdk.impl.SqlClusterExecutor;
+
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Runs against a disposable OpenMLDB 0.6.3 server. Enable with 
-Dopenmldb.integration=true and
+ * optionally -Dopenmldb.host / -Dopenmldb.port for standalone mode, or 
-Dopenmldb.cluster=true
+ * -Dopenmldb.zk.host -Dopenmldb.zk.path for cluster mode. The SDK's native 
library requires a
+ * compatible Linux amd64 runtime.
+ */
+@EnabledIfSystemProperty(named = "openmldb.integration", matches = "true")
+class TestOpenMldbSourceIT {
+    private static final String DATABASE = "st_" + 
UUID.randomUUID().toString().replace("-", "");
+    private static final String SECOND_DATABASE = DATABASE + "_second";
+    private static final String FIELDS =
+            "id=STRING, b=BOOLEAN, s=SMALLINT, i=INT, l=BIGINT, "
+                    + "f=FLOAT, d=DOUBLE, text=STRING, day=DATE, ts=TIMESTAMP";
+    private static SqlClusterExecutor setup;
+
+    @BeforeAll
+    static void prepare() throws Exception {
+        setup = OpenMldbSqlExecutor.create(parameters("select * from 
values_table"));
+        assertTrue(setup.createDB(DATABASE));
+        assertTrue(setup.createDB(SECOND_DATABASE));
+        String columns =
+                "(id string, b bool, s smallint, i int, l bigint, f float, d 
double, "
+                        + "text string, day date, ts timestamp, 
index(key=id));";
+        assertTrue(setup.executeDDL(DATABASE, "create table values_table" + 
columns));
+        assertTrue(setup.executeDDL(DATABASE, "create table empty_table" + 
columns));
+        assertTrue(
+                setup.executeInsert(
+                        DATABASE,
+                        "insert into values_table values"
+                                + "('nulls', null, null, null, null, null, 
null, null, null, null),"
+                                + "('zeros', false, 0, 0, 0, 0.0, 0.0, '', 
'2020-02-29', 0),"
+                                + "('values', true, -12, 42, 1234567890123, 
1.5, 2.25, 'hello', '2024-01-02', 1704153600000);"));
+        assertTrue(
+                setup.executeDDL(
+                        SECOND_DATABASE,
+                        "create table orders(id string, amount int, 
index(key=id));"));
+        assertTrue(setup.executeInsert(SECOND_DATABASE, "insert into orders 
values('order', 7);"));
+        assertTrue(
+                setup.executeDDL(
+                        DATABASE,
+                        "create table bulk_table(id string, seq int, 
index(key=id))"
+                                + (Boolean.getBoolean("openmldb.cluster")
+                                        ? " 
options(partitionnum=4,replicanum=1)"
+                                        : "")
+                                + ";"));
+        StringBuilder bulk = new StringBuilder("insert into bulk_table 
values");
+        for (int i = 0; i < 1101; i++) {
+            if (i > 0) {
+                bulk.append(',');
+            }
+            bulk.append("('key").append(i).append("',").append(i).append(')');
+        }
+        assertTrue(setup.executeInsert(DATABASE, bulk.append(';').toString()));
+    }
+
+    @AfterAll
+    static void cleanup() {
+        if (setup != null) {
+            setup.executeDDL(DATABASE, "drop table values_table;");
+            setup.executeDDL(DATABASE, "drop table empty_table;");
+            setup.executeDDL(DATABASE, "drop table bulk_table;");
+            setup.executeDDL(SECOND_DATABASE, "drop table orders;");
+            setup.dropDB(DATABASE);
+            setup.dropDB(SECOND_DATABASE);
+            setup.close();
+        }
+    }
+
+    @Test
+    void multiTablePreservesNullsTypesAndTableIdentity() throws Exception {
+        OpenMldbSource source =
+                source(
+                        entry("empty", "select * from empty_table", FIELDS, 
null)
+                                + ","
+                                + entry("values", "select * from 
values_table", FIELDS, null)
+                                + ","
+                                + entry(
+                                        "orders",
+                                        "select amount as total, id as 
order_id from orders",
+                                        "order_id=STRING, total=INT",
+                                        SECOND_DATABASE));
+        assertEquals(3, source.getProducedCatalogTables().size());
+        SingleSplitReaderContext context = context();
+        List<SeaTunnelRow> rows = new ArrayList<>();
+        try (OpenMldbSourceReader reader = (OpenMldbSourceReader) 
source.createReader(context)) {
+            reader.open();
+            reader.pollNext(collector(rows));
+            reader.pollNext(collector(rows));
+        }
+        assertEquals(4, rows.size());
+        verify(context).signalNoMoreElement();
+        CatalogTable values = source.getProducedCatalogTables().get(1);
+        Map<String, SeaTunnelRow> byId = new HashMap<>();
+        for (SeaTunnelRow row : rows) {
+            if 
(values.getTableId().toTablePath().toString().equals(row.getTableId())) {
+                byId.put((String) field(row, values.getSeaTunnelRowType(), 
"id"), row);
+            }
+        }
+        assertEquals(3, byId.size());
+        SeaTunnelRow nulls = byId.get("nulls");
+        for (String name : new String[] {"b", "s", "i", "l", "f", "d", "text", 
"day", "ts"}) {
+            assertNull(field(nulls, values.getSeaTunnelRowType(), name), name);
+        }
+        SeaTunnelRow zeros = byId.get("zeros");
+        assertEquals(false, field(zeros, values.getSeaTunnelRowType(), "b"));
+        assertEquals((short) 0, field(zeros, values.getSeaTunnelRowType(), 
"s"));
+        assertEquals(0, field(zeros, values.getSeaTunnelRowType(), "i"));
+        assertEquals(0L, field(zeros, values.getSeaTunnelRowType(), "l"));
+        assertEquals(0F, field(zeros, values.getSeaTunnelRowType(), "f"));
+        assertEquals(0D, field(zeros, values.getSeaTunnelRowType(), "d"));
+        assertEquals("", field(zeros, values.getSeaTunnelRowType(), "text"));
+        assertEquals(LocalDate.of(2020, 2, 29), field(zeros, 
values.getSeaTunnelRowType(), "day"));
+        assertEquals(
+                new Timestamp(0).toLocalDateTime(),
+                field(zeros, values.getSeaTunnelRowType(), "ts"));
+        SeaTunnelRow nonNull = byId.get("values");
+        Object[] expected = {
+            true,
+            (short) -12,
+            42,
+            1234567890123L,
+            1.5F,
+            2.25D,
+            "hello",
+            LocalDate.of(2024, 1, 2),
+            new Timestamp(1704153600000L).toLocalDateTime()
+        };
+        String[] names = {"b", "s", "i", "l", "f", "d", "text", "day", "ts"};
+        for (int i = 0; i < names.length; i++) {
+            assertEquals(
+                    expected[i], field(nonNull, values.getSeaTunnelRowType(), 
names[i]), names[i]);
+        }
+        assertNotSame(nulls.getFields(), zeros.getFields());
+        CatalogTable orders = source.getProducedCatalogTables().get(2);
+        SeaTunnelRow order =
+                rows.stream()
+                        .filter(
+                                row ->
+                                        row.getTableId()
+                                                .equals(
+                                                        orders.getTableId()
+                                                                .toTablePath()
+                                                                .toString()))
+                        .findFirst()
+                        .get();
+        assertEquals("order", field(order, orders.getSeaTunnelRowType(), 
"order_id"));
+        assertEquals(7, field(order, orders.getSeaTunnelRowType(), "total"));
+    }
+
+    @Test
+    void legacySourcePreservesNullsAndReaderOwnership() throws Exception {
+        OpenMldbSource legacy = new OpenMldbSource(parameters("select * from 
values_table"));
+        assertTrue(
+                legacy.getProducedCatalogTables()
+                        .get(0)
+                        .getTableSchema()
+                        .getColumns()
+                        .get(1)
+                        .isNullable());
+        SingleSplitReaderContext context = context();
+        try (OpenMldbSourceReader first = (OpenMldbSourceReader) 
legacy.createReader(context);
+                OpenMldbSourceReader second = (OpenMldbSourceReader) 
legacy.createReader(context)) {
+            first.open();
+            second.open();
+            first.close();
+            List<SeaTunnelRow> rows = new ArrayList<>();
+            second.pollNext(collector(rows));
+            assertEquals(3, rows.size());
+            SeaTunnelRow nulls =
+                    rows.stream().filter(row -> 
"nulls".equals(row.getField(0))).findFirst().get();
+            for (int i = 1; i < 10; i++) {
+                assertNull(nulls.getField(i));
+            }
+        }
+    }
+
+    @Test
+    void rejectsInvalidQueryAndSchemaWithoutSuccessfulCompletion() throws 
Exception {
+        for (String entry :
+                new String[] {
+                    entry("missing", "select * from missing_table", 
"id=STRING", null),
+                    entry("count", "select * from values_table", "id=STRING", 
null),
+                    entry("type", "select i from values_table", "i=STRING", 
null),
+                    entry("name", "select i from values_table", "wrong=INT", 
null),
+                    entry(
+                            "duplicate",
+                            "select i as value, i as value from values_table",
+                            "value=INT, other=INT",
+                            null)
+                }) {
+            OpenMldbSource source = source(entry);
+            SingleSplitReaderContext context = context();
+            List<SeaTunnelRow> rows = new ArrayList<>();
+            try (OpenMldbSourceReader reader =
+                    (OpenMldbSourceReader) source.createReader(context)) {
+                reader.open();
+                OpenMldbConnectorException error =
+                        assertThrows(
+                                OpenMldbConnectorException.class,
+                                () -> reader.pollNext(collector(rows)));
+                assertTrue(error.getCause() instanceof SQLException);
+            }
+            assertTrue(rows.isEmpty());
+            verify(context, never()).signalNoMoreElement();
+        }
+    }
+
+    @Test
+    void invalidQueryDoesNotPreventLaterQueriesOnSameClient() throws Exception 
{
+        OpenMldbSource source =
+                source(entry("one", "select id from values_table", 
"id=STRING", null));
+        SeaTunnelRowType type = 
source.getProducedCatalogTables().get(0).getSeaTunnelRowType();
+        try (OpenMldbReadClient client =
+                new OpenMldbReadClient(parameters("select id from 
values_table"))) {
+            assertThrows(
+                    SQLException.class,
+                    () -> client.execute(DATABASE, "select * from 
missing_table", type, true));
+            try (OpenMldbReadClient.Query query =
+                    client.execute(DATABASE, "select id from values_table", 
type, true)) {
+                int rows = 0;
+                while (query.next()) {
+                    
assertFalse(query.readRow().getField(0).toString().isEmpty());
+                    rows++;
+                }
+                assertEquals(3, rows);
+            }
+        }
+    }
+
+    private static Object field(SeaTunnelRow row, SeaTunnelRowType type, 
String name) {
+        return row.getField(type.indexOf(name));
+    }
+
+    @Test
+    void readsAllRowsAcrossPartitions() throws Exception {
+        OpenMldbSource source =
+                source(entry("bulk", "select seq, id from bulk_table", 
"id=STRING, seq=INT", null));
+        List<SeaTunnelRow> rows = new ArrayList<>();
+        try (OpenMldbSourceReader reader = (OpenMldbSourceReader) 
source.createReader(context())) {
+            reader.open();
+            reader.pollNext(collector(rows));
+        }
+        assertEquals(1101, rows.size());
+        HashSet<Object> keys = new HashSet<>();
+        SeaTunnelRowType type = 
source.getProducedCatalogTables().get(0).getSeaTunnelRowType();
+        for (SeaTunnelRow row : rows) {
+            Object id = field(row, type, "id");
+            assertEquals("key" + field(row, type, "seq"), id);
+            assertTrue(keys.add(id));
+        }
+    }
+
+    private static String connection() {
+        if (Boolean.getBoolean("openmldb.cluster")) {
+            return "cluster_mode=true\nzk_host=\""
+                    + System.getProperty("openmldb.zk.host", "127.0.0.1:2181")
+                    + "\"\nzk_path=\""
+                    + System.getProperty("openmldb.zk.path", "/openmldb")
+                    + "\"\ndatabase="
+                    + DATABASE
+                    + "\n";
+        }
+        return "cluster_mode=false\nhost=\""
+                + System.getProperty("openmldb.host", "127.0.0.1")
+                + "\"\nport="
+                + System.getProperty("openmldb.port", "6527")
+                + "\ndatabase="
+                + DATABASE
+                + "\n";
+    }
+
+    private static OpenMldbParameters parameters(String sql) {
+        return OpenMldbParameters.buildWithConfig(
+                ConfigFactory.parseString(connection() + "sql=\"" + sql + 
"\""));
+    }
+
+    private static OpenMldbSource source(String entries) {
+        return (OpenMldbSource)
+                new OpenMldbSourceFactory()
+                        .<SeaTunnelRow, SingleSplit, 
SingleSplitEnumeratorState>createSource(
+                                new TableSourceFactoryContext(
+                                        ReadonlyConfig.fromConfig(
+                                                ConfigFactory.parseString(
+                                                        connection()
+                                                                + 
"tables_configs=["
+                                                                + entries
+                                                                + "]")),
+                                        
TestOpenMldbSourceIT.class.getClassLoader()))
+                        .createSource();
+    }
+
+    private static String entry(String table, String sql, String fields, 
String database) {
+        return "{sql=\""
+                + sql
+                + "\", schema {table="
+                + table
+                + ", fields {"
+                + fields
+                + "}}"
+                + (database == null ? "" : ", database=" + database)
+                + "}";
+    }
+
+    private static SingleSplitReaderContext context() {
+        SingleSplitReaderContext context = 
mock(SingleSplitReaderContext.class);
+        when(context.getBoundedness()).thenReturn(Boundedness.BOUNDED);
+        return context;
+    }
+
+    private static Collector<SeaTunnelRow> collector(List<SeaTunnelRow> rows) {
+        return new Collector<SeaTunnelRow>() {
+            @Override
+            public void collect(SeaTunnelRow row) {
+                rows.add(row);
+            }
+
+            @Override
+            public Object getCheckpointLock() {
+                return rows;
+            }
+        };
+    }
+}

Reply via email to