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

JNSimba pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-spark-connector.git


The following commit(s) were added to refs/heads/master by this push:
     new 223240b  [Feature] Support reading external catalog tables (#383)
223240b is described below

commit 223240b5e88290fec89f4a77e1cdfcc17321b464
Author: wudi <[email protected]>
AuthorDate: Thu Nov 5 09:57:08 2026 +0800

    [Feature] Support reading external catalog tables (#383)
---
 README.md                                          |  25 ++++
 spark-doris-connector/pom.xml                      |   2 +-
 .../doris/spark/client/DorisFrontendClient.java    |   6 +-
 .../spark/client/read/DorisFlightSqlReader.java    |   5 +-
 .../spark/client/read/DorisReadModeResolver.java   |  10 ++
 .../client/read/ReaderPartitionGenerator.java      |  46 ++++---
 .../org/apache/doris/spark/config/DorisConfig.java |  14 +++
 .../apache/doris/spark/rdd/AbstractDorisRDD.scala  |   2 +-
 .../doris/spark/sql/sources/DorisRelation.scala    |   4 +-
 .../scala/org/apache/doris/spark/util/URLs.scala   |   3 +
 .../client/read/DorisReadModeResolverTest.java     |  26 ++++
 .../spark/client/read/ExternalCatalogReadTest.java | 108 +++++++++++++++++
 .../client/read/ReaderPartitionGeneratorTest.java  |  11 +-
 .../org/apache/doris/spark/util/URLsTest.scala     |   8 ++
 .../container/AbstractMySqlCatalogTestBase.java    | 135 +++++++++++++++++++++
 .../spark/container/AbstractS3TvfTestBase.java     |   2 +-
 .../spark/container/instance/DorisContainer.java   |   4 +
 .../spark/sql/DorisExternalCatalogITCase.scala     |  88 ++++++++++++++
 .../doris/spark/sql/DorisSourceProvider.scala      |   2 +
 .../doris/spark/catalog/DorisTableBase.scala       |   6 +-
 .../spark/catalog/DorisTableProviderBase.scala     |  12 +-
 .../doris/spark/catalog/DorisTableBaseTest.scala   |  55 +++++++++
 .../doris/spark/catalog/DorisTableBase.scala       |   6 +-
 .../spark/catalog/DorisTableProviderBase.scala     |  12 +-
 .../doris/spark/catalog/DorisTableBaseTest.scala   |  55 +++++++++
 25 files changed, 593 insertions(+), 54 deletions(-)

diff --git a/README.md b/README.md
index 9241ca1..f559130 100644
--- a/README.md
+++ b/README.md
@@ -133,6 +133,31 @@ dorisSparkDF = spark.read.format("doris")
 dorisSparkDF.show(5)
 ```
 
+## Reading external catalog tables
+
+Use a three-part `catalog.database.table` identifier to read tables from Doris
+external catalogs such as Hive, Iceberg, and Paimon:
+
+```scala
+spark.read.format("doris")
+  .option("doris.fenodes", "127.0.0.1:8030")
+  .option("doris.table.identifier", "hive_catalog.database.table")
+  .option("doris.user", "root")
+  .option("doris.password", "")
+  .load()
+```
+
+Arrow Flight SQL must be enabled on Doris. The connector discovers its port;
+when frontend discovery is disabled, set `doris.read.arrow-flight-sql.port`
+explicitly. Three-part identifiers require Arrow mode and do not fall back to
+Thrift. The same identifier works with SQL `USING doris` and the RDD API.
+
+External reads use one Spark partition. Doris plans and executes the external
+scan. Column projection and filter pushdown are retained. Complex types
+use the existing connector conversions: ARRAY as a string (or an array of
+strings with `doris.read.array.native-type=true`), MAP as a map of strings, and
+STRUCT as a string. Writing through a three-part identifier is not supported.
+
 ## TLS
 
 The connector supports one-way TLS for Doris HTTP APIs and Stream Load,
diff --git a/spark-doris-connector/pom.xml b/spark-doris-connector/pom.xml
index fcb5ce2..bdf2137 100644
--- a/spark-doris-connector/pom.xml
+++ b/spark-doris-connector/pom.xml
@@ -96,7 +96,7 @@
         <netty.version>4.1.110.Final</netty.version>
         <fasterxml.jackson.version>2.13.5</fasterxml.jackson.version>
         <thrift-service.version>1.0.1</thrift-service.version>
-        <testcontainers.version>1.17.6</testcontainers.version>
+        <testcontainers.version>1.21.4</testcontainers.version>
         <awssdk.version>2.29.52</awssdk.version>
         <jmockit.version>1.49</jmockit.version>
         <fe_ut_parallel>1</fe_ut_parallel>
diff --git 
a/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/DorisFrontendClient.java
 
b/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/DorisFrontendClient.java
index 3d3fa0b..05eba20 100644
--- 
a/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/DorisFrontendClient.java
+++ 
b/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/DorisFrontendClient.java
@@ -332,8 +332,12 @@ public class DorisFrontendClient implements Serializable {
     }
 
     public Schema getTableSchema(String db, String table) throws Exception {
+        return getTableSchema(new String[]{db, table});
+    }
+
+    public Schema getTableSchema(String[] tableIdentifier) throws Exception {
         return requestFrontends((frontend, httpClient) -> {
-            String url = URLs.tableSchema(frontend.getHost(), 
frontend.getHttpPort(), db, table, isHttpsEnabled);
+            String url = URLs.tableSchema(frontend.getHost(), 
frontend.getHttpPort(), tableIdentifier, isHttpsEnabled);
             HttpGet httpGet = new HttpGet(url);
             HttpUtils.setAuth(httpGet, username, password);
             Schema dorisSchema;
diff --git 
a/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/read/DorisFlightSqlReader.java
 
b/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/read/DorisFlightSqlReader.java
index 71d4b88..7f05048 100644
--- 
a/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/read/DorisFlightSqlReader.java
+++ 
b/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/read/DorisFlightSqlReader.java
@@ -222,7 +222,8 @@ public class DorisFlightSqlReader extends DorisReader {
     protected String generateQuerySql(DorisReaderPartition partition) throws 
OptionRequiredException {
         String columns = String.join(",", partition.getReadColumns());
         String fullTableName = 
config.getValue(DorisOptions.DORIS_TABLE_IDENTIFIER);
-        String tablets = String.format("TABLET(%s)", 
StringUtils.join(partition.getTablets(), ","));
+        String tablets = partition.getTablets().length == 0 ? ""
+                : String.format("TABLET(%s)", 
StringUtils.join(partition.getTablets(), ","));
         String predicates = partition.getFilters().length == 0 ? "" : " WHERE 
" + String.join(" AND ", partition.getFilters());
         String limit = partition.getLimit() > 0 ? " LIMIT " + 
partition.getLimit() : "";
         return generateQueryPrefix() + String.format("SELECT %s FROM %s 
%s%s%s", columns, fullTableName, tablets, predicates, limit);
@@ -235,7 +236,7 @@ public class DorisFlightSqlReader extends DorisReader {
 
     protected Schema processDorisSchema(DorisReaderPartition partition) throws 
Exception {
         Schema processedSchema = new Schema();
-        Schema tableSchema = 
frontendClient.getTableSchema(partition.getDatabase(), partition.getTable());
+        Schema tableSchema = 
frontendClient.getTableSchema(config.getTableIdentifierParts());
         Map<String, Field> fieldTypeMap = tableSchema.getProperties().stream()
                 .collect(Collectors.toMap(Field::getName, 
Function.identity()));
         String[] readColumns = partition.getReadColumns();
diff --git 
a/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/read/DorisReadModeResolver.java
 
b/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/read/DorisReadModeResolver.java
index f4766d1..65fc9ab 100644
--- 
a/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/read/DorisReadModeResolver.java
+++ 
b/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/read/DorisReadModeResolver.java
@@ -56,7 +56,11 @@ public class DorisReadModeResolver {
     static String resolve(DorisConfig config, FlightSqlPortSupplier 
portSupplier)
             throws OptionRequiredException {
         String readMode = 
config.getValue(DorisOptions.READ_MODE).toLowerCase(Locale.ROOT);
+        boolean catalogRead = config.getTableIdentifierParts().length == 3;
         if (THRIFT.equals(readMode)) {
+            if (catalogRead) {
+                throw new IllegalArgumentException("Reading catalog.db.table 
requires Arrow Flight SQL");
+            }
             return THRIFT;
         }
         if (!ARROW.equals(readMode)) {
@@ -71,6 +75,9 @@ public class DorisReadModeResolver {
         try {
             flightSqlPort = portSupplier.get();
         } catch (Exception e) {
+            if (catalogRead) {
+                throw new IllegalArgumentException("Reading catalog.db.table 
requires an available Arrow Flight SQL port", e);
+            }
             LOG.warn("failed to discover Arrow Flight SQL port, falling back 
to Thrift", e);
             return THRIFT;
         }
@@ -80,6 +87,9 @@ public class DorisReadModeResolver {
             return ARROW;
         }
 
+        if (catalogRead) {
+            throw new IllegalArgumentException("Reading catalog.db.table 
requires an available Arrow Flight SQL port");
+        }
         LOG.warn("Arrow Flight SQL port {} is invalid or unavailable, falling 
back to Thrift", flightSqlPort);
         return THRIFT;
     }
diff --git 
a/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/read/ReaderPartitionGenerator.java
 
b/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/read/ReaderPartitionGenerator.java
index bc6c410..cdc0adb 100644
--- 
a/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/read/ReaderPartitionGenerator.java
+++ 
b/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/read/ReaderPartitionGenerator.java
@@ -65,25 +65,34 @@ public class ReaderPartitionGenerator {
     public static DorisReaderPartition[] generatePartitions(DorisConfig config,
                                                             String[] fields, 
String[] filters, Integer limit,
                                                             Boolean 
datetimeJava8ApiEnabled) throws Exception {
+        String[] tableParts = config.getTableIdentifierParts();
+        String db = tableParts[tableParts.length - 2];
+        String table = tableParts[tableParts.length - 1];
         DorisFrontendClient frontend = new DorisFrontendClient(config);
-        String fullTableName = 
config.getValue(DorisOptions.DORIS_TABLE_IDENTIFIER);
-        String[] tableParts = fullTableName.split("\\.");
-        String db = tableParts[0].replaceAll("`", "");
-        String table = tableParts[1].replaceAll("`", "");
-        String[] originReadCols = fields;
-        if (fields == null || fields.length == 0) {
-            originReadCols = frontend.getTableAllColumns(db, table);
+        try {
+            Schema tableSchema = frontend.getTableSchema(tableParts);
+            String[] originReadCols = fields;
+            if (fields == null || fields.length == 0) {
+                originReadCols = 
tableSchema.getProperties().stream().map(Field::getName).toArray(String[]::new);
+            }
+            String[] finalReadColumns = getFinalReadColumns(config, 
tableSchema, originReadCols);
+            if (tableParts.length == 3) {
+                // Doris plans catalog scans without connector-side tablet 
assignment.
+                return new DorisReaderPartition[]{new DorisReaderPartition(db, 
table, null,
+                        new Long[0], "", finalReadColumns, filters, limit, 
config, datetimeJava8ApiEnabled)};
+            }
+            String finalReadColumnString = String.join(",", finalReadColumns);
+            String finalWhereClauseString = filters.length == 0 ? "" : " WHERE 
" + String.join(" AND ", filters);
+            String sql = "SELECT " + finalReadColumnString + " FROM `" + db + 
"`.`" + table + "`" + finalWhereClauseString;
+            LOG.info("get query plan for table " + db + "." + table + ", sql: 
" + sql);
+            QueryPlan queryPlan = frontend.getQueryPlan(db, table, sql);
+            Map<String, List<Long>> beToTablets = 
mappingBeToTablets(queryPlan);
+            int maxTabletSize = 
config.getValue(DorisOptions.DORIS_TABLET_SIZE);
+            return distributeTabletsToPartitions(db, table, beToTablets, 
queryPlan.getOpaqued_query_plan(), maxTabletSize,
+                    finalReadColumns, filters, config, limit, 
datetimeJava8ApiEnabled);
+        } finally {
+            frontend.close();
         }
-        String[] finalReadColumns = getFinalReadColumns(config, frontend, db, 
table, originReadCols);
-        String finalReadColumnString = String.join(",", finalReadColumns);
-        String finalWhereClauseString = filters.length == 0 ? "" : " WHERE " + 
String.join(" AND ", filters);
-        String sql = "SELECT " + finalReadColumnString + " FROM `" + db + 
"`.`" + table + "`" + finalWhereClauseString;
-        LOG.info("get query plan for table " + db + "." + table + ", sql: " + 
sql);
-        QueryPlan queryPlan = frontend.getQueryPlan(db, table, sql);
-        Map<String, List<Long>> beToTablets = mappingBeToTablets(queryPlan);
-        int maxTabletSize = config.getValue(DorisOptions.DORIS_TABLET_SIZE);
-        return distributeTabletsToPartitions(db, table, beToTablets, 
queryPlan.getOpaqued_query_plan(), maxTabletSize,
-                finalReadColumns, filters, config, limit, 
datetimeJava8ApiEnabled);
     }
 
     @VisibleForTesting
@@ -130,8 +139,7 @@ public class ReaderPartitionGenerator {
         return partitions.toArray(new DorisReaderPartition[0]);
     }
 
-    protected static String[] getFinalReadColumns(DorisConfig config, 
DorisFrontendClient frontendClient, String db, String table, String[] 
readFields) throws Exception {
-        Schema tableSchema = frontendClient.getTableSchema(db, table);
+    protected static String[] getFinalReadColumns(DorisConfig config, Schema 
tableSchema, String[] readFields) throws Exception {
         Map<String, String> fieldTypeMap = 
tableSchema.getProperties().stream().collect(
                 Collectors.toMap(Field::getName, Field::getType));
         Boolean bitmapToString = 
config.getValue(DorisOptions.DORIS_READ_BITMAP_TO_STRING);
diff --git 
a/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/config/DorisConfig.java
 
b/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/config/DorisConfig.java
index 1fa1fcc..6c22a64 100644
--- 
a/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/config/DorisConfig.java
+++ 
b/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/config/DorisConfig.java
@@ -40,6 +40,20 @@ public class DorisConfig implements Serializable {
         checkOptions(this.configOptions);
     }
 
+    public String[] getTableIdentifierParts() throws OptionRequiredException {
+        String[] parts = 
getValue(DorisOptions.DORIS_TABLE_IDENTIFIER).split("\\.", -1);
+        if (parts.length != 2 && parts.length != 3) {
+            throw new IllegalArgumentException("doris.table.identifier must be 
db.table or catalog.db.table");
+        }
+        for (int i = 0; i < parts.length; i++) {
+            parts[i] = parts[i].replace("`", "");
+            if (parts[i].trim().isEmpty()) {
+                throw new IllegalArgumentException("doris.table.identifier 
contains an empty name");
+            }
+        }
+        return parts;
+    }
+
     private Map<String, String> processOptions(Map<String, String> options) {
         Map<String, String> dottedOptions = new HashMap<>();
         for (Map.Entry<String, String> entry : options.entrySet()) {
diff --git 
a/spark-doris-connector/spark-doris-connector-base/src/main/scala/org/apache/doris/spark/rdd/AbstractDorisRDD.scala
 
b/spark-doris-connector/spark-doris-connector-base/src/main/scala/org/apache/doris/spark/rdd/AbstractDorisRDD.scala
index 50f6474..e354da3 100644
--- 
a/spark-doris-connector/spark-doris-connector-base/src/main/scala/org/apache/doris/spark/rdd/AbstractDorisRDD.scala
+++ 
b/spark-doris-connector/spark-doris-connector-base/src/main/scala/org/apache/doris/spark/rdd/AbstractDorisRDD.scala
@@ -39,7 +39,7 @@ protected[spark] abstract class AbstractDorisRDD[T: ClassTag](
 
   override def getPreferredLocations(split: Partition): Seq[String] = {
     val dorisSplit = split.asInstanceOf[DorisPartition]
-    Seq(dorisSplit.dorisPartition.getBackend.hostRpcPortString())
+    
Option(dorisSplit.dorisPartition.getBackend).map(_.hostRpcPortString()).toSeq
   }
 
   override def checkpoint(): Unit = {
diff --git 
a/spark-doris-connector/spark-doris-connector-base/src/main/scala/org/apache/doris/spark/sql/sources/DorisRelation.scala
 
b/spark-doris-connector/spark-doris-connector-base/src/main/scala/org/apache/doris/spark/sql/sources/DorisRelation.scala
index 084b821..11d311c 100644
--- 
a/spark-doris-connector/spark-doris-connector-base/src/main/scala/org/apache/doris/spark/sql/sources/DorisRelation.scala
+++ 
b/spark-doris-connector/spark-doris-connector-base/src/main/scala/org/apache/doris/spark/sql/sources/DorisRelation.scala
@@ -42,9 +42,7 @@ private[sql] class DorisRelation(
   private lazy val frontend: DorisFrontendClient = new DorisFrontendClient(cfg)
 
   private lazy val lazySchema = {
-    val tableIdentifier = cfg.getValue(DorisOptions.DORIS_TABLE_IDENTIFIER)
-    val tableIdentifierArr = 
tableIdentifier.split("\\.").map(_.replaceAll("`", ""))
-    val dorisSchema = frontend.getTableSchema(tableIdentifierArr(0), 
tableIdentifierArr(1))
+    val dorisSchema = frontend.getTableSchema(cfg.getTableIdentifierParts)
     val arrayNativeType = 
cfg.getValue(DorisOptions.DORIS_READ_ARRAY_NATIVE_TYPE)
     StructType(dorisSchema.getProperties.asScala.map(field => {
       StructField(field.getName, 
SchemaConvertors.toCatalystType(field.getType, field.getPrecision, 
field.getScale, arrayNativeType))
diff --git 
a/spark-doris-connector/spark-doris-connector-base/src/main/scala/org/apache/doris/spark/util/URLs.scala
 
b/spark-doris-connector/spark-doris-connector-base/src/main/scala/org/apache/doris/spark/util/URLs.scala
index b4e868a..f5545ee 100644
--- 
a/spark-doris-connector/spark-doris-connector-base/src/main/scala/org/apache/doris/spark/util/URLs.scala
+++ 
b/spark-doris-connector/spark-doris-connector-base/src/main/scala/org/apache/doris/spark/util/URLs.scala
@@ -35,6 +35,9 @@ object URLs {
   def tableSchema(host: String, port: Int, database: String, table: String, 
enableHttps: Boolean): String =
     tableSchema(s"${assemblePath(host, port)}", database, table, enableHttps)
 
+  def tableSchema(host: String, port: Int, tableIdentifier: Array[String], 
enableHttps: Boolean): String =
+    s"${schema(enableHttps)}://${assemblePath(host, 
port)}/api/${tableIdentifier.mkString("/")}/_schema"
+
   def queryPlan(feNode: String, database: String, table: String, enableHttps: 
Boolean = false) =
     s"${schema(enableHttps)}://$feNode/api/$database/$table/_query_plan"
 
diff --git 
a/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/client/read/DorisReadModeResolverTest.java
 
b/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/client/read/DorisReadModeResolverTest.java
index 96d5f70..794a52a 100644
--- 
a/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/client/read/DorisReadModeResolverTest.java
+++ 
b/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/client/read/DorisReadModeResolverTest.java
@@ -103,6 +103,32 @@ public class DorisReadModeResolverTest {
         }
     }
 
+    @Test
+    public void catalogReadRejectsThrift() throws Exception {
+        DorisConfig config = createConfig("thrift", null);
+        config.setProperty(DorisOptions.DORIS_TABLE_IDENTIFIER, "hive.db.tbl");
+        
org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class,
+                () -> DorisReadModeResolver.resolve(config, () -> 9040));
+    }
+
+    @Test
+    public void catalogReadDoesNotFallBackWhenFlightPortIsUnavailable() throws 
Exception {
+        DorisConfig config = createConfig("arrow", null);
+        config.setProperty(DorisOptions.DORIS_TABLE_IDENTIFIER, "hive.db.tbl");
+        
org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class,
+                () -> DorisReadModeResolver.resolve(config, () -> -1));
+    }
+
+    @Test
+    public void catalogReadDoesNotFallBackWhenDiscoveryFails() throws 
Exception {
+        DorisConfig config = createConfig("arrow", null);
+        config.setProperty(DorisOptions.DORIS_TABLE_IDENTIFIER, "hive.db.tbl");
+        
org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class,
+                () -> DorisReadModeResolver.resolve(config, () -> {
+                    throw new java.io.IOException("discovery failed");
+                }));
+    }
+
     private DorisConfig createConfig(String readMode, Integer flightSqlPort) 
throws Exception {
         Map<String, String> options = new HashMap<>();
         options.put("doris.fenodes", "127.0.0.1:8030");
diff --git 
a/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/client/read/ExternalCatalogReadTest.java
 
b/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/client/read/ExternalCatalogReadTest.java
new file mode 100644
index 0000000..28151e4
--- /dev/null
+++ 
b/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/client/read/ExternalCatalogReadTest.java
@@ -0,0 +1,108 @@
+// 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.doris.spark.client.read;
+
+import org.apache.doris.spark.client.entity.DorisReaderPartition;
+import org.apache.doris.spark.config.DorisConfig;
+import org.junit.Assert;
+import org.junit.Test;
+
+import com.sun.net.httpserver.HttpServer;
+
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+public class ExternalCatalogReadTest {
+
+    @Test
+    public void catalogScanUsesCatalogSchemaAndOneTabletFreePartition() throws 
Exception {
+        List<String> requests = new CopyOnWriteArrayList<>();
+        HttpServer server = HttpServer.create(new 
InetSocketAddress("127.0.0.1", 0), 0);
+        server.createContext("/", exchange -> {
+            String path = exchange.getRequestURI().getPath();
+            requests.add(path);
+            String data = path.endsWith("/_schema")
+                    ? "{\"properties\":[{\"name\":\"id\",\"type\":\"INT\"},"
+                        + "{\"name\":\"name\",\"type\":\"STRING\"}]}"
+                    : 
"{\"status\":0,\"partitions\":{},\"opaqued_query_plan\":\"\"}";
+            byte[] response = ("{\"code\":0,\"data\":" + data + "}")
+                    .getBytes(StandardCharsets.UTF_8);
+            exchange.sendResponseHeaders(200, response.length);
+            try (java.io.OutputStream out = exchange.getResponseBody()) {
+                out.write(response);
+            }
+        });
+        server.start();
+        try {
+            DorisConfig config = createConfig("hive.db.tbl", 
server.getAddress().getPort());
+            String[] filters = {"id > 1"};
+            DorisReaderPartition[] partitions = 
ReaderPartitionGenerator.generatePartitions(
+                    config, new String[0], filters, 5, false);
+
+            Assert.assertEquals(1, partitions.length);
+            Assert.assertEquals("db", partitions[0].getDatabase());
+            Assert.assertEquals("tbl", partitions[0].getTable());
+            Assert.assertNull(partitions[0].getBackend());
+            Assert.assertEquals(0, partitions[0].getTablets().length);
+            Assert.assertArrayEquals(new String[]{"`id`", "`name`"}, 
partitions[0].getReadColumns());
+            Assert.assertArrayEquals(filters, partitions[0].getFilters());
+            Assert.assertEquals(Integer.valueOf(5), partitions[0].getLimit());
+            Assert.assertTrue(requests.contains("/api/hive/db/tbl/_schema"));
+            Assert.assertFalse(requests.stream().anyMatch(path -> 
path.endsWith("/_query_plan")));
+            Assert.assertEquals(1L, requests.stream().filter(path -> 
path.endsWith("/_schema")).count());
+
+            DorisReaderPartition[] projected = 
ReaderPartitionGenerator.generatePartitions(
+                    config, new String[]{"name"}, new String[0], -1, false);
+            Assert.assertArrayEquals(new String[]{"`name`"}, 
projected[0].getReadColumns());
+        } finally {
+            server.stop(0);
+        }
+    }
+
+    @Test
+    public void identifiersPreserveCatalogAndNormalizeBackticks() throws 
Exception {
+        Assert.assertArrayEquals(new String[]{"hive", "db", "tbl"},
+                createConfig("`hive`.`db`.`tbl`", 
8030).getTableIdentifierParts());
+        Assert.assertArrayEquals(new String[]{"db", "tbl"},
+                createConfig("db.tbl", 8030).getTableIdentifierParts());
+    }
+
+    @Test
+    public void malformedIdentifiersAreRejected() {
+        for (String identifier : new String[]{"db", ".tbl", "db.", "cat..tbl", 
"a.b.c.d", "``.tbl"}) {
+            
org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class,
+                    () -> createConfig(identifier, 
8030).getTableIdentifierParts());
+        }
+    }
+
+    private DorisConfig createConfig(String tableIdentifier, int httpPort) 
throws Exception {
+        Map<String, String> options = new HashMap<>();
+        options.put("doris.fenodes", "127.0.0.1:" + httpPort);
+        options.put("doris.fe.auto.fetch", "false");
+        options.put("doris.table.identifier", tableIdentifier);
+        options.put("doris.user", "root");
+        options.put("doris.password", "");
+        options.put("doris.read.arrow-flight-sql.port", "9040");
+        return DorisConfig.fromMap(options, false);
+    }
+
+}
diff --git 
a/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/client/read/ReaderPartitionGeneratorTest.java
 
b/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/client/read/ReaderPartitionGeneratorTest.java
index 768ddd6..7cf29be 100644
--- 
a/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/client/read/ReaderPartitionGeneratorTest.java
+++ 
b/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/client/read/ReaderPartitionGeneratorTest.java
@@ -19,7 +19,6 @@ package org.apache.doris.spark.client.read;
 
 import mockit.Expectations;
 import mockit.Mocked;
-import org.apache.doris.spark.client.DorisFrontendClient;
 import org.apache.doris.spark.config.DorisConfig;
 import org.apache.doris.spark.config.DorisOptions;
 import org.apache.doris.spark.rest.models.Field;
@@ -70,7 +69,7 @@ public class ReaderPartitionGeneratorTest {
     }
 
     @Test
-    public void getFinalReadColumnsTest(@Mocked DorisConfig config, @Mocked 
DorisFrontendClient frontendClient) throws Exception {
+    public void getFinalReadColumnsTest(@Mocked DorisConfig config) throws 
Exception {
 
         String[] cols = new String[]{"c0", "c1", "c2"};
         Schema schema = new Schema();
@@ -83,11 +82,9 @@ public class ReaderPartitionGeneratorTest {
             result = true;
             config.getValue(DorisOptions.DORIS_READ_BITMAP_TO_STRING);
             result = false;
-            frontendClient.getTableSchema(anyString, anyString);
-            result = schema;
         }};
 
-        String[] finalReadColumns1 = 
ReaderPartitionGenerator.getFinalReadColumns(config, frontendClient, "db", 
"tbl", cols);
+        String[] finalReadColumns1 = 
ReaderPartitionGenerator.getFinalReadColumns(config, schema, cols);
         Assert.assertArrayEquals(new String[]{"`c0`", "bitmap_to_base64(`c1`) 
AS `c1`", "'Read unsupported' AS `c2`"}, finalReadColumns1);
 
         new Expectations() {{
@@ -95,11 +92,9 @@ public class ReaderPartitionGeneratorTest {
             result = false;
             config.getValue(DorisOptions.DORIS_READ_BITMAP_TO_STRING);
             result = true;
-            frontendClient.getTableSchema(anyString, anyString);
-            result = schema;
         }};
 
-        String[] finalReadColumns2 = 
ReaderPartitionGenerator.getFinalReadColumns(config, frontendClient, "db", 
"tbl", cols);
+        String[] finalReadColumns2 = 
ReaderPartitionGenerator.getFinalReadColumns(config, schema, cols);
         Assert.assertArrayEquals(new String[]{"`c0`", "bitmap_to_string(`c1`) 
AS `c1`", "'Read unsupported' AS `c2`"}, finalReadColumns2);
 
     }
diff --git 
a/spark-doris-connector/spark-doris-connector-base/src/test/scala/org/apache/doris/spark/util/URLsTest.scala
 
b/spark-doris-connector/spark-doris-connector-base/src/test/scala/org/apache/doris/spark/util/URLsTest.scala
index ff977c0..1799a63 100644
--- 
a/spark-doris-connector/spark-doris-connector-base/src/test/scala/org/apache/doris/spark/util/URLsTest.scala
+++ 
b/spark-doris-connector/spark-doris-connector-base/src/test/scala/org/apache/doris/spark/util/URLsTest.scala
@@ -34,6 +34,14 @@ class URLsTest {
     Assert.assertEquals(URLs.tableSchema("127.0.0.1", 8030, "db", "tbl", 
enableHttps = true), "https://127.0.0.1:8030/api/db/tbl/_schema";)
   }
 
+  @Test
+  def catalogTableSchemaTest(): Unit = {
+    Assert.assertEquals("http://127.0.0.1:8030/api/hive/db/tbl/_schema";,
+      URLs.tableSchema("127.0.0.1", 8030, Array("hive", "db", "tbl"), 
enableHttps = false))
+    Assert.assertEquals("https://127.0.0.1:8030/api/hive/db/tbl/_schema";,
+      URLs.tableSchema("127.0.0.1", 8030, Array("hive", "db", "tbl"), 
enableHttps = true))
+  }
+
   @Test
   def queryPlanTest(): Unit = {
     Assert.assertEquals(URLs.queryPlan("127.0.0.1", 8030, "db", "tbl", 
enableHttps = false), "http://127.0.0.1:8030/api/db/tbl/_query_plan";)
diff --git 
a/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/container/AbstractMySqlCatalogTestBase.java
 
b/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/container/AbstractMySqlCatalogTestBase.java
new file mode 100644
index 0000000..ecc91bd
--- /dev/null
+++ 
b/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/container/AbstractMySqlCatalogTestBase.java
@@ -0,0 +1,135 @@
+// 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.doris.spark.container;
+
+import org.apache.doris.spark.container.instance.DorisContainer;
+
+import org.junit.AfterClass;
+import org.junit.Assume;
+import org.junit.BeforeClass;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.utility.DockerImageName;
+import org.testcontainers.utility.MountableFile;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.Statement;
+import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+import static org.junit.Assert.assertTrue;
+
+/** Shares a MySQL-backed JDBC Catalog across the external-catalog read tests. 
*/
+public abstract class AbstractMySqlCatalogTestBase extends 
AbstractContainerTestBase {
+    private static final Logger LOG = 
LoggerFactory.getLogger(AbstractMySqlCatalogTestBase.class);
+    private static final String DATABASE = "spark_catalog_source";
+    private static final String MYSQL_PASSWORD = "catalog-test";
+    private static GenericContainer<?> mysql;
+    private static String catalog;
+    private static boolean catalogCreated;
+
+    @BeforeClass
+    public static void startMySqlCatalog() throws Exception {
+        Assume.assumeTrue("This case requires the managed Doris container",
+                dorisContainerService instanceof DorisContainer);
+        GenericContainer<?> doris = ((DorisContainer) 
dorisContainerService).getContainer();
+        String suffix = UUID.randomUUID().toString().replace("-", 
"").substring(0, 8);
+        catalog = "spark_mysql_catalog_" + suffix;
+        String mysqlHost = "mysql-catalog-" + suffix;
+        mysql = new GenericContainer<>(DockerImageName.parse("mysql:8.0.36"))
+                .withNetwork(doris.getNetwork())
+                .withNetworkAliases(mysqlHost)
+                .withExposedPorts(3306)
+                .withEnv("MYSQL_ROOT_PASSWORD", MYSQL_PASSWORD)
+                .withEnv("MYSQL_ROOT_HOST", "%")
+                .withEnv("MYSQL_DATABASE", DATABASE)
+                .waitingFor(Wait.forLogMessage(".*mysqld: ready for 
connections.*port: 3306 .*\\n", 1))
+                .withStartupTimeout(Duration.ofMinutes(2));
+        mysql.start();
+        String mysqlUrl = "jdbc:mysql://" + mysql.getHost() + ":" + 
mysql.getMappedPort(3306)
+                + "/" + DATABASE + 
"?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC";
+        try (Connection connection = DriverManager.getConnection(mysqlUrl, 
"root", MYSQL_PASSWORD);
+                Statement statement = connection.createStatement()) {
+            statement.execute("CREATE TABLE `order` (id INT PRIMARY KEY, name 
VARCHAR(32), score INT)");
+            statement.execute("INSERT INTO `order` VALUES 
(1,'alpha',10),(2,'beta',20),(3,NULL,NULL)");
+            statement.execute("CREATE TABLE empty_records (id INT PRIMARY KEY, 
name VARCHAR(32), score INT)");
+        }
+
+        Path driverJar = Paths.get(com.mysql.cj.jdbc.Driver.class
+                .getProtectionDomain().getCodeSource().getLocation().toURI());
+        assertTrue("Expected mysql-connector-j JAR on the test classpath: " + 
driverJar,
+                Files.isRegularFile(driverJar) && 
driverJar.toString().endsWith(".jar"));
+        String driverPath = "/tmp/spark-catalog-mysql-" + suffix + ".jar";
+        
doris.copyFileToContainer(MountableFile.forHostPath(driverJar.toString()), 
driverPath);
+        Map<String, String> properties = new LinkedHashMap<>();
+        properties.put("type", "jdbc");
+        properties.put("user", "root");
+        properties.put("password", MYSQL_PASSWORD);
+        properties.put("jdbc_url", "jdbc:mysql://" + mysqlHost + ":3306/" + 
DATABASE
+                + 
"?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC");
+        properties.put("driver_url", "file://" + driverPath);
+        properties.put("driver_class", "com.mysql.cj.jdbc.Driver");
+        properties.put("only_specified_database", "true");
+        executeSql("CREATE CATALOG `" + catalog + "` PROPERTIES(" + 
renderOptions(properties) + ")");
+        catalogCreated = true;
+    }
+
+    @AfterClass
+    public static void stopMySqlCatalog() throws Exception {
+        try {
+            if (catalogCreated) {
+                executeSql("DROP CATALOG `" + catalog + "`");
+            }
+        } finally {
+            if (mysql != null) {
+                mysql.stop();
+            }
+        }
+    }
+
+    protected Map<String, String> connectorOptions(String table) {
+        Map<String, String> options = new LinkedHashMap<>();
+        options.put("doris.fenodes", getFenodes());
+        options.put("doris.table.identifier", "`" + catalog + "`.`" + DATABASE 
+ "`.`" + table + "`");
+        options.put("doris.user", getDorisUsername());
+        options.put("doris.password", getDorisPassword());
+        return options;
+    }
+
+    protected static String renderOptions(Map<String, String> options) {
+        return options.entrySet().stream()
+                .map(entry -> "'" + entry.getKey().replace("'", "''") + "'='"
+                        + entry.getValue().replace("'", "''") + "'")
+                .collect(Collectors.joining(","));
+    }
+
+    private static void executeSql(String sql) throws Exception {
+        try (Connection connection = getDorisQueryConnection()) {
+            ContainerUtils.executeSQLStatement(connection, LOG, sql);
+        }
+    }
+}
diff --git 
a/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/container/AbstractS3TvfTestBase.java
 
b/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/container/AbstractS3TvfTestBase.java
index ab91781..59a1bd6 100644
--- 
a/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/container/AbstractS3TvfTestBase.java
+++ 
b/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/container/AbstractS3TvfTestBase.java
@@ -52,7 +52,7 @@ public abstract class AbstractS3TvfTestBase extends 
AbstractContainerTestBase {
 
     private static final int MINIO_PORT = 9000;
     private static final String MINIO_IMAGE =
-            "quay.io/minio/minio:RELEASE.2024-10-13T13-34-11Z";
+            "doristhirdpartydocker/minio:RELEASE.2024-11-07T00-52-20Z";
 
     private static GenericContainer<?> minio;
     private static S3Client s3Client;
diff --git 
a/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/container/instance/DorisContainer.java
 
b/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/container/instance/DorisContainer.java
index 1e99536..ab8a0f4 100644
--- 
a/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/container/instance/DorisContainer.java
+++ 
b/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/container/instance/DorisContainer.java
@@ -65,6 +65,10 @@ public class DorisContainer implements ContainerService {
         dorisContainer = createDorisContainer();
     }
 
+    public GenericContainer<?> getContainer() {
+        return dorisContainer;
+    }
+
     public GenericContainer createDorisContainer() {
         LOG.info("Will create doris containers.");
         GenericContainer container =
diff --git 
a/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/sql/DorisExternalCatalogITCase.scala
 
b/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/sql/DorisExternalCatalogITCase.scala
new file mode 100644
index 0000000..2b6295c
--- /dev/null
+++ 
b/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/sql/DorisExternalCatalogITCase.scala
@@ -0,0 +1,88 @@
+// 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.doris.spark.sql
+
+import org.apache.doris.spark.container.AbstractMySqlCatalogTestBase
+import 
org.apache.doris.spark.container.AbstractMySqlCatalogTestBase.renderOptions
+import org.apache.doris.spark.sparkContextFunctions
+import org.apache.spark.sql.{Row, SparkSession}
+import org.junit.Assert.assertEquals
+import org.junit.{After, Before, Test}
+
+import scala.collection.JavaConverters._
+
+/** Covers each Spark read API against a real MySQL-backed external catalog. */
+class DorisExternalCatalogITCase extends AbstractMySqlCatalogTestBase {
+
+  private var session: SparkSession = _
+
+  @Before
+  def startSpark(): Unit = {
+    session = SparkSession.builder()
+      .appName("doris-external-catalog-it")
+      .master("local[2]")
+      .config("spark.ui.enabled", "false")
+      .config("spark.driver.host", "127.0.0.1")
+      .config("spark.driver.bindAddress", "127.0.0.1")
+      .config("spark.sql.shuffle.partitions", "2")
+      .getOrCreate()
+  }
+
+  @After
+  def stopSpark(): Unit = {
+    if (session != null) session.stop()
+  }
+
+  @Test
+  def testDataFrameRead(): Unit = {
+    // Leave read mode and Flight port unset to exercise the default Arrow 
path.
+    val data = 
session.read.format("doris").options(connectorOptions("order").asScala).load()
+    assertEquals(1, data.rdd.getNumPartitions)
+    assertEquals(Seq(Row(1, "alpha", 10), Row(2, "beta", 20), Row(3, null, 
null)),
+      data.orderBy("id").collect().toSeq)
+    assertEquals(Seq(Row("beta", 2), Row(null, 3)),
+      data.select("name", "id").where("id >= 2").orderBy("id").collect().toSeq)
+    assertEquals(1, data.limit(1).collect().length)
+  }
+
+  @Test
+  def testSparkSqlRead(): Unit = {
+    session.sql("CREATE TEMPORARY VIEW external_catalog_source USING doris 
OPTIONS(" +
+      renderOptions(connectorOptions("order")) + ")")
+    assertEquals(Seq(Row("beta", 2), Row(null, 3)), session.sql(
+      "SELECT name,id FROM external_catalog_source WHERE id >= 2 ORDER BY 
id").collect().toSeq)
+  }
+
+  @Test
+  def testRddRead(): Unit = {
+    val options = connectorOptions("order").asScala.toMap
+    val rows = session.sparkContext.dorisRDD(
+      tableIdentifier = Some(options("doris.table.identifier")),
+      query = Some("id >= 2"),
+      cfg = Some(options + ("doris.read.fields" -> "name,id")))
+      .collect().map(_.asInstanceOf[Array[AnyRef]].toSeq)
+      .sortBy(_(1).asInstanceOf[java.lang.Number].intValue()).toSeq
+    assertEquals(Seq(Seq("beta", 2), Seq(null, 3)), rows)
+  }
+
+  @Test
+  def testEmptyTableRead(): Unit = {
+    assertEquals(0L, session.read.format("doris")
+      .options(connectorOptions("empty_records").asScala).load().count())
+  }
+}
diff --git 
a/spark-doris-connector/spark-doris-connector-spark-2/src/main/scala/org/apache/doris/spark/sql/DorisSourceProvider.scala
 
b/spark-doris-connector/spark-doris-connector-spark-2/src/main/scala/org/apache/doris/spark/sql/DorisSourceProvider.scala
index b2fb7e5..42f9b74 100644
--- 
a/spark-doris-connector/spark-doris-connector-spark-2/src/main/scala/org/apache/doris/spark/sql/DorisSourceProvider.scala
+++ 
b/spark-doris-connector/spark-doris-connector-spark-2/src/main/scala/org/apache/doris/spark/sql/DorisSourceProvider.scala
@@ -56,6 +56,7 @@ private[sql] class DorisSourceProvider extends 
DorisSourceRegisterTrait
                               data: DataFrame): BaseRelation = {
 
     val config = 
DorisConfig.fromMap(sqlContext.sparkContext.getConf.getAll.toMap.asJava, 
parameters.asJava, false)
+    require(config.getTableIdentifierParts.length == 2, "Writing 
catalog.db.table is not supported")
 
     mode match {
       case SaveMode.Overwrite =>
@@ -91,6 +92,7 @@ private[sql] class DorisSourceProvider extends 
DorisSourceRegisterTrait
 
   override def createSink(sqlContext: SQLContext, parameters: Map[String, 
String], partitionColumns: Seq[String], outputMode: OutputMode): Sink = {
     val config = DorisConfig.fromMap(Utils.params(parameters, logger).asJava, 
false)
+    require(config.getTableIdentifierParts.length == 2, "Writing 
catalog.db.table is not supported")
     if (config.getValue(DorisOptions.LOAD_MODE) == "tvf") {
       throw new UnsupportedOperationException("tvf write mode does not support 
Structured Streaming")
     }
diff --git 
a/spark-doris-connector/spark-doris-connector-spark-3-base/src/main/scala/org/apache/doris/spark/catalog/DorisTableBase.scala
 
b/spark-doris-connector/spark-doris-connector-spark-3-base/src/main/scala/org/apache/doris/spark/catalog/DorisTableBase.scala
index 214732f..ef0921a 100644
--- 
a/spark-doris-connector/spark-doris-connector-spark-3-base/src/main/scala/org/apache/doris/spark/catalog/DorisTableBase.scala
+++ 
b/spark-doris-connector/spark-doris-connector-spark-3-base/src/main/scala/org/apache/doris/spark/catalog/DorisTableBase.scala
@@ -40,11 +40,14 @@ abstract class DorisTableBase(identifier: Identifier, 
config: DorisConfig, schem
   override def name(): String = identifier.toString
 
   override def schema(): StructType = schema.getOrElse({
-    val dorisSchema = frontend.getTableSchema(identifier.namespace()(0), 
identifier.name())
+    val dorisSchema = frontend.getTableSchema(identifier.namespace() :+ 
identifier.name())
     dorisSchemaToStructType(dorisSchema)
   })
 
   override def capabilities(): util.Set[TableCapability] = {
+    if (identifier.namespace().length == 2) {
+      return Set[TableCapability](BATCH_READ).asJava
+    }
     val capabilities = mutable.Set(BATCH_READ,
       BATCH_WRITE,
       STREAMING_WRITE,
@@ -59,7 +62,6 @@ abstract class DorisTableBase(identifier: Identifier, config: 
DorisConfig, schem
   }
 
   override def newScanBuilder(caseInsensitiveStringMap: 
CaseInsensitiveStringMap): ScanBuilder = {
-    config.setProperty(DorisOptions.DORIS_TABLE_IDENTIFIER, name())
     createScanBuilder(config, schema())
   }
 
diff --git 
a/spark-doris-connector/spark-doris-connector-spark-3-base/src/main/scala/org/apache/doris/spark/catalog/DorisTableProviderBase.scala
 
b/spark-doris-connector/spark-doris-connector-spark-3-base/src/main/scala/org/apache/doris/spark/catalog/DorisTableProviderBase.scala
index a03435d..39eeaeb 100644
--- 
a/spark-doris-connector/spark-doris-connector-spark-3-base/src/main/scala/org/apache/doris/spark/catalog/DorisTableProviderBase.scala
+++ 
b/spark-doris-connector/spark-doris-connector-spark-3-base/src/main/scala/org/apache/doris/spark/catalog/DorisTableProviderBase.scala
@@ -17,7 +17,7 @@
 
 package org.apache.doris.spark.catalog
 
-import org.apache.doris.spark.config.{DorisConfig, DorisOptions}
+import org.apache.doris.spark.config.DorisConfig
 import org.apache.spark.sql.connector.catalog.{Identifier, Table, 
TableProvider}
 import org.apache.spark.sql.connector.expressions.Transform
 import org.apache.spark.sql.types.StructType
@@ -38,9 +38,8 @@ abstract class DorisTableProviderBase extends TableProvider {
     if (t != null) t
     else {
       val dorisConfig = DorisConfig.fromMap(properties, false)
-      val tableIdentifier = 
dorisConfig.getValue(DorisOptions.DORIS_TABLE_IDENTIFIER)
-      val tableIdentifierArr = tableIdentifier.split("\\.")
-      newTableInstance(Identifier.of(Array[String](tableIdentifierArr(0)), 
tableIdentifierArr(1)), dorisConfig, Some(schema))
+      val tableIdentifierArr = dorisConfig.getTableIdentifierParts
+      newTableInstance(Identifier.of(tableIdentifierArr.dropRight(1), 
tableIdentifierArr.last), dorisConfig, Some(schema))
     }
   }
 
@@ -48,9 +47,8 @@ abstract class DorisTableProviderBase extends TableProvider {
     if (t != null) t
     else {
       val dorisConfig = DorisConfig.fromMap(options, false)
-      val tableIdentifier = 
dorisConfig.getValue(DorisOptions.DORIS_TABLE_IDENTIFIER)
-      val tableIdentifierArr = tableIdentifier.split("\\.")
-      newTableInstance(Identifier.of(Array[String](tableIdentifierArr(0)), 
tableIdentifierArr(1)), dorisConfig, None)
+      val tableIdentifierArr = dorisConfig.getTableIdentifierParts
+      newTableInstance(Identifier.of(tableIdentifierArr.dropRight(1), 
tableIdentifierArr.last), dorisConfig, None)
     }
   }
 
diff --git 
a/spark-doris-connector/spark-doris-connector-spark-3-base/src/test/scala/org/apache/doris/spark/catalog/DorisTableBaseTest.scala
 
b/spark-doris-connector/spark-doris-connector-spark-3-base/src/test/scala/org/apache/doris/spark/catalog/DorisTableBaseTest.scala
new file mode 100644
index 0000000..7fedca5
--- /dev/null
+++ 
b/spark-doris-connector/spark-doris-connector-spark-3-base/src/test/scala/org/apache/doris/spark/catalog/DorisTableBaseTest.scala
@@ -0,0 +1,55 @@
+// 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.doris.spark.catalog
+
+import org.apache.doris.spark.config.{DorisConfig, DorisOptions}
+import org.apache.spark.sql.connector.catalog.Identifier
+import org.apache.spark.sql.connector.read.ScanBuilder
+import org.apache.spark.sql.connector.write.WriteBuilder
+import org.apache.spark.sql.types.StructType
+import org.apache.spark.sql.util.CaseInsensitiveStringMap
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+import java.util
+import scala.collection.JavaConverters._
+
+class DorisTableBaseTest {
+
+  @Test
+  def readPreservesConfiguredIdentifierQuoting(): Unit = {
+    val tableIdentifier = "`hive`.`db`.`order`"
+    val config = DorisConfig.fromMap(Map(
+      "doris.fenodes" -> "127.0.0.1:8030",
+      "doris.table.identifier" -> tableIdentifier,
+      "doris.user" -> "root",
+      "doris.password" -> "").asJava, false)
+    val table = new DorisTableBase(
+      Identifier.of(Array("hive", "db"), "order"), config, 
Some(StructType(Nil))) {
+      override protected def createScanBuilder(readConfig: DorisConfig, 
schema: StructType): ScanBuilder = {
+        assertEquals(tableIdentifier, 
readConfig.getValue(DorisOptions.DORIS_TABLE_IDENTIFIER))
+        null
+      }
+
+      override protected def createWriteBuilder(writeConfig: DorisConfig, 
schema: StructType): WriteBuilder =
+        throw new UnsupportedOperationException()
+    }
+
+    table.newScanBuilder(new 
CaseInsensitiveStringMap(util.Collections.emptyMap[String, String]()))
+  }
+}
diff --git 
a/spark-doris-connector/spark-doris-connector-spark-4-base/src/main/scala/org/apache/doris/spark/catalog/DorisTableBase.scala
 
b/spark-doris-connector/spark-doris-connector-spark-4-base/src/main/scala/org/apache/doris/spark/catalog/DorisTableBase.scala
index 830aa5e..dea91e0 100644
--- 
a/spark-doris-connector/spark-doris-connector-spark-4-base/src/main/scala/org/apache/doris/spark/catalog/DorisTableBase.scala
+++ 
b/spark-doris-connector/spark-doris-connector-spark-4-base/src/main/scala/org/apache/doris/spark/catalog/DorisTableBase.scala
@@ -40,11 +40,14 @@ abstract class DorisTableBase(identifier: Identifier, 
config: DorisConfig, schem
   override def name(): String = identifier.toString
 
   override def schema(): StructType = schema.getOrElse({
-    val dorisSchema = frontend.getTableSchema(identifier.namespace()(0), 
identifier.name())
+    val dorisSchema = frontend.getTableSchema(identifier.namespace() :+ 
identifier.name())
     dorisSchemaToStructType(dorisSchema)
   })
 
   override def capabilities(): util.Set[TableCapability] = {
+    if (identifier.namespace().length == 2) {
+      return Set[TableCapability](BATCH_READ).asJava
+    }
     val capabilities = mutable.Set(BATCH_READ,
       BATCH_WRITE,
       STREAMING_WRITE,
@@ -59,7 +62,6 @@ abstract class DorisTableBase(identifier: Identifier, config: 
DorisConfig, schem
   }
 
   override def newScanBuilder(caseInsensitiveStringMap: 
CaseInsensitiveStringMap): ScanBuilder = {
-    config.setProperty(DorisOptions.DORIS_TABLE_IDENTIFIER, name())
     createScanBuilder(config, schema())
   }
 
diff --git 
a/spark-doris-connector/spark-doris-connector-spark-4-base/src/main/scala/org/apache/doris/spark/catalog/DorisTableProviderBase.scala
 
b/spark-doris-connector/spark-doris-connector-spark-4-base/src/main/scala/org/apache/doris/spark/catalog/DorisTableProviderBase.scala
index a03435d..39eeaeb 100644
--- 
a/spark-doris-connector/spark-doris-connector-spark-4-base/src/main/scala/org/apache/doris/spark/catalog/DorisTableProviderBase.scala
+++ 
b/spark-doris-connector/spark-doris-connector-spark-4-base/src/main/scala/org/apache/doris/spark/catalog/DorisTableProviderBase.scala
@@ -17,7 +17,7 @@
 
 package org.apache.doris.spark.catalog
 
-import org.apache.doris.spark.config.{DorisConfig, DorisOptions}
+import org.apache.doris.spark.config.DorisConfig
 import org.apache.spark.sql.connector.catalog.{Identifier, Table, 
TableProvider}
 import org.apache.spark.sql.connector.expressions.Transform
 import org.apache.spark.sql.types.StructType
@@ -38,9 +38,8 @@ abstract class DorisTableProviderBase extends TableProvider {
     if (t != null) t
     else {
       val dorisConfig = DorisConfig.fromMap(properties, false)
-      val tableIdentifier = 
dorisConfig.getValue(DorisOptions.DORIS_TABLE_IDENTIFIER)
-      val tableIdentifierArr = tableIdentifier.split("\\.")
-      newTableInstance(Identifier.of(Array[String](tableIdentifierArr(0)), 
tableIdentifierArr(1)), dorisConfig, Some(schema))
+      val tableIdentifierArr = dorisConfig.getTableIdentifierParts
+      newTableInstance(Identifier.of(tableIdentifierArr.dropRight(1), 
tableIdentifierArr.last), dorisConfig, Some(schema))
     }
   }
 
@@ -48,9 +47,8 @@ abstract class DorisTableProviderBase extends TableProvider {
     if (t != null) t
     else {
       val dorisConfig = DorisConfig.fromMap(options, false)
-      val tableIdentifier = 
dorisConfig.getValue(DorisOptions.DORIS_TABLE_IDENTIFIER)
-      val tableIdentifierArr = tableIdentifier.split("\\.")
-      newTableInstance(Identifier.of(Array[String](tableIdentifierArr(0)), 
tableIdentifierArr(1)), dorisConfig, None)
+      val tableIdentifierArr = dorisConfig.getTableIdentifierParts
+      newTableInstance(Identifier.of(tableIdentifierArr.dropRight(1), 
tableIdentifierArr.last), dorisConfig, None)
     }
   }
 
diff --git 
a/spark-doris-connector/spark-doris-connector-spark-4-base/src/test/scala/org/apache/doris/spark/catalog/DorisTableBaseTest.scala
 
b/spark-doris-connector/spark-doris-connector-spark-4-base/src/test/scala/org/apache/doris/spark/catalog/DorisTableBaseTest.scala
new file mode 100644
index 0000000..7fedca5
--- /dev/null
+++ 
b/spark-doris-connector/spark-doris-connector-spark-4-base/src/test/scala/org/apache/doris/spark/catalog/DorisTableBaseTest.scala
@@ -0,0 +1,55 @@
+// 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.doris.spark.catalog
+
+import org.apache.doris.spark.config.{DorisConfig, DorisOptions}
+import org.apache.spark.sql.connector.catalog.Identifier
+import org.apache.spark.sql.connector.read.ScanBuilder
+import org.apache.spark.sql.connector.write.WriteBuilder
+import org.apache.spark.sql.types.StructType
+import org.apache.spark.sql.util.CaseInsensitiveStringMap
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+import java.util
+import scala.collection.JavaConverters._
+
+class DorisTableBaseTest {
+
+  @Test
+  def readPreservesConfiguredIdentifierQuoting(): Unit = {
+    val tableIdentifier = "`hive`.`db`.`order`"
+    val config = DorisConfig.fromMap(Map(
+      "doris.fenodes" -> "127.0.0.1:8030",
+      "doris.table.identifier" -> tableIdentifier,
+      "doris.user" -> "root",
+      "doris.password" -> "").asJava, false)
+    val table = new DorisTableBase(
+      Identifier.of(Array("hive", "db"), "order"), config, 
Some(StructType(Nil))) {
+      override protected def createScanBuilder(readConfig: DorisConfig, 
schema: StructType): ScanBuilder = {
+        assertEquals(tableIdentifier, 
readConfig.getValue(DorisOptions.DORIS_TABLE_IDENTIFIER))
+        null
+      }
+
+      override protected def createWriteBuilder(writeConfig: DorisConfig, 
schema: StructType): WriteBuilder =
+        throw new UnsupportedOperationException()
+    }
+
+    table.newScanBuilder(new 
CaseInsensitiveStringMap(util.Collections.emptyMap[String, String]()))
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to